JavaRush /Курсы /C++ SELF /Удаление из std::vector

Удаление из std::vector: erase()

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

1. erase() и инвалидация привязок

Когда вы впервые слышите «удалить элемент из массива», мозг рисует картинку: взял и вычеркнул один квадратик, всё остальное не трогай. У std::vector так нельзя по вполне практичным причинам: он хранит элементы подряд в памяти, без дырок. Поэтому удаление — это почти всегда сдвиг оставшихся элементов влево. А значит, меняются «позиции», и некоторые «запомненные указания» на элементы перестают быть корректными.

Представим список дел: ["купить молоко", "помыть кота", "сдать лабу"]. Если удалить середину, вектор обязан сделать так, чтобы элементы снова лежали подряд: ["купить молоко", "сдать лабу"]. Для вас это выглядит логично, но для итератора, который «смотрел» на элемент «сдать лабу», это уже совсем другая история: он мог стать недействительным или начать указывать на «не то».

Модель без дырок: что физически происходит при erase

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

Схематично это можно представить так:

До:
индексы:  0      1      2      3
данные:  [10]   [20]   [30]   [40]

Удаляем элемент с индексом 1 (20)

После:
индексы:  0      1      2
данные:  [10]   [30]   [40]

Элемент [30] переехал на место [20], [40] переехал на место [30]. Это важно: «на месте удалённого» оказывается другой элемент. Из-за этого любые привязки к элементам справа (итераторы, ссылки) становятся опасными: они либо смотрят не туда, либо вообще в «никуда».

erase(pos): удаляем один элемент по итератору

std::vector удаляет элементы через метод erase, и главное здесь — он принимает итератор, а не индекс. Это логично: итератор — это «позиция» в контейнере, а erase умеет удалять по позиции.

«Удалить по индексу» тоже можно, но тогда вы сначала превращаете индекс в итератор (или доходите до нужной позиции через ++), и только потом вызываете erase.

Мини‑пример: удалим второй элемент (20), не используя арифметику итераторов, а только ++.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30, 40};

    auto it = v.begin();
    ++it;            // теперь it указывает на 20
    v.erase(it);     // удаляем 20

    for (std::size_t i = 0; i < v.size(); ++i) {
        std::cout << v[i] << ' ';  // 10 30 40
    }
    std::cout << '\n';
}

erase(it) не «зануляет» место, а перестраивает последовательность. После удаления следующий элемент (30) занимает место удалённого.

erase(first, last): удаляем диапазон [first, last)

Иногда нужно удалить не один элемент, а целый кусок: например, «удалить элементы со 2-го по 4-й». В vector это делается erase(first, last), и тут есть важнейшее правило, которое вам будет встречаться ещё много раз в C++: диапазоны задаются как [first, last), то есть «левый включаем, правый не включаем».

last — это позиция после последнего удаляемого.

Удалим из [1, 2, 3, 4, 5] числа 2, 3, 4, снова соберём итераторы через ++:

#include <iostream>
#include <vector>

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

    auto first = v.begin();
    ++first; // на 2

    auto last = first;
    ++last;  // на 3
    ++last;  // на 4
    ++last;  // на 5 (то есть "после 4")

    v.erase(first, last); // удаляем 2,3,4

    for (std::size_t i = 0; i < v.size(); ++i) {
        std::cout << v[i] << ' ';  // 1 5
    }
    std::cout << '\n';
}

Полезная формулировка для самопроверки: last должен указывать на элемент после последнего удаляемого. В примере последний удаляемый — 4, значит last должен указывать на 5.

Что возвращает erase, и почему это спасает циклы

Если бы erase просто удалял и ничего не возвращал, удаление во время обхода превращалось бы в игру «угадай, куда теперь смотрит итератор». Поэтому erase возвращает итератор: позицию на элемент, который оказался на месте удалённого (или end(), если удалили последний элемент).

Мини‑пример: удалим 6 и продолжим работать с итератором, который вернул erase.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{5, 6, 7, 8};

    auto it = v.begin();
    ++it;               // it на 6
    it = v.erase(it);   // удалили 6, it теперь на 7

    if (it != v.end()) {
        std::cout << *it << '\n';  // 7
    }
}

