Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Алгоритмы и анализ сложности.doc
Скачиваний:
13
Добавлен:
08.09.2019
Размер:
74.75 Кб
Скачать

1) Основные понятия и методы, связанные с построением и анализом алгоритмов.

Алгоритм — набор инструкций, описывающих порядок действий исполнителя для достижения результата решения задачи за конечное время Алгоритм (algorithm) —это формально описанная вычислительная процеду­ра, получающая исходные данные (input), называемые также входом алгоритма или его аргументом, и выдающая результат вычислений на выход (output). Алгоритмы строятся для решения тех или иных вычислительных задач (computational problems). Формулировка задачи описывает, каким требовани­ям должно удовлетворять решение задачи, а алгоритм, решающий эту задачу, находит объект, этим требованиям удовлетворяющий.Мы рассматриваем задачу сортировки (sorting problem); по­мимо своей практической важности эта задача служит удобным примером для иллюстрации различных понятий и методов. Она описывается так: Вход: Последовательность п чисел (а1, а2,… , ап). Выход: Перестановка (а'1, а'2,…, а'п) исходной последовательности, для которой а'1≤ а'2≤… ≤а'п.Например, получив на вход (31,41,59,26,41,58), алгоритм сортировки должен выдать на выход (26,31,41,41,58,59). Подлежащая сортировке последовательность называется входом (instance) задачи сортировки. Многие алгоритмы используют сортировку в качестве промежуточного ша­га. Имеется много разных алгоритмов сортировки; выбор в конкретной ситуа­ции зависит от длины сортируемой последовательности, от того, в какой степени она уже отсортирована, а также от типа имеющейся памяти (оперативная память, диски, магнитные ленты). Алгоритм считают правильным (correct), если на любом допустимом (для данной задачи) входе он заканчивает работу и выдает результат, удовлетворяю­щий требованиям задачи. В этом случае говорят, что алгоритм решает (solves) данную вычислительную задачу. Неправильный алгоритм может (для некоторо­го входа) вовсе не остановиться или дать неправильный результат. (Впрочем, неправильный алгоритм может быть полезен, если ошибки достаточно редки. Подобная ситуация встретится нам в главе 33 при поиске больших простых чисел. Но это всё же скорее исключение, чем правило.) Алгоритм может быть записан на русском или английском языке, в виде компьютерной программы или даже в машинных кодах — важно только, чтобы процедура вычислений была чётко описана. Сортировка вставками (insertion sort) удобна для сортировки коротких по­следовательностей. Именно таким способом обычно сортируют карты: держа в левой руке уже упорядоченные карты и взяв правой рукой очередную карту, мы вставляем её в нужное место, сравнивая с имеющимися и идя справа налево (рис. 1.1).Запишем этот алгоритм в виде процедуры Insertion-Sort, параметром которой является массив А[1..п] (последовательность длины n, подлежащая сортировке). Мы обозначаем число элементов в массиве А через length[A]. Последовательность сортируется «на

5 2 4 6 1 3

2 5 4 6 1 3

2 4 5 6 1 3

2 4 5 6 1 3

1 2 4 5 6 3

1 2 3 4 5 6

Рис. 1. Работа процедуры Insertion-Sort для входа А = (5,2,4,6,1,3). Позиция j пока­зана жирными числами.

месте», без дополнительной памяти (in place): помимо массива мы используем лишь фиксированное число ячеек памяти. После выполнения процедуры Insertion-Sort массив А упорядочен по возрастанию.

1 for (j=2;j<=n;j++){

2 key=A[j];

3 i=j-1;

4 while (i>0 && A[i]>key){

5 A[i+1]=A[i];

6 i=i-1;

7 }

8 A[i+1]=key;

9 }

На рис. 1.2 показана работа алгоритма при А = (5,2,4,6,1,3). Участок A[1.. j-1] составляют уже отсортированные карты, a A[j + 1..n] — ещё не просмотренные. В цикле for индекс j пробегает массив слева направо. Мы берём элемент A[j] (строка 2 алгоритма) и сдвигаем идущие перед ним и большие его по величине элементы (начиная с (j-1)-го) вправо, освобождая место для взято­го элемента (строки 3-6). В строке 8 элемент A[j] помещается на освобождённое место. Сортировка вставками: анализ. Время сортировки вставками зависит от размера сортируемого массива: чем больше массив, тем больше может потребоваться времени. Обычно изучают зависимости времени работы от размера входа. (Впрочем, для алгоритма сорти­ровки вставками важен не только размер массива, но и порядок его элементов: если массив почти упорядочен, то времени требуется меньше.)Как измерять размер входа (input size)? Это зависит от конкретной задачи. В одних случаях размером разумно считать число элементов на входе (сорти­ровка, преобразование Фурье). В других более естественно считать размером общее число битов, необходимое для представления всех входных данных. Иног­да размер входа измеряется не одним числом, а несколькими (например, число вершин и число рёбер графа).Временем работы (running time) алгоритма мы называем число элементар­ных шагов, которые он выполняет — вопрос только в том, что считать эле­ментарным шагом. Мы будем полагать, что одна строка псевдокода требует не более чем фиксированного числа операций (если только это не словесное описание каких-то сложных действий — типа «отсортировать все точки по x-координате»). Мы будем различать также вызов (call) процедуры (на который уходит фиксированное число операций) и её исполнение (execution), которое может быть долгим. Итак, вернёмся к процедуре Insertion-Sort и отметим около каждой стро­ки её стоимость (число операций) и число раз, которое эта строка исполняется. Для каждого j от 2 до n (здесь n = length[A] — размер массива) подсчитаем, сколько раз будет исполнена строка 4, и обозначим это число через tj. (Заме­тим, что строки внутри цикла выполняются на один раз меньше, чем проверка, поскольку последняя проверка выводит из цикла.)

Insertion-Sort(A) стоимость число раз

1 for(j=2;j<=n;j++){ c1 n

2 key=A[j]; c2 n-1

3 i=j-1; c3 n-1

4 while (i>0 && A[i]>key){ c4 ∑j=2ntj

5 A[i+1]=A[i]; c5 ∑j=2n(tj-1)

6 i=i-1; c6 ∑j=2n(tj-1)

7 }

8 A[i+1]=key; c 8 n-1

9 }

Строка стоимостью с, повторённая m раз, даёт вклад cm в общее число опе­раций. (Для количества использованной памяти этого сказать нельзя!) Сложив вклады всех строк, получим T(n)=c1n+c2(n-1) +c3(n-1)+c4∑j=2ntj+c5∑j=2n(tj-1)+c6∑j=2n(tj-1)+c8(n-1). Как мы уже говорили, время работы процедуры зависит не только от n, но и от того, какой именно массив размера n подан ей на вход. Для процедуры Insertion-Sort наиболее благоприятен случай, когда массив уже отсортиро­ван. Тогда цикл в строке 4 завершается после первой же проверки (поскольку А[i]≤key при i=j-1), так что все tj равны 1, и общее время есть T(n)=c1n+c2(n-1) +c3(n-1)+c4(n-1) +c8(n-1)= (c1+с2+с3+с4+ с8)n - (с2+с3+с4+ с8). Таким образом, в наиболее благоприятном случае время Т(n), необходимое для сортировки массива размера га, является линейной функцией (linear function) от n, т.е. имеет вид Т(n) = аn-b для некоторых констант а и b. (Эти константы определяются выбранными значениями с1,...,с8.) Если же массив расположен в обратном (убывающем) порядке, время работы процедуры будет максимальным: каждый элемент A[j] придётся сравнить со всеми элементами А[1],..., A[j-1]. При этом tj=j. Поскольку∑j=2nj=n(n+1)/2-1, ∑j=2n(j-1)=n(n-1)/2, получаем, что в худшем случае время работы процедуры равно Т(п)=с1+с2(п-1) +с3(п-1) + с4 (n(n+1)/2-1)+c5+c6)(n(n-1)/2)+c8(n-1)= 0.5(c4+c5+c6)n2+(c1+c2+c3+0.5(c4-c5-c6+c8)n-(c2+c3+c4+c8). Теперь функция Т(n) — квадратичная (quadratic function), т.е. имеет вид Т(п)=an2 + bn + с. (Константы а, b и c здесь также определяются значениями c1, ... ,с8.) Порядок роста. Наш анализ времени работы процедуры Insertion-Sort был основан на нескольких упрощающих предположениях. Сначала мы предположили, что время выполнения n-й строки постоянно и равно с,. Затем мы огрубили оценку до an2+bn+c. Сейчас мы пойдём ещё дальше и скажем, что время работы в худшем случае имеет порядок роста (rate of growth, order of growth) n2, отбрасывая члены меньших порядков (линейные) и не интересуясь коэффициентом при n2. Это записывают так: Т(n) = 0(n2) (подробное объяснение обозначений мы отложим до следующей главы). Алгоритм с меньшим порядком роста времени работы обычно предпочти­тельнее: если, скажем, один алгоритм имеет время работы 0(n2), а другой — 0(n3), то первый более эффективен. Каждый алгоритм в зависимости от реализации имеет определенную сложность вычисления.  Чаще всего под сложностью вычисления понимают количество времени необходимое алгоритму для решения задачи.

2) Алгоритмы генерации комбинаторных объектов.

