Part IV · Search, Selection and Optimisation Pattern 11 4 problems

Modified Binary Search

Binary search is not about sorted arrays. It is about any yes/no question whose answer flips exactly once along a line. Find the flip, and you have halved the world.

Everyone can write the textbook version. Interviews test the modified versions: rotated arrays, first and last occurrence, peaks, and the big one, binary search on the answer, where there is no array to search at all. All of them are the same loop with a different question in the middle.

Contents

  1. When to use
  2. Core idea
  3. Getting the loop right, every time
  4. The templates
  5. Binary search on the answer
  6. Common mistakes
  7. Binary Search
  8. Search in Rotated Sorted Array
  9. Find First and Last Position
  10. Peak Index in a Mountain Array
  11. Recap

When to use

The trigger. The search space is monotone with respect to the question you are asking. Write the answer to your yes/no question at every position: if it reads F F F F T T T, with a single flip, binary search applies. Sortedness is the most common way to get monotonicity, but it is not the only one.
SignalWhat it looks like
Sorted input“sorted array”, “rotated sorted array”, “sorted matrix”.
Boundary language“first index where…”, “last occurrence”, “insertion position”, “the smallest x such that…”.
A log requirement“must run in O(log n)”. That is not a hint, it is an instruction.
Minimise a maximum“minimum capacity to ship in D days”, “smallest eating speed”, “split the array to minimise the largest sum”. This is binary search on the answer.
Huge numeric rangeAn answer somewhere in 1..10⁹ with a cheap feasibility check.

Core idea

Maintain a range that is guaranteed to contain the answer. Test the midpoint. The test must let you discard one side with certainty. Repeat. Each step halves the range, so a range of size n is exhausted in about log₂n steps. Every difficulty in this pattern comes from one place: proving the discard is safe.
predicate value at each index F F F T T T 01 23 45 the boundary: first index where the predicate is true every binary-search variant is a search for this one index
Figure 11.1 — Rewrite any binary search as “find the first True”. Then there is one loop to remember instead of five.

Getting the loop right, every time

Binary search is notorious for off-by-one errors. The cure is to pick one convention and never deviate. Two conventions are worth knowing.

Closed range [left, right]Half-open range [left, right)
Initialleft = 0, right = n - 1left = 0, right = n
Loop whileleft <= rightleft < right
Discard leftleft = mid + 1left = mid + 1
Discard rightright = mid - 1right = mid
Best forFinding an exact matchFinding a boundary
Result on exitleft > right, nothing foundleft == right, the boundary
The rule that prevents infinite loops. Every iteration must shrink the range. With mid = (left + right) // 2 the midpoint rounds down, so mid can equal left but never equals right when left < right. That means right = mid is safe and left = mid is not: it can leave the range unchanged forever. If you ever need left = mid, round the midpoint up instead, with mid = (left + right + 1) // 2.

Write mid = left + (right - left) // 2 rather than (left + right) // 2. In Python they are identical, since integers never overflow. In C++ or Java the second can overflow, and this is a famous bug that sat in the JDK’s own binary search for nine years. Interviewers notice the safe form.

The templates

Template A — exact match
def find(nums: list[int], target: int) -> int:
    """Index of target in a sorted list, or -1."""
    left, right = 0, len(nums) - 1

    while left <= right:
        mid = left + (right - left) // 2

        if nums[mid] == target:
            return mid
        if nums[mid] < target:
            left = mid + 1
        else:
            right = mid - 1

    return -1
Template B — first index satisfying a predicate
def first_true(lo: int, hi: int, predicate) -> int:
    """Smallest x in [lo, hi] with predicate(x) true, or hi + 1 if none.

    Requires the predicate to be monotone: once true, true forever.
    """
    left, right = lo, hi + 1        # right is one past the last candidate

    while left < right:
        mid = left + (right - left) // 2

        if predicate(mid):
            right = mid             # mid might be the answer, so keep it
        else:
            left = mid + 1          # mid is definitely not, so discard it

    return left

