# 在开始之前
30 秒速记
- 递归回溯不是玄学,它就是「做选择 → 往下走 → 撤销选择」的穷举套路
- 门槛主要在心理上:一看到「思想」两个字就先吓住了
- 学法:从最小的例子手推,比如两个数的全排列,看每一层做了什么
- 先能写出能跑的小例子,再回头补术语和定义
- 一道题会了不算,换一道同类题还能写出来才算
递归回溯听起来唬人,其实就是一套能反复套用的穷举套路,难的是第一次跨过心理门槛。 很多人一看到「思想」就觉得是一大坨理论,还没开始看题就先放弃了。我的建议是别从定义入手,拿一个最小的例子手推一遍,比如 [1,2] 的全排列,每一层选了谁、什么时候返回、返回后数组变成什么样,在纸上画出来。画清楚一次,后面的组合、子集都是换汤不换药。
根据我深耕技术写作多年的经验,很多同学一看到标题里有“思想”两个字,就会觉得接下来要讲的一定是一个非常复杂的“高大上”理论,于是他会先给自己箍上一个“我一定学不会”的紧箍咒,接着心里就开始打退堂鼓了。这样的同学在和算法正面交锋之前,就先被自己内心的恐惧击垮了,实在可惜。
站在讲解者的角度来说,我确实不会先给大家画个饼,说这玩意儿有多么多么简单——这是一个非常不负责任的承诺。因为对于初学者来说,没有什么是简单的,从不会到会本来就是一个过程。况且,你现在学的是不少前端er都不肯学/学不动的算法,这本就不是一个轻松的挑战。但既然走到了这一步,不管你这会儿心里有多慌,我都希望你可以坚持一下、读读看,你会发现这玩意儿真的不是玄学——它真的很香。
面试官追问
追问 1能背出回溯的定义,但说不清 path 为什么一会儿变长一会儿变短,说明什么?
说明还没把概念对上执行过程。让他拿 [1,2] 手推一遍,每次 push 和 pop 时 path 是什么写在旁边,推完基本就通了。
追问 2新人非要先把递归理论读完再动手,两天了还写不出最小例子,怎么办?
直接给一道输入很小的题,先写出能跑的版本,再对着代码讲「这行就是选择、这行就是撤销」。理论在有了具体代码之后再补,吸收快很多。
追问 3面试时候选人一听回溯就紧张,你会怎么引导?
先让他写两个元素的全排列,规模小、分支少,写出来信心就有了。能写通再加约束,比如去重或者限定长度;连递归终止条件都不清楚的话,扩题也没意义。
追问 4说「算法太玄学只能背模板」,换一道题就不会了,问题在哪?
模板只是把已经理解的东西压缩了一下,没理解就背,约束一变就不知道改哪。要能说出每层有哪些候选、什么时候结束、返回后状态怎么恢复,这三点说清楚了,模板自己就能推出来。
# 如何学好这一节
30 秒速记
- 面试不考背定义,考能不能把题做出来 → 以题为纲
- 先手画搜索树的前两层:每层有哪些候选、哪些分支该剪
- 模板里的参数各管一件事:
path记当前选择,start管不回头,used管不重复,remain管剩余目标 - 用最小输入逐步跟踪,再拿空输入、重复元素、无解验证边界
- 「思想」本质是套路,吃透一个能带走一大批题
这一节别纠结什么是递归、什么是回溯,直接做题,从题里认识套路。 面试里算法几乎不考背概念,看的是你能不能把题写出来。我的做法是遇到「列出所有方案」的题,先在纸上画出搜索树的前两层,看每一层在做什么重复的选择、什么时候停。画出来之后,代码里的 path、start、used 分别对应什么就很清楚了。刚开始想不到递归很正常,做完全排列、子集、组合这三道,自然会形成条件反射。
不可否认,在一些传统教材里,谈及“思想”必定会有大段理论文字的堆砌,这也是很多同学学完数据结构直接放弃学习算法思想的重要原因。
但站在面试的角度来看,算法相关的考察几乎不存在“背知识点”这种形式,更多还是看你能不能把题做出来。算法思想是抽象的,题目却是具体的。我们常说“以题为纲”,其目的就是帮助大家站在具体去理解抽象。
本节我们先不用纠结什么是递归、什么是回溯,而是直接来做一道题,从题中去认识所谓的“思想”。
通过本节的学习,我希望大家能够认识到,“思想”并不是一坨剪不断理还乱、学了只能用来吹水的虚无概念。“思想”本质上就是套路,而且是普适性非常强的套路,它有着大量的对口问题。搞定了它,就搞定了一大波面试题——爽不爽?要想爽这一把,就不要轻易撤退。
面试官追问
追问 1能默写回溯模板,但改不成组合题,怎么证明自己真懂了?
拿一个具体输入画搜索树,把模板里每个参数对应到树上:哪个是层数、哪个是这一层的候选范围、哪个是叶子条件。能对上就是懂了,对不上就是背的。
追问 2全排列模板换成有重复元素的输入,结果出现重复,先改代码还是先画图?
先画图,找出是哪两条分支走到了同一个结果。一般是同一层选了两个值相同的元素,排序后加 if (i > 0 && nums[i] === nums[i-1] && !used[i-1]) continue 就行。不画图直接堆条件,样例过了也说不清对不对。
追问 3连续三道回溯题都卡十分钟后看答案,复盘只抄了最终代码,问题出在哪?
复盘少了过程。应该记下自己卡在哪一层、每层的候选和终止条件,隔一天不看答案拿小输入重新推一遍,再换一道相邻的题验证能不能迁移。
追问 4怎么判断一道题该往回溯上想?
两个信号:题目要你「列出所有」具体方案;问题能拆成一个个「坑位」逐个做选择。只问方案数量的话,多半是动态规划。
# 关键套路初相见:全排列问题
30 秒速记
- 全排列 = 逐个填坑:第
depth层决定第depth个位置放谁 path记当前排列,used[i]记下标i在这条路径上用没用过- 每轮固定三步:选择 → 递归 → 撤销,撤销必须和选择对称
- 收集答案写
[...path],直接存path所有结果会变成同一个数组 n个不同元素共n!种,光输出就要O(n·n!)
全排列可以看成填坑:有几个数就有几个坑,每一层从还没用过的数里挑一个填进去,填满了就是一个答案。 用 path 存当前填好的部分,用 used 记哪些数已经用过,避免同一个数出现两次。递归返回后要把刚才的选择撤销掉,path.pop() 加上 used[i] = false,这样兄弟分支才能从干净的状态开始。最常见的坑是收集结果时直接 res.push(path),因为后面还会继续改这个数组,最后所有结果都一样。
function permute(nums) {
const res = [], path = [], used = []
const dfs = () => {
if (path.length === nums.length) { res.push([...path]); return }
for (let i = 0; i < nums.length; i++) {
if (used[i]) continue
used[i] = true; path.push(nums[i]) // 选择
dfs() // 递归
path.pop(); used[i] = false // 撤销
}
}
dfs()
return res // permute([1,2,3]) 共 6 个
}
回答参考:“我把搜索过程看成一棵决策树:第
depth层决定排列的第depth个位置,每层枚举尚未使用的元素,返回时恢复现场。”
面试官追问
追问 1每层都枚举全部元素、不用 used,说递归深度不同就不会重复,对吗?
不对。深度只说明在填第几个坑,不会自动排除已经选过的数,结果里会出现 [1,1,1] 这种。必须有 used 或者等价的标记。
追问 2输入变成 [1,1,2],结果出现重复排列,怎么改?
先排序,再在同一层跳过和前一个值相同、且前一个还没被用的元素:if (i > 0 && nums[i] === nums[i-1] && !used[i-1]) continue。used 只能防同一个下标重复用,防不了相同的值在同一层被选两次。
追问 3结果里有好几条完全一样的数组,日志看递归过程是对的,先查什么?
先查是不是 res.push(path) 存了引用。递归过程打印出来没问题,但所有结果指向同一个 path,最后回溯完它就是空数组或者最后一个状态。
追问 4只要字典序第 k 个排列,生成全部再取下标可以吗?
n 很小可以,n = 10 就是 360 万个排列,太浪费了。按阶乘分组逐位确定:首位每换一个数就跳过 (n-1)! 个排列,O(n^2) 就能算出来。
题目描述:给定一个没有重复数字的序列,返回其所有可能的全排列。
示例:
输入: [1,2,3]
输出: [
[1,2,3],
[1,3,2],
[2,1,3],
[2,3,1],
[3,1,2],
[3,2,1]
]
思路分析
“全排列”是高中数学里的一个概念,这里先带大家复习一下:
从n个不同元素中任取m(m≤n)个元素,按照一定的顺序排列起来,叫做从n个不同元素中取出m个元素的一个排列。当m=n时所有的排列情况叫全排列。
不过就算你已经完全忘了“全排列”到底是一个什么样的数学概念,也没有关系。结合题目描述和示例,我们依然可以分析出这道题想让我们做的事情:拿到一个 n 个数的数组作为入参,穷举出这 n 个数的所有排列方式。
哎?等等,我好像看到一个熟悉的词眼——穷举!楼上是不是说了穷举?我们最近还在哪里见过穷举?是不是在上一节?上一节的哪个位置?DFS 对不对?DFS 用什么实现比较好?递归!好,来得早不如来得巧,我现在就决定用递归来做这个题。




