回溯

回溯法,可理解为试探性搜索方法,在搜索到某一步的时候,如果发现不能满足条件,那么就退回到上一步,在上一步重新选择。它本质上是对暴力解法的一种优化,一种尝试失败则提前剪枝的算法。

回溯法是以深度优先方式来搜索问题的解,里面的每一步可以理解为一个结点,这些步骤串起来就是一棵树,也就是解空间树。

当搜索解空间树中的任何一个结点的时候,判断该结点是不是包含问题的解:

  • 如果不包含,那么就把当前的结点以及剩下的节点步骤全部抛弃(也称为剪枝),然后往上一层的结点回溯,也就是退回上一步重新选择(之前的选择走不通)。

  • 如果包含,说明当前结点是可能获取到解的,继续进入下一层子树进行深度优先搜索。

一般回溯法有两种做法:递归和递推,递归的设计思路简单但效率不高,递推的算法设计较复杂,但效率高。

八皇后问题

八皇后问题是要在8x8的国际象棋棋盘上放置八个皇后,使得它们互不攻击,即任何两个皇后都不能在同一行、同一列,或者同一斜线上,问一共有多少种摆法。

通常使用回溯法解决,步骤如下:

  1. 按行放置,从第 0 行开始逐行放置皇后。

  2. 尝试在该行的每一列放置皇后,检查是否符合规则:

    1. 是否与已有的皇后在同一列

    2. 是否在同一条对角线上

  3. 如果满足要求,则递归处理下一行。

  4. 如果当前行没有合法的放置方案,则回溯到上一行,尝试改变上一行皇后的位置。

代码实现如下:

若是 n 皇后问题,空间复杂度为 $O(n)$,这里使用剪枝(isSafe() 返回 true 才进行递归)情况下,实际的搜索节点大约是指数级的,接近 $O(c^N)$,其中 c 介于 1.5 到 2 之间,无剪枝,最坏情况下为 $O(N!)$。

装载问题

假设现在有一批共 n 个集装箱要装上一艘载重量为 c 的轮船,其中集装箱 i 的重量为 Wi,找出一种最优装载方案,将轮船尽可能装满,即在装载体积不受限制的情况下,将尽可能重的集装箱装上轮船。

这个问题是 0-1 背包问题的一个变种,目标是使得装上轮船的总重量尽可能接近但不超过载重量 c,可以使用动态规划或回溯法,这里先实现回溯法。

需要定义一个 boolean[] visited 数组表示货物是否被访问过,sum 表示当前装载的货物总的重量,result 为最终的最大总重量。

遍历每一件货物,尝试将当前货物加上总重量,如果不大于最大的重量,并且加上之后的总重量大于保存的最大实际载重,那么就更新最大实际载重,否则直接返回。接着,将当前的货物的状态置为已经访问过,执行对下一个货物的判断,执行完之后,将当前货物置为不被访问过,相当于回溯,退回上一步。实现代码如下:

空间复杂度是 O(n),时间复杂度是指数级别 O(n!)。

该回溯法的思路是通过 for 循环遍历所有可能的组合,也可以使用 0-1 背包中的朴素回溯方法,即“选或不选”递归。思路是先排序(大数优先),减少不必要搜索,然后按固定的“二叉决策树”深度优先搜索:

dfs() 中,先剪枝,避免后续无意义的计算,再更新 best,确保 current 未超载的情况下才更新,最后检查是否遍历完,如果所有集装箱都考虑过了,就返回。这样的顺序保证了正确性和效率的平衡。

最后,还可以使用 0-1 背包问题的动态规划算法,这里使用优化后的一维数组,定义 dp[j] 表示载重量不超过 j 时的最大装载重量:

  • 初始化:dp[j] = 0

  • 遍历每个集装箱重量 Wi,逆序更新 dp[j]dp[j] = max(dp[j], dp[j - Wi] + Wi) (如果 j >= Wi);

  • 结果是 dp[c],即不超过 c 的最大装载重量。

实现如下:

时间复杂度 O(nc),适合 c 不太大的情况。

0-1 背包

前面已经介绍了 0-1 背包问题的动态规划以及递归+备忘录的解法,现在介绍回溯法。

对每一件物品都尝试性加入,在加入之前,我们需要判断是否被添加过,加入后的总重量会不会超重,如果符合条件,则判断加入的总价值是否为当前最大,符合则保存最大的价值,反之,跳过当前物品(相当于减枝)。然后尝试对后面的物品进行相同的判断,处理完成之后,需要将该物品拿出来(相当于不放该物品,回溯的做法),接着判断后续的物品。实现如下:

时间复杂度是 $O(n^n)$。

其实可以不用 visited 数组,优化后的回溯法如下:

for 循环用于枚举物品,但递归时 j + 1,避免重复选择。每次递归考虑两种情况:

  • 选当前物品:递归调用 backtrack,更新 sumValue 和 sumWeight。

  • 不选当前物品:递归自然跳过当前 j,因为 j + 1 传入。

