Problem overview
The function lower_bound takes a sorted vector nums and a value. It returns the smallest index at which value could be inserted so that the vector stays sorted. Equivalently, it returns the index of the first element that is not less than value.
For nums = [1, 2, 3, 5, 7] and value = 4 the answer is 3: inserting 4 at index 3 gives [1, 2, 3, 4, 5, 7]. For nums = [1, 2, 3] and value = 2 the answer is 1, the index of the existing 2, since inserting there still keeps the order. If every element is smaller than value, the answer is the length of the vector, one past the last index.
The statement asks for O(log n) time and O(1) extra space.
Approach: binary search for the boundary
A linear scan would stop at the first element that is at least value, which costs O(n). Because the vector is sorted, the elements split into two blocks: first the ones smaller than value, then the ones that are not. The answer is the index where the second block starts, and binary search can find a boundary like that in O(log n).
Look at the middle element of the current range:
- if it is smaller than
value, it and everything to its left belong to the first block, so the boundary is further right; - otherwise it belongs to the second block, so the boundary is at
middleor to its left.
Unlike the plain binary search for a value, there is no early exit on equality: even when the middle equals value, an earlier copy might exist, so the search keeps going to the left. When the range is empty, left sits exactly on the boundary.
On [1, 2, 3, 5, 7] with value = 4: the range is indexes 0 to 4 and the middle is index 2, holding 3, which is smaller, so left becomes 3. The range 3 to 4 has middle 3, holding 5, which is not smaller, so right becomes 2. Now left is 3 and right is 2, the loop ends, and 3 is returned.
Duplicates show why the search must not stop on equality. On [1, 2, 2, 3] with value = 2, the first middle is index 1, which holds 2. That element is not smaller than value, so right becomes 0. The next middle is index 0, holding 1, which is smaller, so left becomes 1. The loop ends with left = 1, the first 2. A search that returned at the first match would be wrong on [2, 2, 2, 2]: its first middle is index 1, but the answer is 0.
The same function also answers "is the value present?": it is present exactly when the returned index is below nums.len() and the element at that index equals value.
Step by step
- Set the bounds.
leftis0i32, andrightisnums.len() as i32 - 1. Both are signed becauserightbecomes -1 when every element is at leastvalue, and an unsigned type can't hold that. - Loop while
left <= right. The range is inclusive, so one remaining element still gets examined. - Compute
middleas(left + right) / 2. Integer division discards the fraction, so it is always a valid index. - If
nums[middle as usize] < value, no index up tomiddlecan be the answer, soleft = middle + 1. - Otherwise
middlemight be the answer, but nothing to its right can be better, soright = middle - 1. - Return
left. When the loop ends,rightisleft - 1: everything beforeleftis smaller thanvalue, and everything fromlefton is at leastvalue.
The solution in Rust
impl Solution {
fn lower_bound(nums: Vec<i32>, value: i32) -> i32 {
let mut left = 0i32;
let mut right = nums.len() as i32 - 1;
while left <= right {
let middle = (left + right) / 2;
if nums[middle as usize] < value {
left = middle + 1;
} else {
right = middle - 1;
}
}
left
}
}
Rust details to note:
- Signed bounds. With
rightas ausize, an empty vector would makenums.len() - 1wrap around in an optimized build and panic in a debug build. Casting the length toi32first turns it into a plain -1. - Indexing needs
usize.nums[middle as usize]is the only place where the cast is needed. Rust doesn't convert between integer types on its own. - The midpoint.
(left + right) / 2adds two indexes. A vector's length is far below the point where that could overflow ani32, which is why the lesson uses the simple form.left + (right - left) / 2is the form that stays safe even for bounds near the top of thei32range. - The tail expression. The function ends with
left, with no semicolon and noreturn.
Complexity
Time: O(log n). Each pass through the loop discards at least half of the range, so there are about log₂ n passes, each doing one comparison.
Space: O(1). Only left, right and middle are kept.
Edge cases and common mistakes
- Empty vector.
rightis -1, the loop never runs, andleft, 0, is returned. - Value smaller than everything.
[1, 2, 3]with0returns0. - Value larger than everything.
[1, 2, 3]with10returns3, the length. That index doesn't exist in the vector, so use it only as an insertion position and never readnums[result]without checking. - Duplicates.
[2, 2, 2, 2]with2returns0, the first copy, and[1, 2, 2, 3]with3returns3. Using<=in the comparison would return the position after the last equal element instead. - Returning on equality. Stopping as soon as the middle equals
valuemay return a later duplicate instead of the first. - Using
left < rightin the loop condition. With this inclusive-bounds version, the last remaining element would never be examined.
Testing
[1, 2, 3, 5, 7]with4gives3.[1, 2, 3]with2gives1.[1, 2, 3]with0gives0, and with10gives3.[2, 2, 2, 2]with2gives0.[]with10gives0.
