Учебное пособие Чебоксары 2008 удк 004. 657 П 36 icon

Учебное пособие Чебоксары 2008 удк 004. 657 П 36



Смотрите также:
1   2   3   4   5   6   7

^ 2. Задачи поиска в структурах данных

Одно из наиболее часто встречающихся в программировании действий – поиск. Существует несколько основных вариантов поиска, и для них создано много различных алгоритмов. При дальнейшем рассмотрении делается принципиальное допущение: группа данных, в которой необходимо найти заданный элемент, фиксирована. Будет считаться, что множество из N элементов задано в виде такого массива:

a: array[0..N–1] of Item

Обычно тип Item описывает запись с некоторым полем, играющим роль ключа. Задача заключается в поиске элемента, ключ которого равен “аргументу поиска” x. Полученный в результате индекс i, удовлетворяющий условию а[i].key = x, обеспечивает доступ к другим полям обнаруженного элемента. Так как здесь рассматривается прежде всего сам процесс поиска, то будем считать, что тип Item включает только ключ.

^ Линейный поиск

Если нет никакой дополнительной информации о разыскиваемых данных, то очевидный подход – простой последовательный просмотр массива с увеличением шаг за шагом той его части, где желаемого элемента не обнаружено. Такой метод называется линейным поиском. Условия окончания поиска таковы:

Элемент найден, т. е. аi = x.

Весь массив просмотрен, и совпадения не обнаружено. Это дает нам линейный алгоритм:

Алгоритм 1.

i:=0;

while (iх) do i:=i+1

Следует обратить внимание, что если элемент найден, то он найден вместе с минимально возможным индексом, т.е. это первый из таких элементов. Равенство i = N свидетельствует, что совпадения не существует.

Очевидно, что окончание цикла гарантировано, поскольку на каждом шаге значение i увеличивается, и, следовательно, оно достигнет за конечное число шагов предела N; фактически же, если совпадения не было, это произойдет после N шагов.

На каждом шаге алгоритма осуществляется увеличение индекса и вычисление логического выражения. Можно упростить шаг алгоритма, если упростить логическое выражение, которое состоит из двух членов. Это упрощение осуществляется путем формулирования логического выражения из одного члена, но при этом необходимо гарантировать, что совпадение произойдет обязательно. Для этого достаточно в конец массива поместить дополнительный элемент со значением x. Такой вспомогательный элемент называется “барьером”. Теперь массив будет описан так:

а: array[0..N] of integer

и алгоритм линейного поиска с барьером выглядит следующим образом:

Алгоритм 1*.

a[N]:=x; i:=0;

while a[i]<>x do i:=i+1

Ясно, что равенство i=N свидетельствует о том, что совпадения (если не считать совпадения с барьером) не было.

^ Поиск делением пополам (двоичный поиск)

Совершенно очевидно, что других способов убыстрения поиска не существует, если, конечно, нет еще какой-либо информации о данных, среди которых идет поиск. Хорошо известно, что поиск можно сделать значительно более эффективным, если данные будут упорядочены. Поэтому приведем алгоритм (он называется “поиском делением пополам”) (рис.2.1), основанный на знании того, что массив A упорядочен, т. е. удовлетворяет условию:

, где

Основная идея – выбрать случайно некоторый элемент, предположим Am, и сравнить его с аргументом поиска x. Если он равен x, то поиск заканчивается, если он меньше x, то делается вывод, что все элементы с индексами, меньшими или равными m, можно исключить из дальнейшего поиска; если же он больше x, то исключаются индексы больше и равные m. Выбор m совершенно не влияет на корректность алгоритма, но влияет на его эффективность. Очевидно, что чем большее количество элементов исключается на каждом шаге алгоритма, тем этот алгоритм эффективнее. Оптимальным решением будет выбор среднего элемента, так как при этом в любом случае будет исключаться половина массива.



Рис.2.1. Поиск делением пополам

В этом алгоритме используются две индексные переменные L и R, которые отмечают соответственно левый и правый конец секции массива a, где еще может быть обнаружен требуемый элемент.

Алгоритм 2.

L:=0; R:=N-1; Found:=false;

while(L<=R) and not Found do begin

             m:=(L+R) div 2;

                          if a[m]=x then begin

                                       Found:=true

                         end else begin

                                if a[m]
                         end

end;

Максимальное число сравнений для этого алгоритма равно log2n, округленному до ближайшего целого. Таким образом, приведенный алгоритм существенно выигрывает по сравнению с линейным поиском, ведь там ожидаемое число сравнений – N/2.

Эффективность несколько улучшается, если поменять местами заголовки условных операторов. Проверку на равенство можно выполнять во вторую очередь, так как она встречается лишь единожды и приводит к окончанию работы. Но более существенный выигрыш даст отказ от окончания поиска при фиксации совпадения. На первый взгляд это кажется странным, однако при внимательном рассмотрении обнаруживается, что выигрыш в эффективности на каждом шаге превосходит потери от сравнения с несколькими дополнительными элементами (число шагов в худшем случае равно logN).

Алгоритм 2*.

L:=0; R:=N;

while L
                 m:=(L+R) div 2;

                 if а[m]
end

Окончание цикла гарантировано. Это объясняется следующим. В начале каждого шага L
Выполнение условия L=R еще не свидетельствует о нахождении требуемого элемента. Здесь требуется дополнительная проверка. Также необходимо учитывать, что элемент a[R] в сравнениях никогда не участвует. Следовательно, и здесь необходима дополнительная проверка на равенство a[R]=x. Следует отметить, что эти проверки выполняются однократно.

Приведенный алгоритм, как и в случае линейного поиска, находит совпадающий элемент с наименьшим индексом.

^ Поиск в таблице

Поиск в массиве иногда называют поиском в таблице, особенно если ключ сам является составным объектом, таким, как массив чисел или символов. Часто встречается именно последний случай, когда массивы символов называют строками или словами. Строковый тип определяется так:

String = array[0..М–1] of char;

соответственно определяется и отношение порядка для строк x и y:

x = y, если xj = yj для 0 face="Symbol" =< j < M

x < y, если xi < yi для 0 face="Symbol" =< i < M и xj = yj для 0 face="Symbol" =< j < i

Для того чтобы установить факт совпадения, необходимо установить, что все символы сравниваемых строк соответственно равны один другому. Поэтому сравнение составных операндов сводится к поиску их несовпадающих частей, т. е. к поиску “на неравенство”. Если неравных частей не существует, то можно говорить о равенстве. Предположим, что размер слов достаточно мал, меньше 30. В этом случае можно использовать линейный поиск.

Для большинства практических приложений желательно исходить из того, что строки имеют переменный размер. Это предполагает, что размер указывается в каждой отдельной строке. Если исходить из ранее описанного типа, то размер не должен превосходить максимального размера M. Такая схема достаточно гибка и подходит для многих случаев, в то же время она позволяет избежать сложностей динамического распределения памяти. Чаще всего используются два таких представления размера строк:

Размер неявно указывается путем добавления концевого символа, больше этот символ нигде не употребляется. Обычно для этой цели используется “непечатаемый” символ со значением 00h. Для дальнейшего важно, что это минимальный символ из всего множества символов.

