DSA sheet · Binary Search · Lower & upper bound pattern

First & Last Position of an Element

This is the first problem of a new pattern in the sheet: lower bound / upper bound. Plain binary search stops as soon as it finds the target. Here the array can hold the target many times, and we must report the leftmost and the rightmost copy. The teacher shows the one trick that powers this whole pattern: when mid matches, don't stop. Write the index down and keep searching in one direction. Count Occurrences and Floor & Ceiling (the next problems) are built on exactly this trick.

Every part below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the conditions from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember

Part 0 · Before starting

Why binary search works here

Binary search needs one thing: after looking at one element, you must be able to say "the answer is definitely not on this side". A sorted array gives us that. If the middle value is smaller than what we want, everything to its left is even smaller, so the whole left half can be thrown away in one go.

Another way to see it: ask a yes/no question about each cell, like "is nums[i] >= 8?". In a sorted array the answers always line up as no no no yes yes yes. They never jump back and forth. Binary search is just a fast way to find where the answers switch.

left, right and mid

Why mid = left + (right - left) // 2 and not (left + right) // 2?

Both give the same number. The difference is only in Java/C++, where an int has a maximum (about 2.1 × 10⁹). If left and right are both huge, left + right can go past that maximum and wrap into a negative number (this is called overflow). right - left is never bigger than the array, so left + (right - left) // 2 stays safe.

In Python, integers never overflow, so either form works. We still write the safe form, because the teacher wants it as a habit, and you'll need it the day you write the same thing in Java or C++.

Why while left <= right?

The search area is [left … right]. When left == right, there is still one element in it that we haven't checked. Only when left > right (the pointers have crossed) is the area truly empty. Part A shows a real case where stopping one step early gives the wrong answer.

Lower bound vs upper bound (the name of this pattern)

Take a sorted array and a value x.

Here they are drawn for nums = [5, 7, 7, 8, 8, 10] and x = 8:

index0123456
value5778810·
>= 8 ?nononoyesyesyes
> 8 ?nononononoyes
bounds↑LB↑UBlower bound = 3, upper bound = 5
  index:   0   1   2   3   4   5
  value:   5   7   7  [8   8]  10
                       ^       ^
          lower bound (3)      upper bound (5)
          = first 8            = first value after the 8s
  first position = lower bound      = 3
  last position  = upper bound - 1  = 4

And for a value that is missing, x = 6: both bounds land on the same index (1, the first 7), because there are zero 6's between them.

index012345
value5778810
bounds↑LB ↑UBLB = UB = 1 → 6 is not present
Key facts to keep • first occurrence of x = lower bound (only if the value there really is x)
• last occurrence of x = upper bound − 1
• how many x's = upper bound − lower bound
• x is missing ⇔ lower bound == upper bound

The teacher doesn't write a function named lower_bound in this video. She writes "find first" and "find last" directly. Part D shows how her two functions are the same thing as the two bounds.

Part A · Finding the FIRST position

LeetCode 34 · Find First and Last Position of Element in Sorted Array

1The question in simple words

You get an array nums sorted in non-decreasing order. That means each value is bigger than or equal to the one before it, so repeats are allowed (5, 7, 7, 8, 8, 10 is fine). You also get a target.

Return two indexes: where the target appears for the first time, and where it appears for the last time. If the target is not in the array at all, return [-1, -1].

index012345
value5778810target 8 → [3, 4]

LeetCode also asks for an O(log n) solution, so a simple left-to-right scan is not what they want. This part solves only the first half of the answer: the first index.

2What the constraints tell us

Doubt: the values are up to 10⁹, but mid is made from indexes, not values. Can left + right really overflow here?
→ Honestly, not in this problem: indexes are at most 10⁵, so left + right is at most 2 × 10⁵. The real danger is when the search range itself is huge (like binary searching over numbers up to 10⁹, as in Sqrt(x)). The teacher's advice still stands as a habit: always write left + (right - left) // 2 and you never have to think about it. In Python it doesn't matter at all.

3Intuition: how to think about it

Normal binary search would find an 8 and stop. But it might land on the second 8, not the first.

