Skip to content

Uniform-cost search

Breadth-first search finds paths with the fewest edges. On a road map, that’s the route through the fewest intersections, which is rarely the route you want. A two-mile drive down a back road with a stop sign in the middle beats a twenty-mile drive on the highway with no stops at all. What you want is the route with the smallest total driving time, or distance, or fuel, or toll.

Here is a small town, whose roads are labeled with their driving times, in minutes:

4 2 1 3 5 9 2 6 3 park cafe bank home school store gym

A number on an edge is called its weight, and a graph with numbers on its edges is a weighted graph. The cost of a path is the sum of the weights of its edges. The path home, school, store, gym has three edges, and costs 2+9+3=142 + 9 + 3 = 14 minutes. No path from home to the gym has fewer than three edges, so breadth-first search would return this path, or home, cafe, bank, gym, which costs 4+5+6=154 + 5 + 6 = 15. But the path home, school, cafe, bank, store, gym, with five edges, costs only 2+1+5+2+3=132 + 1 + 5 + 2 + 3 = 13. A path with the smallest cost is called a cheapest path, or, when the weights are distances, a shortest path.

Uniform-cost search finds cheapest paths from a start vertex. It is breadth-first search with one change: the queue becomes a priority queue. These notes describe the priority queue ADT, then uniform-cost search, why it works, and how long it takes.

Picture breadth-first search as waves of runners sent out from the start vertex: at each tick of the clock, a runner crosses one edge. For a weighted graph, change the picture so that the runners run along the roads at a steady speed, and a runner takes ww minutes to cross a road of weight ww. Whenever a runner arrives at a place that no runner has reached before, record the time, and send out a fresh runner along each road leaving that place. A runner who arrives at a place that has already been reached simply stops.

Start the runners at home, at time 0:

  • Runners leave home for the cafe and the school. The runner to the school arrives first, at time 2, and new runners set out from the school, for the cafe and the store.
  • At time 3, the runner from the school arrives at the cafe. Runners leave the cafe for the park and the bank. (The runner from home arrives at the cafe at time 4, too late, and stops.)
  • At time 6, a runner reaches the park, and at time 8, a runner reaches the bank. From the bank, runners set out for the store and the gym.
  • At time 10, the runner from the bank reaches the store, and a runner sets out from there for the gym. (The runner from the school reaches the store at time 11, and stops.)
  • At time 13, the runner from the store reaches the gym. (The runner from the bank arrives at time 14, and stops.)

The time at which the first runner reaches each place is the cost of the cheapest path to it, since the first runner to arrive is the one that took the fastest route. As in breadth-first search, record for each place its parent, the place the first runner came from. Following the parents back from the gym gives gym, store, bank, cafe, school, home, the cheapest path, reversed.

A program can’t run the runners at the same time; it has to simulate them, one arrival at a time, in order of arrival time. Each runner who has set out but not yet arrived is an event: a place, and the time at which a runner will arrive there. The simulation repeatedly takes the event with the earliest time, out of all the events waiting, which need not be the event that was added first or last. At time 2, the waiting events are the cafe at time 4 and the school at time 2; at time 2, the school’s runners add events for the cafe at time 3 and the store at time 11. The cafe at 3, added last, comes next, ahead of the cafe at 4, added first.

The ADT for this job is the priority queue. Each item in a priority queue has a number, its priority, and the item that comes out first is the one with the smallest priority, no matter when it went in. (Some priority queues give out the largest priority first instead; flipping the sign of every priority turns one kind into the other.) The operations are:

  • insert(item, priority): add an item with the given priority
  • remove_min(): remove and return the item with the smallest priority
  • is_empty(): whether the priority queue holds no items

Priority queues show up wherever things must be handled in order of importance rather than order of arrival: patients in an emergency room, jobs waiting for a computer’s processor, packets waiting to be sent over a busy network link, and events in a simulation.

There are many ways to implement a priority queue. An unsorted list can insert in constant time, by appending, but then remove_min must look through the whole list for the smallest priority, which takes Θ(n)\Theta(n) time for a priority queue of nn items. A list kept sorted can remove the smallest item quickly, but must shift items over to insert each new one in its place, which takes O(n)O(n) time. A cleverer data structure, a binary heap, does both operations in O(log⁡n)O(\log n) time.

Python’s heapq module implements a priority queue as a binary heap, stored in an ordinary Python list. Its two main functions are heappush(heap, item), which adds item to the list heap, and heappop(heap), which removes and returns the smallest item. Each takes O(log⁡n)O(\log n) time. The list starts out empty, and should be changed only by these functions, since they keep its items in a special order. (The smallest item is always heap[0], but the rest of the list is not sorted.)

