Binary search boundary: find the first true position
Constructed practice problem; no company attribution. Prerequisites: ordered data and half-open bounds.
Candidate brief
A sorted price list may contain duplicates. Return the insertion position immediately before every price equal to a target, or before the next larger price when the target is absent. Define what you return beyond either end, then explain why each search update is safe.
Write this:
def lower_bound(nums, target):
...
| Contract | Decision |
|---|---|
| Input | Nondecreasing integer list/tuple and integer target; bool excluded |
| Output | First index whose value is at least target, or len(nums) if none |
| Boundaries | Empty gives 0; duplicates return their first position; no mutation |
| Invalid input | Invalid types or unsorted input raise ValueError after validation |
| Excluded | Concurrent mutation and automatic sorting; the checked wrapper costs O(n) |
Optional refresher · the underlying tool
Lower bound means the first index whose value is at least the target, including the insertion position len(nums) when none exists:
nums = [1, 2, 2, 5]
lo, hi = 0, len(nums) # uninspected elements: [lo, hi); answer boundary: [lo, hi]
mid = (lo + hi) // 2
print(mid, nums[mid]) # 2, 2; still search LEFT for the first 2
The answer for target 2 is index 1. Walk the full boundary loop and explain why hi = mid keeps mid eligible.
A design choice worth saying aloud
[lo, hi) contains uninspected elements; inclusive [lo, hi] contains the possible answer boundary. hi is a known true boundary, or the sentinel n. Setting hi = mid removes mid from inspection but keeps it eligible as the answer. Return len(nums) when every element is smaller.
| Input / target | Boundary trace (lo, hi) |
Answer |
|---|---|---|
[] / 2 |
(0,0); no elements to inspect |
0 |
[1,2] / 9 |
(0,2) → (2,2) |
2, the sentinel |
[1,3,3,8] / 3 |
(0,4) → (0,2) → (0,1) → (1,1) |
1, retained when mid became hi |
What the interviewer expects
The interviewer gives you the scenario and the contract above. Explain what a successful call returns, walk through one example below, and name what your state means before choosing a data structure.
Done means: First index whose value is at least target, or len(nums) if none.
Now predict each output before looking at the reference; invalid input should leave any existing state unchanged unless the contract says otherwise.
Test-case scenarios to settle before coding
01 · Present duplicate
- Input / starting state
[1,3,3,8], target3- Expected result
1
What it is testing: Return the first equal position.
02 · Absent middle
- Input / starting state
- same list, target
4 - Expected result
3
What it is testing: Return the insertion boundary.
03 · Beyond right
- Input / starting state
- same list, target
9 - Expected result
4
What it is testing: The sentinel is len(nums).
04 · Empty
- Input / starting state
[], target2- Expected result
0
What it is testing: The only insertion point is zero.
05 · Before left
- Input / starting state
[2,4], target1- Expected result
0
What it is testing: Nothing is proven smaller.
06 · Invalid/atomic
- Input / starting state
[3,1], target2- Expected result
ValueError; input unchanged
What it is testing: Checked input must actually be sorted.
For each case, show which branch or state change produces that result.
lower_bound([1, 3, 3, 8], 3) == 1; target 4 gives 3; target 9 gives 4.
lower_bound([], 2) == 0; lower_bound([3, 1], 2) raises ValueError.
The returned index is an insertion boundary and need not identify an equal value.
Before opening the explanation, restate the contract, trace the smallest useful example, implement a baseline, and identify the repeated work. Then implement your improvement independently and derive tests from the contract. Say what your state means before saying which data structure stores it.
Worked lesson, changed requirements, and reference
Prove the discarded region
A linear scan for the first value at least target is an O(n)-time, O(1)-space
baseline. In a sorted array, the predicate nums[i] >= target changes at most
once from false to true. Binary search finds that transition without inspecting
every position. The useful invariant divides the array into three regions:
| Region | Meaning for [1, 3, 3, 8], target 3 |
|---|---|
[0, lo) |
Proven smaller than target |
[lo, hi) |
Positions whose predicate may still need inspection |
[hi, n) |
Proven at least target |
Initialize lo=0, hi=n; both proven regions are empty. Choose a midpoint in
the unresolved interval. If its value is smaller, sortedness proves every
position through mid is also smaller, so set lo=mid+1. Otherwise mid itself
could be the first true position, so set hi=mid, preserving it as the boundary.
This distinction is the proof of the updates, not a formula to memorize.
| lo | hi | mid / value | Decision |
|---|---|---|---|
| 0 | 4 | 2 / 3 | hi = 2 |
| 0 | 2 | 1 / 3 | hi = 1 |
| 0 | 1 | 0 / 1 | lo = 1 |
| 1 | 1 | interval empty | return 1 |
The nonnegative interval length strictly decreases each iteration. At termination,
all positions before lo are smaller and all from lo onward are at least target,
so lo is the required boundary, including n if the true region is empty.
The search loop takes O(log(n+1)) comparisons and O(1) auxiliary space. The
supplied public function first validates types and sortedness in O(n), so its
end-to-end worst-case time is O(n). This cost is intentional: it honors the
invalid-input contract rather than claiming to validate ordering logarithmically.
Follow-up 1: count exact target occurrences
Predict the changed predicate for the other edge: find the first value strictly greater than target. Subtract the lower boundary from that upper boundary.
| Input | Lower predicate | Upper predicate | Count |
|---|---|---|---|
[1, 3, 3, 8], target 3 |
first >= 3 is 1 |
first > 3 is 3 |
2 |
| same, target 4 | first >= 4 is 3 |
first > 4 is 3 |
0 |
Do not search for equality and then scan duplicates: that loses the logarithmic search bound when almost every value equals target.
Follow-up 2: thousands of queries on one immutable snapshot
Move validation to snapshot construction and expose a trusted internal search; copying to a tuple also has O(n) time and space cost. Total work becomes O(n + q log(n+1)) for q queries, with O(n) snapshot storage. A senior candidate states each discarded-region proof and tests all-equal arrays. A lead candidate defines snapshot ownership so mutation cannot invalidate sortedness between validation and search; a checked list reference alone cannot guarantee that.
Run and check
From the repository root:
cd curriculum/01-code/02-data-structures-algorithms/problems/11-binary-search-boundary
python -m unittest -v test_solution.py
Reference implementation (download file, source below) · Contract and oracle tests (download file, source below). Read the tests after your attempt. A green reference suite verifies the supplied implementation; it does not demonstrate independent transfer. Reimplement one follow-up with the reference closed and explain which old invariant no longer holds.
def _integers(values):
if not isinstance(values, (list, tuple)) or any(type(x) is not int for x in values):
raise ValueError("expected a list or tuple of integers, excluding bool")
def lower_bound(nums, target):
_integers(nums)
if type(target) is not int or any(nums[i] > nums[i + 1] for i in range(len(nums) - 1)):
raise ValueError("expected sorted integers and integer target")
lo, hi = 0, len(nums)
while lo < hi:
mid = lo + (hi - lo) // 2
if nums[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
import itertools
import unittest
from solution import lower_bound
class BoundaryTests(unittest.TestCase):
def test_contract(self):
self.assertEqual(lower_bound([1, 3, 3, 8], 3), 1)
self.assertEqual(lower_bound([1, 3, 3, 8], 4), 3)
self.assertEqual(lower_bound([1, 3, 3, 8], 9), 4)
self.assertEqual(lower_bound([], 2), 0)
for nums, target in [([3, 1], 2), ([True], 1), (None, 2), ([1], 1.0)]:
with self.assertRaises(ValueError):
lower_bound(nums, target)
def test_linear_boundary_oracle(self):
for n in range(8):
for nums in itertools.combinations_with_replacement(range(-2, 3), n):
for target in range(-3, 4):
result = lower_bound(nums, target)
expected = next((i for i, value in enumerate(nums) if value >= target), n)
self.assertEqual(result, expected)
self.assertTrue(all(value < target for value in nums[:result]))
self.assertTrue(all(value >= target for value in nums[result:]))
if __name__ == "__main__":
unittest.main()