Part IV · Search, Selection and Optimisation Pattern 11 4 problems

Modified Binary Search

Binary search is not about sorted arrays. It is about any yes/no question whose answer flips exactly once along a line. Find the flip, and you have halved the world.

Everyone can write the textbook version. Interviews test the modified versions: rotated arrays, first and last occurrence, peaks, and the big one, binary search on the answer, where there is no array to search at all. All of them are the same loop with a different question in the middle.

Contents

  1. When to use
  2. Core idea
  3. Getting the loop right, every time
  4. The templates
  5. Binary search on the answer
  6. Common mistakes
  7. Binary Search
  8. Search in Rotated Sorted Array
  9. Find First and Last Position
  10. Peak Index in a Mountain Array
  11. Recap

When to use

The trigger. The search space is monotone with respect to the question you are asking. Write the answer to your yes/no question at every position: if it reads F F F F T T T, with a single flip, binary search applies. Sortedness is the most common way to get monotonicity, but it is not the only one.

Core idea

Maintain a range that is guaranteed to contain the answer. Test the midpoint. The test must let you discard one side with certainty. Repeat. Each step halves the range, so a range of size n is exhausted in about log₂n steps. Every difficulty in this pattern comes from one place: proving the discard is safe.
predicate value at each index F F F T T T 01 23 45 the boundary: first index where the predicate is true every binary-search variant is a search for this one index
Figure 11.1 — Rewrite any binary search as “find the first true”. Then there is one loop to remember instead of five.

Getting the loop right, every time

Binary search is notorious for off-by-one errors. The cure is to pick one convention and never deviate. Two conventions are worth knowing.

The rule that prevents infinite loops. Every iteration must shrink the range. With mid = left + (right - left) / 2 the midpoint rounds down, so mid can equal left but never equals right when left < right. That means right = mid is safe and left = mid is not: it can leave the range unchanged forever. If you ever need left = mid, round the midpoint up instead, with mid = left + (right - left + 1) / 2.

Write mid = left + (right - left) / 2 rather than (left + right) / 2. In C# an int holds at most about 2.1 billion. If left + right goes past that, it wraps to a negative number without any error, because C# arithmetic is unchecked by default. The negative mid then throws IndexOutOfRangeException. This exact bug sat in the JDK’s own binary search for nine years. Interviewers notice the safe form.

C# rounding detail. Integer / in C# truncates toward zero, not toward minus infinity like Python’s //. For non-negative ranges the two agree. If your search range can be negative, as in a search over answer values from -10⁹ up, (right - left) / 2 is still safe because right - left is never negative. Keep that form and the rounding stays “down”.

The templates

Template A — exact match
public static class TemplateExact
{
    /// <summary>Template A: index of target in a sorted array, or -1.</summary>
    /// <param name="nums">Ascending integers.</param>
    /// <param name="target">The value to find.</param>
    /// <returns>An index holding target, or -1.</returns>
    /// <example><c>Find([1, 3, 5, 7, 9, 11, 13], 9)</c> returns 4.</example>
    public static int Find(int[] nums, int target)
    {
        // Closed range [left, right]. 0 is the first index. Length - 1 is the last.
        int left = 0, right = nums.Length - 1;

        // Invariant: if target is in nums, its index is inside [left, right].
        // <= because a range with left == right still holds one candidate.
        while (left <= right)
        {
            // / 2: integer midpoint, rounds down for non-negative ints.
            // left + (right - left) / 2 cannot overflow. (left + right) / 2 can.
            int mid = left + (right - left) / 2;

            if (nums[mid] == target)
            {
                return mid;             // exact hit, done
            }
            if (nums[mid] < target)
            {
                left = mid + 1;         // + 1: mid is too small, so drop it too
            }
            else
            {
                right = mid - 1;        // - 1: mid is too large, so drop it too
            }
        }

        return -1;                      // -1: the "not found" signal, never a real index
    }
}
nums = [1, 3, 5, 7, 9, 11, 13], target = 9 0 1 2 3 4 5 6 left=0 right=6 mid=3 1 3 5 7 9 11 13 L M R 7 < 9: too small left = mid + 1 = 4 left=4 right=6 mid=5 1 3 5 7 9 11 13 L M R 11 > 9: too big right = mid - 1 = 4 left=4 right=4 mid=4 1 3 5 7 9 11 13 L,M,R 9 == 9: return 4 in range mid discarded answer
Figure 11.2 — Each probe at mid throws away the half that cannot hold the target, plus mid itself.

