Skip to main content
C++ intermediate Lesson 12 of 23

STL Algorithms in C++

Use sort, find, transform, accumulate, and C++20 ranges and views for expressive, efficient data processing.

STL Algorithms in C++

The <algorithm> header provides over 100 generic algorithms that work on any container through iterators. They let you express what you want to do — sort, find, transform — without spelling out how to do it with explicit loops. This benefits readability, correctness (these algorithms are battle-tested), and performance (some implementations use SIMD internally). C++20 Ranges take this further by making algorithms composable and eliminating most of the boilerplate.

Sorting

Sorting is one of the most common operations in real code, and the STL gives you several variants. std::sort is an introsort (O(n log n) worst case), std::stable_sort preserves the relative order of equal elements (useful for multi-key sorting), and custom comparators let you sort by any criterion without writing a separate comparator struct.

#include <algorithm>
#include <vector>
#include <string>
#include <iostream>

int main() {
    std::vector<int> v{5, 2, 8, 1, 9, 3};

    // Default: ascending order using operator<
    std::sort(v.begin(), v.end());
    // v: {1, 2, 3, 5, 8, 9}

    // Custom comparator: descending — any callable works here
    std::sort(v.begin(), v.end(), std::greater<int>{});
    // v: {9, 8, 5, 3, 2, 1}

    // Sort strings by length — lambda comparator is idiomatic
    std::vector<std::string> words{"banana", "fig", "apple", "kiwi"};
    std::sort(words.begin(), words.end(),
              [](const auto& a, const auto& b) { return a.size() < b.size(); });
    // words: {"fig", "kiwi", "apple", "banana"}

    // stable_sort preserves relative order of equal elements — important for multi-key sort
    std::stable_sort(words.begin(), words.end(),
                     [](const auto& a, const auto& b) { return a.size() < b.size(); });
}

Searching

Choosing the right search algorithm matters for performance. std::find is a linear scan for unsorted data. std::binary_search, std::lower_bound, and std::upper_bound require sorted data but run in O(log n) — use them whenever you can sort once and search many times.

#include <algorithm>
#include <vector>

std::vector<int> v{1, 3, 5, 7, 9, 11};

// Linear search: O(n) — works on any range, sorted or not
auto it = std::find(v.begin(), v.end(), 7);
if (it != v.end())
    std::cout << "Found: " << *it << "\n";

// Predicate search — find first element satisfying a condition
auto it2 = std::find_if(v.begin(), v.end(),
                        [](int x) { return x > 6; });
// *it2 == 7

// Binary search on sorted range: O(log n) — requires sorted data
bool found = std::binary_search(v.begin(), v.end(), 5); // true

// lower_bound: first element >= value — useful for insertion point
auto lb = std::lower_bound(v.begin(), v.end(), 6);  // points to 7
// upper_bound: first element > value — used with lower_bound to get a range
auto ub = std::upper_bound(v.begin(), v.end(), 7);  // points to 9

Counting

Counting with algorithms is clearer than a manual loop because it separates the predicate logic from the counting mechanism, and the intent is immediately obvious from the function name.

#include <algorithm>
#include <vector>

std::vector<int> v{1, 2, 3, 2, 4, 2, 5};

// Count exact matches
int twos = std::count(v.begin(), v.end(), 2);       // 3

// Count by predicate — count elements satisfying a condition
int evens = std::count_if(v.begin(), v.end(),
                          [](int x) { return x % 2 == 0; }); // 4

Transforming

std::transform applies a function to each element and writes the result to an output range. It is more expressive than a raw loop because it clearly states “I am transforming one range into another,” and the source and destination can be the same container for in-place transformation.

#include <algorithm>
#include <vector>
#include <string>
#include <cctype>

std::vector<int> src{1, 2, 3, 4, 5};
std::vector<int> dst(src.size());

// Unary transform: apply function to each element, write results to dst
std::transform(src.begin(), src.end(), dst.begin(),
               [](int x) { return x * x; });
// dst: {1, 4, 9, 16, 25}

// Binary transform: element-wise operation on two ranges
std::vector<int> a{1, 2, 3}, b{10, 20, 30}, c(3);
std::transform(a.begin(), a.end(), b.begin(), c.begin(),
               [](int x, int y) { return x + y; });
// c: {11, 22, 33}

// In-place: uppercase a string — same container as source and destination
std::string s = "hello";
std::transform(s.begin(), s.end(), s.begin(),
               [](unsigned char c) { return std::toupper(c); });
// s: "HELLO"

Accumulate and Reduce

std::accumulate is the general fold operation: it combines all elements with a binary operation, starting from an initial value. It is the right tool for sums, products, joins, and any aggregation. std::reduce (C++17) is a parallelizable version that requires the operation to be commutative and associative.

#include <numeric>
#include <vector>
#include <string>
#include <execution>

std::vector<int> v{1, 2, 3, 4, 5};

// Sequential sum — the initial value (0) determines the return type
int total = std::accumulate(v.begin(), v.end(), 0);  // 15

