Введение
В завершение разбора структур данных рассмотрим три специализированные структуры, каждая решающая конкретный, узкий класс задач значительно эффективнее общих структур: кучу для эффективного доступа к минимуму/максимуму (уже использованную в алгоритме Дейкстры), trie (префиксное дерево) для эффективной работы со строками по общим префиксам, и union-find для эффективного отслеживания принадлежности элементов к непересекающимся группам.
Концепция
Куча (heap) — структура, гарантирующая O(1) доступ к минимальному (или максимальному, в зависимости от типа кучи) элементу и O(log n) для вставки нового элемента или удаления текущего минимума/максимума, реализуемая, как правило, через массив с неявной структурой полного бинарного дерева (без явных указателей между узлами, в отличие от деревьев ) — std::priority_queue предоставляет готовую, стандартную реализацию этой структуры. Trie организует набор строк в дерево, где каждый путь от корня соответствует общему префиксу набора строк, давая эффективный поиск всех строк с заданным префиксом (полезно для автодополнения, статья, упоминавшая поисковые подсказки) значительно быстрее линейного перебора всех строк с проверкой префикса для каждой. Union-find (disjoint set union) эффективно поддерживает разбиение множества элементов на непересекающиеся группы, поддерживая две основные операции — объединение двух групп и определение, принадлежат ли два элемента одной группе, обе почти константной (амортизированной) сложности при правильной реализации с оптимизациями (сжатие пути, объединение по рангу).
Пример кода
// std::priority_queue — куча для задачи "K наибольших элементов" (вариант: K заказов с наибольшей суммой)
#include <queue>
QVector<double> kLargestOrderAmounts(const QVector<double> &allAmounts, int k)
{
std::priority_queue<double, std::vector<double>, std::greater<>> minHeap; // мин-куча размера k
for (double amount : allAmounts) {
minHeap.push(amount);
if (minHeap.size() > k) {
minHeap.pop(); // удаляем наименьший — оставляем только k НАИБОЛЬШИХ элементов из всех виденных
}
}
QVector<double> result;
while (!minHeap.empty()) {
result.append(minHeap.top());
minHeap.pop();
}
return result; // O(n log k) вместо O(n log n) полной сортировки всех элементов
}
// Trie — эффективный поиск по префиксу (концептуальная реализация для автодополнения)
struct TrieNode {
std::unordered_map<QChar, std::unique_ptr<TrieNode>> children;
bool isEndOfWord = false;
};
class Trie {
public:
void insert(const QString &word) {
TrieNode *current = &root;
for (QChar ch : word) {
if (!current->children.contains(ch)) {
current->children[ch] = std::make_unique<TrieNode>();
}
current = current->children[ch].get();
}
current->isEndOfWord = true;
}
bool hasPrefix(const QString &prefix) const {
const TrieNode *current = &root;
for (QChar ch : prefix) {
auto it = current->children.find(ch);
if (it == current->children.end()) return false; // префикс не найден вовсе
current = it->second.get();
}
return true; // дошли до конца префикса — он существует в trie
}
private:
TrieNode root;
};
// Union-Find — отслеживание связных компонент (например, группы взаимосвязанных пользователей)
class UnionFind {
public:
UnionFind(int n) : parent(n), rank(n, 0) {
for (int i = 0; i < n; ++i) parent[i] = i; // изначально каждый элемент — отдельная группа
}
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]); // сжатие пути — ускоряет будущие вызовы
return parent[x];
}
void unite(int x, int y) {
int rootX = find(x), rootY = find(y);
if (rootX == rootY) return;
if (rank[rootX] < rank[rootY]) std::swap(rootX, rootY); // объединение по рангу
parent[rootY] = rootX;
if (rank[rootX] == rank[rootY]) rank[rootX]++;
}
private:
std::vector<int> parent, rank;
};
Пояснения к коду
kLargestOrderAmounts показывает практичное применение мин-кучи фиксированного размера k для задачи «K наибольших элементов» — поддержание кучи именно размера k (а не полной сортировки всех элементов) даёт сложность O(n log k), что для большого общего количества элементов n и относительно небольшого k значительно эффективнее полной сортировки O(n log n). Trie::hasPrefix проходит ровно по символам искомого префикса, независимо от общего количества хранимых в trie слов — это даёт сложность поиска по префиксу, зависящую только от длины самого префикса, а не от количества слов в коллекции, что принципиально отличается от линейной проверки префикса для каждой строки коллекции отдельно. UnionFind::find с «сжатием пути» (parent[x] = find(parent[x])) — оптимизация, которая при каждом вызове find «выпрямляет» путь до корня группы для всех посещённых при этом вызове элементов, делая будущие вызовы find для этих же элементов значительно быстрее; в сочетании с «объединением по рангу» эта структура достигает почти константной амортизированной сложности для обеих основных операций.
Подводные камни
- Использование
std::priority_queueбез понимания, что по умолчанию она реализует МАКС-кучу (наибольший элемент сверху), а не мин-кучу — задача, реально требующая эффективного доступа к минимальному элементу (как в реализации алгоритма Дейкстры или в примереkLargestOrderAmounts), требует явного указанияstd::greater<>как компаратора при созданииpriority_queue, и забытое указание этого параметра даёт формально работающий, но логически неверный (противоположный нужному) порядок извлечения элементов. - Реализация trie с хранением всех возможных детей узла через массив фиксированного размера (например, размером алфавита) вместо
unordered_map, как в примере, для случаев со значительно более широким диапазоном возможных символов (Unicode, а не только ASCII-алфавит) — фиксированный по размеру массив для каждого узла при широком диапазоне возможных символов тратит огромное количество памяти на преимущественно пустые, неиспользуемые ячейки, и для таких случаевunordered_map, хранящий только реально присутствующие переходы, значительно эффективнее по памяти, хотя и немного медленнее по константе доступа к отдельному переходу. - Реализация Union-Find без обеих ключевых оптимизаций (сжатие пути и объединение по рангу/размеру) — без этих оптимизаций структура остаётся корректной по своей базовой логике, но может деградировать до O(n) для отдельных операций в худшем случае (длинная, невыпрямленная цепочка родителей), тогда как с обеими оптимизациями достигается почти константная амортизированная сложность — пропуск этих, относительно простых для реализации оптимизаций существенно меняет практическую применимость структуры для больших наборов данных.
- Применение специализированной структуры данных (куча, trie, union-find) для задачи, где более простая, общая структура решает ту же задачу не менее эффективно на практике — каждая из этих специализированных структур добавляет определённую сложность реализации и понимания по сравнению с более общими структурами, и эта дополнительная сложность оправдана только реальной потребностью в её специфичных, узких преимуществах (как поиск по префиксу для trie или эффективное определение принадлежности к группе для union-find), а не использованием «потому что это более продвинутая структура данных».