Алгоритмы на C++

Принципы анализа алгоритмов

Разбить на страницы
Показывать лекцию целиком

Анализ - это ключ к пониманию алгоритмов в степени, достаточной для их эффективного применения к практическим задачам. Хотя у нас нет возможности проводить исчерпывающие эксперименты и глубокий математический анализ каждой программы, мы можем задействовать и эмпирическое тестирование, и приближенный анализ, которые помогут нам изучить основные характеристики производительности наших алгоритмов, чтобы можно было сравнивать различные алгоритмы и применять их для практических целей.

Сама идея точного описания производительности сложного алгоритма с помощью математического анализа кажется на первый взгляд устрашающей перспективой, поэтому мы будем часто обращаться к исследовательской литературе за результатами подробного математического изучения. Хотя целью данной книги не является обзор методов анализа или их результатов, для нас важно знать, что при сравнении различных методов мы находимся на твердой теоретической почве. Более того, тщательное применение относительно простых методов позволяет получить о многих алгоритмах большой объем подробной информации. Во всей книге мы отводим главное место простым аналитическим результатам и методам анализа, особенно тогда, когда это может помочь нам в понимании внутреннего механизма фундаментальных алгоритмов. В этой главе наша основная цель - обеспечить среду и инструменты, необходимые для работы с алгоритмами.

Пример в демонстрирует многие из базовых концепций анализа алгоритмов, поэтому для конкретизации определенных моментов мы будем часто ссылаться на производительность алгоритмов объединение-поиск. Несколько новых примеров будут подробно рассмотрены в разделе 2.6.

Анализ играет определенную роль в каждой точке процесса разработки и реализации алгоритмов. Прежде всего, как было показано, за счет правильного выбора алгоритма можно сократить время выполнения на три-шесть порядков. Чем эффективнее будут рассматриваемые алгоритмы, тем сложнее будет становиться задача выбора между ними, поэтому необходимо подробнее изучить их свойства. В поиске наилучшего (в некотором точном техническом смысле) алгоритма мы будем находить и практически полезные алгоритмы, и теоретические вопросы, требующие решения.

