前言
之前一直想不明白dfs的時間複雜度是怎麼算的,前幾天想了下大概想明白了,現在記錄一下。
存圖方式都是鏈式前向星或鄰接矩陣。主要通過幾道經典題目來闡述dfs時間複雜度的計算方法。
$n$是圖中結點的個數,$e$是圖中邊的個數。
深度優先遍歷圖的每一個結點
時間複雜度為:鏈式前向星:$O\left( n + e \right)$;鄰接矩陣:$O\left( n \right)$
給定我們一個圖(鏈式前向星儲存),通過深度優先遍歷的方式把圖中每個結點遍歷一遍。
首先,圖中每個結點最多被遍歷一次,這是因為當某個結點被遍歷到後,就會對其進行標記,下次再搜尋到該結點時就不會再從這個結點進行搜尋,所以每個結點最多呼叫一次dfs函式,所以這裡的時間複雜度為$O\left( n \right)$。遍歷圖的本質是遍歷每個結點的鄰接結點,也就是說,當從一個結點開始,去遍歷其他結點時,都要對該結點的鄰接結點遍歷一次。因此對於每個結點來說,遍歷其鄰接結點的時間複雜度為$O\left( e_{i} \right)$,這裡的$e_{i}$是指第$i$個結點與其鄰接結點相連的邊的數量。所以,遍歷整一個圖的時間複雜度就是把每一個結點進行深度優先遍歷的時間複雜度加起來,也就是$O\left( n + e \right)$,即所有結點和所有邊的數量。
如果圖是用鄰接矩陣進行儲存,則時間複雜度為$O\left( n^{2} \right)$,這是因為每個結點都要對其鄰接結點掃描一遍,也就是遍歷矩陣的一行,即$O\left( n \right)$,又因為共有$n$個結點,所以時間複雜度就為$O\left( n^{2} \right)$。
下面給了深度優先遍歷的一個例圖。
深度優先遍歷的程式碼:
1 #include <cstdio> 2 #include <cstring> 3 #include <algorithm> 4 using namespace std; 5 6 const int N = 1e5 + 10; 7 8 int head[N], e[N], ne[N], idx; 9 bool vis[N]; 10 11 void add(int v, int w) { 12 e[idx] = w, ne[idx] = head[v], head[v] = idx++; 13 } 14 15 void dfs(int src) { 16 vis[src] = true; 17 printf("%d ", src); 18 for (int i = head[src]; i != -1; i = ne[i]) { 19 if (!vis[e[i]]) dfs(e[i]); 20 } 21 } 22 23 int main() { 24 int n, m; 25 scanf("%d %d", &n, &m); 26 memset(head, -1, sizeof(head)); 27 28 // 輸入邊,建圖 29 for (int i = 0; i < m; i++) { 30 int v, w; 31 scanf("%d %d", &v, &w); 32 add(v, w), add(v, w); 33 } 34 35 // 深度優先遍歷,從1號結點開始搜尋 36 dfs(1); 37 38 return 0; 39 }
以上圖為例,這裡給出執行的結果:
數字全排列問題
時間複雜度為$O\left( {n \cdot n!} \right)$
給定一個數字$n$,求出所有$1 \sim n$的一個全排列。
例如求$3$的一個全排列,這裡畫了個圖方面理解(畫線的部分是徒手畫的,湊合著看吧...)。
從$s$出發,第$1$層可以選擇$3$個不同的結點,每個結點對應著一個分支。我們先看$s$最左邊的那個分支,也就是對應數字$1$的那個分支。第$1$層選擇“$1$”這個結點,接著遍歷了$3$個鄰接結點,對應$3$條邊。其中由於$1$已經遍歷過了,所有接下來只會從“$2$”和“$3$”開始遍歷,而這兩個結點都要把其鄰接結點都遍歷一遍來確定排列的最後一個數字選擇哪個。第$1$層的結點“$2$”和“$3$”這兩個分支的分析同上。
可以發現,每一個結點的鄰接結點個數都是$3$,所以迴圈要執行$3$次來掃描完這$3$個鄰接結點。且隨著樹的深度增加,每層可以選擇的結點個數也會隨著減小,即深度每增加$1$,該層可選擇的結點個數就減$1$(因為上一層已經確定了一個數,每遍歷一層都會少一個數的選擇)。
所以我們來推導更一般的規律,假設求$n$的一個全排列。對應的搜尋樹大概是這個樣子:
第$1$層可選擇的結點有$n$個。然後對於第$2$層,由於上一層(第$1$層)有$n$個可選擇的結點,而每個結點雖然有$n$個鄰接結點,但因為在上一層已經確定了一個數,所以實際上下一層(第$2$層)能夠選擇的結點只有$n - 1$個。又因為上一層有$n$個結點,所以第$2$層可選擇的結點個數為$n \cdot \left( n - 1 \right)$(主要是想強調雖然每個鄰接結點會被遍歷到,但並不是所有的鄰接結點都被可以選擇去進行下一層的dfs)。同理,在第$3$層中,上一層有$n \cdot \left( n - 1 \right)$個可選擇的結點,每個結點實際可選擇的鄰接結點只有$n - 2$個,故第$3$層可選擇的結點個數為$n \cdot \left( n - 1 \right) \cdot \left( n - 2 \right)$。以此類推,到第$n - 1$層,可選擇的結點個數為$n \cdot \left( {n - 1} \right) \cdot \left( {n - 2} \right) \cdot ~\cdots~ \cdot 2 = n!$。最後一層不考慮,因為葉子結點沒有鄰接結點,不會進行遍歷鄰接結點這個操作。
所以時間複雜度就是所有可選擇結點遍歷其鄰接結點的次數。設第$i$層的可選擇結點的結點個數為$s_{i}$,每個結點都有$n$個鄰接結點,因此所有可選擇結點遍歷其鄰接結點的次數為$$O\left( {n \cdot {\sum\limits_{i = 0}^{n - 1}s_{i}} = n \cdot \left( {1 + n + n \cdot \left( {n - 1} \right) + \cdots + n!} \right)} \right) = O\left( {n \cdot n!} \right)$$
全排列問題的程式碼:
1 #include <cstdio> 2 #include <algorithm> 3 using namespace std; 4 5 const int N = 20; 6 7 int a[N]; 8 bool vis[N]; 9 10 void dfs(int u, int n) { 11 if (u == n) { 12 for (int i = 1; i <= n; i++) { 13 printf("%d ", a[i]); 14 } 15 printf("\n"); 16 17 return; 18 } 19 20 for (int i = 1; i <= n; i++) { 21 if (!vis[i]) { 22 vis[i] = true; 23 a[u + 1] = i; 24 dfs(u + 1, n); 25 vis[i] = false; 26 } 27 } 28 } 29 30 int main() { 31 int n; 32 scanf("%d", &n); 33 34 // 從第0層開始搜尋 35 dfs(0, n); 36 37 return 0; 38 }
比如求$4$的一個全排列,執行結果如下:
由於時間複雜度太高,因此為了能在$1s$內算出結果,一般$n$的取值不會超過$8$。
n-皇后問題
$n$皇后問題是指將$n$個皇后放在$ n \times n $的國際象棋棋盤上,使得皇后不能相互攻擊到,即任意兩個皇后都不能處於同一行、同一列或同一斜線上。現在給定整數$n$,求出所有的滿足條件的棋子擺法。
方法一
時間複雜度為$O\left( 2^{n^{2}} \right)$
最純粹的dfs,即考慮每一個格子放棋子還是不放棋子。
先貼出搜尋樹大概的樣子:
因為有$n^{n}$個格子,所有搜尋樹有$n^{n}$層(不算第$0$層),每個格子對應著搜尋樹中的那一層。當搜尋完第$n^{n}$層,這意味著我們為每一個格子選擇了放棋子或不放棋子,即確定一種方案(該方案不一定滿足解的要求)。每層的結點對應著這個位置的格子,比如第$1$層的結點對應第$1$個格子,第$n$層的結點對應第$n$個格子,以此類推。又因為每個格子有放棋子或不放棋子這兩種選擇,對應著每個結點有兩個鄰接結點。
我們現在計算遍歷每個節點的鄰接結點的次數(先不考慮剪枝,也就是最糟糕的情況)。因為最後一層,即第$n^{n}$層的節點為葉子節點,沒有鄰接結點,所以只用考慮第$0 \sim n^{n} - 1$層的結點。然後,由於每個結點都有兩個鄰接結點,設第$i$層有$s_{i}$個結點,所以第$i$層的結點一共會遍歷$2 \cdot s_{i}$次鄰接結點。因此,所有結點的對鄰接結點進行遍歷次數為$$O\left( {2 \cdot {\sum\limits_{i = 0}^{n^{n} - 1}s_{i}} = 2 \cdot {\sum\limits_{i = 0}^{n^{n} - 1}2^{i}} = 2 \cdot \frac{1 - 2^{n^{n}}}{1 - 2} = 2 \cdot 2^{n^{2}} - 2} \right) = O\left( 2^{n^{2}} \right)$$
當然,實際上我們會通過剪枝的操作,把已經明確不會存在解的情況過濾掉,不會再從這個地方繼續搜尋下去。上面分析的是最糟糕的情況下的時間複雜度,所以實際上執行的效率會優於上面分析的時間複雜度。
$n$皇后問題的方法一的程式碼如下:
1 #include <cstdio> 2 #include <algorithm> 3 using namespace std; 4 5 const int N = 20; 6 7 char graph[N][N]; 8 bool row[N], col[N], dg[N], udg[N]; 9 10 void dfs(int x, int y, int cnt, int n) { 11 if (cnt == n) { 12 for (int i = 0; i < n; i++) { 13 printf("%s\n", graph[i]); 14 } 15 printf("\n"); 16 17 return; 18 } 19 20 if (y == n) { 21 y = 0, x++; 22 if (x == n) return; 23 } 24 25 dfs(x, y + 1, cnt, n); 26 27 if (!row[x] && !col[y] && !dg[y - x + n] && !udg[y + x]) { 28 row[x] = col[y] = dg[y - x + n] = udg[y + x] = true; 29 graph[x][y] = 'Q'; 30 dfs(x, y + 1, cnt + 1, n); 31 row[x] = col[y] = dg[y - x + n] = udg[y + x] = false; 32 graph[x][y] = '.'; 33 } 34 } 35 36 int main() { 37 int n; 38 scanf("%d", &n); 39 40 for (int i = 0; i < n; i++) { 41 for (int j = 0; j < n; j++) { 42 graph[i][j] = '.'; 43 } 44 } 45 46 // 從(0, 0)這個位置的格子開始搜尋 47 dfs(0, 0, 0, n); 48 49 return 0; 50 }
下面給出$4$個皇后的解:
由於時間複雜度太高,因此為了能在$1s$內算出結果,一般$n$的取值不會超過$9$。
方法二
時間複雜度為$O\left( {n \cdot n!} \right)$
這時我們要對上面方法進行進一步的優化。我們可以發現,根據題意,每一行都只會有一個皇后,所以我們可以列舉每一行。且每一列都只有一個皇后,因此在每一行選擇了一個皇后後,記錄該皇后所在的列號,在剩下的行中,每一行的皇后都不可以放置在前面已標記的列號上。也就是說,每列舉一行選擇其中一列放置皇后後,下一行可放置皇后的列數都會減$1$,即放置的位置會減$1$。
是不是與前面全排列分析的很像,我們給出大致的搜尋樹:
沒錯,與上面的全排列的搜尋樹j幾乎是一樣的。
第$i$就代表棋盤的第$i$行,在以每個結點為根的子樹中,根節點的$n$個鄰接結點就代表$n$個列號。按照上面的分析,每次往下走一層,那麼可選擇的鄰接結點都會減少一個。
這與全排列的分析是一樣的,所以時間複雜度就是$O\left( {n \cdot n!} \right)$。
$n$皇后問題的方法二的程式碼如下:
1 #include <cstdio> 2 #include <algorithm> 3 using namespace std; 4 5 const int N = 20; 6 7 char graph[N][N]; 8 bool row[N], col[N], dg[N], udg[N]; 9 10 void dfs(int x, int n) { 11 if (x == n) { 12 for (int i = 0; i < n; i++) { 13 printf("%s\n", graph[i]); 14 } 15 printf("\n"); 16 17 return; 18 } 19 20 for (int i = 0; i < n; i++) { 21 if (!col[i] && !dg[i - x + n] && !udg[i + x]) { 22 col[i] = dg[i - x + n] = udg[i + x] = true; 23 graph[x][i] = 'Q'; 24 dfs(x + 1, n); 25 col[i] = dg[i - x + n] = udg[i + x] = false; 26 graph[x][i] = '.'; 27 } 28 } 29 } 30 31 int main() { 32 int n; 33 scanf("%d", &n); 34 35 for (int i = 0; i < n; i++) { 36 for (int j = 0; j < n; j++) { 37 graph[i][j] = '.'; 38 } 39 } 40 41 // 從第0行開始搜尋 42 dfs(0, n); 43 44 return 0; 45 }
與方法一相比$n$取值會有所增加,為了能在$1s$內算出結果,$n$可以取到$13$。
記憶化搜尋
時間複雜度為$O \left( n \right)$
這裡給出一道例題來分析。
在集合-Nim遊戲中,我們要對每個出現過的數求其sg值,而在搜尋的過程中某個數字可能會出現多次,又因為在同一個遊戲中每個數字的sg值都是一樣的,所以如果對搜尋過程中每一個數字都直接進行dfs來求其sg值,那麼這就會就會重複搜尋,浪費時間。因此我們可以開個陣列來記錄每次求得的某個數字的sg值。如果在搜尋的過程中發現某個數字的sg是已經求得的,那麼就可以直接從陣列中得到結果,而不需要再從這個數字進行dfs。
這樣一來,每個數字的sg值都只會被搜尋一次,如果這個遊戲中出現$n$個不同的數字,那麼時間複雜度就是$O \left( n \right)$。
其實和這圖的深度優先遍歷很像,就是不讓同一個數字(結點)進行重複遍歷,保證最多被遍歷一次。
以題目的測試樣例,我們畫出這個求sg值的圖:
題目的AC程式碼如下:
1 #include <cstdio> 2 #include <cstring> 3 #include <unordered_set> 4 #include <algorithm> 5 using namespace std; 6 7 const int N = 110, M = 1e4 + 10; 8 9 int n, m; 10 int s[N], sg[M]; 11 12 int getSG(int val) { 13 if (sg[val] != -1) return sg[val]; 14 15 unordered_set<int> st; 16 for (int i = 0; i < n; i++) { 17 if (val - s[i] >= 0) st.insert(getSG(val - s[i])); 18 } 19 20 for (int i = 0; ; i++) { 21 if (st.count(i) == 0) return sg[val] = i; 22 } 23 } 24 25 int main() { 26 memset(sg, -1, sizeof(sg)); 27 28 scanf("%d", &n); 29 for (int i = 0; i < n; i++) { 30 scanf("%d", s + i); 31 } 32 scanf("%d", &m); 33 34 int ret = 0; 35 while (m--) { 36 int val; 37 scanf("%d", &val); 38 ret ^= getSG(val); 39 } 40 41 printf("%s", ret ? "Yes" : "No"); 42 43 return 0; 44 }
參考資料
Acwing:https://www.acwing.com/
最後,今天是2022-02-01,祝大家春節快樂!