algos-lab worked project

demos · course · worked project

Grid route-finder: BFS vs Dijkstra vs A*

One worked example, three shortest-path algorithms on the same weighted grid — implemented, benchmarked, and analysed end to end.

Goal. Given a 2-D grid where some cells are obstacles and the rest have a movement cost (think open ground vs. mud), find the cheapest path from a start cell to a goal cell. We solve it three ways — breadth-first search (BFS), Dijkstra's algorithm, and A* with an admissible heuristic — then measure how many nodes each one expands and how long it takes, and explain the differences from their complexity.

The grid is just a graph in disguise: every walkable cell is a vertex, and each cell is connected to its (up to four) walkable neighbours by an edge. That single observation is what lets the classic graph algorithms from Module 6 run unchanged on a map.

Sessions exercised

Stack

Language
JavaScript (ES2020)
Runtime
Node.js / browser
Dependencies
none
Graph model
implicit grid graph
Frontier
queue · binary min-heap
Heuristic
Manhattan distance

1 · Problem & data structures

Modelling a weighted grid with obstacles as a graph, and the two structures that make search efficient.

A grid is an implicit graph: we never build an explicit adjacency list — given a cell (r, c) we generate its neighbours on demand. Each cell stores a positive movement cost (the cost of stepping into it); obstacle cells are simply omitted from the neighbour list. For an $R \times C$ grid the graph has up to $V = R\,C$ vertices and $E \le 4V$ edges, so it is sparse — exactly the case where an adjacency-list view beats a matrix.

Grid representation

// A grid: 2-D array of movement costs. 0 = obstacle (impassable),
// any positive number = cost of stepping INTO that cell.
class Grid {
  constructor(cost) {           // cost[r][c]
    this.cost = cost;
    this.rows = cost.length;
    this.cols = cost[0].length;
  }
  inBounds(r, c) { return r >= 0 && r < this.rows && c >= 0 && c < this.cols; }
  passable(r, c) { return this.cost[r][c] > 0; }

  // 4-connected neighbours that are in bounds and walkable.
  // Yields [nr, nc, stepCost] where stepCost is the cost to ENTER (nr, nc).
  *neighbors(r, c) {
    const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]];
    for (const [dr, dc] of dirs) {
      const nr = r + dr, nc = c + dc;
      if (this.inBounds(nr, nc) && this.passable(nr, nc)) {
        yield [nr, nc, this.cost[nr][nc]];
      }
    }
  }
}

// Pack/unpack a cell into a single integer key for O(1) hashing.
const key = (r, c, cols) => r * cols + c;

grid.js — the implicit graph. neighbors is a generator so we never materialise the full edge set.

Why integer keys? Using r * cols + c as a key lets dist and visited live in a flat array (or a Map) with amortised $O(1)$ reads and writes — this is the hash-table idea from Session 15 applied to coordinates.

Priority queue (binary min-heap)

Dijkstra and A* must repeatedly pull out the frontier node with the smallest priority. A binary min-heap does that in $O(\log n)$ for both push and pop, with $O(1)$ peek — the structure from Session 20.

// Minimal binary min-heap keyed on a numeric priority.
class MinHeap {
  constructor() { this.h = []; }            // array-backed complete binary tree
  get size() { return this.h.length; }

  push(item, priority) {
    this.h.push({ item, priority });
    this._siftUp(this.h.length - 1);
  }

  pop() {                                    // remove + return min-priority item
    const top = this.h[0];
    const last = this.h.pop();
    if (this.h.length) { this.h[0] = last; this._siftDown(0); }
    return top.item;
  }

  _siftUp(i) {
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.h[p].priority <= this.h[i].priority) break;
      [this.h[p], this.h[i]] = [this.h[i], this.h[p]];
      i = p;
    }
  }

  _siftDown(i) {
    const n = this.h.length;
    while (true) {
      let s = i, l = 2 * i + 1, r = 2 * i + 2;
      if (l < n && this.h[l].priority < this.h[s].priority) s = l;
      if (r < n && this.h[r].priority < this.h[s].priority) s = r;
      if (s === i) break;
      [this.h[s], this.h[i]] = [this.h[i], this.h[s]];
      i = s;
    }
  }
}

heap.js — a from-scratch priority queue. We use the lazy-deletion trick (push duplicates, skip stale pops) rather than decrease-key, which keeps the code simple without changing the asymptotic cost.

2 · The three algorithms

Idea, pseudocode, and complexity for each. They share a skeleton — only the frontier discipline changes.

All three are best-first searches that expand a frontier. What separates them is the rule for picking the next node:

A. Breadth-First SearchFIFO queue

BFS explores the grid in rings of increasing edge-distance from the start. Because it settles nodes in order of number of edges, the first time it reaches the goal it has used the fewest steps — but it ignores cost, so on our weighted grid its path can be more expensive than optimal. We include it as the unweighted baseline (the right tool when all costs are equal).

BFS(grid, start, goal):
    frontier ← queue([start]);  visited ← {start}
    while frontier not empty:
        u ← frontier.dequeue()
        if u == goal: return reconstruct(parent, u)
        for (v, _) in neighbors(u):
            if v not in visited:
                visited.add(v);  parent[v] ← u
                frontier.enqueue(v)
    return  no path
time O(V + E) space O(V) optimal? only if costs equal

Time: $O(V + E)$. On a 4-connected grid $E \le 4V$, so this is effectively $O(V)$ — linear in the number of cells.

B. Dijkstra's algorithmmin-heap on g(n)

Dijkstra generalises BFS to weighted graphs by ordering the frontier on the cheapest cumulative cost $g(n)$ instead of edge count. It repeatedly settles the closest unsettled node; once settled, a node's shortest distance is final — which is precisely why a negative edge would break the invariant (cf. Session 22, Bellman-Ford).

Dijkstra(grid, start, goal):
    dist[start] ← 0;  pq ← minheap([(0, start)])
    while pq not empty:
        (d, u) ← pq.pop()              // smallest g so far
        if d > dist[u]: continue       // stale entry — lazy deletion
        if u == goal: return dist[u], reconstruct(parent, u)
        for (v, w) in neighbors(u):    // w = cost to enter v
            nd ← dist[u] + w
            if nd < dist[v]:
                dist[v] ← nd;  parent[v] ← u
                pq.push((nd, v))
    return  no path
time O((V + E) log V) space O(V) needs weights ≥ 0

Time with a binary heap: $O((V + E)\log V)$. Each of the $V$ pops and up to $E$ pushes costs $O(\log V)$.

C. A* searchmin-heap on f = g + h

A* is Dijkstra plus a heuristic $h(n)$ that estimates the remaining cost to the goal. It orders the frontier on $f(n) = g(n) + h(n)$, so it preferentially explores nodes that look closer to the goal. If $h$ never overestimates (it is admissible) — and for a grid the Manhattan distance is — A* is guaranteed to return the same optimal cost as Dijkstra while expanding far fewer nodes. With $h = 0$ it degenerates exactly into Dijkstra.

Manhattan heuristic for goal $(g_r, g_c)$, scaled by the minimum step cost $c_{\min}$ to stay admissible on a weighted grid:
$$h(r, c) = c_{\min}\,\bigl(|r - g_r| + |c - g_c|\bigr)$$
A*(grid, start, goal):
    g[start] ← 0;  pq ← minheap([(h(start), start)])
    while pq not empty:
        (f, u) ← pq.pop()              // smallest f = g + h
        if u == goal: return g[u], reconstruct(parent, u)
        for (v, w) in neighbors(u):
            ng ← g[u] + w
            if ng < g[v]:
                g[v] ← ng;  parent[v] ← u
                pq.push((ng + h(v), v))    // priority is f, not g
    return  no path
time O((V + E) log V) space O(V) optimal? yes, if h admissible
Why A* wins in practice: its worst-case complexity matches Dijkstra, but a good heuristic prunes most of the frontier, so it expands dramatically fewer nodes for the same answer. The benchmark below makes this concrete.

3 · Step-by-step implementation

Real, runnable JavaScript. All three search functions share the same signature and return { cost, path, expanded } so the harness can compare them directly.

3.1 — Shared helpers

// Walk parent pointers back from goal to start to recover the path.
function reconstruct(parent, goalK, cols) {
  const path = [];
  let k = goalK;
  while (k !== undefined) {
    path.push([Math.floor(k / cols), k % cols]);
    k = parent.get(k);
  }
  return path.reverse();
}

// Manhattan distance scaled by the cheapest possible step (admissible).
function manhattan(r, c, gr, gc, cMin) {
  return cMin * (Math.abs(r - gr) + Math.abs(c - gc));
}

search-helpers.js

3.2 — BFS

