算法之回溯算法

1 回溯算法

「回溯算法 backtracking algorithm」是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止。
回溯算法通常采用“深度优先搜索”来遍历解空间。在二叉树章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。
例题一
给定一个二叉树,搜索并记录所有值为 7 的节点,请返回节点列表。
对于此题,我们前序遍历这颗树,并判断当前节点的值是否为 7 ,若是则将该节点的值加入到结果列表 res之中。相关过程实现如下图和以下代码所示。
/* 前序遍历:例题一 */
void preOrder(TreeNode root) {
    if (root == null) {
        return;
    }
    if (root.val == 7) {
        // 记录解
        res.add(root);
    }
    preOrder(root.left);
    preOrder(root.right);
}

1.1 尝试与回退

之所以称之为回溯算法,是因为该算法在搜索解空间时会采用“尝试”与“回退”的策略 。当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,并尝试其他可能的选择。
对于例题一,访问每个节点都代表一次“尝试”,而越过叶节点或返回父节点的 return 则表示“回退”。值得说明的是,回退并不仅仅包括函数返回 。为解释这一点,我们对例题一稍作拓展。
例题二
在二叉树中搜索所有值为 7 的节点, 请返回根节点到这些节点的路径
在例题一代码的基础上,我们需要借助一个列表 path 记录访问过的节点路径。当访问到值为 7 的节点时,则复制 path 并添加进结果列表 res 。遍历完成后, res 中保存的就是所有的解。
/* 前序遍历:例题二 */
void preOrder(TreeNode root) {
    if (root == null) {
        return;
     }
    // 尝试
    path.add(root);
    if (root.val == 7) {
        // 记录解
        res.add(new ArrayList<>(path));
    }
    preOrder(root.left);
    preOrder(root.right);
    // 回退
    path.remove(path.size() - 1);
}

 在每次“尝试”中,我们通过将当前节点添加进 path 来记录路径;而在“回退”前,我们需要将该节点从path 中弹出,以恢复本次尝试之前的状态。 观察下图所示的过程,我们可以将尝试和回退理解为“前进”与“撤销”,两个操作是互为逆向的。

 1.2 剪枝

复杂的回溯问题通常包含一个或多个约束条件, 约束条件通常可用于“剪枝”
例题三
在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径, 并要求路径中不包含
值为 3 的节点
为了满足以上约束条件, 我们需要添加剪枝操作 :在搜索过程中,若遇到值为 3 的节点,则提前返回,停止继续搜索。

 

/* 前序遍历:例题三 */
void preOrder(TreeNode root) {
    // 剪枝
    if (root == null || root.val == 3) {
       return;
    }
    // 尝试
    path.add(root);
    if (root.val == 7) {
    // 记录解
        res.add(new ArrayList<>(path));
    }
    preOrder(root.left);
    preOrder(root.right);
    // 回退
    path.remove(path.size() - 1);
}
剪枝是一个非常形象的名词。如下图所示,在搜索过程中,我们“剪掉”了不满足约束条件的搜索分支 ,避免许多无意义的尝试,从而提高了搜索效率。

1.3 框架代码

接下来,我们尝试将回溯的“尝试、回退、剪枝”的主体框架提炼出来,提升代码的通用性。
在以下框架代码中, state 表示问题的当前状态, choices 表示当前状态下可以做出的选择。
    /* 回溯算法框架 */
    void backtrack(State state, List<Choice> choices, List<State> res) {
        // 判断是否为解
        if (isSolution(state)) {
            // 记录解
            recordSolution(state, res);
            // 停止继续搜索
            return;
        }
        // 遍历所有选择
        for (Choice choice : choices) {
            // 剪枝:判断选择是否合法
            if (isValid(state, choice)) {
                // 尝试:做出选择,更新状态
                makeChoice(state, choice);
                backtrack(state, choices, res);
                // 回退:撤销选择,恢复到之前的状态
                undoChoice(state, choice);
            }
        }
    }
