Dijkstra - Variations and Applications
Key Points
- •Dijkstra’s algorithm can be adapted to track the second shortest path by keeping the best and second-best distances per vertex.
- •The K-shortest-walks variant stores up to K distances per vertex and uses a priority queue to generate answers in increasing order.
- •Dial’s algorithm replaces the heap with buckets for graphs with small non-negative integer weights, giving linear-time behavior in practice.
- •0-1 BFS is a special, very fast case of Dial’s algorithm when all weights are 0 or 1.
- •A* is Dijkstra guided by a heuristic h(v); with an admissible and consistent heuristic, it finds an optimal path faster in practice.
- •State-augmented Dijkstra runs on (vertex, extra-state) pairs to handle constraints like coupons, parity, or limited resources.
- •All these variants rely on non-negative edge weights to preserve Dijkstra’s greedy correctness.
- •For contest problems, choose the variant that matches structure: small integer weights (Dial/0-1 BFS), need a few best paths , or extra constraints (state graph).
Prerequisites
- →Graph representations (adjacency lists) — All implementations store the graph as adjacency lists to enable efficient edge relaxations.
- →Classic Dijkstra’s algorithm — Each variation builds on the core idea of greedy expansion via a priority mechanism with non-negative weights.
- →Priority queues and heaps — Understanding push/pop and comparators is necessary to maintain the frontier of nodes to explore.
- →Breadth-first search (BFS) — 0-1 BFS generalizes BFS by weighting edges 0 or 1 and using a deque instead of a queue.
- →Greedy algorithms and invariants — Correctness of Dijkstra variants relies on maintaining ordering and relaxation invariants.
- →Basic number theory / metrics — A* heuristics often rely on metric properties like triangle inequality for consistency.
- →Algorithmic complexity analysis — Choosing the right variant depends on understanding time and space trade-offs.
Detailed Explanation
Tap terms for definitions01Overview
Dijkstra’s algorithm is the baseline method for finding single-source shortest paths in non-negative weighted graphs. But many real problems need more than the plain shortest-path answer: you may need the second shortest route, the top K routes, or you may have special graph structure like small integer weights. Sometimes you have constraints that make the notion of a node richer, e.g., you can use a one-time coupon, or must track parity, fuel, or time modulo a period. There is also A*, which accelerates shortest-path search by using a heuristic to prioritize nodes that seem closer to the goal. These variations all preserve the core idea: expand nodes in increasing order of estimated best distance using a priority mechanism. To get the second or Kth path, we allow each vertex to be processed multiple times up to a cap. For small integer weights, we replace a heap with buckets to get near-linear time. For A*, we alter the key to include a heuristic term. For state-augmented problems, we treat each state as its own node and connect them appropriately. In contests, recognizing which variation applies is half the battle. The right choice can turn a TLE into an AC, and a complex DP into a straightforward graph search.
02Intuition & Analogies
Imagine navigating a city. Plain Dijkstra is like expanding neighborhoods in rings of increasing driving time from your starting point. You always explore the closest not-yet-finalized intersection next, guaranteeing that once you fix the best way to an intersection, nothing later can beat it. If you want the second shortest route, think of keeping two best arrival times per intersection: the very best and a backup. As you explore, a newly found way to an intersection might not beat the best route but could still be better than all other backups—store it as the second best. For K shortest, it’s like keeping a top-K leaderboard of arrival times for every intersection. You’re allowed to revisit intersections if the newly found arrival time is among their K best. When all roads are tolls of 0 or 1 dollar, you don’t need a complex priority queue. You can just maintain two queues: free moves to the front, paid moves to the back—this is 0-1 BFS, a turbo version of Dijkstra. If tolls are small integers, you generalize this idea with multiple buckets (Dial’s algorithm), one per cumulative cost modulo the maximum toll. A* adds a hunch (heuristic) about how far each intersection is from the destination—like “as the crow flies” distance. You still expand by best-known time, but you add the hunch to the priority so you chase the destination more aggressively, provided your hunch never overestimates (so you don’t skip the true best). Finally, sometimes the real state is not just intersection X, but (X, coupon-used?), or (X, fuel-left), etc. Then you run Dijkstra on an expanded graph whose nodes are these richer states.
03Formal Definition
04When to Use
Use the second-shortest or K-shortest variants when the problem explicitly asks for alternative routes, reliability analysis, or redundancy (e.g., find two disjoint-ish paths or report the top K arrival times). The K-shortest-walks version is common in contests and outputs non-decreasing path lengths, possibly with repeated vertices; if you need simple (vertex-disjoint) paths, consider Yen’s or Eppstein’s algorithms instead. Use Dial’s algorithm when edge weights are small non-negative integers. It replaces a binary heap with buckets and can achieve linear-time behavior O(|V| + |E| + C) where C is the max weight, which is excellent for constraints like weights in [0, 50] or 0/1 edges (then 0-1 BFS is ideal). Use A* when a good, admissible heuristic is available, such as Euclidean/Manhattan distance in geometric road networks. A* guides the search towards the goal, reducing explored nodes dramatically while preserving optimality. Use state-augmented Dijkstra when the path cost depends on additional resources or conditions—e.g., at most one free edge, limited fuel, parity constraints, passing through checkpoints, or time-of-day effects. Modeling these as (vertex, state) nodes with appropriate transitions nearly always simplifies the reasoning and yields a correct and efficient solution when weights remain non-negative.
⚠️Common Mistakes
• Using Dijkstra with negative weights. Any negative edge breaks the greedy property; use Bellman-Ford or Johnson’s algorithm instead. • Forgetting that K-shortest-walks in the typical Dijkstra-style variant can revisit vertices, so results are walks, not necessarily simple paths. If you need simple paths, use specialized algorithms like Yen/Eppstein. • Mismanaging duplicates in second/K-shortest variants. You must cap how many times a vertex contributes distances (2 for second-shortest or K in general), and ignore pops that exceed that cap. • Implementing Dial’s algorithm with an impractically large number of buckets (like O(C·|V|)) in memory-constrained settings. Prefer 0-1 BFS when C=1, or use a circular-bucket approach carefully; otherwise, a binary heap might be safer. • Using a heuristic in A* that overestimates or is inconsistent, which can break optimality or cause re-expansions. Always ensure h(v) \le true shortest distance to the goal and h(u) \le w(u,v) + h(v). • In state-augmented Dijkstra, forgetting to duplicate vertices for each state, or missing transitions (e.g., failing to connect both coupon and non-coupon edges). Also ensure that the expanded graph’s weights remain non-negative. • Overflowing 32-bit integers on cumulative distances. In C++, prefer long long (int64) for distances.
Key Formulas
Relaxation Inequality
Explanation: This inequality defines the relaxation step: the best-known distance to v cannot exceed going through u and then paying w(u,v). Dijkstra keeps applying this until no improvement is possible.
Dijkstra with Binary Heap
Explanation: For a graph with n vertices and m edges using a binary heap, each push/pop costs O(log n), leading to total O((n + m) log n) time. This is the standard complexity for sparse graphs.
Second-Shortest Invariants
Explanation: Maintain two best distances per vertex. Any new candidate distance updates or so that [v] is the shortest and [v] is the second shortest distinct path length.
K-Shortest-Walks via Dijkstra
Explanation: Each vertex can be finalized up to K times, and each relaxation may cause a heap operation. The extra K factor reflects allowing multiple best arrivals per vertex.
Dial’s Algorithm Time
Explanation: With integer weights in [0, C], replacing the heap with C+1 buckets yields linear dependence on m plus an additive nC. For C=1, this reduces to O(n + m).
A* Priority Function
Explanation: A* selects the next node with minimal f, where g is the cost so far and h estimates the remaining cost. If h is admissible and consistent, A* is optimal and often expands far fewer nodes.
Admissibility and Consistency
Explanation: These conditions ensure A* does not overestimate and respects triangle inequality. They guarantee optimality and reduce or eliminate re-expansions.
State-Augmented Graph Size
Explanation: When augmenting each vertex with |S| states, the new graph has n' vertices and roughly m' edges. Dijkstra then runs in O((n' + m') log n'), so keep |S| small.
Manhattan Heuristic
Explanation: On 4-neighbor grids with unit weights, Manhattan distance never overestimates the true shortest path length, making it an admissible heuristic for A*.
0-1 BFS Complexity
Explanation: Each edge is processed once and each vertex is pushed/popped a constant number of times using a deque, yielding linear time in the size of the graph.
Complexity Analysis
Code Examples
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Computes shortest and second shortest distances from s to all nodes in a directed graph with non-negative weights. 5 // Input format: 6 // n m 7 // u v w (m lines, 1-indexed vertices) 8 // s t 9 // Output: second shortest distance from s to t (or -1 if not exists) 10 11 int main() { 12 ios::sync_with_stdio(false); 13 cin.tie(nullptr); 14 15 int n, m; 16 if (!(cin >> n >> m)) return 0; 17 struct Edge {int to; long long w;}; 18 vector<vector<Edge>> g(n + 1); 19 for (int i = 0; i < m; ++i) { 20 int u, v; long long w; 21 cin >> u >> v >> w; 22 g[u].push_back({v, w}); 23 // If the graph is undirected, also add: g[v].push_back({u, w}); 24 } 25 int s, t; cin >> s >> t; 26 27 const long long INF = (long long)4e18; 28 vector<long long> d1(n + 1, INF), d2(n + 1, INF); 29 using PLL = pair<long long,int>; 30 priority_queue<PLL, vector<PLL>, greater<PLL>> pq; 31 32 d1[s] = 0; 33 pq.push({0, s}); 34 35 while (!pq.empty()) { 36 auto [dist, u] = pq.top(); pq.pop(); 37 // Ignore if this distance is already worse than our recorded second-best 38 if (dist > d2[u]) continue; 39 for (auto [v, w] : g[u]) { 40 long long nd = dist + w; 41 if (nd < d1[v]) { 42 // Found a new best; old best becomes second best 43 d2[v] = d1[v]; 44 d1[v] = nd; 45 pq.push({d1[v], v}); 46 if (d2[v] < INF) pq.push({d2[v], v}); 47 } else if (nd > d1[v] && nd < d2[v]) { 48 // Found a new second-best distinct distance 49 d2[v] = nd; 50 pq.push({d2[v], v}); 51 } 52 } 53 } 54 55 if (d2[t] == INF) cout << -1 << "\n"; 56 else cout << d2[t] << "\n"; 57 return 0; 58 } 59
We keep two distances per node: d1 for the shortest and d2 for the second shortest distinct path length. Each relaxation can update d1 or d2, and we push any improved distances into the priority queue. We ignore popped entries that exceed d2[u] because they cannot lead to improvements. The answer is d2[t].
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Finds K shortest s->t walks (non-negative weights). Outputs K distances in non-decreasing order. 5 // This variant allows revisiting nodes; results are walks, not necessarily simple paths. 6 // Input: 7 // n m k 8 // u v w (m lines, 1-indexed) 9 // s t 10 11 int main() { 12 ios::sync_with_stdio(false); 13 cin.tie(nullptr); 14 15 int n, m, K; cin >> n >> m >> K; 16 struct Edge {int to; long long w;}; 17 vector<vector<Edge>> g(n + 1); 18 for (int i = 0; i < m; ++i) { 19 int u, v; long long w; cin >> u >> v >> w; 20 g[u].push_back({v, w}); 21 // For undirected graphs, also add reverse edges 22 } 23 int s, t; cin >> s >> t; 24 25 using PLL = pair<long long,int>; 26 priority_queue<PLL, vector<PLL>, greater<PLL>> pq; 27 vector<int> cnt(n + 1, 0); // how many times we've finalized node v 28 29 pq.push({0, s}); 30 vector<long long> answers; 31 32 while (!pq.empty() && (int)answers.size() < K) { 33 auto [d, u] = pq.top(); pq.pop(); 34 if (cnt[u] >= K) continue; // already have K best arrivals to u 35 cnt[u]++; 36 if (u == t) { 37 answers.push_back(d); 38 } 39 for (auto e : g[u]) { 40 pq.push({d + e.w, e.to}); 41 } 42 } 43 44 for (size_t i = 0; i < answers.size(); ++i) { 45 cout << answers[i] << (i + 1 == answers.size() ? '\n' : ' '); 46 } 47 // If fewer than K paths exist, fewer numbers will be printed. 48 return 0; 49 } 50
Each time we pop a node u, we count that as one arrival to u. We permit relaxing from u up to K times, ensuring that each vertex contributes at most K arrivals. As soon as we pop t K times, we have the K shortest s→t walk lengths in non-decreasing order.
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Computes shortest paths from s when all edges have weight 0 or 1. 5 // Input: 6 // n m 7 // u v w (w must be 0 or 1) 8 // s 9 // Output: distances from s to all vertices (or -1 if unreachable) 10 11 int main() { 12 ios::sync_with_stdio(false); 13 cin.tie(nullptr); 14 15 int n, m; cin >> n >> m; 16 struct Edge {int to; int w;}; 17 vector<vector<Edge>> g(n + 1); 18 for (int i = 0; i < m; ++i) { 19 int u, v, w; cin >> u >> v >> w; // w in {0,1} 20 g[u].push_back({v, w}); 21 // For undirected graphs, also add: g[v].push_back({u, w}); 22 } 23 int s; cin >> s; 24 25 const int INF = 1e9; 26 vector<int> dist(n + 1, INF); 27 deque<int> dq; 28 dist[s] = 0; 29 dq.push_front(s); 30 31 while (!dq.empty()) { 32 int u = dq.front(); dq.pop_front(); 33 for (auto [v, w] : g[u]) { 34 if (dist[v] > dist[u] + w) { 35 dist[v] = dist[u] + w; 36 if (w == 0) dq.push_front(v); 37 else dq.push_back(v); 38 } 39 } 40 } 41 42 for (int v = 1; v <= n; ++v) { 43 cout << (dist[v] == INF ? -1 : dist[v]) << (v == n ? '\n' : ' '); 44 } 45 return 0; 46 } 47
0-1 BFS uses a deque instead of a heap. A 0-weight relaxation pushes to the front (as it should be processed next), while a 1-weight relaxation pushes to the back. Each vertex and edge is processed a constant number of times, yielding linear time.
1 #include <bits/stdc++.h> 2 using namespace std; 3 4 // Shortest path from s to t when you may traverse at most one edge for free (cost 0). 5 // Model states as (vertex, usedCoupon in {0,1}). Run Dijkstra on the expanded graph. 6 // Input: 7 // n m 8 // u v w (m lines, w >= 0) 9 // s t 10 11 int main() { 12 ios::sync_with_stdio(false); 13 cin.tie(nullptr); 14 15 int n, m; cin >> n >> m; 16 struct Edge {int to; long long w;}; 17 vector<vector<Edge>> g(n + 1); 18 for (int i = 0; i < m; ++i) { 19 int u, v; long long w; cin >> u >> v >> w; 20 g[u].push_back({v, w}); 21 // For undirected graphs, also add reverse edges 22 } 23 int s, t; cin >> s >> t; 24 25 const long long INF = (long long)4e18; 26 // dist[v][used] where used in {0,1} 27 vector<array<long long,2>> dist(n + 1, {INF, INF}); 28 dist[s][0] = 0; 29 30 using State = tuple<long long,int,int>; // (dist, node, used) 31 priority_queue<State, vector<State>, greater<State>> pq; 32 pq.push({0LL, s, 0}); 33 34 while (!pq.empty()) { 35 auto [d, u, used] = pq.top(); pq.pop(); 36 if (d != dist[u][used]) continue; // stale 37 for (auto [v, w] : g[u]) { 38 // 1) Move without coupon usage 39 if (dist[v][used] > d + w) { 40 dist[v][used] = d + w; 41 pq.push({dist[v][used], v, used}); 42 } 43 // 2) If coupon not used yet, traverse this edge for free 44 if (used == 0 && dist[v][1] > d) { 45 dist[v][1] = d; 46 pq.push({dist[v][1], v, 1}); 47 } 48 } 49 } 50 51 long long ans = min(dist[t][0], dist[t][1]); 52 cout << (ans >= INF/2 ? -1 : ans) << "\n"; 53 return 0; 54 } 55
We duplicate each vertex into two layers: coupon unused (0) and used (1). From layer 0, for each edge we either pay normally (stay in layer 0) or consume the coupon and pay 0 (move to layer 1). From layer 1, we must pay normal costs. Dijkstra on this expanded graph returns the optimal total cost.