Текст книги "Фундаментальные алгоритмы и структуры данных в Delphi"
Автор книги: Джулиан Бакнелл
сообщить о нарушении
Текущая страница: 11 (всего у книги 36 страниц)
Существуют три основных типа последовательностей данных, которые можно использовать для тестирования функций сортировки. Можно сортировать данные, находящиеся в произвольном порядке (перетасованные данные, если хотите), уже отсортированные данные и данные, отсортированные в обратном порядке. Вторая последовательность данных позволит оценить поведение алгоритма на отсортированном списке – некоторые алгоритмы в подобной ситуации выполняются неэффективно.
Список, отсортированный в обратной последовательности, также является критическим для некоторых алгоритмов – многие элементы должны пройти длинный путь до попадания в требуемую позицию.
Кроме того, есть еще одна последовательность данных, которую можно использовать при тестировании алгоритмов сортировки, – набор данных, содержащий большое количество повторений ограниченного количества элементов. Другими словами, в таком наборе находятся элементы, для многих из которых функция сравнения будет возвращать значение 0. Это немного странно, но есть, по крайней мере, один алгоритм сортировки, который традиционно плохо работает с наборами данных с повторениями элементов.
Ситуация, в которой мы сейчас находимся, напоминает замкнутый круг (для тестирования алгоритмов сортировки и определения их эффективности необходимы наборы отсортированных данных, но как их получить?), однако два набора отсортированных данных можно сформировать очень просто. Первый набор, используемый при тестировании алгоритмов, случайная последовательность, заслуживает отдельного рассмотрения. Как это ни парадоксально, но обсуждение сортировки мы начнем с описания методов перестановки данных с целью получения случайной последовательности. Выражаясь языком физики, сейчас мы научимся увеличивать энтропию, перед тем как показать, как ее уменьшать.
Тасование массива TList
Каким образом можно перетасовать элементы массива TList? Большинство из вас в качестве первого алгоритма приведут самый простой: посетить каждый элемент массива, от первого до последнего, и переставить его с другим, случайно выбранным элементом. Реализация такого алгоритма в Delphi будет выглядеть следующим образом:
Листинг 5.2. Простое тасование элементов
procedure TDSimpleListShuffie(aList : TList;
aStart, aEnd : integer);
var
Range : integer;
Inx : integer;
Randomlnx : integer;
TempPtr : pointer;
begin
TDValidateListRange(aList, aStart, aEnd, 'TDSimpleListShuffle');
Range := succ(aEnd – aStart);
for Inx := aStart to aEnd do
begin
Randomlnx := aStart + Random (Range);
TempPtr := aList.List^[Inx];
aList.List^[Inx] := aList.List^[RandomInx];
aList.List^[RandomInx] := TempPtr;
end;
end;
А теперь давайте попробуем определить, сколько последовательностей можно получить с помощью приведенного алгоритма. После первого выполнения цикла мы получим одну из n возможных комбинаций (первый элемент может быть переставлен с любым другим, включая самого себя). После второго выполнения цикла мы снова получим одну из n возможных комбинаций, которые совместно с n комбинациями после первого выполнения дадут n(^2^) возможных комбинаций. Очевидно, что после выполнения всего цикла мы получим одну из n(^n^) возможных комбинаций.
С описанным алгоритмом связана только одна проблема. Если рассматривать тасование с другой точки зрения, с позиции главных принципов, можно показать, что для первой позиции можно выбрать один из n элементов. После этого для второй позиции останется выбор только из n – 1 элементов. Далее для третьей позиции элементов будет уже n – 2 и т.д. В результате таких рассуждений можно прийти к выводу, что общее количество возможных комбинаций будет вычисляться как n! (n! означает n факториал и сводится к произведению n * (n– 1) * (n-2) *...* 1.)
Вернемся к проблеме: если не брать во внимание случай, когда n = 1, n(^n^) больше, а часто намного больше, чем n! Таким образом, с помощью описанного алгоритма формируются повторяющиеся последовательности, причем некоторые из них будут повторяться чаще, нежели другие, поскольку n(^n^) не делится на n! без остатка.
В качестве более эффективного алгоритма тасования можно предложить метод, с помощью которого мы определили точное количество возможных комбинаций: брать первый элемент со всех n элементов, второй – из оставшихся (n – 1) элементов и т.д. На основе такого алгоритма можно создать следующую реализацию, где для удобства вычисления индекса цикл начинается с конца, а не с начала массива.
Листинг 5.3. Корректный метод тасования массива TList
procedure TDListShuffle(aList : TList; aStart, aEnd : integer);
var
Range : integer;
Inx : integer;
RandomInx : integer;
TempPtr : pointer;
begin
TDValidateListRange(aList, aStart, aEnd, 'TDListShuffle');
{для каждого элемента, считая справа...}
for Inx := (aEnd – aStart) downto aStart + 1 do
begin
{сгенерировать случайное число из диапазона от aStart до текущего индекса}
RandomInx := aStart + Random(Inx-aStart+ 1);
{если случайный индекс не равен текущему, переставить элементы}
if (RandomInx <> Inx) then begin
TempPtr := aList.List^[Inx];
aList.List^[Inx] := aList.List^[RandomInx];
aList.List^ [RandomInx] TempPtr;
end;
end;
end;
Основы сортировки
Алгоритмы сортировки можно разделить на два типа: устойчивые и неустойчивые. К устойчивой сортировке относятся те алгоритмы, которые при наличии в наборе данных нескольких равных элементов в отсортированном наборе оставляют их в том же порядке, в котором эти элементы были в исходном наборе. Например, предположим, что в наборе имеется три элемента и значение каждого элемента равно 42 (т.е. элементы равны). Пусть в исходном наборе элемент А находится в позиции 12, элемент В – в позиции 234, а С – в позиции 3456. После выполнения устойчивой сортировки они будут находиться в последовательности А, В, С, т.е. их взаимный порядок не изменится. С другой стороны, неустойчивая сортировка не гарантирует, что элементы с равными значениями будут находиться в определенной последовательности. Для нашего примера элементы А, В и С могут оказаться в последовательности А, В, С, или С, В, А, или любой другой.
В большинстве случаев устойчивость сортировки не имеет никакого значения. Устойчивая сортировка бывает нужна только для отдельных алгоритмов, но, как правило, нам нечего беспокоится об устойчивости.
Каждый из алгоритмов сортировки с целью упрощения понимания будет описан на примере сортировки колоды карт. Выберите все черви из колоды и перетасуйте их (манипулирование только 13 картами позволит упростить вашу работу).
Самые медленные алгоритмы сортировки
Мы будет рассматривать все алгоритмы сортировки, разделяя их на три группы. К первой группе отнесем медленные алгоритмы, принадлежащие к классу O(n(^2^)), хотя парочка из них в отдельных ситуациях на определенных распределениях данных дает очень высокие показатели производительности.
Пузырьковая сортировка
Первый алгоритм, с которым сталкиваются все программисты при изучении азов программирования, – это пузырьковая сортировка (bubble sort). Как это ни прискорбно, но из всех известных алгоритмов пузырьковая сортировка является самой медленной. Хотя, возможно, ее легче запрограммировать, чем другие алгоритмы сортировки (хотя и не намного).

