Exponential search
Find a range that holds the target by doubling a bound (1, 2, 4, 8...), then binary search inside that range.
O(log i)Space O(1)2 practice problemsStep-through pictureThe idea
Binary search needs to know where the list ends. Sometimes you don't know that, such as a feed that loads more items as you scroll, or a list so long that it is better to start small. Exponential search handles this.
It first looks at index 1, then 2, then 4, then 8, doubling each time, until it finds an item that is not smaller than the target (or runs past the end). Now the target, if present, must sit between the previous bound and this one. Then it runs a normal binary search in that small range.
If the target is near the start, this is faster than plain binary search over the whole list. A target at index i is found in about 2 × log i steps, however long the list is.
An everyday example
Finding a page in a book with no page count
You need page 300 in a huge book whose last page you don't know. You flip to page 1, then 2, 4, 8, 16, and so on, doubling each time. At page 256 you are still too early, but page 512 is past it. Now you know page 300 is between 256 and 512, and you can split that gap in half again and again.
| In the example | In the code |
|---|---|
| Doubling the page you flip to | bound *= 2 |
| Page 512 is past the one you want | items[bound] >= target stops the doubling |
| The gap from 256 to 512 | low = Math.floor(bound / 2) to high = bound |
| Halving the gap | The binary search loop |
From the slow way to exponential search
- Binary search needs both ends of the range before it starts. For a feed that keeps loading, or a sorted list with no known length, there is no
highto start from. The obvious fix is to walk forward from index0until you pass the target, which costsichecks for a target at indexi. - Walking one step at a time is slow because each step rules out only one item. Doubling the step instead, checking indexes
1, 2, 4, 8, 16, reaches indexiafter only aboutlog₂ ichecks. You stop at the firstboundwhereitems[bound]is not smaller than the target, or whereboundruns past the end. - When the doubling stops, you know two things. The item at
bound / 2was smaller than the target, because it was checked on the step before (or it is index0), anditems[bound]is not smaller, or doesn't exist. So the target, if present, sits betweenbound / 2andbound: a range of abouti / 2items with both ends known. - Run plain binary search on that range, which takes about
log₂ imore checks. The total cost depends on where the target is, not on how long the list is. Doubling to find a range, then binary search inside it: that loop is exponential search.
How it works
- If the list is empty, return
-1. - Start with
bound = 1. - While
boundis inside the list anditems[bound]is smaller than the target, double it:bound *= 2. - The target can only be between
low = Math.floor(bound / 2)andhigh = Math.min(bound, items.length - 1). - Run a normal binary search from
lowtohighand return what it finds.
Watch it run
This picture runs the code below on a real input. Press Step to move one step at a time, or Play to watch. The highlighted line is the one that just ran. Change the input to see other cases.
See every step at onceThe same run, laid out side by side
The code
Point at a note to light up the lines it explains.
function exponentialSearch(items, target) {if (items.length === 0) return -1;let bound = 1;while (bound < items.length && items[bound] < target) {bound *= 2;}let low = Math.floor(bound / 2);let high = Math.min(bound, items.length - 1);while (low <= high) {const mid = Math.floor((low + high) / 2);if (items[mid] === target) return mid;if (items[mid] < target) {low = mid + 1;} else {high = mid - 1;}}return -1;}
How fast it is
| Time | O(log i) |
|---|---|
| Extra memory | O(1) |
i is the target's index. The doubling takes about log₂ i steps and the binary search about log₂ i more.
- Best case
O(1)The target is at index
0or1: the doubling stops at once and the range has two items. Finding1at index 0 in the trace's list takes 2 reads. - Average case
O(log i)A target at index
icosts aboutlog₂ idoubling reads pluslog₂ ibinary search reads. For index 1,000 in a list of a billion numbers, that is 17 reads against 30 for plain binary search. - Worst case
O(log n)The target is near the end or missing: about
2 × log₂ nreads, 58 for a target near the end of a billion items, against 29 for plain binary search.
Common mistakes
- Starting
boundat0. Doubling0gives0forever, so the loop never ends. Start at1. - Forgetting
Math.min(bound, items.length - 1). Whenboundran past the end,highpoints outside the list. - Starting the binary search at
0instead ofbound / 2. It still works, but it throws away the speed-up for targets near the start. - Using it on an unsorted list. Like binary search, it silently gives wrong answers.
When to use it
Look for these clues in a problem:
- The list is sorted but its length is unknown, or it is unbounded, like a stream or an endless feed.
- The target is likely close to the start of a very long sorted list.
- Reading far into the data is expensive, such as loading pages from a server, so you want to probe small indexes first.
Check yourself
Answer each one in your head first, then open it.
Why does bound start at 1 and not 0?
Doubling 0 gives 0 forever, so the loop would never move. Index 0 is still covered, because the final range starts at Math.floor(1 / 2) = 0.
If the target is at index 100 of a long list, what is the last bound the doubling reaches?
128. The items at 1, 2, 4, ..., 64 are all smaller than the target, and 128 is the first power of two past 100.
Why can the final range start at bound / 2 instead of 0?
The previous doubling step read items[bound / 2] and found it smaller than the target, so it and everything before it are already ruled out.
When is exponential search slower than plain binary search?
When the length is known and the target is near the end. It spends about log₂ n reads doubling and then about log₂ n more inside the last range, close to twice as many.
Practice problems
Stuck on one? Tuto is in the playground too, and gives you hints, never the answer.
0 of 2 solved. Solve one to earn the bronze medal, half for silver, and all of them for gold.
Start practicingGo further
You don't need these to start practicing. Open one when you want more.
Why it works
The reasoning that makes it always right
While doubling, the loop keeps a promise: every item before index Math.floor(bound / 2) is smaller than target. At the start bound = 1, so that covers no items and is true. Each doubling happens only when items[bound] < target, and since the list is sorted, every item up to bound is smaller too. The new bound is twice the old one, so the promise holds for it.
The doubling stops either because bound is past the end or because items[bound] >= target. In the second case, if items[bound] equals the target it is inside the range, and if it is bigger, every later item is bigger too. Together with the promise, the target can only be from Math.floor(bound / 2) to Math.min(bound, items.length - 1).
The binary search then runs on a range that holds the target if it is anywhere, so its usual proof finishes the job. Both loops end: the doubling because bound passes the length after at most about log₂ n steps, and the binary search because its range shrinks with every check.
Going deeper
3 more ideas
Why the range is right
The doubling stopped because items[bound] was at least the target, or because bound ran past the end. The step before, items[bound / 2] was smaller than the target (or it was index 0). Since the list is sorted, the target can't be before bound / 2 or after bound. So the binary search range holds the target if it is anywhere.
How long it takes
If the target is at index i, the doubling stops after about log₂ i steps, and the binary search range has about i / 2 items, so it takes about log₂ i more steps. The total is O(log i). Since i is at most n, it is never worse than O(log n).
Galloping in real sorting code
TimSort, the sort used by V8 for Array.prototype.sort, uses the same idea while merging. When one run keeps winning, it stops comparing one item at a time and "gallops": it checks positions 1, 3, 7, 15... ahead to find how far it can jump, then binary searches that range and copies the whole block at once.
Work it by hand
Every step in a table, with why
Look for 50 in a sorted list of 13 numbers. It isn't there, and bound runs past the end, so the trace shows the clamp too.
items = [1, 3, 8, 10, 15, 22, 26, 31, 37, 44, 52, 60, 71], target = 50
| Step | Phase | bound or range | mid | Item read | What happens | Why |
|---|---|---|---|---|---|---|
| 1 | double | bound = 1 | - | items[1] = 3 | 3 < 50, so bound = 2 | The first probe is index 1. 3 is smaller than the target, so keep doubling. |
| 2 | double | bound = 2 | - | items[2] = 8 | 8 < 50, so bound = 4 | Still smaller. Every item up to index 2 is now ruled out. |
| 3 | double | bound = 4 | - | items[4] = 15 | 15 < 50, so bound = 8 | Still smaller. The jumps get bigger each time. |
| 4 | double | bound = 8 | - | items[8] = 37 | 37 < 50, so bound = 16 | Still smaller, so everything up to index 8 is out. |
| 5 | double | bound = 16 | - | none | 16 < 13 is false: stop | bound ran past the end of the 13-item list, so the doubling stops without reading anything. |
| 6 | set up | low = 8, high = 12 | - | - | high = Math.min(16, 12) | The range starts at 16 / 2 = 8. Math.min pulls high back to the last index, 12, so the search never reads past the end. |
| 7 | binary | 8 to 12 | 10 | items[10] = 52 | 52 > 50, so high = 9 | A normal binary search step on the small range. |
| 8 | binary | 8 to 9 | 8 | items[8] = 37 | 37 < 50, so low = 9 | 37 is too small, so move right. |
| 9 | binary | 9 to 9 | 9 | items[9] = 44 | 44 < 50, so low = 10 | One item is left, and it is too small too. |
| 10 | binary | 10 to 9 | - | - | low > high: stop | The range is empty, so 50 is not in the list. |
It returns -1 after 7 item reads: 4 while doubling and 3 in the binary search. The target would have sat between 44 and 52, which is exactly where the range closed.
Compared with other ways
When to pick something else
| Approach | Time | Extra memory | Choose it when |
|---|---|---|---|
| Exponential search | O(log i) | O(1) | The list has no known end, or the target is usually near the front. |
| Binary search | O(log n) | O(1) | You know the length and targets can be anywhere: its worst case needs about half the reads. |
| Linear search from the front | O(i) | O(1) | The target is almost always in the first few items, or the list isn't sorted. |
Extra memory: O(1): bound, low, high, and mid.
How the steps grow
- Exponential search, Binary search
O(log i) - Linear search from the front
O(i)
| Approach | n = 10 | n = 1,000 | n = 1,000,000 |
|---|---|---|---|
| Exponential search | 3 | 10 | 20 |
| Binary search | 3 | 10 | 20 |
| Linear search from the front | 10 | 1,000 | 1 million |
Inputs that need care
6 tricky inputs
- An empty list:
items = [] - The first line returns
-1right away, before any index is read. - One item:
items = [7], target7 bound = 1is already past the end, so the doubling never runs. The range is0toMath.min(1, 0) = 0, and the binary search finds7at index 0.- A target at index 0: the trace's list, target
1 - The doubling reads
items[1] = 3, which is not smaller, so it stops. The range is0to1, and the binary search finds1atmid = 0. - A target bigger than every item: the trace's list, target
90 - The doubling runs until
bound = 16passes the end, then the binary search covers indexes 8 to 12 and returns-1. This is the slowest kind of input. - No known length: a function
get(i)that returnsInfinitypast the end - Drop the
bound < items.lengthcheck. An out-of-range read returnsInfinity, which is never smaller than the target, so the doubling stops by itself. The binary search then reads throughgettoo. - Copies of the target:
[2, 5, 5, 5, 9], target5 - It returns whichever copy the binary search lands on. For the first copy, run a lower bound on the final range instead.
Variations and follow-ups
Twists interviewers add
Searching near a known spot
If the last answer was at index p and the next target is probably close, double outward from p: check p + 1, p + 2, p + 4, and so on. The cost is O(log d), where d is the distance from p. Scrolling through a sorted timeline often has this shape.
Lower bound inside the range
Swap the final binary search for a lower bound to get the first copy of the target, or the spot to insert it. The doubling phase doesn't change, because it only asks "is items[bound] smaller than the target?".
Bigger steps
You can grow bound by any factor, such as 4 instead of 2. That means fewer doubling reads but a wider final range. Doubling keeps the two phases balanced, so it is the usual choice.
In an interview
Spot it, explain it, answer follow-ups
- Spot it from "sorted array of unknown size", "infinite sorted stream", an interface like
reader.get(i)that fails past the end, or "the target is likely near the start". - Explain it as two phases: "first find a range by doubling, then binary search inside it". Then say why the range is right: the previous
boundwas too small, and this one isn't. - Test by hand: a target at index
0, a target exactly at a power of two like index4, a target past the end, and a list of one item. - The usual follow-up is "what is the time if the target is at index
i?". Answer:O(log i), because both phases take aboutlog₂ isteps. It is never worse thanO(log n), and at most about twice plain binary search.
Where frontend code uses it
The same idea in real UI code
- Searching an infinite-scroll feed sorted by time when the total count is unknown: probe pages 1, 2, 4, 8 until you pass the target time, then binary search the pages between.
- V8's
Array.prototype.sortis TimSort, which uses galloping (doubling, then binary search) when merging runs where one side keeps winning. - Looking up a recent version in a long sorted change log: most lookups are for recent entries near the front, so doubling from the front is fast.