Reading the figure. Blue cells are still in the range. Amber is mid. Red cells are discarded. Green is the answer. The purple letters mark left, mid, and right. Notice that mid is dropped with + 1 or - 1, since it was already checked.

Template B — first index satisfying a predicate
public static class FirstTrue
{
    /// <summary>
    /// Template B: smallest x in [lo, hi] with predicate(x) true, or hi + 1 if none.
    /// Requires the predicate to be monotone: once true, true forever.
    /// </summary>
    /// <param name="lo">The smallest candidate x.</param>
    /// <param name="hi">The largest candidate x. Must be below int.MaxValue.</param>
    /// <param name="predicate">A yes/no test: false, false, ..., then true forever.</param>
    /// <returns>The first x where predicate is true, or hi + 1.</returns>
    /// <example>
    /// With nums = [1, 3, 3, 5, 8]: <c>Find(0, 4, i =&gt; nums[i] &gt;= 4)</c> returns 3.
    /// </example>
    public static int Find(int lo, int hi, Func<int, bool> predicate)
    {
        // lo, hi: the smallest and largest candidate x.
        //   Lower bound example: lo = 0, hi = nums.Length - 1.
        // predicate(x): a yes/no test that is false, false, ..., then true forever.
        //   Lower bound example: predicate(i) is nums[i] >= target.
        //   Peak example: predicate(i) is arr[i] > arr[i + 1].
        // + 1: right is one past the last candidate, so "none true" returns hi + 1.
        int left = lo, right = hi + 1;

        // Invariant: every x below left is false. Every x at or above right is true
        // (or past the end). The answer is always inside [left, right].
        // < not <=: stop when left == right, the range is down to one spot.
        while (left < right)
        {
            int mid = left + (right - left) / 2;    // / 2 rounds down, so mid < right

            if (predicate(mid))
            {
                right = mid;            // mid might be the answer, so keep it
            }
            else
            {
                left = mid + 1;         // + 1: mid is definitely not, so discard it
            }
        }

        return left;                    // left == right: the first true, or hi + 1
    }
}

This one template covers lower bound, upper bound, insertion position, first occurrence, last occurrence, and every “binary search on the answer” problem. If you memorise one, memorise this one.

nums = [1, 3, 3, 5, 8], predicate(i) = nums[i] >= 4 0 1 2 3 4 5 nums 1 3 3 5 8 end left=0 right=5 mid=2 F F F T T T L M R F: left = mid + 1 = 3 left=3 right=5 mid=4 F F F T T T L M R T: right = mid = 4 left=3 right=4 mid=3 F F F T T T L,M R T: right = mid = 3 left == right == 3: the loop stops and returns 3, the first T.
Figure 11.3 — Template B never returns early. It squeezes left and right together until they meet on the first true.

Reading the figure. Each cell shows predicate(i), which is nums[i] >= 4. The extra cell at index 5 is the “none true” slot, hi + 1. Blue is still in range, amber is mid, red is discarded, green is the answer. A T at mid keeps mid with right = mid. An F drops it with left = mid + 1.

What .NET gives you. Array.BinarySearch and List<T>.BinarySearch return the index when they find the value. When they do not, they return a negative number, the bitwise complement of the insertion point. So ~result gives the insertion point. Two traps follow. First, with duplicates the hit can be any copy, not the first, so it is not a lower bound. Second, using a negative result as an index throws. There is no bisect_left in the base library. In an interview, write Template B. Problem 3 shows a comparer trick that makes Array.BinarySearch act as a true lower bound.

Binary search on the answer

