13.1 回溯算法

「回溯算法 backtracking algorithm」是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态
出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择
都无法找到解为止。

回溯算法通常采用“深度优先搜索”来遍历解空间。在二叉树章节中,我们提到前序、中序和后序遍历都属
于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。

b
例题一

给定一个二叉树,搜索并记录所有值为 7 的节点,请返回节点列表。

对于此题,我们前序遍历这颗树,并判断当前节点的值是否为 7 ,若是则将该节点的值加入到结果列表 res
之中。相关过程实现如图 13‑1 和以下代码所示。

// === File: preorder_traversal_i_compact.c ===
/* 前序遍历:例题一 */
void preOrder(TreeNode *root) {
if (root == NULL) {
return;
}
if (root->val == 7) {
// 记录解
res[resSize++] = root;
}
preOrder(root->left);
preOrder(root->right);
}

13.1.1 尝试与回退

之所以称之为回溯算法,是因为该算法在搜索解空间时会采用“尝试”与“回退”的策略。当算法在搜索过
程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,
并尝试其他可能的选择。
对于例题一,访问每个节点都代表一次“尝试”,而越过叶节点或返回父节点的 return 则表示“回退”。
值得说明的是,回退并不仅仅包括函数返回。为解释这一点,我们对例题一稍作拓展。

b

例题二

在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径。

在例题一代码的基础上,我们需要借助一个列表 path 记录访问过的节点路径。当访问到值为 7 的节点时,则
复制 path 并添加进结果列表 res 。遍历完成后,res 中保存的就是所有的解。

// === File: preorder_traversal_ii_compact.c ===
/* 前序遍历:例题二 */
void preOrder(TreeNode *root) {
if (root == NULL) {
return;
}
// 尝试
path[pathSize++] = root;
if (root->val == 7) {
// 记录解
for (int i = 0; i < pathSize; ++i) {
res[resSize][i] = path[i];
}
resSize++;
}
preOrder(root->left);
preOrder(root->right);
// 回退
pathSize--;
}

在每次“尝试”中,我们通过将当前节点添加进 path 来记录路径;而在“回退”前,我们需要将该节点从
path 中弹出,以恢复本次尝试之前的状态。

观察图 13‑2 所示的过程,我们可以将尝试和回退理解为“前进”与“撤销”,两个操作是互为逆向的。

13.1.2 剪枝

复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于“剪枝”。

b
例题三

在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径,并要求路径中不包含
值为 3 的节点。

为了满足以上约束条件,我们需要添加剪枝操作:在搜索过程中,若遇到值为 3 的节点,则提前返回,停止
继续搜索。

// === File: preorder_traversal_iii_compact.c ===
/* 前序遍历:例题三 */
void preOrder(TreeNode *root) {
// 剪枝
if (root == NULL || root->val == 3) {
return;
}
// 尝试
path[pathSize++] = root;
if (root->val == 7) {
// 记录解
for (int i = 0; i < pathSize; i++) {
res[resSize][i] = path[i];
}
resSize++;
}
preOrder(root->left);
preOrder(root->right);
// 回退
pathSize--;
}

剪枝是一个非常形象的名词。如图 13‑3 所示,在搜索过程中,我们“剪掉”了不满足约束条件的搜索分支,
避免许多无意义的尝试,从而提高了搜索效率。

13.1.3 框架代码

接下来,我们尝试将回溯的“尝试、回退、剪枝”的主体框架提炼出来,提升代码的通用性。

在以下框架代码中,state 表示问题的当前状态,choices 表示当前状态下可以做出的选择。

/* 回溯算法框架 */
void backtrack(State *state, Choice *choices, int numChoices, State *res, int numRes) {
// 判断是否为解
if (isSolution(state)) {
// 记录解
recordSolution(state, res, numRes);
// 停止继续搜索
return;
}
// 遍历所有选择
for (int i = 0; i < numChoices; i++) {
// 剪枝:判断选择是否合法
if (isValid(state, &choices[i])) {
// 尝试:做出选择,更新状态
makeChoice(state, &choices[i]);
backtrack(state, choices, numChoices, res, numRes);
// 回退:撤销选择,恢复到之前的状态
undoChoice(state, &choices[i]);
}
}
}

