Глава A. Введение в алгоритмику Урок A2. Оценка

реклама
Глава A. Введение в алгоритмику
Урок A2. Оценка эффективности алгоритма
Поразительно, скольким программистам приходится слишком дорогим
способом выяснять, что их программа не может обработать входные
данные раньше, чем через несколько дней машинного времени. Лучше
было бы предугадать такие случаи с помощью карандаша и бумаги.
С.Гудман, С.Хидетниеми1
Лучше меньше, да лучше.2
В.И.Ульянов (Ленин)
Выше мы уже говорили о необходимости анализировать конструируемый
алгоритм. Такой анализ должен дать четкое представление, во-первых, о
емкостной и, во-вторых, о временно́й сложности процесса.
Первая часть вполне ясна: речь идет о размерах памяти, в которой предстоит
размещать все данные, участвующие в вычислительном процессе. Естественно,
к ним относятся входные наборы, промежуточные, и выходная информация.
Возможно, не все перечисленные наборы требуют одновременного хранения, –
что ж, значит, удается сэкономить. В ряде случаев, впрочем, оценка емкостной
сложности становится менее очевидной: так обстоит дело при использовании
динамических структур, но об этом – в другой главе.
А вот анализу временно̀й трудоемкости мы уделим внимание уже сейчас.
Итак, поставлена некоторая задача и для ее решения спроектирован алгоритм.
Он описывает вычислительный процесс, который завершается за конечное
число действий–шагов. Мы уже говорили, что реальное время выполнения
каждого отдельного шага зависит от конкретного вычислительного устройства.
Иначе говоря, неотъемлемым участником вычислительного процесса, – не
алгоритма! – является исполнитель. А вот имея в виду предполагаемого
исполнителя, не грех заранее оценить его вычислительные способности.
Можно сказать, что алгоритм должен быть понятен исполнителю. Это означает,
что любое предписание алгоритма должно входить в тот фиксированный набор
инструкций, которые исполнитель умеет реализовывать. Скажем, ваш
карманный калькулятор рассчитан на выполнение нескольких арифметических
операций, а более мощный, программируемый калькулятор, уже готов
выполнять несложные программы. Соответственно, наборы встроенных,
элементарных, инструкций у этих устройств различаются. При том, любая
команда, которую готов выполнить второй калькулятор, все равно состоит из
некоторого числа арифметических и логических операций, другое дело, что нам
не приходится их вызывать по отдельности. Таким образом, либо алгоритм явно
предписывает выполнять арифметико-логические операции, – и такой уровень
программирования рассчитан непосредственно на работу процессора, – либо
используются «укрупненные» инструкции, и для их обработки применяется
специальный язык.
Так, общение клиента с банкоматом предусматривает участие двух
исполнителей алгоритма – человека и банкомата, – причем первый из них
обращается ко второму посредством кнопочного интерфейса. Опытный
владелец пластиковой карты знаком с процедурой, тем не менее, второй
1
S.E.Goodman, S.T.Hedetniemi // Гудман С., Хидетниеми С. Введение в разработку и анализ
алгоритмов. — М.: Мир, 1981. — 368 с.
2
название статьи
участник процесса не учитывает компетентность клиента, и потому на табло
высвечиваются, поочередно, все необходимые инструкции. Как видим,
разработчики системы, обеспечивающей этот диалог, довели пошаговую
детализацию алгоритма до определенного уровня «компетентности», разбив
укрупненные шаги – «снятия наличности» или «получения справки о счете» –
на шаги более мелкие.
Как мы выяснили, исполнитель нашего алгоритма должен «уметь» выполнить
любую инструкцию из числа тех, которые он воспринимает как элементарные, а
никакие другие он и не выполнит. Собственно, одно из различий между
процессорами компьютеров состоит в несовпадении их «личных» наборов
инструкций.
Но было бы нелепо в предлагаемом читателю курсе ориентироваться на столь
«интеллектуально ограниченного» исполнителя, каким является обыкновенный
процессор. Напротив, «способности» нашего подразумеваемого исполнителя
должны постепенно развиваться. Потому, по мере удаления от начальных
страниц, степень детализации будет уменьшаться, и алгоритмы будут
описываться в укрупненных шагах. Так, опытный повар вполне готов исполнить
предписание «приготовить ростбиф», – помните, мы с ним выше сталкивались?
– как элементарную инструкцию.
Предположим, алгоритм для решения некоторой задачи нам удалось построить.
Раз так, что мешает предположить существование и другого алгоритма, и еще
следующего? Но если удается сконструировать целый ряд различных
алгоритмов решения одной и той же задачи, то кажется разумным – выбрать
«наилучший». Что под этим следует понимать?
Как правило, речь идет о таком варианте решения, который, в сравнении с
конкурентами, нуждается в наименее продолжительном по времени
вычислительном процессе. Разумеется, давать такую оценку правомерно, лишь
имея в виду одного и того же потенциального исполнителя.
Далее: скорость реализации выбираемого алгоритма может существенно
зависеть от содержания набора входных данных. Скажем, быстрый «в среднем»
механизм способен давать сбои в отдельных «плохих» случаях. И, если задача
должна наверняка решаться за определенное время работы процессора, то в
этом случае, вероятно, мы предпочтем алгоритм более медленный в среднем,
зато надежный в худших ситуациях.
За примерами вновь обратимся к некомпьютерной сфере. Чтобы выпить чашку
кофе, надо, во всяком случае, подогреть воду. Достаточно удобна и эффективна
в этом случае кофеварка или мощный электрочайник. Если к вам в дом, – а это
весьма вероятно, – подведено электричество, то способ решения задачи,
привлекающий один из указанных приборов, стоит предпочесть в большинстве
случаев. Однако неполадки в распределительном электрощите, сколь бы редко
они ни возникали, лишат вас ожидаемого удовольствия (как и автора, который
ссылается здесь на собственный печальный опыт, когда одновременно с
кофеваркой перестает работать и компьютер). Так что газовые плиты пока не
стоит отменять.
Впрочем, остается и самый надежный, хотя и наиболее трудоемкий метод:
вскипятить воду на открытом огне, разложив небольшой костер. Чтобы
читатель не забывал анализировать и емкостную сложность алгоритма, заметим,
что последний пример сочетает свойства наихудшей эффективности как по
времени, так и по требованиям к ресурсам, учитывая безвозвратный расход
горючего и низкий коэффициент полезного действия при изменении
агрегатного состояния. Однако даже такой алгоритм имеет право на
существование!
Итак, давая оценку быстродействия алгоритма, следует рассмотреть поведение
вычислительного процесса в среднем и, отдельно, в экстремальных для него
условиях, то есть – в худшем случае.
Моделирование «худших» случаев всегда связано с содержанием самого
алгоритма. Можно предложить лишь малое число рецептов выделения и
рассмотрения подобных ситуаций. Один из них состоит в проверке поведения
алгоритма на входных данных, принимающих граничные значения из
разрешенного диапазона. Другой рецепт: тестировать алгоритм на максимально
больших по объему входных наборах, что важно для анализа как временно́й, так
и емкостной эффективности.
Пожалуй, умение предвидеть «нехорошие» ситуации, – а они нередко
возникают при выполнении готовой программы, – как раз и отличает
квалифицированного алгоритмиста от обыкновенного кодировщика. В связи с
расширением сферы производства программного обеспечения сформировалась
даже самостоятельная специализация – «тестеры программ».
Хорошей тренировкой в развитии навыков проектирования контрпримеров к
разрабатываемому алгоритму – и, разумеется, программе – станет для читателя
выполнение заданий. Построены они по современным, – «олимпиадным», –
канонам. Вам будут далее предлагаться многочисленные примеры, решение
которых состоит в написании соответствующих программ. Готовые программы
предстоит тестировать автоматизированной системе, причем наборы тестов
составлены так, чтобы алгоритм прошел через «огонь и воду». Для этого первые
тесты проверяют работоспособность «в общем», с точки зрения получения
ожидаемого результата, а вот далее, если это соответствует постановке задачи, –
«в частности», когда на вход программы подаются «плохие», в рассмотренном
нами смысле, наборы.
Однако продолжим обсуждение того, как оценивать временну́ю эффективность
собственно алгоритма. Обычно ее не связывают с конкретной вычислительной
установкой, а оперируют только «шагами». В самом деле, хорошо бы давать
такие оценки, которые будут актуальны и «завтра», когда быстродействие
техническое, – аппаратное, – наверняка возрастет.
Естественно, любой шаг алгоритма реализуется некоторым числом машинных
операций, или тех самых элементарных инструкций исполнителя, поскольку
вряд ли вы станете проектировать алгоритм на языке ассемблера. По существу,
анализ требует такой степени детализации алгоритма, чтобы в отношении
отдельного шага не требовалась его дальнейшая алгоритмическая проработка.
Здесь возможны лишь две ситуации: либо фиксированное время исполнения
такого шага определено некоторым набором простых, без циклов, команд языка
программирования, либо речь идет об «укрупненном» шаге, в отношении
которого соответствующий анализ уже проводился и результаты известны.
Обратимся к примерам.
Пример A2-1. Алгоритм обмена двух переменных – a и b – реализуется, в
общем случае, за три шага, независимо от того, к какому типу простых
переменных он применяется:
1.
2.
3.
temp ← a
a←b
b ← temp
С точки зрения количества машинных операций, две разных ситуации – обмена
содержимым между переменными, занимающими одно машинное слово, или
занимающими два машинных слова – неравноценны. Но оценка
алгоритмической трудоемкости этого не учитывает.
Пример A2-2. Найти сумму натуральных чисел от 1 до заданного n. Если
воспользоваться известной формулой для суммы арифметической прогрессии,
то вычисления также потребуют лишь 3 шага: сложение, умножение и деление.
Если же реализовать вычислительный процесс как циклический: цикл с
параметром, управляющая переменная «пробежит» значения от 1 до n, – то
придется выполнить n шагов алгоритма. Сомнений, какой из алгоритмов
более эффективен, кажется, не возникает. Однако вспомните о «худших
случаях»: при небольших значениях n (скажем, от 2 до 4), «медленный»
алгоритм, вероятно, предпочтительнее.
Для алгоритмов, подобных только что рассмотренному, – n шагов для
обработки n входных значений, – очевидно, что количество шагов является
функцией от числа обрабатываемых элементов. Можно считать, что количество
шагов является функцией от количества элементов – f(n).
В математике существует специальный механизм оценки скорости роста
интересующей исследователя величины: ее сравнивают с какой-нибудь
функцией, чье поведение хорошо исследовано. При этом используется
обозначение g(n)=O(f(n)), – читается: О-большое, – которое мы будем
относить к интересующим нас дискретным функциям f(n) натурального n
и ко всем функциям g(n), растущим не быстрее f(n). Формулировка
«растущим не быстрее» означает, что существует такая пара положительных
значений M и n0, что g(n) ≤ Mf(n0) для n≥n0. Еще говорят, что
функция g(n) – «порядка f(n) для больших n».
Такая O–нотация дает нам верхнюю оценку временно́й трудоемкости алгоритма
– его асимптотическую сложность. Обратите внимание, что использование
констант M и n0, фигурирующих в определении, фактически связано с
«большими» значениями аргумента n, и мало что дает при его малых
значениях.
Укажем несколько важных свойств O-операций:
(a) f(n)=O(f(n))
(b) c•O(f(n))=O(f(n)), где c – некоторая константа
(c) O(f(n))+O(f(n))=O(f(n))
(d) O(O(f(n)))=O(f(n))
(e) O(f(n))•O(g(n))=O(f(n)•g(n))
Кроме введенной терминологии, полезна и другая, т.н. o–нотация («о-малое»).
Обозначение o(f(n)) относится к функциям, которые растут быстрее f(n).
Вновь обращаясь к примеру с суммой арифметической прогрессии, можем
сказать, что асимптотическая эффективность алгоритма непосредственного
суммирования n элементов соответствует линейной сложности, поскольку его
быстродействие, то есть число шагов, согласно свойству (a), есть O(n).
Вообще говоря, если алгоритм связан с обработкой n входных элементов, и
аналитического выражения – формулы – для быстрого вычисления результата в
нашем распоряжении нет, то достижение лучшей эффективности, чем O(n),
если это вообще возможно, следует рассматривать как большой успех.
А вот более трудоемкие алгоритмы, при том же объеме входного набора,
существуют, и ускорить процесс далеко не всегда удается.
Пример A2-3. Независимый выбор пары соседей для первой парты в классе из
n учеников можно осуществить n(n-1) способами. Если предстоит
рассмотреть все варианты для выбора наиболее приемлемого в некотором
заданном смысле, то трудоемкость алгоритма, очевидно, оценивается как
O(n2), или квадратичная. Согласно (b)–свойству O-операций, собственно
константа, определяющая трудоемкость каждого отдельного шага оценки
«приемлемости», скрыта внутри обозначения, и даже может быть нам
неизвестна.
Пример A2-4. Совсем не трудно представить еще менее эффективный алгоритм.
Предлагается для набора из n попарно неравных отрезков подсчитать
количество всевозможных «троек», из которых получаются невырожденные
треугольники. Здесь, очевидно, предстоит проверить n(n-1)(n-2)
вариантов, что соответствует кубической трудоемкости – O(n3).
В наших примерах речь идет о верхних оценках скорости роста трудоемкости.
Обычно, при поверхностном анализе алгоритма, такой оценки вполне
достаточно, особенно когда речь идет об алгоритмах с трудоемкостью «разного
порядка», как-то: O(n2) и O(n3). В общем случае, если эффективность
алгоритма определяется вычислительной сложностью обработки многочлена
порядка k, часто удовлетворяются оценкой O(nk), не обращая внимания,
согласно свойству (b), на старший коэффициент.
Такой подход, как правило, оправдывает себя для «больших» n.
Действительно, как следует из определения O-асимптотики, – и это
демонстрируют приводимые графики, – сколь бы ни был мал коэффициент при
старшем k-члене и, напротив, велик – при любом другом m-члене (m<k),
вклад первого из них в поведение всего многочлена рано или поздно становится
решающим.
Однако нередко представляет интерес более тщательный анализ. В частности,
когда сравнивают эффективность двух равноценных, с точки зрения
O-асимптотики, алгоритмов. Тогда возникает необходимость в уточнении
первого «приближения».
Так, оценка O(n3) в последнем примере получена нами без раскрытия скобок в
произведении. Для ее уточнения достаточно проделать несколько умножений:
n(n-1)(n-2)=n3-3n2+2n=n3+O(n2)
или, еще точнее,
n3-3n2+2n=n3-3n2+O(n).
На практике оказывается недостаточно «O(nk)-шкалы» для сравнения
временно́й сложности алгоритмов. Так, более эффективным, чем алгоритм с
линейной трудоемкостью, является его конкурент, чье поведение оценивается
как O(log2n). Еще быстрее работает алгоритм с постоянной трудоемкостью –
O(1), с одним из них мы уже имели дело – это алгоритм обмена.
Соответственно, «между» O(n) и O(n2) находится место для O(n•log2n).
Знакомство с такими алгоритмами нам предстоит.
Скачать