The principle of binary search
The fundamental principle of binary search is this: discard half of the input on every iteration in order to make exponential progress.
Let's look at a related algorithm that uses the same principle. It is called lower_bound
in C++'s STL. It could also more descriptively be called binary_search_leftmost_not_less_than
. Here's a simple implementation:
// searches the sorted range `[begin, end)` for the
// leftmost value that is not less than `needle`
1. while (begin != end) {
2. assert(begin < end);
3. auto p = begin + (end - begin) / 2;
4. if (*p < needle) {
5. begin = p + 1;
6. } else {
7. end = p;
8. }
9. }
10. return begin;
I love this algorithm for the following reasons:
- it is extremely concise;
- it is efficient;
- every line illustrates an important binary search mechanic.
Here's a short breakdown of the implementation:
There are only two comparisons per iteration at lines 1
and 4
(assuming the defensive assert
at line 2
is optimized away or removed)`
Line 3
is the classic midpoint computation.
It stays true to the aforementioned principle: make exponential progress at every iteration. It either discards the first half of the input at line 5
or it discards the second half of the input at line 7
.
Note that p
never "remains" on the search range after each iteration, so there's no possibility of infinite loop. Progress is always made since at least one element is guaranteed to be discarded:
- line
5
explicitly removes p
by moving begin
immediately after it;
- line
7
makes it the end
iterator, which can be somewhat confusing. But remember that the search range is closed on begin
but open on end
, so p
remains right there on the edge but is not quite part of the search range.
The search range is progressively narrowed down until it is exhausted, at which point begin
and end
will point to the same element. That element will either be the original end
, or it will be the leftmost element not-less-than needle
(that is, greater or equal). That iterator is returned at line 10
.
Keep it simple
As stated before, lower_bound
is guaranteed to return either the input's original end
iterator in case of a failed search, or an element that is greater than or equal to needle
in case of success.
With a couple extra conditionals, binary search can be implemented in terms of lower_bound
, as follows:
// searches for `needle` in the sorted range `[begin, end)`
1. auto i = lower_bound(begin, end, needle);
2. return i != end && *i == needle
3. ? i
4. : end;
The extra conditionals are in line 2
. The first one checks whether lower_bound
succeeded or not. The second one checks whether a successful search yielded an element that is equal to needle
or greater than it.
Line 3
is evaluated on successful searches that found an element that's equal to needle
, representing a successful binary search.
Line 4
is evaluated otherwise (failed or greater than), representing a failed binary search.
One might argue that two extra conditionals are too many conditionals, therefore it is better to move the equality check into the loop and bail out early in case of success.
This brings me to a seemingly unimportant observation about the code presented in the original question, which performs this equality check inside the loop. Assuming random input:
The exact match check optimizes for the less likely case
The very first if
statement inside the question's code represents the exact match check. This check will always be evaluated on every loop iteration.
On average, binary search runs lg(n)
loop iterations (base 2 logarithm), so the check will be evaluated, on average, lg(n)
times, even though it will branch, on average, only 1 out of lg(n)
times.
The exact match can branch, by design, at most once since it signifies a successful search and, therefore, the end of the algorithm.
Moving it out of the loop optimizes for the most likely case: that elements will differ from needle
and the search range will need to be narrowed down.
That is exactly what the extra conditionals after lower_bound
do: they hoist the equality check outside the loop. They are evaluated at most once (i != end
is evaluated exactly once, *i == needle
is evaluated at most once).
The inlined version
Here's what it would look like if we inline lower_bound
in the binary_search
implementation:
// searches for `needle` in the sorted range `[begin, end)`
1. auto const input_end = end;
2. while (begin != end) {
3. assert(begin < end);
4. auto p = begin + (end - begin) / 2;
5. if (*p < needle) {
6. begin = p + 1;
7. } else {
8. end = p;
9. }
10. }
11. return begin != input_end && *begin == needle
12. ? begin
13. : input_end;
This is pretty much lower_bound
, with a modified return
statement.
In my opinion, easy to write, easy to remember, easy to derive from first principles and plenty efficient.
Exact match checking in the else
One might argue that this implementation can be modified to efficiently perform the exact match check inside the loop as follows:
// searches for `needle` in the sorted range `[begin, end)`
1. auto const input_end = end;
2. while (begin != end) {
3. assert(begin < end);
4. auto p = begin + (end - begin) / 2;
5. if (*p < needle) {
6. begin = p + 1;
7. } else if (*p > needle) {
8. end = p;
9. } else {
10. return p;
11. }
12. }
13. return begin != input_end && *begin == needle
14. ? begin
15. : input_end;
The else
in line 9
is equivalent to the exact match check.
This version is indeed better, on the average case, than the one that performs the exact match check as the first thing in the loop's body.
But now instead of having two conditionals in the loop, we have three at lines 1
, 5
and 7
.
Conclusion
Note that every single variation explored here can be valid and efficient. It all depends on what the input will look like.
If your input is highly likely to yield an exact match, then by all means move the exact match check inside the loop.
At the end of the day, the best path forward is to measure and analyze, and only then make a judgement call.
The discussion presented here should not be seen as "never do that, do this instead" but rather as a repertoire of techniques that could be employed, with accompanying notes of when they make sense and when they don't.
Unless and until your input is well known, or observed to have properties that can be exploited for efficiency by special casing algorithms, the simple implementation of binary search presented here will do just fine and will likely be optimal.
v > *(end-1)
. Please read here: stackoverflow.com/a/6393352/7328782 -- you could also look at previous code reviews under the binary-search tag. \$\endgroup\$