This is the version that separates candidates, and it deserves its own heading because there is no array in it at all.

The shape. The question asks for the smallest (or largest) value x such that something is achievable. Checking “is x achievable” is easy and linear. And achievability is monotone: if x works then anything larger works too. Then binary search over the range of possible answers, not over any input array, calling the feasibility check at each step.
public static class MinimumFeasible
{
    /// <summary>Smallest value in [lo, hi] that works, assuming feasibility is monotone.</summary>
    /// <param name="lo">Smallest possible answer.</param>
    /// <param name="hi">Largest possible answer. Assumed to work.</param>
    /// <param name="isFeasible">True if answer x works. Once true, larger x stays true.</param>
    /// <returns>The smallest feasible value.</returns>
    /// <example>
    /// Koko with piles [3, 6, 7, 11] and h = 8:
    /// <c>Find(1, 11, k =&gt; hoursAt(k) &lt;= 8)</c> returns 4.
    /// </example>
    public static long Find(long lo, long hi, Func<long, bool> isFeasible)
    {
        // long, not int: answer ranges such as 1..10^12 do not fit in an int.
        // lo, hi: the range of possible answers, not array indexes.
        //   Koko example: lo = 1 (slowest speed), hi = piles.Max() (one pile per hour).
        // isFeasible(x): true if answer x works. Once true, larger x stays true.
        //   Koko example: can she eat every pile within h hours at speed x?
        // No + 1 on hi: hi is assumed to work, so the answer always exists.
        long left = lo, right = hi;

        // Invariant: the smallest feasible value is inside [left, right].
        while (left < right)
        {
            long mid = left + (right - left) / 2;   // / 2 rounds down, so mid < right

            if (isFeasible(mid))
            {
                right = mid;            // mid works, but something smaller might too
            }
            else
            {
                left = mid + 1;         // + 1: mid fails, and so does everything below
            }
        }

        return left;                    // left == right: the smallest value that works
    }
}
piles = [3, 6, 7, 11], h = 8. Find the smallest speed k that works. speed k hours is_feasible 1 27 F 2 15 F 3 10 F 4 8 T 5 8 T 6 6 T 7 5 T 8 5 T 9 5 T 10 5 T 11 4 T [1, 11] mid=6 k=6: 6 h, works. right = 6 [1, 6] mid=3 k=3: 10 h, too slow. left = 4 [4, 6] mid=5 k=5: 8 h, works. right = 5 [4, 5] mid=4 k=4: 8 h, works. right = 4 left == right == 4: Koko needs speed 4. Speed 3 takes 10 hours.
Figure 11.4 — Search the range of answers, not an array. The feasibility check plays the role of nums[mid].

Reading the figure. The top row lists every possible speed and the hours it would take for piles = [3, 6, 7, 11]. A speed is feasible (T) when the hours are at most 8. Amber is the speed tested, red is ruled out, green is the answer. The search calls the check only four times, not eleven.

Total cost is O(log(range) × cost of one check). When you see “minimise the maximum” or “maximise the minimum”, say “binary search on the answer” immediately. It is the single most reliable pattern-recognition win in the whole set.

Common mistakes

The problems

1. Binary Search Easy

Problem

Given a sorted array of distinct integers and a target, return its index, or -1 if it is absent. Must be O(log n).

Approach

Solution

public static class BinarySearch
{
    /// <summary>Index of target in a sorted array of distinct integers, or -1.</summary>
    /// <param name="nums">Ascending, distinct integers.</param>
    /// <param name="target">The value to find.</param>
    /// <returns>The index of target, or -1 if it is not present.</returns>
    /// <example><c>Search([-1, 0, 3, 5, 9, 12], 9)</c> returns 4.</example>
    public static int Search(int[] nums, int target)
    {
        // Closed range. 0 is the first index. nums.Length - 1 is the last index.
        int left = 0, right = nums.Length - 1;

        // Invariant: if target is in nums, its index is inside [left, right].
        // <= because left == right still leaves one candidate to check.
        while (left <= right)
        {
            // Overflow-safe form. Required in C#, where int wraps past 2^31 - 1.
            // / 2: integer midpoint, rounds down.
            int mid = left + (right - left) / 2;

            if (nums[mid] == target)
            {
                return mid;
            }
            if (nums[mid] < target)
            {
                left = mid + 1;         // + 1: mid and everything left of it is too small
            }
            else
            {
                right = mid - 1;        // - 1: mid and everything right of it is too large
            }
        }

        return -1;                      // range is empty: -1 means "not present"
    }
}