接下来,我们基于框架代码来解决例题三。状态 state 为节点遍历路径,选择 choices 为当前节点的左子节点和右子节点,结果 res 是路径列表。
  /* 回溯算法:例题三 */
    //state 表示当前的选择路径,choices 表示当前可选择的节点,res 表示存储解的列表。
    void backtrack(List<TreeNode> state, List<TreeNode> choices, List<List<TreeNode>> res) {
        // 检查是否为解
        if (isSolution(state)) {
            // 记录解
            recordSolution(state, res);
        }
        // 遍历所有选择
        for (TreeNode choice : choices) {
            // 剪枝:检查选择是否合法
            if (isValid(state, choice)) {
                // 尝试:做出选择,更新状态
                makeChoice(state, choice);
                // 进行下一轮选择
                backtrack(state, Arrays.asList(choice.left, choice.right), res);
                // 回退:撤销选择,恢复到之前的状态
                undoChoice(state, choice);
            }
        }
    }

    /* 判断当前状态是否为解 */
    boolean isSolution(List<TreeNode> state) {
        //检查选择路径 state 是否为空,以及路径的最后一个节点的值是否等于 7
        return !state.isEmpty() && state.get(state.size() - 1).val == 7;
    }
    /* 记录解 */
    void recordSolution(List<TreeNode> state, List<List<TreeNode>> res) {
        //将当前选择路径 state 添加到解的列表 res 中。
        res.add(new ArrayList<>(state));
    }
    /* 判断在当前状态下,该选择是否合法 */
    boolean isValid(List<TreeNode> state, TreeNode choice) {
        // 检查选择 choice 是否为非空,且节点值不等于 3。
        return choice != null && choice.val != 3;
    }
    /* 更新状态 */
    void makeChoice(List<TreeNode> state, TreeNode choice) {
        //将选择 choice 添加到当前选择路径 state 中。
        state.add(choice);
    }

    /* 恢复状态 */
    void undoChoice(List<TreeNode> state, TreeNode choice) {
        //移除当前选择路径 state 的最后一个节点,以便回退到之前的状态。
        state.remove(state.size() - 1);
    }
根据题意,我们在找到值为 7 的节点后应该继续搜索, 因此需要将记录解之后的 return 语句删除。下图对比了保留或删除 return 语句的搜索过程。
相比基于前序遍历的代码实现,基于回溯算法框架的代码实现虽然显得啰嗦,但通用性更好。实际上, 许多 回溯问题都可以在该框架下解决 。我们只需根据具体问题来定义 state choices ,并实现框架中的各个方法即可。

1.4 常用术语

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

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

1.5 优势与局限性

回溯算法本质上是一种深度优先搜索算法,它尝试所有可能的解决方案直到找到满足条件的解。这种方法的优势在于它能够找到所有可能的解决方案,而且在合理的剪枝操作下,具有很高的效率。
适用于问题的解空间规模相对较小且解的数量可预测的情况。回溯算法适用于很多组合优化问题、搜索问题和约束满足问题,例如八皇后问题、子集和问题等。
时间 :回溯算法通常需要遍历状态空间的所有可能,时间复杂度可以达到指数阶或阶乘阶。
空间 :在递归调用中需要保存当前的状态(例如路径、用于剪枝的辅助变量等),当深度很大时,空间需求可能会变得很大。
即便如此, 回溯算法仍然是某些搜索问题和约束满足问题的最佳解决方案 。对于这些问题,由于无法预测哪些选择可生成有效的解,因此我们必须对所有可能的选择进行遍历。在这种情况下,关键是如何进行效率优 ,常见的效率优化方法有两种。
剪枝:避免搜索那些肯定不会产生解的路径,从而节省时间和空间。选择合适的剪枝条件对于算法性能至关重要。
启发式搜索 :在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径。

启发式搜索(Heuristic Search)是一种在搜索问题中引入启发信息(heuristic)来指导搜索方向的搜索算法。启发式信息是一种对问题领域的额外知识,通常是一些估计值或规则,用于估计每个状态的“好坏”程度。这种估计有助于算法更聪明地选择下一步搜索的方向,从而更有效地达到目标。

以下是启发式搜索的一般步骤和一些常见的启发式搜索算法:

启发式搜索的一般步骤:

  1. 初始化: 将初始状态加入搜索队列。
  2. 循环直到找到解或搜索完整个状态空间:
    • 从队列中取出当前状态。
    • 根据启发式函数评估每个可能的下一步状态,将它们加入队列。
    • 根据启发式函数的估计值排序队列,以优先考虑最有希望的状态。
    • 检查当前状态是否为目标状态,如果是,则搜索结束。
  3. 返回解或标记问题无解。

常见的启发式搜索算法:

  1. A*算法:

    • 使用启发式函数估计从起点到当前状态的代价和从当前状态到目标的代价。
    • 计算当前状态的总代价(估计值 + 实际代价),并根据总代价进行排序。
    • A*算法通过估计函数实现了一种较为精确的搜索,可以找到最优解。
  2. IDA算法(Iterative Deepening A):

    • 类似于A*算法,但使用深度优先搜索策略,并通过逐渐增加深度限制来提高搜索效率。
    • 通过迭代深化的方式,IDA*在每一轮迭代中限制总代价,并逐渐扩大这个限制。
  3. 贪心搜索:

    • 仅根据启发式函数的估计值选择下一步状态,不考虑过去的代价。
    • 贪心搜索通常效率较高,但可能无法找到最优解,因为它可能会陷入局部最优解。
  4. IDA算法(Iterative Deepening A):

    • 类似于A*算法,但使用深度优先搜索策略,并通过逐渐增加深度限制来提高搜索效率。
    • 通过迭代深化的方式,IDA*在每一轮迭代中限制总代价,并逐渐扩大这个限制。
  5. 局部搜索算法(如爬山算法、模拟退火):

    • 通过在当前解附近进行局部调整,尝试找到更好的解。
    • 这些算法不一定保证找到全局最优解,但在某些问题上表现很好。

启发式搜索的性能取决于启发式函数的质量,好的启发式函数应该提供对当前状态的良好估计,以引导搜索朝着更有希望的方向前进。

1.6 回溯典型例题

回溯算法可用于解决许多搜索问题、约束满足问题和组合优化问题。
搜索问题 :这类问题的目标是找到满足特定条件的解决方案。
‧ 全排列问题:给定一个集合,求出其所有可能的排列组合。
‧ 子集和问题:给定一个集合和一个目标和,找到集合中所有和为目标和的子集。
‧ 汉诺塔问题:给定三个柱子和一系列大小不同的圆盘,要求将所有圆盘从一个柱子移动到另一个柱子,每次只能移动一个圆盘,且不能将大圆盘放在小圆盘上。
约束满足问题 :这类问题的目标是找到满足所有约束条件的解。
𝑛 皇后:在 𝑛 × 𝑛 的棋盘上放置 𝑛 个皇后,使得它们互不攻击。
‧ 数独:在 9 × 9 的网格中填入数字 1 ~ 9 ,使得每行、每列和每个 3 × 3 子网格中的数字不重复。
‧ 图着色问题:给定一个无向图,用最少的颜色给图的每个顶点着色,使得相邻顶点颜色不同。
组合优化问题 :这类问题的目标是在一个组合空间中找到满足某些条件的最优解。
‧ 0‑1 背包问题:给定一组物品和一个背包,每个物品有一定的价值和重量,要求在背包容量限制内,选择物品使得总价值最大。
‧ 旅行商问题:在一个图中,从一个点出发,访问所有其他点恰好一次后返回起点,求最短路径。
‧ 最大团问题:给定一个无向图,找到最大的完全子图,即子图中的任意两个顶点之间都有边相连。
请注意,对于许多组合优化问题,回溯都不是最优解决方案。
‧ 0‑1 背包问题通常使用动态规划解决,以达到更高的时间效率。
‧ 旅行商是一个著名的 NP‑Hard 问题,常用解法有遗传算法和蚁群算法等。
‧ 最大团问题是图论中的一个经典问题,可用贪心等启发式算法来解决。

2 全排列问题 

全排列问题是回溯算法的一个典型应用。它的定义是在给定一个集合(如一个数组或字符串)的情况下,找出这个集合中元素的所有可能的排列。
下表列举了几个示例数据,包括输入数组和对应的所有排列。

 如数组:[1,2,3]

他的全部排列有:[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]

