Глава E. Массивы Урок E6. Сортировки обменом «на расстоянии» Наконец, Алиса решилась: обхватила гриб руками и отломила с каждой стороны по кусочку. – Интересно, какой из них какой? – подумала она и откусила немножко от того, который держала в правой руке. Льюис Кэрролл До сих пор, упорядочивая линейный массив, мы устраняли беспорядки по одному, перебирая только пары соседей. Так, в алгоритмах E5-1, E5-2 и их модификациях каждый элемент, прежде чем попасть на уготованное ему место, протаскивался через все промежуточные позиции на пути к нему. Нечто подобное нам уже встречалось, когда мы рассматривали циклические перестановки вектора и алгоритм E4-1. Тогда идея сэкономить усилия и ставить очередной элемент сразу на окончательное место привела к заметному уменьшению трудоемкости. Нельзя ли воспользоваться знакомым механизмом? Все, что нужно для этого – знать, куда должен попасть очередной элемент. Предположим, в отношении некоторого элемента мы выяснили, куда он держит путь. Остается произвести обмен между «временно прописанным» и законным хозяином ячейки. Таким образом, мы отказываемся от прежнего ограничения, и допускаем обмены между удаленными друг от друга компонентами. В отличие от простых обменов, не всякий обмен «на расстоянии» обеспечивает уменьшение количества беспорядков. Но это не важно, поскольку – и не увеличивает, а за N таких обменов все элементы гарантированно займут свои места. Итак, теперь нам нужен механизм перебора не пар, а элементов, которые мы собираемся ставить на место. Как их отбирать? Желательно, поставив элемент на место, больше его не двигать, временно «забыть о нем». Но и такой механизм нам тоже уже встречался – это алгоритм E3-3, который переносит элемент в хвост, а затем фиктивно удаляет его, «укорачивая» вектор. В нашем случае единственным кандидатом на перенос в хвост является наибольший элемент подмассива (естественно, можно расставлять и наименьшие элементы, – слева). И еще один алгоритм нам пригодился – E2-1(2). Построенную разновидность обменной сортировки обычно называют сортировкой выбором. Алгоритм E6-1 type index1 = 0..N-1; {N > 1} massive1 = array [index1] of item; procedure SelectionSort (var Mas: massive1); var i: index1; begin for i := (N-1) downto 1 do Swap(Mas[i], Mas[IndexMax(Mas,0,i)]); end; Трудоемкость этого алгоритма O(N2), поскольку фактически используется двойной цикл: внешний цикл с параметром и внутренний, в теле IndexMax,– той же разновидности. Так как оба цикла отрабатывают до конца, то количество шагов алгоритма никак не зависит от контекста и определено только длиной вектора. Упражнения E6-1 a) Каково точное число алгоритмических шагов процедуры SelectionSort при обработке вектора длины N? b) Как мы знаем, N различных значений можно разместить в векторе N! способами, иначе говоря, можно построить N! векторов. Применение к любому из них сортировки выбором потребует одного и того же числа «укрупненных» шагов, составляющих содержание процедуры SelectionSort. Однако, рассматривая более «мелкие» операции, мы обнаружим, что их количество уже зависит от контекста, и реальное время выполнения сортировок векторов окажется неодинаковым. Какой из векторов будет обработан быстрее всех, а какой – медленнее остальных? c) Эта задача предлагалась школьникам на городской олимпиаде по информатике в Санкт-Петербурге, точнее, еще в Ленинграде. Задан массив Mas[1..N], состоящий из 0, 1 и 2. Требуется перестроить его таким образом, чтобы все 0 оказались в начале массива, далее – все 1 и в конце – все 2. Можно ли обойтись одним просмотром массива? Как мы до сих пор ни старались, асимптотическая трудоемкость всех рассмотренных выше модификаций обменной сортировки оставалась одной и той же – квадратичной. Однако возможности улучшить эту оценку есть. Вспомним один из общих алгоритмических подходов к задаче, обсуждавшийся нами в Уроке A3, а именно, метод частных целей. Сейчас, как мы увидим, подходящей случаю окажется его разновидность, также упомянутая ранее – в Уроке A4. Его формула – «разделяй и властвуй» – приписывается некоторыми источниками македонскому царю Филиппу, жившему еще в IV веке до н.э., отцу легендарного завоевателя Александра. Изначальный смысл состоит в том, чтобы облегчить управление государством, искусно обостряя противоречия между населяющими его сообществами. Алгоритмическое содержание, к счастью, гораздо проще: речь идет о выделении подзадач, которые можно решать независимо друг от друга. В приложении к сортировке речь идет о разбиении вектора на такие подмассивы, что дальнейший обмен элементами между ними уже не требуется. Вот примеры такого разделения. Пример E6-1 В школу поступило N заявлений о приеме учеников. В качестве предварительной обработки удобно применить процедуру распределения заявлений на 11 групп, по числу классных параллелей. На это потребуется только один просмотр массива, то есть трудоемкость разделения составит O(N). После чего лексикографическая сортировка по фамилиям учащихся пройдет внутри каждой из параллелей независимо от остальных. Пример E6-1 При игре в карты, получив после раздачи на руки N карт, игрок часто сортирует их по масти, и лишь затем – по старшинству, внутри каждого из четырех поднаборов независимо от остальных. В обоих примерах вместо алгоритмов с квадратичной трудоемкостью O(N2) сначала работает разделение на несколько подмассивов, а затем каждый из них упорядочивается отдельно. Если таких подмассивов k, то общая трудоемкость составит O(N) + ΣO(Ni2), где через Ni (i=1..k) обозначено количество элементов i-го подмассива. Заметим, что суммарная стоимость всех сортировок подмассивов уменьшается с ростом их числа k и, соответственно, уменьшением мощности каждого из них. Это обстоятельство свидетельствует о перспективности такого механизма. Таким образом, располагая некоторой процедурой разделения вектора на подмассивы и, затем, их независимого упорядочивания, мы могли бы снизить общую трудоемкость сортировки вектора как целого. Как построить такую процедуру разделения? Вспомним механизм, который применялся нами в Уроке E2. Тогда нам удалось переставить элементы 0-1 вектора так, что фактически он разделился на две части – слева стояли нулевые компоненты, а справа – единицы. Обработка потребовала необычной организации просмотра – с двух концов вектора навстречу – и проведения, при необходимости, «удаленных» обменов. Управление механизмом 0-1 разделения осуществлял контекст, в отличие от обычных обменных сортировок, в которых перебор партнеров регламентирован заголовками циклов. Теперь контекст, естественно, «разнообразнее», поскольку значения элементов лежат в некотором диапазоне item. Поэтому приходится осуществлять разделения поэтапно, стремясь на каждом проходе получить на месте текущего подмассива два новых. При этом все элементы «нового левого» подмассива должны быть не больше элементов «нового правого». Это позволит новые подмассивы обрабатывать далее порознь. Как с помощью имеющегося контекста управлять разделением? В Уроке E4, при обработке кольцевого буфера, мы прибегли к введению фиктивной компоненты, которую назвали барьером, показывая тем самым, что через него нельзя перейти. В механизме разделения подмассива нет нужды создавать фиктивную компоненту, напротив, удобно выбрать на роль барьера какой-нибудь из элементов, имеющихся в нем. Существует несколько способов выбрать такой элемент и организовать переключение между левым и правым индексами, участвующими в процедуре просмотра подмассива и организации обменов. Вот один из них: Алгоритм E6-2 type index1 = 0..N-1; {N > 1} massive1 = array [index1] of item; function Partition (var Mas: massive1; left, right: index1): index1; var i, j: index1; barrier: item; begin barrier := Mas[(left + right) div 2]; i := left; j := right; repeat while (Mas[i] < barrier) do i := i+1; while (Mas[j] > barrier) do j := j-1; if i ≤ j then begin Swap{Mas[i], Mas[j]}; i := i+1; j:= j-1 end; until i > j; Partition := j end; Как видим, здесь на роль барьера выбрано значение элемента, располагающегося на середине входного подмассива. После завершения процедуры левый подмассив базируется на диапазоне индексов left..j, а правый, соответственно, на диапазоне j+1..right. Например, применив этот механизм к вектору (a), показанному на рис. E6-1, получим два подмассива – (b). (a) 15 23 11 40 34 29 (b) 11 j 23 i 15 40 34 29 Рис. E6-1 Кажется, результат не слишком вдохновляющий: от вектора в новый подмассив «отщипнулся» только один элемент. Но такая ситуация встречается достаточно редко, и мы специально привели ее как иллюстрацию возможных «плохих» случаев, возникающих при работе процедуры разделения. Можно, разумеется, за счет выбора барьера обеспечить более симметричное разделение подмассива на два новых, но добиться этого удается только за счет «утяжеления» алгоритмического механизма. Впрочем, проиллюстрированная ситуация встречается нечасто, да и то – лишь на отдельных шагах разделения, а не на всех итерациях. Итак, после выполнения процедура возвращает границу, разделяющую два непересекающихся подмассива. К каждому из них можно вновь применить ту же процедуру и т.д. Ясно, что очередной вызов процедуры теряет смысл, как только длина подмассива оказывается единичной. В такое состояние должны прийти N подмассивов, что и завершит процесс. Алгоритм был опубликован в 1962 году Хоаром1 и, видимо, настолько понравился самому автору, что получил от него имя QuickSort. Впрочем, сортировка Хоара и впрямь оказалась самой быстрой из известных к тому времени. Алгоритм E6-3 procedure QuickSort (var Mas: massive1; left, right: index1); var ind : index1; begin ind := Partition (Mas, left, right); if ind > left then QuickSort (Mas, left, ind); if ind+1 < right then QuickSort (Mas, ind+1, right); end; {Partition} Трудоемкость быстрой сортировки оценивается как O(N*log2N), если предполагать, что всякое разделение будет приводить к появлению почти одинаковых по длине подмассивов. Обоснованность такого предположения можно доказать, и мы предлагаем читателю заглянуть в одну из упомянутых в этой главе монографий. К сожалению, приведенная оценка действует в среднем, но встречаются и исключения, вроде того, которое мы специально привели выше – на рис. E6-1. Есть и другие отрицательные моменты. В частности, если входной набор уже 1 Charles Hoare упорядочен, то быстрая сортировка, ничего не переставляя в нем, отработает до конца, то есть будет упорно дробить его вплоть до разделения на N подмассивов единичной длины. Напротив, пузырьковая сортировка с флажком – BubbleSort2 из упражнения в Уроке E5, – чья асимптотическая эффективность заметно хуже, в этом случае уже давно бы остановилась. Столь же неразумным выглядит применение QuickSort к массиву из одинаковых элементов. Насколько медленной может оказаться быстрая сортировка? Ответ прост: если на каждой итерации разделения будет отщипываться подмассив единичной длины. В этом случае трудоемкость составит O(N2). Упражнение E6-2 Смоделируйте пример исходного массива длины N=10, иллюстрирующий описанную ситуацию, когда на каждой итерации отделяется подмассив с единственной компонентой. Подобные «мелкие неприятности» вызвали появление ряда модификаций алгоритма быстрой сортировки, в основном связанных с тактикой выбора барьера на очередной итерации, а также с ограничением длины интервала снизу. Именно снизу, поскольку с уменьшением мощности подмассива начинают все в большей степени сказываться значения коэффициентов, «растворившихся» в асимптотических оценках трудоемкости. Иначе говоря, на «коротких» векторах реальное время работы квадратичных сортировок оказывается меньшим, чем при выполнении QuickSort. Какое число N скрывает качественная оценка «короткий», зависит и от деталей реализации алгоритма, и от аппаратных характеристик компьютера. Одно из популярных решений состоит в том, чтобы останавливать рекурсивные вызовы процедуры, как только длина подмассива уменьшается не до 1, а до некоторого натурального M>1. Эту идею выдвинул Седжвик2, предложив и дальнейшую стратегию устранения остающихся беспорядков, которые присутствуют в неупорядоченных M-подмассивах, возникших на месте исходного вектора. Собственно механизм заключается в том, что вектор, уже разделенный на такие подмассивы, подвергается заключительной обработке с помощью алгоритма сортировки простыми вставками. Эту разновидность обширного семейства сортировок мы обсудим ниже. Упражнения E6-3 a) Напишите процедуру QuickSort2, разделяющую входной вектор на подмассивы длиной не больше заданного значения M, которое также передается в качестве входного параметра. b) На входе программы числовой массив заданной длины N. Переставить его элементы таким образом, чтобы выходной массив имел вид, наименее пригодный для работы алгоритма E6-2, а именно, на каждой итерации разделения появлялся бы подмассив единичной длины. Если таких перестановок существует более одной, то достаточно вывести любую из них. 2 Robert Sedgewick, 1978