Getting Stuck: When Dijkstra Meets Negative Edges
One of the joys (and pains) of computer science is when you take a tool you know, confidently apply it, and then watch everything break down in surprising ways. This happened to me recently when I decided to revisit shortest path algorithms — specifically, Dijkstra’s algorithm — only to get stuck in a swamp of negative edges.
Setting the Scene
The problem was classic:
Given a directed, weighted graph and a source vertex, find the shortest path to all other vertices.
This is the kind of problem that every CS student learns early, usually with Dijkstra’s algorithm. I already knew Dijkstra like an old friend:
-
Keep a priority queue of distances.
-
Always expand the node with the current smallest distance.
-
Relax its edges.
Simple, elegant, efficient (O((V + E) log V) with a binary heap).
So when I started coding a graph utility for my project — a tool to analyze transportation networks — I confidently plugged in Dijkstra.
Attempt 1: Dijkstra on All Graphs
Here’s my initial code:
import heapq
def dijkstra(graph, source):
n = len(graph)
dist = [float('inf')] * n
dist[source] = 0
pq = [(0, source)]
while pq:
d, u = heapq.heappop(pq)
if d > dist[u]:
continue
for v, w in graph[u]:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
heapq.heappush(pq, (dist[v], v))
return dist
The graph was stored as an adjacency list, graph[u] = [(v, weight), ...].
I tested it on a clean, positive-weighted example:
0 --1--> 1 --1--> 2
\ /
--------2---------
Output matched expectations.
Then, like any curious coder, I tried a graph with a negative edge:
0 --4--> 1
0 --5--> 2
1 -- -2 --> 2
The shortest path from 0 to 2 should be 0 → 1 → 2 with cost 2.
But Dijkstra gave me 5.
I stared at the code. No bugs. Dijkstra just… failed.
Red Herring #1: Did I Implement It Wrong?
My first thought was: maybe I had a bug. Maybe I wasn’t handling the priority queue right.
I added debug prints:
print("Relaxing", u, "->", v, "with weight", w)
I watched the updates step by step. And everything checked out.
But the algorithm stubbornly refused to take the negative edge into account properly.
I was stuck — but not yet ready to admit that the problem wasn’t my code.
Red Herring #2: Maybe If I “Fix” the Edge Weights
In frustration, I thought: what if I just shift all weights up by a constant, so none are negative? For example, add +2 to every weight in the graph.
That would turn the previous graph into:
0 --6--> 1
0 --7--> 2
1 --0--> 2
Now there are no negative weights.
I reran Dijkstra… and the result was wrong again.
Why? Because shifting weights changes relative path costs. A path with more edges is disproportionately penalized, even if it was shorter before.
I realized this “fix” was bogus. Dead end.
Researching the Problem
At this point, I did what anyone does when stuck: I started Googling “Dijkstra negative edges.”
The answer was blunt:
Dijkstra’s algorithm only works on graphs with nonnegative edge weights.
The moment negative weights are involved, it can’t guarantee correctness.
I felt both relief (my code was fine) and despair (my tool was useless on real-world graphs where discounts or penalties could introduce negative weights).
So what now?
Attempt 2: Bellman–Ford to the Rescue
The standard alternative is the Bellman–Ford algorithm. Unlike Dijkstra, it doesn’t assume nonnegative weights. It works by relaxing all edges repeatedly — up to V-1 times for V vertices.
Here’s the code I wrote:
def bellman_ford(graph, source):
n = len(graph)
dist = [float('inf')] * n
dist[source] = 0
for _ in range(n-1):
for u in range(n):
for v, w in graph[u]:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
# detect negative cycles
for u in range(n):
for v, w in graph[u]:
if dist[u] + w < dist[v]:
raise ValueError("Negative cycle detected")
return dist
I tested it on the failing graph:
0 --4--> 1
0 --5--> 2
1 -- -2 --> 2
This time, the answer was correct: distance to 2 was 2.
Victory!
But…
Performance Problems
Bellman–Ford runs in O(VE) time. For small graphs, fine. But when I tried it on a real dataset (thousands of nodes, tens of thousands of edges), it crawled.
I had traded correctness for performance.
Red Herring #3: Can I Hybridize?
I wondered: Could I run Dijkstra when weights are nonnegative, and Bellman–Ford only when they’re negative?
Sure. But that meant writing code to detect “negative edges” and switching algorithms on the fly. Worse, what if some subgraph was negative while others weren’t? You’d still need Bellman–Ford globally.
Not elegant.
Attempt 3: Johnson’s Algorithm
Digging deeper, I discovered Johnson’s algorithm. This one was a revelation.
The trick is:
-
Add a dummy vertex connected to all others with edge weight
0. -
Run Bellman–Ford once to compute “potentials.”
-
Reweight edges so all weights become nonnegative.
-
Run Dijkstra from each node.
This way, you combine the correctness of Bellman–Ford with the speed of Dijkstra.
I sketched a rough implementation:
def johnson(graph):
n = len(graph)
# Step 1: add dummy vertex
new_graph = graph + [[]]
dummy = n
for u in range(n):
new_graph[dummy].append((u, 0))
# Step 2: Bellman-Ford from dummy
h = bellman_ford(new_graph, dummy)
# Step 3: reweight edges
reweighted = [[] for _ in range(n)]
for u in range(n):
for v, w in graph[u]:
reweighted[u].append((v, w + h[u] - h[v]))
# Step 4: Dijkstra for each source
dist_all = []
for u in range(n):
dist = dijkstra(reweighted, u)
# fix back weights
dist = [d + h[v] - h[u] if d < float('inf') else d for v, d in enumerate(dist)]
dist_all.append(dist)
return dist_all
Now, shortest paths for large graphs with negative edges became feasible again.
Reflection
This entire journey taught me several lessons:
-
Algorithms come with assumptions.
Dijkstra is brilliant, but only when all weights are nonnegative. I had overlooked that critical detail. -
Dead ends matter.
My attempts to “shift weights” or hybridize were wrong, but exploring them deepened my intuition about why negative edges are tricky. -
There’s always a bigger hammer.
Bellman–Ford was the first fix, Johnson’s algorithm the ultimate one. Sometimes you need to layer algorithms instead of searching for a single magical solution. -
Getting stuck is the best way to learn.
I’ll never forget now why Dijkstra fails with negative edges. That confusion, the red herrings, the frustration — they carved the lesson deeper than any lecture slide could.
Comments
Post a Comment