Picture it like this: every time you land on an 8, you write its index on a sticky note (a variable ans), and then keep searching to the left, because if there is an earlier 8, it can only be on the left side (the array is sorted). If you find another 8 further left, you overwrite the note. When the search area runs out, the note holds the leftmost 8.

Saving the index first is what makes it safe to keep moving: even if the left side has no more 8's, we haven't lost the one we found.

4Building the conditions from examples

The teacher builds the code one case at a time while walking through nums = [5, 7, 7, 8, 8, 10], target 8. Let's do the same.

Start: left = 0, right = 5 → mid = 2

index012345
value5778810L=0, mid=2, R=5

Case 1: nums[mid] < target → go right, left = mid + 1

nums[2] = 7 and we want 8. Since the array is sorted, every 8 must be to the right of this 7. The whole left half (5, 7, 7) is useless, and so is mid itself, because 7 is not 8. So we jump left past mid: left = mid + 1 = 3.

value5778810grey = thrown away. Now search only 3…5
Case 1if nums[mid] < target: left = mid + 1

Case 2: nums[mid] == target → save it, then keep going LEFT

New mid = 3 + (5 − 3) // 2 = 4. nums[4] = 8, a match! But is it the first 8? We can't tell yet. There may be another 8 on its left (and in fact there is, at index 3).

value5778810ans = 4, right = 3
Case 2 (first position)if nums[mid] == target: ans = mid; right = mid - 1
Doubt 1: why mid - 1? Mid might be the answer. Shouldn't we keep it inside the range?
→ We don't need to keep it in the range because it's already saved in ans. If the left side has no more 8's, ans still holds 4. The teacher says that keeping mid in the range (right = mid) is also a correct approach, but then the loop must be while left < right, and it stops when both pointers sit on the same index. She chooses the "save in a variable, then move past mid" style because it's easier to reason about. Part C shows the other style.

Why the loop must be while left <= right (her long explanation)

Now left = 3 and right = 3. Both pointers are on the same cell. Should we stop?

The teacher says no, check that last cell too. Her reasoning: we haven't looked at this cell yet. It might be another 8 (then the answer improves), or it might be something else (she imagines it being a 6). We can only know which by checking it. If we skip it, we can't tell whether the saved answer is really the first.

In our example it really matters. mid = 3, nums[3] = 8 → ans = 3, right = 2. Now left (3) > right (2): the pointers have crossed, every cell has been considered, and we stop with ans = 3 ✓.

loop conditionwhat happens at left = right = 3answer
while left <= rightchecks index 3, finds 8, updates ans to 33 ✓
while left < right (with right = mid - 1)stops without looking at index 34 ✗ (the second 8)

Case 3: nums[mid] > target → go left, right = mid - 1

At first the teacher had only the two cases above, and then she noticed a hole with a new example: nums = [1, 2, 3, 4, 4, 5], target 1.

index012345
value123445mid = 2 → 3 > 1

mid lands on 3, which is bigger than 1. Our code had no rule for this. Since the array is sorted, the 1 must be on the left. And mid itself is not 1, so there's no point keeping it: right = mid - 1. Now we search only [1, 2].

Case 3else (nums[mid] > target): right = mid - 1

Starting value of ans

We start with ans = -1. If we never land on the target, it's never overwritten, and −1 is exactly what the question wants for "not found".

Doubt 2: notice that case 2 and case 3 both do right = mid - 1. Why keep them separate?
→ Only case 2 also does ans = mid. In other words, the rule is "if nums[mid] >= target, move right to mid − 1, and if it was equal, save it". The teacher writes three clear cases so beginners can see each reason. Part D shows that "first index with value >= target" is exactly the lower bound.

5Approach steps

  1. Set left = 0, right = len(nums) - 1, ans = -1.
  2. While left <= right: compute mid = left + (right - left) // 2.
  3. If nums[mid] == target: save ans = mid, then search left: right = mid - 1.
  4. Else if nums[mid] < target: the target is on the right: left = mid + 1.
  5. Else (nums[mid] > target): the target is on the left: right = mid - 1.
  6. When the pointers cross, return ans.

6Code (Python)

