STL Foundations: Containers, Iterators, Algorithms and Time Complexity

15
0

STL-I: Essential C++ Standard Template Library

The C++ Standard Template Library, commonly called the STL, provides reusable containers, iterators, algorithms, and utility types. These components reduce the amount of low-level code required to store, organize, search, and process data.

Understanding the STL requires more than memorizing function names. Each operation has a behavior, a suitable use case, and a time complexity. Choosing an appropriate container often determines whether a solution remains efficient as its input grows.

Prerequisites

Before working with the STL, it is useful to understand:

  • C++ functions and parameter passing

  • Arrays and dynamic arrays

  • Strings

  • Recursion

  • Pointers and memory access

  • Basic time-complexity notation

Time Complexity Foundations

Time complexity describes how the amount of work performed by an algorithm grows with the size of its input. It is commonly expressed using Big-O notation, such as O(1), O(n), or O(log n).

Linear search examines array elements from left to right until the target is found. Since the search begins at index 0, returning immediately when a match is found also returns the first occurrence.

Consider the array {10, 20, 30, 40, 50}:

  • Searching for 30 returns index 2.

  • Searching for 10 returns index 0.

  • Searching for 60 returns -1 because it is absent.

#include <bits/stdc++.h>
using namespace std;
int linearSearch(int values[], int size, int target) {
for (int index = 0; index < size; ++index) {
if (values[index] == target) {
return index;
}
}
return -1;
}
int main() {
int values[] = {10, 20, 30, 40, 50};
cout << linearSearch(values, 5, 30) << '\n';
cout << linearSearch(values, 5, 60) << '\n';
return 0;
}

Best, Worst, and Average Cases

If the target is the first element, the loop performs one comparison. This is the best case and takes O(1) time.

If the target is absent or appears at the final position, the loop may inspect all n elements. This is the worst case and takes O(n) time.

If the target is equally likely to occur at any position, the expected number of comparisons is:

(1 + 2 + 3 + ... + n) / n = (n + 1) / 2

Constant factors are ignored in asymptotic analysis, so the average time complexity is also O(n).

Constant Work Inside a Loop

Accessing an array element, reading a variable, comparing two integers, or returning a value is treated as a constant-time operation. Each such operation takes O(1) time.

If constant work is repeated n times, the total complexity becomes O(n). Additional fixed operations do not alter this classification. For example, O(n + 5) is simplified to O(n).

Amortized Complexity

Amortized complexity considers the average cost of a sequence of related operations. Most operations may be inexpensive while an occasional operation is considerably more expensive.

Appending to a vector is a standard example. Most insertions place the new element directly into available capacity and take O(1) time. Occasionally, the vector must allocate a larger block and move its existing elements, which takes O(n). Across a long sequence of append operations, the average cost per append is still amortized O(1).

Using auto

The auto keyword asks the compiler to infer a variable’s type from its initializer. It is especially useful with iterators and long template types.

For example, in a range-based loop over an integer array, auto value is inferred as an integer. For an iterator returned by container.begin(), auto avoids writing the iterator’s full template type.

Pairs and Tuples

pair

A pair stores exactly two values. The values may have different types and are accessed through first and second.

A pair can also contain another pair. For example, pair<int, pair<int, int>> can hold three related integers through nesting.

  • value.first accesses the first value.

  • value.second.first accesses the first member of the nested pair.

  • value.second.second accesses its second member.

tuple

A tuple stores a fixed number of values that may have different types. Unlike a pair, it is not restricted to two members.

Tuple members are accessed using get<index>(tuple). The index must be known at compile time and ranges from 0 to one less than the number of stored values.

Problem: Return the Highest Score and Its Index

Given a non-empty array of scores, return the highest score and its zero-based index as a pair. If the maximum appears more than once, return its first occurrence.

For {10, 40, 20, 60, 30, 50}, the answer is {60, 3}.

