JavaRush /Курсы /C++ SELF /Удаление при обходе контейнера

Удаление при обходе контейнера

C++ SELF
59 уровень , 0 лекция
Открыта

1. Введение

Удаление элементов из контейнера — это момент, когда программист перестаёт быть романтиком и становится взрослым человеком. Пока мы просто читаем элементы или создаём новый контейнер, всё довольно безопасно. Но как только вы говорите: «иду по контейнеру и прямо сейчас выкидываю некоторые элементы», вы начинаете пилить ветку, на которой стоите. Иногда это нормально (и даже нужно), но только если вы знаете, где у ветки опора.

Практическая мотивация обычно такая: вам важно не просто “отфильтровать всё”, а делать работу по мере обхода. Например, вы идёте по списку задач и удаляете выполненные, параллельно печатая, что именно удалили. Или вы просматриваете map и удаляете элементы по сложному условию, где решение зависит от текущего элемента и, скажем, каких-то счётчиков/логики. В таких ситуациях хочется остаться в одном цикле и не городить «второй проход».

Чтобы в примерах не говорить абстрактно про «элементы», продолжим мини-приложение TaskBook (условный менеджер задач в консоли), где у нас есть список задач, и иногда нужно чистить список прямо во время обхода.

2. Почему erase в цикле часто ломает код

Первое, что делает новичок (и иногда даже опытный, если устал), выглядит примерно так: «иду по vector, вижу плохой элемент, делаю erase, потом как-нибудь продолжаю». И тут появляются две классические проблемы: пропуск элементов и использование «протухших» итераторов/индексов.

Самое неприятное в том, что код может иногда работать. А иногда — “удалять не всё”, “удалять через один”, “падать” или “вести себя странно”. Это как чинить розетку отвёрткой из шоколада: в теории инструмент есть, а на практике — сюрпризы.

Пример плохой идеи: erase в range-for

Range-for скрывает от нас итератор. А erase — это операция, которая меняет структуру контейнера. То есть вам нужно управлять «куда идти дальше», но range-for вам этого не даёт.

#include <vector>

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

    // ПЛОХО: range-for скрывает итератор.
    // При попытке erase вы теряете контроль над продолжением обхода.
    // for (int x : v) {
    //     if (x % 2 == 0) {
    //         v.erase(...); // дальше начинается боль
    //     }
    // }
}

Да, иногда люди пытаются «внутри range-for» найти индекс, а потом удалить. Это не становится лучше — это просто превращается в “range-for + костыли”.

Пример плохой идеи: индексный цикл и erase без коррекции индекса

С индексами другая ловушка: после удаления элементы сдвигаются, а ваш i продолжает расти, будто ничего не случилось. В итоге вы часто пропускаете элемент, который переехал на место удалённого.

#include <vector>

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

    for (std::size_t i = 0; i < v.size(); ++i) {
        if (v[i] == 2) {
            v.erase(v.begin() + static_cast<long long>(i));
            // После erase на позицию i приехал следующий элемент,
            // а цикл сделает ++i и пропустит его.
        }
    }
}

Можно, конечно, после erase делать --i (и следить, чтобы i не ушёл в минус), но это быстро превращается в хрупкую конструкцию, которую страшно трогать.

3. Безопасный паттерн: it = c.erase(it)

Вот теперь — хорошая новость. В STL есть очень практичная идея: удаление по итератору обычно возвращает итератор на следующий элемент. И это не случайно: это ровно то, что нам нужно, чтобы продолжить обход корректно.

Надёжный шаблон выглядит так:

  • если элемент надо удалить, делаем it = c.erase(it) и не инкрементим it вручную;
  • если элемент удалять не надо, делаем обычный ++it.

С точки зрения алгоритма это почти как «сдвиг указателя», но с учётом того, что контейнер внутри может переорганизовать элементы.

Мини-схема (логика цикла)

