reserve/ resize: зачем и как уменьшать массив

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

1. Почему std::vector перевыделяет память

Когда вы только начали программировать, массив выглядел как вещь спокойная: вот int arr[10], вот 10 чисел — и никуда они не денутся. Но std::vector живёт активной жизнью. Он умеет расти, а значит, ему иногда нужно больше памяти, чем было выделено изначально. И вот тут начинается главное кино сегодняшней лекции.

Представьте, что vector — это автобус с местами. size() — сколько пассажиров уже сидит. capacity() — сколько сидений вообще есть. Пока есть свободные сиденья, новый пассажир (push_back) заходит легко. Но когда автобус полный, диспетчер присылает новый, более большой автобус, пассажиры пересаживаются, старый автобус уезжает. Это и есть перевыделение памяти (reallocation).

Мини-демо: наблюдаем, что capacity() растёт скачками

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;

    for (int i = 0; i < 10; ++i) {
        v.push_back(i);
        std::cout << "size=" << v.size()
                  << " capacity=" << v.capacity() << '\n';
    }
}

Здесь полезно увидеть глазами: capacity() обычно увеличивается не на 1, а «порциями». Это сделано, чтобы не переезжать на каждый push_back.

Почему переезд дорогой

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

2. reserve(n): выделяем память заранее

Если вы заранее знаете, сколько данных будет добавлено (например, «введи N чисел»), то reserve() — это способ сказать вектору: «Дорогой, я почти уверен, что нам понадобится как минимум n мест. Давай подготовимся и не будем переезжать каждые 5 минут».

Важнейшая мысль: reserve() управляет памятью, но не количеством элементов. То есть size() от reserve() не меняется.

Пример: reserve не меняет size

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;

    v.reserve(100);

    std::cout << v.size() << '\n';     // 0
    std::cout << v.capacity() << '\n'; // >= 100
}

После reserve(100) элементов по-прежнему нет. Это означает, что обращаться к v[0] всё ещё нельзя (даже если capacity() большой). Мы ещё подробно поговорим о безопасном доступе позже, но интуицию фиксируем прямо сейчас: «место есть» не означает «элемент существует».

Пример: reserve + push_back — меньше переездов

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;
    v.reserve(10);

    for (int i = 0; i < 10; ++i) {
        v.push_back(i);
    }

    std::cout << "size=" << v.size() << '\n';       // size=10
    std::cout << "capacity=" << v.capacity() << '\n';
}

Если мы заранее знаем, что добавим 10 элементов, то reserve(10) почти всегда уменьшит количество перевыделений (иногда до нуля).

Когда reserve() особенно полезен

Чаще всего reserve() используют, когда программа накапливает данные: читает ввод, собирает результаты вычислений, хранит историю действий. То есть когда мы делаем много push_back.

И да, reserve() — это не «магическая оптимизация ради оптимизации». Это скорее «не заставляй программу делать лишнюю работу, если ты уже знаешь план».

3. resize(n): меняем количество элементов

resize(n) звучит похоже на reserve(n), и именно поэтому новички так часто путают эти две команды. Но смысл у resize другой: resize меняет реальное число элементов. То есть после resize(n) становится верно v.size() == n.

И вот здесь начинается важная практическая разница: после увеличения через resize появляются новые элементы, и они получают значение по умолчанию (для int это 0, для double это 0.0, для std::string — пустая строка).

Пример: увеличили resize — получили новые элементы

#include <iostream>
#include <vector>

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

    v.resize(5);

    std::cout << v.size() << '\n'; // 5
    std::cout << v[3] << ' ' << v[4] << '\n'; // 0 0
}

Да, здесь мы используем []. Это нормально в демонстрации, потому что индексы мы выбираем корректные (в пределах 0..size()-1). Позже мы разберём, какие есть альтернативы и как это влияет на безопасность.

Пример: уменьшили resize — элементы с конца исчезли

#include <iostream>
#include <vector>

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

    v.resize(2);

    std::cout << v.size() << '\n';  // 2
    std::cout << v[0] << ' ' << v[1] << '\n'; // 10 20
}

