Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0, quickjs 0.32.0, Java 25, Clang 21.0.0 and Go 1.27.1
  • By MySmartCoPilot
Free core, Pro depth: 9 of 25 minutes free
Free: When one position is not enough · The first-true search · Lower bound and upper bound, traced · 2 more sections. With a pass: 2 in-depth parts and 3 interview answers, about 16 minutes.

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 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, mid could be the first true position, so keep it: hi = mid;
  • if it is false, mid and 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.

On 12, 15, 15, 15, 18, 21, 21, 30 the test a[i] ≥ 15 first holds at position 1 and a[i] > 15 at position 4; the 3 copies lie between.Two first-true searches on one sorted listvalue012115215315418521621730a[i] ≥ 15falsetruetruetruetruetruetruetruelower bound = 1a[i] > 15falsefalsefalsefalsetruetruetruetrueupper bound = 4the copies of 15: positions 1, 2, 3count = upper bound − lower bound = 4 − 1 = 3

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:

Both bounds from one first-true search Python · bounds/bounds.py
# 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

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.

Lower bound, upper bound and the first-true search
Criterion Lower boundUpper boundFirst-true search
Test at position i a[i] ≥ xa[i] > xany test that turns true once
For a repeated x the first copyone past the last copythe first true position
For a missing x where x would be insertedthe same positionn when nothing is true
In Python bisect_leftbisect_rightwrite it once
When to choose First copy, insertion point, count of values below xLast copy (minus one), count of values up to xAny 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:

Marks from 60 to 75 with two bisect calls Python · marks/marks_in_range.py
# 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

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.binarySearch returns the position of some copy, not necessarily the first, or a negative number that encodes the insertion point.
  • C++ has std::lower_bound and std::upper_bound, which return iterators, and std::equal_range, which returns both bounds at once.
  • Go’s slices.BinarySearch returns the lower bound and whether the value was found.
  • Go’s sort.Search is 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.

  1. Question 1 of 4 In the sorted list 4, 7, 7, 7, 7, 9, 12, the lower bound of 7 is 1. The upper bound is 5. How many copies of 7 are there?

    Type a number.

    Show the answer to question 1

    Answer: 4 copies

    The copies of a value fill the positions from its lower bound up to, but not including, its upper bound, so their count is the difference: 5 − 1 = 4.

  2. Question 2 of 4 What do bisect_left and bisect_right return for 10 in the sorted list [3, 8, 12, 20]?

    Choose one answer.

    Show the answer to question 2

    Answer: Both return 2

    For a missing value both bounds are the position where it would be inserted to keep the list sorted: after 8 and before 12, position 2. Neither function ever returns −1 or raises for a missing value.

  3. Question 3 of 4 In the first-true search, why does the true branch set hi = mid rather than hi = mid - 1?

    Read the code, then choose one answer.

    while lo < hi:
        mid = lo + (hi - lo) // 2
        if test(mid):
            hi = mid
        else:
            lo = mid + 1
    return lo
    Show the answer to question 3

    Answer: mid may itself be the first true position, so it must stay inside the range

    A true test at mid says that the answer is mid or earlier. Moving hi to mid - 1 would throw away mid itself, which is the answer whenever the position before it is false, as in the list [12, 15] with x = 15.

  4. Question 4 of 4 Which expression counts the values x of a sorted list a with 60 ≤ x ≤ 75?

    Choose one answer.

    Show the answer to question 4

    Answer: bisect_right(a, 75) - bisect_left(a, 60)

    The values from 60 on start at bisect_left(a, 60), and the values above 75 start at bisect_right(a, 75), so the values in between number the difference. Using bisect_left for 75 would leave out the copies of 75, and bisect_right for 60 would leave out the copies of 60.

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.

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.

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

  1. 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.

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • The lower bound is the first position with a[i] >= x, the upper bound the first with a[i] > x; the copies of x sit between them and their count is the difference.
  • Both are one first-true search on [lo, hi): keep mid with hi = mid when the test is true, skip it with lo = mid + 1 when it is false, and return lo.
  • 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.