DSA sheet · Graphs · Dijkstra pattern

Dijkstra's Algorithm

This is the first problem of the Dijkstra pattern. The teacher builds the whole algorithm from scratch: start from BFS, throw away the visited array, and swap the normal queue for a min-heap that always gives the closest node first. Every later problem in this pattern (Network Delay Time, Cheapest Flights, Minimum Effort) is this same code with small changes, so this page is the base for all of them. The only thing you need beforehand is the heap data structure (she has a short video on it; Part 0 below covers what we need).

Every problem below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the logic from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember

Part 0 · Before starting

Nodes, edges, directed and undirected

A graph is a set of nodes (also called vertices, the circles) joined by edges (the lines). Nodes are usually numbered 0 to V-1, where V is the number of nodes. E is the number of edges.

Weighted edges

In a weighted graph every edge carries a number, its weight: the cost, the time or the length of that road. The edge [u, v, w] means "a road between u and v that costs w". In an undirected graph the same w is used in both directions: if 0 to 1 costs 4, then 1 to 0 also costs 4.

      4
 (0)—————(1)       the road between 0 and 1 costs 4,
                   in both directions (undirected)

The length of a path is the sum of its edge weights, not the number of edges. So a path with 3 cheap edges can be shorter than a path with 1 expensive edge. This one fact is why we need a new algorithm.

Reading the input and building an adjacency list

