Skip to content

Shortest Path: Practice Problems with Solutions

Welcome to the practice problems for shortest path. If you need to refresh the algorithm code first, the code templates have Dijkstra, BFS, Bellman-Ford, and Floyd-Warshall in all 6 languages. Each problem below includes a hint, a visual snapshot, and a full working solution you can trace against a small input. If the “why this pattern” lines do not click yet, the concept guide builds the selection logic from scratch.

Recommended Study Order

The problems build on each other, and the order matters more than the individual solutions.

  1. Minimum Depth of Binary Tree teaches the BFS layer mechanic on an input where the graph is a tree. The graph noise is gone, so the layer-by-layer discovery is visible by itself.
  2. Shortest Path in Binary Matrix moves BFS onto a grid, which is where most interviewers actually test it. The only new wrinkle is the 8-direction adjacency.
  3. Network Delay Time swaps the queue for a heap. This is the Dijkstra you will write most in interviews, and the start of weighted thinking.
  4. Cheapest Flights Within K Stops adds a budget, and the budget turns the problem into a Bellman-Ford round count. If you understood problem 3 fully, this one clicks quickly.
  5. Find the City With the Smallest Number of Neighbors at a Threshold Distance is the all-pairs problem. Floyd-Warshall does the heavy lifting and the answer logic is the new part.
  6. Swim in Rising Water redefines what “short” means. The path cost is the tallest obstacle on the path, and Dijkstra still works.
  7. Shortest Path Visiting All Nodes is the hardest. The visited mask turns the state space into 2^N states, and BFS over states finishes it.
The order above is designed to build intuition progressively. The app schedules your reviews so you do not forget the Easy patterns while grinding Hard ones. Set up your review schedule .

Easy

1. Minimum Depth of Binary Tree

LeetCode 111 | Difficulty: Easy

Brief: Return the minimum depth of a binary tree, which is the number of nodes along the shortest path from the root down to the nearest leaf.

Why this pattern: a tree is a graph where each node has at most two neighbors, and every edge costs one level. BFS visits nodes layer by layer, so the first time a node with no children shows up, that layer count is the answer.

Key Insight: the answer is the depth of the first leaf in BFS order. No need to search the rest of the tree.

Visual:

    graph TD
    R["root: 3, depth 1"] --> A["9, depth 2"]
    R --> B["20, depth 2"]
    B --> C["15, depth 3"]
    B --> D["7, depth 3"]
    L["BFS pops 9 at depth 2. It is a leaf, so depth 2 is the answer"]
    A -.-> L
  

Code:

var minDepth = function(root) {
    if (root === null) return 0;

    const queue = [root];
    let depth = 1;

    while (queue.length > 0) {
        const levelSize = queue.length;
        for (let i = 0; i < levelSize; i++) {
            const node = queue.shift();
            // BFS checks nodes in depth order, so the first leaf
            // found is the one the answer must come from
            if (node.left === null && node.right === null) return depth;
            if (node.left !== null) queue.push(node.left);
            if (node.right !== null) queue.push(node.right);
        }
        depth++;
    }
    return depth;
};

The level counter is the extra piece over plain BFS. By draining exactly one level per iteration, depth reflects how many edges separate the current layer from the root, and the first leaf encountered answers the question. A DFS would need to track the best-so-far and prune, which is more machinery for the same answer.

Medium

2. Shortest Path in Binary Matrix

LeetCode 1091 | Difficulty: Medium

Brief: return the length of the shortest clear path from the top-left to the bottom-right cell in an n x n binary matrix, moving in 8 directions through cells that contain 0.

Why this pattern: the grid is a graph, and moving to a neighbor costs one step. BFS guarantees the first time the target cell is discovered, it is via fewest steps, so the distance array doubles as the visited set.

Key Insight: an 8-directional move stays one step, so the 8 neighbors are simply a second direction list. The answer counts cells visited, which the distance value naturally does when the start is counted as 1.

Visual:

    graph TD
    A["grid: 0 0 0 / 1 1 0 / 1 1 0 (1 = blocked)"] --> B["Start (0,0) at distance 1"]
    B --> C["Only (0,1) is open from the start: distance 2"]
    C --> D["Step to (0,2) or (1,2): distance 3"]
    D --> E["Target (2,2) reached at distance 4"]
  

Code (all 6 languages):

