Работа с памятью. Олимпиадный аллокатор

В олимпиадном програмировании вам обычно не нужно сталкиваться с методами, напрямую работащими с сырокй памятью в C++ (new, delete), поскольку вы используете готовую обертку - стандартные структуры данных, такие как vector, set, map и другие. Эти обертки сделают всё за вас и вам не придется думать о том, как же выделяется память под переменную. Однако данный блок:

  1. Даст вам больше понимания о том, как работает ваша программа и операционная система
  2. Детали работы с памятью могут помочь в оптимизации кода, в частности, мы напишем свой собственный аллоктор, который значительно ускоряет работу с памятью (но чем-то жертвует).

Области памяти в C++

В языке C++, как и в операционной системе, память для работы программы делится на две основные области:

  • Стек (stack) — структура данных, которая организована по принципу “последним пришёл — первым вышел” (LIFO). Стек используется для хранения локальных переменных, параметров функций и данных, которые должны быть быстро доступны.
  • Куча (heap) — область памяти для динамического выделения, где хранятся объекты и данные, размеры которых заранее неизвестны или могут изменяться во время выполнения программы.

Подробнее поговорим о каждой из областей памяти.

Стек

Особенности переменных на стеке

  • Переменные в стеке создаются очень быстро. Это связано с тем, что операция выделения памяти в стеке сводится к простой модификации указателя стека.
  • Переменные, созданные в стеке, автоматически удаляются при выходе из области видимости, так как стек очищается функцией, освобождающей память, после завершения вызова.
  • Стековые переменные имеют фиксированный размер, который должен быть известен на этапе компиляции - это ещё одна причина быстрого создания переменных в стеке.

Пример создания переменной в стеке

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int arr[1000]; // На стеке

void example() {
    int a = 42; // на стеке
    int* b = &a; // на стеке

    if (true) {
        int c = 42; // на стеке
    } // c удаляется автоматически

    int n;
    cin >> n;
    vector<int> v(n); // не на стеке, а в куче
    // потому что размер массива не известен на этапе компиляции
}
  • Важно: Сильно упрощая, переменная на стеке — это либо базовый тип данных (например, int или double), либо указатель на объект в куче, либо С-массив фиксированного размера.

Куча

Особенности переменных в куче

  • Создание переменной в куче требует больше времени, так как операционная система должна найти подходящий участок памяти в сложной структуре данных (например, двоичное дерево или хэш-таблица).
  • В куче можно создавать объекты любого размера, даже если их размер неизвестен на этапе компиляции.
  • Управление памятью в куче выполняется вручную (с помощью new и delete).

Пример создания переменной в куче

1
2
3
4
5
6
7
void example() {
    int* a = new int(42);       // Выделение памяти под int в куче
    double* b = new double[10]; // Выделение памяти под массив из 10 элементов типа double

    delete a;                   // Удаление переменной из кучи
    delete[] b;                 // Удаление массива из кучи
}

Сравнение стековых и кучевых переменных

Особенность Стек Куча
Время создания Очень быстро Медленнее
Автоматическое удаление Да Нет
Размер известен на этапе компиляции Да Нет
Возможные ошибки Переполнение стека Утечки памяти

При создании программы важно учитывать, что стек имеет фиксированный ограниченный размер, и его переполнение может привести к аварийному завершению программы. В олимпиадном программировании размер стека равен Menory Limit (ML) задачи и поэтому не влияет на ваш код, однако в личных проектах стоит знать о такой особенности и понимать, что в какой-то момент размер стека придется настроить.

Утечка памяти - это ситуация, когда программа выделяет память в куче, но забывает её освободить. Это приводит к тому, что память, которая была выделена, но не используется, остаётся занятой и не может быть использована для других целей. Такая ситуация может привести к тому, что программа начнёт потреблять всё больше и больше памяти, пока не завершится из-за нехватки памяти. В олимпиадном программировании вы не будете работать с сырой памятью, поэтому утечек памяти в ваших программах не бывает.

Олимпиадный аллокатор

Аллокатор (англ. allocator) — это механизм, который отвечает за выделение и освобождение памяти для объектов на куче. В контексте C++ он является частью стандартной библиотеки и используется всеми контейнерами (vector, list, map и т.д.) для работы с памятью. Аллокатор - это более высокий уровень абстракции, чем new и delete. Мы не будем глубоко погружаться в эту абстракцию - главный факт который вам нужно знать, что аллокаторы используются для выделения памяти под объекты на куче и то что они рассчитаны на “стандартные” программы.

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

Итак, мы сделаем следующее:

  1. Вместо использования кучи мы заранее зарезервируем очень много памяти на стеке.
  2. Мы перестанем удалять элементы из памяти. Если ячейка памяти уже была использована, то мы просто не будем её использовать повторно.

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

1
2
3
4
5
6
7
8
9
10
11
12
const int ML = 1e8;

int pos_memory = 0;
char memory[ML];

void* operator new(size_t n) { // двигаем указатель на n байт вперёд
    char *res = memory + pos_memory;
    pos_memory += n;
    assert(pos_memory <= ML); // проверка на то что памяти хватило
    return (void*) res;
}
void operator delete(void *){} // Ничего не делаем

Обратите внимение, это повлияет на все стандартные контейнеры: vector, set, map и другие. Теперь они будут работать быстрее, но с ограничением на количество элементов, когда либо созданных за время работы программы.

Например, если ваш код делал $O(n \log n)$ вставок и удалений в set с ограниечением на то, что всегда во множестве не больше $O(n)$ элементов, то теперь вы будете потреблять $O(n \log n)$ памяти, а не $O(n)$.