Время работы в худшем случае и в среднем
Итак, мы видим, что время работы в худшем случае и в лучшем случае могут сильно различаться. Большей частью нас будет интересовать время работы в худшем случае (worst-case running time), которое определяется как максимальное время работы для входов данного размера. Почему? Вот несколько причин.
Зная время работы в худшем случае, мы можем гарантировать, что выполнение алгоритма закончится за некоторое время, даже не зная, какой именно вход (данного размера) попадётся.
На практике «плохие» входы (для которых время работы близко к максимуму) могут часто попадаться. Например, для базы данных плохим запросом может быть поиск отсутствующего элемента (довольно частая ситуация).
Время работы в среднем (о котором мы говорим дальше) может быть довольно близко к времени работы в худшем случае. Пусть, например, мы сортируем случайно расположенные п чисел с помощью процедуры INSERTION-SoRT. Сколько раз придётся выполнить цикл в строках 4-8? В среднем около половины элементов массива A[1..j-1] больше A[j], так что tj в среднем можно считать равным j/2, и время Т(п) квадратично зависит от п.
В некоторых случаях нас будет интересовать также среднее время работы (average-case running time, expexted running time) алгоритма на входах данной длины. Конечно, эта величина зависит от выбранного распределения вероятностей, и на практике реальное распределение входов может отличаться от предполагаемого, которое обычно считают равномерным. Иногда можно добиться равномерности распределения, используя датчик случайных чисел.
Порядок роста
Наш анализ времени работы процедуры Insertion-Sort был основан на нескольких упрощающих предположениях. Сначала мы предположили, что время выполнения n-й строки постоянно и равно с,. Затем мы огрубили оценку до an2+bn+c. Сейчас мы пойдём ещё дальше и скажем, что время работы в худшем случае имеет порядок роста (rate of growth, order of growth) n2, отбрасывая члены меньших порядков (линейные) и не интересуясь коэффициентом при n2. Это записывают так: Т(n) = 0(n2) (подробное объяснение обозначений мы отложим до следующей главы).
Алгоритм с меньшим порядком роста времени работы обычно предпочтительнее: если, скажем, один алгоритм имеет время работы 0(n2), а другой — 0(n3), то первый более эффективен (по крайней мере для достаточно длинных входов; будут ли реальные входы таковыми — другой вопрос).
Построение алгоритмов
Есть много стандартных приёмов, используемых при построении алгоритмов. Сортировка вставками является примером алгоритма, действующего по шагам (incremental approach): мы добавляем элементы один за другим к отсортированной части массива.
В этом разделе мы покажем в действии другой подход, который называют «разделяй и властвуй» (divide-and-conquer approach), и построим с его помощью значительно более быстрый алгоритм сортировки.
Принцип «разделяй и властвуй»
Многие алгоритмы по природе своей рекурсивны (recursive): решая некоторую задачу, они вызывают самих себя для решения её подзадач. Идея метода «разделяй и властвуй» состоит как раз в этом. Сначала задача разбивается на несколько подзадач меньшего размера. Затем эти задачи решаются (с помощью рекурсивного вызова — или непосредственно, если размер достаточно мал). Наконец, их решения комбинируются и получается решение исходной задачи.
Для задачи сортировки эти три этапа выглядят так. Сначала мы разбиваем массив на две половины меньшего размера. Затем мы сортируем каждую из половин отдельно. После этого нам остаётся соединить два упорядоченных массива половинного размера в один. Рекурсивное разбиение задачи на меньшие происходит до тех пор, пока размер массива не дойдёт до единицы (любой массив длины 1 можно считать упорядоченным).
Нетривиальным этапом является соединение двух упорядоченных массивов в один. Оно выполняется с помощью вспомогательной процедуры Merge(A,p,q,r). Параметрами этой процедуры являются массив А и числа p,q,r, указывающие границы сливаемых участков. Процедура предполагает, что p≤q<r и что участки A[p..q] и A[q+1..r] уже отсортированы, и сливает (merges) их в один участок A[p..r].
Мы оставляем подробную разработку этой процедуры читателю (упр. 1.3-2), но довольно ясно, что время работы процедуры merge есть О(n), где n — общая длина сливаемых участков (n=r-p+1). Это легко объяснить на картах. Пусть мы имеем две стопки карт (рубашкой вниз), и в каждой карты идут сверху вниз в возрастающем порядке. Как сделать из них одну? На каждом шаге мы берём меньшую из двух верхних карт и кладём ее (рубашкой вверх) в результирующую стопку. Когда одна из исходных стопок становится пустой, мы добавляем все оставшиеся карты второй стопки к результирующей стопке. Ясно, что каждый шаг требует ограниченного числа действий, и общее число действий есть Θ(n).
5 |
|
2 |
|
4 |
|
6 |
|
1 |
|
3 |
|
2 |
|
6 |
|
2 5 |
|
|
|
4 6 |
|
|
|
1 3 |
|
|
|
2 6 |
|
|
|
|
2 4 5 6 |
|
|
|
|
|
|
|
1 2 3 6 |
|
|
|
|
|
|
|
|
|
|
1 2 2 3 4 5 6 6 |
|
|
|
|
|
|
|
Рис. 2. Сортировка слиянием для массива А = (5,2,4,6,1, 3,2,6).
Теперь напишем процедуру сортировки слиянием Merge-Sort(A,p,r), которая сортирует участок А[р..r] массива А, не меняя остальную часть массива. При р≥r участок содержит максимум один элемент, и тем самым уже отсортирован. В противном случае мы отыскиваем число q, которое делит участок на две примерно равные части A[р..q] (содержит [n/2˥ элементов) и A[q+1..r] (содержит [п/2˩ элементов). Здесь через [х˥ мы обозначаем целую часть х (наибольшее целое число, меньшее или равное х), а через [х˩—наименьшее целое число, большее или равное х.
void Merge_Sort(Vector A, int p, int r)
{ int q;
if (p<r) {
q=(p+r)/2;
Merge_Sort(A,p,q);
Merge_Sort(A,q+1,r);
Merge(A,p,q,r); // Слияние сортированных подмассивов
} // A[p..q] и A[q+1,r]
}
Весь массив теперь можно отсортировать с помощью вызова Merge-Sort(A,1, length[A]). Если длина массива п=length[A] есть степень двойки, то в процессе сортировки произойдёт слияние пар элементов в отсортированные участки длины 2, затем слияние пар таких участков в отсортированные участки длины 4 и так далее до п (на последнем шаге соединяются два отсортированных участка длины п/2). Этот процесс показан на рис. 2.
В рекурсивном методе использованы:
const int maxn=100001;
typedef int Vector[maxn];
Vector A, b;
int Merge(Vector A, int p, int q, int r)
{ int i1, i2, i;
i1=p; i2=q+1; i=p;
while (i1<=q && i2<=r) {
if (A[i1]<=A[i2]) b[i++]=A[i1++];
else b[i++]=A[i2++];
}
while (i1<=q) b[i++]=A[i1++];
while (i2<=r) b[i++]=A[i2++];
for (i=p;i<=r;i++) A[i]=b[i];
return 0;
}