🎯 引言:你以为回溯是“暴力”,其实是“聪明的暴力”
在刷 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 函数检查 |
💡 四、幽默小结:回溯就像人生
- 做选择:就像选专业、选城市、选对象。
- 递归:一头扎进去,努力奋斗。
- 回溯:发现不合适,及时止损,回到上一个路口。
- 剪枝:听爸妈/导师/直觉的建议,避开明显坑。
所以,回溯不是“试错”,而是有策略地探索可能性。
就像我们的人生——既要勇敢尝试,也要懂得及时回头。
📚 五、结语 & 鼓励
回溯算法看似复杂,但只要掌握“路径 + 选择 + 撤销”三板斧,再配合剪枝技巧,就能轻松拿下一大类题目。
刷题建议:
- 先刷 78、77、46,建立基本模型;
- 再攻克 90、47、40,掌握去重;
- 最后挑战 51、37,感受回溯的威力!
记住:你不是在写代码,你是在教计算机如何“聪明地穷举”。