4. Базовые структуры данных

Массив

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

Массив позволяет за константное $(O(1))$ время получать доступ к элементу по индексу, а также изменять этот элемент. При этом массив имеет фиксированный размер, поэтому не поддерживает операций вставки и удаления элементов. Если хочется вставить или удалить элемент, можно создать новый массив нужного размера и скопировать информацию в него, но это потребует $\Theta(n)$ операций, где $n$ - длина массива.

Поиск элемента в массиве по значению также имеет сложность $\Theta(n)$, так как нужно проверить все элементы массива. Если массив специально упорядочен (например, элементы массива расположены в возрастающем порядке), то поиск можно делать быстрее (скоро мы изучим алгоритм двоичного поиска).

Немного о работе с массивом в C++:

int a[n]; // объявить массив длины n, нумерация ячеек от 0 до n-1
a[i]; // обращение к i-му элементу массива
a[i] = x; // присвоить x в i-ю ячейку массива
int b[n][m]; // объявить двумерный массив, нумерация ячеек двумя индексами,
    // от 0 до n-1 и от 0 до m-1
b[i][j]; // обращение к j-му элементу i-й строки массива
b[i][j] = y; // присвоение

Связный список

Связный список (linked list) состоит из узлов, каждый из которых содержит данные и ссылки на соседние узлы. В двусвязном списке поддерживаются ссылки на следующий и предыдущий узел, в односвязном списке - только на следующий. Также поддерживаются ссылки на начало и конец списка (их часто называют головой и хвостом). Для того, чтобы посетить все узлы, можно начать с головы и переходить по ссылке на следующий узел, пока он существует.

Преимущество списка перед массивом - возможность вставлять и удалять элементы за $O(1)$.

Недостаток списка - невозможность быстрого доступа к произвольному элементу. Так, доступ к $i$-му элементу можно получить, лишь $i$ раз пройдя по ссылке вперёд, начиная из головы списка (то есть за $\Theta(i)$ ).

Удобно делать голову и хвост фиктивными элементами и не хранить в них никаких данных, тогда функции вставки и удаления элементов пишутся проще.

Примерная реализация двусвязного списка на C++:

struct Node { // один узел списка
    Node *next, *prev; // указатели на следующий и предыдущий узлы
    int x; // данные, хранящиеся в узле
};
struct List {
    Node *head, *tail; // указатели на начало и конец списка
    List() { // инициализируем пустой список - создаём фиктивные head и tail
                // и связываем их друг с другом
        head = new Node();
        tail = new Node();
        head->next = tail;
        tail->prev = head;
    }
    void pushBack(int x) { // вставить x в конец списка
        Node *v = new Node();
        v->x = x, v->prev = tail->prev, v->next = tail;
        v->prev->next = v, tail->prev = v;
    }
    void insert(Node *v, int x) { // вставить x после v
        Nove *w = new Node();
        w->x = x, w->next = v->next, w->prev = v;
        v->next = w, w->next->prev = w;
    }
    void erase(Node *v) { // удалить узел v
        v->prev->next = v->next;
        v->next->prev = v->prev;
        delete v;
    }
    Node *find(int x) { // найти x в списке
        for (Node *v = head->next; v != tail; v = v->next) {
            if (v->x == x) {
                return v;
            }
        }
        return nullptr;
    }
};

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

Можно также использовать встроенный в C++ двусвязный список - std::list. Односвязный список использует меньше дополнительной памяти, но не позволяет перемещаться по списку в сторону начала. Также из него сложнее удалять элементы - не получится удалить элемент, имея ссылку только на него. Нужно как-то получить доступ к предыдущему элементу, чтобы пересчитать ссылку из него на следующий.

Динамический массив

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

живать его реальную длину. Но далеко не всегда максимальное количество элементов известно заранее.

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

int *a; // используемая память
int size; // размер памяти
int n; // реальный размер массива
void pushBack(int x) { // вставить х в конец массива
    if (n == size) {
        int *b = new int[size * 2];
        for (int i = 0; i < size; ++i) {
            b[i] = a[i];
        }
        delete[] a;
        a = b;
        size *= 2;
    }
    a[n] = x;
    n += 1;
}
void popBack() { // удалить последний элемент
    n -= 1;
}

В C++ можно и нужно использовать встроенную реализацию динамического массива std::vector.

Каждая конкретная операция вставки элемента может работать долго $-\Theta(n)$, где $n$ - длина массива. Зато можно показать, что среднее время работы операции вставки $O(1)$. Докажем этот факт при помощи метода амортизационного анализа, который нам ещё не раз пригодится.

Свойство 4.3.1

Среднее время работы операции вставки - $O(1)$.

Это утверждение равносильно тому, что суммарное время работы $m$ операций вставки - $O(m)$.

Доказательство

Операция вставки работает не за $O(1)$ только тогда, когда происходит удвоение размера используемой памяти. Заметим, что если такая операция увеличила размер с size до $2 \cdot$ size, то хотя бы size $/ 2$ предыдущих операций вставки работали за $O(1)$ (может быть, и больше, если происходили также удаления элементов). Тогда суммарное время работы этих size $/ 2+1$ операций есть $O$ (size), поэтому среднее время их работы есть $O(1)$.

Стек, очередь, дек

Стек (stack) позволяет поддерживать список элементов, вставлять элемент в конец списка, а также удалять элемент из конца списка. Часто также говорят, что он организован по принципу LIFO (last in - first out).

Очередь (queue) организована по принципу FIFO (first in - first out). Она позволяет вставлять элемент в конец списка, а также удалять элемент из начала списка.

Дек (deque - double ended queue), или двусторонняя очередь, отличается от обычной очереди тем, что позволяет добавлять и удалять элементы как в начало, так и в конец списка.

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

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

int *a; // используемая память
int size; // размер памяти
int b, e; // дек использует ячейки в диапазоне [b,e), или,
    // если b >= e, в диапазонах [b,size) и [0,e)
int getSize() { // узнать количество элементов в деке
    if (b < e) {
        return e - b;
    } else {
        return e - b + size;
    }
}
void pushBack(int x) { // вставить x в конец дека
    if (getSize() == size) {
        ... // выделяем вдвое больше памяти
    }
    a[e] = x;
    e = (e + 1) % size;
}
void popBack() { // удалить элемент из конца дека
    e = (e - 1 + size) % size;
}
void pushFront(int x) { // вставить x в начало дека
    if (getSize() == size) {
        ... // выделяем вдвое больше памяти
    }
    a[b] = x;
    b = (b - 1 + size) % size;
}
void popFront() { // удалить элемент из начала дека
    b = (b + 1) % size;
}

В C++ существуют встроенные реализации этих структур - std::stack, std::queue, std::deque.