Размер явно хранится в качестве первого элемента массива, т. е. строка s имеет следующий вид: s = s0, s1, s2, ..., sM-1. Здесь s1, ..., sM-1 – фактические символы строки, а s0 = Chr(M). Такой прием имеет то преимущество, что размер явно доступен, недостаток же в том, что этот размер ограничен размером множества символов (256).

В последующем алгоритме поиска отдается предпочтение первой схеме. В этом случае сравнение строк выполняется так:

i:=0;

while (x[i]=y[i]) and (x[i]<>00h) do i:=i+1

Концевой символ работает здесь как барьер.

Теперь вернемся к задаче поиска в таблице. Он требует “вложенных” поисков, а именно: поиска по строчкам таблицы, а для каждой строчки последовательных сравнений – между компонентами. Например, пусть таблица T и аргумент поиска x определяются таким образом:

T: array [0..N-1] of String;

x: String

Допустим, N достаточно велико, а таблица упорядочена в алфавитном порядке. При использовании алгоритма поиска делением пополам и алгоритма сравнения строк, речь о которых шла выше, получаем такой фрагмент программы:

L:=0; R:=N;

while L
             m:=(L+R) div 2; i:=0;

             while (T[m,i]=x[i]) and (x[i]<>00h) do i:=i+1;

                       if T[m,i]
end;

if R
           i:=0;

            while (T[R,i]=х[i]) and (х[i]<>00h) do i:=i+1

end

