Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Shortest Paths at Scale  ›  Lesson

Searching From Both Ends

Bidirectional Search 28 minTwo small circles are cheaper than one big one
You're building a piece ofMeridian — the router
This piece — meet_in_middle(): Searches from the pickup and the drop-off at once and proves when they've met on the cheapest route.
Scenario Cross-city queries are the slow ones: the pickup and the drop-off are far apart, and a search from one end sweeps up half the city before it arrives. Meridian searches from both ends at once and stops the moment it can prove the two halves have met on the cheapest route.
Your task
Build meet_in_middle(roads, start, goal). `roads` is a two-way edge list of [a, b, minutes] with minutes >= 0. Search from `start` and from `goal` at the same time, always growing the side whose next pop is cheaper, and return the cheapest cost between them, 0 if start == goal, or -1 if there is no route. Keep the best meeting seen (any road joining the two searched sides) and stop only when the two heaps' smallest costs add up to at least it. Example: meet_in_middle([["depot", "market", 3], ["market", "harbour", 3], ["depot", "canal", 1], ["canal", "ridge", 3], ["ridge", "harbour", 1]], "depot", "harbour") → 5.

Searching From Both Ends

In short: running two searches at once — one forward from the start, one backward from the goal — and stopping when they meet cuts the explored region from about b^d junctions to about 2·b^(d/2), a square root, not a halving. With equal edge weights the first meeting point is already the answer. With different weights, run two Dijkstras and record every crossing between the two settled regions, stopping only when the two heaps' cheapest remaining entries add up to at least the best crossing found — the first touch is not always the cheapest route.

Two overlapping circles, forward (green, from depot) and backward (red, from harbour), with a grey dot labelled market: first touch at cost 6, and a green dot lower down labelled canal-ridge: cheapest at cost 5

The slowest queries Meridian answers are the cross-city ones: a pickup on the east ring road, a drop-off on the far west. A search from the pickup spreads out in rings, and by the time a ring reaches the drop-off it has swept up most of the city.

Here's a better idea. Start a second search from the drop-off at the same time, and stop when the two meet in the middle. Two small circles instead of one big one. On a network where each junction branches out, that is not a small saving, and this lesson shows how big it is. It also shows the one detail that makes the weighted version subtle: the moment the searches first touch is not always the moment you have the answer.

Why two halves beat one whole

Suppose every junction leads to about b new junctions, and the route is d roads long. A search from one end explores roughly b to the power d junctions before it arrives. Two searches that each go halfway explore about 2 × b to the power d/2:

bdversus2 bd/2b^{d} \quad\text{versus}\quad 2\,b^{d/2}

That's a square root, not a halving:

