INDEX
L01234M567891R0

Binary search

By Yichao Ma at

Have you ever played the guessing game? The rule of the game is simple. A number is randomly picked from a given range (e.g. between 1 and 100). Now you need to guess what that secret number is. If we don't get any feedback other than correct or incorrect after each guess, then all we can do is just to try different numbers randomly in the range, or try numbers one by one sequentially till we find the answer. These strategies do work because the range is finite. But, what if the number of times we are allow to guess is limited (e.g. 10 guesses). Then those strategies might only work occasionally.

We can actually do much better when some extra information is given after each guess. If we can be told whether our guess is greater or less than the correct answer, then we can always pick the middle number from the remaining entries. This is really powerful because each time we will shrink the range by half. I don't quite remember how I learned this technique when playing the guessing game with my friends back in the days when I was a little kid. And only after I started studying computer science, I finally knew the official name for this technique—binary search.

Implementation

Here's the "canonical form" implementation of binary search for finding the position of a target value in a sorted array, returning -1 indicates the abcense of the target value.

func bsearch(nums []int, target int) int {
        lo, hi := 0, len(nums)-1
        for lo <= hi {
                mid := lo + (hi-lo)/2
                if nums[mid] == target {
                        return mid
                }
                if nums[mid] < target {
                        lo = mid + 1
                } else {
                        hi = mid - 1
                }
        }
        return -1
}

The problem of the code snippet above is that it can only be used for equility test between an element and the target value. But sometimes we might also need to find the biggest/smallest value that is less/greater than a target value, or maybe we want to find the first/last occurrence of a value in a sorted array.

The standard library of Go provides a Search method under the sort package which has the following signature:

func Search(n int, f func(int) bool) int

Given an integer n and a predicate function f. It will return the leftmost index i such that f(i) == true where i is in range \([0,n)\). But instead of returning -1 as the "not found" value, it returns n. This is really convenient for finding the lower bound that satisfies the predicate. And for the upper bound, I am not sure if there's a better way of doing this, but I usually just invert the logic defined in the predicate function. But this will return the index j of the leftmost element that does not satisfy the condition. So I end up having to check if j-1 exists.

Another constraint of this Search method is that the range only starts from 0. Maybe we can do some offset tricks in the predicate function so we can search any subarray of the original array (again, I am not sure if this is the best practice). So, sometimes I will just implement my own search functions if it will make my life easier.

func lowerbound(start, end int, pred func(int) bool) int {
        // end+1 will be the "not found" value
        lo, hi := start, end+1
        for lo < hi {
                mid := lo + (hi-lo)/2
                if !pred(mid) {
                        lo = mid + 1
                } else {
                        hi = mid
                }
        }
        return lo
}

func upperbound(start, end int, pred func(int) bool) int {
        // start-1 will be the "not found" value
        lo, hi := start-1, end
        for lo < hi {
                mid := lo + (hi-lo)/2 + 1
                if !pred(mid) {
                        hi = mid - 1
                } else {
                        lo = mid
                }
        }
        return lo
}

Conclusion

Binary search is one of those simple and powerful algorithms. If you leverage it in appropriate situations, it can really help you speed up things because its \(\log_2{n}\) runtime complexity. Also, there could be more variations than the ones I mentioned above. So I will keep exploring and update this post once I find something interesting.