32 KiB
C++算法讲义:搜索算法 - 深度优先搜索 (DFS) 与回溯
1. 引言
-
问题引入:四阶数独(OJ:B0201) 数独是一种经典的逻辑游戏。我们这里要解决的是一个简化版的 4x4 数独。 要求: 给定一个完全空白的 4×4 网格,要求填写数字 1 到 4,使得以下条件同时满足:
- 每一行必须包含 1, 2, 3, 4 各一次(无重复)。
- 每一列必须包含 1, 2, 3, 4 各一次(无重复)。
- 每个 2×2 子区域(共 4 个)必须包含 1, 2, 3, 4 各一次(无重复)。 (子区域划分:将 4×4 网格均分为左上、右上、左下、右下四个 2×2 的小方块) 任务: 求所有可能的合法填写方法的总数。
-
价值阐述 暴力枚举的策略,会将所有可能的情况都枚举一遍。但其效率如同愚公移山,无法应付数据范围稍大的情形。 搜索算法(包括 DFS 和 BFS)是在暴力枚举的基础上,引入了更聪明的策略,从起点开始,逐渐扩大寻找范围,直到找到需要的答案为止。严格来说,搜索算法也算是一种暴力枚举策略,但是它的算法特性决定了效率比直接的枚举所有答案要高,因为它可以通过剪枝跳过一些无效状态,降低问题规模。
DFS 及其背后的回溯思想,是解决很多复杂问题(如全排列、组合、路径寻找、NP 问题)的基石。掌握后我们可以:
- 应对复杂问题:解决那些需要穷举所有可能性,但又可以通过“提前发现错误”来优化的问题。
- 理解递归深层逻辑:深入理解函数调用栈在算法中的关键作用。
- 培养抽象思维:将现实问题抽象为搜索路径,并设计剪枝策略。
**2. 核心思想 **
2.1 从暴力到智能:数独问题的思考
我们以 4x4 数独为例,看看为什么我们需要 DFS。
-
解法一:纯暴力枚举
- 思路:4×4 共 16 个格子。第 1 个格子试 1、2、3、4;第 2 个格子试 1、2、3、4……直到第 16 个格子。全填满之后,再去检查行、列、小方块有没有重复。
- 计算代价:每个格子 4 种选择,16 个格子总共是
4^{16}种情况。4^{16}大约等于 42 亿!计算机为了算这一个迷你数独,要足足判断 42 亿次。这种方式非常低效,不可行。
-
解法二:按行生成全排列
- 思路:既然每一行都必须是由 1、2、3、4 组成,且不能重复,那我们干嘛要让第一行出现
1, 1, 1, 1这种明显违规的排列呢?我们可以把“按格子填”改成“按行填”。1 到 4 的全排列(比如 1234, 1243, 1324...)一共有4! = 24种。既然有 4 行,我们只需要写 4 层 for 循环,去组合这 4 行的排列。 - 计算代价:总情况数降到了
24 \times 24 \times 24 \times 24 = 331,776种。33 万次计算,计算机大概零点几秒就能跑完。
这种解法完美了吗? 如果我们把问题放大到 9 阶数独,每行是 9 的全排列(36 万种),9 行组合起来就是 $362,880^9$,依然是个天文数字!解法 2 依然存在巨大的资源浪费。
解法二的愚蠢之处 模拟解法二的运行:假设程序正在运行解法二,此时:
- 第 1 行填了:
[1, 2, 3, 4] - 第 2 行准备填:
[1, 3, 4, 2]看一眼这两行,发现了什么问题?第一列出现了两个 1,已经违规了! 但是,解法 2 并不会发现违规了,它依然会傻乎乎地去把第 3 行的 24 种可能、第 4 行的 24 种可能全部生成出来,拼成完整的 4x4 矩阵,然后才去检查。仅仅因为前两行这个明显的错误,程序在后面做了24 \times 24 = 576次毫无意义的拼凑和检查。
思考:聪明人会怎么做? 一旦发现某一步已经错了,立刻停手,绝不继续往下走,赶紧换一种填法! 而在算法中,这种提前发现错误并中断后续操作的思路,就叫做剪枝。我们现在有了要随时检查,及时止损**的策略。但问题来了:
for循环非常死板,写了 16 层就是 16 层,很难做到进退自如。我们需要一种更灵活的框架。 - 思路:既然每一行都必须是由 1、2、3、4 组成,且不能重复,那我们干嘛要让第一行出现
2.2 深度优先搜索 (DFS) 的核心逻辑
DFS 的核心思想就是:走一步看一步,不合法就回头,换条路再走。
递归枚举(DFS)的逻辑思想:
我们不用循环,而是写一个递归函数 void dfs(当前填第几个空)。流程可以想象成走迷宫:
- 走一步看一步:站在当前需要填写的空格,从所有可能的数字(1~4)中逐个尝试。
- 立刻检查:看看当前的行、列、小方块里有没有刚刚填写的数字。
- 合法就深入(深度优先):如果没有冲突,说明这个填的数字暂时没问题!我把它填进去(保存现场),然后进入下一个空格
dfs(下一个空格)。 - 死胡同就回头(回溯):假如我在某一个空格试了 1、2、3、4 发现全都冲突了,怎么办?这说明我在前面几步走错路了!此时,我不能强行往下走,而是要退回到上一个空格,把之前填的数字擦掉(恢复现场),换一个数字重新试。
通过这种**“填数字 \rightarrow 检查 \rightarrow 不行就擦掉重填”**的递归机制,我们就避开了所有的无效状态。这种一般化的枚举框架,就是深度优先搜索与回溯法。
2.3 递归与回溯的底层机制:系统栈
既然逻辑已经理顺了,现在就把这些想法变成一行行能跑的代码。 首先要解决一个最基本的问题:为什么非得用函数,而且是自己调自己的递归函数?
知识回顾:栈不仅仅是一个数据结构。它其实是一种暂时挂起、等待时机的逻辑模型。它让我们在面对复杂、嵌套、或者需要回头看的任务时,能够不慌不忙,一次只专注处理眼前最紧急的那件事。
-
系统栈:当函数调用另一个函数时,计算机会把当前的进度和相关信息压栈 (push),等结束了再弹栈 (pop) 回来继续干活。在普通的
for循环里,代码是一口气执行到底的,没有停留。 但在递归中,当dfs(k)执行到dfs(k + 1)这一行时,dfs(k)会暂时停工。 -
挂起:假设你现在是
dfs(1),你刚刚把数字 1 填入第一个格子。接下来你调用了dfs(2)。此时,dfs(1)的这个函数并没有结束,它的for循环停在了num = 1这一步,不再往下走了。 本质:计算机把dfs(1)当前所有的数据(当前在第几格、填了什么数字、接下来还要试哪些数字)打包放进了系统栈里。任务干了一半,要准备填第二个格子了,那我就先搁置在这里等着你的结果。 -
等待时机:被挂起的
dfs(1)待在栈里干什么呢?它在等待。 它在等什么?它在等dfs(2)给它一个明确的结局。 因为dfs(1)填入数字 1 这个动作,到底是通向胜利的大道,还是通向死胡同?dfs(1)自己是不知道的。它必须等待后续的格子尝试后返回到自己这里,才能得到答案。 这种**“我先做一个初步决定,然后静静等待后续发展”**的模式,就是等待时机。 -
流程模拟:
dfs(k)调用dfs(k+1):代表基于当前成功的决策,向问题的下一个阶段前进。dfs(k)的状态被压栈。dfs(k+1)返回return:- 如果
dfs(k+1)找到了解:return后,dfs(k)所在的那条路径可能是一条完整解的一部分。 - 如果
dfs(k+1)发现死胡同:dfs(k+1)尝试完所有可能性后,没有为第k+1格找到任何一个合法的数字,它会执行return,并将执行控制权交还给它的调用者——dfs(k)。 此时,dfs(k)从暂停处恢复执行。它收到dfs(k+1)的返回,意识到当初为k格填入的数字,导致了后续格子无解。它必须清除自己刚才做的决策,即把第k格的数字擦掉(恢复现场),以便for循环尝试下一个数字。 然后,dfs(k)的for循环继续,尝试为第k格填入下一个数字。
- 如果
总结: 递归将 前进探索 和 失败回退 这两个核心操作,映射到了 函数调用 和 函数返回 这两个编程构造上,使得复杂的搜索与回溯逻辑变得结构清晰且易于实现。递归函数调用栈是这个机制的底层实现。它自动保存了每个暂停的函数的状态,确保函数可以从正确的位置继续执行。
3. 实例
3.1 数独问题:完整框架搭建 (例15-3)
准备工作:定义棋盘和递归函数框架
首先,我们需要一个 4x4 的棋盘、辅助变量和一个 dfs 函数的基本框架。
vector<vector<int>> grid(4, vector<int>(4, 0)); // 4x4棋盘,0代表空格
int count = 0; // 用来统计解的总数
// 检查函数,判断在(x, y)位置填num是否合法(下面会详细实现)
bool check(int x, int y, int num) {
// 检查行
for (int j = 0; j < 4; ++j) {
if (grid[x][j] == num) return false;
}
// 检查列
for (int i = 0; i < 4; ++i) {
if (grid[i][y] == num) return false;
}
// 检查2x2子区域
int startX = (x / 2) * 2; // 计算2x2子区域的左上角行坐标
int startY = (y / 2) * 2; // 计算2x2子区域的左上角列坐标
for (int i = startX; i < startX + 2; ++i) {
for (int j = startY; j < startY + 2; ++j) {
if (grid[i][j] == num) return false;
}
}
return true; // 所有检查都通过,合法
}
// dfs函数,参数k代表当前要填的是第k个空格(从0到15)
void dfs(int k) {
// 递归的出口:如果k等于16,说明所有格子都填完了
if (k == 16) {
count++; // 找到了一个完整的合法解
return; // 返回,结束这条探索路径
}
// 将第k个空格(0-15)转换为二维的(x, y)坐标
int x = k / 4; // 行号
int y = k % 4; // 列号
// 步骤 1: 走一步看一步 —— 遍历所有可能的数字
for (int num = 1; num <= 4; ++num) {
// 步骤 2: 立刻检查 —— 判断填入num是否合法
if (check(x, y, num)) {
// 步骤 3: 合法就深入 —— 保存现场,递归调用
grid[x][y] = num; // 把数字实际填入棋盘
dfs(k + 1); // 深入,去处理下一个空格 (k+1)
// 步骤 4: 死胡同就回头(回溯)—— 恢复现场
// 当dfs(k+1)返回,意味着从那一步开始的所有路径都探索完了。
// 我们必须恢复现场,把格子变回空格,以便for循环下一次尝试新的num。
grid[x][y] = 0;
}
}
}
int main() {
dfs(0); // 游戏开始:从第0个格子开始填写
cout << count << endl;
return 0;
}
check 函数的实现细节:
bool check(int x, int y, int num)
- 检查行:
num不能与(x, y)所在行的任何其他数字相同。- 行号
x是固定的。我们需要循环改变的是列号j。 for (int j = 0; j < 4; ++j) { if (grid[x][j] == num) return false; }
- 行号
- 检查列:
num不能与(x, y)所在列的任何其他数字相同。- 列号
y是固定的。我们需要循环改变的是行号i。 for (int i = 0; i < 4; ++i) { if (grid[i][y] == num) return false; }
- 列号
- 检查 2x2 子区域:不能与当前坐标所在的 2x2 小方块内的任何其他数字相同。
- 如何确定子区域的左上角
(startX, startY)? 我们观察到:- 对于第 0、1 行,左上角的行号总是 0。对于第 2、3 行,左上角的行号总是 2。
- 这个规律可以用一个整数除法公式来表示:
int startX = (x / 2) * 2; - 同理,
int startY = (y / 2) * 2;
- 然后遍历
(startX, startY)到(startX+1, startY+1)这四个格子。
- 如何确定子区域的左上角
优化 check 函数:使用桶数组
为了提高 check 函数的效率,可以引入三个桶数组(布尔数组)来实时记录每个区域的数字占用情况。这样,check 函数就不需要遍历,而是 O(1) 地查询。
vector<vector<int>> grid(4, vector<int>(4, 0)); // 4x4棋盘
int count = 0;
// 三个桶数组,用来快速检查冲突
bool row_used[4][5]; // row_used[i][num] 表示第i行num是否已被使用 (数字1-4)
bool col_used[4][5]; // col_used[j][num] 表示第j列num是否已被使用
bool block_used[4][5]; // block_used[k][num] 表示第k个2x2子区域num是否已被使用
// check函数变得非常简单,只是查询桶数组
bool check(int x, int y, int num) {
int block_idx = (x / 2) * 2 + (y / 2); // 计算当前格子所在的2x2子区域索引 (0-3)
return !row_used[x][num] && !col_used[y][num] && !block_used[block_idx][num];
}
void dfs(int k) {
if (k == 16) {
count++;
return;
}
int x = k / 4;
int y = k % 4;
int block_idx = (x / 2) * 2 + (y / 2);
for (int num = 1; num <= 4; ++num) {
if (check(x, y, num)) {
// 保存现场:更新棋盘和桶数组
grid[x][y] = num;
row_used[x][num] = true;
col_used[y][num] = true;
block_used[block_idx][num] = true;
dfs(k + 1); // 深入
// 恢复现场:撤销棋盘和桶数组的更改
grid[x][y] = 0;
row_used[x][num] = false;
col_used[y][num] = false;
block_used[block_idx][num] = false;
}
}
}
int main() {
// 初始化桶数组
for (int i = 0; i < 4; ++i) {
for (int j = 0; j < 5; ++j) { // 数字1-4,所以大小为5
row_used[i][j] = false;
col_used[i][j] = false;
block_used[i][j] = false;
}
}
dfs(0);
cout << count << endl;
return 0;
}
至此,我们已经将所有逻辑思想转换为了代码,它们组合在一起,就构成了一个完整的、带有回溯功能的深度优先搜索。我们可以把这道题目总结为一套框架。
3.2 DFS 回溯框架总结
从暴力枚举到启发式剪枝:
- 问题抽象:在 N×N 矩阵中填充数字/放置棋子,寻找满足行、列、块约束的所有合法解。本质是在有限的状态空间中寻找目标状态。
- 算法复杂度演变:
- 盲目搜索:每个格子独立尝试,时间复杂度高达 O(
4^{16})。 - 排列组合搜索:按行生成全排列,时间复杂度降至 O(
24^4),但存在大量无意义的后续匹配。 - 深度优先搜索配合剪枝:引入“及时止损”策略,一旦当前前缀状态不合法,立即截断后续搜索分支,极大收敛了搜索空间。
- 盲目搜索:每个格子独立尝试,时间复杂度高达 O(
深度优先搜索(DFS)流程可概括为四个步骤:
- 状态扩展:确定当前决策节点,枚举所有可能的候选动作(如尝试数字 1-4)。
- 约束满足性检查:在执行动作前,验证动作的合法性(剪枝)。
- 状态转移与深入:
- 保存现场:将当前合法的动作更新至全局状态(修改棋盘、更新标记)。
- 递归调用:将问题规模缩小,调用进入下一层计算。
- 状态回退:
- 恢复现场:在子问题计算结束后,必须撤销当前动作对全局状态的修改 (如决策填写了 3,而决策为 3 的子问题计算结束了,那就需要将其撤销),以保证下一次同级枚举的初始状态纯净。
DFS 回溯代码模板在实现上,会呈现其严格的对称性: 递归调用的上方是对状态的修改,下方必定是对状态的逆向撤销。以下是给出**==DFS的模板代码==**:
void dfs(int step) {
// 递归出口:如果到达目标状态/所有决策都已完成
if (到达目标状态) {
记录解;
return;
}
// 状态扩展:遍历所有候选选项
for (遍历所有候选选项) {
// 约束满足性检查:剪枝
if (当前选项满足约束条件) {
// 1. 保存现场(对全局状态进行修改)
update_state(选项);
// 2. 状态转移:进入子问题,递归调用
dfs(step + 1);
// 3. 恢复现场(撤销对全局状态的修改)
undo_state(选项);
}
}
}
3.3 经典案例:八皇后问题 (P190页 例14-2, OJ:B0202)
题目描述:在一个 N \times N 的国际象棋棋盘上,我们要放置 N 个皇后。皇后的攻击范围呈米字型:同一行、同一列、以及两条斜线(左上到右下、右上到左下)上的所有格子。
任务要求:放置 N 个皇后,使得她们互不攻击。给定棋盘大小 $N$($N \le 13$),求出一共有多少种合法的放置方案,并按字典序输出前 3 种具体的放置方法。
核心转化:
很多人一看到 N \times N 的棋盘,第一反应是:建一个二维数组 board[13][13],然后写两层 for 循环,每个格子去试放不放皇后。
但是规则里有同一行只能放一个皇后。既然如此,我们根本不需要去遍历棋盘上的每一个格子!我们只需要给每一行分配一个皇后就行了。于是我们的任务从在棋盘上找空位放置皇后,变成了决定第 1 行的皇后坐在第几列、第 2 行的皇后坐在第几列……
我们只需要一个一维桶数组 a[row] = col,它代表着 row 行的皇后坐在 col 列,就足以记录整个棋盘的状态!比如 a[1]=3,就代表第 1 行的皇后坐在了第 3 列。
这就把一个复杂的二维平面搜索,变成了在一维上逐行推进搜索。
我们的递归函数 dfs(row) 的含义也呼之欲出:当前正在为第 row 行的皇后挑选列号。
如何 check 冲突?
在确定了逐行推进(dfs(row))后,我们准备在第 col 列放下皇后。放下之前,必须立刻检查(回溯四步法之二)。
- 检查同列冲突:简单!搞一个布尔数组
b[col],它的含义是如果b[col] = true,那么代表着col这一列已经有皇后了,反之则没有皇后。 - 检查斜线冲突:难道要写
for循环,左上右下地去扫描棋盘吗?太慢了!- 右上
\rightarrow左下的斜线 (副对角线):你观察一下这条线上的坐标:(0,0), (1,1), (2,2)(0,1), (1,2), (2,3)(1,0), (2,1), (3,2)发现了什么?它们的 行号 + 列号 永远等于一个常数! 解决方案:开一个数组c[row + col]。只要在这个斜线上放了皇后,就把c[sum] = true。下次谁想放在坐标和为sum的地方,就先查看一下c[sum],发现结果等于true,也就是已经被占用了! - 左上
\rightarrow右下的斜线 (主对角线):你再观察这条线上的坐标:(0,2), (1,1), (2,0)(0,0)(1,2), (2,3)规律是:它们的 行号 - 列号 永远等于一个常数! 但是,行号减列号可能是负数(比如1 - 2 = -1),而数组下标不能是负数。怎么办? 解决方案:那我们统一加上一个常数N!不让他变为负数不就可以了,让下标变成row - col + N。我们就开一个数组d[row - col + N]。只要这条线上有皇后,对应的d[offset]就是true。
- 右上
int N; // 棋盘大小, 最多为 13 x 13
int ans_count = 0; // 记录总方案数
int a[20]; // 记录答案:a[行号] = 列号 (a[1]表示第一行皇后的列号)
// 三个桶数组,分别是列,副对角线,主对角线
bool col_used[20]; // 监控列:col_used[列号]
bool diag_sum_used[40]; // 监控副对角线:diag_sum_used[行 + 列] (取值最大为 12+12=24)
bool diag_diff_used[40]; // 监控主对角线:diag_diff_used[行 - 列 + N] (取值最大为 12 - 1 + 12 = 23)
// 递归函数:为第 row 行的皇后选列的座位
void dfs(int row) {
// 递归出口:如果 1 到 N 行都安排满了,说明棋盘已经放完所有的皇后了!
if (row > N) {
ans_count++; // 找到一种合法方案,总数 +1
if (ans_count <= 3) { // 题目要求:打印前 3 种方案
for (int i = 1; i <= N; i++)
cout << a[i] << " "; // a[i] 代表着 i 行的皇后在a[i]列
cout << endl;
}
return; // 功成身退,弹出回溯,尝试其他可能
}
// 从第一列开始,面对 1 到 N 列,挨个尝试放置皇后
for (int col = 1; col <= N; col++) {
// 检查:看三个标记数组是不是全是 false (即当前位置是否合法)
// (row + col) 作为副对角线索引
// (row - col + N) 作为主对角线索引 (加上N保证非负)
if (!col_used[col] && !diag_sum_used[row + col] && !diag_diff_used[row - col + N]) {
// 位置合法,那就深入
a[row] = col; // 记录当前行的决定:第row行的皇后放在col列
// 保存现场:放了皇后,对应的标记数组标记为 true
col_used[col] = true;
diag_sum_used[row + col] = true;
diag_diff_used[row - col + N] = true;
// 挂起当前状态,带着现有的布局,给下一行安排皇后
dfs(row + 1);
// 恢复现场:当 dfs(row + 1) 返回时,说明后面的路走完了
// 我们必须撤销刚才的决定,准备 for 循环尝试在换个位置在下一列放皇后
col_used[col] = false;
diag_sum_used[row + col] = false;
diag_diff_used[row - col + N] = false;
}
}
}
int main() {
cin >> N;
dfs(1); // 从第 1 行的皇后开始安排
cout << ans_count << endl; // 最后输出总方案数
return 0;
}
注意:如果不写 b[col] = c[row + col] = d[row - col + N] = false; 这句会怎样?
哪怕你已经退回到了上一行准备换个策略,标记数组依然以为那个位置有皇后。这就好比你搬出了酒店,却没有退房,后面的人永远住不进来。恢复现场是回溯算法一定要做的事情!
八皇后问题和四阶数独,本质上是在用同一个思想。数独是在空格子里填数字,八皇后是在行里填列号。它们的骨架全都是试探 \rightarrow 占座 \rightarrow 递归 \rightarrow 退座。这告诉我们:只要能找出问题中需要尝试填写什么,并设计出检查函数(怎么判断冲突),然后在当前决策下,尝试递归去搜索解,无论是什么样的复杂的迷宫,DFS 都能用同一套逻辑帮你解决!
3.4 剪枝优化:考前临时抱佛脚 (P191页 例14-3, OJ:B0203)
题目描述:kkksc03 需要考 4 科。每科都有一个习题集,分别有 s_1, s_2, s_3, s_4 道题目,完成每道题目需要一些时间。kkksc03 的左右两个大脑可以同时计算 2 道不同的题目,但是仅限于同一科。因此,kkksc03 必须一科一科地复习。他希望尽快把事情做完,所以他希望知道能够完成复习的最短时间。
题意总结:给定 4 个独立的数字集合(每科的题目耗时)。对于每个集合,要求将其分为两个子集(左右脑),使得这两个子集的“元素之和的最大值”尽可能小。求这 4 个集合的该最小值之和。
算法拆解:
- 每科独立计算:
- “必须一科一科复习”意味着 4 个科目是完全独立的。
- 总最短时间 = 科目1最短时间 + 科目2最短时间 + 科目3最短时间 + 科目4最短时间。
- 我们只需要专心写好“单科的最优解”函数,调用 4 次即可。
- 单科耗时取最大值:
- “左右脑同时计算同一科的不同题目”意味着面对单科的一堆题目,我们要把它们装进“左脑”和“右脑”两个桶里。
- 单科耗时 =
max(左脑做题总耗时, 右脑做题总耗时)。
- 均分逼近目标:
- 为了让
max(左脑, 右脑)尽可能小,最好的策略就是让左右脑分配到的任务重量尽可能接近(即贴近该科总耗时的一半),谁也不能闲着。
- 为了让
DFS 框架应用: 我们怎么把题目分给左右脑呢?这不就是我们在八皇后和数独里的“走一步看一步”吗! 对于每一道题,我们只有 2 种选择:
- 选择 A:交给左脑。
- 选择 B:交给右脑。
我们可以构思一个递归函数 dfs(index, sum_L, sum_R),参数传递:当前正准备分配第 index 题,以及此时左右脑的占用情况 sum_L 和 sum_R。
int s; // 当前科目题目总数
vector<int> dfs_times; // 当前科目每道题所需时间
int min_total_time; // 记录当前科目的最短完成时间
// dfs函数:为当前科目的第index道题分配给左脑或右脑
// index: 当前处理的题目索引
// sum_L: 左脑当前累计的完成时间
// sum_R: 右脑当前累计的完成时间
void dfs(int index, int sum_L, int sum_R) {
// 递归出口:如果所有题目都已分配完毕
if (index == s) {
min_total_time = min(min_total_time, max(sum_L, sum_R));
return;
}
// 剪枝1:最优性剪枝
// 如果当前的 sum_L 或 sum_R 已经比目前找到的最优解还要大,
// 那么这条路径无论如何都无法得到更优的解,可以直接放弃。
if (max(sum_L, sum_R) >= min_total_time) {
return;
}
// 决策分支1:将第 index 道题分配给左脑
// 深入:带着新的左脑时间进入下一层递归
dfs(index + 1, sum_L + dfs_times[index], sum_R);
// 决策分支2:将第 index 道题分配给右脑
// 深入:带着新的右脑时间进入下一层递归
dfs(index + 1, sum_L, sum_R + dfs_times[index]);
}
int main() {
int total_min_time = 0; // 四科的总最短时间
for (int k = 0; k < 4; ++k) { // 处理4门科目
cin >> s; // 读取当前科目的题目数量
dfs_times.resize(s); // 调整 vector 大小
for (int i = 0; i < s; ++i) {
cin >> dfs_times[i]; // 读取每道题的时间
}
// 剪枝2:优化搜索顺序 (可选)
// 将题目按时间从大到小排序,可以更早触发最优性剪枝。
sort(dfs_times.rbegin(), dfs_times.rend());
min_total_time = 1e9; // 初始化当前科目的最短时间为一个极大值
dfs(0, 0, 0); // 从第0题开始分配,左右脑初始时间为0
total_min_time += min_total_time; // 累加当前科目的最短时间
}
cout << total_min_time << endl;
return 0;
}
剪枝优化:
- 最优性剪枝:
- 在
dfs函数开头添加判断:if (max(sum_L, sum_R) >= min_total_time) return; - 如果在搜索过程中,
sum_L或sum_R已经超过了当前已经找到的min_total_time,那么无论后续题目如何分配,最终的max(sum_L, sum_R)肯定会比min_total_time更大。因此,这条路不可能通向更优的解,可以直接放弃。
- 在
- 优化搜索顺序:
- 为了让剪枝条件尽早触发,我们可以改变搜索的顺序。如果先处理时间较长的题目,
sum_L和sum_R会增长得更快,从而更快地达到剪枝的阈值。 - 因此,一个非常有效的优化是在 DFS 开始前,将该科的所有题目按时间从大到小排序。
- 为了让剪枝条件尽早触发,我们可以改变搜索的顺序。如果先处理时间较长的题目,
书本上的做法:进一步优化
如果我们把任务分成两堆,必然是一堆 $\le \mathrm{sum}/2$,另一堆 $\ge \mathrm{sum}/2$。
既然左右脑是对称的,我们完全可以规定:左脑永远是拿得较少(或相等)的那一半。也就是说,左脑的任务上限就是 total_sum / 2!
目标变成了:在不超过 total_sum / 2 的前提下,让左脑拿到的题目总耗时尽可能大(越接近 total_sum / 2 越好)。
当我们准备把某道题给左脑时,如果 当前左脑时间 + 这道题的时间 > total_sum / 2,这就意味着打破了我们的规定,直接判定为不合法分支,立刻剪枝!
使用该做法就不再需要在参数里同时记录左脑和右脑的时间了。我们只需要关注左脑!因为对于每一道题,我们的选择变成了:
- 装进左脑(前提是装得下,不超过
total_sum / 2)。 - 不装进左脑(那就意味着丢给右脑了,不管它)。
跑完这个
dfs后,左脑尽了最大努力拿到了max_left_time。 那么这科的总耗时到底是多少呢?因为左脑拿的是较少的一半,所以右脑肯定拿了剩下较多的一半。最终这科的耗时就是右脑的时间:total_sum - max_left_time。
int s; // 当前科目题目总数
vector<int> times; // 当前科目每道题所需时间
int half_sum; // 当前科目总时间的一半
int max_left; // 左脑能累积到的最大时间(不超过half_sum)
// 优化后的dfs函数:只关注左脑的累积时间
// index: 当前处理的题目索引
// current_L_time: 左脑当前累计的完成时间
void dfs(int index, int current_L_time) {
// 递归出口:如果所有题目都已分配完毕
if (index == s) {
max_left = max(max_left, current_L_time);
return;
}
// 将第 index 道题尝试分配给左脑
// 条件:只有当加入这道题后不超过 half_sum,才可以分配给左脑!
if (current_L_time + times[index] <= half_sum) {
// 深入:把题装进去了,时间增加
dfs(index + 1, current_L_time + times[index]);
}
// 如果不分配给左脑的话,将第 index 道题尝试分配给右脑
dfs(index + 1, current_L_time);
}
int main() {
int ans = 0; // 四科的总最短时间
for (int k = 0; k < 4; ++k) { // 处理4门科目
cin >> s; // 读取当前科目的题目数量
times.resize(s); // 调整 vector 大小
int current_sum = 0; // 当前科目的总时间
for (int i = 0; i < s; ++i) {
cin >> times[i]; // 读取每道题的时间
current_sum += times[i];
}
// 剪枝:优化搜索顺序 (推荐)
sort(times.rbegin(), times.rend());
half_sum = current_sum / 2; // 左脑的理论上限
max_left = 0; // 初始化左脑能累积到的最大时间
dfs(0, 0); // 从第0题开始分配,左脑初始时间为0
// 当前科目的最短时间 = max(左脑时间, 右脑时间)
// 因为左脑尽量接近 half_sum,所以右脑的时间 = current_sum - max_left
// 最终最短时间 = max(max_left, current_sum - max_left)
ans += max(max_left, current_sum - max_left);
}
cout << ans << endl;
return 0;
}
**4. 总结与练习 **
- 要点回顾
- DFS 核心思想:通过递归试探性地向前探索,遇到死胡同或不合法状态则回溯,撤销当前决策,尝试其他路径。
- 回溯本质:利用系统栈保存函数上下文,实现状态的“前进探索”和“失败回退”。
- DFS 基本框架:
- 递归函数
dfs(step) - 递归出口:达到目标或所有决策完成。
- 决策分支 (
for循环):遍历当前步骤的所有可能性。 - 剪枝 (
if条件):提前判断当前决策是否合法,不合法则跳过,避免无效搜索。 - 保存现场:在递归调用前,修改全局状态。
- 恢复现场:在递归调用返回后,撤销对全局状态的修改,确保回溯到决策点时状态纯净。
- 递归函数
- 剪枝策略:
- 可行性剪枝:检查当前决策是否违反规则。
- 最优性剪枝:如果当前路径已不可能达到更优解,则剪掉。
- 搜索顺序优化:合理排序决策选项,有助于更早触发剪枝。