Я думаю, что в этой теме похоронено несколько вопросов:
- Как реализовать
buildHeap
, чтобы он работал за O (n) время? - Как вы показываете, что
buildHeap
работает правильно O (n) , когда реализовано правильно? - Почему эта же логика не работает для выполнения сортировки кучи в O (n) время вместо O (n log n) ?
Как реализовать buildHeap
, чтобы он работал в O (n) время?
Часто ответы на эти вопросы сосредоточены на разнице между siftUp
и siftDown
.Правильный выбор между siftUp
и siftDown
крайне важен для получения производительности O (n) для 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) .
- 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
из верхней части дерева на каждой итерации! Хотя дерево сжимается, оно не сжимается достаточно быстро : Высота дерева остается постоянной, пока вы не удалите первую половину узлов (когда полностью очистите нижний слой).Затем для следующей четверти высота составляет ч - 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
делает.