What does this print?
b, d = 3, 8
print(b ** d, 2 * b ** (d // 2))

Here's the difference as the route gets longer, when each junction leads to 3 new ones:

route length d one search (3^d) two searches (2 × 3^(d/2)) ratio
4 81 18 4.5
8 6,561 162 40.5
14 4,782,969 4,374 1,093.5
20 3,486,784,401 118,098 29,524.5

The ratio is 3 to the d/2, over 2, so every two extra roads triple it, and at 14 roads it passes a thousand. City routes of 30 or 40 roads make the one-sided search millions of times more expensive, at least until the network's edges stop the frontier growing.

Two BFS searches, unweighted

With every road counting as 1, run a BFS from each end. Each round, grow the side with the smaller frontier by one whole ring, since that's the cheaper side to grow. The moment a ring reaches a junction the other side has seen, the route length is the sum of the two hop counts.

In code, keep one hops dict and one frontier list per side, indexed 0 and 1, so the same loop body serves both directions:

python

With every road costing 1, the first touch is the answer: both searches grow a whole ring at a time, so no shorter crossing can still be hiding.

Watch it on a stretch of Meridian: a ring road where every junction also has one cross-town link. The forward search's junctions are badged F, the backward search's B:

your code, drawn · graph

Your bidirectional BFS. Two frontiers each grow halfway, and together touch half the junctions one search needs.

Try changing the goal to "j18", or the cross-town rule from i * 8 + 14 to i * 3 + 5, and compare the two counts in the last frame. On a plain grid, where rings grow only linearly, the saving shrinks to about half. It's biggest where the network branches.

With weights, meeting isn't finishing

Now let roads have different costs, and run two Dijkstras. Growing the side with the cheaper next pop keeps both searches roughly the same "radius". The tempting rule is to stop as soon as some junction is settled by both searches. That rule is wrong, and here is the smallest network that shows it:

watch it move · graph

The searches first touch at the market, but the cheapest route crosses between them along the canal–ridge road. Only a threshold rule knows when to stop.

Forward Dijkstra from the depot, backward Dijkstra from the harbour. Badges show each side's cost. 5 nodes, 5 edges, undirected. depot: F0, harbour: B0.

1 / 6

Two things make it right:

  1. Record every crossing. Whenever a road joins a junction one side has settled to a junction the other side has reached, that's a complete route. Keep the cheapest as best.
  2. Stop on a threshold. Any route you haven't seen yet must leave the forward region at a cost of at least the forward heap's smallest entry, and enter the backward region at a cost of at least the backward heap's smallest. So once top_forward + top_backward >= best, nothing unseen can win.

Here is the tempting rule, in code. It stops at the first junction both sides settle:

broken — fix it

This prints 6, the route through the market. The cheapest route, depot → canal → ridge → harbour, costs 5. Record every crossing and use the threshold rule to stop.

Many starts at once: multi-source search

The opposite trick is just as useful. To find, for every junction, the distance to its nearest depot, don't run one search per depot. Put every depot in the queue at distance 0 and run one BFS. The frontiers grow together, and each junction is claimed by whichever arrives first:

step through it
1from collections import deque
2roads = {"a": ["b"], "b": ["a", "c"], "c": ["b", "d"], "d": ["c", "e"], "e": ["d"]}
3hops = {"a": 0, "e": 0}
4queue = deque(["a", "e"])
5while queue:
6 node = queue.popleft()
7 for nxt in roads[node]:
8 if nxt not in hops:
9 hops[nxt] = hops[node] + 1
10 queue.append(nxt)
11print(hops)

It's the same as adding a hidden junction joined to every depot by a free road and searching from that. The drill exercise builds it.

Two weights only: 0-1 BFS

Some Meridian queries count surcharges: roads on the contracted network are free, others cost 1. Dijkstra works, but with only two weights a deque replaces the heap. A free road leads to a junction at the same cost, so it goes to the front; a surcharged road leads one higher, so it goes to the back:

What does this print?
from collections import deque
roads = {"depot": [("mill", 1), ("canal", 0)], "canal": [("quay", 0)],
         "quay": [("mill", 0)], "mill": []}
best = {"depot": 0}
queue = deque(["depot"])
while queue:
    node = queue.popleft()
    for nxt, w in roads[node]:
        if best[node] + w < best.get(nxt, float("inf")):
            best[nxt] = best[node] + w
            queue.appendleft(nxt) if w == 0 else queue.append(nxt)
print(best["mill"])

That runs in O(V + E), no log factor. The build exercise asks you to write it.

Every variant here keeps one hops/dist entry and one frontier slot per visited junction, on each side: O(V) space whether it's one BFS, two, or a deque. The saving bidirectional search buys is entirely in time — fewer junctions ever get visited in the first place.


Interview-style: where should two couriers meet?

Two couriers start at different junctions and must hand over a parcel. They drive at the same time; the handover happens when the later one arrives. Which junction gives the earliest handover?

It's tempting to take the shortest route between them and pick its middle. But the best junction may be off that route, somewhere both can reach quickly. Search from both ends: one Dijkstra from each courier gives every junction's two arrival times, and the answer minimises the later of the two. The boss exercise builds it.

What it costs

Work Note
One-sided BFS / Dijkstra about b^d junctions explores behind the start as much as ahead
Bidirectional search about 2 b^(d/2) needs the goal known, and the reverse graph for one-way roads
Multi-source BFS O(V + E), any number of sources distances to the nearest source
0-1 BFS O(V + E) weights 0 and 1 only

❓ Cross-question — "Can you combine this with A*?" Yes, but carefully: each side needs its own heuristic, and the stopping rule has to account for both. Real route planners do combine them, usually with landmark heuristics like the ones in the previous lesson's capstone.


Quick practice

What does this print?
b, d = 2, 10
print(b ** d, 2 * b ** (d // 2))
broken — fix it

depot and harbour are connected (depot → mill → plaza → harbour, 3 roads), but this returns -1. Fix which side the touch check reads.

🎯 Your turn

Write meet_in_middle(roads, start, goal). roads are two-way [a, b, minutes]. Run Dijkstra from both ends, always popping from the side with the cheaper next entry, record every crossing, and stop when the two heap tops add up to at least the best crossing. Return the cheapest cost, 0 if start == goal, or -1:

  • meet_in_middle([["depot", "market", 3], ["market", "harbour", 3], ["depot", "canal", 1], ["canal", "ridge", 3], ["ridge", "harbour", 1]], "depot", "harbour") → 5

Hint: keep dist, heaps and done as two-element lists indexed by side, so the forward and backward code is the same code.

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green = this piece of Meridian's router is built. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upHow fast does the frontier grow?

Write frontier_sizes(roads, start, levels). roads are two-way [a, b] pairs. Return a list of levels + 1 numbers: how many junctions are exactly 0, 1, 2, … levels hops from start (the first is always 1, for start itself). Example: from "hub" on a network where the hub has 3 neighbours and each has 3 more, levels = 2 → [1, 3, 9].

bfsqueue
DrillHops to the nearest depot

Meridian wants to know, for every junction, how many roads away its nearest depot is. Write nearest_depot(roads, depots) where roads are two-way [a, b] pairs and depots is a list of junction names. Return a dict from every junction reachable from some depot to its hop count to the nearest one (depots themselves at 0). Use a single BFS, not one per depot.

bfsgraph-traversal
BuildSurcharges only: 0-1 BFS

Roads on Meridian's contracted network cost nothing extra; any other road adds a surcharge of 1. roads are two-way [a, b, w] with w either 0 or 1. Write zero_one_cost(roads, start, goal) returning the fewest surcharges from start to goal, or -1 if it can't be reached. Use a deque: a 0 road's junction goes to the front, a 1 road's to the back.

dequeshortest-paths
BossWhere two couriers should meet

An interview-style routing problem. Two couriers start at junctions a and b and must hand a parcel over at one junction. They drive at the same time on two-way roads [x, y, minutes], and the handover happens when the later of the two arrives. Write meeting_point(roads, a, b) returning {"junction": name, "time": minutes} for the junction with the earliest handover (ties go to the alphabetically first junction), or {"junction": None, "time": -1} if they can't reach a common junction.

dijkstrashortest-paths
CapstoneVia a fuel stop, on one-way roads

Long runs must stop at one of Meridian's fuel stations on the way. roads are one-way [from, to, minutes], and stations lists the junctions that sell fuel. Write refuel_route(roads, start, goal, stations) returning the cheapest cost of a route from start to goal that passes through at least one station (the start or goal may itself be a station), or -1 if there isn't one. Use exactly two Dijkstra searches, however many stations there are.

dijkstrashortest-pathsgraph-traversal
ExplainWhen is it safe to stop?

In bidirectional Dijkstra, the first junction settled by both searches does not always lie on the cheapest route. Explain why not, and why stopping once the two searches' next costs add up to at least the best route seen so far is safe.

meet_in_middle.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
meet_in_middle(roads, start, goal) → intThe cheapest cost between two junctions, found by searching from both ends; -1 if unreachable.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.