// Custom binary op: product — accumulate with any binary operation
int product = std::accumulate(v.begin(), v.end(), 1,
                               [](int acc, int x) { return acc * x; }); // 120

// Join strings — accumulate works on any type with the right operator
std::vector<std::string> words{"one", "two", "three"};
std::string joined = std::accumulate(
    std::next(words.begin()), words.end(), words[0],
    [](const std::string& a, const std::string& b) { return a + ", " + b; });
// "one, two, three"

// C++17 std::reduce — like accumulate but operation must be commutative/associative
// Allows parallel execution across multiple cores
int sum = std::reduce(std::execution::par, v.begin(), v.end(), 0);

Erase-Remove Idiom

std::remove does not actually erase elements from the container — it shifts the “kept” elements to the front and returns an iterator to the new logical end, leaving the tail in a valid-but-unspecified state. You must pair it with container::erase to truly remove elements. This two-step design lets the algorithm work on any range, not just containers with an erase method.

#include <algorithm>
#include <vector>

std::vector<int> v{1, 2, 3, 2, 4, 2, 5};

// Step 1: remove shifts kept elements to front, returns new logical end
auto newEnd = std::remove(v.begin(), v.end(), 2);
// Step 2: erase the tail — now v is {1, 3, 4, 5}
v.erase(newEnd, v.end());

// Remove by predicate — one-liner combining both steps
v.erase(std::remove_if(v.begin(), v.end(),
                       [](int x) { return x % 2 != 0; }),
        v.end());

C++20 adds std::erase and std::erase_if free functions that handle both steps in one call:

std::vector<int> v{1, 2, 3, 2, 4, 2, 5};
std::erase(v, 2);                                        // remove all 2s
std::erase_if(v, [](int x) { return x % 2 != 0; });     // remove odd numbers

Partition

Partitioning rearranges a range so all elements satisfying a predicate come before those that don’t. It is useful for separating data into two groups without sorting, and it is an O(n) operation rather than O(n log n).

#include <algorithm>
#include <vector>

std::vector<int> v{5, 1, 8, 2, 9, 3, 7, 4, 6};

// Partition: elements satisfying predicate go first — relative order NOT preserved
auto mid = std::partition(v.begin(), v.end(),
                          [](int x) { return x % 2 == 0; });
// [8,2,4,6 | 5,1,9,3,7] (order within groups may vary)

// stable_partition preserves relative order within each group
std::stable_partition(v.begin(), v.end(),
                      [](int x) { return x % 2 == 0; });
// [2,8,4,6 | 1,3,5,7,9]

C++20 Ranges

The Ranges library replaces the verbose .begin()/.end() iterator-pair interface with a cleaner model where algorithms take a range directly. This eliminates the most common boilerplate in algorithm calls and integrates naturally with std::views.

#include <ranges>
#include <algorithm>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v{5, 2, 8, 1, 9, 3};

    // No more .begin()/.end() pairs — just pass the container
    std::ranges::sort(v);

    auto it = std::ranges::find(v, 8);
    std::cout << *it << "\n"; // 8
}

C++20 Views — Lazy Composition

Views are range adaptors that transform or filter a range lazily — no intermediate containers are created. They compose with the pipe operator |, creating readable data processing pipelines. Because they are lazy, you pay only for what you consume: a pipeline ending in take(3) processes at most 3 elements from a million-element range.

#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v{1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    // Filter evens, square them, take first 3 — all lazy, zero intermediate allocations
    auto pipeline = v
        | std::views::filter([](int x) { return x % 2 == 0; })
        | std::views::transform([](int x) { return x * x; })
        | std::views::take(3);

    for (int n : pipeline)
        std::cout << n << " ";   // 4 16 36
    std::cout << "\n";

    // Generate an infinite sequence — only first 5 are ever computed
    auto squares = std::views::iota(1)
                 | std::views::transform([](int x) { return x * x; })
                 | std::views::take(5);
    // 1 4 9 16 25
}

Views that produce elements only when iterated include filter, transform, take, drop, reverse, keys, values, zip (C++23), and join. Because they are lazy, you pay only for what you consume.

Parallel Execution Policies (C++17)

Several algorithms accept an execution policy as their first argument, enabling parallel or vectorized execution with a single-word change. This is one of the easiest ways to use multiple CPU cores in C++.

#include <algorithm>
#include <execution>
#include <vector>

std::vector<int> v(1'000'000);
std::iota(v.begin(), v.end(), 0);

// Run on all available CPU cores — no threading code required
std::sort(std::execution::par, v.begin(), v.end());

// Hint for SIMD vectorization — single-threaded but wider operations
std::for_each(std::execution::unseq, v.begin(), v.end(),
              [](int& x) { x *= 2; });

Policies: seq (sequential, default), par (parallel), par_unseq (parallel + vectorized), unseq (vectorized only). The operation must be safe to execute concurrently when using par.

Frequently Asked Questions

Why use STL algorithms instead of raw loops?
Algorithms communicate intent, are often more efficient (SIMD-optimized in some implementations), and are easier to parallelize (std::execution policies).
What are C++20 views?
Lazy range adaptors that compose without creating intermediate containers, like filter | transform | take.