Работа с памятью. Олимпиадный аллокатор
Оглавление
В олимпиадном програмировании вам обычно не нужно сталкиваться с методами, напрямую работащими с сырокй памятью в C++ (new
, delete
), поскольку вы используете готовую обертку - стандартные структуры данных, такие как vector
, set
, map
и другие. Эти обертки сделают всё за вас и вам не придется думать о том, как же выделяется память под переменную. Однако данный блок:
- Даст вам больше понимания о том, как работает ваша программа и операционная система
- Детали работы с памятью могут помочь в оптимизации кода, в частности, мы напишем свой собственный аллоктор, который значительно ускоряет работу с памятью (но чем-то жертвует).
Области памяти в 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
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)$.