Рисунок 5.1. Один проход с помощью алгоритма пузырьковой сортировки
Пузырьковая сортировка работает следующим образом. Разложите ваши карты (помните, что их всего 13?). Посмотрите на двенадцатую и тринадцатую карту. Если двенадцатая карта старше тринадцатой, поменяйте их местами. Теперь перейдите к одиннадцатой и двенадцатой картам. Если одиннадцатая карта старше двенадцатой, поменяйте их местами. То же сделайте и для пар (10, 11), (9, 10) и т.д., пока не дойдете до первой и второй карты. После первого прохода по всей колоде туз окажется на первой позиции. Фактически когда вы «зацепились» за туз он «выплыл» на первую позицию. Теперь вернитесь к двенадцатой и тринадцатой картам. Выполните описанный выше процесс, на этот раз остановившись на второй и третьей картах. Обратите внимание, что вам удалось переместить двойку на вторую позицию. Продолжайте процесс сортировки, уменьшая с каждым новым циклом количество просматриваемых карт и поступая так до тех пор, пока вся колода не будет отсортирована.
Полагаем, вы согласитесь с тем, что сортировка была довольно-таки утомительной. При реализации алгоритма на языке Pascal "утомительность" выражается медленной скоростью работы. Тем не менее, существует один простой метод оптимизации пузырьковой сортировки: если при выполнении очередного прохода не было выполнено ни одной перестановки, значит, карты уже отсортированы в требуемом порядке.
Листинг 5.4. Пузырьковая сортировка
procedure TDBubbleSort(aList : TList;
aFirst : integer;
aLast : integer;
aCompare : TtdCompareFunc);
var
i, j : integer;
Temp : pointer;
Done : boolean;
begin
TDValidateListRange(aList, aFirst, aLast, 'TDBubbleSort');
for i := aFirst to pred(aLast) do
begin
Done := true;
for j := aLast downto succ ( i ) do
if (aCompare(aList.List^[j], aList.List^ ) < 0) then begin
{переставить j-ый и (j – 1)-ый элементы}
Temp := aList.List^ [ j ];
aList.List^[j] := aList.List^[j-1];
aList.List^[j-1] :=Temp;
Done := false;
end;
if Done then
Exit;
end;
end;
Пузырьковая сортировка принадлежит к алгоритмам класса O(n(^2^)). Как видите, в реализации присутствуют два цикла: внешний и внутренний, при этом количество выполнений каждого цикла зависит от количества элементов в массиве. При первом выполнении внутреннего алгоритма будет произведено n – 1 сравнений, при втором – n – 2, при третьем – n – 3 и т.д. Всего будет n – 1 таких циклов, таким образом, общее количество сравнений составит:
(n-1) + (n-2)+... + 1
Приведенную сумму можно упростить до n (n – 1)/2 или (n(^2^) – n)/2. Другими словами, получаем O(n(^2^)). Количество перестановок вычислить несколько сложнее, но в худшем случае (когда элементы в исходном наборе были отсортированы в обратном порядке) количество перестановок будет равно количеству сравнений, т.е. снова получаем O(n(^2^)).
Небольшая оптимизация метода пузырьковой сортировки, о которой мы говорили чуть выше, означает, что если элементы в наборе уже отсортированы в нужном порядке, пузырьковая сортировка будет выполняться очень быстро: будет выполнен всего один проход по списку, не будет сделано ни одной перестановки и выполнение алгоритма завершится, (n -1) сравнений и ни одной перестановки говорят о том, что в лучшем случае быстродействие пузырьковой сортировки равно O(n).
Одна большая проблема, связанная с пузырьковой сортировкой, да и честно говоря, со многими другими алгоритмами, состоит в том, что переставляются только соседние элементы. Если элемент с наименьшим значением оказывается в самом конце списка, он будет меняться местами с соседними элементами до тех пор, пока он не достигнет первой позиции.
Пузырьковая сортировка относится к нестабильным алгоритмам, поскольку из двух элементов с равными значениями первым в отсортированном списке будет тот, который находился в исходном списке дальше от начала. Если изменить тип сравнения на "меньше чем" или "равен", а не просто "меньше", тогда пузырьковая сортировка станет устойчивой, но количество перестановок увеличится, и введенная нами оптимизация не даст запланированного выигрыша в скорости.
Шейкер-сортировка
Пузырьковая сортировка имеет одну малоизвестную вариацию, которая на практике дает незначительное увеличение скорости, – это так называемая шейкер-сортировка (shaker sort).