var shortestPathBinaryMatrix = function(grid) {
    const n = grid.length;
    if (grid[0][0] === 1 || grid[n - 1][n - 1] === 1) return -1;

    const dirs = [
        [-1, -1], [-1, 0], [-1, 1],
        [0, -1],           [0, 1],
        [1, -1],  [1, 0],  [1, 1]
    ];
    const queue = [[0, 0, 1]]; // [row, col, steps]
    const visited = Array.from({ length: n }, () => new Array(n).fill(false));
    visited[0][0] = true;

    while (queue.length > 0) {
        const [r, c, steps] = queue.shift();
        if (r === n - 1 && c === n - 1) return steps;

        for (const [dr, dc] of dirs) {
            const nr = r + dr, nc = c + dc;
            if (nr >= 0 && nr < n && nc >= 0 && nc < n &&
                grid[nr][nc] === 0 && !visited[nr][nc]) {
                // First discovery is the shortest path, so mark
                // immediately to keep duplicates out of the queue
                visited[nr][nc] = true;
                queue.push([nr, nc, steps + 1]);
            }
        }
    }
    return -1;
};

Counting steps as 1 + the number of moves is the same pattern as depth in the previous problem. The 8 directions may look like extra work, but each cell still gets discovered exactly once, so the runtime stays

O(N²)
with the visited matrix doing double duty as the distance record.

3. Network Delay Time

LeetCode 743 | Difficulty: Medium

Brief: a signal starts at node k and travels through a weighted directed graph. Return the time until every node received the signal, or -1 if some node is unreachable.

Why this pattern: this is single-source shortest distances, exactly what Dijkstra computes, and the answer is the maximum of those distances.

Key Insight: once Dijkstra finishes, one scan over the distance array answers both remaining questions. An Infinity means unreachable, and the largest finite distance is the moment the last node got the signal.

Visual:

    graph TD
    A["times: 1-2 cost 1, 1-3 cost 4, 2-3 cost 1"] --> B["Dijkstra from 1"]
    B --> C["dist[1] = 0, pop 1"]
    C --> D["relax 2 to 1, relax 3 to 4"]
    D --> E["pop 2 (closest unsettled), relax 3 to 2"]
    E --> F["dist[2]=1, dist[3]=2, max = 2"]
  

Code:

class MinHeap {
    constructor() { this.items = []; }
    push(item) {
        this.items.push(item);
        let i = this.items.length - 1;
        while (i > 0) {
            const p = (i - 1) >> 1;
            if (this.items[p][0] <= this.items[i][0]) break;
            [this.items[p], this.items[i]] = [this.items[i], this.items[p]];
            i = p;
        }
    }
    pop() {
        const top = this.items[0];
        const last = this.items.pop();
        if (this.items.length > 0) {
            this.items[0] = last;
            let i = 0;
            while (true) {
                let s = i;
                const l = 2 * i + 1, r = 2 * i + 2;
                if (l < this.items.length && this.items[l][0] < this.items[s][0]) s = l;
                if (r < this.items.length && this.items[r][0] < this.items[s][0]) s = r;
                if (s === i) break;
                [this.items[i], this.items[s]] = [this.items[s], this.items[i]];
                i = s;
            }
        }
        return top;
    }
    get size() { return this.items.length; }
}

var networkDelayTime = function(times, n, k) {
    const graph = Array.from({ length: n + 1 }, () => []);
    for (const [u, v, w] of times) graph[u].push([v, w]);

    const dist = new Array(n + 1).fill(Infinity);
    dist[k] = 0;

    const heap = new MinHeap();
    heap.push([0, k]);

    while (heap.size > 0) {
        const [d, u] = heap.pop();
        // Skip heap entries that aged out after a better path was found
        if (d > dist[u]) continue;

        for (const [v, w] of graph[u]) {
            const nd = d + w;
            if (nd < dist[v]) {
                dist[v] = nd;
                heap.push([nd, v]);
            }
        }
    }

    let maxTime = 0;
    for (let i = 1; i <= n; i++) {
        if (dist[i] === Infinity) return -1; // node never reached
        maxTime = Math.max(maxTime, dist[i]);
    }
    return maxTime;
};

The distance array starts at 0 for the source and Infinity for everyone else. After the heap is drained, Infinity means the signal never arrived, and the code reads that sentinel in the final max loop instead of a second structure. This is the canonical “start a weighted problem, see if Dijkstra fits” problem, and the heap handles triple-digit edge counts without rework.

4. Cheapest Flights Within K Stops

LeetCode 787 | Difficulty: Medium

Brief: find the cheapest flight price from src to dst with at most k intermediate stops, or -1 if no such route exists.

Why this pattern: the stop limit bounds the number of flight legs to k + 1. Bellman-Ford relaxes edges once per allowed leg, so the k budget maps directly onto the round count.

Key Insight: each Bellman-Ford round must run against the snapshot from the previous round, never the in-progress array. Without the snapshot, one round can chain several flights and quietly exceed the stop limit.

Visual:

    graph TD
    A["flights: 0 to 1 cost 1, 0 to 2 cost 5, 1 to 2 cost 1, 2 to 3 cost 1; src 0, dst 3, k = 1"] --> B["round 1: best 1-leg prices 0-1 = 1, 0-2 = 5"]
    B --> C["round 2: extend round-1 prices, 1 to 2 now 2, 2 to 3 now 6"]
    C --> D["Answer: cheapest dst = 6, 2 flight legs = 1 stop"]
  

