Problem overview
The function count receives a sorted IntArray called nums and an Int called value. It returns how many times value appears in nums. The statement asks for O(log n) time and O(1) extra space, so walking through the array and counting is too slow: that takes O(n).
In the example, nums = [1, 2, 2, 2, 4, 5] and value = 2. The 2s sit at indices 1, 2 and 3, so the answer is 3. If the value is missing, the answer is 0.
Approach: two lower bounds
Because the array is sorted, equal values are neighbours. All the occurrences of value form one block of consecutive indices, and the block's length is the count. To measure the block you need only its two ends: the first index holding value and the first index after the block.
Both ends come from the same tool, the lower bound: the first index whose element is not less than a given number. If the number is absent, the lower bound is the index where it would be inserted to keep the array sorted.
lowerBound(nums, value)is the start of the block.lowerBound(nums, value + 1)is the first index whose element is at leastvalue + 1. The array holds integers, so that is the first element greater thanvalue, which is one past the end of the block.
The count is the difference. On the example, lowerBound(nums, 2) is 1 and lowerBound(nums, 3) is 4, so the answer is 4 - 1 = 3. For a missing value such as 3, both calls return 4 and the difference is 0, with no special case needed.
Each lower bound is a binary search, O(log n), and two of them are still O(log n). Another way to say it: the first occurrence and the last occurrence can each be found by a binary search, and this lesson turns the second search into a second lower bound to reuse the code.
Step by step
- Call
lowerBoundfor the value. Store the result inlow, avalbecause it never changes. The helper doesn't exist yet; the lesson writes the caller first. - Call it again for
value + 1and store the result inhigh. Together,lowandhigh - 1bracket every occurrence. - Return
high - low. That is the length of the block. - Declare the helper above
countasfun lowerBound(nums: IntArray, value: Int): Int. - Set up the window. Two
vars,left = 0andright = nums.size - 1, mark the first and last indices still in play. They change as the search narrows, so they arevar. - Loop while the window has an element:
left <= right. - Probe the middle with
(left + right) / 2. Integer division rounds down, somiddleis always a valid index. - If
nums[middle]is less thanvalue, the middle and everything before it are too small, so the answer lies to the right:left = middle + 1. - Otherwise
nums[middle]is at leastvalue, so the lower bound is atmiddleor earlier. Setright = middle - 1. - Return
leftafter the loop. At that pointlefthas moved past every element that is less thanvalue, so it is the first index whose element is not less, ornums.sizeif there is none.
The solution in Kotlin
class Solution {
fun lowerBound(nums: IntArray, value: Int): Int {
var left = 0
var right = nums.size - 1
while (left <= right) {
val middle = (left + right) / 2
if (nums[middle] < value) {
left = middle + 1
} else {
right = middle - 1
}
}
return left
}
fun count(nums: IntArray, value: Int): Int {
val low = lowerBound(nums, value)
val high = lowerBound(nums, value + 1)
return high - low
}
}
A few Kotlin details:
valandvar.low,highandmiddleare assigned once per call or iteration, so they areval.leftandrightare reassigned inside the loop, so they must bevar. The compiler rejects reassigning aval.- Parameters are read-only. That is why the search keeps its moving bounds in new variables instead of changing
numsorvalue. - Integer division. With two
Intoperands,/drops the fraction, so(0 + 5) / 2is 2. - Methods call each other freely. Inside a class,
countcan calllowerBoundeven though it is declared above it; the order of members doesn't matter. - Arrays are passed by reference. Passing
numstolowerBoundcopies nothing, which keeps the extra space atO(1).
Complexity
Time: O(log n). Every loop iteration discards at least half of the window, so a lower bound takes at most about log2(n) + 1 iterations. count makes two such searches.
Space: O(1). The code keeps a handful of Int variables whatever the size of nums.
Edge cases and common mistakes
- Empty array.
rightstarts at -1, the loop doesn't run,lowerBoundreturns 0 both times, andcountreturns 0. - Missing value. Both lower bounds land on the same index, so the difference is 0. For
[1, 2, 2, 2, 4, 5]andvalue = 3, both calls return 4. - Every element equals the value. For
[5, 5, 5]andvalue = 5, the lower bounds are 0 and 3, so the count is 3. The second search runs off the end of the array and returnsnums.size, which is exactly what the subtraction needs. - Using
<in the loop condition. Withleft < right, a window of one element is never examined, and the search can stop one index early. - Returning
middleon a match. That finds some occurrence, not the first one. The lower bound keeps searching left even after a hit. - Overflow at the top of the range. An
Intwraps silently, sovalue + 1becomesInt.MIN_VALUEwhenvalueisInt.MAX_VALUE. For[1, 2147483647, 2147483647]andvalue = 2147483647the function returns -1 instead of 2. The tests don't use values this large, but a robust version would search for the first element greater thanvaluedirectly instead of adding 1.
Testing
nums = [1, 2, 2, 2, 4, 5],value = 2gives 3.nums = [1, 2, 2, 2, 4, 5],value = 3gives 0.nums = [5, 5, 5],value = 5gives 3.nums = [],value = 3gives 0.