STL Algorithms

1. Introduction

1.1 Brief overview of the algorithms provided by STL

The Standard Template Library (STL) is a rich assembly of C++ template classes to provide general-purpose classes and functions with templates that implement many popular and commonly used algorithms and data structures. When we talk about the algorithms in STL, we're referring to a collection of functions specially designed to be used on a range of elements. These algorithms encompass a range of operations, including searching, sorting, modifying, and more.

1.2 Understanding the importance of iterators in STL algorithms:

Iterators are a fundamental concept in C++. They act like pointers and allow us to access elements within containers without worrying about the underlying structure of the container. Almost all STL algorithms require iterators as arguments, allowing these algorithms to be used with any STL container that supports the required iterator type. Think of iterators as a bridge between containers and algorithms. Without them, the generality and versatility of STL algorithms would be drastically reduced.


2. Non-modifying Sequence Operations

These are algorithms that inspect or read the content of sequences without changing them.

for_each: Apply a function to a range.

This algorithm takes in two iterators representing a range and a unary function, and it applies this function to each element in the range.

std::vector<int> nums = {1, 2, 3, 4};
std::for_each(nums.begin(), nums.end(), [](int &n) { std::cout << n << " "; });

count, count_if: Count occurrences.

count returns the number of elements that are equal to a specified value, while count_if counts elements that satisfy a predicate.

int n = std::count(nums.begin(), nums.end(), 2);  // Counts occurrences of 2
int m = std::count_if(nums.begin(), nums.end(), [](int x) { return x % 2 == 0; });  // Counts even numbers

find, find_if, find_if_not: Search elements.

find searches for an element, find_if searches based on a predicate, and find_if_not searches for elements that don't satisfy a predicate.

auto it = std::find(nums.begin(), nums.end(), 3);  // Finds 3 in nums

all_of, any_of, none_of: Check elements by condition.

These algorithms check elements based on a predicate. For instance, all_of returns true if all elements satisfy the predicate.

bool all_even = std::all_of(nums.begin(), nums.end(), [](int x) { return x % 2 == 0; });

mismatch: Find the first mismatching pair.

Compares two sequences and returns a pair of iterators pointing to the first mismatching elements.

std::vector<int> nums2 = {1, 2, 4, 4};
auto mm = std::mismatch(nums.begin(), nums.end(), nums2.begin());

equal: Compare range with another.

Checks if two sequences are the same.

bool same = std::equal(nums.begin(), nums.end(), nums2.begin());

search: Search range for a subsequence.

Searches for the first occurrence of the subsequence in the range.

std::vector<int> pattern = {2, 3};
auto it2 = std::search(nums.begin(), nums.end(), pattern.begin(), pattern.end());

find_end: Find last subsequence.

Similar to search, but finds the last occurrence of the subsequence.

auto it3 = std::find_end(nums.begin(), nums.end(), pattern.begin(), pattern.end());

adjacent_find: Find adjacent matching elements.

Returns an iterator pointing to the first of the two duplicate adjacent elements.

std::vector<int> nums3 = {1, 2, 2, 3};
auto it4 = std::adjacent_find(nums3.begin(), nums3.end());

3. Modifying Sequence Operations

These are algorithms that directly modify the content of sequences based on certain conditions or patterns.

copy, copy_if: Copy elements.

copy duplicates elements from one range to another, while copy_if does the same but only for elements satisfying a certain predicate.

std::vector<int> v1 = {1, 2, 3, 4, 5};
std::vector<int> v2(5);
std::copy(v1.begin(), v1.end(), v2.begin());

std::vector<int> v3(5);
std::copy_if(v1.begin(), v1.end(), v3.begin(), [](int x){ return x % 2 == 0; });  // Copies even numbers

move: Move elements.

Transfers elements from one range to another.

std::vector<std::string> strings1 = {"a", "b", "c"};
std::vector<std::string> strings2(3);
std::move(strings1.begin(), strings1.end(), strings2.begin());

swap, swap_ranges: Swap content.

swap exchanges the content of two elements, while swap_ranges swaps two ranges.

int a = 5, b = 10;
std::swap(a, b);

std::swap_ranges(v1.begin(), v1.end(), v2.begin());

fill, fill_n: Fill sequence.

Sets all elements in a range to a particular value.

std::fill(v1.begin(), v1.end(), 0);  // Fills entire vector with zeros

std::fill_n(v1.begin(), 3, 7);  // Fills first three elements with sevens

transform: Apply a function.

Applies a given function to a range and stores the result in another range.

std::transform(v1.begin(), v1.end(), v1.begin(), [](int x){ return x * 2; });  // Doubles each element

generate, generate_n: Assign value generated by function.

Fills a range with values produced by a generator function.

int current = 1;
std::generate(v1.begin(), v1.end(), [&current](){ return current++; });  // Fills with sequence 1, 2, ...

std::generate_n(v1.begin(), 3, [&current](){ return current += 2; });  // Fills first three elements with values 3, 5, 7

remove, remove_if: Remove elements.

Removes certain elements from a range. This doesn't change the size of the container but returns an iterator pointing past the last valid element. This 2-step process is a common idiom in the STL.