Walkthrough

nums = [-1, 0, 3, 5, 9, 12], target = 2:

nums = [-1, 0, 3, 5, 9, 12], target = 2 0 1 2 3 4 5 left=0 right=5 mid=2 -1 0 3 5 9 12 L M R 3 > 2: right = 1 left=0 right=1 mid=0 -1 0 3 5 9 12 L,M R -1 < 2: left = 1 left=1 right=1 mid=1 -1 0 3 5 9 12 L,M,R 0 < 2: left = 2 left=2 right=1 -1 0 3 5 9 12 R L left > right: return -1 in range mid discarded answer
Figure 11.5 — The range shrinks to nothing, so 2 is not in the list and the answer is -1.

Reading the figure. Blue cells are still in the range. Amber is mid. Red cells are discarded. Green is the answer. There is no green cell here. After the third step left passes right, the range is empty, and the loop exits with -1.

TimeO(log n)SpaceO(1)

Why log n, precisely

The range starts at size n and at least halves each iteration, so after k iterations it is at most n / 2ᵏ. The loop stops when the size drops below 1, which needs k > log₂n. For a billion elements that is 30 iterations.

Edge cases to raise

Say this out loud: “The invariant is that if the target exists, it is inside [left, right]. Every branch discards only values that cannot be the target, so the invariant survives.”

2. Search in Rotated Sorted Array Medium

Problem

A sorted array of distinct values has been rotated at some unknown pivot, for example [4,5,6,7,0,1,2]. Find the index of a target, or -1. Must be O(log n).

The key observation

Cut a rotated array at any midpoint and at least one of the two halves is still properly sorted. There is only one rotation point, so it can sit in one half but not both. Compare nums[left] with nums[mid] to find which half is the clean one, then check whether the target falls inside that half’s known range. If it does, search there. If it does not, it must be in the messy half.

The reasoning is always about the sorted half, because that is the only half whose contents you can reason about from its two endpoints.

Solution

public static class RotatedSearch
{
    /// <summary>Index of target in a rotated sorted array of distinct values, or -1.</summary>
    /// <param name="nums">A sorted array rotated at an unknown pivot. Values are distinct.</param>
    /// <param name="target">The value to find.</param>
    /// <returns>Its index, or -1.</returns>
    /// <example><c>Search([4, 5, 6, 7, 0, 1, 2], 0)</c> returns 4.</example>
    public static int Search(int[] nums, int target)
    {
        // Closed range. 0 is the first index. nums.Length - 1 is the last index.
        int left = 0, right = nums.Length - 1;

        // Invariant: if target is in nums, its index is inside [left, right].
        while (left <= right)
        {
            int mid = left + (right - left) / 2;    // / 2: integer midpoint, rounds down

            if (nums[mid] == target)
            {
                return mid;
            }

            // <= not <: when two elements remain, mid == left and they are equal.
            if (nums[left] <= nums[mid])
            {
                // Left half [left, mid] is sorted, so its range is known exactly.
                // mid is excluded with < because we already know it is not target.
                if (nums[left] <= target && target < nums[mid])
                {
                    right = mid - 1;    // - 1: target is in the left half, drop mid
                }
                else
                {
                    left = mid + 1;     // + 1: target is not there, drop mid too
                }
            }
            else
            {
                // Then the right half [mid, right] must be the sorted one.
                if (nums[mid] < target && target <= nums[right])
                {
                    left = mid + 1;     // + 1: target is in the right half, drop mid
                }
                else
                {
                    right = mid - 1;    // - 1: target is not there, drop mid too
                }
            }
        }

        return -1;                      // range is empty: -1 means "not present"
    }
}

