LeetCode 33 is, I think, a bad problem. The idea is that you are given an array and you have to find the index of a value in that array. If you can’t find the value, return \(-1\).
This is a super common problem in programming. So common that most standard libraries include this exact function. For Go, the function is slices.Index.
The folks at LeetCode, though, have two twists. First, the input slice is sorted. This means that we can find the index in \(O(log(n))\) time. So, we should use slices.BinarySearch instead. However, there is a second twist: the input slice is rotated to the left. What that means is that they took the sorted array, and then shifted it to the left:
Example Slice: [1,2,3,4,5,6]
Left Shift 1: [2,3,4,5,6,1]
Left Shift 2: [3,4,5,6,1,2]
Left Shift 3: [4,5,6,1,2,3]
Left Shift 4: [5,6,1,2,3,4]
Left Shift 5: [6,1,2,3,4,5]
Left Shift 6: [1,2,3,4,5,6]
The challenge, then, is how to figure out what the left shift of the slice is such that we can run a binary search. The obvious answer is to search until the array is not sorted. This, though, is an \(O(n)\) operation, and do you know what is also \(O(n)\)? slices.Index.
func search(nums []int, target int) int {
return slices.Index(nums, target)
}
This beats 100% of the solutions in terms of runtime, and that’s why I don’t like this problem. There is no penalty for being lazy. The requirement that the solution must have a runtime of \(O(log(n))\) is contrived. Regardless, we might as well find the correct solution, which is a modified binary search:
| |
Unfortunately, the algorithm is both simple because it is binary search and difficult because it has special cases that require you to think them through. I’m going to describe it in bullets:
- Define
leftandrightwhich point to the start and end of the slice. - Iterate on line 4 while
left <= right.- Calculate the midpoint (i.e., the mean) of
leftandrightand store inmid. - If
nums[mid]is equal totarget, return that index. Search is done. - Otherwise, we check if
nums[left]is less than or equal tonums[mid]on line 11.- True (this means that the values between
nums[left]andnums[mid]are sorted with no pivot):- Check if
targetis greater thannums[mid]or smaller thannums[left]. In the first case,targetis too big for the sorted left half, so it has to be on the right if it is anywhere. In the second case,targetis too small for that half (target < nums[left]), and since the pivot is on the right, the only values smaller thannums[left]must be on the right side.- TRUE: set
lefttomid+1 - FALSE:
targetsits inside the sorted left half, so setrighttomid-1
- TRUE: set
- Check if
- False (the pivot is somewhere in the left half, which means the values between
nums[mid]andnums[right]are the sorted ones):- Check if
targetis smaller thannums[mid]or greater thannums[right]. Same reasoning as before, just mirrored. Too small for the sorted right half means it can only be on the left, and too big for it means it is one of the large values before the pivot, which is also on the left.- TRUE: set
righttomid-1 - FALSE:
targetsits inside the sorted right half, so setlefttomid+1
- TRUE: set
- Check if
- True (this means that the values between
- Calculate the midpoint (i.e., the mean) of
- Outside of the loop,
return\(-1\) becausetargetcould not be found.
This code also beats 100% of solutions in terms of runtime, so you’d expect this algorithm to have a similar runtime to slices.Index, but, if you’ve read any of the previous posts with benchmarking, you know that that is obviously bogus. So, I benchmarked both solutions. You can find the code on GitHub. Each benchmark runs with slice lengths n ∈ {100, 200, ..., 1000} using -count=10, analyzed with benchstat, on an AMD Ryzen AI 9 HX 370. Each slice is the values 0..n-1 rotated left by a random amount, seeded so every n gets the same shift on every run. The shifts landed anywhere from 24% to 100% of the length.
How long slices.Index takes depends entirely on where the target sits. So I ran two cases. In the “present” case I pick the target after the rotation, by reading index n/2 of the already rotated slice, so the scan does exactly n/2 comparisons no matter what the shift happened to be. In the “missing” case the target isn’t in the slice at all, so the scan does all n of them.
You might worry that putting the target at the midpoint results in an \(O(1)\) search, but don’t. The first lookup is at (n-1)/2 and every n I benchmarked is even, so it lands one slot to the left of the target and misses.
The results are clear. Linear scan always loses. This isn’t that surprising since we are talking about binary search versus linear. What is surprising is that LeetCode’s runtime calculation is poor enough that it misses this.
As for dynamic programming practice, I’m hoping to come back to it in a week or two. Things are a bit busy for me at the moment.
Till next time, friends.