1.Размещения с повторениями из n символов по k – это последовательность длины k, в которых n-возможных элементов будут повторяться. A kсверху n снизу =n!/(n-k)! Напечатать все последовательности длины k из чисел 1..n. Решение: Будем печатать их в лексикографическом порядке (последовательность а предшествует последовательности b, если для некоторого i их начальные отрезки длины i равны, а (i+1)-ый член последовательности а меньше). Первой будет последовательность <1,1,...,1>, последней - <n,n,...,n>. Будем хранить последнюю напечатанную последовательность в массиве x[1],..,x[k].

last[1],..,last[k] положим равным n. Для того, чтобы перейти к следующей последовательности надо: двигаясь с конца последовательности, найти самый правый член, меньший n, увеличить его на 1, а идущие за ним члены положить равными 1. Процедура print(x:massiv) - вывод последовательности.

Функция egual(x,last:massiv):boolean - сравнивает элементы массивов,если x<>last, то egual:=false иначе egual:=true.

for i:=1 to k do x[i]:=1;

print(x);

for i:=1 to k do last[i]:=n;

while not(equal(x,last)) do

begin

p:=k;

while not (x[p]<n) do p:=p-1;

x[p]:=x[p]+1;

for i:=p+1 to k do x[i]:=1;

print(x);