auto it = std::remove(v1.begin(), v1.end(), 3);  // Removes all threes
v1.erase(it, v1.end());

auto it2 = std::remove_if(v1.begin(), v1.end(), [](int x){ return x % 2 == 0; });  // Removes even numbers
v1.erase(it2, v1.end());

In C++20, we can use std::erase or std::erase_if to remove elements in a single line:

std::erase(v1, 3);  // Removes all threes
std::erase_if(v1, [](int x){ return x % 2 == 0; });  // Removes even numbers

replace, replace_if: Replace elements.

Substitutes elements in a range.

std::replace(v1.begin(), v1.end(), 2, 8);  // Replaces all twos with eights

std::replace_if(v1.begin(), v1.end(), [](int x){ return x < 5; }, 0);  // Replaces numbers less than 5 with zero

reverse: Reverse elements.

Reverses the order of elements in a range.

std::reverse(v1.begin(), v1.end());

rotate: Rotate elements left.

Rotates the elements in the range to the left.

std::rotate(v1.begin(), v1.begin() + 2, v1.end());  // Rotates left by two positions

shuffle: Randomly rearrange elements.

Rearranges the elements randomly.

std::random_device rd;
std::mt19937 g(rd());
std::shuffle(v1.begin(), v1.end(), g);

These are algorithms that either help order elements in a specific manner or exploit the ordered nature of sequences for certain operations.

sort: Sort elements in a range.

Sorts elements in ascending order by default.

std::vector<int> v = {5, 2, 9, 1};
std::sort(v.begin(), v.end());  // v: {1, 2, 5, 9}

partial_sort: Partially sort elements.

Sorts the first N elements in ascending order.

std::vector<int> v = {9, 7, 5, 3, 1, 8, 6, 4, 2};
std::partial_sort(v.begin(), v.begin() + 3, v.end());  // v: {1, 2, 3, ...}

nth_element: Sort nth element.

Ensures the nth element is in the position it would be in a sorted range.

std::nth_element(v.begin(), v.begin() + 4, v.end());  // The fifth element is in its sorted position

merge: Merge sorted sequences.

Merges two sorted sequences into one.

std::vector<int> v1 = {1, 3, 5};
std::vector<int> v2 = {2, 4, 6};
std::vector<int> result(6);
std::merge(v1.begin(), v1.end(), v2.begin(), v2.end(), result.begin());  // result: {1, 2, 3, 4, 5, 6}

inplace_merge: Merge consecutive sorted ranges.

Merges two consecutive sorted ranges within a single sequence.

std::vector<int> v = {1, 3, 5, 2, 4, 6};
std::inplace_merge(v.begin(), v.begin() + 3, v.end());  // v: {1, 2, 3, 4, 5, 6}

partition: Partition by predicate.

Partitions the sequence such that elements satisfying a predicate come before those that don't.

std::partition(v.begin(), v.end(), [](int x){ return x % 2 == 0; });  // Evens first, then odds

stable_partition: Partition preserving order.

Similar to partition, but maintains the relative order of equivalent elements.

std::stable_partition(v.begin(), v.end(), [](int x){ return x % 2 == 0; });  // Evens first in their original order, then odds

binary_search: Binary search on sorted range.

Returns true if an element exists in the sorted range, false otherwise.

bool exists = std::binary_search(v.begin(), v.end(), 4);  // true if 4 is in the vector

lower_bound, upper_bound: Binary search returning iterator.

lower_bound returns an iterator to the first element not less than the given value. upper_bound returns an iterator to the first element greater than the value.

auto low = std::lower_bound(v.begin(), v.end(), 4);  // Points to first element >= 4
auto up = std::upper_bound(v.begin(), v.end(), 4);  // Points to first element > 4

5. Numeric Algorithms

Numeric algorithms are specifically designed to operate on sequences of numbers, offering a range of functions from basic arithmetic operations to more complex calculations.

iota: Store increasing sequence.

Fills a range with a sequence of increasing values.

std::vector<int> v(5);
std::iota(v.begin(), v.end(), 10);  // v: {10, 11, 12, 13, 14}

accumulate: Accumulate values.

Computes the sum of a range's elements and can also accumulate results with custom operations.

std::vector<int> nums = {1, 2, 3, 4, 5};
int sum = std::accumulate(nums.begin(), nums.end(), 0);  // sum: 15

// With custom operation
int product = std::accumulate(nums.begin(), nums.end(), 1, std::multiplies<int>());
// product: 120 (because 1*2*3*4*5)

inner_product: Inner product of two sequences.

Computes the dot product of two sequences.

std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = {4, 5, 6};
int result = std::inner_product(v1.begin(), v1.end(), v2.begin(), 0);  // result: 32 (because 1*4 + 2*5 + 3*6)

adjacent_difference: Compute adjacent difference.

Computes the differences between adjacent elements in a sequence.

std::vector<int> v = {1, 2, 4, 7, 11};
std::vector<int> result(5);
std::adjacent_difference(v.begin(), v.end(), result.begin());  // result: {1, 1, 2, 3, 4}

