接下來幾篇要介紹時間複雜度各種的分析技巧,不過因為個人能力有限,因此這裡開始會大量參考網路資源,尤其是 AA 競程在 YouTube 的時間複雜度教學影片。
AA 競程是個非常專業的競技程式補習班,他們的學生很多都在 APCS、TOI、資訊能力競賽等等都拿到了極佳的成果。最近AA 競程的 YouTube 有在做 Atcoder Beginner Contest 的賽後講題,有興趣的人可以去參考看看。
不過當然我雖然參考影片寫,不過我不會止步於影片本身。我會盡量把裡面留作練習卻沒那麼好想和我自己學習時曾經卡住的點完整講過一遍。
那麼就讓我們開始接下來的內容!
昨天提到過其實只要取被執行最多次的指令們,把它們的漸進複雜度相加就可以了。
此時有一個用的小觀察:迴圈套越多層內部被執行的次數越多,所以只要從外往內數迴圈執行次數的漸進複雜度後加總再取一次漸進複雜度就可以了。
那這種技巧之所以會叫做數迴圈,是因為要知道內層迴圈的執行次數,其實只要從外層一路「乘」進來就好了,過程就好像在「數」有幾層迴圈一樣。
int func(int n, int m) {
int result = 0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
++result;
}
for (int j = 0; j < n; ++j) {
++result;
}
}
for (int i = 0; i < m; ++i) {
++result;
}
return result;
}
以上面程式為例:最深的迴圈出現在三個 ++result
那邊,而它們的漸進複雜度相加是:
值得注意的是,因為無法確定 和 誰大誰小,因此不能把 消去。
剛剛提到的數迴圈,雖然是最常用的技巧,但也是新手最容易搞錯和誤用的技巧。
可能有些初學者學過剛剛的數迴圈後,會得出上面這種結論,但事情並非總是這麼簡單!
有時候迴圈不會像上面的例子那樣執行次數穩定、每次次數都固定,這時候我們就需要做一點數學分析來確定它的上界是什麼,以估算它的漸進複雜度。
這邊以 Atcoder 的 ARC133 A - ABC 舉例
給定正整數 ,求有幾個正整數三元組 能滿足 。若兩個三元組數字完全相同但排列不一樣也會被分開計為不同的三元組。
時間限制 2 秒
首先可以用最直覺最暴力的方式來想該怎麼做:執行試遍 A、B、C 所有可能的數字!
因此我們可以像下面這樣三層迴圈暴力枚舉:
void solve() {
int K;
cin >> K;
long long ans = 0;
for (int A = 1; A <= K; ++A) {
for (int B = 1; B <= K; ++B) {
for (int C = 1; C <= K; ++C) {
if ((long long)A * B * C <= K) {
++ans;
}
}
}
}
cout << ans << "\n";
}
這看起來真是暴力至極,時間複雜度是 ,看看輸入 的最大數值 , ,回想 Day 4 的實測,絕對不可能在兩秒內跑完。
但真的有必要枚舉那麼多種可能嗎?看看我們的條件判斷,當我們已經知道 、 的情況下能否直接判斷出 的數量?
假設 、 已知,此時不等式 且因為 為正整數,所以 。
既然都知道符合需求的 的範圍是 ,就不用大費周章的枚舉了:
void solve() {
int K;
cin >> K;
long long ans = 0;
for (int A = 1; A <= K; ++A) {
for (int B = 1; B <= K; ++B) {
// 區間右界減去左界再加一
ans += (K / A / B) - 1 + 1;
}
}
cout << ans << "\n";
}
如此一來時間複雜度降至 ,不過 ,當初我們 Day 4 的結論是一般的電腦每秒的步驟數約落在 ,目前要在兩秒內跑完還是沒辦法,因此得必須繼續優化。
我們再想想,我們在枚舉 的過程中,真的每次都能產生出符合需求的 嗎?
例如像 ,則 不可能大於 1!所以 從 1 之後的枚舉都是無效的,事實上 B 最大只能到 ,因為要是 就代表 直接違反了題目的前提!
所以可以進一步優化 的迴圈:
void solve() {
int K;
cin >> K;
long long ans = 0;
for (int A = 1; A <= K; ++A) {
for (int B = 1; (long long)A * B <= K; ++B) {
// 區間右界減去左界再加一
ans += (K / A / B) - 1 + 1;
}
}
cout << ans << "\n";
}
很神奇的是,這樣子就可以通過這題了,時間還只有幾毫秒而已!
那這樣子的時間複雜度是多少呢?
第一層迴圈會執行 次毋庸置疑,但第二層迴圈的次數就比較怪了,每次都不一樣,這邊就需要稍微數學分析一下。
第二層迴圈的次數取決於 的大小,第一輪會跑 次、第二輪會跑 次、第三輪會跑 次...
因此第二層迴圈的次數可以如下估計
因為括號內的級數難以精確算出總和,不過我們只要估計上界就好了。
因此此時可以用一個小技巧,就是將級數內的元素分組後讓每組變成方便計算的模樣:
因為每一組內的加總值都變成了 1,於是現在問題轉換成了 個東西按照上面的分組方式會被變成幾組?
按照我們的分組方式,每組內的元素數量如下表格:
第一組 | 第二組 | 第三組 | 第四組 | |
---|---|---|---|---|
我們令 會被分成 組,則按照等比級數和公式:
小提醒:因為我們是按照二的冪次來決定幾個元素一組的,因此若 並非二的冪次減一,最後一組可能會填不滿,所以下面才會寫小於等於
要知道之所以會是不等式,是因為最後一組分不滿的問題,所以其實滿足該不等式的最小值才會是我們 的值,且因為分組數必為整數。因此可以推論
最後代回原式
這就是我們的時間複雜度,這樣的複雜度在 下為 ,對照我們 Day 4 的結論,2 秒內根本輕輕鬆鬆。
實際上,剛剛那題還可以做到 ,做法我們可以先看 Atcoder 的 ABC227 C - ABC conjecture
給定正整數 ,求有幾個正整數三元組 能滿足 且 。保證答案能小於 (答案在 long long 範圍內)。
時間限制 2 秒
這題跟剛剛幾乎一樣!只是輸入的數值範圍變大了超多、且多了一個條件 而已。
一樣從最暴力開始看:
using ll = long long;
void solve() {
ll N;
cin >> N;
ll ans = 0;
for (ll A = 1; A <= N; ++A) {
for (ll B = A; B <= N; ++B) {
for (ll C = B; C <= N; ++C) {
if (A * B * C <= N) ++ans;
}
}
}
cout << ans << "\n";
}
狀況幾乎與我們剛剛的情況很像,只是迴圈的次數不一樣了而已,因此我們一次分析到底囉!
按照以上三個結論,可以優化成:
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) {
// 區間右界減去左界再加一
ans += N / A / B - B + 1;
}
}
cout << ans << "\n";
}
這樣寫時間複雜度是 ,以這題的輸入 而言兩秒內可以輕鬆通過。
那剛剛那一題的解法能怎麼應用回去原本那題呢?
我們可以發現 ARC133_A 這題的三元組若排列不同則算是不同的三元組、但是 ABC227_C 的三元組限制必須由小到大,那我們是不是只要計算目前「由小到大」的三元組可以生成多少個排列,加總後就可以把 ABC227_C 的解法用到 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";
}
如此一來就可以以 的時間複雜度通過 ARC133 A ,但是因為它輸入只有到 ,所以從執行時間上你看不出速度差距就是了。
因為篇幅問題,因此新解法的時間複雜度證明就留到明天囉!
然後明天預計會繼續介紹更多數學分析漸進複雜度的例子,請各位敬請期待!