Why nums[left] <= nums[mid] and not <

When the range narrows to two elements, mid equals left, so the two values are the same and a strict < would wrongly classify the left half as unsorted. With distinct values, <= is correct and safe. If duplicates are allowed, this test breaks down entirely: for [1, 1, 1, 0, 1] you cannot tell which half is sorted, and the standard fix is to shrink left by one when nums[left] == nums[mid] == nums[right], which makes the worst case O(n). Raise this. It is the standard follow-up.

Walkthrough

nums = [4,5,6,7,0,1,2], target = 0:

nums = [4, 5, 6, 7, 0, 1, 2], target = 0 0 1 2 3 4 5 6 left=0 right=6 mid=3 4 5 6 7 0 1 2 left half 4..7 sorted 0 not in it: left = 4 left=4 right=6 mid=5 4 5 6 7 0 1 2 left half 0..1 sorted 0 in it: right = 4 left=4 right=4 mid=4 4 5 6 7 0 1 2 0 == 0: return 4 in range mid discarded answer
Figure 11.6 — At each step one half is sorted. The target is either inside its known range or in the other half.

Reading the figure. Blue cells are in range, amber is mid, red is discarded, green is the answer. The purple bracket marks the half that is sorted. In step 1, 0 is outside 4 to 7, so the whole left half goes. In step 2, 0 is inside 0 to 1, so the search keeps the left part.

TimeO(log n)SpaceO(1)

The two-pass alternative

Find the rotation point with a separate binary search (the smallest element), then run a normal binary search on the correct segment. Two clean passes, still O(log n), and easier to get right under pressure. The one-pass version above is what most interviewers expect, but naming the two-pass option shows you have thought about the trade-off between cleverness and clarity.

Edge cases to raise

Say this out loud: “There is only one rotation point, so at least one half is always properly sorted. I identify that half, and I can decide about the target because I know exactly what range it covers.”

3. Find First and Last Position of Element in Sorted Array Medium

Problem

Given a sorted array that may contain duplicates, return the first and last index of a target as [first, last], or [-1, -1] if it is absent. Must be O(log n).

Approach

Solution

public static class SearchRange
{
    /// <summary>
    /// First index where nums[index] &gt;= target, or nums.Length if there is none.
    /// The predicate "nums[i] &gt;= target" is false then true, with one flip.
    /// </summary>
    /// <param name="nums">Ascending integers.</param>
    /// <param name="target">The bound. A long, so callers can pass int.MaxValue + 1L.</param>
    /// <returns>The insertion point for target.</returns>
    /// <example><c>LowerBound([5, 7, 7, 8, 8, 10], 8)</c> returns 3.</example>
    public static int LowerBound(int[] nums, long target)
    {
        // Half-open [left, right). 0 is the first index. right = nums.Length is one
        // past the end, so "every value is smaller" can return nums.Length.
        int left = 0, right = nums.Length;

        // Invariant: nums[..left] are all < target. nums[right..] are all >= target.
        while (left < right)
        {
            int mid = left + (right - left) / 2;    // / 2 rounds down, so mid < right

            if (nums[mid] < target)
            {
                left = mid + 1;         // + 1: mid fails, discard it
            }
            else
            {
                right = mid;            // mid might be the answer, keep it
            }
        }

        return left;                    // left == right: the first index >= target
    }

    /// <summary>First and last index of target in a sorted array, or [-1, -1].</summary>
    /// <param name="nums">Ascending integers, duplicates allowed.</param>
    /// <param name="target">The value to locate.</param>
    /// <returns>[firstIndex, lastIndex], or [-1, -1] if target is absent.</returns>
    /// <example><c>Find([5, 7, 7, 8, 8, 10], 8)</c> returns [3, 4].</example>
    public static int[] Find(int[] nums, int target)
    {
        int first = LowerBound(nums, target);

        // LowerBound always returns a valid insertion point, which may be past
        // the end or may point at a larger value. Both mean "not present".
        // The length check comes first so nums[first] never reads past the end.
        if (first == nums.Length || nums[first] != target)
        {
            return [-1, -1];            // -1, -1: the "absent" answer the problem asks for
        }

        // The last target sits just before the first value greater than target.
        // + 1L: the next integer up, done in long so int.MaxValue + 1 cannot wrap.
        // - 1: step back from that first larger value onto the last target.
        int last = LowerBound(nums, target + 1L) - 1;

        return [first, last];
    }
}

