Алгоритм 5. Сортировка двоичной кучей
Проблема первых трех алгоритмов, описанных в прошлой части статьи, состояла в том, что после того как элемент занимал свое место, информация об уже произведенных сравнениях никак не использовалась. Структура двоичного дерева позволяет сохранить эту информацию. Итак, представим массив в виде дерева. Корень дерева — элемент с индексом 1; элемент с индексом i является «родителем» для элементов с индексами 2*i и 2*i+1, а те, в свою очередь, являются его «детьми». Каждый элемент кроме первого имеет «родителя» и может иметь до двух «детей» — речь ведь идет именно о ДВОИЧНОМ дереве. Очевидно, что корнем дерева является наименьший элемент, а наибольший не имеет детей. Тут возникают два вопроса: как нам такую кучу наплодить? И зачем нам это вообще нужно? Пренебрегая порядком, отвечу сразу на второй вопрос: мы хотим извлечь из кучи минимальный элемент, а потом как-то преобразовать и восстановить кучу. Таким образом, по очереди извлечь все элементы и получить отсортированный массив. И вот как мы собираемся это сделать: пусть поддеревья с корнями 2*i и 2*i+1 уже имеют свойство кучи, мы же хотим, чтобы такое свойство имело и поддерево с корнем i. Для этого, если корень больше наименьшего своего «ребенка», мы меняем корень дерева (элемент с индексом i) с этим «ребенком», после повторяем алгоритм для поддерева, куда перешел бывший корень. Выполняя этот алгоритм «снизу вверх» (сначала для маленьких поддеревьев, потом для больших), мы добьемся того, что свойство кучи будет выполняться для всего дерева. Извлечение элемента происходит очень простым способом: мы ставим последний элемент на первое место и запускаем алгоритм исправления кучи от корня дерева… Я тут много наговорил, но на самом деле, реализация совсем несложная:
Program HeapSort;
Var A,B : array[1..1000] of integer;
N,i,P : integer;
Procedure Heapi(ind : integer); {процедура, формирующая и исправляющая кучу} Var k : integer;
Begin
k:=ind*2;
If k<=N then
begin
if (k+1<=N) and (A[k]>A[k+1]) then
k:=k+1;
if A[ind]>A[k] then
begin
P:=A[ind];
A[ind]:=A[k];
A[k]:=P;
Heapi(k);
end;
end; End;
Begin
{Определение размера массива A — N) и его заполнение} … {формирование кучи} for i:=N div 2 downto 1 do
Heapi(i); {формирование массива B} for i:=1 to N do begin
B[i]:=A[1];
A[1]:=A[N];
N:=N-1;
Heapi(1);
end; {Вывод отсортированного массива B} … End.
Program HeapSort;
Var A,B : array[1..1000] of integer;
N,i,P : integer;
Procedure Heapi(ind : integer);
{процедура, формирующая и исправляющяя кучу} Var k : integer; Begin
k:=ind*2;
If k<=N then
begin
if (k+1<=N) and (A[k]>A[k+1]) then
k:=k+1;
if A[ind]>A[k] then
begin
P:=A[ind];
A[ind]:=A[k];
A[k]:=P;
Heapi(k);
end;
end;
End; Begin {Определение размера массива A — N) и его заполнение} …
{формирование кучи} for i:=N div 2 downto 1 do
Heapi(i);
{формирование массива B} for i:=1 to N do
begin
B[i]:=A[1];
A[1]:=A[N];
N:=N-1;
Heapi(1);
end;
{Вывод отсортированного массива B} …
End. |
А теперь главное, т. е. оценка сложности. Время работы процедуры исправляющей кучу зависит от высоты дерева. Высота всего дерева равна log2n, значит, время работы процедуры есть O(log2n). Программа состоит из двух частей: формирование кучи и создание отсортированного массива B. Время исполнения каждой из частей не больше O(n log2n) (в каждой части исправляющая процедура вызывается не более n раз). Значит, время работы то же, что и в сортировке слиянием.
Теперь лирическое отступление насчет времени работы. Может, читатель думает, что быстрые алгоритмы сложны в исполнении и проще написать что-то вроде сортировки вставками. Что ж, рассмотрим простой пример: допустим, вы написали сортировку вставками, тщательно, с помощью ассемблера, и время работы получилось 2n2, а какой-нибудь раздолбай написал сортировку слиянием со временем работы 50nlog2n. И тут появилась необходимость отсортировать 1000000 элементов (что в наше время не редкость). Вы использовали крутой компьютер, который делает 108 операций сравнения и перестановки в секунду, а у него компьютер похуже — всего 106 операций в секунду. И вы будете ждать 2*(106)2/108 = 20 000 секунд (приблизительно 5.56 часов), а ваш конкурент — 50*(106)*log2(106)/106 = 1000 секунд (приблизительно 17 минут). Надеюсь, вы проведете это время (5 часов) с пользой для себя и поймете, что хороший алгоритм — быстрый алгоритм :-). Хотя, если вы будете сортировать маленький массив или много маленьких массивов, то 2n2 для вас будет лучше, чем 50nlog2n. Эту закономерность использует один из способов оптимизации сортировки слиянием: сортировать маленькие части массива вставками.