Problem overview
The method find receives a sorted array of integers, nums, and a value. It returns the index of value in nums, or -1 if it isn't there. This lesson asks for the recursive form of binary search, with O(log n) time and O(log n) extra space.
In the example, nums = [1, 2, 4, 5] and value = 4. The 4 is at index 2, so the answer is 2.
Approach: halve the range, recurse on one half
Scanning the array takes O(n). Because the array is sorted, one comparison with the middle element rules out half of it:
- if the middle element equals
value, return its index; - if it is smaller than
value, the target can only be to the right; - if it is larger, the target can only be to the left.
Searching one half is the same problem again on a smaller range, which makes it a natural fit for recursion. The range is described by two indices, left and right, so the array is never copied. When left passes right, the range is empty and the value is not there.
For [1, 2, 4, 5] and value = 4: the first call has left = 0 and right = 3, so mid = 1 and nums[1] = 2 is smaller than 4. The next call has left = 2 and right = 3, so mid = 2 and nums[2] = 4. It returns 2.
A missing value shows the base case at work. For value = 3 in the same array, the first call has mid = 1 and nums[1] = 2, smaller than 3, so the range becomes 2 to 3. There mid = 2 and nums[2] = 4, larger than 3, so the range becomes 2 to 1. That range is empty, left > right holds, and the call returns -1. The two outer calls pass that -1 upward unchanged.
The same algorithm can be written with a while loop and no recursion. The recursive form is worth learning because many other problems split in the same way: solve a smaller instance, then use or pass along its answer. Here the "combining" step is trivial, since each call just returns what the deeper call returned.
Step by step
- Delegate from
find. It callsthis.binarySearch(nums, 0, nums.length - 1, value)and returns the result, searching the whole array. - Declare the helper above
find, taking the array, theleftandrightbounds and thevalue, and returningnumber. The bounds as parameters let each call work on a smaller range of the same array. - Write the base case. If
left > right, return-1. Without it the recursion would not stop. - Compute
midasMath.floor((left + right) / 2). The division can give a fraction, such as 2.5, and an index must be a whole number. - Found it. If
nums[mid] == value, returnmid. - Too small. If
nums[mid] < value, return the result of the same method withmid + 1as the new left bound. - Too large. Otherwise return the result of the same method with
mid - 1as the new right bound.
The solution in TypeScript
class Solution {
binarySearch(nums: number[], left: number, right: number, value: number): number {
if (left > right) {
return -1;
}
let mid = Math.floor((left + right) / 2);
if (nums[mid] == value) {
return mid;
}
if (nums[mid] < value) {
return this.binarySearch(nums, mid + 1, right, value);
} else {
return this.binarySearch(nums, left, mid - 1, value);
}
}
find(nums: number[], value: number): number {
return this.binarySearch(nums, 0, nums.length - 1, value);
}
}
Some TypeScript details:
- Division does not round. A
numberis a double, so(0 + 3) / 2is1.5. Readingnums[1.5]givesundefined, soMath.flooris required. - No overflow in the midpoint. In languages with 32-bit integers,
left + rightcan overflow for huge arrays. A double has no such problem for array indices, so the simple sum is safe. - Return the recursive result. Writing
this.binarySearch(...)withoutreturndiscards the answer, and the method would give backundefined(with the lessons' compiler settings, tsc does not report this, so the bug is easy to miss). - Calls go through
this.findand the helper are methods of the class. - The
numberannotations are erased when the code is compiled to JavaScript. They check nothing while the program runs.
Complexity
Time: O(log n). Each call halves the range, and does a constant amount of work. An array of a million elements needs about 20 calls.
Space: O(log n). Every pending call occupies a frame on the call stack, and the depth is the number of halvings. A loop-based version uses O(1) space, which is the usual comparison between the two forms.
Edge cases and common mistakes
- Empty array. The first call has
left = 0andright = -1, so it returns-1immediately. - Value missing. For
[1, 2, 4, 5]andvalue = 3the range shrinks untilleftpassesright, and the method returns-1. - Value at either end. The first and last elements are found like any other. In
[1, 2, 4, 5], the value 1 gives 0 and the value 5 gives 3. midinstead ofmid + 1ormid - 1. The middle element has already been ruled out. Keeping it in the range can make a call repeat itself forever.- Using
left >= rightas the base case. A range with one element would be skipped, and a present value would be reported as missing. - Unsorted input. The method gives wrong answers without any error.
- Duplicates. If the value occurs several times, the method returns the index of one of them, not necessarily the first.
Testing
nums = [1, 2, 4, 5],value = 4gives2.- The same array with
value = 3gives-1. - The same array with
value = 1gives0, and withvalue = 5gives3. nums = [],value = 1gives-1.nums = [1],value = 1gives0.