Code:

var findCheapestPrice = function(n, flights, src, dst, k) {
    let prices = new Array(n).fill(Infinity);
    prices[src] = 0;

    // at most k stops means at most k + 1 flight legs
    for (let round = 0; round < k + 1; round++) {
        // snapshot: read the previous round so one pass cannot
        // chain multiple flights into a single "leg"
        const next = [...prices];
        for (const [u, v, price] of flights) {
            if (prices[u] === Infinity) continue;
            const candidate = prices[u] + price;
            if (candidate < next[v]) next[v] = candidate;
        }
        prices = next;
    }

    return prices[dst] === Infinity ? -1 : prices[dst];
};

The snapshot copy is the whole trick, and it is the easiest thing to drop in a hurry. If you relax edge (u, v) and then immediately read the updated price[v], the next edge in the same flight list can build on it, letting one round trail through unlimited legs. The interview version of this problem is often stated as “Dijkstra with a stop budget”, and the Bellman-Ford framing here answers it without a three-dimensional visited structure.

5. Find the City With the Smallest Number of Neighbors at a Threshold Distance

LeetCode 1334 | Difficulty: Medium

Brief: given an undirected weighted graph, return the city with the fewest other cities reachable within a distance threshold. Ties go to the city with the larger index.

Why this pattern: the question must count neighbors for every city, and every pair distance must be compared to the threshold. That is the all-pairs requirement, and Floyd-Warshall fills exactly that matrix.

Key Insight: every city needs distances to every other city, so running Dijkstra n times (n log V each) works but the triple loop solves it in one pass over the matrix, which is less code to get signed-off.

Visual:

    graph TD
    A["threshold = 4"] --> B["dist matrix after Floyd-Warshall"]
    B --> C["count per city of dist[i][j] <= 4"]
    C --> D["city 1 reaches 2, city 0 reaches 4"]
    D --> E["fewest = city 3 (2 neighbors), answer 3"]
  

Code:

var findTheCity = function(n, edges, distanceThreshold) {
    const INF = Infinity;
    const dist = Array.from({ length: n }, () => new Array(n).fill(INF));
    for (let i = 0; i < n; i++) dist[i][i] = 0;
    for (const [u, v, w] of edges) {
        dist[u][v] = w;
        dist[v][u] = w;
    }

    // k-th pass lets every pair route through city k
    for (let k = 0; k < n; k++) {
        for (let i = 0; i < n; i++) {
            for (let j = 0; j < n; j++) {
                if (dist[i][k] !== INF && dist[k][j] !== INF) {
                    const via = dist[i][k] + dist[k][j];
                    if (via < dist[i][j]) dist[i][j] = via;
                }
            }
        }
    }

    let bestCity = -1;
    let fewest = Infinity;
    for (let i = 0; i < n; i++) {
        let count = 0;
        for (let j = 0; j < n; j++) {
            if (j !== i && dist[i][j] <= distanceThreshold) count++;
        }
        // strictly fewer wins; equal count prefers the larger index
        if (count < fewest || (count === fewest && i > bestCity)) {
            fewest = count;
            bestCity = i;
        }
    }
    return bestCity;
};

The threshold comparison happens after the matrix is complete, which is why only one Floyd-Warshall run is needed. The tie-break (“largest index wins”) is worth writing down before coding, since the straightforward scan returns the smallest index when no tie logic exists. The matrix space is the price of this approach, and the scoring check makes the value of all-pairs clear: without the full table, every query would recompute the graph.

Hard

6. Swim in Rising Water

LeetCode 778 | Difficulty: Hard

Brief: water rises at one unit per minute, and an elevation becomes passable only when the water covers it. Return the earliest time you can reach the bottom-right cell from the top-left cell if you can move only through covered elevation.

Why this pattern: the cost of a path is the highest elevation on it, not the sum. That changed definition breaks BFS and plain sum-Dijkstra, but the update rule of Dijkstra adapts: a path through a neighbor costs max(current so far, neighbor’s elevation).

Key Insight: minimize the max, not the total. The heap now pops the path with the lowest peak elevation reached, and the first time the target cell leaves the heap is the answer.

Visual:

    graph TD
    A["grid: 0 2 / 1 3"] --> B["start (0,0) elev 0, t = 0"]
    B --> C["(1,0) elev 1 -> t = 1"]
    B --> D["(0,1) elev 2 -> t = 2"]
    C --> E["(1,1) elev 3 -> t = 3"]
    D --> E
  

Code:

