本文将对检测循环依赖这一面试大热题型进行介绍,并且重点介绍两种方法拓扑排序和三色标记法解决此类问题,并且将图扩展到了无向图下如何判断环路问题,包括一些鲜为人知的并查集。本文代码基于golang语言实现,所有代码经过LeetCode 210检测,通过全部测试用例。
原题描述
检测循环依赖可以抽象为一般问题,即在一个有向图中,是否存在环路。比如对于如下有向图,是存在环路的。

方法一、拓扑排序
思路简述
拓扑排序是解决此类问题相当经典的方法。拓扑排序的过程是,不断寻找入度为零的顶点,入度为零意味着该节点不依赖其他节点,然后将该节点从图中去除加入到拓扑排序队列中,然后由于该节点及其连接的边的去除,又会有一些节点入度变为零,重复这一个过程,找到找不到入度为零的节点。如果最后拓扑排序队列的长度等于节点个数,那么说明该图无环,且该拓扑排序队列就是在循环依赖场景下可行的执行路径,否则说明有环。
代码实现
// Graph定义图的抽象接口
type Graph interface {
PointCounts() int // 返回图中点的个数
EdgeCounts() int // 返回图中边的个数
IsDirect() bool // 返回图是否是一个有向图
HasCycle() (bool, []int) // 判断图中是否存在环路,当不存在环路时,输出一个可行的输出队列
}
type MatrixGraph struct {
matrix [][]int
edgeCount int
isDirect bool
}
func NewMatrixGraph(edges [][]int, isDirect bool) Graph {
return &MatrixGraph{} // TODO 待实现
}
func (g *MatrixGraph) PointCounts() int {
return len(g.matrix)
}
func (g *MatrixGraph) EdgeCounts() int {
return g.edgeCount
}
func (g *MatrixGraph) IsDirect() bool {
return g.isDirect
}
func (g *MatrixGraph) HasCycle() (bool, []int) {
return false, nil
}
type AdjTableNode struct {
Point int
Cost int
}
type AdjTable struct {
tab []*LinkedList[AdjTableNode]
edgeCount int
isDirect bool
edges [][]int
}
func NewAdjTable(edges [][]int, isDirect bool, n int) Graph {
tab := make([]*LinkedList[AdjTableNode], n)
for _, edge := range edges {
a, b := edge[0], edge[1]
c := 1
if len(edge) > 2 { // 兼容有权图以及无权图
c = edge[2]
}
var insertNode func(a, b, c int) = func(a, b, c int) {
if tab[a] == nil {
tab[a] = NewLinkedList([]AdjTableNode{{b, c}})
} else {
tab[a].InsertNode(AdjTableNode{b, c})
}
}
insertNode(a, b, c)
if !isDirect { // 兼容无向图
insertNode(b, a, c)
}
}
edgeCount := len(edges)
if !isDirect {
edgeCount *= 2
}
return &AdjTable{tab: tab, edgeCount: edgeCount, isDirect: isDirect, edges: edges}
}
func (g *AdjTable) PointCounts() int {
return len(g.tab)
}
func (g *AdjTable) EdgeCounts() int {
return g.edgeCount
}
func (g *AdjTable) IsDirect() bool {
return g.isDirect
}
func (g *AdjTable) HasCycle() (bool, []int) {
if g.isDirect {
return g.hasCycleDFS()
}
return g.hasCycleUnionSet(), nil
}
func (g *AdjTable) topoSort() (bool, []int) {
n := g.PointCounts()
// 首先统计各个节点的入度
inDegrees := make([]int, n)
for i := 0; i < n; i++ {
if g.tab[i] == nil {
continue
}
node := g.tab[i].Head
for node != nil {
p, _ := node.Val.Point, node.Val.Cost
inDegrees[p]++
node = node.Next
}
}
// 初始化队列,收集入度为零的节点
deque := NewList[int]()
for i := 0; i < n; i++ {
if inDegrees[i] == 0 {
deque.AppendTail(i)
}
}
// 开始拓扑排序
topoSorted := make([]int, 0)
for !deque.IsEmpty() {
node := deque.RemoveHead()
topoSorted = append(topoSorted, node)
if g.tab[node] == nil {
continue
}
head := g.tab[node].Head
for head != nil {
p, _ := head.Val.Point, head.Val.Cost
inDegrees[p]--
if inDegrees[p] == 0 {
deque.AppendTail(p)
}
head = head.Next
}
}
return len(topoSorted) != n, topoSorted
}
在实现上稍微抽象了一些接口,由于代码里面依赖了一些笔者自己实现的库,需要引用,可以参考Gulc。
复杂度分析
- 时间复杂度: O ( M + N ) O(M+N) O(M+N),其中M和N分别是图的边数和点数
- 空间复杂度: O ( M + N ) O(M+N) O(M+N)
方法二、三色标记法
思路简述
还可以基于深度优先搜索实现的三色标记法,所有节点初始为白色,如果被访问,则被染色为灰色,如果其邻居节点全部访问完,其染色为黑色,顺着深度优先搜索,如果准备访问的节点是一个灰色节点,说明图中有环。并且还可以根据被染色为黑色节点构造出拓扑排序序列,具体的思路就是越早被染色为黑色的节点在拓扑排序序列中越后面,所以可以在深度优先搜索的时候将这些黑色节点压入一个栈中。
这里也大概解释一下为什么用三色标记,而不是传统的是否被访问这样的两个状态,考虑下图,按照深度优先搜索,节点B在访问节点C之前是被访问过的,于是在深度优先搜索节点C中,发现节点C被访问过,此时能说明图中有环吗?