heapq has no separate priorities: it compares the items themselves, with <. To give each item a priority, push a tuple with the priority first. Python compares tuples one entry at a time, from left to right, so the tuple with the smallest first entry is the smallest tuple:

import heapq
events = []
heapq.heappush(events, (4, "cafe"))
heapq.heappush(events, (2, "school"))
heapq.heappush(events, (3, "cafe"))
print(heapq.heappop(events)) # (2, 'school')
print(heapq.heappop(events)) # (3, 'cafe')
print(heapq.heappop(events)) # (4, 'cafe')

When two tuples have the same priority, Python compares their second entries, and so on. Strings compare in dictionary order, so ties among places’ names are broken alphabetically. If the later entries are objects that can’t be compared with <, a tie raises a TypeError; a common fix is to put a counter, which increases with each push, between the priority and the item.

In uniform-cost search, the priority queue holds the waiting runners: each entry is a path cost, the vertex the runner is headed for, and the vertex the runner came from. The vertices that have been reached are called explored, and the priority queue is the frontier:

put (0, s, None) in the priority queue
while the priority queue is not empty:
remove the entry (c, u, p) with the smallest cost c
if u has been explored:
skip this entry
mark u as explored, with cost c and parent p
if u is the goal:
stop
for each neighbor v of u, along an edge of weight w:
if v has not been explored:
put (c + w, v, u) in the priority queue

To store a weighted graph, the dictionary for each vertex can map each neighbor to the weight of the edge to it, instead of listing the neighbors. In the town, the dictionary for home is {"cafe": 4, "school": 2}.

Notice that no line of the pseudocode compares two costs for the same vertex. The priority queue does the comparing. When a vertex has several entries in the frontier, the cheapest one comes out first, and its cost and parent are the ones recorded; the others are skipped when they come out.

backchain follows the parents back from the goal, and reverses the result, as in breadth-first search:

def backchain(parent, goal):
path = []
v = goal
while v is not None:
path.append(v)
v = parent[v]
path.reverse()
return path

The search compares only costs and vertices, so it works just as well on a directed graph, and on an implicit graph, whose neighbors and edge weights are computed only when the search asks for them.

Section titled “Three differences from breadth-first search”

Uniform-cost search is nearly breadth-first search, with a priority queue in place of the queue: in Python, heappush and heappop in place of append and popleft. But three details differ, and each matters.

A vertex is explored when it is removed, not when it is added. Breadth-first search marks a vertex as reached as soon as it’s added to the queue, since the first path found to a vertex has the fewest edges. In uniform-cost search, the first path found to a vertex need not be the cheapest. The first entry for the cafe is added when home is explored, with cost 4; the entry with cost 3, from the school, is added later. Only when a vertex is removed from the priority queue is its cost certain.

The same vertex can be in the priority queue more than once. The cafe is in the frontier twice, with costs 4 and 3, until the cheaper entry is removed. The more expensive entry is still there, and when it is removed, later, the cafe has already been explored, and the entry is skipped. Skipped entries are the runners who arrive too late.

The search stops when the goal is removed, not when it is added. When the bank is explored, an entry for the gym, with cost 14, is added. Stopping then would return a path that costs 14, but the cheapest costs 13, and its entry isn’t added until the store is explored.

Here is the town again, with the driving time on each road:

4 2 1 3 5 9 2 6 3 park cafe bank home school store gym

Uniform-cost search from home, with the gym as the goal, runs as follows. Each frontier entry is written as (cost, vertex, parent): the cost of a path from home, the vertex at the end of that path, and the vertex just before it. Each row of the table is one pass through the loop. It shows the entry removed from the frontier; what the search does with it, either exploring the vertex, by recording its cost and parent, or skipping it, if the vertex was explored already; the entries added for the neighbors that haven’t been explored; and the frontier afterward. The frontier is listed in order of cost, although a heap doesn’t store it that way, and its entries are shortened to (cost, vertex), since the parent doesn’t affect the order.

RemoveResultEntries addedFrontier afterward
(0, home, None)(0, home)
(0, home, None)explore home: cost 0(4, cafe, home), (2, school, home)(2, school), (4, cafe)
(2, school, home)explore school: cost 2(3, cafe, school), (11, store, school)(3, cafe), (4, cafe), (11, store)
(3, cafe, school)explore cafe: cost 3(6, park, cafe), (8, bank, cafe)(4, cafe), (6, park), (8, bank), (11, store)
(4, cafe, home)skip: cafe was explored(6, park), (8, bank), (11, store)
(6, park, cafe)explore park: cost 6(8, bank), (11, store)
(8, bank, cafe)explore bank: cost 8(10, store, bank), (14, gym, bank)(10, store), (11, store), (14, gym)
(10, store, bank)explore store: cost 10(13, gym, store)(11, store), (13, gym), (14, gym)
(11, store, school)skip: store was explored(13, gym), (14, gym)
(13, gym, store)explore gym: cost 13; it’s the goal, so stop(14, gym)