#include <bits/stdc++.h>
using namespace std;
pair<int, int> getHighest(int scores[], int size) {
int maximum = scores[0];
int maximumIndex = 0;
for (int index = 1; index < size; ++index) {
if (scores[index] > maximum) {
maximum = scores[index];
maximumIndex = index;
}
}
return {maximum, maximumIndex};
}
int main() {
pair<int, int> simple = {10, 20};
cout << simple.first << ' ' << simple.second << '\n';
pair<int, pair<int, int>> nested = {10, {20, 30}};
cout << nested.first << ' ' << nested.second.first
<< ' ' << nested.second.second << '\n';
tuple<int, char, double, string> data = {8, 'R', 9.8, "Raj"};
cout << get<0>(data) << ' ' << get<3>(data) << '\n';
int scores[] = {10, 40, 20, 60, 30, 50};
auto result = getHighest(scores, 6);
cout << result.first << ' ' << result.second << '\n';
}

The comparison uses > instead of >=. Therefore, an equal maximum found later does not replace the index of the first occurrence.

The array is traversed once, so the time complexity is O(n). The function uses O(1) auxiliary space.

Iterators and Half-Open Ranges

An iterator identifies a position in a sequence. It behaves similarly to a pointer: it can be dereferenced to access an element and advanced to visit another position.

Beginning and End Iterators

  • begin(sequence) points to the first element.

  • end(sequence) points one position after the final element.

  • cbegin(sequence) returns a read-only iterator to the first element.

  • cend(sequence) returns a read-only one-past-the-end iterator.

  • rbegin(sequence) begins reverse traversal at the last element.

  • rend(sequence) marks the position after the first element in reverse traversal.

The end iterator is a boundary marker and must not be dereferenced. The last element of a random-access sequence can be reached with end(sequence) - 1.

Iterator Utility Functions

  • next(iterator) returns an iterator advanced by one position without changing the original.

  • next(iterator, count) returns an iterator advanced by the specified count.

  • prev(iterator) returns an iterator moved one position backward.

  • advance(iterator, count) modifies the supplied iterator by moving it.

  • distance(first, last) returns the number of steps from one iterator to another.

Half-Open Ranges

STL algorithms generally operate on half-open ranges written as [first, last). The first position is included, while the last position is excluded.

For an array and indexes L = 1 and R = 4, the range contains indexes 1, 2, and 3. The element at index 4 is not included.

#include <bits/stdc++.h>
using namespace std;
int main() {
int values[] = {10, 20, 30, 40, 50};
auto pointer = begin(values);
auto readOnly = cbegin(values);
cout << *pointer << '\n';
cout << *(end(values) - 1) << '\n';
cout << *rbegin(values) << '\n';
cout << *readOnly << '\n';
auto nextPosition = next(pointer);
cout << *nextPosition << '\n';
advance(pointer, 2);
cout << *pointer << '\n';
cout << distance(begin(values), end(values)) << '\n';
for (auto it = begin(values); it != end(values); ++it) {
cout << *it << ' ';
}
cout << '\n';
int left = 1;
int right = 4;
reverse(begin(values) + left, begin(values) + right);
return 0;
}

To reverse an entire array, any of the following equivalent ranges can be used:

  • reverse(begin(values), end(values))

  • reverse(values, values + size)

vector: A Dynamic Contiguous Array

A vector is a dynamically sized sequence whose elements are stored contiguously. Unlike a built-in array, it can grow or shrink during execution.

A vector may store primitive values, strings, pairs, other containers, or user-defined objects.

Common vector Operations

  • push_back(value) appends an element.

  • pop_back() removes the last element.

  • operator[] performs unchecked indexed access.

  • at(index) performs bounds-checked access and throws an exception for an invalid index.

  • insert(iterator, value) inserts before an iterator position.

  • erase(iterator) removes one element.

  • erase(first, last) removes a half-open range.

  • size() returns the number of elements.

  • empty() reports whether the vector has no elements.

  • clear() removes all elements.

  • reserve(capacity) requests storage without changing the vector’s size.

  • resize(size) changes the number of elements.

Using operator[] with an invalid index causes undefined behavior. It is not guaranteed to produce an error. Use at() when explicit bounds checking is required.

Capacity, Reallocation, and Complexity

A vector maintains a size and a capacity. The size is the number of constructed elements, while the capacity is the amount of allocated storage currently available.

reserve(10) reserves enough memory for at least ten elements but does not create ten elements. By contrast, resize(10) changes the size to ten, value-initializing any newly created integers to zero.

Appending is amortized O(1). Inserting or erasing near the beginning or middle takes O(n) because later elements must be shifted. Reallocation may also invalidate pointers, references, and iterators referring to vector elements.