flowchart TD
    A["it указывает на текущий элемент"] --> B{Удаляем?}
    B -- "да" --> C["it = c.erase(it)"]
    C --> A
    B -- "нет" --> D["++it"]
    D --> A

Пример на std::vector: удаляем отрицательные числа

#include <vector>

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

    for (auto it = v.begin(); it != v.end(); ) {
        if (*it < 0) {
            it = v.erase(it);   // берём следующий валидный итератор
        } else {
            ++it;               // двигаемся сами
        }
    }
}

Почему это работает стабильно? Потому что мы всегда точно знаем, что it после ветки erase указывает туда, куда надо продолжать. Не «куда-то рядом», не «вроде бы на следующий», а именно на корректное место продолжения обхода.

Встраиваем в TaskBook: удаление пустых названий

Пусть задача — это хотя бы текст. Если текст пустой, считаем задачу мусором и удаляем. Мы пока без сложных классов — используем struct.

#include <string>
#include <vector>

struct Task {
    int id = 0;
    std::string title;
};

int main() {
    std::vector<Task> tasks{
        {1, "Buy milk"},
        {2, ""},
        {3, "Read C++ book"},
        {4, ""}
    };

    for (auto it = tasks.begin(); it != tasks.end(); ) {
        if (it->title.empty()) {
            it = tasks.erase(it);
        } else {
            ++it;
        }
    }
}

Обратите внимание: мы используем it->title, потому что it — итератор, а -> это удобный доступ к полям структуры (как будто it “указывает” на элемент).

4. Где паттерн применим: vector, list, map, set

Когда вы пишете код со STL, полезно держать в голове две вещи. Первая — не все контейнеры одинаково устроены внутри. Вторая — не все контейнеры одинаково “болезненно” реагируют на удаление в середине.

Сейчас нас интересует практическое: «могу ли я в этом контейнере удалять по итератору в цикле и продолжать дальше тем же паттерном?». В большинстве стандартных контейнеров ответ: да. Но детали (что инвалидируется, насколько это дорого) различаются.

Ниже — очень прикладная таблица. Она не претендует на формальность стандарта, а даёт “рабочую память”.

Контейнер Можно ли делать it = erase(it)? Что важно помнить новичку
std::vector
да После erase элементы сдвигаются, итераторы на элементы после удалённого обычно инвалидируются. Но возвращаемый итератор — валиден.
std::deque
да Похож на vector по идее, но внутренне устроен иначе. Правило «используй возвращаемый итератор» особенно важно.
std::list
да Удаление дешёвое, остальные итераторы не “портятся” так массово, как у vector. Но паттерн всё равно тот же.
std::map / std::set
да Элементы живут в узлах. erase(it) возвращает следующий итератор — очень удобно для фильтрации.
std::unordered_map / std::unordered_set
да Тоже обычно можно, но помните: при некоторых операциях контейнер может перераспределяться (rehash). В рамках простого цикла «удаляю текущий» этот шаблон — нормальная практика.
std::forward_list
«почти», но иначе Там нет erase(it), потому что итератор односвязный: используется erase_after(prev). Это отдельный стиль.

Психологически важно принять одну мысль: в большинстве контейнеров erase “ломает” какие-то ваши старые итераторы/ссылки, но возвращаемый итератор — ваш спасательный круг. Поэтому мы и строим цикл вокруг него.

Пример на std::map: удаляем задачи с маленьким id

В TaskBook мы можем хранить задачи не только в vector, но и в map<int, Task>, где ключ — id. Это удобно, когда нужно быстро найти задачу по номеру. И да, удалять при обходе тут тоже можно.

#include <map>
#include <string>

struct Task {
    std::string title;
};

int main() {
    std::map<int, Task> tasks{
        {1, {"Buy milk"}},
        {2, {"Read C++ book"}},
        {3, {"Walk"}}
    };

    for (auto it = tasks.begin(); it != tasks.end(); ) {
        if (it->first < 2) {
            it = tasks.erase(it);
        } else {
            ++it;
        }
    }
}

