[CodeChef] Starters 93 — Thank U, Next: dijkstra on energy graph
Published 5 September 2026
Problem: CodeChef — Thank U, Next (Starters 93, difficulty 2268)
This one looks like a plain reachability question and turns into a nice little lesson about what
Dijkstra actually needs to be Dijkstra. I solved it, my solution passed, and then I spent longer
than I’d like to admit arguing with myself about a single line of the relaxation step — the
e - 1 > best[w] check. Is it load-bearing or is it decoration? The answer turned out to be
“neither, exactly”, and chasing it down led me to Dial’s algorithm, which removes the heap from
this problem entirely.
The problem¶
You get an undirected graph with \(N\) nodes and \(M\) edges, and \(K\) mail carriers. Carrier \(i\) starts on node \(X_i\) and can deliver to any node at distance at most \(D_i\) from \(X_i\).
The statement defines distance in a slightly unusual way: it’s the number of nodes on the
shortest path, not the number of edges. So two nodes joined by a single edge are at distance
\(2\), and a node is at distance \(1\) from itself. Output YES if every node in the graph gets
mail from somebody, NO otherwise.
Constraints are the usual multi-test shape — \(T\) up to \(10^5\), with \(N\) and \(M\) each up to \(10^5\) per test and, crucially, the sums of \(N\) and of \(M\) across all tests also capped at \(10^5\). Remember that last part; it comes back later.
Energy, not distance¶
The node-counting definition of distance is annoying to reason about directly, so the first thing I did was reframe it. Give each carrier a tank of fuel. Carrier \(i\) starts at \(X_i\) holding \(D_i\) units. Crossing one edge burns one unit. A node is covered if some carrier can arrive there with at least \(1\) unit still in the tank.
Check it against the definition: the carrier sits on \(X_i\) with \(D_i \ge 1\), so its own node is covered. A neighbour costs one unit and is reached with \(D_i - 1\), so it’s covered exactly when \(D_i \ge 2\) — which is what “distance \(2\)” means here. The reframing is exact, and now everything is in units of edges, which is how I’d rather think.
So the quantity to compute for every node \(v\) is
the most fuel anyone can still have on arrival, and the answer is YES iff every node has
\(\text{best}[v] \ge 1\).
I like this framing because it turns \(K\) separate range queries into a single global quantity. Nobody cares which carrier covers a node, only that somebody does, and the maximum is exactly the thing that’s easiest to propagate.
Why plain multi-source BFS is wrong¶
The obvious first move is multi-source BFS: throw all the carriers into a queue, propagate outwards, mark visited nodes as you go. That fails, and the failure mode is worth naming because it’s the exact reason this problem is rated where it is.
Ordinary multi-source BFS is correct when all sources are equivalent — every source starts at distance \(0\) and every node’s first visit is its best visit. Here the sources are not equivalent. A carrier with \(D = 2\) might reach node \(v\) first and mark it visited with one unit of fuel left, blocking a carrier with \(D = 50\) that arrives a step later with \(47\) units to spare. The visited flag throws away the fuel that would have kept the search going.
The fix is not to visit nodes in the order they’re reached but in the order of how much fuel they’re reached with — richest first. Once you say that out loud you’ve described Dijkstra, run on maximum rather than minimum. The values decrease monotonically along every edge, so the greedy argument survives intact: when the fullest tank in the queue comes out, nothing still in the queue can ever improve it.
The Dijkstra¶
Seed best[X_i] = D_i for each carrier — taking the max when two carriers share a node — put
every seeded node into a max-heap, and run:
import heapq
best = [0] * (n + 1)
for x, d in carriers:
best[x] = max(best[x], d)
heap = [(-best[v], v) for v in range(1, n + 1) if best[v]]
heapq.heapify(heap)
seen = bytearray(n + 1)
covered = 0
while heap:
ne, v = heapq.heappop(heap)
if seen[v]:
continue
seen[v] = 1
covered += 1
e = -ne
if e > 1: # nothing left to give a neighbour
for w in adj[v]:
if not seen[w] and e - 1 > best[w]:
best[w] = e - 1
heapq.heappush(heap, (-(e - 1), w))
print("YES" if covered == n else "NO")
heapq is a min-heap, so the energies go in negated. There’s no decrease-key, so I use the
standard lazy-deletion trick: push a new entry every time a node improves and discard stale pops
with the seen[v] guard at the top of the loop.
Does the e - 1 > best[w] check matter?¶
This is the line I got stuck on. What happens if I write the relaxation the lazy way instead —
skip already-finalised neighbours and push everything else, no comparison against best[w]?
The honest answer is that this is still correct. It passes. I submitted it and it got AC.
That surprised me at first, and then it didn’t. Correctness in this loop comes entirely from the
seen[v] guard on pop, not from anything on push. Extra entries in the heap are stale by
construction: they carry an energy no better than the one that finalised the node, so when they
surface they hit seen[v] and get thrown away. You can push as much garbage as you like and the
output doesn’t move. What moves is the size of the heap, and that’s a complexity question, not a
style question.
What it costs¶
Without the check, node \(w\) receives one push from every neighbour that gets finalised before it, so the total number of pushes is \(\sum_w \deg(w) = 2M\). With the check, a push into \(w\) only happens when it strictly improves \(\text{best}[w]\), and improvements form a strictly increasing sequence bounded above by \(B = \max_i D_i\). So the bound becomes \(\min(\deg(w), B)\) per node.
On the shapes where \(\deg\) is small those two bounds are the same number and the check buys nothing. On dense graphs they diverge hard. I measured it, running both versions on the same adjacency lists:
| graph | with check | without check |
|---|---|---|
| random tree, \(N = 10^5\) | 100,000 pushes, 0.09 s | 100,000 pushes, 0.07 s |
| path, \(N = 10^5\), one source | 100,000 pushes, 0.02 s | 100,000 pushes, 0.02 s |
| \(N = 10^3\), \(M = 10^5\), 200 sources | 1,163 pushes, 0.005 s | 100,185 pushes, 0.07 s |
The dense row is the whole argument: 1,163 pushes against 100,185, and a 14× difference in wall clock. Every one of those extra 99,000 entries costs a \(\log\) on the way in and a \(\log\) on the way out, to be discarded on arrival.
The tree row is the honest counterweight. There the check is pure overhead — an extra array read and an extra array write on every edge, buying nothing, and it shows up as a consistent 30% slowdown. If you knew your graphs were always sparse you could drop it and win a little.
The part that isn’t about speed¶
There’s a second cost to dropping the check, and it’s the one that actually decided it for me.
In the unguarded version best[] is never written to during the search. It keeps whatever the
seeding step put there and nothing else. That’s fine for this problem, where the only question is
YES or NO and all the real state lives in seen. But the array’s name is now a lie — it’s
initial_carrier_fuel, not best. Anything that reads it mid-search, and any variant of the
problem that asks for the actual fuel remaining at each node, quietly gets garbage.
So the check stays. It costs one array read, which is cheaper than the \(\log N\) push it usually avoids, and it keeps the invariant that makes this code legible as Dijkstra rather than as “a BFS that happens to work.”
Why the lazy version survives here anyway¶
Worth being precise about why the unguarded submission passes, because it’s a property of the constraints and not of the code. The sum of \(M\) over all test cases is capped at \(10^5\), so the unguarded bound of \(2M\) pushes is \(2 \times 10^5\) across the entire input. The heap can’t blow up because the input isn’t allowed to make it. Lift that cap, or move to a problem where edges can be relaxed repeatedly, and the same code degrades.
An optimisation that didn’t work¶
With the heap settled I went looking for the next thing, and the obvious suspect was
which allocates \(N\) list objects, once per test case, with \(T\) up to \(10^5\). That smells expensive. The standard replacement is a flat CSR adjacency — one counting pass for the degrees, a prefix sum for the row starts, one pass to fill — so that the entire graph is three flat integer arrays and nothing per-node gets allocated at all:
deg = [0] * (n + 2)
for i in range(m):
deg[us[i]] += 1
deg[vs[i]] += 1
head = [0] * (n + 2)
s = 0
for i in range(1, n + 1):
head[i] = s
s += deg[i]
head[n + 1] = s
pos = head[:]
adj = [0] * (2 * m)
for i in range(m):
u, v = us[i], vs[i]
adj[pos[u]] = v; pos[u] += 1
adj[pos[v]] = u; pos[v] += 1
Neighbours of v are then the slice adj[head[v]:head[v+1]], or a bare index range if you want
to skip the slice allocation too.
It made things slower. On a worst-case input of \(10^5\) two-node test cases the list-of-lists build ran in 0.11 s and CSR in 0.13 s; on a single \(N = M = 10^5\) graph it was 0.13 s against 0.15 s. Consistently, in both directions.
In hindsight it’s obvious why. CSR trades allocations for three Python-level passes over the edges where the naive build does one, and in CPython an interpreted loop iteration costs far more than a small list allocation. CSR is a real win in C++, where the loop is free and the cache behaviour is the whole story. Here the cost model is inverted, and I’d been optimising someone else’s machine. Worth knowing before you rewrite a working graph build.
Dial’s algorithm: throwing the heap away¶
Here’s the thing that had been nagging at me. Every edge in this graph has weight \(1\). Dijkstra with a heap costs \(O((N + M)\log N)\), and the \(\log\) exists to sort keys that can be arbitrary reals. Mine can’t. They’re integers in \([1, B]\), and they only ever move by one.
That’s exactly the situation Dial’s algorithm was invented for. Robert Dial published it in 1969,
working on transportation networks where travel times were small integers. The observation is
simple: if edge weights are integers bounded by \(C\), then at any moment during Dijkstra every
key sitting in the queue lies in a window of width \(C\) above the current minimum. So you don’t
need a heap at all — you need \(C + 1\) buckets, reused cyclically, indexed by key. Insert is
append. Decrease-key is a splice. Extract-min is “walk forward to the next non-empty bucket”.
Total cost \(O(M + N \cdot C)\), with no logarithms anywhere.
The sanity check that the idea is sound: set \(C = 1\) and it degenerates into ordinary BFS, which is exactly what BFS is — Dijkstra where the bucket structure is a single queue.
Where it turns up in practice:
- 0-1 BFS. Weights are only \(0\) or \(1\), so the bucket array collapses to two buckets, which is just a deque — push-front for a zero edge, push-back for a one edge. Most competitive programmers know this trick without knowing it’s Dial’s. It’s the standard answer for “minimum number of edges to flip”, or grids where some moves are free.
- Small integer weights generally. Road networks with times rounded to seconds; anything where the weight alphabet is tiny.
- Min-cost flow and Johnson’s algorithm. After reweighting, the costs are small nonnegative integers, and Dial’s is a standard inner-loop choice.
The descendant worth knowing about is the radix heap — buckets with exponentially growing widths, giving \(O(M + N \log C)\), which removes the dependence on \(C\) being tiny. Thorup’s \(O(M)\) undirected shortest paths is where that line of work ends up.
This problem is the easy case¶
Our graph has uniform weight \(1\) plus a one-time non-uniform offset at the sources — carrier \(i\) enters the process at level \(B - D_i\). Since all those offsets are known before the search starts, they can be placed into buckets up front, and no cyclic wraparound is ever needed: relaxation from level \(t\) only ever writes into level \(t + 1\).
Which means the whole bucket array collapses to two lists and a pointer into the sources sorted by fuel:
seeds = sorted(((best[v], v) for v in range(1, n + 1) if best[v]), reverse=True)
seen = bytearray(n + 1)
covered = 0
i, cur = 0, []
e = seeds[0][0] if seeds else 0
while e >= 1:
while i < len(seeds) and seeds[i][0] == e: # carriers joining at this level
cur.append(seeds[i][1])
i += 1
nxt = []
for v in cur:
if seen[v]:
continue
seen[v] = 1
covered += 1
if e > 1:
for w in adj[v]:
if not seen[w] and e - 1 > best[w]:
best[w] = e - 1
nxt.append(w)
cur = nxt
if not cur:
if i >= len(seeds):
break
e = seeds[i][0] # jump straight to the next carrier's level
else:
e -= 1
It reads like a BFS with staggered start times, which is precisely what it is. No heap, no
negation, \(O(N + M)\) after the seed sort. The if not cur branch matters: without it, a test
with \(B = 10^5\) and two nodes would spin through 100,000 empty levels one at a time. Clamping
\(D_i\) to \(N\) at read time is worth doing for the same reason — no path visits more than
\(N\) nodes, so fuel beyond that is unspendable.
Measured against the heap version on the same graphs:
| graph | heap | level sweep |
|---|---|---|
| path, \(N = 10^5\) | 0.017 s | 0.015 s |
| random tree, \(N = 10^5\) | 0.069 s | 0.031 s |
| \(N = 10^3\), \(M = 10^5\), 200 sources | 0.006 s | 0.005 s |
Better everywhere, and a bit more than 2× on the tree, which is the shape where the heap has the most nodes to shuffle.
The one shape where it loses is the degenerate multi-test case — \(10^5\) graphs of two nodes
each, where the per-test sorted() over the seeds is most of the work and there’s no search to
speak of. There the heap version came in at 0.13 s and the level sweep at 0.16 s. Not enough to
matter against a 1.5 s limit, but it’s the reminder that the seed sort isn’t free and the
\(O(N + M)\) only kicks in once there’s an actual graph to walk.
One thing I tried and would skip next time: the textbook version with a real array of \(B + 1\) buckets. On the path test that allocates 100,001 lists to hold 100,000 nodes and came out at 0.039 s — slower than the heap it was supposed to beat. The two-list version has the same asymptotics without the allocation, and that difference is the entire story in CPython.
Takeaways¶
- When sources aren’t equivalent — different budgets, different start times, different offsets — multi-source BFS is wrong and multi-source Dijkstra is right. The tell is a visited flag that discards information the later arrival would have needed.
- Correctness in lazy-deletion Dijkstra lives in the
seenguard on pop. Everything on the push side is about queue size. Knowing which is which stops you from being scared of the wrong line. - But keep the relaxation guard anyway. It’s near-free, it’s the difference between \(\deg(w)\)
and \(\min(\deg(w), B)\) pushes on dense inputs, and it’s what keeps
best[]honest. - If your keys are bounded small integers and increase monotonically, you don’t need a heap. That’s 0-1 BFS, that’s this problem, and that’s Dial’s algorithm — worth recognising by shape rather than by name.