end;

Перестановки из n элементов - частный случай размещения элементов из Е по k, при k=n. Иными словами, перестановками называют размещения без повторений из n элементов, в которые входят все элементы. Можно также сказать, что перестановками из n элементов называют всевозможные n-расстановки, каждая из которых содержит все эти элементы по одному разу, и которые отличаются друг от друга лишь порядком элементов. Число перестановок вычисляется по формуле: Pn=n! 1. Просматриваем а1, ..., аn с конца до тех пор, пока не попадется ai<ai+1. Если таковых нет, то генерация закончена.

2. Рассматриваем ai+1, ai+2, ..., an. Найдем первый с конца am больший ai и поменяем их местами.

3. ai+1, ai+2, ..., an переставим в порядке возрастания (для этого достаточно её переписать с конца).

4. Печатаем найденную перестановку.

5. Возвращаемся к пункту 1.

Сочетанием элементов из Е={a1, ..., an} по k называется упорядоченное подмножество из k элементов, принадлежащих Е и отличающиеся друг то друга составом, но не порядком элементов. Число сочетаний вычисляется по формуле: Ckn=n!/(n-k)!k! Алгоритм генерации сочетаний Ckn.

1. Для i:=1 до k ci:=i; печатаем ci, для i=1..k.

2. С конца находим такое i, что ci<>n-k+i. Если такого i нет, то генерация сочетаний закончена.

3. ci:=ci+1; для m=i+1, i+2, ..., k выполним cm:=cm-1+1; выводим ci для i=1, ..., k.

4. Возрващаемся к пункту 2.

3) Динамическое программирование — способ решения сложных задач путём разбиения их на более простые подзадачи. Ключевая идея в динамическом программировании достаточно проста. Как правило, чтобы решить поставленную задачу, требуется решить отдельные части задачи (подзадачи), после чего объединить решения подзадач в одно общее решение. Часто многие из этих подзадач одинаковы. Подход динамического программирования состоит в том, чтобы решить каждую подзадачу только один раз, сократив тем самым количество вычислений. Это особенно полезно в случаях, когда число повторяющихся подзадач экспоненциально велико.

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

Этапы:

  1. Разбиение задачи на подзадачи меньшего размера.

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

  3. Использование полученного решения подзадач для конструирования решения исходной задачи.

 Ярким примером является вычисление последовательности Фибоначчи,Ф3=Ф2+Ф1  

Динамическое программирование обычно придерживается двух подходов к решению задач:

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

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

Задача о ранце: из неограниченного множества предметов со свойствами «стоимость» и «вес», требуется отобрать некое число предметов таким образом, чтобы получить максимальную суммарную стоимость при ограниченном суммарном весе.