Ключ map лежит в it->first, значение — в it->second. И снова: мы или удаляем и берём новый it, или делаем ++it.

5. Отличие от erase-remove и std::erase_if

Сейчас будет важное разделение “по смыслу”, а не “по синтаксису”. Есть два стиля удаления.

Первый стиль — удаление во время обхода, когда вам нужен контроль над процессом: вы удаляете, что-то считаете, логируете, иногда принимаете решения на основе того, что видели секунду назад. Здесь наш герой — цикл и паттерн it = c.erase(it).

Второй стиль — массовое удаление по условию, когда вы хотите сказать: “удали все элементы, удовлетворяющие предикату, и точка”. Это ближе к «фильтрации». Там часто используют erase-remove идиому (remove_if + erase), а в современном C++ ещё и готовые функции std::erase_if.

Начиная с C++20 в стандартной библиотеке есть свободные функции std::erase и std::erase_if, которые как раз выражают идею “удали всё по условию и верни, сколько удалили”. Это отличный инструмент, но он решает другую задачу: не “удаляю и иду дальше по пути”, а “чисто фильтрую”.

Мини-контраст на одном примере

Представим, что мы хотим удалить все пустые задачи. Если нам не нужно печатать каждую удалённую задачу и вообще не нужны побочные эффекты, то можно мыслить как “фильтр”. Для vector<Task> (и многих других контейнеров) это читается очень прямо:

#include <string>
#include <vector>
// #include <algorithm>  // может не понадобиться, если используете std::erase_if

struct Task {
    int id = 0;
    std::string title;
};

int main() {
    std::vector<Task> tasks{{1, ""}, {2, "Read"}, {3, ""}};

    // Идея: "удали всё, где title пустой"
    // std::erase_if(tasks, [](const Task& t){ return t.title.empty(); });
}

Мы не будем здесь разворачивать erase-remove “по косточкам”, потому что это отдельная техника и у вас в курсе уже было место, где её можно подробно обсудить. Важно другое: если вы удаляете “как фильтр” — используйте фильтровый стиль; если вы удаляете “как процесс” — используйте it = c.erase(it).

6. Когда цикл с erase выигрывает

Здесь хочется дать ощущение “зачем это вообще помнить”. Потому что std::erase_if (или erase-remove) кажется проще, и иногда действительно проще. Но есть сценарии, где цикл с erase — это не “старомодно”, а уместно.

Сценарий A: удаляем и печатаем, что именно удалили

Сделаем мини-функцию, которая удаляет задачи с пустым названием и печатает их id. Это уже не просто фильтр: у нас есть побочный эффект (вывод), и он привязан к моменту удаления.

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

struct Task {
    int id = 0;
    std::string title;
};

int main() {
    std::vector<Task> tasks{{1, ""}, {2, "Read"}, {3, ""}};

    for (auto it = tasks.begin(); it != tasks.end(); ) {
        if (it->title.empty()) {
            std::cout << "Removed task id=" << it->id << '\n'; // Removed task id=1 ...
            it = tasks.erase(it);
        } else {
            ++it;
        }
    }
}

Можно ли было бы “как-то” сделать это через массовое удаление? Теоретически да, но тогда придётся отдельно собирать список удаляемых и отдельно печатать. Иногда так и надо, но часто это лишняя сложность. Здесь цикл читается честно: “иду и удаляю, сообщая”.

Сценарий B: удаляем «пачками», но решение зависит от соседей

Представим правило: если встречаем задачу с названием "TEMP", удаляем её и следующую задачу тоже, потому что следующая — “промежуточная”. Это уже вообще не похоже на простой предикат t -> bool, потому что решение зависит от позиции в последовательности.

#include <string>
#include <vector>

struct Task {
    std::string title;
};

