Почти любая программа что-то хранит и что-то с этим делает: список товаров в корзине, сообщения в чате, точки на карте. Вопрос, который встаёт всегда, — как разложить эти данные, чтобы с ними было удобно и быстро работать. От ответа зависит, будет ли поиск нужной записи мгновенным или программа задумается на секунды, влезет ли всё в память и легко ли добавить новый элемент. Ответ на этот вопрос и дают структуры данных и алгоритмы. Этот раздел — маршрут по ним с нуля, от массива до графа.
Что такое структура данных и алгоритм
Структура данных — это способ организации данных в памяти (или на диске). Массив, связанный список, стек, дерево, хеш-таблица — всё это разные способы разложить одни и те же данные. Каждый способ по-своему хорош: один быстро ищет, другой быстро вставляет, третий экономит память.
Алгоритм — это последовательность шагов, решающая задачу над данными. Для большинства структур базовые задачи одни и те же:
- вставить новый элемент;
- найти нужный элемент;
- удалить элемент;
- перебрать все элементы по очереди;
- отсортировать элементы по порядку.
Структура и алгоритм связаны: способ хранения определяет, какие операции будут дешёвыми, а какие — дорогими. Поэтому их изучают вместе.
Три роли структур данных
Где это вообще пригодится? Условно — три большие области.
Хранение реальных данных. Данные о людях, товарах, заказах, операциях. Классическая аналогия — картотека с карточками: каждая карточка описывает одну сущность (человека, товар), а вся стопка — базу. Как только карточек становится много, возникают те самые вопросы: как быстро найти нужную, как добавить новую, как отсортировать по алфавиту. Реальные базы данных сложнее картотеки, но идея та же.
Инструментарий программиста. Не все структуры видит пользователь. Многие нужны самой программе как рабочий инструмент: стек, чтобы разобрать выражение или откатить действие; очередь, чтобы обработать задачи по порядку; приоритетная очередь, чтобы всегда доставать самое срочное. Это вспомогательные структуры «под капотом».
Моделирование. Некоторые структуры прямо отражают реальный мир. Граф моделирует маршруты между городами, связи в социальной сети или зависимости между задачами. Очередь моделирует клиентов в банке или машины на въезде. Здесь структура — это карта самой задачи.
Базовые термины
Несколько слов, которые встречаются постоянно; проще договориться о них сразу.
В картотеке одна карточка описывает одного человека целиком: это запись (record), в коде обычно объект класса. Имя, адрес и телефон на карточке это её поля (field), в объекте его переменные. А то поле, по которому карточку ищут, называют ключом (key): ищем сотрудника по фамилии, значит фамилия ключ; у тех же данных ключей может быть несколько, сегодня фамилия, завтра телефон.
Эти термины помогают говорить об операциях единообразно: «найти запись по ключу», «вставить запись», «удалить по ключу» — независимо от того, какая структура внутри.
Обзор: какая структура для чего
Главная мысль всего раздела: идеальной структуры нет — у каждой свои сильные и слабые стороны. Выбор всегда компромисс. Пока хватит трёх строк: нужен доступ по номеру — массив; по ключу — хеш-таблица; по ключу и ещё в порядке — дерево. Остальное в таблице ниже, к каждой строке вернёмся в своей статье.
| Структура | Сильна в | Слаба в |
|---|---|---|
| Массив | доступ по индексу, вставка в конец | поиск, удаление, фиксированный размер |
| Упорядоченный массив | поиск (двоичный) | вставка и удаление |
| Стек | доступ по принципу «последним пришёл — первым вышел» | доступ к остальным элементам |
| Очередь | доступ по принципу «первым пришёл — первым вышел» | доступ к остальным элементам |
| Дек | добавление и выемка с обоих концов; стек и очередь в одном | доступ к середине |
| Связанный список | вставка и удаление | поиск |
| Двоичное дерево поиска | быстрые поиск, вставка, удаление (если сбалансировано) | сложное удаление, деградация без баланса |
| Сбалансированное дерево (красно-чёрное, 2-3-4) | всё быстро и гарантированно | сложность реализации |
| Хеш-таблица | очень быстрый доступ по ключу | нет порядка, память, плохо без ключа |
| Куча (пирамида) | быстро достать наибольший/наименьший | доступ к остальным элементам |
| Граф | моделирование связей | некоторые алгоритмы дороги |
Запоминать таблицу не нужно — она станет очевидной по мере чтения.
Слева то, что структура делает за один-два шага, справа то, чем она за это платит: строки без правой колонки здесь нет.
У выбора есть и вторая ось, которая в таблице спрятана в одном слове «память» напротив хеш-таблицы. Массив хранит только сами значения и лежит плотнее всех. Связанный список и дерево тратят на каждый элемент отдельный объект-узел с двумя-тремя ссылками, и это в несколько раз больше, чем занимает само значение. Хеш-таблица держит массив корзин заметно просторнее, чем данных, иначе ключи начинают сталкиваться, плюс узел на каждую запись.
Когда записей миллионы, разница между «четыре байта на число» и «сорок байт на число» становится разницей между «влезло в память» и «нет», и структуру выбирают не только по скорости операций. Цифры по каждой структуре есть в её статье, сводка в статье про выбор структуры.
Как измеряют «быстро» и «медленно»
Слова «быстро» и «медленно» скоро станут точными. Скорость алгоритма измеряют не в секундах (они зависят от железа), а в том, как растёт число операций с ростом объёма данных — это называют O-нотацией. Поиск в массиве перебором на миллионе элементов делает до миллиона шагов; двоичный поиск в упорядоченном массиве — около двадцати. Эту разницу и описывает O-нотация. Саму мерку, степени и логарифмы, разбирает следующая статья, математика для O-нотации, а впервые она работает на массивах и двоичном поиске.
Одно число на операцию при этом всегда с оговоркой, и её стоит замечать с первой таблицы. «Хеш-таблица ищет за O(1)» верно в среднем, на нормальных ключах; в худшем случае, когда ключи столпились в одной корзине, поиск дольше. «Быстрая сортировка за O(N·log N)» тоже в среднем, а на неудачных данных O(N²). А «вставка в массив за O(1)» верна амортизированно: почти всегда мгновенно, изредка дорогой переезд в больший массив. Поэтому рядом со сложностью в статьях раздела стоит пометка, какой случай имеется в виду, и для сервиса, где важна каждая задержка, смотрят на худший, а для пакетной обработки на средний.
Одни и те же записи, две раскладки. Список ищет перебором — до восьми сравнений; хеш-таблица считает номер корзины по ключу и попадает с первого раза. Выбор структуры — это выбор цены операции.
Как это сделано в стандартной библиотеке
Грабля, общая для всех. В Java тип переменной и настоящая структура — разные вещи. За объявлением List<String> items может стоять и ArrayList, и LinkedList, а items.get(i) стоит у них O(1) и O(N) соответственно. Тип говорит, что с данными можно делать; сколько это стоит, знает только конкретный класс. Поэтому, читая чужой код, смотрите на строчку с new, а не на объявление.
Хорошая новость: почти всё из таблицы выше в Java уже написано — эти структуры лежат в стандартной библиотеке коллекций (Java Collections Framework). Свой связанный список или своё дерево в рабочем коде приходится писать редко. Но выбирать приходится каждый день, и цена ошибки ровно та же: взяли неподходящую структуру — получили медленный код там, где мог быть быстрый. Поэтому в статьях раздела у каждой структуры есть блок про её готовую реализацию: какой класс, что у него внутри и где он подводит. Вот карта целиком:
| Структура | Класс в Java | Что внутри |
|---|---|---|
| Массив | ArrayList | обычный массив; когда заполнился, переезжает в новый, в полтора раза больше |
| Связанный список | LinkedList | двусвязный список; концы дёшевы, а доступ по номеру — обход от края |
| Стек и очередь | ArrayDeque | массив, замкнутый в кольцо; годится и как стек, и как очередь |
| Хеш-таблица | HashMap | корзины по хешу ключа, совпавшие ключи — цепочкой в одной корзине; слишком длинная цепочка перестраивается в дерево |
| Куча | PriorityQueue | двоичная куча, уложенная в обычный массив; на вершине всегда минимум |
| Дерево поиска и сбалансированное | TreeMap, TreeSet | красно-чёрное дерево; всё по порядку и за O(log N) |
| Граф | своего класса нет | собирают руками: у каждой вершины список соседей — в Map или, если вершины пронумерованы, в массиве |
Разницу видно на десятке строк: положим одни и те же записи в ArrayList и в HashMap и поищем последнюю.
живой пример
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class Lookup {
public static void main(String[] args) {
System.out.println(find(10));
System.out.println(find(1000));
}
static String find(int size) {
List<String> list = new ArrayList<>();
Map<String, Integer> map = new HashMap<>();
for (int i = 1; i <= size; i++) {
list.add("key-" + i);
map.put("key-" + i, i);
}
String target = "key-" + size;
int compared = 0;
for (String key : list) {
compared++;
if (key.equals(target)) break;
}
return size + " записей: перебор списка — " + compared
+ " сравнений, HashMap вернул " + map.get(target) + " сразу";
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Грабля, общая для всех. В Go тип виден сразу: []string это срез, map[string]int это хеш-таблица, а за any может стоять что угодно. Но цена операции в типе не написана: append почти всегда O(1), но иногда переезжает в новый массив; поиск по срезу через slices.Contains это перебор за O(N), а по map за O(1). Читая чужой код, смотрите не на объявление, а на то, что со структурой делают в цикле.
Хорошая новость: часть таблицы уже лежит в стандартной библиотеке, а часть в Go принято собирать из среза и map руками, потому что это пять строк. Дерева поиска в стандартной библиотеке нет вовсе. Поэтому в статьях раздела у каждой структуры есть блок про её готовую реализацию: какой тип, что у него внутри и где он подводит. Вот карта целиком:
| Структура | В Go | Что внутри |
|---|---|---|
| Массив | срез []T | указатель на массив, длина и ёмкость; когда место кончилось, append переезжает в массив вдвое больше, после 256 элементов примерно в 1,25 раза |
| Связанный список | container/list | двусвязный список из Element со значением any; в бою почти всегда проигрывает срезу |
| Стек и очередь | срез []T | стек это append и s[:len(s)-1]; очередь это срез с индексом головы или канал |
| Хеш-таблица | map[K]V | корзины по хешу ключа, с Go 1.24 «швейцарская таблица»; порядок обхода намеренно случайный |
| Куча | container/heap | функции над вашим типом с методами Less, Swap, Push, Pop; сама куча лежит в срезе |
| Дерево поиска и сбалансированное | нет | сторонний пакет вроде github.com/google/btree или отсортированный срез со slices.BinarySearch |
| Граф | своего типа нет | map[int][]int или срез срезов, если вершины пронумерованы |
Разницу видно на десятке строк: положим одни и те же записи в срез и в map и поищем последнюю.
живой пример
package main
import "fmt"
func main() {
fmt.Println(find(10))
fmt.Println(find(1000))
}
func find(size int) string {
list := make([]string, 0, size)
index := make(map[string]int, size)
for i := 1; i <= size; i++ {
key := fmt.Sprintf("key-%d", i)
list = append(list, key)
index[key] = i
}
target := fmt.Sprintf("key-%d", size)
compared := 0
for _, key := range list {
compared++
if key == target {
break
}
}
return fmt.Sprintf("%d записей: перебор среза — %d сравнений, map вернула %d сразу",
size, compared, index[target])
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Грабля, общая для всех. В JavaScript у переменной вообще нет объявленного типа: const items = [] и const items = new Map() выглядят одинаково безобидно, а items.includes(x) и items.has(x) стоят O(N) и O(1). Движок к тому же меняет внутреннее устройство массива на ходу: массив из одних целых чисел лежит плотно, но стоит положить в него строку или оставить дырку, и он становится медленнее. Читая чужой код, смотрите, чем структуру создали и что в неё кладут.
Хорошая новость: массив, хеш-таблица и множество в языке есть (Array, Map, Set), остальное собирают из них руками или берут пакет. Поэтому в статьях раздела у каждой структуры есть блок про её готовую реализацию: что есть в языке, что у него внутри и где он подводит. Вот карта целиком:
| Структура | В JavaScript | Что внутри |
|---|---|---|
| Массив | Array | динамический массив; движок V8 держит плотные числа отдельно от объектов и растёт с запасом примерно в полтора раза |
| Связанный список | нет | пишут класс с полем next руками; нужен редко, массив почти всегда быстрее |
| Стек и очередь | Array | стек это push и pop; очередь через shift стоит O(N), для больших очередей держат индекс головы или берут пакет denque |
| Хеш-таблица | Map, Set | хеш-таблица с сохранением порядка вставки; обычный объект {} тоже словарь, но с ключами-строками и своими сюрпризами |
| Куча | нет | пишут двоичную кучу в массиве сами или берут пакет вроде heap-js |
| Дерево поиска и сбалансированное | нет | сторонний пакет вроде sorted-btree или отсортированный массив с двоичным поиском |
| Граф | своего класса нет | Map<number, number[]> или массив массивов |
Разницу видно на десятке строк: положим одни и те же записи в массив и в Map и поищем последнюю.
живой пример
function find(size) {
const list = [];
const index = new Map();
for (let i = 1; i <= size; i++) {
list.push(`key-${i}`);
index.set(`key-${i}`, i);
}
const target = `key-${size}`;
let compared = 0;
for (const key of list) {
compared++;
if (key === target) break;
}
return `${size} записей: перебор массива — ${compared} сравнений, Map вернула ${index.get(target)} сразу`;
}
console.log(find(10));
console.log(find(1000));
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Грабля, общая для всех. В Python тип написан на самой структуре, но цена операции там не написана: x in items для list это перебор за O(N), для set и dict это O(1), а выглядит одинаково. Читая чужой код, смотрите, что стоит справа от = при создании: [], {}, set() или deque().
Хорошая новость: почти всё из таблицы выше уже есть в стандартной библиотеке, а свой связанный список или дерево в рабочем коде пишут редко. Но выбирать приходится каждый день, и цена ошибки ровно та же: взяли неподходящую структуру — получили медленный код там, где мог быть быстрый. Поэтому в статьях раздела у каждой структуры есть блок про её готовую реализацию: какой тип, что у него внутри и где он подводит. Вот карта целиком:
| Структура | В Python | Что внутри |
|---|---|---|
| Массив | list | массив ссылок на объекты, растёт с небольшим запасом, около одной восьмой, поэтому append в среднем O(1) |
| Связанный список | collections.deque | двусвязный список из блоков по 64 элемента: концы дёшевы, доступ по номеру в середину O(N) |
| Стек и очередь | list, collections.deque | стек это append и pop списка; очередь только deque, потому что list.pop(0) сдвигает всё за O(N) |
| Хеш-таблица | dict, set | открытая адресация, порядок вставки сохраняется, с Python 3.7 это гарантия языка |
| Куча | heapq | функции над обычным списком: heappush, heappop; на вершине всегда минимум |
| Дерево поиска и сбалансированное | нет | bisect над отсортированным списком или сторонний sortedcontainers |
| Граф | своего класса нет | dict со списками соседей или collections.defaultdict(list) |
Разницу видно на десятке строк: положим одни и те же записи в list и в dict и поищем последнюю.
живой пример
def find(size: int) -> str:
items = []
index = {}
for i in range(1, size + 1):
items.append(f"key-{i}")
index[f"key-{i}"] = i
target = f"key-{size}"
compared = 0
for key in items:
compared += 1
if key == target:
break
return f"{size} записей: перебор списка — {compared} сравнений, dict вернул {index[target]} сразу"
print(find(10))
print(find(1000))
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Записей стало в сто раз больше — список ищет в сто раз дольше, а хеш-таблице всё равно. Это и есть O(N) против O(1).
Коротко
- Структура данных — способ организации данных; алгоритм — шаги операций над ними (вставка, поиск, удаление, перебор, сортировка).
- Структуры нужны для трёх вещей: хранить реальные данные, служить инструментом программисту, моделировать задачи из мира.
- Базовый словарь — запись, поле, ключ — позволяет говорить об операциях единообразно.
- Идеальной структуры нет: каждая быстра в одном и медленна в другом, выбор — всегда компромисс под задачу.
- «Быстро» измеряют O-нотацией — тем, как растёт число операций с объёмом данных.
- У выбора две оси: скорость операций и память на элемент; массив плотнее всех, список и дерево платят объектом-узлом, хеш-таблица запасом корзин.
- Сложность операции всегда с пометкой: в среднем, в худшем случае или амортизированно; для задержек смотрят на худший, для пакетов на средний.
Что почитать дальше
- Математика для O-нотации — степени и логарифмы, если школьная математика подзабылась.
- Массивы, двоичный поиск и O-нотация — первая структура раздела и первая мерка скорости.
- Простая сортировка: пузырёк, выбор, вставка — три учебных алгоритма, на которых видно, откуда берётся
O(N²). - Как выбрать структуру данных — итог раздела: та же таблица, но уже с пониманием, почему так.