A few steps are worth a closer look:

  • The cafe gets two entries. Exploring home adds (4, cafe, home), and exploring the school, at cost 2, adds (3, cafe, school), the path home, school, cafe. The cheaper entry comes out first, so the cafe is explored with cost 3 and parent school. When (4, cafe, home) comes out, two rows later, the cafe has already been explored, so the entry is skipped.
  • Explored neighbors get no entries. The school’s neighbors are home, the cafe, and the store, but home has been explored, so exploring the school adds entries only for the cafe and the store. In the same way, exploring the cafe adds entries only for the park and the bank, and exploring the park, whose only neighbor is the cafe, adds nothing.
  • The store and the gym also get two entries each. The store’s entry from the school costs 2+9=112 + 9 = 11, and its entry from the bank costs 8+2=108 + 2 = 10; the gym’s entry from the bank costs 8+6=148 + 6 = 14, and its entry from the store costs 10+3=1310 + 3 = 13. Each time, the cheaper entry comes out first.
  • The search stops when the gym is removed, with cost 13. The entry (14, gym, bank) is still in the frontier, and is never removed. Backchaining from the gym gives its parent, the store; the store’s parent, the bank; then the cafe, the school, and home, whose parent is None. Reversed, the path is home, school, cafe, bank, store, gym, which costs 2+1+5+2+3=132 + 1 + 5 + 2 + 3 = 13.

The search explores every vertex that is cheaper to reach than the goal, here every vertex, in order of cost: home, school, cafe, park, bank, store, gym. If the search doesn’t stop at a goal, but runs until the frontier is empty, the parents form a tree of cheapest paths from the start to every vertex that can be reached:

4 9 6 2 1 3 5 2 3 park 6 cafe 3 bank 8 home 0 school 2 store 10 gym 13

If every edge has weight 1, a path’s cost is its number of edges, and uniform-cost search finds the same distances as breadth-first search.

The runners give the idea: the first runner to reach a place took the fastest route. Here is the same argument in terms of the pseudocode. Suppose the search removes an entry (c,u,p)(c, u, p) and explores uu, and that the costs of all the vertices explored before uu are correct. We claim that cc is the cost of a cheapest path to uu: every path from the start to uu costs at least cc.

The entry was added when pp was explored, so there is a path to uu, through pp, that costs cc. Now take any path PP from the start to uu. It starts at an explored vertex, the start, and ends at uu, which is not yet explored, so somewhere along PP there is a first edge, from some vertex xx to some vertex yy, that leaves the explored vertices: xx is explored and yy is not. When xx was explored, an entry for yy was added, with cost cost(x)+w\text{cost}(x) + w, where ww is the weight of the edge from xx to yy. Since cost(x)\text{cost}(x) is the cheapest way to reach xx, that entry costs no more than the part of PP from the start to yy. That entry is still in the frontier, since yy isn’t explored. The priority queue removes the smallest entry, and it removed (c,u,p)(c, u, p) while the entry for yy was still there, so cc is at most the cost of the entry for yy. Putting the steps together:

c≤cost(x)+w≤cost of P up to y≤cost of P.c \le \text{cost}(x) + w \le \text{cost of } P \text{ up to } y \le \text{cost of } P.

The last step holds because the rest of PP, from yy to uu, can’t have a negative cost. So no path to uu costs less than cc.

That last step is where the argument needs every weight to be zero or more. With a negative weight, a path that looks expensive partway along can become cheap at the end. Suppose a graph has edges from ss to aa of weight 1, from ss to bb of weight 3, and from bb to aa of weight −3-3. Uniform-cost search explores aa first, with cost 1, but the path s,b,as, b, a costs 0. Road maps, driving times, and fares have no negative weights, and uniform-cost search is correct for all of them. Graphs with negative weights need a different algorithm.

How many entries can the priority queue hold? An entry is added only when a vertex is explored, one entry for each edge leaving it, and each vertex is explored at most once. So in a graph with nn vertices and mm edges, at most mm entries are added in a directed graph, and at most 2m2m in an undirected one, one from each end of each edge, plus the entry for the start. Each entry is pushed once and popped at most once.