find the FIRST position
def find_first(nums, target):
    left, right = 0, len(nums) - 1
    ans = -1                                  # -1 means "not found yet"
    while left <= right:
        mid = left + (right - left) // 2      # overflow-safe habit
        if nums[mid] == target:
            ans = mid                         # might be the first one: save it
            right = mid - 1                   # but look for an earlier one on the LEFT
        elif nums[mid] < target:
            left = mid + 1                    # target is on the right
        else:
            right = mid - 1                   # target is on the left
    return ans

7Code line by line

linewhat it means
left, right = 0, len(nums) - 1The search area starts as the whole array. For an empty array, right = −1, so the loop below never runs.
ans = -1The sticky note. Stays −1 if the target never shows up.
while left <= right:Keep going while at least one unchecked cell is left (the = covers the single-cell case).
mid = left + (right - left) // 2The middle index. // is integer division, so mid is a whole number.
if nums[mid] == target: ans = mid right = mid - 1Found one. Write it down, then shrink the area to the part left of mid to hunt for an earlier copy.
elif nums[mid] < target: left = mid + 1mid is too small, and so is everything left of it. Keep only the right part.
else: right = mid - 1mid is too big, and so is everything right of it. Keep only the left part.
return ansThe leftmost index we ever saw holding the target, or −1.

8Dry run (hand table)

nums = [5, 7, 7, 8, 8, 10], target = 8.

stepleftrightmidnums[mid]decisionansthrown away
105277 < 8 → left = 3−1indexes 0–2
23548equal → save, right = 34indexes 4–5
33338equal → save, right = 23index 3
end32left > right → stop, return 3 ✓
step 15778810mid=2, too small
step 25778810mid=4, match → ans=4, go left
step 35778810mid=3, match → ans=3, go left
end5778810nothing left → 3

Her second example, [1, 2, 3, 4, 4, 5], target 1: mid=2 (3 > 1) → right=1; mid=0 (1 = 1) → ans=0, right=−1; crossed → 0 ✓.

9Complexity & remember

Remember: first positionOn a match: save, then go LEFT (right = mid - 1). Loop with <=. Start ans = -1.

Part B · Finding the LAST position

1The question

Same array, same target. Now find the index of the last copy of the target (4 for target 8 in [5, 7, 7, 8, 8, 10]), or −1.

2Constraints

Same as Part A: possibly empty, big values, sorted.

3Intuition: what changes

The teacher asks: "can the same code give us the last position?" Go back to step 2 of the dry run, where mid = 4 landed on an 8. For the first position we went left. But if we want the last position, a later 8 could only be on the right. So we still save the index, but we move the other way.

That's why one function can't do both jobs: at a match it must choose a direction. So we write a second function with one line changed.

4The conditions

casefirst position (Part A)last position (Part B)
nums[mid] == targetans = mid; right = mid - 1 (go left)ans = mid; left = mid + 1 (go right)
nums[mid] < targetleft = mid + 1, same in both
nums[mid] > targetright = mid - 1, same in both (her [1,2,3,4,4,5] example)

The same reasoning about <= applies: the last unchecked cell might be one more 8 further right, so check it.

5Approach steps

  1. Same setup: left = 0, right = n - 1, ans = -1.
  2. On a match: save ans = mid, then search right: left = mid + 1.
  3. Too small → left = mid + 1. Too big → right = mid - 1.
  4. Return ans.

6Code (Python)

