Итак, двоичная куча — это:
O(1)
, добавляющий элемент за O(log n)
и удаляющий максимальный элемент за O(log n)
.Эти свойства и определяют двоичную кучу:
Двоичная кучаВ алгоритмах кучи двоичная куча представляется как массив. В представлении массивом дочерние элементы узла с индексом i
расположены в индексах 2*i+1
и 2*i+2
. Вот так он выглядит:
Массив может быть преобразован в двоичную кучу за время O(n)
. Здорово, правда? Вот алгоритм:
Почему это работает?
x
. Мы итерируем кучу от конца к началу. Когда мы достигнем конца, поддеревья, имеющие корни в обоих своих дочерних элементах, уже удовлетворяют свойству кучи. x
больше обоих дочерних элементов— готово.x
большим дочерним элементом. Это приведет к тому, что новый корень будет больше, чем оба дочерних элемента.x
не удовлетворяет свойству кучи в новом поддереве, процесс нужно повторять до тех пор, пока результат не будет удовлетворять свойству кучи, или поддерево не станет листом, то есть у него не будет ни одного дочернего элемента. Это верно для всех узлов в куче, включая корень.
Древовидная сортировка работает в 2 этапа:
O(n)
.O(log n)
, что в сумме составляет O(n * log n)
для всего контейнера.Примечательное свойство реализации Go в том, что входной массив используется для хранения выходных данных, а значит, не нужно выделять память О(n)
для этих данных.
Библиотека сортировки Go поддерживает любую коллекцию, которая проиндексирована целыми числами, имеет определенный порядок элементов и поддерживает замену элементов между двумя индексами:
type Interface interface { // Len - количество элементов в группе. Len() int // Less сообщает, должен ли элемент с индексом i // быть отсортирован перед элементом с индексом j. Less(i, j int) bool // Swap меняет местами элементы с индексами i и j. Swap(i, j int)}
Ссылка на код выше.
Естественно, любой непрерывный контейнер чисел удовлетворяет этому интерфейсу.
Давайте посмотрим на тело метода heapSort()
:
func heapSort(data Interface, a, b int) { first := a lo := 0 hi := b - a // Формирует кучу с наибольшим элементом в вершине. for i := (hi - 1) / 2; i >= 0; i-- { siftDown(data, i, hi, first) } // Вставляет элементы, сперва наибольшие, в конец кучи. for i := hi - 1; i >= 0; i-- { data.Swap(first, first+i) siftDown(data, lo, i, first) }}
Ссылка на код выше.
Возможно, выглядит немного загадочно, но первые 3 строки проясняют все:
a
и b
— индексы в data
. heapSort(data, a, b)
сортирует data
в полуоткрытое множество [a, b)
.first
— это копия a
.lo
и high
— индексы, нормированные поa
: lo
всегда начинается с нуля. hi
начинается с размера ввода.Далее код формирует двоичную кучу:
// Формирует кучу с наибольшим элементом в вершине.for i := (hi - 1) / 2; i >= 0; i-- { siftDown(data, i, hi, first)}
Как мы видели ранее, код проверяет кучу с уровня выше листьев и использует siftDown()
для перемещения текущего элемента вниз, пока он не будет соответствовать свойству кучи. Более детально я расскажу о siftDown()
ниже. На этом этапе data
— это двоичная куча. Добавим все элементы, чтобы создать отсортированный массив.
// Добавляет элементы, сначала наибольшие, в конец кучи. for i := hi - 1; i >= 0; i-- { data.Swap(first, first+i) siftDown(data, lo, i, first)}
В этой цикле i
— последний индекс кучи. В каждой итерации:
first
меняется местами с ее последним элементом.first
вниз до тех пор, пока он не будет удовлетворять свойству кучи.i
уменьшается на один. Другими словами, мы заполняем массив от конца к началу, двигаясь от наибольшего элемента к наименьшему, в результате получая отсортированный массив.
Восстановление свойства кучиДля восстановления свойства кучи используетсяsiftDown()
. Давайте посмотрим, как он работает.
// siftDown применяет свойство кучи к данным [lo, hi)./* first - это ответвление внутри массива, в котором лежит корень кучи. */func siftDown(data Interface, lo, hi, first int) { root := lo for { child := 2*root + 1 if child >= hi { break } if child+1 < hi && data.Less(first+child, first+child+1) { child++ } if !data.Less(first+root, first+child) { return } data.Swap(first+root, first+child) root = child }}
Ссылка на код выше.
Этот код перемещает элемент root
вниз по дереву, пока он не станет больше обоих дочерних элементов. При переходе на уровень ниже элемент заменяется наибольшим из дочерних. Убеждаемся в том, что родительский узел больше обоих дочерних элементов:
Первые несколько строк считают индекс первого дочернего элемента и проверяют его существование:
child := 2*root + 1if child >= hi { break}
child >= hi
означает, что текущий root
— лист, поэтому алгоритм останавливается. Далее выбираем наибольший из двух дочерних элементов:
if child+1 < hi && data.Less(first+child, first+child+1) { child++}
Так как любые дочерние элементы узла находятся рядом в массиве, child++
выбирает второй дочерний элемент. Далее мы проверяем, действительно ли родительский элемент меньше дочернего:
if !data.Less(first+root, first+child) { return}
Если родительский элемент больше, чем наибольший из дочерних, мы закончили.
Наконец, если родительский элемент меньше, чем дочерний, мы меняем местами два элемента и увеличиваем root
для подготовки к следующей итерации:
data.Swap(first+root, first+child)root = child
Перевод статьи Ehud Tamir: How to Implement Heap-Sort in the Go Standard Library
Комментарии