Problem: Read Numbers and Print Their Sum

For the values 10, 20, 30, 40, 50, the resulting sum is 150.

#include <bits/stdc++.h>
using namespace std;
int main() {
int size;
cin >> size;
vector<int> values(size);
for (int& value : values) {
cin >> value;
}
values.reserve(size + 5);
values.push_back(60);
values.pop_back();
values.insert(values.begin() + 1, 15);
values.erase(values.begin() + 1);
long long sum = 0;
for (int value : values) {
sum += value;
}
cout << "Size: " << values.size() << '\n';
cout << "Sum: " << sum << '\n';
return 0;
}

array: A Fixed-Size STL Container

std::array wraps a fixed-size built-in array in a container interface. Its size is part of its type and cannot change during execution.

Unlike built-in arrays, one std::array can be copied directly into another. The copy owns independent elements, so modifying the original does not modify the copy.

#include <bits/stdc++.h>
using namespace std;
int main() {
array<int, 5> values = {10, 20, 30, 40, 50};
array<int, 5> copy = values;
values[0] = 100;
cout << values[0] << '\n';
cout << copy[0] << '\n';
for (auto it = copy.begin(); it != copy.end(); ++it) {
cout << *it << ' ';
}
return 0;
}

deque: Efficient Operations at Both Ends

A deque, pronounced “deck,” is a double-ended queue. It supports insertion and removal at both the front and back.

  • push_back(value) appends at the back.

  • push_front(value) inserts at the front.

  • pop_back() removes the last element.

  • pop_front() removes the first element.

  • front() accesses the first element.

  • back() accesses the last element.

  • operator[] supports indexed access.

Problem: Ordinary and Priority Customers

Process customer operations using these rules:

  • O id: an ordinary customer joins the back.

  • P id: a priority customer joins the front.

  • S: print and remove the customer at the front.

For the operations O 1, O 2, P 3, S, O 4, S, the served customers are 3 and 1.

#include <bits/stdc++.h>
using namespace std;
int main() {
int operations;
cin >> operations;
deque<int> customers;
while (operations--) {
char operation;
cin >> operation;
if (operation == 'O') {
int id;
cin >> id;
customers.push_back(id);
} else if (operation == 'P') {
int id;
cin >> id;
customers.push_front(id);
} else if (operation == 'S') {
if (!customers.empty()) {
cout << customers.front() << '\n';
customers.pop_front();
}
} else {
cout << "Invalid operation\n";
}
}
return 0;
}

The emptiness check prevents access to the front of an empty deque. Operations at either end take constant time, O(1).

Useful string Operations

std::string behaves like a dynamic sequence of characters and provides several STL-style operations.

  • size() returns the number of characters.

  • empty() reports whether the string contains no characters.

  • push_back(character) appends one character.

  • pop_back() removes the final character.

  • find(value) returns the first matching position.

  • find_last_of(characters) returns the last position containing any supplied character.

  • insert(position, text) inserts text at an index.

  • insert(iterator, character) inserts before an iterator.

  • insert(position, count, character) inserts repeated characters.

  • erase(iterator) removes one character.

  • erase(first, last) removes a half-open iterator range.

  • substr(position) returns the suffix beginning at a position.

  • substr(position, count) returns at most the requested number of characters.

A string has no direct push_front() or pop_front(). Its push_back() accepts a character, not another complete string.

Search Examples

For "striver", find('r') returns 2, the first occurrence. The second r at index 6 is ignored by find.

find_last_of("ir") returns the farthest-right position containing either i or r. For "striver", that position is index 6. If the string becomes "striveri", the result becomes index 7.

A failed string search returns string::npos, not a valid index.

Problem: Extract the Text After the Last Dot

Return the portion of a filename following its last dot. Return an empty string when:

  • The filename contains no dot.

  • The last dot is the first character.

  • The last dot is the final character.

#include <bits/stdc++.h>
using namespace std;
string afterLastDot(const string& text) {
auto lastDot = text.find_last_of('.');
if (lastDot == string::npos ||
lastDot == 0 ||
lastDot == text.size() - 1) {
return "";
}
return text.substr(lastDot + 1);
}
int main() {
cout << afterLastDot("Raj.striver") << '\n';
cout << afterLastDot("archive.tar.gz") << '\n';
cout << afterLastDot(".hidden") << '\n';
cout << afterLastDot("file.") << '\n';
cout << afterLastDot("filename") << '\n';
return 0;
}