class MinHeap {
    constructor() { this.items = []; }
    push(item) {
        this.items.push(item);
        let i = this.items.length - 1;
        while (i > 0) {
            const p = (i - 1) >> 1;
            if (this.items[p][0] <= this.items[i][0]) break;
            [this.items[p], this.items[i]] = [this.items[i], this.items[p]];
            i = p;
        }
    }
    pop() {
        const top = this.items[0];
        const last = this.items.pop();
        if (this.items.length > 0) {
            this.items[0] = last;
            let i = 0;
            while (true) {
                let s = i;
                const l = 2 * i + 1, r = 2 * i + 2;
                if (l < this.items.length && this.items[l][0] < this.items[s][0]) s = l;
                if (r < this.items.length && this.items[r][0] < this.items[s][0]) s = r;
                if (s === i) break;
                [this.items[i], this.items[s]] = [this.items[s], this.items[i]];
                i = s;
            }
        }
        return top;
    }
    get size() { return this.items.length; }
}

var swimInWater = function(grid) {
    const n = grid.length;
    const dirs = [[0, 1], [1, 0], [0, -1], [-1, 0]];

    // t[r][c] is the lowest water level at which (r,c) is reachable
    const t = Array.from({ length: n }, () => new Array(n).fill(Infinity));
    t[0][0] = grid[0][0];

    const heap = new MinHeap();
    heap.push([grid[0][0], 0, 0]);

    while (heap.size > 0) {
        const [level, r, c] = heap.pop();
        if (level > t[r][c]) continue; // stale entry
        if (r === n - 1 && c === n - 1) return level;

        for (const [dr, dc] of dirs) {
            const nr = r + dr, nc = c + dc;
            if (nr < 0 || nr >= n || nc < 0 || nc >= n) continue;
            // entering a cell raises the level to that cell's elevation
            const next = Math.max(level, grid[nr][nc]);
            if (next < t[nr][nc]) {
                t[nr][nc] = next;
                heap.push([next, nr, nc]);
            }
        }
    }
    return -1;
};

The same stale-entry guard from Dijkstra is what keeps this runtime honest. BFS cannot solve the problem because the queue has no cost ordering, and sum-Dijkstra fails because elevation is a max, not a sum. Keeping the relaxation rule opposite the weight type (“minimize the largest”) is a good interview habit to practice.

7. Shortest Path Visiting All Nodes

LeetCode 847 | Difficulty: Hard

Brief: given an undirected graph, return the length of the shortest path that starts at any node and visits every node at least once.

Why this pattern: the distances between pairs are irrelevant here. What matters is combinatorial progress, and the state (current node, set of visited nodes) is exactly what BFS over states explores step by step.

Key Insight: encode the visited set as a bitmask, one bit per node. A state is a (node, mask) pair, and two states with the same pair have identical futures, so only one of them needs exploring. That pruning is what keeps the search from exploding into a full permutation scan.

Visual:

    graph TD
    A["Start states: (0, 001), (1, 010), (2, 100)"] --> B["Pop (0, 001), step 0"]
    B --> C["go to 1: (1, 011), step 1"]
    C --> D["go to 2: (2, 111), step 2"]
    D --> E["mask 111 == all nodes, return 2"]
  

Code:

var shortestPathLength = function(graph) {
    const n = graph.length;
    const full = (1 << n) - 1;

    // try starting from every node at once; a state is (node, mask)
    const queue = [];
    const seen = new Set();
    for (let i = 0; i < n; i++) {
        queue.push([i, 1 << i, 0]);
        seen.add(i + "," + (1 << i));
    }

    while (queue.length > 0) {
        const [node, mask, steps] = queue.shift();
        if (mask === full) return steps;

        for (const next of graph[node]) {
            const nextMask = mask | (1 << next);
            const key = next + "," + nextMask;
            // re-visiting (node, mask) is dead work, so BFS ignores it
            if (!seen.has(key)) {
                seen.add(key);
                queue.push([next, nextMask, steps + 1]);
            }
        }
    }
    return -1;
};

The trick in this problem is that BFS no longer spreads over graph nodes. It spreads over graph states, a pair of a current node and a visited-mask, and the shortest path guarantee still works on that state graph. The first time the all-ones mask appears, its step count is the answer. The state count is n × 2^n, and the seen set is what keeps the search from re-exploring states it has already been to.

Hard problems are where patterns separate candidates who pass from candidates who get offers. Track your misses and review them on a spaced schedule so you do not repeat them in the real interview.

These seven problems cover the full runtime of the shortest path pattern. Start with BFS layers on the tree, move through grid BFS, then Dijkstra’s heap, then the Bellman-Ford budget version, then the all-pairs matrix, and finish with the two state-heavy hard problems. The progression closes the gap between “I can implement Dijkstra” and “I can spot the shortest path problem inside a word problem”.

Done with these problems? The app has more, plus a spaced repetition system that brings problems back right before you would forget them. Continue your prep .