При уменьшении resize вектор «обрезается». Это удобно, когда вы сначала выделили «с запасом по количеству элементов», а потом решили, что реально нужно меньше.

Важное наблюдение: resize() тоже может привести к выделению памяти

Если вы делаете resize(1'000'000), а у вектора capacity() была маленькая, ему придётся выделить память под миллион элементов. То есть resize может быть не только про «логический размер», но и про реальную память.

Поэтому иногда делают связку: сначала reserve(n), потом постепенно push_back, а иногда — сразу resize(n) и заполняют по индексам.

4. Как выбрать между reserve и resize

Когда два слова отличаются одной буквой, у программиста включается режим «ну это почти одно и то же». На этом моменте многие и теряют пару часов жизни. Давайте зафиксируем разницу максимально «в лоб».

Таблица: reserve против resize

Операция Что меняет Что НЕ меняет Что появляется по факту
v.reserve(n)
capacity()
становится
>= n
size()
не меняется
Новых элементов нет
v.resize(n)
size()
становится
n
capacity()
может вырасти (если не хватает)
Элементы добавляются или удаляются

Если вам нужно запомнить одну фразу: reserve — «подготовь место», resize — «сделай элементы».

Практическая модель: копим vs заполняем по местам

Чтобы не выбирать между reserve и resize на уровне «мне кажется…», полезно думать в двух сценариях.

Первый сценарий: «копим». Мы не обращаемся к элементам по индексу сразу, мы просто добавляем их один за другим: push_back. Это похоже на то, как вы складываете чеки в коробку: «ещё один чек, ещё один». Для такого сценария reserve идеален.

Второй сценарий: «заполняем по местам». Мы заранее знаем, что будет n элементов, и хотим заполнить их в цикле по индексам. Например, считать n чисел и положить их на позиции 0..n-1. Здесь удобно resize, потому что он создаёт элементы, и индексы становятся корректными.

Это не «правильный и неправильный» путь. Это два разных способа работать, и вы выбираете тот, который совпадает с задачей.

5. Практический пример: мини‑приложение «Учёт расходов»

Продолжим идею учебного приложения, которое хранит список чисел. Пусть это будут расходы за день (в евро).

Сделаем два режима:

  1. пользователь заранее знает, сколько расходов будет введено (N чеков) → делаем reserve(N) и читаем N чисел через push_back;
  2. пользователь хочет ввести расходы за 7 дней недели «по ячейкам» → делаем resize(7) и заполняем v[i].

Режим «введи N расходов»: используем reserve и push_back

Подводка простая: если пользователь прямо говорит нам число N, а мы всё равно не делаем reserve(N), то мы как бы добровольно соглашаемся на возможные лишние переезды. Компьютер, конечно, справится, но ему будет обидно.

#include <iostream>
#include <vector>

int main() {
    std::size_t n = 0;
    std::cin >> n;

    std::vector<int> expenses;
    expenses.reserve(n);

    for (std::size_t i = 0; i < n; ++i) {
        int x = 0;
        std::cin >> x;
        expenses.push_back(x);
    }

    std::cout << "count=" << expenses.size() << '\n'; // count=n
}

Здесь важно, что reserve(n) не добавляет элементы, и мы честно добавляем их через push_back. В результате size() становится n.

Режим «7 дней недели»: используем resize и заполнение по индексам

Теперь другой сценарий: у нас фиксированное количество «слотов» — 7 дней. Нам не нужно «накапливать неизвестно сколько», нам нужно создать 7 элементов и заполнить каждый.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> by_day;
    by_day.resize(7);

    for (std::size_t day = 0; day < by_day.size(); ++day) {
        std::cin >> by_day[day];
    }

    std::cout << by_day[0] << '\n'; // расход в день 0 (условно понедельник)
}

Смысл resize здесь очень «предметный»: мы создаём 7 элементов, и теперь by_day[day] — это существующий элемент.

6. Полезные нюансы

Как reserve уменьшает перевыделения

В этом месте обычно хочется сказать: «Всё, понял: reserve быстрее». Но давайте чуть аккуратнее.

Когда вы делаете много push_back без reserve, вектор растёт так: он выделяет память, заполняется, потом понимает, что места нет, и расширяется. Из-за этого некоторые push_back «обычные», а некоторые «с переездом».

