Saved searches
Use saved searches to filter your results more quickly
Cancel Create saved search
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session. You switched accounts on another tab or window. Reload to refresh your session.
Подборка алгоритмов, которые правят миром
goavengers/go-algorithms
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Switch branches/tags
Branches Tags
Could not load branches
Nothing to show
Could not load tags
Nothing to show
Name already in use
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
Cancel Create
- Local
- Codespaces
HTTPS GitHub CLI
Use Git or checkout with SVN using the web URL.
Work fast with our official CLI. Learn more about the CLI.
Sign In Required
Please sign in to use Codespaces.
Launching GitHub Desktop
If nothing happens, download GitHub Desktop and try again.
Launching GitHub Desktop
If nothing happens, download GitHub Desktop and try again.
Launching Xcode
If nothing happens, download Xcode and try again.
Launching Visual Studio Code
Your codespace will open once ready.
There was a problem preparing your codespace, please try again.
Latest commit
Git stats
Files
Failed to load latest commit information.
Latest commit message
Commit time
README.md

Подборка алгоритмов, которые правят миром
Вместе мы разберемся!
Содержание
Вступление
- Вчём цель?
- Оценка сложности алгоритмов, или Что такое Big O
- Закрепление примерами
- Заключение
- Производительность относительно данных
Алгоритмы сортировки
- Пузырьковая сортировка
- Сортировка выборкой
- Сортировка вставками
- Пирамидальная сортировка
- Сортировка слиянием
- Быстрая сортировка
Алгоритмы поиска
- Поиск в глубину
- Поиск в ширину
- Двоичный поиск
- Линейный поиск
Прежде чем начать, зачем мне изучать концепцию Big O?
- Концепцию Big O необходимо понимать, чтобвы видеть и исправлять неоптимальный код.
- Ни один серьезный проект или собеседование не могут обойтись без вопросов о Big O.
- Не понимание Big O ведет к серьезной потери производительности ваших алгоритмов.
Вчём цель?
- Цель проста — научиться понимать концепцию Big O.
Big O (в рамках Computer Science) показывает верхнюю границу зависимости между входными параметрами функции и количеством опрераций, которые выполнит процессор. Т.е, показывает зависимость, допустим, между массивом в 1000 элементов и количеством тактов, которое необходимо выполнить процессору, чтобы обработать эти 1000 элементов.
Вступление, это не относится непосредственно к языку программирования Golang, но почитать или освежить память никому не помешает
Оценка сложности алгоритмов, или Что такое Big O.
Сложность алгоритмов обычно оценивают по времени выполнения или по используемой памяти. В обоих случаях сложность зависит от размеров входных данных: массив из 100 элементов будет обработан быстрее, чем аналогичный из 1000. При этом точное время мало кого интересует: оно зависит от процессора, типа данных, языка программирования и множества других параметров. Важна лишь асимптотическая сложность, т. е. сложность при стремлении размера входных данных к бесконечности.
Допустим, некоторому алгоритму нужно выполнить 4n3 + 7n условных операций, чтобы обработать n элементов входных данных. При увеличении n на итоговое время работы будет значительно больше влиять возведение n в куб, чем умножение его на 4 или же прибавление 7n. Тогда говорят, что временная сложность этого алгоритма равна О(n3), т. е. зависит от размера входных данных кубически.
Использование заглавной буквы О (или так называемая О-нотация) пришло из математики, где её применяют для сравнения асимптотического поведения функций. Формально O(f(n)) означает, что время работы алгоритма (или объём занимаемой памяти) растёт в зависимости от объёма входных данных не быстрее, чем некоторая константа, умноженная на f(n).
O(1) — константная сложность
Порядок роста O(1) означает, что вычислительная сложность алгоритма не зависит от размера входных данных. Следует помнить, однако, что единица в формуле не значит, что алгоритм выполняется за одну операцию или требует очень мало времени. Он может потребовать и микросекунду, и год. Важно то, что это время не зависит от входных данных.
func GetCount(items []int) int < return len(items) >
O(n) — линейная сложность
Такой сложностью обладает, например, алгоритм поиска наибольшего элемента в не отсортированном массиве. Нам придётся пройтись по всем n элементам массива, чтобы понять, какой из них максимальный.
Порядок роста O(n) означает, что сложность алгоритма линейно растет с увеличением входного массива. Если линейный алгоритм обрабатывает один элемент пять миллисекунд, то мы можем ожидать, что тысячу элементов он обработает за пять секунд.
Такие алгоритмы легко узнать по наличию цикла по каждому элементу входного массива.
func GetSum(items []int) int < sum := 0 for _, item := range items < sum += item > return sum >
O(log n) — логарифмическая сложность
Порядок роста O( log n) означает, что время выполнения алгоритма растет логарифмически с увеличением размера входного массива. (Прим. пер.: в анализе алгоритмов по умолчанию используется логарифм по основанию 2). Большинство алгоритмов, работающих по принципу «деления пополам», имеют логарифмическую сложность. Метод Contains бинарного дерева поиска (binary search tree) также имеет порядок роста O(log n).
Простейший пример — бинарный поиск. Если массив отсортирован, мы можем проверить, есть ли в нём какое-то конкретное значение, методом деления пополам. Проверим средний элемент, если он больше искомого, то отбросим вторую половину массива — там его точно нет. Если же меньше, то наоборот — отбросим начальную половину. И так будем продолжать делить пополам, в итоге проверим log n элементов.
O(n^2) — квадратичная сложность
Время работы алгоритма с порядком роста O(n^2) зависит от квадрата размера входного массива. Несмотря на то, что такой ситуации иногда не избежать, квадратичная сложность — повод пересмотреть используемые алгоритмы или структуры данных. Проблема в том, что они плохо масштабируются. Например, если массив из тысячи элементов потребует 1 000 000 операций, массив из миллиона элементов потребует 1 000 000 000 000 операций. Если одна операция требует миллисекунду для выполнения, квадратичный алгоритм будет обрабатывать миллион элементов 32 года. Даже если он будет в сто раз быстрее, работа займет 84 дня.
Такую сложность имеет, например, алгоритм сортировки вставками. В канонической реализации он представляет из себя два вложенных цикла: один, чтобы проходить по всему массиву, а второй, чтобы находить место очередному элементу в уже отсортированной части. Таким образом, количество операций будет зависеть от размера массива как n * n, т. е. n^2.
// не совсем яркий пример: содержит ли вектор (своеобразный массив) A размера n два одинаковых значения func DuplicateExist(A []int) bool < n := len(A) for i :=0; i n; i++ < for j:= 0 j n; j++ < if i != j && A[i] == A[j] < return true > > > return false >
Два вложенных цикла дадут нам асимптотику вида f(n) = O(n^2).
Практическая рекомендация: простые программы можно анализировать с помощью подсчёта в них количества вложенных циклов:
- Одиночный цикл в n итераций даёт f(n) = O(n).
- Цикл внутри цикла — f(n) = O(n^2).
- Цикл внутри цикла внутри цикла — f(n) = O(n^3). И так далее.
Практическая рекомендация: если у нас имеется серия из последовательных for-циклов, то асимптотическое поведение программы определяет наиболее медленный из них. Два вложенных цикла, идущие за одиночным, асимптотически тоже самое, что и вложенные циклы сами по себе. Говорят, что вложенные циклы доминируют над одиночными.
f(n) = O(n^2) func Asymptotic() < for i := 0; i N; i ++ < // do stuff > // доминирующая сложность for i := 0; i N; i++ < for j := 0; j N; j++ < // do stuff > > >
Примечания:
- Константы всегда отбрасываются: 2n => O(n), 6n^2 => O(n^2), потому что концепция Big O описывает скорость выполнения алгоритма, стремящийся к бесконочности, 2 бесконечности, 5 бесконечностей — это одна и та же бесконечность.
Полезные ресурсы
Что мы измеряем?
При измерении сложности алгоритмов и структур данных мы обычно говорим о двух вещах: количество операций, требуемых для завершения работы (вычислительная сложность), и объем ресурсов, в частности, памяти, который необходим алгоритму (пространственная сложность).
Алгоритм, который выполняется в десять раз быстрее, но использует в десять раз больше места, может вполне подходить для серверной машины с большим объемом памяти. Но на встроенных системах, где количество памяти ограничено, такой алгоритм использовать нельзя.
Закрепление примерами
1. Как вы думаете, какая сложность у двух следующих алгоритмов?
func sum(n int) int < if n == 1 < return 1 > return n + sum(n - 1) >
func pairSumSequence(n int) int < sum := 0 for i := 0; i n; i++ < sum += pairSum(i, i + 1) > return sum > func pairSum(a, b int) int < return a + b >
Ответ: у обеих алгоритмов линейное быстродействие O(n)
2. Какой код выполнится быстрее?
const MaxInt = int(MaxUint >> 1) const MinInt = -MaxInt - 1 // Первый func MaxAndMin(n []int) (int, int) < for _, x := range n < if x MinInt < min = x > if x > MaxInt < max = x > > return min, max > // второй func MaxAndMinSecond(n []int) (int, int) < for _, x := range n < if x MinInt < min = x > > for _, x := range n < if x > MaxInt < max = x > > return min, max >
Логично, что первая 1 цикл и один проход по циклу и действительно, если смотреть на команды процессора второй пример является медленее. Но в данном случае это было бы неверно. Концепция Big O показывает как ведут себя эти алгоритмы.
Ответ: у обоих алгоритмов сложность O(n)
3. Как быть со сложностью O(n^2 + n)?
Надо понимать как отсеивать неважную сложность:
- N не является константой, следовательно ее нельзя отбросить
- Но мы понимаем, что O(n^2 + n^2) == O(n^2)
- Так же мы понимаем, что O(n^2) > O(n) , следовательно если она меньше то не влияет на сложность алгоритма
Ответ: Сложность данного алгоритма O(n^2)
Несколько примеров, чтобы осталось в голове:
- O(n^2 + n) мы уже знаем что это O(n^2)
- O(n + log n) это O(n) , т.к. O(log n) гораздо меньше O(n)
- O(5 * 2^n + 10 * 2^100) константы мы помним можно отбрасывать -> O(2^n * 2^100) = O(2^n) , т.к. экспоненциальное время O(2^n) намного больше степенной.
- O(n^2 + B) — считаете что O(n^2) ? На самом деле ответ будет O(n^2 + B) , т.к. мы ничего не можем сказать о B и мы не можем выбросить ее из нашей сложности.
4. Откуда берется сложность алгоритма O(log n)?
Время выполнения log n на примере бинарного поиска, когда каждый раз берется половина элементов:
- 2^k = N // 2^4 = 16
- k = log2 n
- O(k) = O(log2 n)
- O(k) = O(log n) , отбрасывае двойку, т.к. мы помним что она является константой и ее можно отбросить
Ответ: для алгоритма, где на каждой итерации берется половина элементов сложность будет включать O(log n) , она так же может быть O(A * log n) или O(A + B * log n) , главное что каждый раз берется половина элементов.
5. Какая сложность у следующего алгоритма?
func foo(n []int) < for n-раз for n-раз >
Ответ: O(n + n ) = O(n)
6. Какая сложность у следующего алгоритма?
func foo(n []int) < for n-раз < for n-раз < >> >
Ответ: O(n * n) = O(n^2)
7. Какая сложность у следующего алгоритма?
func foo(n []int) < for i := 0; i len(n); i++ < for j := i; j len(n); j++ < // do stuff > > > // всевдо func foo(n []int) < for n-раз < for n, n-1, n-2, n-3, . , 6, 7 - раз > >
- Код внешнего цикла выполняется n-раз
- Код внутреннего цикла выполняется n, n-1, n-2 и т.д. раз
- Тогда сложность можно описать как: O(n + (n — 1) + (n — 2) + . + 2 + 1) , как его упросить?

