🌟回溯算法:从“暴力枚举”到“优雅剪枝”的进阶之旅(附14道力扣经典题解)

4 阅读5分钟

🎯 引言:你以为回溯是“暴力”,其实是“聪明的暴力”

在刷 LeetCode 的路上,你是否也曾被“回溯”二字吓退?
看到题目要求“所有可能组合”“所有分割方案”“所有有效 IP”……心里一咯噔:“完了,又要写 8 层 for 循环了?”

别慌!回溯不是暴力,而是带着地图的探险。它用递归+状态恢复的方式,系统地遍历所有可能性,并通过剪枝提前避开死胡同——就像你在迷宫里边走边画标记,发现此路不通就立刻回头。

今天,我们就一起揭开回溯算法的神秘面纱,用14 道经典力扣题,带你从“子集”走到“数独”,从“全排列”玩到“N皇后”,最后还能复原 IP 地址、分割回文串,甚至解出 Sudoku!

准备好了吗?Let’s backtrack!


🧠 一、回溯的核心思想:三步走战略

回溯 = 递归 + 选择 + 撤销

function backtrack(路径, 选择列表) {
  if (满足结束条件) {
    result.push(路径拷贝);
    return;
  }
  
  for (选择 of 选择列表) {
    路径.push(选择);      // 做选择
    backtrack(路径, 新选择列表); // 递归
    路径.pop();           // 撤销选择(回溯!)
  }
}

关键点:

  • 路径(path) :当前构建的答案片段。
  • 选择列表:剩下的可选项(通常用 startIndex 控制)。
  • 结束条件:什么时候把当前路径加入结果?
  • 剪枝:提前跳过无效分支,提升效率!

🧩 二、分类刷题:14 道题,覆盖回溯四大场景

我们将题目分为四类,由浅入深:

🔹 类型1:子集 & 组合(无重复 / 有重复)

✅ 78. 子集(元素互异)

所有可能子集,包括空集。

// 核心:每层都记录 path,不管长度
res.push([...path]);
for (let i = startIndex; i < nums.length; i++) {
  path.push(nums[i]);
  backtracking(i + 1);
  path.pop();
}

💡 思考:为什么每次都要 push?因为子集不要求固定长度!


✅ 90. 子集 II(含重复元素)

去重是关键!先排序,再跳过 i > startIndex && nums[i] === nums[i-1]

if (i > startIndex && nums[i] === nums[i-1]) continue;

🤔 灵魂拷问:为什么是 i > startIndex 而不是 i > 0
答:同一层不能选相同值,但不同层可以(比如 [1,2,2] 中第一个 2 和第二个 2 可以共存)。


✅ 77. 组合

从 1~n 选 k 个数。

if (path.length === k) { res.push([...path]); return; }
// 剪枝优化:i <= n - (k - path.length) + 1

技巧:提前终止循环,避免无效递归!


✅ 216. 组合总和 III

用 1~9 中 k 个不重复数字,和为 n。

if (sum > target) return; // 提前剪枝
if (path.length === k && sum === target) ...

🎯 对比 77 题:多了“和”的限制,但思路一致。


🔹 类型2:组合总和(可重复 / 不可重复)

✅ 39. 组合总和(元素可重复使用)

同一个数能用多次 → 递归时传 i 而非 i+1

backtracking(candidates, target, sum, i); // 注意是 i!

🔄 关键区别startIndex 不推进,允许重复选自己。


✅ 40. 组合总和 II(每个数只能用一次 + 去重)

先排序,再 i > startIndex && nums[i] === nums[i-1] 跳过

backtracking(..., i + 1); // 不能重复用

🧠 记忆口诀

  • 可重复 → 递归传 i
  • 不可重复 → 传 i+1
  • 有重复元素 → 排序 + 同层去重

🔹 类型3:排列(顺序 matters!)

✅ 46. 全排列(无重复)

used[] 数组标记是否已选