The question gives the graph as an edge list: a list of [u, v, w] triples. Looking through the whole list every time we want "the neighbours of node 3" would be slow. So first we build an adjacency list: for every node, a small list of (neighbour, weight) pairs. (The other storage, an adjacency matrix, is a V×V table where cell [u][v] holds the weight. It takes V² space, far too much for big V, so we don't use it here.)

edge list → adjacency list (undirected, weighted)
V = 3
edges = [[0, 1, 1], [1, 2, 3], [0, 2, 6]]

graph = [[] for _ in range(V)]       # one empty list per node
for u, v, w in edges:
    graph[u].append((v, w))          # u can go to v, cost w
    graph[v].append((u, w))          # undirected: v can also go to u, cost w

print(graph)   # [[(1, 1), (2, 6)], [(0, 1), (2, 3)], [(1, 3), (0, 6)]]
Doubt: why [[] for _ in range(V)] and not [[]] * V?
→ [[]] * V makes V references to the same list, so adding a neighbour to node 0 would add it to every node. The list comprehension makes V separate lists.

BFS, the visited array, and shortest paths when every edge costs 1

BFS (Breadth First Search) uses a queue (collections.deque): put the start node in, then keep taking a node from the front and adding its unseen neighbours to the back. It spreads out layer by layer: first everything 1 edge away, then 2 edges away, and so on. A visited set stops us from adding the same node twice (otherwise a cycle would loop forever).

When every edge has weight 1, "fewest edges" and "shortest distance" are the same thing, so the BFS layer of a node is its shortest distance. That's why BFS solved the unweighted shortest-path problems earlier in this playlist (Shortest Path in Binary Matrix, Rotting Oranges, Word Ladder).

BFS distances when every edge costs 1 (the old tool)
from collections import deque

def bfs_dist(graph, src):
    dist = [-1] * len(graph)        # -1 = not reached yet (also acts as visited)
    dist[src] = 0
    queue = deque([src])
    while queue:
        node = queue.popleft()
        for nei in graph[node]:
            if dist[nei] == -1:     # first time we see it = fewest edges
                dist[nei] = dist[node] + 1
                queue.append(nei)
    return dist

print(bfs_dist([[1, 2], [0, 2], [0, 1, 3], [2]], 0))   # [0, 1, 1, 2]

With weights, "fewest edges" is no longer "cheapest". Part B shows exactly how this breaks. Dijkstra fixes it.

The heap (priority queue) and Python's heapq

A min-heap is a container that always hands you its smallest item first, no matter in what order you put things in. Adding and removing both take O(log n) time. A heap used like this is also called a priority queue: a queue where the item with the best priority (here, the smallest number) jumps to the front.

In Python, heapq turns a normal list into a min-heap:

heapq in 6 lines
import heapq

pq = []
heapq.heappush(pq, (8, 2))      # (distance, node)
heapq.heappush(pq, (4, 1))
heapq.heappush(pq, (10, 4))
print(heapq.heappop(pq))        # (4, 1)  smallest distance comes out first
print(heapq.heappop(pq))        # (8, 2)
Doubt: the teacher's Java writes a comparator a[0] - b[0]. What's the Python version?
→ Nothing to write. Putting the distance first in the tuple does the same job, because tuples are compared from the left. If you wrote (node, distance) by mistake, the heap would sort by node number, which is useless.
Doubt: Python has only a min-heap. What if I ever need the largest first?
→ Push the negative: heappush(pq, (-value, x)) and negate it again when you pop. Not needed in this problem.

Part A · Dijkstra's algorithm

GeeksforGeeks · Dijkstra Algorithm

1The question in simple words

You get an undirected, weighted graph with V nodes numbered 0 to V-1, and a list edges where each [u, v, w] is a road between u and v of length w (usable both ways). You also get a source node src.

Return a list dist where dist[i] = the shortest distance from src to node i. The distance from src to itself is 0.

The teacher's picture (she uses it for the whole video):

        4          6
  (0)——————(1)——————(4)
   |                  |
  8|                  |5
   |                  |
  (2)———————————————(3)
            10

She first reads it with source = 4:

targetbest route from 4shortest distancewhy not the other way?
4stay0no move needed
14→164→3→2→0→1 = 5+10+8+4 = 27
04→1→0104→3→2→0 = 5+10+8 = 23
34→35the long way round is 6+4+8+10 = 28
24→3→2154→1→0→2 = 6+4+8 = 18

So the answer for source 4 is [10, 6, 15, 5, 0] (index = node). There are always two ways round this ring, and we must pick the cheaper one for every node.

There is one source, so this is a single-source shortest path problem (not multi-source like Rotting Oranges). And just like with a tree's root or a linked list's head, a graph has no natural "start", so the question must tell us where to begin: that's src.

2What the constraints tell us

3Intuition: a ripple that spreads in order of distance

Picture dropping a stone in water at the source. The ripple reaches near places first and far places later. Dijkstra copies that: always expand the node that is currently closest to the source, then use it to improve the guesses for its neighbours.

We keep two things:

It's BFS, except the queue is replaced by a min-heap, and there's no visited array.

4Building the logic from her examples

Question 1: where do we start? → the source, with distance 0

The source is given. Its distance to itself is 0, because we don't move at all. So dist[src] = 0 and the first heap entry is (0, src).

Question 2: BFS or DFS?

The question wants the distance to every node, so it is a traversal: we must visit all of them. Both DFS and BFS can traverse. The teacher picks BFS because this is a shortest-path question, and BFS (spreading outward) is the natural shape for shortest paths. With weights, BFS needs one upgrade (the heap), which we'll reach in Question 4.

Question 3: do we need a visited array? → No!

In every graph problem so far we kept a visited array so we'd never enter a node twice. Here that is wrong. Her example, with source 0:

  path A: 0 → 2 → 3          = 8 + 10     = 18
  path B: 0 → 1 → 4 → 3      = 4 + 6 + 5  = 15

Suppose we happen to reach node 3 through path A first, with 18, and mark it visited. Later path B arrives with 15, which is better. With a visited array we would refuse to enter 3 again and keep the wrong 18.

It's not just node 3. If 3 had a further neighbour, say node 6 with an edge of 1, then 6 should get 15+1 = 16, not 18+1 = 19. A better distance at one node has to flow onward to everything after it. So we must be allowed to visit a node again whenever we arrive with a smaller distance.

Rule 1: replace "visited" with "is this better?"We enter a node again only if the new distance is smaller than the one saved in dist. Arriving with 18 when 15 is saved → ignore it. Arriving with 15 when 18 is saved → overwrite and continue from there.

Question 4: which neighbour first? → the cheapest one (greedy) → min-heap

From 0 there are two neighbours: 1 (cost 4) and 2 (cost 8). Which should we handle first? We can't see the future: maybe the roads after 1 are expensive and the roads after 2 are cheap. But the greedy choice (take what looks best right now) is to continue from the node with the smallest distance so far, which is 1 with 4.

A normal queue gives nodes in the order they were added, not by distance. A min-heap ordered by distance gives the smallest one every time. That's the only real change from BFS.

Doubt: why is "take the smallest first" safe? Couldn't a bigger offer later turn out better?
→ Not for the node we just popped. Say the smallest offer in the heap is (d, x). Any other route to x must start from some other offer in the heap, which is already ≥ d, and then add more edges, each with weight ≥ 0. So it can only be ≥ d. Nothing can ever beat d for x. This is why non-negative weights are required: one negative edge could make a later route cheaper, and the greedy choice would be wrong.
Doubt: the teacher says "if a question asked for the longest distance, use a max-heap". Is that right?
→ Careful, no. The argument above works because adding edges can only make a path longer. When you want the longest path, adding edges makes it better, so the first time you pop a node proves nothing. Longest simple path in a general graph has no fast algorithm at all. Use a max-heap only where the problem's own "best" really behaves like a minimum (for example "maximum probability path", where multiplying by numbers ≤ 1 can only make things worse).

Question 5: what do we start dist with? → infinity

Every update is "keep the smaller of the old value and the new one". For the very first real distance to win, the starting value must be bigger than anything: infinity (float('inf') in Python; the teacher's Java uses Integer.MAX_VALUE). Then min(inf, 4) = 4 and the 4 is saved. The source is the only exception: dist[src] = 0.

Question 6: what goes into the heap? → (distance, node)

Question 7: what happens for each neighbour? → new distance, compare, push

When we pop (d, node) and look at an edge node → nei of weight w, the distance to nei through node is d + w: everything up to the parent, plus this one road.

Her example: we pop node 1 with distance 4. Its neighbour 0 would get 4 + 4 = 8. But dist[0] = 0 already. Going 0→1→0 and then onward can never beat just starting from 0, so we stop there. Her words: going to 2 straight from 0 is cheaper than 1→0→2.

Question 8: the stale entry → skip it

A node can sit in the heap more than once. In her example node 3 is pushed first with 18 (from 2), then again with 15 (from 4). The heap gives (15, 3) first; we use it. Later (18, 3) comes out too, but dist[3] is already 15. This old entry is called stale (out of date).

Rule 2: skip stale entriesif d > dist[node]: continue
The popped distance is worse than what we already know, so everything we could build from it is also worse. Skip it and pop the next one.
Doubt: why does a stale entry even exist? Can't we just change 18 to 15 inside the heap?
→ heapq has no "decrease this item" operation. Finding (18, 3) inside the heap would take O(size) time. It is much cheaper to just push a new (15, 3) and ignore the old one when it comes out. That's why the check is needed.
Doubt: what if I forget the stale check? Wrong answer?
→ No, still the right answer, because the d + w < dist[nei] check refuses every push a stale entry tries to make (it would offer 18 + w when 15 + w is already there). But you waste time scanning neighbours again. On big graphs with many stale entries that can mean TLE. Keep it.
Doubt: in her board walkthrough she writes into dist when a node is popped, but in the code she writes when it is pushed. Which one?
→ Use the code version (write when pushing). Then dist always holds the best offer so far, and the comparison d + w < dist[nei] stops worse offers from even entering the heap. (She even catches herself on the board: "I did not fill here when we popped".) If you only wrote on pop, you would need a "done" check instead and would push more junk into the heap. Both versions give the same final answers.

Question 9: what do we return? → the dist list

When the heap is empty, every node's best distance is in dist. Her Java copies it into an ArrayList because GFG wants that type. In Python the list itself is the answer.

Doubt: what if a node can't be reached at all?
→ Its slot stays infinity. This GFG problem's graphs are connected, so it doesn't come up, but Network Delay Time (next page) is all about this case.

5Approach steps

  1. Build the adjacency list from edges, adding each edge both ways.
  2. Make dist of size V filled with infinity; set dist[src] = 0.
  3. Make a min-heap with one entry (0, src).
  4. While the heap is not empty: pop the smallest (d, node).
  5. If d > dist[node], it's stale: continue.
  6. For each (nei, w) of node: nd = d + w. If nd < dist[nei], set dist[nei] = nd and push (nd, nei).
  7. Return dist.

6Code (Python)

Dijkstra with heapq
import heapq

class Solution:
    def dijkstra(self, V, edges, src):
        graph = [[] for _ in range(V)]
        for u, v, w in edges:
            graph[u].append((v, w))
            graph[v].append((u, w))         # undirected: both directions

        dist = [float('inf')] * V
        dist[src] = 0
        pq = [(0, src)]                     # (distance, node)

        while pq:
            d, node = heapq.heappop(pq)     # closest offer first
            if d > dist[node]:              # stale entry, a better one was used
                continue
            for nei, w in graph[node]:
                nd = d + w                  # distance to nei through node
                if nd < dist[nei]:          # only if it improves
                    dist[nei] = nd
                    heapq.heappush(pq, (nd, nei))
        return dist

7Code line by line

linewhat it means
graph = [[] for _ in range(V)]One empty neighbour list per node.
graph[u].append((v, w)) graph[v].append((u, w))Store each road from both ends, because the graph is undirected. Each entry is (where it goes, what it costs).
dist = [float('inf')] * V"Unknown so far". Infinity loses every < comparison, so the first real distance always gets saved.
dist[src] = 0 pq = [(0, src)]The source costs nothing to reach. It is the first offer in the heap. (A one-item list is already a valid heap.)
d, node = heapq.heappop(pq)Take the offer with the smallest distance. This is where Dijkstra differs from BFS's popleft().
if d > dist[node]: continueA better offer for this node was already used. This one is old: skip it.
for nei, w in graph[node]:Look at every road leaving this node.
nd = d + wDistance to the neighbour = distance to here + this road.
if nd < dist[nei]:The "visited" replacement: continue only if this route is strictly better.
dist[nei] = nd heapq.heappush(pq, (nd, nei))Save the better distance and add the offer so the neighbour's own roads get explored later.
return distEvery slot now holds the shortest distance from src.

8Dry run: the heap and dist after every pop

Her picture, source = 0. Adjacency list (in the order the edges are read): 0: (1,4) (2,8) · 1: (0,4) (4,6) · 2: (0,8) (3,10) · 3: (2,10) (4,5) · 4: (1,6) (3,5). dist is written as [node0, node1, node2, node3, node4], ∞ = infinity.

Start: dist = [0, ∞, ∞, ∞, ∞], heap = [(0,0)].

poppopped (d, node)neighbours checkedheap AFTERdist AFTERnote
1(0, 0)1: 0+4=4 < ∞ ✓ push
2: 0+8=8 < ∞ ✓ push
(4,1) (8,2)[0, 4, 8, ∞, ∞]both neighbours improved
2(4, 1)0: 4+4=8 < 0? no
4: 4+6=10 < ∞ ✓ push
(8,2) (10,4)[0, 4, 8, ∞, 10]going back to 0 is pointless
3(8, 2)0: 16 < 0? no
3: 8+10=18 < ∞ ✓ push
(10,4) (18,3)[0, 4, 8, 18, 10]3 gets a first guess of 18
4(10, 4)1: 16 < 4? no
3: 10+5=15 < 18 ✓ push
(15,3) (18,3)[0, 4, 8, 15, 10]3 improved: two entries for node 3 now
5(15, 3)2: 25 < 8? no
4: 20 < 10? no
(18,3)[0, 4, 8, 15, 10]15 = dist[3], a real entry
6(18, 3)none: 18 > 15 → stale, skipempty[0, 4, 8, 15, 10]loop ends

Answer: [0, 4, 8, 15, 10]. Node 3 is reached by 0→1→4→3 (4+6+5 = 15), not by 0→2→3 (18).

after pop 3 (3 has guess 18)
  [0]——4——[4]——6——[10]
   |                 |
   8                 5
   |                 |
  [8]——————10——————[18]
after pop 4 (3 improved to 15)
  [0]——4——[4]——6——[10]
   |                 |
   8                 5
   |                 |
  [8]——————10——————[15]
end
  node : 0  1  2  3   4
  dist : 0  4  8  15  10

Each box shows the node's current dist value, drawn at the node's position in the picture (top row: 0, 1, 4; bottom row: 2, 3).

Notice the pop order: distances 0, 4, 8, 10, 15. They come out in increasing order. That's the ripple from step 3, and it's why each node's first real pop is final.

A second quick check (GFG example)

V = 3, edges [[0,1,1],[1,2,3],[0,2,6]], src = 2. Pop (0,2): node 1 gets 3, node 0 gets 6. Pop (3,1): node 0 gets 3+1 = 4 < 6, push (4,0). Pop (4,0): nothing improves. Pop (6,0): stale (6 > 4), skip. Answer [4, 3, 0]: going 2→1→0 (3+1) beats the direct road 2→0 (6).

9Complexity & remember

Remember DijkstraBFS + min-heap of (dist, node) + no visited array.
dist = ∞ everywhere, dist[src] = 0 · pop smallest · stale? skip · relax each edge: if d + w < dist[nei] → save and push.
Works only with non-negative weights.

Her checklist for every Dijkstra question: ① build the adjacency list, ② decide how to travel (BFS), ③ set up the priority queue and decide min-heap or max-heap, ④ decide which value the heap is ordered by (here the distance, not the node), ⑤ know what to return.


Part B · Why plain BFS fails with weights

The teacher says "we go with BFS", but the BFS she builds has a heap and no visited array. It's worth seeing why the ordinary BFS from earlier videos can't be used as it is. (This part is extra explanation, written to answer the obvious doubt.)

1The question

Same question as Part A. We just try to answer it with the old tool: a normal queue plus a visited array, setting each node's distance the first time we see it.

2What the constraints tell us

Weights can be anything ≥ 0, and they can be very different from each other (1 next to 10). That's exactly what breaks BFS: BFS only counts edges.

3Intuition

BFS spreads by number of roads. Dijkstra spreads by total length. If one long road reaches a place in 1 step and three short roads reach it in 3 steps, BFS meets the place first through the long road and, because of visited, never looks again.

4The counterexample

           10
  (0)———————————(1)
    \           /
    1\         /1
      \       /
        (2)

From 0: the direct road to 1 costs 10, but 0→2→1 costs 1 + 1 = 2.

stepplain BFS (queue + visited)queue afterdist after
1pop 0 → see 1 (0+10) and 2 (0+1), mark both visited1, 2[0, 10, 1]
2pop 1 → neighbours 0, 2 already visited2[0, 10, 1]
3pop 2 → 1 would be 1+1 = 2, but 1 is visited, skippedempty[0, 10, 1]

Plain BFS answers 10 for node 1. The true answer is 2. Wrong.

What if I keep BFS but drop visited and allow updates?

Then you get a correct answer (each improvement re-queues the node), but a node can be improved and re-queued many times, and every time all its successors get redone too. On bad graphs that grows far beyond V + E. The heap prevents this: because nodes come out in order of distance, a node's first real pop is already final, and the only repeats are the cheap stale entries we skip.

Doubt: when is plain BFS still fine?
→ When every edge has the same weight (all 1, or all some c). Then "fewer edges" really means "shorter", and the BFS order is the distance order. With equal weights Dijkstra and BFS give the same numbers; Dijkstra just pays an extra log.

5Approach steps of the broken version

  1. Queue with src, dist[src] = 0, src marked visited.
  2. Pop a node; for every unvisited neighbour set dist = dist[node] + w, mark visited, push.
  3. Return dist. The first distance found is never improved.

6Code (Python), kept here to show the bug

WRONG for weighted graphs: plain BFS with visited
from collections import deque

def plain_bfs_dist(V, edges, src):
    graph = [[] for _ in range(V)]
    for u, v, w in edges:
        graph[u].append((v, w))
        graph[v].append((u, w))
    dist = [float('inf')] * V
    dist[src] = 0
    visited = [False] * V
    visited[src] = True
    queue = deque([src])
    while queue:
        node = queue.popleft()
        for nei, w in graph[node]:
            if not visited[nei]:            # BUG: first arrival is locked in
                visited[nei] = True
                dist[nei] = dist[node] + w
                queue.append(nei)
    return dist

print(plain_bfs_dist(3, [[0, 1, 10], [0, 2, 1], [2, 1, 1]], 0))   # [0, 10, 1]  (wrong: node 1 should be 2)

7Code line by line: the two lines that differ from Dijkstra

plain BFSDijkstrawhy it matters
node = queue.popleft()d, node = heappop(pq)Oldest-added vs closest. Only "closest" guarantees the node's distance is final.
if not visited[nei]:if nd < dist[nei]:"Never again" vs "again whenever better".

8Dry run

Done in the table in step 4: node 1 is locked at 10 on the very first pop, and the cheaper route through 2 arrives too late. Run Dijkstra on the same graph: pop (0,0) → push (10,1), (1,2); pop (1,2) → 1 improves to 2, push (2,1); pop (2,1) → final; pop (10,1) → stale, skip. Answer [0, 2, 1] ✓.

9Complexity & remember

Plain BFS is O(V + E), faster than Dijkstra, but it's simply wrong on weighted graphs, so its speed doesn't matter.

RememberBFS = shortest by number of edges. Dijkstra = shortest by sum of weights. Equal weights → BFS is enough. Different weights → heap.

Part C · Revision page

BFS (unweighted)Dijkstra (weighted, ≥ 0)
containerdeque, first-in first-outheapq min-heap of (dist, node)
stops repeats withvisited arraynd < dist[nei] + stale skip
a node can be in the containeronceseveral times (old copies are stale)
dist starts as-1 / ∞∞, and 0 for src
timeO(V + E)O((V + E) log V)
breaks whenedges have different weightsan edge weight is negative
If you remember only 5 lines 1. dist = [∞] * V, dist[src] = 0, heap = [(0, src)].
2. Pop the smallest (d, node). If d > dist[node] → stale, continue.
3. For each (nei, w): nd = d + w; if nd < dist[nei] → dist[nei] = nd, push (nd, nei).
4. No visited array: a node may be improved and pushed again.
5. O((V + E) log V); needs non-negative weights.
Mistakes to avoid ✗ using a visited array (locks in the first, maybe worse, distance)
✗ pushing (node, dist) instead of (dist, node) (heap sorts by node)
✗ adding the edge only one way in an undirected graph
✗ forgetting dist[src] = 0
✗ using [[]] * V for the adjacency list
✗ using Dijkstra when weights can be negative
test it yourself (paste under the Dijkstra solution)
s = Solution()
print(s.dijkstra(5, [[0, 1, 4], [0, 2, 8], [1, 4, 6], [2, 3, 10], [4, 3, 5]], 0))   # [0, 4, 8, 15, 10]
print(s.dijkstra(5, [[0, 1, 4], [0, 2, 8], [1, 4, 6], [2, 3, 10], [4, 3, 5]], 4))   # [10, 6, 15, 5, 0]
print(s.dijkstra(3, [[0, 1, 1], [1, 2, 3], [0, 2, 6]], 2))                          # [4, 3, 0]
print(s.dijkstra(3, [[0, 1, 10], [0, 2, 1], [2, 1, 1]], 0))                         # [0, 2, 1]
print(s.dijkstra(1, [], 0))                                                         # [0]

Based on this video: Dijkstra's Algorithm