Рисунок 5.2. Два прохода с помощью шейкер-сортировки
Вернемся к картам. Выполните первый проход согласно алгоритму сортировки. Туз попадет на первую позицию. Теперь, вместо прохода колоды карт справа налево, пройдите слева направо: сравните вторую и третью карты и старшую карту поместите на третью позицию. Сравните третью и четвертую карты, и при необходимости поменяйте их местами. Продолжайте сравнения вплоть до достижения пары (12, 13). По пути к правому краю колоды вы «захватили» короля и переместили его на последнюю позицию.
А теперь снова пройдите колоду справа налево до второй карты. Во вторую позицию попадет двойка. Продолжайте чередовать направления проходов до тех пор, пока не будет отсортирована вся колода.
Листинг 5.5. Шейкер-сортировка
procedure TDShakerSort(aList :TList;
aFirst : integer; aLast : integer;
aCompare : TtdCompareFunc);
var
i : integer;
Temp : pointer;
begin
TDValidateListRange(aList, aFirst, aLast, 'TDShakerSort');
while (aFirst < aLast) do
begin
for i := aLast downto succ(aFirst) do
if (aCompare(aList.List^[i], aList.List^[i-1]) < 0) then begin
Temp := aList.List^[i];
aList.List^[i] := aList.List^[i-1];
aList.List^[i-1] := Temp;
end;
inc(aFirst);
for i := succ(aFirst) to aLast do
if (aCompare(aList.List^[i], aList.List^[i-1]) < 0) then begin
Temp := aList.List^[i];
aList.List^[i] := aList.List^[i-1];
aList.List^[i-1] := Teilend;
dec(aLast);
end;
end;
Несмотря на то что шейкер-сортировка принадлежит к алгоритмам класса O(n(^2^)), время ее выполнения немного меньше, чем для пузырьковой сортировки. Причина, по которой алгоритм назван именно шейкер-сортировкой, состоит в том, что элементы в списке колеблются относительно своих позиций до тех пор, пока список не будет отсортирован.
Как и пузырьковая сортировка, шейкер-сортировка относится к неустойчивым алгоритмам.
Сортировка методом выбора
Следующим алгоритмом, который мы рассмотрим, будет сортировка методом выбора (selection sort). Это пока что первый метод, который действительно можно использовать в повседневной практике (о пузырьковой сортировке и шейкер-сортировке можно уже забыть).
Начиная с правого края колоды, просмотрите все карты и найдите самую младшую (конечно, это будет туз). Поменяйте местами туз с первой картой. Теперь, игнорируя первую карту, снова просмотрите всю колоду справа налево в поисках самой младшей карты. Поменяйте местами младшую карту со второй картой. Далее, игнорируя первые две карты, просмотрите всю колоду справа налево в поисках самой младшей карты и поменяйте найденную карту с третьей картой. Продолжайте процесс до тех пор, пока вся колода не будет отсортирована. Очевидно, что тринадцатый цикл не понадобится, поскольку он будет манипулировать только с одной картой, которая к тому времени уже будет находиться в требуемой позиции.
Листинг 5.6. Сортировка методом выбора
procedure TDSelectionSort(aList : TList;
aFirst : integer; aLast : integer;
aCompare : TtdCompareFunc);
var
i, j : integer;
IndexOfMin : integer;
Temp : pointer;
begin
TDValidateListRange(aList, aFirst, aLast, 'TDSelectionSort');
for i := aFirst to pred(aLast) do
begin
IndexOfMin := i;
for j := succ(i) to aLast do
if (aCompare(aList.List^[j], aList.List^[IndexOfMin]) < 0) then
IndexOfMin := j;
if (aIndexOfMin <> i) then begin
Temp := aList.List^[i];
aList.List^[i] := aList.List^[IndexOfMin];
aList.List^[IndexOfMin] := Teilend;
end;
end;

