Exercises — Binary search — iterative, recursive; O(log n); searching in sorted - rotated arrays
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. Is6 <= 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.)

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 ansTrace 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 left →
hi = 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 loTrace 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 -1Trace 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 -1Worked 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)=12 → return 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 .