std::vector in Practice: reserve vs resize, Iterator Invalidation and 2D Vectors
Key takeaways
std::vector explained: why it beats raw arrays, reserve vs resize, iterator invalidation, algorithms, 2D vectors, and practical examples with pitfalls.
For how dynamic arrays relate to the classic “array vs linked list” discussion in complexity analysis and interviews, see arrays and lists.
Why vectors are better than arrays
// problem with array
int arr[100];// Fixed size, cannot be changed
// arr[100] = 1;// Out of range not checked
// Advantages of vector
vector<int> v;//Automatically adjust size
v.push_back(1);// add dynamically
//v.at(100);// Check range (raise exception)
A std::vector is a contiguous block of heap memory plus three pieces of bookkeeping: where the block starts, how many elements are in use (size()), and how many fit before it has to grow (capacity()). That layout is the reason it is the default container in modern C++. Elements sit next to each other in memory, so iterating is cache-friendly and you can hand v.data() to any C API that expects a pointer and a length. The vector also owns its memory, so there is no delete[] to forget, and it knows its own size, so you never pass a separate length around and get the two out of sync.
The trade-off is what happens when it runs out of room. When push_back finds size() == capacity(), the vector allocates a larger block (implementations typically grow by 1.5x or 2x), moves or copies every element into it, and frees the old block. That keeps push_back amortized O(1), but it also means any pointer, reference or iterator into the old block is now dangling. Most of the pitfalls later in this article come from that one fact.
Creating, growing, and accessing a vector
Declaration and initialization
#include <vector>
using namespace std;
// empty vector
vector<int>v1;
// Specify size
vector<int> v2(10);// 10 initialized to 0
// initialize with value
vector<int> v3(10, 5);// 10 initialized to 5
// initialization list
vector<int> v4 = {1, 2, 3, 4, 5};
// Copy another vector
vector<int> v5 = v4;
Watch the difference between parentheses and braces. vector<int> v2(10) means “ten elements”, while vector<int> v{10} means “one element whose value is 10”, because braces prefer the std::initializer_list constructor whenever one exists. The same trap applies to vector<int> v3(10, 5) versus vector<int> v{10, 5} (ten fives versus two elements). A good habit is parentheses for sizes and braces for element lists.
vector<int> v5 = v4; is a deep copy: it allocates new memory and copies every element, which is O(n). If you no longer need v4, vector<int> v5 = std::move(v4); steals its buffer in O(1) and leaves v4 valid but unspecified (in practice empty). Accidental copies of large vectors are one of the most common silent performance problems in C++ code, and they usually hide in function parameters and range-for loops over auto instead of const auto&.
Add/delete elements
vector<int> v;
// add at the end
v.push_back(10);
v.push_back(20);
v.push_back(30);
// v = [10, 20, 30]
// remove end
v.pop_back();
// v = [10, 20]
// insert at specific location
v.insert(v.begin() + 1, 15);
// v = [10, 15, 20]
// Delete specific location
v.erase(v.begin() + 1);
// v = [10, 20]
// delete all
v.clear();
// v = []
The cost of these operations depends on where they happen. push_back and pop_back work at the end and are O(1) (amortized for push_back). insert and erase in the middle are O(n), because every element after the position has to shift one slot. Inserting at v.begin() in a loop is therefore O(n²) overall; if you need frequent front insertion, std::deque is a better fit, and if you just need the final order reversed, push to the back and call std::reverse once.
Two details surprise people. pop_back() on an empty vector is undefined behavior, not an exception, so check empty() first. And clear() destroys the elements but does not release the memory: capacity() stays the same. That is usually what you want when a buffer is reused every frame, but if you genuinely need the memory back, call v.shrink_to_fit() (a non-binding request) or swap with an empty vector: vector<int>().swap(v);.
Access
vector<int> v = {10, 20, 30, 40, 50};
// index access
cout << v[0];// 10 (no range check)
cout << v.at(0);// 10 (range check)
// first/last element
cout << v.front();// 10
cout << v.back();// 50
// size
cout << v.size();// 5
// check if empty
if (v.empty()) {
cout << "empty" << endl;
}
operator[] does no bounds checking, which is why it is fast and why an out-of-range index is undefined behavior rather than an error. at() checks and throws std::out_of_range with a message such as vector::_M_range_check: __n (which is 10) >= this->size() (which is 5) on libstdc++. Using at() everywhere is rarely necessary; a common compromise is [] in hot loops where the index comes from the loop bounds, and at() (or an explicit check) where the index comes from user input or a file. During development you can also compile with -D_GLIBCXX_ASSERTIONS (GCC) or use MSVC debug builds to make [] check bounds too.
front() and back() on an empty vector are undefined behavior for the same reason. Prefer v.empty() over v.size() == 0; both are O(1) for vectors, but empty() states the intent and works the same way on every container.
Three ways to loop over a vector
Index based
vector<int> v = {1, 2, 3, 4, 5};
for (int i = 0; i < v.size(); i++) {
cout << v[i] << " ";
}
Range-based for (recommended)
// read only
for (int x : v) {
cout << x << " ";
}
// editable
for (int& x : v) {
x *= 2;// double each element
}
Iterator
for (auto it = v.begin(); it != v.end(); it++) {
cout << *it << " ";
}
The index loop compares a signed int i with the unsigned v.size(), which triggers -Wsign-compare warnings and becomes a real bug when the loop counts down (see the size() mistake below). Use size_t i, or better, the range-based for when you do not need the index. Range-for is also the form least likely to go wrong: it cannot run off the end, and for (int& x : v) makes the “I am modifying elements” intent explicit. For non-trivial element types such as std::string, write for (const auto& s : names); for (auto s : names) copies every string.
All three forms share one rule: do not add or remove elements while iterating. A range-for caches begin() and end() before the first iteration, so a push_back inside the body can reallocate and leave the loop walking freed memory.
Two-dimensional vector
// 2D vector declaration
vector<vector<int>> matrix;
// 3x4 matrix (initialized to 0)
vector<vector<int>> matrix2(3, vector<int>(4, 0));
// value access
matrix2[0][0] = 1;
matrix2[1][2] = 5;
// add row
matrix.push_back({1, 2, 3});
matrix.push_back({4, 5, 6});
// output
for (int i = 0; i < matrix.size(); i++) {
for (int j = 0; j < matrix[i].size(); j++) {
cout << matrix[i][j] << " ";
}
cout << endl;
}
A vector<vector<int>> is a vector of independent vectors, so every row is its own heap allocation. That makes jagged rows possible (the loop above uses matrix[i].size() for exactly that reason), but it also has costs: rows are not contiguous with each other, so walking the whole matrix jumps around memory, and creating a 1000x1000 grid performs a thousand separate allocations. For fixed-size numeric grids, a single vector<int> grid(rows * cols) indexed as grid[r * cols + c] is usually noticeably faster and trivially passed to libraries that expect a flat buffer.
When you do use nested vectors, iterate row by row (outer loop over i, inner over j) so each inner loop reads one contiguous row. Swapping the loop order on a large matrix is a classic way to make the same algorithm several times slower without changing its big-O complexity. Also note that matrix[i][j] on a default-constructed matrix (no rows yet) is undefined behavior; the first declaration above is empty until you push_back rows or resize it.
Sorting and searching with <algorithm>
Sort
#include <algorithm>
vector<int> v = {3, 1, 4, 1, 5, 9};
// ascending order
sort(v.begin(), v.end());
// v = [1, 1, 3, 4, 5, 9]
// descending order
sort(v.begin(), v.end(), greater<int>());
// v = [9, 5, 4, 3, 1, 1]
Search
vector<int> v = {1, 2, 3, 4, 5};
// find value
auto it = find(v.begin(), v.end(), 3);
if (it != v.end()) {
cout << "Find: " << *it << endl;
}
// binary search (sorted vector)
bool found = binary_search(v.begin(), v.end(), 3);
find is a linear scan, O(n), and works on any vector. binary_search is O(log n) but only gives correct results if the vector is sorted by the same ordering you search with; on unsorted data it does not fail loudly, it just returns wrong answers. It also only returns bool. If you need the position, use lower_bound, which returns an iterator to the first element not less than the value, and then check it != v.end() && *it == value. A sorted vector plus lower_bound is a legitimate alternative to std::set when the data is built once and searched many times: it uses less memory and is friendlier to the cache.
Other
vector<int> v = {1, 2, 3, 4, 5};
// reverse order
reverse(v.begin(), v.end());
// v = [5, 4, 3, 2, 1]
// maximum/minimum value
int maxVal = *max_element(v.begin(), v.end());
int minVal = *min_element(v.begin(), v.end());
// total
int sum = accumulate(v.begin(), v.end(), 0);
Two of these hide traps. *max_element(...) dereferences the returned iterator, which is v.end() for an empty vector, so on empty input this line is undefined behavior; check empty() first or keep the iterator and compare it with end(). accumulate lives in <numeric>, not <algorithm>, and it accumulates in the type of its initial value. With 0 the running total is an int even for a vector<long long>, and with 0 on a vector<double> every partial sum is truncated to an integer. Pass 0LL or 0.0 to match the element type.
Unsigned sizes, erasing while looping, and out-of-range access
Storing size() in an int
// ❌ Dangerous code
for (size_t i = v.size() - 1; i >= 0; i--) { // size_t is unsigned: i >= 0 is always true!
// ...
}
// ✅ Correct code
for (int i = (int)v.size() - 1; i >= 0; i--) {
// ...
}
size() returns size_t, an unsigned type. Unsigned arithmetic never goes negative; it wraps around. So when i is 0 and the loop does i--, i becomes 18446744073709551615 on a 64-bit system, the condition i >= 0 is still true, and the next v[i] reads far outside the vector. Compilers warn about this (comparison of unsigned expression in '>= 0' is always true), which is one reason to build with -Wall -Wextra. The same wrap-around bites v.size() - 1 on an empty vector: it is a huge number, not -1.
Casting to int works for vectors under about two billion elements. Other safe patterns are for (size_t i = v.size(); i-- > 0; ), reverse iterators (for (auto it = v.rbegin(); it != v.rend(); ++it)), or C++20’s std::ssize(v), which returns a signed size.
Erasing during iteration
// ❌ Invalid code
for (int i = 0; i < v.size(); i++) {
if (v[i] == target) {
v.erase(v.begin() + i);// Index twist!
}
}
// ✅ Correct code
for (int i = v.size() - 1; i >= 0; i--) {
if (v[i] == target) {
v.erase(v.begin() + i);
}
}
The forward loop fails because erase shifts every later element one slot left. The element that was at i + 1 is now at i, and the loop’s i++ jumps over it, so two adjacent matches leave one behind. Walking backwards avoids that, because erasing at i only moves elements you have already visited.
Both loops are still O(n²) in the worst case, since each erase shifts the tail. For removing by value or predicate, the standard answer is the erase-remove idiom, which does one pass:
v.erase(std::remove(v.begin(), v.end(), target), v.end()); // C++98..17
std::erase(v, target); // C++20
std::erase_if(v, [](int x) { return x < 0; }); // C++20
std::remove does not remove anything by itself; it compacts the kept elements to the front and returns the new logical end. Forgetting the outer erase is a common bug: the code compiles, the size never changes, and the tail holds leftover values. See C++ remove algorithms for the details.
Indexing past the end
vector<int> v(10);
// ❌ Out of range
v[10] = 1;// Undefined behavior (unchecked): may crash, may silently corrupt memory
// ✅ Safe method
v.at(10) = 1;// throws std::out_of_range
“Crash” is the lucky outcome. Writing one element past the end of a vector usually lands inside the same heap allocation’s slack or the allocator’s bookkeeping, so the program keeps running and fails later, somewhere unrelated, often inside free() with a message such as free(): invalid next size. That distance between cause and symptom is what makes these bugs expensive. AddressSanitizer (-fsanitize=address) reports the exact line of the out-of-bounds write, and it is the first tool I reach for whenever a program crashes in the allocator.
A score manager, filters, and a 2D game map
A student score manager
#include <iostream>
#include <vector>
#include <algorithm>
#include <numeric>
using namespace std;
class ScoreManager {
private:
vector<int> scores;
public:
// add score
void addScore(int score) {
if (score >= 0 && score <= 100) {
scores.push_back(score);
cout << score << " points added" << endl;
} else {
cout << "Invalid score (0-100)" << endl;
}
}
// calculate average
double getAverage() const {
if (scores.empty()) return 0.0;
return (double)accumulate(scores.begin(), scores.end(), 0) / scores.size();
}
//highest/lowest score
void printMinMax() const {
if (scores.empty()) {
cout << "No score" << endl;
return;
}
cout << "Highest score: " << *max_element(scores.begin(), scores.end()) << endl;
cout << "Minimum score: " << *min_element(scores.begin(), scores.end()) << endl;
}
// score distribution
void printDistribution() const {
vector<int> dist(5, 0);// A, B, C, D, F
for (int score : scores) {
if (score >= 90) dist[0]++;
else if (score >= 80) dist[1]++;
else if (score >= 70) dist[2]++;
else if (score >= 60) dist[3]++;
else dist[4]++;
}
cout << "=== Score distribution ===" << endl;
cout << "A (90-100): " << dist[0] << " students" << endl;
cout << "B (80-89): " << dist[1] << " students" << endl;
cout << "C (70-79): " << dist[2] << " students" << endl;
cout << "D (60-69): " << dist[3] << " students" << endl;
cout << "F (0-59): " << dist[4] << " students" << endl;
}
// Output sorted scores
void printSorted() const {
vector<int> sorted = scores;// copy
sort(sorted.begin(), sorted.end(), greater<int>());// descending order
cout << "=== Score ranking ===" << endl;
for (int i = 0; i < sorted.size(); i++) {
cout << "#" << (i + 1) << ": " << sorted[i] << " points" << endl;
}
}
};
int main() {
ScoreManager sm;
// add score
sm.addScore(85);
sm.addScore(92);
sm.addScore(78);
sm.addScore(95);
sm.addScore(88);
// Statistics output
cout << "\nAverage: " << sm.getAverage() << " points" << endl;
sm.printMinMax();
cout << endl;
sm.printDistribution();
cout << endl;
sm.printSorted();
return 0;
}
Description: This is a small score manager built on a single vector<int>. A few design choices are worth noticing. The class validates input in addScore so the rest of the methods can assume every score is in 0–100. Every read-only method is marked const, which is what lets it be called on a const ScoreManager&. getAverage and printMinMax both guard against an empty vector, because dividing by scores.size() or dereferencing max_element on empty input would be a division by zero and undefined behavior respectively.
printSorted deliberately copies the vector (vector<int> sorted = scores;) before sorting, since a const method cannot reorder the member and the insertion order may matter elsewhere. That copy is O(n) and fine for a class of students; if you needed the top 3 out of a million scores, std::partial_sort or std::nth_element would avoid sorting everything. The distribution uses a second vector<int> of five counters indexed by grade, which is simpler than five separate variables and makes adding a grade a one-line change. The loop compares int i with sorted.size(), which will produce a sign-compare warning; size_t i silences it.
Filtering into new vectors
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// Filter only even numbers
vector<int> filterEven(const vector<int>& numbers) {
vector<int> result;
for (int num : numbers) {
if (num % 2 == 0) {
result.push_back(num);
}
}
return result;
}
// Filter only numbers in the range
vector<int> filterRange(const vector<int>& numbers, int min, int max) {
vector<int> result;
for (int num : numbers) {
if (num >= min && num <= max) {
result.push_back(num);
}
}
return result;
}
// remove duplicates
vector<int> removeDuplicates(vector<int> numbers) {
sort(numbers.begin(), numbers.end());
auto it = unique(numbers.begin(), numbers.end());
numbers.erase(it, numbers.end());
return numbers;
}
// Vector output helper function
void printVector(const string& label, const vector<int>& v) {
cout << label << ": ";
for (int num : v) {
cout << num << " ";
}
cout << endl;
}
int main() {
vector<int> numbers = {5, 2, 8, 1, 9, 3, 7, 2, 5, 8, 4, 6};
printVector("Original", numbers);
// only even numbers
vector<int> evens = filterEven(numbers);
printVector("Even number", evens);
// range 3-7
vector<int> ranged = filterRange(numbers, 3, 7);
printVector("Range 3-7", ranged);
// remove duplicates
vector<int> unique_nums = removeDuplicates(numbers);
printVector("Remove duplicates", unique_nums);
return 0;
}
Description: Each filter takes the input as const vector<int>& (no copy) and returns a new vector by value. Returning by value is cheap here: the compiler either constructs result directly in the caller’s variable (named return value optimization) or moves it, so no element-by-element copy happens. The hand-written loops can also be expressed with std::copy_if(numbers.begin(), numbers.end(), std::back_inserter(result), pred); which one reads better is mostly a matter of team style. If the result size is roughly known, calling result.reserve(numbers.size()) first avoids repeated reallocations at the cost of some possibly unused capacity.
removeDuplicates takes its parameter by value on purpose. It needs a mutable copy to sort anyway, so copying at the call boundary is honest, and a caller who no longer needs the original can pass std::move(numbers) to avoid the copy entirely. The function relies on std::unique only removing adjacent duplicates, which is why it sorts first; calling unique on unsorted data leaves duplicates that are not next to each other. The trade-off is that the output is sorted, not in original order. If you need to keep the first-seen order, track seen values in a std::unordered_set while copying.
A game map as a 2D vector
#include <iostream>
#include <vector>
using namespace std;
class GameMap {
private:
vector<vector<char>> map;
int rows, cols;
public:
GameMap(int r, int c) : rows(r), cols(c) {
// initialize empty map ('.' = empty space)
map.resize(rows, vector<char>(cols, '.'));
}
//obstacle placement
void placeObstacle(int r, int c) {
if (isValid(r, c)) {
map[r][c] = '#';
}
}
// Player Placement
void placePlayer(int r, int c) {
if (isValid(r, c) && map[r][c] == '.') {
map[r][c] = 'P';
}
}
// Item placement
void placeItem(int r, int c) {
if (isValid(r, c) && map[r][c] == '.') {
map[r][c] = 'I';
}
}
// map output
void print() const {
cout << "\n=== Game Map ===" << endl;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
cout << map[i][j] << " ";
}
cout << endl;
}
}
// Check whether it is possible to move
bool canMove(int r, int c) const {
return isValid(r, c) && map[r][c] != '#';
}
// player movement
bool movePlayer(int fromR, int fromC, int toR, int toC) {
if (!isValid(fromR, fromC) || !isValid(toR, toC)) {
return false;
}
if (map[fromR][fromC] != 'P') {
cout << "There are no players" << endl;
return false;
}
if (!canMove(toR, toC)) {
cout << "Cannot move" << endl;
return false;
}
// Acquire item
if (map[toR][toC] == 'I') {
cout << "Item acquired!"<< endl;
}
map[fromR][fromC] = '.';
map[toR][toC] = 'P';
return true;}
private:
bool isValid(int r, int c) const {
return r >= 0 && r < rows && c >= 0 && c < cols;
}
};
int main() {
GameMap game(5, 8);
//obstacle placement
game.placeObstacle(1, 2);
game.placeObstacle(1, 3);
game.placeObstacle(2, 3);
game.placeObstacle(3, 5);
// Item placement
game.placeItem(1, 6);
game.placeItem(3, 2);
// Player Placement
game.placePlayer(0, 0);
game.print();
// player movement
cout << "\nPlayer moves: (0,0) -> (0,1)" << endl;
game.movePlayer(0, 0, 0, 1);
game.print();
cout << "\nPlayer moves: (0,1) -> (1,1)" << endl;
game.movePlayer(0, 1, 1, 1);
game.print();
return 0;
}
Description: The map is a vector<vector<char>> created with map.resize(rows, vector<char>(cols, '.')), which fills every row with a copy of the template row. All access goes through isValid, and that single bounds check is what keeps map[r][c] from ever reading outside the grid; forgetting it in one new method is the most likely way this class would gain an out-of-bounds bug. Note that the coordinates are (row, column), the opposite of the (x, y) order many game APIs use, and mixing the two conventions is a frequent source of “the player moved the wrong way” bugs.
The class stores the player as a character in the grid rather than as a separate position. That keeps the example short, but it means movePlayer needs the caller to pass the current position, and the item under the player is overwritten when the player steps on it. A real game would usually keep terrain in the grid and entities (player, items) in their own containers, so a tile can hold more than one thing. For a fixed-size map, a flat vector<char> of rows * cols would also avoid one allocation per row.
reserve vs resize, invalidated iterators, and hidden copies
reserve() vs resize()
Symptom: Memory is allocated, but an error occurs when accessing it. Cause: Not understanding the difference between reserve() and resize() Solution:
// ❌ Invalid code
vector<int> v;
v.reserve(100);// Reserve memory only
v[0] = 10;// error!size() is still 0
// ✅ Correct code (method 1: use resize)
vector<int> v;
v.resize(100);// set size + initialize to 0
v[0] = 10;//OK
// ✅ Correct code (method 2: use push_back)
vector<int> v;
v.reserve(100);// Pre-allocate memory (avoid reallocation)
for (int i = 0; i < 100; i++) {
v.push_back(i);//OK
}
Differences:
reserve(n): Only increases capacity (size remains the same)resize(n): Change size (increase capacity if necessary)
The dangerous part of the first snippet is that it often appears to work. After reserve(100) the memory really exists, so v[0] = 10 usually writes somewhere valid and does not crash. But size() is still 0, so the value is invisible to for loops, size(), and copies, and the next push_back overwrites it. Debug builds of MSVC and libstdc++ with _GLIBCXX_ASSERTIONS do catch this with an assertion such as vector subscript out of range.
Use reserve when you know roughly how many elements you are about to push_back and want to avoid repeated reallocation; use resize (or the size constructor) when you want the elements to exist immediately so you can assign by index. A related subtlety: calling reserve repeatedly with small increments inside a loop can defeat the vector’s geometric growth and make appending slower, because each call may reallocate to exactly the requested size. Reserve once, up front.
Iterator invalidation after growth
Symptom: Crash when using iterator after modifying vector. Cause: When a vector is reallocated with push_back, erase, etc., the existing iterator becomes invalid. Solution:
// ❌ Invalid code
vector<int> v = {1, 2, 3, 4, 5};
auto it = v.begin();
v.push_back(6);// Reallocation may occur
cout << *it;// Crash!it is invalid
// ✅ Correct code (Method 1: Use index)
vector<int> v = {1, 2, 3, 4, 5};
int idx = 0;
v.push_back(6);
cout << v[idx];//OK
// ✅ Correct code (Method 2: Prevent reallocation with reserve)
vector<int> v = {1, 2, 3, 4, 5};
v.reserve(100);// Ensure sufficient space
auto it = v.begin();
v.push_back(6);// no reallocation
cout << *it;//OK
// ❌ Use iterator after erase
vector<int> v = {1, 2, 3, 4, 5};
for (auto it = v.begin(); it != v.end(); it++) {
if (*it == 3) {
v.erase(it);// invalidate it!
// Crash if it++
}
}
// ✅ Correct code
vector<int> v = {1, 2, 3, 4, 5};
for (auto it = v.begin(); it != v.end(); ) {
if (*it == 3) {
it = v.erase(it);// erase returns the next iterator
} else {
it++;
}
}
The exact rules are worth memorizing because “may invalidate” is doing a lot of work. If push_back, emplace_back, insert or reserve causes a reallocation, every iterator, pointer and reference into the vector is invalidated. If no reallocation happens, push_back invalidates only end(), and insert invalidates everything at or after the insertion point. erase invalidates the erased element and everything after it. You can tell whether a reallocation will happen by comparing size() with capacity() beforehand, which is exactly what the reserve fix relies on.
The case I have seen cause the most confusion in real code is not iterators at all but pointers and references to elements. Storing &v[3] or a Widget& from a vector inside another object, then adding more elements, leaves that pointer dangling, and nothing at the call site looks suspicious. If other objects need stable handles to elements, store indices instead, or use a container whose elements do not move, such as std::deque (stable on push at either end) or std::list, or hold std::unique_ptr<Widget> in the vector so only the pointers move.
Copying vectors by value into functions
Symptom: Program slows down when passing vectors to functions Cause: Copying entire vector due to value passing. Solution:
// ❌ Slow code (full copy)
void processVector(vector<int> v) { // Copy occurs!
for (int x : v) {
cout << x << " ";
}
}
int main() {
vector<int> v(1000000);// 1 million
processVector(v);// 1 million full copies!
}
// ✅ Fast code (const reference)
void processVector(const vector<int>& v) { // no copy
for (int x : v) {
cout << x << " ";
}
}
// ✅ If modifications are needed (reference)
void modifyVector(vector<int>& v) { // no copy
for (int& x : v) {
x *= 2;
}
}
// ✅ Transfer of ownership (move)
vector<int> createLargeVector() {
vector<int> v(1000000);
// ... initialize ...
return v;// no copy with move semantics
}
What the copy actually costs: passing a million-int vector by value allocates about 4 MB and copies it on every call, so the cost grows with the vector’s size, while a const& parameter passes a single pointer regardless of size. The exact timings depend on the machine and allocator, so measure your own case rather than trusting a fixed ratio; the important point is that one is O(n) per call and the other is O(1).
The createLargeVector case deserves a note, because it is the opposite advice. Returning a local vector by value is not a copy: the compiler constructs it directly in the caller (copy elision) or, failing that, moves it. Writing return std::move(v); here is actually worse, since it disables the elision and forces a move; compilers warn about it with -Wpessimizing-move. The rule of thumb: take input vectors by const&, take vectors you will store or modify-and-keep by value (and std::move them into place), and return vectors by value.
What actually makes vectors fast
- Reserve when you know the size. A loop of
push_backinto an empty vector reallocates about log₂(n) times and moves every element each time. Onereserve(n)up front removes all of that. - Construct in place.
emplace_back(args...)builds the element directly in the vector’s storage;push_back(T(args...))builds a temporary and moves it. For cheap-to-move types the difference is negligible, for types with expensive moves it matters. - Make element moves cheap and
noexcept. When a vector reallocates, it only moves elements if the move constructor isnoexcept; otherwise it copies them to preserve the strong exception guarantee. A user-defined class with a non-noexceptmove constructor can silently turn every reallocation into a full deep copy. - Avoid middle insertion and erasure in loops. Batch them: collect, then sort or use erase-remove once.
- Keep data contiguous. A
vector<Widget>iterates much faster than avector<Widget*>orvector<unique_ptr<Widget>>because the elements are next to each other in memory. Use the pointer variants when you need polymorphism or stable addresses, not by default. - Measure with optimizations on.
-O0builds make everyoperator[]and iterator increment a function call, so vector code looks far slower than it is. Benchmark with-O2and a tool such as Google Benchmark.
In my experience, point 3 is the one that surprises people the most: a class that looks cheap to store in a vector becomes slow only because its move constructor was hand-written without noexcept, and nothing in the code or the compiler output points to it. static_assert(std::is_nothrow_move_constructible_v<Widget>); next to the class makes the assumption explicit.
FAQ
Q1: Should I use std::vector or std::array?
A: Use std::array<T, N> when the size is a compile-time constant and small; it lives inline (often on the stack) with no heap allocation. Use std::vector whenever the size is decided at run time or can change. Both are contiguous and work with the same algorithms.
Q2: When is std::list or std::deque better?
A: Less often than textbooks suggest. std::deque is a good choice for frequent insertion at both ends or when you need references to stay valid while pushing at the ends. std::list wins only when you splice or insert in the middle through iterators you already hold, and its per-node allocation and poor cache locality usually make it slower than a vector even for workloads that look list-friendly.
Q3: Why is vector<bool> different?
A: It is a space-optimized specialization that stores bits, so v[i] returns a proxy object instead of a bool&. You cannot take &v[0] as a bool*, auto x = v[i] holds a proxy rather than a copy, and it is not safe to write different elements from different threads. If you need real bool elements, use vector<char> or std::deque<bool>.
Q4: Does clear() free memory?
A: No. It destroys the elements and sets size() to 0, but capacity() is unchanged. Use shrink_to_fit() or swap with an empty vector if you really need the memory back.
Related Articles
- C++ vector vs list vs deque |
- C++ array vs vector |
- Arrays and Lists
- std::string Pitfalls
- set vs unordered_set in C++
- C++ if and switch Pitfalls