Skip to content

← Containers, Strings, Algorithms step 5 of 5

Medium Primitives

Erasing while you walk

Every container promises that its iterators, pointers and references stay valid — until you do something that breaks them. Those promises are specific, and they are the reason this is a rule and not a superstition.

Container What invalidates what
vector growth past capacity invalidates everything; erase invalidates from the erased position onward
deque insert or erase in the middle invalidates everything
map, set, list only the erased element; everything else survives
unordered_map rehashing invalidates all iterators, but references to elements survive

The vector row is the one to internalise, and it is what Track 4 promised to come back to:

int* p = &values[0];
values.push_back(x);      // may reallocate
*p = 1;                   // p points into freed memory

A vector holds one contiguous block. Growing past its capacity means a new block and a relocation, and every pointer, reference and iterator into the old one is now pointing at memory that has been freed.

The erase-while-looping bug

This one does not even need undefined behaviour to be wrong:

for (std::size_t i = 0; i < values.size(); ++i) {
    if (unwanted(values[i])) {
        values.erase(values.begin() + i);
    }
}

Erasing at i shifts everything after it down by one, so the element that moves into position i is then skipped by ++i. Two unwanted values in a row, and the second one survives. Perfectly defined, entirely wrong, and it passes any test whose unwanted elements happen not to be adjacent.

The iterator version of the same loop is worse, because it is undefined rather than merely incorrect. erase returns the iterator to the element after the one removed, precisely so that a loop can be written correctly:

for (auto it = v.begin(); it != v.end(); ) {
    if (unwanted(*it)) {
        it = v.erase(it);   // erase hands back the next valid iterator
    } else {
        ++it;
    }
}

That is correct. It is also O(n²) for a vector — each erase shifts the entire tail — and it is more moving parts than the job needs.

What to write instead

C++20:

std::erase_if(values, unwanted);   // returns how many were removed

One pass, one shift, and the count comes back for free.

Before C++20 it was the erase-remove idiom, which you will still meet constantly:

values.erase(std::remove_if(values.begin(), values.end(), unwanted), values.end());

remove_if does not remove anything — it cannot, since an algorithm only has iterators and no container to shrink. It shuffles the keepers to the front and returns the iterator marking the new end; the container’s erase then drops the tail. Both halves are required, and forgetting the second one leaves the vector its original length with unspecified values at the back.

Your task

int purge(std::vector<int>& values, int divisor);

Remove every value that is exactly divisible by divisor, in place, preserving the order of the rest. Return how many were removed. A divisor of zero removes nothing — division by zero is undefined behaviour, not an exception, so it must not be reached.

The starter erases as it walks and skips whatever moves up behind it.