接下来,我们基于框架代码来解决例题三。状态 state 为节点遍历路径,选择 choices 为当前节点的左子节
点和右子节点,结果 res 是路径列表。

// === File: preorder_traversal_iii_template.c ===
/* 判断当前状态是否为解 */
bool isSolution(void) {
return pathSize > 0 && path[pathSize - 1]->val == 7;
}
/* 记录解 */
void recordSolution(void) {
for (int i = 0; i < pathSize; i++) {
res[resSize][i] = path[i];
}
resSize++;
}
/* 判断在当前状态下,该选择是否合法 */
bool isValid(TreeNode *choice) {
return choice != NULL && choice->val != 3;
}
/* 更新状态 */
void makeChoice(TreeNode *choice) {
path[pathSize++] = choice;
}
/* 恢复状态 */
void undoChoice(void) {
pathSize--;
}
/* 回溯算法:例题三 */
void backtrack(TreeNode *choices[2]) {
// 检查是否为解
if (isSolution()) {
// 记录解
recordSolution();
}
// 遍历所有选择
for (int i = 0; i < 2; i++) {
TreeNode *choice = choices[i];
// 剪枝:检查选择是否合法
if (isValid(choice)) {
// 尝试:做出选择,更新状态
makeChoice(choice);
// 进行下一轮选择
TreeNode *nextChoices[2] = {choice->left, choice->right};
backtrack(nextChoices);
// 回退:撤销选择,恢复到之前的状态
undoChoice();
}
}
}

根据题意,我们在找到值为 7 的节点后应该继续搜索,因此需要将记录解之后的 return 语句删除。图 13‑4
对比了保留或删除 return 语句的搜索过程。

相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰嗦,但通用性更好。实际上,许多
回溯问题都可以在该框架下解决。我们只需根据具体问题来定义 state 和 choices ,并实现框架中的各个方法即可。

13.1.4 常用术语

为了更清晰地分析算法问题,我们总结一下回溯算法中常用术语的含义,并对照例题三给出对应示例

表 13‑1 常见的回溯算法术语
名词 定义 例题三

解 Solution 解是满足问题特定条件的答案,可能有一个或
多个
根节点到节点 7 的满足约束条件的所有路

约束条件
Constraint
约束条件是问题中限制解的可行性的条件,通
常用于剪枝
路径中不包含节点 3
状态 State 状态表示问题在某一时刻的情况,包括已经做
出的选择
当前已访问的节点路径,即 path 节点列表

尝试
Attempt
尝试是根据可用选择来探索解空间的过程,包
括做出选择,更新状态,检查是否为解
递归访问左(右)子节点,将节点添加进
path ,判断节点的值是否为 7

回退
Backtracking
回退指遇到不满足约束条件的状态时,撤销前
面做出的选择,回到上一个状态
当越过叶节点、结束节点访问、遇到值为 3
的节点时终止搜索,函数返回

名词 定义 例题三

剪枝
Pruning
剪枝是根据问题特性和约束条件避免无意义的
搜索路径的方法,可提高搜索效率
当遇到值为 3 的节点时,则终止继续搜索

b
问题、解、状态等概念是通用的,在分治、回溯、动态规划、贪心等算法中都有涉及。

13.1.5 优势与局限性

回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的
优势在于它能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。

然而,在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受。

‧ 时间:回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。

‧ 空间:在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间
需求可能会变得很大。

即便如此,回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案。对于这些问题,由于无法预测哪
些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,关键是如何进行效率优
化,常见的效率优化方法有两种。

‧ 剪枝:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。

‧ 启发式搜索:在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。

13.1.6 回溯典型例题

回溯算法可用于解决许多搜索问题、约束满足问题和组合优化问题。

搜索问题:这类问题的目标是找到满足特定条件的解决方案。

‧ 全排列问题:给定一个集合,求出其所有可能的排列组合。

‧ 子集和问题:给定一个集合和一个目标和,找到集合中所有和为目标和的子集。

‧ 汉诺塔问题:给定三个柱子和一系列大小不同的圆盘,要求将所有圆盘从一个柱子移动到另一个柱子,
每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。

约束满足问题:这类问题的目标是找到满足所有约束条件的解。