int main() {
    std::vector<Task> tasks{{"A"}, {"TEMP"}, {"B"}, {"C"}};

    for (auto it = tasks.begin(); it != tasks.end(); ) {
        if (it->title == "TEMP") {
            it = tasks.erase(it);          // удалили TEMP, it теперь на "B"
            if (it != tasks.end()) {
                it = tasks.erase(it);      // удалили "B"
            }
        } else {
            ++it;
        }
    }
}

Да, это уже “логика”, и именно поэтому нам нужен контроль над итератором.

Сценарий C: удаление в set при обходе

Для множества задач (например, набор “заблокированных слов” или “тегов”) set удобен уникальностью. И там часто делают “пройти и удалить лишнее”.

#include <set>
#include <string>

int main() {
    std::set<std::string> tags{"work", "temp", "cpp"};

    for (auto it = tags.begin(); it != tags.end(); ) {
        if (*it == "temp") {
            it = tags.erase(it);
        } else {
            ++it;
        }
    }
}

Код выглядит одинаково, хотя контейнер совершенно другой. И это, честно говоря, одна из причин любить STL: при аккуратном стиле код становится переносим между контейнерами.

Нюанс: erase может вернуть end()

Если вы удаляете последний элемент, то “следующий” — это end(). И это нормально. Паттерн цикла как раз это и учитывает: условие it != end() проверится, и цикл корректно завершится.

Важно не пытаться разыменовывать итератор после erase, не проверив, что это не end(). В примере с "TEMP" выше мы сделали if (it != tasks.end()) перед вторым удалением — это ровно такая защита.

7. Типичные ошибки при удалении элементов при обходе контейнера

Ошибка №1: делать ++it после it = c.erase(it).
Это самая популярная “ошибка на автомате”. Кажется, что раз мы в цикле, то надо обязательно инкрементить итератор. Но erase уже вернул вам следующий элемент. Если вы сделаете ++it ещё раз, вы перескочите через один элемент и получите загадочное “удаляет не всё”.

Ошибка №2: удалять элементы внутри range-for и надеяться, что «оно само разрулится».
Range-for создан для чтения (или аккуратного изменения значений), но не для структурных изменений контейнера. Удаление меняет контейнер, и без явного итератора вы теряете контроль над тем, что такое “следующий элемент”. Обычно это заканчивается пропусками или падениями, а иногда — ещё хуже — «иногда работает».

Ошибка №3: смешивать индексы и erase в vector, не меняя логику индекса.
В vector удаление элемента сдвигает хвост. Если вы удалили v[i], то бывший v[i+1] становится новым v[i]. Если ваш цикл дальше делает ++i, вы пропускаете этот элемент. Часто новички потом добавляют --i, и начинается танец с проверками, где легко ошибиться на границах.

Ошибка №4: хранить итераторы/ссылки на элементы vector, а потом удалять из него элементы.
Даже если вы удаляете “не тот элемент”, vector может сдвинуть элементы, и ваша ссылка/итератор на “старый объект” станет невалидной. В результате ошибка может проявиться далеко от места удаления. В простом и безопасном стиле лучше считать: “после структурных изменений vector нельзя доверять старым ссылкам и итераторам”.

Ошибка №5: забывать проверку it != end() в сценариях, где вы после удаления делаете ещё действия.
Паттерн it = erase(it) сам по себе безопасен, но часто после удаления хочется удалить ещё что-то или прочитать следующий элемент. Если удалили последний, итератор станет end(). Поэтому при “двухшаговых” удалениях (как в примере с "TEMP") обязательно добавляйте проверку на end(), иначе вы разыменуете конец диапазона.

1
Задача
C++ SELF, 59 уровень, 0 лекция
Недоступна
Нулевая уборка
Нулевая уборка
1
Задача
C++ SELF, 59 уровень, 0 лекция
Недоступна
Чётный карантин
Чётный карантин
1
Задача
C++ SELF, 59 уровень, 0 лекция
Недоступна
Пустые карточки
Пустые карточки
1
Задача
C++ SELF, 59 уровень, 0 лекция
Недоступна
Сломанный график
Сломанный график
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