Problem overview
The function find receives a sorted IntArray called nums and an Int called value. It returns the index of value in nums, or -1 when the value is absent. The search has to be recursive and run in O(log n) time with O(log n) extra space.
For nums = [1, 2, 4, 5] and value = 4 the answer is 2, because nums[2] is 4.
Approach: shrink the range with each call
Binary search compares the target with the middle element of a range. Because the array is sorted, that single comparison removes half of the range:
- If the middle element equals
value, return its index. - If the middle element is smaller than
value, the target can only be to the right, so search the range after the middle. - If the middle element is larger, the target can only be to the left, so search the range before the middle.
The recursive version expresses "search a smaller range" as a call to itself. The helper binarySearch(nums, left, right, value) searches only nums[left..right]. The bounds travel as parameters, so each call works on the same array without copying any part of it.
The base case is an empty range: when left has moved past right, there is nothing left to look at, so the value is not present and the answer is -1.
Take [1, 2, 4, 5] and value = 4. The first call covers indices 0 to 3, and the middle index is (0 + 3) / 2 = 1, holding 2. That is smaller than 4, so the next call covers indices 2 to 3. Its middle is 2, which holds 4, so it returns 2. Each earlier call returns what the call below it returned, and the 2 comes back to find.
Step by step
- Delegate from
find. The function returnsbinarySearch(nums, 0, nums.size - 1, value), so the first call covers the whole array. - Declare the helper above
findwith the array, the boundsleftandright, and the target. Passing the bounds lets every call narrow the range. - Write the base case. If
left > right, return -1. Without it the recursion would continue until the stack overflows. - Compute
midas aval, the index halfway betweenleftandright. - Check for a match. If
nums[mid] == value, returnmidat once. The next two branches then handle only "too small" and "too big". - Go right when the middle is too small. Return the result of
binarySearch(nums, mid + 1, right, value). Thereturnmatters: it passes the index found deep in the recursion back up the chain. - Otherwise go left. Return
binarySearch(nums, left, mid - 1, value). Usingmid - 1rather thanmiddrops the element already ruled out; keeping it could repeat the same range forever.
The solution in Kotlin
class Solution {
fun binarySearch(nums: IntArray, 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)
}
}
fun find(nums: IntArray, value: Int): Int {
return binarySearch(nums, 0, nums.size - 1, value)
}
}
The Kotlin details worth a closer look:
- Parameters are read-only. The bounds are never reassigned. Each recursive call receives new values, so no
varis needed anywhere in the function. - Arrays are references. Passing
numsto the recursive call hands over the same array object, so the cost of a call doesn't depend on the array's size. - Integer division.
(left + right) / 2drops the fraction, so for non-negative indices it is always a valid index in the range. For huge arraysleft + rightcould overflow anInt;left + (right - left) / 2is the safe form. - Every path returns. The last
if/elsereturns in both branches, so Kotlin accepts the function without a statement after it. - No automatic tail-call optimisation. The recursive calls here are the last thing the function does, but the compiler turns them into a loop only if the function is marked
tailrec. Without it, each call uses a stack frame. That is theO(log n)space the statement describes.
Complexity
Time: O(log n). Each call does constant work and passes on at most half of its range, so there are at most about log2(n) + 1 calls.
Space: O(log n). The calls are pending on the stack until the answer returns, and the depth is the same logarithmic number. For an array of a million elements that is about 20 frames.
Edge cases and common mistakes
- Empty array. The first call has
left = 0andright = -1, which is the base case, so the function returns -1. - Value missing. For
[1, 2, 4, 5]andvalue = 3, the range shrinks untilleftpassesright, and the result is -1. - First and last elements. The value 1 gives index 0 and 5 gives index 3 in the example array.
- Base case as
left == right. A range of one element still has to be compared. Stopping whenleft == rightwould skip it. - Forgetting
returnbefore the recursive call. The call runs, but its result is discarded, and the compiler complains that the function needs a return. - Using
midin both recursive calls. Searchingmidtorightorlefttomidcan produce a range that never shrinks, such as a one-element range. - Unsorted input. Binary search assumes sorted data. On an unsorted array it can miss values that are present.
Testing
nums = [1, 2, 4, 5],value = 4gives 2.nums = [1, 2, 4, 5],value = 3gives -1.nums = [1, 2, 4, 5],value = 1gives 0.nums = [1, 2, 4, 5],value = 5gives 3.nums = [],value = 3gives -1.