Рисунок 5.3 Сортировка методом выбора
Как видите, в приведенном коде снова присутствуют два вложенных цикла, следовательно, сортировка методом выбора относится к алгоритмам класса O(n(^2^)). В первом цикле индекс проходит значения от aFast до aLast-1 и при каждом его выполнении во внутреннем цикле определяется элемент с минимальным значением в оставшейся части списка. В отличие от нашего примера с картами, внутренний цикл заранее не знает, каковым будет минимальный элемент в списке, поэтому ему нужно просмотреть все элементы. После обнаружения минимального элемента он переставляется в требуемую позицию.
Сортировка методом выбора интересна одной своей особенностью. Количество выполняемых сравнений для первого прохода равно n, для второго – n-1 и т.д. Общее количество сравнений будет равно n (n + 1)/2 = 1, т.е. сортировка принадлежит к классу алгоритмов O(n(^2^)). Тем не менее, количество перестановок намного меньше: при каждом выполнении внешнего цикла производится всего одна перестановка. Таким образом, общее количество перестановок (n – 1), т.е. O(n). Что это означает на практике? Если стоимость перестановки элементов намного больше, чем время сравнения (под стоимостью в данном случае понимается время или требуемые ресурсы), сортировка методом выбора оказывается достаточно эффективной.
Сортировка методом выбора относится к группе устойчивых алгоритмов. Поиск наименьшего значения будет возвращать первое в списке наименьшее значение из нескольких имеющихся. Таким образом, равные значения будут находиться в отсортированном списке в том же порядке, в котором они были в исходном списке.
Сортировка методом вставок
И последний алгоритм из первого рассматриваемого нами набора – сортировка методом вставок, или сортировка простыми вставками (Insertion sort). Этот алгоритм покажется знакомым всем, кто играет в такие карточные игры, как вист или бридж, поскольку большинство игроков сортирует свои карты именно так.