‧ 𝑛 皇后:在 𝑛 × 𝑛 的棋盘上放置 𝑛 个皇后,使得它们互不攻击。

‧ 数独:在 9 × 9 的网格中填入数字 1 ~ 9 ,使得每行、每列和每个 3 × 3 子网格中的数字不重复。

‧ 图着色问题:给定一个无向图,用最少的颜色给图的每个顶点着色,使得相邻顶点颜色不同。

组合优化问题:这类问题的目标是在一个组合空间中找到满足某些条件的最优解。

‧ 0‑1 背包问题:给定一组物品和一个背包,每个物品有一定的价值和重量,要求在背包容量限制内,选
择物品使得总价值最大。

‧ 旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。

‧ 最大团问题:给定一个无向图,找到最大的完全子图,即子图中的任意两个顶点之间都有边相连。

请注意,对于许多组合优化问题,回溯都不是最优解决方案。

‧ 0‑1 背包问题通常使用动态规划解决,以达到更高的时间效率。

‧ 旅行商是一个著名的 NP‑Hard 问题,常用解法有遗传算法和蚁群算法等。

‧ 最大团问题是图论中的一个经典问题,可用贪心等启发式算法来解决。

13.2 全排列问题

全排列问题是回溯算法的一个典型应用。它的定义是在给定一个集合(如一个数组或字符串)的情况下,找
出这个集合中元素的所有可能的排列。

表 13‑2 列举了几个示例数据,包括输入数组和对应的所有排列。

表 13‑2 全排列示例

输入数组 所有排列

[1] [1]

[1, 2] [1, 2], [2, 1]

[1, 2, 3] [1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]

13.2.1 无相等元素的情况
b

输入一个整数数组,数组中不包含重复元素,返回所有可能的排列。

从回溯算法的角度看,我们可以把生成排列的过程想象成一系列选择的结果。假设输入数组为 [1, 2, 3] ,如果我们先选择 1、再选择 3、最后选择 2 ,则获得排列 [1, 3, 2] 。回退表示撤销一个选择,之后继续尝试其他选择。

从回溯代码的角度看,候选集合 choices 是输入数组中的所有元素,状态 state 是直至目前已被选择的元素。

请注意,每个元素只允许被选择一次,因此 state 中的所有元素都应该是唯一的。

如图 13‑5 所示,我们可以将搜索过程展开成一个递归树,树中的每个节点代表当前状态 state 。从根节点开始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。

  1. 重复选择剪枝

为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 selected ,其中 selected[i] 表示 choices[i]
是否已被选择,并基于它实现以下剪枝操作。

‧ 在做出选择 choice[i] 后,我们就将 selected[i] 赋值为 True ,代表它已被选择。

‧ 遍历选择列表 choices 时,跳过所有已被选择过的节点,即剪枝。

如图 13‑6 所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1 和元素 3 的分支。

观察图 13‑6 发现,该剪枝操作将搜索空间大小从 𝑂(𝑛𝑛) 降低至 𝑂(𝑛!) 。

  1. 代码实现

想清楚以上信息之后,我们就可以在框架代码中做“完形填空”了。为了缩短代码行数,我们不单独实现框
架代码中的各个函数,而是将他们展开在 backtrack() 函数中。

// === File: permutations_i.c ===
/* 回溯算法:全排列 I */
void backtrack(int *state, int stateSize, int *choices, int choicesSize, bool *selected, int **res, int
↪ *resSize) {
// 当状态长度等于元素数量时,记录解
if (stateSize == choicesSize) {
res[*resSize] = (int *)malloc(choicesSize * sizeof(int));
for (int i = 0; i < choicesSize; i++) {
res[*resSize][i] = state[i];
}
(*resSize)++;
return;
}
// 遍历所有选择
for (int i = 0; i < choicesSize; i++) {
int choice = choices[i];
// 剪枝:不允许重复选择元素
if (!selected[i]) {
// 尝试:做出选择,更新状态
selected[i] = true;
state[stateSize] = choice;
// 进行下一轮选择
backtrack(state, stateSize + 1, choices, choicesSize, selected, res, resSize);
// 回退:撤销选择,恢复到之前的状态
selected[i] = false;
}
}
}
/* 全排列 I */
int **permutationsI(int *nums, int numsSize, int *returnSize) {
int *state = (int *)malloc(numsSize * sizeof(int));
bool *selected = (bool *)malloc(numsSize * sizeof(bool));
for (int i = 0; i < numsSize; i++) {
selected[i] = false;
}
int **res = (int **)malloc(MAX_SIZE * sizeof(int *));
*returnSize = 0;
backtrack(state, 0, nums, numsSize, selected, res, returnSize);
free(state);
free(selected);
return res;
}

