В информатике анализ алгоритмов — это процесс нахождения вычислительной сложности алгоритмов — количества времени, памяти или других ресурсов, необходимых для их выполнения. Обычно это включает определение функции , которая связывает размер входных данных алгоритма с числом шагов, которые он выполняет (его временная сложность ) или числом ячеек памяти, которые он использует (его пространственная сложность ). Алгоритм считается эффективным, когда значения этой функции малы или растут медленно по сравнению с ростом размера входных данных. Различные входные данные одинакового размера могут привести к разному поведению алгоритма, поэтому описания наилучшего, наихудшего и среднего случая могут представлять практический интерес. Если не указано иное, функция, описывающая производительность алгоритма, обычно является верхней границей , определяемой наихудшими входными данными алгоритма.
Термин «анализ алгоритмов» был придуман Дональдом Кнутом . [1] Анализ алгоритмов является важной частью более широкой теории сложности вычислений , которая дает теоретические оценки ресурсов, необходимых любому алгоритму, решающему заданную вычислительную задачу . Эти оценки дают представление о разумных направлениях поиска эффективных алгоритмов .
В теоретическом анализе алгоритмов принято оценивать их сложность в асимптотическом смысле, т. е. оценивать функцию сложности для произвольно больших входных данных. Для этой цели используются нотации Big O , Big-omega и Big-theta . [2] Например, говорят, что двоичный поиск выполняется за количество шагов, пропорциональное логарифму размера n отсортированного списка, в котором выполняется поиск, или за O (log n ) , в разговорной речи «за логарифмическое время ». Обычно используются асимптотические оценки, поскольку различные реализации одного и того же алгоритма могут отличаться по эффективности. Однако эффективности любых двух «разумных» реализаций данного алгоритма связаны постоянным мультипликативным множителем, называемым скрытой константой .
Иногда можно вычислить точные (не асимптотические) меры эффективности, но обычно они требуют определенных предположений относительно конкретной реализации алгоритма, называемой моделью вычисления . Модель вычисления может быть определена в терминах абстрактного компьютера , например, машины Тьюринга , и/или постулированием того, что определенные операции выполняются за единицу времени. Например, если отсортированный список, к которому мы применяем бинарный поиск, имеет n элементов, и мы можем гарантировать, что каждый поиск элемента в списке может быть выполнен за единицу времени, то для возврата ответа потребуется максимум log 2 ( n ) + 1 единиц времени.
Оценки эффективности времени зависят от того, что мы определяем как шаг. Чтобы анализ соответствовал фактическому времени выполнения, время, необходимое для выполнения шага, должно быть гарантированно ограничено сверху константой. Здесь нужно быть осторожным; например, некоторые анализы считают сложение двух чисел одним шагом. Это предположение может быть необоснованным в определенных контекстах. Например, если числа, участвующие в вычислении, могут быть произвольно большими, время, необходимое для одного сложения, больше не может считаться постоянным.
Обычно используются две модели затрат: [3] [4] [5] [6] [7]
Последний вариант более громоздкий в использовании, поэтому его применяют только при необходимости, например, при анализе арифметических алгоритмов произвольной точности , подобных тем, которые используются в криптографии .
Ключевым моментом, который часто упускается из виду, является то, что опубликованные нижние границы для задач часто даются для модели вычислений, которая более ограничена, чем набор операций, которые вы могли бы использовать на практике, и поэтому существуют алгоритмы, которые быстрее, чем можно было бы наивно считать возможным. [8]
Анализ времени выполнения — это теоретическая классификация, которая оценивает и предсказывает увеличение времени выполнения (или времени выполнения или времени исполнения) алгоритма по мере увеличения его входного размера (обычно обозначаемого как n ). Эффективность времени выполнения — тема, представляющая большой интерес в компьютерной науке : выполнение программы может занять секунды, часы или даже годы, в зависимости от того, какой алгоритм она реализует. Хотя методы профилирования программного обеспечения могут использоваться для измерения времени выполнения алгоритма на практике, они не могут предоставить данные о времени для всех бесконечно многих возможных входных данных; последнее может быть достигнуто только теоретическими методами анализа времени выполнения.
Поскольку алгоритмы не зависят от платформы (т. е. заданный алгоритм может быть реализован на произвольном языке программирования на произвольном компьютере , работающем под управлением произвольной операционной системы ), существуют дополнительные существенные недостатки использования эмпирического подхода для оценки сравнительной производительности заданного набора алгоритмов.
Возьмем в качестве примера программу, которая ищет определенную запись в отсортированном списке размера n . Предположим, что эта программа была реализована на компьютере A, современной машине, с использованием линейного алгоритма поиска, и на компьютере B, гораздо более медленной машине, с использованием бинарного алгоритма поиска . Тестирование производительности на двух компьютерах, на которых запущены соответствующие программы, может выглядеть примерно так:
На основании этих показателей можно легко прийти к выводу, что компьютер A использует алгоритм, который по эффективности намного превосходит алгоритм компьютера B. Однако если размер входного списка увеличить до достаточного значения, этот вывод резко опровергается:
Компьютер A, запускающий линейную программу поиска, демонстрирует линейную скорость роста. Время выполнения программы прямо пропорционально размеру ее входных данных. Удвоение размера входных данных удваивает время выполнения, учетверение размера входных данных учетверяет время выполнения и т. д. С другой стороны, компьютер B, запускающий двоичную программу поиска, демонстрирует логарифмическую скорость роста. Учетверение размера входных данных увеличивает время выполнения только на постоянную величину (в этом примере 50 000 нс). Несмотря на то, что компьютер A, по-видимому, более быстрая машина, компьютер B неизбежно превзойдет компьютер A по времени выполнения, поскольку он запускает алгоритм с гораздо более медленной скоростью роста.
Неформально можно сказать, что алгоритм демонстрирует темп роста порядка математической функции , если за пределами определенного размера входных данных n функция f ( n ), умноженная на положительную константу, обеспечивает верхнюю границу или предел для времени выполнения этого алгоритма. Другими словами, для заданного размера входных данных n, большего некоторого n 0 , и константы c время выполнения этого алгоритма никогда не будет больше, чем c × f ( n ) . Эта концепция часто выражается с помощью нотации Big O. Например, поскольку время выполнения сортировки вставкой растет квадратично с увеличением размера входных данных, можно сказать, что сортировка вставкой имеет порядок O ( n 2 ) .
Обозначение «О большое» — это удобный способ выражения наихудшего сценария для заданного алгоритма, хотя его также можно использовать для выражения среднего случая — например, наихудший сценарий для быстрой сортировки равен O ( n 2 ) , но среднее время выполнения равно O ( n log n ) .
Предполагая, что время выполнения следует правилу мощности, t ≈ kn a , коэффициент a может быть найден [9] путем проведения эмпирических измерений времени выполнения { t 1 , t 2 } в некоторых точках размера проблемы { n 1 , n 2 } и вычисления t 2 / t 1 = ( n 2 / n 1 ) a так, что a = log( t 2 / t 1 )/log( n 2 / n 1 ) . Другими словами, это измеряет наклон эмпирической линии на графике в логарифмическом масштабе времени выполнения против размера входных данных в некоторой точке размера. Если порядок роста действительно следует правилу мощности (и поэтому линия на графике в логарифмическом масштабе действительно является прямой линией), эмпирическое значениеостанется постоянным в разных диапазонах, а если нет, то изменится (и линия будет кривой) — но все равно может служить для сравнения любых двух заданных алгоритмов относительно их эмпирических локальных порядков поведения роста. Применительно к таблице выше:
Ясно видно, что первый алгоритм демонстрирует линейный порядок роста, действительно следуя правилу мощности. Эмпирические значения для второго алгоритма быстро уменьшаются, что говорит о том, что он следует другому правилу роста и в любом случае имеет гораздо более низкие локальные порядки роста (и еще более улучшающиеся) эмпирически, чем первый.
Сложность выполнения для наихудшего сценария данного алгоритма иногда можно оценить, изучив структуру алгоритма и сделав некоторые упрощающие предположения. Рассмотрим следующий псевдокод :
1 получить положительное целое число n из входных данных 2, если n > 103 отпечатка «Это может занять некоторое время...»4 для i = от 1 до n5 для j = 1 для i6 печатать i * j7 печать "Готово!"
Данный компьютер будет тратить дискретное количество времени на выполнение каждой из инструкций, участвующих в реализации этого алгоритма. Скажем, действия, выполняемые на шаге 1, считаются потребляющими время не более T 1 , шаг 2 использует время не более T 2 и т. д.
В алгоритме выше шаги 1, 2 и 7 будут выполнены только один раз. Для оценки наихудшего случая следует предположить, что шаг 3 также будет выполнен. Таким образом, общее время выполнения шагов 1-3 и шага 7 составляет:
Циклы на шагах 4, 5 и 6 сложнее оценить. Внешний цикл проверки на шаге 4 будет выполнен ( n + 1) раз, [10] что потребляет T 4 ( n + 1) времени. Внутренний цикл, с другой стороны, регулируется значением j, которое выполняет итерации от 1 до i . При первом проходе через внешний цикл j выполняет итерации от 1 до 1: Внутренний цикл выполняет один проход, поэтому выполнение тела внутреннего цикла (шаг 6) потребляет T 6 времени, а проверка внутреннего цикла (шаг 5) потребляет 2 T 5 времени. Во время следующего прохода через внешний цикл j выполняет итерации от 1 до 2: внутренний цикл выполняет два прохода, поэтому выполнение тела внутреннего цикла (шаг 6) потребляет 2 T 6 времени, а проверка внутреннего цикла (шаг 5) потребляет 3 T 5 времени.
В целом, общее время, необходимое для выполнения тела внутреннего цикла , можно выразить в виде арифметической прогрессии :
который может быть разложен [11] как
Общее время, необходимое для выполнения теста внутреннего цикла, можно оценить аналогичным образом:
что можно разложить на множители
Таким образом, общее время выполнения этого алгоритма составляет:
что сводится к
В качестве практического правила можно предположить, что член наивысшего порядка в любой заданной функции доминирует над ее скоростью роста и, таким образом, определяет ее порядок времени выполнения. В этом примере n 2 является членом наивысшего порядка, поэтому можно заключить, что f ( n ) = O ( n 2 ) . Формально это можно доказать следующим образом:
Докажите, что
Пусть k будет константой, большей или равной [ T 1 .. T 7 ]. Поэтому
Более элегантный подход к анализу этого алгоритма состоял бы в том, чтобы объявить, что [ T 1 .. T 7 ] все равны одной единице времени, в системе единиц, выбранной так, чтобы одна единица была больше или равна фактическому времени для этих шагов. Это означало бы, что время выполнения алгоритма распадается следующим образом: [12]
Методология анализа времени выполнения может также использоваться для прогнозирования других темпов роста, таких как потребление пространства памяти . В качестве примера рассмотрим следующий псевдокод, который управляет и перераспределяет использование памяти программой на основе размера файла , которым эта программа управляет:
пока файл все еще открыт: пусть n = размер файла на каждые 100 000 килобайт увеличения размера файла удваивают объем зарезервированной памяти
В этом случае, по мере увеличения размера файла n, память будет потребляться с экспоненциальной скоростью роста , которая имеет порядок O (2 n ) . Это чрезвычайно быстрая и, скорее всего, неуправляемая скорость роста потребления ресурсов памяти .
Анализ алгоритмов важен на практике, поскольку случайное или непреднамеренное использование неэффективного алгоритма может существенно повлиять на производительность системы. В чувствительных ко времени приложениях алгоритм, работающий слишком долго, может сделать свои результаты устаревшими или бесполезными. Неэффективный алгоритм также может потребовать неэкономного количества вычислительной мощности или хранилища для работы, что снова делает его практически бесполезным.
Анализ алгоритмов обычно фокусируется на асимптотической производительности, особенно на элементарном уровне, но в практических приложениях важны постоянные факторы, а реальные данные на практике всегда ограничены по размеру. Пределом обычно является размер адресуемой памяти, поэтому на 32-битных машинах 2 32 = 4 GiB (больше, если используется сегментированная память ), а на 64-битных машинах 2 64 = 16 EiB. Таким образом, учитывая ограниченный размер, порядок роста (времени или пространства) можно заменить постоянным фактором, и в этом смысле все практические алгоритмы являются O (1) для достаточно большой константы или для достаточно малых данных.
Эта интерпретация в первую очередь полезна для функций, которые растут чрезвычайно медленно: (двоичный) итеративный логарифм (log * ) меньше 5 для всех практических данных (2 65536 бит); (двоичный) логарифм-логарифм (log log n ) меньше 6 практически для всех практических данных (2 64 бита); и двоичный логарифм (log n ) меньше 64 практически для всех практических данных (2 64 бита). Алгоритм с непостоянной сложностью может, тем не менее, быть более эффективным, чем алгоритм с постоянной сложностью на практических данных, если накладные расходы алгоритма постоянного времени приводят к большему постоянному множителю, например, можно иметь так долго, как и .
Для больших данных линейные или квадратичные факторы не могут быть проигнорированы, но для малых данных асимптотически неэффективный алгоритм может быть более эффективным. Это особенно используется в гибридных алгоритмах , таких как Timsort , которые используют асимптотически эффективный алгоритм (здесь сортировка слиянием , с временной сложностью ), но переключаются на асимптотически неэффективный алгоритм (здесь сортировка вставкой , с временной сложностью ) для малых данных, поскольку более простой алгоритм быстрее на малых данных.