Рисунок 5.4. Стандартная сортировка методом вставок
Начинаем с левого края колоды. Сравниваем две первые карты и располагаем их в правильном порядке. Смотрим на третью карту. Вставляем ее в требуемое место по отношению к первым двум картам. Смотрим на четвертую карту и вставляем ее в требуемое место по отношению к первым трем картам. Те же операции выполняем с пятой, шестой, седьмой и всеми последующими картами. При перемещении слева направо левая часть колоды будет отсортированной.
Листинг 5.7. Стандартная сортировка методом вставок
procedure TDInsertionSortStd(aList : TList;
aFirst : integer;
aLast : integer;
aCompare : TtdCompareFunc);
var
i, j : integer;
Temp : pointer;
begin
TDValidateListRange(aList, aFirst, aLast, 'TDInsertionSortStd');
for i := succ(aFirst) to aLast do
begin
Temp := aList.List^[i];
j :=i;
while (j > aFirst) and (aCompare(Temp, aList.List^[j-1]) < 0) do
begin
aList.List^[j] := aList.List^[j-1];
dec(j);
end;
aList.List^[j] := Temp;
end;
end;
В приведенной реализации сортировки методом вставок имеется одна очень интересная особенность: значение текущего элемента сохраняется в локальной переменной, а затем при поиске нужного места его вставки (внутренний цикл) мы перемещаем каждый элемент, значение которого больше текущего, на одну позицию вправо, тем самым, перемещая «дыру» в списке влево. В конце концов, мы находим нужное место и помещаем сохраненное значение в освободившееся место.
Давайте посмотрим на внутренний цикл. Его выполнение завершается при соблюдении одного из двух условий: достигнуто начало списка, т.е. текущее значение меньше значений всех уже отсортированных элементов, или обнаружено значение, меньшее текущего. Тем не менее, обратите внимание, что первое условие проверяется при каждом выполнении внутреннего цикла, несмотря на то что оно соблюдается достаточно редко, когда текущее значение меньше, чем значения всех уже отсортированных элементов, однако оно предотвращает выход за пределы списка. Традиционным методом исключения этой дополнительной проверки является введение в начало списка сигнального элемента, который меньше любого другого элемента в списке. К сожалению, в общем случае минимальный элемент в списке заранее неизвестен и, кроме того, в списке нет места для вставки дополнительного элемента. (Теоретически потребуется скопировать весь список в другой, размер которого больше на один элемент, установить значение первого элемента в этом новом списке равным минимальному значению из сортируемого списка, а затем после сортировки скопировать элементы в исходный список. И все это ради того, чтобы исключить одну проверку. Нет уж, спасибо.)

