Я думаю, что в этой теме похоронено несколько вопросов:
- Как вы реализуете,
buildHeapчтобы он работал в O (N) времени?
- Как вы показываете, что
buildHeapработает в O (N) времени при правильной реализации?
- Почему та же самая логика не работает для того, чтобы сортировка кучи выполнялась за O (n), а не за O (n log n) ?
Как вы реализуете, buildHeapчтобы он работал в O (N) времени?
Часто ответы на эти вопросы сосредоточены на разнице между siftUpи siftDown. Делая правильный выбор между siftUpи siftDownимеет решающее значение для получения (п) O производительность buildHeap, но ничего не делает , чтобы помочь человеку не понять разницу между buildHeapи heapSortв целом. Действительно, правильные реализации обоих так buildHeapи heapSortбудут использовать толькоsiftDown . Эта siftUpоперация необходима только для вставки в существующую кучу, поэтому она будет использоваться для реализации очереди с приоритетами, например, с использованием двоичной кучи.
Я написал это, чтобы описать, как работает максимальная куча. Этот тип кучи обычно используется для сортировки кучи или для очереди с приоритетами, где более высокие значения указывают на более высокий приоритет. Мин куча также полезна; например, при извлечении элементов с целочисленными ключами в порядке возрастания или строк в алфавитном порядке. Принципы точно такие же; просто переключите порядок сортировки.
Свойство heap указывает, что каждый узел в двоичной куче должен быть как минимум такого же размера, как оба его дочерних элемента. В частности, это означает, что самый большой элемент в куче находится в корне. Отбор и отбор - это, по сути, одна и та же операция в противоположных направлениях: перемещать нарушающий узел, пока он не удовлетворяет свойству кучи:
siftDown меняет узел, который является слишком маленьким, с его самым большим дочерним элементом (тем самым перемещая его вниз), пока он не станет по крайней мере таким же большим, как оба узла под ним.
siftUp меняет узел, который слишком велик, с его родителем (тем самым перемещая его вверх), пока он не станет больше, чем узел над ним.
Количество операций, необходимых для siftDownи siftUpпропорционально расстоянию, которое может пройти узел. Ведь siftDownэто расстояние до нижней части дерева, поэтому siftDownоно дорого для узлов в верхней части дерева. При siftUpэтом работа пропорциональна расстоянию до вершины дерева, поэтому siftUpстоит дорого для узлов в нижней части дерева. Хотя в худшем случае обе операции равны O (log n) , в куче только один узел находится вверху, тогда как половина узлов лежит на нижнем уровне. Поэтому не должно быть слишком удивительно, что если нам нужно применить операцию к каждому узлу, мы бы предпочли siftDownболее siftUp.
buildHeapФункция принимает массив неупорядоченных элементов и перемещает их , пока они все не удовлетворяют кучного собственности, в результате чего получают действительную кучу. Есть два подхода можно было бы принять для buildHeapиспользования siftUpи siftDownопераций мы описали.
Начните с верхней части кучи (начало массива) и вызовите siftUpкаждый элемент. На каждом шаге ранее просеянные элементы (элементы перед текущим элементом в массиве) образуют правильную кучу, а отсеивание следующего элемента помещает его в правильную позицию в куче. После просеивания каждого узла все элементы удовлетворяют свойству кучи.
Или идите в противоположном направлении: начните с конца массива и двигайтесь назад вперед. На каждой итерации вы просеиваете предмет, пока он не окажется в правильном месте.
Какая реализация для buildHeapболее эффективна?
Оба эти решения будут создавать допустимую кучу. Неудивительно, что более эффективной является вторая операция, которая использует siftDown.
Пусть h = log n представляет высоту кучи. Требуемая для siftDownподхода работа определяется суммой
(0 * n/2) + (1 * n/4) + (2 * n/8) + ... + (h * 1).
Каждый член в сумме имеет максимальное расстояние, которое должен пройти узел на данной высоте (ноль для нижнего слоя, h для корня), умноженное на количество узлов на этой высоте. Напротив, сумма для вызова siftUpна каждом узле
(h * n/2) + ((h-1) * n/4) + ((h-2)*n/8) + ... + (0 * 1).
Должно быть понятно, что вторая сумма больше. Одно только первое слагаемое имеет вид hn / 2 = 1/2 n log n , поэтому такой подход в лучшем случае имеет сложность O (n log n) .
Как мы докажем, что сумма для siftDownподхода действительно O (n) ?
Один из методов (есть и другие анализы, которые также работают) - превратить конечную сумму в бесконечный ряд, а затем использовать ряд Тейлора. Мы можем игнорировать первый член, который равен нулю:

Если вы не уверены, почему каждый из этих шагов работает, вот оправдание для процесса в словах:
- Все члены являются положительными, поэтому конечная сумма должна быть меньше бесконечной суммы.
- Ряд равен степенному ряду, оцененному в x = 1/2 .
- Этот степенной ряд равен (постоянному времени) производной ряда Тейлора для f (x) = 1 / (1-x) .
- х = 1/2 находится в интервале сходимости этого ряда Тейлора.
- Следовательно, мы можем заменить ряд Тейлора на 1 / (1-x) , дифференцировать и оценить, чтобы найти значение бесконечного ряда.
Поскольку бесконечная сумма равна ровно n , мы заключаем, что конечная сумма не больше, и, следовательно, O (n) .
Почему сортировка кучи требует времени O (n log n) ?
Если можно работать buildHeapза линейное время, почему сортировка кучи требует времени O (n log n) ? Ну, куча сортировки состоит из двух этапов. Сначала мы обращаемся buildHeapк массиву, который требует оптимального времени O (n) . Следующим этапом является повторное удаление самого большого элемента в куче и помещение его в конец массива. Поскольку мы удаляем элемент из кучи, сразу после окончания кучи всегда есть открытое место, где мы можем сохранить элемент. Таким образом, сортировка кучи достигает отсортированного порядка, последовательно удаляя следующий по величине элемент и помещая его в массив, начиная с последней позиции и двигаясь вперед. Это сложность этой последней части, которая доминирует в куче. Цикл выглядит так:
for (i = n - 1; i > 0; i--) {
arr[i] = deleteMax();
}
Ясно, что цикл выполняется O (n) раз ( точнее, n - 1 , последний элемент уже на месте). Сложность deleteMaxдля кучи составляет O (log n) . Обычно это выполняется путем удаления корня (самый большой элемент, оставшийся в куче) и замены его последним элементом в куче, который является листом и, следовательно, одним из самых маленьких элементов. Этот новый корень почти наверняка нарушит свойство кучи, поэтому вы должны вызывать его, siftDownпока не вернете его обратно в приемлемое положение. Это также приводит к перемещению следующего по величине элемента до корня. Обратите внимание, что в отличие от того, buildHeapгде для большинства узлов мы вызываем siftDownиз нижней части дерева, мы теперь вызываем siftDownиз верхней части дерева на каждой итерации!Хотя дерево сжимается, оно не сжимается достаточно быстро : высота дерева остается постоянной, пока вы не удалите первую половину узлов (когда вы полностью очистите нижний слой). Тогда для следующей четверти высота h - 1 . Таким образом, общая работа для этого второго этапа
h*n/2 + (h-1)*n/4 + ... + 0 * 1.
Обратите внимание на переключение: теперь нулевой рабочий случай соответствует одному узлу, а h рабочий случай соответствует половине узлов. Эта сумма равна O (n log n) так же, как неэффективная версия, buildHeapкоторая реализована с использованием siftUp. Но в этом случае у нас нет выбора, так как мы пытаемся отсортировать, и мы требуем, чтобы следующий самый большой элемент был удален следующим.
Таким образом, работа по сортировке кучи является суммой двух этапов: O (n) время для buildHeap и O (n log n) для удаления каждого узла по порядку , поэтому сложность составляет O (n log n) . Вы можете доказать (используя некоторые идеи из теории информации), что для сортировки на основе сравнения O (n log n) - лучшее, на что вы можете надеяться, так что нет никаких причин разочаровываться этим или ожидать, что сортировка кучи достигнет O (N) ограничено по времени, что buildHeapделает.