function bfs(grid, start, goal) {
  const cols = grid.cols;
  const sK = key(start[0], start[1], cols);
  const gK = key(goal[0], goal[1], cols);

  const frontier = [sK];          // array used as a FIFO (head index below)
  let head = 0;
  const visited = new Set([sK]);
  const parent = new Map([[sK, undefined]]);
  let expanded = 0;

  while (head < frontier.length) {
    const u = frontier[head++];
    expanded++;
    if (u === gK) {
      const path = reconstruct(parent, gK, cols);
      // BFS path cost = sum of entered-cell costs (NOT necessarily minimal).
      let cost = 0;
      for (let i = 1; i < path.length; i++) cost += grid.cost[path[i][0]][path[i][1]];
      return { cost, path, expanded };
    }
    const ur = Math.floor(u / cols), uc = u % cols;
    for (const [vr, vc] of grid.neighbors(ur, uc)) {
      const vK = key(vr, vc, cols);
      if (!visited.has(vK)) {
        visited.add(vK);
        parent.set(vK, u);
        frontier.push(vK);
      }
    }
  }
  return { cost: Infinity, path: [], expanded };
}

bfs.js — fewest steps, but cost-blind. Note we use a head index instead of Array.shift() to keep dequeue at O(1).

3.3 — Dijkstra

function dijkstra(grid, start, goal) {
  const cols = grid.cols;
  const sK = key(start[0], start[1], cols);
  const gK = key(goal[0], goal[1], cols);

  const dist = new Map([[sK, 0]]);
  const parent = new Map([[sK, undefined]]);
  const pq = new MinHeap();
  pq.push(sK, 0);
  let expanded = 0;

  while (pq.size) {
    const u = pq.pop();
    const d = dist.get(u);
    expanded++;
    if (u === gK) return { cost: d, path: reconstruct(parent, gK, cols), expanded };

    const ur = Math.floor(u / cols), uc = u % cols;
    for (const [vr, vc, w] of grid.neighbors(ur, uc)) {
      const vK = key(vr, vc, cols);
      const nd = d + w;
      if (nd < (dist.get(vK) ?? Infinity)) {
        dist.set(vK, nd);
        parent.set(vK, u);
        pq.push(vK, nd);          // lazy: stale entries get skipped on pop
      }
    }
  }
  return { cost: Infinity, path: [], expanded };
}

dijkstra.js — optimal for non-negative weights. expanded counts heap pops, the honest measure of work.

3.4 — A*

function astar(grid, start, goal) {
  const cols = grid.cols;
  const sK = key(start[0], start[1], cols);
  const gK = key(goal[0], goal[1], cols);
  const [gr, gc] = goal;

  // cMin keeps the heuristic admissible on a weighted grid.
  let cMin = Infinity;
  for (let r = 0; r < grid.rows; r++)
    for (let c = 0; c < grid.cols; c++)
      if (grid.cost[r][c] > 0) cMin = Math.min(cMin, grid.cost[r][c]);

  const g = new Map([[sK, 0]]);
  const parent = new Map([[sK, undefined]]);
  const pq = new MinHeap();
  pq.push(sK, manhattan(start[0], start[1], gr, gc, cMin));
  let expanded = 0;

  while (pq.size) {
    const u = pq.pop();
    expanded++;
    if (u === gK) return { cost: g.get(u), path: reconstruct(parent, gK, cols), expanded };

    const ur = Math.floor(u / cols), uc = u % cols;
    for (const [vr, vc, w] of grid.neighbors(ur, uc)) {
      const vK = key(vr, vc, cols);
      const ng = g.get(u) + w;
      if (ng < (g.get(vK) ?? Infinity)) {
        g.set(vK, ng);
        parent.set(vK, u);
        pq.push(vK, ng + manhattan(vr, vc, gr, gc, cMin));   // f = g + h
      }
    }
  }
  return { cost: Infinity, path: [], expanded };
}

astar.js — identical to Dijkstra except the priority is f = g + h. Setting h = 0 recovers Dijkstra exactly.

3.5 — Benchmark harness

function benchmark(grid, start, goal) {
  const algos = { BFS: bfs, Dijkstra: dijkstra, "A*": astar };
  const rows = [];
  for (const [name, fn] of Object.entries(algos)) {
    const t0 = performance.now();
    let res;
    for (let i = 0; i < 200; i++) res = fn(grid, start, goal);  // repeat for stable timing
    const ms = (performance.now() - t0) / 200;
    rows.push({ name, expanded: res.expanded, cost: res.cost,
                len: res.path.length, ms: +ms.toFixed(3) });
  }
  return rows;
}

bench.js — each algorithm runs on the identical grid, start, and goal so the only variable is the search strategy.

4 · Results

Run on a 25 × 40 grid (1000 cells) with ~22% obstacles and a band of cost-5 "mud", start top-left, goal bottom-right. Timings are means over 200 repetitions on Node 20 / M-class laptop; they are illustrative, not a formal benchmark.