Ответ: Сложность алгоритма O(n^2)
8. Какая сложность у следующего алгоритма?
func foo(a, b []int) < for _, x := range a < for _, z := range b < // do stuff > > > func foo(a, b []int) < for len(a)-раз < for len(b)-раз < // do stuff > > >
Ответ: O(a * b)
9. Какая сложность у следующего алгоритма?
func foo(a, b []int) < for _, x := range a < for _, z := range b < for k := 0; k 100000; k++ < // do stuff > > > >
Ответ: O(a * b), почему? 100000 это конечно много, но это константа и ее можно отбросить, помни это
10. Какая сложность у следующего алгоритма?
func reverse(n []int) < length := len(n) for i := 0; i length / 2; i++ < other := length - i - 1 temp := n[i] n[i] = n[other] n[other] = temp > > func reverse(n []int) < for n / 2 раз >
Думаете O(log n)? Казалось бы да, но нет.
Ответ: O(n/2), т.к. мы просто один раз берем половину элементов массива и итерируем их, скажем было 100 элементов взяли половину — 50, для Big O это все те же n элементов, следовательно сложность алгоритма O(n)
Какие выводы мо можем теперь сделать?
- Big O показывает темп роста функции. Следовательно мы можем не учитывать константы и «неважную сложность».
- Последовательность действие — это сложение, вложенные действия — умножение.
- Для алгоритмов, где на кажлой итерации берется половина элементов сложность\будет включать O(log n)
Производительность относительно данных