This one template covers lower bound, upper bound, insertion position, first occurrence, last occurrence, and every “binary search on the answer” problem. If you memorise one, memorise this one.

Python already has this. bisect.bisect_left(nums, x) is the first index where nums[i] >= x, and bisect_right is the first index where nums[i] > x. In production, use them. In an interview, write the loop, then mention them.

Binary search on the answer

This is the version that separates candidates, and it deserves its own heading because there is no array in it at all.

The shape. The question asks for the smallest (or largest) value x such that something is achievable. Checking “is x achievable” is easy and linear. And achievability is monotone: if x works then anything larger works too. Then binary search over the range of possible answers, not over any input array, calling the feasibility check at each step.
def minimum_feasible(lo: int, hi: int, is_feasible) -> int:
    """Smallest value in [lo, hi] that works, assuming feasibility is monotone."""
    left, right = lo, hi

    while left < right:
        mid = left + (right - left) // 2

        if is_feasible(mid):
            right = mid
        else:
            left = mid + 1

    return left
ProblemSearch overFeasibility check
Koko Eating Bananasspeed, 1 to max(piles)Can she finish within h hours at this speed?
Capacity to Ship in D Dayscapacity, max(w) to sum(w)Does a greedy pack fit in d days?
Split Array Largest Sumthe largest allowed sumCan the array be cut into at most k pieces under that cap?
Minimum Days to Make Bouquetsday numberAre there m runs of k bloomed flowers by that day?

Total cost is O(log(range) × cost of one check). When you see “minimise the maximum” or “maximise the minimum”, say “binary search on the answer” immediately. It is the single most reliable pattern-recognition win in the whole set.

Common mistakes

The problems

1. Binary Search Easy

Problem

Given a sorted array of distinct integers and a target, return its index, or -1 if it is absent. Must be O(log n).

Approach

Solution

def binary_search(nums: list[int], target: int) -> int:
    """Index of target in a sorted list of distinct integers, or -1.

    Args:
        nums: Ascending, distinct integers.
        target: The value to find.

    Returns:
        The index of target, or -1 if it is not present.

    Example:
        >>> binary_search([-1, 0, 3, 5, 9, 12], 9)
        4
    """
    left, right = 0, len(nums) - 1

    # Invariant: if target is in nums, its index is inside [left, right].
    while left <= right:
        # Overflow-safe form. Identical in Python, required in C++ or Java.
        mid = left + (right - left) // 2

        if nums[mid] == target:
            return mid
        if nums[mid] < target:
            left = mid + 1          # everything at or left of mid is too small
        else:
            right = mid - 1         # everything at or right of mid is too large

    return -1

Walkthrough

nums = [-1, 0, 3, 5, 9, 12], target = 2:

leftrightmidnums[mid]action
0523too big, right = 1
010-1too small, left = 1
1110too small, left = 2
21left > right, return -1
TimeO(log n)SpaceO(1)

Why log n, precisely

The range starts at size n and at least halves each iteration, so after k iterations it is at most n / 2ᵏ. The loop stops when the size drops below 1, which needs k > log₂n. For a billion elements that is 30 iterations.

Edge cases to raise

Say this out loud: “The invariant is that if the target exists, it is inside [left, right]. Every branch discards only values that cannot be the target, so the invariant survives.”

2. Search in Rotated Sorted Array Medium

Problem

A sorted array of distinct values has been rotated at some unknown pivot, for example [4,5,6,7,0,1,2]. Find the index of a target, or -1. Must be O(log n).

The key observation

Cut a rotated array at any midpoint and at least one of the two halves is still properly sorted. There is only one rotation point, so it can sit in one half but not both. Compare nums[left] with nums[mid] to find which half is the clean one, then check whether the target falls inside that half’s known range. If it does, search there. If it does not, it must be in the messy half.

The reasoning is always about the sorted half, because that is the only half whose contents you can reason about from its two endpoints.

Solution

def search_rotated(nums: list[int], target: int) -> int:
    """Index of target in a rotated sorted array of distinct values, or -1.

    Args:
        nums: A sorted array rotated at an unknown pivot. Values are distinct.
        target: The value to find.

    Returns:
        Its index, or -1.

    Example:
        >>> search_rotated([4, 5, 6, 7, 0, 1, 2], 0)
        4
    """
    left, right = 0, len(nums) - 1

    while left <= right:
        mid = left + (right - left) // 2

        if nums[mid] == target:
            return mid

        if nums[left] <= nums[mid]:
            # Left half [left, mid] is sorted, so its range is known exactly.
            if nums[left] <= target < nums[mid]:
                right = mid - 1
            else:
                left = mid + 1
        else:
            # Then the right half [mid, right] must be the sorted one.
            if nums[mid] < target <= nums[right]:
                left = mid + 1
            else:
                right = mid - 1

    return -1

Why nums[left] <= nums[mid] and not <

When the range narrows to two elements, mid equals left, so the two values are the same and a strict < would wrongly classify the left half as unsorted. With distinct values, <= is correct and safe. If duplicates are allowed, this test breaks down entirely: for [1, 1, 1, 0, 1] you cannot tell which half is sorted, and the standard fix is to shrink left by one when nums[left] == nums[mid] == nums[right], which makes the worst case O(n). Raise this; it is the standard follow-up.

Walkthrough

nums = [4,5,6,7,0,1,2], target = 0:

leftrightmidnums[mid]sorted halfaction
0637left, 4 to 70 not in [4, 7), go right
4651left, 0 to 10 in [0, 1), go left
4440match, return 4
TimeO(log n)SpaceO(1)

The two-pass alternative

Find the rotation point with a separate binary search (the smallest element), then run a normal binary search on the correct segment. Two clean passes, still O(log n), and easier to get right under pressure. The one-pass version above is what most interviewers expect, but naming the two-pass option shows you have thought about the trade-off between cleverness and clarity.

Edge cases to raise

Say this out loud: “There is only one rotation point, so at least one half is always properly sorted. I identify that half, and I can decide about the target because I know exactly what range it covers.”

3. Find First and Last Position of Element in Sorted Array Medium

Problem

Given a sorted array that may contain duplicates, return the first and last index of a target as [first, last], or [-1, -1] if it is absent. Must be O(log n).

Approach

Solution

def lower_bound(nums: list[int], target: int) -> int:
    """First index where nums[index] >= target, or len(nums) if there is none.

    This is the boundary template: the predicate "nums[i] >= target" is
    false then true, with exactly one flip, because nums is sorted.
    """
    left, right = 0, len(nums)        # half-open: right is one past the end

    while left < right:
        mid = left + (right - left) // 2

        if nums[mid] < target:
            left = mid + 1            # mid fails, discard it
        else:
            right = mid               # mid might be the answer, keep it

    return left


def search_range(nums: list[int], target: int) -> list[int]:
    """First and last index of target in a sorted array, or [-1, -1].

    Args:
        nums: Ascending integers, duplicates allowed.
        target: The value to locate.

    Returns:
        [first_index, last_index], or [-1, -1] if target is absent.

    Example:
        >>> search_range([5, 7, 7, 8, 8, 10], 8)
        [3, 4]
    """
    first = lower_bound(nums, target)

    # lower_bound always returns a valid insertion point, which may be past
    # the end or may point at a larger value. Both mean "not present".
    if first == len(nums) or nums[first] != target:
        return [-1, -1]

    # The last target sits just before the first value greater than target.
    last = lower_bound(nums, target + 1) - 1

    return [first, last]

Walkthrough

nums = [5, 7, 7, 8, 8, 10], target = 8. lower_bound(8) returns 3, the first index with a value at least 8. lower_bound(9) returns 5, the first index with a value at least 9, so the last 8 is at index 4. Answer [3, 4].

