今天要繼續介紹時間複雜度的常用分析手法,文章內容參考了各種網路資源,其中最主要的是 AA 競程在 YouTube 的時間複雜度教學影片 ,有興趣的可以去他們的 YT 頻道看看。
AA 競程是個非常專業的競技程式補習班,他們的學生很多都在 APCS、TOI、資訊能力競賽等等都拿到了極佳的成果。最近AA 競程的 YouTube 有在做 Atcoder Beginner Contest 的賽後講題,有興趣的人可以去參考看看。
不過首先我會把昨天 ARC133 A 新解法的時間複雜度分析補上:
using ll = long long;
void solve() {
ll N;
cin >> N;
ll ans = 0;
for (ll A = 1; A * A <= N / A; ++A) {
for (ll B = A; B * B <= N / A; ++B) {
ll bound = N / A / B;
if (A == B) {
if (B == bound) ans += 1;
else ans += (bound - B + 1) * 3 - 2;
} else {
if (B == bound) ans += 3;
else ans += (bound - B + 1) * 6 - 3;
}
}
}
cout << ans << "\n";
}
大致觀察後,可以發現外層迴圈的步驟數是 ,內層迴圈的操作數取決於 的大小,目前只大概知道會跑 次,至於迴圈內的程式碼都是 。因此難點在第二層迴圈,需要一些數學分析。
按照上面的觀察,對於迴圈最內層會跑的次數可以列式如下:
要知道其實我們也不需要真的算出這條式子,只要估計它的上界就可以了,因此可以這樣做:
(注意求和符號中的 A 是從 2 開始)
然後先算出不定積分
( 是積分常數)
再算定積分
積分之所以會大於等於求和是因為定積分算是一種連續平滑的求和,就像下圖表示的那樣
不過可以注意到剛剛的算式我們把求和算式中 A 的起始值變成 2,這是因為當 N 從 1 開始時,求和會大於定積分,不過我們可以把它當特例先暫時忽略,之後再加回來就好,因此有:
如此一來就證明完成,時間複雜度為 ,這樣的時間複雜度在 ARC133 A 中 的數值下超級低,低到基本上看不出與原解法 的時間差距
請問下面程式碼的時間複雜度是什麼?
int func(int n) {
int result = 0;
while (n > 0) {
for (int i = 0; i < n; ++i) {
// 某些跟 result 有關的 O(1) 操作
}
n /= 2;
}
return result;
}
首先從外面的 while 迴圈開始看,它的執行次數是 n 能被除以 2 的次數,這不就是對數的定義嗎?因此 while 迴圈內的東西至少會跑 次。
至於裡面的 for 迴圈會跑的次數是取決於當下的 n ,這比較難處理,因此我們可以先列出前面幾次來觀察:
以上是我們內層 for 迴圈會跑的次數,仔細看會發現這就只是等比級數而已,所以我們可以直接套用級數和的公式計算。
我們已經知道了 while 迴圈會跑 次,這代表等比級數有 項,因此可以如下計算:
因此這段程式的時間複雜度是
不過因為漸進複雜度在分析的是函數的量級,所以 的大小不是有限的數字,因此剛剛其實也能用無窮等比級數公式來計算:
無窮等比級數公式: ,1 減公比分之首項
更多資訊請參考維基百科等比數列條目
結果是一樣的
請問下面程式碼的時間複雜度是什麼?
int func(int n) {
int result = 0;
while (n > 1) {
// 某些跟 result 有關的 O(1) 操作
n = (int)sqrt(n);
}
return result;
}
因為 sqrt 回傳 double 被我們強制轉型成 int,這樣做等同是開根號後再做向下取整,因此到 n <= 3 時開根號就會直接變 1,不會無窮迴圈
關於這段程式,可以觀察到程式執行的次數取決於 n 能被開幾次根號才會小於等於 3 ?,因為有點難分析,所以我們可以先列出前幾次的 n 觀察一下:
可以注意到其實開根號等同是在次方不斷除以 2,因此 while 迴圈的次數其實大約會是 n 能在次方被除以 2 幾次才會小於等於 3 ?
之所以講"大約"是因為每次開根號都會被向下取整,所以其實很難找出精確的次數,我們這樣只能算是估計而已
那我們就先令 代表 while 迴圈會跑的次數,按照剛才觀察的結論,可列式如下:
(t 是在次方的位置喔!不知道為什麼這邊的數學式渲染得有點擠XD)
因為 while 迴圈的執行次數是跑幾次 n 能小於等於 3 ?,這其實就是在問最少需要跑幾次, n 才能小於等於 3 ?,因此我們的執行次數是滿足不等式中的最小的 ,即為 。
因此這段程式的時間複雜度是 ,這種時間複雜度超級小,而且很少見。
話說埃拉托斯特尼篩法的時間複雜度是 ,有興趣的可以去它的維基百科條目看看
int func(int n) {
int result = 0;
for (int i = 0; i < (1 << n); ++i) {
for (int j = i; j; j = (j - 1) & i) {
// 某些跟 result 有關的 O(1) 操作
}
}
return result;
}
我知道這次的練習乍看之下很可怕XD,主要是那些位元運算可能會讓人很疑惑,因此這邊會先稍微解釋一下它在做什麼:
for (int i = 0; i < (1 << n); ++i)
:(1 << n)
的意思是 ,因此這行的意思就是讓 i 從 遍歷到
for (int j = i; j; j = (j - 1) & i)
:i & (i - 1)
會是什麼結果?你會發現這個算式把 i
在二進位表示下最低位的 1 給拿掉了,因為減 1 會把一個數從二進位表示下最低位的 1 開始,把後面的所有 bit 翻轉。0b111{10000} - 0b00000001 = 0b111{01111}
大括號內的 bit 被翻轉了for (int j = i; j; j = (j - 1) & i)
就比較特別了,它是在枚舉當前 i
在二進位表示下所有的 1 所能形成的非空子集合。i = 26 (0b11010)
j = i
j = (j - 1) & i = 24 (0b11000)
j = (j - 1) & i = 18 (0b10010)
j = (j - 1) & i = 16 (0b10000)
j = (j - 1) & i = 10 (0b01010)
j = (j - 1) & i = 8 (0b01000)
j = (j - 1) & i = 2 (0b00010)
你會發現 26 在二進位表示下出現的那三個 1 所能組成的所有非空子集合都出現過一遍了。解釋完之後我們就可以開始分析了。
首先可以觀察到外層迴圈會跑 次,內層迴圈則取決於 在二進位表示下的 1 的有多少種非空的子集合,如果用 表示 二進位表示下 1 的數量,那非空子集合的數量就是 。
若從一個有 個元素的集合中選出一個子集合,則對於集合內的每個元素都有"選它"、"不選它"兩種選項,因此共有 種子集,這時再去掉誰都不選的空集合,就可以知道非空子集合的數量會是
然後我們前面都是先列式出來後才成功分析複雜度的,但現在狀況要怎麼列式呢? 只是我們定義的一個函數,要怎麼算出來可還沒有解決呢!而且我們又沒辦法推算 之中的數在二進位表示下 1 的數量...怎麼辦,卡關了?
既然如此不如換一種思路來想:既然我們沒辦法知道每個數在二進位表示下 1 的數量,那我們能不能知道 中有幾個數在二進位表示下剛好有 k (假設 k 為某特定位元數)個 1 呢?值得一試!
先觀察看看區間 的狀況:
0000
0001
0010
0011
0100
0101
0110
0111
1000
1001
1010
1011
1100
1101
1110
1111
請問在 中有幾個數在二進位表示下有 0 個 1?零個!
請問在 中有幾個數在二進位表示下有 1 個 1?四個!
請問在 中有幾個數在二進位表示下有 2 個 1?六個!
請問在 中有幾個數在二進位表示下有 3 個 1?四個!
請問在 中有幾個數在二進位表示下有 4 個 1?一個!
有沒有發現什麼?對於 範圍內的數,在二進位表示下有 k 個 1 的數字會有 個!其實這件事蠻好理解的,因為把已知的 k 個 1 擺進 n 個位元裡面,相當於從 n 個座位裡面抽出 k 個座號,而這不就正好是組合數的概念嗎?
因此按照我們發現的規則,對於前幾次內層迴圈的次數,我們可以先列出來觀察
整理一下可以得出這條式子:
乍看之下是不是完全沒有頭緒?是不是完全不知道要怎麼化簡含有組合數的式子?
沒關係我們先把式子分開來看一下
OH YA!還是沒想法...這種時候去維基百科翻翻看有沒有好的性質是很棒的做法XD
啊哈!有想法了,所以剛剛那條式子的左項可以變成:
右項可以變成:
所以結論就是:
因此這段程式的時間複雜度是
這段程式一般都用在動態規劃的枚舉子集中,每個 bit 會代表三種狀態(不在集合內、在集合內但還沒被枚舉到、在集合內且正在枚舉),如想了解更多可以參考: OI-wiki 、 CSDN 状态压缩动态规划(状压DP) 及 枚举子集优化
今天做了好幾個數學分析時間複雜度的練習,相信各位已經相當熟悉數學分析的技巧了。但算數學真的好累
明天將會帶給各位最後一個時間複雜度的分析技巧,請各位敬請期待囉!