2.1 无相等元素的情况

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

从回溯算法的角度看, 我们可以把生成排列的过程想象成一系列选择的结果 。假设输入数组为 [1, 2, 3] ,如 果我们先选择 1 、再选择 3 、最后选择 2 ,则获得排列 [1, 3, 2] 。回退表示撤销一个选择,之后继续尝试其他选择。
从回溯代码的角度看,候选集合 choices 是输入数组中的所有元素,状态 state 是直至目前已被选择的元素。请注意,每个元素只允许被选择一次,因此 state 中的所有元素都应该是唯一的
如下图所示,我们可以将搜索过程展开成一个递归树,树中的每个节点代表当前状态 state 。从根节点开始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。

1. 重复选择剪枝

为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 selected ,其中 selected[i] 表示 choices[i]是否已被选择,并基于它实现以下剪枝操作。
‧ 在做出选择 choice[i] 后,我们就将 selected[i] 赋值为 True ,代表它已被选择。
‧ 遍历选择列表 choices 时,跳过所有已被选择过的节点,即剪枝。
如下图所示,假设我们第一轮选择 1 ,第二轮选择 3 ,第三轮选择 2 ,则需要在第二轮剪掉元素 1 的分支,在第三轮剪掉元素 1 和元素 3 的分支。

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

2 代码实现

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

    void backtrack(List<Integer> state, int[] choices, boolean[] selected, List<List<Integer>> res) {
        // 当状态长度等于元素数量时,记录解
        if (state.size() == choices.length) {
            res.add(new ArrayList<Integer>(state));
            return;
        }
        // 遍历所有选择
        for (int i = 0; i < choices.length; i++) {
            int choice = choices[i];
            // 剪枝:不允许重复选择元素
            if (!selected[i]) {
                // 尝试:做出选择,更新状态
                selected[i] = true;
                state.add(choice);
                // 进行下一轮选择
                backtrack(state, choices, selected, res);
                // 回退:撤销选择,恢复到之前的状态
                selected[i] = false;
                state.remove(state.size() - 1);
            }
        }
    }
    /* 全排列 I */
    List<List<Integer>> permutationsI(int[] nums) {
        List<List<Integer>> res = new ArrayList<List<Integer>>();
        backtrack(new ArrayList<Integer>(), nums, new boolean[nums.length], res);
        return res;
    }

2.2 考虑相等元素的情况

Q: 输入一个整数数组, 数组中可能包含重复元素 ,返回所有不重复的排列。
假设输入数组为 [1, 1, 2] 。如下图所示,上述方法生成的排列有一半都是重复的。

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

1. 相等元素剪枝

观察下图 ,在第一轮中,选择 第一个 1 或选择 第二个 1 是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 1 剪枝掉。
同理,在第一轮选择 2 之后,第二轮选择中的 1 1 也会产生重复分支,因此也应将第二轮的 1 剪枝。本质上看,我们的目标是在某一轮选择中,保证多个相等的元素仅被选择一次

2. 代码实现

在上一题的代码的基础上,我们考虑在每一轮选择中开启一个哈希表 duplicated ,用于记录该轮中已经尝试过的元素,并将重复元素剪枝。
 /* 回溯算法:全排列 II */
    void backtrack(List<Integer> state, int[] choices, boolean[] selected, List<List<Integer>> res) {
        // 当状态长度等于元素数量时,记录解
        if (state.size() == choices.length) {
            res.add(new ArrayList<Integer>(state));
            return;
        }
        // 遍历所有选择
        Set<Integer> duplicated = new HashSet<Integer>();
        for (int i = 0; i < choices.length; i++) {
            int choice = choices[i];
            // 剪枝:不允许重复选择元素 且 不允许重复选择相等元素
            if (!selected[i] && !duplicated.contains(choice)) {
                // 尝试:做出选择,更新状态
                duplicated.add(choice); // 记录选择过的元素值
                selected[i] = true;
                state.add(choice);
                // 进行下一轮选择
                backtrack(state, choices, selected, res);
                // 回退:撤销选择,恢复到之前的状态
                selected[i] = false;
                state.remove(state.size() - 1);
            }
        }
    }

    /* 全排列 II */
    List<List<Integer>> permutationsII(int[] nums) {
        List<List<Integer>> res = new ArrayList<List<Integer>>();
        backtrack(new ArrayList<Integer>(), nums, new boolean[nums.length], res);
        return res;
    }