find the LAST position
def find_last(nums, target):
    left, right = 0, len(nums) - 1
    ans = -1
    while left <= right:
        mid = left + (right - left) // 2
        if nums[mid] == target:
            ans = mid                         # might be the last one: save it
            left = mid + 1                    # ONLY CHANGE: look for a later one on the RIGHT
        elif nums[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
    return ans

7Code line by line

linewhat it means
ans = mid left = mid + 1Found one. Write it down, then keep only the part right of mid, hunting for a later copy.
everything elseIdentical to find_first, for the same reasons.

8Dry run (hand table)

nums = [5, 7, 7, 8, 8, 10], target = 8.

stepleftrightmidnums[mid]decisionansthrown away
105277 < 8 → left = 3−10–2
23548equal → save, left = 543–4
35551010 > 8 → right = 445
end54crossed → return 4 ✓
step 25778810match → ans=4, go right
step 3577881010 is too big → right = 4

Her second example [1, 2, 3, 4, 4, 5], target 1: mid=2 (3 > 1) → right=1; mid=0 (match) → ans=0, left=1; mid=1 (2 > 1) → right=0; crossed → 0. First = last = 0, because 1 appears once.

9Complexity & remember

O(log n) time, O(1) space, same as Part A.

Remember: last positionSame code as first, but on a match: save, then go RIGHT (left = mid + 1).

Part C · Putting both together

1The question

Return [first, last], which is the full LeetCode answer.

2Constraints

An empty array must give [-1, -1], and both helpers already do that.

3Intuition

The teacher's final code has two helper functions (find first, find last). The main function calls both, stores the results in first and last, and returns them as a pair. If the target is missing, both helpers return −1 on their own, so we get [-1, -1] with no extra check.

4Building it: walk a "not found" case

target 6 in [5, 7, 7, 8, 8, 10], inside find_first:

stepleftrightmidnums[mid]decisionans
105277 > 6 → right = 1−1
201055 < 6 → left = 1−1
311177 > 6 → right = 0−1
end10crossed, ans never changed → −1

Notice where left ended: index 1, the first value ≥ 6. That's the lower bound of 6 from Part 0. Binary search always leaves left at the switch point.

5Approach steps

  1. first = find_first(nums, target)
  2. last = find_last(nums, target)
  3. Return [first, last].

6Code (Python)

LeetCode 34, the teacher's approach
class Solution:
    def searchRange(self, nums, target):
        first = self.findFirst(nums, target)
        last = self.findLast(nums, target)
        return [first, last]

    def findFirst(self, nums, target):
        left, right = 0, len(nums) - 1
        ans = -1
        while left <= right:
            mid = left + (right - left) // 2
            if nums[mid] == target:
                ans = mid
                right = mid - 1          # go left for an earlier copy
            elif nums[mid] < target:
                left = mid + 1
            else:
                right = mid - 1
        return ans

    def findLast(self, nums, target):
        left, right = 0, len(nums) - 1
        ans = -1
        while left <= right:
            mid = left + (right - left) // 2
            if nums[mid] == target:
                ans = mid
                left = mid + 1           # go right for a later copy
            elif nums[mid] < target:
                left = mid + 1
            else:
                right = mid - 1
        return ans

The other style she mentions: keep mid inside the range (right = mid)

Instead of saving mid in ans, you can keep it inside the search area by setting right = mid. Then the loop runs while left < right and ends when both pointers meet. At the end, you check whether that one cell is the target.

variant: no ans variable
def find_first_v2(nums, target):
    if not nums:
        return -1
    left, right = 0, len(nums) - 1
    while left < right:
        mid = left + (right - left) // 2          # lower middle
        if nums[mid] < target:
            left = mid + 1
        else:
            right = mid                           # mid may be the first copy: keep it
    return left if nums[left] == target else -1

def find_last_v2(nums, target):
    if not nums:
        return -1
    left, right = 0, len(nums) - 1
    while left < right:
        mid = left + (right - left + 1) // 2      # UPPER middle (see doubt)
        if nums[mid] > target:
            right = mid - 1
        else:
            left = mid                            # mid may be the last copy: keep it
    return left if nums[left] == target else -1
Doubt (a fix the video doesn't mention): why does the last-position variant use (right - left + 1) // 2?
→ Try [8, 8] with the normal mid. left=0, right=1 → mid=0 → match → left = mid = 0. Nothing changed, so the loop runs forever. With two cells left, the normal formula picks the left one, and left = mid doesn't move. Rounding mid up picks the right one (mid=1), so left = 1 and the loop ends. Rule: if a branch does left = mid, round mid up. This trap is one reason the teacher prefers the "save in ans, then mid ± 1" style. Both pointers always move, so it can never get stuck.

7Code line by line (main function)

linewhat it means
first = self.findFirst(nums, target)Binary search #1, leaning left on matches.
last = self.findLast(nums, target)Binary search #2, leaning right on matches.
return [first, last]Both −1 if missing. Otherwise first ≤ last.

8Dry run of the whole thing

  1. searchRange([5,7,7,8,8,10], 8) → findFirst gives 3 (Part A table) → findLast gives 4 (Part B table) → [3, 4].
  2. searchRange([5,7,7,8,8,10], 6) → both helpers never match → [-1, -1].
  3. searchRange([], 0) → right = −1, both loops skip → [-1, -1].
  4. searchRange([1], 1) → mid=0 matches in both → [0, 0].

9Complexity & remember

RememberTwo binary searches. Same three cases. The only difference is the direction you go after saving a match.

Part D · The same answer using lower_bound / upper_bound

Not shown in the video. This is added to connect her code to the pattern's name.

1The idea

From Part 0: first position = lower bound (first index with value ≥ target), and last position = upper bound − 1 (one before the first index with value > target). Each bound is one binary search that never needs an "equal" case.

index012345
value5778810
bounds↑LB↑UBfirst = LB = 3, last = UB − 1 = 4

2Code (Python)

lower / upper bound version
def lower_bound(nums, x):          # first index with nums[i] >= x  (n if none)
    left, right = 0, len(nums) - 1
    ans = len(nums)
    while left <= right:
        mid = left + (right - left) // 2
        if nums[mid] >= x:
            ans = mid
            right = mid - 1
        else:
            left = mid + 1
    return ans

def upper_bound(nums, x):          # first index with nums[i] > x   (n if none)
    left, right = 0, len(nums) - 1
    ans = len(nums)
    while left <= right:
        mid = left + (right - left) // 2
        if nums[mid] > x:
            ans = mid
            right = mid - 1
        else:
            left = mid + 1
    return ans

def search_range(nums, target):
    lb = lower_bound(nums, target)
    if lb == len(nums) or nums[lb] != target:
        return [-1, -1]                       # target is missing
    return [lb, upper_bound(nums, target) - 1]

The only difference between the two bounds is >= vs >. Python's built-in bisect.bisect_left(nums, x) is the lower bound and bisect.bisect_right(nums, x) is the upper bound, which is handy for checking your answer. But in an interview, write the loop yourself.

3Complexity

Still two binary searches: O(log n) time, O(1) space.


Part E · Revision page

find firstfind last
startleft = 0, right = n - 1, ans = -1
loopwhile left <= right (check the last single cell too)
matchans = mid; right = mid - 1ans = mid; left = mid + 1
too smallleft = mid + 1
too bigright = mid - 1
same as…lower bound (if the value there is the target)upper bound − 1
lower boundupper bound
definitionfirst index with value ≥ xfirst index with value > x
in [5,7,7,8,8,10], x=83 (first 8)5 (the 10, just after the 8s)
in [5,7,7,8,8,10], x=611 (equal → 6 is missing)
Python built-inbisect_leftbisect_right
If you remember only 5 lines 1. Sorted array + "first/last" → binary search that doesn't stop on a match.
2. On a match: save ans = mid, then go left (first) or right (last).
3. Smaller → left = mid + 1; bigger → right = mid - 1.
4. while left <= right, so the last single cell is checked too.
5. Two searches = 2 log n = O(log n). Missing target → both stay −1.
Mistakes to avoid ✗ returning as soon as nums[mid] == target (that's plain binary search)
✗ trying to get first and last from one search
✗ while left < right together with mid - 1 moves (misses the last cell)
✗ forgetting the "nums[mid] > target" case
✗ left = mid with the normal (lower) mid → infinite loop on two cells
✗ (left + right) / 2 in Java/C++ on huge ranges (overflow)
test it yourself (paste under the Part C solution)
s = Solution()
print(s.searchRange([5, 7, 7, 8, 8, 10], 8))   # [3, 4]
print(s.searchRange([5, 7, 7, 8, 8, 10], 6))   # [-1, -1]
print(s.searchRange([], 0))                    # [-1, -1]
print(s.searchRange([1, 2, 3, 4, 4, 5], 1))    # [0, 0]
print(s.searchRange([2, 2, 2, 2], 2))          # [0, 3]

Based on this video: Find First and Last Position of Element in Sorted Array