13.2.2 考虑相等元素的情况

b
输入一个整数数组,数组中可能包含重复元素,返回所有不重复的排列。

假设输入数组为 [1, 1, 2] 。为了方便区分两个重复元素 1 ,我们将第二个 1 记为 1̂。

如图 13‑7 所示,上述方法生成的排列有一半都是重复的。

那么如何去除重复的排列呢?最直接地,考虑借助一个哈希表,直接对排列结果进行去重。然而这样做不够优
雅,因为生成重复排列的搜索分支是没有必要的,应当被提前识别并剪枝,这样可以进一步提升算法效率。

  1. 相等元素剪枝

观察图 13‑8 ,在第一轮中,选择 1 或选择 1̂是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 1̂剪枝掉。

同理,在第一轮选择 2 之后,第二轮选择中的 1 和 1̂也会产生重复分支,因此也应将第二轮的 1̂剪枝。

本质上看,我们的目标是在某一轮选择中,保证多个相等的元素仅被选择一次。

  1. 代码实现

在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希表 duplicated ,用于记录该轮中已经尝试
过的元素,并将重复元素剪枝。

// === File: permutations_ii.c ===
/* 回溯算法:全排列 II */
void backtrack(int *state, int stateSize, int *choices, int choicesSize, bool *selected, int **res, int
↪ *resSize) {
// 当状态长度等于元素数量时,记录解
if (stateSize == choicesSize) {
res[*resSize] = (int *)malloc(choicesSize * sizeof(int));
for (int i = 0; i < choicesSize; i++) {
res[*resSize][i] = state[i];
}
(*resSize)++;
return;
}
// 遍历所有选择
bool duplicated[MAX_SIZE] = {false};
for (int i = 0; i < choicesSize; i++) {
int choice = choices[i];
// 剪枝:不允许重复选择元素 且 不允许重复选择相等元素
if (!selected[i] && !duplicated[choice]) {
// 尝试:做出选择,更新状态
duplicated[choice] = true; // 记录选择过的元素值
selected[i] = true;
state[stateSize] = choice;
// 进行下一轮选择
backtrack(state, stateSize + 1, choices, choicesSize, selected, res, resSize);
// 回退:撤销选择,恢复到之前的状态
selected[i] = false;
}
}
}
/* 全排列 II */
int **permutationsII(int *nums, int numsSize, int *returnSize) {
int *state = (int *)malloc(numsSize * sizeof(int));
bool *selected = (bool *)malloc(numsSize * sizeof(bool));
for (int i = 0; i < numsSize; i++) {
selected[i] = false;
}
int **res = (int **)malloc(MAX_SIZE * sizeof(int *));
*returnSize = 0;
backtrack(state, 0, nums, numsSize, selected, res, returnSize);
free(state);
free(selected);
return res;
}

假设元素两两之间互不相同,则 𝑛 个元素共有 𝑛! 种排列(阶乘);在记录结果时,需要复制长度为 𝑛 的列表,使用 𝑂(𝑛) 时间。因此时间复杂度为 𝑂(𝑛!𝑛) 。

最大递归深度为 𝑛 ,使用 𝑂(𝑛) 栈帧空间。

selected 使用 𝑂(𝑛) 空间。同一时刻最多共有 𝑛 个 duplicated ,使用 𝑂(𝑛2) 空间。因此空间复杂度为 𝑂(𝑛2) 。

  1. 两种剪枝对比

请注意,虽然 selected 和 duplicated 都用作剪枝,但两者的目标是不同的。

‧ 重复选择剪枝:整个搜索过程中只有一个 selected 。它记录的是当前状态中包含哪些元素,作用是防止 choices 中的任一元素在 state 中重复出现。

