《回溯算法不再玄学:先画树,再编码》

75 阅读4分钟

深入理解回溯算法:从树形图出发,写出清晰可靠的代码

最近在刷 LeetCode 的过程中,我频繁遇到“子集”、“组合”、“全排列”、“组合总和”等题目。它们形式各异,但解题核心高度一致——回溯算法。起初我对这类题目感到混乱,常常写不出正确的递归边界或搞不清何时收集结果。直到我养成了一个习惯:动笔画出递归树。从此,回溯不再“玄学”,而变成了一种可视化、可推理的系统性方法。


什么是回溯?

回溯本质上是一种带剪枝的深度优先搜索(DFS) 。它通过递归尝试每一种可能的选择,在某条路径走不通时,就“撤销”上一步操作(即“回溯”),然后尝试其他选项。其核心思想可以概括为三步:

  1. 做选择(将当前元素加入路径)
  2. 递归探索(进入下一层决策)
  3. 撤销选择(从路径中移除,恢复状态)

这种“试探—失败—回退—再试”的机制,使得我们能高效地遍历所有合法路径。


JavaScript 回溯通用模板

虽然每道题细节不同,但绝大多数回溯题都可以套用以下结构:

var backtrack = function(nums) {
    const result = [];      // 存放最终所有解
    const path = [];        // 当前正在构建的路径

    function backstrack(startIndex) {
        // **关键**判断是否需要在此处收集结果
        if (满足结束条件) {
            result.push([...path]); // 注意:必须拷贝!
            return;
        }

        for (let i = startIndex; i < nums.length; i++) {
            // 剪枝(可选):提前跳过无效选择
            if (不满足条件) continue;

            path.push(nums[i]);       // 做选择
            backstrack(i + 1);               // 递归:进入下一层(注意参数)
            path.pop();               // 撤销选择(回溯)
        }
    }

    backstrack(0);
    return result;
};

模板要点解析:

  • resultpath:分别记录所有解和当前路径。
  • startIndex:控制“选择列表”的起点,避免重复组合(如 [1,2] 和 [2,1] 被视为相同)。
  • [...path] :必须使用浅拷贝,否则后续 pop() 会修改已存入的结果。
  • 剪枝逻辑:可在 for 循环内加入条件提前跳过无效分支,提升效率。
  • 递归参数:进行递归时的参数取决于当前题目的元素是否能够重复的选取

我的核心方法论:先画树,再写代码

对我而言,最有效的回溯入门方式是手动画出递归树。这不仅能帮助理解问题结构,还能明确两个关键问题:

  1. 哪些节点需要被收集? (所有节点?仅叶子?满足特定条件的节点?)
  2. 递归何时终止? (是否需要显式终止条件?还是靠循环自然结束?)

示例:LeetCode 78. 子集

输入:nums = [1,2,3]

我先画出递归树:

                      []
          /            |            \
        [1]           [2]           [3]
       /   \           |
    [1,2] [1,3]     [2,3]
      /
  [1,2,3]

观察发现:

  • 每一个节点都是一个有效子集(包括根节点 []);
  • 不需要等到“叶子”才收集,而是每次进入递归函数就应保存当前 path
  • 递归没有显式的“终止条件”,而是靠 for 循环自然结束(当 i >= nums.length 时不再进入循环)。

因此,代码应这样写:

/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var subsets = function(nums) {
    let path= [];
    let result =[];
    function backstarcking(index){
            result.push([...path]);
      
        for(let i=index;i<nums.length;i++)
        {
            path.push(nums[i]);
            backstarcking(i+1);
            path.pop();
        }
        return;
    }
   
    backstarcking(0);
    return result;
};

对比:LeetCode 77. 组合(只收集叶子)

若题目要求“所有长度为 k 的组合”,则只有深度为 k 的叶子节点才是解。此时递归终止条件变为:

if (path.length === k) {
    result.push([...path]);
    return; //  明确终止,不再往下探索
}

总结:我的回溯四步法

  1. 画树:手动画出小规模输入的递归树,看清结构;
  2. 定收集点:判断是收集所有节点、叶子节点,还是满足某条件的节点;
  3. 写终止条件:根据收集策略决定是否需要显式 return
  4. 套模板编码:填充选择、递归、回溯三步,注意拷贝和索引控制。

回溯算法看似复杂,但只要把树画清楚,代码就是对图形的忠实翻译。可视化思维,是破解递归迷宫的钥匙

刷题不是背模板,而是理解结构。愿你我都能在树的枝桠间,找到属于自己的那条路径。