With a binary heap, such as heapq, each push or pop takes O(log⁡m)O(\log m) time, since the heap holds at most 2m+12m + 1 entries. Apart from the pushes and pops, the search does a constant amount of work for each entry and for each edge, so the whole search takes O((n+m)log⁡m)O((n + m) \log m) time. A graph has at most n2n^2 edges, so log⁡m≤log⁡n2=2log⁡n\log m \le \log n^2 = 2 \log n, and the bound is often written O((n+m)log⁡n)O((n + m) \log n).

The priority queue’s implementation matters. With an unsorted list, each remove_min takes time proportional to the number of entries, and the search could take time proportional to m2m^2. A road map of the United States has tens of millions of intersections, and the difference between mlog⁡mm \log m and m2m^2 is the difference between a search that takes a fraction of a second and one that takes years.

When a search stops at a goal, it explores only the vertices cheaper to reach than the goal, which can be far fewer than all nn. That matters most in an implicit graph with too many vertices to store: the search builds only the part of the graph it explores.

Uniform-cost search is a version of an algorithm published by Edsger Dijkstra in 1959, and known as Dijkstra’s algorithm. Dijkstra is said to have worked it out in about twenty minutes, without pencil and paper, while having coffee with his fiancée at a café in Amsterdam, in order to demonstrate a new computer by computing driving routes between Dutch cities.

Dijkstra’s algorithm, as it is usually written, finds the cheapest paths from the start to every vertex. It begins with every vertex in the priority queue, with priority ∞\infty for every vertex but the start. When a vertex uu is removed, the algorithm checks each neighbor vv, and if the path through uu is cheaper than vv‘s current priority, it lowers vv‘s priority to the new cost, an extra priority queue operation called decrease-key. So each vertex is in the priority queue exactly once, and no entries are ever skipped. Uniform-cost search instead adds a new entry for the cheaper path, and leaves the old one to be skipped. That trades a little extra space for a simpler priority queue, without decrease-key, which heapq doesn’t have; and since uniform-cost search adds a vertex to the priority queue only when a path to it is found, it never needs a list of all the vertices, and works on graphs too large to store. The name uniform-cost search comes from artificial intelligence, where searches of enormous implicit graphs are common.

The runners on roads are adapted from the description of breadth-first search as waves of runners in the CS 1 notes by Balkcom et al., chapter 18, “Graphs.” The pseudocode, with the goal test applied when an entry is removed, and the handling of repeated entries, follow Russell and Norvig, Artificial Intelligence: A Modern Approach, chapter 3, and the CS 76 mazeworld assignment by Balkcom, which uses the approach from the heapq documentation of adding a new entry rather than changing an old one. The correctness argument and the running time follow Cormen, Leiserson, Rivest, and Stein, Introduction to Algorithms, in its chapter on single-source shortest paths.

The text, the worked search of the town from home to the gym, the figures, and the practice problems were drafted by Claude (Anthropic) at Balkcom’s direction, following the sources above, and then edited by Balkcom. The same material can also be found in Kleinberg and Tardos, Algorithm Design, section 4.4, and in Sedgewick and Wayne, Algorithms, section 4.4.

  • heapq: Heap queue algorithm, including its notes on implementing a priority queue
  • Edsger W. Dijkstra, “A note on two problems in connexion with graphs,” Numerische Mathematik 1 (1959), 269–271: the original paper, three pages long
  • NetworkX: Shortest paths: library implementations of Dijkstra’s algorithm and others

Try these on your own before checking the solutions.

Run uniform-cost search on the town map from the park, without stopping at a goal. Give the order in which vertices are explored, with their costs and parents, and list the entries that are skipped. What is the cheapest path from the park to the gym?

(Solution)

Suppose uniform-cost search checked for the goal when an entry is added to the frontier, and returned at once. What path from home to the gym would it return on the town map, and what would it cost?

(Solution)

Suppose uniform-cost search marked each vertex as explored, with its cost and parent, when its entry is added to the frontier, as breadth-first search does, and never added a second entry for a vertex that’s already marked. What cost would it record for the cafe, starting from home? For the gym?

(Solution)

Every edge of a graph has weight 5. Explain why uniform-cost search explores the vertices in order of their number of edges from the start, and how the cost it finds for each vertex compares with the distance found by breadth-first search.

(Solution)

To make every weight positive in a graph with some negative weights, someone suggests adding the same large number to every weight. Show, with the town map or a small graph of your own, that a cheapest path in the new graph need not be a cheapest path in the original one.

(Solution)

In a graph where every vertex is adjacent to every other vertex, how many entries could be in the frontier at once, in terms of nn? Give an upper bound, and explain it.

(Solution)