C++ Big-O Notation
Big-O notation is a mathematical shorthand for describing how the running time or memory usage of an algorithm grows as its input size grows. Instead of measuring exact seconds or bytes, which change depending on hardware and compiler, Big-O focuses on the shape of that growth as the input size n gets large. Every C++ programmer needs it to answer a simple but critical question: will this code still be fast when the input has a million items instead of ten? Understanding Big-O lets you choose the right container, algorithm, and loop structure before performance becomes a real problem.
Overview: How Big-O Works
Big-O notation counts the number of basic operations an algorithm performs as a function of the input size n, then describes how that count grows in the worst case as n approaches infinity. It deliberately ignores two things: constant factors (whether an operation takes 1 nanosecond or 100 nanoseconds) and lower-order terms (a term like 3n is dominated by n^2 once n is large, so we drop the 3n). What remains is the growth rate — the trend that actually determines whether an algorithm scales.
For example, an algorithm that performs 5n + 20 operations and one that performs n operations are both described as O(n), because as n grows, the difference between them becomes insignificant compared to how fast both grow relative to a quadratic or exponential algorithm. This abstraction is what makes Big-O useful: it lets you compare algorithms independent of the specific machine, compiler optimizations, or programming language.
Common Complexity Classes
| Notation | Name | Typical Example |
|---|---|---|
| O(1) | Constant | Accessing vector[i] or a hash map lookup |
| O(log n) | Logarithmic | Binary search on a sorted array |
| O(n) | Linear | A single loop over all elements |
| O(n log n) | Linearithmic | Efficient sorting: std::sort, merge sort |
| O(n^2) | Quadratic | Nested loops comparing every pair: bubble sort |
| O(2^n) | Exponential | Naive recursive Fibonacci, generating all subsets |
As n grows, these classes separate dramatically. For n = 1,000,000, an O(n) algorithm does about a million operations, an O(n log n) algorithm does about 20 million, but an O(n^2) algorithm does a trillion — the difference between milliseconds and hours.
Best, Worst, and Average Case
Big-O typically describes the worst case unless stated otherwise. A linear search for a value that happens to be first takes O(1) time (best case), but Big-O reports O(n) because that is the guarantee across all possible inputs. You may also see Big-Omega (Ω) for best-case lower bounds and Big-Theta (Θ) when the best and worst case match, but in everyday engineering conversation, “Big-O” is used loosely to mean “how does this scale in the worst realistic case.”
Syntax
Big-O is not C++ syntax — it is written as O(f(n)) where f(n) is a function describing the operation count. In code, you determine it by counting how loops and recursive calls scale with the input size:
int n = 10;
int count = 0;
for (int i = 0; i <n; ++i) { // runs n times: contributes O(n)
count++; // O(1) work inside the loop
}
std::cout << "Operations performed: " << count << std::endl;
Output:
Operations performed: 10
Some rules for reading code and deriving its complexity:
- Sequential statements add: a loop of O(n) followed by another loop of O(n) is O(n) + O(n) = O(2n), which simplifies to O(n).
- Nested loops multiply: a loop of O(n) containing a loop of O(n) is O(n) × O(n) = O(n^2).
- Drop constants: O(3n) becomes O(n); O(n/2) also becomes O(n).
- Keep only the dominant term: O(n^2 + n) becomes O(n^2), because it dominates for large n.
- Halving the input each step suggests O(log n): binary search, and any “divide the problem in half” algorithm.
Examples
Example 1: O(1) Access vs. O(n) Search
#include <iostream>
#include <vector>
int main() {
std::vector<int> scores = {88, 95, 72, 61, 99, 45, 80};
// O(1): direct index access - one multiplication and one addition
std::cout << "Score at index 3 (O(1) access): " << scores[3] << std::endl;
// O(n): linear search - must check indices one-by-one until found
int target = 99;
int foundIndex = -1;
for (std::size_t i = 0; i < scores.size(); ++i) {
if (scores[i] == target) {
foundIndex = static_cast<int>(i);
break;
}
}
if (foundIndex != -1) {
std::cout << "Found " << target << " at index " << foundIndex << " (O(n) search)" << std::endl;
} else {
std::cout << target << " not found" << std::endl;
}
return 0;
}
Output:
Score at index 3 (O(1) access): 61
Found 99 at index 4 (O(n) search)
Reading scores[3] takes exactly the same amount of work no matter how large the vector is — that is O(1). Finding 99, however, requires scanning up to every element, so the work grows linearly with the vector’s size — that is O(n).
Example 2: O(n^2) Bubble Sort
#include <iostream>
#include <vector>
int main() {
std::vector<int> data = {29, 10, 14, 37, 13};
int n = static_cast<int>(data.size());
// Bubble sort: two nested loops -> O(n^2) comparisons in the worst case
for (int pass = 0; pass < n - 1; ++pass) {
for (int j = 0; j < n - 1 - pass; ++j) {
if (data[j] > data[j + 1]) {
std::swap(data[j], data[j + 1]);
}
}
}
std::cout << "Sorted: ";
for (int value : data) {
std::cout << value << " ";
}
std::cout << std::endl;
return 0;
}
Output:
Sorted: 10 13 14 29 37
The outer loop runs roughly n times, and for each pass the inner loop also runs roughly n times, giving about n × n = n^2 comparisons. This is why bubble sort (and other simple sorts) becomes painfully slow on large datasets, even though it is easy to write.
Example 3: O(log n) Binary Search
#include <iostream>
#include <vector>
int binarySearch(const std::vector<int>& sorted, int target) {
int low = 0;
int high = static_cast<int>(sorted.size()) - 1;
int steps = 0;
while (low <= high) {
++steps;
int mid = low + (high - low) / 2;
if (sorted[mid] == target) {
std::cout << "Found " << target << " in " << steps << " steps" << std::endl;
return mid;
} else if (sorted[mid] < target) {
low = mid + 1;
} else {
high = mid - 1;
}
}
std::cout << target << " not found after " << steps << " steps" << std::endl;
return -1;
}
int main() {
std::vector<int> sorted = {2, 5, 8, 12, 16, 23, 38, 45, 56, 72, 91};
binarySearch(sorted, 56);
binarySearch(sorted, 100);
return 0;
}
Output:
Found 56 in 2 steps
100 not found after 4 steps
Each iteration of binary search throws away half of the remaining elements, so the number of steps needed grows only with log2(n). Searching 11 elements never takes more than 4 steps, and searching a billion elements would take at most about 30 steps — this is why sorted data structures paired with binary search are so powerful.
Under the Hood
The compiler and CPU know nothing about “Big-O” — it is purely a way for you to reason about an algorithm’s shape before it runs. What actually happens under the hood is that each loop iteration, comparison, and memory access takes real (small, roughly constant) time, and Big-O is the abstraction that counts how many of those operations occur relative to n.
Two subtleties matter in real C++ code:
- Amortized complexity:
std::vector::push_backis described as O(1) amortized, not strictly O(1). Internally, when the vector’s capacity is full, it allocates a new, larger buffer (typically doubling) and copies every existing element — an O(n) operation. But because this only happens occasionally (capacity doubles each time), the average cost per push_back across many calls works out to O(1). - Space complexity: Big-O also describes memory. An algorithm that needs an extra array of size
nis O(n) space; one that only uses a few extra variables is O(1) space. Recursion also consumes O(depth) space on the call stack, which is easy to overlook.
Container choice directly affects real-world complexity: std::vector gives O(1) indexed access but O(n) insertion in the middle; std::unordered_map gives average O(1) lookup and insertion (via hashing) but O(n) worst case if many keys collide; std::map gives guaranteed O(log n) operations because it is a balanced binary search tree internally.
Common Mistakes
Mistake 1: Using a Nested Loop When a Sort Would Do
A common beginner pattern for detecting duplicates compares every element to every other element, which is O(n^2):
#include <iostream>
#include <vector>
int main() {
std::vector<int> nums = {4, 2, 7, 2, 9, 4};
bool hasDuplicate = false;
// O(n^2): every element is compared against every other element
for (std::size_t i = 0; i < nums.size(); ++i) {
for (std::size_t j = 0; j < nums.size(); ++j) {
if (i != j && nums[i] == nums[j]) {
hasDuplicate = true;
}
}
}
std::cout << (hasDuplicate ? "Duplicates found" : "No duplicates") << std::endl;
return 0;
}
Output:
Duplicates found
This works, but it is unnecessarily slow for large vectors. Sorting first reduces the total work to O(n log n):
#include <iostream>
#include <vector>
#include <algorithm>
int main() {
std::vector<int> nums = {4, 2, 7, 2, 9, 4};
// O(n log n): sort first, then a single O(n) pass checks neighbors
std::sort(nums.begin(), nums.end());
bool hasDuplicate = false;
for (std::size_t i = 1; i < nums.size(); ++i) {
if (nums[i] == nums[i - 1]) {
hasDuplicate = true;
break;
}
}
std::cout << (hasDuplicate ? "Duplicates found" : "No duplicates") << std::endl;
return 0;
}
Output:
Duplicates found
Both versions produce the same result, but the sorted version scales far better as the input grows.
Mistake 2: Inserting at the Front of a Vector in a Loop
Because std::vector stores elements contiguously, inserting at the beginning forces every existing element to shift over — an O(n) operation. Doing it in a loop makes the whole loop O(n^2):
#include <iostream>
#include <vector>
int main() {
std::vector<int> result;
// O(n^2): inserting at the front shifts every existing element each time
for (int i = 1; i <= 5; ++i) {
result.insert(result.begin(), i);
}
for (int value : result) {
std::cout << value << " ";
}
std::cout << std::endl;
return 0;
}
Output:
5 4 3 2 1
The fix is to append at the back, which is O(1) amortized, and reverse once at the end if order matters:
#include <iostream>
#include <vector>
#include <algorithm>
int main() {
std::vector<int> result;
// O(n) amortized total: push_back never shifts existing elements
for (int i = 1; i <= 5; ++i) {
result.push_back(i);
}
std::reverse(result.begin(), result.end());
for (int value : result) {
std::cout << value << " ";
}
std::cout << std::endl;
return 0;
}
Output:
5 4 3 2 1
Same output, but the second version does a total of O(n) work instead of O(n^2), which matters enormously once the vector holds thousands or millions of elements.
Best Practices
- Always think about how your data size will grow in production, not just in your test with 5 elements — an O(n^2) algorithm can look fine in a demo and fail in production.
- Prefer
std::sort,std::unordered_map, andstd::unordered_setfor O(n log n) or average O(1) solutions instead of hand-written nested loops. - Remember that Big-O hides constants: an O(n) algorithm with heavy per-element work can be slower than an O(n log n) algorithm for small or moderate n. Measure with real timing (
std::chrono) when it matters. - Watch for hidden costs in standard library calls:
vector::insertat the front,stringconcatenation in loops without reserving capacity, and repeated linear searches inside another loop. - Consider both time and space complexity — an algorithm that trades memory for speed (like using a hash set) is often the right choice, but not always if memory is constrained.
- When in doubt, count the loops: sequential loops add complexities, nested loops multiply them, and halving-the-input patterns suggest logarithmic growth.
Practice Exercises
Exercise 1: For each of the following, state the Big-O complexity in terms of n: (a) a single for loop from 0 to n; (b) two separate, non-nested for loops each from 0 to n; (c) a for loop from 0 to n containing another for loop from 0 to n; (d) a while loop that divides a value by 2 each iteration until it reaches 1.
Exercise 2: Write a function int findMax(const std::vector<int>& v) that returns the largest value in a vector using a single loop. What is its time complexity? What is its space complexity?
Exercise 3: Rewrite a nested-loop function that checks whether any two numbers in a vector sum to a target value (O(n^2)) so that it runs in O(n) using a std::unordered_set<int> to remember values you have already seen. Test it on {2, 7, 11, 15} with target 9.
Summary
- Big-O notation describes how an algorithm’s running time or memory use grows as the input size
ngrows, ignoring constants and lower-order terms. - Common classes, from fastest to slowest growth, include O(1), O(log n), O(n), O(n log n), O(n^2), and O(2^n).
- Sequential code blocks add their complexities; nested loops multiply theirs.
- Big-O usually describes the worst case; amortized analysis (like
vector::push_back) accounts for occasionally expensive operations averaged over many calls. - Choosing the right container and algorithm — sorting, hashing, binary search — often turns an O(n^2) solution into an O(n) or O(n log n) one.
- Big-O hides constant factors, so always consider real-world performance for your actual data sizes, not just the asymptotic trend.
