专题 解空间的一种遍历方式:深度优先(Depth First)
概念解释
定义
首先需要知道解空间是什么。概括地,解空间是一个逻辑结构,为实际问题中的解可能存在的逻辑区域。
深度优先,则是一种非常常见、应用非常广泛的一个遍历方式。在各种各样的解空间中,包括线性空间, 树结构,图结构中都可以用到深度优先的一种方式。
自然,在线性序列中寻找解的深度优先,称为深度优先搜索(Depth First Serach);在树与图上称为深度优先遍历(其实本质是一样的)。
特征
深度优先,这限定了这个思想是有优先级的:在面临决策时,往往朝着一个方向深入,直到这个方向上再无可能存在可行解,才会返回。用古话讲:
“ 不撞南墙不回头。”
算法分析
通常,这种深度优先的思想可以借助于一种物理模型来实现:栈(FILO)。更常用的,则是借助递归这一逻辑结构来实现的。本文不做区分,介绍DFS也就介绍了递归。(本栏目介绍DP而不介绍递推。)
递归,通俗理解,为自调用函数。顾名思义,自己调用自己的函数。我们需要知道这么几个特性:
- 栈帧。这是计算机储存某一层次递归时的信息的位置结构;
- 返回,又称回溯。这是程序执行到“无路可走”的时候将会返回上一层的栈帧,并提取上一层的信息。
- 搜索树。这是一个解空间,但是,实际情况是:这个树结构并不客观存在,但是在DFS运行过程中体现的一种逻辑结构
- 状态。这个在研究DP时会着重提到,但是在DFS的概念里,我们认为:搜索树上的一个节点代表着一个状态。事实上,关于DP的许多概念在DFS里也是有体现的,这里不展开了。
- 剪枝。这是搜索算法特有的一种手段。由于大部分情况下,搜索算法本质上是一种暴力枚举,这导致时间复杂度通常不是多项式级别的。相对于搜索树,我们“剪去不可能达到的枝叶”,等效于排除不可能的状态,这是搜索算法生命力旺盛的原因之一:剪枝会使时间复杂度趋近于多项式复杂度。
下面介绍几个例题,加深对DFS的理解。
luogu.B3621 枚举元组
luogu.B3622 枚举子集(递归实现指数型枚举)
luogu.P10448 组合型枚举
B3623 枚举排列(递归实现排列型枚举)
这四道题是计数问题的基石。我们直接对后三道题给出分析:
一、参数设计对比
-
子集枚举(B3622):
- 仅需
x
记录当前处理位置 a[]
数组存储选择状态(0/1)- 无
mark
数组(实际未使用)
- 仅需
-
组合枚举(B10448):
x
记录已选元素数量last
保证组合升序(关键去重参数)mark
数组防止元素重复使用
-
排列枚举(B3623):
x
记录当前填充位置mark
数组确保元素不重复- 注意:代码中
a[x]=i
被重复赋值(第二处是冗余操作)
二、递归结构差异
子集枚举(n=3示例):
dfs(1)
├─ a[1]=0 → dfs(2)
│ ├─ a[2]=0 → dfs(3)
│ │ ├─ a[3]=0 → 输出NNN
│ │ └─ a[3]=1 → 输出NNY
│ └─ a[2]=1 → dfs(3)
│ ├─ a[3]=0 → 输出NYN
│ └─ a[3]=1 → 输出NYY
└─ a[1]=1 → dfs(2)├─ a[2]=0 → dfs(3)│ ├─ a[3]=0 → 输出YNN│ └─ a[3]=1 → 输出YNY└─ a[2]=1 → dfs(3)├─ a[3]=0 → 输出YYN└─ a[3]=1 → 输出YYY
特点:二叉树结构,每个节点分两支(选/不选)
组合枚举(n=3,m=2示例):
dfs(1,1)
├─ i=1: mark[1]=1 → dfs(2,1)
│ ├─ i=2: mark[2]=1 → 输出1 2
│ └─ i=3: mark[3]=1 → 输出1 3
├─ i=2: mark[2]=1 → dfs(2,2)
│ └─ i=3: mark[3]=1 → 输出2 3
└─ i=3: 不执行(start=3但n=3)
特点:通过last
限制选择范围,避免[1,2]和[2,1]重复
排列枚举(n=2,k=2示例):
dfs(1)
├─ i=1: mark[1]=1 → dfs(2)
│ └─ i=2: mark[2]=1 → 输出1 2
└─ i=2: mark[2]=1 → dfs(2)└─ i=1: mark[1]=1 → 输出2 1
特点:每次循环从1开始,用mark
保证不重复。
三、关键逻辑区别
维度 | 子集枚举 | 组合枚举 | 排列枚举 |
---|---|---|---|
选择策略 | 每个元素独立决策 | 从剩余元素中选择 | 全排列 |
去重方式 | 无(自然不重复) | last 参数跳过已处理元素 | mark 数组标记已使用元素 |
递归参数 | 仅需位置x | 需要x和last | 仅需位置x |
输出特征 | 所有子集(2^n种) | C(n,m)种组合 | n!或P(n,k)种排列 |
参考程序
//B3621 枚举元组
#include<iostream>
using namespace std;
int a[10],n,k;
bool mark[10];
void dfs(int x)
{if(x>n) {for(int i=1;i<=n;i++) cout<<a[i]<<' ';cout<<endl;return;}for(int i=1;i<=k;i++)a[x]=i,dfs(x+1);}
int main()
{cin>>n>>k;dfs(1);return 0;
}
//B3622 枚举子集(递归实现指数型枚举)
#include<iostream>
#include<vector>
using namespace std;
int n;
int a[15];
void dfs(int x)
{if(x>n) {for(int i=1;i<=n;i++) if(a[i]) cout<<'Y';else cout<<'N';cout<<endl;return;}a[x]=0;dfs(x+1);a[x]=1;dfs(x+1);
}
int main()
{cin>>n;dfs(1);return 0;
}
//P10448 组合型枚举
#include<iostream>
using namespace std;
int n,m,a[30];
bool mark[30];
void dfs(int x,int last)
{if(x>m){for(int i=1;i<=m;i++) cout<<a[i]<<' ';cout<<endl;return;}for(int i=last;i<=n;i++)if(!mark[i]){mark[i]=1;a[x]=i;dfs(x+1,i);mark[i]=0;}
}
int main()
{cin>>n>>m;dfs(1,1);return 0;
}
//B3623 枚举排列(递归实现排列型枚举)
#include<iostream>
using namespace std;
int n,k;
int a[15];
bool mark[15];
void dfs(int x)
{if(x>k){for(int i=1;i<=k;i++)cout<<a[i]<<' ';cout<<endl;return;}for(int i=1;i<=n;i++)if(!mark[i]){mark[i]=1;a[x]=i;dfs(x+1);a[x]=i;mark[i]=0;}
}
int main()
{cin>>n>>k;dfs(1);return 0;
}
细节实现
弄清子集、排列、组合这些数学的意义。子集是 级别的,排列是
的,组合是
的。组合不考虑顺序,也就是不同顺序的不算做不同的答案;而排列正好相反。另外,在设计DFS函数时,可以先从边界条件入手,抓住递归的特性(解决小问题,逐步扩大规模),然后从状态转移的角度入手。
总结归纳
期待再次更新。