Метод потенциалов: динамический массив
Оглавление
Мы с вами уже много раз говорили о С-массивах и о динамических массивах в С++, которые эффективно поддерживают операции добавления и удаления элементов из конца (то есть работают за константу). В этом разделе мы рассмотрим алгоритм работы динамического массива и проанализируем время его работы с помощью метода потенциалов.
Поскольку мы будем самостоятельно реализовывать динамический массив, мы будем работать с сырой памятью, используя методы new[n]
и delete[]
. Напомню, что выделение памяти прозвольного размера в RAM-модели занимает $O(1)$ времени.
Метод в лоб
Итак, предположим что у нас есть массив, которых хранит в себе $n$ элементов и приходит запрос на добавление элемента. Тогда:
- Выделяем новый массив размера $n + 1$ - $O(1)$
- Копируем все элементы из старого массива в новый - $O(n)$
- Добавляем новый элемент в конец нового массива - $O(1)$
- Удаляем старый массив - $O(1)$
Таким образом, время добавления нового элемента в массив равно $O(n)$. Безусловно это неудовлетворительно, поскольку мы хотим, чтобы операция добавления элемента в массив работала за константу.
Прежде чем перейти к улучшению алгоритма я рекомендую вам подумать самостоятельно о том, как можно улучшить алгоритм так, чтобы добавление элемента в массив работало за константу.
Подсказка
Время работы добавления элемента будет не константа, а константа в среднем.Одно простое улучшение
Вместо того, чтобы выделять новый массив размера $n + 1$ и копировать в него все элементы, мы можем выделить массив размера $2n$ и добавлять элементы в него. Интуитивно, после этого нам не придется делать копирования следующие $n$ увеличений массива. Однако давайте подробно проанализируем время работы алгоритма с помощью метода “монеток” и докажем формально время $O(1)$ на добавление в среднем.
Метод монеток (потенциалов)
Этот метод крайне прост и нагляден, и в общем случае заключается в следующем:
- На каждой “легкой” операции мы будем класть $C$ монеток в банк (где $C$ - некоторая заранее выбранная константа)
- На каждой “тяжелой” операции мы будем тратить соответствующее количество монеток из банка (сколько операций нужно, столько мы и возьмем из банка)
- Если баланс банка всегда неотрицателен, то среднее время работы на каждой опрации равно $O(1)$.
Доказательство
Поскольку баланс банка всегда неотрицателен, то сумма всех “падений” баланса банка не превышает сумму всех “подъемов” баланса банка. Поскольку сумма всех “подъемов” баланса банка равна $Cn$, то сумма всех “падений” баланса банка не превышает $Cn$, то есть суммарное время работы алгоритма равно $O(n)$, а значит в среднем на одну операцию $O(1)$.
Итак, давайте применим метод монеток к нашему алгоритму добавления элемента в массив.
- За каждую операцию добавления элемента в конец массива мы будем класть в банк 2 монетки
- За каждую операцию копирования элементов из старого массива в новый мы будем тратить 1 монетку на каждый элемент, то есть всего $n$ монеток
Докажем, что при таком определении банка баланс банка всегда неотрицателен.
Доказательство
- Начнем с ситуации, в которой у нас есть массив размера $n$, но в неём зарезервировано $2n$ памяти, а баланс нулевой.
- Следующие $n$ операций добавления в массив сделают наш банк размером $2n$ монеток
- Затем мы будем вынуждены скопировать все элементы из старого массива (размера $2n$) в новый (размера $4n$) - то есть нам нужно $2n$ операций, но у нас есть $2n$ монеток в банке (за $n$ операций вставки), а значит мы можем себе это позволить
- Таким образом, баланс в банке всегда неотрицательный, и, следовательно, время работы алгоритма на добавление элемента в массив равно $O(1)$ в среднем.
*Реализация на C++
Я напишу шаблонный код - сильно упрощенный аналог std::vector
, который будет поддерживать только операцию добавления элемента в конец массива. И взятия элемента по индексу.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <iostream>
using namespace std;
template <typename T>
class DynamicArray {
private:
T* array; // указатель на массив элементов типа T
int n; // количество элементов в массиве
int capacity; // количество элементов, которые могут быть добавлены в массив
public:
DynamicArray() {
n = 0; // изначально массив пуст
capacity = 1; // изначально зарезервировано место для 1 элемента
array = new T[capacity]; // выделяем память под 1 элемент
}
~DynamicArray() {
delete[] array; // освобождаем память при удалении объекта
}
void push_back(T value) {
if (n == capacity) { // если массив заполнен
reallocate(); // перевыделяем память
}
array[n] = value; // добавляем элемент в конец массива
n++; // увеличиваем количество элементов в массиве
}
T& operator[](int index) {
return array[index];
}
private:
void reallocate() {
T* tempArray = new T[capacity * 2]; // создаем новый массив размера 2 * capacity
for (int i = 0; i < n; i++) {
tempArray[i] = array[i]; // копируем все элементы из старого массива в новый
}
delete[] array; // удаляем старый массив
array = tempArray; // переопределяем указатель на новый массив
capacity *= 2; // увеличиваем capacity вдвое
}
};
int main() {
DynamicArray<int> dynamicArray;
for (int i = 1; i <= 10; i++) {
dynamicArray.push_back(i);
}
for (int i = 0; i < 10; i++) {
cout << dynamicArray[i] << ' ';
}
cout << endl;
return 0;
}
*Деамортизация
В некоторых жизненно важных случаях нужно гарантировать время работы программы не в среднем, а в худшем случае. Действительно, представте, что в среднем ваш запрос в гугл занимает 0.01 секунды, но в один прекрасный день он занимает 100 секунд. Ситуация неприятная, поэтому в некоторых областях нужно гарантировать время работы в худшем случае.
Оказывается, динамический массив можно деамортизировать, то есть гарантировать время работы в худшем случае. Прежде чем перейти к решению я рекомендую подумать над этой задачей как минимум день.
Подсказка
Вам понадобится больше памятиРешение
Давайте опять начнем со случая, когда у вас есть массив размера $2n$ и $n$ элементов в нем заняты.
Ключевой трюк - добавим еще один массив размера $4n$. Теперь на каждое добавление в конец первого массива мы будем совершать еще 2 операции копирования очередных элементов из первого массива во второй. Таким образом к моменту полного заполнения первого массива ($n$ операций) у нас будет второй массив, в котором все элементы из первого массива будут скопированы ($2n$ копирований). После этого мы можем забыть о первом заполненном массиве и создать новый массив размера $8n$ и так продолжать.