Binary Search Reconsidered (2018)
solipsys.co.uk
solipsys.co.uk
fn binary_search<T: Ord>(mut array: &[T], key: T) -> Option<T> {
use std::cmp::Ordering::*;
while !array.is_empty() {
let middle = array.len() / 2;
match key.cmp(&array[middle]) {
Less => array = &array[..middle],
Equal => return Some(key),
Greater => array = &array[middle + 1..],
}
}
None
}
The type &[T] is a slice. Under the hood it consists of a pointer into an array, plus a length. So it (i) does some bookkeeping for you and reduces the number of variables you need by 1, and (ii) avoids the "lower + upper overflow" issue. fn binary_search<T: Ord>(mut array: &[T], key: T) -> Option<T>
A more useful signature would have been fn binary_search<T: Ord>(mut array: &[T], key: T) -> Option<usize>
And then returning `Some(middle)` on the Equal match. Returning the value you were given or None would be better expressed as returning True/False to avoid the unnecessary copy.Good point! And if you're doing that, there's no reason to use slices. So I guess the only thing to learn from my code... is that slices do useful bookkeeping for problems similar to this, in the case that you don't care about the indices?
> Returning the value you were given or None would be better expressed as returning True/False to avoid the unnecessary copy.
Just because two values are equal, doesn't mean they're the same. Though my function signature was wonky, regardless.
Equal => return Some(key),
The article's implementations return the index, not the value being searched for as you've done here. Do slices give access to their underlying offset?Also the article manages a single branch per iteration. How well does this cmp / match perform in comparison?
They don't have to, you already have `middle` available, they can just return Some(middle)
And __s's comment does it best: use the standard library. And that's a nice implementation (despite the unsafe stuff), which tracks the basepoint and size to keep the arithmetic clean.
https://algorithmica.org/en/eytzinger
Funnily enough, the first example of a standard binary search contains the same overflow error.
eg Etyzinger is awesome, but in most real world usage something like this is far more practical and ultimately performant: Faster Lookups, Insertions, and In-Order Traversal Than a Red-Black or AVL Tree [0].
[0]: https://neosmart.net/blog/2019/sorted-list-vs-binary-search-...
int BinarySearch<t>(t[] array, t find, int min, int max, Comparer<t> comparer)
{
int mid = 0;
while (min <= max)
{
mid = (int)(((long)min + (long)max) / 2);
int compared = comparer.Compare(find, array[mid]);
if (compared > 0)
min = mid + 1;
else if (compared < 0)
max = mid - 1;
else
return mid;
}
return ~mid;
}"Specifically, it fails if the sum of low and high is greater than the maximum positive int value (231 - 1). The sum overflows to a negative value, and the value stays negative when divided by two." -https://ai.googleblog.com/2006/06/extra-extra-read-all-about...
As soon as your array is storing objects that are 2 bytes or larger, you can't overflow the unsigned integer any more. The same applies for a 64 bit address and 64 bit integers. Never mind that most 64 bit hardware in existence is actually limited to a 48 bit address space, in which case the bug CAN NOT be exercised even with signed integers.
It's less concerning for C#, which is a managed language, of course.
mid = min + (max - min) / 2;
add rax, rbx
rcr rax, 1
It explicitly uses the carry flag from the addition as the top bit of the right-rotated result.
Not super useful for anything other than averaging two uints but that's x86 for you.
What I missed in my first pass was
* Forgetting exactly which register was the low index and which was the high index
* Underflow if array size was zero from setting the last index to (size - 1)edit: this is a bit longer version, if signed conversion/shift is not available: int mid = (min >> 1) + (max >> 1) + (min & max & 1)
Where x is min and y is max, the midpoint is:
(x+y)/2 = x+(y-x)/2 x+y = 2x+y-x x+y=x+y
fn search<T: Ord>(haystack: &[T], needle: &T) -> Result<usize, usize> {
let mut range = 0..haystack.len();
while !range.is_empty() {
if range.len() == 1 {
if &haystack[range.start] == needle {
return Ok(range.start);
} else {
return Err(range.start + 1);
}
}
let search_range = &haystack[range.clone()];
let (left, right) = search_range.split_at(search_range.len() / 2);
if needle < &right[0] {
range.end -= right.len();
} else {
range.start += left.len();
}
}
Err(range.start)
}
I'm not overly happy with the break conditions inside the loop. They feel messy.The big takeaway for me was the importance of thinking about invariants, and of documenting said invariants in comments next to the code. It has become my go-to example if I need to teach someone about invariants.
def binary_search(numbers: List[int], target: int) -> int:
if not numbers:
return -1
if len(numbers) == 1 and numbers[0] != target:
return -1
middle = math.floor(len(numbers) / 2)
if numbers[middle] == target:
return target
elif numbers[middle] > target:
return binary_search(numbers[0: middle], target)
else:
return binary_search(numbers[middle + 1:], target)Tuples are immutable in Python, so in theory it should be able to but I have no idea if Python actually makes that optimization.
Even if it does, this is probably not a good approach in Python because Python does not optimize away tail recursion.
Regardless, even if that optimization is present, the overhead of checking and deduping the tuples against the intern cache as they are constructed after the split operations would likely be considerable. Indexes would likely remain the very fastest strategy.
An optimization theoretically exists which could make tuple splitting as fast as index accounting (representing split results as slices/views into the original tuple), but I do not believe python does that, as it would cause unexpected memory growth in some situations, unless accompanied by a very specialized garbage collection scheme.
The reasons it is slow are interesting and useful to understand.
On modern equipment, to get decent performance, everything needs to pipeline, so you start an operation and get the answer out 3, 5, sometimes 12 cycles later. In the meantime, you're pushing new operations into the pipeline so that useful answers will come out the cycle after. To keep pipelines (plural; there are lots) full, we have "branch prediction": the CPU doesn't know which way an upcoming conditional branch will go, so it guesses. It has a neural network to remember how this branch went other times, that is quite clever. When it guesses right, all is good, and the pipeline stays full. When it guesses wrong, it has gone off doing lots of wrong computations that have to be thrown away, and the pipeline restarted at the correct spot.
Binary search presents the worst case for prediction. At each stage, any prediction is 50% likely to be wrong. You hardly get two steps into your pipeline before it turns out you were wrong, and have been doing the wrong thing.
There are ways to reduce the worst effects, but they make the algorithm much less pleasant to read, and even trickier to get right. One is to use clever arithmetic to arrange that the same instruction sequence runs whichever way the choice goes. Another is to split the range into more than two subranges, so the choice is made less often--log base 3 or -base 4 iterations, instead of two. Maybe you pre-fetch values you expect to look at soon: half of them will be ones you won't actually need, and you won't know which until later, but you didn't need to wait for the right one.
Once a range gets small enough, you do better searching sequentially, because CPUs are tuned to do that very fast.
Hash tables avoid all this foolishness, and (so) can be very fast.
To write good, fast code for things like binary search, intuition is entirely inadequate. You really need to pay a lot of attention to "performance counter" registers modern chips have, that can tell you about prediction failures and cache misses that slow you to a crawl. It is humbling to discover how wrong all we learned in school, about algorithm performance, is.
When you home in on a code sequence that minimizes pipeline bubbles, you have to expect that the next generation of CPU chips will have completely different details, and that what was fast is now slow.
The deep lesson is that we never know if our code is fast until we find code that is faster; what seemed fast becomes slow in an instant. All the other old faithful algorithms, like Quicksort, can be easily 2x faster or slower according to obscure details that are hard to reason about.
It makes some people decide that 2x, 4x, 100x, or 1000 times slower than necessary ought to be considered fast enough.