Рисунок 5.5. Сортировка методом вставок
Существует более эффективный метод оптимизации: просмотреть весь список, найти элемент с наименьшим значением и переставить его на первое место (фактически это выполнение первого цикла Сортировки методом выбора). Теперь, когда первый элемент находится в требуемой позиции, можно выполнять стандартную процедуру метода вставок и игнорировать возможность выхода за начало списка.
Листинг 5.8. Оптимизированная сортировка методом вставок
procedure TDInsertionSort(aList : TList;
aFirst : integer;
aLast : integer;
aCompare : TtdCompareFunc);
var
i, j : integer;
IndexOfMin : integer;
Temp : pointer;
begin
TDValidateListRange(aList, aFirst, aLast, 'TDInsertionSort');
{найти наименьший элемент и поместить его в первую позицию}
IndexOfMin := aFirst;
for i := succ(aFirst) to aLast do
if (aCompare(aList.List^[i], aList.List^[IndexOfMin]) < 0) then
IndexOfMin := i;
if (aFirst <> indexOfMin) then begin
Temp := aList.List^[aFirst];
aList.List^[aFirst] := aList.List^[IndexOfMin];
aList.List^ [IndexOfMin] := Teufend;
{отсортировать с использованием метода простых вставок}
for i := aFirst+2 to aLast do
begin
Temp := aList.List^[i];
j := i;
while (aCompare(Temp, aList.List^[j-1]) < 0) do
begin
aList.List^[j] := aList.List^[j-1];
dec(j);
end;
aList.List^[j] := Temp;
end;
end;
Хотите верьте, хотите нет, но предварительная установка наименьшего значения в первую позицию и исключение дополнительной проверки выхода за границы списка при тестировании дала увеличение быстродействия примерно на 7%.
Как и три предыдущие рассмотренные нами алгоритма, сортировка методом вставок принадлежит к классу алгоритмов O(n(^2^)). Как и в случае с пузырьковой, сортировкой, если исходный список уже отсортирован, сортировка методом вставок практически не выполняет никаких действий помимо сравнения пар двух соседних элементов. Худшим случаем для сортировки методом вставок является ситуация, когда исходный список отсортирован в обратном порядке (как и для пузырьковой сортировки) – для попадания в требуемое место каждому элементу нужно пройти максимальное расстояние.
Тем не менее, если список частично отсортирован, и каждый элемент находится недалеко от требуемого места, сортировка методом вставок будет выполняться очень быстро. Фактически она превращается в алгоритм класса O(n). (Другими словами, внешний цикл выполняется n – 1 раз, а внутренний – всего несколько раз, что соответствует небольшому расстоянию элементов от их позиции в отсортированном списке.) Таким образом, во внутреннем цикле выполняется некоторое постоянное количество проходов (т.е. сравнений и перемещений), скажем, d. Для внешнего цикла, как мы уже говорили, количество проходов равно n – 1. Следовательно, общее количество сравнений и перемещений будет выражаться значением d(n– 1) (алгоритм класса O(n)). Несмотря на то что на практике частично отсортированные списки встречаются достаточно редко, тем не менее, возможна ситуация, когда с частично отсортированными списками можно сталкиваться гораздо чаще. Мы рассмотрим эту ситуацию немного ниже.
Сортировка методом вставок (любая ее вариация) принадлежит к группе устойчивых алгоритмов. Она сохраняет относительное положение элементов с равными значениями, поскольку поиск требуемой позиции для элемента завершается, когда найден элемент, значение которого меньше или равно значению текущего элемента. Следовательно, относительное положение элементов с равными значениями сохраняется.
Как и при пузырьковой сортировке, при сортировке методом вставок элементы попадают в нужные позиции только за счет смены позиций с соседними элементами. Если элемент находится далеко от требуемой позиции, его перемещение занимает много времени. Если бы только мы могли перемещать элементы не через соседние элементы, а сразу в некоторый диапазон, где текущий элемент должен находиться! Давайте познакомимся со вторым набором алгоритмов.
Быстрые алгоритмы сортировки
Алгоритмы второго набора работают быстрее всех тех методов, которые мы только что рассмотрели. Тем не менее, в отличие от набора самых быстрых сортировок, к которому мы вскоре перейдем, очень сложно выполнить их математический анализ. Несмотря на то что на практике алгоритмы этой группы выполняются достаточно быстро, используют их сравнительно редко.
Сортировка методом Шелла
Этот метод разработал Дональд Л. Шелл (Donald L. Shell) в 1959 году. Он основан на сортировке методом вставок и при первом рассмотрении может показаться несколько странным.
Сортировка методом Шелла (Shell sort) пытается повысить скорость работы за счет более быстрого перемещения элементов, находящихся далеко от нужных им позиций. Она предполагает перемещение таких элементов большими "прыжками" через несколько элементов одновременно, уменьшая размер "прыжков" и, в конце концов, окончательная установка элементов в нужные позиции выполняется с помощью классической сортировки методом вставок.
Выполнение сортировки методом Шелла на примере карточной колоды требует немало усилий, но не будем терять времени. Разложите колоду в длинную линию. Извлеките из колоды первую и каждую четвертую карту после первой (т.е., пятую, девятую и тринадцатую). Выполните сортировку выбранных карт с помощью метода вставок и снова поместите все карты в колоду. Извлеките из колоды вторую и каждую четвертую карту после второй (т.е., шестую и десятую). Выполните сортировку выбранных карт с помощью метода вставок и снова поместите все карты в колоду. Выполните те же операции над третьей и каждой четвертой картой после третьей, а затем над четвертой и каждой четвертой картой после четвертой.
После первого прохода карты будут находиться в отсортированном порядке по 4. Какую бы карту вы не выбрали, карты, которые находятся на количество позиций, кратном 4 вперед и назад, будут отсортированы в требуемом порядке. Обратите внимание, что карты в целом не отсортированы, но, тем не менее, независимо от исходного положения карт, после первого прохода они будут находиться недалеко от своих мест в отсортированной последовательности.
Теперь выполним стандартную сортировку методом вставок, в результате чего получим отсортированную колоду карт. Как уже говорилось, при небольших расстояниях между элементами в исходном списке и их позициями в отсортированном списке (что мы и получили после первого прохода) быстродействие сортировки методом вставок линейно зависит от числа элементов.
Говоря более строгим языком, сортировка методом Шелла работает путем вставки отсортированных подмножеств основного списка. Каждое подмножество формируется за счет выборки каждого h-ого элемента, начиная с любой позиции в списке. В результате будет получено h подмножеств, которые отсортированы методом вставок. Полученная последовательность элементов в списке называется отсортированной по h. Затем значение к уменьшается и снова выполняется сортировка. Уменьшение значение к происходит до тех пор, пока к не будет равно 1, после чего последний проход будет представлять собой стандартную сортировку методом вставок (которая, если быть точным, представляет собой сортировку по 1).
Суть сортировки методом Шелла заключается в том, что сортировка по h быстро переносит элементы в область, где они должны находиться в отсортированном списке, а уменьшение значения к позволяет постепенно уменьшать размер "прыжков" и, в конце концов, поместить элемент в требуемую позицию. Медленному перемещению элементов предшествуют большие "скачки", сводящиеся к простой сортировке методом вставок, которая практически не передвигает элементы.
Какие значения к лучше всего использовать? Шелл в своей первой статье на эту тему предложил значения 1, 2, 4, 8, 16, 32 и т.д. (естественно, в обратном порядке), но с этими значениями связана одна проблема: до последнего прохода элементы с четными индексами никогда не сравниваются с элементами с нечетными индексами. И, следовательно, при выполнении последнего прохода все еще возможны перемещения элементов на большие расстояния (представьте себе, например, искусственный случай, когда элементы с меньшими значениями находятся в позициях с четными индексами, а элементы с большими значениями – в позициях с нечетными индексами).