Полный охват методов анализа алгоритмов сам по себе является предметом книги (см. раздел ссылок), и здесь мы рассмотрим лишь основы, которые позволят

  • Проиллюстрировать процесс.
  • Описать в одном месте используемые математические соглашения.
  • Обеспечить основу для обсуждения вопросов более высокого уровня.
  • Выработать понимание научной основы результатов, получаемых при анализе алгоритмов.
  • Алгоритмы и их анализ зачастую переплетены. В этой книге мы не станем углубляться в сложные математические преобразования, но все же проделаем достаточно выкладок, чтобы понять, что представляют собой наши алгоритмы и как их можно использовать наиболее эффективно.

    Реализация и эмпирический анализ

    Мы разрабатываем и реализуем алгоритмы, создавая иерархию абстрактных операций, которые помогают понять сущность решаемых вычислительных проблем. Этот процесс достаточно важен, но при теоретическом изучении он может увести очень далеко от проблем реального мира. Поэтому в данной книге мы выражаем все рассматриваемые нами алгоритмы реальным языком программирования - С++. Такой подход иногда оставляет очень туманное различие между алгоритмом и его реализацией, но это лишь небольшая плата за возможность работать с конкретной реализацией и учиться на ней.

    Несомненно, правильно разработанные программы на реальном языке программирования представляют собой эффективные методы выражения алгоритмов. В этой книге мы рассмотрим большое количество важных и эффективных алгоритмов, кратко и точно реализованных на С++. Описания на обычном языке или абстрактные высокоуровневые представления зачастую неопределённы и незакончены, а реальные реализации заставляют нас находить экономные представления, позволяющие не завязнуть в деталях.

    Мы выражаем алгоритмы на С++ , но эта книга об алгоритмах, а не о программировании на С++. Конечно же, мы будем рассматривать реализации на С++ многих важных задач, и когда существует удобный и эффективный способ решить задачу именно средствами С++, мы воспользуемся этим достоинством. Однако подавляющее большинство выводов о реализации алгоритмов применимо к любой современной среде программирования. Перевод программ из и почти всех других программ из данной книги на другой современный язык программирования - это достаточно простая задача. Если в некоторых случаях какой-либо другой язык обеспечивает более эффективный механизм решения определенных задач, мы будем указывать на это. Наша цель - использовать С++ как средство выражения алгоритмов, а не задерживаться на вопросах, специфичных для языка.

    Если алгоритм должен быть реализован как часть большой системы, мы используем абстрактные типы данных или аналогичный механизм, который позволяет изменить алгоритмы или детали реализации после того, как станет ясно, какая часть системы заслуживает наиболее пристального внимания. Однако в самом начале потребуется понимание характеристик производительности каждого алгоритма, так как требования системы к проектированию могут в значительной степени повлиять на производительность алгоритма. Подобные исходные решения при разработке необходимо принимать с осторожностью, поскольку в конце часто оказывается, что производительность всей системы зависит от производительности нескольких базовых алгоритмов наподобие тех, которые обсуждаются в этой книге.

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

    Однако при анализе каждого алгоритма мы будем уделять основное внимание существенным характеристикам производительности алгоритма. Предполагается, что главный интерес будут вызывать наиболее эффективные алгоритмы, особенно если они еще и более простые.

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

    Один из первых шагов в понимании производительности алгоритмов - это эмпирический анализ. Если есть два алгоритма для решения одной задачи, то все естественно: мы запустим оба и увидим, который из них выполняется дольше! Это концепция может показаться слишком очевидной, чтобы о ней стоило говорить, но ее часто упускают из виду при сравнительном анализе алгоритмов. Трудно не заметить, что один алгоритм в 10 раз быстрее другого, если один выполняется 3 секунды, а другой 30 секунд, однако при математическом анализе эту разницу легко упустить из виду как небольшой постоянный множитель. При замерах производительности тщательно выполненных реализаций алгоритмов для типичных данных мы получаем результаты, которые не только являются прямым показателем эффективности, но и содержат информацию, необходимую для сравнения алгоритмов и обоснования прилагаемых математических результатов (см., например, таблица 1.1). Если эмпирическое изучение начинает поглощать значительное количество времени, на помощь приходит математический анализ. Вряд ли стоит ожидать завершения программы в течение часа или целого дня, чтобы убедиться, что она работает медленно - особенно если тот же результат может дать несложный анализ.

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

    Вторая проблема, с которой мы сталкиваемся при эмпирическом анализе, - это определение природы входных данных и других факторов, оказывающих непосредственное влияние на производимые эксперименты. Обычно существуют три основных возможности: реальные данные, случайные данные и ошибочные данные. Реальные данные позволяют точно измерить параметры используемой программы; случайные данные гарантируют, что эксперименты тестируют алгоритм, а не данные; ошибочные данные гарантируют, что программы могут воспринимать любые поданные на их вход данные. Например, при тестировании алгоритмов сортировки можно запустить их с такими данными, как слова из романа "Моби Дик", сгенерированные случайным образом целые числа и файлы, содержащие одинаковые числа. При анализе алгоритмов возникает и задача определения, какие входные данные необходимо использовать для сравнения алгоритмов.

    При сравнении различных реализаций легко допустить ошибки, особенно если приходится использовать разные машины, компиляторы или системы, либо очень большие программы с плохо заданными параметрами. Принципиальная опасность при эмпирическом сравнении программ таится в том, что код для одной реализации может быть написан более тщательно, чем для другой. Изобретатель предлагаемого нового алгоритма, скорее всего, обратит больше внимания на все аспекты его реализации, но не потратит слишком много усилий на классический конкурирующий алгоритм. Чтобы быть уверенным в точности эмпирического сравнения алгоритмов, необходимо быть уверенным, что каждой реализации уделялось одинаковое внимание.

    Один из подходов, который часто применяется в этой книге, и который был продемонстрирован в , заключается в построении алгоритмов за счет внесения небольших изменений в другие алгоритмы, уже существующие для данной задачи - тогда сравнительное изучение даст достоверные результаты. В более общем смысле, мы пытаемся определить важнейшие абстрактные операции и начинаем сравнение алгоритмов с использования таких операций. Например, эмпирические результаты, приведенные в таблица 1.1, почти не зависят от языков и сред программирования, поскольку в них используются аналогичные программы, оперирующие одним и тем же набором базовых операций. Для любой конкретной среды программирования эти числа можно превратить в реальные времена выполнения. Но чаще всего просто требуется узнать, какая из двух программ будет быстрее, или до какой степени определенное изменение может улучшить требования программы к времени или памяти.

    Возможно, наиболее распространенной ошибкой при выборе алгоритма является игнорирование характеристик производительности. Более быстрые алгоритмы, как правило, сложнее, чем прямые решения, и разработчики часто предпочитают более медленные алгоритмы, дабы избежать лишних сложностей. Однако, как было показано на примере алгоритмов объединение-поиск, можно добиться значительных улучшений с помощью даже нескольких строк кода. Пользователи удивительно большого числа компьютерных систем теряют много времени в ожидании решения задачи простыми квадратичными алгоритмами, в то время как доступные алгоритмы сложности $$N logN$$ или линейные алгоритмы ненамного сложнее, но могут решить задачу значительно быстрее. Но когда мы имеем дело с большими задачами, приходится искать наилучший алгоритм, что и будет показано далее.

    Возможно, вторая наиболее распространенная ошибка при выборе алгоритма - слишком большое внимание к характеристикам его производительности. Снижение времени выполнения программы в 10 раз несущественно, если ее выполнение занимает несколько микросекунд. Даже если программа требует нескольких минут, время и усилия, необходимые, чтобы она стала выполняться в 10 раз быстрее, могут не стоить того, в особенности, если программа должна применяться лишь несколько раз. Суммарное время, требуемое для реализации и отладки улучшенного алгоритма, может быть значительно больше времени, необходимого для выполнения более медленной программы - ведь часть работы вполне можно доверить компьютеру. Но еще хуже, если потратить много времени и усилий на реализацию идей, которые должны были бы улучшить программу, но на самом деле так не получается.

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

    Упражнения

    2.1. Переведите программу из на другой язык программирования и ответьте на вопросы упражнения 1.22 для вашей реализации.

    2.2. Сколько времени займет посчитать до 1 миллиарда (не учитывая переполнение)? Определите количество времени, необходимое программе

    int i, j, k, count = 0;
    for (i = 0; i < N; i ++)
      for (j = 0; j < N; j ++)
        for (k = 0; k < N; k ++)
          count ++;
          

    для выполнения в вашей среде программирования для $$N = 10, 100$$ и $$1000$$. Если в вашем компиляторе имеются средства оптимизации, предназначенные для повышения эффективности программ, проверьте, дают ли они какой-либо результат для этой программы.

    Анализ алгоритмов

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

    Мы будем выполнять математический анализ алгоритмов, в частности:

  • Для сравнения разных алгоритмов, предназначенных для решения одной задачи
  • Для приблизительной оценки производительности программы в новой среде
  • Для установки значений параметров алгоритма
  • В данной книге вы увидите немало примеров каждой из этих причин. Эмпирический анализ годится для некоторых задач, но, как будет показано, математический анализ может оказаться более информативным (и менее дорогим!).

    Анализ алгоритмов - не всегда легкое занятие. Некоторые из алгоритмов, приведенных в этой книге, понятны до той степени, когда известны точные математические формулы для расчета времени выполнения в реальных ситуациях. Эти формулы разработаны путем тщательного изучения программы и определения времени выполнения в зависимости от фундаментальных математических величин, а затем их математического анализа. Однако характеристики производительности других алгоритмов из этой книги не поняты до конца - или потому, что их анализ ведет к нерешенным математическим проблемам, или потому, что известные реализации слишком сложны, чтобы их подробный анализ имел смысл, или же (скорее всего) потому, что невозможно точно охарактеризовать типы входных данных.

    Есть несколько важных факторов точного анализа, на которые программист обычно не может влиять. Во-первых, программы на С++ переводятся в машинные коды конкретного компьютера, и может оказаться достаточно сложной задачей определить, сколько времени займет выполнение даже одного оператора С++ (особенно в среде, где возможен совместный доступ к ресурсам, так что одна и та же программа в разное время может иметь различные характеристики производительности). Во-вторых, многие программы чрезвычайно чувствительны ко входным данным, поэтому производительность может меняться в больших пределах в зависимости от них. В-третьих, многие интересующие нас программы еще не поняты до конца, поэтому для них пока не существует специальных математических результатов. И, наконец, две программы могут совершенно не поддаваться сравнению: одна работает более эффективно с определенным типом входных данных, а другая выполняется более эффективно в других условиях.

    Но все же часто вполне возможно предсказать, сколько времени займет выполнение определенной программы или же понять, что в определенных ситуациях одна программа будет выполняться эффективнее другой. Более того, зачастую эту информацию можно получить, используя относительно небольшой набор математических инструментов. Задача аналитика алгоритмов - получить как можно больше информации о производительности алгоритмов; задача программиста - использовать эту информацию при выборе алгоритмов для конкретных приложений. В этом и нескольких следующих разделах мы будем уделять основное внимание идеализированному миру аналитика. Чтобы эффективно применять лучшие алгоритмы, иногда просто необходимо посещать такой мир.

    Первый шаг при анализе алгоритма состоит в определении абстрактных операций, на которых основан алгоритм, чтобы отделить анализ от реализации. Например, мы отделяем подсчет, сколько раз одна из реализаций алгоритма объединение-поиск запускает фрагмент кода $$i = a[i]$$, от выяснения, сколько наносекунд требуется для выполнения этого фрагмента кода на данном компьютере. Для определения реального времени выполнения программы на конкретном компьютере требуются оба этих элемента. Первый из них определяется свойствами алгоритма, а второй - свойствами компьютера. Такое разделение зачастую позволяет сравнивать алгоритмы таким способом, который не зависит от определенной реализации или от определенного типа компьютера.

    Количество используемых абстрактных операций может оказаться очень большим, однако производительность алгоритма, как правило, зависит от нескольких величин, причем наиболее важные для анализа величины обычно определить несложно. Один из способов их определения заключается в использовании механизма профилирования (подсчитывает количество выполнений каждой инструкции, доступен во многих реализациях С++) для нахождения наиболее часто исполняемых частей программы по результатам нескольких пробных запусков. Или же, как алгоритмы объединение-поиск из раздела 1.3 , наша реализация может быть построена лишь на нескольких абстрактных операциях. В любом случае, анализ сводится к определению частоты исполнения нескольких фундаментальных операций. Принцип нашей работы заключается в том, чтобы отыскать приблизительные оценки этих величин, зная, что для важных программ при необходимости можно будет произвести полный анализ. Более того, как будет показано далее, часто можно достаточно точно предсказать результаты на основе приближенных аналитических результатов в сочетании с эмпирическим изучением.

    Кроме того, необходимо изучать данные и моделировать такие их наборы, которые могут быть поданы на вход алгоритма. Чаще всего мы будем рассматривать один из двух подходов к анализу: или предполагаем, что входные данные случайны, и изучаем среднюю производительность программы, или же рассматриваем самые неудобные данные и изучаем наихудшую производительность программы. Процесс описания случайных входных данных для многих алгоритмов достаточно сложен, но для многих других алгоритмов он совсем прост и приводит к аналитическим результатам, дающим полезную информацию. Средний случай может быть просто математической фикцией, не зависящей от данных, для которых используется программа, а наихудший - вычурной последовательностью, которая никогда не встречается на практике, но в большинстве случаев эти виды анализа предоставляют полезную информацию о производительности. Например, мы можем сравнить аналитические и эмпирические результаты (см. раздел 2.1). Если они совпадут, это повысит уверенность в обоих вариантах; а если не совпадут, мы сможем узнать больше об алгоритме и модели, рассмотрев их расхождения.

    В последующих трех разделах мы кратко рассмотрим математические инструменты, которыми будем пользоваться во всей книге. Этот материал находится за пределами нашей основной задачи, поэтому читатели с хорошей математической подготовкой или же те, кто не собирается подробно проверять наши математические выражения, связанные с производительностью алгоритмов, могут перейти сразу к разделу 2.6 и обращаться к этому материалу там, где это указано в книге позже. Однако рассматриваемые нами математические основы, в общем-то, не сложны для понимания и настолько близки к базовым вопросам разработки алгоритмов, что их не стоит игнорировать всем, кто стремится эффективно использовать компьютер.

    Вначале, в разделе 2.3, рассматриваются математические функции, которые обычно нужны для описания характеристик производительности алгоритмов. Далее, в разделе 2.4, будет рассмотрена О-нотация (О-notation) и понятие пропорционально (is proportional to), которые позволяют опустить детали при математическом анализе. Затем, в разделе 2.5, изучаются рекуррентные соотношения (recurrence relations) - основной аналитический инструмент, используемый для выражения характеристик алгоритма в виде математических равенствах. И в завершение, в разделе 2.6, приводятся примеры, в которых все эти инструменты применяются для анализа конкретных алгоритмов.

    Упражнения

  • 2.3. Напишите выражение вида с0 + c1N + c2N^2 + c3N3, которое точно описывает время выполнения программы из упражнения 2.2. Сравнить время, получаемое из этого выражения, с реальным при N = 10, 100, 1000.
  • 2.4. Напишите выражение, которое точно описывает время выполнения программы 1.1 в зависимости от M и N.
  • Возрастание функций

    Большинство алгоритмов имеют главный параметр $$N$$, который наиболее сильно влияет на время их выполнения. Параметр $$N$$ может быть степенью полинома, размером файла при сортировке или поиске, количеством символов в строке или некоторой другой абстрактной мерой размера рассматриваемой задачи: чаще всего он прямо пропорционален объему обрабатываемого набора данных. Когда таких параметров существует более одного (например, $$M$$ и $$N$$ в алгоритмах объединение-поиск, которые были рассмотрены в разделе 1.3 ), мы часто сводим анализ к одному параметру, задавая его как функцию других, или рассматривая одновременно только один параметр (считая остальные постоянными) - то есть без потери общности ограничиваясь рассмотрением только одного параметра $$N$$. Нашей целью является выражение требований программ к ресурсам (как правило, это время выполнения) в зависимости от $$N$$ с использованием математических формул, которые максимально просты и справедливы для больших значений параметров. Алгоритмы в этой книге обычно имеют время выполнения, пропорциональное одной из следующих функций:

    1 Большинство инструкций большинства программ выполняются один или несколько раз. Если все инструкции программы обладают таким свойством, мы говорим, что время выполнения программы постоянно.
    $$ logN$$ Когда время выполнения программы является логарифмическим, программа выполняется несколько медленнее с ростом $$N$$. Такое время выполнения обычно присуще программам, которые сводят большую задачу к набору меньших задач, уменьшая на каждом шаге размер задачи в некоторое постоянное количество раз. В интересующей нас области время выполнения можно считать небольшой константой. Основание логарифма изменяет константу, но не намного: когда $$N$$ - тысяча, $$ logN$$ равно 3, если основание равно 10, или порядка 10, если основание равно 2; когда $$N$$ равно миллиону, значения $$ logN$$ только удвоятся. При удвоении $$N$$ величина $$ logN$$ увеличивается на постоянную величину, а удваивается лишь тогда, когда $$N$$ достигает N^2.
    $$N$$ Когда время выполнения программы является линейным, это обычно значит, что каждый входной элемент подвергается небольшой обработке. Если $$N$$ равно миллиону, то время выполнения равно некоторой величине. Когда $$N$$ удваивается, то же происходит и со временем выполнения. Эта ситуация оптимальна для алгоритма, который должен обработать $$N$$ входных данных (или выдать $$N$$ выходных данных).
    $$NlogN$$ Время выполнения, пропорциональное $$N logN$$, возникает тогда, когда алгоритм решает задачу, разбивая ее на меньшие подзадачи, решая их независимо и затем объединяя решения. Из-за отсутствия подходящего прилагательного ("линерифмический"?) мы просто говорим, что время выполнения такого алгоритма равно $$N logN$$. Если $$N$$ равно 1 миллиону, $$N logN$$ примерно равно 20 миллионам. При удвоении $$N$$ время выполнения более чем (но не сильно) удваивается.
    $$N^2$$ Если время выполнения алгоритма является квадратичным, он полезен для практического использования для относительно небольших задач. Квадратичное время выполнения обычно появляется в алгоритмах, которые обрабатывают все пары элементов данных (возможно, в цикле двойного уровня вложенности). Когда $$N$$ равно 1 тысяче, время выполнения равно 1 миллиону. При удвоении $$N$$ время выполнения увеличивается вчетверо.
    $$ N^3$$ Аналогично, эта ситуация характерна для алгоритма, который обрабатывает тройки элементов данных (возможно, в цикле тройного уровня вложенности), имеет кубическое время выполнения и практически применим лишь для малых задач. Если $$N$$ равно 100, время выполнения равно 1 миллиону. При удвоении $$N$$ время выполнения увеличивается в восемь раз.
    $$ 2^N$$ Лишь несколько алгоритмов с экспоненциальным временем выполнения имеют практическое применение, хотя такие алгоритмы возникают естественным образом при попытках прямого решения задачи. Если $$N$$ равно 20, время выполнения равно 1 миллиону. При удвоении $$N$$ время выполнения возводится в квадрат!

    Время выполнения определенной программы обычно равно некоторой константе, умноженной на один из этих элементов (главный член) плюс меньшие слагаемые. Значения постоянного коэффициента и остальных слагаемых зависят от результатов анализа и деталей реализации. В первом приближении коэффициент при главном члене связан с количеством инструкций во внутреннем цикле: на любом уровне разработки алгоритма разумно сократить количество таких инструкций. Для больших $$N$$ доминирует эффект главного члена, для малых $$N$$ или для тщательно разработанных алгоритмов ощутимый вклад дают и другие слагаемые, поэтому сравнение алгоритмов затрудняется. В большинстве случаев мы будем называть время выполнения программ просто "линейным", "N logN", "кубическим" и т.д. Обоснование этого подробно приводится в разделе 2.4.

    В итоге, чтобы уменьшить общее время выполнения программы, мы минимизируем количество инструкций во внутреннем цикле. Каждую инструкцию необходимо тщательно рассмотреть и решить, является ли она необходимой. Существует ли более эффективный способ выполнить такую же задачу? Некоторые программисты считают, что средства автоматизации, содержащиеся в современных компиляторах, могут создавать наилучший машинный код; другие утверждают, что оптимальным способом является написание внутренних циклов вручную на машинном языке, или ассемблере. Обычно мы будем останавливаться, доходя до рассмотрения оптимизации на таком уровне, хотя иногда будем указывать, сколько машинных инструкций требуется для выполнения определенных операций. Это потребуется для того, чтобы понять, почему на практике одни алгоритмы могут оказаться быстрее других.

    Для малых задач время решения практически не зависит от метода - быстрый современный компьютер все равно выполнит задачу мгновенно. Но по мере увеличения размера задачи числа, с которыми мы имеем дело, становятся огромными, как продемонстрировано в таблица 2.1. Когда количество исполняемых инструкций в медленном алгоритме становится по-настоящему большим, время, необходимое для их выполнения, становится недостижимым даже для самых быстрых компьютеров. На приведен перевод большого количества секунд в дни, месяцы, годы и т.д.; в таблица 2.1">рис 2.2">таблица 2.1 приведен перевод большого количества секунд в дни, месяцы, годы и т.д.; в (рис 2.1) Перевод секунд

    Огромная разница между такими числами, как $$10^4$$ и $$10^8$$, становится более очевидной, если взять соответствующее количество секунд и перевести в привычные единицы измерения. Мы можем позволить программе выполняться 2,8 часа, но вряд ли мы будем созерцать программу, выполнение которой займет 3,1 года. Поскольку $$2^10$$ примерно равно $$10^3$$, этой таблицей можно воспользоваться и для перевода степеней 2. Например, $$2^{32}$$ секунд - это примерно 124 года.

    Значения часто встречающихся функций
    $$lgN$$ $$$\sqrt{N}$$$ N $$NlgN$$ $$ N(lgN)^2 $$ $$ N^{3/2}$$ $$N^2$$
    3 3 10 33 110 32 100
    7 10 100 664 4414 1000 10000
    10 32 1000 9966 99317 31623 1000000
    13 100 10000 132877 1765633 1000000 100000000
    17 316 100000 1660964 27588016 31622777 10000000000
    20 1000 1000000 19931569 397267426 1000000000 1000000000000

    В этой таблице показаны относительные величины некоторых функций, которые часто встречаются при анализе алгоритмов. Квадратичная функция очевидно доминирует, особенно для больших значений $$N$$, а различия между меньшими функциями оказываются не такими, как можно было ожидать для малых $$N$$. Например, $$N^{3/2}$$ должно быть больше, чем $$ Nlg^2 $$ для очень больших значений $$N$$, однако для малых $$N$$ наблюдается обратная ситуация. Точное время выполнения алгоритма может быть линейной комбинацией этих функций. Быстрые алгоритмы легко отличить от медленных из-за огромной разницы между, например, $$lgN$$ и $$N$$ или $$N$$ и $$N^2$$, но различие между двумя быстрыми алгоритмами может потребовать тщательного изучения.

    Время для решения гигантских задач
    Операций в секунду Размер задачи 1 миллион Размер задачи 1 миллиард
    N $$NlgN$$ $$ N^2$$ N $$NlgN$$ $$ N^2$$
    $$10^6$$ секунды секунды недели часы часы никогда
    $$10^9$$ мгновенно мгновенно часы секунды секунды десятилетия
    $$10^{12}$$ мгновенно мгновенно секунды мгновенно мгновенно недели

    Во многих случаях единственным шансом решить очень большую задачу является использование эффективного алгоритма. В этой таблице показано минимальное количество времени, необходимое для решения задач размером 1 миллион и 1 миллиард с использованием линейных, $$Nlog N$$ и квадратичных алгоритмов на компьютерах с быстродействием 1 миллион, 1 миллиард и 1 триллион инструкций в секунду. Быстрый алгоритм позволяет решить задачу на медленной машине, но быстрая машина бессильна при использовании медленного алгоритма.

    При анализе алгоритмов возникает еще несколько функций. Например, алгоритм с $$N^2$$ входными данными, имеющий время выполнения $$N^3$$, можно рассматривать, как $$N^{3/2}$$ алгоритм. Кроме того, некоторые алгоритмы разбиваются на подзадачи в два этапа и имеют время выполнения, пропорциональное $$Nlog^2$$. Из таблица 2.1 видно, что обе эти функции гораздо ближе к $$NlogN$$, чем $$N^2$$.

    Логарифмическая функция играет особую роль в разработке и анализе алгоритмов, поэтому ее стоит рассмотреть подробнее. Поскольку мы часто имеем дело с аналитическими результатами, которые различаются на постоянный множитель, мы используем запись "log N" без указания основания. Изменение основания логарифма меняет значение логарифма лишь на постоянный множитель, однако в определенных контекстах возникают особые значения основания логарифма.

    В математике настолько важным является натуральный логарифм (основание e = 2,71828...), что распространено следующее сокращение: $$$\log{_{e}N}\equiv\ln{N}$$$. В вычислительной технике очень важен двоичный логарифм (основание равно 2), поэтому часто используется сокращение $$$\log{_{2}N}\equiv\lg{N}$$$.

    Наименьшее целое число, большее $$lgN$$, равно количеству битов, необходимых для представления $$N$$ в двоичном формате; точно так же наименьшее целое, большее $$$\log{_{10}N}$$$, - это количество цифр, необходимое для представления $$N$$ в десятичном формате.

    Оператор С++

              for (lgN = 0; N > 0; lgN++, N /= 2) ;
          

    дает простой способ подсчета наименьшего целого, большего $$lgN$$. Вот аналогичный метод для вычисления этой функции:

            for (lgN = 0, t = 1; t < N; lgN++, t += t) ;
          

    В нем подчеркивается, что $$${2^{n}}\leq{N}<{2^{n+1}}$$$, когда $$n$$ - это наименьшее целое, большее $$lgN$$.

    Иногда бывает нужно вычислить логарифм логарифма, обычно для больших чисел. Например, $$ lglg2^{256} = lg 256 = 8 $$. Как видно из данного примера, обычно для практических целей выражение $$loglog N$$ можно считать константой, поскольку оно мало даже для очень больших $$N$$.

    Кроме того, часто мы сталкиваемся с некоторыми специальными функциями и математическими обозначениями из классического анализа, которые удобны для краткого описания свойств программ. В таблица 2.3 приведены наиболее используемые из этих функций; ниже мы кратко обсудим их и некоторые наиболее важные их свойства.

    Специальные функции и постоянные
    Функция Название Пример Приближение
    $$$\lfloor{x}\rfloor$$$ округление до меньшего $$$\lfloor{3,14}\rfloor$$$= 3 x
    $$$\lceil{x}\rceil$$$ округление до большего $$$\lceil{3,14}\rceil$$$= 4 x
    $$lg N$$ двоичный логарифм $$lg1024 =10$$ $$1,44lnN$$
    $$F^n$$ числа Фибоначчи $$ F^10 = 55 $$ $$$\phi^{N}/\sqrt{5}$$$
    $$H^N$$ гармонические числа $$ $H_{10}\approx2,9$$$ $$$\ln{N}+\gamma$$$
    N! факториал $$10! = 3628800$$ $$ (N/e)^N $$
    $$lg (N!)$$ $$$\lg{100!}\approx520$$$ $$ N lgN - 1,44 N $$

    $$e = 2,71828...$$

    $$$\gamma = 0,57721...$$$

    $$$\phi=(1+\sqrt{5})/2=1,61803...$$$

    $$ln2 = 0,693147...$$

    $$ lge = 1/ln 2 = 1,44269...$$

    В этой таблице собраны математические обозначения функций и постоянных, которые часто появляются в формулах, описывающих производительность алгоритмов. Формулы для приближенных значений можно при необходимости уточнить, учтя следующие слагаемые разложения (см. раздел ссылок).

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

    $$$\lfloor{x}\rfloor$$$: наибольшее целое, меньшее или равное x

    $$$\lceil{x}\rceil$$$: наименьшее целое, большее или равное х.

    Например, $$$\lfloor{\pi}\rfloor$$$и $$$\lceil{e}\rceil$$$оба равны 3, а $$$\lceil{x}\rceil$$$ - это количество битов, необходимое для двоичного представления числа $$N$$. Другое важное применение этих функций возникает в том случае, когда необходимо поделить множество $$N$$ объектов пополам. Этого нельзя сделать точно, если $$N$$ является нечетным, поэтому для точности мы можем создать одно подмножество, содержащее $$$\lfloor{N/2}\rfloor$$$объектов, а второе - $$$\lceil{N/2}\rceil$$$объектов. Если $$N$$ четно, тогда размеры обоих поднаборов равны ( $$$\lfloor{N/2}\rfloor$$$= $$$\lceil{N/2}\rceil$$$); если же $$N$$ нечетно, то их размер отличается на единицу ($$$\lfloor{N/2}\rfloor + 1$$$ = $$$\lceil{N/2}\rceil$$$). В С++ можно напрямую подсчитать значения этих функций при выполнении операций над целыми числами (например, если $N\geq0$N, тогда $$ N/2$$ равно $$$\lfloor{\pi}\rfloor$$$ а $$N-(N/2)$$ равно $$$\lceil{x}\rceil$$$), а при операциях над числами с плавающей точкой можно воспользоваться функциями floor и ceil из заголовочного файла math.h.

    При анализе алгоритмов часто возникает дискретизированная версия функции натурального логарифма, называемая гармоническими числами. N-е гармоническое число определяется выражением

    $$$H_{N}=1+\dfrac{1}{2}+\dfrac{1}{3}+...+\dfrac{1}{N}$$$

    Натуральный логарифм $$ln N$$ - это значение площади под кривой $$1/х$$ между 1 и $$N$$ ; гармоническое число H_N - это площадь под ступенчатой функцией, которую можно определить, вычисляя значения функции $$1/х$$ для целых чисел от 1 до $$N$$. Эта зависимость показана на рис 2.2.

    (рис 2.2) Гармонические числа

    Гармонические числа представляют собой приближенные значения площади под кривой 1/х. Постоянная y учитывает разницу между H_N и $$$\ln{N}=\int_{1}^{N}{dx/x}$$$

    Формула

    $$ $H_{N}\approx \ln{N}+\gamma +1/(12N)$$$ где $$$\gamma = 0,57721... $$$ (эта константа называется постоянной Эйлера), дает отличное приближение для H_N. В отличие от $$$\lceil{lgN}\rceil$$$и $$$\lfloor{lgN}\rfloor$$$ для вычисления H_N лучше воспользоваться библиотечной функцией log, а не подсчитывать его непосредственно из определения.

    Последовательность чисел

    $$ 0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 ...$$ определяемая формулой $$$F_{N}=F_{N-1}+F_{N-2}$$$ где $$$N\geq2$$$ а $$F_0 = 0$$ и $$F_1 = 1$$ известна как числа Фибоначчи и имеет множество интересных свойств. Например, отношение двух последовательных чисел приближенно равно золотому сечению (golden ratio) $$$\phi=(1+\sqrt{5}/2 \approx 1,61803...$$$ Более подробный анализ показывает, что $$F_N$$ равно значению выражения $$$\phi^{N}/\sqrt{5}$$$ округленному до ближайшего целого числа.

    При анализе алгоритмов часто встречается также функция факториал $$N!$$. Как и экспоненциальная функция, факториал возникает при лобовом решении задач и растет слишком быстро, чтобы такие решения представляли практический интерес. Он также возникает при анализе алгоритмов, поскольку представляет собой количество способов упорядочения $$N$$ объектов.

    Для аппроксимации $$N!$$ используется формула Стирлинга:

    $$ ${\lg{N}!}\approx{N\lg{N}}-N\lg{e}+\lg{\sqrt{2\pi N}}$$$.

    Например, из формулы Стирлинга следует, что количество битов в представлении числа $$N!$$ примерно равно $$NlgN$$.

    Большинство рассматриваемых в этой книге формул выражается через несколько функций, рассмотренных в этой главе. Однако при анализе алгоритмов может встретиться множество других специальных функций.

    Например, классическое биномиальное распределение и распределение Пуассона играют важную роль при разработке и анализе некоторых фундаментальных поисковых алгоритмов, которые будут рассмотрены в и . Функции, не приведенные здесь, обсуждаются по мере их появления.

    Упражнения

    $$$\triangleright$$$ 2.5. Для каких значений $$N$$ справедливо $$ 10NlgN> 2N^2 $$ ?

    $$$\triangleright$$$ 2.6. Для каких значений $$N$$ выражение $$N^{3/2}$$ имеет значение в пределах от $$ N(lgN)^2/2 $$ до $$ 2N(lgN)^2 $$ ?

  • 2.7. Для каких значений $$N$$ справедливо $$ 2NH_n - N < N lgN + 10N $$ ?
  • 2.8. Для какого наименьшего значения $$N$$ справедливо $$ log_10log_10 >8 $$ ?
  • 2.9. Докажите, что $$$\lfloor{\lg{N}}\rfloor$$$+ 1 - это количество битов, необходимое для представления числа $$N$$ в двоичной форме.
  • 2.10. Добавьте в таблица 2.2 столбцы для $$ N(lgN)^2 и N^{3/2} $$.
  • 2.11. Добавьте в таблица 2.2 строки для $$10^7$$ и $$10^8$$ инструкций в секунду.
  • 2.12. Напишите на С++ функцию, которая подсчитывает $$H^N$$, используя функцию $$log$$ из стандартной математической библиотеки.
  • 2.13. Напишите эффективную функцию на С++, подсчитывающую $$$\lceil{\lg{\lg{N}}}\rceil$$$ Не используйте библиотечную функцию.
  • 2.14. Сколько цифр в десятичном представлении числа 1 миллион факториал?
  • 2.15. Сколько битов в двоичном представлении числа $$lg(N!)$$ ?
  • 2.16. Сколько битов в двоичном представлении $$H^N$$ ?
  • 2.17. Приведите простое выражение для $$$\lfloor{\lg{F_{N}}}\rfloor$$$.
  • 2.18. Приведите наименьшие значения $$N$$, для которых $$$\lfloor{H_{N}}\rfloor$LHNJ = i, где ${1}\leq{i}\leq{10}$$$
  • 2.19. Приведите наибольшее значение $$N$$, для которого можно решить задачу, требующую выполнения f(N) инструкций, на машине с быстродействием $$10^9$$ операций в секунду для следующих функций $$f(N): N^{3/2}, N^{5/4}$$, $$ 2NH^N $$, $$NlgNlglgN$$ и $$ N^2lgN $$.
  • О-нотация

    Математическая запись, позволяющая отбрасывать детали при анализе алгоритмов, называется О-нотацией. Она определена следующим образом.

    Определение 2.1. Говорят, что функция $$g(N)$$ имеет порядок $$O(f(N))$$, если существуют такие постоянные $$с_0$$ и $$N_0$$, что $$ g (N) < c_0 f (N) $$ для всех $$ N > N_0 $$.

    О-нотация используется по трем различным причинам:

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

    Постоянные $$с_0$$ и $$N_0$$, не выраженные явно в О-нотации, часто скрывают практически важные подробности реализации. Очевидно, что выражение "алгоритм имеет время выполнения $$O(f (N))$$ " ничего не говорит о времени выполнения при $$N$$, меньшем $$N_0$$, а $$с_0$$ может иметь большое значение, необходимое для работы в наихудшем случае. Понятно, что лучше иметь алгоритм, время выполнения которого составляет $$N^2$$ наносекунд, а не $$log N$$ столетий, но мы не можем сделать такой выбор на основе О-нотации.

    Часто результаты математического анализа являются не точными, а приближенными именно в техническом смысле: результат представляет собой сумму убывающих слагаемых. Так же как мы интересуемся в основном внутренним циклом программы, нас интересует больше всего главные члены (наибольшие по величине слагаемые) математического выражения. О-нотация позволяет работать с приближенными математическими выражениями, рассматривая главные члены и опуская меньшие слагаемые, а также записывать краткие выражения, дающие неплохие приближения для анализируемых величин.

    Некоторые из основных действий, которые используются при работе с выражениями, содержащими О-нотацию, являются предметом упражнений 2.20 - 2.25. Многие из этих действий интуитивно понятны, а склонные к математике читатели могут с интересом выполнить упражнение 2.21, где требуется доказать верность базовых операций, исходя из определения. По сути, из этих упражнений следует, что в алгебраических выражениях с О-нотацией можно раскрывать скобки так, как будто ее там нет, а затем отбрасывать все слагаемые, кроме наибольшего. Например, если требуется раскрыть скобки в выражении

    $$(N + O (1))(N + O (log N) + O (1))$$, то мы получим шесть слагаемых

    $$ N^2 + O (N) + O (N log N) + O (log N) + O (N) + O (1) $$.

    Однако можно отбросить все О-слагаемые, кроме наибольшего из них, и тогда останется приближенное выражение

    $$ N^2 + O (N log N) $$.

    То есть при больших $$N$$ хорошей аппроксимацией этого выражения является N^2. Эти действия интуитивно ясны, но О-нотация позволяет выразить их с математической точностью. Формула с одним О-слагаемым называется асимптотическим выражением (asymptotic expression).

    В качестве более конкретного примера предположим, что (после некоторого математического анализа) мы выяснили, что определенный алгоритм имеет внутренний цикл, выполняемый в среднем $$ NH_N $$ раз, внешний раздел, выполняемый $$N$$ раз, и некоторый код инициализации, исполняемый однократно. Далее предположим, что (после тщательного исследования реализации) мы определили, что каждая итерация внутреннего цикла требует $$а_0$$ наносекунд, внешний раздел - $$а_1$$ наносекунд, а код инициализации - $$а_2$$ наносекунд. Тогда среднее время выполнения программы (в наносекундах) равно $$ 2а_0 N H_N + a_1 + а_2 $$.

    Поэтому для времени выполнения справедлива следующая формула: $$ 2а_0 NH_N + O(N) $$.

    Эта более простая формула важна, поскольку из нее следует, что для аппроксимации времени выполнения при больших $$N$$ нет необходимости искать значения величин $$ а_1$$ и $$а_$$2 . В общем случае, в точном математическом выражении для времени выполнения может содержаться множество других слагаемых, ряд которых трудно анализировать. О-нотация обеспечивает способ получения приближенного ответа для больших $$N$$, не заботясь о подобных слагаемых.

    Далее, О-нотация позволяет в данном примере выразить время выполнения через более знакомую функцию $$lnN$$. С помощью таблица 2.3 полученное выражение можно приближенно записать как $$ H_N = lnN + O(1) $$. Таким образом, асимптотическое выражение для общего времени выполнения алгоритма имеет вид $$ 2a_0lnN + O(N) $$. То есть при больших $$N$$ оно будет близко к легко вычисляемому выражению $$ 2a_0lnN $$. Постоянный множитель $$а0$$ зависит от времени выполнения инструкций внутреннего цикла.

    Более того, нам не нужно знать значения $$a0$$, чтобы предсказать, что при больших $$N$$ время выполнения для входных данных размером $$2N$$ будет вдвое больше, чем для входных данных размером $$N$$, поскольку $$$\dfrac{2a_{0}(2N)\ln{#2N#}+O(2N)}{2a_{0}N\ln{N}+O(N)}=\dfrac{2\ln{(2N)}+O(1)}{\ln{N}+O(1)}=2+O\left(\dfrac{1}{\log{N}}\right)$.$$

    Таким образом, асимптотическая формула позволяет нам делать точные прогнозы, не вдаваясь в подробности реализации или анализа. Отметьте, что такое предсказание не было бы возможным, если бы О-аппроксимация была задана только для главного члена.

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

    Когда функция $$f (N)$$ асимптотически велика по сравнению с другой функцией $$g (N)$$ (т.е. $$$g(N)/f(N)\rightarrow0$$$ при $$$N\rightarrow\infty$$$), иногда в данной книге мы будем использовать термин (конечно, неточный) порядка $$f(N)$$, что означает $$f (N) + O(g(N))$$. Потеря математической точности компенсируется большей наглядностью, так как нас больше интересует производительность алгоритмов, а не математические детали. В таких случаях мы можем быть уверены в том, что при больших (а, может, даже и всех) значениях $$N$$ исследуемая величина будет близка к $$f(N)$$. Например, даже если мы знаем, что некоторая величина равна $$N (N - 1) / 2$$, ее можно рассматривать как $$ N^2/ 2 $$. Такой способ выражения результатов более понятен, чем подробный и точный результат, и, к примеру, при $$N= 1000$$ отличается от правильного значения всего лишь на 0,1%. Потеря точности в данном случае намного меньше, чем при распространенном использовании $$O(f (N))$$. При описании производительности алгоритмов мы будем по возможности стараться быть и точными, и краткими.

    В похожем ключе мы иногда говорим, что время выполнения алгоритма пропорционально $$f (N)$$, т.е. можно доказать, что оно равно с $$f (N) + g(N), где g(N)$$ асимптотически мало по сравнению с $$f (N)$$. При таком подходе можно предсказать время выполнения для $$2N$$, если оно известно для $$N$$, как в рассмотренном выше примере. На рис. 2.3 рис 2.3 приводятся значения множителей для таких прогнозов поведения функций, которые часто возникают при анализе алгоритмов. В сочетании с эмпирическим изучением (см. раздел 2.1) данный подход освобождает от определения постоянных величин, зависящих от реализации. Или же, применяя его в обратном направлении, зачастую мы можем выдвинуть гипотезу о функциональной зависимости времени выполнения программы, изучив, как меняется время выполнения при удвоении $$N$$.

    Различия между О-оценками пропорционально (is proportional to) и порядка (about) проиллюстрированы на рис 2.4 и рис 2.5. О-нотация используется, прежде всего, для исследования фундаментального асимптотического поведения алгоритма; пропорционально требуется при экстраполяции производительности на основе эмпирического изучения, а порядка - при сравнении производительности или при предсказании абсолютной производительности.

    (рис 2.3) Влияние удвоения размеров задачи на время выполнения

    Нетрудно предсказать влияние удвоения размеров задачи на время выполнения, если время выполнения пропорционально одной из простых функций, указанных в таблице. Теоретически это влияние можно вычислить только для больших $$N$$, но данный метод на удивление эффективен. И наоборот, быстрый метод определения функционального роста времени выполнения программы заключается в запуске программы, удвоении объема входных данных для максимально возможного $$N$$, а затем оценка функциональной зависимости согласно приведенной таблице.

    (рис 2.4) Ограничение функции с помощью О-аппроксимации

    На этой схематической диаграмме осциллирующая кривая представляет собой функцию $$g(N)$$, которую мы пытаемся аппроксимировать; плавная черная кривая представляет собой другую функцию, $$f(N)$$, которая используется для аппроксимации, а плавная серая кривая является функцией $$cf(N)$$ с некоторой неопределенной постоянной $$c$$. Вертикальная прямая задает значение $$N0$$, указывающее, что аппроксимация справедлива для $$ N > N_0 $$. Когда мы говорим, что $$g(N) = O(f(N))$$, мы лишь ожидаем, что значение функции $$g(N)$$ находится ниже некоторой кривой, имеющей форму функции $$f(N)$$, и правее некоторой вертикальной прямой. Поведение функции $$f(N)$$ может быть любым (например, она не обязательно должна быть непрерывной).

    (рис 2.5) Аппроксимация функций

    Когда говорят, что функция $$g(N)$$ пропорциональна функции $$f(N)$$ (верхний график), то подразумевают, что она растет как $$f(N)$$, но, возможно, смещена относительно последней на неизвестный коэффициент. Если задано некоторое значение $$g(N)$$, можно предсказать поведение функции при больших $$N$$. Когда говорят, что $$g(N)$$ порядка $$f(N)$$ (нижний график), то подразумевают, что функцию $$f$$ можно использовать для достаточно точной оценки значений функции g.

    Упражнения

    $$$\triangleright$$$ 2.20. Докажите, что О(1) - то же самое, что и О(2).

    2.21. Докажите, что в выражениях с О-нотацией можно выполнить любое из перечисленных преобразований:

    $$ \begin{align*} f(N)\rightarrow O(f(N))\\ cO(f(N))\rightarrow O(f(N))\\ O(cf(N))\rightarrow O(f(N))\\ f(N)-g(N)=O(h(N))\rightarrow f(N)=g(N)+O(h(N))\\ O(f(N))O(g(N))\rightarrow O(f(N)g(N))\\ O(f(N))+O(g(N))\rightarrow O(g(N))\\ \end{align*} $$

  • 2.22. Покажите, что (N + 1)(H_N + O(1)) = N lnN + O(N).
  • 2.23. Покажите, что $$ N lnN = O(N^{3/2}) $$.
  • 2.24. Покажите, что $$$N^{M}=O(\alpha^{N})$$$ для любого $$M$$ и любого постоянного $$$\alpha >1$$$.
  • 2.25. Докажите, что $$$\dfrac{N}{N+O(1)}=1+O\left(\dfrac{1}{N} \right)$$$
  • 2.26. Предположим, что $$ H_k = N $$. Найдите приближенную формулу, которая выражает $$k$$ как функцию $$N$$.
  • 2.27. Предположим, что $$lg(k!) = N$$. Найдите приближенную формулу, которая выражает k как функцию $$N$$.
  • 2.28. Известно, что время выполнения одного алгоритма равно $$O(N logN)$$, а другого - $$ O(N^3) $$. Что это неявно говорит об относительной производительности алгоритмов?
  • 2.29. Известно, что время выполнения одного алгоритма всегда порядка $$NlogN$$, а другого - $$ O(N^3) $$. Что это неявно говорит об относительной производительности алгоритмов?
  • 2.30. Известно, что время выполнения одного алгоритма всегда порядка $$NlogN$$, а другого - всегда $$N^3$$. Что это неявно говорит об относительной производительности алгоритмов?
  • 2.31. Известно, что время выполнения одного алгоритма всегда пропорционально $$N logN$$, а другого - всегда пропорционально $$N^3$$. Что это неявно говорит об относительной производительности алгоритмов?
  • 2.32. Выведите значения множителей, приведенных на рис 2.3: для каждой функции $$f (N)$$, показанной слева, найдите асимптотическую формулу для $$f (2N) / f (N)$$.
  • Простейшие рекурсии

    Как мы увидим далее в этой книге, многие алгоритмы основаны на принципе рекурсивного разбиения большой задачи на меньшие, а решения подзадач используются для решения исходной задачи. Эта тема подробно обсуждается в , в основном с практической точки зрения, где основное внимание уделено различным реализациям и приложениям. Кроме того, в разделе 2.6 приводится подробный пример. А в этом разделе будут рассмотрены простейшие методы анализа таких алгоритмов и вывод решений нескольких стандартных формул, которые возникают при анализе многих изучаемых алгоритмов. Понимание математических свойств формул в этом разделе дает необходимое понимание свойств производительности алгоритмов.

    Рекурсивное разбиение алгоритма напрямую проявляется в его анализе. Например, время выполнения подобных алгоритмов определяется величиной и количеством подзадач, а также временем, необходимым для разбиения задачи. Математически зависимость времени выполнения алгоритма для $$N$$ входных данных от времени выполнения при меньшем количестве данных легко определить с помощью рекуррентных соотношений. Такие формулы точно описывают производительность алгоритмов, и для вычисления времени выполнения необходимо решить эти уравнения. Более строгие рассуждения, связанные со спецификой алгоритмов, встретятся при их непосредственном рассмотрении; здесь же внимание уделяется только формулам.

    Формула 2.1. В рекурсивной программе, где при каждой итерации цикла количество входных данных уменьшается на единицу, возникает следующее рекуррентное соотношение:

    $$ C_N = C_N-1 + N $$, где $$$N\geq2$$$ и $$ C_1 = 1 $$.

    Решение: $$C_N$$ имеет порядок $$ N^2/ 2 $$. Для решения рекуррентного уравнения его можно развернуть, применяя само к себе следующим образом: $$ \begin{equation*} C_{N}=C_{N-1}+N\\ =C_{N-2}+(N-1)+N\\ =C_{N-3}+(N-2)+(N-1)+N\\ \vdots \end{equation*} $$ Продолжая таким же образом, можно получить $$ \begin{equation*} C_{N}=C_{1}+2+\ldots+(N-2)+(N-1)+N\\ =1+2+\ldots+(N-2)+(N-1)+N\\ =\dfrac{N(N+1)}{2} \end{equation*} $$

    Подсчет суммы $$1 + 2 + ... + (N - 2) + (N - 1) + N$$ элементарен: прибавим к сумме ее же, но в обратном порядке. Результирующая сумма - удвоенный искомый результат - будет состоять из $$N$$ слагаемых, каждое из которых равно $$N + 1$$.

    Формула 2.2. В рекурсивной программе, где на каждом шаге количество вводов уменьшается вдвое, возникает следующее рекуррентное соотношение: $$$$C_{N}=C_{N/2}+1{, где }N\geq2{ и }C_{1}=1$$$$

    Решение: CN имеет порядок $$lg N$$. Это уравнение бессмысленно, если $$N$$ нечетно, или же нужно предположить, что $$ N/2$$ - целочисленное деление. Чтобы рекурсия была всегда определена, предположим, что $$ N= 2^n $$. (Отсюда $$п = lg N$$.) Тогда развернуть рекурсию еще проще, чем в предыдущем случае: $$ \begin{equation*} C_{2^{n}}=C_{2^{n-1}}+1\\ =C_{2^{n-2}}+1+1\\ =C_{2^{n-3}}+3\\ \vdots\\ =C_{2^{n}}+n\\ =n+1. \end{equation*} $$

    Точное решение для произвольного $$N$$ зависит от интерпретации $$ N/2$$. Если $$ N/2$$ представляет собой $$$\lfloor N/2\rfloor$$$, то существует очень простое решение: CN - это количество битов в двоичном представлении числа $$N$$, т.е. по определению $$$\lfloor lgN\rfloor+1$$$. Этот вывод немедленно следует из того, что операция отбрасывания правого бита в двоичном представлении любого числа $$N > 0$$ превращает его в $$$\lfloor N/2\rfloor$$$ (см. рис 2.6).

    (рис 2.6) Целочисленные функции и двоичные представления

    Для заданного двоичного представления числа $$N$$ (в центре) отбрасывание правого бита дает $$$\lfloor N/2\rfloor$$$. То есть количество битов в двоичном представлении числа $$N$$ на единицу больше, чем в представлении числа $$$\lfloor N/2\rfloor$$$. Поэтому количество битов в двоичном представлении числа - $$$N\lfloor N/2\rfloor+1$$$ - является решением формулы 2.2 в случае интерпретации N/2 как $$$\lfloor N/2\rfloor$$$.

    Формула 2.3. В рекурсивной программе, где объем входных данных уменьшается вдвое, но необходимо проверить каждый элемент, возникает следующее рекуррентное соотношение: $$$$C_{N}=C_{N/2}+N{, где }N\geq2{ и }C_{1}=0$$$$.

    Решение: CN имеет порядок 2N. Рекурсия развертывается в сумму:

    $$N + N/2 + N/4 + N/8 + ...$$ .

    (Как и формуле 2.2, рекуррентное соотношение определено точно только в том случае, если $$N$$ является степенью числа 2). Для бесконечной последовательности сумма простой геометрической прогрессии равна в точности 2N. Однако поскольку используется целочисленное деление, и мы останавливаемся на 1, это значение является приближением к точному ответу. В точном решении используются свойства двоичного представления числа $$N$$.

    Формула 2.4. В рекурсивной программе, которая должна выполнить линейный проход по входным данным до, в течение или после разбиения их на две половины, возникает следующее рекуррентное соотношение: $$$$C_{N}=2C_{N/2}+N{, где }N\geq2{ и }C_{1}=0$$$$.

    Решение: CN имеет порядок $$NlgN$$. Это решение применяется намного чаще, чем остальные из приведенных здесь, поскольку эта рекурсия используется в целом семействе алгоритмов "разделяй и властвуй". $$ \begin{equation*} C_{2^{n}}=2C_{2^{n-1}}+2^{n}\\ \dfrac{C_{2^{n}}}{2^{n}}=\dfrac{C_{2^{n-1}}}{2^{n-1}}+1\\ =\dfrac{C_{2^{n-2}}}{2^{n-2}}+1+1\\ \vdots\\ =n. \end{equation*} $$

    Решение находится почти так же, как это было сделано в формуле 2.2, но с дополнительным приемом на втором шаге - делением обеих частей равенства на $$2^n$$, который позволяет развернуть рекурсию.

    ) возникает следующая рекурсия. $$$$C_{N}=2C_{N/2}+1{, где }N\geq2{ и }C_{1}=1$$$$.

    Решение: C_N имеет порядок $$2N$$. Это решение можно получить так же, как и решение формулы 2.4.

    С помощью приведенных методов можно решить различные варианты этих уравнений, имеющие другие начальные условия или небольшие отличия в добавочном члене. Однако необходимо помнить, что некоторые, на первый взгляд похожие, рекурсии могут иметь гораздо более сложные решения. Существует множество дополнительных общих методов для математически строгого решения таких уравнений (см. раздел ссылок). Несколько сложных рекуррентных соотношений встретятся в последующих главах, где и будут приведены их решения.

    Упражнения

  • 2.33. Составьте таблицу значений $$C_N$$, заданных формулой 2.2 для $$$1\leq N\leq32$$$, считая, что $$ N/2$$ означает $$$\lfloor N/2\rfloor$$$.
  • 2.34. Выполните упражнение 2.33, но считая, что $$ N/2$$ означает $$$\lceil N/2\rceil$$$.
  • 2.35. Выполните упражнение 2.34 для формулы 2.3.
  • 2.36. Предположим, что $$f_N$$ пропорционально постоянной величине и что $$$C_{N}=C_{N/2}+f_{N}{ при }N\geq t{ и }0\leq C_{N}<c{ при }N<t$$$, где $$с$$ и $$t$$ - постоянные. Покажите, что $$C_N$$ пропорционально $$lgN$$.
  • 2.37. Сформулируйте и докажите обобщенные версии формул 2.3 - 2.5, аналогичные обобщенной версии формулы 2.2 в упражнении 2.36.
  • 2.38. Составьте таблицу значений $$C_N$$, заданных формулой 2.4 при $$$1\leq N\leq32$$$ для трех следующих случаев: (1) $$ N/2$$ означает $$$\lfloor N/2\rfloor$$$, (2) $$ N/2$$ означает $$$\lceil N/2\rceil$$$, (3) 2C_N/2 равно $$$C_{\lfloor N/2\rfloor}+C_{\lceil N/2\rceil}$$$
  • 2.39. Решите уравнение 2.4 для случая, когда $$ N/2$$ означает $$$\lfloor N/2\rfloor$$$, используя соответствие двоичному представлению числа $$N$$, как это было сделано в доказательстве формулы 2.2. Подсказка: Рассмотрите все числа, меньшие $$N$$.
  • 2.40. Решите рекуррентное уравнение $$$C_{N}=C_{N/2}+N^{2}$$$ при $$$N\geq 2{\ и\ }C_{1}=0$$$, если $$N$$ является степенью числа 2.
  • 2.41. Решите рекуррентное уравнение $$$C_{N}=C_{N/\alpha}+1$$$ при $$$N\geq 2{\ и\ }C_{1}=0$$$, если $$N$$ является степенью числа а.
  • 2.42. Решите рекуррентное уравнение $$$C_{N}=\alpha C_{N/2}+N^{2}$$$ при $$$N\geq 2{\ и\ }C_{1}=1$$$, если $$N$$ является степенью числа 2.
  • 2.43. Решите рекуррентное уравнение $$$C_{N}=(C_{N/2})^{2}+N^{2}$$$ при $$$N\geq 2{\ и\ }C_{1}=1$$$, если $$N$$ является степенью числа 2.
  • 2.44. Решите рекуррентное уравнение $$$C_{N}=\left(2+\dfrac{1}{\lg{N}}\right)C_{N/2}$$$ при $$$N\geq 2{\ и\ }C_{1}=1$$$, если $$N$$ является степенью числа 2.
  • 2.45. Рассмотрите семейство рекурсий наподобие формулы 2.1, где $$ N/2$$ может означать $$$\lfloor N/2\rfloor$$$ или $$$\lceil N/2\rceil$$$, с единственным требованием: рекурсия выполняется при $$ N > с_0 $$, а при $$$N\leq c_{0}$$$ имеет место $$ C_N = O(1) $$. Докажите, что решением всех таких рекурсий является формула $$lgN + O(1)$$.
  • 2.46. Выведите обобщенные рекурсии и их решения, как в упражнении 2.45, для формул 2.2-2.5.
  • Примеры анализа алгоритмов

    Вооружившись инструментами, о которых было рассказано в трех предыдущих разделах, мы рассмотрим анализ последовательного поиска и бинарного поиска - двух основных алгоритмов для определения того, входит ли некоторая последовательность объектов в заданное множество объектов. Наша цель - показать, как можно сравнивать алгоритмы, а не подробно описать сами алгоритмы. Для простоты предположим, что все рассматриваемые объекты являются целыми числами. Более общие приложения будут подробно рассмотрены в лекциях 12 - 16 . Простые версии алгоритмов, которые мы сейчас рассмотрим, не только демонстрируют многие аспекты задачи их разработки и анализа, но и имеют практическую ценность.

    Например, представим себе компанию, обрабатывающую кредитные карточки и имеющую $$N$$ рискованных или украденных кредитных карточек. При этом компании необходимо проверять, нет ли среди $$M$$ транзакций какого-либо из этих $$N$$ плохих номеров. Для большей конкретности будем считать $$N$$ большим (скажем, порядка $$10^3$$ - $$10^6$$), а $$M$$ - огромным (порядка $$10^6$$ - $$10^9$$). Цель анализа заключается в приблизительной оценке времен выполнения алгоритмов, когда параметры принимают значения из указанного диапазона.

    В программе 2.1 реализовано прямое решение задачи поиска. Для совместимости с другими вариантами кода для этой задачи, которые мы исследуем в части 4, она оформлена как функция С++, обрабатывающая массив (см. ). Однако необязательно вдаваться в детали программы для понимания алгоритма: мы сохраняем все объекты в массиве, затем для каждой транзакции мы последовательно просматриваем массив от начала до конца, проверяя, содержится ли в нем искомый номер.

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

    Поэтому время выполнения зависит от данных. Если бы все поиски выполнялись для чисел, которые находятся в первой позиции, то алгоритм был бы быстрым; если бы они выполнялись для чисел, находящихся в последней позиции, тогда алгоритм был бы медленным. В разделе 2.7 мы обсудим разницу между возможностью гарантировать производительность и предсказать производительность. В данном случае лучшее, что мы можем гарантировать - это что будет просмотрено не более $$N$$ чисел.

    Однако чтобы сделать прогноз, необходимо какое-то предположение о данных. В данном случае предположим, что все числа выбраны случайным образом.

    Программа 2.1. Последовательный поиск

    Данная функция проверяет, находится ли число v среди элементов массива a[l] , a[l+1], ..., a[r], путем последовательного сравнения с каждым элементом, начиная с начала. Если по достижении последнего элемента нужное значение не найдено, функция возвращает значение -1. Иначе она возвращает индекс элемента массива, содержащего искомое число.

    int search(int a[], int v, int l, int r) {
      for (int i = l; i <= r; i++)
        if (v == a[i]) return i;
      return -1;
    }
          

    Из этого предположения следует, например, что предметом поиска с одинаковой вероятностью может оказаться каждое число в таблице. После некоторых размышлений мы приходим к выводу, что именно это свойство поиска является наиболее важным, потому что среди случайно выбранных чисел вряд ли найдется нужное нам (см. упражнение 2.48). В некоторых приложениях количество транзакций с успешным поиском может быть высоким, а в других - низким. Чтобы не запутывать модель свойствами приложения, мы разобьем задачу на два случая (успешный и неудачный поиск) и проанализируем их независимо. Данный пример иллюстрирует, что важным моментом эффективного анализа является разработка разумной модели приложения. Наши аналитические результаты будут зависеть от доли успешных поисков; более того, они обеспечат нас информацией, необходимой для выбора различных алгоритмов для разных приложений на основании этого параметра.

    Лемма 2.1. Последовательный поиск проверяет $$N$$ чисел при каждом неудачном поиске и в среднем порядка N/ 2 чисел при каждом успешном поиске.

    Если объектом поиска с равной вероятностью может быть любое число в таблице, то средняя стоимость поиска равна (1 + 2 + ... + N) / N = (N + 1)/ 2. $$$\blacksquare$$$

    Из леммы 2.1 следует, что время выполнения программы 2.1 пропорционально $$N$$, если средняя стоимость сравнения двух чисел постоянна. Значит, к примеру, можно ожидать, что если удвоить количество объектов, то и время, необходимое для поиска, также удвоится.

    Последовательный поиск в случае неудачи можно ускорить, если упорядочить числа в таблице. Сортировка чисел в таблице является предметом рассмотрения глав 6-11. Несколько алгоритмов, которые мы рассмотрим, выполняют эту задачу за время, пропорциональное $$N logN$$, которое незначительно по сравнению со стоимостью поиска при очень больших M. В упорядоченной таблице можно прервать поиск сразу по достижении числа, большего, чем искомое. Такое изменение уменьшает стоимость последовательного поиска до N/ 2 чисел, которые необходимо в среднем проверить при неудачном поиске, что совпадает с затратами для успешного поиска.

    Лемма 2.2. Алгоритм последовательного поиска в упорядоченной таблице проверяет $$N$$ чисел для каждого поиска в худшем случае и порядка $$N/ 2$$ чисел в среднем.

    Здесь все еще необходимо определить модель неудачного поиска. Этот результат следует из предположения, что поиск может с равной вероятностью закончиться на любом из $$N + 1$$ интервалов, задаваемых $$N$$ числами таблицы, а это непосредственно приводит к выражению $$(1 + 2 + ... + N + N )/N = (N + 3)/ 2$$.

    Стоимость неудачного поиска, который заканчивается до или после N-ой записи в таблице, такая же: $$N$$. $$$\blacksquare$$$

    Другой способ выразить результат леммы 2.2 - это сказать, что время выполнения последовательного поиска пропорционально $$MN$$ для $$M$$ транзакций и в среднем, и в худшем случае. Если удвоить или количество транзакций, или количество объектов в таблице, то время выполнения удвоится; если мы удвоим обе величины одновременно, то время выполнения увеличится в 4 раза. Этот результат говорит о том, что данный метод не годится для очень больших таблиц. Если для проверки одного числа требуется c микросекунд, а $$ M= 10^9 $$ и $$ N= 10^6 $$, то время выполнения для всех транзакций будет равно, по крайней мере, $$ (с / 2)10^9 $$ секунд, или, согласно рис 2.1, около 16c лет, что недопустимо.

    Программа 2.2. Бинарный поиск

    Эта программа делает то же самое, что и программа 2.1, но гораздо эффективнее.

    int search(int a[], int v, int l, int r) {
      while (r >= l) {
        int m = (l+r)/2;
        if ( v == a[ m] ) return m;
        if (v < a[m]) r = m-1; else l = m+1;
      }
      return -1;
    }
          

    Программа 2.2 представляет собой классическое решение задачи поиска методом, гораздо более эффективным, чем последовательный поиск. Он основан на идее, что если числа в таблице упорядочены, то после сравнения искомого значения с числом из середины таблицы мы можем отбросить половину из них. Если они равны, значит, поиск завершен успешно, если искомое число меньше, то мы применим этот же метод к левой части таблицы, а если больше - то к правой. На рис 2.7 представлен пример выполнения этого метода на множестве чисел.

    Лемма 2.3. Бинарный поиск проверяет не более $$$\lfloor N/2\rfloor +1$$$чисел.

    Доказательство данной леммы иллюстрирует применение рекуррентных соотношений при анализе алгоритмов. Пусть $$T_N$$ - это количество сравнений, необходимое бинарному поиску в худшем случае. Тогда из сведения поиска в таблице размером $$N$$ к поиску в два раза меньшей таблице непосредственно следует, что $$$$T_{N}\leq T_{\lfloor N/2\rfloor}+1{ при }N\geq 2{ и }T_{1}=1$$ $$

    При поиске в таблице размером $$N$$ мы проверяем число посредине, затем производим поиск в таблице размером не более $$$\lfloor N/2\rfloor$$$. Реальная стоимость может быть меньше этого значения, так как сравнение может закончиться успешно или таблица будет иметь размер $$$\lfloor N/2\rfloor -1$$$ (если $$N$$ четно). Так же, как это было сделано в решении формулы 2.2, легко доказать, что $$$T_{N}\leq n+1$$$ при $$ N= 2^n $$, а затем получить общий результат с помощью индукции. $$$\blacksquare$$$

    (рис 2.7) Бинарный поиск

    Чтобы проверить, содержится ли число 5025 в таблице, приведенной в левой колонке, мы сначала сравниваем его с 6504, из чего следует, что дальше необходимо рассматривать первую половину массива. Затем производится сравнение с числом 4548 (середина первой половины), что приводит нас ко второй половине первой половины. Мы продолжаем этот процесс, постоянно работая с подмассивом, в котором может содержаться искомое число, если оно есть в таблице. В заключение мы получаем подмассив с одним элементом, не равным 5025, из чего следует, что 5025 в таблице не содержится.

    Лемма 2.3 позволяет решить очень большую задачу поиска в 1 миллионе чисел при помощи 20 сравнений на транзакцию, то есть быстрее, чем требуется для чтения или записи числа на большинстве современных компьютеров. Задача поиска настолько важна, что было разработано несколько еще более быстрых методов, чем приведенный здесь (см. лекции 12 - 16).

    В формулировках лемм 2.1 и 2.2 используются операции, наиболее часто выполняемые над данными. Как отмечено в комментарии, следующем за леммой 2.1, мы предполагаем, что каждая операция должна занимать постоянное время, тогда можно заключить, что время выполнения бинарного поиска пропорционально lgN, в отличие от $$N$$ для последовательного поиска. При удвоении $$N$$ время бинарного поиска несколько увеличивается, но не удваивается, как это имеет место для последовательного поиска. С ростом $$N$$ разница между двумя методами становится огромной.

    Аналитическое доказательство лемм 2.1 и 2.2 можно проверить, написав программу и протестировав алгоритм. Например, в и 11. Кроме того, использование библиотечных и внешних функций и другие детали создания программ из отдельных компонентов, включая и функцию sort, объясняются в . Так что пока мы просто подчеркнем, что проведение эмпирического тестирования - это неотъемлемая часть оценки эффективности алгоритма.

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

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

    При необходимости все более точных алгоритмических решений вычислительных задач и сложного математического анализа характеристик производительности мы будем обращаться за математическими результатами к специальной литературе, чтобы основное внимание в книге уделять самим алгоритмам. Здесь нет возможности производить тщательное математическое и эмпирическое изучение всех алгоритмов, а нашей главной задачей является определение наиболее существенных характеристик производительности. При этом, в принципе, всегда можно разработать научную основу, необходимую для осознанного выбора алгоритмов для важных приложений.

    Упражнения

  • 2.47. Найдите среднее число сравнений, используемых программой 2.1, если $$$\alpha N$$$поисков оказались успешными, $$$0\leq \alpha \leq1$$$.
  • 2.48. Оцените вероятность того, что хотя бы одно из $$M$$ случайных десятизначных чисел будет содержаться в наборе из $$N$$ чисел, при $$M= 10, 100, 1000$$ и $$ N= 10^3, 10^4, 10^5, 10^6 $$.
  • 2.49. Напишите вызывающую программу, которая генерирует $$M$$ целых чисел и помещает их в массив, затем подсчитывает количество $$N$$ случайных целых чисел, которые совпадают с одним из чисел массива, используя последовательный поиск. Запустите программу при $$M= 10, 100, 1000$$ и $$N= 10, 100, 1000$$.
  • 2.50. Сформулируйте и докажите лемму, аналогичную лемме 2.3 для бинарного поиска.
  • Приведенные ниже относительные времена выполнения подтверждают наши аналитические результаты: в случае $$M$$ поисков в таблице из $$N$$ объектов время последовательного поиска пропорционально MN, а время бинарного поиска - $$M lgN$$. При удвоении $$N$$ время последовательного поиска также удваивается, а время бинарного поиска ненамного увеличивается. Последовательный поиск неприменим для очень больших $$M$$ и $$N$$, а бинарный поиск выполняется достаточно быстро даже для огромных таблиц.

    Эмпирическое исследование последовательного и бинарного поиска
    N $$M=1000$$ $$M=10000$$ $$M= 100000$$
    S B S B S B
    125 1 1 13 2 130 20
    250 3 0 25 2 251 22
    500 5 0 49 3 492 23
    1250 13 0 128 3 1276 25
    2500 26 1 267 3 28
    5000 53 0 533 3 30
    12500 134 1 1337 3 33
    25000 268 1 3 35
    50000 537 0 4 39
    100000 1269 1 5 47

    Обозначения:

    S последовательный поиск (программа 2.1)

    B бинарный поиск (программа 2.2)

    Гарантии, предсказания и ограничения

    Время выполнения большинства алгоритмов зависит от входных данных. Обычно нашей целью при анализе алгоритмов является устранение каким-либо образом этой зависимости: необходимо иметь возможность сказать что-то о производительности наших программ, что почти не зависит от входных данных, так как в общем случае неизвестно, какими будут входные данные при каждом новом запуске программы. Примеры из раздела 2.6 иллюстрируют два основных подхода, которые применяются в этом случае: анализ производительности в худшем случае и анализ производительности в среднем.

    Изучение производительности алгоритмов в худшем случае привлекательно тем, что оно позволяет гарантированно сказать что-либо о времени выполнения программ. Мы говорим, что количество выполнений определенных абстрактных операций меньше, чем определенная функция от объема входных данных, независимо от значений этих данных. Например, лемма 2.3 представляет собой пример такой гарантии для бинарного поиска, а лемма 1.3 - для взвешенного быстрого объединения. Если гарантированное время мало, как в случае с бинарным поиском, то это хорошо: значит, удалось устранить ситуации, когда программа работает медленно. Поэтому программы с хорошими характеристиками в худшем случае являются основной целью разработки алгоритмов.

    Однако при анализе производительности в худшем случае существуют и некоторые трудности. Для некоторых алгоритмов может существовать весомая разница между временем, необходимым для решения задачи в случае худших входных данных, и временем, необходимым для данных, которые обычно встречаются на практике. Например, быстрое объединение в худшем случае требует времени выполнения, пропорционального $$N$$, но лишь $$ logN$$ для обычных данных. Часто не удается доказать, что существуют входные данные, для которых время выполнения алгоритма достигает определенного предельного значения; можно лишь доказать, что время выполнения наверняка ниже этого предела. Более того, для некоторых задач алгоритмы с хорошей производительностью в худшем случае гораздо сложнее других алгоритмов. Часто бывает так, что алгоритм с хорошими характеристиками в худшем случае при работе с обычными данными оказывается медленнее, чем более простые алгоритмы, или же при незначительном выигрыше в скорости он требует дополнительных усилий для достижения хороших характеристик в худшем случае. Для многих приложений другие качества - переносимость и надежность - более важны, чем гарантии для худшего случая. Например, как было показано в , взвешенное быстрое объединение со сжатием пути обеспечивает лучшую гарантированную производительность, чем взвешенное быстрое объединение, но для типичных данных, встречающихся на практике, эти алгоритмы имеют примерно одинаковое время выполнения.

    Изучение средней производительности алгоритмов привлекательно тем, что оно позволяет делать предположения о времени выполнения программ. В простейшем случае можно точно охарактеризовать входные данные алгоритма; например, алгоритм сортировки может выполняться для массива из $$N$$ случайных целых чисел или геометрический алгоритм может обрабатывать набор из $$N$$ случайных точек на плоскости с координатами между 0 и 1. Затем можно подсчитать, сколько раз в среднем выполняется каждая инструкция, и вычислить среднее время выполнения программы, умножив частоту выполнения каждой инструкции на время ее выполнения и просуммировав по всем инструкциям.

    Однако и в анализе средней производительности существуют трудности. Во-первых, модель входных данных может неточно характеризовать данные, встречающиеся на практике, или же естественная модель входных данных может вообще не существовать. Мало кто будет возражать против использования таких моделей входных данных, как "случайно упорядоченный файл" для алгоритма сортировки или "множество случайных точек" для геометрического алгоритма, и для таких моделей можно получить математические результаты, которые будут точно предсказывать производительность программ в реальных приложениях. Но как можно характеризовать входные данные для программы, которая обрабатывает текст на английском языке? Даже для алгоритмов сортировки в определенных приложениях рассматриваются модели, отличные от случайно упорядоченных данных. Во-вторых, анализ может требовать глубоких математических выкладок. Например, сложно выполнить анализ средней производительности для алгоритмов объединение-поиск. Хотя вывод таких результатов обычно выходит за рамки этой книги, мы будем иллюстрировать их природу некоторыми классическими примерами, а также, при необходимости, будем ссылаться на важные результаты (к счастью, анализ большинства алгоритмов можно найти в исследовательской литературе). В третьих, знания среднего значения времени выполнения не всегда достаточно: может понадобиться среднеквадратичное отклонение или другие сведения о распределении времени выполнения, вывод которых может оказаться еще более трудным. В частности, нас будет часто интересовать вероятность того, что алгоритм будет работать значительно медленнее, нежели ожидается.

    Во многих случаях на первое возражение можно ответить превращением случайности в достоинство. Например, если случайным образом "взболтать" массив перед сортировкой, то предположение о случайном порядке элементов массива будет выполнено. Для таких алгоритмов, которые называются рандомизированными (randomized), анализ средней производительности приводит к ожидаемому времени выполнения в строгом вероятностном смысле. Более того, часто можно доказать, что вероятность медленной работы такого алгоритма пренебрежимо мала. К подобным алгоритмам относятся быстрая сортировка ( ), рандомизированные BST () и хеширование ().

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

    Анализ производительности в худшем случае с использованием О-нотации освобождает аналитика от необходимости включать в рассмотрение характеристики конкретной машины. Выражение "время выполнения алгоритма равно Of(N))" не зависит от входных данных, полезно для распределения алгоритмов по категориям вне зависимости от входных данных и деталей реализации и таким образом отделяет анализ алгоритма от любой конкретной его реализации. В анализе мы, как правило, отбрасываем постоянные множители. В большинстве случаев, если нужно знать, чему пропорционально время выполнения алгоритма - $$N$$ или $$ logN$$ - не имеет значения, где будет выполняться алгоритм: на небольшом компьютере или на суперкомпьютере. Не имеет значения даже то, хорошо или плохо реализован внутренний цикл алгоритма.

    Когда можно доказать, что время выполнения алгоритма в худшем случае равно $$O(f (N))$$, то говорят, что $$f (N)$$ является верхней границей сложности задачи. Другими словами, время выполнения лучшего алгоритма не больше, чем время любого другого алгоритма для данной задачи.

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

    Когда изучение сложности задачи показывает, что верхняя и нижняя границы алгоритма совпадают, тогда можно быть уверенным в том, что нет смысла пытаться разработать алгоритм, который был бы существенно быстрее, чем наилучший из известных. В этом случае остается сконцентрировать внимание на реализации. Например, бинарный поиск является оптимальным в том смысле, что никакой алгоритм, использующий только сравнения, не сможет в худшем случае обойтись меньшим их количеством, чем бинарный поиск.

    Верхняя и нижняя границы совпадают также и для алгоритмов объединение-поиск, использующих указатели. В 1975 г. Тарьян (Tarjan) показал, что алгоритм взвешенного быстрого объединения со сжатием пути требует менее чем $$O(lg* V)$$ переходов по указателям в худшем случае, и что любой алгоритм с указателями должен перейти более чем по постоянному числу указателей в худшем случае. Другими словами, нет смысла в поиске какого-либо нового улучшения, которое гарантировало бы решение задачи линейным числом операций $$i=a[i]$$. На практике эта разница едва ощутима, поскольку $$lg* V$$ очень мало, однако поиск простого линейного алгоритма для этой задачи был темой исследований в течение долгого времени, и найденная Тарьяном нижняя граница направила усилия исследователей на другие задачи. Более того, оказывается, нельзя обойти функции вроде довольно сложной функции $$log*$$, поскольку они присущи самой задаче.

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

    Не все алгоритмы стоят такого тщательного рассмотрения; ведь при создании алгоритмов желательно работать с приближенными признаками производительности, чтобы не усложнять процесс разработки излишними деталями. По мере усложнения разработки то же самое должно происходить и с анализом, с привлечением более сложных математических инструментов. Часто процесс создания алгоритма приводит к детальному изучению сложности, которые, в свою очередь, ведут к таким теоретическим алгоритмам, которые далеки от каких-либо практических приложений. Распространенная ошибка - считать, что грубый анализ исследований сложности сразу же приведет к эффективному и удобному алгоритму. Такие ожидания завершаются неприятными сюрпризами. С другой стороны, вычислительная сложность - это мощный инструмент, который помогает определить границы производительности при разработке алгоритма и может послужить отправной точкой для сужения промежутка между верхней и нижней границами.

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

    Упражнение

    2.51. Известно, что временная сложность одной задачи равна N*log N, а другой - N3. Что следует из данного утверждения об относительной производительности алгоритмов, которые решают эти задачи?

    Ссылки к части I

    Существует множество начальных учебников по программированию. Стандартный справочник по языку C++ - книга Страуструпа (Stroustup), а наилучшим источником конкретных сведений о C с примерами программ, многие из которых верны и для C++ и написаны в том же духе, что и программы в этой книге, является книга Кернигана и Ричи (Kernigan, Ritchie).

    Несколько вариантов алгоритмов для задачи объединение-поиск из собраны и объяснены в статье Ван Левена и Тарьяна (van Leewen, Tarjan).

    В книгах Бентли (Bentley) описаны в том же стиле, что и изложенный здесь материал, несколько подробных примеров использования различных подходов при разработке и реализации алгоритмов для решения различных интересных задач.

    Классический справочник по анализу алгоритмов на основе измерений асимптотической производительности в худшем случае - это книга Ахо, Хопкрофта и Ульмана (Aho, Hopcroft, Ullman). Книги Кнута (Knuth) содержат более полный анализ средней производительности и являются заслуживающим доверия описанием конкретных свойств многих алгоритмов. Более современные работы - книги Гонне, Баеса-Ятеса (Gonnet, Baeza-Yates) и Кормена, Лейзерсона, Ривеста (Cormen, Leiserson, Rivest). Обе они содержат обширные списки ссылок на исследовательскую литературу.

    Книга Грэма, Кнута, Паташника (Graham, Knuth, Patashnik) рассказывает о разделах математики, которые обычно встречаются при анализе алгоритмов; этот же материал разбросан и в упомянутых ранее книгах Кнута. Книга Седжвика и Флажоле (Sedgewick and Flajolet) представляет собой исчерпывающее введение в предмет.

    1. A.V Aho, J.E. Hopcroft, and J.D. Ullman, The Design and Analysis of Algorithms, Addison-Wesley, Reading, MA, 1975.

    2. J.L. Bentley, Programming Pearls, Addison-Wesley, Reading, MA, 1985; More Programming Pearls, Addison-Wesley, Reading, MA, 1988.

    3. R. Baeza-Yates and G.H. Gonnet, Handbook of Algorithms and Data Stru^ures, second edition, Addison-Wesley, Reading,MA, 1984.

    4. Томас Х. Кормен, Чарльз И. Лейзерсон, Рональд Л. Ривест, Клиффорд Штайн, Алгоритмы: построение и анализ, 2-е издание, ИД "Вильямс", 2009 г.

    5. R.L. Graham, D.E. Knuth, and O. Patashnik, Con^ete Mathemattes, Addison-Wesley, Reading, MA, 1988.

    6. Брайан У. Керниган, Деннис М. Ритчи, Язык программирования C (Си), 2-е издание, ИД "Вильямс", 2008 г.

    7. Д.Э. Кнут, Искусство программирования, том 1: Основные алгоритмы, 3-е издание, ИД "Вильямс", 2008 г.; Д.Э. Кнут, Искусство программирования, том 2: Получисленные алгоритмы, 3-е издание, ИД "Вильямс", 2008 г.; Д.Э. Кнут, Искусство программирования, том 3. Сортировка и поиск, 2-е издание, ИД "Вильямс", 2008 г.

    8. R. Sedgewick and P. Flajolet, An Introdu^on to the Analysis of Algorithms, Addison-Wesley, Reading, MA, 1996.

    9. B. Stroustrup, The C+ + Programming Language, third edition, Addison-Wesley, Reading MA, 1997.

    10. J. van Leeuwen and R.E. Tarjan, "Worst-case analysis of set-union algorithms", Journal of the ACM, 1984.

    Страницы:

    Анализ - это ключ к пониманию алгоритмов в степени, достаточной для их эффективного применения к практическим задачам. Хотя у нас нет возможности проводить исчерпывающие эксперименты и глубокий математический анализ каждой программы, мы можем задействовать и эмпирическое тестирование, и приближенный анализ, которые помогут нам изучить основные характеристики производительности наших алгоритмов, чтобы можно было сравнивать различные алгоритмы и применять их для практических целей.

    Сама идея точного описания производительности сложного алгоритма с помощью математического анализа кажется на первый взгляд устрашающей перспективой, поэтому мы будем часто обращаться к исследовательской литературе за результатами подробного математического изучения. Хотя целью данной книги не является обзор методов анализа или их результатов, для нас важно знать, что при сравнении различных методов мы находимся на твердой теоретической почве. Более того, тщательное применение относительно простых методов позволяет получить о многих алгоритмах большой объем подробной информации. Во всей книге мы отводим главное место простым аналитическим результатам и методам анализа, особенно тогда, когда это может помочь нам в понимании внутреннего механизма фундаментальных алгоритмов. В этой главе наша основная цель - обеспечить среду и инструменты, необходимые для работы с алгоритмами.

    Пример в демонстрирует многие из базовых концепций анализа алгоритмов, поэтому для конкретизации определенных моментов мы будем часто ссылаться на производительность алгоритмов объединение-поиск. Несколько новых примеров будут подробно рассмотрены в разделе 2.6.

    Анализ играет определенную роль в каждой точке процесса разработки и реализации алгоритмов. Прежде всего, как было показано, за счет правильного выбора алгоритма можно сократить время выполнения на три-шесть порядков. Чем эффективнее будут рассматриваемые алгоритмы, тем сложнее будет становиться задача выбора между ними, поэтому необходимо подробнее изучить их свойства. В поиске наилучшего (в некотором точном техническом смысле) алгоритма мы будем находить и практически полезные алгоритмы, и теоретические вопросы, требующие решения.

    Полный охват методов анализа алгоритмов сам по себе является предметом книги (см. раздел ссылок), и здесь мы рассмотрим лишь основы, которые позволят

  • Проиллюстрировать процесс.
  • Описать в одном месте используемые математические соглашения.
  • Обеспечить основу для обсуждения вопросов более высокого уровня.
  • Выработать понимание научной основы результатов, получаемых при анализе алгоритмов.
  • Алгоритмы и их анализ зачастую переплетены. В этой книге мы не станем углубляться в сложные математические преобразования, но все же проделаем достаточно выкладок, чтобы понять, что представляют собой наши алгоритмы и как их можно использовать наиболее эффективно.

    Реализация и эмпирический анализ

    Мы разрабатываем и реализуем алгоритмы, создавая иерархию абстрактных операций, которые помогают понять сущность решаемых вычислительных проблем. Этот процесс достаточно важен, но при теоретическом изучении он может увести очень далеко от проблем реального мира. Поэтому в данной книге мы выражаем все рассматриваемые нами алгоритмы реальным языком программирования - С++. Такой подход иногда оставляет очень туманное различие между алгоритмом и его реализацией, но это лишь небольшая плата за возможность работать с конкретной реализацией и учиться на ней.

    Несомненно, правильно разработанные программы на реальном языке программирования представляют собой эффективные методы выражения алгоритмов. В этой книге мы рассмотрим большое количество важных и эффективных алгоритмов, кратко и точно реализованных на С++. Описания на обычном языке или абстрактные высокоуровневые представления зачастую неопределённы и незакончены, а реальные реализации заставляют нас находить экономные представления, позволяющие не завязнуть в деталях.

    Мы выражаем алгоритмы на С++ , но эта книга об алгоритмах, а не о программировании на С++. Конечно же, мы будем рассматривать реализации на С++ многих важных задач, и когда существует удобный и эффективный способ решить задачу именно средствами С++, мы воспользуемся этим достоинством. Однако подавляющее большинство выводов о реализации алгоритмов применимо к любой современной среде программирования. Перевод программ из и почти всех других программ из данной книги на другой современный язык программирования - это достаточно простая задача. Если в некоторых случаях какой-либо другой язык обеспечивает более эффективный механизм решения определенных задач, мы будем указывать на это. Наша цель - использовать С++ как средство выражения алгоритмов, а не задерживаться на вопросах, специфичных для языка.

    Если алгоритм должен быть реализован как часть большой системы, мы используем абстрактные типы данных или аналогичный механизм, который позволяет изменить алгоритмы или детали реализации после того, как станет ясно, какая часть системы заслуживает наиболее пристального внимания. Однако в самом начале потребуется понимание характеристик производительности каждого алгоритма, так как требования системы к проектированию могут в значительной степени повлиять на производительность алгоритма. Подобные исходные решения при разработке необходимо принимать с осторожностью, поскольку в конце часто оказывается, что производительность всей системы зависит от производительности нескольких базовых алгоритмов наподобие тех, которые обсуждаются в этой книге.

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

    Однако при анализе каждого алгоритма мы будем уделять основное внимание существенным характеристикам производительности алгоритма. Предполагается, что главный интерес будут вызывать наиболее эффективные алгоритмы, особенно если они еще и более простые.

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

    Один из первых шагов в понимании производительности алгоритмов - это эмпирический анализ. Если есть два алгоритма для решения одной задачи, то все естественно: мы запустим оба и увидим, который из них выполняется дольше! Это концепция может показаться слишком очевидной, чтобы о ней стоило говорить, но ее часто упускают из виду при сравнительном анализе алгоритмов. Трудно не заметить, что один алгоритм в 10 раз быстрее другого, если один выполняется 3 секунды, а другой 30 секунд, однако при математическом анализе эту разницу легко упустить из виду как небольшой постоянный множитель. При замерах производительности тщательно выполненных реализаций алгоритмов для типичных данных мы получаем результаты, которые не только являются прямым показателем эффективности, но и содержат информацию, необходимую для сравнения алгоритмов и обоснования прилагаемых математических результатов (см., например, таблица 1.1). Если эмпирическое изучение начинает поглощать значительное количество времени, на помощь приходит математический анализ. Вряд ли стоит ожидать завершения программы в течение часа или целого дня, чтобы убедиться, что она работает медленно - особенно если тот же результат может дать несложный анализ.

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

    Вторая проблема, с которой мы сталкиваемся при эмпирическом анализе, - это определение природы входных данных и других факторов, оказывающих непосредственное влияние на производимые эксперименты. Обычно существуют три основных возможности: реальные данные, случайные данные и ошибочные данные. Реальные данные позволяют точно измерить параметры используемой программы; случайные данные гарантируют, что эксперименты тестируют алгоритм, а не данные; ошибочные данные гарантируют, что программы могут воспринимать любые поданные на их вход данные. Например, при тестировании алгоритмов сортировки можно запустить их с такими данными, как слова из романа "Моби Дик", сгенерированные случайным образом целые числа и файлы, содержащие одинаковые числа. При анализе алгоритмов возникает и задача определения, какие входные данные необходимо использовать для сравнения алгоритмов.

    При сравнении различных реализаций легко допустить ошибки, особенно если приходится использовать разные машины, компиляторы или системы, либо очень большие программы с плохо заданными параметрами. Принципиальная опасность при эмпирическом сравнении программ таится в том, что код для одной реализации может быть написан более тщательно, чем для другой. Изобретатель предлагаемого нового алгоритма, скорее всего, обратит больше внимания на все аспекты его реализации, но не потратит слишком много усилий на классический конкурирующий алгоритм. Чтобы быть уверенным в точности эмпирического сравнения алгоритмов, необходимо быть уверенным, что каждой реализации уделялось одинаковое внимание.

    Один из подходов, который часто применяется в этой книге, и который был продемонстрирован в , заключается в построении алгоритмов за счет внесения небольших изменений в другие алгоритмы, уже существующие для данной задачи - тогда сравнительное изучение даст достоверные результаты. В более общем смысле, мы пытаемся определить важнейшие абстрактные операции и начинаем сравнение алгоритмов с использования таких операций. Например, эмпирические результаты, приведенные в таблица 1.1, почти не зависят от языков и сред программирования, поскольку в них используются аналогичные программы, оперирующие одним и тем же набором базовых операций. Для любой конкретной среды программирования эти числа можно превратить в реальные времена выполнения. Но чаще всего просто требуется узнать, какая из двух программ будет быстрее, или до какой степени определенное изменение может улучшить требования программы к времени или памяти.

    Возможно, наиболее распространенной ошибкой при выборе алгоритма является игнорирование характеристик производительности. Более быстрые алгоритмы, как правило, сложнее, чем прямые решения, и разработчики часто предпочитают более медленные алгоритмы, дабы избежать лишних сложностей. Однако, как было показано на примере алгоритмов объединение-поиск, можно добиться значительных улучшений с помощью даже нескольких строк кода. Пользователи удивительно большого числа компьютерных систем теряют много времени в ожидании решения задачи простыми квадратичными алгоритмами, в то время как доступные алгоритмы сложности $$N logN$$ или линейные алгоритмы ненамного сложнее, но могут решить задачу значительно быстрее. Но когда мы имеем дело с большими задачами, приходится искать наилучший алгоритм, что и будет показано далее.

    Возможно, вторая наиболее распространенная ошибка при выборе алгоритма - слишком большое внимание к характеристикам его производительности. Снижение времени выполнения программы в 10 раз несущественно, если ее выполнение занимает несколько микросекунд. Даже если программа требует нескольких минут, время и усилия, необходимые, чтобы она стала выполняться в 10 раз быстрее, могут не стоить того, в особенности, если программа должна применяться лишь несколько раз. Суммарное время, требуемое для реализации и отладки улучшенного алгоритма, может быть значительно больше времени, необходимого для выполнения более медленной программы - ведь часть работы вполне можно доверить компьютеру. Но еще хуже, если потратить много времени и усилий на реализацию идей, которые должны были бы улучшить программу, но на самом деле так не получается.

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

    Упражнения

    2.1. Переведите программу из на другой язык программирования и ответьте на вопросы упражнения 1.22 для вашей реализации.

    2.2. Сколько времени займет посчитать до 1 миллиарда (не учитывая переполнение)? Определите количество времени, необходимое программе

    int i, j, k, count = 0;
    for (i = 0; i < N; i ++)
      for (j = 0; j < N; j ++)
        for (k = 0; k < N; k ++)
          count ++;
          

    для выполнения в вашей среде программирования для $$N = 10, 100$$ и $$1000$$. Если в вашем компиляторе имеются средства оптимизации, предназначенные для повышения эффективности программ, проверьте, дают ли они какой-либо результат для этой программы.

    Анализ алгоритмов

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

    Мы будем выполнять математический анализ алгоритмов, в частности:

  • Для сравнения разных алгоритмов, предназначенных для решения одной задачи
  • Для приблизительной оценки производительности программы в новой среде
  • Для установки значений параметров алгоритма
  • В данной книге вы увидите немало примеров каждой из этих причин. Эмпирический анализ годится для некоторых задач, но, как будет показано, математический анализ может оказаться более информативным (и менее дорогим!).

    Анализ алгоритмов - не всегда легкое занятие. Некоторые из алгоритмов, приведенных в этой книге, понятны до той степени, когда известны точные математические формулы для расчета времени выполнения в реальных ситуациях. Эти формулы разработаны путем тщательного изучения программы и определения времени выполнения в зависимости от фундаментальных математических величин, а затем их математического анализа. Однако характеристики производительности других алгоритмов из этой книги не поняты до конца - или потому, что их анализ ведет к нерешенным математическим проблемам, или потому, что известные реализации слишком сложны, чтобы их подробный анализ имел смысл, или же (скорее всего) потому, что невозможно точно охарактеризовать типы входных данных.

    Есть несколько важных факторов точного анализа, на которые программист обычно не может влиять. Во-первых, программы на С++ переводятся в машинные коды конкретного компьютера, и может оказаться достаточно сложной задачей определить, сколько времени займет выполнение даже одного оператора С++ (особенно в среде, где возможен совместный доступ к ресурсам, так что одна и та же программа в разное время может иметь различные характеристики производительности). Во-вторых, многие программы чрезвычайно чувствительны ко входным данным, поэтому производительность может меняться в больших пределах в зависимости от них. В-третьих, многие интересующие нас программы еще не поняты до конца, поэтому для них пока не существует специальных математических результатов. И, наконец, две программы могут совершенно не поддаваться сравнению: одна работает более эффективно с определенным типом входных данных, а другая выполняется более эффективно в других условиях.

    Но все же часто вполне возможно предсказать, сколько времени займет выполнение определенной программы или же понять, что в определенных ситуациях одна программа будет выполняться эффективнее другой. Более того, зачастую эту информацию можно получить, используя относительно небольшой набор математических инструментов. Задача аналитика алгоритмов - получить как можно больше информации о производительности алгоритмов; задача программиста - использовать эту информацию при выборе алгоритмов для конкретных приложений. В этом и нескольких следующих разделах мы будем уделять основное внимание идеализированному миру аналитика. Чтобы эффективно применять лучшие алгоритмы, иногда просто необходимо посещать такой мир.

    Первый шаг при анализе алгоритма состоит в определении абстрактных операций, на которых основан алгоритм, чтобы отделить анализ от реализации. Например, мы отделяем подсчет, сколько раз одна из реализаций алгоритма объединение-поиск запускает фрагмент кода $$i = a[i]$$, от выяснения, сколько наносекунд требуется для выполнения этого фрагмента кода на данном компьютере. Для определения реального времени выполнения программы на конкретном компьютере требуются оба этих элемента. Первый из них определяется свойствами алгоритма, а второй - свойствами компьютера. Такое разделение зачастую позволяет сравнивать алгоритмы таким способом, который не зависит от определенной реализации или от определенного типа компьютера.

    Количество используемых абстрактных операций может оказаться очень большим, однако производительность алгоритма, как правило, зависит от нескольких величин, причем наиболее важные для анализа величины обычно определить несложно. Один из способов их определения заключается в использовании механизма профилирования (подсчитывает количество выполнений каждой инструкции, доступен во многих реализациях С++) для нахождения наиболее часто исполняемых частей программы по результатам нескольких пробных запусков. Или же, как алгоритмы объединение-поиск из раздела 1.3 , наша реализация может быть построена лишь на нескольких абстрактных операциях. В любом случае, анализ сводится к определению частоты исполнения нескольких фундаментальных операций. Принцип нашей работы заключается в том, чтобы отыскать приблизительные оценки этих величин, зная, что для важных программ при необходимости можно будет произвести полный анализ. Более того, как будет показано далее, часто можно достаточно точно предсказать результаты на основе приближенных аналитических результатов в сочетании с эмпирическим изучением.

    Кроме того, необходимо изучать данные и моделировать такие их наборы, которые могут быть поданы на вход алгоритма. Чаще всего мы будем рассматривать один из двух подходов к анализу: или предполагаем, что входные данные случайны, и изучаем среднюю производительность программы, или же рассматриваем самые неудобные данные и изучаем наихудшую производительность программы. Процесс описания случайных входных данных для многих алгоритмов достаточно сложен, но для многих других алгоритмов он совсем прост и приводит к аналитическим результатам, дающим полезную информацию. Средний случай может быть просто математической фикцией, не зависящей от данных, для которых используется программа, а наихудший - вычурной последовательностью, которая никогда не встречается на практике, но в большинстве случаев эти виды анализа предоставляют полезную информацию о производительности. Например, мы можем сравнить аналитические и эмпирические результаты (см. раздел 2.1). Если они совпадут, это повысит уверенность в обоих вариантах; а если не совпадут, мы сможем узнать больше об алгоритме и модели, рассмотрев их расхождения.

    В последующих трех разделах мы кратко рассмотрим математические инструменты, которыми будем пользоваться во всей книге. Этот материал находится за пределами нашей основной задачи, поэтому читатели с хорошей математической подготовкой или же те, кто не собирается подробно проверять наши математические выражения, связанные с производительностью алгоритмов, могут перейти сразу к разделу 2.6 и обращаться к этому материалу там, где это указано в книге позже. Однако рассматриваемые нами математические основы, в общем-то, не сложны для понимания и настолько близки к базовым вопросам разработки алгоритмов, что их не стоит игнорировать всем, кто стремится эффективно использовать компьютер.

    Вначале, в разделе 2.3, рассматриваются математические функции, которые обычно нужны для описания характеристик производительности алгоритмов. Далее, в разделе 2.4, будет рассмотрена О-нотация (О-notation) и понятие пропорционально (is proportional to), которые позволяют опустить детали при математическом анализе. Затем, в разделе 2.5, изучаются рекуррентные соотношения (recurrence relations) - основной аналитический инструмент, используемый для выражения характеристик алгоритма в виде математических равенствах. И в завершение, в разделе 2.6, приводятся примеры, в которых все эти инструменты применяются для анализа конкретных алгоритмов.

    Упражнения

  • 2.3. Напишите выражение вида с0 + c1N + c2N^2 + c3N3, которое точно описывает время выполнения программы из упражнения 2.2. Сравнить время, получаемое из этого выражения, с реальным при N = 10, 100, 1000.
  • 2.4. Напишите выражение, которое точно описывает время выполнения программы 1.1 в зависимости от M и N.
  • Возрастание функций

    Большинство алгоритмов имеют главный параметр $$N$$, который наиболее сильно влияет на время их выполнения. Параметр $$N$$ может быть степенью полинома, размером файла при сортировке или поиске, количеством символов в строке или некоторой другой абстрактной мерой размера рассматриваемой задачи: чаще всего он прямо пропорционален объему обрабатываемого набора данных. Когда таких параметров существует более одного (например, $$M$$ и $$N$$ в алгоритмах объединение-поиск, которые были рассмотрены в разделе 1.3 ), мы часто сводим анализ к одному параметру, задавая его как функцию других, или рассматривая одновременно только один параметр (считая остальные постоянными) - то есть без потери общности ограничиваясь рассмотрением только одного параметра $$N$$. Нашей целью является выражение требований программ к ресурсам (как правило, это время выполнения) в зависимости от $$N$$ с использованием математических формул, которые максимально просты и справедливы для больших значений параметров. Алгоритмы в этой книге обычно имеют время выполнения, пропорциональное одной из следующих функций:

    1 Большинство инструкций большинства программ выполняются один или несколько раз. Если все инструкции программы обладают таким свойством, мы говорим, что время выполнения программы постоянно.
    $$ logN$$ Когда время выполнения программы является логарифмическим, программа выполняется несколько медленнее с ростом $$N$$. Такое время выполнения обычно присуще программам, которые сводят большую задачу к набору меньших задач, уменьшая на каждом шаге размер задачи в некоторое постоянное количество раз. В интересующей нас области время выполнения можно считать небольшой константой. Основание логарифма изменяет константу, но не намного: когда $$N$$ - тысяча, $$ logN$$ равно 3, если основание равно 10, или порядка 10, если основание равно 2; когда $$N$$ равно миллиону, значения $$ logN$$ только удвоятся. При удвоении $$N$$ величина $$ logN$$ увеличивается на постоянную величину, а удваивается лишь тогда, когда $$N$$ достигает N^2.
    $$N$$ Когда время выполнения программы является линейным, это обычно значит, что каждый входной элемент подвергается небольшой обработке. Если $$N$$ равно миллиону, то время выполнения равно некоторой величине. Когда $$N$$ удваивается, то же происходит и со временем выполнения. Эта ситуация оптимальна для алгоритма, который должен обработать $$N$$ входных данных (или выдать $$N$$ выходных данных).
    $$NlogN$$ Время выполнения, пропорциональное $$N logN$$, возникает тогда, когда алгоритм решает задачу, разбивая ее на меньшие подзадачи, решая их независимо и затем объединяя решения. Из-за отсутствия подходящего прилагательного ("линерифмический"?) мы просто говорим, что время выполнения такого алгоритма равно $$N logN$$. Если $$N$$ равно 1 миллиону, $$N logN$$ примерно равно 20 миллионам. При удвоении $$N$$ время выполнения более чем (но не сильно) удваивается.
    $$N^2$$ Если время выполнения алгоритма является квадратичным, он полезен для практического использования для относительно небольших задач. Квадратичное время выполнения обычно появляется в алгоритмах, которые обрабатывают все пары элементов данных (возможно, в цикле двойного уровня вложенности). Когда $$N$$ равно 1 тысяче, время выполнения равно 1 миллиону. При удвоении $$N$$ время выполнения увеличивается вчетверо.
    $$ N^3$$ Аналогично, эта ситуация характерна для алгоритма, который обрабатывает тройки элементов данных (возможно, в цикле тройного уровня вложенности), имеет кубическое время выполнения и практически применим лишь для малых задач. Если $$N$$ равно 100, время выполнения равно 1 миллиону. При удвоении $$N$$ время выполнения увеличивается в восемь раз.
    $$ 2^N$$ Лишь несколько алгоритмов с экспоненциальным временем выполнения имеют практическое применение, хотя такие алгоритмы возникают естественным образом при попытках прямого решения задачи. Если $$N$$ равно 20, время выполнения равно 1 миллиону. При удвоении $$N$$ время выполнения возводится в квадрат!

    Время выполнения определенной программы обычно равно некоторой константе, умноженной на один из этих элементов (главный член) плюс меньшие слагаемые. Значения постоянного коэффициента и остальных слагаемых зависят от результатов анализа и деталей реализации. В первом приближении коэффициент при главном члене связан с количеством инструкций во внутреннем цикле: на любом уровне разработки алгоритма разумно сократить количество таких инструкций. Для больших $$N$$ доминирует эффект главного члена, для малых $$N$$ или для тщательно разработанных алгоритмов ощутимый вклад дают и другие слагаемые, поэтому сравнение алгоритмов затрудняется. В большинстве случаев мы будем называть время выполнения программ просто "линейным", "N logN", "кубическим" и т.д. Обоснование этого подробно приводится в разделе 2.4.

    В итоге, чтобы уменьшить общее время выполнения программы, мы минимизируем количество инструкций во внутреннем цикле. Каждую инструкцию необходимо тщательно рассмотреть и решить, является ли она необходимой. Существует ли более эффективный способ выполнить такую же задачу? Некоторые программисты считают, что средства автоматизации, содержащиеся в современных компиляторах, могут создавать наилучший машинный код; другие утверждают, что оптимальным способом является написание внутренних циклов вручную на машинном языке, или ассемблере. Обычно мы будем останавливаться, доходя до рассмотрения оптимизации на таком уровне, хотя иногда будем указывать, сколько машинных инструкций требуется для выполнения определенных операций. Это потребуется для того, чтобы понять, почему на практике одни алгоритмы могут оказаться быстрее других.

    Для малых задач время решения практически не зависит от метода - быстрый современный компьютер все равно выполнит задачу мгновенно. Но по мере увеличения размера задачи числа, с которыми мы имеем дело, становятся огромными, как продемонстрировано в таблица 2.1. Когда количество исполняемых инструкций в медленном алгоритме становится по-настоящему большим, время, необходимое для их выполнения, становится недостижимым даже для самых быстрых компьютеров. На приведен перевод большого количества секунд в дни, месяцы, годы и т.д.; в таблица 2.1">рис 2.2">таблица 2.1 приведен перевод большого количества секунд в дни, месяцы, годы и т.д.; в (рис 2.1) Перевод секунд

    Огромная разница между такими числами, как $$10^4$$ и $$10^8$$, становится более очевидной, если взять соответствующее количество секунд и перевести в привычные единицы измерения. Мы можем позволить программе выполняться 2,8 часа, но вряд ли мы будем созерцать программу, выполнение которой займет 3,1 года. Поскольку $$2^10$$ примерно равно $$10^3$$, этой таблицей можно воспользоваться и для перевода степеней 2. Например, $$2^{32}$$ секунд - это примерно 124 года.

    Значения часто встречающихся функций
    $$lgN$$ $$$\sqrt{N}$$$ N $$NlgN$$ $$ N(lgN)^2 $$ $$ N^{3/2}$$ $$N^2$$
    3 3 10 33 110 32 100
    7 10 100 664 4414 1000 10000
    10 32 1000 9966 99317 31623 1000000
    13 100 10000 132877 1765633 1000000 100000000
    17 316 100000 1660964 27588016 31622777 10000000000
    20 1000 1000000 19931569 397267426 1000000000 1000000000000

    В этой таблице показаны относительные величины некоторых функций, которые часто встречаются при анализе алгоритмов. Квадратичная функция очевидно доминирует, особенно для больших значений $$N$$, а различия между меньшими функциями оказываются не такими, как можно было ожидать для малых $$N$$. Например, $$N^{3/2}$$ должно быть больше, чем $$ Nlg^2 $$ для очень больших значений $$N$$, однако для малых $$N$$ наблюдается обратная ситуация. Точное время выполнения алгоритма может быть линейной комбинацией этих функций. Быстрые алгоритмы легко отличить от медленных из-за огромной разницы между, например, $$lgN$$ и $$N$$ или $$N$$ и $$N^2$$, но различие между двумя быстрыми алгоритмами может потребовать тщательного изучения.

    Время для решения гигантских задач
    Операций в секунду Размер задачи 1 миллион Размер задачи 1 миллиард
    N $$NlgN$$ $$ N^2$$ N $$NlgN$$ $$ N^2$$
    $$10^6$$ секунды секунды недели часы часы никогда
    $$10^9$$ мгновенно мгновенно часы секунды секунды десятилетия
    $$10^{12}$$ мгновенно мгновенно секунды мгновенно мгновенно недели

    Во многих случаях единственным шансом решить очень большую задачу является использование эффективного алгоритма. В этой таблице показано минимальное количество времени, необходимое для решения задач размером 1 миллион и 1 миллиард с использованием линейных, $$Nlog N$$ и квадратичных алгоритмов на компьютерах с быстродействием 1 миллион, 1 миллиард и 1 триллион инструкций в секунду. Быстрый алгоритм позволяет решить задачу на медленной машине, но быстрая машина бессильна при использовании медленного алгоритма.

    При анализе алгоритмов возникает еще несколько функций. Например, алгоритм с $$N^2$$ входными данными, имеющий время выполнения $$N^3$$, можно рассматривать, как $$N^{3/2}$$ алгоритм. Кроме того, некоторые алгоритмы разбиваются на подзадачи в два этапа и имеют время выполнения, пропорциональное $$Nlog^2$$. Из таблица 2.1 видно, что обе эти функции гораздо ближе к $$NlogN$$, чем $$N^2$$.

    Логарифмическая функция играет особую роль в разработке и анализе алгоритмов, поэтому ее стоит рассмотреть подробнее. Поскольку мы часто имеем дело с аналитическими результатами, которые различаются на постоянный множитель, мы используем запись "log N" без указания основания. Изменение основания логарифма меняет значение логарифма лишь на постоянный множитель, однако в определенных контекстах возникают особые значения основания логарифма.

    В математике настолько важным является натуральный логарифм (основание e = 2,71828...), что распространено следующее сокращение: $$$\log{_{e}N}\equiv\ln{N}$$$. В вычислительной технике очень важен двоичный логарифм (основание равно 2), поэтому часто используется сокращение $$$\log{_{2}N}\equiv\lg{N}$$$.

    Наименьшее целое число, большее $$lgN$$, равно количеству битов, необходимых для представления $$N$$ в двоичном формате; точно так же наименьшее целое, большее $$$\log{_{10}N}$$$, - это количество цифр, необходимое для представления $$N$$ в десятичном формате.

    Оператор С++

              for (lgN = 0; N > 0; lgN++, N /= 2) ;
          

    дает простой способ подсчета наименьшего целого, большего $$lgN$$. Вот аналогичный метод для вычисления этой функции:

            for (lgN = 0, t = 1; t < N; lgN++, t += t) ;
          

    В нем подчеркивается, что $$${2^{n}}\leq{N}<{2^{n+1}}$$$, когда $$n$$ - это наименьшее целое, большее $$lgN$$.

    Иногда бывает нужно вычислить логарифм логарифма, обычно для больших чисел. Например, $$ lglg2^{256} = lg 256 = 8 $$. Как видно из данного примера, обычно для практических целей выражение $$loglog N$$ можно считать константой, поскольку оно мало даже для очень больших $$N$$.

    Кроме того, часто мы сталкиваемся с некоторыми специальными функциями и математическими обозначениями из классического анализа, которые удобны для краткого описания свойств программ. В таблица 2.3 приведены наиболее используемые из этих функций; ниже мы кратко обсудим их и некоторые наиболее важные их свойства.

    Специальные функции и постоянные
    Функция Название Пример Приближение
    $$$\lfloor{x}\rfloor$$$ округление до меньшего $$$\lfloor{3,14}\rfloor$$$= 3 x
    $$$\lceil{x}\rceil$$$ округление до большего $$$\lceil{3,14}\rceil$$$= 4 x
    $$lg N$$ двоичный логарифм $$lg1024 =10$$ $$1,44lnN$$
    $$F^n$$ числа Фибоначчи $$ F^10 = 55 $$ $$$\phi^{N}/\sqrt{5}$$$
    $$H^N$$ гармонические числа $$ $H_{10}\approx2,9$$$ $$$\ln{N}+\gamma$$$
    N! факториал $$10! = 3628800$$ $$ (N/e)^N $$
    $$lg (N!)$$ $$$\lg{100!}\approx520$$$ $$ N lgN - 1,44 N $$

    $$e = 2,71828...$$

    $$$\gamma = 0,57721...$$$

    $$$\phi=(1+\sqrt{5})/2=1,61803...$$$

    $$ln2 = 0,693147...$$

    $$ lge = 1/ln 2 = 1,44269...$$

    В этой таблице собраны математические обозначения функций и постоянных, которые часто появляются в формулах, описывающих производительность алгоритмов. Формулы для приближенных значений можно при необходимости уточнить, учтя следующие слагаемые разложения (см. раздел ссылок).

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

    $$$\lfloor{x}\rfloor$$$: наибольшее целое, меньшее или равное x

    $$$\lceil{x}\rceil$$$: наименьшее целое, большее или равное х.

    Например, $$$\lfloor{\pi}\rfloor$$$и $$$\lceil{e}\rceil$$$оба равны 3, а $$$\lceil{x}\rceil$$$ - это количество битов, необходимое для двоичного представления числа $$N$$. Другое важное применение этих функций возникает в том случае, когда необходимо поделить множество $$N$$ объектов пополам. Этого нельзя сделать точно, если $$N$$ является нечетным, поэтому для точности мы можем создать одно подмножество, содержащее $$$\lfloor{N/2}\rfloor$$$объектов, а второе - $$$\lceil{N/2}\rceil$$$объектов. Если $$N$$ четно, тогда размеры обоих поднаборов равны ( $$$\lfloor{N/2}\rfloor$$$= $$$\lceil{N/2}\rceil$$$); если же $$N$$ нечетно, то их размер отличается на единицу ($$$\lfloor{N/2}\rfloor + 1$$$ = $$$\lceil{N/2}\rceil$$$). В С++ можно напрямую подсчитать значения этих функций при выполнении операций над целыми числами (например, если $N\geq0$N, тогда $$ N/2$$ равно $$$\lfloor{\pi}\rfloor$$$ а $$N-(N/2)$$ равно $$$\lceil{x}\rceil$$$), а при операциях над числами с плавающей точкой можно воспользоваться функциями floor и ceil из заголовочного файла math.h.

    При анализе алгоритмов часто возникает дискретизированная версия функции натурального логарифма, называемая гармоническими числами. N-е гармоническое число определяется выражением

    $$$H_{N}=1+\dfrac{1}{2}+\dfrac{1}{3}+...+\dfrac{1}{N}$$$

    Натуральный логарифм $$ln N$$ - это значение площади под кривой $$1/х$$ между 1 и $$N$$ ; гармоническое число H_N - это площадь под ступенчатой функцией, которую можно определить, вычисляя значения функции $$1/х$$ для целых чисел от 1 до $$N$$. Эта зависимость показана на рис 2.2.

    (рис 2.2) Гармонические числа

    Гармонические числа представляют собой приближенные значения площади под кривой 1/х. Постоянная y учитывает разницу между H_N и $$$\ln{N}=\int_{1}^{N}{dx/x}$$$

    Формула

    $$ $H_{N}\approx \ln{N}+\gamma +1/(12N)$$$ где $$$\gamma = 0,57721... $$$ (эта константа называется постоянной Эйлера), дает отличное приближение для H_N. В отличие от $$$\lceil{lgN}\rceil$$$и $$$\lfloor{lgN}\rfloor$$$ для вычисления H_N лучше воспользоваться библиотечной функцией log, а не подсчитывать его непосредственно из определения.

    Последовательность чисел

    $$ 0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 ...$$ определяемая формулой $$$F_{N}=F_{N-1}+F_{N-2}$$$ где $$$N\geq2$$$ а $$F_0 = 0$$ и $$F_1 = 1$$ известна как числа Фибоначчи и имеет множество интересных свойств. Например, отношение двух последовательных чисел приближенно равно золотому сечению (golden ratio) $$$\phi=(1+\sqrt{5}/2 \approx 1,61803...$$$ Более подробный анализ показывает, что $$F_N$$ равно значению выражения $$$\phi^{N}/\sqrt{5}$$$ округленному до ближайшего целого числа.

    При анализе алгоритмов часто встречается также функция факториал $$N!$$. Как и экспоненциальная функция, факториал возникает при лобовом решении задач и растет слишком быстро, чтобы такие решения представляли практический интерес. Он также возникает при анализе алгоритмов, поскольку представляет собой количество способов упорядочения $$N$$ объектов.

    Для аппроксимации $$N!$$ используется формула Стирлинга:

    $$ ${\lg{N}!}\approx{N\lg{N}}-N\lg{e}+\lg{\sqrt{2\pi N}}$$$.

    Например, из формулы Стирлинга следует, что количество битов в представлении числа $$N!$$ примерно равно $$NlgN$$.

    Большинство рассматриваемых в этой книге формул выражается через несколько функций, рассмотренных в этой главе. Однако при анализе алгоритмов может встретиться множество других специальных функций.

    Например, классическое биномиальное распределение и распределение Пуассона играют важную роль при разработке и анализе некоторых фундаментальных поисковых алгоритмов, которые будут рассмотрены в и . Функции, не приведенные здесь, обсуждаются по мере их появления.

    Упражнения

    $$$\triangleright$$$ 2.5. Для каких значений $$N$$ справедливо $$ 10NlgN> 2N^2 $$ ?

    $$$\triangleright$$$ 2.6. Для каких значений $$N$$ выражение $$N^{3/2}$$ имеет значение в пределах от $$ N(lgN)^2/2 $$ до $$ 2N(lgN)^2 $$ ?

  • 2.7. Для каких значений $$N$$ справедливо $$ 2NH_n - N < N lgN + 10N $$ ?
  • 2.8. Для какого наименьшего значения $$N$$ справедливо $$ log_10log_10 >8 $$ ?
  • 2.9. Докажите, что $$$\lfloor{\lg{N}}\rfloor$$$+ 1 - это количество битов, необходимое для представления числа $$N$$ в двоичной форме.
  • 2.10. Добавьте в таблица 2.2 столбцы для $$ N(lgN)^2 и N^{3/2} $$.
  • 2.11. Добавьте в таблица 2.2 строки для $$10^7$$ и $$10^8$$ инструкций в секунду.
  • 2.12. Напишите на С++ функцию, которая подсчитывает $$H^N$$, используя функцию $$log$$ из стандартной математической библиотеки.
  • 2.13. Напишите эффективную функцию на С++, подсчитывающую $$$\lceil{\lg{\lg{N}}}\rceil$$$ Не используйте библиотечную функцию.
  • 2.14. Сколько цифр в десятичном представлении числа 1 миллион факториал?
  • 2.15. Сколько битов в двоичном представлении числа $$lg(N!)$$ ?
  • 2.16. Сколько битов в двоичном представлении $$H^N$$ ?
  • 2.17. Приведите простое выражение для $$$\lfloor{\lg{F_{N}}}\rfloor$$$.
  • 2.18. Приведите наименьшие значения $$N$$, для которых $$$\lfloor{H_{N}}\rfloor$LHNJ = i, где ${1}\leq{i}\leq{10}$$$
  • 2.19. Приведите наибольшее значение $$N$$, для которого можно решить задачу, требующую выполнения f(N) инструкций, на машине с быстродействием $$10^9$$ операций в секунду для следующих функций $$f(N): N^{3/2}, N^{5/4}$$, $$ 2NH^N $$, $$NlgNlglgN$$ и $$ N^2lgN $$.
  • О-нотация

    Математическая запись, позволяющая отбрасывать детали при анализе алгоритмов, называется О-нотацией. Она определена следующим образом.

    Определение 2.1. Говорят, что функция $$g(N)$$ имеет порядок $$O(f(N))$$, если существуют такие постоянные $$с_0$$ и $$N_0$$, что $$ g (N) < c_0 f (N) $$ для всех $$ N > N_0 $$.

    О-нотация используется по трем различным причинам:

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

    Постоянные $$с_0$$ и $$N_0$$, не выраженные явно в О-нотации, часто скрывают практически важные подробности реализации. Очевидно, что выражение "алгоритм имеет время выполнения $$O(f (N))$$ " ничего не говорит о времени выполнения при $$N$$, меньшем $$N_0$$, а $$с_0$$ может иметь большое значение, необходимое для работы в наихудшем случае. Понятно, что лучше иметь алгоритм, время выполнения которого составляет $$N^2$$ наносекунд, а не $$log N$$ столетий, но мы не можем сделать такой выбор на основе О-нотации.

    Часто результаты математического анализа являются не точными, а приближенными именно в техническом смысле: результат представляет собой сумму убывающих слагаемых. Так же как мы интересуемся в основном внутренним циклом программы, нас интересует больше всего главные члены (наибольшие по величине слагаемые) математического выражения. О-нотация позволяет работать с приближенными математическими выражениями, рассматривая главные члены и опуская меньшие слагаемые, а также записывать краткие выражения, дающие неплохие приближения для анализируемых величин.

    Некоторые из основных действий, которые используются при работе с выражениями, содержащими О-нотацию, являются предметом упражнений 2.20 - 2.25. Многие из этих действий интуитивно понятны, а склонные к математике читатели могут с интересом выполнить упражнение 2.21, где требуется доказать верность базовых операций, исходя из определения. По сути, из этих упражнений следует, что в алгебраических выражениях с О-нотацией можно раскрывать скобки так, как будто ее там нет, а затем отбрасывать все слагаемые, кроме наибольшего. Например, если требуется раскрыть скобки в выражении

    $$(N + O (1))(N + O (log N) + O (1))$$, то мы получим шесть слагаемых

    $$ N^2 + O (N) + O (N log N) + O (log N) + O (N) + O (1) $$.

    Однако можно отбросить все О-слагаемые, кроме наибольшего из них, и тогда останется приближенное выражение

    $$ N^2 + O (N log N) $$.

    То есть при больших $$N$$ хорошей аппроксимацией этого выражения является N^2. Эти действия интуитивно ясны, но О-нотация позволяет выразить их с математической точностью. Формула с одним О-слагаемым называется асимптотическим выражением (asymptotic expression).

    В качестве более конкретного примера предположим, что (после некоторого математического анализа) мы выяснили, что определенный алгоритм имеет внутренний цикл, выполняемый в среднем $$ NH_N $$ раз, внешний раздел, выполняемый $$N$$ раз, и некоторый код инициализации, исполняемый однократно. Далее предположим, что (после тщательного исследования реализации) мы определили, что каждая итерация внутреннего цикла требует $$а_0$$ наносекунд, внешний раздел - $$а_1$$ наносекунд, а код инициализации - $$а_2$$ наносекунд. Тогда среднее время выполнения программы (в наносекундах) равно $$ 2а_0 N H_N + a_1 + а_2 $$.

    Поэтому для времени выполнения справедлива следующая формула: $$ 2а_0 NH_N + O(N) $$.

    Эта более простая формула важна, поскольку из нее следует, что для аппроксимации времени выполнения при больших $$N$$ нет необходимости искать значения величин $$ а_1$$ и $$а_$$2 . В общем случае, в точном математическом выражении для времени выполнения может содержаться множество других слагаемых, ряд которых трудно анализировать. О-нотация обеспечивает способ получения приближенного ответа для больших $$N$$, не заботясь о подобных слагаемых.

    Далее, О-нотация позволяет в данном примере выразить время выполнения через более знакомую функцию $$lnN$$. С помощью таблица 2.3 полученное выражение можно приближенно записать как $$ H_N = lnN + O(1) $$. Таким образом, асимптотическое выражение для общего времени выполнения алгоритма имеет вид $$ 2a_0lnN + O(N) $$. То есть при больших $$N$$ оно будет близко к легко вычисляемому выражению $$ 2a_0lnN $$. Постоянный множитель $$а0$$ зависит от времени выполнения инструкций внутреннего цикла.

    Более того, нам не нужно знать значения $$a0$$, чтобы предсказать, что при больших $$N$$ время выполнения для входных данных размером $$2N$$ будет вдвое больше, чем для входных данных размером $$N$$, поскольку $$$\dfrac{2a_{0}(2N)\ln{#2N#}+O(2N)}{2a_{0}N\ln{N}+O(N)}=\dfrac{2\ln{(2N)}+O(1)}{\ln{N}+O(1)}=2+O\left(\dfrac{1}{\log{N}}\right)$.$$

    Таким образом, асимптотическая формула позволяет нам делать точные прогнозы, не вдаваясь в подробности реализации или анализа. Отметьте, что такое предсказание не было бы возможным, если бы О-аппроксимация была задана только для главного члена.

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

    Когда функция $$f (N)$$ асимптотически велика по сравнению с другой функцией $$g (N)$$ (т.е. $$$g(N)/f(N)\rightarrow0$$$ при $$$N\rightarrow\infty$$$), иногда в данной книге мы будем использовать термин (конечно, неточный) порядка $$f(N)$$, что означает $$f (N) + O(g(N))$$. Потеря математической точности компенсируется большей наглядностью, так как нас больше интересует производительность алгоритмов, а не математические детали. В таких случаях мы можем быть уверены в том, что при больших (а, может, даже и всех) значениях $$N$$ исследуемая величина будет близка к $$f(N)$$. Например, даже если мы знаем, что некоторая величина равна $$N (N - 1) / 2$$, ее можно рассматривать как $$ N^2/ 2 $$. Такой способ выражения результатов более понятен, чем подробный и точный результат, и, к примеру, при $$N= 1000$$ отличается от правильного значения всего лишь на 0,1%. Потеря точности в данном случае намного меньше, чем при распространенном использовании $$O(f (N))$$. При описании производительности алгоритмов мы будем по возможности стараться быть и точными, и краткими.

    В похожем ключе мы иногда говорим, что время выполнения алгоритма пропорционально $$f (N)$$, т.е. можно доказать, что оно равно с $$f (N) + g(N), где g(N)$$ асимптотически мало по сравнению с $$f (N)$$. При таком подходе можно предсказать время выполнения для $$2N$$, если оно известно для $$N$$, как в рассмотренном выше примере. На рис. 2.3 рис 2.3 приводятся значения множителей для таких прогнозов поведения функций, которые часто возникают при анализе алгоритмов. В сочетании с эмпирическим изучением (см. раздел 2.1) данный подход освобождает от определения постоянных величин, зависящих от реализации. Или же, применяя его в обратном направлении, зачастую мы можем выдвинуть гипотезу о функциональной зависимости времени выполнения программы, изучив, как меняется время выполнения при удвоении $$N$$.

    Различия между О-оценками пропорционально (is proportional to) и порядка (about) проиллюстрированы на рис 2.4 и рис 2.5. О-нотация используется, прежде всего, для исследования фундаментального асимптотического поведения алгоритма; пропорционально требуется при экстраполяции производительности на основе эмпирического изучения, а порядка - при сравнении производительности или при предсказании абсолютной производительности.

    (рис 2.3) Влияние удвоения размеров задачи на время выполнения

    Нетрудно предсказать влияние удвоения размеров задачи на время выполнения, если время выполнения пропорционально одной из простых функций, указанных в таблице. Теоретически это влияние можно вычислить только для больших $$N$$, но данный метод на удивление эффективен. И наоборот, быстрый метод определения функционального роста времени выполнения программы заключается в запуске программы, удвоении объема входных данных для максимально возможного $$N$$, а затем оценка функциональной зависимости согласно приведенной таблице.

    (рис 2.4) Ограничение функции с помощью О-аппроксимации

    На этой схематической диаграмме осциллирующая кривая представляет собой функцию $$g(N)$$, которую мы пытаемся аппроксимировать; плавная черная кривая представляет собой другую функцию, $$f(N)$$, которая используется для аппроксимации, а плавная серая кривая является функцией $$cf(N)$$ с некоторой неопределенной постоянной $$c$$. Вертикальная прямая задает значение $$N0$$, указывающее, что аппроксимация справедлива для $$ N > N_0 $$. Когда мы говорим, что $$g(N) = O(f(N))$$, мы лишь ожидаем, что значение функции $$g(N)$$ находится ниже некоторой кривой, имеющей форму функции $$f(N)$$, и правее некоторой вертикальной прямой. Поведение функции $$f(N)$$ может быть любым (например, она не обязательно должна быть непрерывной).

    (рис 2.5) Аппроксимация функций

    Когда говорят, что функция $$g(N)$$ пропорциональна функции $$f(N)$$ (верхний график), то подразумевают, что она растет как $$f(N)$$, но, возможно, смещена относительно последней на неизвестный коэффициент. Если задано некоторое значение $$g(N)$$, можно предсказать поведение функции при больших $$N$$. Когда говорят, что $$g(N)$$ порядка $$f(N)$$ (нижний график), то подразумевают, что функцию $$f$$ можно использовать для достаточно точной оценки значений функции g.

    Упражнения

    $$$\triangleright$$$ 2.20. Докажите, что О(1) - то же самое, что и О(2).

    2.21. Докажите, что в выражениях с О-нотацией можно выполнить любое из перечисленных преобразований:

    $$ \begin{align*} f(N)\rightarrow O(f(N))\\ cO(f(N))\rightarrow O(f(N))\\ O(cf(N))\rightarrow O(f(N))\\ f(N)-g(N)=O(h(N))\rightarrow f(N)=g(N)+O(h(N))\\ O(f(N))O(g(N))\rightarrow O(f(N)g(N))\\ O(f(N))+O(g(N))\rightarrow O(g(N))\\ \end{align*} $$

  • 2.22. Покажите, что (N + 1)(H_N + O(1)) = N lnN + O(N).
  • 2.23. Покажите, что $$ N lnN = O(N^{3/2}) $$.
  • 2.24. Покажите, что $$$N^{M}=O(\alpha^{N})$$$ для любого $$M$$ и любого постоянного $$$\alpha >1$$$.
  • 2.25. Докажите, что $$$\dfrac{N}{N+O(1)}=1+O\left(\dfrac{1}{N} \right)$$$
  • 2.26. Предположим, что $$ H_k = N $$. Найдите приближенную формулу, которая выражает $$k$$ как функцию $$N$$.
  • 2.27. Предположим, что $$lg(k!) = N$$. Найдите приближенную формулу, которая выражает k как функцию $$N$$.
  • 2.28. Известно, что время выполнения одного алгоритма равно $$O(N logN)$$, а другого - $$ O(N^3) $$. Что это неявно говорит об относительной производительности алгоритмов?
  • 2.29. Известно, что время выполнения одного алгоритма всегда порядка $$NlogN$$, а другого - $$ O(N^3) $$. Что это неявно говорит об относительной производительности алгоритмов?
  • 2.30. Известно, что время выполнения одного алгоритма всегда порядка $$NlogN$$, а другого - всегда $$N^3$$. Что это неявно говорит об относительной производительности алгоритмов?
  • 2.31. Известно, что время выполнения одного алгоритма всегда пропорционально $$N logN$$, а другого - всегда пропорционально $$N^3$$. Что это неявно говорит об относительной производительности алгоритмов?
  • 2.32. Выведите значения множителей, приведенных на рис 2.3: для каждой функции $$f (N)$$, показанной слева, найдите асимптотическую формулу для $$f (2N) / f (N)$$.
  • Простейшие рекурсии

    Как мы увидим далее в этой книге, многие алгоритмы основаны на принципе рекурсивного разбиения большой задачи на меньшие, а решения подзадач используются для решения исходной задачи. Эта тема подробно обсуждается в , в основном с практической точки зрения, где основное внимание уделено различным реализациям и приложениям. Кроме того, в разделе 2.6 приводится подробный пример. А в этом разделе будут рассмотрены простейшие методы анализа таких алгоритмов и вывод решений нескольких стандартных формул, которые возникают при анализе многих изучаемых алгоритмов. Понимание математических свойств формул в этом разделе дает необходимое понимание свойств производительности алгоритмов.

    Рекурсивное разбиение алгоритма напрямую проявляется в его анализе. Например, время выполнения подобных алгоритмов определяется величиной и количеством подзадач, а также временем, необходимым для разбиения задачи. Математически зависимость времени выполнения алгоритма для $$N$$ входных данных от времени выполнения при меньшем количестве данных легко определить с помощью рекуррентных соотношений. Такие формулы точно описывают производительность алгоритмов, и для вычисления времени выполнения необходимо решить эти уравнения. Более строгие рассуждения, связанные со спецификой алгоритмов, встретятся при их непосредственном рассмотрении; здесь же внимание уделяется только формулам.

    Формула 2.1. В рекурсивной программе, где при каждой итерации цикла количество входных данных уменьшается на единицу, возникает следующее рекуррентное соотношение:

    $$ C_N = C_N-1 + N $$, где $$$N\geq2$$$ и $$ C_1 = 1 $$.

    Решение: $$C_N$$ имеет порядок $$ N^2/ 2 $$. Для решения рекуррентного уравнения его можно развернуть, применяя само к себе следующим образом: $$ \begin{equation*} C_{N}=C_{N-1}+N\\ =C_{N-2}+(N-1)+N\\ =C_{N-3}+(N-2)+(N-1)+N\\ \vdots \end{equation*} $$ Продолжая таким же образом, можно получить $$ \begin{equation*} C_{N}=C_{1}+2+\ldots+(N-2)+(N-1)+N\\ =1+2+\ldots+(N-2)+(N-1)+N\\ =\dfrac{N(N+1)}{2} \end{equation*} $$

    Подсчет суммы $$1 + 2 + ... + (N - 2) + (N - 1) + N$$ элементарен: прибавим к сумме ее же, но в обратном порядке. Результирующая сумма - удвоенный искомый результат - будет состоять из $$N$$ слагаемых, каждое из которых равно $$N + 1$$.

    Формула 2.2. В рекурсивной программе, где на каждом шаге количество вводов уменьшается вдвое, возникает следующее рекуррентное соотношение: $$$$C_{N}=C_{N/2}+1{, где }N\geq2{ и }C_{1}=1$$$$

    Решение: CN имеет порядок $$lg N$$. Это уравнение бессмысленно, если $$N$$ нечетно, или же нужно предположить, что $$ N/2$$ - целочисленное деление. Чтобы рекурсия была всегда определена, предположим, что $$ N= 2^n $$. (Отсюда $$п = lg N$$.) Тогда развернуть рекурсию еще проще, чем в предыдущем случае: $$ \begin{equation*} C_{2^{n}}=C_{2^{n-1}}+1\\ =C_{2^{n-2}}+1+1\\ =C_{2^{n-3}}+3\\ \vdots\\ =C_{2^{n}}+n\\ =n+1. \end{equation*} $$

    Точное решение для произвольного $$N$$ зависит от интерпретации $$ N/2$$. Если $$ N/2$$ представляет собой $$$\lfloor N/2\rfloor$$$, то существует очень простое решение: CN - это количество битов в двоичном представлении числа $$N$$, т.е. по определению $$$\lfloor lgN\rfloor+1$$$. Этот вывод немедленно следует из того, что операция отбрасывания правого бита в двоичном представлении любого числа $$N > 0$$ превращает его в $$$\lfloor N/2\rfloor$$$ (см. рис 2.6).

    (рис 2.6) Целочисленные функции и двоичные представления

    Для заданного двоичного представления числа $$N$$ (в центре) отбрасывание правого бита дает $$$\lfloor N/2\rfloor$$$. То есть количество битов в двоичном представлении числа $$N$$ на единицу больше, чем в представлении числа $$$\lfloor N/2\rfloor$$$. Поэтому количество битов в двоичном представлении числа - $$$N\lfloor N/2\rfloor+1$$$ - является решением формулы 2.2 в случае интерпретации N/2 как $$$\lfloor N/2\rfloor$$$.

    Формула 2.3. В рекурсивной программе, где объем входных данных уменьшается вдвое, но необходимо проверить каждый элемент, возникает следующее рекуррентное соотношение: $$$$C_{N}=C_{N/2}+N{, где }N\geq2{ и }C_{1}=0$$$$.

    Решение: CN имеет порядок 2N. Рекурсия развертывается в сумму:

    $$N + N/2 + N/4 + N/8 + ...$$ .

    (Как и формуле 2.2, рекуррентное соотношение определено точно только в том случае, если $$N$$ является степенью числа 2). Для бесконечной последовательности сумма простой геометрической прогрессии равна в точности 2N. Однако поскольку используется целочисленное деление, и мы останавливаемся на 1, это значение является приближением к точному ответу. В точном решении используются свойства двоичного представления числа $$N$$.

    Формула 2.4. В рекурсивной программе, которая должна выполнить линейный проход по входным данным до, в течение или после разбиения их на две половины, возникает следующее рекуррентное соотношение: $$$$C_{N}=2C_{N/2}+N{, где }N\geq2{ и }C_{1}=0$$$$.

    Решение: CN имеет порядок $$NlgN$$. Это решение применяется намного чаще, чем остальные из приведенных здесь, поскольку эта рекурсия используется в целом семействе алгоритмов "разделяй и властвуй". $$ \begin{equation*} C_{2^{n}}=2C_{2^{n-1}}+2^{n}\\ \dfrac{C_{2^{n}}}{2^{n}}=\dfrac{C_{2^{n-1}}}{2^{n-1}}+1\\ =\dfrac{C_{2^{n-2}}}{2^{n-2}}+1+1\\ \vdots\\ =n. \end{equation*} $$

    Решение находится почти так же, как это было сделано в формуле 2.2, но с дополнительным приемом на втором шаге - делением обеих частей равенства на $$2^n$$, который позволяет развернуть рекурсию.

    ) возникает следующая рекурсия. $$$$C_{N}=2C_{N/2}+1{, где }N\geq2{ и }C_{1}=1$$$$.

    Решение: C_N имеет порядок $$2N$$. Это решение можно получить так же, как и решение формулы 2.4.

    С помощью приведенных методов можно решить различные варианты этих уравнений, имеющие другие начальные условия или небольшие отличия в добавочном члене. Однако необходимо помнить, что некоторые, на первый взгляд похожие, рекурсии могут иметь гораздо более сложные решения. Существует множество дополнительных общих методов для математически строгого решения таких уравнений (см. раздел ссылок). Несколько сложных рекуррентных соотношений встретятся в последующих главах, где и будут приведены их решения.

    Упражнения

  • 2.33. Составьте таблицу значений $$C_N$$, заданных формулой 2.2 для $$$1\leq N\leq32$$$, считая, что $$ N/2$$ означает $$$\lfloor N/2\rfloor$$$.
  • 2.34. Выполните упражнение 2.33, но считая, что $$ N/2$$ означает $$$\lceil N/2\rceil$$$.
  • 2.35. Выполните упражнение 2.34 для формулы 2.3.
  • 2.36. Предположим, что $$f_N$$ пропорционально постоянной величине и что $$$C_{N}=C_{N/2}+f_{N}{ при }N\geq t{ и }0\leq C_{N}<c{ при }N<t$$$, где $$с$$ и $$t$$ - постоянные. Покажите, что $$C_N$$ пропорционально $$lgN$$.
  • 2.37. Сформулируйте и докажите обобщенные версии формул 2.3 - 2.5, аналогичные обобщенной версии формулы 2.2 в упражнении 2.36.
  • 2.38. Составьте таблицу значений $$C_N$$, заданных формулой 2.4 при $$$1\leq N\leq32$$$ для трех следующих случаев: (1) $$ N/2$$ означает $$$\lfloor N/2\rfloor$$$, (2) $$ N/2$$ означает $$$\lceil N/2\rceil$$$, (3) 2C_N/2 равно $$$C_{\lfloor N/2\rfloor}+C_{\lceil N/2\rceil}$$$
  • 2.39. Решите уравнение 2.4 для случая, когда $$ N/2$$ означает $$$\lfloor N/2\rfloor$$$, используя соответствие двоичному представлению числа $$N$$, как это было сделано в доказательстве формулы 2.2. Подсказка: Рассмотрите все числа, меньшие $$N$$.
  • 2.40. Решите рекуррентное уравнение $$$C_{N}=C_{N/2}+N^{2}$$$ при $$$N\geq 2{\ и\ }C_{1}=0$$$, если $$N$$ является степенью числа 2.
  • 2.41. Решите рекуррентное уравнение $$$C_{N}=C_{N/\alpha}+1$$$ при $$$N\geq 2{\ и\ }C_{1}=0$$$, если $$N$$ является степенью числа а.
  • 2.42. Решите рекуррентное уравнение $$$C_{N}=\alpha C_{N/2}+N^{2}$$$ при $$$N\geq 2{\ и\ }C_{1}=1$$$, если $$N$$ является степенью числа 2.
  • 2.43. Решите рекуррентное уравнение $$$C_{N}=(C_{N/2})^{2}+N^{2}$$$ при $$$N\geq 2{\ и\ }C_{1}=1$$$, если $$N$$ является степенью числа 2.
  • 2.44. Решите рекуррентное уравнение $$$C_{N}=\left(2+\dfrac{1}{\lg{N}}\right)C_{N/2}$$$ при $$$N\geq 2{\ и\ }C_{1}=1$$$, если $$N$$ является степенью числа 2.
  • 2.45. Рассмотрите семейство рекурсий наподобие формулы 2.1, где $$ N/2$$ может означать $$$\lfloor N/2\rfloor$$$ или $$$\lceil N/2\rceil$$$, с единственным требованием: рекурсия выполняется при $$ N > с_0 $$, а при $$$N\leq c_{0}$$$ имеет место $$ C_N = O(1) $$. Докажите, что решением всех таких рекурсий является формула $$lgN + O(1)$$.
  • 2.46. Выведите обобщенные рекурсии и их решения, как в упражнении 2.45, для формул 2.2-2.5.
  • Примеры анализа алгоритмов

    Вооружившись инструментами, о которых было рассказано в трех предыдущих разделах, мы рассмотрим анализ последовательного поиска и бинарного поиска - двух основных алгоритмов для определения того, входит ли некоторая последовательность объектов в заданное множество объектов. Наша цель - показать, как можно сравнивать алгоритмы, а не подробно описать сами алгоритмы. Для простоты предположим, что все рассматриваемые объекты являются целыми числами. Более общие приложения будут подробно рассмотрены в лекциях 12 - 16 . Простые версии алгоритмов, которые мы сейчас рассмотрим, не только демонстрируют многие аспекты задачи их разработки и анализа, но и имеют практическую ценность.

    Например, представим себе компанию, обрабатывающую кредитные карточки и имеющую $$N$$ рискованных или украденных кредитных карточек. При этом компании необходимо проверять, нет ли среди $$M$$ транзакций какого-либо из этих $$N$$ плохих номеров. Для большей конкретности будем считать $$N$$ большим (скажем, порядка $$10^3$$ - $$10^6$$), а $$M$$ - огромным (порядка $$10^6$$ - $$10^9$$). Цель анализа заключается в приблизительной оценке времен выполнения алгоритмов, когда параметры принимают значения из указанного диапазона.

    В программе 2.1 реализовано прямое решение задачи поиска. Для совместимости с другими вариантами кода для этой задачи, которые мы исследуем в части 4, она оформлена как функция С++, обрабатывающая массив (см. ). Однако необязательно вдаваться в детали программы для понимания алгоритма: мы сохраняем все объекты в массиве, затем для каждой транзакции мы последовательно просматриваем массив от начала до конца, проверяя, содержится ли в нем искомый номер.

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

    Поэтому время выполнения зависит от данных. Если бы все поиски выполнялись для чисел, которые находятся в первой позиции, то алгоритм был бы быстрым; если бы они выполнялись для чисел, находящихся в последней позиции, тогда алгоритм был бы медленным. В разделе 2.7 мы обсудим разницу между возможностью гарантировать производительность и предсказать производительность. В данном случае лучшее, что мы можем гарантировать - это что будет просмотрено не более $$N$$ чисел.

    Однако чтобы сделать прогноз, необходимо какое-то предположение о данных. В данном случае предположим, что все числа выбраны случайным образом.

    Программа 2.1. Последовательный поиск

    Данная функция проверяет, находится ли число v среди элементов массива a[l] , a[l+1], ..., a[r], путем последовательного сравнения с каждым элементом, начиная с начала. Если по достижении последнего элемента нужное значение не найдено, функция возвращает значение -1. Иначе она возвращает индекс элемента массива, содержащего искомое число.

    int search(int a[], int v, int l, int r) {
      for (int i = l; i <= r; i++)
        if (v == a[i]) return i;
      return -1;
    }
          

    Из этого предположения следует, например, что предметом поиска с одинаковой вероятностью может оказаться каждое число в таблице. После некоторых размышлений мы приходим к выводу, что именно это свойство поиска является наиболее важным, потому что среди случайно выбранных чисел вряд ли найдется нужное нам (см. упражнение 2.48). В некоторых приложениях количество транзакций с успешным поиском может быть высоким, а в других - низким. Чтобы не запутывать модель свойствами приложения, мы разобьем задачу на два случая (успешный и неудачный поиск) и проанализируем их независимо. Данный пример иллюстрирует, что важным моментом эффективного анализа является разработка разумной модели приложения. Наши аналитические результаты будут зависеть от доли успешных поисков; более того, они обеспечат нас информацией, необходимой для выбора различных алгоритмов для разных приложений на основании этого параметра.

    Лемма 2.1. Последовательный поиск проверяет $$N$$ чисел при каждом неудачном поиске и в среднем порядка N/ 2 чисел при каждом успешном поиске.

    Если объектом поиска с равной вероятностью может быть любое число в таблице, то средняя стоимость поиска равна (1 + 2 + ... + N) / N = (N + 1)/ 2. $$$\blacksquare$$$

    Из леммы 2.1 следует, что время выполнения программы 2.1 пропорционально $$N$$, если средняя стоимость сравнения двух чисел постоянна. Значит, к примеру, можно ожидать, что если удвоить количество объектов, то и время, необходимое для поиска, также удвоится.

    Последовательный поиск в случае неудачи можно ускорить, если упорядочить числа в таблице. Сортировка чисел в таблице является предметом рассмотрения глав 6-11. Несколько алгоритмов, которые мы рассмотрим, выполняют эту задачу за время, пропорциональное $$N logN$$, которое незначительно по сравнению со стоимостью поиска при очень больших M. В упорядоченной таблице можно прервать поиск сразу по достижении числа, большего, чем искомое. Такое изменение уменьшает стоимость последовательного поиска до N/ 2 чисел, которые необходимо в среднем проверить при неудачном поиске, что совпадает с затратами для успешного поиска.

    Лемма 2.2. Алгоритм последовательного поиска в упорядоченной таблице проверяет $$N$$ чисел для каждого поиска в худшем случае и порядка $$N/ 2$$ чисел в среднем.

    Здесь все еще необходимо определить модель неудачного поиска. Этот результат следует из предположения, что поиск может с равной вероятностью закончиться на любом из $$N + 1$$ интервалов, задаваемых $$N$$ числами таблицы, а это непосредственно приводит к выражению $$(1 + 2 + ... + N + N )/N = (N + 3)/ 2$$.

    Стоимость неудачного поиска, который заканчивается до или после N-ой записи в таблице, такая же: $$N$$. $$$\blacksquare$$$

    Другой способ выразить результат леммы 2.2 - это сказать, что время выполнения последовательного поиска пропорционально $$MN$$ для $$M$$ транзакций и в среднем, и в худшем случае. Если удвоить или количество транзакций, или количество объектов в таблице, то время выполнения удвоится; если мы удвоим обе величины одновременно, то время выполнения увеличится в 4 раза. Этот результат говорит о том, что данный метод не годится для очень больших таблиц. Если для проверки одного числа требуется c микросекунд, а $$ M= 10^9 $$ и $$ N= 10^6 $$, то время выполнения для всех транзакций будет равно, по крайней мере, $$ (с / 2)10^9 $$ секунд, или, согласно рис 2.1, около 16c лет, что недопустимо.

    Программа 2.2. Бинарный поиск

    Эта программа делает то же самое, что и программа 2.1, но гораздо эффективнее.

    int search(int a[], int v, int l, int r) {
      while (r >= l) {
        int m = (l+r)/2;
        if ( v == a[ m] ) return m;
        if (v < a[m]) r = m-1; else l = m+1;
      }
      return -1;
    }
          

    Программа 2.2 представляет собой классическое решение задачи поиска методом, гораздо более эффективным, чем последовательный поиск. Он основан на идее, что если числа в таблице упорядочены, то после сравнения искомого значения с числом из середины таблицы мы можем отбросить половину из них. Если они равны, значит, поиск завершен успешно, если искомое число меньше, то мы применим этот же метод к левой части таблицы, а если больше - то к правой. На рис 2.7 представлен пример выполнения этого метода на множестве чисел.

    Лемма 2.3. Бинарный поиск проверяет не более $$$\lfloor N/2\rfloor +1$$$чисел.

    Доказательство данной леммы иллюстрирует применение рекуррентных соотношений при анализе алгоритмов. Пусть $$T_N$$ - это количество сравнений, необходимое бинарному поиску в худшем случае. Тогда из сведения поиска в таблице размером $$N$$ к поиску в два раза меньшей таблице непосредственно следует, что $$$$T_{N}\leq T_{\lfloor N/2\rfloor}+1{ при }N\geq 2{ и }T_{1}=1$$ $$

    При поиске в таблице размером $$N$$ мы проверяем число посредине, затем производим поиск в таблице размером не более $$$\lfloor N/2\rfloor$$$. Реальная стоимость может быть меньше этого значения, так как сравнение может закончиться успешно или таблица будет иметь размер $$$\lfloor N/2\rfloor -1$$$ (если $$N$$ четно). Так же, как это было сделано в решении формулы 2.2, легко доказать, что $$$T_{N}\leq n+1$$$ при $$ N= 2^n $$, а затем получить общий результат с помощью индукции. $$$\blacksquare$$$

    (рис 2.7) Бинарный поиск

    Чтобы проверить, содержится ли число 5025 в таблице, приведенной в левой колонке, мы сначала сравниваем его с 6504, из чего следует, что дальше необходимо рассматривать первую половину массива. Затем производится сравнение с числом 4548 (середина первой половины), что приводит нас ко второй половине первой половины. Мы продолжаем этот процесс, постоянно работая с подмассивом, в котором может содержаться искомое число, если оно есть в таблице. В заключение мы получаем подмассив с одним элементом, не равным 5025, из чего следует, что 5025 в таблице не содержится.

    Лемма 2.3 позволяет решить очень большую задачу поиска в 1 миллионе чисел при помощи 20 сравнений на транзакцию, то есть быстрее, чем требуется для чтения или записи числа на большинстве современных компьютеров. Задача поиска настолько важна, что было разработано несколько еще более быстрых методов, чем приведенный здесь (см. лекции 12 - 16).

    В формулировках лемм 2.1 и 2.2 используются операции, наиболее часто выполняемые над данными. Как отмечено в комментарии, следующем за леммой 2.1, мы предполагаем, что каждая операция должна занимать постоянное время, тогда можно заключить, что время выполнения бинарного поиска пропорционально lgN, в отличие от $$N$$ для последовательного поиска. При удвоении $$N$$ время бинарного поиска несколько увеличивается, но не удваивается, как это имеет место для последовательного поиска. С ростом $$N$$ разница между двумя методами становится огромной.

    Аналитическое доказательство лемм 2.1 и 2.2 можно проверить, написав программу и протестировав алгоритм. Например, в и 11. Кроме того, использование библиотечных и внешних функций и другие детали создания программ из отдельных компонентов, включая и функцию sort, объясняются в . Так что пока мы просто подчеркнем, что проведение эмпирического тестирования - это неотъемлемая часть оценки эффективности алгоритма.

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

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

    При необходимости все более точных алгоритмических решений вычислительных задач и сложного математического анализа характеристик производительности мы будем обращаться за математическими результатами к специальной литературе, чтобы основное внимание в книге уделять самим алгоритмам. Здесь нет возможности производить тщательное математическое и эмпирическое изучение всех алгоритмов, а нашей главной задачей является определение наиболее существенных характеристик производительности. При этом, в принципе, всегда можно разработать научную основу, необходимую для осознанного выбора алгоритмов для важных приложений.

    Упражнения

  • 2.47. Найдите среднее число сравнений, используемых программой 2.1, если $$$\alpha N$$$поисков оказались успешными, $$$0\leq \alpha \leq1$$$.
  • 2.48. Оцените вероятность того, что хотя бы одно из $$M$$ случайных десятизначных чисел будет содержаться в наборе из $$N$$ чисел, при $$M= 10, 100, 1000$$ и $$ N= 10^3, 10^4, 10^5, 10^6 $$.
  • 2.49. Напишите вызывающую программу, которая генерирует $$M$$ целых чисел и помещает их в массив, затем подсчитывает количество $$N$$ случайных целых чисел, которые совпадают с одним из чисел массива, используя последовательный поиск. Запустите программу при $$M= 10, 100, 1000$$ и $$N= 10, 100, 1000$$.
  • 2.50. Сформулируйте и докажите лемму, аналогичную лемме 2.3 для бинарного поиска.
  • Приведенные ниже относительные времена выполнения подтверждают наши аналитические результаты: в случае $$M$$ поисков в таблице из $$N$$ объектов время последовательного поиска пропорционально MN, а время бинарного поиска - $$M lgN$$. При удвоении $$N$$ время последовательного поиска также удваивается, а время бинарного поиска ненамного увеличивается. Последовательный поиск неприменим для очень больших $$M$$ и $$N$$, а бинарный поиск выполняется достаточно быстро даже для огромных таблиц.

    Эмпирическое исследование последовательного и бинарного поиска
    N $$M=1000$$ $$M=10000$$ $$M= 100000$$
    S B S B S B
    125 1 1 13 2 130 20
    250 3 0 25 2 251 22
    500 5 0 49 3 492 23
    1250 13 0 128 3 1276 25
    2500 26 1 267 3 28
    5000 53 0 533 3 30
    12500 134 1 1337 3 33
    25000 268 1 3 35
    50000 537 0 4 39
    100000 1269 1 5 47

    Обозначения:

    S последовательный поиск (программа 2.1)

    B бинарный поиск (программа 2.2)

    Гарантии, предсказания и ограничения

    Время выполнения большинства алгоритмов зависит от входных данных. Обычно нашей целью при анализе алгоритмов является устранение каким-либо образом этой зависимости: необходимо иметь возможность сказать что-то о производительности наших программ, что почти не зависит от входных данных, так как в общем случае неизвестно, какими будут входные данные при каждом новом запуске программы. Примеры из раздела 2.6 иллюстрируют два основных подхода, которые применяются в этом случае: анализ производительности в худшем случае и анализ производительности в среднем.

    Изучение производительности алгоритмов в худшем случае привлекательно тем, что оно позволяет гарантированно сказать что-либо о времени выполнения программ. Мы говорим, что количество выполнений определенных абстрактных операций меньше, чем определенная функция от объема входных данных, независимо от значений этих данных. Например, лемма 2.3 представляет собой пример такой гарантии для бинарного поиска, а лемма 1.3 - для взвешенного быстрого объединения. Если гарантированное время мало, как в случае с бинарным поиском, то это хорошо: значит, удалось устранить ситуации, когда программа работает медленно. Поэтому программы с хорошими характеристиками в худшем случае являются основной целью разработки алгоритмов.

    Однако при анализе производительности в худшем случае существуют и некоторые трудности. Для некоторых алгоритмов может существовать весомая разница между временем, необходимым для решения задачи в случае худших входных данных, и временем, необходимым для данных, которые обычно встречаются на практике. Например, быстрое объединение в худшем случае требует времени выполнения, пропорционального $$N$$, но лишь $$ logN$$ для обычных данных. Часто не удается доказать, что существуют входные данные, для которых время выполнения алгоритма достигает определенного предельного значения; можно лишь доказать, что время выполнения наверняка ниже этого предела. Более того, для некоторых задач алгоритмы с хорошей производительностью в худшем случае гораздо сложнее других алгоритмов. Часто бывает так, что алгоритм с хорошими характеристиками в худшем случае при работе с обычными данными оказывается медленнее, чем более простые алгоритмы, или же при незначительном выигрыше в скорости он требует дополнительных усилий для достижения хороших характеристик в худшем случае. Для многих приложений другие качества - переносимость и надежность - более важны, чем гарантии для худшего случая. Например, как было показано в , взвешенное быстрое объединение со сжатием пути обеспечивает лучшую гарантированную производительность, чем взвешенное быстрое объединение, но для типичных данных, встречающихся на практике, эти алгоритмы имеют примерно одинаковое время выполнения.

    Изучение средней производительности алгоритмов привлекательно тем, что оно позволяет делать предположения о времени выполнения программ. В простейшем случае можно точно охарактеризовать входные данные алгоритма; например, алгоритм сортировки может выполняться для массива из $$N$$ случайных целых чисел или геометрический алгоритм может обрабатывать набор из $$N$$ случайных точек на плоскости с координатами между 0 и 1. Затем можно подсчитать, сколько раз в среднем выполняется каждая инструкция, и вычислить среднее время выполнения программы, умножив частоту выполнения каждой инструкции на время ее выполнения и просуммировав по всем инструкциям.

    Однако и в анализе средней производительности существуют трудности. Во-первых, модель входных данных может неточно характеризовать данные, встречающиеся на практике, или же естественная модель входных данных может вообще не существовать. Мало кто будет возражать против использования таких моделей входных данных, как "случайно упорядоченный файл" для алгоритма сортировки или "множество случайных точек" для геометрического алгоритма, и для таких моделей можно получить математические результаты, которые будут точно предсказывать производительность программ в реальных приложениях. Но как можно характеризовать входные данные для программы, которая обрабатывает текст на английском языке? Даже для алгоритмов сортировки в определенных приложениях рассматриваются модели, отличные от случайно упорядоченных данных. Во-вторых, анализ может требовать глубоких математических выкладок. Например, сложно выполнить анализ средней производительности для алгоритмов объединение-поиск. Хотя вывод таких результатов обычно выходит за рамки этой книги, мы будем иллюстрировать их природу некоторыми классическими примерами, а также, при необходимости, будем ссылаться на важные результаты (к счастью, анализ большинства алгоритмов можно найти в исследовательской литературе). В третьих, знания среднего значения времени выполнения не всегда достаточно: может понадобиться среднеквадратичное отклонение или другие сведения о распределении времени выполнения, вывод которых может оказаться еще более трудным. В частности, нас будет часто интересовать вероятность того, что алгоритм будет работать значительно медленнее, нежели ожидается.

    Во многих случаях на первое возражение можно ответить превращением случайности в достоинство. Например, если случайным образом "взболтать" массив перед сортировкой, то предположение о случайном порядке элементов массива будет выполнено. Для таких алгоритмов, которые называются рандомизированными (randomized), анализ средней производительности приводит к ожидаемому времени выполнения в строгом вероятностном смысле. Более того, часто можно доказать, что вероятность медленной работы такого алгоритма пренебрежимо мала. К подобным алгоритмам относятся быстрая сортировка ( ), рандомизированные BST () и хеширование ().

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

    Анализ производительности в худшем случае с использованием О-нотации освобождает аналитика от необходимости включать в рассмотрение характеристики конкретной машины. Выражение "время выполнения алгоритма равно Of(N))" не зависит от входных данных, полезно для распределения алгоритмов по категориям вне зависимости от входных данных и деталей реализации и таким образом отделяет анализ алгоритма от любой конкретной его реализации. В анализе мы, как правило, отбрасываем постоянные множители. В большинстве случаев, если нужно знать, чему пропорционально время выполнения алгоритма - $$N$$ или $$ logN$$ - не имеет значения, где будет выполняться алгоритм: на небольшом компьютере или на суперкомпьютере. Не имеет значения даже то, хорошо или плохо реализован внутренний цикл алгоритма.

    Когда можно доказать, что время выполнения алгоритма в худшем случае равно $$O(f (N))$$, то говорят, что $$f (N)$$ является верхней границей сложности задачи. Другими словами, время выполнения лучшего алгоритма не больше, чем время любого другого алгоритма для данной задачи.

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

    Когда изучение сложности задачи показывает, что верхняя и нижняя границы алгоритма совпадают, тогда можно быть уверенным в том, что нет смысла пытаться разработать алгоритм, который был бы существенно быстрее, чем наилучший из известных. В этом случае остается сконцентрировать внимание на реализации. Например, бинарный поиск является оптимальным в том смысле, что никакой алгоритм, использующий только сравнения, не сможет в худшем случае обойтись меньшим их количеством, чем бинарный поиск.

    Верхняя и нижняя границы совпадают также и для алгоритмов объединение-поиск, использующих указатели. В 1975 г. Тарьян (Tarjan) показал, что алгоритм взвешенного быстрого объединения со сжатием пути требует менее чем $$O(lg* V)$$ переходов по указателям в худшем случае, и что любой алгоритм с указателями должен перейти более чем по постоянному числу указателей в худшем случае. Другими словами, нет смысла в поиске какого-либо нового улучшения, которое гарантировало бы решение задачи линейным числом операций $$i=a[i]$$. На практике эта разница едва ощутима, поскольку $$lg* V$$ очень мало, однако поиск простого линейного алгоритма для этой задачи был темой исследований в течение долгого времени, и найденная Тарьяном нижняя граница направила усилия исследователей на другие задачи. Более того, оказывается, нельзя обойти функции вроде довольно сложной функции $$log*$$, поскольку они присущи самой задаче.

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

    Не все алгоритмы стоят такого тщательного рассмотрения; ведь при создании алгоритмов желательно работать с приближенными признаками производительности, чтобы не усложнять процесс разработки излишними деталями. По мере усложнения разработки то же самое должно происходить и с анализом, с привлечением более сложных математических инструментов. Часто процесс создания алгоритма приводит к детальному изучению сложности, которые, в свою очередь, ведут к таким теоретическим алгоритмам, которые далеки от каких-либо практических приложений. Распространенная ошибка - считать, что грубый анализ исследований сложности сразу же приведет к эффективному и удобному алгоритму. Такие ожидания завершаются неприятными сюрпризами. С другой стороны, вычислительная сложность - это мощный инструмент, который помогает определить границы производительности при разработке алгоритма и может послужить отправной точкой для сужения промежутка между верхней и нижней границами.

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

    Упражнение

    2.51. Известно, что временная сложность одной задачи равна N*log N, а другой - N3. Что следует из данного утверждения об относительной производительности алгоритмов, которые решают эти задачи?

    Ссылки к части I

    Существует множество начальных учебников по программированию. Стандартный справочник по языку C++ - книга Страуструпа (Stroustup), а наилучшим источником конкретных сведений о C с примерами программ, многие из которых верны и для C++ и написаны в том же духе, что и программы в этой книге, является книга Кернигана и Ричи (Kernigan, Ritchie).

    Несколько вариантов алгоритмов для задачи объединение-поиск из собраны и объяснены в статье Ван Левена и Тарьяна (van Leewen, Tarjan).

    В книгах Бентли (Bentley) описаны в том же стиле, что и изложенный здесь материал, несколько подробных примеров использования различных подходов при разработке и реализации алгоритмов для решения различных интересных задач.

    Классический справочник по анализу алгоритмов на основе измерений асимптотической производительности в худшем случае - это книга Ахо, Хопкрофта и Ульмана (Aho, Hopcroft, Ullman). Книги Кнута (Knuth) содержат более полный анализ средней производительности и являются заслуживающим доверия описанием конкретных свойств многих алгоритмов. Более современные работы - книги Гонне, Баеса-Ятеса (Gonnet, Baeza-Yates) и Кормена, Лейзерсона, Ривеста (Cormen, Leiserson, Rivest). Обе они содержат обширные списки ссылок на исследовательскую литературу.

    Книга Грэма, Кнута, Паташника (Graham, Knuth, Patashnik) рассказывает о разделах математики, которые обычно встречаются при анализе алгоритмов; этот же материал разбросан и в упомянутых ранее книгах Кнута. Книга Седжвика и Флажоле (Sedgewick and Flajolet) представляет собой исчерпывающее введение в предмет.

    1. A.V Aho, J.E. Hopcroft, and J.D. Ullman, The Design and Analysis of Algorithms, Addison-Wesley, Reading, MA, 1975.

    2. J.L. Bentley, Programming Pearls, Addison-Wesley, Reading, MA, 1985; More Programming Pearls, Addison-Wesley, Reading, MA, 1988.

    3. R. Baeza-Yates and G.H. Gonnet, Handbook of Algorithms and Data Stru^ures, second edition, Addison-Wesley, Reading,MA, 1984.

    4. Томас Х. Кормен, Чарльз И. Лейзерсон, Рональд Л. Ривест, Клиффорд Штайн, Алгоритмы: построение и анализ, 2-е издание, ИД "Вильямс", 2009 г.

    5. R.L. Graham, D.E. Knuth, and O. Patashnik, Con^ete Mathemattes, Addison-Wesley, Reading, MA, 1988.

    6. Брайан У. Керниган, Деннис М. Ритчи, Язык программирования C (Си), 2-е издание, ИД "Вильямс", 2008 г.

    7. Д.Э. Кнут, Искусство программирования, том 1: Основные алгоритмы, 3-е издание, ИД "Вильямс", 2008 г.; Д.Э. Кнут, Искусство программирования, том 2: Получисленные алгоритмы, 3-е издание, ИД "Вильямс", 2008 г.; Д.Э. Кнут, Искусство программирования, том 3. Сортировка и поиск, 2-е издание, ИД "Вильямс", 2008 г.

    8. R. Sedgewick and P. Flajolet, An Introdu^on to the Analysis of Algorithms, Addison-Wesley, Reading, MA, 1996.

    9. B. Stroustrup, The C+ + Programming Language, third edition, Addison-Wesley, Reading MA, 1997.

    10. J. van Leeuwen and R.E. Tarjan, "Worst-case analysis of set-union algorithms", Journal of the ACM, 1984.

    Вернуться к учебному плану