Structs & Data Structures · intermediate · ~10 min
## What you will learn - Implement **linear search** to find a value in any array, sorted or not, and know its O(n) cost. - Implement **binary search** correctly on a sorted array using a half-open interval `[lo, hi)`. - Compute a midpoint safely with `lo + (hi - lo) / 2` and explain why `(lo + hi) / 2` is buggy. - Compare the two algorithms and choose the right one based on whether the data is sorted and how often you search. - Use the standard library's `bsearch` from `<stdlib.h>` with a correct comparison function. - Avoid the classic search bugs: searching unsorted data, off-by-one boundaries, and integer overflow.
Searching means answering a simple question about a collection: is this value here, and if so, where? Almost every program asks this constantly. A contacts app finds a name, a game checks whether a key is held down, a database looks up a row by id, a compiler checks whether a word is a reserved keyword. The data lives in an array (which you met in the Arrays lesson), and searching is how you turn "I have a value" into "I have its position."
There are two foundational strategies, and the difference between them is one of the most important lessons in all of computing.
The first, linear search, is the obvious one: walk through the array from the start and compare each element to the value you want. It always works, on any array, in any order.
The second, binary search, is dramatically faster but comes with a strict precondition: the array must already be sorted. That is exactly why this lesson follows the Sorting overview lesson. Sorting is the price of admission for binary search. Once the data is in order, binary search can repeatedly cut the remaining range in half, the same way you find a word in a dictionary without reading every page.
Along the way you will meet big-O notation (O(n), O(log n)). Big-O is just a compact way to say "how does the amount of work grow as the data gets bigger?" It ignores constant factors and small details and focuses on the shape of the growth, because that shape is what decides whether your program stays fast when the input goes from a hundred items to a hundred million.
Search performance is rarely noticeable on tiny data and absolutely decisive on large data. Consider an array of one million sorted integers:
| Strategy | Worst-case comparisons | Feels like |
|---|---|---|
| Linear search | ~1,000,000 | Reading every page of a dictionary |
| Binary search | ~20 | Flipping to the middle, then halving |
That gap is not a 10% improvement, it is the difference between an instant lookup and a program that stutters. Real systems lean on fast search everywhere: databases build indexes so lookups are logarithmic instead of linear; operating systems search sorted tables of processes and files; networking code searches routing tables; autocomplete searches dictionaries on every keystroke.
There is also a correctness angle. Binary search is famously easy to get almost right and subtly wrong. A binary search bug shipped in the Java standard library for nearly a decade because of an integer-overflow midpoint calculation. Learning to write it carefully, with the right interval convention, is a rite of passage that teaches you to reason precisely about loop boundaries, a skill that pays off across all of programming.
Definition. Linear search (also called sequential search) examines elements one at a time, from the first to the last, returning the index of the first match or a "not found" marker if it reaches the end.
How it works. You keep an index i starting at 0. At each step you compare a[i] to the target. If they are equal, you are done. Otherwise you move to i + 1. The loop ends either at a match or when i reaches n.
target = 7
index: 0 1 2 3 4 5
array: [3] [9] [1] [7] [4] [8]
| | | |
3? 9? 1? 7? -> match at index 3, return 3
Structure / cost. No precondition at all. Best case O(1) (match at the front), worst and average case O(n). It does the minimum number of comparisons that any algorithm could on unsorted data, because without order there is no way to rule out elements you have not looked at.
When to use it. The array is small, unsorted, or searched only a few times; or you need the first match by position; or the data changes constantly so keeping it sorted is not worth it.
When NOT to use it. Large data that is searched repeatedly. If you will do many lookups, sorting once and using binary search wins decisively.
Common pitfall. Forgetting that linear search returns the first match. If duplicates exist and you wanted the last one, linear search from the front gives the wrong answer.
Knowledge check (explain in your own words): Why does linear search work on an unsorted array while binary search does not?
Definition. Binary search finds a target in a sorted array by repeatedly comparing the target to the middle element and discarding the half that cannot contain it.
How it works internally. You track a range of still-possible positions. Look at the middle element. If it equals the target, you are done. If the middle is less than the target, the target (if present) must be to the right, so discard the left half. If the middle is greater, discard the right half. Each step halves the range.
target = 13, sorted array
index: 0 1 2 3 4 5 6
array: [1] [4] [6] [9] [13][20][31]
Step 1: lo=0 hi=7 mid=3 a[3]=9 < 13 -> go right, lo=4
Step 2: lo=4 hi=7 mid=5 a[5]=20 > 13 -> go left, hi=5
Step 3: lo=4 hi=5 mid=4 a[4]=13 == 13 -> found at index 4
Structure / cost. Precondition: the array MUST be sorted in the order your comparisons assume (ascending here). Cost is O(log n) because the range halves each step: from n possibilities you reach 1 in about log2(n) steps.
The half-open interval [lo, hi). This lesson uses the convention that lo is included and hi is excluded. The range is empty exactly when lo == hi, which makes the loop condition cleanly while (lo < hi). When you go right you set lo = mid + 1 (mid is ruled out); when you go left you set hi = mid (mid is ruled out, and since hi is excluded you do not write mid - 1). Keeping one consistent convention is the single best defense against off-by-one bugs.
Meaning of [lo, hi): candidates are indices lo, lo+1, ..., hi-1
Empty range: lo == hi -> stop, not found
When to use it. Sorted data searched many times, especially large data.
When NOT to use it. The data is unsorted (sort first or use linear search), tiny, or changes so often that maintaining sort order costs more than the searches save.
Common pitfall. Mixing interval conventions, e.g. using hi = n - 1 (an inclusive upper bound) but writing the loop as while (lo < hi). Inclusive bounds need while (lo <= hi) and hi = mid - 1. Pick one style and never mix.
Knowledge check (predict the output): With the array
[2, 5, 8, 11]and the half-open algorithm above, how many comparisons ofa[mid]happen when searching for8? Tracelo,hi, andmid.
Definition. Big-O describes how an algorithm's work grows with input size n, ignoring constant factors.
Why O(log n) beats O(n) so much. Doubling the data adds one step to binary search but doubles the work of linear search. The table in "Why it matters" shows the practical effect: ~20 steps versus a million.
n linear (worst) binary (worst, ~log2 n)
10 10 4
1,000 1,000 10
1,000,000 1,000,000 20
Pitfall. Big-O hides constants. For very small n, linear search can actually be faster because it has no setup and great cache behavior. Big-O tells you who wins as n grows, not who wins at n = 5.
Knowledge check (concept): If sorting an array costs O(n log n), is it worth sorting just to do one binary search instead of one linear search? When does sorting pay off?
[lo, hi))long binary_search(const int *a, size_t n, int target) {
size_t lo = 0, hi = n; // candidates are indices [lo, hi)
while (lo < hi) { // stop when the range is empty
size_t mid = lo + (hi - lo) / 2; // overflow-safe midpoint
if (a[mid] == target) {
return (long)mid; // found: return the index
} else if (a[mid] < target) {
lo = mid + 1; // target is to the right; drop left half (incl. mid)
} else {
hi = mid; // target is to the left; drop right half (mid excluded by hi)
}
}
return -1; // empty range: not present
}
Key syntax points:
size_t is the correct unsigned type for array indices and sizes. Because it is unsigned, never write mid - 1 here (it could wrap around below 0); the half-open convention avoids needing it.long so a real index (0..n-1) is distinguishable from the -1 "not found" sentinel. Returning size_t would make -1 wrap to a huge value.const int *a signals the function does not modify the array.Linear search checks every element, one by one. In the worst case it examines all n items, so its running time is O(n). The big-O notation here just describes how the work grows as the data gets larger.
Binary search is much faster, but it has one requirement: the data must already be sorted. Each step throws away half of the remaining elements, so the running time is O(log n). For a million items, that is about 20 steps instead of a million.
The C standard library provides bsearch in <stdlib.h>, which does the work for you.
If you write your own, use a half-open interval [lo, hi). This means lo is included but hi is not. Compute the midpoint as:
mid = lo + (hi - lo) / 2;
Writing it this way avoids two classic problems:
lo + hi can exceed the maximum value of the type on very large inputs. lo + (hi - lo) / 2 cannot.#include <stdio.h>
#include <stdlib.h>
#include <stddef.h>
/* Linear search: works on any array, returns first matching index or -1. */
long linear_search(const int *a, size_t n, int target) {
for (size_t i = 0; i < n; i++) {
if (a[i] == target) {
return (long)i;
}
}
return -1;
}
/* Binary search on an ASCENDING-sorted array using the half-open range [lo, hi). */
long binary_search(const int *a, size_t n, int target) {
size_t lo = 0, hi = n;
while (lo < hi) {
size_t mid = lo + (hi - lo) / 2; /* overflow-safe midpoint */
if (a[mid] == target) {
return (long)mid;
} else if (a[mid] < target) {
lo = mid + 1; /* search the right half */
} else {
hi = mid; /* search the left half */
}
}
return -1;
}
/* Comparison function required by the standard library bsearch(). */
static int cmp_int(const void *pa, const void *pb) {
int a = *(const int *)pa;
int b = *(const int *)pb;
return (a > b) - (a < b); /* -1, 0, or +1 without risk of overflow */
}
int main(void) {
int data[] = {1, 4, 6, 9, 13, 20, 31}; /* already sorted ascending */
size_t n = sizeof data / sizeof data[0];
int target = 13;
long li = linear_search(data, n, target);
long bi = binary_search(data, n, target);
printf("linear_search(%d) -> %ld\n", target, li);
printf("binary_search(%d) -> %ld\n", target, bi);
/* Standard-library binary search; returns a pointer or NULL. */
int *found = bsearch(&target, data, n, sizeof data[0], cmp_int);
if (found != NULL) {
printf("bsearch(%d) -> index %ld\n", target, (long)(found - data));
} else {
printf("bsearch(%d) -> not found\n", target);
}
int missing = 7; /* not in the array */
printf("binary_search(%d) -> %ld\n", missing, binary_search(data, n, missing));
return 0;
}
What it does. It searches a 7-element sorted array for 13 three different ways (your linear search, your binary search, and the library bsearch), then confirms that a missing value 7 correctly returns -1. bsearch returns a pointer into the array, so we convert it to an index with pointer subtraction found - data.
Expected output:
linear_search(13) -> 4
binary_search(13) -> 4
bsearch(13) -> index 4
binary_search(7) -> -1
Edge cases worth noting. An empty array (n == 0): binary search's loop never runs and returns -1 immediately. Duplicates: this version returns some matching index, not necessarily the first. A target smaller than every element or larger than every element both correctly return -1.
Walkthrough of binary_search(data, 7, 13) on {1, 4, 6, 9, 13, 20, 31}.
| Step | lo | hi | mid = lo+(hi-lo)/2 | a[mid] | Compare to 13 | Action |
|---|---|---|---|---|---|---|
| start | 0 | 7 | 3 | 9 | 9 < 13 | go right: lo = mid+1 = 4 |
| 2 | 4 | 7 | 5 | 20 | 20 > 13 | go left: hi = mid = 5 |
| 3 | 4 | 5 | 4 | 13 | 13 == 13 | return 4 |
Line by line through the function body:
size_t lo = 0, hi = n; sets the candidate range to all of [0, 7).while (lo < hi) checks the range is non-empty. 0 < 7 is true, so we enter.mid = lo + (hi - lo) / 2 = 0 + 7/2 = 3. We examine a[3] = 9.9 == 13? No. 9 < 13? Yes, so the answer must be to the right; lo = 4. The range is now [4, 7).mid = 4 + (7-4)/2 = 4 + 1 = 5, a[5] = 20. 20 > 13, so the answer is to the left; hi = 5. Range [4, 5).mid = 4 + (5-4)/2 = 4, a[4] = 13. Equal, return 4.Now trace the not found case, binary_search(data, 7, 7):
| Step | lo | hi | mid | a[mid] | vs 7 | Action |
|---|---|---|---|---|---|---|
| start | 0 | 7 | 3 | 9 | > | hi = 3 |
| 2 | 0 | 3 | 1 | 4 | < | lo = 2 |
| 3 | 2 | 3 | 2 | 6 | < | lo = 3 |
| 4 | 3 | 3 | — | — | — | lo < hi is false -> return -1 |
Notice the range shrinks to [3, 3), which is empty, so the loop exits and we return -1 without ever reading an out-of-range index.
(lo + hi) / 2size_t mid = (lo + hi) / 2; // WRONG on very large arrays
Why it is wrong. If lo and hi are both close to the maximum representable value, lo + hi overflows before the division. With size_t (unsigned) it wraps to a small number, producing a wildly incorrect mid and likely an out-of-bounds access. This exact bug lived in the Java standard library for years.
size_t mid = lo + (hi - lo) / 2; // CORRECT: (hi - lo) is small, no overflow
How to recognize it. It works fine in tests with small arrays and fails only on huge inputs, the worst kind of bug. Always use the subtraction form by habit.
int data[] = {9, 1, 13, 4}; // NOT sorted
binary_search(data, 4, 13); // result is meaningless
Why it is wrong. Binary search's entire logic depends on "everything left of mid is smaller, everything right is larger." If that is false, discarding a half can throw away the element you wanted. It may return -1 for a value that is actually present, or return the wrong index.
Fix. Sort first (qsort), or use linear_search if the data cannot be sorted.
How to prevent it. Treat "is this sorted?" as a precondition. In debug builds you can assert it.
size_t lo = 0, hi = n - 1; // inclusive upper bound...
while (lo < hi) { // ...but loop written for half-open!
size_t mid = lo + (hi - lo) / 2;
if (a[mid] < target) lo = mid + 1;
else hi = mid;
}
// bug: misses the last element when target sits at index n-1
Why it is wrong. With an inclusive hi, the empty-range test must be lo > hi (so the loop is while (lo <= hi)) and the left step must be hi = mid - 1. Mixing the two styles silently skips elements.
Fix. Pick the half-open style of this lesson and stick to it everywhere: hi = n, while (lo < hi), hi = mid.
size_t so -1 is unusablesize_t binary_search(...) { ... return -1; } // -1 wraps to SIZE_MAX!
Why it is wrong. size_t is unsigned, so the caller's if (result == -1) may not behave as expected and the "index" is enormous. Fix: return a signed type (long) for the index, or use an out-parameter plus a bool found return.
comparison of integers of different signs or -Wsign-compare: usually from comparing a size_t (unsigned) with an int (signed). Keep indices size_t and return indices as long. Compile with -Wall -Wextra to surface these.bsearch/qsort: include <stdlib.h>.bsearch callback signature: the comparator must be int f(const void *, const void *). A mismatch triggers an incompatible-pointer warning.AddressSanitizer: heap-buffer-overflow: you read a[mid] when the range was empty, or used the wrong loop condition. Build with -fsanitize=address and re-run; ASan points at the exact line.lo = mid (instead of mid + 1) in the go-right branch. Because mid can equal lo, the range never shrinks. The fix is lo = mid + 1.printf("lo=%zu hi=%zu mid=%zu a[mid]=%d\n", lo, hi, mid, a[mid]); and watch the range shrink every iteration.lo increases or hi decreases)? If not, that branch is the infinite-loop culprit.a[mid] is only safe when lo < hi and mid lies in [0, n). The half-open invariant lo <= mid < hi <= n guarantees this, which is the deep reason to keep the interval convention consistent. Break the invariant and you risk reading past the array, which is undefined behavior.size_t is unsigned: 0 - 1 is not -1, it is SIZE_MAX. Never write mid - 1 or hi - 1 without proving the value cannot be 0. The half-open form deliberately avoids subtracting 1 from an index.lo + (hi - lo) / 2 cannot overflow for valid indices; (lo + hi) / 2 can. Prefer the safe form even when n is small, so the habit is automatic.n. If a caller passes an n larger than the real array, every search can read out of bounds. The function cannot detect this; the caller must pass the true length, e.g. sizeof arr / sizeof arr[0] for a true array (not a pointer).malloc the data you search, the owner of that buffer must still free it after the searches finish.const correctness. Declaring the parameter const int *a documents and enforces that searching does not mutate the data, preventing accidental writes.WHERE id = ? query on an indexed column is instant even on millions of rows.bsearch, C++'s std::lower_bound, and Python's bisect are all binary search. They are the tested, overflow-safe versions you should reach for in real code.Beginner rules:
bsearch, qsort) over hand-rolled versions in production; only write your own to learn or when you need a custom variant.lo, hi, mid, target) and one interval convention throughout the codebase.-Wall -Wextra -fsanitize=address,undefined while developing.Advanced rules:
1. Linear search with count. Write long linear_search(const int *a, size_t n, int target) that returns the first index of target or -1. Then extend it to also print how many comparisons it made.
{4, 8, 15, 16, 23, 42}, target 15 → returns 2, made 3 comparisons.for loop with a counter. Concepts: linear search, O(n).2. Verify sortedness. Write int is_sorted_asc(const int *a, size_t n) returning 1 if the array is ascending (ties allowed) and 0 otherwise. Use it to guard a binary search call.
a[i] with a[i-1]. Concepts: precondition checking.3. Half-open binary search from scratch. Implement long binary_search(const int *a, size_t n, int target) using [lo, hi) and lo + (hi - lo) / 2. Test it on an empty array, a single-element array, and a target at the very last index.
{2,5,8,11}, target 11 → 3; target 3 → -1.while (lo < hi); go-right is lo = mid + 1, go-left is hi = mid. Concepts: binary search, intervals, boundaries.4. First occurrence (lower bound). Modify binary search to return the index of the first element equal to target when duplicates exist (or -1 if absent).
{1,2,2,2,5}, target 2 → 1 (not 2 or 3).hi = mid) and remember the candidate. Concepts: lower-bound variant.5. Search a rotated sorted array. A sorted ascending array has been rotated at an unknown pivot, e.g. {6, 9, 12, 1, 3, 4}. Write long search_rotated(const int *a, size_t n, int target) that still runs in O(log n).
3 → 4; target 7 → -1.a[lo] to a[mid], then check whether the target falls inside that sorted half. Concepts: binary search, invariants, careful case analysis.[lo, hi): loop while (lo < hi), go right with lo = mid + 1, go left with hi = mid, and compute mid = lo + (hi - lo) / 2 to avoid integer overflow.bsearch from <stdlib.h> (with a correct int cmp(const void*, const void*)) is the tested version for real code.(lo + hi) / 2 overflow; binary searching unsorted data; mixing interval conventions; returning size_t so -1 wraps. Keep indices size_t, return the index as long, and always test the empty / single / first / last / absent boundaries.