假设元素两两之间互不相同,则 𝑛 个元素共有 𝑛! 种排列(阶乘);在记录结果时,需要复制长度为 𝑛 的列表,使用 𝑂(𝑛) 时间。 因此时间复杂度为 𝑂(𝑛!𝑛)
最大递归深度为 𝑛 ,使用 𝑂(𝑛) 栈帧空间。 selected 使用 𝑂(𝑛) 空间。同一时刻最多共有 𝑛 duplicated ,使用 𝑂(𝑛 2 ) 空间。 因此空间复杂度为 𝑂(𝑛 2 )

3.两种剪枝对比

请注意,虽然 selected duplicated 都用作剪枝,但两者的目标是不同的。
重复选择剪枝 :整个搜索过程中只有一个 selected 。它记录的是当前状态中包含哪些元素,作用是防止 choices 中的任一元素在 state 中重复出现。
相等元素剪枝 :每轮选择(即每个调用的 backtrack 函数)都包含一个 duplicated 。它记录的是在本轮遍历(即 for 循环)中哪些元素已被选择过,作用是保证相等的元素只被选择一次。
下图展示了两个剪枝条件的生效范围。注意,树中的每个节点代表一个选择,从根节点到叶节点的路径上的各个节点构成一个排列。

3 子集和问题

3.1 无重复元素的情况

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

例如,输入集合 {3, 4, 5} 和目标整数 9 ,解为 {3, 3, 3}, {4, 5} 。需要注意以下两点。
‧ 输入集合中的元素可以被无限次重复选取。
‧ 子集是不区分元素顺序的,比如 {4, 5} {5, 4} 是同一个子集。

1. 参考全排列解法

类似于全排列问题,我们可以把子集的生成过程想象成一系列选择的结果,并在选择过程中实时更新“元素和”,当元素和等于 target 时,就将子集记录至结果列表。
而与全排列问题不同的是, 本题集合中的元素可以被无限次选取 ,因此无须借助 selected 布尔列表来记录元素是否已被选择。我们可以对全排列代码进行小幅修改,初步得到解题代码。
    void backtrack(List<Integer> state, int target, int total, int[] choices, List<List<Integer>> res) {
        // 子集和等于 target 时,记录解
        if (total == target) {
            res.add(new ArrayList<>(state));
            return;
        }
        // 遍历所有选择
        for (int i = 0; i < choices.length; i++) {
            // 剪枝:若子集和超过 target ,则跳过该选择
            if (total + choices[i] > target) {
                continue;
            }
            // 尝试:做出选择,更新元素和 total
            state.add(choices[i]);
            // 进行下一轮选择
            backtrack(state, target, total + choices[i], choices, res);
            // 回退:撤销选择,恢复到之前的状态
            state.remove(state.size() - 1);
        }
    }

    /* 求解子集和 I(包含重复子集) */
    List<List<Integer>> subsetSumINaive(int[] nums, int target) {
        List<Integer> state = new ArrayList<>(); // 状态(子集)
        int total = 0; // 子集和
        List<List<Integer>> res = new ArrayList<>(); // 结果列表(子集列表)
        backtrack(state, target, total, nums, res);
        return res;
    }
向以上代码输入数组 [3, 4, 5] 和目标元素 9 ,输出结果为 [3, 3, 3], [4, 5], [5, 4] 虽然成功找出了所有和为9 的子集,但其中存在重复的子集 [4, 5] [5, 4]
这是因为搜索过程是区分选择顺序的,然而子集不区分选择顺序。如图 13‑10 所示,先选 4 后选 5 与先选 5 后选 4 是两个不同的分支,但两者对应同一个子集。
为了去除重复子集, 一种直接的思路是对结果列表进行去重 。但这个方法效率很低,有两方面原因。
‧ 当数组元素较多,尤其是当 target 较大时,搜索过程会产生大量的重复子集。
‧ 比较子集(数组)的异同非常耗时,需要先排序数组,再比较数组中每个元素的异同。
‧ 不够牛逼