‧ 相等元素剪枝:每轮选择(即每个调用的 backtrack 函数)都包含一个 duplicated 。它记录的是在本
轮遍历(即 for 循环)中哪些元素已被选择过,作用是保证相等的元素只被选择一次。

图 13‑9 展示了两个剪枝条件的生效范围。注意,树中的每个节点代表一个选择,从根节点到叶节点的路径上
的各个节点构成一个排列。

13.3 子集和问题

13.3.1 无重复元素的情况

b

给定一个正整数数组 nums 和一个目标正整数 target ,请找出所有可能的组合,使得组合中的
元素和等于 target 。给定数组无重复元素,每个元素可以被选取多次。请以列表形式返回这
些组合,列表中不应包含重复组合。

例如,输入集合 {3, 4, 5} 和目标整数 9 ,解为 {3, 3, 3}, {4, 5} 。需要注意以下两点。

‧ 输入集合中的元素可以被无限次重复选取。

‧ 子集是不区分元素顺序的,比如 {4, 5} 和 {5, 4} 是同一个子集。

  1. 参考全排列解法

类似于全排列问题,我们可以把子集的生成过程想象成一系列选择的结果,并在选择过程中实时更新“元素
和”,当元素和等于 target 时,就将子集记录至结果列表。

而与全排列问题不同的是,本题集合中的元素可以被无限次选取,因此无须借助 selected 布尔列表来记录元素是否已被选择。我们可以对全排列代码进行小幅修改,初步得到解题代码。

// === File: subset_sum_i_naive.c ===
/* 回溯算法:子集和 I */
void backtrack(int target, int total, int *choices, int choicesSize) {
// 子集和等于 target 时,记录解
if (total == target) {
for (int i = 0; i < stateSize; i++) {
res[resSize][i] = state[i];
}
resColSizes[resSize++] = stateSize;
return;
}
// 遍历所有选择
for (int i = 0; i < choicesSize; i++) {
// 剪枝:若子集和超过 target ,则跳过该选择
if (total + choices[i] > target) {
continue;
}
// 尝试:做出选择,更新元素和 total
state[stateSize++] = choices[i];
// 进行下一轮选择
backtrack(target, total + choices[i], choices, choicesSize);
// 回退:撤销选择,恢复到之前的状态
stateSize--;
}
}
/* 求解子集和 I(包含重复子集) */
void subsetSumINaive(int *nums, int numsSize, int target) {
resSize = 0; // 初始化解的数量为 0
backtrack(target, 0, nums, numsSize);
}

向以上代码输入数组 [3, 4, 5] 和目标元素 9 ,输出结果为 [3, 3, 3], [4, 5], [5, 4] 。虽然成功找出了所有和为9 的子集,但其中存在重复的子集 [4, 5] 和 [5, 4] 。

这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。如图 13‑10 所示,先选 4 后选 5 与先选 5后选 4 是两个不同的分支,但两者对应同一个子集。

为了去除重复子集,一种直接的思路是对结果列表进行去重。但这个方法效率很低,有两方面原因。

‧ 当数组元素较多,尤其是当 target 较大时,搜索过程会产生大量的重复子集。

‧ 比较子集(数组)的异同非常耗时,需要先排序数组,再比较数组中每个元素的异同。

  1. 重复子集剪枝

我们考虑在搜索过程中通过剪枝进行去重。观察图 13‑11 ,重复子集是在以不同顺序选择数组元素时产生的,例如以下情况。

  1. 当第一轮和第二轮分别选择 3 和 4 时,会生成包含这两个元素的所有子集,记为 [3, 4, … ] 。

  2. 之后,当第一轮选择 4 时,则第二轮应该跳过 3 ,因为该选择产生的子集 [4, 3, … ] 和 1. 中生成的子集完全重复。

在搜索中,每一层的选择都是从左到右被逐个尝试的,因此越靠右的分支被剪掉的越多。

  1. 前两轮选择 3 和 5 ,生成子集 [3, 5, … ] 。
  2. 前两轮选择 4 和 5 ,生成子集 [4, 5, … ] 。
  3. 若第一轮选择 5 ,则第二轮应该跳过 3 和 4 ,因为子集 [5, 3, … ] 和 [5, 4, … ] 与第 1. 和 2. 步中描述的子集完全重复。

