Problem overview
The method find receives a sorted Array[Int] called nums and an integer value. It returns the index where value sits, or -1 when the array doesn't contain it. The search has to be recursive and must run in O(log n) time with O(log n) extra space.
In the example, nums = [1, 2, 4, 5] and value = 4. The 4 is at index 2, so find returns 2.
Approach: throw away half of the range each time
Checking every element is O(n). Because the array is sorted, you can do much better. Look at the middle element of the range you are searching:
- if it equals
value, you are done; - if it is smaller than
value, everything to its left is smaller too, so the answer can only be in the upper half; - if it is larger, the answer can only be in the lower half.
Each comparison discards half of the remaining range. An array of one million elements needs at most about 20 comparisons, because halving a million 20 times leaves nothing. That is where O(log n) time comes from.
To make this recursive, the function must be able to search just a piece of the array. That is why the lesson adds a helper, binarySearch(nums, left, right, value), whose left and right parameters are the inclusive bounds of the piece. Nothing is copied: every call receives the same array and only the bounds change.
The recursion needs a base case. When left becomes greater than right, the range is empty, so the value isn't there and the answer is -1.
Step by step
- Delegate from
find. The first search covers the whole array, from index 0 tonums.length - 1. Both bounds are inclusive. - Declare the helper with the array, the two bounds and the target, and an
Intresult. - Write the base case first. If
left > right, return-1. Without it the recursion would never stop. - Compute
midas aval: the average ofleftandright, using integer division. - Check for a hit. If
nums(mid) == value, returnmid. Scala reads an array element with parentheses, not square brackets. - Go right when the middle is too small. If
nums(mid) < value, return the result of searchingmid + 1toright. - Otherwise go left. Return the result of searching
lefttomid - 1. Usingmid - 1andmid + 1, notmid, matters:midhas just been ruled out, and keeping it could make the range stop shrinking.
The solution in Scala
class Solution {
def binarySearch(nums: Array[Int], left: Int, right: Int, value: Int): Int = {
if (left > right) {
return -1
}
val mid = (left + right) / 2
if (nums(mid) == value) {
return mid
}
if (nums(mid) < value) {
return binarySearch(nums, mid + 1, right, value)
} else {
return binarySearch(nums, left, mid - 1, value)
}
}
def find(nums: Array[Int], value: Int): Int = {
return binarySearch(nums, 0, nums.length - 1, value)
}
}
Details worth knowing:
- Explicit
return. Scala normally returns the value of a method's last expression, soreturnis rarely needed. Here it makes each branch easy to read, and it is allowed because the methods declare their result type (: Int). - Integer division.
(left + right) / 2divides twoInts and drops the fraction, somidis always a valid index in the range. Forleft = 0andright = 3it is 1. - Arrays are passed by reference. Passing
numsdown the recursion costs nothing, no matter how big it is. - Returning the recursive result. Each
return binarySearch(...)hands the answer found deeper in the recursion straight back to the caller. Dropping thereturnin the middle of a method would discard that answer.
Complexity
Time: O(log n). Each call does a constant amount of work and the range shrinks to at most half its size, so there are at most about log2(n) + 1 calls.
Space: O(log n). Every call that hasn't returned yet occupies a stack frame, and the recursion is at most that many calls deep. An iterative loop with left and right variables would need only O(1).
Edge cases and common mistakes
- Value not present. With
[1, 2, 4, 5], searching for 3 returns-1, and so do 0 and 9, which lie outside the array's range. - First and last elements. Searching for 1 returns 0 and searching for 5 returns 3. These are the cases that expose a base case written as
left >= right, which would skip a range of one element. - Empty array. The first call is
binarySearch(nums, 0, -1, value). Since 0 is greater than -1 the base case fires andfindreturns-1. - One element.
Array(7)searched for 7 returns 0. - Duplicates. The method returns the index of some match, not necessarily the first. For
Array(2, 2, 2, 2, 2)searching for 2 it returns 2, the middle. - Overflow in the midpoint.
Intis 32 bits and wraps silently. Withleft = 1500000000andright = 2000000000,(left + right) / 2gives-397483648, because the sum doesn't fit. Writingleft + (right - left) / 2gives the correct1750000000. Arrays that large are rare, but the second form costs nothing and is a good habit. - Unsorted input. Binary search only works on sorted data. On an unsorted array it can return
-1for a value that is present.
Testing
nums = [1, 2, 4, 5],value = 4gives2.nums = [1, 2, 4, 5],value = 3gives-1.nums = [1, 2, 4, 5],value = 1gives0, andvalue = 5gives3.nums = [],value = 7gives-1.nums = [7],value = 7gives0.