The examples test every logical branch: a valid extension, multiple dots, a dot at the beginning, a dot at the end, and no dot. Testing each branch is an effective way to validate conditional code.

list: A Doubly Linked Sequence

std::list is generally implemented as a doubly linked list. Its elements are not required to occupy contiguous memory.

Because the elements are linked rather than indexed, operations such as list[1] and iterator + 2 are unavailable. An iterator can still move with ++iterator, --iterator, next(), prev(), or advance().

Common list Operations

  • push_front() and push_back() insert at either end.

  • pop_front() and pop_back() remove from either end.

  • insert(iterator, value) inserts before a position.

  • insert(iterator, count, value) inserts repeated values.

  • erase(iterator) removes one node.

  • erase(first, last) removes a half-open range.

  • remove(value) removes every occurrence of a value.

  • front() and back() access the end elements.

  • size() and empty() inspect the list state.

  • sort() sorts the list.

  • reverse() reverses its order.

#include <bits/stdc++.h>
using namespace std;
int main() {
list<int> values = {10, 20, 30};
values.push_front(40);
values.push_back(50);
auto position = values.begin();
++position;
values.insert(position, 100);
values.push_back(10);
values.remove(10);
values.sort();
values.reverse();
for (int value : values) {
cout << value << ' ';
}
cout << '\n';
cout << values.front() << ' ' << values.back();
return 0;
}

stack: Last In, First Out

A stack follows LIFO order: the last element inserted is the first one removed. It exposes only the top element and does not support indexed access or ordinary iterator traversal.

  • push(value) places an element on top.

  • emplace(arguments) constructs an element on top.

  • top() accesses the top element without removing it.

  • pop() removes the top element without returning it.

  • size() returns the number of elements.

  • empty() checks whether the stack is empty.

If 10, 20, 100, and 89 are pushed in that order, top() returns 89. After one pop, the new top is 100.

To print all stack elements, repeatedly read top() and then call pop(). This process empties the stack.

queue: First In, First Out

A queue follows FIFO order: the first element inserted is the first one removed.

  • push(value) or emplace(arguments) inserts at the back.

  • front() accesses the next element to be removed.

  • back() accesses the most recently inserted element.

  • pop() removes the front element.

  • size() and empty() inspect its state.

If 10, 20, 90, and 80 are inserted in that order, repeated front-and-pop operations produce 10, 20, 90, 80.

Problem: Serve the First k Customers

Given customer IDs in arrival order and a non-negative integer k, print the first k customers in service order. For {10, 20, 30} and k = 2, the answer is 10 20.

#include <bits/stdc++.h>
using namespace std;
int main() {
int size;
cin >> size;
queue<int> customers;
for (int index = 0; index < size; ++index) {
int id;
cin >> id;
customers.push(id);
}
int count;
cin >> count;
// Avoid removing more customers than are available.
count = min(count, static_cast<int>(customers.size()));
while (count--) {
cout << customers.front() << ' ';
customers.pop();
}
return 0;
}

priority_queue: Access by Priority

A priority queue exposes the element with the highest priority rather than the earliest or latest inserted element.

For integers, the default priority queue is a max-heap. The greatest integer appears at the top. If 9, 10, 7, 8, and another 8 are inserted, repeated top-and-pop operations produce:

10 9 8 8 7

Duplicates are preserved. Creating a min-heap reverses the priority:

priority_queue<int, vector<int>, greater<int>>

The same values then emerge as 7 8 8 9 10.

For pairs, comparison is lexicographical. The first component determines priority; if the first components are equal, the second components are compared.

Problem: Print the k Smallest Values

For the values {2, 9, 2, 7} and k = 3, the three smallest values are 2 2 7.

#include <bits/stdc++.h>
using namespace std;
int main() {
int size;
cin >> size;
priority_queue<int, vector<int>, greater<int>> values;
for (int index = 0; index < size; ++index) {
int value;
cin >> value;
values.push(value);
}
int count;
cin >> count;
count = min(count, static_cast<int>(values.size()));
while (count--) {
cout << values.top() << ' ';
values.pop();
}
return 0;
}

