29 AUGUST 2022/LEETCODE

Blind 75: Binary Search

Binary Search

So you have mastered the basic string and array problems. Awesome. Now we will be moving on to Binary Search. Binary Search is the most common searching mechanism, and really the only one seen in Leetcode problems. Let's give an overview of how Binary Search works, and why it is better than a common linear search.

So Swift has a built in method of searching for an element in an array, we could simply do:

let array = [1, 2, 3, 4, 5, 6, 7] if let indexOfElement = array.firstIndex(of: 3) { print(indexOfElement) } // 2

Using the built-in Swift search, we can search our entire array to find the index of the element we are searching for. This is great if our element is in the beginning of the array, but if our element is at the very end of the array it becomes very time consuming. The built in Swift search, is a linear search, which gives us on average O(n) time. If our array has 1 million elements, it will on average take us 1 million steps to find the element. Ouch.

This is where Binary Search comes in. Binary Search takes O(Log(n)) time on average. So our 1 million element array, will only take us about 20 steps on average to find the element. Awesome. BUT, there is a downside to the Binary Search, and that is the array must be sorted in order. We will see why that is the case.

The way Binary Search works is:

  1. Find which half of the array our element is in (Left half vs Right half)
  2. If the element is in the right half, we repeat this process with the right half of the array (and vice-versa for left half)
  3. Repeat until the element is found
  4. If we cannot split the array anymore, our element doesn't exist in our array

As you can now see, we must have a sorted array when doing Binary Search. We can easily find which half of the array our element resides in, with a simple comparison operator like < or >. You will often hear Binary Search referred to as "Divide and Conquer" for this reason.

Implementation

Binary Search can be implemented both recursively and iteratively.

Recursive

The recursive implementation is very straight forward.

