Data Structures & Algorithms (DSA) Module 6 – Binary search and searching
Lower bound, upper bound and first-true search
Find the first and last copy of a value, counts and insertion points with one first-true search: lower and upper bounds, bisect and its library twins.
What you will learn
- Find the first and last positions of a value with two bound searches
- Express binary search as the first position where a monotone test is true
- Count the values in a range with two bounds instead of a scan
- Use bisect, Arrays.binarySearch, lower_bound and slices.BinarySearch correctly
Before you start
What you’ll learn · Before you start
What you will learn
- Find the first and last positions of a value with two bound searches
- Express binary search as the first position where a monotone test is true
- Count the values in a range with two bounds instead of a scan
- Use bisect, Arrays.binarySearch, lower_bound and slices.BinarySearch correctly
Before you start
On this page
A lower bound is the first position in a sorted array whose value is at least x, and an upper bound is the first
position whose value is greater than x. Both are cases of one search, the first-true search: given a test that is
false for the first positions and true for all the rest, it finds the first position where the test is true. When x
repeats, its copies fill exactly the positions from the lower bound up to, but not including, the upper bound, so two
searches give the first copy, the last copy and the count. When x is missing, both bounds return the same position:
where x would be inserted to keep the array sorted. Python’s bisect_left and bisect_right compute these two
bounds.
When one position is not enough
The search of the previous lesson stops at the first copy of x it happens to probe. With repeated values that copy can be anywhere in the run: in the list 12, 15, 15, 15, 18 it may report any of the three 15s. Many questions need a particular position instead:
- the first or the last occurrence of a value;
- how many times a value occurs, or how many values fall in a range such as marks from 60 to 75;
- where a missing value would go, to insert it or to find its neighbours on both sides.
Each of these is a lower bound, an upper bound or a difference of the two, and each costs about 20 probes on a million values instead of a scan.
The first-true search
The first-true search works on any test of positions that is false up to some point and true from there on, for
example “is a[i] >= 15?” on a sorted list. It keeps a half-open range [lo, hi) of positions it has not decided
yet, with this invariant: the test is false at every position before lo and true at every position from hi on. It starts with lo = 0 and
hi = n, where nothing is decided, and repeats one step while lo < hi:
- test the middle position
mid; - if the test is true there,
midcould be the first true position, so keep it:hi = mid; - if it is false,
midand everything before it are false:lo = mid + 1.
When lo meets hi, every position before it is false and every position from it on is true, so lo is the answer.
If no position passes the test, the answer is n, one past the end. The undecided range at least halves on every
probe, so the search makes at most ⌊log₂ n⌋ + 1 probes, the same bound as before.
Lower and upper bound as two first-true searches
Text description of the diagram
The top row shows a sorted list of eight values at positions 0 to 7: 12, 15, 15, 15, 18, 21, 21 and 30.
The second row evaluates the test "a[i] ≥ 15" at every position: false at position 0, then true from position 1 to the end. An arrow marks the first true position, the lower bound, 1.
The third row evaluates the test "a[i] > 15": false at positions 0 to 3, then true from position 4 to the end. An arrow marks its first true position, the upper bound, 4.
A bracket under positions 1, 2 and 3 marks the copies of 15, which lie from the lower bound up to, but not including, the upper bound. The last line says that their count is upper bound minus lower bound, 4 − 1 = 3.
The lower bound of x is this search with the test a[i] >= x, and the upper bound uses a[i] > x. That single
character is the whole difference between them. The same search also finds the first broken commit in a history, or
an integer square root, because each is a test that turns true once.
Lower bound and upper bound, traced
This program writes the search once, builds both bounds on it, prints every probe of the lower bound of 15, and then
checks both functions against bisect on 2,000 random lists:
# Lower bound, upper bound and the first-true search they are both made of: a traced run on a list with repeats,
# then a check against Python's bisect module on 2,000 random lists.
import bisect
import random
def first_true(lo, hi, test, trace=False):
"""The smallest i in [lo, hi) with test(i) true, or hi when there is none.
test must be false on a prefix of [lo, hi) and true on the rest."""
while lo < hi: # invariant: test is false before lo and true from hi on
mid = lo + (hi - lo) // 2
if trace:
print(f" lo={lo} hi={hi} mid={mid} test(mid)={test(mid)}")
if test(mid):
hi = mid # mid may be the answer: keep it
else:
lo = mid + 1 # mid and everything before it are false
return lo
def lower_bound(a, x, trace=False):
"""The first position whose value is at least x (len(a) when there is none)."""
return first_true(0, len(a), lambda i: a[i] >= x, trace)
def upper_bound(a, x):
"""The first position whose value is greater than x (len(a) when there is none)."""
return first_true(0, len(a), lambda i: a[i] > x)
a = [12, 15, 15, 15, 18, 21, 21, 30]
print("a =", a)
print("lower bound of 15, probe by probe:")
lower_bound(a, 15, trace=True)
for x in (15, 21, 20, 10, 31):
lo, hi = lower_bound(a, x), upper_bound(a, x)
print(f"x = {x}: lower bound {lo}, upper bound {hi}, copies {hi - lo}")
rng = random.Random(5)
for _ in range(2000):
b = sorted(rng.choices(range(8), k=rng.randint(0, 12)))
x = rng.randint(-1, 8)
assert lower_bound(b, x) == bisect.bisect_left(b, x)
assert upper_bound(b, x) == bisect.bisect_right(b, x)
print("2,000 random lists: lower_bound == bisect_left and upper_bound == bisect_right every time") Output
a = [12, 15, 15, 15, 18, 21, 21, 30] lower bound of 15, probe by probe: lo=0 hi=8 mid=4 test(mid)=True lo=0 hi=4 mid=2 test(mid)=True lo=0 hi=2 mid=1 test(mid)=True lo=0 hi=1 mid=0 test(mid)=False x = 15: lower bound 1, upper bound 4, copies 3 x = 21: lower bound 5, upper bound 7, copies 2 x = 20: lower bound 5, upper bound 5, copies 0 x = 10: lower bound 0, upper bound 0, copies 0 x = 31: lower bound 8, upper bound 8, copies 0 2,000 random lists: lower_bound == bisect_left and upper_bound == bisect_right every time
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bounds.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
| Probe | lo, hi | mid | a[mid] ≥ 15? | Step |
|---|---|---|---|---|
| 1 | 0, 8 | 4 | 18: true | hi = 4 |
| 2 | 0, 4 | 2 | 15: true | hi = 2 |
| 3 | 0, 2 | 1 | 15: true | hi = 1 |
| 4 | 0, 1 | 0 | 12: false | lo = 1 |
The search does not stop at the first 15 it sees: a true test only moves hi left, until the position before it is
known to be false. For 15 the bounds are 1 and 4, so the copies are at positions 1 to 3; for 20 both bounds are 5,
the position 20 would take.
| Criterion | Lower bound | Upper bound | First-true search |
|---|---|---|---|
| Test at position i | a[i] ≥ x | a[i] > x | any test that turns true once |
| For a repeated x | the first copy | one past the last copy | the first true position |
| For a missing x | where x would be inserted | the same position | n when nothing is true |
| In Python | bisect_left | bisect_right | write it once |
| When to choose | First copy, insertion point, count of values below x | Last copy (minus one), count of values up to x | Any yes-or-no question over a range of positions or numbers |
Counting a range with two bounds
How many of a million sorted exam marks lie between 60 and 75? The marks from 60 on start at the lower bound of 60, and the marks above 75 start at the upper bound of 75, so the answer is the difference. A key function that counts its own calls shows how much of the list each call reads:
# How many of 1,000,000 sorted exam marks lie in [60, 75]? Two bisect calls answer it. A key function that counts
# its calls shows how many marks each call read (bisect applies the key to the list's values, never to x).
import bisect
import random
rng = random.Random(11)
marks = sorted(min(100, max(0, round(rng.gauss(58, 15)))) for _ in range(1_000_000))
reads = 0
def seen(mark):
global reads
reads += 1
return mark
left = bisect.bisect_left(marks, 60, key=seen) # first mark >= 60
left_reads, reads = reads, 0
right = bisect.bisect_right(marks, 75, key=seen) # first mark > 75
right_reads = reads
print(f"marks from 60 to 75: {right - left:,} of {len(marks):,}")
print(f"bisect_left read {left_reads} marks, bisect_right read {right_reads}")
print(f"below 60: {left:,}; above 75: {len(marks) - right:,}")
print(f"a scan agrees: {sum(60 <= m <= 75 for m in marks):,}, after reading all {len(marks):,}") Output
marks from 60 to 75: 339,241 of 1,000,000 bisect_left read 20 marks, bisect_right read 20 below 60: 538,745; above 75: 122,014 a scan agrees: 339,241, after reading all 1,000,000
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 marks_in_range.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Each call read 20 marks, and the scan that confirms the answer read all 1,000,000. The two bounds also give the marks below 60 and above 75 without another read.
The same bounds in other languages
Most languages ship these searches under different names and with different promises:
- Java’s
Arrays.binarySearchreturns the position of some copy, not necessarily the first, or a negative number that encodes the insertion point. - C++ has
std::lower_boundandstd::upper_bound, which return iterators, andstd::equal_range, which returns both bounds at once. - Go’s
slices.BinarySearchreturns the lower bound and whether the value was found. - Go’s
sort.Searchis the first-true search itself, over positions 0 to n − 1. - JavaScript has no binary search in its standard library.
Check yourself
4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
The first-true search proved, and the bounds in four more languages Pro: opens with a Pro, Premium or Ultimate pass
A short proof shows why the template returns the first true position for every input, which is what makes it safe to reuse without testing its boundaries again. The same bounds then run in JavaScript, Java, C++ and Go, beside each library’s own call.
In this part:
- Why the template always returns the first true position: the proof
- JavaScript, Java, C++ and Go, built and run
- What each library promises, side by side
2 runnable examples (C++, Go, Java, JavaScript, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
First-true search beyond arrays, with five slips drilled Pro: opens with a Pro, Premium or Ultimate pass
The template needs no array: it searches any range of whole numbers on which a test turns from false to true once. This part uses it on commits and on square roots, measures five boundary slips against a scan, and ends with the practice exercise.
In this part:
- Ten builds instead of 732: finding the first broken commit
- Integer square roots up to 10¹⁸, checked against math.isqrt
- Five slips, measured against a scan on 20,000 cases
- Keys, descending order and records, without new code
- Searching part of a list: the second bound starts at the first
- Practise: the first and last position, within a read budget
- First and last position of a value, within a read budget
- Results of the sample tests
- Key takeaways from these parts
2 runnable examples (Python) · 1 exercise with sample tests · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
bisect_left returns 4 for a value that is not in a sorted list. What does that 4 tell you, and what can you do with it?
It is the insertion point: the position where the value would go to keep the list sorted. So exactly 4 values are smaller than it, and every value from position 4 on is larger. That one number answers several questions. It is the value's rank, the count of values below it. It locates the neighbours: the largest smaller value is at position 3 and the smallest larger one at position 4, and either may not exist when the insertion point is 0 or equals the length. And inserting the value at position 4, which bisect.insort does, keeps the list sorted, although the insertion itself shifts every later value. For a value that is present, bisect_left gives its first copy instead, and the same reasoning about neighbours holds.
-
Fresher to mid
A colleague says two bound searches for the first and last position of x waste probes, and proposes one search that stops at any copy, then two short searches inside the range still open at that moment. Compare the probe counts on 10⁶ values, worst case and typical, and say which you would write.
-
Mid to senior
You search 2,000 commits for the first one that broke a test. Broken commits always fail, but the test also fails on 5 % of runs of good commits, at random. What does a plain first-true search return, and how do you make the answer reliable at a known cost?
-
Fresher to mid
A sorted list holds a day's 10⁵ train departure times in minutes after midnight. For 10⁶ queries, return the first departure at or after time t and the last one strictly before t. Write the searches, handle times before the first and after the last train, and give the total cost.
Key takeaways
- The lower bound is the first position with
a[i] >= x, the upper bound the first witha[i] > x; the copies of x sit between them and their count is the difference. - Both are one first-true search on
[lo, hi): keepmidwithhi = midwhen the test is true, skip it withlo = mid + 1when it is false, and returnlo. - For a missing x both bounds give its insertion point, which may be
n. - A range count is two bounds and a subtraction: 40 reads instead of a million.
References
- bisect: array bisection algorithm (Python Software Foundation)
- math.isqrt (Python Software Foundation)
- Java SE 25 API: Arrays.binarySearch (Oracle)
- std::lower_bound (cppreference.com)
- std::upper_bound (cppreference.com)
- std::equal_range (cppreference.com)
- Package slices: BinarySearch (The Go Authors)
- Package sort: Search (The Go Authors)
- ECMAScript language specification: Array.prototype.indexOf (Ecma International (TC39))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress