3.6.11 · D4Sorting & Searching

Exercises — Binary search — iterative, recursive; O(log n); searching in sorted - rotated arrays

2,930 words13 min readBack to topic

Prerequisite reminders you may lean on: Logarithms, Time Complexity & Big-O, Recurrence Relations, Linear Search.


Level 1 — Recognition

Goal: can you spot when binary search applies and trace one step?

L1.1 — Is the precondition met?

For each array, say whether plain binary search (the sorted version) will return correct results: (a) [2, 4, 6, 8, 10] (b) [10, 8, 6, 4, 2] (c) [3, 3, 3, 3] (d) [5, 1, 9, 2]

Recall Solution

Binary search needs the array to be sorted ascending so that "middle too small → go right" is valid.

  • (a) Ascending → ✔ works.
  • (b) Descending, not ascending → ✗ the halving logic points the wrong way. (It would work if you flip every comparison.)
  • (c) All equal is a valid non-decreasing (sorted) array → ✔ works; it returns some index holding 3.
  • (d) Unsorted → ✗ garbage results.

Answer: works on (a) and (c) only.

L1.2 — One-step trace

In [1, 4, 9, 16, 25, 36, 49] (7 elements, indices 0–6), what is the first mid index computed, and its value?

Recall Solution

lo = 0, hi = 6. Overflow-safe mid: arr[3] = 16. First mid index is 3, value 16.


Level 2 — Application

Goal: run the full loop to a final answer.

L2.1 — Full trace, found

Find 23 in [2, 5, 8, 12, 16, 23, 38, 56, 72, 91] (10 elements, indices 0–9). Give every (lo, hi, mid, arr[mid], action) row and the returned index.

Recall Solution
step lo hi mid arr[mid] action
1 0 9 4 16 16 < 23 → lo = 5
2 5 9 7 56 56 > 23 → hi = 6
3 5 6 5 23 equal → return 5

mid of step 2: . mid of step 3: . Returned index: 5. Three comparisons for 10 elements, matching as an upper bound. ✔

L2.2 — Full trace, not found

Find 40 in the same array [2, 5, 8, 12, 16, 23, 38, 56, 72, 91].

Recall Solution
step lo hi mid arr[mid] action
1 0 9 4 16 16 < 40 → lo = 5
2 5 9 7 56 56 > 40 → hi = 6
3 5 6 5 23 23 < 40 → lo = 6
4 6 6 6 38 38 < 40 → lo = 7
5 lo(7) > hi(6) → return −1

The window emptied: 40 sits between 38 and 56, so it can't exist. Returned: −1.


Level 3 — Analysis

Goal: reason about counts, complexity, and rotated structure.

L3.1 — Worst-case comparison count

An array has sorted elements. What is the maximum number of middle-comparisons plain binary search can make? Justify with the recurrence.

Recall Solution

Each step does one comparison then halves the window, giving the recurrence (from the parent note) The window shrinks . Number of halvings satisfies , i.e. , i.e. . Total comparisons . For : , so at most comparisons. Answer: 10. (Sanity: , so 10 halvings suffice.) See Logarithms and Recurrence Relations.

L3.2 — Which half is sorted?

In the rotated array [6, 7, 8, 1, 2, 3, 4] with lo = 0, hi = 6, compute mid and decide which half around mid is sorted. Explain the test.

Recall Solution

mid = 0 + \lfloor(6-0)/2\rfloor = 3, arr[mid] = 1. Test from the parent note: compare arr[lo] with arr[mid].

  • arr[lo] = 6, arr[mid] = 1. Is 6 <= 1? No.
  • Because the left endpoint is bigger than the middle, the pivot (the wrap-around point) lies in the left part, so the right half [mid..hi] = [1,2,3,4] is the cleanly sorted one.

Answer: the right half is sorted. (Look at figure below for the picture of why exactly one side stays sorted.)

Figure — Binary search — iterative, recursive; O(log n); searching in sorted - rotated arrays

L3.3 — Rotated full trace

Find 3 in [6, 7, 8, 1, 2, 3, 4].

Recall Solution
step lo hi mid arr[mid] sorted half decision
1 0 6 3 1 right (arr[lo]=6 > 1) is 1 < 3 <= arr[hi]=4? yes → lo = 4
2 4 6 5 3 arr[mid]==3 → return 5

Returned index: 5. At step 1 we checked the sorted (right) half's range (arr[mid], arr[hi]] = (1, 4]; since 3 lies inside it, we moved right.


Level 4 — Synthesis

Goal: combine ideas or adapt the template to a new question.

L4.1 — First occurrence (leftmost) in duplicates

In [1, 2, 2, 2, 3, 4] find the index of the first (leftmost) 2. Standard binary search may land on any 2. Modify it and trace.

Recall Solution

Idea: when arr[mid] == target, don't stop — record mid as a candidate and keep searching left (hi = mid - 1) to see if an earlier 2 exists. This connects to Lower & Upper Bound (bisect).

def first_index(arr, target):
    lo, hi, ans = 0, len(arr) - 1, -1
    while lo <= hi:
        mid = lo + (hi - lo) // 2
        if arr[mid] == target:
            ans = mid          # candidate
            hi = mid - 1       # keep looking left
        elif arr[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return ans

Trace on [1,2,2,2,3,4], target 2:

step lo hi mid arr[mid] action
1 0 5 2 2 ==2 → ans=2, hi=1
2 0 1 0 1 1<2 → lo=1
3 1 1 1 2 ==2 → ans=1, hi=0
4 lo(1) > hi(0) → stop

Returned: 1 — the leftmost 2. Still : every step halves the window.

L4.2 — Find rotation index (smallest element)

In a rotated sorted array with distinct values, find the index of the minimum (the pivot) in . Test on [4, 5, 6, 7, 0, 1, 2].

Recall Solution

Idea: the minimum is the only element smaller than its predecessor. Compare arr[mid] to arr[hi]:

  • if arr[mid] > arr[hi], the dip (minimum) is to the right of mid → lo = mid + 1.
  • else the minimum is at mid or to its lefthi = mid (keep mid, it may be the min).
def min_index(arr):
    lo, hi = 0, len(arr) - 1
    while lo < hi:              # stop when they meet
        mid = lo + (hi - lo) // 2
        if arr[mid] > arr[hi]:
            lo = mid + 1
        else:
            hi = mid           # note: no -1 here
    return lo

Trace on [4,5,6,7,0,1,2]:

step lo hi mid arr[mid] arr[hi] action
1 0 6 3 7 2 7 > 2 → lo = 4
2 4 6 5 1 2 1 < 2 → hi = 5
3 4 5 4 0 1 0 < 1 → hi = 4
4 lo hi 4 → stop

Returned index: 4 (value 0, the minimum). Here hi = mid (no −1) is correct because mid is still a min candidate, and the loop guard is lo < hi (not <=) — otherwise hi = mid with lo == hi never shrinks and loops forever.


Level 5 — Mastery

Goal: invent, prove, or handle a subtle edge case fully.

L5.1 — Search in rotated array with duplicates

The classic search_rotated assumed distinct values. In [2, 2, 2, 0, 2, 2], searching for 0, the test arr[lo] <= arr[mid] becomes ambiguous. Explain why, patch the algorithm, and trace.

Recall Solution

Why it breaks: with duplicates, arr[lo] == arr[mid] == arr[hi] can happen while the sorted half is genuinely not the left one. Here arr[0]=2, arr[2]=2 (a mid), arr[5]=2 — the equality 2 <= 2 wrongly claims "left half sorted," and we'd discard the half that actually holds 0.

Patch: when arr[lo] == arr[mid] == arr[hi], we cannot tell which side is sorted, so we shrink both ends by one and retry:

def search_rot_dup(arr, target):
    lo, hi = 0, len(arr) - 1
    while lo <= hi:
        mid = lo + (hi - lo) // 2
        if arr[mid] == target:
            return mid
        if arr[lo] == arr[mid] == arr[hi]:
            lo += 1; hi -= 1                 # can't decide — trim both
        elif arr[lo] <= arr[mid]:            # left sorted
            if arr[lo] <= target < arr[mid]:
                hi = mid - 1
            else:
                lo = mid + 1
        else:                                # right sorted
            if arr[mid] < target <= arr[hi]:
                lo = mid + 1
            else:
                hi = mid - 1
    return -1

Trace on [2,2,2,0,2,2], target 0:

step lo hi mid arr[mid] branch action
1 0 5 2 2 arr[lo]=arr[mid]=arr[hi]=2 trim both → lo=1, hi=4
2 1 4 2 2 arr[lo]=2 ≤ arr[mid]=2, left "sorted" is 2 ≤ 0 < 2? no → lo = 3
3 3 4 3 0 arr[mid]==0 → return 3

Returned index: 3.

Complexity note: the trim-both branch is what breaks the clean logarithm — in the pathological all-equal case (e.g. [2,2,2,2,2] searching 3) it degrades to . So worst case is with duplicates, average still near .

L5.2 — Search an infinite/unbounded sorted stream

You can only call get(i) (returns the value at index i, or +∞ if i is past the end). You don't know n. Find target x, or prove absence, in where p is the target's position. Design and justify.

Recall Solution

Problem: ordinary binary search needs hi = n-1, but n is unknown. We must first find a valid hi whose value is , then binary search in [lo, hi].

Phase 1 — exponential expansion (galloping): start lo=0, hi=1. While get(hi) < x, set lo = hi and hi *= 2. Doubling means after steps hi = 2^k, so we reach past position p in steps — this is , and it's why doubling (not adding a fixed amount) is essential. Phase 2 — ordinary binary search on [lo, hi]; that window has width , so it costs too. Total .

def search_unbounded(get, x):
    lo, hi = 0, 1
    while get(hi) < x:          # phase 1: find a hi with get(hi) >= x
        lo = hi
        hi *= 2
    while lo <= hi:             # phase 2: normal binary search
        mid = lo + (hi - lo) // 2
        v = get(mid)
        if v == x:
            return mid
        elif v < x:
            lo = mid + 1
        else:
            hi = mid - 1
    return -1

Worked check: stream [1,3,7,9,12,15,...] (get past end = ∞), target 12 at index 4. Phase 1: get(1)=3 < 12 → lo=1,hi=2; get(2)=7 < 12 → lo=2,hi=4; get(4)=12, not < 12, stop. Window [2,4]. Phase 2: mid = 2+1 = 3, get(3)=9 < 12 → lo=4; mid=4, get(4)=12return 4.


Recall One-line self-check before you move on

Loop guard vs update pairing ::: lo <= hi needs mid + 1 / mid - 1 shrinking; lo < hi allows hi = mid. Never mix them. Rotated with duplicates worst case ::: , because the all-equal case forces linear trimming. Unbounded search boundary trick ::: exponential doubling of hi, giving .