代码实现
func (g *AdjTable) hasCycleDFS() (bool, []int) {
n := g.PointCounts()
colors := make([]int, n) // 三色标记
hasCycle := false
s := NewStack[int]()
var dfs func(cur, parent int)
dfs = func(cur, parent int) {
if colors[cur] != 0 {
return
}
colors[cur] = 1 // 灰色
if g.tab[cur] == nil {
colors[cur] = 2 // 黑色
s.Push(cur)
return
}
head := g.tab[cur].Head
for head != nil {
p, _ := head.Val.Point, head.Val.Cost
if (g.isDirect|| p != parent) && colors[p] == 1 {
hasCycle = true
return
}
dfs(p, cur)
if hasCycle {
return
}
head = head.Next
}
colors[cur] = 2 // 黑色
s.Push(cur)
}
for i := 0; i < n; i++ {
if colors[i] == 0 { // 未被访问
dfs(i, -1)
if hasCycle {
return hasCycle, nil
}
}
}
topoSorted := make([]int, 0, n)
for !s.IsEmpty() {
topoSorted = append(topoSorted, s.Pop())
}
return hasCycle, topoSorted
}
复杂度分析
- 时间复杂度: O ( M + N ) O(M+N) O(M+N)
- 空间复杂度: O ( M + N ) O(M+N) O(M+N)
无向图扩展
以上两种方法给出的是有向图的判断环路的方法,下面将场景扩展到无向图。对于无向图来说,上面的两种方法同样适用,对于拓扑排序,需要略微修改一下思路,即考虑的是将度为1的节点去除,然后继续检测度为1的节点即可。
除了以上的两种思路,还可以使用并查集来解决此类问题。考虑遍历每一条边,然后发现边的两侧节点已经在同一个集合中,则说明图中存在环路,如果不在一个集合,则将两个节点对应的集合合并在一起。实现代码如下:
func (g *AdjTable) hasCycleUnionSet() bool {
n := g.PointCounts()
unionSet := NewUnionSet(n)
for _, edge := range g.edges {
a, b := edge[0], edge[1]
if unionSet.InSame(a, b) {
return true
}
unionSet.Merge(a, b)
}
return false
}
复杂度分析如下:
- 时间复杂度: O ( M l o g N ) O(MlogN) O(MlogN),其中M和N分别是图中的边数和点数,logN考虑的是并查集理想情况下的查询开销
- 空间复杂度: O ( N ) O(N) O(N)
为什么并查集可以用来检测无向图的环路呢?设想一下,一个无环的无向图本质上可以看成一个森林,对于其中的每一颗树来说,边两侧的节点进行合并是一定不会出现两个节点在同一个集合里的,但是对于一个n环来说,前面的n-1个节点没事,但此时所有节点已经在一个集合中了,此时再考虑一边就正常判断出环路了。
168万+

被折叠的 条评论
为什么被折叠?



