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

В этой главе мы узнаем

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

Точно ли нынешнее решение — самое эффективное?

Насколько эффективна реализация алгоритма?

Может, купить компьютер помощнее?

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

Как оценить сложность с помощью эмпирической модели

Лучше всего начать сразу с примера, в котором теоретический анализ оказывается в исследовании конкретных программных систем очень даже практичным. Предположим, вы делаете приложение — часть большого задания, которое еженощно обрабатывает большой набор данных. Задание запускается в полночь и должно отработать до шести утра. Ночной набор данных состоит из нескольких миллионов значений — и ожидается, что лет за пять его объём удвоится2.

И вот вы уже написали работающий прототип, но тестируете его на небольших наборах данных — по 100, 1000 и 10000 элементов. В таблице 2-1 зафиксировано время работы прототипа на этих данных.

N

Время в секундах

100

0.063

1 000

0.565

10 000

5.946

Можно ли по этим предварительным результатам прогнозировать время работы прототипа на больших наборах данных, например, на 100 000 или миллионе значений? Попробуем построить математическую модель на основании только этих результатов и найдём некоторую функцию T(N), которая будет предсказывать время работы на данных заданного объёма. Достоверная модель не только вычислит T(N) достаточно близко к трём значениям из таблицы 2-1, но ещё и предскажет время работы на больших наборах, представленное в таблице 2-2 (известные нам результаты указаны в ней в квадратных скобках).

