LeetCode 2608 - Shortest Cycle in a Graph
The problem gives us an undirected graph with n vertices labeled from 0 to n - 1. The graph is represented using an edge list, where each edge connects two vertices in both directions. Our task is to find the length of the shortest cycle in the graph.
Difficulty: 🔴 Hard
Topics: Breadth-First Search, Graph Theory
Solution
Problem Understanding
The problem gives us an undirected graph with n vertices labeled from 0 to n - 1. The graph is represented using an edge list, where each edge connects two vertices in both directions.
Our task is to find the length of the shortest cycle in the graph. A cycle is a path that starts and ends at the same vertex without reusing any edge. Since the graph is undirected, traversing an edge from u to v is the same as traversing from v to u.
The input consists of:
- An integer
n, the number of vertices. - A list
edges, where each element[u, v]represents an undirected edge.
The output should be:
- The length of the shortest cycle if at least one cycle exists.
-1if the graph is acyclic.
The constraints are relatively small:
n <= 1000edges.length <= 1000
These constraints strongly suggest that graph traversal algorithms such as Breadth-First Search (BFS) are feasible even if repeated multiple times.
Several important observations come from the problem guarantees:
- No self-loops exist, so cycles of length
1are impossible. - No duplicate edges exist, so cycles of length
2are also impossible. - The graph may be disconnected, so we must consider all connected components.
- The shortest cycle could exist entirely inside one component while others are trees.
Important edge cases include:
- A graph with no cycles at all.
- Multiple disconnected components.
- Multiple cycles with different lengths.
- Dense local structures where several cycles overlap.
- Very small graphs where a cycle cannot possibly exist.
Approaches
Brute Force Approach
A naive solution would attempt to enumerate all possible cycles in the graph and track the minimum length encountered.
One possible brute-force strategy is:
- Start a DFS from every node.
- Explore all possible paths.
- Detect when a path returns to the starting node.
- Record cycle lengths.
This approach is correct because it eventually explores every possible cycle. However, it is extremely inefficient because the number of simple paths in a graph grows exponentially. Even with pruning, the repeated exploration becomes prohibitively expensive.
For graphs with up to 1000 vertices, exhaustive cycle enumeration is not practical.
Key Insight
The important observation is that BFS naturally finds shortest paths in unweighted graphs.
Suppose we run BFS from a starting node start.
During BFS, if we encounter a previously visited neighbor that is not the parent of the current node, then we have found a cycle.
Why does this work?
- BFS explores nodes level by level.
- Therefore, the first time we reconnect through a non-parent edge, we can compute a valid cycle length using the distances already discovered.
For a cycle detected between nodes u and v:
cycle_length = dist[u] + dist[v] + 1
This works because:
dist[u]is the shortest distance fromstarttoudist[v]is the shortest distance fromstarttov- The edge
(u, v)closes the cycle
By running BFS from every node, we ensure that every possible shortest cycle is considered.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | Exponential | O(n) | Enumerates all possible cycles |
| Optimal BFS | O(n × (n + e)) | O(n + e) | Runs BFS from every node and detects cycles efficiently |
Here:
nis the number of verticeseis the number of edges
Since both are at most 1000, the BFS solution is efficient enough.
Algorithm Walkthrough
Optimal BFS Algorithm
- Build an adjacency list for the graph.
Since the graph is undirected, every edge [u, v] is added in both directions.
2. Initialize a variable shortest_cycle to infinity.
This variable keeps track of the minimum cycle length found across all BFS traversals. 3. Run BFS starting from every node.
We do this because the shortest cycle may not involve every node, and disconnected components are possible. 4. For each BFS traversal, maintain:
- A
distancearray initialized to-1 - A
parentarray initialized to-1 - A queue for BFS
The distance array stores shortest distances from the starting node.
The parent array prevents falsely detecting the immediate reverse edge as a cycle.
5. Start BFS from the current source node.
Set:
distance[start] = 0
Then push the node into the queue. 6. Process nodes level by level.
For each current node u, examine every neighbor v.
7. If v has not been visited yet:
- Set its distance
- Record its parent
- Push it into the queue
- Otherwise, if
vis already visited andvis not the parent ofu:
We found a cycle.
Compute:
cycle_length = distance[u] + distance[v] + 1
Then update the global minimum. 9. After all BFS traversals finish:
- Return the minimum cycle length found
- If no cycle was discovered, return
-1
Why it works
BFS guarantees shortest path distances in unweighted graphs. When BFS encounters an already visited node that is not the parent, the current edge closes a cycle.
The computed cycle length correctly represents the number of edges in that cycle because:
- One shortest path connects the source to
u - Another shortest path connects the source to
v - The edge
(u, v)completes the loop
Running BFS from every node guarantees that the globally shortest cycle is eventually discovered.
Python Solution
from collections import deque
from typing import List
class Solution:
def findShortestCycle(self, n: int, edges: List[List[int]]) -> int:
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
shortest_cycle = float("inf")
for start in range(n):
distance = [-1] * n
parent = [-1] * n
queue = deque([start])
distance[start] = 0
while queue:
current = queue.popleft()
for neighbor in graph[current]:
if distance[neighbor] == -1:
distance[neighbor] = distance[current] + 1
parent[neighbor] = current
queue.append(neighbor)
elif parent[current] != neighbor:
cycle_length = (
distance[current]
+ distance[neighbor]
+ 1
)
shortest_cycle = min(
shortest_cycle,
cycle_length
)
return shortest_cycle if shortest_cycle != float("inf") else -1
Implementation Explanation
The first step constructs the adjacency list representation of the graph. Since the graph is undirected, each edge is inserted twice.
The outer loop runs BFS from every node. This guarantees that every possible cycle can be detected.
Inside each BFS:
distance[node]stores the shortest number of edges from the starting node.parent[node]stores the node from which we first reached that node.
The parent tracking is critical. In an undirected graph, every edge appears twice. Without checking the parent relationship, the traversal would incorrectly interpret the reverse traversal edge as a cycle.
When we encounter a previously visited neighbor that is not the parent, we compute the cycle length using:
distance[current] + distance[neighbor] + 1
This formula counts:
- The shortest path from the start to
current - The shortest path from the start to
neighbor - The connecting edge between them
Finally, if no cycle was found, the answer remains infinity and we return -1.
Go Solution
package main
import "container/list"
func findShortestCycle(n int, edges [][]int) int {
graph := make([][]int, n)
for _, edge := range edges {
u, v := edge[0], edge[1]
graph[u] = append(graph[u], v)
graph[v] = append(graph[v], u)
}
const INF int = 1 << 30
shortestCycle := INF
for start := 0; start < n; start++ {
distance := make([]int, n)
parent := make([]int, n)
for i := 0; i < n; i++ {
distance[i] = -1
parent[i] = -1
}
queue := list.New()
queue.PushBack(start)
distance[start] = 0
for queue.Len() > 0 {
front := queue.Front()
queue.Remove(front)
current := front.Value.(int)
for _, neighbor := range graph[current] {
if distance[neighbor] == -1 {
distance[neighbor] = distance[current] + 1
parent[neighbor] = current
queue.PushBack(neighbor)
} else if parent[current] != neighbor {
cycleLength := distance[current] + distance[neighbor] + 1
if cycleLength < shortestCycle {
shortestCycle = cycleLength
}
}
}
}
}
if shortestCycle == INF {
return -1
}
return shortestCycle
}
Go-specific Notes
The Go solution mirrors the Python implementation closely.
A few language-specific details are worth noting:
- Go does not provide a built-in deque, so
container/listis used for BFS queue operations. - Arrays are initialized explicitly because Go does not support Python-style list multiplication.
- A large constant
INFis used instead of floating-point infinity. - Slices are used for adjacency lists because they dynamically grow as neighbors are added.
No integer overflow concerns exist because the maximum cycle length is at most n, which is only 1000.
Worked Examples
Example 1
n = 7
edges = [[0,1],[1,2],[2,0],[3,4],[4,5],[5,6],[6,3]]
Graph Structure
Component 1:
0 - 1
\ /
2
Component 2:
3 - 4
| |
6 - 5
BFS Starting from Node 0
Initial state:
| Node | Distance | Parent |
|---|---|---|
| 0 | 0 | -1 |
| 1 | -1 | -1 |
| 2 | -1 | -1 |
Queue:
| Queue |
|---|
| [0] |
Process node 0:
- Visit
1 - Visit
2
Updated state:
| Node | Distance | Parent |
|---|---|---|
| 0 | 0 | -1 |
| 1 | 1 | 0 |
| 2 | 1 | 0 |
Queue:
| Queue |
|---|
| [1, 2] |
Process node 1:
- Neighbor
0is parent, ignore - Neighbor
2already visited and not parent
Cycle found:
distance[1] + distance[2] + 1
= 1 + 1 + 1
= 3
Shortest cycle becomes 3.
No smaller cycle is possible because:
- Self-loops are forbidden
- Duplicate edges are forbidden
Final answer:
3
Example 2
n = 4
edges = [[0,1],[0,2]]
Graph:
1
\
0
/
2
Node 3 is isolated.
Running BFS from every node never encounters a previously visited non-parent neighbor.
Therefore, no cycle exists.
Final answer:
-1
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n × (n + e)) | BFS runs once per node |
| Space | O(n + e) | Adjacency list plus BFS arrays |
The adjacency list requires O(n + e) space because every edge is stored twice in an undirected graph.
Each BFS traversal processes every node and edge once, giving O(n + e) per BFS. Since BFS is executed from every node, the total complexity becomes:
O(n × (n + e))
With the given constraints, this is efficient enough in practice.
Test Cases
from typing import List
class Solution:
def findShortestCycle(self, n: int, edges: List[List[int]]) -> int:
from collections import deque
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
shortest_cycle = float("inf")
for start in range(n):
distance = [-1] * n
parent = [-1] * n
queue = deque([start])
distance[start] = 0
while queue:
current = queue.popleft()
for neighbor in graph[current]:
if distance[neighbor] == -1:
distance[neighbor] = distance[current] + 1
parent[neighbor] = current
queue.append(neighbor)
elif parent[current] != neighbor:
cycle_length = (
distance[current]
+ distance[neighbor]
+ 1
)
shortest_cycle = min(
shortest_cycle,
cycle_length
)
return shortest_cycle if shortest_cycle != float("inf") else -1
solution = Solution()
assert solution.findShortestCycle(
7,
[[0,1],[1,2],[2,0],[3,4],[4,5],[5,6],[6,3]]
) == 3 # triangle cycle
assert solution.findShortestCycle(
4,
[[0,1],[0,2]]
) == -1 # acyclic graph
assert solution.findShortestCycle(
3,
[[0,1],[1,2],[2,0]]
) == 3 # single triangle
assert solution.findShortestCycle(
4,
[[0,1],[1,2],[2,3],[3,0]]
) == 4 # square cycle
assert solution.findShortestCycle(
6,
[[0,1],[1,2],[2,0],[2,3],[3,4],[4,2]]
) == 3 # multiple cycles
assert solution.findShortestCycle(
5,
[[0,1],[1,2],[2,3],[3,4]]
) == -1 # simple path
assert solution.findShortestCycle(
1_000,
[]
) == -1 # empty graph
assert solution.findShortestCycle(
6,
[[0,1],[1,2],[2,3],[3,0],[0,2]]
) == 3 # smaller cycle inside larger cycle
assert solution.findShortestCycle(
8,
[[0,1],[1,2],[2,3],[3,0],[4,5],[5,6],[6,7],[7,4]]
) == 4 # disconnected cyclic components
Test Summary
| Test | Why |
|---|---|
| Triangle graph | Smallest valid cycle |
| Acyclic graph | Ensures -1 is returned |
| Single cycle | Basic correctness |
| Square cycle | Non-triangle cycle |
| Multiple overlapping cycles | Must choose shortest |
| Linear chain | No cycles present |
| Empty edge set | Handles isolated nodes |
| Nested cycles | Detects minimum correctly |
| Multiple disconnected cycles | Works across components |
Edge Cases
Graph With No Cycles
A tree or disconnected forest contains no cycles at all. This is an important edge case because naive implementations may accidentally interpret parent edges as cycles in undirected graphs.
The implementation avoids this by tracking the parent of each node during BFS. When revisiting a node, the algorithm only counts it as a cycle if the neighbor is not the parent.
If no cycle is ever found, the result remains infinity and the function correctly returns -1.
Disconnected Graphs
The graph may contain several disconnected components. Some components may contain cycles while others may not.
A buggy implementation might only run BFS from node 0 and miss cycles in other components.
This solution runs BFS starting from every node, guaranteeing that all components are explored completely.
Overlapping Cycles
Graphs may contain multiple cycles sharing edges or vertices.
For example:
0 - 1
| X |
2 - 3
Such cases are tricky because different traversal orders may discover longer cycles first.
Because BFS always computes shortest distances in unweighted graphs, every detected cycle length is valid, and taking the global minimum guarantees the shortest cycle is returned.