总结来看,给定输入数组 [𝑥1
, 𝑥2
, … , 𝑥𝑛] ,设搜索过程中的选择序列为 [𝑥𝑖1
, 𝑥𝑖2
, … , 𝑥𝑖𝑚
] ,则该选择序列
需要满足 𝑖1 ≤ 𝑖2 ≤ ⋯ ≤ 𝑖𝑚 ,不满足该条件的选择序列都会造成重复,应当剪枝。

  1. 代码实现

为实现该剪枝,我们初始化变量 start ,用于指示遍历起点。当做出选择 𝑥𝑖 后,设定下一轮从索引 𝑖 开始遍历。这样做就可以让选择序列满足 𝑖1 ≤ 𝑖2 ≤ ⋯ ≤ 𝑖𝑚 ,从而保证子集唯一。

除此之外,我们还对代码进行了以下两项优化。

‧ 在开启搜索前,先将数组 nums 排序。在遍历所有选择时,当子集和超过 target 时直接结束循环,因为
后边的元素更大,其子集和都一定会超过 target 。

‧ 省去元素和变量 total ,通过在 target 上执行减法来统计元素和,当 target 等于 0 时记录解。

// === File: subset_sum_i.c ===
/* 回溯算法:子集和 I */
void backtrack(int target, int *choices, int choicesSize, int start) {
// 子集和等于 target 时,记录解
if (target == 0) {
for (int i = 0; i < stateSize; ++i) {
res[resSize][i] = state[i];
}
resColSizes[resSize++] = stateSize;
return;
}
// 遍历所有选择
// 剪枝二:从 start 开始遍历,避免生成重复子集

for (int i = start; i < choicesSize; i++) {
// 剪枝一:若子集和超过 target ,则直接结束循环
// 这是因为数组已排序,后边元素更大,子集和一定超过 target
if (target - choices[i] < 0) {
break;
}
// 尝试:做出选择,更新 target, start
state[stateSize] = choices[i];
stateSize++;
// 进行下一轮选择
backtrack(target - choices[i], choices, choicesSize, i);
// 回退:撤销选择,恢复到之前的状态
stateSize--;
}
}
/* 求解子集和 I */
void subsetSumI(int *nums, int numsSize, int target) {
qsort(nums, numsSize, sizeof(int), cmp); // 对 nums 进行排序
int start = 0; // 遍历起始点
backtrack(target, nums, numsSize, start);
}

如图 13‑12 所示,为将数组 [3, 4, 5] 和目标元素 9 输入到以上代码后的整体回溯过程。

13.3.2 考虑重复元素的情况

给定一个正整数数组 nums 和一个目标正整数 target ,请找出所有可能的组合,使得组合中的
元素和等于 target 。给定数组可能包含重复元素,每个元素只可被选择一次。请以列表形式
返回这些组合,列表中不应包含重复组合。

相比于上题,本题的输入数组可能包含重复元素,这引入了新的问题。例如,给定数组 [4, 4, 5] ̂ 和目标元素9 ,则现有代码的输出结果为 [4, 5], [4, 5] ̂ ,出现了重复子集。

造成这种重复的原因是相等元素在某轮中被多次选择。在图 13‑13 中,第一轮共有三个选择,其中两个都为
4 ,会产生两个重复的搜索分支,从而输出重复子集;同理,第二轮的两个 4 也会产生重复子集。

  1. 相等元素剪枝

为解决此问题,我们需要限制相等元素在每一轮中只被选择一次。实现方式比较巧妙:由于数组是已排序的,
因此相等元素都是相邻的。这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过,
因此直接跳过当前元素。

与此同时,本题规定数组中的每个元素只能被选择一次。幸运的是,我们也可以利用变量 start 来满足该约束:当做出选择 𝑥𝑖 后,设定下一轮从索引 𝑖 + 1 开始向后遍历。这样即能去除重复子集,也能避免重复选择元素。

2 代码实现