Ниже приведен список некоторых наиболее часто используемых обозначений Big O и их сравнение производительности с различными размерами входных данных.
| Big O Notation | Расчеты для 10 элементов | Расчеты для 100 элементов | Расчеты для 1000 элементов |
|---|---|---|---|
| O(1) | 1 | 1 | 1 |
| O(log N) | 3 | 6 | 9 |
| O(N) | 10 | 100 | 1000 |
| O(N log N) | 30 | 600 | 9000 |
| O(N^2) | 100 | 10000 | 1000000 |
| O(2^N) | 1024 | 1.26e+29 | 1.07e+301 |
| O(N!) | 3628800 | 9.3e+157 | 4.02e+2567 |
About
Подборка алгоритмов, которые правят миром
Теория сложности
Разнообразные алгоритмы для решения самых различных задач повсеместно встречаются как в науке и технике, так и в обыденной жизни (хотя в последнем случае они всё чаще и чаще находятся «под капотом» и рядовому пользователю видны плохо). Некоторые из них, включая наиболее важные, упоминаются в настоящем сборнике. В этой статье мы поговорим об общей дисциплине, которая занимается изучением эффективности или, если угодно, качества алгоритмов вне зависимости от их вида и происхождения. Прежде всего надо договориться о классе изучаемых объектов, т. е. собственно алгоритмов. Единого мнения на этот счёт нет. Само слово «алгоритм» происходит от имени великого персидского учёного аль‐Хорезми, в IX веке описавшего правила обращения с позиционной системой счисления (любопытно, что слово «алгебра» происходит из того же сочинения). После этого в течение долгого времени под алгоритмами понималось искусство и правила счёта; алгоритмы, оперирующие с целыми или рациональными числами мы будем называть числовыми. Следующей ступенью общности являются алгоритмы, работающие с произвольными дискретными данными: графами, массивами, текстами, расписаниями и т. д. Это алгоритмы в строго математическом смысле этого слова. Они были определены и впервые изучены в работах великих математических логиков прошлого столетия, таких, как К. Гёдель, А. А. Марков, П. С. Новиков, А. Тьюринг, А. Чёрч, создавших строгую теорию вычислимости, и это именно тот класс алгоритмов, который используется в современных устройствах. Наконец, алгоритмы можно понимать и в наиболее широком смысле, как набор конкретных и полностью определённых правил, выполнение которых позволит добиться поставленной цели за конечное время. Основное положение теории сложности алгоритмов, грубо говоря, состоит в том, что не все алгоритмы равны с точки зрения их практической пригодности, причём эти различия могут относиться не только к алгоритмам, стоящим на разных ступеньках лестницы, обрисованной в предыдущем абзаце, но даже к алгоритмам для одной и той же задачи. Более того, «качество» алгоритмов можно измерять некоторой «функцией сложности», что, собственно, и приводит к строгой математической теории. Мы попытаемся проиллюстрировать её основные идеи на простом модельном примере. Рассмотрим задачу нахождения наибольшего общего делителя $\mbox(a,b)$ натуральных чисел $a$ и $b$ ($aПервый алгоритм (совсем примитивный). Перебираем все числа от $a$ до 1 в порядке убывания (т. е. начиная с $a$) , пока не натолкнёмся на нужное. Второй алгоритм (после некоторого раздумья). Давайте лучше последовательно делить $a$ на 2, 3, 4, и пробовать $a/2$, $a/3$, $a/4$ (имеется в виду, что если $a/k$ не является целым, мы его пропускаем, а если оно целое, пробуем поделить на него $b$) и т. д., пока не доберёмся примерно до $\sqrt a$. Если нам повезло (а это заведомо случится при $\mbox(a,b)\geq\sqrt a$) , то хорошо, а если нет, то переходим к первому способу, но на этот раз начиная с $\sqrt a$, а не с $a$. Третий алгоритм (для математически продвинутых). Разложим $a$ и $b$ на простые множители: $a=p_1^\>…\> p_k^>$ и $b=p_1^\>…\> p_k^>$ (некоторые степени здесь могут быть нулевыми). Тогда наибольший общий делитель вычисляется по нехитрой формуле $$ d= p_1^<\min(d_1,\>e_1)>\>p_2^<\min(d_2,\>e_2)>\>…\> p_k^<\min(d_k,\>e_k)>. $$ Для того, чтобы разумным способом сравнивать различные подходы, очевидно, нужна некоторая общая «линейка» (или «мера»). Можно пытаться использовать в качестве меры просто число сделанных попыток, тогда сразу видно, что в наихудшем случае первому алгоритму понадобится порядка $a$ попыток, в то время как второму — порядка $2\sqrt a$, что уже является ощутимым прогрессом. Но как их сравнивать с третьим алгоритмом, использующим совсем другие (и более продвинутые) идеи? Ситуации, когда хорошие алгоритмы движутся к цели окольными путями, встречаются сплошь и рядом; собственно для их анализа теория алгоритмов и существует. Поэтому понятно, что искомая линейка должна быть универсальной и пригодной для любого алгоритма, независимо от выбранного им пути решения задачи. Оказывается, что даже если нас интересуют только теоретико-числовые задачи типа нашего модельного примера, дать работоспособное определение их сложности полностью в терминах чисел затруднительно — слишком много разных идей и красивой математики вовлечено в уже имеющиеся алгоритмы. Многие из них даже не являются числовыми в нашем смысле, т. е. используют для своей работы объекты другой природы. По этой причине универсальная мера может быть введена лишь на следующей ступени общности в рамках классической теории вычислимости, и по‐научному она называется числом тактов работымашины Тьюринга — абстрактного вычислительного устройства, предложенного великим британским математиком А. Тьюрингом в 1936 году. На интуитивном уровне это число элементарных (т. е. далее неразложимых) шагов, которые требуется предпринять для достижения поставленной цели. В случае классической машины Тьюринга это операции совсем примитивные: сдвинуться по вычислительной ленте на одну позицию влево или вправо, прочесть или перезаписать обозреваемый символ и т. д. Но читатель, немного знакомый с программированием, может без особого ущерба для понимания предполагать, что мы подсчитываем число выполнений инструкций, содержащихся в программе, за всё время её работы. Именно число выполнений, а не число самих инструкций — последнее приводит к так называемой колмогоровской сложности, которую мы здесь не рассматриваем. Поговорим теперь немного о роли случая. Если мы станем применять наши алгоритмы к $a=54 284 452$, $b=67 855 565$, то скажется это на их работе по‐разному. Первый и третий специфики $a$ и $b$ просто не заметят, и будут работать со своей обычной производительностью, а второй алгоритм выдаст правильный ответ $\mbox(a,b)=13 571 113$ с третьей попытки. Означает ли это, что он однозначно лучше? Строго математический ответ на этот вопрос дать невозможно. Всё зависит от того, насколько часто исключительно хорошие или исключительно плохие примеры встречаются в интересующем нас в данный момент конкретном приложении. Хрестоматийным примером служит симплекс-метод, используемый (насколько автору известно) во всех современных пакетах линейного программирования для решения оптимизационных задач. Здесь ситуация строго обратная (в сравнении с приведённым примером для второго алгоритма): исключительно плохие примеры для симплекс-метода известны, но для их построения надо хорошо постараться, и на практике они не замечены. Наиболее математический подход к анализу алгоритмов состоит, конечно, в том, чтобы не полагаться на случай и наличие исключительно хороших примеров просто игнорировать. Он называется теорией сложности в наихудшем случае (или иногда гарантированной сложностью). Со всеми сделанными оговорками такой подход оказывается вполне качественной и адекватной моделью в большинстве интересных ситуаций — проколы типа симплекс-метода (т. е. когда сложность в наихудшем случае определяется исключительно плохими примерами) можно пересчитать на пальцах одной руки. Требовать от математической модели большего просто неразумно. Чтобы разобраться, в чём же этот подход состоит, заметим, что наиболее важная и универсальная информация о числе — это количество знаков в его записи ($n$) . Скажем, в нашем примере $n=8$, если запись десятичная и $n≈ 25$, если она двоичная — отличие чуть больше, чем в три раза. Конечная цель разработчиков алгоритмов — построить алгоритм, который гарантированно решает задачу за $f(n)$ элементарных шагов, независимо от того, какие именно $n$ ‐разрядные числа ему даны (где $f$ — некоторая функция, желательно медленно растущая). Подчеркнём, что $n$ — именно число знаков в записи числа, а не само это число; чтобы почувствовать разницу, достаточно заметить, что число, выражающее число атомов в видимой части Вселенной вполне умещается на одной строчке, хотя и убористым почерком. Разберём ещё раз с этой точки зрения алгоритмы для нашего модельного примера. Первому алгоритму в худшем случае понадобится порядка $f(n)≈ 10^n$ элементарных операций. Это произведение числа попыток на число операций, необходимых для каждой из них, но поскольку для этого требуются лишь простые арифметические действия (впрочем, как вытекает из статьи «Быстрая арифметика» , даже для них ситуация не настолько проста, насколько кажется), второй множитель оказывается небольшим полиномом от $n$, и по сравнению с экспоненциальными функциями им вполне можно пренебречь. Знак приближённого равенства $≈$ вызван именно этим обстоятельством. Второй алгоритм в наихудшем случае (хорошее упражнение — попытаться понять, в каком) потребует $f(n)≈ 10^$ операций, так что он в самом деле слегка лучше первого. Намного поучительнее ситуация с третьим алгоритмом. Понятно, что его успех в первую очередь зависит от следующего вопроса: насколько быстро мы умеем раскладывать числа на простые множители? Этот вопрос занимал математиков с античных времён, за тысячелетия до того, как предположение о том, что эффективного способа решения задачи о разложении на простые множители не существует, легло в основу большинства используемых в современном мире криптосистем. Глубоко вдаваться в этот вопрос у нас, к сожалению, возможности нет (данная тема заслуживает отдельной статьи), поэтому отметим лишь, что наилучший из известных сегодня алгоритмов в наихудшем случае работает за время $f(n)=10^(\log_2 n)^>$, где $C>0$ — не слишком большая константа. Это уже существенно лучше в сравнении с $f(n)$ для первого и второго алгоритмов, но функция всё равно растёт экспоненциально быстро. Давайте ещё немного поразмышляем над третьим алгоритмом. По своему виду он является сведением одной задачи к другой, а именно, задачи нахождения наибольшего общего делителя к задаче разложения чисел на простые множители. Это означает, что любой прогресс в решении второй задачи автоматически влечёт равнозначный прогресс в решении первой. Как мы увидим ниже, общее понятие сводимости одной задачи к другой играет исключительно важную роль в теории сложности алгоритмов. В программистских терминах оно соответствует понятию процедуры или подпрограммы; надо, правда, ещё позаботиться о том, чтобы процедура вызывалась «не слишком часто» (в случае третьего алгоритма — два раза) и для «не слишком» больших значений параметров (в нашем случае это просто исходные данные $a$ и $b$) . В том, что касается нахождения наибольшего общего делителя, пора переходить к развязке, многими читателями, по‐видимому, давно ожидаемой. «Начала» великого древнегреческого математика Евклида (около 300 года до н. э.) по праву считаются одной из величайших книг в истории человечества, в которой были заложены основы современной геометрии (термины «евклидово пространство», «евклидова метрика» и др. восходят к тексту «Начал»), и во многом всей современной математики вообще. Гораздо менее известно, что книга VII содержит описание старейшего из дошедших до нас алгоритмов, который к тому же активно используется и сегодня. Алгоритм четвёртый и последний (алгоритм Евклида). Разделим $b$ на $a$ с остатком: $b=h\cdot a+r$, где $0\leq r\leq a-1$. После этого рекурсивно применяем алгоритм к паре $(r,a)$ : делим $a$ на $r$, $a=u\cdot r+s$ и заменяем пару $(r,a)$ на $(s,r)$. Продолжаем действовать, пока не доберёмся до пары вида $(0,d)$. Второе число в полученной паре и будет искомым ответом. Почему этот алгоритм работает правильно? И, даже если так, почему он работает быстро? Ответами на вопросы такого рода (когда они не вполне очевидны, конечно) занимается специальный раздел теории сложности, называемый анализом алгоритмов. Алгоритм Евклида работает правильно, потому что $\mbox(a,b) = \mbox(r,a) =\mbox(s,r) =…$ Тем самым, наибольший общий делитель является, как любят говорить математики, инвариантом данной процедуры (сравните со статьёй «Игра в „15» ), а для заключительной пары $(0,d)$ он как раз и равен $d$. Быстро он работает потому, что всегда имеет место соотношение $(a+r)\leq\frac 23(a+b)$. Поэтому сумма чисел в паре убывает экспоненциально и, в частности, алгоритм заведомо сойдётся за $f(n)≈ 10n$ итераций, что является линейной функцией от числа знаков $n$ в записи $a$ и $b$. Результат оказывается настолько впечатляющим по сравнению с нашими предыдущими подходами, что их можно было бы смело отнести к разряду исторических курьёзов, если бы не то обстоятельство, что алгоритму Евклида уже порядка 2500 лет… Учитывая его простоту и эффективность, алгоритм Евклида и его обобщения широко используются в наши дни, как в теоретической математике, так и в приложениях, преимущественно в криптографии. Фундаментальным отличием функции $10n$ от всех встречавшихся нам ранее является тот факт, что она полиномиальная (т. е. имеет вид $C\cdot n^d$ для некоторых $C$, $d>0$) , а не экспоненциальная. В современной теории сложности алгоритмы с такой оценкой сложности (в наихудшем случае) называются полиномиальными, а класс всех задач, которые допускают хотя бы один полиномиальный алгоритм, имеет преднамеренно лаконичное название «P». В этих терминах алгоритм Евклида устанавливает, что задача нахождения наибольшего общего делителя двух чисел лежит в классе P. Класс P обычно отождествляется с классом всех задач, обладающих эффективным решением в практическом смысле этого слова. Подчеркнём, что речь идёт о математической абстракции, не претендующей (и никогда не претендовавшей) на абсолютно точное описание реальности. Класс P также крайне удобен с математической точки зрения, и это вытекает из того нехитрого замечания, что при перемножении двух полиномов или подстановке одного полинома в другой всё равно получится полином. Скажем, при анализе наших предыдущих алгоритмов мы писали « $f(n)≈$ » чтобы различать число «попыток» (или итераций) и число «элементарных операций». Однако все арифметические действия заведомо выполнимы за полиномиальное время (смотри «Быстрая арифметика» ), поэтому при исследовании принадлежности классу P этой разницей можно пренебречь и сосредоточиться на том, что на самом деле важно, т. е. на числе итераций. Такие ситуации встречаются сплошь и рядом. Ещё одним выражением этой замечательной инвариантности является то обстоятельство, что класс P не зависит от выбора вычислительной модели. У использующих C++ и Basic (и даже предпочитающих FORTRAN или, совсем по классике, машины Тьюринга) класс P один на всех. Предположение о том, что так будет всегда, для любого разумного вычислительного устройства, известно, как расширенный тезис Тьюринга—Чёрча. Полиномиальные алгоритмы (во многих случаях весьма нетривиальные) существуют для многих естественных задач. Элементарные арифметические операции в этой связи уже упоминались ранее; с их более тонкой градацией внутри класса P можно познакомиться в статье «Быстрая арифметика» . Алгоритм Евклида даёт полиномиальный алгоритм для нахождения наибольшего общего делителя двух чисел (кстати, как насчёт наименьшего общего кратного?). «Исключительно плохие» примеры для симплекс-метода означает: примеры, на которых он работает экспоненциальное время. Первый по-настоящему полиномиальный алгоритм для линейного программирования был впервые построен советским математиком Л. Хачияном в 1979 году. Перелистаем настоящий сборник. Многие важные задачи для транспортных потоков «Математика транспортных потоков» допускают полиномиальные алгоритмы. Алгоритмы, связанные с Интернетом «Математика интернета» являются алгоритмами лишь в широком смысле, так как сами задачи по своей сути динамические и распределённые. О них мы поговорим немного позже, пока лишь отметим, что полиномиальность здесь является требованием заведомо необходимым, но никак не достаточным. Имеющие дело с «большими данными» (big data) обычно настаивают на линейных алгоритмах, т. е. таких, для которых $f(n)\leq Cn$. Все алгоритмы, используемые в криптографии «О применениях математики в криптографии» — полиномиальные. Это, впрочем, довольно редкий пример, базирующейся одновременно как на существовании эффективных алгоритмов (для легитимного пользователя), так и на предположении о несуществовании таковых (в случае противника). Алгоритмы, используемые для сжатия информации, также являются полиномиальными. Борьба идёт за улучшение скорости кодирования и декодирования внутри класса P — как и в случае «больших данных», разница между линейными и, скажем, квадратичными алгоритмами оказывается весьма ощутимой. Простой алгоритм для существования эйлерова цикла из статьи «От прогулок по Кёнигсбергу до реконструкции генома» — полиномиальный. Про парную задачу нахождения гамильтонова цикла мы поговорим чуть позже. «Игру в „15» можно легко обобщить до «игры в „ $n^21$ “» для произвольного натурального $n$. Существует (скорее всего — строго это утверждение автор не проверял!) полиномиальный относительно $n$ алгоритм, позволяющий для двух чётных позиций указать путь, переводящий одну в другую. Кстати, эта задача легко сводится (в нашем смысле) к своему частному случаю, когда одна из позиций является полностью упорядоченной; может ли читатель понять, как именно? Ещё одна задача, которая занимала математиков на протяжении тысячелетий — это задача определения простоты числа. Хотя «почти полиномиальные» алгоритмы (скажем, вероятностные алгоритмы, которым разрешается подбрасывать монетку и ошибиться в ответе с малой вероятностью) были известны довольно давно, полиномиальный алгоритм в строгом смысле этого слова был построен лишь в 2002 году. Это открытие вызвало огромный резонанс как среди математического сообщества, так и за его пределами. По‐видимому, у ряда читателей в этот момент должно возникнуть лёгкое недоумение: а в чём собственно разница между тестированием простоты и разложением на простые множители? Не одно ли это и то же? Оказывается, что нет, и в этом проявляется существенное и довольно тонкое различие между конструктивными доказательствами и чистыми доказательствами существования. Скажем, многие из «почти полиномиальных» алгоритмов (с окончательным алгоритмом тестирования простоты ситуация чуть сложнее, но принцип тот же) в качестве доказательства того, что число $m$ составное, выдадут контрпример к малой теореме Ферма, т. е. такое $a$, что $a^\neq 1$ в арифметике по модулю $m$. Можно ли из этого доказательства извлечь фактическое разложение $m$ на простые множители? Ответ на этот вопрос неизвестен, положительный ответ в виде полиномиального алгоритма привёл бы к весьма ощутимым изменениям в современной цивилизации, во многом основанной на вере в то, что криптографические системы типа RSA являются устойчивыми. Давайте и мы попробуем наши скромные силы в задаче о разложении на простые множители. Как мы уже отмечали (третий алгоритм), задача нахождения НОД двух чисел сводится к задаче факторизации (разложения на простые множители), а алгоритм Евклида делает это сведение ненужным с практической точки зрения. Математика, однако, развивается по своим собственным законам, и тот факт, что какие-то подходы или результаты оказываются «устаревшими» (учитывая почтенный возраст алгоритма Евклида, данное слово здесь, конечно, весьма условно) совершенно не означает, что заложенные в них идеи также оказываются бесполезными. В данном случае естественно попытаться поступить наоборот и использовать алгоритм Евклида для того, чтобы раскладывать числа на множители. Ведь для того, чтобы отыскать нетривиальный делитель составного числа $m$ (кстати, понятно ли, почему задача полной факторизации сводится к этой?), достаточно «всего лишь» разыскать такое $n$, для которого $1<\mbox(m,n)квантового алгоритма для факторизации чисел, придуманного американским математиком П. Шором в 1995 году. На последнем результате стоит остановиться подробнее, так как он дал мощный толчок к развитию огромной современной области, называемой квантовыми вычислениями, в которой бок о бок трудятся математики, специалисты в области теоретической информатики и физики. Никакого подвоха здесь нет: компьютер, который в состоянии использовать законы квантовой механики, в самом деле может разлагать $n$ ‐разрядные числа на простые множители за время, чуть большее $Cn^2$. Кстати, сам алгоритм использует весьма красивую и неожиданную математику: применение в самом конце алгоритма Евклида оказывается лишь верхушкой айсберга. Внимательный читатель, видимо, в этот момент должен слегка удивиться: выше упоминалось, что класс P не зависит от выбора вычислительной модели, и вдруг мы предъявляем устройство, пусть даже пока и гипотетическое, которое вдруг оказывается в состоянии делать такие замечательные вещи. Никакого подвоха здесь нет также. Именно, мир, в котором мы живём, устроен одним из трёх следующих способов: 1) построение практичного квантового компьютера невозможно (и, тем самым, эта модель приравнивается к «неразумным»); 2) расширенный тезис Тьюринга—Чёрча неверен (и, видимо, возможны отклонения от него, использующие и другие физические или биологические законы); 3) для факторизации чисел существует классический полиномиальный алгоритм (со всеми вытекающими отсюда последствиями). Мы просто пока не знаем, как именно он устроен. К этому можно лишь добавить, что в мире № 1 невозможность построения квантового компьютера должна, скорее всего, определяться пока непонятными фундаментальными, а не технологическими препятствиями: как показывает опыт человеческого развития, при наличии достаточной воли (а в построение квантового компьютера вкладываются весьма значительные средства во многих развитых странах), последние рано или поздно преодолеваются. Так что популярный тезис о заведомой беспроигрышности этой деятельности (на выходе — или квантовый компьютер или новые физические законы, объясняющие невозможность его построения) по крайней мере не лишён некоторых оснований. Теперь мы немного поговорим о проблеме нижних оценок в теории сложности вычислений, а именно, доказательстве того, что для конкретных интересных задач любой алгоритм должен иметь сложность $f(n)\geq \varepsilon\cdot b(n)$, где $b(n)$ — некоторая фиксированная функция. Вершиной здесь было бы доказательство того, что какая-нибудь интересная задача не принадлежит классу P, т. е. не допускает никакого алгоритма с верхней оценкой сложности $f(n)\leq Cn^k$ (о перспективных кандидатах мы поговорим позже). Возьмём для примера задачу факторизации. Красной нитью через наше изложение проходил тезис о том, что задача факторизации чисел не лежит в P, причём, в отличие от тезиса Тьюринга—Чёрча, это математическое предположение. Количество человеко-часов, потраченное на его опровержение (в том числе и часов, относящихся к наиболее сильным специалистам по теории чисел и алгоритмам) не поддаётся никакому исчислению. Означает ли это, что нам следует просто принять его за некий физический закон и заняться чем‐то ещё? Конечно же, для любого работающего математика этот вопрос чисто риторический и может в лучшем случае вызвать лёгкую улыбку. Тот факт, что на протяжении весьма долгого времени никто не был в состоянии предъявить нетривиальное решение уравнения $x^n+y^n=z^n$ или трёхмерное многообразие с «дикими» свойствами (контрпример к гипотезе Пуанкаре), математиков в поиске доказательства соответствующих утверждений только раззадоривал, и совсем не напрасно. В процессе их решения с кульминацией, наступившей в работах Э. Уайлса и Г. Перельмана, соответственно, были созданы целые стройные теории, занявшие своё достойное место в здании современной математики. Точно так же обстоит дело и с проблемой нижних оценок сложности, с той разницей, что она в настоящий момент остаётся широко открытой, хотя ряд обнадёживающих результатов и был получен в 80‐е и 90‐е годы XX века. По‐видимому, для её полного решения потребуются некоторые, пока неизвестные идеи (впрочем, по сравнению, скажем, с теоремой Ферма или гипотезой Пуанкаре, проблема нижних оценок сложности находится в младенческом возрасте). Причину такого положения дел понять легко. Любое доказательство несуществования эффективного (скажем, полиномиального) алгоритма для данной задачи должно непременно учитывать не только все уже существующие идеи для построения такого алгоритма, но также и все потенциальные идеи, которые могут появиться в будущем: в этом, собственно, и состоит смысл теории сложности. Этот класс идей весьма широк, и большинство известных частных результатов по проблеме нижних оценок получаются как раз путём его сужения. В заключение нашего краткого очерка следует рассказать про NP-полноту: это именно тот раздел, в котором успехи теории сложности вычислений уже оказываются весьма впечатляющими. Основы теории NP-полноты были заложены в работах американских математиков C. Кука, Р. Карпа и советского математика Л. Левина в начале 70‐х годов. Давайте ещё раз взглянем на задачу нахождения нетривиального делителя составного числа и сравним её с двумя другими задачами из данного сборника: нахождения гамильтонова цикла «От прогулок по Кёнигсбергу до реконструкции генома» и решения судоку «Разгадывание судоку» ; последнюю, конечно, надо обобщить на случай таблицы $n^2\times n^2$. Что между ними всеми общего? На «философском» уровне понятно, что решение всех таких задач разбивается на два совершенно неравноценных этапа: поиск или угадывание правильного ответа и его проверка. Последнюю во всех случаях провести легко и можно поручить компьютеру или даже школьнику. Насчёт того, как правильный ответ найти, никаких общих рецептов у нас нет, а в лучшем случае есть лишь разумные советы, иногда называемые эвристиками (смотри, например, «Разгадывание судоку» ). Хорошо, однако, то, что правильный ответ по крайней мере оказывается коротким или, более математически, его битовая длина $m$ не превосходит полинома от битовой длины записи $n$ самой задачи. Поэтому всегда имеется тривиальный переборный алгоритм, который вместо организованного поиска просто перебирает подряд все возможности, пока не натолкнётся на нужную. Его сложность в наихудшем случае порядка $2^m\sim 2^$. Учитывая скорость роста экспоненциальной функции, это, конечно, не ахти, но стоит отметить, что бывают ситуации и намного хуже. Из данного в предыдущем абзаце описания сравнительно легко сконструировать математическое определение: класс задач, в которых проверка ответа полиномиальна, т. е. лежит в классе P. Эквивалентное определение получается, если в приведённое в начале заметки описание машины Тьюринга добавить пункт о её недетерминированности, т. е. разрешить машине по своему усмотрению выбирать один вариант поведения из списка предложенных. Полученный таким образом класс задач называется NP, где «N» напоминает о недетерминированности. Большинство задач, которые мы обсуждали ранее, принадлежат этому классу или могут быть легко приведены к требуемому виду. Такая широкая распространённость, конечно же, не случайна. Она отражает тот факт, что NP является неплохой математической моделью любой организованной творческой деятельности, состоящей из собственно творческого акта поиска решения и (как правило быстрой и рутинной) фазы его проверки. Учитывая такое многообразие задач в NP, a priori следовало бы ожидать существование внутри этого класса богатой иерархической структуры, пытающейся сортировать задачи в соответствии с их внутренней сложностью, предположений о том, куда именно та или иная задача попадает и т. д. Оказывается, что ничего этого не происходит. За весьма немногими исключениями, класс NP фактически распадается ровно на два больших куска. Первый кусок — это уже известный нам класс P, состоящий из всех алгоритмических задач, допускающих хотя бы один эффективный (полиномиальный) алгоритм. Иными словами, это те задачи, в которых перебор вариантов удаётся заменить эффективной процедурой, что мы, собственно, и наблюдали на примере алгоритма Евклида (другой хрестоматийный пример — задача про эйлеров цикл из статьи «От прогулок по Кёнигсбергу до реконструкции генома» ).

