C++ STL Algorithms Basics: Replacing Hand-Written Loops with sort, find_if, transform and accumulate
Introduction: Manual loops bred bugs
Hand-rolled bubble sort, linear search, and sum loops are error-prone (off-by-one, invalid iterators). STL algorithms take half-open ranges [first, last) and a predicate or operation—usually clearer and well-tested. STL rewrite of the naive example:
#include <algorithm>
#include <numeric>
std::vector<int> vec = {5, 2, 8, 1, 9};
std::sort(vec.begin(), vec.end());
auto it = std::find(vec.begin(), vec.end(), 8);
int index = (it != vec.end()) ? static_cast<int>(std::distance(vec.begin(), it)) : -1;
int sum = std::accumulate(vec.begin(), vec.end(), 0);
flowchart TB
subgraph problem[Common mistakes]
P1[Manual loops → index bugs]
P2["Sorted data but linear find"]
P3[remove without erase]
P4[Wrong comparator]
end
subgraph solution[Fix]
S1[STL algorithms]
S2[lower_bound / binary_search]
S3[erase-remove idiom]
S4[Strict weak ordering]
end
P1 --> S1
P2 --> S2
P3 --> S3
P4 --> S4
Problem scenarios
- Big data sorted with O(n²) → use std::sort O(n log n)
- Sorted range search with
find→ use lower_bound O(log n) - Counting with manual loops → count_if
removewithouterase→ size unchanged → erase-remove- Product with
accumulate(..., 0, multiplies)— wrong; use initial value1for products
sort
std::sort(vec.begin(), vec.end());
std::sort(vec.begin(), vec.end(), std::greater<int>());
Custom comparator: strict weak ordering—typically return a < b for ascending. stable_sort preserves relative order of equal elements.
std::sort is required to run in O(n log n) comparisons (since C++11; implementations use introsort, a quicksort that falls back to heapsort when recursion gets too deep, plus insertion sort for small partitions). It needs random-access iterators, which is why std::sort(list.begin(), list.end()) on a std::list fails to compile with a long template error about operator- — use the member function list.sort() instead.
The strict-weak-ordering rule is not pedantry. The comparator must return false for comp(a, a), and if a is “less than” b then b must not be “less than” a. A comparator written as return a <= b; breaks the first rule. With small inputs it often seems to work, which is exactly what makes it dangerous: on larger inputs with many equal elements, introsort’s unguarded partition loop can walk past the end of the buffer, and the result is a crash or heap corruption far from the call site. Debug builds of MSVC catch this with an “invalid comparator” assertion; libstdc++ with -D_GLIBCXX_DEBUG does too. When I sort structs by several keys I reach for std::tie so the ordering is lexicographic and automatically correct:
std::sort(people.begin(), people.end(), [](const Person& a, const Person& b) {
return std::tie(a.last, a.first) < std::tie(b.last, b.first);
});
stable_sort costs more (it usually allocates a temporary buffer and runs merge sort), so use it only when order among equal keys matters — for example, sorting a table by “department” after it was already sorted by “hire date” and wanting the hire-date order to survive within each department. If you only need the top k elements, std::partial_sort or std::nth_element avoid sorting the whole range.
find
- find: linear search for value
- find_if: first element satisfying predicate
- Sorted range: binary_search, lower_bound, upper_bound
find returns an iterator, not an index or a bool. When nothing matches it returns the last iterator you passed in, so the only valid test is it != vec.end(). Dereferencing end() is undefined behavior; in practice it reads whatever memory sits after the last element, so the program may print a garbage number instead of crashing, which makes the bug easy to miss in tests.
binary_search only answers “is it there?”. If you need the position, use lower_bound, which returns the first element not less than the value; you still have to check both it != end() and *it == value, because lower_bound happily returns the insertion point for a missing value. upper_bound returns the first element greater than the value, so upper_bound - lower_bound is the number of copies, and std::equal_range gives you both in one call.
For associative containers, prefer the member functions: std::find(set.begin(), set.end(), x) is a linear walk over a tree, while set.find(x) is O(log n) and unordered_set::find is O(1) on average. The free function compiles either way, so this mistake only shows up in profiles.
count and accumulate
int n2 = std::count(vec.begin(), vec.end(), 2);
int evens = std::count_if(vec.begin(), vec.end(), [](int x){ return x % 2 == 0; });
int sum = std::accumulate(vec.begin(), vec.end(), 0);
int prod = std::accumulate(vec.begin(), vec.end(), 1, std::multiplies<int>());
String concatenation: start from std::string(), not "" as const char* (avoid subtle issues).
The type of the result of accumulate is the type of the initial value, not the element type. This is the most common surprise with the function. std::accumulate(doubles.begin(), doubles.end(), 0) sums into an int, truncating every partial sum, so {0.5, 0.5, 0.5} gives 0. Likewise, summing a large vector<int> into 0 can overflow where 0LL would not. With strings, an initial value of "" is a const char*, and const char* + std::string does compile via the std::string overload, but accumulate(words.begin(), words.end(), "") fails because the accumulator must be assignable from std::string — the fix is std::string{}.
count and count_if return std::ptrdiff_t (the iterator’s difference type), not int; storing into int is fine for small data but triggers sign-conversion warnings with -Wconversion. In C++17 and later, std::reduce is the parallel-friendly cousin of accumulate: it may reorder operations, so it is only correct for associative and commutative operations — summing floating-point values with it can give slightly different results from run to run under a parallel execution policy.
transform
Unary: transform each element to output range. In-place: output vec.begin(). Binary: combine two sequences element-wise (same length discipline).
Algorithms never change a container’s size. They see only iterators, and an iterator cannot call push_back. So std::transform(src.begin(), src.end(), dst.begin(), f) into an empty dst writes past the end of its buffer — a silent memory corruption, not a compile error. You have two correct options: size the destination first (std::vector<int> dst(src.size());), or pass std::back_inserter(dst), an output iterator whose assignment calls push_back. The first is faster for trivial types; the second is necessary when you do not know the output size ahead of time, and pairing it with dst.reserve(src.size()) avoids repeated reallocation.
The same “algorithms do not resize” rule explains remove: it shifts the kept elements forward and returns the new logical end, but the tail still holds leftover values and size() is unchanged. That is why remove must be followed by erase(new_end, end()). The first time I ported a loop to std::remove_if, I checked size() afterwards, saw the old number, and assumed the predicate was wrong — the predicate was fine, I had simply forgotten the erase. Since C++20, std::erase_if(vec, pred) does both steps in one call and is the version I now reach for.
Examples
A self-contained pass that combines sort, find, count, transform, and the erase-remove idiom:
#include <algorithm>
#include <numeric>
#include <vector>
#include <iostream>
int main() {
std::vector<int> scores = {72, 95, 48, 88, 61, 95, 30};
// sort
std::sort(scores.begin(), scores.end());
// find (sorted, so binary_search/lower_bound are valid here)
bool has95 = std::binary_search(scores.begin(), scores.end(), 95);
// count_if: how many passing scores (>= 60)
int passing = std::count_if(scores.begin(), scores.end(),
[](int s) { return s >= 60; });
// transform: curve every score by +5, capped at 100
std::vector<int> curved(scores.size());
std::transform(scores.begin(), scores.end(), curved.begin(),
[](int s) { return std::min(100, s + 5); });
// accumulate: average of the curved scores
double avg = std::accumulate(curved.begin(), curved.end(), 0.0) / curved.size();
// erase-remove: drop any score below 40 from the original list
scores.erase(std::remove_if(scores.begin(), scores.end(),
[](int s) { return s < 40; }),
scores.end());
std::cout << "has95=" << has95 << " passing=" << passing
<< " avg=" << avg << " remaining=" << scores.size() << "\n";
return 0;
}
Each step reuses the same half-open-range convention (begin()/end()), so no index bookkeeping is needed between steps — the output of one algorithm feeds directly into the next.
Common errors
- Dereference find result without != end()
- remove only—must erase from
new_endtoend() - transform output range too small—size or back_inserter
- Product
accumulatewith initial 0 - lower_bound on unsorted data → meaningless
- Comparator <= for sort → not a strict weak ordering
Best practices
- Use const Person& in predicates for large structs
- reserve before back_inserter when size known
- Check is_sorted before binary search if unsure
Production patterns
- erase-remove / erase-remove_if
- sort + unique + erase for duplicates
- minmax_element for min and max in one pass
- merge on sorted inputs
Checklist
- Sorted? → binary search APIs
-
remove→ pairederase -
find→ checkend -
accumulateproduct → init1
FAQ
Default toolkit?
A. sort, find/find_if, count_if, accumulate, transform cover most loops.
Sorted vector vs set?
A. Many searches, few inserts: sorted vector + binary search can be faster/more cache-friendly. Frequent inserts/erases: set/map.
C++20 ranges?
A. std::ranges::sort(vec) and friends reduce iterator noise—see cppreference.
The ranges versions are more than shorter spelling. They are constrained with concepts, so passing a std::list to std::ranges::sort produces a short “constraints not satisfied: random_access_range” diagnostic instead of pages of template noise. They also accept projections: std::ranges::sort(people, {}, &Person::age) sorts by age without writing a comparator lambda, and std::ranges::find(people, "Kim", &Person::last) searches by one field. One behavioral difference to know: std::ranges::find on a temporary (for example std::ranges::find(make_vector(), 3)) returns std::ranges::dangling instead of an iterator into a destroyed vector, which turns a use-after-free into a compile error.
When is a plain loop still the better choice?
A. When the loop body does several unrelated things per element, has early exits based on state gathered along the way, or needs the index for something other than addressing the element. Forcing that logic into for_each with a lambda that captures five variables by reference is harder to read than the loop it replaced. The rule I follow is: if the loop’s intent has a name in <algorithm> (find the first, count the matching, copy the ones that, is any of them), use the algorithm; if describing the loop takes a sentence with “and then”, keep the loop.
Prefer STL algorithms over ad-hoc loops; pair remove with erase, and use lower_bound on sorted data.
Previous: vector basics