Files
cpp-courseware/2.Course/1.深入浅出程序设计竞赛(基础篇)/2.初涉算法/7.搜索算法/深度优先搜索与回溯 讲义.md
T

32 KiB
Raw Blame History

C++算法讲义:搜索算法 - 深度优先搜索 (DFS) 与回溯

1. 引言

  • 问题引入:四阶数独(OJ:B0201) 数独是一种经典的逻辑游戏。我们这里要解决的是一个简化版的 4x4 数独。 要求: 给定一个完全空白的 4×4 网格,要求填写数字 1 到 4,使得以下条件同时满足:

    1. 每一行必须包含 1, 2, 3, 4 各一次(无重复)。
    2. 每一列必须包含 1, 2, 3, 4 各一次(无重复)。
    3. 每个 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 层,很难做到进退自如。我们需要一种更灵活的框架。

2.2 深度优先搜索 (DFS) 的核心逻辑

DFS 的核心思想就是:走一步看一步,不合法就回头,换条路再走。

递归枚举(DFS)的逻辑思想: 我们不用循环,而是写一个递归函数 void dfs(当前填第几个空)。流程可以想象成走迷宫:

  1. 走一步看一步:站在当前需要填写的空格,从所有可能的数字(1~4)中逐个尝试。
  2. 立刻检查:看看当前的行、列、小方块里有没有刚刚填写的数字。
  3. 合法就深入(深度优先):如果没有冲突,说明这个填的数字暂时没问题!我把它填进去(保存现场),然后进入下一个空格 dfs(下一个空格)。
  4. 死胡同就回头(回溯):假如我在某一个空格试了 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),但存在大量无意义的后续匹配。
    • 深度优先搜索配合剪枝:引入“及时止损”策略,一旦当前前缀状态不合法,立即截断后续搜索分支,极大收敛了搜索空间。

深度优先搜索(DFS)流程可概括为四个步骤:

  1. 状态扩展:确定当前决策节点,枚举所有可能的候选动作(如尝试数字 1-4)。
  2. 约束满足性检查:在执行动作前,验证动作的合法性(剪枝)。
  3. 状态转移与深入:
    • 保存现场:将当前合法的动作更新至全局状态(修改棋盘、更新标记)。
    • 递归调用:将问题规模缩小,调用进入下一层计算。
  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 列放下皇后。放下之前,必须立刻检查(回溯四步法之二)。

  1. 检查同列冲突:简单!搞一个布尔数组 b[col],它的含义是如果 b[col] = true,那么代表着 col 这一列已经有皇后了,反之则没有皇后。
  2. 检查斜线冲突:难道要写 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 个集合的该最小值之和。

算法拆解:

  1. 每科独立计算:
    • “必须一科一科复习”意味着 4 个科目是完全独立的。
    • 总最短时间 = 科目1最短时间 + 科目2最短时间 + 科目3最短时间 + 科目4最短时间。
    • 我们只需要专心写好“单科的最优解”函数,调用 4 次即可。
  2. 单科耗时取最大值:
    • “左右脑同时计算同一科的不同题目”意味着面对单科的一堆题目,我们要把它们装进“左脑”和“右脑”两个桶里。
    • 单科耗时 = max(左脑做题总耗时, 右脑做题总耗时)。
  3. 均分逼近目标:
    • 为了让 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;
}

剪枝优化:

  1. 最优性剪枝:
    • 在 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 更大。因此,这条路不可能通向更优的解,可以直接放弃。
  2. 优化搜索顺序:
    • 为了让剪枝条件尽早触发,我们可以改变搜索的顺序。如果先处理时间较长的题目,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. 总结与练习 **

  • 要点回顾
    1. DFS 核心思想:通过递归试探性地向前探索,遇到死胡同或不合法状态则回溯,撤销当前决策,尝试其他路径。
    2. 回溯本质:利用系统栈保存函数上下文,实现状态的“前进探索”和“失败回退”。
    3. DFS 基本框架:
      • 递归函数 dfs(step)
      • 递归出口:达到目标或所有决策完成。
      • 决策分支 (for 循环):遍历当前步骤的所有可能性。
      • 剪枝 (if 条件):提前判断当前决策是否合法,不合法则跳过,避免无效搜索。
      • 保存现场:在递归调用前,修改全局状态。
      • 恢复现场:在递归调用返回后,撤销对全局状态的修改,确保回溯到决策点时状态纯净。
    4. 剪枝策略:
      • 可行性剪枝:检查当前决策是否违反规则。
      • 最优性剪枝:如果当前路径已不可能达到更优解,则剪掉。
      • 搜索顺序优化:合理排序决策选项,有助于更早触发剪枝。