dfs时间复杂度分析

前言

  之前一直想不明白dfs的时间复杂度是怎么算的,前几天想了下大概想明白了,现在记录一下。

  存图方式都是链式前向星或邻接矩阵。主要通过几道经典题目来阐述dfs时间复杂度的计算方法。

  n是图中结点的个数,e是图中边的个数。

 

深度优先遍历图的每一个结点

  时间复杂度为:链式前向星:O(n+e);邻接矩阵:O(n)

  给定我们一个图(链式前向星存储),通过深度优先遍历的方式把图中每个结点遍历一遍。

  首先,图中每个结点最多被遍历一次,这是因为当某个结点被遍历到后,就会对其进行标记,下次再搜索到该结点时就不会再从这个结点进行搜索,所以每个结点最多调用一次dfs函数,所以这里的时间复杂度为O(n)遍历图的本质是遍历每个结点的邻接结点,也就是说,当从一个结点开始,去遍历其他结点时,都要对该结点的邻接结点遍历一次。因此对于每个结点来说,遍历其邻接结点的时间复杂度为O(ei),这里的ei是指第i个结点与其邻接结点相连的边的数量。所以,遍历整一个图的时间复杂度就是把每一个结点进行深度优先遍历的时间复杂度加起来,也就是O(n+e),即所有结点和所有边的数量。

  如果图是用邻接矩阵进行存储,则时间复杂度为O(n2),这是因为每个结点都要对其邻接结点扫描一遍,也就是遍历矩阵的一行,即O(n),又因为共有n个结点,所以时间复杂度就为O(n2)

  下面给了深度优先遍历的一个例图。

  深度优先遍历的代码:

复制代码
 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(nn!)

  给定一个数字n,求出所有1n的一个全排列。

  例如求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层)能够选择的结点只有n1个。又因为上一层有n个结点,所以第2层可选择的结点个数为n(n1)(主要是想强调虽然每个邻接结点会被遍历到,但并不是所有的邻接结点都被可以选择去进行下一层的dfs)。同理,在第3层中,上一层有n(n1)个可选择的结点,每个结点实际可选择的邻接结点只有n2个,故第3层可选择的结点个数为n(n1)(n2)。以此类推,到第n1层,可选择的结点个数为n(n1)(n2)  2=n!。最后一层不考虑,因为叶子结点没有邻接结点,不会进行遍历邻接结点这个操作。

  所以时间复杂度就是所有可选择结点遍历其邻接结点的次数。设第i层的可选择结点的结点个数为si,每个结点都有n个邻接结点,因此所有可选择结点遍历其邻接结点的次数为O(ni=0n1si=n(1+n+n(n1)++n!))=O(nn!)

  补充:我们知道2nn!,因此可以将1+n+n(n1)++n!进行放缩1+n+n(n1)++n!=n!n!+n!(n1)!+n!(n2)!++n!=n!(1n!+1(n1)!+1(n2)!++1)n!(12n+12n1+12n2++1)n!2其中120+121+122++12n=212n1<2所以有n!1+n+n(n1)++n!2n!O(1+n+n(n1)++n!)=O(n!)

  全排列问题的代码:

复制代码
 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×n的国际象棋棋盘上,使得皇后不能相互攻击到,即任意两个皇后都不能处于同一行、同一列或同一斜线上。现在给定整数n,求出所有的满足条件的棋子摆法。

方法一

  时间复杂度为O(2n2)

  最纯粹的dfs,即考虑每一个格子放棋子还是不放棋子。

  先贴出搜索树大概的样子:

  因为有n2个格子,所有搜索树有n2层(不算第0层),每个格子对应着搜索树中的那一层。当搜索完第n2层,这意味着我们为每一个格子选择了放棋子或不放棋子,即确定一种方案(该方案不一定满足解的要求)。每层的结点对应着这个位置的格子,比如第1层的结点对应第1个格子,第n层的结点对应第n个格子,以此类推。又因为每个格子有放棋子或不放棋子这两种选择,对应着每个结点有两个邻接结点。

  我们现在计算遍历每个节点的邻接结点的次数(先不考虑剪枝,也就是最糟糕的情况)。因为最后一层,即第n2层的节点为叶子节点,没有邻接结点,所以只用考虑第0n21层的结点。然后,由于每个结点都有两个邻接结点,设第i层有si个结点,所以第i层的结点一共会遍历2si次邻接结点。因此,所有结点的对邻接结点进行遍历次数为O(2i=0n21si=2i=0n212i=212n212=22n22)=O(2n2)

  当然,实际上我们会通过剪枝的操作,把已经明确不会存在解的情况过滤掉,不会再从这个地方继续搜索下去。上面分析的是最糟糕的情况下的时间复杂度,所以实际上运行的效率会优于上面分析的时间复杂度。

  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(nn!)

  这时我们要对上面方法进行进一步的优化。我们可以发现,根据题意,每一行都只会有一个皇后,所以我们可以枚举每一行。且每一列都只有一个皇后,因此在每一行选择了一个皇后后,记录该皇后所在的列号,在剩下的行中,每一行的皇后都不可以放置在前面已标记的列号上。也就是说,每枚举一行选择其中一列放置皇后后,下一行可放置皇后的列数都会减1,即放置的位置会减1

  是不是与前面全排列分析的很像,我们给出大致的搜索树:

  没错,与上面的全排列的搜索树j几乎是一样的。

  第i就代表棋盘的第i行,在以每个结点为根的子树中,根节点的n个邻接结点就代表n个列号。按照上面的分析,每次往下走一层,那么可选择的邻接结点都会减少一个。

  这与全排列的分析是一样的,所以时间复杂度就是O(nn!)

  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(n)

  这里给出一道例题来分析。

  集合-Nim游戏

  在集合-Nim游戏中,我们要对每个出现过的数求其sg值,而在搜索的过程中某个数字可能会出现多次,又因为在同一个游戏中每个数字的sg值都是一样的,所以如果对搜索过程中每一个数字都直接进行dfs来求其sg值,那么这就会就会重复搜索,浪费时间。因此我们可以开个数组来记录每次求得的某个数字的sg值。如果在搜索的过程中发现某个数字的sg是已经求得的,那么就可以直接从数组中得到结果,而不需要再从这个数字进行dfs。

  这样一来,每个数字的sg值都只会被搜索一次,如果这个游戏中出现n个不同的数字,那么时间复杂度就是O(n)

  其实和这图的深度优先遍历很像,就是不让同一个数字(结点)进行重复遍历,保证最多被遍历一次。

  以题目的测试样例,我们画出这个求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,祝大家春节快乐!

posted @   onlyblues  阅读(10159)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· NetPad:一个.NET开源、跨平台的C#编辑器
· PowerShell开发游戏 · 打蜜蜂
· 凌晨三点救火实录:Java内存泄漏的七个神坑,你至少踩过三个!
Web Analytics
点击右上角即可分享
微信分享提示