1. Сущность алгоритмической сложности: почему секунды не подходят для оценки производительности
Сущность алгоритмической сложности: почему секунды не подходят для оценки производительности
Два разработчика написали функцию поиска дубликатов в базе данных пользователей. Первый запустил свой код на рабочем сервере, и обработка миллиона записей заняла 0,5 секунды. Второй запустил свой вариант на старом офисном ноутбуке, и код отработал за 2 секунды. На техническом ревью выясняется, что код второго разработчика в тысячи раз эффективнее, а решение первого полностью обрушит систему, если база вырастет до десяти миллионов пользователей. Измерение скорости программы с помощью секундомера — одна из самых опасных ловушек в инженерии программного обеспечения.
Привязка к физическому времени создает иллюзию объективности. Кажется логичным: чем меньше секунд выполняется скрипт, тем лучше алгоритм. Однако физическое время выполнения — это нестабильная метрика, которая зависит от множества переменных, не имеющих отношения к качеству самого кода.
Первая переменная — аппаратное обеспечение. Процессоры имеют разную тактовую частоту, разный объем кэш-памяти уровней L1/L2/L3 и разную архитектуру предсказания ветвлений.
Вторая переменная — среда выполнения и операционная система. В любой момент времени планировщик задач ОС может приостановить выполнение вашей программы, чтобы выделить ресурсы фоновому обновлению системы, антивирусу или сборщику мусора в виртуальной машине языка программирования. Если вы запустите один и тот же код сто раз подряд, вы получите сто разных результатов в миллисекундах.
Третья переменная — компилятор или интерпретатор. Один и тот же цикл на языке C, скомпилированный с флагом оптимизации -O3, и скрипт на Python будут иметь колоссальную разницу в скорости из-за накладных расходов на интерпретацию и динамическую типизацию, хотя математическая логика алгоритма останется идентичной.
Но самая главная проблема физического времени заключается в том, что оно не дает ответа на фундаментальный вопрос: как поведет себя программа при увеличении объема входных данных?
Инженерам нужен инструмент оценки, который абстрагирован от мощности процессора, загруженности оперативной памяти и языка программирования. Эта метрика должна оценивать саму математическую суть алгоритма.
Переход к логическому времени: подсчет базовых операций
Чтобы избавиться от аппаратной зависимости, в информатике используется абстрактная вычислительная модель, известная как RAM-машина (Random Access Machine). В этой модели мы отказываемся от секунд и начинаем считать «шаги» или базовые операции.
> Базовая операция — это элементарное действие, которое процессор выполняет за условно постоянное время, независимо от обрабатываемых значений.
К базовым операциям относятся:
Вместо того чтобы говорить «алгоритм работает 2 секунды», мы говорим «алгоритм выполняет операций», где — это размер входных данных. Размер данных может означать количество элементов в массиве, длину строки, количество узлов в графе или просто величину переданного числа.
Рассмотрим процесс поиска конкретного числа в массиве. Алгоритм последовательно проверяет каждый элемент, пока не найдет нужный.
Если массив состоит из 10 элементов, в худшем случае (если искомого числа нет или оно стоит на последнем месте) алгоритму потребуется сделать 10 проверок. Если элементов 1 000 000 — потребуется миллион проверок.
Обозначим количество операций как функцию от размера данных . Для линейного поиска функция времени выполнения будет выглядеть примерно так:
Здесь:
Эта формула описывает алгоритм гораздо точнее, чем секундомер. Она показывает, что время выполнения растет пропорционально размеру данных.
Асимптотический анализ: взгляд в бесконечность
Формула полезна, но на практике инженеры редко высчитывают точное количество операций вплоть до единиц. Причина кроется в концепции масштабирования.
Представьте, что размер данных становится огромным — миллиард элементов. Если , то . Прибавка константы к трем миллиардам не имеет никакого математического или практического веса. Точно так же, разница между и меркнет, когда мы сравниваем их с алгоритмом, которому требуется операций.
Именно здесь на сцену выходит нотация О-большое (Big O). Асимптотический анализ изучает поведение функции при стремлении аргумента к бесконечности. Big O описывает верхнюю границу сложности алгоритма, отбрасывая все несущественные детали: константы и младшие члены уравнения.
Вместо мы просто пишем . Это читается как «О от эн» и означает, что при увеличении объема данных в 10 раз, количество работы, требуемой алгоритму, также увеличится примерно в 10 раз. Это линейная сложность.
Если мы используем вложенные циклы, где для каждого из элементов нужно снова пройтись по всем элементам, мы получим функцию вида . В нотации Big O это записывается как — квадратичная сложность. При увеличении входных данных в 10 раз, время выполнения такого алгоритма вырастет в 100 раз.
!Сравнение роста операций для O(n) и O(n^2)
Почему железо не спасет плохой алгоритм
Чтобы окончательно понять, почему оценка в операциях важнее мощности процессора, проведем мысленный эксперимент. Устроим соревнование между двумя системами:
На кластере мы запустим алгоритм со сложностью , а на часах — алгоритм со сложностью . Задача — обработать массив данных размером элементов.
Посчитаем логическое время (количество операций): Для кластера: операций. Для часов: операций.
Теперь переведем это в физическое время, разделив количество операций на мощность устройства: Время кластера: секунд. Время часов: секунды.
Слабое устройство, работающее в тысячу раз медленнее, обогнало мощный сервер в 100 раз исключительно за счет правильной алгоритмической сложности. Никакое наращивание серверных мощностей, добавление оперативной памяти или покупка дорогих процессоров не компенсирует экспоненциальный или квадратичный рост количества операций при увеличении базы данных.
Худший, средний и лучший сценарии
При оценке алгоритма с помощью Big O инженеры почти всегда говорят о худшем сценарии (worst-case scenario).
Возвращаясь к поиску числа в массиве: если искомое число находится в самой первой ячейке, алгоритм завершит работу за одну итерацию, независимо от того, миллион элементов в массиве или миллиард. Это лучший случай, и он обозначается как — константное время.
Но при проектировании надежных систем нельзя опираться на удачу. Сервер не должен падать из-за того, что нужная запись случайно оказалась в конце таблицы. Поэтому, когда мы говорим, что линейный поиск имеет сложность , мы подразумеваем именно верхнюю границу — максимальное количество работы, которое алгоритм будет вынужден проделать при самом неудачном стечении обстоятельств.
Оценка сложности заставляет разработчика смотреть на код через призму масштабирования. Строка кода, которая внутри небольшого тестового набора выполняется мгновенно, может стать узким горлышком всей системы в продакшене. Понимание того, как логические операции накапливаются в зависимости от размера , отделяет интуитивное программирование от инженерного проектирования.