我们在0x03节中使用递归实现的指数型、排列型和组合型枚举,其实就是深搜的三种最简单的形式。与之相关的 子集和问题、全排列问题、N皇后问题 等都是可以用深搜求解的经典NPC问题。
下节我们将进一步系统低讨论各类剪枝技巧。
1、AcWing 165. 小猫爬山
题意 :
- 索道上的缆车最大承重量为 W,而 N 只小猫的重量分别是 C1、C2……CN。
- 当然,每辆缆车上的小猫的重量之和不能超过 W。
- 每租用一辆缆车,翰翰和达达就要付 1 美元,所以他们想知道,最少需要付多少美元才能把这 N 只小猫都运送下山?
- 1≤N≤18,
- 1≤Ci≤W≤108
思路 :
- 可以使用深度优先搜索算法解决本题。在搜索的过程中,我们可以尝试依次把每一只小猫分配到一辆已经租用的缆车上,或者新租一辆缆车安置这只小猫。于是,我们实时关心的状态有:已经运送的小猫有多少只,已经租用的缆车有多少辆,每辆缆车上当前搭载的小猫重量之和。
- 编写函数 d f s ( n o w , c n t ) dfs(now,cnt) dfs(now,cnt)处理第 now 只小猫的分载过程(前now - 1只小猫已经装载),并且目前已经租用了 cnt 辆缆车。对于已经租用的这cnt辆缆车的当前搭载辆,我们使用一个全局数组 cab[] 来记录
- now,cnt和cab数组共同标识着问题状态空间所类比的“图”中的一个”节点“。在这个节点“上,我们至多有 cnt + 1 个可能的分支:
1、尝试把第now只小猫分配到已经租用的第i ( 1 ≤ i ≤ c n t 1 \leq i \leq cnt 1≤i≤cnt) 辆缆车上。如果第i辆缆车还装得下,我们就在 cab[i] 中累加 C n o w C_{now} Cnow,然后递归 d f s ( n o w + 1 , c n t ) dfs(now+1,cnt) dfs(now+1,cnt)
2、尝试新租一辆缆车来安置这只小猫,也就是令cab[cnt + 1] = C_now,然后递归 d f s ( n o w + 1 , c n t + 1 ) dfs(now+1,cnt+1) dfs(now+1,cnt+1) - 当now = N + 1时,说明搜索到了递归边界,此时就可以用cnt更新答案
- 为了让搜索过程更加高效,我们可以加入一个很显然的优化:如果在搜索的任何时刻发现cnt已经大于或等于已经搜到的答案,那么当前分支就可以立即回溯了。另外,重量较大的小猫显然比重量较轻的小猫更“难“运送,我们还可以在整个搜索前把小猫按照重量递减排序,优先搜索重量较大的小猫,减少搜索树“分支”的数量(搜索顺序很重要:大部分情况下,我们应该优先搜索分支数量比较少的节点:因为质量大的小猫能够选择的缆车数量少)。
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 20;
int n, m;
int c[N];
int mi = 18 * 1e8 + 1;
int cab[N];
void dfs(int now, int cur) {
if (now == n + 1) {
mi = min(mi, cur);
return ;
}
if (cur >= mi) return ;
for (int i = 1; i <= cur; ++ i) {
if (cab[i] + c[now] <= m) {
cab[i] += c[now];
dfs(now + 1, cur);
cab[i] -= c[now];
}
}
cab[cur + 1] += c[now];
dfs(now + 1, cur + 1);
cab[cur + 1] -= c[now];
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++ i) scanf("%d", &c[i]);
sort(c + 1, c + n + 1);
reverse(c + 1, c + n + 1);
dfs(1, 0);
printf("%d", mi);
}
2、AcWing 166. 数独
题意 :
- 数独 是一种传统益智游戏,你需要把一个 9×9 的数独补充完整,使得数独中每行、每列、每个 3×3 的九宫格内数字 1∼9 均恰好出现一次。
- 请编写一个程序填写数独。
- 每个测试用例占一行,包含 81 个字符,代表数独的 81 个格内数据(顺序总体由上到下,同行由左到右)。
- 每个字符都是一个数字(1−9)或一个 .(表示尚未填充)。
- 您可以假设输入中的每个谜题都只有一个解决方案。
思路 :
- 数独问题的搜索框架非常简单,我们关心的“状态”就是数独的每个位置上填了什么数。在每个状态下,我们找出一个还没有填的位置,检查有哪些合法的数字可以填。这些合法的数字就构成该状态向下继续递归的“分支”
- 搜索边界分为两种:1、如果所有位置都被填满,就找到了一个解;2、如果发现某个位置没有能填的合法数字,说明当前分支搜索失败,应该回溯去尝试其他分支
- Hint:在任意一个状态下,我们只需要找出1个位置,考虑该位置上填什么数(如上页图搜索树的根节点只有2个分支),不需要枚举所有的位置和可填的数字向下递归(因为其他位置在更深的层次会被搜索到),以免重叠、混淆“层次”和“分支”,造成重复遍历若干棵覆盖同一状态空间的搜索树。
- 然而,数独问题的“搜索树”规模仍然很大,在搜索算法中,应该采取与人类类似的策略:在每个状态下,从所有未填的位置里选择“能填的合法数字”最少的位置(也就是说优先搜索分支最少的节点),考虑该位置上填什么数,作为搜索的分支,而不是任意找出1个位置。
- 在搜索程序中,影响时间效率的因素除了搜索树的规模(影响算法的时间复杂度),还有在每个状态上记录、检索、更新的开销(影响程序运行的“常数”时间)。我们可以使用 位运算 来代替数组执行“对数独各个位置所填数组的记录”以及“可填性的检查与统计”。这就是我们所说的程序“常数优化”。具体地说:
1、对于 每行、每列、每个九宫格,分别用一个 9位二进制(全局整数变量)保存哪些数字还可以填(1表示还可以填)
2、对于每个位置,把它所在行、列、九宫格的 3个二进制数 做 位与**&** 运算,就可以得到该位置能填哪些数(&后如果为1说明在三个二进制数中均为1,说明在三个中都可以被填),用lowbit运算就可以把能填的数字取出。
3、当一个位置填上某个数后,把该位置所在的行、列、九宫格记录的二进制数的对应位改为0,即可更新当前状态;回溯时改回1即可还原现场 - 通过本节的内容,读者可能已发现,一般搜索解法的基本框架并不难,但如何减小搜索树的规模并快速遍历搜索树却是一门高深的学问。在接下来的两节中,我们就终点研究所搜的优化。我们还会探讨16*16的数独问题,进一步提高求解数独的效率。
#include <iostream>
using namespace std;
const int N = 9;
char str[100];
int map[1 << N]; // map[i]表示log_2{i},常与lowbit搭配
int ones[1 << N]; // ones[i]表示二进制表示下为i的1的个数
int row[N], col[N], cell[3][3];
inline int lowbit(int x) {
return x & -x;
}
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;
}
}
}
inline int get(int x, int y) {
return row[x] & col[y] & cell[x / 3][y / 3];
}
bool dfs(int cnt) {
if (!cnt) return true;
int minv = 10;
int x, y;
for (int i = 0; i < N; ++ i) {
for (int j = 0; j < N; ++ j) {
if (str[i * 9 + j] == '.') {
int t = ones[get(i, j)];
if (minv > t) {
minv = t;
x = i, y = j;
}
}
}
}
for (int i = get(x, y); i; i -= lowbit(i)) { // 枚举(x, y)所有可选方案
int t = map[lowbit(i)]; // 二进制表示为i,
// 修改状态
row[x] -= 1 << t;
col[y] -= 1 << t;
cell[x / 3][y / 3] -= 1 << t;
str[x * 9 + y] = t + '1';
if (dfs(cnt - 1)) return true;
// 恢复现场
row[x] += 1 << t;
col[y] += 1 << t;
cell[x / 3][y / 3] += 1 << t;
str[x * 9 + y] = '.';
}
return false;
}
int main() {
for (int i = 0; i < N; ++ i) map[1 << i] = i;
// 数二进制下1的个数
for (int i = 0; i < 1 << N; ++ i) {
int s = 0;
for (int j = i; j; j -= lowbit(j)) ++ s;
ones[i] = s;
}
while (scanf("%s", 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) { // k = i * 9 + j,k为字符串中对应位置
if (str[k] != '.') {
int t = str[k] - '1';
row[i] -= 1 << t;
col[j] -= 1 << t;
cell[i / 3][j / 3] -= 1 << t; // 由行、列得到处于第几个九宫格
}
else ++ cnt;
}
}
dfs(cnt);
printf("%s\n", str);
}
}