Могучая куча
В этой главе мы изучим
Два абстрактных типа данных — очередь и приоритезированную очередь.
Изобретённую в 1964 году структуру данных — двоичную кучу — которую можно хранить в обычном массиве.
Двоичную кучу с порядком убывания, в которой элемент, имеющий наибольшее числовое значение приоритета считается элементом с наивысшим приоритетом, и двоичную кучу с порядком возрастания, в которой наивысшим считается приоритет с минимальным числовым значением.
Как добавить пару (значение, приоритет) в двоичную кучу за O(log N) операций, где N — количество элементов в куче.
Как и где найти в двоичной куче элемент с наивысшим приоритетом за O(1) операций.
Как снять с вершины двоичной кучи элемент с наивысшим приоритетом за O(log N) операций.
Что если мы будем хранить не просто набор значений, а набор пар, в котором каждому значению будет сопоставлен некоторый числовой приоритет? Будем считать, что чем выше приоритет, тем «важнее» для нас значение, так что на этот раз нам нужно уметь только добавлять пару (значение, приоритет) и получать из нашей структуры одно значение с наивысшим приоритетом, одновременно удаляя его оттуда (это называется операцией снятия).
Мы сейчас описали работу т. н. «приоритезированной очереди» — структуры данных, в которой эффективно реализованы две операции: добавления пары enqueue(значение, прироритет) и снятия (удаления + возврата) значения с наивысшим приоритетом значение = dequeue(). Приоритезированная очередь не похожа на уже изученные нами в предыдущей главе хеш-таблицы: нам не нужно заранее знать приоритет, чтобы снять наиболее приоритетное значение.
Допустим, ночной клуб переполнен, и у входа уже выстроилась очередь, как на иллюстрации 4-1. Для того, чтобы попасть внутрь, каждый вновь пришедший должен встать в конец очереди. Первым попадёт в клуб человек из начала очереди — он ждал дольше всех. Так работает собственно очередь — структура данных, в которой предусмотрена операция добавления последнего появившегося значения в конец очереди, enqueue(значение), и операция снятия первого значения по времени добавления, dequeue(). Иными словами, принцип работы очереди — «первым вошёл, первым вышел» («First in, first out», или FIFO), что в развёрнутом виде можно прочесть как «(элемент, который) первым вошёл (в очередь), первым вышел (из неё, как только к ней обратились)».
![]() |
| Иллюстрация 4-1. Ночной клуб. Ожидание в очереди. |
Упомянутую в прошлой главе структуру данных — связный список — можно было бы смоделировать на Python довольно просто: каждый элемент списка — это пара (значение, следующий элемент). Весь список — это «матрёшка» таких пар, в каждой из которых, кроме последней, поле .next — это следующая пара, а в последней .next равен None.
Из таких узлов можно выстроить и очередь. На иллюстрации 4-2 показан результат добавления в клубную очередь «Ивана», «Ирины» и «Игоря» (именно в таком порядке). «Иван» окажется первым, кого снимут из очереди, тогда в ней останется два посетителя, из которых первой окажется «Ирина».
![]() |
| Иллюстрация 4-2. Три варианта представления очереди в ночной клуб |
Уровень абстракции и модель памяти языка Python позволяет нам относиться к связным спискам и как к «матрёшкам», в которых весь «хвост» списка вложен в его первый элемент, и как к «бахроме», в которых каждый объект представлен изолированно, а в узлах хранятся только ссылки на эти объекты. Действительности, однако, соответствует именно вторая модель, «бахрома»: поэтому, например, фактический размер узла в списке (элемента типа Node) не зависит от размера поля .value. Поскольку обе модели тяжелы для восприятия, впредь мы будем пользоваться их упрощённым гибридом, «цепочкой», в которой явными ссылками представлены только элементы списка, а все остальные данные проще показывать как хранящиеся внутри соответствующих структур. Сами такие структуры мы время от времени будем называть узлами.
В примере 4-1 класс Queue имеет два метода — .enqueue() для добавления элемента в конец связного списка и .dequeue() для снятия элемента из его начала. Оба метода требуют константное время для работы, независимо от количества элементов в очереди.
1 class Queue:
2 def __init__(self): # 1
3 self.first = None
4 self.last = None
5
6 def is_empty(self):
7 return self.first is None # 2
8
9 def enqueue(self, val):
10 if self.first is None: # 3
11 self.first = self.last = Node(val)
12 else:
13 self.last.next = Node(val) # 4
14 self.last = self.last.next
15
16 def dequeue(self):
17 if self.is_empty():
18 raise RuntimeError('Queue is empty')
19 val = self.first.value # 5
20 self.first = self.first.next # 6
21 return val
Пример 4-1. Реализация очереди при помощи связного списка.
Поначалу и first, и last — это None.
Если first — пусто, очередь считается пустой.
Если очередь пуста, первый добавляемый элемент и есть последний.
Если очередь непуста, добавляем элемент после текущего last; на этот новый элемент last должен указывать в дальнейшем.
Значение, которое нужно вернуть, хранится в first.
Первый элемент исключается из списка, и first теперь указывает на следующий (или равно None, если такого не было).
Поменяем условия. Допустим, ночной клуб стал продавать своим завсегдатаям входные билеты произвольной стоимости, кто сколько заплатит, и проставлять эту стоимость на выданном билете. К примеру, один может купить пятидесятидолларовый билет, а другой — стодолларовый. Люди всё так же встают в очередь, когда клуб переполнен, но первым в него войдёт тот, чей билет дороже всех остальных билетов в очереди. Если самые дорогие билеты оказываются у нескольких человек, очередь на вход предоставляется одному из них. Безбилетники считаются обладателями бесплатного билета.
На иллюстрации 4-3 в середине очереди есть завсегдатай с самым дорогим — стодолларовым — билетом. Если сейчас двери откроются, он первым попадёт в клуб. За ним последуют двое с пятидесятидолларовыми билетами (в неустановленном пока порядке), следом — безбилетники; они тоже считаются равноправными (владельцами бесплатных билетов), так что их порядок также не определён.
![]() |
| Иллюстрация 4-3. Кто больше заплатит, войдёт первым |
![]() |
В приоритезированной очереди не задаётся, что делать с двумя и больше значениями, приоритеты которых совпадают. В действительности приоритезированная очередь может возвращать такие значения не в порядке добавления — это зависит от реализации. Вариант на основе кучи, описанный в этой главе, возвращает элементы с одинаковым приоритетом не в том порядке, в котором их добавляли. Встроенный модуль heapq, о котором мы поговорим в главе 8, тоже использует кучу. |
Исправленные таким образом требования определяют абстрактный тип данных — приоритезированную очередь. Правда, в ней уже нельзя реализовать обе операции — enqueue() и dequeue() — за константное время. С одной стороны, если воспользоваться связным списком, то enqueue() останется O(1), а вот dequeue() придётся просматривать все элементы в поисках значения с наивысшим приоритетом, так что в худшем случае сложность ожидается O(N). С другой стороны, если всегда держать элементы в очереди упорядоченными по приоритету, dequeue() окажется O(1), а enqueue() в худшем случае потребует O(N) просмотров в поисках правильного места для вставки в список.
По опыту судя, есть пять сравнительно простых способов организовать из набора объектов типа Entry приоритезированную очередь пар (значение, приоритет):
- Array
Неупорядоченный массив элементов безо всякой внутренней структуры — может, и так сойдёт. Операция enqueue() занимает константное время, а dequeue() мало того, что занимается поиском по всему массиву, так ещё и вынуждена переставлять его элементы после удаления одного. Вдобавок массив имеет фиксированный размер, так что очередь может ещё и переполниться. В Python такого типа данных нет.
- Built-in
Вместо массива в Python можно использовать тип list, и воспользоваться встроенными методами поиска, добавления и удаления элементов. Если элементы в нём не упорядочивать, сложность операций останется той же, что и при использовании массива, разве что мультипликативная константа будет меньше и снимется ограничение по размеру.
- OrderA
Массив, элементы которого упорядочены по приоритету. Необходимый для enqueue() поиск места для вставки элемента по такому массиву можно убыстрить с помощью двоичного поиска (описан в примере 2-4), однако мы всё равно вынуждены копировать часть массива вручную, чтобы освободить нужную ячейку посреди других элементов. При этом dequeue() может иметь константную сложность, так как в массиве, упорядоченном по приоритетам, нужный элемент оказывается всегда в конце. Массив имеет фиксированный размер, так что очередь может ещё и переполниться. В Python такого типа данных нет
- Linked
Связный список, элементы которого всегда расположены по убыванию приоритета: начальный элемент имеет наивысший приоритет, все остальные — меньший либо равный приоритету предыдущего элемента. В этом варианте в операции enqueue() линейную сложность имеет поиск места для вставки элемента, а сама вставка и операция dequeue() константны.
- OrderL
Элементы хранятся в порядке приоритетов в Python-овском динамическом массиве типа list. Для поиска в enqueue() можно снова использовать двоичный поиск, но сама вставка (встроенными в list методами) требует перемещения в среднем половины элементов массива, и оттого линейна. Операция dequeue() ожидаемо константна, потому что элемент с наибольшим приоритетом находится в конце массива.
Мы сравнили производительность всех описанных способов, для чего провели испытания, в которых благополучно вызвали 3·N/2 операции enqueue() и 3·N/2 операции dequeue()1. Для каждого способа измерялось отношение общего времени работы к количеству операций, 3N, в результате получалось среднее время выполнения одной операции. Результаты, показанные в таблице 4-1, говорят следующее. Имитация «массива», в которой все действия реализованы явно, оказалась самым медленным вариантом, а использование встроенных методов list ускорило тест почти вдвое. Упорядоченное хранение элементов в «массиве» снова почти удвоило скорость, использование связных списков ускорило тест ещё примерно на 20%; однако реализация OrderL, посредством упорядоченных списков типа list и их встроенных методов, оказалась вне конкуренции.
N |
Heap |
OrderL |
Linked |
OrderA |
Built-in |
Array |
256 |
6.4 |
2.5 |
3.9 |
6.0 |
8.1 |
13.8 |
512 |
7.3 |
2.8 |
6.4 |
9.5 |
14.9 |
26.4 |
1024 |
7.9 |
3.4 |
12.0 |
17.8 |
28.5 |
52.9 |
2048 |
8.7 |
4.1 |
23.2 |
33.7 |
57.4 |
107.7 |
4096 |
9.6 |
5.3 |
46.6 |
65.1 |
117.5 |
220.3 |
8192 |
10.1 |
7.4 |
95.7 |
128.4 |
235.8 |
446.6 |
16384 |
10.9 |
11.7 |
196.4 |
255.4 |
470.4 |
899.9 |
32768 |
11.5 |
20.3 |
— |
— |
— |
— |
65536 |
12.4 |
36.8 |
— |
— |
— |
— |
Таблица 4-1. Среднее время операции в наносекундах для объёма данных размера N
Так или иначе, для всех упомянутых способов среднее время либо enqueue(), либо dequeue() прямо пропорционально N. А вот в столбце, помеченном как «Heap», числа ведут себя по-другому: среднее время выполнения пропорционально log(N). Этот столбец содержит результаты эксперимента над структурой данных Heap (т. н. кучей); на иллюстрации 4-4 хорошо видно, насколько она эффективней нашего победителя — упорядоченных списков Python. Хороший эмпирический признак логарифмической сложности — константное приращение времени выполнения при удвоении объёма входных данных. В таблице 4-1 объём входных данных как раз удваивается в каждой следующей строке — и среднее время работы одной операции с кучей увеличивается примерно на 0.8 наносекунд.
Простейший вариант кучи — т. н. «двоичную кучу» — изобрёл для своего алгоритма сортировки валлийско-канадский математик Дж. В. Дж. Вильямс (в русскоязычной литературе этот алгоритм обычно называют пирамидальной сортировкой). С помощью кучи можно добиться логарифмической сложности операций над приоритезированной очередью. В этой главе мы не станем больше обращать внимания на тип поставленного в очередь объекта: это могут быть строки, числа или вообще картинки, значения не имеет. Нам будет важен только числовой приоритет, который хранится в паре с объектом. Так что во всех иллюстрациях, где для простоты показан только приоритет, подразумевается и объект, который с этим приоритетом поставлен в очередь. В куче с порядком убывания у того из двух объектов приоритет выше, у кого он больше.
В нашем варианте куча будет иметь заранее заданный наибольший размер, M, и сможет хранить N < M элементов. Теперь давайте изучим её структуру, посмотрим, как она растёт (в рамках наибольшего размера) и убывает в процессе работы с ней, а также научимся хранить Nэлементов кучи в обычном массиве.
![]() |
| Иллюстрация 4-4. Логарифмическая сложность операций над кучей куда эффективнее линейной сложности других подходов |
Производительность пяти простых подходов |
(вертикально) Среднее время операции в наносекундах |
N = объём входных данных |
Сравнение производительности Heap и OrderL |
(вертикально) Среднее время операции в наносекундах |
N = объём входных данных |
метки не переводятся, в названии второго графика именно OrderL, а не Order, в иллюстрации ошибка -- FrBrGeorge 2022-08-25 19:01:26
Двоичная куча
Не вполне понятно зачем, но предположим, что наши данные отсортированы только частично. Например, на иллюстрации 4-5 изображена двоичная куча с частичным порядком — убыванием приоритетов2; у каждого элемента показан только приоритет. На уровне 0 находится единственный элемент, и приоритет его будет наивысшим среди всех элементов кучи. Если два элемента x → y связывает стрелка, то приоритет x больше либо равен приоритету y.
![]() |
| Иллюстрация 4-5. Пример двоичной кучи с частичным убыванием |
уровень 0 |
уровень 1 |
уровень 2 |
уровень 3 |
уровень 4 |
Частично упорядоченная по убыванию куча — это не сортированный список, в ней, например, нельзя сразу найти элемент с наименьшим приоритетом (если что, он на уровне 3). Зато у неё есть иные полезные свойства. На первом уровне кучи находятся два элемента, приоритет одного из которых — непременно наивысший для всех уровней, кроме приоритета элемента на уровне 0 (а не то равен и ему). Любой уровень k — кроме последнего — содержит 2k элементов; иными словами, он полон. Неполным может быть только самый низкий уровень (в нашем примере там два из возможных шестнадцати элементов); заполняются уровни слева направо. Приоритет не уникален: в куче из примера приоритеты 8 и 14 встречаются более одного раза.
В двоичной куче из каждого узла может исходить не более двух стрелок. Возьмём узел на уровне 0, приоритет которого равен 15. Он связан стрелками с двумя узлами на уровне 1: узел с приоритетом 13 — это его левый потомок, а узел с приоритетом 14 — правый потомок. Для узлов на первом уровне узел на нулевом уровне (с приоритетом 15) является, соответственно родительским.
Опишем структурные свойства двоичной кучи:
- Частичный порядок
- Приоритет узла не меньше приоритетов его левого и правого потомков (если они есть). Приоритет любого узла (кроме того, что на вершине кучи) не больше приоритета его родительского узла.
- Пирамидальность
Каждый уровень k кучи, кроме последнего, содержит ровно 2k узлов, последний может содержать меньше, заполняется он слева направо, и до тех пор, пока он неполон, уровень k + 1 не появляется.
Если в двоичной куче только один элемент, в ней только один уровень — нулевой; 20=1, пирамидальность соблюдена. Сколько уровней понадобится, чтобы хранить N > 0 элементов? Нужна математическая формула, которая по заданному N позволит вычислить необходимое количество уровней в куче. С помощью иллюстрации 4-6 можно попробовать догадаться, что это за формула. Расположим в виде пирамиды 16 узлов и пронумеруем их согласно принципу пирамидальности: на вершине — e1, затем последовательно по каждому уровню с увеличением индекса на 1. Получится четыре полных уровня и единственный узел на пятом.
![]() |
| Иллюстрация 4-6. Сколько уровней в куче из N узлов? |
Для хранения N>0 элементов требуется 1 + ⌊log₂N⌋ уровней |
|
|
Для N=7 необходимо 3 уровня, log₂7 = 2.78073… |
Для N=8 необходимо уже 4 уровня, log₂8 = 3 |
|
Для 8 ⩽ N ⩽ 15 необходимо 4 уровня; log₂15 = 3.9069… |
|
Пока в куче только 7 узлов e1 - e7, они помещаются на трёх уровнях. Начиная с восьми узлов уже нужно четыре уровня. Если идти по стрелкам сверху налево, нам встретятся узлы e1, e2, e4, e8 и e16 — очевидно, степень двойки влияет не только на размер уровня, но и на размер всей кучи. В формуле оценки количества слоёв логично ожидать двоичный логарифм от N — если быть более точным, количество слоёв L для хранения N элементов в двоичной куче равно L(N) = 1 + ⌊log₂N⌋ (неполные квадратные скобки здесь означают округление до целого числа вниз, аналогичная функция Python — math.floor()).
![]() |
В каждом следующем уровне двоичной кучи помещается больше узлов, чем во всех предыдущих вместе взятых! В двоичной куче из L полных уровней содержится 2L-1 элемент. В самом деле, в куче из одного уровня 21-1=1 узел, в куче из двух уровней — 22-1=3 узла; предположим, что в куче из L-1 уровней 2L-1-1 узел — тогда в куче из L уровней с добавлением ещё 2L-1 элемента из очередного слоя окажется 2L-1-1 + 2L-1 = 2(2L-1)-1 = 2L-1 узел, и формула индуктивно доказана. Как следствие, если к куче добавить всего один слой, она вместо N элементов будет способна хранить 2N+1 элемент. |
Что же касается двоичных логарифмов, то мы помним правило: при удвоении N логарифм увеличивается на 1; или в виде формулы: log₂ 2N = 1 + log₂ N.
Какие из вариантов на иллюстрации 4-7 соответствую двоичной куче с порядком убывания?
![]() |
| Иллюстрация 4-7. Что из этого можно назвать двоичной кучей с убыванием? |
Сначала давайте проверим свойство пирамидальности. Варианты №1 и №2 подходят, потому что в них заполнены все уровни. Вариант №3 тоже подходит, потому что в нём неполон только последний уровень, и заполнен он правильно: содержит три узла из допустимых четырёх, и это три самых левых узла. А вот в варианте №4 пирамидальность не соблюдается: последний уровень содержит три узла, при этом самый левый узел пуст.
Теперь проверим частичный порядок. В случае убывания приоритет каждого родителя должен быть не меньше приоритетов его потомков. Это верно для варианта №1,в чём можно убедиться, сравнив все начала и концы стрелок. Вариант №3 не годится, потому что у узла с приоритетом 8 есть потомок с приоритетом 9. Вариант №2 не годится, потому что уже на уровне 0 приоритет узла равен 4, а у обоих его потомков он выше.
![]() |
В действительности вариант №2 — это пример двоичной кучи с убыванием, в которой приоритет родительского узла не превосходит приоритеты потомков. Этот вариант двоичной кучи мы рассмотрим в главе 7. |
Операции добавления элемента в кучу enqueue() и снятия с кучи элемента с наивысшим приоритетом dequeue() должны сохранять оба её свойства. Если удастся их реализовать так, чтобы производительность обеих операций оценивалась как O(log N), мы получим значительное улучшение быстродействия по сравнению с медленными моделями из таблицы 4-1, в которых либо enqueue(), либо dequeue() в наихудшем случае показывали быстродействие O(N).
Добавление пары в кучу
Если задаться целью добавить пару (значение, приоритет) в двоичную кучу, соблюдая только свойство пирамидальности, то в каком конкретно месте функции enqueue() придётся заводить узел? Ответ всегда одинаков:
Если последний уровень неполон, заполняется самый левый пустой узел этого уровня.
Если последний уровень полон, заводится новый уровень, и заполняется самый левый его узел
На иллюстрации 4-8 показан свежедобавленный в кучу узел с приоритетом 12: он занял третье место на уровне 4. Свойство пирамидальности соблюдено — все уровни, кроме последнего, полны, а последний заполнен слева. Однако во многих случаях частичный порядок от такого добавления нарушается.
К счастью это дело поправимое, причём достаточно переставить местами только узлы «по дороге» от вершины кучи к только что добавленному элементу. На иллюстрации 4-10 показана куча с восстановленным частичным порядком; можно заметить, что для этого пришлось переставить в порядке невозрастания узлы отмеченной серым пути от вершины до крайнего левого узла на последнем уровне.
![]() |
| Иллюстрация 4-8. Добавление элемента. Начальный шаг — заполняется первый свободный узел |
![]() |
Путь до заданного узла двоичной кучи — это последовательность узлов и стрелок вправо или влево между ними от единственного узла на уровне 0 до заданного. |
Теперь надо позаботиться о восстановлении частичного порядка. Добавленный элемент начинает «всплывать» вдоль пути — если порядок нарушен, он и его родитель меняются местами. В примере на иллюстрации 4-8 свежедобавленный узел с приоритетом 12 нарушает порядок, потому что его приоритет выше, чем 2, приоритет родителя. Два узла меняются местами; результат показан на иллюстрации 4-9. Между тем всплытие продолжается.
![]() |
| Иллюстрация 4-9. Добавление элемента. Второй шаг — если нужно, узел всплывает на уровень выше |
После первого «всплытия» структура с вершиной в узле 12 будет полноценной двоичной кучей из двух элементов. Но 12 всё ещё нарушает частичный порядок, потому что приоритет родителя этого узла — 9, а это меньше 12. Надо всплывать ещё выше, как показано на иллюстрации 4-10.
После очередного всплытия структура с вершиной в узле 12 будет полноценной двоичной кучей. Меняя местами 9 и 12 (по правой стрелке), мы можем не проверить, соблюдаются ли свойства кучи в структуре с вершиной 8 (по левой стрелке). Поскольку раньше все элементы этой структуры были не больше 9, они и подавно будут меньше 12. После третьего шага приоритет родительского узла, 13, оказывается больше 12 — частичный порядок восстановлен.
![]() |
| Иллюстрация 4-10. Добавление элемента. Третий шаг — если нужно, узел всплывает на уровень выше |
Все приоритеты не больше 9 |
Попробуем вручную отработать вызов enqueue(значение, 16) на куче из иллюстрации 4-10. Сначала узел добавляется в четвёртую ячейку уровня 4 в качестве правого потомка узла с приоритетом 9. Затем узел всплывает всё выше и выше вплоть до уровня 0. В результате должна получается двоичная куча как на иллюстрации 4-11.
![]() |
| Иллюстрация 4-11. Элемент с приоритетом 16 после добавления всплывает на вершину кучи |
Наихудший случай для операции добавления — когда добавляется элемент с приоритетом, превышающим все имеющиеся приоритеты в куче. Длина пути при добавлении равна количеству уровней в куче, 1+ ⌊log₂N⌋, а это значит, что наибольшее возможное количество операций обмена будет на одну меньше — ⌊log₂N⌋. Теперь уже можно уверенно оценивать и процедуру восстановления пирамидальности, и всю операцию enqueue() как O(log N). Большое дело — но это только половина решения всей задачи: теперь надо убедиться в том, что операцию снятия с кучи элемента с наивысшим приоритетом можно реализовать так же эффективно.
Снятие элемента с кучи
Искать в куче элемент с наивысшим приоритетом не надо — он всегда находится на её вершине, в единственном узле уровня 0. Но если просто удалить его, нарушится свойство пирамидальности — уровень 0, не последний в структуре, будет содержать пустой узел. К счастью, для dequeue() тоже есть способ эффективной перестройки кучи, как мы это увидим в последующих иллюстрациях. Если сохранять только свойство пирамидальности, удаление выглядит довольно просто:
Запомним самый последний (самый правый в последнем уровне) элемент кучи и удалим его. Получившаяся структура сохранит все свойства двоичной кучи.
Запомним значение элемента с наивысшим приоритетом (с уровня 0) — это значение должна вернуть функция dequeue().
Заменим верхний элемент кучи с уровня 0 на элемент, который мы удалили из нижнего уровня и запомнили на первом шаге. Это сохранит пирамидальность, но, скорее всего, нарушит частичный порядок.
На примере иллюстрации 4-12: сначала мы запоминаем и удаляем из кучи элемент 9; куча при этом остаётся кучей. Затем запоминаем возвращаемое значение, приоритет которого наивысший, 16; на иллюстрации это действие не показано.
![]() |
| Иллюстрация 4-12. Снятие элемента. Удаляем последний узел |
На втором шаге мы подменяем узел на уровне 0 только что удалённым нами узлом. Как это понятно из иллюстрации 4-13, частичный порядок в куче нарушится. Мы видим, что приоритет единственного узла на уровне 0 меньше и приоритета его левого потомка, 15, и приоритета его правого потомка, 14. Чтобы порядок восстановился, узел должен начать «тонуть», погружаясь вглубь кучи вплоть до отведёного ему места.
![]() |
| Иллюстрация 4-13. Снятие элемента. Подставляем последний элемент вместо верхнего, порядок при этом нарушается |
Поскольку наш узел нарушает порядок (сейчас на уровне 0 оказалась пара с приоритетом 9), надо решить, какой из узлов кучи необходимо поставить на его место. Очевидно, это должен быть узел с наивысшим приоритетом среди тех, что доступны по стрелкам на уровнях ниже нашего. Выбор придётся делать среди всего двух узлов — правого и левого потомков (приоритеты остальных заведомо не выше). Если правого потомка нет, то остаётся только левый. В нашем примере мы выбираем левого потомка, чей приоритет равен 15, и это больше, чем 14, приоритет правого потомка. Меняем местами наш узел и выбранного потомка с наивысшим приолритетом, получаем состояние, показанное на иллюстрации 4-14.
Все приоритеты не больше 14 |
Можно заметить, что вся структура, начинающаяся с узла с приоритетом 14 на уровне 1, — это полноценная двоичная куча; мы её не трогали, и менять там ничего не надо. А вот в структуре под узлом с приоритетом 9, который оказался на уровне 1 после обмена, порядок нарушен — оба его потомка имеют более высокий приоритет. Значит, наш узел будет продолжать «тонуть», меняясь с левым потомком (как это показано на иллюстрации 4-15), потому что приоритет левого потомка 13 выше, чем приоритет правого.
![]() |
| Иллюстрация 4-15. Снятие элемента. Узел продолжает тонуть |
Все приоритеты не больше 11 |
Осталось немного! На иллюстрации 4-15 видно, что в новом положении у нашего узла с приоритетом 9 есть правый потомок, чей приоритет оставшихся — 12, так что мы меняем эти два узла, после чего частичный порядок в нашей куче восстанавливается, как это видно на иллюстрации 4-16.
![]() |
| Иллюстрация 4-16. Снятие элемента. Куча после того, как узел погрузился на нужный уровень |
Все приоритеты не больше 8 |
В отличие от операции добавления элемента в приоритезированную очередь, в операции снятия нельзя спроста предсказать путь до будущего положения «тонущего» узла. Однако узел «тонет» с уровня на уровень, так что наибольшее возможное количество «погружений» не больше, чем переходов между уровнями в двоичной куче. Это число на единицу меньше, чем самих уровней, иными словами, ⌊log₂N⌋.
Ещё можно посчитать, сколько раз сравниваются приоритеты двух узлов за одну операцию снятия. Каждое «погружение» требует максимум двух сравнений: выбора наибольшего приоритета среди двух потомков и сравнения этого приоритета с приоритетом родителя. Таким образом можно ожидать, что сравнений будет не больше 2·⌊log₂N⌋.
Мы сделали очень важное предположение: и добавление элемента в двоичную кучу, и снятие элемента с её вершины занимают время, в наихудшем случае прямо пропорциональное log N. Пора переходить от теории к практике, а заодно посмотреть, как можно хранить двоичную кучу в обычном массиве.
Можно заметить, что пирамидальность кучи позволяет перечислить все элементы, если начать перебор с уровня 0 вниз, а по уровню двигаться слева направо. Этой особенностью можно воспользоваться: оказывается, элементы двоичной кучи вполне удобно хранить в обычном массиве подряд по уровням.
Хранение двоичной кучи в массиве
На иллюстрации 4-17 показан один из способов записать двоичную кучу размером N = 18 в массив фиксированного размера M > N. В такой куче будет пять уровней, каждый из которых которые будет соответствовать определённому диапазону индексов массива, содержащего хранимые пары. Пунктирные квадраты в правой стороне иллюстрации отмечают индекс, по которому хранится соответствующий узел двоичной кучи. Как обычно, при изображении кучи мы показываем только приоритеты, а значения опускаем.
![]() |
| Иллюстрация 4-17. Запись двоичной кучи в массив |
Двоичная куча из 18 элементов |
Каждому элементу соответствует индекс в массиве storage |
Массив storage хранит элементы кучи последовательно по уровням |
|
Итак, каждой хранимой паре сопоставляется индекс в массиве storage[]. Чтобы не упражняться лишний раз с вычитанием и добавлением единицы к этому индексу, оставим storage[0] пустым, и ничего там хранить не будем. Элемент с наивысшим приоритетом, 15, хранится в storage[1]. Согласно иллюстрации, его левый потомок, с приоритетом 13, хранится в storage[2]. В общем случае, если у элемента storage[k] есть левый потомок, он будет храниться в storage[2*k] (в этом легко убедиться, проследив стрелочки между пунктирными квадратами на иллюстрации 4-17). Если у элемента storage[k] есть правый потомок, он будет храниться, соответственно, в storage[2*k+1].
Если k > 1, родительский узел для storage[k] всегда находится в storage[k//2] (в Python k//2 — это целая часть от деления k на 2). Если располагать вершину кучи в storage[1], то чтобы вычислить индекс родителя любого узла, надо просто целочисленно поделить на 2 индекс узла-потомка. В примере родительский узел для storage[5] (с приоритетом 11) окажется в storage[2], потому что 5//2 = 2.
Если в куче содержится N элементов, storage[k] для любого 0 < k ⩽ N содержит узел из двоичной кучи. Как следствие, если 2·k > N, то у k-го узла нет потомков; например, у узла storage[10] (с приоритетом 1) потомков нет, потому что 2 · 10 = 20 > 18 = N. А у узла storage[9] (с приоритетом, так случайно вышло, тоже 9) нет только правого потомка, потому что 2 · 9 = 18 = N, а вот 2 · 9 + 1 = 19 > N.
Как погружаться и всплывать
Начнём программировать нашу двоичную кучу с частичным убыванием с задания класса для пары (значение, приоритет):
В примере 4-2 для хранения узлов кучи применяется массив storage. При создании экземпляра класса PQ размер storage на единицу превосходит максимальный размер кучи — параметр size конструктора — потому что, напомним, мы храним элементы кучи в массиве начиная с storage[1].
Чтобы можно было лучше изучить работу нашего класса, мы написали два вспомогательных метода. Метод .less(i, j) проверяет, что приоритет i-го узла меньше приоритета j-го, и возвращает True или False. Когда нам понадобится оценить, сколько раз мы сравниваем приоритеты двух узлов, достаточно будет просто посчитать количество вызовов .less(). Метод swap(i, j) меняет местами i-й и j-й узлы в массиве, он нам понадобится для подсчёта количества обменов при перестройке кучи, когда узел «всплывает» или «тонет».
1 class PQ:
2 def less(self, i, j): # 1
3 return self.storage[i].priority < self.storage[j].priority
4
5 def swap(self, i, j): # 2
6 self.storage[i], self.storage[j] = self.storage[j], self.storage[i]
7
8 def __init__(self, size): # 3
9 self.size = size
10 self.storage = [None] * (size+1)
11 self.N = 0
12
13 def enqueue(self, v, p): # 4
14 if self.N == self.size:
15 raise RuntimeError('Priority Queue is Full!')
16 self.N += 1
17 self.storage[self.N] = Entry(v, p)
18 self.swim(self.N)
19
20 def swim(self, child): # 5
21 while child > 1 and self.less(child//2, child): # 6
22 self.swap(child, child//2) # 7
23 child = child // 2 # 8
Пример 4-2. Реализация кучи с методами .enqueue() и .swim()
Метод .less(i, j) проверяет, что storage[i] имеет меньший приоритет, чем storage[j].
Метод swap(i, j) меняет местами i-й и j-й узел.
Узлы хранятся в ячейках от storage[1] до storage[size], а storage[0] не испльзуется.
Чтобы добавить элемент в кучу, надо расположить его в первой свободной ячейке массива, а затем позволить ему «всплыть».
Метод .swim() перестраивает массив storage, возвращая куче пирамидальность.
Родительский узел для storage[child] находится в storage[child//2] (child//2 — это целочисленное деление child на 2).
Меняем местами storage[child] и его родителя storage[child//2].
Если надо, узел продолжит всплывать, и теперь child равен индексу родителя.
Метод .swim() оказался весьма лаконичен! Индекс свежедобавленного узла — child, индекс его родителя, если таковой имеется, — child//2. Если приоритет родителя меньше приоритета child, они меняются местами, и всплытие продолжается.
На иллюстрации 4-18 представлено, как меняется массив storage после вызова enqueue(значение, 12) из состояния, которое было показано на иллюстрации 4-8. В каждом следующем ряду ячейки storage меняется, как это ранее отображалось на очередной иллюстрации. В последнем ряду — состояние массива, соответствующее двоичной куче с пирамидальностью и частичным порядком.
![]() |
| Иллюстрация 4-18. Как меняется storage после вызова enqueue() на иллюстрации 4-08 |
Серым обозначены узлы, составляющие путь от уровня 0 до свежедобавленного узла с приоритетом 12. |
После двух «всплытий» узел с приоритетом 12 попадает на своё место |
Путь от вершины кучи до только что добавленного элемента с приоритетом 12 состоит из пяти узлов — они помечены серым на иллюстрации 4-18. После двух итераций цикла whlle в методе .swim(), в которых узел «всплывает», меняясь местами со своим родителем, он попадает в ячейку storage[4], и свойство пирамидальности кучи восстанавливается. Количество обменов не может превышать log₂N, где N — общее число ячеек в двоичной куче.
В примере 4-3 мы реализовали метод .sink(), который который используется для восстановления свойств двойчной кучи при снятии элемента с её вершины при помощи .dequeue().
1 def dequeue(self):
2 if self.N == 0:
3 raise RuntimeError ('PriorityQueue is empty!')
4 max_entry = self.storage[1] # 1
5 self.storage[1] = self.storage[self.N] # 2
6 self.storage[self.N] = None
7 self.N -= 1 # 3
8 self.sink(1)
9 return max_entry.value # 4
10
11 def sink(self, parent):
12 while 2*parent <= self.N: # 5
13 child = 2*parent
14 if child < self.N and self.less(child, child+1): # 6
15 child += 1
16 if not self.less(parent, child): # 7
17 break
18 self.swap(child, parent) # 8
19 parent = child
Пример 4-3. После добавления методов .dequeue() и .sink() мы получаем полноценно работающую кучу
Запомним ячейку на вершине кучи.
Переставим на вершину последний (самый правый в самом нижнем уровне) элемент, а его место освободим.
Уменьшим количество узлов и запустим sink() для storage[1].
Вернём значение элемента, снятого с вершины кучи.
Проверка продолжается, пока у узла есть хотя бы левый потомок.
Если правый потомок существует и приоритет левого потомка меньше, нам нужен правый потомок.
Если приоритет родителя не меньше максимального приоритета потомков, пирамидальность восстановлена.
Если нет, поменяем местами родителя и потомка и продолжим «топить» наш узел уже с нового места.
На иллюстрации 4-19 изображено, как меняется storage в процессе работы dequeue() — начиная с двоичной кучи с иллюстрации 4-11. Первая строка на иллюстрации 4-19 — это массив из 19 элементов. Во второй строке самый последний элемент с приоритетом 9 перемещается в самое начало (что соотвествует вершине двоичной кучи и нарушает пирамидальность); коме того, теперь в куче 18 узлов, так как последний мы удалили.
![]() |
| Иллюстрация 4-19. Изменения массива storage в процессе снятия элемента с вершины кучи, приведённой на иллюстрации 4-11 |
Серым показаны потомки узла с приоритетом 9; на место одного из них мы «топим» этот узел |
После троекратного «утопления» узел с приоритетом 9 оказывается на своём месте |
После трёх последовательных итераций цикла while в методе .sink() узел с приоритетом 9 «погружается» на позицию, в которой достигается пирамидальность кучи, серым всюду обозначены потомки этого узла (ном лежат в массиве правее). Всякий раз, когда выясняется, что приоритет родителя меньше приоритета потомка, родитель и потомок и более высоким приоритетом меняются местами. Количество таких обменов не может превысить log₂N.
Метод .sink() наглядно показать сложнее, потому что нет однозначного «маршрута», по которому «тонет» переставленный на вершину кучи узел, — в отличие от .swim(). В окончательном состоянии storage из примера 4-19 погрузившийся на своё место узел с приоритетом 9 имеет ещё одного потомка (с приоритетом 2). После отрабатывания .sink() мы можем быть уверены, что приоритет узла не меньше (т. е. больше либо равен) приоритетов всех имеющихся у него потомков. Как частный случай, индекс ячейки p может превышать N // 2, тогда у узла потомков нет, т. к. элементов с индексами 2p и 2p + 1 нет в storage.
![]() |
В методе .dequeue() важно сначала уменьшить N на 1, а только потом вызвать .sink(1), иначе sink() примет элемент, лежащий за пределами кучи, за принадлежащий ей. На всякий случай, если кто-то всё-таки решит туда заглянуть, мы предварительно записываем None в storage[N] — тогда уж точно её не перепутать с узлом. В программировании такой приём — защита от ошибки, которой вроде бы и так не должно случиться — называется «hardening», усиление надёжности исходного текста. |
Я выбросил глубокомысленное замечание о том, что «порядок операторов имеет значение», это какая-то уж совсем стыдоба -- FrBrGeorge 2022-09-01 19:08:39
Чтобы убедиться в правильности такой логики метода .dequeue(), посмотрим, что случиться, если куча содержит единственный элемент, а мы его снимаем. Узел max_entry мы запомнили, N уменьшили до 0, затем вызывали sink(), а он, как и ожидается, ничего не делает, потому что 2 · 1 > 0.
Заключение
С помощью двоичной кучи можно эффективно реализовать абстрактный тип данных — приоритезированную очередь. Довольно много алгоритмов, например те, что мы обсудим в главе 7, пользуются этим типам данных.
Добавление в очередь пары (значение, приоритет) имеет логарифмическую вычислительную сложность O(log N).
Снятие из начала очереди элемента с наивысшим приоритетом тоже имеет сложность O(log N).
Подсчёт количества элементов в очереди имеет константную сложность O(1).
В этой главе мы занимались только двоичной кучей с частичным порядком убывания. Если требуется частичный порядок возрастания, когда на вершине содержится наименьший элемент, достаточно изменить совсем немного (это нам понадобится в главе 7). В примере 4-2 надо взять метод .less() и поменять в нём операцию больше на меньше («>» на «<»), не трогая сверх того ничего.
Приоритезированная очередь может быть любого размера, но в нашей реализации использовалась куча фиксированного размера M, в которой можно было хранить только N < M элементов. Если куча полна, больше элементов в очередь не добавить. Мы выбрали эту реализацию, чтобы аккуратнее оценить быстродействие: посчитать количество операций сравнения и обмена, не опасаясь, что какие-то другие действия с данными не попадут в оценку. Из главы 4 мы знаем, что массив фиксированного размера всегда можно заменить на динамический массив с геометрическим масштабированием (удвоением при заполнении), это не изменит оценку сложности в среднем, так что сложность enqueue() останется O(log N).
Тренировочные задания
В действительности для реализации двоичной кучи произвольного размера почти ничего дополнительно делать не надо: всё равно мы используем не массив, а Python-овский list, динамические свойства которого как раз и обеспечиваются геометрическим масштабированием. Перепишите класс PQ, чтобы в приоритезированной очереди можно было хранить произвольное число элементов. Сравните быстродействие полученной структуры данных с PQ, для чего создайте достаточной длины последовательность со случайными приоритетами, добавьте их по одному в каждую из структур, а потом по одному снимите. Какая структура работает быстрее? Почему? Значимо ли это различие, т. е. есть ли основание считать, что какие-то операции над этими структурами имеют различный класс сложности?
Отдельно хранить .size и .N нет необходимости: они всегда равны len(storage)
Для работы с последней ячейкой массива используйте .append() и .pop()
Другие методы, такие как .insert() или .pop() с параметром, использовать нельзя: их сложность в среднем линейна, в то время как сложность .append() и .pop() константна
Эту задачу я добавил, чтобы у читателя не было ощущения бессмысленной работы с заведомо не нужной в жизни структурой данных; по этой же причине переформулировал предпоследнюю задачу -- FrBrGeorge 2022-09-08 11:49:26
Обычную очередь фиксированного размера можно весьма эффективно реализовать с помощью массива storage фиксированного размера, причём быстродействие операций enqueue() и dequeue() будет константным, O(1). Один из способов — это так называемый «кольцевой буфер», использующий простую, но остроумную идею: начальный элемент очереди не обязательно лежит в storage[0]. Достаточно просто хранить два индекса — forst, позицию того, кто стоит в очереди дольше всех, и last, позицию, куда мы поместим очередной добавляемый элемент, когда он появится. Посмотрим на иллюстрацию 4-20:

Иллюстрация 4-20. Массив в качестве кольцевого буфера При каждом добавлении элемента в конец очереди и снятии элемента из её начала надо аккуратно обновлять first и last. Скорее всего, будет удобно хранить N — количество элементов в очереди, и придётся использовать остаток от деления 9операцию «%»). Изучите пример 4-4, допишите соответствующие методы и проверьте, что они действительно работают за константное время.
1 class Queue: 2 def __init__(self, size): 3 self.size = size 4 self.storage = [None] * size 5 self.first = 0 6 self.last = 0 7 self.N = 0 8 9 def is_empty(self): 10 return self.N == 0 11 12 def is_full(self): 13 return self.N == self.size 14 15 def enqueue(self, item): 16 """If not full, enqueue item in O(1) performance.""" 17 18 def dequeue(self): 19 """If not empty, dequeue head in O(1) performance."""
Пример 4-4. Допишите реализацию очереди в виде кольцевого буфера
Добавьте в порядке возрастания N = 2k - 1 элемент в пустую двоичную кучу размера N. Изучите получившийся массив, который хранит узлы кучи (напомним, позиция 0 в нём не используется). Можно ли предсказать, какие места в массиве будут занимать k наибольших элементов? А если в пустую двоичную кучу добавить N элементов в порядке убывания — нельзя ли предсказать место каждого элемента в массиве ячеек?
Допустим, у нас есть две кучи размером M и N. Придумайте алгоритм, который заполняет массив размером M + N всеми элементами обеих куч в порядке возрастания за O(M log M + N log N) операций. Для подтверждения правильности алгоритма подготовьте таблицу с результатами экспериментов.
Придумайте метод получения k наименьших элементов среди произвольного набора из N элементов за время O(N log k). Для подтверждения правильности алгоритма подготовьте таблицу с результатами экспериментов.
В двоичной куче у каждого узла бывает не больше двух потомков. Рассмотрим другой вариант, в котором узел на вершине кучи имеет двух потомков, каждый из них имеет трёх потомков (назовём их внуками), каждый из внуков — четырёх потомков и так далее, как показано на иллюстрации 4-21. Назовём эту структуру факториальной кучей: в ней с каждым уровнем у узлов прибавляется по дополнительному потомку. Куча должна обладать Свойствами пирамидальности и частичного порядка. Реализуйте факториальную кучу, используя хранение ячеек в массиве, проэкспериментируйте с ней, и убедитесь, что она работает медленнее двоичной. Попробуйте оценить вычислительную сложность; это задача похитрее, но в конце концов у вас должно получиться O((log N)/(log(log N))).

Иллюстрация 2-21. Инновационная факториальная куча
Используя геометрическое масштабирование, описанное в главе 3, доработайте кольцевой буфер из упражнения 2 так, чтобы он мог работать с произвольным количеством элементов. Переносить ячейки из старого массива в новый удобнее не по одной, а сегментами (таких сегментов будет один или два). Реализуйте уменьшение размера массива, если показатель заполнения опустился ниже ¼.
Допустим, мы хотим сделать итератор по всей куче, который возвращает по одному значения в том порядке, в котором мы снимали бы их с кучи. С одной стороны, такой итератор не должен модифицировать саму структуру, с другой — при снятии элемента с кучи нужно переструктурировать массив ячеек. Как совместить оба требования? Одно из решений — написать генератор-функцию iterator(pq), которая получает на вход приоритезированную очередь pq и создаёт её копию, pqit, откуда потом и снимает по одному все элементы. Начнём с более «надёжного» варианта, в котором pqit хранит не сами элементы pq, а их индексы в массиве pq.storage; приоритеты при этом остаются такими же, как и у соответствующих значений. По этим индексам мы и будем обращаться к pq.storage, до самого последнего момента не трогая его содержимого, и вообще не меняя его структуры.
Допишите следующий пример, в начале которого в pqit добавляется индекс 1, который соответствует элементу pq с наивысшим приоритетом. Продолжите заполнение pqit и допишите цикл while из примера:
Такой итератор должен возвращать все элементы pq в порядке приоритетов, не изменяя сам pq.
Стоит заметить, что особой надёжности использование в куче-копии индексов вместо самих элементов, по-видимому, не добавляет. Напишите вариант нашей функции, iteratorc(pq), в которой задаётся новая куча pqit минимального размера, а затем все поля pq просто копируются туда (включая storage, который можно получить с помощью pq.storage.copy()). Будет ли такая структура надёжной: нет ли случаев, в которых что-то в pq модифицируется? Какая из функций работает быстрее и почему?
Автор не поясняет, откуда взялся множитель 3, при том, что по его словам объём входных данных здесь именно N. Не пытаясь угадать его намерения, заметим только, что на общую тенденцию это почти не влияет. (1)
В русскоязычной терминологии не принято явно указывать, какой именно частичный порядок установлен в куче, обычно это понятно из контекста. Поэтому, а также во избежание путаницы (ибо куча с порядком убывания у автора называется «max heap») мы будем использовать просто термин «куча», а порядок указывать только если возможна неоднозначность. (2)






