Walkthrough

nums = [5, 7, 7, 8, 8, 10], target = 8. LowerBound(8) returns 3, the first index with a value at least 8. LowerBound(9) returns 5, the first index with a value at least 9, so the last 8 is at index 4. Answer [3, 4].

Now target = 6. LowerBound(6) returns 1, but nums[1] is 7, not 6, so the guard fires and the answer is [-1, -1]. That validation step is essential. A boundary search never tells you the value is present, only where it would go.

nums = [5, 7, 7, 8, 8, 10], target = 8 0 1 2 3 4 5 nums 5 7 7 8 8 10 nums[i] >= 8 F F F T T T LowerBound(8) = 3 first = 3 nums[i] >= 9 F F F F F T LowerBound(9) = 5 last = 5 - 1 = 4 Answer [3, 4]. For target 6, LowerBound gives 1, but nums[1] is 7, so the value check returns [-1, -1].
Figure 11.7 — The run of 8s sits between two boundaries. Two boundary searches find it in O(log n).

Reading the figure. Each row is one call to LowerBound. A cell shows T when nums[i] reaches the target, and green marks the first T. The run of 8s (amber) starts at the first boundary and ends one cell before the second.

TimeO(log n)SpaceO(1)

The standard-library version

public static class SearchRangeBcl
{
    // Never returns 0, so BinarySearch never stops on an equal value. It keeps going
    // left on ties and ends at the complement (~) of the first index >= value.
    // -1 = "element is smaller, go right". 1 = "element is bigger or equal, go left".
    private static readonly Comparer<int> Lower = Comparer<int>.Create((a, b) => a < b ? -1 : 1);
    // Same trick, but ties go right: ends at ~ of the first index > value.
    private static readonly Comparer<int> Upper = Comparer<int>.Create((a, b) => a <= b ? -1 : 1);

    /// <summary>
    /// What you would ship: Array.BinarySearch with a comparer that never says "equal",
    /// which turns it into a lower bound and an upper bound.
    /// </summary>
    /// <param name="nums">Ascending integers, duplicates allowed.</param>
    /// <param name="target">The value to locate.</param>
    /// <returns>[firstIndex, lastIndex], or [-1, -1] if target is absent.</returns>
    /// <example><c>Find([5, 7, 7, 8, 8, 10], 8)</c> returns [3, 4].</example>
    public static int[] Find(int[] nums, int target)
    {
        // ~ undoes the complement. The search never "finds", so the result is always < 0.
        int first = ~Array.BinarySearch(nums, target, Lower);
        // Past the end, or a different value there: target is absent.
        if (first == nums.Length || nums[first] != target)
        {
            return [-1, -1];            // -1, -1: the "absent" answer
        }
        // Upper: first index with nums[i] > target.
        // - 1: step back onto the last copy of target.
        int last = ~Array.BinarySearch(nums, target, Upper) - 1;
        return [first, last];
    }
}

The comparer never returns 0, so Array.BinarySearch never stops on a match. On ties, Lower says “go left” and Upper says “go right”. The search runs off the end of the range and returns the complement of the boundary. Lower gives the lower bound, the first index with a value at least target. Upper gives the upper bound, the first index with a value greater than target. Knowing which is which is worth stating. It is clever, so in an interview write LowerBound by hand first and offer this second.

Edge cases to raise

Say this out loud: “Two boundary searches, not one search and a scan, because a long run of equal values would make the scan linear. And a boundary search only gives an insertion point, so I have to check the value is actually there.”

4. Peak Index in a Mountain Array Medium

Problem

