前言
前两节课蒟蒻君给大家讲解了dfs的基本用法,蒟蒻君来给大家讲一下它的时间复杂度优化~
铺垫一下
1.搜索树和状态
我们可以根据搜索状态构建一张抽象的图,图上的一个节点就是一个状态,而图上的边就是状态之间转移的关系,包括继续dfs或者回溯。
搜索树里每个节点上记录的就是执行到这个状态时的值,对于每个数,我们都有几种选择,都会使目前的状态产生分支。
简单来说,剪枝前的搜索树每个状态有k种选择时,它就是一个完全k叉树(不会的小伙伴可以把这句话当空气)。
2.举个栗子
接下来蒟蒻君现编一道题…
- 题目
蒟蒻君今天想吃x斤食物(蒟蒻君是吃货没错),现在有n盘食物,第i盘食物有a[i]斤,蒟蒻君想知道有几种吃法。 - 分析一下
对与每个食物,我们都有选和不选两种选择(即k=2),我们在每次搜索中都要尝试这两种选择,达到条件计数器+1. - 植树~
让我们来看一下这道题的搜索树叭(蒟蒻君画得不咋地)
我们用S来记录当前总斤数和,用N来记录选择的食物的个数。
- 代码
#include <bits/stdc++.h>
using namespace std;
const int MAX_N = 15; // n的最大值,这里设它为15
int x, n, sum; // 输入数据
int res; // 总方案数
int a[MAX_N];
void dfs(int i, int N, int S) {
// 递归终止条件:所有数都搜索完了(也可以是找到n盘食物了,同理)
if (i == x) {
if (N == n && S == sum) { // 如果食物数量和重量都符合要求,就把答案+1
++res;
}
return ;
}
dfs(i + 1, N, S); // 如果不选当前菜
dfs(i + 1, N + 1, S + a[i]); // 如果选当前菜
}
int main() {
cin >> x >> n >> sum;
for (int i = 0; i < x; ++i) {
cin >> a[i];
}
dfs(0, 0, 0); // 从编号为0的菜开始搜索,最开始有0个菜,重量和为0
cout << res << '\n';
return 0;
}
当前,目前的时间复杂度为O(2^n),明显有点太慢了。
一会我们会讲到四种剪枝优化,其中有一些就是针对这种哒。
知识梳理
剪枝,顾名思义就是从搜索树上剪掉一些没用的子树,让时间复杂度降低。
一、可行性剪枝
还是刚才的问题。
当n=2的时候,如果当N=2时,已经选了2个数,再继续选就是没有意义了,所以我们可以直接减去这个搜索分支。也就是:
再比如,一旦现在的值已经>sum了,也没必要继续搜索了。
- 代码
#include <bits/stdc++.h>
using namespace std;
const int MAX_N = 15; // n的最大值,这里设它为15
int x, n, sum; // 输入数据
int res; // 总方案数
int a[MAX_N];
void dfs(int i, int N, int S) {
if (N > n) { // 剪枝1
return ;
}
if (S > sum) { // 剪枝2
return ;
}
// 递归终止条件:所有数都搜索完了(也可以是找到n盘食物了,同理)
if (i == x) {
if (N == n && S == sum) { // 如果食物数量和重量都符合要求,就把答案+1
++res;
}
return ;
}
dfs(i + 1, N, S); // 如果不选当前菜
dfs(i + 1, N + 1, S + a[i]); // 如果选当前菜
}
int main() {
cin >> x >> n >> sum;
for (int i = 0; i < x; ++i) {
cin >> a[i];
}
dfs(0, 0, 0); // 从编号为0的菜开始搜索,最开始有0个菜,重量和为0
cout << res << '\n';
return 0;
}
二、最优性剪枝
对于求最优解的问题,有些时候可以用最优性剪枝。
比如求解迷宫最短路径类的问题,如果当前步数已经大于等于答案了,就可以停止搜索。
此外,在判断是否有可行解的过程中,只要有一个解,后面的搜索就不用进行啦(可以使用exit(0)),这是最优性剪枝的一种特例。
- 代码
这里就写剪枝优化的代码~
int res = 0x3f3f3f3f; // 答案
int n, m;
......
void dfs(......, int stp) { // stp表示当前步数
if (stp >= res) {
return ;
}
if (符合条件) { // 排除来不是最优解的情况后,此时stp肯定是目前的最优解
res = stp;
return ;
}
......
}
三、重复性剪枝
对于某些搜索方式,一个方案可能会被搜索多次,这样是没必要了。
还是那道题…
我们其实不需要像之前那样每次dfs都把每个都枚举了。因为我们只关注最这串数的和,而不关注顺序。所以我们可以使用重复性剪枝。
我们设定选出的数的下标是递增的,所以我们可以设置一个变量表示上次枚举的下标,这次从上次+1开始枚举就不会有重复的了。
- 代码
bool eaten[MAX_N]; // eaten[i]表示第i个食物是否被吃过
void dfs(int N, int S, int pos) {
...... // 判断边界 + 可行性剪枝
for (int i = pos; i <= n; ++i) {
if (!eaten[i]) { // 没吃过就吃
eaten[i] = true;
dfs(N + 1, S + a[i], i + 1); // 下一个的位置在这个的后面
eaten[i] = false; // 回溯,把食物吐出来(蒟蒻君还可以继续吃...)
}
}
}
四、奇偶性剪枝
大家先看一道题(上次农场里那几只挑剔的小猪又登场了…):
- 题目
农场主嫌这些小猪又懒又馋(猪猪不都是这样吗),决定给它们一个考验。
他给猪猪们出了一个n * m的迷宫,其中有起点、终点、墙和平地。小猪猪们只能向上下左右走,不能爬墙(它们貌似也不会爬墙)。
它们走每步需要一个小时的时间(因为它们实在是太胖了),到第T个小时时,农场主就会不耐烦,把猪粮送给隔壁的老母猪。
猪猪们很饿,想让你判断一下是否可以吃到猪粮。
起点:‘A’
终点:‘B’
平地:’.’
墙:’*’ - 分析
这里会的小伙伴可能会想到小学奥数证明题中的染色问题,其中的一个方法就是黑白染色。
如图,每隔一个格子染成黑色,其余的为白色。
在这个图中,每走一步就会变一次色。
我们还可以看出来,如果行数+列数为计数,当前格子就为黑色。否则为白色。
从A走到B要奇数次(即A、B两格不同色),而T为偶数,或者要偶数次,而T为奇数,则可以剪枝。
剪枝例题
- 分析
这道题其实就是很简单的dfs+剪枝,别的难点都没用到(蒟蒻君好不容易挑哒)。
本题思路分为以下几步:
- 预处理所有木棍的总长度,保证枚举答案的值能被总长度整除。
- 每根木棍的长度可用hash存储,预处理最长的和最短的木棍的长度,dfs时从最大长度到最小长度枚举。
- 记录当前长度,下次dfs从当前长度-1来枚举(重复性剪枝)。
- 若某组拼接不成立,且此时已拼接的长度为0或当前已拼接的长度与刚才枚举的长度之和为最终枚举的答案时,则可直接跳出循环(可行性剪枝)。
- 代码
#include <bits/stdc++.h>
using namespace std;
const int N = 70;
int n;
int res;
int maxn, minn = N;
int box[N]; // step2
void dfs(int pre, int sum, int x, int y) {
if (pre == 0) { // step4
cout << x << '\n';
exit(0);
}
if (sum == x) { // step3
dfs(pre - 1, 0, x, maxn);
return ;
}
for (int i = y; i >= minn; --i) { // step2 & step3
if (box[i] > 0 && i + sum <= x) {
--box[i];
dfs(pre, sum + i, x, i);
++box[i]; // 回溯
if (sum == 0 || sum + i == x) { // step4
break;
}
}
}
}
int main() {
cin >> n;
while (n--) {
int t;
cin >> t;
if (t > 50) { // 过滤
continue;
}
++box[t];
res += t;
maxn = max(maxn, t); // step1
minn = min(minn, t);
}
int en = res >> 1; // 由于res一直在改变,所以需要在循环前定义一个变量存储
for (int i = maxn; i <= en; ++i) {
if (res % i == 0) {
dfs(res / i, 0, i, maxn);
}
}
cout << res << '\n';
return 0;
}
dfs的课程到此结束,接下来蒟蒻君将为大家讲解bfs~