我们的一生会遇到很多岔路口,每个选择都会影响我们今后的人生。有的人在每个岔路口都能做出最正确的选择,最后生活、事业都达到了一个很高的高度;而有的人一路选错,最后碌碌无为。如果人生可以量化,那如何才能在岔路口做出最正确的选择,让自己的人生最优呢?

我们可以借助贪心算法,在每次面对岔路口的时候,都做出在当时看起来最优的选择,但是,贪心算法并不一定能得到全局的最优解。那有没有什么办法能得到最优解呢?回溯算法就是用来解决这类问题的。笼统地讲,回溯算法很多时候都应用在“搜索”这类问题上。不过这里的搜索,并不是狭义的指前面讲过的图的搜索算法,而是在一组可能的解中,搜索满足期望的解。

回溯的处理思想,有点类似枚举搜索。我们枚举所有的解,找到满足期望的解。为了有规律地枚举所有可能的解,避免遗漏和重复,我们把问题求解的过程分为多个阶段。每个阶段,我们都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通时(不符合期望的解)就回退到上一个岔路口,另选一种走法继续走。

八皇后问题

八皇后问题是回溯算法应用的典型例子。我们有一个 8x8 的棋盘,希望往里放 8 个棋子(皇后),每个棋子所在的行、列、对角线都不能有另一个棋子。以下图所示,第一幅图是满足条件的一种方法,第二幅图是不满足条件的。八皇后问题就是期望找到所有满足这种要求的放棋子方式。
image.png
我们把这个问题划分成 8 个阶段,依次将 8 个棋子放到第一行、第二行、第三行……第八行。在放置过程中,我们不停地检查当前放法,是否满足要求。满足则跳到下一行继续放置棋子;不满足就换种放法继续尝试。

代码实现

  1. public class EightQueen {
  2. /** 数组下标为行,值为棋子在该行所处的列 */
  3. private static final int[] queen = new int[8];
  4. /**
  5. * 放棋子(皇后),获取符合条件的每种排列组合
  6. *
  7. * @param row 行数,从0开始
  8. */
  9. public void putQueen(int row) {
  10. // 棋子放置完毕,打印棋盘
  11. if (row == queen.length) {
  12. printEightQueen();
  13. return;
  14. }
  15. // 循环每列,即会获取第一个棋子在第一行中的每一列的所有排列组合
  16. for (int column = 0; column < queen.length; column++) {
  17. // 判断本次放法是否满足要求
  18. if (putSuccess(row, column)) {
  19. queen[row] = column;
  20. // 递归放置棋子
  21. putQueen(row + 1);
  22. }
  23. }
  24. }
  25. /**
  26. * 判断row行column列放置是否合适,row、column都是从0开始
  27. */
  28. private boolean putSuccess(int row, int column) {
  29. int leftUp = column - 1;
  30. int rightUp = column + 1;
  31. // 遍历row上面的已经放置了棋子的所有行,i表示row的上一行,因为row从0开始,所以i>=0
  32. for (int i = row - 1; i >= 0; i--) {
  33. // 判断在该行以上的其他行中,有没有棋子放在了同一列上的
  34. if (queen[i] == column) {
  35. return false;
  36. }
  37. // 判断该位置的左上位置是否已经有棋子了,即左对角线
  38. if (leftUp >= 0) {
  39. if (queen[i] == leftUp) {
  40. return false;
  41. }
  42. }
  43. // 判断该位置的右上位置是否已经有棋子了,即右对角线
  44. if (rightUp < 8) {
  45. if (queen[i] == rightUp) {
  46. return false;
  47. }
  48. }
  49. leftUp--;
  50. rightUp++;
  51. }
  52. return true;
  53. }
  54. /**
  55. * 打印二维矩阵的棋盘
  56. */
  57. private void printEightQueen() {
  58. for (int row = 0; row < queen.length; row++) {
  59. for (int column = 0; column < queen.length; column ++) {
  60. if (queen[row] == column) {
  61. System.out.print("Q ");
  62. } else {
  63. System.out.print("* ");
  64. }
  65. }
  66. System.out.println();
  67. }
  68. System.out.println();
  69. }
  70. }

0-1 背包问题

0-1 背包是非常经典的算法问题,很多场景都可以抽象成这个问题模型。这个问题的经典解法是动态规划,不过还有一种简单但没有那么高效的解法,就是回溯算法。我们先来看下,如何用回溯法解决这个问题?

假设我们有一个背包,背包总的承载重量是 Wkg。现在我们有 n 个物品,每个物品的重量不等且不可分割。我们期望选择几件物品装到背包中。在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大?

之前在讲贪心算法时讲到了背包问题,不过那里讲的物品是可以分割的,我们可以装某个物品的一部分到背包里。今天讲的这个背包问题,物品是不可分割的,要么装要么不装,所以叫 0-1 背包问题。显然,这个问题已经无法通过贪心算法来解决了。那我们现在来看看,用回溯算法如何解决呢?

解题思路
对于每个物品来说,都有两种选择,装进背包或者不装进背包。对于 n 个物品来说,总的装法就有 2n 种,我们去掉总重量超过 Wkg 的装法,然后从剩下的装法中选择总重量最接近 Wkg 的,就是总重量最大的装法。但是,我们要如何才能不重复地穷举出这 2n 种装法呢?

这里就可以用回溯算法的处理思想。我们可以把物品依次排列,那整个问题就分解为了 n 个阶段,每个阶段对应一个物品怎么选择。先对第一个物品进行处理,选择装进去或者不装进去,然后再递归地处理剩下的物品。在处理过程中,当发现已经选择的物品的重量超过 Wkg 后,我们就停止继续探测剩下的物品,提前结束本次递归。

代码实现

  1. public class ZeroOnePackage {
  2. /** 每个物品的重量 */
  3. private final int[] itemWeight;
  4. /** 背包可承载的总容量 */
  5. private final int packageCapacity;
  6. /** 选择装入到背包中的物品的总容量 */
  7. private int maxSelectCapacity = 0;
  8. public ZeroOnePackage(int packageCapacity, int[] itemWeight) {
  9. this.itemWeight = itemWeight;
  10. this.packageCapacity = packageCapacity;
  11. }
  12. /**
  13. * 把物品放入背包中
  14. *
  15. * @param index 当前要装载物品的索引
  16. * @param currentWeight 当前背包已装载的重量
  17. */
  18. private void put(int index, int currentWeight) {
  19. // 如果背包容量装满了或者物品装完了
  20. if (currentWeight == packageCapacity || index == itemWeight.length) {
  21. // 更新当前背包装载的最大重量
  22. if (currentWeight > maxSelectCapacity) {
  23. this.maxSelectCapacity = currentWeight;
  24. }
  25. return;
  26. }
  27. // 表示不选择当前物品,直接考虑下一个
  28. put(index + 1, currentWeight);
  29. // 如果已经超过了背包容量,就不再装了
  30. if (currentWeight + itemWeight[index] <= packageCapacity) {
  31. // 选择当前物品,更新currentWeight
  32. put(index + 1, currentWeight + itemWeight[index]);
  33. }
  34. }
  35. }

总结

回溯算法的思想非常简单,大部分情况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解。回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。利用剪枝,我们并不需要穷举搜索所有的情况,从而提高搜索效率(比如超过背包容量就提前结束递归)。

尽管回溯算法的原理非常简单,但是却可以解决很多问题,比如深度优先搜索、八皇后、0-1 背包问题、图的着色、旅行商问题、数独、全排列、正则表达式匹配等等。