{(R
^ Прямой поиск строки

Часто приходится сталкиваться со специфическим поиском, так называемым поиском строки. Его можно определить следующим образом. Пусть задан массив s из N элементов и массив p из M элементов, причем 0 < M face="Symbol" =< N. Описаны они так:

s: array[0..N–1] of Item

р: array[0..M–1] of Item

Поиск строки обнаруживает первое вхождение p в s. Обычно Item – это символы, т.е. s можно считать некоторым текстом, а p – словом, и необходимо найти первое вхождение этого слова в указанном тексте. Это действие типично для любых систем обработки текстов, отсюда и очевидная заинтересованность в поиске эффективного алгоритма для этой задачи. Разберем алгоритм поиска, который будем называть прямым поиском строки.

Алгоритм 3.

i:=-1;

repeat

           i:=i+1; j:=0;

           while (j
until (j=M) or (i=N-M)

Вложенный цикл с предусловием начинает выполняться тогда, когда первый символ слова p совпадает с очередным, i-м, символом текста s. Этот цикл повторяется столько раз, сколько совпадает символов текста s, начиная с i-го символа, с символами слова p (максимальное количество повторений равно M). Цикл завершается при исчерпании символов слова p (перестает выполняться условие j
Этот алгоритм работает достаточно эффективно, если допустить, что несовпадение пары символов происходит после незначительного количества сравнений во внутреннем цикле. При большой мощности типа Item это достаточно частый случай. Можно предполагать, что для текстов, составленных из 128 символов, несовпадение будет обнаруживаться после одной или двух проверок. В худшем случае производительность будет внушать опасение.

^ Алгоритм Кнута, Мориса и Пратта

Приблизительно в 1970 г. Д.Кнут, Д.Морис и В.Пратт изобрели алгоритм, фактически требующий только N сравнений даже в самом плохом случае. Новый алгоритм основывается на том соображении, что после частичного совпадения начальной части слова с соответствующими символами текста фактически известна пройденная часть текста и можно “вычислить” некоторые сведения (на основе самого слова), с помощью которых потом можно быстро продвинуться по тексту. Приведенный пример поиска слова ABCABD показывает принцип работы такого алгоритма. Символы, подвергшиеся сравнению, здесь подчеркнуты. Обратите внимание: при каждом несовпадении пары символов слово сдвигается на все пройденное расстояние, поскольку меньшие сдвиги не могут привести к полному совпадению.

Основным отличием КМП-алгоритма от алгоритма прямого поиска является осуществление сдвига слова не на один символ на каждом шаге алгоритма, а на некоторое переменное количество символов. Таким образом, перед тем как осуществлять очередной сдвиг, необходимо определить величину сдвига. Для повышения эффективности алгоритма необходимо, чтобы сдвиг на каждом шаге был бы как можно большим.



Если j определяет позицию в слове, содержащую первый несовпадающий символ (как в алгоритме прямого поиска), то длина сдвига определяется как j-D. Значение D определяется как размер самой длинной последовательности символов слова, непосредственно предшествующих позиции j, которая полностью совпадает с началом слова. D зависит только от слова и не зависит от текста. Для каждого j будет своя величина D, которую обозначим dj.

Так как величины dj зависят только от слова, то перед началом фактического поиска можно вычислить вспомогательную таблицу d; эти вычисления сводятся к некоторой предтрансляции слова. Соответствующие усилия будут оправданными, если размер текста значительно превышает размер слова (M<
Последний пример на рис. 2.2 показывает: так как pj равно A вместо F, то соответствующий символ текста не может быть символом A из-за того, что условие si<>pj заканчивает цикл. Следовательно, сдвиг на 5 не приведет к последующему совпадению, и поэтому можно увеличить размер сдвига до шести. Учитывая это, предопределяем вычисление dj как поиск самой длинной совпадающей последовательности с дополнительным ограничением pdj<>pj. Если никаких совпадений нет, то считается dj =-1, что указывает на сдвиг “на целое” слово относительно его текущей позиции. Следующая программа демонстрирует КМП-алгоритм.



Рис. 2.2. Частичное совпадение со словом и вычисление d j

Program KMP;

const

        Mmax = 100; Nmax = 10000;

var

        i, j, k, M, N: integer;

        p: array[0..Mmax-1] of char; {слово}

        s: array[0..Mmax-1] of char; {текст}

        d: array[0..Mmax-1] of integer;

begin

{Ввод текста s и слова p}

Write('N:'); Readln(N);

Write('s:'); Readln(s);

Write('M:'); Readln(M);

Write('p:'); Readln(p);

{Заполнение массива d}

j:=0; k:=-1; d[0]:=-1;

while j<(M-1) do begin

            while(k>=0) and (p[j]<>p[k]) do k:=d[k];

            j:=j+1; k:=k+1;

            if p[j]=p[k] then

                    d[j]:=d[k]

            else

                    d[j]:=k;

end;

{Поиск слова p в тексте s}

i:=0; j:=0;

while (j
         while (j>=0) and (s[i]<>p[j]) do j:=d[j];

{Сдвиг слова}

          i:=i+1; j:=j+1;

end;

{Вывод результата поиска}

if j=M then Writeln('Yes') {найден }

else Writeln('No'); {не найден}

Readln;

end.

Точный анализ КМП-поиска, как и сам его алгоритм, весьма сложен. Его изобретатели доказывают, что требуется порядка M+N сравнений символов, что значительно лучше, чем M×N сравнений из прямого поиска. Они также отмечают то положительное свойство, что указатель сканирования i никогда не возвращается назад, в то время как при прямом поиске после несовпадения просмотр всегда начинается с первого символа слова и поэтому может включать символы, которые ранее уже просматривались. Это может привести к негативным последствиям, если текст читается из вторичной памяти, ведь в этом случае возврат обходится дорого. Даже при буферизованном вводе может встретиться столь большое слово, что возврат превысит емкость буфера.

^ Алгоритм Боуера и Мура

КМП-поиск дает подлинный выигрыш только тогда, когда неудаче предшествовало некоторое число совпадений. Лишь в этом случае слово сдвигается более чем на единицу. К несчастью, это скорее исключение, чем правило: совпадения встречаются значительно реже, чем несовпадения. Поэтому выигрыш от использования КМП-стратегии в большинстве случаев поиска в обычных текстах весьма незначителен. Метод же, предложенный Р.Боуером и Д.Муром в 1975 г., не только улучшает обработку самого плохого случая, но дает выигрыш в промежуточных ситуациях.

БМ-поиск основывается на необычном соображении – сравнение символов начинается с конца слова, а не с начала. Как и в случае КМП-поиска, слово перед фактическим поиском трансформируется в некоторую таблицу. Пусть для каждого символа x из алфавита величина dx – расстояние от самого правого в слове вхождения x до правого конца слова. Представим себе, что обнаружено расхождение между словом и текстом. В этом случае слово сразу же можно сдвинуть вправо на dpM-1 позиций, т.е. на число позиций, скорее всего, больше единицы. Если несовпадающий символ текста в слове вообще не встречается, то сдвиг становится даже больше, а именно сдвигать можно на длину всего слова. Вот пример, иллюстрирующий этот процесс:



Ниже приводится программа с упрощенной стратегией Боуера–Мура, построенная так же, как и предыдущая программа с КМП-алгоритмом. Обратите внимание на такую деталь: во внутреннем цикле используется цикл с repeat, где перед сравнением s и p увеличиваются значения k и j. Это позволяет исключить в индексных выражениях составляющую -1.

Program BM;

const

        Mmax = 100; Nmax = 10000;

var

        i, j, k, M, N: integer;

        ch: char;

        p: array[0..Mmax-1] of char; {слово}

        s: array[0..Nmax-1] of char; {текст}

        d: array[' '..'z'] of integer;

begin

{Ввод текста s и слова p}

Write('N:'); Readln(N);

Write('s:'); Readln(s);

Write('M:'); Readln(M);

Write('p:'); Readln(p);

{Заполнение массива d}

for ch:=' ' to 'z' do d[ch]:=M;

for j:=0 to M-2 do d[p[j]]:=M-j-1;

{Поиск слова p в тексте s}

i:=M;

repeat

          j:=M; k:=i;

          repeat {Цикл сравнения символов}

                     k:=k-1; j:=j-1; {слова начиная с правого}

          until (j<0) or (p[j]<>s[k]); {Выход, если сравнили все}

          {слово или несовпадение}

          i:=i+d[s[i-1]]; {Сдвиг слова вправо }

until (j<0) or (i>N);

{Вывод результата поиска}

if j<0 then Writeln('Yes') {найден }

else Writeln('No'); {не найден}

Readln;

end.

Почти всегда, кроме специально построенных примеров, данный алгоритм требует значительно меньше N сравнений. В самых же благоприятных обстоятельствах, когда последний символ слова всегда попадает на несовпадающий символ текста, число сравнений равно N/M.

Авторы алгоритма приводят и несколько соображений по поводу дальнейших усовершенствований алгоритма. Одно из них – объединить приведенную только что стратегию, обеспечивающую большие сдвиги в случае несовпадения, со стратегией Кнута, Морриса и Пратта, допускающей “ощутимые” сдвиги при обнаружении совпадения (частичного). Такой метод требует двух таблиц, получаемых при предтрансляции: d1 – только что упомянутая таблица, а d2 – таблица, соответствующая КМП-алгоритму. Из двух сдвигов выбирается больший, причем и тот и другой “говорят”, что никакой меньший сдвиг не может привести к совпадению. Дальнейшее обсуждение этого предмета приводить не будем, поскольку дополнительное усложнение формирования таблиц и самого поиска не оправдывает видимого выигрыша в производительности. Фактические дополнительные расходы будут высокими, и неизвестно, приведут все эти ухищрения к выигрышу или проигрышу.


^ 3. Задачи сортировки в структурах данных

Здесь будем обсуждать методы сортировки информации. В общей постановке задача ставится следующим образом. Имеется последовательность однотипных записей, одно из полей которых выбрано в качестве ключевого (далее будем называть его ключом сортировки). Тип данных ключа должен содержать операции сравнения (=, >, <, >= и <=). Задачей сортировки является преобразование исходной последовательности в последовательность, содержащую те же записи, но в порядке возрастания (или убывания) значений ключа. Метод сортировки называется устойчивым, если при его применении не изменяется относительное положение записей с равными значениями ключа.

Различают сортировку массивов записей, целиком расположенных в основной памяти (внутреннюю сортировку), и сортировку файлов, хранящихся во внешней памяти и не помещающихся полностью в основной памяти (внешнюю сортировку). Для внутренней и внешней сортировки требуются существенно разные методы. В этой части рассмотрим наиболее известные методы внутренней сортировки, начиная с простых и понятных, но не слишком быстрых, и заканчивая не столь просто понимаемыми усложненными методами.

Естественным условием, предъявляемым к любому методу внутренней сортировки, является то, что эти методы не должны требовать дополнительной памяти: все перестановки с целью упорядочения элементов массива должны производиться в пределах того же массива. Мерой эффективности алгоритма внутренней сортировки является число требуемых сравнений значений ключа (C) и число перестановок элементов (M).

Заметим, что поскольку сортировка основана только на значениях ключа и никак не затрагивает оставшиеся поля записей, можно говорить о сортировке массивов ключей. В следующих разделах, чтобы не привязываться к конкретному языку программирования и его синтаксическим особенностям, будем описывать алгоритмы словами и иллюстрировать их на простых примерах.

^ Сортировка включением

Одним из наиболее простых и естественных методов внутренней сортировки является сортировка с простыми включениями. Идея алгоритма очень проста. Пусть имеется массив ключей a[1], a[2], ..., a[n]. Для каждого элемента массива, начиная со второго, производится сравнение с элементами с меньшим индексом (элемент a[i] последовательно сравнивается с элементами a[i-1], a[i-2] ...) и до тех пор, пока для очередного элемента a[j] выполняется соотношение a[j] > a[i], a[i] и a[j] меняются местами. Если удается встретить такой элемент a[j], что a[j] <= a[i], или если достигнута нижняя граница массива, производится переход к обработке элемента a[i+1] (пока не будет достигнута верхняя граница массива).

Легко видеть, что в лучшем случае (когда массив уже упорядочен) для выполнения алгоритма с массивом из n элементов потребуется n-1 сравнение и 0 пересылок. В худшем случае (когда массив упорядочен в обратном порядке) потребуется n(n-1)/2 сравнений и столько же пересылок. Таким образом, можно оценивать сложность метода простых включений как n2.

Можно сократить число сравнений, применяемых в методе простых включений, если воспользоваться тем фактом, что при обработке элемента a[i] массива элементы a[1], a[2], ..., a[i-1] уже упорядочены, и воспользоваться для поиска элемента, с которым должна быть произведена перестановка, методом двоичного деления. В этом случае оценка числа требуемых сравнений становится nlog(n). Заметим, что поскольку при выполнении перестановки требуется сдвижка на один элемент нескольких элементов, то оценка числа пересылок остается n2.

Пример сортировки методом простого включения

Начальное состояние массива : 8 23 5 65 44 33 1 6

Шаг 1: 8 23 5 65 44 33 1 6

Шаг 2:

8 5 23 65 44 33 1 6

5 8 23 65 44 33 1 6

Шаг 3: 5 8 23 65 44 33 1 6

Шаг 4: 5 8 23 44 65 33 1 6

Шаг 5:

5 8 23 44 33 65 1 6

5 8 23 33 44 65 1 6

Шаг 6:

5 8 23 33 44 1 65 6

5 8 23 33 1 44 65 6

5 8 23 1 33 44 65 6

5 8 1 23 33 44 65 6

5 1 8 23 33 44 65 6

1 5 8 23 33 44 65 6

Шаг 7:

1 5 8 23 33 44 6 65

1 5 8 23 33 6 44 65

1 5 8 23 6 33 44 65

1 5 8 6 23 33 44 65

1 5 6 8 23 33 44 65

Дальнейшим развитием метода сортировки с включениями является сортировка методом Шелла, называемая по-другому сортировкой включениями с уменьшающимся расстоянием. Мы не будем описывать алгоритм в общем виде, а ограничимся случаем, когда число элементов в сортируемом массиве является степенью числа 2. Для массива с 2n элементами алгоритм работает следующим образом. В первой фазе производится сортировка включением всех пар элементов массива, расстояние между которыми есть 2(n-1). Во второй фазе производится сортировка включением элементов полученного массива, расстояние между которыми есть 2(n-2). И так далее, пока мы не дойдем до фазы с расстоянием между элементами, равным единице, и не выполним завершающую сортировку с включениями. Применение метода Шелла к массиву, используемому в наших примерах, показано ниже.

Пример сортировки методом Шелла

Начальное состояние массива :

8 23 5 65 44 33 1 6

Фаза 1 (сортируются элементы, расстояние между которыми четыре):

8 23 5 65 44 33 1 6

8 23 5 65 44 33 1 6

8 23 1 65 44 33 5 6

8 23 1 6 44 33 5 65

Фаза 2 (сортируются элементы, расстояние между которыми два):

1 23 8 6 44 33 5 65

1 23 8 6 44 33 5 65

1 23 8 6 5 33 44 65

1 23 5 6 8 33 44 65

1 6 5 23 8 33 44 65

1 6 5 23 8 33 44 65

1 6 5 23 8 33 44 65

Фаза 3 (сортируются элементы, расстояние между которыми один):

1 6 5 23 8 33 44 65

1 5 6 23 8 33 44 65

1 5 6 23 8 33 44 65

1 5 6 8 23 33 44 65

1 5 6 8 23 33 44 65

1 5 6 8 23 33 44 65

1 5 6 8 23 33 44 65

В общем случае алгоритм Шелла естественно переформулируется для заданной последовательности из t расстояний между элементами h1, h2, ..., ht, для которых выполняются условия h1 = 1 и h(i+1) < hi. Дональд Кнут показал, что при правильно подобранных t и h сложность алгоритма Шелла является 1,2n, что существенно меньше сложности простых алгоритмов сортировки.

^ Обменная сортировка

Простая обменная сортировка (в просторечии называемая "методом пузырька") для массива a[1], a[2], ..., a[n] работает следующим образом. Начиная с конца массива сравниваются два соседних элемента (a[n] и a[n-1]). Если выполняется условие a[n-1] > a[n], то значения элементов меняются местами. Процесс продолжается для a[n-1] и a[n-2] и т.д., пока не будет произведено сравнение a[2] и a[1]. Понятно, что после этого на месте a[1] окажется элемент массива с наименьшим значением. На втором шаге процесс повторяется, но последними сравниваются a[3] и a[2]. На последнем шаге будут сравниваться только текущие значения a[n] и a[n-1]. Понятна аналогия с пузырьком, поскольку наименьшие элементы (самые "легкие") постепенно "всплывают" к верхней границе массива.

Пример сортировки методом пузырька

Начальное состояние массива :

8 23 5 65 44 33 1 6

Шаг 1:

8 23 5 65 44 33 1 6

8 23 5 65 44 1 33 6

8 23 5 65 1 44 33 6

8 23 5 1 65 44 33 6

8 23 1 5 65 44 33 6

8 1 23 5 65 44 33 6

1 8 23 5 65 44 33 6

Шаг 2:

1 8 23 5 65 44 6 33

1 8 23 5 65 6 44 33

1 8 23 5 6 65 44 33

1 8 23 5 6 65 44 33

1 8 5 23 6 65 44 33

1 5 8 23 6 65 44 33

Шаг 3:

1 5 8 23 6 65 33 44

1 5 8 23 6 33 65 44

1 5 8 23 6 33 65 44

1 5 8 6 23 33 65 44

1 5 6 8 23 33 65 44

Шаг 4:

1 5 6 8 23 33 44 65

1 5 6 8 23 33 44 65

1 5 6 8 23 33 44 65

1 5 6 8 23 33 44 65

Шаг 5:

1 5 6 8 23 33 44 65

1 5 6 8 23 33 44 65

1 5 6 8 23 33 44 65

Шаг 6:

1 5 6 8 23 33 44 65

1 5 6 8 23 33 44 65

Шаг 7:

1 5 6 8 23 33 44 65

Для метода простой обменной сортировки требуется число сравнений n(n-1)/2, минимальное число пересылок 0, а среднее и максимальное число пересылок n2.

Метод пузырька допускает три простых усовершенствования. Во-первых, на четырех последних шагах расположение значений элементов не менялось (массив оказался уже упорядоченным). Поэтому если на некотором шаге не было произведено ни одного обмена, то выполнение алгоритма можно прекращать. Во-вторых, можно запоминать наименьшее значение индекса массива, для которого на текущем шаге выполнялись перестановки. Очевидно, что верхняя часть массива до элемента с этим индексом уже отсортирована, и на следующем шаге можно прекращать сравнения значений соседних элементов при достижении такого значения индекса. В-третьих, метод пузырька работает неравноправно для "легких" и "тяжелых" значений. Легкое значение попадает на нужное место за один шаг, а тяжелое на каждом шаге опускается по направлению к нужному месту на одну позицию. На этих наблюдениях основан метод шейкерной сортировки (ShakerSort). При его применении на каждом следующем шаге меняется направление последовательного просмотра. В результате на одном шаге "всплывает" очередной наиболее легкий элемент, а на другом "тонет" очередной самый тяжелый. Пример шейкерной сортировки приведен ниже.

Пример шейкерной сортировки

Начальное состояние массива :

8 23 5 65 44 33 1 6

Шаг 1:

8 23 5 65 44 33 1 6

8 23 5 65 44 1 33 6

8 23 5 65 1 44 33 6

8 23 5 1 65 44 33 6

8 23 1 5 65 44 33 6

8 1 23 5 65 44 33 6

1 8 23 5 65 44 33 6

Шаг 2:

1 8 23 5 65 44 33 6

1 8 5 23 65 44 33 6

1 8 5 23 65 44 33 6

1 8 5 23 44 65 33 6

1 8 5 23 44 33 65 6

1 8 5 23 44 33 6 65

Шаг 3:

1 8 5 23 44 6 33 65

1 8 5 23 6 44 33 65

1 8 5 6 23 44 33 65

1 8 5 6 23 44 33 65

1 5 8 6 23 44 33 65

Шаг 4:

1 5 6 8 23 44 33 65

1 5 6 8 23 44 33 65

1 5 6 8 23 44 33 65

1 5 6 8 23 33 44 65

Шаг 5:

1 5 6 8 23 33 44 65

1 5 6 8 23 33 44 65

1 5 6 8 23 33 44 65

Шейкерная сортировка позволяет сократить число сравнений (по оценке Кнута средним числом сравнений является (n2 - n(const + ln(n))), хотя порядком оценки по-прежнему остается n2. Число же пересылок, вообще говоря, не меняется.

Шейкерную сортировку рекомендуется использовать в тех случаях, когда известно, что массив "почти упорядочен".

^ Сортировка выбором

При сортировке массива a[1], a[2], ..., a[n] методом простого выбора среди всех элементов находится элемент с наименьшим значением a[i], a[1] и a[i] обмениваются значениями. Затем этот процесс повторяется для получаемых подмассивов a[2], a[3], ..., a[n], ... a[j], a[j+1], ..., a[n] до тех пор, пока мы не дойдем до подмассива a[n], содержащего к этому моменту наибольшее значение. Работа алгоритма иллюстрируется следующим примером.

Пример сортировки простым выбором

Начальное состояние массива :

8 23 5 65 44 33 1 6

Шаг 1:

1 23 5 65 44 33 8 6

Шаг 2:

1 5 23 65 44 33 8 6

Шаг 3:

1 5 6 65 44 33 8 23

Шаг 4:

1 5 6 8 44 33 65 23

Шаг 5:

1 5 6 8 33 44 65 23

Шаг 6:

1 5 6 8 23 44 65 33

Шаг 7:

1 5 6 8 23 33 65 44

Шаг 8:

1 5 6 8 23 33 44 65

Для метода сортировки простым выбором требуемое число сравнений – n(n-1)/2. Порядок требуемого числа пересылок (включая те, которые требуются для выбора минимального элемента) в худшем случае составляет n2. Однако порядок среднего числа пересылок есть nln(n), что в ряде случаев делает этот метод предпочтительным.

^ Сортировка разделением (Quicksort)

Метод сортировки разделением был предложен Чарльзом Хоаром (он любит называть себя Тони) в 1962 г. Этот метод является развитием метода простого обмена и настолько эффективен, что его стали называть "методом быстрой сортировки – Quicksort". Основная идея алгоритма состоит в том, что случайным образом выбирается некоторый элемент массива x, после чего массив просматривается слева, пока не встретится элемент a[i] такой, что a[i] > x, а затем массив просматривается справа, пока не встретится элемент a[j] такой, что a[j] < x. Эти два элемента меняются местами, и процесс просмотра, сравнения и обмена продолжается, пока мы не дойдем до элемента x. В результате массив оказывается разбитым на две части – левую, в которой значения ключей будут меньше x, и правую со значениями ключей, большими x. Далее процесс рекурсивно продолжается для левой и правой частей массива до тех пор, пока каждая часть не будет содержать в точности один элемент. Понятно, что, как обычно, рекурсию можно заменить итерациями, если запоминать соответствующие индексы массива. Проследим этот процесс на примере нашего стандартного массива.

Пример быстрой сортировки

Начальное состояние массива :

8 23 5 65 |44| 33 1 6

Шаг 1 (в качестве x выбирается a[5]):

|--------|

8 23 5 6 44 33 1 65

8 23 5 6 1 33 44 65

Шаг 2 (в подмассиве a[1], a[5] в качестве x выбирается a[3]):

8 23 |5| 6 1 33 44 65

|--------|

1 23 5 6 8 33 44 65

|--|

1 5 23 6 8 33 44 65

Шаг 3 (в подмассиве a[3], a[5] в качестве x выбирается a[4]) :

1 5 23 |6| 8 33 44 65

|----|

1 5 8 6 23 33 44 65

Шаг 4 (в подмассиве a[3], a[4] выбирается a[4]):

1 5 8 |6| 23 33 44 65

|--|

1 5 6 8 23 33 44 65

Алгоритм недаром называется быстрой сортировкой, поскольку для него оценкой числа сравнений и обменов является nlog(n). На самом деле в большинстве утилит, выполняющих сортировку массивов, используется именно этот алгоритм.

^ Сортировка с помощью дерева (Heapsort)

Начнем с простого метода сортировки с помощью дерева, при использовании которого явно строится двоичное дерево сравнения ключей. Построение дерева начинается с листьев, которые содержат все элементы массива. Из каждой соседней пары выбирается наименьший элемент, и эти элементы образуют следующий (ближе к корню уровень дерева). Из каждой соседней пары выбирается наименьший элемент и т.д., пока не будет построен корень, содержащий наименьший элемент массива. Двоичное дерево сравнения для массива, используемого в наших примерах, показано на рис. 3.1. Итак, имеем наименьшее значение элементов массива. Для того чтобы получить следующий элемент, спустимся от корня по пути, ведущему к листу с наименьшим значением. В этой листовой вершине проставляется фиктивный ключ с "бесконечно большим" значением, а во все промежуточные узлы, занимавшиеся наименьшим элементом, заносится наименьшее значение из узлов – непосредственных потомков (рис. 3.2). Процесс продолжается до тех пор, пока все узлы дерева не будут заполнены фиктивными ключами (рис. 3.3 - 3.8).

На каждом из n шагов, требуемых для сортировки массива, нужно log(n) (двоичный) сравнений. Следовательно, всего потребуется nlog(n) сравнений, но для представления дерева понадобится 2n - 1 дополнительных единиц памяти.

Имеется более совершенный алгоритм, который принято называть пирамидальной сортировкой (Heapsort). Его идея состоит в том, что вместо полного дерева сравнения исходный массив a[1], a[2], ..., a[n] преобразуется в пирамиду, обладающую тем свойством, что для каждого a[i] выполняются условия a[i] <= a[2i] и a[i] <= a[2i+1]. Затем пирамида используется для сортировки.

Наиболее наглядно метод построения пирамиды выглядит при древовидном представлении массива, показанном на рис. 3.9. Массив представляется в виде двоичного дерева, корень которого соответствует элементу массива a[1]. На втором ярусе находятся элементы a[2] и a[3]. На третьем – a[4], a[5], a[6], a[7] и т.д.



Рис.3.1. Двоичное дерево сравнения для массива



Рис. 3.2. Второй шаг сортировки



Рис. 3.3. Третий шаг сортировки

Как видно, для массива с нечетным количеством элементов соответствующее дерево будет сбалансированным, а для массива с четным количеством элементов n элемент a[n] будет единственным (самым левым) листом "почти" сбалансированного дерева. Очевидно, что при построении пирамиды нас будут интересовать элементы a[n/2], a[n/2-1], ..., a[1] для массивов с четным числом элементов и элементы a[(n-1)/2], a[(n-1)/2-1], ..., a[1] для массивов с нечетным числом элементов (поскольку только для таких элементов существенны ограничения пирамиды).



Рис. 3.4. Четвертый шаг сортировки



Рис. 3.5. Пятый шаг сортировки



Рис. 3.6. Шестой шаг сортировки



Рис. 3.7. Седьмой шаг сортировки



Рис. 3.8. Восьмой шаг сортировки

Пусть i – наибольший индекс из числа индексов элементов, для которых существенны ограничения пирамиды. Тогда берется элемент a[i] в построенном дереве, и для него выполняется процедура просеивания, состоящая в том, что выбирается ветвь дерева, соответствующая min(a[2*i], a[2*i+1]), и значение a[i] меняется местами со значением соответствующего элемента. Если этот элемент не является листом дерева, для него выполняется аналогичная процедура и т.д. Такие действия выполняются последовательно для a[i], a[i-1], ..., a[1]. Легко видеть, что в результате мы получим древовидное представление пирамиды для исходного массива (последовательность шагов для используемого в наших примерах массива показана на рис. 3.10-3.13).



Рис. 3.9. Первый шаг сортировки



Рис. 3.10. Второй шаг сортировки



Рис. 3.11. Третий шаг сортировки



Рис. 3.12. Четвёртый шаг сортировки



Рис. 3.13. Пятый шаг сортировки

В 1964 г. Флойд предложил метод построения пирамиды без явного построения дерева (хотя метод основан на тех же идеях). Построение пирамиды методом Флойда для нашего стандартного массива показано ниже.

Пример построения пирамиды

Начальное состояние массива :

8 23 5 |65| 44 33 1 6

Шаг 1:

8 23 |5| 6 44 33 1 65

Шаг 2:

8 |23| 1 6 44 33 5 65

Шаг 3:

|8| 6 1 23 44 33 5 65

Шаг 4:

1 6 8 23 44 33 5 65

1 6 5 23 44 33 8 65

Ниже показано, как производится сортировка с использованием построенной пирамиды. Суть алгоритма заключается в следующем. Пусть i – наибольший индекс массива, для которого существенны условия пирамиды. Тогда начиная с a[1] до a[i] выполняются следующие действия. На каждом шаге выбирается последний элемент пирамиды (в нашем случае первым будет выбран элемент a[8]). Его значение меняется со значением a[1], после чего для a[1] выполняется просеивание. При этом на каждом шаге число элементов в пирамиде уменьшается на 1 (после первого шага в качестве элементов пирамиды рассматриваются a[1], a[2], ..., a[n-1]; после второго – a[1], a[2], ..., a[n-2] и т.д., пока в пирамиде не останется один элемент). Легко видеть, что в результате мы получим массив, упорядоченный по убыванию. Можно модифицировать метод построения пирамиды и сортировки, чтобы получить упорядочение по возрастанию, если изменить условие пирамиды на a[i] >= a[2i] и a[1] >= a[2i+1] для всех осмысленных значений индекса i.

Сортировка с помощью пирамиды

Исходная пирамида:

1 6 5 23 44 33 8 65

Шаг 1:

65 6 5 23 44 33 8 1

5 6 65 23 44 33 8 1

5 6 8 23 44 33 65 1

Шаг 2:

65 6 8 23 44 33 5 1

6 65 8 23 44 33 5 1

6 23 8 65 44 33 5 1

Шаг 3:

33 23 8 65 44 6 5 1

8 23 33 65 44 6 5 1


Шаг 4:

44 23 33 65 8 6 5 1

23 44 33 65 8 6 5 1

Шаг 5:

65 44 33 23 8 6 5 1

33 44 65 23 8 6 5 1

Шаг 6:

65 44 33 23 8 6 5 1

44 65 33 23 8 6 5 1

Шаг 7:

65 44 33 23 8 6 5 1

Процедура сортировки с использованием пирамиды требует выполнения порядка nxlog n шагов (логарифм – двоичный) в худшем случае, что делает ее особо привлекательной для сортировки больших массивов.

^ Сортировка со слиянием

Сортировки со слиянием, как правило, применяются в тех случаях, когда требуется отсортировать последовательный файл, не помещающийся целиком в основной памяти. Методам внешней сортировки посвящается следующая часть книги, в которой основное внимание будет уделяться методам минимизации числа обменов с внешней памятью. Однако существуют и эффективные методы внутренней сортировки, основанные на разбиениях и слияниях.

Один из популярных алгоритмов внутренней сортировки со слияниями основан на следующих идеях (для простоты будем считать, что число элементов в массиве, как и в нашем примере, является степенью числа 2). Сначала поясним, что такое слияние. Пусть имеются два отсортированных в порядке возрастания массива p[1], p[2], ..., p[n] и q[1], q[2], ..., q[n] и имеется пустой массив r[1], r[2], ..., r[2n], который мы хотим заполнить значениями массивов p и q в порядке возрастания. Для слияния выполняются следующие действия: сравниваются p[1] и q[1], и меньшее из значений записывается в r[1]. Предположим, что это значение p[1]. Тогда p[2] сравнивается с q[1] и меньшее из значений заносится в r[2]. Предположим, что это значение q[1]. Тогда на следующем шаге сравниваются значения p[2] и q[2] и т.д., пока мы не достигнем границ одного из массивов. Тогда остаток другого массива просто дописывается в "хвост" массива r.

Для сортировки со слиянием массива a[1], a[2], ..., a[n] заводится парный массив b[1], b[2], ..., b[n]. На первом шаге производится слияние a[1] и a[n] с размещением результата в b[1], b[2], слияние a[2] и a[n-1] с размещением результата в b[3], b[4], ..., слияние a[n/2] и a[n/2+1] с помещением результата в b[n-1], b[n]. На втором шаге производится слияние пар b[1], b[2] и b[n-1], b[n] с помещением результата в a[1], a[2], a[3], a[4], слияние пар b[3], b[4] и b[n-3], b[n-2] с помещением результата в a[5], a[6], a[7], a[8], ..., слияние пар b[n/2-1], b[n/2] и b[n/2+1], b[n/2+2] с помещением результата в a[n-3], a[n-2], a[n-1], a[n]. И так далее. На последнем шаге, например (в зависимости от значения n), производится слияние последовательностей элементов массива длиной n/2 a[1], a[2], ..., a[n/2] и a[n/2+1], a[n/2+2], ..., a[n] с помещением результата в b[1], b[2], ..., b[n].

Пример слияния двух массивов показан на рис. 3.14.



Рис. 3.14. Слияние двух массивов

Для случая массива, используемого в наших примерах, последовательность шагов показана ниже.

Пример сортировки со слиянием

Начальное состояние массива:

8 23 5 65 44 33 1 6

Шаг 1:

6 8 1 23 5 33 44 65

Шаг 2:

6 8 44 65 1 5 23 33

Шаг 3:

1 5 6 8 23 33 44 65

При применении сортировки со слиянием число сравнений ключей и число пересылок оценивается как nlog(n). Но следует учитывать, что для выполнения алгоритма для сортировки массива размера n требуется 2n элементов памяти.

^ Сравнение методов внутренней сортировки

Для рассмотренных в начале этой части простых методов сортировки существуют точные формулы, вычисление которых дает минимальное, максимальное и среднее число сравнений ключей (C) и пересылок элементов массива (M).

Для оценок сложности усовершенствованных методов сортировки точных формул нет. Известно лишь, что для сортировки методом Шелла порядок C и M есть 1,2n, а для методов Quicksort, Heapsort и сортировки со слиянием –nlog(n). Однако результаты экспериментов показывают, что Quicksort показывает результаты в 2-3 раза лучшие, чем Heapsort. Видимо, по этой причине именно Quicksort обычно используется в стандартных утилитах сортировки (в частности, в утилите sort, поставляемой с операционной системой UNIX).

^ Методы внешней сортировки.

Принято называть "внешней" сортировку последовательных файлов, располагающихся во внешней памяти и слишком больших, чтобы можно было целиком переместить их в основную память и применить один из рассмотренных в предыдущем разделе методов внутренней сортировки. Наиболее часто внешняя сортировка применяется в системах управления базами данных при выполнении запросов, и от эффективности применяемых методов существенно зависит производительность СУБД.

Следует пояснить, почему речь идет именно о последовательных файлах, т.е. о файлах, которые можно читать запись за записью в последовательном режиме, а писать можно только после последней записи. Методы внешней сортировки появились, когда наиболее распространенными устройствами внешней памяти были магнитные ленты. Для лент чисто последовательный доступ был абсолютно естественным. Когда произошел переход к запоминающим устройствам с магнитными дисками, обеспечивающими "прямой" доступ к любому блоку информации, казалось, что чисто последовательные файлы потеряли свою актуальность. Однако это ощущение было ошибочным.

Все дело в том, что практически все используемые в настоящее время дисковые устройства снабжены подвижными магнитными головками. При выполнении обмена с дисковым накопителем выполняется подвод головок к нужному цилиндру, выбор нужной головки (дорожки), прокрутка дискового пакета до начала требуемого блока и, наконец, чтение или запись блока. Среди всех этих действий самое большое время занимает подвод головок. Именно это время определяет общее время выполнения операции. Единственным доступным приемом оптимизации доступа к магнитным дискам является как можно более "близкое" расположение на накопителе последовательно адресуемых блоков файла. Но и в этом случае движение головок будет минимизировано только в том случае, когда файл читается или пишется в чисто последовательном режиме. Именно с такими файлами при потребности сортировки работают современные СУБД.

Следует также заметить, что на самом деле скорость выполнения внешней сортировки зависит от размера буфера (или буферов) основной памяти, которая может быть использована для этих целей. Рассмотрим основные методы внешней сортировки, работающие при минимальных расходах основной памяти.

^ Прямое слияние

Начнем с того, как можно использовать в качестве метода внешней сортировки алгоритм простого слияния, обсуждавшийся в конце предыдущей части. Предположим, что имеется последовательный файл A, состоящий из записей a1, a2, ..., an (снова для простоты предположим, что n представляет собой степень числа 2). Будем считать, что каждая запись состоит ровно из одного элемента, представляющего собой ключ сортировки. Для сортировки используются два вспомогательных файла B и C (размер каждого из них будет n/2).

Сортировка состоит из последовательности шагов, в каждом из которых выполняется распределение состояния файла A в файлы B и C, а затем слияние файлов B и C в файл A. (Заметим, что процедура слияния для файлов полностью иллюстрируется рис. 3.14.) На первом шаге для распределения последовательно читается файл A, записи a1, a3, ..., a(n-1) пишутся в файл B, а записи a2, a4, ..., an – в файл C (начальное распределение). Начальное слияние производится над парами (a1, a2), (a3, a4), ..., (a(n-1), an), и результат записывается в файл A. На втором шаге снова последовательно читается файл A, в файл B записываются последовательные пары с нечетными номерами, а в файл C – с четными. При слиянии образуются и пишутся в файл A упорядоченные четверки записей. И так далее. Перед выполнением последнего шага файл A будет содержать две упорядоченные подпоследовательности размером n/2 каждая. При распределении первая из них попадет в файл B, а вторая – в файл C. После слияния файл A будет содержать полностью упорядоченную последовательность записей. Ниже показан пример внешней сортировки простым слиянием.

Пример внешней сортировки прямым слиянием

Начальное состояние файла A:

8 23 5 65 44 33 1 6

Первый шаг:

Распределение

Файл B 8 5 44 1

Файл C 23 65 33 6

Слияние: файл A

8 23 5 65 33 44 1 6

Второй шаг:

Распределение

Файл B 8 23 33 44

Файл C 5 65 1 6

Слияние: файл A

5 8 23 65 1 6 33 44

Третий шаг:

Распределение

Файл B 5 8 23 65

Файл C 1 6 33 44

Слияние: файл A

1 5 6 8 23 33 44 65

Заметим, что для выполнения внешней сортировки методом прямого слияния в основной памяти требуется расположить всего лишь две переменные – для размещения очередных записей из файлов B и C. Файлы A, B и C будут log(n) раз прочитаны и столько же раз записаны.

^ Естественное слияние

При использовании метода прямого слияния не принимается во внимание то, что исходный файл может быть частично отсортированным, т.е. содержать упорядоченные подпоследовательности записей. Серией называется подпоследовательность записей ai, a(i+1), ..., aj такая, что ak <= a(k+1) для всех i <= k < j, ai < a(i-1) и aj > a(j+1). Метод естественного слияния основывается на распознавании серий при распределении и их использовании при последующем слиянии.

Как и в случае прямого слияния, сортировка выполняется за несколько шагов, на каждом из которых сначала выполняется распределение файла A по файлам B и C, а потом слияние B и C в файл A. При распределении распознается первая серия записей и переписывается в файл B, вторая – в файл C и т.д. При слиянии первая серия записей файла B сливается с первой серией файла C, вторая серия B – со второй серией C и т.д. Если просмотр одного файла заканчивается раньше, чем просмотр другого (по причине разного числа серий), то остаток недопросмотренного файла целиком копируется в конец файла A. Процесс завершается, когда в файле A остается только одна серия. Пример сортировки файла показан на рис. 3.15 и 3.16.



Рис. 3.15. Первый шаг



Рис. 3.16. Второй шаг

Очевидно, что число чтений/перезаписей файлов при использовании этого метода будет не хуже, чем при применении метода прямого слияния, а в среднем – лучше. С другой стороны, увеличивается число сравнений за счет тех, которые требуются для распознавания концов серий. Кроме того, поскольку длина серий может быть произвольной, то максимальный размер файлов B и C может быть близок к размеру файла A.

^ Сбалансированное многопутевое слияние

В основе метода внешней сортировки сбалансированным многопутевым слиянием – распределение серий исходного файла по m вспомогательным файлам B1, B2, ..., Bm и их слияние в m вспомогательных файлах C1, C2, ..., Cm. На следующем шаге производится слияние файлов C1, C2, ..., Cm в файлы B1, B2, ..., Bm и т.д., пока в B1 или C1 не образуется одна серия.

Многопутевое слияние является естественным развитием идеи обычного (двухпутевого) слияния (рис. 3.17).



Рис. 3.17. Многопутевое слияние

На рис. 3.17 показан простой пример применения сортировки многопутевым слиянием. Он, конечно, слишком тривиален, чтобы продемонстрировать несколько шагов выполнения алгоритма, однако достаточен в качестве иллюстрации общей идеи метода. Заметим, что, как показывает этот пример, по мере увеличения длины серий вспомогательные файлы с большими номерами (начиная с номера n) перестают использоваться, поскольку им "не достается" ни одной серии. Преимуществом сортировки сбалансированным многопутевым слиянием является то, что число проходов алгоритма оценивается как log(n) (n – число записей в исходном файле), где логарифм берется по основанию n. Порядок числа копирования записей – log(n). Конечно, число сравнений не будет меньше, чем при применении метода простого слияния.

^ Многофазная сортировка

При использовании рассмотренного выше метода сбалансированной многопутевой внешней сортировки на каждом шаге примерно половина вспомогательных файлов используется для ввода данных и примерно столько же для вывода сливаемых серий. Идея многофазной сортировки состоит в том, что из имеющихся m вспомогательных файлов (m-1) файл служит для ввода сливаемых последовательностей, а один для вывода образуемых серий. Как только один из файлов ввода становится пустым, его начинают использовать для вывода серий, получаемых при слиянии серий нового набора (m-1) файлов. Таким образом, имеется первый шаг, при котором серии исходного файла распределяются по m-1 вспомогательному файлу, а затем выполняется многопутевое слияние серий из (m-1) файла, пока в одном из них не образуется одна серия.

Очевидно, что при произвольном начальном распределении серий по вспомогательным файлам алгоритм может не сойтись, поскольку в единственном непустом файле будет существовать более, чем одна серия. Предположим, например, что используется три файла B1, B2 и B3, и при начальном распределении в файл B1 помещено десять серий, а в файл B2 – шесть. При слиянии B1 и B2 к моменту, когда мы дойдем до конца B2, в B1 останется четыре серии, а в B3 попадёт шесть серий. Продолжится слияние B1 и B3, и при завершении просмотра B1 в B2 будет содержаться четыре серии, а в B3 останется две серии. После слияния B2 и B3 в каждом из файлов B1 и B2 будет содержаться по две серии, которые будут слиты и образуют две серии в B3 при том, что B1 и B2 пусты.

Попробуем понять, каким должно быть начальное распределение серий, чтобы алгоритм трехфазной сортировки благополучно завершал работу и выполнялся максимально эффективно. Для этого рассмотрим работу алгоритма в обратном порядке, начиная от желательного конечного состояния вспомогательных файлов. Нас устраивает любая комбинация конечного числа серий в файлах B1, B2 и B3 из (1,0,0), (0,1,0) и (0,0,1). Для определенности выберем первую комбинацию. Для того чтобы она сложилась, необходимо, чтобы на непосредственно предыдущем этапе слияний существовало распределение серий (0,1,1). Чтобы получить такое распределение, необходимо, чтобы на непосредственно предыдущем этапе слияний распределение выглядело как (1,2,0) или (1,0,2). Для определенности остановимся на первом варианте. Чтобы его получить, на предыдущем этапе годились бы следующие распределения: (3,0,2) и (0,3,1). Но второй вариант хуже, поскольку он приводится к слиянию только одной серии из файлов B2 и B3, в то время как при наличии первого варианта распределения будут слиты две серии из файлов B1 и B3. Пожеланием к предыдущему этапу было бы наличие распределения (0,3,5), еще раньше – (5,0,8), еще раньше – (13,8,0) и т.д.

Это рассмотрение показывает, что метод трехфазной внешней сортировки дает желаемый результат и работает максимально эффективно (на каждом этапе сливается максимальное число серий), если начальное распределение серий между вспомогательными файлами описывается соседними числами Фибоначчи. Напомним, что последовательность чисел Фибоначчи начинается с 0, 1, а каждое следующее число образуется как сумма двух предыдущих:

(0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, ....).

Аналогичные (хотя и более громоздкие) рассуждения показывают, что в общем виде при использовании m вспомогательных файлов условием успешного завершения и эффективной работы метода многофазной внешней сортировки является то, что начальное распределение серий между m-1 файлами описывается суммами соседних (m-1), (m-2), ..., 1 чисел Фибоначчи порядка m-2. Последовательность чисел Фибоначчи порядка p начинается с p нулей, (p+1)-й элемент равен 1, а каждый следующий равняется сумме предыдущих p+1 элементов. Ниже показано начало последовательности чисел Фибоначчи порядка 4:

(0, 0, 0, 0, 1, 1, 2, 4, 8, 16, 31, 61, ...).

При использовании шести вспомогательных файлов идеальными распределениями серий являются следующие:

1 0 0 0 0

1 1 1 1 1

2 2 2 2 1

4 4 4 3 2

8 8 7 6 4

16 15 14 12 8

........

Понятно, что если распределение основано на числе Фибоначчи fi, то минимальное число серий во вспомогательных файлах будет равно fi, а максимальное – f(i+1). Поэтому после выполнения слияния мы получим максимальное число серий – fi, а минимальное – f(i-1). На каждом этапе будет выполняться максимально возможное число слияний, и процесс сойдет всего до одной серии.

Поскольку число серий в исходном файле может не обеспечивать возможность такого распределения серий, применяется метод добавления пустых серий, которые в дальнейшем как можно более равномерного распределяются между промежуточными файлами и опознаются при последующих слияниях. Понятно, что чем меньше таких пустых серий, т.е. чем ближе число начальных серий к требованиям Фибоначчи, тем более эффективно работает алгоритм.

^ Улучшение эффективности внешней сортировки за счет использования основной памяти

Понятно, что чем более длинные серии содержит файл перед началом применения внешней сортировки, тем меньше потребуется слияний и тем быстрее закончится сортировка. Поэтому до начала применения любого из методов внешней сортировки, основанных на применении серий, первый файл частями считывается в основную память, к каждой части применяется один из наиболее эффективных алгоритмов внутренней сортировки (обычно Quicksort или Heapsort) и отсортированные части (образующие серии) записываются в новый файл (в старый нельзя, потому что он последовательный). При выполнении распределений и слияний используется буферизация блоков файла(ов) в основной памяти. Возможный выигрыш в производительности зависит от наличия достаточного числа буферов достаточного размера.





страница2/7
Дата конвертации17.11.2013
Размер1,66 Mb.
ТипУчебное пособие
1   2   3   4   5   6   7
Разместите кнопку на своём сайте или блоге:
rud.exdat.com


База данных защищена авторским правом ©exdat 2000-2012
При копировании материала укажите ссылку
обратиться к администрации
Документы