Cairn
algorithms · shortest paths · O(E + VC) with C = max edge weight

↩ back to Shortest Paths

Dial's Algorithm

0-1 BFS's own Pitfalls section names this gap directly: its two-ended deque trick "does generalize to a small, bounded range of integer weights by swapping the two-ended deque for an array of several buckets, one per reachable distance — not built as its own entry here." Dial's algorithm (Robert Dial, 1969) is that generalization. When every edge cost is a small non-negative integer no larger than some bound C — not just 0 or 1 — a two-ended deque isn't enough to keep the frontier sorted, but a circular array of C + 1 buckets is: replacing Dijkstra's O(log V) heap operations with plain array indexing, in exchange for assuming edge weights never exceed C.

Try it

Click a cell to cycle its cost through 0 → 1 → 2 → 3 (green is the start, orange is the end — fixed, always cost 1). The default terrain has a costly band across the middle (2/3/2) and a free walkway near the top. Step through the search and watch the bucket strip below the grid: four boxes, one per possible remainder mod 4 — the currently-active bucket is outlined, and each relaxed cell gets pushed into the bucket matching its new distance mod 4, not its raw distance. Watch the outline sweep left to right and wrap back around to bucket 0 as the search progresses past distance 3 — the same physical box holds distance 0, then later distance 4, then distance 8, and so on, never colliding because by the time it's reused the earlier occupant has always already been popped.

Click cells to change terrain cost, then press Step or Run.

Why it works

The whole method rests on one window argument: whenever a cell is popped at bucket index idx, every relaxation it triggers pushes a neighbor at distance somewhere in idx .. idx + C — never less (edges are non-negative), never more (no edge costs more than C). That's a range of exactly C + 1 distinct values, which is exactly the buffer's size, so two live entries can never land in the same physical slot at the same time: by the time the sweep comes back around to reuse a slot, every entry that was ever placed there has a distance small enough that it must already have been popped. Sweeping the index forward one at a time and popping from whichever bucket it currently points at therefore visits cells in non-decreasing distance order, exactly like Dijkstra's heap, without ever comparing two distances against each other — the array position is the comparison.

One consequence worth noticing, shared with Dijkstra and 0-1 BFS: a cell can be pushed more than once, and the stale, more-expensive entry is simply popped and skipped once reached, since the cell is already finalized with a better distance by then. A subtler consequence is specific to this structure: unlike a heap or deque, which naturally runs empty when the search is done, a bare "advance idx until its bucket is non-empty" loop has no built-in stopping condition — on a graph with unreachable cells, every bucket can be empty forever and the sweep would spin indefinitely. The reference implementation below tracks a separate pending count (incremented on every push, decremented on every pop, regardless of whether that pop turns out to be stale) and stops once it hits zero — the one piece of bookkeeping a plain "keep sweeping" description of the algorithm tends to leave out.

Reference implementation

Grid version, tracking a parent pointer per cell exactly like Dijkstra and 0-1 BFS, relaxing through a small-integer cost grid into a circular array of maxWeight + 1 buckets:

