
- •Глава 1. Разработка эффективных алгоритмов
- •Value значение переменной X, для которого рассчитывается Pn(X);
- •Value значение переменной X, для которого рассчитывается Pn(X);
- •Value значение переменной X, для которого рассчитывается Pn(X);
- •Void Insert_Element(char New_Unit, unsigned Free, unsigned Position, char *Name, unsigned Next)
- •Void Max_Min_Element(int array[], unsigned Size, unsigned max, unsigned min)
- •Алгоритм 4.2 нахождения наибольшего и наименьшего элементов множества
- •Алгоритм 6. Сортировка последовательности чисел слиянием
- •Void Merge(int *out1, int *out2, unsigned size, int *sorted)
- •Алгоритм 7. Динамического программирования для вычисления порядка, минимизирующего сложность умножения цепочки из n матриц m1m2…Mn
- •Void main()
Void Max_Min_Element(int array[], unsigned Size, unsigned max, unsigned min)
// Функция находит максимальный и минимальный элементы массива array размера Size
{
unsigned index;
int *ptr;
ptr=&array[0];
min=max=*ptr++;
index=1;
while (index++<Size)
{
if (max<*ptr) max=*ptr;
if (min>*ptr) min =*ptr;
*ptr++
}
} // Конец Max_Min_Eletment
Итак, понятно, что для нахождения минимального и максимального элементов множества S требуется 2n-2 сравнений при n 2. (Вообще-то достаточно на одно сравнение меньше, если реализовать поиск данных элементов последовательно и после нахождения максимального не учитывать его при поиске минимального, т.е. 2n-3 сравнений).
Применяя приём «разделяй и властвуй» (divide and conquer), пришлось разбить бы множество S на два подмножества S1 и S2 по n/2 элементов в каждом. Тогда описанный выше алгоритм нашёл бы наибольший и наименьший элементы в каждом из двух половин с помощью рекурсии. Наибольший и наименьший элементы всего множества S можно было бы определить, произведя ещё два сравнения наибольших элементов в S1 и S2 и наименьших элементов в них. Сформулируем данный алгоритм более точно.
Алгоритм 4.2 нахождения наибольшего и наименьшего элементов множества
Вход. Множество S из n элементов, n2, n степень числа 2.
Выход. Наибольший и наименьший элементы множества S.
Метод. К множеству S применяется рекурсивная процедура MaxMin. Она имеет два аргумента, представляющие собой само множество S и его размер ║S║=2k, и выдаёт на выходе пару (max, min), соответственно наибольший и наименьший элементы в S. Если множество S представлено массивом, то можно эффективно организовать вызов MaxMin, используя указатели на первый и последний элементы массива.
// Программа 4.2
// Программа находит максимальный и минимальный элемент в массиве
// методом «разделяй и властвуй» с использованием рекурсивной процедуры
#include <stdlib.h>
#include <stdio.h>
#define SIZE 1024 .// Размер массива array
typedef struct{ // Структура, состоящая из минимального и
int max; // максимального элементов, найденных в
int min; // массиве array
} pair;
pair value;
pair Max_Min(int array[], unsigned size, int *in_min, int *in_max);
int array[SIZE];
void main()
{
unsigned *in_max, *in_min;
// Здесь должна находиться функция ввода массива целых чисел array
in_max=&array[SIZE-1]; in_min=&array[0]; // Указатели на крайние элементы массива
value=Max_Min(array, SIZE, in_min, in_max);
} //Конец main
pair Max_Min(int array[], unsigned size, int *in_min, int *in_max)
// Функция находит максимальный и минимальный элемент в массиве array
{
unsigned size1;
int *in_max1, *in_min1, *in_max2, *in_min2;
pair value, value1, value2;
if ((size)==2) 6
{
if (*in_min < *in_max) 1
{
value.max=*in_max;
value.min=*in_min;
} else
{
value.max=*in_min;
value.min=*in_max;
}
return value;
} else
{
size1=size>>1; // Уменьшение размера массива вдвое
in_min1=in_min; in_max2=in_max; // Указатели на крайние элементы двух
in_max1=in_max-size1; in_min2=in_min1+size1; // новых массивов, составляющих исходный
value1=Max_Min(array, size1, in_min1, in_max1); 4
value2=Max_Min(array, size1, in_min2, in_max2); 5
if (value1.max < value2.max) value.max=value2.max; 2// Вместо этих операторов для массива
else value.max=value1.max; // int можно использовать библиотечные
if (value1.min < value2.min) value.min=value1.min; 3//функции max и min языка C++, что
else value.min=value2.min; // делает программу более изящной
return value;
}
} // Конец Max_Min
Вместо предпоследних четырёх строк функции Max_Min перед оператором return value можно воспользоваться библиотечными функциями из stdlib.h Borland C++
value.max=max(value1.max, value2.max); value.min=min(value1.min, value2.min),
что сделает код программы более изящным, но если тип массива будет не int, то эти строки должны остаться без изменений. Структура pair введена для удобства записи кода программы.
Легко заметить, что сравнения элементов множества S происходят только в строках 1, 2, 3, помеченных символом . Пусть C(n) число сравнений элементов множества S, которые надо произвести в процедуре MaxMin, чтобы найти наибольший и наименьший элементы множества S. Ясно, что C(2)=1. Если n >2, то C(n) общее число сравнений, произведенных в двух вызовах функции MaxMin (строки 4 и 5), для множеств размера n/2, и ещё два сравнения в строках2 и 3. Следовательно:
(1.4)
Решением рекуррентных
уравнений (1.4) является функция C(n)=n–2.
Это легко доказать по индукции. Очевидно,
что при n=2 эта функция удовлетворяет
(1.4). Предположим, что при n=m
2 данная функция
тоже является решением (1.4). Тогда
C(2m)
= 2+
2
=
(2m)
2,
т.е. функция C(n) удовлетворяет (1.4) при следующем значении n=2m. Значит, если n является степенью числа 2, то выбранная функция удовлетворяет рекуррентному соотношению (1.4).
В теории алгоритмов
доказано, что для одновременного
нахождения наибольшего и наименьшего
элементов из n-элементного
множества надо сделать не менее
n–2
сравнений его элементов.
Следовательно, предложенный алгоритм
нахождения максимального и минимального
элементов множества S оптимален в
смысле числа сравнений между элементами
из S, когда n есть
степень 2.
Однако, хотя сам алгоритм и оптимален в смысле минимума сравнений между элементами, но его реализация на языке высокого уровня может уступать по временной сложности другим алгоритмам. В приведённом примере программа Max_Min_Element считает существенно быстрее, чем программа Max_Min, основанная на методе «разделяй и властвуй». Это происходит потому, что не учитываются сравнения в строке 6, когда проверяется размер массива, и не учитывается время расчёта указателей на крайние элементы массивов при их делении пополам. Всё же алгоритм с рекурсией в сочетании с «разделяй и властвуй» дает лучшие результаты в тех случаях, в которых время проверки размера множества существенно меньше времени сравнения элементов этого множества.
Обычно временная сложность процедуры определяется числом и размером подзадач и, в меньшей степени, работой, необходимой для разбиения данной задачи на подзадачи. Так как рекуррентные уравнения вида (1.4), (1.6) часто возникают при анализе рекурсивных алгоритмов типа «разделяй и властвуй», целесообразно рассмотреть решение таких уравнений в общем виде.
Теорема 1. Пусть a, b и c неотрицательные постоянные. Решение рекуррентных уравнений
где n степень числа c, имеют вид:
(1.8)
Доказательство. Если n степень числа c (т.е. n=1, c, c2 и т.д.), то легко проверить непосредственной подстановкой, что ряд
удовлетворяет условию теоремы. Пусть теперь a<c, тогда ряд в последнем равенстве сходится, и, следовательно, C(n) = O(n). Если a=c, то каждым членом ряда будет 1, а всего в нём O(logn) членов. Поэтому C(n) = O(n logn). Наконец, если a>c, то
,
что составляет O(aq), или O(nr), где r=logca.
Из теоремы 1 вытекает, что разбиение задачи размера n (за линейное время) на две подзадачи размера n/2 даёт алгоритм сложности O(nlogn). Если бы подзадач было 3, 4 или 8, то получился бы алгоритм сложности порядка nlog3, n2 или n3 соответственно. С другой стороны, разбиение задачи на 4 подзадачи размера n/2 даёт алгоритм сложности O(nlogn), а на 9 и 16 порядка nlog3 и n2 соответственно. Поэтому асимптотически более быстрый алгоритм умножения целых чисел можно было бы получить, если бы удалось так разбить исходные целые числа на 4 части, чтобы суметь выразить исходное умножение через 8 или менее меньших умножений.
Если n не является степенью числа c, то обычно можно вложить задачу размера n в задачу размера m, где m наименьшая степень числа c, большая или равная n. Поэтому порядки роста, указанные в теореме 1, сохраняются для любого n.
1.8 Балансировка
В обоих примерах на технику “разделяй и властвуй” задача разбивалась на две подзадачи равных размеров. Это не было случайностью. Поддержание равновесия основной руководящий принцип при разработке хорошего алгоритма. Для иллюстрации этого принципа рассмотрим пример из сортировки и сопоставим эффекты от разбиения задачи на подзадачи неравных размеров и подзадачи равных размеров. Но из этого примера не следует делать скоропалительный вывод о том, что балансировка (balancing) полезна только для техники “разделяй и властвуй”. В последующих разделах будут приведены примеры применения балансировки для решения других задач.
Рассмотрим задачу расположения целых чисел в порядке возрастания. По-видимому, простейший способ сделать это найти наименьший элемент, исследуя всю последовательность и затем меняя наименьший элемент с первым. Процесс повторяется на остальных n-1 элементах, и это повторение приводит к тому, что второй наименьший элемент оказывается на втором месте. Повторение процесса для остальных n-2, n-3, ..., 2 элементов сортирует всю последовательность.
(1.9)
для числа сравнений, произведённых между сортируемыми элементами. Решением для (1.9) будет C(n)=n(n-1)/2, что составляет O(n2).
Хотя это алгоритм можно считать рекурсивным применением приёма “разделяй и властвуй” с разбиением задачи на неравные части, он не эффективен для больших n. Для разработки асимптотически эффективного алгоритма сортировки надо позаботиться о сбалансированности. Вместо того чтобы разбивать задачу размера n на две подзадачи, одна из которых имеет размер 1, а другая размер n-1, надо разбить её на две подзадачи с размерами примерно n/2. Это выполняется методом, известным под именем сортировка слиянием (merge sorting).
Рассмотрим последовательность целых чисел x1, x2, …, xn. Пусть для простоты опять n есть степень числа 2. Один из способов упорядочить эту последовательность – разбить её на две подпоследовательности x1, x2, …, xn/2 и x(n/2)+1, x(n/2)+2, …, xn, упорядочить каждую из них и затем слить их. Под «слиянием» здесь понимается объединение двух уже упорядоченных последовательностей в одну упорядоченную последовательность.