An array is a mountain: it strictly increases to a single peak, then strictly decreases. Return the index of the peak, in O(log n).

Why binary search works with no sorted array

The array is not sorted, but the question “is arr[i] > arr[i + 1]?” is monotone: false all the way up the mountain, then true all the way down, flipping exactly once at the peak. That is the F F F T T T shape from Figure 11.1, so Template B applies directly. This problem is the best demonstration that binary search is about monotone predicates, not about sortedness.

Solution

public static class MountainPeak
{
    /// <summary>Index of the single peak of a mountain array.</summary>
    /// <param name="arr">Strictly increasing then strictly decreasing, length &gt;= 3.</param>
    /// <returns>The index i where arr[i] is the maximum.</returns>
    /// <example><c>PeakIndex([0, 2, 5, 8, 4, 1])</c> returns 3.</example>
    public static int PeakIndex(int[] arr)
    {
        // 0 is the first index. arr.Length - 1 is the last. The peak is in between.
        int left = 0, right = arr.Length - 1;

        // Invariant: the peak is somewhere in [left, right].
        // < not <=: stop when one index is left, that index is the peak.
        while (left < right)
        {
            int mid = left + (right - left) / 2;    // / 2 rounds down, so mid < right

            // mid + 1 is safe: mid < right, so mid + 1 is at most right.
            // + 1 compares mid with its right neighbour to see the slope.
            if (arr[mid] < arr[mid + 1])
            {
                left = mid + 1;     // + 1: still climbing, so mid is not the peak
            }
            else
            {
                right = mid;        // descending or at the peak, so mid may be it
            }
        }

        return left;                // left == right, and that is the peak
    }
}

Why right = mid and not mid - 1

When arr[mid] > arr[mid + 1], the array is already descending at mid, so mid itself might be the peak. Discarding it with mid - 1 would lose the answer. In the other branch, arr[mid] < arr[mid + 1] proves mid is not the peak, so mid + 1 is safe. Discard only what you have proved cannot be the answer. That sentence is the whole discipline of this pattern.

Note also that mid + 1 is always a valid index inside the loop: the guard is left < right, so mid < right ≤ n - 1. No bounds check is needed, and saying so shows you checked.

Walkthrough

arr = [0, 2, 5, 8, 4, 1]:

arr = [0, 2, 5, 8, 4, 1] 0 2 5 8 4 1 0 1 2 3 4 5 left=0 right=5 mid=2 F F F T T - L M R 5 < 8, climbing left = mid + 1 = 3 left=3 right=5 mid=4 F F F T T - L M R 4 > 1, descending right = mid = 4 left=3 right=4 mid=3 F F F T T - L,M R 8 > 4, descending right = mid = 3 left == right == 3: return 3, the peak.
Figure 11.8 — Comparing arr[mid] with its right neighbour tells you which slope you are on. That is enough to halve the range.

Reading the figure. The bars show the mountain. The F/T row is arr[i] > arr[i + 1], false while climbing and true going down. Blue is in range, amber is mid, red is discarded, green is the peak. On a climb the code drops mid. On a descent it keeps mid, since it might be the peak.

TimeO(log n)SpaceO(1)

The generalisation: Find Peak Element

The sibling problem drops the mountain guarantee: the array may have several local peaks, and you must return any one of them, with nums[-1] and nums[n] treated as negative infinity. Remarkably, the identical code works. If arr[mid] < arr[mid + 1] then the rising slope to the right must eventually turn over or hit the boundary, so a peak exists in [mid + 1, right]. The same argument runs the other way. The invariant is not “the peak” but “a peak”, and it survives. Being able to explain why is a strong finish.

Edge cases to raise

Say this out loud: “The array is not sorted, but the predicate is this position on the downslope is false then true with exactly one flip. That is all binary search needs.”

Recap

The seven things to carry forward

Where this goes next

Pattern 12, Dynamic Programming, is the last and the largest. Where binary search discards half the possibilities, DP keeps all of them but makes sure no subproblem is ever solved twice.


← 10 — Subsets (Backtracking) 12 — Dynamic Programming →