Binary Search: First & Last Occurrences and Element Counting
This chapter explores how to leverage Binary Search to find the first and last occurrences of a target element in a sorted array, and how to extend this logic to count the total occurrences of an element in time. We will transition from a naive linear scan to an optimized approach using Lower/Upper Bounds, and finally build a custom, raw Binary Search solution from scratch.
1. The Problem Statement & Core Intuition
Given a sorted array of integers containing duplicate values and a target value , we need to solve two closely related problems:
- First and Last Occurrences: Find the starting (first) and ending (last) index of . If is not present in the array, return
[-1, -1]. - Count Occurrences: Find the total number of times appears in the array.
Real-World Example
Consider the sorted array:
2. The Naive Approach (Linear Scan)
The most straightforward way to find the first and last occurrences of is to traverse the array from left to right.
Algorithm Workflow
- Initialize
first = -1andlast = -1. - Iterate through the array from index to :
- If the current element
arr[i] == X:- If
firstis still-1, this is the first time we are seeing the element. Updatefirst = i. - Always update
last = ibecause any subsequent match will push the last occurrence further to the right.
- If
- If the current element
- Return the pair
[first, last].
Code Implementation (C++)
#include <vector>
#include <utility>
std::pair<int, int> findFirstAndLastNaive(const std::vector<int>& arr, int n, int x) {
int first = -1;
int last = -1;
for (int i = 0; i < n; i++) {
if (arr[i] == x) {
if (first == -1) {
first = i;
}
last = i;
}
}
return {first, last};
}
Complexity Analysis
- Time Complexity: because we must scan the entire array of size .
- Space Complexity: as we only use a few auxiliary variables.
3. Mental Model: Lower Bound & Upper Bound Connection
Since the input array is sorted, we can optimize our search from to using Binary Search. We can achieve this by reusing two fundamental Binary Search concepts: Lower Bound and Upper Bound.
Definitions
- Lower Bound (LB): The smallest index
isuch that . - Upper Bound (UB): The smallest index
isuch that .
The Mathematical Relationship
If the target element exists in the array:
- The first occurrence of will always be at the index returned by the Lower Bound of .
- The last occurrence of will always be at the index immediately preceding the Upper Bound of (i.e., ).
4. Optimized Approach 1: Lower & Upper Bound Implementation
To implement this approach, we first define our helper functions for Lower Bound and Upper Bound, and then handle the edge cases where the element does not exist in the array.
Critical Edge Cases
- Element is larger than any element in the array: The Lower Bound will point to the hypothetical index . We cannot access .
- Element is absent but falls within the array's range: The Lower Bound will point to the first element greater than . For example, if we search for in the array above, the Lower Bound points to index (value ). Since , the element is absent.
Thus, the element is present if and only if:
Code Implementation (C++)
#include <vector>
#include <utility>
// Returns the smallest index where arr[index] >= x
int getLowerBound(const std::vector<int>& arr, int n, int x) {
int low = 0, high = n - 1;
int ans = n;
while (low <= high) {
int mid = low + (high - low) / 2;
if (arr[mid] >= x) {
ans = mid;
high = mid - 1; // Look for smaller index on the left
} else {
low = mid + 1; // Look on the right
}
}
return ans;
}
// Returns the smallest index where arr[index] > x
int getUpperBound(const std::vector<int>& arr, int n, int x) {
int low = 0, high = n - 1;
int ans = n;
while (low <= high) {
int mid = low + (high - low) / 2;
if (arr[mid] > x) {
ans = mid;
high = mid - 1; // Look for smaller index on the left
} else {
low = mid + 1; // Look on the right
}
}
return ans;
}
std::pair<int, int> firstAndLastOccurrence(const std::vector<int>& arr, int n, int x) {
int lb = getLowerBound(arr, n, x);
// Check if element is not present
if (lb == n || arr[lb] != x) {
return {-1, -1};
}
int ub = getUpperBound(arr, n, x);
return {lb, ub - 1};
}
Complexity Analysis
- Time Complexity: because we perform two independent binary searches.
- Space Complexity: as no extra memory is allocated.
5. Optimized Approach 2: Raw Binary Search from Scratch
Some interviewers may request that you write the binary search logic directly without relying on Lower Bound or Upper Bound abstractions. We can write two distinct, clean binary search routines: one for the first occurrence and one for the last occurrence.
1. First Occurrence Logic
- If
arr[mid] == X:- This is a potential candidate for the first occurrence. Record
first = mid. - Since we want the first occurrence, we must continue searching the left half to see if appears earlier. Thus, we eliminate the right half:
high = mid - 1.
- This is a potential candidate for the first occurrence. Record
- If
arr[mid] < X: Search the right half (low = mid + 1). - If
arr[mid] > X: Search the left half (high = mid - 1).
2. Last Occurrence Logic
- If
arr[mid] == X:- This is a potential candidate for the last occurrence. Record
last = mid. - Since we want the last occurrence, we must continue searching the right half to see if appears later. Thus, we eliminate the left half:
low = mid + 1.
- This is a potential candidate for the last occurrence. Record
- If
arr[mid] < X: Search the right half (low = mid + 1). - If
arr[mid] > X: Search the left half (high = mid - 1).
Optimization Tip: If the first occurrence search returns
-1, the element does not exist in the array. We can immediately return[-1, -1]without running the second binary search, saving unnecessary operations.
6. Visualizing the Binary Search Traversal
Let's trace the raw binary search for the first occurrence of in the array:
First Occurrence Trace
7. Complete Raw Binary Search Code
Below is the clean, decoupled implementation of the raw binary search approach.
#include <vector>
#include <utility>
int findFirst(const std::vector<int>& arr, int n, int x) {
int low = 0, high = n - 1;
int first = -1;
while (low <= high) {
int mid = low + (high - low) / 2;
if (arr[mid] == x) {
first = mid;
high = mid - 1; // Look for earlier occurrences on the left
} else if (arr[mid] < x) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return first;
}
int findLast(const std::vector<int>& arr, int n, int x) {
int low = 0, high = n - 1;
int last = -1;
while (low <= high) {
int mid = low + (high - low) / 2;
if (arr[mid] == x) {
last = mid;
low = mid + 1; // Look for later occurrences on the right
} else if (arr[mid] < x) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return last;
}
std::pair<int, int> firstAndLastOccurrenceRaw(const std::vector<int>& arr, int n, int x) {
int first = findFirst(arr, n, x);
if (first == -1) {
return {-1, -1}; // Optimization: If first doesn't exist, last won't either
}
int last = findLast(arr, n, x);
return {first, last};
}
8. Problem 2: Counting Occurrences in a Sorted Array
Once we have solved the first and last occurrence problem, counting the total occurrences of becomes a trivial constant-time calculation.
The Formula
If the element exists in the array:
If the element does not exist (i.e., first == -1), the count is .
Code Implementation (C++)
#include <vector>
#include <utility>
int countOccurrences(const std::vector<int>& arr, int n, int x) {
std::pair<int, int> occurrences = firstAndLastOccurrenceRaw(arr, n, x);
if (occurrences.first == -1) {
return 0;
}
return occurrences.second - occurrences.first + 1;
}
9. Performance & Complexity Comparison
Summary and Key Takeaways
- Leverage Sorted Properties: Whenever you need to search or count elements in a sorted array, always think of Binary Search () instead of Linear Search ().
- Lower Bound vs. Upper Bound:
- The first occurrence of a target is always at .
- The last occurrence of a target is always at .
- Edge Case Validation: Always check if the Lower Bound index is valid (i.e., ) and that the element at that index actually matches the target () before assuming the element is present.
- Early Exit Optimization: When searching for first and last occurrences, if the first occurrence returns
-1, skip the search for the last occurrence entirely to save operations. - The Counting Formula: The total count of an element in a sorted array is calculated as . This reduces the counting problem to a simple coordinate subtraction after finding the boundaries.