Insertion and removal take O(log n), while reading the top takes O(1).

set: Unique Values in Sorted Order

A set stores unique values in sorted order. Attempting to insert a value already present does not create a duplicate.

For insertions such as 10, 1, 7, 9, 6, 1, 7, 8, the resulting set is:

1 6 7 8 9 10

Common set Operations

  • insert(value) adds a value if it is not already present.

  • find(value) returns an iterator to the value or end().

  • count(value) returns either 1 or 0.

  • erase(value) removes the value.

  • erase(iterator) removes the identified element.

  • erase(first, last) removes a half-open range.

  • size(), empty(), and clear() inspect or reset the container.

lower_bound and upper_bound

lower_bound(x) returns an iterator to the first value greater than or equal to x. In the set above:

  • lower_bound(5) points to 6.

  • lower_bound(6) also points to 6.

  • A query greater than every stored value returns end().

upper_bound(x) returns an iterator to the first value strictly greater than x.

Problem: Print Distinct Values in Increasing Order

For {40, 10, 40, 20}, the output must be 10 20 40.

#include <bits/stdc++.h>
using namespace std;
int main() {
int size;
cin >> size;
set<int> distinctValues;
for (int index = 0; index < size; ++index) {
int value;
cin >> value;
distinctValues.insert(value);
}
for (int value : distinctValues) {
cout << value << ' ';
}
return 0;
}

Each insertion takes O(log n), so inserting all values takes O(n log n). Traversing the distinct values takes O(d), where d is the number of distinct values.

multiset: Sorted Values with Duplicates

A multiset stores values in sorted order while retaining duplicates. If 1, 10, and 2 are each inserted three times, iteration produces:

1 1 1 2 2 2 10 10 10

Important multiset Operations

  • find(x) returns an iterator to one occurrence, normally the first equivalent element.

  • count(x) returns the total number of occurrences.

  • lower_bound(x) points to the first value not less than x.

  • upper_bound(x) points to the first value greater than x.

  • equal_range(x) returns a pair containing both bounds.

  • erase(x) removes every occurrence of x.

  • erase(iterator) removes only the selected occurrence.

Problem: Sell the Most Expensive Affordable Ticket

Given available ticket prices and a customer’s budget, sell the most expensive ticket whose price does not exceed the budget. Only one copy of that ticket must be removed.

For prices {5, 5, 8} and budget 6, one ticket priced 5 is sold. Another ticket priced 5 remains.

The first price greater than the budget is found with upper_bound(). The preceding element, when it exists, is the greatest affordable price.

#include <bits/stdc++.h>
using namespace std;
int main() {
int ticketCount;
cin >> ticketCount;
multiset<int> prices;
for (int index = 0; index < ticketCount; ++index) {
int price;
cin >> price;
prices.insert(price);
}
int budget;
cin >> budget;
auto position = prices.upper_bound(budget);
if (position == prices.begin()) {
cout << "No affordable ticket\n";
return 0;
}
--position;
cout << "Sold for: " << *position << '\n';
// Erasing by iterator removes only this occurrence.
prices.erase(position);
return 0;
}

Testing whether position == prices.begin() is essential. Moving backward from begin() is invalid. Lookup and removal each take O(log n).

unordered_set: Unique Values Through Hashing

An unordered_set stores unique values using hashing. Unlike set, it does not maintain sorted order. Its iteration order is unspecified and may vary between implementations or executions.

It supports familiar operations such as insert, find, count, erase, size, empty, and clear.

Because no sorted order is maintained, lower_bound and upper_bound are unavailable.

Complexity

Insertion, lookup, and deletion take average O(1) time. In the worst case, excessive hash collisions can make an operation take O(n).

Use an unordered set when uniqueness and fast average lookup matter but sorted traversal and bound queries do not.

Understanding O(log n)

Logarithmic complexity commonly appears when each operation reduces the remaining search space by a constant factor, usually one half.

Suppose a hidden number lies between 1 and 100. Guessing sequentially may require up to one hundred attempts. A better strategy starts near the middle:

  • Guess 50 and discard half the range.

  • Guess the middle of the remaining half.

  • Continue halving until one candidate remains.

