Coding Interview Patterns

Course Content

Coding Interview Patterns

20 sections · 146 lessons

Find First and Last Position in a Sorted Array


Plain binary search returns an index that holds the target. With repeated values that is not enough. "When did this user first log in?", "how many orders cost exactly 499?", "which rows fall in this date range?" all need the edges of a run of equal values, not a random point inside it.

This problem is how interviewers test whether you know the boundary search. Most candidates find the target and then walk outwards, which is quietly linear. The strong answer is two binary searches that differ in one character.

The problem

You are given a list of integers sorted in non-decreasing order, so values may repeat, and a target. Return a pair [first, last]: the index of the first and the last copy of the target. If the target does not appear, return [-1, -1].

  • nums = [1, 2, 2, 2, 3, 5], target = 2 → [1, 3]. The 2s sit at indices 1, 2 and 3.
  • Same nums, target = 4 → [-1, -1]. There is no 4.

Constraints: 0 ≤ len(nums) ≤ 10⁵, values up to 10⁹ in size, and the solution must run in O(log n).

Clarifying questions

  • Can the list be empty? Yes. Return [-1, -1].
  • Is it sorted ascending? Yes, non-decreasing: equal neighbours are allowed.
  • Indices or values? Indices, as a two-element list.
  • Could the whole list be the target? Yes. That is the case that breaks the "find then walk outwards" idea.

Approach 1: the simple way

Scan once. Remember the first index where you see the target, and keep updating the last.

Python
def search_range_linear(nums: list[int], target: int) -> list[int]:    """First and last index of target by scanning: O(n)."""    first = last = -1    for i, value in enumerate(nums):        if value == target:            if first == -1:                first = i            last = i    return [first, last]

Time: O(n). Space: O(1).

A cleverer-looking version finds any copy with binary search and then walks left and right to the ends of the run. That is O(log n + k) where k is the number of copies. On an array of 10⁵ copies of the same value, k = n, so it is linear again. The problem asks for O(log n) in the worst case, so both are out.

The key insight

Stop searching for the target. Search for the two boundaries around it.

For [1, 2, 2, 2, 3, 5] and t = 2, lower bound is 1 (the first 2) and upper bound is 4 (the 3). So the first copy is at 1 and the last at 4 - 1 = 3.

Two boundaries around a run of 2s122235012345lower boundupper boundCount of 2 is upper minus lower, which is 4 minus 1 = 3.
Both bounds are insertion points, so they answer even when the target is absent.

Each bound is a first-true search. For the lower bound, the test "is nums[i] >= t?" reads no, no, yes, yes. For the upper bound, "is nums[i] > t?" does the same. Neither search stops when it sees a copy of the target: seeing a 2 at mid only tells the lower-bound search that the first 2 is at mid or further left, so it keeps mid and keeps going. That is why a run of 10⁵ copies costs 17 steps, not 10⁵.

Approach 2: two boundary searches

Python
def lower_bound_idx(nums: list[int], target: int) -> int:    """First index with nums[i] >= target (len(nums) if none)."""    left, right = 0, len(nums)    while left < right:        mid = left + (right - left) // 2        if nums[mid] < target:            left = mid + 1        else:            right = mid    return leftdef upper_bound_idx(nums: list[int], target: int) -> int:    """First index with nums[i] > target (len(nums) if none)."""    left, right = 0, len(nums)    while left < right:        mid = left + (right - left) // 2        if nums[mid] <= target:              # the only change: equal values count as "too small"            left = mid + 1        else:            right = mid    return leftdef search_range(nums: list[int], target: int) -> list[int]:    """First and last index of target in sorted nums, or [-1, -1]."""    first = lower_bound_idx(nums, target)    if first == len(nums) or nums[first] != target:        return [-1, -1]                      # lower bound is only a position; check it holds target    last = upper_bound_idx(nums, target) - 1    return [first, last]

The two searches differ in one character: < versus <=. In the lower bound, a value equal to the target counts as "possibly the answer", so the search keeps it and moves left. In the upper bound, an equal value counts as "too small", so the search moves past it to the right.

Dry run of the lower bound on [1, 2, 2, 2, 3, 5], target 2:

Stepleftrightmidnums[mid]nums[mid] < 2?Action
10632noright = 3
20312noright = 1
30101yesleft = 1
end11———return 1

Step 1 lands on a 2 and does not stop. It keeps index 3 as a candidate and looks left, which finds the first 2 at index 1.

The upper bound on the same input:

Stepleftrightmidnums[mid]nums[mid] <= 2?Action
10632yesleft = 4
24655noright = 5
34543noright = 4
end44———return 4

So search_range returns [1, 4 - 1] = [1, 3].

For target 4, the lower bound runs mid = 3 (2, go right), mid = 5 (5, keep), mid = 4 (3, go right) and returns 5. nums[5] is 5, not 4, so the check returns [-1, -1] without running the second search.

Time: O(log n) — two searches of about 17 steps each at n = 10⁵, whatever the number of copies. Space: O(1).

The two bounds answer a whole family of questions, which is why they are worth learning as a pair:

QuestionExpression
First index of tlower_bound(t), if it is in range and holds t
Last index of tupper_bound(t) - 1, under the same check
How many copies of tupper_bound(t) - lower_bound(t)
Where to insert tlower_bound(t) (before equals) or upper_bound(t) (after)
How many values are less than tlower_bound(t)
How many values are at most tupper_bound(t)
How many values in [a, b]upper_bound(b) - lower_bound(a)

Python's bisect_left and bisect_right are these two functions. C++ has std::lower_bound and std::upper_bound. Java's Arrays.binarySearch is not a boundary search: with duplicates it returns any matching index.

Edge cases

  • Target bigger than everything: the lower bound is len(nums). The check first == len(nums) must come before nums[first], or you read past the end.
  • Target smaller than everything: the lower bound is 0 and nums[0] is not the target, so the answer is [-1, -1].
  • Empty list: the lower bound is 0, which equals len(nums), so the first check catches it.
  • Every value is the target: [7, 7, 7] gives lower bound 0 and upper bound 3, so [0, 2], still in about 2 × log₂ n steps.
  • One element: [7] with 7 gives [0, 0].

Follow-ups

  • "Count the copies." upper_bound - lower_bound, with no need to check presence: it is 0 when the target is absent.
  • "Only integers — can you use one function?" Yes: upper_bound(t) equals lower_bound(t + 1) for integers. It breaks for floats or strings, so say so.
  • "Find the k values closest to x." Lower bound gives the position of x; then grow a window left and right with two pointers, or binary search the window's left edge directly.