partial_sum: Compute partial sums.

Calculates the partial sum of elements in a sequence.

std::vector<int> v = {1, 2, 3, 4, 5};
std::vector<int> result(5);
std::partial_sum(v.begin(), v.end(), result.begin());  // result: {1, 3, 6, 10, 15}

6. Queries on Sequences

These algorithms provide efficient ways to query sequences about their characteristics, such as finding the minimum, maximum, or both values within the range.

max, min: Return max/min elements.

These functions are used to obtain the maximum or minimum of two values.

int a = 5, b = 10;
int maximum = std::max(a, b);  // maximum: 10
int minimum = std::min(a, b);  // minimum: 5

minmax: Return both min and max.

This function returns a pair containing the minimum and maximum of its two input values.

auto result = std::minmax(5, 10);  // result: {5, 10}

max_element, min_element: Return iterator to max/min elements.

These functions return an iterator pointing to the maximum or minimum element of a sequence.

std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
auto it_max = std::max_element(v.begin(), v.end());  // it_max points to 9
auto it_min = std::min_element(v.begin(), v.end());  // it_min points to 1

minmax_element: Return pair of iterators.

Returns a pair of iterators pointing to the minimum and maximum elements of a sequence.

std::vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};
auto it_pair = std::minmax_element(v.begin(), v.end()); 
// it_pair.first points to 1
// it_pair.second points to 9

7. Raw Memory Operations

Raw memory operations deal with the allocation and management of raw memory regions, typically working directly with uninitialized memory. These operations are useful when working at a lower level, especially when combined with custom memory allocators. They can optimize performance by sidestepping default initializations and constructions.

uninitialized_copy: Copy construct elements.

This function copies elements from one range to another uninitialized memory location using the copy constructor.

std::vector<std::string> source = {"Hello", "World"};
std::allocator<std::string> alloc;
std::string* p = alloc.allocate(2);  // allocate raw memory for 2 strings

std::uninitialized_copy(source.begin(), source.end(), p);
// Now, "Hello" and "World" are copy constructed at p and p+1 respectively

alloc.deallocate(p, 2);  // deallocate memory after use

uninitialized_move: Move construct elements.

It moves elements from one range to another uninitialized memory location using the move constructor.

std::vector<std::string> source = {"Hello", "World"};
std::allocator<std::string> alloc;
std::string* p = alloc.allocate(2);

std::uninitialized_move(source.begin(), source.end(), p);
// The contents of 'source' are moved to p and p+1

alloc.deallocate(p, 2);

uninitialized_fill: Fill memory with a value.

This function fills an uninitialized memory range with the specified value.

std::allocator<int> alloc;
int* p = alloc.allocate(5);  // allocate raw memory for 5 integers

std::uninitialized_fill(p, p + 5, 42);
// The range [p, p+5) is now filled with the value 42

alloc.deallocate(p, 5);

destroy: Destroy elements in a range.

It calls the destructor for the specified range of objects without deallocating the memory.

std::allocator<std::string> alloc;
std::string* p = alloc.allocate(2);
std::uninitialized_fill(p, p + 2, "Hello");

std::destroy(p, p + 2);  // Calls the destructor for each element in the range

alloc.deallocate(p, 2);

Certainly! Here's a conclusion and some best practices when dealing with STL algorithms:


8. Conclusion and Best Practices

After exploring the vast array of algorithms that the STL provides, it's evident that the library offers tools to handle almost any computational problem we might face. However, having a multitude of choices also requires an understanding of when and how to use each algorithm optimally. Here are some concluding thoughts and best practices to guide you:

Understand When to Use Which Algorithm

Just because an algorithm exists doesn't mean it's always the best choice. Consider the problem at hand, the constraints, and the properties of the data. For instance: - For searching within sorted sequences, prefer binary_search, lower_bound, or upper_bound over linear searches like find. - Use sort for full ordering, but if you only need to find a position or partially order elements, nth_element or partial_sort may be more efficient.

Emphasize the Importance of Knowing the Complexities and Trade-offs

  • Always be aware of the time and space complexities of the algorithms you're using. It could be the difference between an efficient solution and one that doesn't scale.
  • Understand that some algorithms guarantee stability (like stable_sort) while others don't (sort). Depending on the application, this might be crucial.

Reminder on the Power of Combining STL Containers with STL Algorithms

  • The STL is designed such that its algorithms and containers complement each other. Utilizing them together can significantly enhance performance and code readability.
  • For instance, using std::list's member function sort() takes advantage of the linked list's properties, offering a potentially faster sort than using std::sort().
  • Many algorithms work on a range of elements, represented by iterators. The universality of iterators in the STL means you can often use the same algorithm across different container types without modification.

Additional Best Practices

  • Always prefer algorithms over hand-written loops when possible. They often lead to more readable, concise, and sometimes even faster code due to compiler optimizations.
  • Be cautious when using algorithms that work on raw memory, like the uninitialized family of functions. They offer performance benefits but increase the chance of errors.
  • Always test your code thoroughly, especially when integrating multiple STL algorithms or using them in unconventional ways.