The search-space sizes become approximately 100, 50, 25, 12, 6, 3, 1. Only about seven reductions are required.

  • For roughly 1,000 possibilities, about 10 halvings are needed.

  • For roughly 1,000,000, about 20 are needed.

  • For roughly 1,000,000,000, about 30 are needed.

This slow growth is represented by O(log n). Ordered sets, multisets, and maps generally provide lookup, insertion, and deletion in O(log n).

map: Sorted Key-Value Associations

A map stores key-value pairs with unique keys in sorted key order. In map<int, string>, the integer is the key and the string is its associated value.

For student records, a roll number can serve as the key and the student’s name as the value. Iterating through the map processes records in increasing roll-number order.

Unique Keys

If two entries use the same key during initializer-list construction or insertion, the map retains only one entry for that key. Keys such as 17, 12, 18, 18, 11, 19 are iterated in the order 11, 12, 17, 18, 19.

Accessing Values

  • map[key] accesses the value and inserts a default value if the key is absent.

  • map.at(key) accesses an existing value and throws an exception if the key is absent.

  • find(key) returns an iterator to the pair or end().

  • For an iterator it, it->first is the key and it->second is the value.

For a map<int, string>, using map[missingKey] inserts that key with an empty string. For a map<int, int>, it inserts a zero value.

Other map Operations

  • insert({key, value}) inserts a key-value pair.

  • emplace(key, value) constructs an entry directly.

  • erase(key) removes an entry by key.

  • count(key) returns 0 or 1.

  • lower_bound(key) finds the first key greater than or equal to the argument.

  • upper_bound(key) finds the first key strictly greater than the argument.

  • size(), empty(), and clear() manage the container state.

Problem: Count Frequencies in Sorted Value Order

For {3, 1, 3, 2, 1, 3}, the required frequencies are:

  • 1 appears 2 times.

  • 2 appears 1 time.

  • 3 appears 3 times.

#include <bits/stdc++.h>
using namespace std;
int main() {
int size;
cin >> size;
map<int, int> frequency;
for (int index = 0; index < size; ++index) {
int value;
cin >> value;
// A missing integer value is initialized to zero.
++frequency[value];
}
for (const auto& entry : frequency) {
cout << entry.first << ' ' << entry.second << '\n';
}
return 0;
}

Each map update takes O(log n). Performing it for all n input values gives O(n log n) time. Iteration naturally produces keys in sorted order.

unordered_map: Fast Average Key-Value Lookup

An unordered_map stores unique key-value associations through hashing. It behaves similarly to map, but it does not maintain sorted key order.

Insertion, lookup, and deletion take average O(1) time and worst-case O(n). Since there is no ordering, lower_bound and upper_bound are unavailable.

An unordered map is suitable for frequency counting when only the counts matter. A regular map is preferable when the output must be ordered by key.

Container Selection Summary

  • Use vector for dynamic contiguous storage and indexed access.

  • Use array for fixed-size storage with STL container behavior.

  • Use deque for efficient insertion and deletion at both ends.

  • Use list when linked-node behavior and stable node insertion are useful.

  • Use stack for last-in, first-out processing.

  • Use queue for first-in, first-out processing.

  • Use priority_queue when the highest- or lowest-priority element must be accessed efficiently.

  • Use set for unique sorted values.

  • Use multiset for sorted values with duplicates.

  • Use unordered_set for unique values with fast average lookup and no ordering requirement.

  • Use map for sorted key-value associations.

  • Use unordered_map for key-value associations with fast average lookup and no ordering requirement.

Complexity Summary

  • Vector indexed access: O(1)

  • Vector append: amortized O(1)

  • Vector middle insertion or deletion: O(n)

  • Deque insertion or removal at either end: O(1)

  • Stack and queue push or pop: O(1)

  • Priority queue insertion or removal: O(log n)

  • Priority queue top access: O(1)

  • Set, multiset, and map lookup or update: O(log n)

  • Unordered set and unordered map lookup or update: average O(1), worst-case O(n)

The STL becomes most effective when container behavior, iterator ranges, and complexity are considered together. The right container does more than shorten the code: it makes the intended operations explicit and keeps the solution efficient as the input grows.

C++

Read Similar Blogs

Comments0