Binary Search in JS: trying to find a consistent mental model

Viewed 272

I am grinding LeetCode these days and I encountered the challenge 162. Find Peak Element:

A peak element is an element that is strictly greater than its neighbors.

Given an integer array nums, find a peak element, and return its index. If the array contains multiple peaks, return the index to any of the peaks.

You may imagine that nums[-1] = nums[n] = -∞.

You must write an algorithm that runs in O(log n) time.

Constraints:

  • 1 <= nums.length <= 1000
  • -231 <= nums[i] <= 231 - 1
  • nums[i] != nums[i + 1] for all valid i

This question is about using binary search to find a peak element in an array.

I know we can think of the array as alternating ascending and descending sequences. Here is my solution

var findPeakElement = function(nums) {
    if(nums.length <= 1) return 0
    let left = 0, right = nums.length - 1
    
    while(left <= right) {
        const mid = left + right >>> 1
        if(nums[mid] > nums[mid + 1]) {
            right = mid - 1
        } else {
            left = mid + 1
        }
    }
    
    
    return left === nums.length ? left - 1 : left
};

If the nums[mid] is bigger than the next element in the array that we know we are in the descending sub array and the peak element must be lying in the left, and vice versa if then nums[mid] is smaller than the next element. So far so good. But what confused me is which index I should return eventually - left or right? To figure this out I need to go through a bunch of trial and error.

And if I slightly tweek the question to find the valley element e.g. [1, 3, 20, 4, 1, 0]'s valley elements should be 0. While I can reason about how we narrow the window but I still cannot seem to figure out which index I should return at the end of the binary search.

Here is my attempt for returning the valley element in the array by mirroring what I did for findPeakElement

var findValleyElement = function (nums) {
  if (nums.length <= 1) return 0
  let left = 0,
    right = nums.length - 1

  while (left <= right) {
    const mid = (left + right) >>> 1
    if (nums[mid] > nums[mid + 1]) {
      left = mid + 1
    } else {
      right = mid - 1
    }
  }

  return right
}

But this time I cannot use right as the returned index. I need to use left instead. I cannot seem to think of a consistent way of thinking through this without going through a bunch of examples, which is really not ideal since you still might miss some edge cases.

So my question is, is there some consistent mental model we can adopt when thinking about these binary search problems, specifically which index we should return to satisfy the requirements.

3 Answers

When the following condition is true:

if(nums[mid] > nums[mid + 1]) {

...then it could be that mid is a solution, maybe even the only one. So that means you shouldn't exclude it from the range, yet with right = mid - 1 you do exclude it. You should set right = mid. To then avoid a potentially endless loop, the loop condition should be left < right. This will ensure the loop will always end: the range is guaranteed to become smaller in each iteration*

* Let's for instance assume left == right + 1 at a certain moment. Then mid will become equal to left (since the odd bit in the sum is dropped with >>>). Now either we do right = mid or we do left = mid + 1. In either case we get that left == right. In all other cases where left < right, we get a mid that is strictly between those two limits, and then surely the range will become smaller.

Once the loop exits, left has become equal to right. The only possible index in that range (of 1) is that index.

There is now no more need to check whether left is nums.length, as this cannot happen: with our chosen while condition, left can never become greater than right, ... only equal to it. And since right is a valid index, no such out-of-range check is needed.

Also the case of array size 1 does not need special treatment now.

So:

var findPeakElement = function(nums) {
    let left = 0,
        right = nums.length - 1;

    while (left < right) {
        const mid = (left + right) >>> 1;
        if (nums[mid] > nums[mid + 1]) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }

    return left;
};

Valleys instead of Peaks

Here is my attempt for returning the valley element

If you want to find the valley element, it will not always work unless the following assumption in the question is changed from this:

You may imagine that nums[-1] = nums[n] = -∞

...to this:

You may imagine that nums[-1] = nums[n] = ∞

Once you have that agreed upon, you only have to change the comparison in the above code block from nums[mid] > nums[mid + 1] to nums[mid] < nums[mid + 1].

Since the array is capped at 1000 elements, a simple scan is constant time. If we're imagining that n (size of array) and k (values limited to range -k to +k) can grow, then use trincot's answer, modifying the initial selection of right to be capped at min(2k, n) since the max increasing streak is of size 2k+1.

def f(arr)
  0.upto(998) do |i|
    return arr[i] if arr[i+1] < arr[i]
  end
  return arr[999] # if we reach this, arr[998] < arr[999] and arr[1000] is -infinity
end

A peak is defined as any element whose neighbours are both less than the element. In the example below, there are are two peak elements, 5 and 4 -

        5,
          4,        4,
      3,          3,  3,
    2,      2,  2,      2 ]
