Метод сортировки с помощью прямого выбора основан на повторяющихся поисках наименьшего ключа среди n элементов, среди оставшихся n - 1 элементов и так далее. Обнаружение наименьшего среди n элементов требует — это очевидно — n - 1 сравнения, среди n - 1 уже нужно n - 2 сравнений и так далее. Как же в таком случае можно усовершенствовать упомянутый метод сортировки? Этого можно добиться, только оставляя после каждого прохода больше информации, чем просто идентификация единственного минимального элемента. Например, сделав n /2 сравнений, можно определить в каждой паре ключей меньший. С помощью n /4 сравнений — меньший из пары уже выбранных меньших и т. д. Проделав n - 1 сравнений, мы можем построить дерево выбора, вроде представленного на рис. 2.2 и идентифицировать его корень как нужный нам наименьший ключ.
Рис. 2.2. Повторяющиеся выборы среди двух ключей
Второй этап сортировки — спуск вдоль пути, отмеченного наименьшим элементом, и исключение его из дерева путем замены на пустой элемент (дырку) (рис. 2.3). Элемент, передвинувшийся в корень дерева (рис.2.4.), вновь будет наименьшим (теперь уже вторым) ключом, и его можно исключить. После n таких шагов дерево станет пустым (т. е. в нем останутся только дырки), и процесс сортировки заканчивается. Обратите внимание — на каждом из n шагов выбора требуется только log n сравнений. Поэтому на весь процесс понадобится порядка n * log n элементарных операций плюс еще n шагов на построение дерева. Это весьма существенное улучшение не только прямого метода, требующего n 2 шагов, но и даже метода Шелла, где нужно 1.2 n шагов. Естественно, сохранение дополнительной информации делает задачу более изощренной, поэтому в сортировке по дереву каждый отдельный шаг усложняется. Ведь, в конце концов, для сохранения избыточной информации, получаемой при начальном проходе, создается некоторая древообразная структура. Наша следующая задача — найти приемы эффективной организации этой информации.
44 12 18
44 55 12 42 94 18 67
Рис. 2.3. Выбор наименьшего ключа.
Рис.2.4.Заполнение дырок.
Конечно, хотелось бы, в частности, избавиться от дырок, которыми в конечном итоге будет заполнено все дерево, и которые порождают много ненужных сравнений. Кроме того, надо было бы поискать такое представление дерева из n элементов, которое требует лишь n единиц памяти, а не 2 n - 1, как это было раньше. Этих целей действительно удалось добиться в методе пирамидальная сортировка, изобретенном Д.Уилльямсом, где было получено, очевидно, существенное улучшение традиционных сортировок с помощью деревьев. Пирамида определяется как последовательность ключей hl, hl+1,...,hr, такая, что
hi <= h2i и hi< =h2i+1, для i = l,..., r/2.
Если любое двоичное дерево рассматривать как массив по схеме на рис. 2.6, то можно говорить, что деревья сортировок на рис. 2.7 и 2.8 суть пирамиды, а элемент h1, в частности, их наименьший элемент: h1 = min(h1,h2,…, hn).
Рис. 2.6. Массив h, представленный в виде двоичного дерева
Рис. 2.7. Пирамида из семи элементов.
Рис. 2.8.Просеивание ключа 44 через пирамиду.
Предположим, есть некоторая пирамида с заданными элементами hl+1,.., hr для некоторых значений l и r и нужно добавить новый элемент x, образуя расширенную пирамиду hl,..., hr. Возьмем, например, в качестве исходной пирамиду h1,...,h7, (показанную на рис. 2.7) и добавим к ней слева элемент h 1 = 44. Новая пирамида получается так: сначала x ставится наверх древовидной структуры, а затем он постепенно опускается вниз, каждый раз по направлению наименьшего из двух примыкающих к нему элементов, а сам этот элемент передвигается вверх. В приведенном примере значение 44 сначала меняется местами с 06, затем с 12, и в результате образуется дерево, представленное на рис. 2.8. Теперь мы сформулируем тот сдвигающий алгоритм так: i, j — пара индексов, фиксирующих элементы, меняющиеся на каждом шаге местами. Остается лишь убедиться самому, что предложенный метод сдвигов действительно сохраняет неизменными условия, определяющие пирамиду.
Р.Флойдом был предложен некий "лаконичный" способ по строения пирамиды «на том же месте». Его процедура сдвига представлена как программа 2.2. Здесь h 1… h n — некий массив, причем h n/2+1... h n уже образуют пирамиду, поскольку индексов i, j, удовлетворяющих отношениям j = 2 i (или j = 2 i + 1), просто не существует. Эти элементы образуют как бы нижний слой соответствующего двоичного дерева (рис. 2.6), для них никакой упорядоченности не требуется. Теперь пирамида расширяется влево: каждый раз добавляется и сдвигами ставится в надлежащую позицию новый элемент. Табл. 2.2 иллюстрирует весь тот процесс, а получающаяся пирамида была показана на рис.2.5
PROCEDURE sift(l,r: index);
LABEL 13;
VAR i, j: index; x:item;
BEGIN i:= l; j:= 2*i; x:= a[i];
WHILE (j<=r) do
BEGIN IF j < r THEN
IF a[j].key > a[j+1].key then j:= j+1;
IF x.key <= a[j].key then goto 13;
a[i]:=a[j]; i:=j; j:=2*j;
END;
13: a[i]:=x
END;
Программа 2.2. Sift
Следовательно, процесс формирования пирамиды из n элементов h 1,..., h n на том же самом месте описывается так:
l:= (n DIV 2) + 1;
WHILE l > 1 DO
BEGIN l:= l-1; sift(l, n) END
Таблица 2.2.Построение пирамиды.
Для того чтобы получить не только частичную, но и полную упорядоченность среди элементов, нужно проделать n сдвигающих шагов, причем после каждого шага на вершину дерева выталкивается очередной (наименьший) элемент. И вновь возникает вопрос: где хранить "всплывающие" верхние элементы и можно или нельзя проводить обращение на том же месте? Существует, конечно, такой выход: каждый раз брать последнюю компоненту пирамиды (скажем, это будет x), прятать верхний элемент пирамиды в освободившемся теперь месте, а x сдвигать в нужное место. В табл. 2.3 приведены необходимые в этом случае n - 1 шагов.
Сам процесс описывается с помощью процедуры sift (программа 2.2) таким образом:
r:= n;
WHILE r > 1 DO
BEGIN x:=a[1]; a[1]:=a[r]; a[r]:=x;
r: = r- 1; sift (1,r)
END
Пример из табл. 2.3 показывает, что получающийся порядок фактически является обратным. Однако это можно легко исправить, изменив направление "упорядочивающего отношения" в процедуре sift. В конце концов, получаем процедуру HeapSort (программа 2.3).
Таблица 2.3. Пример процесса сортировки с помощью Heapsort.
procedure HeapSort;
var l,r:index; x:item;
procedure sift;
label 13;
var i,j: index;
begin i:=l; j:=2*i; x:=a[i];
while j<=r do
begin if j<r then
if a[j].key<a[j+1].key then j:=j+1;
if x.key>=a[j].key then goto 13;
a[i]:=a[j];
i:=j;
j:=2*i
end;
13: a[i]:=x
end;
begin l:=(n div 2)+1; r:=n;
while l>1 do
begin l:=l-1; sift
end;
while r>1 do
begin x:=a[1]; a[1]:=a[r]; a[r]:=x;
r:=r-1; sift
end
end;
Программа 2.3. HeapSort
Анализ HeapSort. На первый взгляд вовсе не очевидно, что такой метод сортировки дает хорошие результаты. Ведь, в конце концов, большие элементы, прежде чем попадут на свое место в правой части, сначала сдвигаются влево. И действительно, процедуру не рекомендуется применять для небольшого, вроде нашего примера, числа элементов. Для больших же n HeapSort очень эффективна; чем больше n, тем лучше она работает. Она даже становится сравнимой с сортировкой Шелла.
В худшем случае нужно n/2 сдвигающих шагов, они сдвигают элементы на log(n /2),log(n /2 -1),…, log(n -1) позиций (целая часть логарифма по основанию 2) Следовательно, фаза сортировки требует n - 1 сдвигов с самое большое log(n - 1), log(n - 2),..., 1 перемещениями. Кроме того, нужно еще n – 1 перемещений для просачивания сдвинутого элемента на некоторое расстояние вправо. Эти соображения показывают, что даже в самом плохом из возможных случаев HeapSort потребует n * log n шагов. Великолепная производительность в таких плохих случаях — одно из привлекательных свойств HeapSort.
Совсем не ясно, когда следует ожидать наихудшей (или наилучшей) производительности. Но вообще-то кажется, что HeapSort "любит" начальные последовательности, в которых элементы более или менее отсортированы в обратном порядке. Поэтому ее поведение несколько неестественно. Если мы имеем дело с обратным порядком, то фаза порождения пирамиды не требует каких-либо перемещений. Среднее число перемещений приблизительно равно n /2 * log(n), причем отклонения от этого значения относительно невелики.