// === File: subset_sum_ii.c ===
/* 回溯算法:子集和 II */
void backtrack(int target, int *choices, int choicesSize, int start) {
// 子集和等于 target 时,记录解
if (target == 0) {
for (int i = 0; i < stateSize; i++) {
res[resSize][i] = state[i];
}
resColSizes[resSize++] = stateSize;
return;
}
// 遍历所有选择
// 剪枝二:从 start 开始遍历,避免生成重复子集
// 剪枝三:从 start 开始遍历,避免重复选择同一元素
for (int i = start; i < choicesSize; i++) {
// 剪枝一:若子集和超过 target ,则直接跳过
if (target - choices[i] < 0) {
continue;
}
// 剪枝四:如果该元素与左边元素相等,说明该搜索分支重复,直接跳过
if (i > start && choices[i] == choices[i - 1]) {
continue;
}
// 尝试:做出选择,更新 target, start
state[stateSize] = choices[i];
stateSize++;
// 进行下一轮选择
backtrack(target - choices[i], choices, choicesSize, i + 1);
// 回退:撤销选择,恢复到之前的状态
stateSize--;
}
}
/* 求解子集和 II */
void subsetSumII(int *nums, int numsSize, int target) {
// 对 nums 进行排序
qsort(nums, numsSize, sizeof(int), cmp);
// 开始回溯
backtrack(target, nums, numsSize, 0);
}

图 13‑14 展示了数组 [4, 4, 5] 和目标元素 9 的回溯过程,共包含四种剪枝操作。请你将图示与代码注释相结合,理解整个搜索过程,以及每种剪枝操作是如何工作的。

13.5 小结

  1. 重点回顾

‧ 回溯算法本质是穷举法,通过对解空间进行深度优先遍历来寻找符合条件的解。在搜索过程中,遇到满
足条件的解则记录,直至找到所有解或遍历完成后结束。

‧ 回溯算法的搜索过程包括尝试与回退两个部分。它通过深度优先搜索来尝试各种选择,当遇到不满足
约束条件的情况时,则撤销上一步的选择,退回到之前的状态,并继续尝试其他选择。尝试与回退是两
个方向相反的操作。

‧ 回溯问题通常包含多个约束条件,它们可用于实现剪枝操作。剪枝可以提前结束不必要的搜索分支,大
幅提升搜索效率。

‧ 回溯算法主要可用于解决搜索问题和约束满足问题。组合优化问题虽然可以用回溯算法解决,但往往
存在更高效率或更好效果的解法。

‧ 全排列问题旨在搜索给定集合的所有可能的排列。我们借助一个数组来记录每个元素是否被选择,剪
枝掉重复选择同一元素的搜索分支,确保每个元素只被选择一次。

‧ 在全排列问题中,如果集合中存在重复元素,则最终结果会出现重复排列。我们需要约束相等元素在每
轮中只能被选择一次,这通常借助一个哈希表来实现

‧ 子集和问题的目标是在给定集合中找到和为目标值的所有子集。集合不区分元素顺序,而搜索过程会
输出所有顺序的结果,产生重复子集。我们在回溯前将数据进行排序,并设置一个变量来指示每一轮的
遍历起点,从而将生成重复子集的搜索分支进行剪枝。

‧ 对于子集和问题,数组中的相等元素会产生重复集合。我们利用数组已排序的前置条件,通过判断相邻
元素是否相等实现剪枝,从而确保相等元素在每轮中只能被选中一次。

‧ 𝑛 皇后旨在寻找将 𝑛 个皇后放置到 𝑛 × 𝑛 尺寸棋盘上的方案,要求所有皇后两两之间无法攻击对方。
该问题的约束条件有行约束、列约束、主对角线和副对角线约束。为满足行约束,我们采用按行放置的
策略,保证每一行放置一个皇后。

‧ 列约束和对角线约束的处理方式类似。对于列约束,我们利用一个数组来记录每一列是否有皇后,从而
指示选中的格子是否合法。对于对角线约束,我们借助两个数组来分别记录该主、副对角线是否存在皇
后;难点在于找处在到同一主(副)对角线上格子满足的行列索引规律。

怎么理解回溯和递归的关系?

总的来看,回溯是一种“算法策略”,而递归更像是一个“工具”。

‧ 回溯算法通常基于递归实现。然而,回溯是递归的应用场景之一,是递归在搜索问题中的应用。

‧ 递归的结构体现了“子问题分解”的解题范式,常用于解决分治、回溯、动态规划(记忆化递归)等问题。

posted on 2024-02-26 20:10  lulixiu  阅读(10)  评论(0编辑  收藏  举报