Problem overview
The function find receives a sorted vector of integers, nums, and a number value. It returns the index where value sits, or -1 when it isn't there. The lesson asks for a recursive solution that runs in O(log n) time and uses O(log n) extra space.
For nums = [1, 2, 4, 5] and value = 4 the answer is 2, because nums[2] is 4. For value = 3 the answer is -1.
Approach: discard half of the array each time
Checking every element is O(n). Because the vector is sorted, one comparison can rule out half of what is left. Look at the middle element:
- If it equals
value, return its index. - 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.
The recursion needs to say which part of the vector it is searching. A helper takes the whole slice plus two bounds, left and right, both inclusive, and searches only nums[left..=right]. Passing bounds means no copies of the data: each call works on the same slice with a smaller range. When left moves past right, the range is empty and the value isn't present.
Each call halves the range, so a vector of a million elements needs about 20 calls. The calls stack up one inside another, which is where the O(log n) space comes from.
Step by step
- Start from
find. Compute the last index as ani32,nums.len() as i32 - 1, and call the helper throughSelf::with&nums, the bounds0andright, andvalue. The&borrows the vector so the helper doesn't take ownership. - Declare the helper above
find. It takes a borrowed slice&[i32], the boundsleftandright, andvalue, and returns ani32. - Base case. If
left > right, the range is empty. Return-1. - Find the middle,
mid = (left + right) / 2. Integer division rounds down, somidis always inside the range. - Found it. If
nums[mid as usize]equalsvalue, returnmid. Slices are indexed withusize, so thei32index needs an explicit cast. - Element too small. Recurse on the upper half, from
mid + 1toright. This call is the value of the branch, with noreturn. - Element too large. In the
elsebranch, recurse onlefttomid - 1. Usingmid - 1andmid + 1instead ofmidmatters:midhas just been checked, and keeping it could make the range stop shrinking.
The solution in Rust
impl Solution {
fn binary_search_helper(nums: &[i32], left: i32, right: i32, value: i32) -> i32 {
if left > right {
return -1;
}
let mid = (left + right) / 2;
if nums[mid as usize] == value {
return mid;
}
if nums[mid as usize] < value {
Self::binary_search_helper(nums, mid + 1, right, value)
} else {
Self::binary_search_helper(nums, left, mid - 1, value)
}
}
fn find(nums: Vec<i32>, value: i32) -> i32 {
let right = nums.len() as i32 - 1;
Self::binary_search_helper(&nums, 0, right, value)
}
}
The Rust details worth a closer look:
- Why
i32bounds. The bounds are signed becauserightbecomes-1for an empty vector, andmid - 1can reach-1when the value is smaller than everything. Ausizecan't hold-1: subtracting 1 from ausizethat is 0 panics in a debug build and wraps to a huge number in an optimized one. - Precedence of
as. Innums.len() as i32 - 1, the cast binds tighter than the subtraction, so the length is converted first and then reduced by one. For an empty vector that gives-1, and the helper returns-1at once without touching any element. - Passing
&nums.numsis aVec<i32>, and the helper wants a&[i32]. Rust converts a reference to a vector into a slice automatically, so the call compiles and no data is copied. if/elseas an expression. Both branches end without a semicolon and have typei32, so the wholeif/elseis the value of the function. The two branches must have the same type.- Casting to index.
mid as usizeis safe here becausemidis never negative once the base case has passed:left <= rightandleftstarts at 0 and only grows. - Sum overflow.
left + rightwould overflow ani32only for a vector with more than a billion elements. A vector that large already needs over 4 GB for the numbers alone. The safer formleft + (right - left) / 2avoids the issue.
Complexity
Time: O(log n). Each call does a constant amount of work and then makes at most one recursive call on at most half the range. Halving n repeatedly reaches an empty range after about log n steps.
Space: O(log n). Nothing is copied, but every call that hasn't returned yet occupies a stack frame. The deepest chain is about log n calls long. An iterative version with a loop would need O(1) space.
Edge cases and common mistakes
- Empty vector.
nums = []gives-1, because the first call hasleft = 0andright = -1. - One element. With
nums = [7]andvalue = 7, the range is one element,midis 0 and the answer is 0. - Value at either end. In
[1, 2, 4, 5], searching for 1 gives 0 and searching for 5 gives 3. - Value missing. Searching for 3 in
[1, 2, 4, 5]narrows the range untilleftpassesright, and the result is -1. - Duplicates. In
[1, 2, 2, 2, 3], searching for 2 returns 2, the middle copy. The function returns the index of some match, not necessarily the first. - Recursing on
midinstead ofmid + 1ormid - 1. The range can stop shrinking, and the calls never end. - Missing base case. Without the
left > rightcheck, a missing value leads to an out-of-bounds index and a panic, or to infinite recursion.
Testing
nums = [1, 2, 4, 5],value = 4gives2.nums = [1, 2, 4, 5],value = 1gives0.nums = [1, 2, 4, 5],value = 3gives-1.nums = [7],value = 7gives0.nums = [],value = 3gives-1.
