Complete guide to searching arrays — linear search with early termination, binary search with all variants, interpolation search, and exponential search. Includes full implementations, complexity analysis, and classic search-based interview problems.
Introduction
Searching is one of the most common operations in all of computing. Every time a database responds to a query, every time a search engine ranks results, every time your phone looks up a contact — a search algorithm is running.
For arrays specifically, the choice of search algorithm depends on one critical question: Is the array sorted?
- Unsorted array: Linear search (O(n)) — no way to do better without additional structure
- Sorted array: Binary search (O(log n)) — one of the most elegant algorithms in computer science
This lesson covers both in complete depth, along with advanced variants and the interview problems that test them.
Binary Search
Binary search is one of the most important algorithms in computer science. It works by repeatedly halving the search range — eliminating half the remaining candidates with each comparison.
Requirement: The array must be sorted.
Core Algorithm — Iterative
def binary_search(arr, target):
"""
Search for target in sorted array arr.
Returns index if found, -1 if not found.
Invariant: If target exists, it must be in arr[left..right].
"""
left = 0
right = len(arr) - 1
while left <= right:
# Compute midpoint — avoid overflow: (left + right) // 2
# In Python, integers don't overflow, but in C/Java use:
# mid = left + (right - left) // 2
mid = (left + right) // 2
if arr[mid] == target:
return mid # Found
elif arr[mid] < target:
left = mid + 1 # Target is in the RIGHT half
# arr[mid] is too small, so discard left half
else: # arr[mid] > target
right = mid - 1 # Target is in the LEFT half
# arr[mid] is too large, so discard right half
return -1 # left > right: search space empty, target not found
Step-by-step trace:
| arr[5] = 23 = target | found! Return 5 |
| arr[5] = 23 < 38 | left = 6 |
| arr[8] = 56 > 38 | right = 7 |
| arr[6] = 38 = target | found! Return 6 |
| Iteration 1 | mid=5, arr[5]=23 < 30, left=6 |
| Iteration 2 | mid=8, arr[8]=56 > 30, right=7 |
| Iteration 3 | mid=6, arr[6]=38 > 30, right=5 |
| left(6) > right(5) | return -1 |
Recursive Implementation
def binary_search_recursive(arr, target, left=None, right=None):
"""Recursive binary search — O(log n) time, O(log n) space (call stack)."""
if left is None:
left = 0
if right is None:
right = len(arr) - 1
if left > right:
return -1 # Base case: search space empty
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
return binary_search_recursive(arr, target, mid + 1, right)
else:
return binary_search_recursive(arr, target, left, mid - 1)
Note: The iterative version is preferred in practice because it uses O(1) space vs. O(log n) stack space for the recursive version.
Complexity — Binary Search:
| Case | Comparisons | Notation |
|---|
| Best | 1 | Ω(1) — target at exact midpoint |
| Average | log n - 1 | Θ(log n) |
| Worst | ⌊log₂ n⌋ + 1 | O(log n) |
| Space (iterative) | 1 | O(1) |
| Space (recursive) | log n | O(log n) |
Power of binary search:
| n | O(n) comparisons | O(log n) comparisons |
|---|
| 1,000 | 1,000 | 10 |
| 1,000,000 | 1,000,000 | 20 |
| 1,000,000,000 | 1,000,000,000 | 30 |
Binary Search Variants
The standard binary search finds one occurrence. Real problems often need variations.
Find Leftmost (First) Occurrence
def binary_search_leftmost(arr, target):
"""
Find the index of the FIRST occurrence of target.
Returns -1 if target not present.
"""
left, right = 0, len(arr) - 1
result = -1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
result = mid # Record this match
right = mid - 1 # But keep searching LEFT for earlier occurrences
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return result
arr = [1, 2, 2, 2, 3, 4, 5]
print(binary_search_leftmost(arr, 2)) # 1 (first occurrence at index 1)
Find Rightmost (Last) Occurrence
def binary_search_rightmost(arr, target):
"""Find the index of the LAST occurrence of target."""
left, right = 0, len(arr) - 1
result = -1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
result = mid # Record this match
left = mid + 1 # Keep searching RIGHT for later occurrences
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return result
arr = [1, 2, 2, 2, 3, 4, 5]
print(binary_search_rightmost(arr, 2)) # 3 (last occurrence at index 3)
Count Occurrences
def count_occurrences(arr, target):
"""Count how many times target appears in sorted arr."""
first = binary_search_leftmost(arr, target)
if first == -1:
return 0
last = binary_search_rightmost(arr, target)
return last - first + 1
arr = [1, 2, 2, 2, 3, 4, 5]
print(count_occurrences(arr, 2)) # 3
print(count_occurrences(arr, 6)) # 0
Find Insertion Position (Lower Bound)
def lower_bound(arr, target):
"""
Find the leftmost position where target can be inserted
to keep the array sorted (i.e., first index where arr[i] >= target).
"""
left, right = 0, len(arr) # right = len(arr) allows inserting at end
while left < right: # Note: strict less than (no =)
mid = (left + right) // 2
if arr[mid] < target:
left = mid + 1
else:
right = mid
return left
arr = [1, 3, 5, 7, 9]
print(lower_bound(arr, 5)) # 2 (arr[2] = 5, first position >= 5)
print(lower_bound(arr, 6)) # 3 (arr[3] = 7, first position >= 6)
print(lower_bound(arr, 0)) # 0 (insert before everything)
print(lower_bound(arr, 10)) # 5 (insert after everything)
Find Upper Bound
def upper_bound(arr, target):
"""
Find the rightmost position where target can be inserted
(first index where arr[i] > target).
"""
left, right = 0, len(arr)
while left < right:
mid = (left + right) // 2
if arr[mid] <= target: # Note: <= instead of <
left = mid + 1
else:
right = mid
return left
arr = [1, 3, 5, 5, 5, 7, 9]
print(upper_bound(arr, 5)) # 5 (first index after all 5s)
print(lower_bound(arr, 5)) # 2 (first index of 5)
# Count of 5s: upper_bound - lower_bound = 5 - 2 = 3 ✓
Binary Search on Answer
A powerful interview technique: binary search not on array indices, but on the *answer space*.
Pattern: If you can verify whether a given answer is feasible in O(f(n)) time, and the feasibility changes monotonically with the answer value, you can binary search on the answer space.
def minimum_days_to_make_bouquets(bloom_day, m, k):
"""
Given bloom_day[i] = day flower i blooms,
find minimum days to make m bouquets of k consecutive flowers.
Binary search on the answer (number of days).
"""
def can_make(days):
"""Can we make m bouquets if we wait 'days' days?"""
bouquets = consecutive = 0
for bloom in bloom_day:
if bloom <= days:
consecutive += 1
if consecutive == k:
bouquets += 1
consecutive = 0
else:
consecutive = 0
return bouquets >= m
if m * k > len(bloom_day):
return -1 # Impossible
left, right = min(bloom_day), max(bloom_day)
while left < right:
mid = (left + right) // 2
if can_make(mid):
right = mid # Try to do it in fewer days
else:
left = mid + 1 # Need more days
return left
print(minimum_days_to_make_bouquets([1,10,3,10,2], 3, 1)) # 3
print(minimum_days_to_make_bouquets([1,10,3,10,2], 3, 2)) # -1
Interpolation Search
Idea: Instead of always going to the middle, estimate where the target might be based on its value relative to the range.
def interpolation_search(arr, target):
"""
Like binary search but estimates the target's position using interpolation.
Best for uniformly distributed sorted arrays.
Average: O(log log n), Worst: O(n)
"""
left, right = 0, len(arr) - 1
while left <= right and arr[left] <= target <= arr[right]:
if left == right:
return left if arr[left] == target else -1
# Interpolation formula: estimate where target is
pos = left + ((target - arr[left]) * (right - left)
// (arr[right] - arr[left]))
if arr[pos] == target:
return pos
elif arr[pos] < target:
left = pos + 1
else:
right = pos - 1
return -1
arr = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
print(interpolation_search(arr, 70)) # 6
print(interpolation_search(arr, 25)) # -1
When it beats binary search: Uniformly distributed data (like sorted phone numbers, timestamps, or sequential IDs). On uniform data, interpolation search achieves O(log log n) — much faster than O(log n).
When it is worse: Highly non-uniform data (like exponentially distributed data) can degrade to O(n).
Exponential Search
Idea: First find a range [2^(i-1), 2^i] where the target lies (by doubling), then binary search within that range.
def exponential_search(arr, target):
"""
Useful for unbounded/infinite arrays or when the target is near the beginning.
Time: O(log n), Space: O(1)
"""
n = len(arr)
if arr[0] == target:
return 0
# Find the range for binary search
i = 1
while i < n and arr[i] <= target:
i *= 2 # Double i each step
# Binary search in [i//2, min(i, n-1)]
return binary_search_range(arr, target, i // 2, min(i, n - 1))
def binary_search_range(arr, target, left, right):
while left <= right:
mid = (left + right) // 2
if arr[mid] == target: return mid
elif arr[mid] < target: left = mid + 1
else: right = mid - 1
return -1
arr = [1, 3, 5, 7, 9, 11, 13, 15, 17, 19, 21]
print(exponential_search(arr, 13)) # 6
Comparison of Search Algorithms
| Algorithm | Time (Average) | Time (Worst) | Space | Requires Sorted |
|---|
| Linear Search | O(n) | O(n) | O(1) | No |
| Binary Search | O(log n) | O(log n) | O(1) | Yes |
| Interpolation | O(log log n) | O(n) | O(1) | Yes + Uniform |
| Exponential | O(log n) | O(log n) | O(1) | Yes |
Summary
- Linear search: O(n), works on any array, use for small or unsorted arrays
- Binary search: O(log n), requires sorted array, the standard for sorted data
- Binary search variants: leftmost/rightmost occurrence, count, lower bound, upper bound
- Binary search on answer: Search over feasible values, not array indices — powerful interview technique
- Interpolation search: O(log log n) average for uniform data
- Exponential search: Good when target is near beginning or array is unbounded
- Always normalize with
k = k % n for rotation problems
*Next Lesson: Dynamic Arrays — How Python lists and Java ArrayLists work internally, amortized analysis of resize operations, and building your own dynamic array.*