学回溯算法时,我先弄明白了怎么“退回来”

之前整理过链表的快慢指针。做那类题时,我习惯先画出两个指针的位置,再想循环结束后它们会停在哪里。今天学习回溯算法,我也用了类似的办法:先画出每一步选了什么,再看代码。

刚看到回溯代码时,最容易让我停下来的地方是:明明递归已经调用了下一层,为什么回来之后还要把刚加入的元素删掉?如果只背"递归三部曲",下次换一道题,还是容易写乱。这篇文章就从这个问题开始,记录一下我对回溯的理解。

回溯在做什么

假设有数字 1、2、3,要从中选两个数字,列出所有组合。可以先选 1,再选 2,得到 [1, 2]。这一组记录下来以后,还要回到只选了 1 的状态,再试试 3,得到 [1, 3]。

text 复制代码
开始 []
├── 选 1 → [1]
│   ├── 选 2 → [1, 2]  保存
│   └── 选 3 → [1, 3]  保存
└── 选 2 → [2]
    └── 选 3 → [2, 3]  保存

探索完 [1, 2] 后,需要把 2 从当前路径中移除,才能尝试 [1, 3]。探索完以 1 开头的所有情况后,还要移除 1,才能从 2 开始。

回溯通常用深度优先搜索逐条尝试,并在离开一条分支时恢复修改过的状态。画成树,就是沿着一条分支往下走;走完以后退回上一个节点,再走另一条分支。递归负责进入和返回,撤销选择负责让共享的路径回到上一层的样子。

一段回溯代码通常在维护什么

以找组合为例,可以先记住三个东西:

  • path:当前已经选了哪些数字。
  • 可选范围:接下来还能从哪些数字中选。
  • 结束条件:什么时候得到一组结果,或者什么时候已经不可能得到结果。

代码的核心顺序通常是:

text 复制代码
做选择 → 进入下一层 → 撤销这次选择 → 尝试下一个选择

这里的"撤销"不是删除已经保存的答案,而是清理正在使用的 path。如果不清理,上一条分支选过的数字就会留到下一条分支里。

回溯也不意味着所有问题的写法都一样。有的题不允许重复使用元素,有的题可以重复使用;有的题要找所有结果,有的题找到一个可行结果就可以结束。先确定题目的规则,再决定怎么控制可选范围。

例一:组合,为什么下一层从 i + 1 开始

力扣第 77 题"组合"的要求是:从 1 到 n 中选出 k 个不同的数字,返回所有组合。例如 n = 4、k = 2,结果包括 [1, 2] 和 [1, 3]。

组合不考虑顺序:[1, 2] 和 [2, 1] 表示同一组数字。因此,选过 1 以后,只需要从它后面的数字继续选,不用再回头选前面的数字。

java 复制代码
import java.util.ArrayList;
import java.util.List;

class Solution {
    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> result = new ArrayList<>();
        List<Integer> path = new ArrayList<>();
        backtrack(n, k, 1, path, result);
        return result;
    }

    private void backtrack(int n, int k, int start,
                           List<Integer> path,
                           List<List<Integer>> result) {
        if (path.size() == k) {
            result.add(new ArrayList<>(path));
            return;
        }

        for (int i = start; i <= n; i++) {
            path.add(i);                              // 做选择
            backtrack(n, k, i + 1, path, result);   // 继续选择
            path.remove(path.size() - 1);             // 撤销选择
        }
    }
}

比如当前 path 是 [1],这一层选了 2,递归进入下一层后,path 变成 [1, 2]。长度达到 k,保存结果并返回。返回到上一层时,path.remove(...) 移除末尾的 2,路径重新变回 [1],循环才可以继续尝试 3。

我现在会把 start 理解成"这一层最早可以从哪里开始选"。递归时传 i + 1,一方面避免重复选择当前数字,另一方面也避免出现 [1, 2] 和 [2, 1] 这样的重复组合。

为什么保存时要复制 path

代码里写的是:

java 复制代码
result.add(new ArrayList<>(path));

因为整个搜索过程反复修改的是同一个 path 对象。如果直接写 result.add(path),保存进去的会是同一个列表的引用;后续撤销选择时,这个列表也会跟着变化。复制一份,才是把当时的内容留下来。

可以提前停止吗

可以。假如还需要选 3 个数字,但后面只剩 2 个数字,这条分支就不可能凑够,可以不继续搜索。这种提前排除无效分支的做法叫剪枝。

上面代码中的循环条件可以改为:

java 复制代码
for (int i = start; i <= n - (k - path.size()) + 1; i++) {

其中 k - path.size() 是还需要选择的数量。这个边界不是必须先死记下来;可以先写出不剪枝的版本,再用几个小例子验证:从 i 到 n 剩下的数字,是否还够选。剪枝减少的是无效搜索,不能把可能产生正确答案的分支也剪掉。

例二:全排列,为什么需要 used

力扣第 46 题"全排列"给出一组互不相同的数字,要求列出所有排列。例如 [1, 2] 和 [2, 1] 是两个不同结果。

这和组合不同:每一层都需要重新考虑所有数字,但同一条路径中不能重复使用已经选过的数字。因此可以用 used 数组记录哪些位置已经被选择。

java 复制代码
import java.util.ArrayList;
import java.util.List;

class Solution {
    public List<List<Integer>> permute(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        List<Integer> path = new ArrayList<>();
        boolean[] used = new boolean[nums.length];
        backtrack(nums, used, path, result);
        return result;
    }

    private void backtrack(int[] nums, boolean[] used,
                           List<Integer> path,
                           List<List<Integer>> result) {
        if (path.size() == nums.length) {
            result.add(new ArrayList<>(path));
            return;
        }

        for (int i = 0; i < nums.length; i++) {
            if (used[i]) {
                continue;
            }

            used[i] = true;
            path.add(nums[i]);

            backtrack(nums, used, path, result);

            path.remove(path.size() - 1);
            used[i] = false;
        }
    }
}

这里需要恢复两处状态:从 path 中移除数字,并把对应的 used[i] 改回 false。只恢复其中一个,后续分支看到的状态就不完整。

这份代码针对题目给出的"数字互不相同"的条件。如果数组中有重复数字,还要考虑去重,不能直接照搬。

组合和排列,先看题目在意什么

我以前容易看到"选数字"就想套同一份代码。区分这两类题,可以先问一句:换个顺序,算不算新结果?

问题 组合 排列
[1, 2] 和 [2, 1] 同一组 两种结果
下一层从哪里选 通常从当前位置之后开始 通常重新遍历所有位置
如何避免同一路径重复使用 缩小起始范围 用 used 标记已选位置

这张表只适用于上面两道题。比如有的组合题允许一个数字重复使用,下一层就不一定从 i + 1 开始。遇到新题时,还是要先读清楚规则。

做题时我会检查的几个地方

第一,看结束条件。是路径长度达到要求就保存,还是还要检查总和、合法性?结果保存之后要不要立即返回?这些都取决于题目。

第二,看每一层的选择范围。组合里用 start,排列里用 used;如果题目允许重复使用,范围又会不同。

第三,看状态有没有恢复。除了 path,还可能有标记数组、当前和、棋盘格子等。哪一层修改了共享状态,返回时就要考虑怎样恢复,尤其要留意提前返回的分支。

第四,看结果有没有复制。保存的是当前路径的快照,还是一个后面还会继续变化的对象?Java 里这个区别很容易影响最后的结果。

最后再看性能。回溯可能要探索很多分支,能否剪枝要看题目。上面的全排列有 n! 个结果,每个结果需要复制 n 个元素,时间复杂度为 O(n × n!);不算保存结果所需的空间,路径、标记数组和递归栈使用 O(n) 空间。组合题的搜索分支也会随 n 增长很快,具体数量与 k 和剪枝方式有关。

写在最后

作为准备做后端开发的学生,学这类算法时我也会想:工作中不一定每天都要手写全排列,为什么还要花时间画搜索树?对我来说,收获是开始习惯把问题拆成状态和选择,再检查每一步会给后续带来什么影响。遇到需要枚举方案或组合测试条件的问题时,这种思路也能帮我整理边界;实际项目是否适合用回溯,还要看数据规模和性能要求。

这次学习里,我最想记住的是那句 path.remove(...)。它看起来只删掉了一个元素,实际上是在告诉自己:这一条路已经试完了,恢复现场,才能安心去试下一条。

相关推荐
Lzg_na1 小时前
堆和堆排序
算法
TK泰妞1 小时前
TikTok 带货视频如何做多语言本地化?从翻译脚本到重做场景的实用方法
算法·音视频
房开民1 小时前
DDPM 完整原理 + 全套公式串联
算法
lunar_coder1 小时前
哈希扩展 -- 位图思想
c++·算法·哈希算法
秋天的怀念_David1 小时前
听故事学语言 · 数据结构篇 · 序章一 算法之问
数据结构·程序人生·算法
LG.YDX1 小时前
基于中心的3D目标检测和探索追踪-笔记
笔记·算法·目标检测
民乐团扒谱机2 小时前
【读论文】音色在微分音程记忆中的作用
人工智能·深度学习·算法
Navigator_Z2 小时前
LeetCode //C - 1306. Jump Game III
c语言·算法·leetcode
小O的算法实验室2 小时前
IEEE TASE,面向无人机的不确定性感知信息路径规划:可解释贝叶斯强化学习方法
算法