2. 重复子集剪枝

我们考虑在搜索过程中通过剪枝进行去重 。观察图 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 ≤ ⋯ ≤ 𝑖 𝑚 不满足该条件的选择序列都会造成重复,应当剪枝

3. 代码实现

为实现该剪枝,我们初始化变量 start ,用于指示遍历起点。 当做出选择 𝑥 𝑖 后,设定下一轮从索引 𝑖 开始遍 。这样做就可以让选择序列满足 𝑖 1 ≤ 𝑖 2 ≤ ⋯ ≤ 𝑖 𝑚 ,从而保证子集唯一。
除此之外,我们还对代码进行了以下两项优化。
‧ 在开启搜索前,先将数组 nums 排序。在遍历所有选择时, 当子集和超过 target 时直接结束循环 ,因为后边的元素更大,其子集和都一定会超过 target
‧ 省去元素和变量 total 通过在 target 上执行减法来统计元素和 ,当 target 等于 0 时记录解。
 /* 回溯算法:子集和 I */
    void backtrack(List<Integer> state, int target, int[] choices, int start, List<List<Integer>> res) {
        // 子集和等于 target 时,记录解
        if (target == 0) {
            res.add(new ArrayList<>(state));
            return;
        }
        // 遍历所有选择
        // 剪枝二:从 start 开始遍历,避免生成重复子集
        for (int i = start; i < choices.length; i++) {
            // 剪枝一:若子集和超过 target ,则直接结束循环
            // 这是因为数组已排序,后边元素更大,子集和一定超过 target
            if (choices[i]>target) {
                break;
            }
            // 尝试:做出选择,更新 target, start
            state.add(choices[i]);
            // 进行下一轮选择
            backtrack(state, target - choices[i], choices, i, res);
            // 回退:撤销选择,恢复到之前的状态
            state.remove(state.size() - 1);
        }
    }

    /* 求解子集和 I */
    List<List<Integer>> subsetSumI(int[] nums, int target) {
        Arrays.sort(nums); // 对 nums 进行排序
        List<Integer> state = new ArrayList<>(); // 状态(子集)
        int start = 0; // 遍历起始点
        List<List<Integer>> res = new ArrayList<>(); // 结果列表(子集列表)
        backtrack(state, target, nums, start, res);
        return res;
    }
如下图所示,为将数组 [3, 4, 5] 和目标元素 9 输入到以上代码后的整体回溯过程。

3.2 考虑重复元素的情况 

给定一个正整数数组 nums 和一个目标正整数 target ,请找出所有可能的组合,使得组合中的
元素和等于 target 给定数组可能包含重复元素,每个元素只可被选择一次 。请以列表形式
返回这些组合,列表中不应包含重复组合。
相比于上题, 本题的输入数组可能包含重复元素 ,这引入了新的问题。例如,给定数组 [4, 4, 5] 和目标元素9 ,则现有代码的输出结果为 [4, 5], [4, 5] ,出现了重复子集。
造成这种重复的原因是相等元素在某轮中被多次选择 。在图 13‑13 中,第一轮共有三个选择,其中两个都为4 ,会产生两个重复的搜索分支,从而输出重复子集;同理,第二轮的两个 4 也会产生重复子集。

1.相等元素剪枝

