LeeCode 解题报告:2097. Valid Arrangement of Pairs

零: 为什么要写这个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
  • 因为子路径会形成环而不是长链
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值