Shortest Paths — Dijkstra and Friends¶
Dijkstra inherits from: BFS + a greedy choice + a priority queue. Data structure: graph + min-heap। Thinking tweak: সবসময় cheapest known path আগে expand করো।
Inheritance-টা, খুলে বলা¶
Dijkstra-র দুই-তৃতীয়াংশ তোমার হাতে আগে থেকেই আছে:
Dijkstra = BFS (explore outward from a source)
+ greedy choice (process the CHEAPEST frontier node next)
+ priority queue (the heap from ../08-heap-priority-queue/
serves "cheapest" in O(log n))
BFS unweighted graph-এ কাজ করে কারণ তার queue ঘটনাক্রমে nodes-কে distance order-এ ছাড়ে — প্রতিটা edge-এর cost 1, তাই "first discovered" মানেই "cheapest"। Weights যোগ করো, সেই ভাগ্য মরে যায়: মোট cost 3-এর একটা 2-edge route, cost 10-এর একটা 1-edge route-কে হারাতে পারে, কিন্তু plain BFS 1-edge route-টাকেই আগে মুকুট পরিয়ে দিত।
মেরামতটা একটাই substitution: FIFO queue-টা বদলে path cost-এ key করা একটা min-heap বসাও। এখন পরে যে node ছাড়া হয় সে আগে queue হওয়া node না — সে হলো smallest known total cost-এর node। সেই একই "first release is final" theorem ফিরে আসে — hop count-এর বদলে cost-এর জন্য।
Thinking tweak-টা, গল্পের আকারে¶
তুমি তোমার home city থেকে travel time map করছ। তোমার কাছে আছে একটা frontier: "যেসব candidate route-এর কথা আমি জানি, তাদের total time সহ।" Greedy move-টা: সবসময় cheapest candidate route-টা আগে দেখো।
এটা নিরাপদ কেন? ধরো cheapest candidate বলছে "city X, total 7 ঘণ্টা।" X-এ কি কোনো undiscovered route এর চেয়ে দ্রুত হতে পারে? তাকে অন্য কোনো candidate route ধরে বেরোতে হতো — কিন্তু বাকি প্রতিটা candidate-এর cost এখনই ≥ 7 ঘণ্টা, আর extra edges শুধু আরও যোগ করে (weights non-negative)। তাই 7-ই final। X-কে stamp করো, তার outgoing edges relax করো, repeat।
ওই শেষ clause-টা — extra edges শুধু আরও যোগ করে — হলো load-bearing assumption। মনে রেখো; নিচে এটাই ভাঙে।
Algorithm-টা¶
import heapq
def dijkstra(adj, start):
"""adj: {u: [(v, w), ...]} with weights w >= 0.
Returns dict of final shortest costs from start."""
dist = {start: 0}
done = set() # nodes with FINAL distances
heap = [(0, start)] # (cost so far, node)
while heap:
cost, node = heapq.heappop(heap) # cheapest known route anywhere
if node in done:
continue # stale heap entry: skip (lazy deletion!)
done.add(node) # this cost is now provably final
for nxt, w in adj[node]:
new_cost = cost + w
if nxt not in dist or new_cost < dist[nxt]:
dist[nxt] = new_cost # found a better route: "relax"
heapq.heappush(heap, (new_cost, nxt))
return dist
../08-heap-priority-queue/patterns.md-এর lazy deletion trick-টা খেয়াল করো: কোনো node-এ better route পাওয়া গেলে আমরা তার পুরোনো heap entry শিকার করতে যাই না — একটা নতুন, সস্তা entry push করি আর stale-টাকে পরে ভেসে উঠতে দিই, যেখানে if node in done: continue তাকে ফেলে দেয়। দুটো heap pattern, হুবহু reuse করা।
Binary heap সহ complexity: O((V + E) log V) — প্রতিটা edge একবার push করতে পারে, প্রতিটা push/pop একটা log দেয়।
একটা ছোট্ট dry run¶
(4)
A -------- B
| /|
(1)| (1) |(5)
| / |
C -------- D
(8)
From A: heap=[(0,A)]
pop (0,A): final A=0. push (4,B) (1,C). heap: (1,C) (4,B)
pop (1,C): final C=1. push (1+1=2,B) (1+8=9,D). heap: (2,B) (4,B) (9,D)
pop (2,B): final B=2 <- the 2-edge route A-C-B beat the direct A-B (4)!
push (2+5=7,D). heap: (4,B) (7,D) (9,D)
pop (4,B): B already done -> stale, skip.
pop (7,D): final D=7.
pop (9,D): stale, skip. Result: A=0 C=1 B=2 D=7
যে frame-এ stale (4,B) skip হলো, সেটাই পুরো lazy-deletion idea — এক লাইনে।
Negative edges কেন এটা ভেঙে দেয়¶
Dijkstra-র safety proof দাঁড়িয়ে ছিল এর উপর: path extend করলে সেটা কখনো সস্তা হতে পারে না। একটা negative edge ঠিক সেটাই — এমন এক extension যা path-কে সস্তা করে দেয় — তাই proof-ও, algorithm-ও, ভেঙে পড়ে।
Concrete failure:
Dijkstra cost 2-তে B pop করে তাকে FINAL stamp মেরে দেয় — greedily, আত্মবিশ্বাসে, ভুলভাবে। C-র মধ্য দিয়ে সস্তা route-টা (cost 1) পরে discover হয়, কিন্তু B already done, আর কখনো re-examine হয় না। ওই greedy stamp-টাই bug: negative edge থাকলে, "cheapest so far" আর "cheapest forever" না।
(Common interview follow-up: "সব weight positive করতে শুধু একটা constant যোগ করলেই তো হয়?" না — সেটা বেশি-edge-ওয়ালা route-গুলোকে অসমভাবে penalize করে, কোন route cheapest সেটাই বদলে দেয়।)
Bellman-Ford, ধৈর্যশীল বিকল্পটা (সংক্ষিপ্ত sketch)¶
Bellman-Ford greed পুরোপুরি ছেড়ে দেয়। সে শুধু প্রতিটা edge relax করে, পরপর V−1 বার:
def bellman_ford(n, edges, start): # edges: list of (u, v, w)
INF = float("inf")
dist = [INF] * n
dist[start] = 0
for _ in range(n - 1): # longest simple path has n-1 edges
for u, v, w in edges:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w # relax
# one MORE full pass: anything still improving sits on a negative cycle
for u, v, w in edges:
if dist[u] + w < dist[v]:
return None # negative cycle reachable: no answer
return dist
কাজ করে কেন: pass k-এর পরে, ≤ k edges ব্যবহার করা প্রতিটা shortest path সঠিক; কোনো shortest path-এর V−1-এর বেশি edge লাগে না। Cost: O(V·E) — Dijkstra-র চেয়ে অনেক ধীর, কিন্তু negative edges-এ সমস্যা নেই, আর সে negative cycles detect করে (যেখানে "shortest"-এর আর কোনো মানে থাকে না, কারণ চিরকাল loop করলে cost কমতেই থাকে)। K-pass structure-টা "cheapest with at most k hops" problem-ও solve করে, যেমন Cheapest Flights Within K Stops।
কোন algorithm কখন — মুখস্থ করার table¶
| Situation | Algorithm | Queue-like structure | Cost |
|---|---|---|---|
| Unweighted (every edge = 1) | BFS | plain queue (deque) | O(V + E) |
| Weights are only 0 or 1 | 0-1 BFS | deque: 0-edges push FRONT, 1-edges push BACK | O(V + E) |
| Non-negative weights | Dijkstra | min-heap | O((V + E) log V) |
| Negative weights possible | Bellman-Ford | none (just edge passes) | O(V · E) |
| Negative cycle detection | Bellman-Ford (extra pass) | none | O(V · E) |
| All-pairs, small dense graph | Floyd-Warshall (later topic) | none (triple loop) | O(V^3) |
0-1 BFS row-টা তার এক-লাইনের intuition পাওয়ার দাবিদার: মাত্র দুটো edge cost থাকলে একটা deque নিজেকে free-তে sorted রাখতে পারে — 0-edge cost বাড়ায় না, তাই তার endpoint current node-এর মতোই urgent (push front); 1-edge-এর endpoint এক ধাপ পরে অপেক্ষা করে (push back)। BFS-এর দামে পুরো heap-ছাড়া Dijkstra behavior।
Interview-এ জোরে বলার মতো progression: queue → deque → heap। BFS → 0-1 BFS → Dijkstra। প্রতিটা step আরও fancy "who's next?" structure-এর বিনিময়ে generality কেনে।
Common mistakes (সাধারণ ভুল)¶
- Negative weights-এ Dijkstra চালিয়ে output বিশ্বাস করা (সে দিব্যি চলে — নিঃশব্দে ভুল)।
(cost, node)-এর বদলে(node, cost)push করা — heap তখন node id দিয়ে sort করে। Tuple-এ priority-টা আগে আসতেই হবে।if node in done: continueলাইনটা বাদ দেওয়া — correctness সাধারণত টিকে যায়, কিন্তু stale entry গুলো পুরো re-expand হয় আর running time খারাপ হয়।- BFS-ধাঁচে push-এর সময় nodes-কে done mark করা। Dijkstra-তে কোনো node-এর প্রথম push-এ তার final cost না-ও থাকতে পারে; শুধু প্রথম pop-এ থাকে।
- Distance "update" করতে heap পুনর্নির্মাণ করা — শুধু improved entry-টা push করো (বাকিটা lazy deletion সামলায়)।
- BFS-ই যথেষ্ট (unweighted) এমন জায়গায় Dijkstra ব্যবহার করা — সঠিক, কিন্তু একটা অপ্রয়োজনীয় log factor আর ভুল করার মতো বেশি code।
Trigger words¶
"Cheapest", "fastest", "minimum cost/time/effort", "network delay", edges-এ weights, "probability of success on a path" (একটা product maximize করো — probabilities-এর max-heap-এ Dijkstra), "minimum possible maximum along a path" (minimax — Dijkstra variant, যেমন Swim in Rising Water)।
Representative problems¶
- Network Delay Time — textbook Dijkstra; answer হলো largest finished distance।
- CSES Shortest Routes I — competitive-programming scale-এ Dijkstra; lazy-skip লাইনটা এখানে বাধ্যতামূলক।
- Path With Minimum Effort — grid-এ Dijkstra, sum-এর বদলে maximum step minimize করা।
- Cheapest Flights Within K Stops — hop limit তোমাকে Bellman-Ford-এর pass structure-এর দিকে ঠেলে দেয়।
- Swim in Rising Water — grid-এ minimax Dijkstra।
Proof সহ reference treatment: cp-algorithms Dijkstra। Dry run সহ working code থাকে implementation.py-তে। এই folder-এর theory-র শেষ stop: topological-sort.md।