零、剪枝
如果我们在搜索某些状态前,能预先知道这些状态及之后的状态肯定不会得到一个合法方案,那么就可以提前返回,从而达到提高搜索效率的效果。
常用的剪枝方法:
- 优化搜索顺序:大部分情况下,应该优先搜索分支较少的节点;
- 排除等效冗余:尽量保证不搜索重复的状态 (在不考虑顺序时,用组合方式搜索);
- 可行性剪枝:若继续搜下去所有状态都不合法,可以提前退出;
- 最优性剪枝:若当前花费已经差于当前最优解,可以提前退出;
- 记忆化搜索:即动态规划,在纯搜索剪枝中很少用到,本小节直接略过。
一、小猫爬山
先考虑一个可行的搜索方案:对于每个小猫,它可以放进当前所有的缆车中任意一辆 (前提不超重),或者新开一个缆车,选择上述任何一个方案,继续搜索,然后回溯。直到所有猫全部坐上缆车,得到一种合法方案,与当前最优解取最小值。
接下来考虑剪枝:
- 优化搜索顺序:把所有猫按照重量从大到小排序,从重猫开始枚举,这使得下一只猫能够放的缆车数量尽可能少,从而减少了分支数量;
- 排除等效冗余:按照组合方式枚举,记录当前选择的小猫的下标,下次搜索时从该下标之后的小猫开始枚举;
- 可行性剪枝:小猫坐上某辆缆车后不能超重;
- 最优性剪枝:如果当前缆车数量不小于当前最优解,直接返回。
代码实现:
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 20;
int n, m;
int w[N];
int sum[N];
int ans = N;
void dfs(int u, int k){ //搜索到的小猫下标为u,当前共k辆缆车
if (k >= ans) return; //最优性剪枝
if (u == n){
ans = k;
return;
}
for (int i = 0; i < k; i ++) //枚举当前所有缆车
if (sum[i] + w[u] <= m){ //可行性剪枝
sum[i] += w[u];
dfs(u + 1, k);
sum[i] -= w[u];
}
sum[k] = w[u];
dfs(u + 1, k + 1); //新开一个缆车
sum[k] = 0;
}
int main(){
cin >> n >> m;
for (int i = 0; i < n; i ++) cin >> w[i];
sort(w, w + n);
reverse(w, w + n); //优化搜索顺序,由于sort从小到大排序,需要将数组翻转
dfs(0, 0);
cout << ans << endl;
return 0;
}
二、数独
先考虑一个可行的搜索方案:每次随意选择一个空位置,枚举所有能填在这个位置的数,填入一个数,然后继续搜索,回溯,填入下一个数。直到所有格子都填上了数,直接输出方案。
接下来考虑剪枝:
- 优化搜索顺序:每次选择能填数字最少的格子;
- 排除等效冗余:在能填的数的数量相同时,选择排在最前面的格子;
- 可行性剪枝:某个格子中填的数在其所处行、列、九宫格内都未曾出现过;
- 最优性剪枝:由于本题是可行性问题,没有最优性剪枝。
除了搜索剪枝之外,还可运用位运算来快速找出某个格子能填哪些数:
用
9
9
9 位二进制数代表行、列、九宫格内填过的数字的状态,某个格子能填一个数当且仅当其所在行、列、九宫格的三个状态二进制数对应的一位均为
1
1
1,直接用与运算即可;在与运算后使用
l
o
w
b
i
t
lowbit
lowbit 运算,可以快速得出这个格子总共能填哪些数。
代码实现:
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 9, M = 1 << N;
int ones[M], map[M];
int row[N], col[N], cell[3][3];
char str[100];
void init(){
for (int i = 0; i < N; i ++) row[i] = col[i] = (1 << N) - 1;
for (int i = 0; i < 3; i ++)
for (int j = 0; j < 3; j ++)
cell[i][j] = (1 << N) - 1;
}
void draw(int x, int y, int t, bool is_set){ //在(x,y)处写入或清除数t,is_set=1代表写入,=0代表清除
if (is_set) str[x * N + y] = '1' + t;
else str[x * N + y] = '.';
int v = 1 << t;
if (!is_set) v = -v;
row[x] -= v;
col[y] -= v;
cell[x / 3][y / 3] -= v;
}
int lowbit(int x){
return x & -x;
}
int get(int x, int y){
return row[x] & col[y] & cell[x / 3][y / 3];
}
bool dfs(int cnt){
if (!cnt) return 1;
int minv = 10;
int x, y;
for (int i = 0; i < N; i ++)
for (int j = 0; j < N; j ++)
if (str[i * N + j] == '.'){
int state = get(i, j);
if (ones[state] < minv){
minv = ones[state];
x = i, y = j;
}
}
int state = get(x, y);
for (int i = state; i; i -= lowbit(i)){
int t = map[lowbit(i)];
draw(x, y, t, 1);
if (dfs(cnt - 1)) return 1;
draw(x, y, t, 0);
}
return 0;
}
int main(){
for (int i = 0; i < N; i ++) map[1 << i] = i;
for (int i = 0; i < 1 << N; i ++)
for (int j = 0; j < N; j ++)
ones[i] += i >> j & 1;
while (cin >> str, str[0] != 'e'){
init();
int cnt = 0;
for (int i = 0, k = 0; i < N; i ++)
for (int j = 0; j < N; j ++, k ++)
if (str[k] != '.'){
int t = str[k] - '1';
draw(i, j, t, 1);
}
else cnt ++;
dfs(cnt);
puts(str);
}
return 0;
}
三、木棒
先考虑一个可行的搜索方案:从小到大枚举木棒的长度,对于某一固定长度,从前往后拼接每一根木棒,若所有木棍都能用上,说明方案可行,得到长度最小值。
接下来考虑剪枝:
- 木棍总长度必须是枚举的木棒长度的倍数,木棒长度从最大木棍长度开始枚举;
- 优化搜索顺序:类似小猫爬山一题,在拼接某一固定长度的木棒时,把所有木棍按长度从大到小排序,从长的木棍开始枚举,从而减少分支数量;
- 排除等效冗余:
(1) 按照组合方式枚举,记录当前选择的木棍的下标,下次搜索时从该下标之后的木棍开始枚举;
(2) 如果当前木棍加到当前棒中失败了,直接略过后面所有等长木棍;
(3) 如果尝试拼入当前木棒 i i i 的第一根木棍 u u u 失败了,则直接返回失败;
证明:用反证法,假设存在一种可行方案,则木棍 u u u 会在 i i i 之后的某一根木棒 j ( j ≥ i ) j\ (j\ge i) j (j≥i) 中出现,此时将木棒 j j j 中的木根重新排列,使得木棍 u u u 处在木棒 j j j 的最前端,再将木棒 i i i 与 j j j 调换,这样操作后得到的仍然是一种可行方案,但此时木棍 u u u 是木棒 i i i 的第一根木棍,矛盾。
(4) 如果木棍 u u u 是拼成木棒 i i i 的最后一根木棍,且继续拼接剩余的木棒失败了,则直接返回失败;
证明:用反证法,假设存在一种可行方案,则木棍 u u u 会在 i i i 之后的某一根木棒 j ( j > i ) j\ (j>i) j (j>i) 中出现,且木棒 i i i 最后长度为 l e n ( u ) len(u) len(u) 的部分由不少于一根的木棍拼成,此时将木棍 u u u 与这一部分交换,得到的仍然是一种可行方案,但此时继续拼接剩余的木棒是可行的,矛盾。 - 可行性剪枝:拼上当前一根木棍后长度大于枚举的木棒长度,失败;
- 最优性剪枝:由于本题是可行性问题,没有最优性剪枝。
代码实现:
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 70;
int n, sum, length;
int w[N];
bool st[N];
bool dfs(int u, int s, int start){
if (u * length == sum) return 1;
if (s == length) return dfs(u + 1, 0, 0);
//剪枝3-1,从start开始枚举
for (int i = start; i < n; i ++){
if (st[i]) continue;
if (s + w[i] > length) continue; //可行性剪枝
st[i] = 1;
if (dfs(u, s + w[i], i + 1)) return 1;
st[i] = 0;
//剪枝3-3
if (!s) return 0;
//剪枝3-4
if (s + w[i] == length) return 0;
//剪枝3-2
int j = i;
while (j < n && w[j] == w[i]) j ++;
i = j - 1;
}
return 0;
}
int main(){
while (cin >> n, n){
memset(st, 0, sizeof st);
sum = 0, length = 0;
for (int i = 0; i < n; i ++){
cin >> w[i];
sum += w[i];
length = max(length, w[i]);
}
//剪枝2,优化搜索顺序
sort(w, w + n);
reverse(w, w + n);
while (length <= sum){
if (sum % length == 0 && dfs(0, 0, 0)){
cout << length << endl;
break;
}
length ++;
}
}
return 0;
}
四、生日蛋糕
先考虑一个可行的搜索方案:自底向上搜索,对于每一层蛋糕,枚举其可能的半径和高度,然后继续搜索上一层蛋糕,直到层数与体积与所给题中所给数据相等,更新最小值。
接下来考虑剪枝:
- 优化搜索顺序:每一层先枚举半径 R R R 再枚举高度 H H H (因为在体积公式中,半径次数为 2 2 2,高度次数为 1 1 1),且两者的枚举顺序均为从大到小;
- 对于蛋糕第
u
u
u 层,其半径
R
u
R_u
Ru 与高度
H
u
H_u
Hu 存在一个范围:(设当前从第
u
+
1
u+1
u+1 层到第
m
m
m 层总体积为
v
v
v)
u ≤ R u ≤ m i n { R u + 1 − 1 , ⌊ n − v u ⌋ } u\le R_u\le min\{R_{u+1}-1, \lfloor\sqrt{\frac{n-v}{u}}\rfloor\} u≤Ru≤min{Ru+1−1,⌊un−v⌋}, u ≤ H u ≤ m i n { H u + 1 − 1 , ⌊ n − v R 2 ⌋ } u\le H_u\le min\{H_{u+1}-1, \lfloor\frac{n-v}{R^2}\rfloor\} u≤Hu≤min{Hu+1−1,⌊R2n−v⌋}
由于前 u u u 层蛋糕的体积为 n − v n-v n−v,第 u u u 层蛋糕的高度最小为 u u u,故有 u R u 2 ≤ n − v uR_u^2\le n-v uRu2≤n−v,而当半径确定后,又有 H u R u 2 ≤ n − v H_uR_u^2\le n-v HuRu2≤n−v。 - 排除等效冗余:不会搜到相同的方案,无需剪枝;
- 可行性剪枝:预处理出蛋糕前 u u u 层的最小体积 m i n s v mins_v minsv (即第 i i i 层半径、高度均为 i i i 时),若当前从第 u + 1 u+1 u+1 层到第 m m m 层的体积为 v v v,且 v + m i n v u > n v+minv_u>n v+minvu>n,说明超出了给定体积,直接返回;
- 最优性剪枝:
(1) 预处理出蛋糕前 u u u 层的最小(侧)表面积 m i n s u mins_u minsu (即第 i i i 层半径、高度均为 i i i 时),若当前从第 u + 1 u+1 u+1 层到第 m m m 层的表面积为 s s s,且 s + m i n s u ≥ a n s s+mins_u\ge ans s+minsu≥ans,说明不可能更新最优解,直接返回;
(2) 我们可以用当前剩余的体积 n − v n-v n−v 和第 u + 1 u+1 u+1 层半径 R u + 1 R_{u+1} Ru+1 估算前 u u u 层蛋糕的(侧)表面积,观察前 u u u 层(侧)表面积 S S S 和体积 n − v n-v n−v 的公式:
S = ∑ k = 1 u 2 R k H k S=\sum\limits^u_{k=1}2R_kH_k S=k=1∑u2RkHk, n − v = ∑ k = 1 u R k 2 H k n-v=\sum\limits^u_{k=1}R_k^2H_k n−v=k=1∑uRk2Hk,注意到两式中含有相似部分,对 S S S 进行放缩:
S = 2 R u + 1 ∑ k = 1 u R k R u + 1 H k > 2 R u + 1 ∑ k = 1 u R k 2 H k = 2 R u + 1 ( n − v ) S=\frac{2}{R_{u+1}}\sum\limits^u_{k=1}R_kR_{u+1}H_k>\frac{2}{R_{u+1}}\sum\limits^u_{k=1}R_k^2H_k=\frac{2}{R_{u+1}}(n-v) S=Ru+12k=1∑uRkRu+1Hk>Ru+12k=1∑uRk2Hk=Ru+12(n−v),
故当 s + 2 R u + 1 ( n − v ) > a n s s+\frac{2}{R_{u+1}}(n-v)>ans s+Ru+12(n−v)>ans 时,一定有 s + S > a n s s+S>ans s+S>ans,说明不可能更新最优解,直接返回。
代码实现:
#include <iostream>
#include <cstring>
#include <algorithm>
#include <cmath>
using namespace std;
const int N = 25, INF = 1e9;
int n, m;
int minv[N], mins[N];
int R[N], H[N];
int ans = INF;
void dfs(int u, int v, int s){
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 >= 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, v + r * r * h, s + 2 * r * h + t);
}
}
int main(){
cin >> n >> m;
for (int i = 1; i <= m; i ++){ //预处理出前i层的最小体积和表面积
minv[i] = minv[i - 1] + i * i * i;
mins[i] = mins[i - 1] + 2 * i * i;
}
R[m + 1] = H[m + 1] = INF;
dfs(m, 0, 0);
if (ans == INF) puts("0");
else cout << ans;
return 0;
}