Решение задач
В этой главе мы рассмотрим:
Несколько алгоритмов решения одной и той же вводной задачи
Как исследовать производительность алгоритма на входных наборах данных размера N.
Как посчитать количество основных действий, выполненных при обработке конкретного набора данных.
Как определить уровень падения производительности при удвоении входных данных
Как предсказать сложность алгоритма по времени на основании подсчёта действий, которые он выполнит на входных данных размера N.
Как предсказать сложность алгоритма по памяти на основании оценки дополнительной памяти, которая потребуется ему для обработки входных данных размера N.
Что ж, начнем!
Что такое алгоритм?
Объяснять алгоритм — это как рассказывать сказку. Каждый алгоритм — это новый волшебный предмет или неожиданный поступок, которые помогают быстро и легко справиться с казавшейся трудной задачей. В этой главе мы рассмотрим несколько решений нетрудной задачи, а потом разберёмся, какой алгоритм эффективнее, и почему. Кроме того, поймём, как изучать быстродействие алгоритма независимо от его реализации — хотя непосредственные данные о работе конкретных реализаций у нас тоже будут.
![]() |
Алгоритм — это пошаговая инструкция решения некоторой задачи, реализованная в виде программы. Программа должна возвращать правильный ответ за предсказуемое время. Изучая алгоритм, мы проверяем и правильность ответа (в том числе на всех допустимых входных данных), и его вычислительную сложность (возможно, есть более эффективные решения задачи). |
Давайте посмотрим, как процесс решения и анализа задачи проходит в жизни. Скажем, нам нужно найти наибольшее значение в несортированном списке. В левой части иллюстрации 1-1 приведено три набора данных для нашей задачи1 — каждый набор в виде списка Python. Данные обрабатываются алгоритмом (изображён как цилиндр), который должен выдавать правильный ответ; ответы перечислены в правой части. Как реализован алгоритм решения? Как он себя ведёт на различных входных наборах? Можно ли предсказать время работы? как быстро можно найти наибольшее из миллиона чисел?
Иллюстрация 1-1. Обработка алгоритмом различных наборов входных данных
Набор данных |
Алгоритм |
Правильный ответ |
Алгоритм и реализующая его программа должны не только давать правильный ответ, но и завершаться за предсказуемое время. Наша задача уже решена — для неё в Python есть функция max(). Правда, аккуратно исследовать эффективность алгоритма, оперируя произвольными входными данными, нельзя. Имеет смысл тщательно подготовить подходящие наборы данных.
Таблица 1-1 показывает время работы функции max() на двух типах входных данных различных размеров: в одних наборах целые числа в списке идут по возрастанию, в других — по убыванию. На разных компьютерах результаты окажутся разными, но всегда можно проверить неизменность двух вещей:
Время вычисления max() на достаточно длинных возрастающих последовательностях всегда больше времени вычисления на таких же, но убывающих.
Если длина последовательности увеличивается вдесятеро, время вычисления max() на ней тоже увеличивается плюс-минус вдесятеро, как если бы мы каждую проверку делали вручную.
В нашей задаче вычисляется наибольшее значение, а исходная последовательность не меняется. В некоторых других случаях — например в алгоритмах сортировки элементов списка из главы 5 — требуется не вычислить новое значение, а изменить сами введённые данные. В нашей книге N обозначает размер набора входных данных.
Таблица 1-1. Запуск функции max() на двух типах наборов входных данных размера N.2
N |
Восходящая последовательность |
Нисходящая последовательность |
100 |
0.001 |
0.001 |
1 000 |
0.013 |
0.013 |
10 000 |
0.135 |
0.125 |
100 000 |
1.367 |
1.276 |
1 000 000 |
14.278 |
13.419 |
Замечания о времени работы:
Невозможно с уверенностью предсказать время работы алгоритма на наборе, допустим, из 100 000 элементов (обозначим это время T(100 000)). Для разных языков программирования и на разных компьютерах это время будет разным.
Однако зная T(10 000) предсказать T(100 000) — время работы на десятикратно больших данных — можно, хотя погрешность такого предсказания неизбежна.
Когда придумываешь алгоритм, важнее всего убедиться, что он работает правильно на всех допустимых наборах входных данных. Как сравнивать свойства двух алгоритмов, если они решают одну и ту же задачу? Этим мы, по большей части, займёмся в главе 2. Изучение алгоритма тесно связано с интересными задачами «из настоящей жизни», в которых он требуется. Математическая сторона алгоритма может быть непростой, но мы постараемся каждое отвлечённое понятие связать с таким вопросом жизненной практики.
Принято думать, что оценить эффективность алгоритма — значит подсчитать, сколько ему потребовалось вычислительных операций. Но как раз это совсем непросто! Центральный процессор компьютера (CPU) исполняет машинные инструкции — арифметически операции (наподобие сложения и умножения), пересылку данных из памяти в регистры процессора, сравнения и т. п. Современные языки программирования бываю как компилируемые (Си или C++), в которых текст программы перед запуском транслируется в машинные инструкции, так и интерпретируемые (Python или Java). Программа на интерпретируемом языке транслируется в промежуточное представление, называемое байт-кодом. Затем программа-интерпретатор, Python, например (в свою очередь сам написанный на Си и откомпилированный) разбирает и выполняет этот байт-код3. При этом некоторые функции, например min() и max(), встроены Python — они написаны на Си, скомпилированы в машинные инструкции и так выполняются.
Массив всемогущий
А не ворона ли тут? -- FrBrGeorge 2021-11-04 13:36:14
Массив — фиксированный набор однотипных значений, занимающий один непрерывный фрагмент оперативной памяти. Это — одна и самых старых и самых надёжных структур данных; если значений больше одного — программисты хранят их в массиве. НА схеме изображён целочисленный массив из восьми элементов.
В массиве A — восемь ячеек. Каждая ячейка доступна по номеру, например, A[0] — это 31, а A[7] — 5. В массивах можно хранить и строки, и вообще любые объекты сколь угодно сложного типа.
Что следует знать про массивы:
Начальный элемент массива A длиной N имеет индекс 0 и обозначается A[0], конечный обозначается A[N-1] и имеет индекс N-1.
Зная номер i элемента в массиве, можно прочитать оттуда элемент A[i] или записать на место A[i] новое значение. При этом i — это индекс в диапазоне от 0 до N-1.
Длина массива всегда известна. В Python и Java её можно задать в процессе работы программы, а в Си — только заранее.
Чтобы изменить длину массива, необходимо выделить память под новый массив нужной длины и скопировать туда все данные из старого. Просто так размер массива уменьшить или увеличить нельзя.
Несмотря на простоту, массивы — очень гибкий и удобный инструмент организации данных. В Python имеется тип данных list, с которым можно смело работать как с массивом, хотя его возможности куда шире: в list можно хранить объекты разных типов одновременно, а размер его может меняться в процессе работы программы.
Посчитать, сколько машинных инструкций выполнилось при работе алгоритма, практически невозможно — начать с того, что современные компьютеры выполняют их миллиардами за секунду! Давайте вместо этого считать, сколько основных действий выполнил алгоритм. Вопрос «сколько действий?» может означать «сколько раз сравнивались два элемента массива» или, к примеру «сколько раз вызывалась некоторая функция». В случае max() нас будет интересовать, сколько раз вызывалась операция сравнения «меньше, чем» (>). Подробнее о правилах подсчёта действий мы поговорим в главе 2.
Ну что же, настала пора сорвать покров тайны с алгоритма функции max() и понять, что именно определяет её поведение.
Поиск наибольшего значения в произвольной последовательности
Рассмотрим (неполноценную) реализацию поиска наибольшего значения произвольной последовательности в примере 1-1. Каждый элемент A сравнивается с my_max, и если найдено значение больше, my_max обновляется.
Пример 1-1. Неполноценная реализация поиска наибольшего значения последовательности
Переменная my_max хранит текущее наибольшее значение. Здесь она инициализируется нулём. / my_max is a variable that holds the maximum value; here my_max is initialized to 0. */
В цикле for определена переменная v, которая на каждом проходе цикла равна очередному элементу A. Оператор if внутри цикла выполняется для каждого такого v.
Переменная my_max обновляется, если v оказалось больше.
Главное в нашем решении — операция сравнения двух выражений на меньше (<), которая определяет, меньше ли первое выражение второго. На иллюстрации 1-2 показано, что по мере прохождения переменной v всех значений из A переменная my_max меняется трижды. Функция flawed() определяет наибольшее значение A из шести элементов, вызывая «<» не более шести раз, по одному разу на каждый элемент. Если в наборе данных будет N элементов, flawed() вызовет «<» не более N раз.
Иллюстрация 1-2. Как работает flawed()
|
Окончательное значение — наибольшее |
Начальное значение |
|
Обновлять my_max, если v больше него |
|
Эта реализация алгоритма содержит ошибку: предполагается, что в A есть хотя бы одно неотрицательное число. Вызов flawed([–5,–3,–11]) вернёт 0 — что неправильно. Часто вместо нуля пытаются использовать «наименьшее возможное число», примерно так: my_max = float('-inf'). Этот подход также небезупречен, потому что для пустого списка A = [] он вернёт -inf, которого там не было. Недочёт надо исправить.
![]() |
Python-выражение range(x,y) вычисляет последовательность целых чисел от x до y (не включая y). Если x больше, чем y, можно задать убывающую последовательность от x до y, не включая y, с помощью range(x,y,–1). Если сделать список из range(1,7), list(range(1,7)) даст [1,2,3,4,5,6]. Соответственно, list(range(5,0,–1)) даст [5,4,3,2,1], а если дополнительно задать шаг — приращение последовательности — list(range(1,10,2)) даст [1,3,5,7,9]: разность между соседними элементами будет равна 2. |
Подсчёт действий
Первоначальное значение my_max лучше выбрать из элементов A — тогда можно быть уверенным, что вычисленное нами наибольшее значение будет тоже из A. Работающая на всех входных наборах, кроме пустых, а значит — правильная функция largest() приведена в примере 1-2. Здесь мы выбираем в качестве начального значения my_max начальный элемент A, а затем сравниваем его со всем остальными: вдруг там найдётся побольше?
Пример 1-2. Правильная функция, которая находит наибольшее значение в списке
Сделаем my_max равным начальному элементу списка (он доступен по индексу 0).
Переменная idx принимает целочисленные значения от 1 до len(A)-1 включительно, не достигая len(A).
Если в A по индексу idx стоит большее значение, обновить my_max.
![]() |
Если передать largest() пустой список — largest([]), — в первой же строчке возникнет исключение IndexError — потому что никакого элемента A[0] в списке нет. Встроенная функция max([]) на пустом списке порождает исключение ValueError с пояснением о том, что пустые последовательности не входят в область определения max(). Так программисту проще понять, в чём его ошибка. |
В исправленном алгоритме можно уже начинать считать количество действий. Сколько раз вызывалась в нём операция сравнения «<»? Правильно, N-1 раз. Значит, мы не только избежали ошибки, но и улучшили его производительность (по правде говоря, совсем чуть-чуть).
Почему важно считать именно операции сравнения? Это действие, описанное в алгоритме — сравнить два значения. Другие операторы в программе (например, for или while) выбираются в соответствие с возможностями используемого языка программирования и вполне могут отличаться. Подробнее про это мы поговорим в следующей главе, а пока продолжим считать сравнения.4
Как оценить эффективность алгоритма по схеме?
Допустим, нам предлагают совсем иной алгоритм решения нашей задачи. На котором из них остановиться? Рассмотрим функцию alternate() из примера 1-3. В ней каждое значение из A сравнивается со всеми остальными, и если выяснится, что оно не меньше, это и есть ответ. Выдаст ли этот алгоритм правильный ответ? И сколько раз в нём выполняется сравнение на входных данных размера N?
Пример 1-3. Другой способ найти наибольшее значение в списке A.
Предположим, что очередное значение v из цикла по A — наибольшее.
Если v оказывается меньше какого-то другого значения x из A, прекращаем сравнение и запоминаем, что v — не наибольшее
Если к концу цикла v_is_largest всё ещё истинно, значит, v — это и есть максимум, можно возвращать это значение.
До этого места мы доберёмся, только если список пуст. В этом случае вернём специальный объект Python — None.
К сожалению, автор демонстрирует тут не только неэффективный алгоритм (что входило в его задачу), но и не самый лучший стиль программирования на Python. Вот менее «шумный» и более соответствующий идеологии Python вариант: Для каждого v из A рассмотрим все x из A и сравним их Если v меньше какого-то x, можно больше не сравнивать: это не максимум Если мы просмотрели все x, так ни разу и не выполнив break, значит, v — это максимум, и его можно уже возвращать До этого места выполнение дойдёт только при пустом A. В этом случае вернём специальный объект Python — None. -- FrBrGeorge 2021-10-23 09:59:57
Функция alternate() пытается найти такое значение v из A, чтобы никакое другое значение x из A не оказалось больше него. На этот раз сложно предсказать, сколько потребуется операций сравнения, потому что внутренний цикл по x сразу останавливается, как только выясняется, что x больше v, а внешний — как только v оказывается максимумом. работа alternate() показана на иллюстрации 1-3.
Иллюстрация 1-3. Как работает alternate()
|
Внешний цикл for по всем элементам A |
|
Внутренний цикл for по тем же эелментам |
… |
|
Если v не максимум — сразу остановиться |
Если все x ⩽ v, то v — максимум! |
|
В данном случае было выполнено 14 сравнений. Впрочем, очевидно, что общее количество действий зависит от от того, какие конкретно значения находятся в списке. Что, если бы они шли в другом порядке? Например, так, чтобы для ответа потребовалось как можно меньше действий? Такой набор данных называется лучшим случаем для alternative(). Например, если наибольший из N элементов последовательности стоит в её начале, количество сравнений в точности равно N. Итак:
Лучший случай
Набор данных размера N, на котором алгоритм совершает наименьшее количество действий
Худший случай
Набор данных размера N, требующий наибольшего количества действий
Попробуем отыскать наихудший вариант входных данных для alternate(), в котором количество проделанных сравнений было бы наибольшим. Очевидно, что максимум должен лежать в конце A, но вдобавок к этому в наихудшем наборе данных все значения в A должны быть упорядочены по возрастанию.
На иллюстрации 1-4 показан наилучший случай, в котором A = [9 5 2 1 3 4], и наихудший случай, в котором A = [1 2 3 4 5 9].
Иллюстрация 1-4. Как работает alternate() в лучшем и худшем случаях
Наилучший случай |
Наихудший случай |
В проиллюстрированном лучшем случае функция делает шесть сравнений вида «больше, чем». Если значений всего N, всего сравнений будет N. В худшем случае подсчёт действий слегка сложнее. По иллюстрации 1-4 видно, что шесть элементов, упорядоченные по возрастанию, потребовали 26 сравнений. Немного арифметики, и становится понятно, что для N элементов число сравнений будет равно $$ (N^2+3N-2)/2 $$ 5
N |
largest() |
alternate() |
largest() |
alternate() |
|
(количество сравнений) |
(количество сравнений) |
(время в мс) |
(время в мс) |
8 |
7 |
43 |
0.001 |
0.001 |
16 |
15 |
151 |
0.001 |
0.003 |
32 |
31 |
559 |
0.002 |
0.011 |
64 |
63 |
2 143 |
0.003 |
0.040 |
128 |
127 |
8 383 |
0.006 |
0.153 |
256 |
255 |
33 151 |
0.012 |
0.599 |
512 |
511 |
131 839 |
0.026 |
2.381 |
1 024 |
1 023 |
525 823 |
0.053 |
9.512 |
2 048 |
2 047 |
2 100 223 |
0.108 |
38.161 |
Таблица 1-2. Сравнение работы largest() и alternate() в худших случаях
Пока данных мало, это ещё терпимо. Но если удвоить размер набора данных, количество сравнений у alternate() фактически увеличится вчетверо, а largest() останется далеко позади. Последние два столбца таблицы 1-2 показывают быстродействие обоих алгоритмов на сотне случайно выбранных наихудших наборах данных размером N. Время работы alternate() тоже увеличивается вчетверо.
![]() |
Здесь измерялось время, потраченное алгоритмом на обработку набора размером N. Представлены данные о самом быстром (потребовавшем меньше всего времени) решении среди всех попыток. Такой подход лучше обычного вычисления среднего времени по всем однотипным наборам данных, потому что в среднее может закрасться измерение, которое оказалось большим не по вине алгоритма. |
В книге будут появляться таблицы с подсчётом количества выполненных действий и затраченного времени, наподобие таблицы 1-2, где измерялось количество сравнений на «больше». Строки таких таблиц соответствуют различным объёмам входных данных. Если проглядеть таблицу сверху вниз, становится понятно, как растут показания в каждом столбце по мере удвоения размера данных.
Подсчёт количества сравнений показывает, как работают функции largest() и alternate(). При удвоении N количество сравнений в largest() удваивается, а в alternate() — увеличивается вчетверо. Это поведение вполне стабильно, и несложно предсказать, как оба алгоритма поведут себя на данных большего размера. На иллюстрации 1-5 показано, что количество сравнений в функции alternate() (отмечено по оси Y с левой стороны) довольно точно соответствует её производительности (затраченное время отмечено по оси Y с правой стороны).
Иллюстрация 1-5. Соотношение количества сравнений и времени работы
|
|
Связь производительности с количеством сравнений |
|
|
Количество сравнений |
2 500 000 |
|
|
Время в мс |
2 000 000 |
|
|||
1 500 000 |
|
|||
1 000 000 |
|
|||
500 000 |
|
|||
0 |
8 16 32 64 128 256 512 1024 2048 |
|||
|
|
=== Сравнения -·- Время |
|
|
Можем себя поздравить: мы только что сделали важный шаг на пути исследования алгоритмов — оценили относительную производительность двух функций путём сравнения количества выполняемых ими действий. Конечно, теперь можно взять обе реализации, снабдить тестами производительности, в которых размер входных данных несколько раз удваивается, и измерить её на практике, как это сделано в примерах к книге. В действительности достаточно того, что оценка схемы алгоритмов неплохо предсказывает их работу, и из оценки следует, что largest() быстрее, чем alternate().
Наша функция largest() и встроенная функция Python max() реализуют один и тот же алгоритм, но, как это видно из таблицы 1-3, largest() работает значительно — раза в четыре — медленнее, чем max(). Дело в том, что Python — интерпретируемый язык программирования: написанная программа транслируется в промежуточное представление, называемое байт-кодом, а при выполнении программы запускается интерпретатор Python, который читает, интерпретирует и выполняет инструкции байт-кода. Встроенные же функции, например, max() — часть самого интерпретатора: пока такая функция обрабатывает объект, не нужно ничего дополнительного интерпретировать. Поэтому встроенные функции всегда быстрее тех, что написаны на Python6. Следует заметить, что во всех случаях реализация одного и того же алгоритма должна приводить к одинаковому изменению быстродействия при изменении размера данных — например, при удвоении N время работы и largest(), и max() тоже удваивается, как в наихудшем, так и в наилучшем случае.
В таблице 1-3 показано, что время, которое тратится на работу с данными при увеличении их размера, вполне предсказуемо. Если знать, сколько времени потратили largest() и max() на наборе длиной N, можно вычислить время, которое они затратят, если увеличить этот надор вдвое.
N |
largest() worst case |
max() worst case |
largest() best case |
max() best case |
4 096 |
0.20 |
0.05 |
0.14 |
0.05 |
8 192 |
0.40 |
0.11 |
0.29 |
0.10 |
16 384 |
0.80 |
0.21 |
0.57 |
0.19 |
32 768 |
1.60 |
0.41 |
1.14 |
0.39 |
65 536 |
3.21 |
0.85 |
2.28 |
0.78 |
131 072 |
6.46 |
1.73 |
4.59 |
1.59 |
262 144 |
13.06 |
3.50 |
9.32 |
3.24 |
524 288 |
26.17 |
7.00 |
18.74 |
6.50 |
Таблица 1-3. Быстродействие largest() и max() в лучшем и худшем случаях.
Давайте теперь слегка поменяем условия задачи — так она станет поинтереснее.
Поиск двух наибольших значений в произвльном списке
Разработаем алгоритм поиска двух наибольших значений произвольного списка (второе может быть меньше первого или равно ему, но точно не меньше всех остальных). Наверняка же достаточно немного подправить уже имеющийся алгоритм в largest(). Кстати, самое время попробовать справиться с задачей самостоятельно, а потом уже вернуться к нашему примеру!
В примере 1-4 показано возможное решение этой задачи.
Пример 1-4. Поиск двух максимумов с помощью отредактированной функции largest_two()
Предположим, что my_max и second — первые два значения в списке, если порядок обратный — поменяем их местами.
Если A[idx] больше подходит на роль максимума, сделаем my_max равным [idx], а second — равным my_max.
Если A[idx] больше second, но меньше my_max, обновим только его.
Функция largest_two() работает похоже на largest(). Сначала предполагается, что my_max и second — это первые два элемента A (порядок проверяется).Затем для всех оставшихся элементов A (сколько их? ну да, N - 2) проверяется очередной A[idx], и если он больше my_max, обновляются обе переменные, а если он больше только second, обновляется только second.
Посчитать количество выполненных сравнений не так-то просто, потому что оно опять зависит от порядка данных в наборе.
Реже всего largest_two() будет сравнивать значения, если условие оператора if внутри цикла окажется всегда истинным. Например, если в A каждый следующий элемент больше предыдущего, то сравнение их на меньше всегда истинно, так что всего таких сравнений будет N - 2 — плюс ещё одно, которое мы сделали в самом начале функции. Выходит, что в лучшем случае нам потребуется только N - 1 сравнение для поиска двух максимумов. Сравнение в условии при клаузе elif в лучшем случае не используется вообще.
Можно попробовать построить наихудший для largest_two() набор данных — в нём сравнение в условии оператора if внутри цикла всегда должно быть ложным.
Наверняка уже понятно, что больше всего сравнений largest_two() делает, когда элементы в A упорядочены по убыванию. Строго говоря, в наихудшем случае должны выполняться оба сравнения на каждом обороте цикла, что даёт нам оценку в $$ 1 + 2 * (N - 2) = 2N - 3 $$ действия.
Таки образом, функция largest_two():
в лучшем случае тратит $$ N - 1 $$ сравнение на поиск обоих ответов,
в худшем случае тратит на аналогичный поиск $$ 2N - 3 $$ сравнения.
Но вправду ли это и есть «лучший» алгоритм поиска двух наибольших значений в произвольном списке, и наши исследования закончены? Среди имеющихся алгоритмов можно выбирать по нескольким признакам:
Дополнительная память
Не требуется ли в алгоритме дублировать исходные данные?
Трудоёмкость
Много ли строк в исходной программе?
Изменение исходных данных
Требуется ли в алгоритме менять непосредственно введённые данные, или их можно не трогать?
Скороть
Действительно ли алгоритм работает не хуже других на всех возможных входных наборах данных?
В примере 5-1 рассмотрим ещё три алгоритма, которые решают ту же самую задачу. Функция sorting_two() создаёт из A новый — отсортированный по убыванию — список, так что первые два его элемента и есть ответ. Функция double_two() находит максимум в A с помощью max(), создаёт копию A, удаляет оттуда этот элемент, и ищет второй ответ тем же самым max() по урезанной копии. Функция mutable_two() находит индекс наибольшего элемента в списке, удаляет его оттуда на время, ищет второй ответ в урезанном списке, после чего вставляет удалённый элемент обратно. Первые два алгоритма нуждаются в дублировании исходных данных, третий изменяет их непосредственно; для всех трёх алгоритмов в наборе данных должно быть больше одного элемента.
1 def sorting_two(A):
2 return tuple(sorted(A, reverse=True)[:2]) #1
3
4 def double_two(A):
5 my_max = max(A) #2
6 copy = list(A)
7 copy.remove(my_max) #3
8 return (my_max, max(copy)) #4
9
10 def mutable_two(A):
11 idx = max(range(len(A)), key=A.__getitem__) #5
12 my_max = A[idx] #6
13 del A[idx]
14 second = max(A) #7
15 A.insert(idx, my_max) #8
16 return (my_max, second)
Пример 1-5. Ещё три способа решить задачу средствами самого Python
Создать из A новый отсортированный список и вернуть первые два его элемента.
Использовать встроенную функцию max() и найти максимум.
Создать дубликат списка A и удалить из него этот максимум.
Вернуть кортеж из сходного максмума и максимума в урезанной копии.
Трюк Python, который позволяет найти индекс наибольшего значения, а не само наибольшее значение7.
Запомнить максимум my_max и удалить его из A.
Найти ещё один max() в усечённом списке.
Вставить максимальное значение my_max на место.
Все три способа не используют сравнений явно, потому что задействуют встроенные функции Python. Функции sorting_two() и double_two() копируют исходный список A, а largest_two() — нет; видимо, это необязательно. Вдобавок, сортировать весь список ради всего двух первых элементов тоже кажется излишним. Для обеих функций, задействующих дополнительную память, эту память можно посчитать тем же способом, каким мы считали количество сравнений — и там, и там получится прямо пропорционально N. Третий вариант, mutable_two(), ненадолго изменяет A: удаляет оттуда элемент, а затем добавляет обратно. В программе, откуда была вызвана mutable_two(), возможно, не рассчитывали на то, что A будут изменять8.
Если позволить себе конструкции Python посложнее — ввести специальный класс RecordedItem9, можно отследить, сколько операций сравнения на «меньше» потребует любой алгоритм. Из таблицы 1-4 видно, что double_two() делает больше всего сравнений на возрастающей последовательности, а все largest_two() и все остальные — на убывающей. Последний столбец — «Вперемежку» — представляет работу функций на последовательности, в которой на чётных местах элементы возрастают, а на нечётных — убывают: например, для N = 8 перемежающаяся последовательность выглядит как [0,7,2,5,4,3,6,1].
Алгоритм |
По возрастанию |
По убыванию |
Вперемежку |
largest_two |
524 287 |
1 048 573 |
1 048 573 |
sorting_two |
524 287 |
524 287 |
2 948 953 |
double_two |
1 572 860 |
1 048 573 |
1 048 573 |
mutable_two |
1 048 573 |
1 048 573 |
1 048 573 |
tournament_two |
524 305 |
524 305 |
524 305 |
Таблица 1-4. Производительность других вариантов решения на нисходящих и восходящих последовательностях.
Функция tournament_two(), алгоритм которой описан ниже, стабильно показывает наименьшее количество сравнений на любых входных данных. Её логика покажется знакомой поклонникам баскетбола, например.
![]() |
Если некоторый алгоритм решения задачи даёт наихудший результат на некотором наборе данных, другой алгоритм решения той же самой задачи на том же самом наборе данных вовсе не обязан работать так же медленно. У разных подходов — разные слабые места, и надо ещё постараться их найти. |
Турнирное дерево
В состязаниях на вылет выясняется победитель среди нескольких команд. Лучше всего, если количество команд изначально равно какой-нибудь степени двойки, например, 16 или 64. Розыгрыш состоит из нескольких кругов (туров), в каждом из которых все оставшиеся команды разбиваются на пары для игры; каждый проигравший в паре выбывает из соревнований, а остальные переходят на следующий круг. Команда, выигравшая в финале, объявляется победителем.
Предположим, нам надо найти максимум в списке p = [3,1,4,1,5,9,2,6] длиной N = 8. На иллюстрации 1-6 показан розыгрыш навылет, в первом туре которого попарно сравниваются на «меньше» восемь значений, и те, что больше, переходят на второй.10 В туре «Великолепной Восьмёрки» выбывают четыре значения, и остаётся [3,4,9,6], из тура «Чемпионской Четвёрки» в финал выходят [4,9], и в результате победителем становится 911.
Иллюстрация 1-6. Турнирное дерево с восемью участниками
Финал |
Четвёрка Чемпионов |
Великолепная Восьмёрка |
Для применения турнирного дерева требуется семь сравнений (по одному на игру), и это обнадёживает: как мы уже говорили, это означает, что на поиск максимума в наборе данных размером N требуется N - 1 сравнение. Если запоминать все эти сравнения, нетрудно показать, что второе наибольшее значение находится быстро.
Где может «прячется» второе наибольшее значение, если победителем объявлено 9? Начнём с 4, раз уж оно добралось до финала, и проиграло только там. Однако победитель, 9, участвовал ещё в двух играх, так что надо проверить ещё двух проигравших — 6 из тура «Четвёрки Чемпионов» и 5 из тура «Великолепной Восьмёрки». Получается, что второй ответ — 6.
Чтобы выяснить, что 6 — второе наибольшее значение, для длины списка 8 достаточно двух дополнительных сравнений — «4 меньше 6?» и «6 меньше 5?». То, что $$ 8=2^3 $$, а сравнений было $$ 3-1=2 $$ — не совпадение. Действительно, для $$ N=2^K $$ необходимо $$ K - 1 $$ дополнительное сравнение, при этом K оказывается количеством туров в розыгрыше.
Для $$ 8=2^3 $$ элементов алгоритму требуется розыгрыш из трёх туров. На иллюстрации 1-7 приведён розыгрыш из пяти туров, соответствующий 32 элементам. Если удвоить количество элементов, потребуется ещё один тур. Иными словами, в туре под номером $$ K $$ может участвовать $$ 2^K $$ элементов. Нужно найти максимум среди 64 элементов? Потребуется шесть туров, потому что $$ 2^6=64 $$ .
Иллюстрация 1-7. Турнирное дерево c 32 участниками
Финал |
Четвёрка Чемпионов |
Великолепная Восьмёрка |
Шоколадные Шестнадцать |
Тенденционзные Тридцать два |
Чтобы определить, сколько туров потребуется для произвольного N, испльзуеи логарифмическую функцию log() — это обратная к показательной функции, exp(). Для N = 8 элементов на весь розыгрыш требуется три тура, потому что $$ 2^2=8 $$, и стало быть $$ log_(2)8=3 $$. В нашей книге, как и в большинстве задач оценки сложности, используется логарифм с основанием 2 — двоичный12.
![]() |
Большинство настольных калькуляторов (а заодно — Microsoft Excel) по команде log() вычисляют десятичный логарифм (по основанию 10). Также они имеют команду ln() для вычисления натурального логарифма, основание которого равно константе e (примерно 2.7182818). Чтобы вычислить двоичный логарифм с помощью любой из этих функций, надо поделить результат на логарифм двух: log(N)/log(2). |
Если N — степень двойки, например, 64 или 65536, в розыгрыше будет $$ log_2(N) $$ туров, а это значит, что потребуется ещё $$ log_2(N)-1 $$ сравнение. В примере 1-6 приведён алгоритм, который сводит к минимуму количество сравнений за счёт дополнительной памяти, где откладываются результаты этих сравнений.
1 def tournament_two(A):
2 N = len(A)
3 winner = [None] * (N-1) # 1
4 loser = [None] * (N-1)
5 prior = [-1] * (N-1) # 2
6
7 idx = 0
8 for i in range(0, N, 2): # 3
9 if A[i] < A[i+1]:
10 winner[idx] = A[i+1]
11 loser[idx] = A[i]
12 else:
13 winner[idx] = A[i]
14 loser[idx] = A[i+1]
15 idx += 1
16
17 m = 0 # 4
18 while idx < N-1:
19 if winner[m] < winner[m+1]: # 5
20 winner[idx] = winner[m+1]
21 loser[idx] = winner[m]
22 prior[idx] = m+1
23 else:
24 winner[idx] = winner[m]
25 loser[idx] = winner[m+1]
26 prior[idx] = m
27 m += 2 # 6
28 idx += 1
29
30 largest = winner[m]
31 second = loser[m] # 7
32 m = prior[m]
33 while m >= 0:
34 if second < loser[m]: # 8
35 second = loser[m]
36 m = prior[m]
37
38 return (largest, second)
Пример 1-6. Поиск двух наибольших значений A с помощью турнирного дерева
В этих списках мы станем хранить индексы победителей и проигравших в игре, которых будет N - 1.
Когда значение в позиции m проходит на очередной тур, в prior[m] записывается позиция этого значения в предыдущем туре. Для первого тура такой информации нет, поэтому в начале этого списка хранится -1 в количестве N - 1 штук.
Первый тур состоит из N/2 игр, то есть требует N/2 сравнений на «меньше» в парах «победитель - проигравший».
Игры победителей во всех следующих турах с записью позиции выигравшего в каждой игре.
Еще N/2 - 1 сравнение.
Увеличим m на 2 — это игра двух следующих победителей. Когда idx достигнет N - 1, в winner[m] окажется наибольшее значение.
Первый кандидат на второе место, надо ещё проверить остальных проигравших чемпиону, возможно, они больше подходят для второго места.
Не больше $$ log_2(N)-1 $$ дополнительных сравнений на «меньше».
Иллюстрация 1-8 показывает работу нашего алгоритма. Начальный шаг превращает N значений исходного списка A в N/2 значений в winners и в losers; на иллюстрации 1-6 это четыре пары. На следующем шаге с каждым проходом цикла while победитель и проигравший в очередной игре под номером idx (они находятся на соседних позициях m и m+1) помещаются, соответственно, в winner[idx] и loser[idx]. В prior[idx] мы записываем предыдущую позицию выигравшего, с которой он попал в эту игру (обозначено стрелкой справа налево). После трёх шагов мы имеем всю информацию о розыгрыше, и алгоритм проверяет всех проигравших в играх с чемпионом — последовательность, которую можно проследить по стрелкам от чемпиона назад. Второе наибольшее значение можно найти всего двумя сравнениями, что больше — начальный кандидат (найденный в loser[6]), loser[5] или loser[2].
Итак, мы описали алгоритм поиска двух наибольших элементов A, которому нужно всего $$ N-1+log_2(N)-1=N+log_2(N)-2 $$ сравнения на «меньше» для любого N, равного степени двойки. А насколько практична функция tournament_two()? Работает ли она быстрее, чем largest_two()? Если считать только сравнения элементов на меньше, tournament_two() должна быть быстрее. На наборе данных длиной N = 65536 функция largest_two() выполняет 131069 сравнений, а tournament_two() — только 65536 + 16 - 2 = 65550, то есть примерно половину. Но история на этом не кончается.
Иллюстрация 1-8. Пошаговое выполнение алгоритма с использованием турнирного дерева
winner loser |
Начальный шаг |
winner loser |
Продвижение 1 |
winner loser |
Продвижение 2 |
winner loser |
Продвижение 3 |
В очередной раз хочу заметить, что автор вновь либо демонстрирует невысокий уровень знания Python, либо сознательно избегает рекомендованных в данном случае специфических для Python конструкций, проявляя при этом необъяснимую склонность к путаным и «зашумлённым» реализациям алгоритма. Всё это снижает ценность книги. Отложим разбор авторских прегрешений и рассмотрим более, на мой взгляд, прозрачную, в три раза более короткую и оправданно более быструю (с сохранением того же порядка сложности) реализацию этого алгоритма. Заведём список season, в который будем добавлять участников всех туров розыгрыша. Фактически это очередь всех игр, в которой каждая пара значений соответствует участникам одной игры в порядке их проведения — от самой первой до финала. Порядок проведения первого тура нам известен — это сам список A, так что вначале season — это копия A. После каждой игры будем добавлять победителя в этот список. Под конец первого тура в нём окажется ещё N / 2 значений — это участники второго тура. Будем продолжать до тех пор, пока на каком-то туре не окажется всего двое — это финал — и в конце концов останется единственный победитель. Всего потребуется N - 1 игра. Параллельно в списке winner будем запоминать, кто победил в каждой игре — первый или второй участник (точнее, нулевой или первый). Для того чтобы узнать, кто же занял второе место, достаточно посмотреть, кого чемпион победил в финале, в полуфинале и т. д., и выбрать лучшего. Для этого посмотрим в список winner. В его конце записано, каким по номеру был победитель в финальной игре (0 или 1). Его соперник по финалу — кандидат на второе место. Этот номер также указывает, в какой из двух игр полуфинала участвовал победитель. Его соперник по полуфинальной игре — ещё один кандидат на второе место. В действительности поскольку после каждой игры мы добавляем в очередь одного участника из двух, позиция «k» участника во Всего во всех турах будет N - 1 игр. Рассмотрим позиции игроков в турнирной очереди. Победил ли в этой игре второй игрок? Поскольку True и False в Python равны соответственно 1 и 0, это же выражение равно номеру победителя в игре. Первый кандидат на второе место — проигравший в финале. Финал — это второй и третий с конца элементы season; из них проиграл тот, кто не выиграл, т. е. элемент с индексом -2, если выиграл нулевой, и с индексом -3, если выиграл первый (отсюда и формула). Полуфинальная игра с участием победителя. Если победитель был нулевым в финале, он участвовал в N - 4-й игре, а если первым — в N - 3-й. Проигравший в текущей игре с победителем. Задача not в этой формуле — превратить 1 в 0, а 0 — в 1; можно было бы написать 1 - winner[match]. Обновление кандидата на второе место. Функция max() неявно вызывает сравнение элементов. Игра предыдущего тура с участием победителя. По построению она совпадает с позицией победителя во второй половине очереди, т. е. из позиции победителя номер_игры * 2 + кто_победил надо вычесть длину первой половины очереди (количество участников первого тура). Иллюстрация 1-8-1 показывает работу этой функции. Сначала в очереди весь первый тур розыгрыша. Затем победитель каждой игры добавляется в очередь (а его позиция в игре запоминается). Получается законченная турнирная таблица, из которой мы выбираем наибольшего из проигравших победителю (для удобства в левом верхнем углу клетки указано, в какой игре этот участник победил). 1 def tournament_two(A):
2 season, winner, N = A.copy(), [], len(A) # 1
3
4 for pos in range(0, 2 * (N - 1), 2): # 2
5 who = season[pos] < season[pos + 1] # 3
6 season.append(season[pos + who]) # 4
7 winner.append(who)
8
9 second = season[-2 - winner[-1]] # 5
10 match = N - 4 + winner[-1] # 6
11 while match >= 0:
12 loser = match * 2 + not(winner[match]) # 7
13 second = max(second, season[loser]) # 8
14 match = match * 2 + winner[match] - N # 9
15
16 return second, season[-1]
Иллюстрация 1-8-1. Работа алгоритма с использованием турнирной очереди
Таблица 1-5 показывает, что функция tournament_two() значительно медленнее всех своих соперниц! Достаточно посмотреть, сколько времени у неё уходит на обработку ста случайных наборов данных, размер которых растёт от 1024 до 2097152 элементов. Раз уж мы об этом заговорили, давайте ещё добавим в таблицу производительность функций из пример 1-5. Если программу с примерами запускать на другом компьютере, конкретные результаты получатся другими, но общая закономерность останется.
N |
double_two |
mutable_two |
largest_two |
sorting_two |
tournament_two |
1 024 |
0.00 |
0.01 |
0.01 |
0.01 |
0.03 |
2 048 |
0.01 |
0.01 |
0.01 |
0.02 |
0.05 |
4 096 |
0.01 |
0.02 |
0.03 |
0.03 |
0.10 |
8 192 |
0.03 |
0.05 |
0.05 |
0.08 |
0.21 |
16 384 |
0.06 |
0.09 |
0.11 |
0.18 |
0.43 |
32 768 |
0.12 |
0.20 |
0.22 |
0.40 |
0.90 |
65 536 |
0.30 |
0.39 |
0.44 |
0.89 |
1.79 |
131 072 |
0.55 |
0.81 |
0.91 |
1.94 |
3.59 |
262 144 |
1.42 |
1.76 |
1.93 |
4.36 |
7.51 |
524 288 |
6.79 |
6.29 |
5.82 |
11.44 |
18.49 |
1 048 576 |
16.82 |
16.69 |
14.43 |
29.45 |
42.55 |
2 097 152 |
35.96 |
38.10 |
31.71 |
66.14 |
… |
Таблица 1-5. Сравнение времени работы в миллисекундах всех четырёх алгоритмов.
С непривычки таблица 1-5 выглядит как стена чисел. Если запускать наши функции на другом компьютере — с меньшей памятью или менее мощным процессором — стена будет состоять из других чисел, но некоторые закономерности можно последить безотносительно к быстродействию компьютера. В первую очередь — рост чисел в каждой колонке: при удвоении размера входного набора время выполнения увеличивается тоже примерно вдвое.
В таблице можно заметить кое-что неожиданное: к примеру, double_two() поначалу ведёт себя как самое быстрое решение, но с ростом N (после N > 262,144) уступает пальму первенства largest_two(). А хитрая наша tournament_two() оказалась ужасно медленной — она тратит много времени на создание и обработку вспомогательных списков, размер которых даже больше, чем объём входных данных. Она настолько медленно работает, что на самых больших наборах даже не проверяется — это было бы слишком долго.
Чтобы получить представление обо всех этих числах, посмотрим на иллюстрацию 1-9, где в виде графиков представлена зависимость производительность от роста объёма данных.
Иллюстрация 1-9. Сравнение тестов производительности
Сравнение производительности |
|
Время выполнения (секунды) |
|
|
0 500 000 1 000 000 1 500 000 2 000 000 2 500 000 |
|
Размер набора входных данных |
Графики проявляют особенности работы всех пяти алгоритмов:
Видно, что производительность mutable_two(), double_two() и largest_two() примерно одинакова, но явно отличается от двух других функций. Можно сказать, что эти три функции образуют «семейство»: графики их производительности — прямые линии, предсказать продолжение которых, по-видимому, просто.
Функция tournament_two() — самая медленная, и её график заметно отличается от прочих. Измерений сделано немного, поэтому до конца не ясно, будет ли график продолжать «задираться кверху», или тоже превратится в конце концов в прямую.
Функция sorting_two() явно получше, чем tournament_two(), однако медленнее остальных трех. А её график? Будет ли он и дальше изгибаться кверху — или стремиться к прямой?
Чтобы понять, отчего графики имеют такой вид, надо обратиться к двум важным понятиям, которые определяют сложность, присущую алгоритмам.
Сложность по времени и сложность по памяти
Сложно посчитать, сколько конкретных машинных инструкций (например, сложений, присваиваний или ветвлений) выполнила некоторая программа: это очень зависит от языка программирования, тем более, что некоторые из них, например Python или Java, требуют интерпретатора для работы. Но если общее число выполненных инструкций посчитать можно, то можно исследовать, как это число зависит от объёма входных данных. Задача оценки сложности по времени — получить некоторую формулу C(N), которая вычисляла бы количество инструкций, выполненных некоторым алгоритмом, как функцию от N — размера набора входных данных.
Предположим, выполнение одной машинной инструкции центральныи процессором некоторого компьютера требует фиксированного времени t. Тогда время T, затраченное на работу алгоритма на этом компьютере, можно выразить как $$ T(N)=t*C(N) $$. Пример 1-7 подтверждает догадку о том, что самое важное — это структура программы. Можно в точности почитать, сколько потребуется сложений видя ct = ct + 1 для работы функций f0(), f1(), f2() и f3() на входных данных размера N.
def f0(N): def f1(N): def f2(N): def f3(N):
ct = 0 ct = 0 ct = 0 ct = 0
ct = ct + 1 for i in range(N): for i in range(N): for i in range(N):
ct = ct + 1 ct = ct + 1 ct = ct + 1 for j in range(N):
return ct return ct ct = ct + 1 ct = ct + 1
ct = ct + 1 return ct
ct = ct + 1
ct = ct + 1
ct = ct + 1
ct = ct + 1
return ctПример 1-7. Четыре различные функции с разной вычислительной сложностью
Функция f0() всегда выполняет одно и то же количество действий, независимо от N. Количество действий в f2() всегда в семь раз больше, чем в f1(); при удвоении N обе эти функции выполняют в два раза больше действий. В отличие от них, количество действий, выполняемых f3() растёт гораздо быстрее. Мы такое уже видели: N удваивается, сложность f3(N) вырастает в четыре раза. Получается, что f1() и f2() больше похожи друг на друга, чем на f3(). В следующей главе мы обсуди важную роль, которую играют циклы и вложенные циклы при оценке сложности алгоритма.
N |
f0 |
f1 |
f2 |
f3 |
512 |
2 |
512 |
3 584 |
262 144 |
1 024 |
2 |
1 024 |
7 168 |
1 048 576 |
2 048 |
2 |
2 048 |
14 336 |
4 194 304 |
Таблица 1-6. Количество действий в различных функциях
Когда мы исследуем алгоритм, важно установить его сложность по памяти — посчитать, сколько дополнительной памяти требуется на обработку входных данных размера N. Под «памятью» может подразумеваться занимаемое место в файловой системе или объём оперативной памяти, который требуется для работы. Функция largest_two() — потребляет меньше всего памяти: в ней определено всего три переменных — my_max, second и переменная цикла idx. Каким бы ни был объём входных данных, размер дополнительной памяти не меняется. Следовательно, сложность по памяти не зависит от размера входных данных, то есть является константой. Так же ведёт себя и функция mutable_two(). А вот tournament_two() заводит аж три дополнительных списка размера N-1 — winner, loser и prior. Так что при увеличении N объём дополнительной памяти увеличивается прямо пропорционально объёму входных данных.13 Необходимость создавать турнирную таблицу делает работу tournament_two() заметно медленнее по сравнению с largest_two(). Ещё две функции — double_two() и sorting_two() — дублируют входные данные (список A), так что их потребление памяти скорее похоже на tournament_two(), чем на largest_two(). В нашей книге мы будем исследовать и сложность по времени, и сложность по памяти каждого алгоритма.
Если ещё раз посмотреть на таблицу 1-5, можно заметить, что числа в столбце largest_two с каждой строкой становятся примерно в два раза больше; мы уже подчёркивали, что столбцы double_two и mutable_two устроены в целом так же. Значит, время работы этих алгоритмов прямо пропорционально объёму входных данных, который тоже удваивается на каждой строке. Это важное свойство, ибо эти функции быстрее, чем sorting_two(), а у неё и график быстродействия выглядит по-другому — менее эффективным. Самая медленная — tournament_two(), скорость её работы падает настолько быстрее, чем вдвое, что под конец, на самых больших наборах, мы её даже не запускали.
С научной точки зрения нельзя просто заявить, что графики производительности largest_two() и mutable_two() «похожи». Нужно формальное теоретическое обоснование и способ фиксации этого утверждения. В следующей главе мы познакомимся с математически инструментарием, который необходим для грамотного исследования производительности алгоритмов.
Заключение
В этой главе мы увидели целый спектр хороших и разных алгоритмов. Научились предсказывать производительность алгоритма на входных данных размера N путём подсчёта количества выполненных действий. Также мы посмотрели, как померить производительность конкретной реализации алгоритма опытным путём. В обоих случаях оказалось возможным определить, с какой скоростью падает производительность алгоритма при удвоении объёма N входных данных.
Мы ввели несколько важных понятий, в частности:
Сложность по времени оценивается подсчётом количества основных действий алгоритма на наборе входных данных размера N./* Time complexity as estimated by counting the number of key operations executed by an algorithm on a problem instance of size N. */
Сложность по памяти оценивается измерением дополнительного объёма памяти, который потребуется алгоритму для обработки набора входных данных размера N.
В следующей главе мы рассмотрим математический инструментарий асимптотического анализа и на этом завершим обзор методов формального исследования алгоритмов.
Тренировочные задания
Определитель палиндромов
Палиндром — слово, буквы которого, расположенные в обратном порядке, дают то же самое слово — например, «казак». Придумать и оформить в виде функции алгоритм, определяющий, что данная строка — это палиндром. Проверить на практике, что написанная функция работает быстрее двух других, предложенных а примере 1-8.
1 def is_palindrome1(w): 2 """Сделаем секцию, содержащую все символы w в обратном порядке 3 и сравним её с w.""" 4 return w[::-1] == w 5 6 def is_palindrome2(w): 7 """Если первый и последний символы равны, удалим их. 8 Если они не равны — вернём False.""" 9 while len(w) > 1: 10 if w[0] != w[-1]: # если не равны, вернём False 11 return False 12 w = w[1:-1] # удаляем в цикле первый и последний символ 13 14 return True # это точно палиндром
Пример 1-8. Довольно медленные функции-определители палиндрома
После того, как функция заработает, дополните её: научите определять палиндромы-фразы, в которых пробелы и знаки пунктуации игнорируются, а строчные и прописные буквы не отличаются. Например, вот эта строка — палиндром: «Я не стар, брат Сеня!»
Вычисление медианы за линейное время
Есть замечательный алгоритм нахождения медианы в произвольном списке, имеющий линейную сложность (для простоты будем считать, что размер списка нечётен). Изучите исходный текст функций из пример 1-9 и посчитайте количество сравнений с использованием значений упомянутого в этой главе класса RecordedItem. Реализация алгоритма из примера использует переупорядочивание элементов исходного списка.
1 import random
2
3 def partition(A, lo, hi, idx):
4 """Разделение A на две части относительно значения A[idx]."""
5 if lo == hi: return lo
6
7 A[idx],A[lo] = A[lo],A[idx] # swap into position
8 i = lo
9 j = hi + 1
10 while True:
11 while True:
12 i += 1
13 if i == hi: break
14 if A[lo] < A[i]: break
15
16 while True:
17 j -= 1
18 if j == lo: break
19 if A[j] < A[lo]: break
20
21 if i >= j: break
22 A[i],A[j] = A[j],A[i]
23
24 A[lo],A[j] = A[j],A[lo]
25 return j
26
27 def linear_median(A):
28 """
29 Быстрая реализация поиска медианы в произвольном списке.
30 Предполагается, что в списке нечётное число элементов.
31 Обратите внимание на то, что при работе алгоритма
32 порядок элементов в A меняется.
33 """
34 lo = 0
35 hi = len(A) - 1
36 mid = hi // 2
37 while lo < hi:
38 idx = random.randint(lo, hi) # select valid index randomly
39 j = partition(A, lo, hi, idx)
40
41 if j == mid:
42 return A[j]
43 if j < mid:
44 lo = j+1
45 else:
46 hi = j-1
47 return A[lo]
Пример 1-9. Алгоритм нахождения медианы в неупорядоченном списке за линейное время
Реализуйте другой способ (для него потребуется дополнительная память): отсортируйте список и верните в качестве ответа его средний элемент14. Сравните быстродействие этого алгоритма с работой linear_median(), составив таблицу тестов производительности.
Сортировка подсчётом
Если известно, что некоторый (в остальном произвольный) список A состоит только из целых чисел в диапазоне от 0 до M, такой список можно отсортировать за линейное время, используя дополнительную память размера M.
Хотя в примере 1-10 есть вложенные циклы — for внутри while, — количество присваиваний вида A[pos+idx] = v всё равно ровно N. Докажите это!
Пример 1-10. Сортировка подсчётом за линейное время
Проделайте анализ быстродействия и покажите, что время при удвоении N сортировки N элементов в диапазоне от 0 до M также удваивается.
Внутреннего цикла в counting_sort() можно избежать, если воспользоваться присваиванием секций списка. Если написать что-то вроде список[начало:конец] = [1, 2, 3], то элементы списка в позициях от начала до конца заменятся на 1, 2, 3. Измените counting_sort() и проверьте на практике, что время работы, как и прежде, удваивается при удвоении N, но новая функция работает на 30% быстрее старой.
Исправьте алгоритм с турнирной таблицей так, чтобы он работал для нечётного количества значений.
Насколько хорошо функция из примера 1-11 находит два наибольших значения в списке A?
Пример 1-11. Ещё одна попытка найти два наибольших значения в неупорядоченном списке
При каких условиях функция работает правильно, а при каких — неправильно? Опишите эти условия.
Автор использует термин «problem instance» — буквально: экземпляр задачи. Однако в тексте под этим всегда подразумеваются только входные данные, и никогда — конкретная задача целиком, т. е. данные, условия применимости и требуемый результат. Например, частая формулировка «problem instance of size N» — это очевидно входные данные размера N (1)
В оригинале используются американские договорённости о записи целых и вещественных чисел: тысячи в целых числах разделяются запятыми, а дробная часть вещественного отделается от целой точкой. В российском стандарте всё наоборот, но поскольку в Python разделителем в вещественном числе также является точка, этот формат мы сохранили. В качестве разделителя тысяч, во избежание путаницы, мы используем пробел. В Python для этой же цели можно применять символ подчёркивания: 100_0000. (2)
Главное отличие машинного кода от байт-кода — в том, что машинный код содержит инструкции процессора, которые работают с тем, что на самом деле есть в процессоре, — атомарными ячейками памяти, их адресами и регистрами, а байт-код — инструкции программы-интерпретатора, которые, по сути, отличаются от языка программирования только представлением — в них используются имена, объектная модель, составные типы данных и всё остальное. (3)
Если измерить производительность обеих функций методом, который автор предлагает в предисловии (с помощью timeit), окажется что flawed() работает в полтора раза быстрее largest()! Дело в том, что исправленная функция задействует внутри цикла существенно больше операций, чем ошибочная: в ней используется индексирование (доступ к элементу массива по номеру), а в ошибочной этого не требуется. Таким образом, оценка основных действий алгоритма не всегда напрямую соотносится с затраченным временем и количеством выполненных операций для конкретной реализации этого алгоритма. В частности, индексирования в largest() можно было избежать, и выиграть в производительности, но это потребовало бы большего знания о специфике работы цикла for в Python, и читать программу стало бы труднее. (4)
Вот эта арифметика: квадратная таблица сравнений, как на иллюстрации, содержит $$ N^2 $$ ячеек. Первый внутренний цикл требует только два сравнения (потому что второй элемент больше), следующий — три (потому что третий больше второго) и так далее, до предпоследнего и последнего циклов, которые требуют по N сравнений. Неиспользованные ячейки таблицы образуют «прямоугольный треугольник» со стороной $$ N - 2 $$ . Количество ячеек в нём — половина площади прямоугольника со сторонами $$ N - 2 $$ и $$ N - 1 $$ (это легко проверить, сложив два таких треугольника). Таким образом, количество действий равно $$ N^2 - ((N - 2)(N - 1))/2 $$, и вот тут действительно уже достаточно арифметики, чтобы получить авторскую формулу. (5)
Например, что требуется интерпретировать в цикле (то есть многократно) при выполнении largest()? В первую очередь — поиск имён переменных: Python не может быть уверен, что, скажем, idx вообще существует на каждом следующем проходе цикла, ведь это имя вполне могли удалить! Дополнительное время тратится на вычисление выражений, ибо для каждого промежуточного результата приходится заводить отдельный объект Python, а затем уничтожать его. Схожая ситуация с операцией индексации — и так далее. Встроенная же функция max() тратит время только на обход конкретного выданного ей списка и на сравнение. (6)
Этот трюк работает так. Как все операции с объектами в Python, операция индексирования (т. е. «квадратные скобки») имеет эквивалентный ей метод — .__getitem__() . Параметр key=функция в функции max(последовательность) означает, что максимум будет вычисляться не в исходной последовательности элемент0, элемент1 … , а в последовательности функция(элемент0), функция(элемент1) …, а в качестве результата функция вернёт некоторый элементm. В нашем примере элементы — это 0, 1, …, m, … , len(A)-1, то есть индексы всех объектов в A, а максимум вычисляется среди A.__getitiem__(0), A.__getitiem__(1), … , то есть среди A[0], A[1], …, а возвращается при этом индекс m. (7)
…и, например, сделали A кортежем, на котором все остальные функции спокойно работают! (8)
Класс-обёртка RecordedItem задаёт собственный метод .__lt__(), который соответствует операции «<», в нём, помимо проверки, делается и увеличение счётчика. То же самое делает .__gt__() для «>» (прим. автора). (9)
В случае ничьей, то есть равных значений, берётся первое из них(прим. автора) (10)
Автор использует термины турнира баскетбольной лиги США первого дивизиона: региональные полуфиналы («Sweet Sixteen»), региональные финалы («Elite Eight») и национальные полуфиналы («Final Four»). Поскольку знатокам турнира эти термины хорошо известны, для остальных читателей мы решили перевести эти названия, сохраняя принцип «первые буквы повторяются». (11)
В действительности автор утверждает, что двоичный логарифм «традиционно используется в computer science», и на этом основании везде пишет log() для обозначения двоичного логарифма. Однако само утверждение более, чем спорно: например, в языках программирования Java, C, C++, Python и многих других функция log() обозначает натуральный логарифм, а для двоичного и десятичного имеются log2() и log10(). По этой же причине я постараюсь авторское log() повсюду заменять на $$ log_2 $$ или log2() (в зависимости от того, это математическая формула или функция Python), дабы избежать двусмысленности. (12)
При вычислении сложности по памяти размер самого входного набора не учитывается, интересен объём данных, которые пришлось породить вдобавок к нему (прим. автора). (13)
Это и есть определение медианы (14)



