Coding questions · Uber
Implement binary search from scratch
An Uber software engineer onsite coding question asking you to implement binary search on a sorted integer array from scratch, in both iterative and recursive form. It covers plain search, first/last occurrence with duplicates, the insertion-index variant, overflow-safe midpoint computation, loop invariants and correctness, and time/space complexity analysis.
Question
Implement binary search on a sorted integer array without using any library helpers (no bisect, Arrays.binarySearch, etc.). Provide both iterative and recursive versions, and address each of the following:
- Basic search. Return the index of the target if it is present, or
-1if it is absent. - First occurrence. When the array contains duplicates, return the index of the first occurrence of the target.
- Last occurrence. When the array contains duplicates, also be able to return the index of the last occurrence of the target.
- Insertion index. If the target is absent, return the index at which it could be inserted while keeping the array sorted (the classic "search insert position").
- Integer overflow. Compute the midpoint in a way that avoids integer overflow on large indices.
- Correctness & invariants. Clearly state your loop invariant and boundary handling (inclusive vs. exclusive bounds), and argue why the search terminates and is correct.
- Complexity. State the time and space complexity of each variant.
Example
nums = [1, 2, 2, 2, 5, 7], target = 2
basic search -> any index in {1, 2, 3}
first occ. -> 1
last occ. -> 3
nums = [1, 3, 5, 6], target = 4
basic search -> -1
insertion idx -> 2
Binary Search — Basic Search
Implement binary search on a sorted integer array WITHOUT using any library helpers (no bisect, Arrays.binarySearch, etc.). Return the index of target if it is present in nums, or -1 if it is absent. The array is sorted in non-decreasing order. If the target appears multiple times, returning any valid matching index is acceptable, but these tests use distinct-value arrays for the basic search so the answer is unique.
Use an overflow-safe midpoint computation: mid = lo + (hi - lo) / 2 rather than (lo + hi) / 2.
Example:
nums = [1, 3, 5, 6], target = 5 -> 2
nums = [1, 3, 5, 6], target = 2 -> -1
Constraints
- 0 <= len(nums) <= 10^5
- nums is sorted in non-decreasing order
- -10^9 <= nums[i], target <= 10^9
- Do not use any built-in search helpers (bisect, Arrays.binarySearch, etc.)
Hints
- Maintain an inclusive search window [lo, hi]. Invariant: if target exists, its index is within [lo, hi].
- Compute the midpoint as lo + (hi - lo) // 2 to avoid integer overflow in fixed-width languages.
- When nums[mid] < target, the answer (if any) is strictly to the right, so set lo = mid + 1; otherwise move hi = mid - 1. The loop ends when lo > hi (empty window).
Binary Search — First Occurrence (Lower Bound)
Given a sorted integer array nums (which may contain duplicates) and a target, return the index of the FIRST (leftmost) occurrence of target, or -1 if it is absent. Do not use any library helpers.
The trick: on a match, do not return immediately — record the candidate index and keep searching the LEFT half so an earlier duplicate can override it.
Example:
nums = [1, 2, 2, 2, 5, 7], target = 2 -> 1
nums = [1, 2, 2, 2, 5, 7], target = 4 -> -1
Constraints
- 0 <= len(nums) <= 10^5
- nums is sorted in non-decreasing order and may contain duplicates
- -10^9 <= nums[i], target <= 10^9
- Do not use any built-in search helpers
Hints
- A plain binary search returns the first match it lands on, which may be a middle duplicate, not the leftmost one.
- On nums[mid] == target, store mid as the best answer so far, then move hi = mid - 1 to keep probing the left half.
- The 'equal' and 'greater' branches both move hi left, so you can merge them: if nums[mid] >= target, go left; else go right — recording the index only on exact equality.
Binary Search — Last Occurrence (Upper Bound)
Given a sorted integer array nums (which may contain duplicates) and a target, return the index of the LAST (rightmost) occurrence of target, or -1 if it is absent. Do not use any library helpers.
Mirror image of first-occurrence: on a match, record the candidate index and keep searching the RIGHT half so a later duplicate can override it.
Example:
nums = [1, 2, 2, 2, 5, 7], target = 2 -> 3
nums = [5, 5, 5, 5], target = 5 -> 3
Constraints
- 0 <= len(nums) <= 10^5
- nums is sorted in non-decreasing order and may contain duplicates
- -10^9 <= nums[i], target <= 10^9
- Do not use any built-in search helpers
Hints
- Symmetric to first-occurrence: record the match, then continue into the right half instead of the left.
- On nums[mid] == target, store mid and set lo = mid + 1. The 'equal' and 'less' branches both move lo right, so they can be merged.
- Because lo only ever increases and hi only ever decreases by at least one each iteration, the window strictly shrinks and the loop terminates when lo > hi.
Binary Search — Insertion Index (Search Insert Position)
Given a sorted integer array nums (distinct or with duplicates) and a target, return the index where target is found, OR — if it is absent — the index at which it could be inserted to keep the array sorted. This is the classic lower-bound search. Do not use any library helpers.
Use a half-open interval [lo, hi). When the loop ends, lo equals the number of elements strictly less than target, which is exactly the insertion point (and, if present, the index of the first occurrence).
Example:
nums = [1, 3, 5, 6], target = 5 -> 2 (present)
nums = [1, 3, 5, 6], target = 2 -> 1 (insert before 3)
nums = [1, 3, 5, 6], target = 7 -> 4 (append at end)
Constraints
- 0 <= len(nums) <= 10^5
- nums is sorted in non-decreasing order
- -10^9 <= nums[i], target <= 10^9
- Do not use any built-in search helpers
Hints
- Use a half-open window [lo, hi) with hi initialized to len(nums), not len(nums) - 1. Invariant: every index < lo has value < target, every index >= hi has value >= target.
- When nums[mid] < target, move lo = mid + 1; otherwise keep mid as a candidate with hi = mid (do NOT use mid - 1 here).
- The loop ends when lo == hi; that common value is the lower bound — the first position whose value is >= target, i.e. the insertion point (and the first-occurrence index if target is present).
Solution
One skeleton powers every variant; the only differences are (a) the comparison branch and (b) what you do on a match. For plain search you return immediately; for first/last occurrence you record the match and continue toward the left/right; for the insertion index you use a half-open lower-bound search whose terminal lo is the insert position. The discriminating signals interviewers look for: an overflow-safe midpoint (lo + (hi - lo) // 2), a clearly stated loop invariant with consistent inclusive vs. half-open bounds, correct duplicate handling, and a clean O(log n) time / O(1) iterative space analysis.
Core idea
Binary search exploits the sorted order to halve the search space each step. The whole family of variants below shares one skeleton; what changes is the comparison and which side we keep when we find a match.
1. Basic search (iterative) with overflow-safe midpoint
def binary_search(nums, target):
lo, hi = 0, len(nums) - 1 # inclusive bounds [lo, hi]
while lo <= hi:
mid = lo + (hi - lo) // 2 # overflow-safe midpoint
if nums[mid] == target:
return mid
elif nums[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1
Overflow note. mid = (lo + hi) // 2 can overflow in fixed-width languages (Java/C++ int) when lo + hi exceeds INT_MAX. Using mid = lo + (hi - lo) // 2 keeps the intermediate value bounded by hi. (In Python integers are arbitrary precision, so this is a portability/best-practice habit, but interviewers expect you to call it out.)
1b. Basic search (recursive)
def binary_search_rec(nums, target, lo=0, hi=None):
if hi is None:
hi = len(nums) - 1
if lo > hi:
return -1
mid = lo + (hi - lo) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
return binary_search_rec(nums, target, mid + 1, hi)
else:
return binary_search_rec(nums, target, lo, mid - 1)
2. First occurrence (lower bound)
Do not return on the first match; instead record the candidate and keep searching the left half so an earlier duplicate can override it.
def first_occurrence(nums, target):
lo, hi, ans = 0, len(nums) - 1, -1
while lo <= hi:
mid = lo + (hi - lo) // 2
if nums[mid] == target:
ans = mid
hi = mid - 1 # look further left
elif nums[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return ans
3. Last occurrence (upper bound)
Mirror image: on a match, record it and keep searching the right half.
def last_occurrence(nums, target):
lo, hi, ans = 0, len(nums) - 1, -1
while lo <= hi:
mid = lo + (hi - lo) // 2
if nums[mid] == target:
ans = mid
lo = mid + 1 # look further right
elif nums[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return ans
4. Insertion index (search insert position)
This is the classic lower-bound search over a half-open interval [lo, hi). When the loop ends, lo is exactly the number of elements strictly less than target, which is the insertion point. If nums[lo] == target it is also the index of the first occurrence.
def search_insert(nums, target):
lo, hi = 0, len(nums) # half-open [lo, hi)
while lo < hi:
mid = lo + (hi - lo) // 2
if nums[mid] < target:
lo = mid + 1
else:
hi = mid # keep mid as a candidate
return lo # present -> first index; absent -> insertion point
To combine "return index if present, else insertion index" in one call:
def search_or_insert(nums, target):
i = search_insert(nums, target)
return i if i < len(nums) and nums[i] == target else i # both paths return i;
# use a sentinel/flag if you must distinguish 'found' from 'would-insert-here'
Loop invariant & correctness
- Inclusive form
[lo, hi](used in 1-3): the invariant is "if the target exists, its index lies within[lo, hi]." Each iteration strictly shrinks the interval (loincreases orhidecreases by at least one), so it cannot loop forever; it terminates whenlo > hi, i.e. the interval is empty. - Half-open form
[lo, hi)(used in 4): the invariant is "every index< lohas value< target, and every index>= hihas value>= target." The loop ends whenlo == hi, at which pointlois the first position whose value is>= target— exactly the lower bound / insertion point. - Keep the two styles consistent within a single function; mixing inclusive
hi = len - 1with alo < hicondition (or the reverse) is the most common source of off-by-one bugs.
Complexity
- Time:
O(log n)for every variant — the candidate range halves each step. - Space:
O(1)for all iterative versions. The recursive version isO(log n)stack space; it can be madeO(1)if the language performs tail-call optimization, but most (including Python and Java) do not, so iterative is preferred in production.
[1, 2, 2, 2, 5, 7], target 2 -> first_occurrence = 1, last_occurrence = 3; [1, 3, 5, 6], target 4 -> basic = -1, insertion index = 2; [1, 3, 5, 6], target 5 -> basic = 2, insertion index = 2; empty array, any target -> basic = -1, insertion index = 0. Time O(log n) per variant; space O(1) iterative, O(log n) recursive.