Метод потенциалов: динамический массив

Мы с вами уже много раз говорили о С-массивах и о динамических массивах в С++, которые эффективно поддерживают операции добавления и удаления элементов из конца (то есть работают за константу). В этом разделе мы рассмотрим алгоритм работы динамического массива и проанализируем время его работы с помощью метода потенциалов.

Поскольку мы будем самостоятельно реализовывать динамический массив, мы будем работать с сырой памятью, используя методы new[n] и delete[]. Напомню, что выделение памяти прозвольного размера в RAM-модели занимает $O(1)$ времени.

Метод в лоб

Итак, предположим что у нас есть массив, которых хранит в себе $n$ элементов и приходит запрос на добавление элемента. Тогда:

  1. Выделяем новый массив размера $n + 1$ - $O(1)$
  2. Копируем все элементы из старого массива в новый - $O(n)$
  3. Добавляем новый элемент в конец нового массива - $O(1)$
  4. Удаляем старый массив - $O(1)$

Таким образом, время добавления нового элемента в массив равно $O(n)$. Безусловно это неудовлетворительно, поскольку мы хотим, чтобы операция добавления элемента в массив работала за константу.

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

Подсказка Время работы добавления элемента будет не константа, а константа в среднем.

Одно простое улучшение

Вместо того, чтобы выделять новый массив размера $n + 1$ и копировать в него все элементы, мы можем выделить массив размера $2n$ и добавлять элементы в него. Интуитивно, после этого нам не придется делать копирования следующие $n$ увеличений массива. Однако давайте подробно проанализируем время работы алгоритма с помощью метода “монеток” и докажем формально время $O(1)$ на добавление в среднем.

Метод монеток (потенциалов)

Этот метод крайне прост и нагляден, и в общем случае заключается в следующем:

  1. На каждой “легкой” операции мы будем класть $C$ монеток в банк (где $C$ - некоторая заранее выбранная константа)
  2. На каждой “тяжелой” операции мы будем тратить соответствующее количество монеток из банка (сколько операций нужно, столько мы и возьмем из банка)
  3. Если баланс банка всегда неотрицателен, то среднее время работы на каждой опрации равно $O(1)$.
Доказательство

Поскольку баланс банка всегда неотрицателен, то сумма всех “падений” баланса банка не превышает сумму всех “подъемов” баланса банка. Поскольку сумма всех “подъемов” баланса банка равна $Cn$, то сумма всех “падений” баланса банка не превышает $Cn$, то есть суммарное время работы алгоритма равно $O(n)$, а значит в среднем на одну операцию $O(1)$.

Итак, давайте применим метод монеток к нашему алгоритму добавления элемента в массив.

  1. За каждую операцию добавления элемента в конец массива мы будем класть в банк 2 монетки
  2. За каждую операцию копирования элементов из старого массива в новый мы будем тратить 1 монетку на каждый элемент, то есть всего $n$ монеток

Докажем, что при таком определении банка баланс банка всегда неотрицателен.

Доказательство
  1. Начнем с ситуации, в которой у нас есть массив размера $n$, но в неём зарезервировано $2n$ памяти, а баланс нулевой.
  2. Следующие $n$ операций добавления в массив сделают наш банк размером $2n$ монеток
  3. Затем мы будем вынуждены скопировать все элементы из старого массива (размера $2n$) в новый (размера $4n$) - то есть нам нужно $2n$ операций, но у нас есть $2n$ монеток в банке (за $n$ операций вставки), а значит мы можем себе это позволить
  4. Таким образом, баланс в банке всегда неотрицательный, и, следовательно, время работы алгоритма на добавление элемента в массив равно $O(1)$ в среднем.
Size: 1, Capacity: 2
Bank: 0

*Реализация на 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$ и так продолжать.