代码随想录算法训练营第25天|回溯算法Part04
记录题目:
- 重新安排行程
- N 皇后
- 解数独
给定一个机票的字符串二维数组
tickets
,
其中tickets[i] = [from, to]
表示从from
到to
的航班信息。
从"JFK"
机场出发,返回重新安排后的行程,
行程按字典序排列且恰好使用所有机票一次。
1 | var findItinerary = function (tickets) { |
如果当前路径成功走完(用了所有票),就 return true
,一路向上传递终止;
如果当前路径走死了,就 return false
,上一层回退换一条路走。
方法:slice(start, end)
属性 | 描述 |
---|---|
参数形式 | 截取索引 [start, end) 之间的元素(不含 end ) |
主要作用 | 复制子数组 |
返回值 | 新数组 |
是否修改原数组 | 否 |
方法:splice(start, deleteCount, ...itemsToAdd)
属性 | 描述 |
---|---|
参数形式 | 从索引 start 开始,删除 deleteCount 个元素,并插入 itemsToAdd (可选) |
主要作用 | 删除或插入元素 |
返回值 | 被删除的元素组成的新数组 |
是否修改原数组 | 是 |
1 | function findItinerary(tickets) { |
Hierholzer 算法依赖“不断从当前节点出发,走完一条尽可能长的路径直到无法继续”,并将走过的节点按回溯顺序压入栈中,最终将所有路径拼接成一条欧拉路径。
接下来说明:为何这种看似“贪心 + 回溯”的策略是正确的,并适用于欧拉路径与回路。
性质一:栈中倒序恰好是欧拉路径的顺序
Hierholzer 算法的核心是:在无法再扩展路径(即遇末端节点)时将当前节点压入栈中,这等价于路径的“反向记录”。
对于欧拉路径
- 若图存在欧拉路径,则起点
s
出度比入度多1
,终点t
入度比出度多1
。 - DFS 从
s
出发,最终会“遇末端”在t
—— 即第一次无法继续扩展的节点。 - 此时
t
被最早压入栈中,随着后续 DFS 回溯,s
最后入栈。 - 所以最终栈中为 t → … → s,倒序后得到 s → … → t,即完整的欧拉路径。
对于欧拉回路
- 欧拉回路所有节点入度 = 出度,路径终点应与起点一致。
- 从起点
s
出发,DFS 遍历所有边,最终回到s
,此时s
是最后回溯入栈的节点。 - 所以栈中为 s → … → s,倒序后仍得到完整的回路路径。
小结
无论是欧拉路径还是回路,DFS 终止节点最早入栈,起点最晚入栈, 因此栈倒序即为正向路径。
性质二:“遇末端压栈”是反向路径构造的关键
每次 DFS 尽量深入,直到某节点无未访问的出边,称之为“当前末端节点”:
- 若首次遇末端的是全局终点
t
,自然作为路径终点; - 若是路径中的中继节点,说明它所有出边都已被走完(“被清空”),此时压入栈中表示这一段路径已完成。
这种构造方式具备如下特征:
当前遇末端的节点,总是当前剩余图中某一条子路径的“终点”。 在将其压入栈中后,相当于“剪掉”这段子路径。
之后如果从主路径上其他节点重新开始 DFS, 会构造新的子路径,并拼接到先前路径中。
因此,“遇末端压栈”其实是不断将路径段按回溯顺序压入结果栈的过程, 最终反转即得完整路径。
性质三:拼接子路径等价于延伸覆盖所有边
Hierholzer 算法通过主路径与子路径的拼接,实现对所有边的覆盖。
举例来说,设第一次 DFS 构建出主路径 A → B → C → D
,但 B
节点仍有未访问的回路 B → E → F → B
。此时:
- 算法沿主路径回溯,将
D
和C
压入栈; - 回溯至
B
时,发现其仍有出边,于是开始新一轮 DFS,从B → E → F → B
; - 再次回到
B
时,出边已清空,开始回溯并将B
、F
、E
、B
入栈; - 最后主路径起点
A
入栈。
此时栈中为 D, C, B, F, E, B, A
,反转后得到完整欧拉路径:
A → B → E → F → B → C → D
这种拼接操作,实际上等价于在主路径上发现新回路时,将其原地插入主路径中。
由于每条边都只访问一次,且 DFS 时就被删除,整个过程保证不重不漏,构造出完整欧拉路径。
欧拉路径 vs 欧拉回路:算法是否都有效?
是的,Hierholzer 算法同样适用于欧拉路径和欧拉回路,差别仅在起点选择与终点处理方式。
类型 | 起点选择 | 路径终止位置 | DFS 首次遇末端 | 栈顺序 |
---|---|---|---|---|
欧拉路径 | out(s) = in(s)+1 |
终点 t |
最早入栈 | t ... s |
欧拉回路 | 任一非孤立点 | 回到起点 | 起点最后入栈 | s ... s |
因此,无需区分处理流程,只需正确选择起点即可通用。
附录:为何 Hierholzer 算法高效?
相比暴力回溯,Hierholzer 算法不仅更快,而且更结构化。以 “LeetCode 332. 重新安排行程” 为例,其高效性来源于以下几点:
1. 避免无效回溯树枝
暴力回溯枚举所有可能路径,会产生许多“失效树枝”,例如提前走到终点导致图剩余部分不可达。而 Hierholzer 只要图满足欧拉条件,就从起点不断延展,遇末端就收集路径,避免了枚举与试错。
2. 反向收集,不做猜测
Hierholzer 不会预先构造路径,而是通过“走到底、再回溯”的方式按序收集节点,这种反向构造避免了状态回滚,天然符合栈式操作逻辑。
3. 利用结构性假设:有解就唯一覆盖
欧拉图有解意味着存在一条覆盖所有边的路径或回路,Hierholzer 算法正是以此为前提, 一步步剪掉路径段,最后组合为全局解,无需重复判断。
4. 时间复杂度为 O(E)
每条边最多访问和删除一次,每个节点最多压入栈一次,整体运行在线性时间内。
因此,Hierholzer 算法是典型的“结构特化”算法:它不追求普适性,而是深度利用欧拉图的特性,从而实现常规回溯难以匹敌的效率。
将 n 个皇后放置在 n×n 的棋盘上,使皇后彼此之间不能攻击(任意两个皇后不能处于同一行、同一列或同一对角线上)。
返回所有可能的解决方案。
1 | const backtrack = (row) => { |
编写一个程序,通过填充空格来解决给定的数独问题。
数独的解法需满足:
- 数字 1-9 在每一行只能出现一次。
- 数字 1-9 在每一列只能出现一次。
- 数字 1-9 在每一个 3×3 宫格内只能出现一次。
1 | var solveSudoku = function (board) { |
总结:回溯算法
回溯是递归的副产品,只要有递归就会有回溯,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。
优化回溯算法只有剪枝一种方法。