function dialsAlgorithm(grid, rows, cols, start, end, maxWeight) {
  const key = (r, c) => r * cols + c;
  const n = rows * cols;
  const dist = new Array(n).fill(Infinity);
  const finalized = new Array(n).fill(false);
  const parent = new Array(n).fill(-1);
  const startK = key(...start), endK = key(...end);
  dist[startK] = 0;

  // Circular buffer: any relaxation out of a cell popped at index `idx`
  // lands somewhere in idx..idx+maxWeight, a window exactly maxWeight + 1
  // wide — so a buffer that size can never alias two live entries.
  const numBuckets = maxWeight + 1;
  const buckets = Array.from({ length: numBuckets }, () => []);
  buckets[0].push(startK);
  let pending = 1, idx = 0;

  while (pending > 0) {
    while (buckets[idx % numBuckets].length === 0) idx++;
    const cur = buckets[idx % numBuckets].pop();
    pending--;
    if (finalized[cur]) continue; // stale duplicate, already finalized cheaper
    finalized[cur] = true;
    if (cur === endK) break;

    const r = Math.floor(cur / cols), c = cur % cols;
    for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
      const nr = r + dr, nc = c + dc;
      if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
      const nk = nr * cols + nc;
      if (finalized[nk]) continue;
      const w = grid[nk]; // 0..maxWeight: the cost to enter that cell
      const nd = dist[cur] + w;
      if (nd < dist[nk]) {
        dist[nk] = nd;
        parent[nk] = cur;
        buckets[nd % numBuckets].push(nk);
        pending++;
      }
    }
  }

  if (!finalized[endK]) return null; // unreachable
  const path = [];
  for (let cur = endK; cur !== -1; cur = parent[cur]) path.push(cur);
  return { path: path.reverse(), cost: dist[endK] };
}

Verified against a from-scratch Dijkstra reference: 3,000 random grids up to 8×8 with random integer weights from 0 up to a random bound (1 to 4), every one agreeing on the exact cost to the far corner, plus a check that the reconstructed path only steps between grid-adjacent cells and its own summed weights match the reported cost — zero mismatches, zero invalid reconstructions.

Pitfalls

Sizing the buffer to C instead of C + 1. It's an easy off-by-one to make — "the weights go from 0 to C, so there are C of them" — but the window argument above needs a slot for every value in idx..idx+C inclusive, which is C + 1 values. Shrink the buffer by one and a bucket gets reused one distance early: a fresh entry lands in the same physical slot an older, still-pending entry already occupies, gets popped prematurely (its idx doesn't match its real distance yet, so the stale-check discards it), and is gone for good — not requeued, just lost. A concrete case: three nodes, edge 0→1 costing 2 and edge 1→2 costing 0, C = 2. The correct distances are [0, 2, 2]. With a 2-slot buffer instead of 3, node 1's distance-2 entry gets pushed into slot 2 % 2 = 0 — the same slot node 0 just vacated — and popped immediately while idx is still 0, where the mismatched-distance check throws it away as a false stale duplicate; node 2 never gets discovered at all, reported unreachable. Tested against 5,000 random trials: wrong on 17.2% of them, not a rare edge case.

It's a net loss once C is large relative to the graph. Not a correctness bug — a performance one, and purely a matter of how big C is, not how big the graph is. On this page's own 7×11 demo grid, capping costs at C = 4 (matching the demo), Dial's algorithm does 244 bucket operations against a linear-scan priority queue's 717 — a clear win. Widen the cap to C = 500 on that same 77-cell grid, keeping every other shape identical, and it flips: Dial's algorithm now does 2,702 operations against the priority queue's unchanged 860 — over 3x more, because the circular buffer's sweep is proportional to the final distance reached, which now dwarfs the vertex count. The bound that makes this algorithm fast — total work O(E + VC) — is the same bound that makes it slow the moment C stops being small.

Complexity

Time: O(E + VC), where C is the largest edge weight. Every vertex is popped once and every edge relaxed once, both O(1) work given array indexing — that part is O(V + E), identical to BFS. What BFS and 0-1 BFS don't pay for is the index sweep: across the whole run, idx only ever increases, and it can climb as high as the largest finalized distance, which is at most (V - 1) · C — hence the + VC term, the price of using array position instead of a real comparison. 0-1 BFS is the C = 1 special case, where that term collapses to just V and disappears into the existing O(V + E). Space: O(V + E + C) — the distance, finalized, and parent arrays plus the bucket array, which needs exactly C + 1 slots regardless of how large the graph is.

For a decision guide across all thirteen of this site's shortest-path entries — which one to reach for depending on negative edges, single-source vs. all-pairs, a heuristic, or edge weights this restricted — see Choosing a Shortest-Path Algorithm.