Вычислительная сложность алгоритмов

Курс охватывает основы оценки эффективности программ, включая временную и пространственную сложность, асимптотический анализ и нотацию Big O. Студенты изучат классы сложности (P и NP) и научатся оптимизировать потребление ресурсов, опираясь на фундаментальные концепции информатики ([habr.com](https://habr.com/ru/articles/173821/), [skillbox.ru](https://skillbox.ru/media/code/big-o-notation-chto-eto-takoe-i-kak-eye-poschitat/), [ru.wikipedia.org](https://ru.wikipedia.org/wiki/%D0%92%D1%8B%D1%87%D0%B8%D1%81%D0%BB%D0%B8%D1%82%D0%B5%D0%BB%D1%8C%D0%BD%D0%B0%D1%8F_%D1%81%D0%BB%D0%BE%D0%B6%D0%BD%D0%BE%D1%81%D1%82%D1%8C)).

1. Введение в теорию алгоритмов и понятие вычислительной сложности

Введение в теорию алгоритмов и понятие вычислительной сложности

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

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

Почему важна эффективность алгоритмов?

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

> Компьютеры становятся быстрее, но плохой алгоритм на быстром компьютере всегда проиграет хорошему алгоритму на медленном компьютере при достаточно большом объеме данных. > > Дональд Кнут, автор монографии «Искусство программирования»

Рассмотрим пример. У нас есть база данных интернет-магазина, содержащая 1 000 000 товаров. Нам нужно отсортировать их по цене.

Если мы используем простой, но медленный алгоритм (например, сортировку пузырьком), количество необходимых операций будет пропорционально квадрату количества элементов. Для 1 000 000 товаров потребуется около 1 000 000 000 000 (одного триллиона) операций сравнения. Даже если компьютер выполняет 100 миллионов операций в секунду, процесс займет почти 3 часа.

Если же применить эффективный алгоритм (например, быструю сортировку), количество операций составит около 20 000 000. Тот же самый компьютер справится с задачей за 0,2 секунды. Разница между 3 часами и долей секунды — это и есть результат работы теории алгоритмов.

Время и память: два ресурса вычислительной сложности

При оценке алгоритмов нас интересуют два главных ресурса, которые потребляет программа:

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

    В приведенном выше коде количество операций напрямую зависит от размера списка numbers. Если в списке 10 элементов, цикл выполнится 10 раз. Если 10 000 элементов — 10 000 раз. Зависимость является линейной.

    Асимптотический анализ и нотация «О» большое

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

    Для записи асимптотической сложности используется нотация Big O («О» большое).

    Строгое математическое определение выглядит так:

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

    При использовании нотации Big O применяются два главных правила:

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

    Пример с числами: пусть . Тогда , а . Вклад слагаемого в общий результат составляет всего 0,05%, поэтому им можно пренебречь.

    Основные классы сложности

    Все алгоритмы можно разделить на классы в зависимости от функции . Чем медленнее растет функция, тем эффективнее алгоритм.

    | Класс сложности | Название | Характеристика | Пример алгоритма | Операций при | |---|---|---|---|---| | | Константная | Время выполнения не зависит от объема данных. | Доступ к элементу массива по индексу. | | | | Логарифмическая | Время растет очень медленно. При увеличении данных в 2 раза требуется всего 1 дополнительный шаг. | Бинарный поиск в отсортированном массиве. | | | | Линейная | Время растет прямо пропорционально объему данных. | Поиск максимума в не отсортированном массиве. | | | | Линейно-логарифмическая | Эффективные алгоритмы для работы с большими наборами данных. | Быстрая сортировка, сортировка слиянием. | | | | Квадратичная | Время растет в квадрате. Плохо подходит для больших данных. | Сортировка пузырьком, вложенные циклы. | | | | Экспоненциальная | Время удваивается при добавлении каждого нового элемента. | Полный перебор вариантов (задача о рюкзаке). | (невыполнимо) |

    Алгоритмы с экспоненциальной сложностью практически неприменимы для больших данных. Если для 10 элементов алгоритм выполнит 1024 операции, то для 50 элементов количество операций превысит миллион миллиардов. На современном компьютере это займет годы вычислений.

    Худший, средний и лучший случаи

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

  • Лучший случай (Best case). Ситуация, при которой алгоритм выполняет минимально возможное количество шагов. Например, если мы ищем число в массиве, и оно оказывается самым первым элементом. Сложность составит .
  • Средний случай (Average case). Ожидаемое время работы для случайного распределения входных данных. Требует применения теории вероятностей для расчетов.
  • Худший случай (Worst case). Ситуация, при которой алгоритм выполняет максимальное количество шагов. В примере с поиском числа — если искомого числа вообще нет в массиве, и программе придется проверить каждый элемент. Сложность составит .
  • В подавляющем большинстве случаев программисты и математики ориентируются на худший случай. Это дает гарантию, что при любых обстоятельствах программа не будет работать медленнее заявленного времени. Оценка по худшему случаю позволяет планировать архитектуру высоконагруженных систем, где задержки недопустимы.

    Итоги

    * Вычислительная сложность позволяет оценивать эффективность алгоритмов независимо от мощности железа и языка программирования. * Два главных ресурса, которые мы экономим — это время (количество операций) и память (объем дополнительных данных). Нотация «О» большое (Big O*) описывает асимптотическое поведение алгоритма, отбрасывая константы и младшие члены. * Алгоритмы делятся на классы: от сверхбыстрых константных до практически невыполнимых экспоненциальных . * При проектировании систем всегда следует ориентироваться на оценку сложности в худшем случае.

    2. Асимптотический анализ и нотация Big O

    Асимптотический анализ и нотация Big O

    В прошлой лекции мы выяснили, что измерять эффективность программ в секундах бессмысленно: мощный сервер выполнит плохой код быстрее, чем старый ноутбук — хороший. Мы пришли к выводу, что нужно считать количество элементарных операций. Но как универсально описать это количество, если для массива из 10 элементов программа делает 50 шагов, а для массива из 1000 элементов — 5 миллионов?

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

    Что такое асимптотический анализ?

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

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

    > Суть асимптотического анализа заключается в ответе на один вопрос: «Что произойдет с моей программой, если объем данных увеличится в 10, 100 или 1 000 000 раз?» > > Томас Кормен, соавтор книги «Алгоритмы: построение и анализ»

    Рассмотрим пример с числами. У нас есть два алгоритма для обработки списка пользователей:

  • Алгоритм А выполняет операций.
  • Алгоритм Б выполняет операций.
  • Где — это количество пользователей.

    Если у нас 10 пользователей (): * Алгоритм А сделает 1000 операций. * Алгоритм Б сделает 100 операций. На малых данных Алгоритм Б кажется в 10 раз эффективнее.

    Но что, если пользователей станет 10 000 ()? * Алгоритм А сделает 1 000 000 операций. * Алгоритм Б сделает 100 000 000 операций.

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

    Нотация Big O: язык оценки алгоритмов

    Для записи результатов асимптотического анализа используется нотация Big O («О» большое). Это математический стандарт, который описывает верхнюю границу сложности алгоритма — то есть гарантирует, что программа не будет работать медленнее определенного предела.

    Строгое математическое определение выглядит следующим образом:

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

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

    Главные правила вычисления Big O

    Чтобы перевести сложный код в простую запись Big O, программисты используют два золотых правила упрощения.

    Правило 1: Отбрасывание констант

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

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

    Правило 2: Отбрасывание младших членов

    Если функция времени работы состоит из нескольких слагаемых, мы оставляем только то, которое растет быстрее всего.

    Допустим, точный подсчет операций в сложном алгоритме дал нам следующую формулу:

    Где: * — общее время работы. * — квадратичная часть (например, вложенные циклы). * — линейная часть (одиночные циклы). * — константные операции (инициализация переменных).

    При : * * *

    Вклад слагаемого составляет всего 0,2% от общего времени, а вообще теряется на фоне миллионов. Поэтому мы смело отбрасываем младшие члены ( и ), затем отбрасываем константу () и получаем итоговую сложность: .

    Омега и Тета: другие стороны медали

    Хотя Big O является самым популярным термином на собеседованиях и в документации, в академической среде используются еще две важные нотации.

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

    Почему же в 99% случаев разработчики говорят именно о Big O? Потому что при проектировании надежных систем (банковских приложений, автопилотов, баз данных) инженеров интересует худший сценарий. Нам нужно гарантировать, что даже при самом неудачном стечении обстоятельств программа не зависнет навсегда.

    Шпаргалка по классам сложности

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

    | Класс сложности | Название | Как меняется время при увеличении данных в 10 раз | Пример из жизни | |---|---|---|---| | | Константная | Не меняется вообще. | Определение четности числа. | | | Логарифмическая | Увеличивается всего на несколько шагов. | Поиск слова в отсортированном словаре. | | | Линейная | Увеличивается ровно в 10 раз. | Чтение книги от корки до корки. | | | Линейно-логарифмическая | Увеличивается примерно в 15-20 раз. | Сортировка колоды карт по мастям и рангам. | | | Квадратичная | Увеличивается в 100 раз. | Сравнение каждого человека в толпе с каждым. |

    Рассмотрим пример квадратичной сложности в коде:

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

    Итоги

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