Я думаю, что в этой теме похоронено несколько вопросов:
- Как вы реализуете,
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
делает.