剪枝优化,如果 sumWeight > c,直接 return,避免无效计算。

旅行售货员问题

某售货员要到若干城市去推销商品,已知各城市之间的路程(或旅费)。他要选定一条从驻地出发,经过每个城市一次,使总的路程(或总旅费)最小。(假设不回到驻地)

使用使用二维数据存储每两个节点之间的路程,比如 distances[0][2]distances[2][0] 都表示节点 1 和节点 3 之间的距离。除此外,还要存储城市的数量、路程最小值、当前路程值、被访问的城市。

从第 0 个城市开始,判断步数是否走完所有地方,如果满足并且路径路程比当前最小路程还小,那么就保存当前的路径。接着执行下一步,遍历后续的地方,如果没有被访问过,并且可达,那么就尝试走到该节点,递归遍历,递归完成后,需要回退上一步,接着执行下一个节点。代码如下:

为了不必要的多余遍历,当当前路程值加上到另一节点的路程值大于最小路程值时,直接跳过,进行剪枝操作。注意,这里使用了 step 变量指示走了多少个城市,当 step 等于城市数时,说明遍历结束。

另外,如果不需要回到起始点,也可以直接使用 visited[] 数组判断是否还有没访问的城市,如下:

自动走迷宫

输入一个迷宫的地图,用程序判断迷宫是否有解,如果有解的话,需要输出解的路径图。

给定如下迷宫,1 表示是墙,不可以走,0 表示可以走:

在上下左右都可以走的情况下,我们会选择一个方向来走,每次抉择都是如此,遇到前面走不通,我们就退回到上一步尝试其他方向。如果一个点的四个方向都尝试过了,都不行,还不能到达终点,那么说明这条路是行不通的。

需要用一个同样大小的数组来保存每一个点是否被访问过,用栈来保存访问路径。

首先尝试将 (0, 0) 位置加入堆栈,然后循环判断栈是不是不为空,且没有到达终点:分别尝试上下左右四个方向上的元素是否符合要求(该元素存在、没有被访问过、不是墙),若符合要求,就放入堆栈,并标识已访问过。若都不符合,那么就把堆栈第一个元素弹出,也就是相当于回溯到上一步,此时如果堆栈不为空,那么更新当前位置为栈顶元素的坐标,继续循环。

最后判断堆栈是否为空,如果为空,说明没有解,否则把堆栈反转,从头部取出元素,打印路径。

实现如下:

这里借助了双向链表以及二维数组,空间复杂度为 $O(N^2)$,时间复杂度是 $O(n^2)$,之前都是使用递归来实现回溯,这里用堆栈来替换了递归,时间复杂度有所减低。

如果想要打印出所有的迷宫出口路径,需要先定义出口的条件,比如“出口在最后一行”,然后使用深度优先搜索:

图的 m 着色

给定无向连通图 G 和 m 种不同的颜色。用这些颜色为图 G 的各顶点着色,每个顶点着一种颜色。如果有一种着色法使 G 中每条边的 2 个顶点着不同颜色,则称这个图是 m 可着色的。图的 m 着色问题是对于给定图 G 和 m 种颜色,找出所有不同的着色法。

需要找到所有可能的颜色分配方案,使得相邻的两个顶点颜色不同。可以使用回溯算法来尝试所有可能的颜色组合,并通过剪枝来排除无效的路径,从而高效地找到所有合法的着色方案。

这里使用邻接矩阵表示图,使用一个数组 colored 来记录每个顶点的颜色,初始时所有顶点颜色为 -1,表示未着色。

从第一个节点开始进行尝试,对它进行所有可能颜色的选择。在选择某种颜色时,需要确保与该节点相邻的所有节点中没有已经使用这种颜色的。如果满足这个条件,就可以继续对下一个节点进行相同的操作:为其选择一个在相邻节点中未被使用的颜色。如此递归进行,直到所有节点都被成功着色,每完成一次这样的过程,就是一种有效的着色方案。实现如下:

backtrack 方法中递归处理每个顶点。当处理到最后一个顶点时,保存当前颜色方案。对于每个顶点,尝试所有可能的颜色,合法时继续递归处理下一个顶点,否则回溯。使用 isValid 方法检查当前顶点 node 是否可以分配颜色 color,遍历所有已处理的相邻顶点(索引小于 node),如果存在相邻顶点颜色相同,则返回 False,否则返回 True。

空间复杂度是 $O(n^2)$,时间复杂度是 $O(m^n)$。

猜汉字代表的数字

观察下面的加法算式:

其中相同的汉字代表相同的数字,不同的汉字代表不同的数字。请你写出这8个字各自代表的数字(答案唯一)。

这是经典的回溯法问题,可以给“三羊献瑞祥生辉气”编号 01234567,然后每一个都需要去尝试从 0 到 9,当尝试到最后一个编号的数字时,需要判断等式是否成立。

实现如下: