Okay this is my first shot:
I borrowed the binary tree builder from the binary tree inversion that I messed up (because I never needed to build it to begin with) but I think this time it’s useful?
Seems like a lot of code.
Using a binary tree for search is really fun, but I’m not yet sure if it makes any sense and, if anything, stemmed from conflating the terms “binary tree” and “binary search”.
Here is a more traditional, working approach to binary search, where I have commented the gotchas I stumbled over:
Gotchas
Not returning the recursive cases
This recursive function does not mutate existing variables. Instead, it returns the value from each recursion step. If we forget to return
in the recursive cases, the recursion chain breaks, and the function fails to provide a result - unless a base case is hit directly.
Think of it this way: the function must always return something. It returns a value when it finds the target or exhausts all possibilities. This can happen at any recursion level. If we don’t return a base case, we return the recursive function itself to ensure the base case can eventually be returned.
Typo: slice
I had a brain fart and typed slice[]
instead of slice()
.
Not adding +1
to an index when passing it in the right half search case
By the time we are flowing into the right-half-search-case, we have determined already that the target is
- Not in the left half
- Not at the middle index
Sure, the middle may not be the exact middle of the array if the array is an even number, but that’s irrelevant. What matters is that we checked that index, and the target isn’t there. So when we use slice
to pass a subsection of the initial nums
array, we have to remember that the first argument slice
takes is inclusive. So rather than include the middle
index we already checked, we should start our subarray at middle + 1
Not adding start
to middle
on the right-half-search-case
The whole point of passing start
is to “remember” the index history. It’s interesting to note that this is only relevant to the right-half-search-case. On the left half search case, the start point stays the same in that recursion. If the start point was 0, it stays 0. If the start point has been moved in some right-half-case-search executed previously, it stays at that index. We don’t have to move the index, because only the endpoint of our starting array is modified. The beginning is in the same place relevant to what that recursion loop started with.
nums/2
instead of nums.length/2
Yes. This is a mistake I made. Results in NaN
in case you are curious.
Simplified
Chat GPT also showed me this:
It has an improved base case that I wouldn’t think would work.
Mansi and Anisha coding session:
AHH. This algorithm has given me so much trouble. I’m in a situation where I know the anatomy of it very well - it has 4 parts:
1) Calculating the middle
Usually const mid = Math.floor((left + right) / 2)
2) Unsuccessful Base Case
Determine that the search window has narrowed to an extent that precludes the presence of the target, i.e.
if (left > right)
(middle === left || middle === right)
- or the very tempting
else
condition after our two left and right searches
3) Successful Base Case
Check if the middle
equals the target, usually just if (nums[mid] === target
4) Actual Right Left Search
if (nums[mid] < target)
search right
if (nums[mid] > target
search left
Unfortunately, implementing these is easier said than done. All of the indexes, and initial values can be a little finicky to get right. I’m going to go through and try to set it with the following attitude; don’t let anything by. Make sure that every step taken captures all possibilities.
The code I settled on:
The trickiest parts about this to me
I think the trickiest line for me here is the if (left===right) return -1
, because at first it didn’t really seem clear how this happens.
I still haven’t really followed it down to the atomic level.
In the case of an odd list, we actually end up finding the target organically between it’s two neighbors in a slice of 3.
But because we are using Math.floor, an even numbered list will actually follow the exact same execution pattern. I’ve only followed this execution pattern going left, but in both of these cases, left and right end up being separated by one index value. So theoretically, using if (left = right -1)
would execute slightly sooner, and just as cleanly. Let’s try it.
Nope.
The verdict is, the soonest we can determine target isn’t present seems to be if (left===right)
. I am having trouble following this all the way to the end, but that seems to be the closest we can get.
Things I learned
- Recursion can definitely be tricky to reason about - it may be worth trying iterate approaches
- The JavaScript
slice
function can take one or two arguments. If it just takes one, it assumes the end of the returned array is the same. If it takes 2, the second argument determines the one index after the last index returned. In this way, the first index given is inclusive and the second is exclusive, like some annoying bookend - It is possible to set a default value for a parameter in a function that is calculated as a result of another paremeter, i.e.
function binaryearch(nums, target, left = 0, right = nums.length - 1
Math.floor
can be used as a brief and effective method for collapsing sets of two possible integer conditions into one- ^ Not related to binary search, but this same method is also used in a binary heap priority queue to find the a single parent from either of two potential indexes
Reflections on our failure
base case after a few days
Okay so first off I want to say that I found another way to think of the anatomy of this function. It has five parts in this order:
- failure base case
- middle declaration and calculation
- success base case
- recursive right search commitment
- recursive left search commitment
And I feel rock solid on all five parts except numero uno, failure base case
. I’ve tried to follow the values through the recursion that causes this to either get fired eventually or never get fired, and I’ve really had trouble doing this because it takes a long time to feel that I’ve covered every potential iteration. For example, left search followed by right, right by left, or left by left, or right by right, etc.
However, after thinking about it for a couple of days, I think I almost understand. What does make sense to me is that, if our left
and right
pointers have collapsed almost into each other - let’s say they are 24
and 25
- then we now have a 50% chance of finding out that our middle is our target left.
That is why I am actually unconvinced that left === right
is the right condition to check for our base failure case. I am now of the mind - even though leetcode will accept the above condition - that only left > right
would be correct. This is because if left === right
is true, I think we still need to do one last check that nums[middle] === target
, as we have not yet done this check. Once we’ve done that, left and right will have passed each other and we can now rest easy.
Or, I think it would also be acceptable to continue using left === right
but to put it after or nums[middle] === target
check.
That said, I doubt leetcode is wrong to accept my submission. But that means I am still missing something.
Revisit #1
Okay it’s been a month or so. I’m going to revisit this and see how I do. The code I have recorded above isn’t quite right. The line left===right
should be left>right
in order for the case nums=[5], target=5
to pass.
I have little idea how the edges of all this work anymore so I’m going to go back through it.
Here’s code with my review notes:
Okay well, revisited it, got back to about 2 minutes for recall. Let me try one thing.