Now target = 6. lower_bound(6) returns 1, but nums[1] is 7, not 6, so the guard fires and the answer is [-1, -1]. That validation step is essential: a boundary search never tells you the value is present, only where it would go.

TimeO(log n)SpaceO(1)

The standard-library version

import bisect


def search_range_bisect(nums: list[int], target: int) -> list[int]:
    """What you would ship."""
    first = bisect.bisect_left(nums, target)
    if first == len(nums) or nums[first] != target:
        return [-1, -1]
    return [first, bisect.bisect_right(nums, target) - 1]

bisect_left is lower_bound and bisect_right is upper_bound. Knowing that these map onto the two boundary searches, and which is which, is worth stating.

Edge cases to raise

Say this out loud: “Two boundary searches, not one search and a scan, because a long run of equal values would make the scan linear. And a boundary search only gives an insertion point, so I have to check the value is actually there.”

4. Peak Index in a Mountain Array Medium

Problem

An array is a mountain: it strictly increases to a single peak, then strictly decreases. Return the index of the peak, in O(log n).

Why binary search works with no sorted array

The array is not sorted, but the question “is arr[i] > arr[i + 1]?” is monotone: False all the way up the mountain, then True all the way down, flipping exactly once at the peak. That is the F F F T T T shape from Figure 11.1, so Template B applies directly. This problem is the best demonstration that binary search is about monotone predicates, not about sortedness.

Solution

def peak_index_in_mountain_array(arr: list[int]) -> int:
    """Index of the single peak of a mountain array.

    Args:
        arr: Strictly increasing then strictly decreasing, length >= 3.

    Returns:
        The index i where arr[i] is the maximum.

    Example:
        >>> peak_index_in_mountain_array([0, 2, 5, 8, 4, 1])
        3
    """
    left, right = 0, len(arr) - 1

    # Invariant: the peak is somewhere in [left, right].
    while left < right:
        mid = left + (right - left) // 2

        if arr[mid] < arr[mid + 1]:
            left = mid + 1      # still climbing, so mid is not the peak
        else:
            right = mid         # descending or at the peak, so mid may be it

    return left                 # left == right, and that is the peak

Why right = mid and not mid - 1

When arr[mid] > arr[mid + 1], the array is already descending at mid, so mid itself might be the peak. Discarding it with mid - 1 would lose the answer. In the other branch, arr[mid] < arr[mid + 1] proves mid is not the peak, so mid + 1 is safe. Discard only what you have proved cannot be the answer. That sentence is the whole discipline of this pattern.

Note also that mid + 1 is always a valid index inside the loop: the guard is left < right, so mid < right ≤ n - 1. No bounds check is needed, and saying so shows you checked.

Walkthrough

arr = [0, 2, 5, 8, 4, 1]:

leftrightmidarr[mid] vs arr[mid+1]action
0525 < 8, climbingleft = 3
3544 > 1, descendingright = 4
3438 > 4, descendingright = 3
33loop ends, return 3
TimeO(log n)SpaceO(1)

The generalisation: Find Peak Element

The sibling problem drops the mountain guarantee: the array may have several local peaks, and you must return any one of them, with nums[-1] and nums[n] treated as negative infinity. Remarkably, the identical code works. If arr[mid] < arr[mid + 1] then the rising slope to the right must eventually turn over or hit the boundary, so a peak exists in [mid + 1, right]. The same argument runs the other way. The invariant is not “the peak” but “a peak”, and it survives. Being able to explain why is a strong finish.

Edge cases to raise

Say this out loud: “The array is not sorted, but the predicate is this position on the downslope is false then true with exactly one flip. That is all binary search needs.”

Recap

The six things to carry forward

Where this goes next

Pattern 12, Dynamic Programming, is the last and the largest. Where binary search discards half the possibilities, DP keeps all of them but makes sure no subproblem is ever solved twice.


10 — Subsets (Backtracking) 12 — Dynamic Programming