为解决此问题, 我们需要限制相等元素在每一轮中只被选择一次 。实现方式比较巧妙:由于数组是已排序的, 因此相等元素都是相邻的。这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过, 因此直接跳过当前元素。
与此同时, 本题规定数组中的每个元素只能被选择一次 。幸运的是,我们也可以利用变量 start 来满足该约 束:当做出选择 𝑥 𝑖 后,设定下一轮从索引 𝑖 + 1 开始向后遍历。这样即能去除重复子集,也能避免重复选择元素。
 void backtrack(List<Integer> state, int target, int[] choices, int start, List<List<Integer>> res) {
        // 子集和等于 target 时,记录解
        if (target == 0) {
            res.add(new ArrayList<>(state));
            return;
        }
        // 遍历所有选择
        // 剪枝二:从 start 开始遍历,避免生成重复子集
        for (int i = start; i < choices.length; i++) {
            // 剪枝一:若子集和超过 target ,则直接结束循环
            // 这是因为数组已排序,后边元素更大,子集和一定超过 target
            if (choices[i]>target ) {
                break;
            }
            // 剪枝四:如果该元素与左边元素相等,说明该搜索分支重复,直接跳过
            if (i ==start+1 && choices[i] == choices[i - 1]) {
                continue;
            }
            // 尝试:做出选择,更新 target, start
            state.add(choices[i]);
            // 进行下一轮选择
            backtrack(state, target - choices[i], choices, i, res);
            // 回退:撤销选择,恢复到之前的状态
            state.remove(state.size() - 1);
        }
    }

    /* 求解子集和 I */
    List<List<Integer>> subsetSumI(int[] nums, int target) {
        Arrays.sort(nums); // 对 nums 进行排序
        List<Integer> state = new ArrayList<>(); // 状态(子集)
        int start = 0; // 遍历起始点
        List<List<Integer>> res = new ArrayList<>(); // 结果列表(子集列表)
        backtrack(state, target, nums, start, res);
        return res;
    }
下图展示了数组 [4, 4, 5] 和目标元素 9 的回溯过程,共包含四种剪枝操作。请你将图示与代码注释相结合,理解整个搜索过程,以及每种剪枝操作是如何工作的。

 4 N 皇后问题

Q:

根据国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。给定 𝑛
个皇后和一个 𝑛 × 𝑛 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。
如图所示,当 𝑛 = 4 时,共可以找到两个解。从回溯算法的角度看, 𝑛 × 𝑛 大小的棋盘共有 𝑛的  2次方 个格子,给出了所有的选择 choices 。在逐个放置皇后的过程中,棋盘状态在不断地变化,每个时刻的棋盘就是状态 state

下图展示了本题的三个约束条件:多个皇后不能在同一行、同一列、同一对角线 。值得注意的是,对角线分为主对角线 \ 和次对角线 / 两种。

1. 逐行放置策略

皇后的数量和棋盘的行数都为 𝑛 ,因此我们容易得到一个推论: 棋盘每行都允许且只允许放置一个皇后 。 也就是说,我们可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束。
如图所示,为 4 皇后问题的逐行放置过程。受画幅限制,图 13‑17 仅展开了第一行的其中一个搜索分支,并且将不满足列约束和对角线约束的方案都进行了剪枝。

本质上看,逐行放置策略起到了剪枝的作用,它避免了同一行出现多个皇后的所有搜索分支。  

2. 列与对角线剪枝

为了满足列约束,我们可以利用一个长度为 𝑛 的布尔型数组 cols 记录每一列是否有皇后。在每次决定放置 前,我们通过 cols 将已有皇后的列进行剪枝,并在回溯中动态更新 cols 的状态。
那么,如何处理对角线约束呢?设棋盘中某个格子的行列索引为 (𝑟𝑜𝑤, 𝑐𝑜𝑙) ,选定矩阵中的某条主对角线, 我们发现该对角线上所有格子的行索引减列索引都相等,即对角线上所有格子的 𝑟𝑜𝑤 − 𝑐𝑜𝑙 为恒定值 。 也就是说,如果两个格子满足 𝑟𝑜𝑤 1 − 𝑐𝑜𝑙 1 = 𝑟𝑜𝑤 2 − 𝑐𝑜𝑙 2 ,则它们一定处在同一条主对角线上。利用该规律,我们可以借助下图所示的数组 diag1 ,记录每条主对角线上是否有皇后。
同理, 次对角线上的所有格子的 𝑟𝑜𝑤 + 𝑐𝑜𝑙 是恒定值 。我们同样也可以借助数组 diag2 来处理次对角线约束

3. 代码实现