if (used[i]) continue;
used[i] = true;
...
used[i] = false; // 回溯

🎭 注意:排列没有 startIndex,因为每次都要从头扫!


✅ 47. 全排列 II(有重复)

排序 + if (i>0 && nums[i]===nums[i-1] && !used[i-1]) continue

❓ 为什么是 !used[i-1]
答:保证相同元素按顺序使用。如果前一个没用,说明当前是“新层”的第一个,可以选;如果前一个用了,说明在同一路径中,也能选。只有“前一个没用且当前要选”才是重复!


🔹 类型4:字符串分割 & 构造类问题

✅ 131. 分割回文串

切割问题 = 组合问题变种!startIndex 是切割起点

for (let i = startIndex; i < s.length; i++) {
  if (isPalindrome(s, startIndex, i)) {
    path.push(s.slice(startIndex, i+1));
    backtracking(i+1);
    path.pop();
  }
}

🔍 本质:在字符串上做“组合式切割”。


✅ 93. 复原 IP 地址

每段 0~255,不能有前导零,共 4 段

if (str.length > 3 || +str > 255) break;
if (str.length > 1 && str[0] === '0') break;

🚫 注意:用 break 而非 continue,因为后续更长的子串一定也无效!


✅ 17. 电话号码的字母组合

每个数字对应多个字母 → 多叉树遍历

for (const v of map[digits[index]]) {
  path.push(v);
  backtrack(index + 1);
  path.pop();
}

📞 小彩蛋:这题其实不算典型回溯,更像是 DFS,但结构一致!


✅ 491. 非递减子序列

不能排序!用 Set每一层去重

const used = new Set();
if (used.has(nums[i])) continue;
used.add(nums[i]);

⚠️ 难点:因为不能排序,所以不能用 nums[i] === nums[i-1] 判断,必须用 Set 记录本层已用数字。


🔹 类型5:高阶挑战:棋盘 & 数独

✅ 51. N 皇后

每行放一个皇后,检查列、主对角线、副对角线

function isValid(row, col) {
  // 检查列
  // 检查左上
  // 检查右上
}

👑 经典中的经典:回溯 + 约束检查的完美结合!


✅ 37. 解数独

二维回溯!遇到 '.' 就尝试 1~9

for (let val = 1; val <= 9; val++) {
  if (isValid(i, j, `${val}`)) {
    board[i][j] = `${val}`;
    if (backTracking()) return true; // 找到解就返回!
    board[i][j] = '.';
  }
}

🧩 技巧:函数返回 true/false 表示是否找到解,一旦找到立即退出。


🎨 三、回溯模板总结(建议收藏!)

问题类型是否需要 startIndex是否需要 used[]是否排序去重方式
子集 / 组合有重复才排同层:i>start && nums[i]==nums[i-1]
排列有重复才排i>0 && nums[i]==nums[i-1] && !used[i-1]
切割 / IP / 字符串✅(作为切割点)按规则剪枝
棋盘类❌(用行列坐标)❌(用棋盘状态)isValid 函数检查

💡 四、幽默小结:回溯就像人生

  • 做选择:就像选专业、选城市、选对象。
  • 递归:一头扎进去,努力奋斗。
  • 回溯:发现不合适,及时止损,回到上一个路口。
  • 剪枝:听爸妈/导师/直觉的建议,避开明显坑。

所以,回溯不是“试错”,而是有策略地探索可能性
就像我们的人生——既要勇敢尝试,也要懂得及时回头。


📚 五、结语 & 鼓励

回溯算法看似复杂,但只要掌握“路径 + 选择 + 撤销”三板斧,再配合剪枝技巧,就能轻松拿下一大类题目。

刷题建议

  1. 先刷 78、77、46,建立基本模型;
  2. 再攻克 90、47、40,掌握去重;
  3. 最后挑战 51、37,感受回溯的威力!

记住:你不是在写代码,你是在教计算机如何“聪明地穷举”。