零: 为什么要写这个blog
这题是一个hard的题目,这题我自己的算法复杂度是很吓人的,因为涉及到遍历,我使用的DFS深度优先搜索算法,然后我的depth和node相关,原文要求100000个node,我的DFS在600个节点的时候已经Time out了,我的具体做法大家可以看我的问题初探
但是这个题目的正解也是dfs,但是复杂度居然是O(E)的,我觉得里面有一个非常牛而且很巧妙的思考,所以我写下了这个blog
注意:这个算法的核心是Hierholzer算法,如果你已经非常了解,可以跳过这个blog,如果你也跟我一样刚刚接触,我有一个自己对于这个算法逻辑的总结,大家可以一起讨论下
一、题目分析
1. 问题描述
- 给定一组数对pairs,每个数对[a,b]表示一条从a到b的有向边
- 要求重新排列这些数对,使得每个数对的第二个数字等于下一个数对的第一个数字
- 返回任意一个合法的重排序列
注意题目确认了一定存在一个这样的解
2. 问题特点
输入:pairs = [[5,1],[4,5],[11,9],[9,4]]
输出:[[11,9],[9,4],[4,5],[5,1]]
二、解题思路
原文是一个数列的问题,但是既然考虑到前后的逻辑关系,我们将其转变成图论的问题,每一个pair是一条边,对应两个Node。
1. 关键观察
- 每个数对代表一条有向边
- 要求找到一条通路,走完所有边恰好一次
- 符合欧拉通路的特征(欧拉通路:一笔遍历了所有的边)
2.初步思考
每个节点入度是可以到这个点的有向边的数量,出度是从这个节点出去的边的数量,既然题目报保证了是欧拉通路,那么我们可以简单的保证最多只有两个点的出度不等于入度,则很容易找到起点,要么是唯一的起点,要么是一个欧拉环路,可以随便找一个起点
理论上来说,我们可以认为是一个主环带着无数个子环,以及子环的子环,正确的欧拉路径的逻辑是,每次都先走更深层次的子环,但是问题是我们怎么知道一个点的出入边哪个是主的路径,哪个是子的路径
举个例子:节点如下
[[1,2],[2,3],[3,4],[4,1],[3,5],[5,6],[6,7],[7,3]]
显然1是入口,主环是1-2-3-4-1,3-5-6-7-3是子环,正确的路径就是走到3的时候去走567的路径,但是问题是你其实并不知道5和4到底谁是子环
3.算法初探
理论上来说,欧拉可以认为是从任意一个节点拉通的任意一条环,所以我开始了我的算法思路
- 首先从起点出发,如果有分支那么我们选一个节点加入,然后DFS,如果不可以,则回溯。结果:肉眼可见的超时了
- 然后经过分析发现超时是因为长度太长,depth有点高,那么我们把没有分支的节点不进行递归,而是直接加入,这里涉及到很多的出度入度的计算和回溯来保证,依然超时了,因为很多节点加入然后回溯依然很耗时
- 我发现可以把所有的出度和入度为1的节点,全部缩减成一条边,那么大大的减少了回溯的层数,每次把一个节点加入到路径后,我们就遍历当前出度和入度为1的边,然后将他们和start和end合并,然后保存整条路径,依然需要回溯,解决了600的问题,显然无法解决的10w节点的问题
- 到这个时候,python代码超过200行了,已经我已经很难往下思考了,去看了很多人的解法包括欧拉的一些算法当然涉及到Hierholzer算法,为了给大家一点我感受到的震撼,我直接放这个代码的最核心的代码
4. Hierholzer算法核心
def dfs(node):
while graph[node]:
next_node = graph[node].pop()
dfs(next_node)
stack.append(node)
node是我们选出来的起点节点,graph放的是这个node的邻接边,如果你初看这个算法也是一个DFS的算法平平无奇,你仔细看:这个DFS是不回溯的,换句话说,这个算的复杂度是O(E),E是边的数量
如果你已经对Hierholzer算法和熟悉了,那这篇文章你可以跳过了。
5. 算法框架
整体的算法框架和实现
class Solution:
def validArrangement(self, pairs: List[List[int]]) -> List[List[int]]:
# 1. 建图
graph = defaultdict(list)
in_degree = defaultdict(int)
out_degree = defaultdict(int)
# 2. 找起点
for u, v in pairs:
graph[u].append(v)
out_degree[u] += 1
in_degree[v] += 1
start = pairs[0][0]
for node in graph:
if out_degree[node] - in_degree[node] == 1:
start = node
break
# 3. Hierholzer算法
stack = []
def dfs(node):
while graph[node]:
next_node = graph[node].pop()
dfs(next_node)
stack.append(node)
dfs(start)
#回溯stack
stack = stack[::-1]
# 4. 构建结果
return [[stack[i], stack[i+1]] for i in range(len(stack)-1)]
三、算法详解
如果你对这个算法其实也还有一点疑问,我们可以一起来讨论这个事情。
初看这个算法我是很懵的,我一直在问,凭什么?凭什么我这么努力的做回溯的时候,这个算法就可以直接访问下去了。
这个算法有很多个说法
1:所有的路径都是对的,因为是环
2:所有的路径都会自我调整,因为是环
显然这个依然没法解释我的疑问,如何选择优先选择子环的问题,虽然都是环,怎么保证选到主环在后呢?
然后从某一个角度我想通了这个算法的核心的原理,再发一下代码
def dfs(node):
while graph[node]:
next_node = graph[node].pop()
dfs(next_node)
stack.append(node)
注意代码逻辑while(只要有边),就dfs到下一层。
问题来了这个DFS,什么时候会返回?算法的核心逻辑在于:因为dfs的压栈,真正最开始能返回的一定是出口的那条路径(主环)。换句话说:栈头一定是出口,经过最后的反转,出口一定在队尾。
一个反问:凭什么最后的是主环?逻辑就是,因为先走子环最终会回到自己这个节点,而这个时候,主环依然还有路径可以继续往下dfs,就不会执行到while下面的stack.append()操作
这个逻辑进一步展开,还是刚刚的例子:主环是1-2-3-4-1,3-5-6-7-3是子环
DFS,1,2到3的时候,如果是走到5的分支(子环),子环一圈到3,是不会执行stack操作的,因为转了一圈回到3是依然有别的路径可以走的4到1,dfs深度是9,所以压栈的顺序是[1,4,3,7,6,5,3,2,1]。
但是如果在3优先选择了4,必然从end节点1返回到3,此时最大depth深度是5,栈的压栈(返回)路径是[1,4],但是注意这个时候是不会stack.append()加3,因为3的while还在执行,会走向5,显然dfs顺序5,6,7,3,depth =7,到最后的3的时候,已经没有其他的node可以去访问了,所以会立即返回,注意此时的stack已经是[1,4]了,所以变成[1,4,3,7,6,5]此时返回到3的node的while,4,5都已经访问完毕,开始回溯自己[1,4,3,7,6,5,3,2.1]
但是这么拆开理解是比较散的,我依然用我自己的逻辑去理解:
因为dfs的压栈,真正最开始能返回的一定是出口的那条路径(主环),翻转后就是最后执行的路径
如同我们一开始说的,一切时候先走子环,反过来理解就是:一切时候后走主环
1. 算法正确性
- 每条边只访问一次
- 子路径完整性:走到某节点的所有相关路径会被完整处理
四、复杂度分析
1. 时间复杂度
- O(E),E为边数
- 每条边只被访问一次
- 图的构建和结果构建都是O(E),最多加上O(V),节点数量,本质是最多是2*E
2. 空间复杂度
- O(E) 用于存储图
- 递归栈深度通常远小于E
- 因为子路径会形成环而不是长链