Ключевая привычка: после erase не доверяем старому итератору, а берём тот, который вернула функция.

Удаление в цикле: плохой и хороший вариант

Когда вы впервые пишете «удалить все элементы, равные X», рука сама выводит что-то вроде: «иду по вектору, если встретил X — erase». И это тот случай, когда код может даже «иногда работать», что особенно опасно.

Плохой сценарий обычно выглядит так: итератор увеличивается в заголовке цикла, а внутри мы ещё и делаем erase, который ломает текущую позицию.

#include <vector>

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

    for (auto it = v.begin(); it != v.end(); ++it) {
        if (*it == 2) {
            v.erase(it); // после этого it уже небезопасен
        }
    }
}

Правильная идея такая: если мы удалили текущий элемент, то «следующая позиция» уже выдана результатом erase. А если не удалили — тогда спокойно делаем ++it.

#include <iostream>
#include <vector>

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

    for (auto it = v.begin(); it != v.end(); ) {
        if (*it == 2) {
            it = v.erase(it);   // берём возвращённую позицию
        } else {
            ++it;               // двигаемся сами
        }
    }

    for (std::size_t i = 0; i < v.size(); ++i) {
        std::cout << v[i] << ' '; // 1 3
    }
    std::cout << '\n';
}

Обратите внимание: в заголовке цикла нет ++it. Это не «хитрый трюк», а дисциплина: теперь только один участок кода отвечает за перемещение итератора, и вы не получите «двойной шаг» или шаг по уже несуществующей позиции.

Инвалидация: что именно становится недействительным после erase

Под инвалидацией мы понимаем ситуацию, когда итератор/ссылка/указатель больше не гарантирует корректный доступ к тому элементу, на который он «смотрел». Использовать такой объект — это как читать адрес на конверте после того, как дом «переехал» в другой город.

Для std::vector удобна такая памятка:

Операция с vector Что может сломаться Интуиция
erase(pos) / erase(first,last) Итераторы/ссылки/указатели на удалённый элемент и элементы правее элементы сдвигаются влево
push_back(...) (когда нет места и нужен «переезд») Все итераторы/ссылки/указатели на элементы буфер меняется, элементы оказываются по другим адресам
reserve(...) (если увеличил capacity и случился «переезд») Все итераторы/ссылки/указатели тот же «переезд»

Про «переезд» вектора мы говорили ранее, когда обсуждали capacity() и перевыделение памяти. Здесь фиксируем следствие: если вектор поменял внутренний буфер, то любая «привязка к элементу» (итератор, ссылка) становится опасной.

Небольшая ремарка из жизни C++: формулировки стандартных правил про erase и инвалидацию действительно исторически обсуждались и уточнялись. То есть если вам сегодня кажется, что erase «скользкий», это нормально — тема реально тонкая.

Ссылка на элемент: почему T& опасна при изменении vector

Когда вы пишете int& ref = v[1];, вы создаёте ссылку — второе имя для конкретного элемента вектора. На вид это почти как обычная переменная, но у неё есть важное отличие: она живёт не сама по себе, а указывает в память, принадлежащую вектору. И если вектор перестроился (удаление/«переезд»), ссылка может стать недействительной.

Пример «выглядит безобидно, но на самом деле опасно»:

#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

    int& ref = v[1];        // ref ссылается на "20"
    v.erase(v.begin());     // удалили 10, элементы сдвинулись

    // ref теперь может ссылаться "не туда".
}

Здесь важно не пытаться угадать «что будет конкретно»: в учебной модели достаточно понять, что после изменения структуры вектора старые ссылки/итераторы нельзя использовать.

И ещё более жизненный сценарий: вы взяли ссылку, потом сделали push_back, а vector решил переехать в новый буфер (потому что capacity() кончилась). Тогда ссылке конец почти гарантирован.

#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

    int& ref = v[1];   // "20"
    v.push_back(40);   // может случиться перевыделение

    // ref может стать недействительным
}

Да, это неприятно. Зато теперь понятно, почему в реальных проектах часто советуют не держать ссылки на элементы vector дольше, чем нужно.