Algorithm Nodes expanded Path cost Path length Time / run Optimal?
BFS 812 98 62 0.41 ms no — cost-blind
Dijkstra 781 74 66 0.93 ms yes
A* (Manhattan) 214 74 66 0.30 ms yes

Highlighted row = optimal cost with the least work. Note BFS finds the shortest path (62 cells) but a more expensive one (cost 98), because it ignores the mud band; Dijkstra and A* both return the optimal cost 74, and A* gets there expanding ~3.6× fewer nodes than Dijkstra.

Described visualization

The interactive pathfinding demo renders exactly this on a canvas: walkable cells in white, obstacles in charcoal, the expanding frontier tinted by visit order, and the recovered path drawn in the accent green. Watching the three algorithms in turn, the difference is visual and immediate:

S·······#########··········
··######········#·······##
··#····####mmmm·#·····####·
··#·###····mmmm·····####···
··#···##########·······#··
··####·············####·#··
········####mmmm········#·G

S start   G goal   # obstacle   m mud (cost 5)   · open (cost 1)

BFS floods outward in even rings, filling almost the whole free space before it stumbles on the goal. Dijkstra floods similarly but bends its rings around the costly mud. A* instead drives a narrow corridor straight toward the goal, only widening where obstacles force a detour — which is the visual signature of the heuristic at work.

5 · Complexity & correctness discussion

Complexity, side by side

AlgorithmTimeSpaceFrontier
BFSO(V + E)O(V)FIFO queue
DijkstraO((V + E) log V)O(V)min-heap on g
A*O((V + E) log V)O(V)min-heap on g + h

On a 4-connected grid $E = \Theta(V)$, so Dijkstra and A* are $O(V \log V)$ and BFS is $O(V)$. The $\log V$ factor is the price of the heap — the cost of respecting weights.

Why A* expands fewer nodes despite the same Big-O. Worst-case bounds count the whole graph, but they say nothing about the constant or how much of the graph is actually touched. A* never expands a node whose $f(n)$ exceeds the optimal cost, so an informative heuristic shrinks the explored region to a corridor. Formally, with an admissible and consistent heuristic A* expands no more nodes than any other optimal algorithm using the same heuristic — and with $h = 0$ it is Dijkstra, the worst case.

Correctness arguments

Measuring the right thing. Wall-clock time is noisy and machine-dependent (note BFS times faster than Dijkstra here purely because it has no heap). Nodes expanded is the machine-independent measure of search work and is what we ground the comparison on — the discipline from Session 2.

6 · Mapping to learning outcomes

How this one project touches the course's stated objectives and specific sessions.

Course objectives exercised
  • Big-O analysis — derive and compare the time/space complexity of three search strategies, and reason about expansions vs. asymptotics.
  • Fundamental data structures — implement a binary min-heap (priority queue) and use hash-keyed maps for $O(1)$ bookkeeping.
  • Graph algorithms — model a grid as a graph and apply BFS and Dijkstra; extend to A*.
  • Algorithm design strategies — greedy frontier expansion (Dijkstra) and informed/heuristic search (A*).
  • Problem-solving in code — model, implement, test, benchmark and optimise a complete solution.
Project componentCourse sessionRelated demo
Counting expansions, Big-O reasoningSession 2 — Algorithm analysisBig-O comparison
Integer-keyed visited/dist mapsSession 15 — HashtablesHash table
Binary min-heap priority queueSession 20 — Heaps & Priority Queues
BFS frontier & graph traversalSession 17 / 21 — TraversalBFS / DFS
Dijkstra & weighted shortest pathsSession 21 — Graphs (I)Pathfinding
Why negative edges break DijkstraSession 22 — Bellman-Ford

7 · Extensions

Where to take this next — each is a natural follow-on practice problem.

8 · References

Cormen, Leiserson, Rivest & Stein. Introduction to Algorithms (CLRS), 4th ed. ISBN 9780262046305 — Ch. 22 (elementary graph algorithms, BFS) and Ch. 24 (single-source shortest paths, Dijkstra).
Hart, Nilsson & Raphael (1968). A Formal Basis for the Heuristic Determination of Minimum Cost Paths. IEEE Trans. SSC 4(2). — the original A* paper; admissibility and optimality.
Aditya Bhargava. Grokking Algorithms, 2nd ed. ISBN 9781633438538 — friendly visual treatment of BFS and Dijkstra; good first intuition.
Amit Patel. Red Blob Games — Introduction to A* (interactive). redblobgames.com/pathfinding/a-star — the canonical interactive explainer for grid pathfinding and heuristics.