func binarySearch(_ array: [Int], key: Int, range: Range<Int>) -> Int? { if range.lowerBound >= range.upperBound { // If we get here, then the search key is not present in the array. return nil } else { // Calculate where to split the array. let midIndex = range.lowerBound + (range.upperBound - range.lowerBound) / 2 // Is the search key in the left half? if array[midIndex] > key { return binarySearch(array, key: key, range: range.lowerBound ..< midIndex) // Is the search key in the right half? } else if array[midIndex] < key { return binarySearch(array, key: key, range: midIndex + 1 ..< range.upperBound) // If we get here, then we've found the search key! } else { return midIndex } } } let numbers = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67] if let result = binarySearch(numbers, key: 43, range: 0 ..< numbers.count) { print(result) // gives 13 }

Pay a close bit of attention on how we anchor our left vs right halves of our array. The equation range.lowerBound + (range.upperBound - range.lowerBound) / 2 is something you should remember as we can split both even and odd numbered arrays with this equation. Every time we find our element is in the left or right half of the array, we recursively pass the range of the new array back into our function. This saves us space by not having to keep variables for the left and right halves of the array.

It is also worth noting, that recursive implementations inherently come with O(n) space, due to our machine holding the function call stack in memory. Because of this, the iterative approach is favored here, for efficiency sake.

Iterative

The following is the iterative approach to the Binary Search:

func binarySearch(_ array: [Int], key: Int) -> Int? { var lowerBound = 0 var upperBound = array.count while lowerBound < upperBound { let midIndex = lowerBound + (upperBound - lowerBound) / 2 if array[midIndex] == key { return midIndex } else if array[midIndex] < key { lowerBound = midIndex + 1 } else { upperBound = midIndex } } return nil } let numbers = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67] if let result = binarySearch(numbers, key: 43) { print(result) // gives 13 }

Many recursive functions can be converted to iterative functions by using loops. Note the while loop here.

So that is Binary Search and how it works under the hood. Let's apply this to some Leetcode problems!


Search in Rotated Sorted Array

Time to apply that Binary Search knowledge!

This one can be a little tricky. We know from the intro in this article, that Binary Search takes a sorted array. But the input array in this problem is sorted around a pivot. So we cannot use Binary Search out of the box, we need to alter it a little. Considering the problem statement says our solution must be in O(logN) time, there really is no point figuring out a brute force, O(n) solution. Let's get into the Binary Search solution.

[ Altered Binary Search ]

[ O(logN) time + O(1) space ]

We need to think about how our input array is structured. We can still use Binary search in a way but we must be methodical. We know we will have a left pointer, right pointer & middle pointer of our array. The question is though, is our middle element, in the left sorted half, or right sorted half? If our input array is [4, 5, 6, 7, 0, 1, 2] our midpoint will be 7. We know that 7 (our midpoint) is in the left sorted half of the array. How do we know this?

Well we can compare our midpoint to our leftPointer. If our midpoint is greater than or equal to our left pointer, our midpoint belongs in the left half of the array. Let's see another example. Say our input array is [7, 0, 1, 2, 3]. In this case our midpoint is not greater than or equal to our left pointer, thus our midpoint belongs to the right half of our array. Ok cool. Now what do we do when we figure out what side our midpoint belongs to?

Once we figure out which side of the array our midpoint belongs to, we can see if our target falls within the range of that side of the array. For example, say our input array is [4, 5, 6, 7, 0, 1, 2], we know the midpoint belongs to the left side of the array. So within the left side of the array, we need to check if our target falls in that range. If it does we need to search that half, if it doesn't we need to search the other side of the array. Our target in the example problem is 0. Considering we know the midpoint belongs to the left side of the array, we should compare our target to the array [4, 5, 6, 7]. Does our target fall within that range? No it doesn't, so we should check the other half of the array [0, 1, 2]. Then we repeat this process continuously until we find our element.

In pseudocode:

while leftPointer less than or equal to rightPointer if our midpoint is greater than or equal to our left pointer, midpoint is in the left half of the array if our target is in range of the left side of the array repeat search on the left side of the array else our target is out of range of the left side of the array repeat search on the right side of the array else our midpoint is in the right side of the array if our target is in range of the right side of the array repeat search on the right half of array else our target is out of range of the right side of the array repeat search on the left half of the array

That gets a little wordy! But it outlines our intent very well. As discussed here earlier, the time complexity is O(logN) due to Binary Search, and O(1) space because we are not storing anything here. The real implementation is as follows:

var leftPointer = 0 var rightPointer = nums.count - 1 while leftPointer <= rightPointer { // While our pointers are still valid let mid = leftPointer + (rightPointer - leftPointer) / 2 // Find the midpoint of our array if nums[mid] == target { return mid } // We have found our element! if nums[leftPointer] <= nums[mid] { // Our midpoint is in the left half of the array if target > nums[mid] || target < nums[leftPointer] { // Target is not in range of the left half leftPointer = mid + 1 // Move left pointer } else { // Target is in range of the left half rightPointer = mid - 1 // Move right pointer } } else { // Our midpoint is in the right half of the array if target < nums[mid] || target > nums[rightPointer] { // Target is not in range of the right half rightPointer = mid - 1 // Move right pointer } else { leftPointer = mid + 1 // Move left pointer } } } return -1

Well Done!

---

Find Minimum in Rotated Sorted Array

This problem is actually more straight forward than our last one.

There should be a couple flags here that let us know this is a Binary Search array type problem. First, the problem states the input array is in sorted (but rotated) order, and second, we need an O(LogN) time solution. When we see a combination of these two things, we know it is most likely a Binary Search problem.

[ Altered Binary Search ]

[ O(logN) time + O(1) space ]

This approach is like a slightly altered version of Binary Search. Since our input array is sorted but rotated an unknown amount of times, we don't exactly know where our pivot point is. If we take a look at the previous problem, we can check the midpoint and compare it to the rightmost array element to see if our midpoint belongs to the left or right side of the array. Using that information we can search the right and left arrays continuously until we run out of elements to search.

We will also want to keep a variable for the minimum element in the array. We can use our midpoint anchor to track which element is our smallest.

BUT, there is one situation we should also be aware of. If our array is sorted, based on our left and right pointers, we can break out of our loop, because we know the smallest element will be the leftmost element. Here is the pseudocode:

while leftPointer less than or equal to rightPointer if our array is sorted min is leftmost element break from loop update minimum element with midPointer as needed if midPointer > rightPointer search right half of the array else search left half of array

As discussed here earlier, the time complexity is O(logN) due to Binary Search, and O(1) space because we are not storing anything here, aside from a minimum element variable. The real implementation is below:

var mini = Int.max // Some default value var leftPointer = 0 var rightPointer = nums.count - 1 while leftPointer <= rightPointer { // While our pointers are still valid guard nums[leftPointer] > nums[rightPointer] else { // If our array is sorted mini = min(mini, nums[leftPointer]) // Update global minimum break // Break from loop } let mid = leftPointer + (rightPointer - leftPointer) / 2 mini = min(mini, nums[mid]) // Update global minimum if nums[mid] > nums[rightPointer] { // Midpoint belongs to the right half of the array leftPointer = mid + 1 } else { // Midpoint belongs to the left half of the array rightPointer = mid - 1 } } return mini

Conclusion

Keep an eye out when you see sorted input arrays, and problems that require O(logN) time complexity, this usually points to a classic Binary Search problem!


Alex Stevens

Alex Stevens

iOS Software Engineer