前言
设计DFS搜索首先要保证的是: 设计合理的搜索顺序, 能够涵盖所有的状态。
然而DFS搜索的状态数量是按照指数级别增长, 而且这些状态有些是'无用的', 为此, 我们需要通过剪枝
策略去减少搜索的状态, 从而提高DFS的效率
DFS的剪枝策略可以分为5大类:
-
优化搜索顺序
-
排除等效冗余
-
可行性剪枝
-
最优化剪枝
-
记忆化搜索(DP)
其中记忆化搜索主要用在DP
上。
对于要使用DFS搜索的题目, 我们就可以从以上这几个角度去出发, 发掘题目中的种种性质, 从而减少搜索的方案数, 优化搜索的复杂度
当然,减少递归的层数能够减少系统资源的利用,从而加快程序的运行速度
例题
小猫爬山
题目链接]
读完题意之后, 我们可以从这样去设计搜索的顺序:
每个猫咪看作搜索的层数, 看猫咪能放入哪个缆车中
对于每只小猫, 有两种安置的策略:
-
把小猫放置在已经租用的缆车中
-
把小猫放在新的缆车中
这种搜索顺序可以涵盖所有的可能, 确定好搜索顺序之后, 再分析一下剪枝
剪枝:
- 优化搜索顺序:
先搜索体积大的猫, 那么缆车最后存放的猫的数量也就越少, 搜索的方案也就越少
- 排除等效冗余:
本题的搜索顺序是不存在等效冗余的, 所以无法从这个角度切入
- 可行性剪枝:
只有当前缆车的空余容量大于某个小猫的体积
- 最优化剪枝:
首先, 答案肯定不会超出猫咪的数量
其次, 当搜索的结果大于答案, 则当前的方案肯定不会是最优解, 所以可以提前结束搜索, 进行剪枝
那么最终, 我们可以根据这些分析, 写出本题的代码啦
#include <iostream>
#include <algorithm>
using namespace std;
constexpr int N = 20;
int c[N], n, m, res;
int w[N];
void dfs(int u, int cat) {
// 最优化剪枝
if (u >= res) return;
// 安置好最后一个小猫
if (cat > n) {
res = u;
return;
}
for (int i = 1; i <= u; i++) {
if (w[i] >= c[cat]) { // 可行性剪枝
w[i] -= c[cat];
dfs(u, cat + 1);
w[i] += c[cat];
}
}
w[u + 1] -= c[cat];
dfs(u + 1, cat + 1);
w[u + 1] += c[cat];
}
int main() {
cin >> n >> m;
fill(w, w + N, m);
res = n;
for (int i = 1; i <= n; i++) cin >> c[i];
// 优化搜索顺序
// 因为体积越大的猫,下面的节点就越少,搜索的状态也就越少
sort(c + 1, c + n + 1, greater<int>());
dfs(1, 1);
cout << res << '\n';
return 0;
}
分成互质组
本题有两种可行的搜索顺序:
-
固定组, 看看有哪些数可以放到这个组里
-
固定数,看看这个数能放到哪些组里
对于这两种搜索顺序,是无法从优化搜索顺序和排除等效冗余角度入手的,接下来会分别对这两种搜索顺序从其可以入手的剪枝策略进行分析
顺序1:固定组
搜索顺序:依次枚举所有元素,看其能否加入当前组,否则开辟新组存下此元素
剪枝:
-
可行性剪枝:
判断当前元素能否放入该组中,如果能则将该元素存入当前组中,继续向下搜索
-
最优化剪枝:
如果当前答案大于最优答案,那么无需继续向下搜索
-
排除等效冗余:
从
start
开始枚举,确保枚举的每组数据的组合只出现一次
#include <iostream>
using namespace std;
constexpr int N = 14;
int a[N], n, ans, group[N][N];
bool st[N]; // 标记第i个元素是否属于某个组
int gcd(int a, int b) {return b ? gcd(b, a % b) : a; }
inline bool check(int g[], int n, int x) {
for (int i = 0; i < n; i++)
if (gcd(g[i], x) > 1) return 0;
return 1;
}
void dfs(int u, int c, int sum, int start) {
// 最优化剪枝
if (u >= ans) return;
if (sum == n) {
ans = u;
return;
}
bool ok = 1;
for (int i = start; i < n; i++)
// 可行性剪枝
if (!st[i] && check(group[u], c, a[i])) {
st[i] = 1;
group[u][c] = a[i];
dfs(u, c + 1, sum + 1, i + 1);
st[i] = 0;
ok = 0;
}
if (ok) dfs(u + 1, 0, sum, 0);
}
int main() {
cin >> n;
ans = n;
for (int i = 0; i < n; i++) cin >> a[i];
dfs(1, 0, 0, 0);
cout << ans << '\n';
return 0;
}
顺序2:固定元素
搜索顺序:对于当前元素,看其能否放入已有的组中,或者开辟新组存下该元素
剪枝:
-
可行性剪枝:
如果当前元素能存放在该组中, 那么存下该元素继续搜索
-
最优化剪枝:
如果当前答案大于最优答案,那么无需继续向下搜索
如果当前元素能放入一个已经存在组内,那么就没有必要去搜索放入其他已经存在的组
#include <iostream>
#include <vector>
using namespace std;
using VI = vector<int>;
constexpr int N = 11;
int a[N], w[N], n, res;
VI g[N];
bool st[N];
int gcd(int x, int y) { return y ? gcd(y, x % y) : x; }
inline bool check(VI g, int x) {
for (int i : g)
if (gcd(i, x) > 1)
return 0;
return 1;
}
void dfs(int u, int num) {
// 最优化剪枝
if (u >= res) return;
if (num >= n) {
res = u;
return;
}
int c = a[num];
for (int i = 1; i <= u; i++) {
// 可行性剪枝
if (check(g[i], c)) {
/* 如果一个元素能放入一个组内,那么直接放进去,
* 不需要考虑能不能放进其他的组
*/
g[i].push_back(c);
dfs(u, num + 1);
g[i].pop_back();
break;
}
}
g[u + 1].push_back(c);
dfs(u + 1, num + 1);
g[u + 1].pop_back();
}
int main() {
scanf("%d", &n);
// 一个小小的最优化剪枝
res = n;
for (int i = 0; i < n; i++) scanf("%d", &a[i]);
dfs(1, 0);
cout << res << '\n';
return 0;
}
本题小结
虽然这两种搜索顺序的剪枝策略差不太多,但是顺序2的速度是顺序1的\(30\)多倍。也由此可见,搜索顺序的选择对DFS速度的影响也极其重要
数独
本题不光需要剪枝,还需要二进制状态表示等小技巧
搜索顺序:
选取九宫格中的一个格子,将其填满,之后再选另一个格子填满……
剪枝:
- 优化搜索顺序:
每次选填过的最多的格子,因为选过的格子越多,那么往后枚举的可能也就越少
- 可行性剪枝:
当前位置的列、行和所在的九宫格都没有填过数字\(x\),那么这个数字是可填的
其他的优化:
-
对于每一列、行、和九宫格填过数字的状态,我们可以用二进制来表示,
1
代表每天过,0
代表填过 -
对于求一个九宫格中有多少个没有填过的数,我们可以提前预处理出所有二进制的状态,统计每个状态中有多少个
1
-
判断当前格子能否填数,我们可以去这个格子所在的行、列和九宫格的状态的
&
,得到二进制中只有一个1的数字。找到1出现的位置加上1,得到的结果就是可以填的数字。同样的,这个状态中1出现的位置也可提前预处理出来 -
可以用lowbit去快速计算一个状态中可以填的数字的位置
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 9, M = 1 << N;
int row[N], col[N], f[3][3];
char s[N * N + 5];
int loc[M], Count[M];
inline int lowbit(int x) { return x & -x; }
inline void init() {
for (int i = 0; i < N; i++) col[i] = row[i] = (1 << N) - 1;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++) f[i][j] = (1 << N) - 1;
}
int get(int x, int y) { return row[x] & col[y] & f[x / 3][y / 3]; }
inline void update(int x, int y, int v, bool type) {
if (type) {
s[x * N + y] = '1' + v;
} else s[x * N + y] = '.';
int t = 1 << v;
if (!type) t = -t;
row[x] -= t;
col[y] -= t;
f[x / 3][y / 3] -= t;
}
bool dfs(int cnt) {
if (!cnt) return 1;
int x, y, maxv = 12;
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++) {
if (s[i * N + j] != '.') continue;
int n = Count[get(i, j)];
if (n < maxv) maxv = n, x = i, y = j;
}
for (int i = get(x, y); i; i -= lowbit(i)) {
int c = loc[lowbit(i)];
update(x, y, c, 1);
if (dfs(cnt - 1)) return 1;
update(x, y, c, 0);
}
return 0;
}
int main() {
for (int i = 0; i < N; i++) loc[1 << i] = i;
for (int i = 0; i < 1 << N; i++)
for (int j = i; j; j -= lowbit(j)) Count[i]++;
while(cin >> s && s[0] != 'e') {
init();
int cnt = 0;
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++) if (s[i * N + j] != '.') {
update(i, j, s[i * N + j] - '1', 1);
} else cnt++;
dfs(cnt);
puts(s);
}
return 0;
}
木棒
这里称短的为木棒,组成后长的为木棍
搜索顺序:
首先,搜索的木棍的长度一定是所有木棒长度的因数,那么我们可以枚举木棒总长度的因数作为木棍的长度去搜索。问题就转化为了让不同的木棒组称几个长度相同的组
我们可以依次枚举所有的木棒,如果当前的木棒能放入当前木棍的组中,那就放入组中,继续搜索下一个木棒;否则就放到下一个组中,从头去搜索没有放入的木棍。
搜索顺序:
- 优化搜索顺序:
搜索时按照木棒长度从大到小开始搜索,那么之后木棒的可用的空间就就少了,对应的搜索的状态也随之减少
- 排除等效冗余:
按照木棒的下标从小到大进行搜索,这样就能避免等效冗余(组合的方式枚举)
-
可行性剪枝:
-
设枚举木棒的长度为\(len\),木棒的组数为\(n\),如果有\(n\times len>sum\),那么该状态一定不合法
-
如果某个长度的木棒无法放入,那么后面相同长度的木棍也肯定无法放入
-
如果第一个木棒的长度大于木棍的长度,那么该方案一定不合法
-
如果最后一个木棒\(x\)摆放失败了,那么该方案也一定不合法(反证法:如果存在其他组能让\(x\)摆放成功的话,那么调换木棒的摆放顺序,让\(x\)成为最后摆放的木棍,那么就与先前的结论矛盾了)
#include <iostream>
#include <algorithm>
using namespace std;
constexpr int N = 210;
int n, a[N], sum, len;
bool st[N];
bool dfs(int u, int start, int size) {
if (len * u == sum) return 1;
if (size == len) return dfs(u + 1, 0, 0);
for (int i = start; i < n; i++) {
// 可行性剪枝
if (st[i] || a[i] + size > len) continue;
st[i] = 1;
if (dfs(u, start + 1, size + a[i])) return 1;
st[i] = 0;
if (!size || size + a[i] == len) return 0;
int j = i;
while (j < n && a[i] == a[j]) j++;
i = j - 1;
}
return 0;
}
int main() {
while (scanf("%d", &n) != EOF && n) {
// init
fill(st, st + n + 1, 0);
sum = 0, len = 0;
for (int i = 0; i < n; i++) cin >> a[i], sum += a[i];
// 优化搜索顺序
sort(a, a + n, greater<int>());
while (++len <= sum) {
if (sum % len == 0 && dfs(1, 0, 0)) {
printf("%d\n", len);
break;
}
}
}
return 0;
}
生日蛋糕
题目分析:
蛋糕的搜索顺序有两种:自上而下,自底向上。由于给定的体积是固定的,而且体积是自底向上减少的,所以我们应该选择第二个搜索顺序,减少枚举的状态,从而实现对搜索顺序的优化
我们首先需要对蛋糕进行一下分析,看看根据题目中的信息能获取哪些信息:
- 根据题目中的信息,我们可以写出蛋糕的面积和体积的公式
\(n=\sum^m_{i=1}{R_i^2H_i}\),\(Ans=\sum_{i=1}^m2R_iH_i+R_m^2\)
-
对于第\(u\)层蛋糕,我们可以确定半径和高的取值范围:\(u\le R_u< R_{u+1}-1\),\(u\le H_u< H_{u+1}-1\)。同理,我们也可以推测出\(R\)和\(H\)都去最小值时前\(u\)层的体积和表面积
-
对于第\(u\)层,若上一层的体积是\(v\),那么有\(n-v\ge \sum R_i^2H_i\ge R_i^2\),可以得到\(R_i\le \sqrt{n-v}\),\(H_i\le \frac{n-v}{R_i^2}\)。所以每一层的面积的取值有\(u\le R_u< \min({R_{u+1}-1, \sqrt{n-v}})\),体积的取值有\(u\le H_u< \min(H_{u+1}-1,\frac{n-v}{R_i^2})\)
-
寻找体积和面积的关系:
-
首先对面积进行缩放:
所以我们可得到剪枝的方案:
-
优化搜索顺序:自底向上进行搜索
-
可行性剪枝:
-
如果当前体积(面积)加上前\(u\)层最小体积(面积)大于\(n\)(\(Ans\)),那么该方案不可行
-
每一层的面积的取值有\(u\le R_u< \min({R_{u+1}-1, \sqrt{n-v}})\),体积的取值有\(u\le H_u< \min(H_{u+1}-1,\frac{n-v}{R_i^2})\)
-
Ans要满足\(Ans<\frac{2(n-v)}{R_{u+1}}\)
#include <bits/stdc++.h>
#define io ios::sync_with_stdio(false); cin.tie(0); cout.tie(0)
using namespace std;
constexpr int N = 22, INF = 0x3f3f3f3f;
int n, m, ans = INF;
int mins[N], minv[N], R[N], H[N];
void dfs(int u, int s, int v) {
if (v + minv[u] > n) return;
if (s + mins[u] >= ans) return;
if (s + 2 * (n - v) / R[u + 1] >= ans) return;
if (!u) {
if (v == n) ans = s;
return;
}
for (int r = min(R[u + 1] - 1, (int)sqrt(n - v)); >= u; r--)
for (int h = min(H[u + 1] - 1, (n - v) / r / r); h >= u; h--) {
int t = 0;
if (u == m) t = r * r;
R[u] = r, H[u] = h;
dfs(u - 1, s + t + 2 * r * h, v + r * r * h);
}
}
int main() {
io;
cin >> n >> m;
for (int i = 1; i <= m; i++) {
mins[i] = mins[i - 1] + 2 * i * i;
minv[i] = minv[i - 1] + i * i * i;
}
R[m + 1] = H[m + 1] = INF;
dfs(m, 0, 0);
if (ans == INF) ans = 0;
cout << ans << '\n';
return 0;
}
可见本题的剪枝策略主要来自对于公式的推导