reserve(n) говорит: «Сразу сделай буфер минимум под n элементов». Тогда первые n добавлений обычно пройдут без расширений. Это особенно заметно на больших объёмах данных.

Главное правило использования: reserve имеет смысл, когда вы можете reasonably угадать размер. Не обязательно идеально. Даже приблизительный размер часто уже полезен.

Уменьшение: что делает resize, и что не делает reserve

Иногда возникает задача: «я набрал много данных, потом понял, что часть лишняя». Тут логика такая: если вы хотите удалить элементы с конца, resize(smaller) — ваш базовый инструмент. Он уменьшит size(), то есть число элементов.

Но важно понимать: уменьшение size() не обязательно означает уменьшение capacity(). То есть вектор мог остаться с большим запасом выделенной памяти. Это нормально: стандартная библиотека не обязана тут «сжиматься», потому что это может быть дорого, а иногда даже вредно (если вы потом снова начнёте добавлять элементы).

Кстати, в мире стандартных обсуждений есть отдельная тема про то, где именно в спецификациях должны описываться вещи вроде reserve и shrink_to_fit (что куда относится: к «capacity» или к «modifiers»). Это один из тех моментов, где видно: даже у стандарта бывает «перестановка полок» в шкафу.

Мы в рамках текущего дня не уходим в «сжатие памяти» (это отдельная тема и отдельные нюансы). Нам достаточно дисциплины: resize управляет количеством элементов, reserve управляет запасом.

7. Мини‑схема выбора

Чтобы закрепить мысль, полезно держать в голове простую «блок‑схему» решения.

flowchart TD
    A[Нужно хранить данные в vector] --> B{Знаю число элементов заранее?}
    B -- Нет --> C[Коплю через push_back]
    C --> D["Если могу оценить размер: reserve(примерный_n)"]
    B -- Да --> E{Хочу добавлять постепенно?}
    E -- Да --> F["reserve(n) + push_back"]
    E -- Нет, хочу заполнить по индексам --> G["resize(n) + заполнение v[i]"]

Эта схема не «единственно правильная», но она помогает перестать путать reserve и resize и начать выбирать их осознанно.

8. Типичные ошибки при работе с reserve() и resize()

Ошибка №1: считать, что reserve(n) создаёт n элементов.
Это самая частая путаница. После reserve(n) вектор всё ещё может быть пустым (size() == 0), и попытка обратиться к v[0] — это обращение к несуществующему элементу. Ментальная модель должна быть такая: reserve — «выделил места в памяти», но «пассажиры ещё не зашли».

Ошибка №2: использовать capacity() как истинный размер данных.
Иногда новички начинают писать циклы до capacity(), потому что «раз уж место есть». Но «место есть» не равно «элементы существуют». В логике программы почти всегда ориентируемся на size(). capacity() — это скорее технический параметр, полезный для оптимизации, а не для смысла данных.

Ошибка №3: делать reserve() внутри цикла на каждом шаге.
Выглядит примерно так: на каждой итерации вы делаете reserve(v.size() + 1), надеясь «пусть всегда хватает». На практике это может ухудшить ситуацию: вы сами провоцируете частые перераспределения, потому что каждый раз просите новый размер. reserve обычно делают один раз перед большим набором данных, а не как ежедневную молитву.

Ошибка №4: путать сценарии «копим» и «заполняем по индексам».
Если вы хотите добавлять значения по одному — используйте push_back, а reserve пусть только снижает количество переездов. Если вы хотите заполнять вектор по индексам — сначала сделайте resize, чтобы элементы появились, а потом уже заполняйте. И наоборот: resize ради того, чтобы потом всё равно делать push_back, часто превращается в лишние нули в конце и путаницу «а почему элементы удвоились».

Ошибка №5: ожидать, что уменьшение resize() обязательно уменьшит capacity().
Когда вы уменьшили size(), элементы исчезли с точки зрения логики контейнера. Но память может остаться выделенной. Это не утечка и не баг — просто у вектора есть право «оставить автобус побольше», вдруг вы через минуту снова повезёте толпу пассажиров.

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