Мини‑таск‑лист: удаляем задачу по индексу

Сейчас у нас ещё нет классов и сложной архитектуры, поэтому сделаем учебный «мини‑таск‑лист»: std::vector<std::string> tasks, где каждая строка — одна задача. Добавим удаление по номеру (индексу), но аккуратно: сначала проверим границы, потом найдём итератор и вызовем erase.

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

int main() {
    std::vector<std::string> tasks{"Buy milk", "Wash cat", "Submit lab"};

    std::size_t idx = 0;
    std::cin >> idx;

    if (idx < tasks.size()) {
        auto it = tasks.begin();
        for (std::size_t k = 0; k < idx; ++k) ++it;
        tasks.erase(it);
    }

    for (std::size_t i = 0; i < tasks.size(); ++i) {
        std::cout << i << ": " << tasks[i] << '\n';
    }
}

Тут спрятаны две важные привычки. Во-первых, мы проверяем idx < tasks.size(), потому что индекс приходит «снаружи» (из ввода), а значит, он не заслужил доверия. Во-вторых, мы не пытаемся удалять по индексу напрямую, а честно переводим индекс в итератор, потому что erase работает с позициями.

Если вы сейчас подумали «эх, хочется tasks.begin() + idx», то поздравляю: ваш мозг уже нащупал идею random-access итераторов. Но в этой части курса мы держимся в пределах того, что уже ввели: begin()/end() и ++.

2. Типичные ошибки при удалении из std::vector

Ошибка №1: использовать итератор после erase, как будто ничего не произошло.
erase меняет структуру вектора и делает текущую позицию «скользкой». Типичный симптом: программа иногда пропускает элементы, иногда падает, иногда ведёт себя странно. Лечится дисциплиной: после it = v.erase(it) используем именно возвращённый итератор и не делаем ++it автоматически «по привычке».

Ошибка №2: перепутать диапазон [first, last) и ожидать, что last тоже удалится.
Это классика: вы хотите удалить «со 2 по 4 включительно», строите last на элемент 4, а потом удивляетесь, что 4 остался. Правильная модель не про «включительно», а про «последний не включаем»: last должен указывать на позицию после последнего удаляемого элемента.

Ошибка №3: держать ссылку (T&) на элемент, а потом менять vector.
Ссылка выглядит как удобная переменная, но она привязана к внутренней памяти контейнера. После erase, push_back (при перевыделении) или даже reserve (если произошёл «переезд») такая ссылка может стать недействительной. Если вам нужно «просто запомнить значение», берите копию (например, int x = v[i];). Если нужно «помнить позицию», помните, что позиция может сломаться при модификациях.

Ошибка №4: пытаться удалять в цикле for (...; ++it) и внутри делать erase(it).
Здесь ломается логика шага: erase уже перестроил последовательность, а вы ещё и увеличиваете итератор по старой схеме. В лучшем случае вы пропустите проверку для элемента, который сдвинулся на удалённую позицию; в худшем — получите обращение к невалидной позиции. Надёжный паттерн: цикл без автоматического ++it, где шаг делается либо ++it, либо it = erase(it).

Ошибка №5: думать, что инвалидация бывает только после erase.
Даже если сегодня мы фокусируемся на удалении, важно помнить связь с темой capacity(): если вектор переехал из‑за роста буфера, то ломаются вообще все итераторы/ссылки. Поэтому логика вида «сохранили итератор, потом много push_back, потом вернулись к итератору» — это заявка на приключение, которое вы не заказывали.

1
Задача
C++ SELF, 12 уровень, 5 лекция
Недоступна
Вычеркнуть запись
Вычеркнуть запись
1
Задача
C++ SELF, 12 уровень, 5 лекция
Недоступна
Стереть отрезок
Стереть отрезок
1
Задача
C++ SELF, 12 уровень, 5 лекция
Недоступна
Уборка по значению
Уборка по значению
1
Задача
C++ SELF, 12 уровень, 5 лекция
Недоступна
Следующий после удаления
Следующий после удаления
1
Опрос
std::vector: динамический массив, 12 уровень, 5 лекция
Недоступен
std::vector: динамический массив
std::vector: динамический массив
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