Рисунок 5.6. Сортировка методом Шелла
В 1969 году Дональд Кнут (Donald Knuth) предложил последовательность 1, 4, 13, 40, 121 и т.д. (каждое последующее значение на единицу больше, чем утроенное предыдущее значение). Для списков средних размеров эта последовательность позволяет получить достаточно высокие характеристики быстродействия (на основе эмпирических исследований Кнут оценил быстродействие для среднего случая как O(n(^5/4^)), а для худшего случая было доказано, что скорость работы равна O(n(^3/2^))) при несложном методе вычисления значений самой последовательности. Ряд других последовательностей позволяют получить более высокие значения скорости работы (хотя и не намного), но требуют предварительного вычисления значений последовательности, поскольку используемые формулы достаточно сложны. В качестве примера можно привести самую быструю известную на сегодняшний день последовательность, разработанную Робертом Седжвиком (Robert Sedgewick): 1, 5, 19, 41, 109 и т.д. (формируется путем слияния двух последовательностей – 9 * 4i – 9 * 2i + 1 для i > 0 и 4i – 3 * 2i + 1 для i > 1). Известно, что для этой последовательности время работы в худшем случае определяется как O(n(^4/3^)) при O(n(^7/6^)) для среднего случая. В этой книге мы не будем приводить математические выкладки для определения приведенных зависимостей. Пока не известно, существуют ли еще более быстрые последовательности. (подробнейшие выкладки и анализ всех фундаментальных алгоритмов, в числе которых и алгоритмы, рассмотренные в данной книге, а также эффективная их реализация на языках С, С++ и Java, можно найти в многотомниках Роберта Седжвика «Фундаментальные алгоритмы на С++», «Фундаментальные алгоритмы на С» и «Фундаментальные алгоритмы на Java», которые выпущены издательством «Диасофт».)
Листинг 5.9. Сортировка методом Шелла при использовании последовательности Кнута
procedure TDShellSort(aList : TList;
aFirst : integer;
aLast : integer;
aCompare : TtdCompareFunc);
var
i, j : integer;
h : integer;
Temp : pointer;
Ninth : integer;
begin
TDValidateListRange(aList, aFirst, aLast, 'TDShellSort');
{прежде всего вычисляем начальное значение h; оно должно быть близко к одной девятой количества элементов в списке}
h := 1;
Ninth := (aLast – aFirst) div 9;
while (h<= Ninth) do h := (h * 3) + 1;
{начать выполнение цикла, который при каждом проходе уменьшает значение h на треть}
while (h > 0) do
begin
{выполнить сортировку методом вставки для каждого подмножества}
for i := (aFirst + h) to aLast do
begin
Temp := aList.List^[i];
j := i;
while (j >= (aFirst+h)) and
(aCompare(Temp, aList.List^[j-h]) < 0) do