Возможно, вы пользовались инструментами, которые умеют находить линейную регрессию заданного набора данных, например, Maple (https://maplesoft.com) или Microsoft Excel (https://microsoft.com/excel). Регрессионные модели можно построить и с помощью SciPy — библиотеки Python для математических, общенаучных и инженерных расчётов. В примере 2-1 показано, как с помощью numpy подобрать константы a и b в линейном приближении TL(N) = a * N + b . Функция curve_fit() возвращает пару коэффициентов (a, b) линейного приближения на основании экспериментальных данных, которые передаются ей в списках xs и ys3.

   1 import numpy as np
   2 from scipy.optimize import curve_fit
   3 
   4 def linear_model(v, a, b):
   5   return a*v + b
   6 
   7 # Экспериментальные данные
   8 xs = [100, 1000, 10000]
   9 ys = [0.063, 0.565, 5.946]
  10 
  11 # Первое возвращаемое значение — массив из двух коэффициентов
  12 (a, b), _ = curve_fit(linear_model, xs, ys)
  13 print(f'Linear = {a}*N + {b}')

Полученная математическая модель выражается формулой TL(N) = 0.000596·N - 0.012833. Из таблицы 2-2 понятно, что приближение это не слишком достоверно: чем больше размер входного набора, тем сильнее прогноз времени работы прототипа отстаёт от результатов эксперимента. Можно попробовать повысить степень N до второй и построить квадратичное приближение4:

   1     def quadratic_model(v, a, b):
   2       return a*v*v + b*v;

Задача анализа — подобрать такие параметры a и b функции quadratic_model(), чтобы полученная формула TQ(N) = a·N² + b·N соответствовала имеющимся данным. Если исследовать её по аналогии с примером 2-1, получится формула TQ(N) = 0.000000003206·N² + 0.000563·N. Таблица 2-2 показывает, что эта модель тоже недостоверна: её прогноз времени работы на больших наборах тем сильнее опережает экспериментальные результаты, чем больше данных в наборе.

../crow.png Большинство найденных нами коэффициентов, например, 0.000000003206, т. е. 3.206 · 10⁻⁹, — довольно малые числа. Это происходит оттого, что наши алгоритмы работают на больших наборах данных, где N = 1 000 000 и даже больше. Тот же миллион, возведённый в квадрат — это 1 000 000² = 10¹², так что стоит ожидать, что нам встретятся и очень большие, и очень маленькие числа.

Последний столбец таблицы 2-2 — время работы, оцененное третьей математической моделью, TN(N) = a·log₂ N: в ней один коэффициент — a, а в формуле присутствует двоичный логарифм. Полученное приближение — TN(N) = 0.0000448·log₂ N. Для N = 10 000 000 погрешность приближённого значения находится в пределах 5% от экспериментального.

N

Время в секундах

TL

TQ

TN

100

[0.063]

0.047

0.056

0.030

1 000

[0.565]

0.583

0.565

0.447

10 000

[5.946]

5.944

5.946

5.955

100 000

65.391

59.559

88.321

74.438

1 000 000

860.851

595.708

3769.277

893.257

10 000 000

9879.44

5957.194

326299.837

10421.327

Линейное приближение, TL, сильно преуменьшает время работы, а квадратичное, TQ — преувеличивает. Расчёт времени для N = 10 000 000 у TL равен 5957 секунд (это минут сто), а у TQ — целых 326300 (почти 91 час). У TN предсказывать быстродействие получается куда лучше: ожидаемое время по TN — 10421 секунда (примерно 2.9 часа), а фактическое — 9879 секунд (2.75 часа).

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

Отчего формула a·N·log₂ N так хорошо предсказывает поведение программы? Это наверняка зависит от того, какие алгоритмы лежат в основании самой программы. Оценка сложности алгоритмов часто выдаёт одно из наших трёх приближений — линейное, квадратичное или N-логарифмическое. Следующий пример напомнит нам об одном удивительном открытии шестидесятилетней давности5.

Умножать быстрее, чем в столбик?

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

   456                    123456
 x 712                  x 712835
   ---                    ------
   912                    617280
  456                    370368
3192                    987648
------                 246912
324672                123456
                     864192
                     -----------
                     88003757760

Чтобы умножить два трёхзначных числа друг на друга, надо перемножить девять пар цифр. Два шестизначных числа потребуют уже тридцать шесть умножений отдельных цифр. Умножая таким способом два N-значных числа, мы выполняем умножений цифр. Можно ещё заметить, что от увеличения количества цифр в целочисленных множимом и множителе вдвое количество действий «умножение цифры на цифру» растёт вчетверо. Другие операции (например, сложение) можно даже не считать — умножение важнее.

Центральный процессор ЭВМ умеет быстро умножать целые числа только фиксированного размера — 32 или 64-разрядные, но с числами большей длины самостоятельно не справляется. Если целое число в Python оказывается слишком большим, оно преобразуется в специальную структуру PyLong, которая увеличивается по мере роста числа. Вот на ней и можно исследовать быстродействие умножения произвольных N-значных целых. В таблице 2-3 приведены результаты вычислений четырёх эмпирических моделей производительности, которые построены на основании первых пяти экспериментальных данных (выделены квадратными скобками).

N

Время в секундах

TL

TQ

Karatsuba

TKN

256

[0.0009]

-0.0045

0.0017

0.0010

0.0009

512

[0.0027]

0.0012

0.0038

0.0031

0.0029

1 024

[0.0089]

0.0126

0.0096

0.0094

0.0091

2 048

[0.0280]

0.0353

0.0269

0.0282

0.0278

4 096

[0.0848]

0.0807

0.0850

0.0846

0.0848

8 192

0.2524

0.1716

0.2946

0.2539

0.2571

16 384

0.7504

0.3534

1.0879

0.7617

0.7765

32 768

2.2769

0.7170

4.1705

2.2851

2.3402

65 536

6.7919

1.4442

16.3196

6.8554

7.0418

131 072

20.5617

2.8985

64.5533

20.5663

21.1679

262 144

61.7674

5.8071

256.7635

61.6990

63.5884

Здесь TL — линейное приближение, а TQ — квадратичное. Karatsuba — это приближение с нестандартной степенью, a·N¹·⁵⁸⁵, а TKN — более соответствующая экспериментальным данным формула, TKN(N) = a·N¹·⁵⁸⁵ + b·N, с аккуратно подобранными коэффициентами a и b.6 TL значительно недооценивает время работы умножения. А вот TQ — значительно переоценивает, хотя можно было ожидать, что квадратичное приближение окажется достаточно достоверным: количество действий при умножении столбиком растёт квадратично, о том же говорит и увеличение времени работы вчетверо при росте длины чисел вдвое. Но две других математических модели оказались намного достовернее для расчёта времени, за которое в Python умножаются N-значные целые, — потому что для этого используется более эффективный алгоритм Карацубы умножения длинных целых7.

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

Классы вычислительной сложности

Иногда понять, какой из двух алгоритмов эффективнее справляется с одной и той же задачей, довольно просто: достаточно оценить их вычислительную сложность с помощью математических моделей. Обычно говорят «сложность O(N²) или «в худшем случае сложность N log N». Чтобы начать в этом разбираться, давайте посмотрим на иллюстрацию 2-1. Тем, кто прочёл книжку по теории сложности или изучал материалы в Сети по анализу алгоритмов, картинка покажется знакомой.

Наша задача — построить математическую модель, которая оценивает время работы в наихудшем случае для набора данных размера N. Математики это называют верхней границей; проще говоря, алгоритм никогда не проработает дольше приведённой оценки. Соответственно, нижняя граница оценки относится к минимальному времени работы и звучит как «алгоритм всегда проработает как минимум столько-то».

Поясним понятия верхней и нижней границы на примере автомобильного спидометра. Задача спидометра — вычислять и показывать приблизительное значение текущей скорости автомобиля. Скорость, которую показывает спидометр, должна быть не ниже текущей, чтобы водитель мог соблюдать правила движения. Это и есть нижняя граница. В большую сторону показания скорости не должны превышать 110% плюс 4 км/ч от текущей.8 В математике это соответствует понятию верхней границы.

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

На иллюстрации 2-1 изображены три графика, которые представляют три наших приближения «длинного умножения» в Python — TL, TQ и TKN; экспериментальные данные обозначены там же чёрными квадратиками. TQ(N) — очевидная верхняя граница эксперимента (ибо TQ(N) всегда меньше экспериментального значения на всех N), но по самой иллюстрации видно, что эта граница очень неточная.

Иллюстрация 2-1. Сравнение математических моделей с результатами измерений производительности
Иллюстрация 2-1. Сравнение математических моделей с результатами измерений производительности

Математические модели и данные эксперимента

Время в секундах

TQ

TN

Экспериментальные данные

TL

N — объём входных данных

Если повнимательнее посмотреть на таблицу 2-3, можно заметить, что начиная с порогового значения объёма входных данных N=8192 приближение TKN(N) тоже всегда больше эмпирических данных для остальных N — но при этом гораздо к ним ближе. Обычно это признак того, что функция стабилизировалась, и будет вести себя так впредь для «достаточно больших» N, но для каких именно — зависит от самого алгоритма и его конкретной реализации.

Очевидно также, что линейное приближение TL(N) — это нижняя граница времени работы, потому что TL(N) меньше эмпирических данных для любого N. Впрочем, при увеличении N эта формула всё сильнее отстаёт от экспериментальных значений, так что в качестве математической модели становится вполне бесполезной. Более точной нижней границей оказывается формула, названная в таблице 2-3 «Karatsuba»: a · N¹·⁵⁸⁵.

Оценку производительности можно запускать на разных компьютерах, при этом конкретные числовые характеристики в таблице 2-3 поменяются. Скорость умножения может быть меньше или больше, коэффициенты a и b в TKN(N) могут оказаться другими, сама TKN(N) может стабилизироваться на другом большем или меньшем N. Но степень 1.585 в формулах не поменяется, потому что такова организация алгоритма быстрого умножения Карацубы, которым и определяется быстродействие. Никакой суперкомпьютер9 не сможет заставить умножение работать со скоростью линейного приближения TL(N).

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

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

Понятие аддитивной постоянной часто встречается в обыденной жизни, например, в нашем недавнем разговоре про спидометры. Когда мы говорим «приеду минут через сорок, плюс-минус пять минут», мы имеем в виду как раз аддитивную постоянную.

В асимптотическом анализе эта идея развивается, и вводится понятие мультипликативной постоянной алгоритма. Кто слышал о «Законе Мура», уже представляет себе, в чём оно состоит. Ещё в 1965 году Гордон Мур, один из основателей и генеральных директоров корпорации Intel, высказал предположение, что количество элементов в интегральных микросхемах будет удваиваться ежегодно в течение ближайших десяти лет. В 1975-м он его скорректировал: «количество элементов в интегральных микросхемах удваивается каждые два года». Это наблюдение остаётся верным более 40 лет, и пока оно верно, скорость работы компьютеров тоже удваивается примерно за два года. Такая «мультипликативная постоянная в истории вычислительной техники» означает, что одна и та же программа на старом компьютере будет работать в тысячу раз медленнее (а то и хуже), чем на новом.

Возьмём два алгоритма, решающих одну и ту же задачу. Изучив их уже известным нам способом, предположим, что алгоритм X требует 5·N действий на наборе данных размера N, а алгоритм Y — 2020*log₂ N действий на том же входном наборе. Какой из них эффективнее — X или Y?

Мы реализовали оба алгоритма и запускаем программы на двух компьютерах — один назовём Cfast, и он в два раза быстрее второго, Cslow. На иллюстрации 2-2 приведено количество действий, затраченных каждым алгоритмом на входных данных объёма N, а также время работы обоих алгоритмов на компьютере Cfast (столбцы обозначены как Xfast и Yfast) и на компьютере Cslow (столбцы Xslow и Yslow соответственно).

Иллюстрация 2-2. Производительность алгоритмов X и Y на разных компьютерах
Иллюстрация 2-2. Производительность алгоритмов X и Y на разных компьютерах

Количество действий

Время выполнения

превратить запятые в пробелы

-- FrBrGeorge 2021-12-02 07:23:40

На небольших наборах данных одинакового размера алгоритму X требует меньше действий, чем Y, однако начиная с N=8192 Y становится экономнее X, и чем N больше, тем разница сильнее. На иллюстрации 2-3 видна точка пересечения графиков между 4096 и 8192, после которой Y оказывается эффективнее X по количеству выполняемых действий. На двух разных компьютерах идентичные реализации алгоритма X закономерно покажут, что Xfast, запущенный на Cfast, всегда быстрее Xslow, запущенного на Cslow.

Допустим, мы нашли суперкомпьютер Cfastest, который в 500 раз быстрее, чем Cslow. Можно отыскать такой объём входных данных, начиная с которого эффективный алгоритм Y будет работать на Cslow быстрее, чем неэффективный — на Cfastest. Мы тут, конечно, немножко «сравниваем тёплое с мягким» — компьютеры-то разные! — но всё равно, даже в нашем случае точка пересечения возникает между наборами размером в 4 194 304 и 8 388 608. Если алгоритм в конечном счёте более эффективен, работая на даже медленном компьютере, он обгонит неэффективный алгоритм, запущенный на суперкомпьютере — если объём входных данных достаточно велик.

Иллюстрация 2-3. Графики для числовых данных иллюстрации 2-2
Иллюстрация 2-3. Графики для числовых данных иллюстрации 2-2

Общее количество действий

Количество действий

заменить запятые пробелами -- FrBrGeorge 2021-12-03 07:54:56

Размер набора данных = N

Время выполнения

Время в миллисекундах

заменить запятые пробелами -- FrBrGeorge 2021-12-03 07:54:56

Размер набора данных = N

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

В математике для классификации вычислительной сложности алгоритмов (и в худшем, и в лучшем случаях) используется обозначение «O большое». «O» в данном случае означает «order», т. е. «порядок». Порядок функции — это скорость, с которой она растёт при росте её переменной, N. Например, формула 4·N² + 3·N - 5 — «порядка » (или квадратичная), потому что быстрее всего в этом многочлене растёт первое слагаемое, в котором степень N равна 2.

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

../crow.png T(N) — время, которое потребуется алгоритму для обработки набора данных размера N. Для входных данных из лучшего и худшего случаев могут быть свои, непохожие друг на друга T(N). При этом не имеет значения, в чём измеряется время — в секундах или в миллисекундах.

S(N) — объём памяти, требуемый для работы алгоритма на наборе данных размера N. Для входных данных из лучшего и худшего случаев могут быть свои, непохожие друг на друга S(N). При этом не имеет значения, в чём измеряется объём памяти — в битах или в гигабайтах.

Подсчёт всех действий

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

Для начала определим функцию K(N) — количество основных действий, выполняемых в наихудшем случае на наборе данных размера N. Затем оценим число выполняемых машинных инструкций той же функцией с коэффициентом: c·K(N). Поскольку в современных языках программирования одно действие может быть транслировано в десятки, а то и тысячи инструкций, предосторожность в виде константы c будет нелишней. Вычислять её необязательно, хотя подобрать опытным путём с учётом производительности конкретного компьютера, как мы это делали раньше, вполне возможно.

Классификация вычислительной сложности (или потребления памяти) алгоритмов напрямую сопоставляет сложность с некоторой функцией от N. Этой функцией, f(N), описывается класс сложности O(f(N)) (поначалу в терминах легко запутаться). Относя алгоритм к определённому классу, мы составляем формулу в виде функции f от N. Нам уже встречались четыре класса сложности:

  1. O(N)линейная сложность, где f(n) = N.

  2. O(N¹·⁵⁸⁵) — сложность Карацубы, где f(N) = N¹·⁵⁸⁵.

  3. O(N²)квадратичная сложность, где f(N) = N².

  4. O(N log N)N-логарифмическая сложность, где f(N) = N log N10.

Для точного анализа надо изучать исходный текст программы и выявлять организацию алгоритма. Сколько раз выполняется действие ct += 1 в примере ниже?

   1        for i in range(100):
   2          for j in range(N):
   3              ct += 1

Внешний цикл, по i, делает 100 проходов, и на каждом из них из них внутренний цикл, по j, делает N проходов. Итого действие ct += 1 выполняется 100 * N раз. При должном выборе константы c время выполнения приведённого фрагмента программы T(N) на наборе данных размера N не будет превышать c * N. Запуская программу на конкретном компьютере можно определить значение соответствующего c. Говоря точнее, в терминах «О большого», сложность этого фрагмента оценивается как O(N).

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

Подсчёт всех байтов

Такой же анализ можно проделать для оценки «сложности по памяти» — объёма памяти, потребляемого алгоритмом на входных наборах размера N. Динамическое выделение дополнительной памяти неизбежно ведёт и к понижению производительности, потому что управление памятью — это тоже алгоритм, со своей сложностью.

Вот такие конструкции Python требуют совершенно разных объёмов памяти:

Чётко определить объём той или иной конструкции языка программирования непросто уже потому, что не до конца понятно, в чём его измерять. Допустим, в байтах или битах? Но тогда имеет ли значение, что на некоторых архитектурах целое число занимает 32 бита, а на некоторых — 64? Допустим, в будущем появится компьютер, оперирующий 128-битными целыми11 — значит ли это, что сложность по памяти при работе с целыми числами для них удвоится? Размер объекта в байтах в Python показывает функция sys.getsizeof(объект). Давайте на примере убедимся, что использование вычислимой последовательности range() в Python 3 позволяет ощутимо сэкономить потребление памяти! Для это достаточно запустить интерпретатор python3 и подать ему такие команды:

Здесь хорошо видно, что объём list(range(10000)) в байтах примерно в сто раз больше объёма list(range(100)). Глядя на остальные значения, можно с уверенностью сказать, что список в Python потребляет порядка O(N) памяти.

Для сравнения: и range(100), и range(10000) занимают один и тот же объём памяти — 48 байтов. Это новый для нас класс сложности, в котором значение постоянно и не зависит от N, он так и называется — константным.

  1. O(1)константная сложность, где f(N)=c с некоторой постоянной c.

Ну что ж, в этой главе мы рассмотрели довольно много теоретического материала; настало время применить наши знания на практике. Пора изучить алгоритм оптимального поиска элемента в упорядоченном списке — по-научному он называется алгоритмом двоичного поиска в массивах. Мы выясним, отчего он так эффективен, и попутно познакомимся с новым, логарифмическим классом сложности O(log N).

Одна дверь захлопнулась — другая откроется

За каждой из дверей на иллюстрации 2-4 скрыто по одному произвольному числу. Известно что числа за дверьми возрастают слева направо. Вопрос: сколько дверей необходимо в худшем случае открыть по одной, чтобы найти число 643 — или убедиться, что его за дверьми нет? Можно начать с самой левой и открывать каждую по очереди, пока не появится число 643 или большее (если 643 вообще не было за дверьми). Если не повезёт, придётся открыть все двери. В этом способе никак не учитывается, какие именно числа нашлись за уже открытыми дверями. В действительности для ответа на вопрос достаточно открыть не более трёх дверей. Для начала откроем дверь №4 — среднюю.

Иллюстрация 2-4. Двери Судьбы!
Иллюстрация 2-4. Двери Судьбы!

Там окажется число 173. Раз мы ищем 643, все двери слева от четвёртой нас больше не интересуют, потому что номера за ними меньше 173. Теперь откроем дверь №6 — за ней число 900. Прекрасно, теперь двери правее шестой нас тоже не интересуют. Остаётся только дверь №5 — и либо за ней скрывается 643, либо этого числа вообще не было в исходной последовательности. Пока она закрыта, мы волны воображать и то, и другое.

На любой возрастающей последовательности из семи чисел и для любого искомого числа потребуется открыть не более трёх дверей. Кстати, можно заметить, что 2³ - 1 = 7. А если возрастающая последовательность будет состоять из миллиона элементов за миллионом дверей, и с нами поспорят на 10 000 долларов, что мы не найдём нужное число, открыв только 20 дверей? Надо соглашаться! Ведь 2²⁰=1048575, так что двадцати открытых дверей хватит на то, чтобы проверить возрастающую последовательность из 1 048 575 чисел. Более того, если количество дверей внезапно удвоится (даже дойдёт до 2 097 151), понадобится дополнительно открыть всего одну дверь, 21 дверь на все два с лишним миллиона. Удивительно быстрый способ! Вот мы и изобрели алгоритм двоичного поиска в упорядоченном массиве.

Двоичный поиск в упорядоченном массиве

Двоичный поиск — один из базовых алгоритмов: порядок его сложности отличается от уже рассмотренных нами. В примере 2-3 реализован двоичный поиск значения target в упорядоченном списке A.

   1 def binary_array_search(A, target):
   2   lo = 0
   3   hi = len(A) - 1               # 1
   4 
   5   while lo <= hi:               # 2
   6     mid = (lo + hi) // 2        # 3
   7 
   8     if target < A[mid]:         # 4
   9       hi = mid-1
  10     elif target > A[mid]:       # 5
  11       lo = mid+1
  12     else:
  13       return True               # 6
  14 
  15   return False                  # 7

Поначалу lo и hi равны наименьшему и наибольшему индексу в A. Пока диапазон поиска не исчерпан, с помощью целочисленного деления находим его середину, mid. Если A[mid] и есть target, поиска окончены; иначе становится понятно, сужать ли диапазон до левой части, A[lo … mid-1] или до правой, A[mid+1 … hi].

../crow.png Запись A[lo … mid] в нашей книге означает диапазон значений из списка A начиная с индекса lo до индекса mid включительно. Если lo < mid, диапазон пуст.

Наша функция определяет, принадлежит ли некоторое значение упорядоченному списку из N элементов. На каждом обороте цикла можно либо отыскать нужный элемент, либо сузить диапазон. Цикл заканчивается, когда элемент найден, или когда hi оказывается меньше lo.

Немногим сложнее, чем π

Рассмотрим двоичный поиск числа 53 в списке на иллюстрации 2-5. Переменные hi и lo поначалу совпадают с границами списка A. В цикле while вычисляется mid. Поскольку A[mid] равно 19 и меньше 53, выполнение идёт по ветке elif и значение lo меняется на mid + 1. Дальнейший поиск производится уже по диапазону A[mid+1 … hi]. Серым закрашены не рассматриваемые значения. Размер диапазона уменьшается вполовину (с 7 до 3).

Иллюстрация 2-5. Поиск числа 53
Иллюстрация 2-5. Поиск числа 53

Шаг поиска 1

Шаг поиска 2

На втором проходе цикла while вычисляется новое mid и оказывается что A[mid] равно искомому числу, 53, так что функция возвращается True.

../crow.png Имеет ли смысл перед запуском двоичного поиска поверить, точно ли A[0] <= target <= A[-1]? Так мы могли бы избежать бессмысленного поиска числа, которое в принципе не может оказаться в списке. Говоря коротко — нет, не имеет. Это добавит целых две проверки (которых даже на миллион всего двадцать) к каждому поиску, а сами проверки окажутся лишними в поиске чисел, которые заведомо принадлежат диапазону значений A.

Давайте теперь поищем число 17, которого нет в списке. Как показано на иллюстрации 2-5, lo и hi снова установлены по границам A. A[mid] равно 19; это больше, чем 17, так что вычисление идёт по ветке if, и диапазон сужается до A[lo … mid-1]. Серым закрашены значения, которые нам больше не нужны. A[mid] теперь равно 14, это меньше 17, и вычисление идёт по ветке elif, а диапазон сужается до A[mid+1 … hi].

Иллюстрация 2-6. Поиск числа 17
Иллюстрация 2-6. Поиск числа 17

Шаг поиска 1

Шаг поиска 2

Шаг поиска 3

Шаг поиска 4

На третьем проходе цикла while получаем A[mid] равным 15; это меньше, чем искомое 17. Снова идём по ветке elif, где lo делается больше, чем hi: диапазон «схлопывается», как показано в конце иллюстрации 2-6. Условие цикла while становится ложным, и возвращается False: это означает, что target не найден в A.

Двух зайцев одним выстрелом

Зачастую требуется найти, где именно в A находится target, а не только проверить, что он там есть. Пока что наша функция возвращала только True или False. Слегка подправим исходный текст, и двоичный поиск будет возвращать позицию mid, где находится target.

   1 def binary_array_search(A, target):
   2   lo = 0
   3   hi = len(A) - 1
   4 
   5      while lo <= hi:
   6        mid = (lo + hi) // 2
   7 
   8       if target < A[mid]:
   9         hi = mid-1
  10       elif target > A[mid]:
  11         lo = mid+1
  12       else:
  13         return mid              # 1
  14 
  15      return -(lo+1)             # 2

Что бы такого полезного вернуть, если target нет в A? Можно просто None, как это принято в Python, но в нашем случае есть шанс дать пользователю дополнительный сведения. Например, можно не только сообщить о том, что target отсутствует в A, но и заранее вычислить место, куда его следует при необходимости вставить, не нарушая порядка A.

Давайте ещё раз посмотрим на иллюстрацию 2-6. Пока мы искали число 17 в списке, где его не было, значение lo стало указывать на место, куда 17 можно было бы вставить, соблюдая порядок в A. Можно было бы в этом случае вернуть просто -lo, но если искомый элемент найден в начальной позиции, или не найден, и это только место вставки, в обоих случаях вернётся 0 . Так что возвращаем заведомо отрицательное число -(lo + 1). Если пользователь получит отрицательное x, он будет знать, что target отсутствует в A, но его можно туда добавить в позицию -(x + 1). Если x неотрицателен — это позиция target в A.

Напоследок — ещё немного оптимизации. В примере 2-4 над элементами A производится два сравнения, хотя можно было бы обойтись одним, а потом проверять его результаты. Если значения числовые, таким сравнением может быть их разность — как в примере 2-5, где над элементами A производится всего одно действие (а не два). Кстати, операция индексирования A[mid] в примере тоже одна.

   1 diff = target - A[mid]
   2 if diff < 0:
   3   hi = mid-1
   4 elif diff > 0:
   5   lo = mid+1
   6 else:
   7   return mid

Если target был меньше A[mid], diff окажется отрицательным, и это аналогично сравнению target < A[mid]. Если diff положителен, значит, target оказался больше A[mid]. Допустим, значения A — не числовые, но для них существует операция сравнения, которая возвращает отрицательное число, ноль или положительное число, в соответствии с тем, как эти значения можно упорядочить. Сравнение обычных целых вместо дорогостоящего сравнения элементов увеличивает быстродействие.

../lemur.png Если список упорядочен по убыванию, двоичный поиск тоже работает — только hi и lo должны в цикле while меняться по-другому.

Как быстро работает двоичный поиск на данных объёма N? Чтобы ответить на вопрос, надо привести наихудший случай входных данных и посчитать, сколько раз должен выполниться цикл while. Понятие «логарифма» нам в помощь!12

Что такой логарифм? Попробуем ответить на вопрос: сколько раз нужно удвоить единицу, чтобы получить число 33 554 432? Можно, конечно, попробовать добраться до него вручную — 1, 2, 4, 8, 16 и т. д. — но это довольно скучно. Что мы ищем, с математической точки зрения? Такое x, что 2x=33554432.

Запись 2x — это возведение основания 2 в степень x. Логарифм — функция, обратная возведению в степень, примерно как деление — функция, обратная умножению. Чтобы найти такое x, что 2x=33554432, надо посчитать логарифм по основанию 2 от этого числа, log₂ 33554432. Получится 25.0. Если вычислить 2²⁵ на калькуляторе, получится 33 554 432.

То же значение — ответ на вопрос «как долго можно делить 33554432 пополам?»: после 25 делений получится 1. Логарифм — вещественное число; например, log₂ 137 — примерно 7.098032. Что в целом понятно: раз уж 2⁷=128, то для получения 137 степень нужна чуть-чуть побольше.

В алгоритме двоичного поиска цикл while повторяется до тех пор, пока lo ⩽ hi — то есть пока есть где искать. На первом проходе в диапазоне поиска N значений, на втором их уже N/2 (а если N нечётно, то (N-1)/2). Следовательно, чтобы предсказать количество проходов цикла, надо посчитать, сколько раз придётся делить N пополам, пока не получится 1. А это и есть в точности k = log₂ N, что даёт нам 1 + k проходов цикла (первый — для полного N, остальные k — для меньших диапазонов). Логарифм — вещественное число, нам для оценки количество проходов, конечно, нужно целое. Подойдёт нижнее округление floor(x), математическая функция, возвращающая наибольшее целое число, не превосходящее x13

../lemur.png No handheld calculator has a button to compute log2(X)—most calculator apps don’t either. Don’t panic! You can always compute log2 easily. For example, log2(16) = 4. On your calculator, enter 16 and then press the log button (which is either in base 10 or the nat‐ ural base e). Your display should read something awful like 1.20411998. Now press the / (divide) button, press the 2 button, and finally press the log button again. Your display should read 0.301029995. Now that all hope seems lost, press the equals button. Magically the value 4 appears. This sequence of operations demon‐ strates that log2(X) = log10 (X)/log10 (2).

Прямо испанский стыд какой-то! А можно эту выноску в перевод не включать? Портативные микрокалькуляторы… Если надо ещё раз объяснить про смену основания логарифма, я лучше на примере Python покажу! Примерно так:

Далеко не каждый калькулятор или приложение-калькулятор умеют вычислять двоичный логарифм. Но у нас же есть Python! Лучший в мире программируемый калькулятор всегда у вас под рукой. Достаточно запустить любую версию командного интерпретатора — стандартный python, консоль любого средства разработки (например, IDLE — она есть в базовой поставке python) или специализированный терминал, наподобие iPython — и импортировать нужную библиотеку. В ответ на подсказку «>>> » вводим нужную формулу — и вуаля, ответ перед нами.

   1 >>> from math import *
   2 >>> log2(16)
   3 4.0
   4 >>> log(16)
   5 2.772588722239781
   6 >>> log10(16)
   7 1.2041199826559248
   8 >>> log(16)/log(2)
   9 4.0
  10 >>> log10(16)/log10(2)
  11 4.0
  12 

В примере выше иллюстрируется важное свойство: логарифм одного и того же числа по разным основаниям отличается только множителем. Скажем, чтобы получить логарифм числа 16 по основанию 2, можно воспользоваться натуральным логарифмом (по основанию e ≈ 2.718281828459045…) числа 16 и натуральным логарифмом самого основания 2. Функция log(x) в Python вычисляет натуральный логарифм, log2(x) — двоичный, а log10(x) — логарифм по основанию 10.

-- FrBrGeorge 2021-12-15 09:03:52

Итак, в двоичном поиске цикл while делает не более floor(log2(N))+1 проходов. Это невероятно быстро! Найти нужное число из миллиона отсортированных значений можно всего за 20 попыток.

../lemur.png Мы можем сами убедиться в корректности формулы: для любого объёма входных данных от 8 до 15 элементов требуется не более 4 проходов. Если на первом проходе в диапазоне 15 элементов, то на втором их будет 7, на третьем — 3, и на четвёртом останется только один. Если начать с 10 элементов, диапазон будет меняться так: 10 → 5 → 2 → 1 — и это тоже четыре прохода.

Получается, что изучение двоичного поиска дало нам новый класс сложности, O(log(N)), он называется логарифмическим, потому что определяющая его функция f(N) = log(N).

Стало быть, если при изучении алгоритма мы утверждаем, что его сложность по времени — O(log N), это означает, что существует такая константа c, для которой, начиная с некоторого достаточно большого N, скорость работы алгоритма T(N) будет всегда меньше, чем c · log N. Наше утверждение верно, если неверны аналогичные утверждения относительно более низких классов вычислительной сложности.

Иллюстрация 2-7. Иерархия классов вычислительной сложности в порядке доминирования
Иллюстрация 2-7. Иерархия классов вычислительной сложности в порядке доминирования

У автора — «всех классов», но потом он сам говорит, что это неправда, и тут даже не все описанные в книге классы сложности есть, не говоря о массе других.

Термин sublinear и по-русски (сублинейные), и по-английски имеет совсем другое основное значение.

И ещё. Вот эта стрелка вниз как бы намекает нам о существовании алгоритмов, которые эффективнее, чем O(1), т. е. выдают решение быстрее, чем сразу. Надо бы её убрать, или как минимум задизайнить так, чтобы не показывала вниз от O(1).

Наивысшая сложность

Экспоненциальная

Факториальная

Экспоненциальная

Полиномиальная

Кубическая

Квадратичная

N-логарифмическая (N log N)

Линейная

Долинейная

Логарифмическая

Константная

Минимально возможная сложность

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

Линейная сложность, O(N), предусматривает увеличение времени работы прямо пропорционально объёму данных. Далее идёт набор полиномиальных классов в порядке возрастания сложности — O(N²), O(N³) и так далее вплоть до любой наперёд взятой константы c, O(Nc)14. Между O(N) и O(N²) затесался класс O(N log N); для многих задач именно алгоритм такого класса специалисты считают наилучшим.

Доминирование классов друг над другом выражается ещё и в том, как определяется алгоритм, в оценке сложности которого встречаются несколько различных компонентов-формул. Например, если сложность одной части алгоритма оценивается как O(N log N), а другой — как O(N²), то какова сложность всего алгоритма? Ответ — O(N²), потому что сложность второй части доминирует над сложностью первой. Если, например, оценка сложности вашего алгоритма T(N) оказалась равной 5 N² + 10000000·N·log₂ N, то класс его сложности — O(N²).

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

Как всё это работает

В таблице 2-4 представлены результаты вычислений f(N) для каждого упомянутого класса сложности. Предположим, что числа в таблице — это время работы некоторого алгоритма в секундах. Сложность алгоритма указана в заголовке столбца, а размер входного набора данных N — в заголовке строки. 4096 минут — это час и восемь минут, так что чуть больше, чем за час можно решить:

N

log N

N

N log N

N2

N3

2N

N!

2

1

2

2

4

8

4

2

4

2

4

8

16

64

16

24

8

3

8

24

64

512

256

40 320

16

4

16

64

256

4 096

65 536

2.1·1013

32

5

32

160

1 024

32 768

4.3·109

2.6·1035

64

6

64

384

4 096

262 114

1.8·1019

1.3·1089

128

7

128

896

16 384

2 097 152

3.4·1038

256

8

256

2 048

65 536

16 777 216

1.2·1077

512

9

512

4 608

262 144

1.3·108

1 024

10

1 024

10 240

1 048 576

1.1·109

2 048

11

2 048

22 528

4 194 304

8.6·109

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

Иллюстрация 2-8. График быстродействия разных классов сложности на возрастающем объёме входных данных
Иллюстрация 2-8. График быстродействия разных классов сложности на возрастающем объёме входных данных

Классы сложности «O большое»

Быстродействие различных классов на больших объёмах данных выглядит обычно так, как на иллюстрации 2-8. Ось X отвечает за размер входного набора, ось Y — за полное время работы алгоритма, классы алгоритмов надписаны на графиках. Чем выше класс сложности алгоритма, тем меньше объём данных, которые он сможет обработать за «приемлемое время»15.

Рассмотрим ещё несколько примеров посложнее:

Приближённая кривая — или чёткие границы?

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

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

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

В разговоре про двоичный поиск мы выяснили, что цикл while в функции делает не более floor(log2(N)) + 1 проходов. Это значит, что оценка в худшем случае f(N) = log(N), и порядок сложности двоичного поиска — логарифмический, O(log(N)). Как двоичный поиск работает в лучшем случае? Если искать элемент, равный A[mid], функция завершится всего за один проход цикла. Этот результат постоянен и не зависит от размера входных данных, значит, порядок двоичного поиска в лучшем случае — константный, O(1). Обозначение «О большое» можно применять и для лучших, и для худших случаев, хотя с точки зрения большинства программистов это имеет смысл только для худших.

../lemur.png Для классификации сложности алгоритма нередко применяют обозначение вида Θ(N log N) (с большой греческой буквой тета). Хотя чаще всего это обозначение применяется для оценки сложности алгоритма в среднем, его смысл более узкий. Такая запись означает, что и верхняя и нижняя граница оценки определяется одной и той же формулой, в данном случае верхняя граница — O(N log N), а нижняя — Ω(N log N). По-английски этот термин звучит как «tight bound», «строгая оценка». Строгая оценка производительности алгоритма, если она есть — лучший способ убедиться, что время его работы хорошо предсказуемо.

Заключение

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

Тренировочные задания

  1. Оцените сложность по времени каждого из фрагментов программы в таблице 2-5.

    • Фрагмент 1
         1    for i in range(100):
         2      for j in range(N):
         3        for k in range(10000):
         4          ...
      
    • Фрагмент 2
         1    for i in range(N):
         2      for j in range(N):
         3        for k in range(100):
         4          ...
      
    • Фрагмент 3
         1    for i in range(0,N,2):
         2      for j in range(0,N,2):
         3        ...
      
    • Фрагмент 4
         1    while N > 1:
         2      ...
         3      N = N // 2
      
    • Фрагмент 5
         1    for i in range(2,N,3):
         2      for j in range(3,N,2):
         3        ...
      

    Таблица 2-5. Оцените фрагменты программ

  2. Используя методику анализа, описанную в этой главе, оцените значение ct, возвращаемое функцией f4(N) в примере 2-6.

       1  def f4(N):
       2    ct = 1
       3    while N >= 2:
       4      ct = ct + 1
       5      N = N ** 0.5
       6    return ct
    

    Пример 2-6. Исследуйте функцию

    • Ни одну из рассмотренных в данной главе оценок применить не получится. Попробуйте взять за основу формулу a · log₂(log₂N). Составьте таблицу значений функции вплоть до N = 2⁵⁰ и сравните с вашей оценкой. Полученный класс сложности можно определить как O(log(log N)).

  3. Есть один неэффективный способ сортировки: перебрать все перестановки исходного массива и вернуть ту, которая окажется отсортированной. Этот способ приведён в примере 2-7.

       1 from itertools import permutations, pairwise
       2 from scipy.special import factorial
       3 
       4 def factorial_model(v, a):
       5   return a * factorial(v)
       6 
       7 def check_sorted(a):
       8   for x, y in pairwise(a):
       9     if x > y:
      10       return False
      11   return True
      12 
      13 def permutation_sort(A):
      14   for attempt in permutations(A):
      15     if check_sorted(attempt):
      16       A[:] = attempt             # заполним A отсортированными значениями
      17       return
    

    Пример 2-7. Сортировка путём перебора всех перестановок списка

    • Составьте таблицу быстродействия функции permutation_sort() в наихудшем случае (здесь это список, упорядоченный по убыванию); размер списка от 1 до 12 элементов включительно. Подберите приближённую кривую с помощью функции factorial_model() и исследуйте, насколько хорошо она прогнозирует время работы permutation_sort(). Сколько лет, если верить полученным данным, эта функция будет сортировать упорядоченный по убыванию список из 20 элементов?17

  4. Соберите данные по быстродействию 50 000 экспериментальных запусков двоичного поиска на случайных наборах размера N от 2⁵ до 2²¹. Каждый эксперимент должен использовать случайный входной набор размера N, получаемый с помощью random.sample() из диапазона чисел от 0 до 4·N, который затем нужно отсортировать. Искать в каждом эксперименте нужно случайное число из того же диапазона.

    • Используя методику, описанную в данной главе, подберите с помощью curve_fit() логарифмическое (Log N) приближение к полученным данным для N в диапазоне от 2⁵ до 2¹². Определите пороговое значение N, начиная с которого результаты экспериментов предсказуемо описываются найденным приближением. Нарисуйте график функции-приближения и точки экспериментальных данных. Насколько достоверно найденная функция описывает эксперимент?

  5. В отличие от предыдущих задач, будем исследовать сложность по памяти. Рассмотрим ещё один странный алгоритм сортировки списка в примере 2-8.

       1      def max_sort(A):
       2        result = []
       3        while len(A) > 1:
       4          index_max = max(range(len(A)), key=A.__getitem__)
       5          result.insert(0, A[index_max])
       6          A = list(A[:index_max]) + list(A[index_max+1:])
       7        return A + result
    
    • Пример 2-8. Сортировка путём постоянного удаления наибольшего значения из списка

    • Используя методику из этой главы, оцените сложность по памяти функции max_sort().

  6. Галактический алгоритм — это алгоритм решения некоторой задачи, который становится эффективнее других известных алгоритмов только на «недостижимо большом» наборе данных. Например, алгоритм умножения N-значных чисел, опубликованный в ноябре 2020 года Дэвидом Харви и Йорисом ван дер Хувеном, имеет сложность O(N log N), но только для N, превышающих 2Z, где Z = 1729¹². Это Z — само по себе астрономическое число, примерно 7·10³⁸, а уж если в такую степень возвести 2, получится уже что-то запредельно громадное! Почитайте, что пишут о галактических алгоритмах. На практике их использовать нельзя, но зачастую осознание того, что для данной задачи может существовать алгоритм более низкого класса сложности, подталкивает к дальнейшим открытиям!

  7. В таблице 2-1 приведены три ряда измерения для трёх размеров набора входных данных. Если бы рядов было только два, удалось бы составить приближение для квадратичного алгоритма? Более общий вопрос: если имеется K замеров производительности, какова наибольшая степень многочлена, которым можно приблизить эти результаты?

  1. По-английски произносится в три слога, как «эн-лог-эн» (прим. автора). (1)

  2. Оптимистичный прогноз! Согласно «закону Мура», который никто не подтвердил и не опроверг, потому что он тоже является эмпирической моделью, удвоение стоит ожидать через два года, а не через пять. С другой стороны, различие только в коэффициенте… (2)

  3. Строго говоря, дело обстоит так. Мы передаём в curve_fit() три параметра — векторную функцию linear_model(), массив с иксами и массив с известными значениями этой функции на этих иксах. Функция linear_model() — непростая. Во-первых, первый её операнд, v, — это не число, а массив numpy (умножение которого на число или сложение с числом приводят к поэлементному выполнению соответствующих операций). Остальные операнды этой функции — a и b — это коэффициенты. Их-то и подбирает curve_fit() методом наименьших квадратов, и возвращает в виде массива. Также она возвращает матрицу ковариации, но для данного исследования она не нужна. По договорённости «ненужные» значения принимает специальная переменная с именем «_». (3)

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

  5. Алгоритм быстрого умножения изобрёл в 1960 году двадцатитрёхлетний Анатолий Карацуба, в то время — аспирант механико-математического факультета Московского Государственного Университета. Этот алгоритм используется в Python для умножения больших чисел (прим. автора). (5)

  6. Нестандартная степень 1.585 в формулах — это округлённое значение log₂3 ≈ 1.58496250072116 (прим. автора). (6)

  7. Внимательный читатель, должно быть, заметил, что «точная» формула (TKN) лучше «упрощённой» (Karatsuba) только на не слишком больших числах — аккурат на тех, для которых известны экспериментальные данные. Для умножения действительно больших чисел в Python используется уже не алгоритм Карацубы, а дискретное разложение Фурье. Класс сложности $$ N^(log_2 3) $$ у этого алгоритма тот же (кажется, для достижимых чисел он минимально возможный), а накладных расходов на хранение промежуточных результатов, если их предполагается много, меньше. Возможно — эту гипотезу следует проверить! — TKN приближает как раз алгоритм Карацубы, а Karatsuba, вопреки названию, — алгоритм дискретного разложения. (7)

  8. Это правила Евросоюза; в США показаниям спидометра достаточно держаться в диапазоне ±5 миль в час (прим. автора). В России правила сложнее: погрешность должна быть положительной (т. е. нижняя граница — это текущая скорость); до 60 км/ч верхняя граница не должна превышать +4% от скорости, а дальше на каждые дополнительные 20 км/ч количество процентов увеличивается на 1: для 80 — +5%, для 100 — +6% и т. д. Но это ещё не всё: дополнительно вводится температурная погрешность, которая тем больше, чем дальше температура от 20° по Цельсию. (8)

  9. за исключением, быть может, квантового (9)

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

  11. Такими «компьютерами будущего» являются, например, игровая консоль Sony PlayStatinon 2 и большинство графических процессоров. (11)

  12. По-английски «алгоритм» и «логарифм» звучат очень похоже, но это совпадение случайно! (прим. автора) (12)

  13. Автор забыл упомянуть о том, что наихудший случай для данной реализации алгоритма — поиск числа, заведомо большего всех 2N элементов массива. Именно в этом варианте каждый диапазон поиска в цикле будет чётной длины. Если оказалось, что хотя бы один из диапазонов — нечётной длины, это сэкономит один проход. Поэтому для 2N-1 элементов (в которых такая ситуация невозможна) оценка уже будет меньше. (13)

  14. Иными словами, какую бы константу c мы заранее не взяли, всегда найдется достаточно большой объём данных, на котором наш полиномиальный алгоритм степени c эффективнее данного алгоритма более высокого класса сложности. А вот утверждать то же самое про весь бесконечный ряд {O(Nk)} нельзя. (14)

  15. Графики наивысших классов сложности — экспоненциального и факториального — на иллюстрации отсутствуют, потому что в таком масштабе они практически совпадают с осью OX — сразу уходят в «бесконечность». (15)

  16. Знак «Ω» — это большая греческая буква омега (прим. автора.). (16)

  17. Кое-что в примере нуждается во объяснении. Во-первых, itertools.permutations(A) возвращает в цикл for все перестановки A по одной, не храня их все в памяти. Во-вторых, обычный factorial() из библиотеки math() нельзя применить к массиву v, поэтому в factorial_model() используется специальная векторная функция scipy.special.factorial(v), которая возвращает массив факториалов элементов v. В-третьих, itertools.pairwise(A) возвращает в цикл for все соседние пары элементов в последовательности A, сначала нулевой элемент и первый, затем первый и второй и т. д. (17)

FrBrGeorge/Books/LearningAlgorythms/02_Analyzing_Algorithms (последним исправлял пользователь FrBrGeorge 2021-12-24 18:20:36)