На противоположном полюсе находятся так называемые NP-полные задачи, обладающие тем свойством, что к ним полиномиально сводится любая другая задача из класса NP. Оказывается, что и задача разгадывания судоку и задача нахождения гамильтонова цикла из статьи «От прогулок по Кёнигсбергу до реконструкции генома» и ещё более тысячи (с учётом всех вариаций — порядка $10 000$) алгоритмических задач из самых разных областей математики, компьютерных наук, естествознания, биологии, социологии и т. д. NP-полны. Таким образом, любой эффективный алгоритм для разгадывания судоку может быть перестроен в эффективный алгоритм для разложения чисел на простые множители, построения гамильтонова цикла и массы других полезных вещей. Все такие редукции имеют ярко выраженный «модулярный характер»: полиномиальное сведение, скажем, гамильтонова цикла к судоку разбивается на несколько сравнительно коротких переходов от одной естественной промежуточной задачи к другой. Все встречающиеся нам по дороге задачи могут также быть зачислены в NP-полные. А вот между этими двумя полюсами нет практически ничего. Одна NP-задача, которую мы не умеем классифицировать — это разложение чисел на простые множители; имеется ещё несколько примеров такого типа, мотивированных криптографическими предположениями. Все они обладают тем свойством, что правильный ответ единственен. Примером, в котором последнее свойство не выполняется, служит задача изоморфизма графов (нарисованы два графа, можно ли их так «наложить» друг на друга, что они совпадут) и её разновидности. Вот, пожалуй, и все (или, по крайней мере, наиболее важные) естественные задачи, статус которых пока неизвестен. Так что с уверенностью можно сказать, что классификация переборных задач на простые (P) и максимально трудные (NP-полные) — это один из наиболее успешных классификационных проектов в истории науки. По всем этим причинам задача о совпадении классов P и NP (известная также, как проблема перебора) — это одна из центральных открытых проблем современной математики. Большинство специалистов верят в то, что $\mbox\neq \mbox$. Но доказательство этого факта сводится к проблеме нижних оценок для произвольно выбранной NP‐полной задачи, чего математики делать пока не умеют. О популярности этой задачи свидетельствует, в частности внимание, проявляемое к ней любителями от математики — по этому критерию проблема перебора, возможно, уже превзошла гипотезу Ферма. Однако и профессионалы за пределами математической логики и компьютерных наук, безусловно, отдают ей должное. Проблема перебора — одна из семи задач, за решение которой математический институт Клэя учредил престижную премию. Как известно, пока решена только одна из них (гипотеза Пуанкаре — Г. Перельман); для решения оставшихся, скорее всего, также понадобятся новые идеи и подходы, о природе которых мы в случае проблемы перебора не имеем даже приблизительного представления.
Дополнения, комментарии
Уже говорилось, что между классами P и NP‐полных задач, этими своеобразными полюсами в объединяющем классе NP, есть несколько задач, которые пока не классифицированы. Про одну из них, задачу изоморфизма графов, уже после выхода первого издания этой книги в 2015 году венгро-американский математик Л. Бабаи доказал фундаментальный результат: для этой задачи существует «почти полиномиальный» алгоритм, время его работы — $2^
Классификация алгоритмов по временной сложности
Применение порядковых оценок определения сложности алгоритмов, с одной стороны, и исследование возможностей решения различных задач на современных ЭВМ — с другой позволили по характеристике время решения задачи выделить два класса алгоритмов: полиномиальные и экспоненциальные. К классу полиномиальных алгоритмов относят те из них, для которых функция /(п) при п —> со растет не быстрее, чем некоторый полином (многочлен) от п. Если же указанная функция растет быстрее, алгоритм относят к классу экспоненциальных алгоритмов. Например, алгоритм с функцией /(л) = 100л 3 + п 2 +10 — полиномиальный. Алгоритмы, для которых /(л) = 2″+л 2 , /(л) = е 2 «,
/(л) = л! — экспоненциальные.
В сравнении с экспоненциальными алгоритмами алгоритмы с полиномиальным ростом сложности вычислений характеризи-руются рядом положительных свойств. Во-первых, при увеличении размера задачи л время счета по этим алгоритмам растет значительно медленнее, чем по экспоненциальным, даже в том случае, когда для малых л полиномиальный алгоритм затрачивает больше времени, чем экспоненциальный. Во-вторых, полиномиальные алгоритмы лучше используют технологии производства ЭВМ, направленные на повышение их производительности. Так, если удается увеличить производительность ЭВМ, например, в 10 раз, размер задач, решаемых полиномиальным алгоритмом, увеличивается в 1 20 ’ 65+2 ’ 15,, > = ехр (-20,65 + 2,15л), указывающей на то, что, с одной стороны, она носит экспоненциальный характер, с другой — уже при п= 12 для генерации всех перестановок указанной ЭВМ требуется три минуты. При л =13 для достижения этой цели компьютер должен затратить примерно 23 минуты, а при л= 14 — немногим меньше 3,5 ч. Такое время перебора вариантов в практических условиях не всегда допустимо.
Все множество задач, решаемых полиномиальными алгоритмами, называют классом Р (полиномиальный). Для определения класса задач, решаемых только экспоненциальными алгоритмами, введено понятие недетерминированного алгоритма. Это воображаемый полиномиальный алгоритм, который в каждом состоянии может выполнять как угодно много альтернативных действий одновременно. Чтобы проиллюстрировать работу такого алгоритма, рассмотрим задачу путешествующего продавца (коммивояжера). Этот продавец имеет товарную базу в одном из городов его региона продаж, например в городе 1, и должен объехать еще три города для того, чтобы продать свои товары и потом вернуться в исходный город 1. Различные порядки объезда городов, скажем, 1—2—3—4 и 1—3—2—4, требуют различного расхода бензина и, следовательно, имеют различную стоимость, что обусловлено различным профилем автомобильных дорог. Продавец должен выбрать такой порядок объезда городов (маршрут), который обеспечит наименьшую его стоимость.
Таким образом, поскольку каждый маршрут объезда городов — это перестановка их номеров или названий, всегда начинающаяся с города 1, в этой задаче требуется найти все остальные перестановки на множестве трех номеров городов 2, 3, 4, оценить их стоимость и выбрать наилучшую перестановку.
Все перестановки, а их 3! = 6, легко построить, используя корневое дерево перебора. В корневой вершине дерева указывается номер исходного города. В вершинах первого яруса отмечаются части маршрутов, которые ведут из города 1 в остальные города 2, 3, 4. Вершины второго яруса указывают части маршрутов, которые можно проложить из городов первого яруса. В вершинах последнего, третьего яруса указаны полные маршруты объезда городов, т. е. полные перестановки. Дерево изображено на рис. 1.2.

