Table of Contents
Why Start with a Template
Binary Search: From Beginner to Boundary-Handling Mastery covered the principles: monotonicity, loop invariants, and why binary search is correct. This article tackles a different problem — when you actually sit down in front of the screen, what should the first line be?
The code skeleton for binary search is really just one structure. Among the three variants — searching for an element, searching for the left boundary, and searching for the right boundary — the differences are so small that they come down to just one comparison operator and one return line. But it’s precisely this tiny difference that causes the most trouble: whether the interval is closed or open, whether the initial value of right is n or n - 1, whether to return left or left - 1 — get any one of these backwards, and your code either loops forever or silently returns an index that looks reasonable but is off by one.
This article first converges all three search variants into a single skeleton, then explains one by one why each of the three “fill-in-the-blank” spots is filled the way it is, and finally provides a troubleshooting checklist. The goal is concrete: from now on, whenever you encounter any binary search problem, first recite the skeleton from memory, then just modify the three blanks.
About the Code
All three templates are provided in C / C++ / JavaScript implementations. The logic is completely equivalent; switch tabs to view. All three templates uniformly adopt the left-closed, right-open interval
[left, right)— only “search for an element” retains the more intuitive left-closed, right-closed form because it needs to return early. The reason for not forcing uniformity is explained at the end of the article.
Template Overview: One Skeleton, Three Blanks
Let’s look at the skeleton first. The problem it solves is: find the position of the “first element that satisfies a condition” on [left, right).
/* Generic binary search skeleton: half-open interval [left, right) */left = 0, right = n /* ① The right endpoint is n, not n - 1 */while (left < right): /* As long as there are still elements in the interval, keep bisecting */ mid = left + (right - left) / 2 /* ② Overflow-safe way to compute the midpoint */ if (check(a[mid])): /* ③ The predicate — different for every problem */ right = mid /* mid may be the answer, keep it */ else: left = mid + 1 /* mid is definitely not the answer, exclude it */return left /* ④ Return left, whose value lies in [0, n] */Inside the entire loop, only three spots require thought:
| Blank | Meaning | Range of Variation |
|---|---|---|
③ Condition check(a[mid]) | ”What is the value at mid?” | Only >= and > are used throughout |
| Shrink direction | True goes right = mid, false goes left = mid + 1 | Identical across all three searches — no need to change |
| ④ Return value | left itself, or left ± 1 with verification | Differs across all three searches |
Note that the second row is fixed: as long as the condition is written correctly, the actions for the true and false branches are always the same. This is the most reassuring aspect of this skeleton — you never need to re-derive “should I ±1 or not,” because there’s only one rule:
check(mid)is true →right = mid(mid could be the answer, keep it in the interval); false →left = mid + 1(mid definitely isn’t the answer, exclude it along with itself).
Since mid always satisfies left ≤ mid < right, the two branches respectively make the right end strictly smaller and the left end strictly larger. The interval strictly shrinks every iteration, so it naturally never loops forever.
Unified Perspective: The Three Searches Are Actually the Same Problem
If you break apart “search for an element / left boundary / right boundary,” what they all want is actually the same kind of thing — the first index that satisfies some condition — just with different conditions:
| What You Want | Equivalent to “Find the first position satisfying…” | check(a[mid]) |
|---|---|---|
| Left boundary | Position of >= target | a[mid] >= target |
| Right boundary | Position of > target, then minus one | a[mid] > target |
| Insertion position | Position of >= target (same as left boundary) | a[mid] >= target |
| A specific element | First find position of >= target, then check if it’s exactly equal | a[mid] >= target + one verification |
In other words, the >= version (hereafter lowerBound) and the > version (hereafter upperBound) — these two functions cover all cases — they differ by only one comparison operator. The real difference lies in the return value: the left boundary returns directly, the right boundary needs to subtract one, and searching for an element requires an extra check of “is it actually equal.”
Remember these two conversions first. The three templates that follow are all just a wrapper layered on top of them:
Search left boundary (first == target) = lowerBound(target) + verify a[i] == targetSearch right boundary (last == target) = upperBound(target) - 1 + verify a[i] == targetSearch insertion position = lowerBound(target) (no verification, return directly)Template A: Searching for an Element
The most familiar scenario: find an exact value in an array, return its index if found, otherwise return -1.
This one is most intuitive with a closed interval on both ends [left, right]: return return mid immediately on a hit, and if the loop ends, it means nothing was found.
int search(const int *a, int n, int target) { int left = 0, right = n - 1; /* Closed interval [left, right] */ while (left <= right) { /* Note it's <=, not < */ int mid = left + (right - left) / 2; if (a[mid] == target) return mid; /* Hit, return directly */ if (a[mid] < target) left = mid + 1; /* Answer is in the right half */ else right = mid - 1; /* Answer is in the left half */ } return -1; /* Interval is empty, not found */}#include <vector>
int search(const std::vector<int>& a, int target) { int left = 0, right = static_cast<int>(a.size()) - 1; while (left <= right) { int mid = left + (right - left) / 2; if (a[mid] == target) return mid; if (a[mid] < target) left = mid + 1; else right = mid - 1; } return -1;}function search(a, target) { let left = 0; let right = a.length - 1; // Closed interval [left, right] while (left <= right) { // Note it's <=, not < const mid = left + Math.floor((right - left) / 2); if (a[mid] === target) return mid; // Hit, return directly if (a[mid] < target) left = mid + 1; // Answer is in the right half else right = mid - 1; // Answer is in the left half } return -1;}Key Points
- The initial value of
rightisn - 1, and the loop condition is<=. The “empty” state of a closed interval isleft > right, so the condition for continuing to binary search isleft <= right. - Only when
middefinitely cannot be the answer do we dare to±1. Herea[mid]has already been compared and confirmed unequal, so both branches skipmiditself. - When duplicate elements are present, it may return any one of them. If you want to pinpoint a specific one in
[2,2,2], you’ll need the left/right boundary templates that follow.
Rewriting It with the Unified Skeleton
Template A can also be written as lowerBound plus a single check—this is exactly the key to connecting it back to the unified perspective:
int search(const int *a, int n, int target) { int i = lowerBound(a, n, target); /* First position >= target */ return (i < n && a[i] == target) ? i : -1; /* Only an exact match counts as found */}int search(const std::vector<int>& a, int target) { int i = lowerBound(a, target); return (i < static_cast<int>(a.size()) && a[i] == target) ? i : -1;}function search(a, target) { const i = lowerBound(a, target); // First position >= target return i < a.length && a[i] === target ? i : -1;}This version is a bit longer than the closed-interval one, but the advantage is that there’s only one skeleton left in the entire piece: you no longer need to maintain two kinds of loop conditions in your head.
Template B: Search for the Left Boundary (lowerBound)
Scenario: the array contains duplicate elements, and you want the index of the first == target; or you simply want the insertion position—the position of the first >= target.
Taking nums = [1, 2, 2, 2, 3] and target = 2 as an example, what we want is index 1:
nums: 1 2 2 2 3index: 0 1 2 3 4 ^ lowerBound(2) = 1/* First position >= target, return value range [0, n] */int lowerBound(const int *a, int n, int target) { int left = 0, right = n; /* Open interval [left, right) */ while (left < right) { int mid = left + (right - left) / 2; if (a[mid] >= target) right = mid; /* mid may be the answer, keep it */ else left = mid + 1; /* mid is too small, exclude it along with itself */ } return left; /* Index of first >= target */}#include <vector>
int lowerBound(const std::vector<int>& a, int target) { int left = 0, right = static_cast<int>(a.size()); /* Open interval [left, right) */ while (left < right) { int mid = left + (right - left) / 2; if (a[mid] >= target) right = mid; else left = mid + 1; } return left;}function lowerBound(a, target) { let left = 0; let right = a.length; // Open interval [left, right) while (left < right) { const mid = left + Math.floor((right - left) / 2); if (a[mid] >= target) right = mid; // mid may be the answer, keep it else left = mid + 1; // mid is too small, exclude it along with itself } return left; // Index of first >= target}Tracing through it step by step, you can see how the interval shrinks down to the answer:
Key Points
- The initial value of
rightisn(notn - 1). In a half-open interval,rightmeans “definitely not the first position of the answer,” and the meaning of the initialnis “the answer may fall after the end of the array”—only then can the insertion position possibly returnn. right = midrather thanmid - 1. Because whena[mid] >= target,miditself may be the answer (for example, it is the first position equal totarget), and excluding it would miss the solution.- Will
right = midcause an infinite loop? No.mid < rightalways holds, soright = midmakes the right end strictly smaller; the other branchleft = mid + 1makes the left end strictly larger. This is exactly why the skeleton is safe. - The return value of
lowerBoundmay equaln. This happens when all elements in the array are less thantarget, so you must checki < nbefore using the value.
One Verification Step, and It Becomes “Search for the Left Boundary”
What is returned above is the position of the “first >= target,” and when target does not exist, it points to an element greater than target. To get the strict “first == target,” just add an equality check:
int searchLeft(const int *a, int n, int target) { int i = lowerBound(a, n, target); /* First >= target */ return (i < n && a[i] == target) ? i : -1; /* Only counts if it exists, otherwise -1 */}int searchLeft(const std::vector<int>& a, int target) { int i = lowerBound(a, target); return (i < static_cast<int>(a.size()) && a[i] == target) ? i : -1;}function searchLeft(a, target) { const i = lowerBound(a, target); // First >= target return i < a.length && a[i] === target ? i : -1;}Both checks i < n and a[i] == target are indispensable: the former prevents out-of-bounds access, and the latter distinguishes “insertion position” from “actually exists.”
Template C: Searching for the Right Boundary (upperBound)
Scenario: you want the index of the last == target. Same array, same target = 2, but this time you want index 3.
The most reliable approach isn’t “binary search from right to left,” but rather copy Template B and change >= to >, obtaining the position of the “first > target,” then subtracting one:
nums: 1 2 2 2 3index: 0 1 2 3 4 ^ upperBound(2) = 4 (the first > 2) upperBound(2) - 1 = 3 (the last <= 2, that is, the right boundary of 2)/* First position > target, return value range [0, n] */int upperBound(const int *a, int n, int target) { int left = 0, right = n; while (left < right) { int mid = left + (right - left) / 2; if (a[mid] > target) right = mid; /* Only change: >= becomes > */ else left = mid + 1; } return left; /* Index of first > target */}
int searchRight(const int *a, int n, int target) { int i = upperBound(a, n, target) - 1; /* Last <= target */ return (i >= 0 && a[i] == target) ? i : -1;/* Only an exact match is the right boundary */}#include <vector>
int upperBound(const std::vector<int>& a, int target) { int left = 0, right = static_cast<int>(a.size()); while (left < right) { int mid = left + (right - left) / 2; if (a[mid] > target) right = mid; // Only change: >= becomes > else left = mid + 1; } return left;}
int searchRight(const std::vector<int>& a, int target) { int i = upperBound(a, target) - 1; // Last <= target return (i >= 0 && a[i] == target) ? i : -1;}function upperBound(a, target) { let left = 0; let right = a.length; while (left < right) { const mid = left + Math.floor((right - left) / 2); if (a[mid] > target) right = mid; // Only change: >= becomes > else left = mid + 1; } return left; // Index of first > target}
function searchRight(a, target) { const i = upperBound(a, target) - 1; // Last <= target return i >= 0 && a[i] === target ? i : -1;}Key Points
- Differs from
lowerBoundby only one symbol.lowerBoundchecks>=,upperBoundchecks>; everything else (right = mid/left = mid + 1/ returningleft) is identical word for word. When writing code, you can first copy downlowerBound, then change that one symbol. - Why subtract one.
upperBoundfinds the first position greater thantarget; the cell to its left is the “last position less than or equal totarget”—that is, the right boundary oftarget. Whentargetdoesn’t exist, the element at this position is some other value, so it must be filtered out via thea[i] == targetcheck. upperBound(target) - 1may equal-1. When all elements in the array are greater thantarget,upperBoundreturns0. Before takinga[i], you must first checki >= 0.- Don’t substitute
lowerBound(target + 1) - 1. The effect is the same, but whentargethappens to beINT_MAX,target + 1overflows to a negative number. UsingupperBound(checking> target) avoids this pitfall at its root.
Comparison Table of the Three Templates
Looking at the three searches side by side, the differences are immediately clear:
| Template A: Search for Element | Template B: Left Boundary | Template C: Right Boundary | |
|---|---|---|---|
| Interval | Closed [left, right] | Open [left, right) | Open [left, right) |
Initial value of right | n - 1 | n | n |
| Loop condition | left <= right | left < right | left < right |
| Check condition | a[mid] == target | a[mid] >= target | a[mid] > target |
| True branch | return mid | right = mid | right = mid |
| False branch | By comparison mid ± 1 | left = mid + 1 | left = mid + 1 |
| Return value | mid or -1 | left then verify | left - 1 then verify |
| Return value range | [-1, n-1] | [0, n] | [-1, n-1] |
To summarize all three tables in one sentence: >= and > are swapped back and forth, right = mid never changes, and the return value’s -1 and verification are patched in at the end.
Take another look at how target = 2 performs in [2,2,2,2]—this is also the fastest way to decide which template to use:
| Scenario | Which to use | Result |
|---|---|---|
| Just need to know “is it there or not” | A | The index of any 2 |
Want “the first 2” / insertion position | B | 0 |
Want “the last 2” | C | 3 |
Want the entire range where target appears | B + C | [0, 3] |
Appendix: Why Left-Closed Right-Open Is Recommended for Intervals
The easiest place to make mistakes in binary search isn’t the algorithm—it’s the definition of the interval. Once you pin down the interval, the initial value of right, the loop condition, and the return value can all be mechanically derived. The two approaches are compared below:
Left-closed right-closed [left, right] | Left-closed right-open [left, right) | |
|---|---|---|
Initial value of right | n - 1 | n |
| Condition for loop to continue | left <= right | left < right |
| What “empty interval” looks like | left > right | left == right |
| Interval length | right - left + 1 | right - left |
| When the loop ends | left == right + 1 | left == right |
| Suited for | Searching for one element (return upon finding) | Searching for boundaries / insertion position |
Left-closed right-open is recommended for three reasons:
- The interval length is just
right - left, no+1needed. One less+1means one less place to get backwards. - The empty-interval check is built into the loop condition.
left == rightmeans the interval is empty, andwhile (left < right)’s stopping condition naturally aligns—no need to separately think about “when is the search considered done.” - “The answer may not exist” has somewhere to go. The initial value of
rightisn, meaning “the answer may fall one cell past the end of the array”; returningnas the insertion position won’t go out of bounds. The closed-interval approach has no such position, and handling “insert at the end” requires a special case.
The reason Template A retains the closed interval is simply that once it hits, it return, so it doesn’t need the unified semantics of “the first position satisfying the condition”—the closed interval reads more straightforwardly. If you want to keep only one style in your project, just replace Template A with its lowerBound + verification version.
Troubleshooting Checklist: If You Get Binary Search Wrong, It’s Almost Always One of These Four
1. right Initial Value Set Wrong
For left-closed right-open, you must write n. Writing n - 1 will miss the slot “answer is after the end,” causing the insertion position to never return n.
int left = 0, right = n; /* Open interval, ✓ */int left = 0, right = n - 1; /* Open interval, ✗ the last cell is missed */2. mid Overflow
In C / C++ / Java / Go, when left + right exceeds INT_MAX (about 2.1 billion), (left + right) / 2 will first overflow into a negative number, then dividing by 2 gives a negative index, and a[mid] goes out of bounds directly.
int mid = (left + right) / 2; /* ✗ left + right may overflow */int mid = left + (right - left) / 2; /* ✓ Subtract first, then add, never overflows */JavaScript uses double-precision floating point, so it won’t overflow at the magnitude of array indices, but keeping the same formulation lets you compare code across all three languages at a glance, and avoids stepping into this pitfall when porting to another language later.
3. Infinite Loop
left = mid must never appear in the loop body of while (left < right). The reason is that when mid takes the lower median, mid < right always holds, but when right == left + 1, mid == left, at which point left = mid equals no movement, the interval doesn’t shrink, and you get an infinite loop.
Using left = mid + 1 in the skeleton is precisely to avoid this. Conversely, if you really need to “keep mid as the new left endpoint,” then mid must be changed to the upper median left + (right - left + 1) / 2, otherwise it will get stuck just the same.
Just remember one thing:
while (left < right)paired withleft = mid + 1is always safe.
4. Return Value Out of Bounds
The return value ranges of lowerBound and upperBound are both [0, n], and both can equal n; upperBound(target) - 1 can equal -1. Whenever you want to use this index to access a[i], you must first guard against it:
| Return Value | Value That May Go Out of Bounds | Check Before Use |
|---|---|---|
lowerBound | n (all less than target) | i < n |
upperBound | n (all less than or equal to target) | i < n |
upperBound - 1 | -1 (all greater than target) | i >= 0 |
Template Extension: Find First and Last Position of Element (LeetCode 34)
Combining Template B and Template C gives you this classic problem—obtaining the entire range of target in one pass.
Problem Description
Given an integer array nums sorted in ascending order, and a target value target. Find the starting position and ending position of target in the array. If target does not exist in the array, return [-1, -1].
Example:
Input: nums = [5,7,7,8,8,10], target = 8Output: [3,4]
Input: nums = [5,7,7,8,8,10], target = 6Output: [-1,-1]
Input: nums = [], target = 0Output: [-1,-1]Constraints: 0 <= nums.length <= 10^5, -10^9 <= nums[i] <= 10^9. O(log n) time complexity is required.
Approach: Binary Search Once for Left Boundary and Once for Right Boundary
start = lowerBound(target): the position of the first>= target.end = upperBound(target) - 1: the position of the last<= target.- The precondition for both to exist is
start < nandnums[start] == target—when satisfied,endnecessarily>= start, so return them together directly; otherwise return[-1, -1].
#include <stdlib.h>
int *searchRange(int *nums, int numsSize, int target, int *returnSize) { int *res = (int *)malloc(2 * sizeof(int)); *returnSize = 2;
int left = lowerBound(nums, numsSize, target); /* First >= target */ int right = upperBound(nums, numsSize, target) - 1; /* Last <= target */
if (left < numsSize && nums[left] == target) { /* Confirm target exists */ res[0] = left; res[1] = right; } else { res[0] = -1; res[1] = -1; } return res;}#include <vector>using namespace std;
class Solution {public: vector<int> searchRange(vector<int>& nums, int target) { int left = lowerBound(nums, target); // First >= target int right = upperBound(nums, target) - 1; // Last <= target
if (left < (int)nums.size() && nums[left] == target) // Confirm target exists return {left, right}; return {-1, -1}; }};function searchRange(nums, target) { const left = lowerBound(nums, target); // First >= target const right = upperBound(nums, target) - 1; // Last <= target
if (left < nums.length && nums[left] === target) return [left, right]; return [-1, -1];}Complexity
| Dimension | Analysis |
|---|---|
| Time Complexity | O(log n) — two independent binary searches, 2 × O(log n) is still O(log n) |
| Space Complexity | O(1) — only two pointers used; the array returned by the C version is the result required by the problem and doesn’t count as extra space |
Common Pitfalls
- One binary search can’t get both boundaries. A common mistake is “after finding
target, linearly expand to both sides”—in the worst case (array is alltarget), this degrades to O(n) and times out. You must honestly do two binary searches. upperBound(target) - 1instead ofupperBound(target). Forgetting to subtract one is the most typical error—the returnedendwill point to the element that is “the first one greater thantarget,” off by one position to the right.- Empty array. When
numsSize == 0,lowerBoundreturns0,0 < 0is false, and it goes directly to the[-1, -1]branch without special handling; but in the C version, allocating twointformalloccannot be skipped, because the caller will read according to*returnSize == 2. - Don’t reverse the validation conditions. To check existence, it’s
left < numsSize && nums[left] == target—both conditions are needed: writing only the first half will misjudge the “insertion position” as a hit, and writing only the second half will go out of bounds whenleft == numsSize.
Related Articles
- Binary Search: From Beginner to Boundary Handling Mastery — principles, monotonicity, interval invariants, and more variants
- Sliding Window Core Code Template: Three Problems to Master All Substring and Subarray Problems — same philosophy of “one template converges a class of problems”
- Find the Duplicate Number: Binary Search and Fast-Slow Pointers — a classic example of binary searching on the value range rather than indices
- Array Two Pointers: The Optimization Tool from O(n²) to O(n) — another framework that reduces brute force to a linear scan