请注意, 𝑛 维方阵中 𝑟𝑜𝑤 − 𝑐𝑜𝑙 的范围是 [−𝑛 + 1, 𝑛 − 1] 𝑟𝑜𝑤 + 𝑐𝑜𝑙 的范围是 [0, 2𝑛 − 2] ,所以主对角线和次对角线的数量都为 2𝑛 − 1 ,即数组 diag1 diag2 的长度都为 2𝑛 − 1
    /* 回溯算法:N 皇后 */
    void backtrack(int row, int n, List<List<String>> state, List<List<List<String>>> res,
                   boolean[] cols, boolean[] diags1, boolean[] diags2) {
        // 当放置完所有行时,记录解
        if (row == n) {
            List<List<String>> copyState = new ArrayList<>();
            for (List<String> sRow : state) {
                copyState.add(new ArrayList<>(sRow));
            }
            res.add(copyState);
            return;
        }
        // 遍历所有列
        for (int col = 0; col < n; col++) {
            // 计算该格子对应的主对角线和副对角线
            int diag1 = row - col + n - 1;
            int diag2 = row + col;
            // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
            if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
                // 尝试:将皇后放置在该格子
                state.get(row).set(col, "Q");
                cols[col] = diags1[diag1] = diags2[diag2] = true;
                // 放置下一行
                backtrack(row + 1, n, state, res, cols, diags1, diags2);
                // 回退:将该格子恢复为空位
                state.get(row).set(col, "#");
                cols[col] = diags1[diag1] = diags2[diag2] = false;
            }
        }
    }
    /* 求解 N 皇后 */
    List<List<List<String>>> nQueens(int n) {
        // 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
        List<List<String>> state = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            List<String> row = new ArrayList<>();
            for (int j = 0; j < n; j++) {
                row.add("#");
            }
            state.add(row);
        }
        boolean[] cols = new boolean[n]; // 记录列是否有皇后
        boolean[] diags1 = new boolean[2 * n - 1]; // 记录主对角线是否有皇后
        boolean[] diags2 = new boolean[2 * n - 1]; // 记录副对角线是否有皇后
        List<List<List<String>>> res = new ArrayList<>();
        backtrack(0, n, state, res, cols, diags1, diags2);
        return res;
    }
逐行放置 𝑛 次,考虑列约束,则从第一行到最后一行分别有 𝑛 𝑛 − 1 2 1 个选择, 因此时间复杂度 𝑂(𝑛!) 。实际上,根据对角线约束的剪枝也能够大幅地缩小搜索空间,因而搜索效率往往优于以上时间复杂度。
数组 state 使用 𝑂(𝑛 2 ) 空间,数组 cols diags1 diags2 皆使用 𝑂(𝑛) 空间。最大递归深度为 𝑛 ,使用 𝑂(𝑛) 栈帧空间。因此, 空间复杂度为 𝑂(𝑛 2 )

5 小结


1. 重点回顾
   - 回溯算法本质是一种穷举法,通过深度优先遍历解空间来寻找满足条件的解。在搜索中,尝试与回退是两个关键操作。
   - 剪枝是提高回溯算法效率的重要手段,能够提前结束不必要的搜索分支。
   - 回溯算法主要用于搜索问题和约束满足问题,而组合优化问题可能有更高效的解法。

2. 全排列问题
   - 在全排列问题中,通过数组记录元素选择情况,避免重复选择相同元素。
   - 针对集合中存在重复元素的情况,使用哈希表确保相等元素在每轮中只能被选择一次。

3. 子集和问题
   - 子集和问题旨在找到和为目标值的所有子集。通过排序数组和设置遍历起点,剪枝掉重复搜索分支。
   - 数组中相等元素可能产生重复集合,通过判断相邻元素是否相等实现剪枝。

4. 𝑛 皇后问题
   - 𝑛皇后问题要求在 𝑛 × 𝑛 尺寸棋盘上放置皇后,保证它们两两之间无法攻击。列约束、主对角线和副对角线约束的处理方式。
   - 数组用于记录列约束和对角线约束信息,确保每个格子的选择是合法的。

怎么理解回溯和递归的关系?
总的来看,回溯是一种“算法策略”,而递归更像是一个“工具”。
‧ 回溯算法通常基于递归实现。然而,回溯是递归的应用场景之一,是递归在搜索问题中
的应用。
‧ 递归的结构体现了“子问题分解”的解题范式,常用于解决分治、回溯、动态规划(记
忆化递归)等问题。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值