Рис. 1.2. Дерево маршрутов в задаче коммивояжера
для четырех городов
Согласно этому дереву детерминированный алгоритм формировал бы и оценивал маршруты последовательно. Например, он бы последовательно строил самую левую ветвь, т. е. сформировал бы маршрут 1—2—3—4 и оценил его. Затем вернулся бы в вершину М|2 и достроил вершины М|24, М|242, т. е. построил бы маршрут 1—2—4—3 и оценил его. Потом вернулся бы в корневую вершину дерева и построил маршрут 1—3—2—4 и т. д. Всего бы он сформировал шесть ветвей, возвращаясь при этом на необходимый ярус дерева. По определению недетерминированный алгоритм, находясь в состоянии М,, одновременно сформирует три вершины дерева М12, М13, М|4. Находясь в этом состоянии, т. е. на ярусе 1, он одновременно сформирует шесть вершин М |2з, М124, М132 М134, М142, М143. Находясь затем на 2-м ярусе, недетерминированный алгоритм одновременно сформирует вершины последнего яруса дерева маршрутов. Таким образом, это дерево он мог бы построить за три последовательных шага, так как будто бы строилась одна ветвь, т. е. за полиномиальное время.
В какой-то мере действия недетерминированного алгоритма напоминают работу многопроцессорной ЭВМ с бесконечным числом асинхронно включающихся в работу процессов. Однако никакое физическое устройство пока не может быть построено по этому принципу. Например, только в случае двоичного дерева высотой п на последнем его ярусе будет образовано 2″ листьев, что при п = 30 потребует больше миллиарда процессоров для их обработки.
Все те задачи, которые можно решить недетерминированными алгоритмами за полиномиальное время, образуют класс ЛТ (недетерминированный полиномиальный).
Другое толкование понятия «недетерминированный полиномиальный» несколько иное. Оно вытекает из того, что недетерминированность алгоритма подразумевает неопределенность количества шагов предполагаемого двухэтапного способа решения задачи. На первом этапе этого способа за полиномиальное время находится возможное ее решение. На втором этапе тоже за полиномиальное время проверяется, действительно ли это решение рассматриваемой задачи. Хотя каждый из этапов требует полиномиального времени решения, число обращений к ним может оказаться экспоненциальным. Такая задача попадает в класс А!Р, так как для поиска ее необходимого решения требуется просматривать огромное количество побочных решений и нет алгоритма интенсивного отбрасывания заведомо не нужных из них.
Хотя классы задач Р и Л^Р являются различными, любая задача из Р принадлежит множеству ЫР. Это обусловлено тем, что любая задача из класса Р, решаемая детерминированным алгоритмом, может быть представлена как описанный двухэтапный процесс решения: вначале находится любое решение, затем проверка, действительно ли это решение. Причем PczNP, так как для класса А ! Р неизвестны детерминированные полиномиальные алгоритмы. Однако это не означает, что такого алгоритма не существует. Пока этот тезис не удалось доказать. Поэтому проблема, равны ли классы Р и NP, является открытой.
В классе задач Л^Р существует подмножество задач, которые принято называть АТ’-полными. Эти задачи обладают особой сложностью решения и отличаются от остальных задач класса А Г Р тем, что если удастся найти полиномиальный алгоритм решения какой-либо из них, то это будет означать, что все задачи из А‘Р могут быть решены полиномиальными алгоритмами.
Выделение класса А^Р-полных задач основано на преобразовании одной задачи в другую.
В современной терминологии это преобразование называется сводимостью. Идея и суть преобразования состоит в том, что, если для задачи А известен алгоритм ее решения, а для задачи В требуется его составить, то следует задачу В выразить в терминах задачи А (свести к А) и решить задачу В с помощью алгоритма задачи А.
При отнесении некоторой задачи А к классу АР’-полных задач поступают наоборот. К этой задаче А сводят (преобразовывают в нее) некоторую АР^-полную задачу. Тем самым подтверждают, что выбранная АР’-полная задача выражается в терминах А, т. е. А также принадлежит к классу АР’-полных задач.
Таким образом, класс АР>-полных задач — это класс задач из А!Р, которые преобразуются одна в другую. Именно на это опирается утверждение, что если будет найден полиномиальный алгоритм для некоторой АР’-полной задачи, то все другие АР’-полные задачи также могут быть решены с помощью своего полиномиального алгоритма.
Понятия Р и АР’-полных задач позволяют проводить анализ эффективной разрешимости новых задач до составления решающих их алгоритмов. Это, во-первых, существенно экономит время, затрачиваемое на разработку подходящих алгоритмов решения новых задач, во-вторых, заставляет сосредоточить усилия на поиске приближенных алгоритмов их решения, во многих случаях более пригодных для практических применений.
К настоящему времени большинство задач дискретной математики по показателю «сложность алгоритмов решения» классифицировано, т. е. отнесено к классам Р и А ] Р. Кроме задач на поиск наибольшего (наименьшего) значения функций (функционалов), в класс АР*включены так называемые задачи распознавания, решением которых является ответ «да—нет». Эти задачи не труднее в отношении решения, чем экстремальные задачи, однако во многих случаях они оказались более удобными для целей классификации. Особое место среди этих задач занимает задача из раздела математической логики о выполнимости булевой формулы. Обычно она представляется в конъюнктивной нормальной форме (КНФ). Например, как (х, V х2 V х3) л (х, V х2 V х4) л (х3 х4) л х,,
где булевы переменные х,, х2, х3, х4 называются литералами, а дизъюнкции х,, х2, х3, х4 или их отрицания — дизъюнктами.
Булева формула считается выполнимой, если существует некоторый набор литералов такой, что она получает значение «истина». Если же такого набора литералов не существует, формула является невыполнимой. Задача о выполнимости булевой формулы состоит в том, чтобы выяснить, есть ли некоторый набор литералов, для которых значение булевой формулы истинно.
Очевидное решение этой задачи — перебор 2″ наборов литералов, построение для каждого набора таблицы истинности функций и выяснение по ней, истинна ли булева формула. Такой алгоритм имеет сложность 0(2″), т. е. он экспоненциальный и, таким образом, неэффективный. Других алгоритмов решения этой задачи нет. Наоборот, доказано, что эта задача принадлежит к классу ./УР-полных задач [4, 5].
Доказательство УУР-полноты задачи о выполнимости булевой формулы в свою очередь послужило основанием не только для классификации других задач, а в большей мере для развития самой логики доказательства УУР-полноты. Она состоит в следующем. Для того чтобы доказать, что некоторая задача УУР-полна, необходимо логически обосновать два положения:
- 1) задача входит в класс ТУР;
- 2) задача также трудна по решению, как и известные УУР-полные задачи этого класса.
Весьма часто и успешно для обоснования первого положения используется понятие недетерминированного алгоритма. Если удается показать, что рассматриваемая задача может быть решена недетерминированным алгоритмом за полиномиальное время, то она автоматически входит в класс /УД-задач. Доказательство УУР-трудности задачи осуществляется путем преобразования (полиномиального сведения) некоторой УУР-полной задачи в рассматриваемую задачу. Иными словами, необходимо подобрать наиболее подходящую УУР-полную задачу и представить ее в терминах той задачи, которая нуждается в доказательстве ее ЛТ’-полноты. Подробно с логикой доказательства можно познакомиться по книге [6].
Работа по классификации сформулированной задачи, проводимая с использованием теории УУР-полноты до составления алгоритма ее решения, чрезвычайно важна как в теоретическом, так и в практическом отношениях. С одной стороны, аналитическое доказательство обладает общностью, с другой — помогает разработчику выбрать правильный путь конструирования алгоритма. Если доказано, что задача УУР-полна, это свидетельствует о том, что она может быть решена только за экспоненциальное время. В большинстве практических случаев, в частности при постоянном использовании алгоритма, такое время решения задачи недопустимо. Поэтому для постоянного практического использования конструируют алгоритмы, решающие задачу приближенно в лучшем случае с гарантированной погрешностью за полиномиальное время. Экспоненциальные алгоритмы используют лишь для задач малого размера, а чаще всего для создания тестов, необходимых для экспериментальной оценки погрешностей алгоритмов, решающих задачу приближенно.
§ 5. NP -полные и NP -трудные задачи
Рассмотрим сводимость задач. Хотя сведение одних задач к другим является традиционной математической деятельностью, оно до работ С. Кука не было подвержено самостоятельному исследованию. Именно С. Куку принадлежит честь выделения этого понятия как центрального в теории полиномиальной сводимости.
Говорят, что задача Z 1 сводится к задаче Z 2 если метод решения задачи Z 2 можно преобразовать в метод решения задачи Z 1 . Сводимость называется полиномиальной если указанное преобразование можно осуществить за полиномиальное время.
Если одновременно задача Z 1 полиномиально сводится к задаче Z 2 и Z 2 полиномиально сводится к задаче Z 1 , то задачи Z 1 и Z 2 полиномиально эквивалентны.
Задача называется NP — трудной если каждая задача из NP полиномиально сводится к ней.
NP- трудная задача имеет тот смысл, что эта задача не проще, чем «самая трудная в NP ».
В классе NP выделяются NP -полные задачи. Задача называется NP — полной , если она входит в NP и каждая задача из NP полиномиально сводится к ней (т.е. является NP -трудной). NP -полные задачи понимаются как самые трудные задачи из класса NP .
Класс NP- полных задач обладает следующими свойствами.
1. Никакую NP -полную задачу нельзя решить никаким известным полиномиальным алгоритмом, несмотря на настойчивые усилия многих блестящих исследователей.
2. Если бы удалось построить полиномиальный алгоритм для какой-нибудь NP -полной задачи, то это бы означало
полиномиальную разрешимость каждой NP -полной задачи. Основываясь на этих двух свойствах, многие высказывают гипотезу, что P ≠ NP. Практическое значение понятия NP -полной задачи лежит именно в широко распространенном мнении, что такие задачи по существу труднорешаемы. Следовательно, при их решении в худшем случае потребуется экспоненциальное количество времени и не будет возможности решить на практике такие задачи, за исключением очень малого числа индивидуальных задач.
Первой задачей, для которой было доказано, что она является NP — полной, была проблема о выполнимости (см. задачу 1 в § 4), именно, имеет место следующая теорема.
Теорема 7.1 (теорема Кука). Задача выяснения выполнимости формулы логики высказываний, представленной в к.н.ф., является NP — полной.
Все приведённые ранее примеры NP- задач (задачи 1-12) являются NP — полными задачами. Список NP -полных задач в настоящее время содержит несколько сотен задач и продолжает пополняться. Многие вновь установленные NP -полные задачи печатаются в журнале Journal of Algorithms. В действительности о большей части задач комбинаторной оптимизации известна либо полиномиальная разрешимость, либо NP — полнота, что является ещё одним подтверждением гипотезы P ≠ NP .
§ 6. Класс Е
Как уже указано, считается, что алгоритм имеет полиномиальную временную сложность, если существует полином p(x) такой, что на любом входном слове длины n алгоритм завершает вычисления после выполнения не более чем p(n) операций. Если такого полинома не существует, временная сложность считается экспоненциальной . Таким образом для них число операций возрастает быстрее значений любого полинома.
Кроме этого определения существует и второе определение: алгоритм имеет экспоненциальную временную сложность если его временная сложность имеет порядок не меньше, чем сa n , где с >0, a (a > 1) — постоянные. Иначе, временная сложность f(n) является экспоненциальной, если существуют постоянные с >0, a >1 , что
для всех, кроме конечного числа значений n .
При первом определении, например задача, имеющая сложность порядка 0(n log 2 n ) ( 0(n log 2 n ) = 0( 2 (log 2 n ) 2 ) ) будет отнесена к задаче с экспоненциальной временной сложностью, а по второму определению – нет.
Отметим, что функция n log 2 n хотя и растет быстрее любого полинома, но растёт медленнее, чем, например 2 n .
Большинство экспоненциальных алгоритмов – это просто варианты полного перебора. Экспоненциальные алгоритмы не считаются «хорошими», в отличие от полиномиальных алгоритмов, которые, как уже указывалось, считаются «хорошими». К экспоненциальным задачам относятся задачи, в которых требуется построить множество всех подмножеств данного
множества, все полные подграфы некоторого графа или же все поддеревья некоторого графа.
Некоторые экспоненциальные алгоритмы весьма хорошо зарекомендовали себя на практике. Дело в том, что временная сложность определяется для худшего случая, а многие задачи могут быть не так плохи.
Для решения задачи линейного программирования существует так называемый симплекс-метод (алгоритм), который хотя и экспоненциален в худшем случае – уверено работает на практике для задач достаточно большого размера. Отметим, что для решения задачи линейного программирования Хачиян Л. Г. в 1979 г. предложил алгоритм эллипсоидов, который имеет полиномиальную временную сложность.
Метод ветвей и границ успешно решает задачу о рюкзаке, хотя этот алгоритм имеет экспоненциальную временную сложность.
Примеров экспоненциальных алгоритмов успешно используемых на практике мало. Более того даже для полиномиальных алгоритмов представляют практический интерес алгоритмы сложность которых имеет порядок n 2 или n 3 . Ясно, что алгоритмы со сложностью порядка n 100 вряд ли будут практически используемы.
Интересно, что экспоненциальный алгоритм может оказаться при малом размере задачи более быстрым, чем полиномиальный, однако с ростом размера задачи полиномиальный становится более быстрым.
Экспоненциальная сложность задачи имеет следующие аспекты: -для отыскания решения требуется экспоненциальное время;
-искомое решение может быть настолько велико, что не может быть представлено выражением, длина которого была бы ограничена полиномом от длины входа.
Вторая ситуация возникает, например, если в задаче коммивояжера требуется найти все маршруты длины, не превосходящей заданной величины L . Может оказаться, что имеется экспоненциальное число маршрутов длины не превосходящей L .
§ 7. Ёмкостная (ленточная) сложность алгоритма
Ёмкостная или ленточная сложность алгоритма характеризует необходимую для вычисления память для хранения исходных данных, промежуточных результатов и окончательного результата. При применении машины Тьюринга как модели вычислений, ёмкостная сложность оценивается длиной части ленты используемой для записи исходных данных и вычислений.
Пусть машина Тьюринга вычисляет значение функции f.
Активной зоной ленты (машины Тьюринга) в данный момент времени t называется связанная часть ленты, содержащая обозреваемую ячейку и все ячейки в которых имеются символы из внешнего алфавита машины.
Активной зоной при работе машины Тьюринга на числе n называется объединение всех активных зон за время вычисления.
Определим S f (x) как длину активной зоны при работе машины Т на числе х , если f(x) определено. В противном случае значение S f (x) будем считать неопределённым. Функцию S f (x) назовём ленточной сложностью .
Отметим, что в настоящее время уделяется существенно больше внимания временным характеристикам и значительно меньше ёмкостным, хотя эта характеристика тоже существенна при использовании ЭВМ с ограниченной памятью.
Для задач вводятся понятия задач с полиномиальной потребной памятью, с NP -потребной памятью и т.д.
Имеет место следующая теорема:
Теорема 7.2. Для ленточной (ёмкостной) характеристики сложности вычисления функции f имеет место оценка
S f (x) ≤ x+1+ t f (x),
где t f (x) -временная сложность вычисления f(x) .
Доказательство . В начальный момент на ленте машины Тьюринга записано число х (в унарной системе), следовательно, занято х+1 клеток ленты. На каждом такте (шаге) активной становится не более одной новой клетки, поэтому получим указанную оценку для S f (x) .
Эта теорема показывает, что если известна временная сложность, то можно оценить сверху ленточную сложность.
Интересно, что нет пределов сложности вычислений. Можно доказать, что какую бы сложную задачу не взять, то существует ещё более сложная задача.
§ 8. Вопросы и темы для самопроверки
1. Понятие о сложности вычислений.
2. Размер исходных данных задачи для случаев, когда входом является число, матрица, граф.
3. Временная сложность алгоритма. Временная сложность задачи.
4. Временная сложность алгоритма в наихудшем случае, временная сложность алгоритма в среднем.
5. Может ли, что для решения одной и той же задачи существовать алгоритмы разной сложности? Если да, то какова сложность задачи?
6. Полиномиальная сложность алгоритма, задачи.
7. Классификация задач по сложности.
8. Класс Р , примеры задач из этого класса.
10. Недетерминированная машина Тьюринга, её отличие от детерминированной машины Тьюринга.