[ 1,          1,

So we can take three elements off the input, a, b, and c and -

  1. if any a, b, or c is null, a valid comparison cannot be made and therefore there is no peak. stop the program
  2. otherwise if a < b and b > c, a peak has been found. output the peak
  3. finally drop a, and recur on the same input to search for additional peaks

That would look something like this -

function* peaks ([ a, b, c, ...more ]) {
  if (a == null || b == null || c == null) return // 1
  if (a < b && b > c) yield b                     // 2
  yield *peaks([ b, c, ...more ])                 // 3
}

for (const p of peaks([1,2,1,3,4,5,4,2,1,5,6,7,4]))
  console.log("found peak", p)

found peak 2
found peak 5
found peak 7

If you have a significantly large input, which I'm sure LeetCode will give you, handling arrays like this will create an enormous amount of wasteful intermediate values. A better approach would be to use an index, i -

function* peaks (t, i = 0) {
  let a = t[i], b = t[i + 1], c = t[i + 2]
  if (a == null || b == null || c == null) return // 1
  if (a < b && b > c) yield b                     // 2
  yield *peaks(t, i + 1)                          // 3
}

for (const p of peaks([1,2,1,3,4,5,4,2,1,5,6,7,4]))
  console.log("found peak", p)

found peak 2
found peak 5
found peak 7

And finally the use of recursion will limit the size of input that this program can handle. We can use a for loop to avoid any recursion limits -

function* peaks (t) {
  let a, b, c
  for (let i = 0; i<t.length; i++) {
    a = t[i], b = t[i + 1], c = t[i + 2]
    if (a == null || b == null || c == null) return // 1
    if (a < b && b > c) yield b                     // 2
  }
}

for (const p of peaks([1,2,1,3,4,5,4,2,1,5,6,7,4]))
  console.log("found peak", p)

found peak 2
found peak 5
found peak 7

In the last two example we perform three array lookups per step, t[i], t[i + 1], and t[i + 2]. As an optimization we can reduce this to just a single lookup -

function* peaks (t) {
  let a, b, c
  for (let i = 0; i<t.length; i++) {
    a = b, b = c, c = t[i]
    if (a == null || b == null || c == null) continue
    if (a < b && b > c) yield b
  }
}

for (const p of peaks([1,2,1,3,4,5,4,2,1,5,6,7,4]))
  console.log("found peak", p)

found peak 2
found peak 5
found peak 7

This works because our program effectively shifts the elements through a, b, and c in a "leftward" direction. Note the peaks in the b column -

a b c ...
null null 1 2,1,3,4,5,4,2,1,5,6,7,4
null 1 2 1,3,4,5,4,2,1,5,6,7,4
1 2 (peak) 1 3,4,5,4,2,1,5,6,7,4
2 1 3 4,5,4,2,1,5,6,7,4
1 3 4 5,4,2,1,5,6,7,4
3 4 5 4,2,1,5,6,7,4
4 5 (peak) 4 2,1,5,6,7,4
5 4 2 1,5,6,7,4
4 2 1 5,6,7,4
2 1 5 6,7,4
1 5 6 7,4
5 6 7 4
6 7 (peak) 4

With our optimised program, we can drop several other unnecessary actions. Index i is no longer needed and we can skip having to worry about off-by-one errors caused by i++ and comparisons of i<t.length. Additionally, we can skip the c == null check as c will always represent an element of the input array -

function* peaks (t) {
  let a, b, c
  for (const v of t) {
    a = b, b = c, c = v
    if (a == null || b == null) continue
    if (a < b && b > c) yield b
  }
}

for (const p of peaks([1,2,1,3,4,5,4,2,1,5,6,7,4]))
  console.log("found peak", p)

found peak 2
found peak 5
found peak 7

If you want to collect all peaks, you can use Array.from to convert any iterable into an array -

const allPeaks = peaks([1,2,1,3,4,5,4,2,1,5,6,7,4])
console.log(allPeaks)
[2, 5, 7]

Generators are a good fit for this kind of problem because they can be paused/canceled at any time, ie after the first peak is found -

const firstPeak (t) {
  for (const p of peaks(t))
    return p                 // <- immediately stops `peaks`
}

firstPeak([1,2,1,3,4,5,4,2,1,5,6,7,4])
2

If however you want to write firstPeaks without using a generator, there's nothing stopping you from doing so. Instead of using yield you can simply return -

function firstPeak (t) {
  let a, b, c
  for (const v of t) {
    a = b, b = c, c = v
    if (a == null || b == null) continue
    if (a < b && b > c) return b          // <-
  }
}

console.log("first peak", firstPeak([1,2,1,3,4,5,4,2,1,5,6,7,4]))

first peak 2
Related