Подведём итоги
В этой главе автор местами отстал от жизни, а местами просто довольно неаккуратно изложил подробности реализации в Python, я взял на себя труд в силу собственных знаний исправить положение, не вступая с ним в дискуссию. -- FrBrGeorge 2023-02-24 14:10:15
Цель нашей книги — описать алгоритмы, которые лежат в основании современных информационных технологий, и структуры данных в поддержку этих алгоритмов, от эффективной реализации которых напрямую зависит быстродействие программ. В книге мы привели примеры таких структур данных:
- Набор
Несколько (как правило, однотипных) элементов объединённых единой структурой, в которую можно добавлять элементы, а так же искать их в ней. Удаление элементов из набора обычно не предусмотрено. Если набор реализован с помощью связного списка, добавление — это вставка в начало, и она имеет константную (O(1)) сложность. Если набор реализован с помощью динамического массива, можно добиться также константной сложности добавления в среднем, хотя в редких случаях масштабирования массива сложность будет возрастать до O(N)1.
- Стек
В Python не необходимости моделировать стек (LIFO — Last In, First Out) связным списком, так как с этим легко справляется встроенный тип данных, динамический массив list. Тем не менее связный список — это классический пример реализации стека. Операции добавления значения на вершину стека и снятия значения оттуда, push() и pop() (для типа list — .append() и .pop()) должны иметь константную сложность O(1).
- Очередь
Если набор данных организован по принципу FIFO — First In, First Out, его можно смоделировать связным списком, в котором хранятся ссылки как на первый, так и на последний элементы. При этом обе операции — добавление в конец очереди enqueue() и удаление из начала очереди dequeue() — будут иметь сложность O(1).
- Хеш-таблица
Структура, хранящая пары (ключ, значение), эффективность которой зависит от удачного выбора хеш-функции, которая распределяет значения по хранилищу, сообразуясь с хешем ключа. Хорошо себя показывает открытая адресация хешей в тандеме с геометрическим масштабированием хранилища при его заполнении выше порогового. Геометрическое масштабирование, например, удвоение, позволяет сделать вычислительно «тяжёлую» операцию перехеширования настолько редкой, что это не влияет на среднее быстродействие.
- Приоритетная очередь
Если реализовать кучу — структуру данных, которая хранит пары (значение, приоритет) таки образом, чтобы на её вершине всегда находилась пара с наивысшим приоритетом, её можно рассматривать как очередь. При этом и операция добавления enqueue(), и операция снятия с вершины dequeue() будут иметь логарифмическую сложность O(log N). Как правило, вместимость приоритетной очереди N известна заранее, если же нет, избежать значимого падения быстродействия нам снова поможет геометрическое масштабирование.
- Индексированная приоритетная очередь
Если добавить в реализацию кучи отдельный словарь, в котором записана позиция элемента кучи массиве-хранилище, получится структура данных, в которой можно быстро (за константное время) найти любой, а не только верхний, элемент. Для классических графов, в которых узлы не имеют названий, а просто пронумерованы от 0 до N-1, вместо словаря можно использовать массив, что ещё повысит быстродействие. Индексированная приоритетная очередь поддерживает операции добавления, удаления и повышения приоритета элемента, и сложность всех этих операций — логарифмическая, O(log N).
- Граф
Структура, состоящая из узлов и рёбер, в которой рёбра обычно моделируются матрицей смежности — особенно когда есть подозрение, что в графе могут встретиться вообще все возможные рёбра. Если узлы пронумерованы от 0 до N - 1, для реализации матрицы смежности можно воспользоваться двумерным массивом. Однако в большинстве случаев удобнее применять список смежности, который можно смоделировать словарём, хранящим списки смежных узлов (или словарём с ключом — ребром, то есть парой смежных узлов). Вручную делать собственную реализацию графа смысла не имеет — и писать долго, и работать будет небыстро, лучше воспользоваться какой-нибудь готовой удобной реализацией в стороннем Python-модуле. В нашей книге мы применяли модуль NetworkX, поддержка графов в котором эффективна и разнообразна.
В предисловии к нашей книге мы привели иллюстрацию, в которой кратко упомянули эти абстрактные типы данных. Разбираясь с алгоритмами, мы смоделировали абстрактные типы теми или иными структурами данных в Python и исследовали быстродействие наших моделей. Результаты представлены в таблице 8-12.
В таблице много неадекватных Python-у упоминаний «массивов» и связных списков, я переписал её сообразно правленному мной в предыдущих главах -- FrBrGeorge 2023-02-17 13:09:31
Тип данных |
Действие |
Производительность |
Замечания |
Набор |
size() |
O(1) |
Проще всего использовать Python-овский тип list — динамический массив с геометрическим масштабированием, которое позволяет добавлять элемент к набору за в среднем константное время. Будучи массивом, list поддерживает операцию индексирования, которая тоже работает константное время. |
add() |
O(1) |
||
iterator() |
O(N) |
||
Стек |
push() |
O(1) |
Эту абстракцию прямо реализует тип list, удаление последнего элемента котором также в среднем константно по времени. Операция is_empty() при этом сводится к проверке объекта такого типа на пустоту. |
pop() |
O(1) |
||
is_empty() |
O(1) |
||
Очередь |
enqueue() |
O(1) |
Для моделирования очереди можно использовать связный список, в котором есть ссылки и на первый, и на последний элемент. Добавление в конец очереди модифицирует последний элемент, удаление из начала — первый. Также можно использовать и list, в котором эти границы задаются по типу кольцевого буфера из главы 4, однако масштабированием такой структуры надо заниматься самостоятельно, иначе она может переполниться. |
dequeue() |
O(1) |
||
is_empty() |
O(1) |
||
Хеш-таблица |
put() |
O(1) |
N пар (ключ, значение) можно хранить в виде M наборов, хеши ключей в которых совпадают. Чем больше N, тем больше размер набора, и чтобы сохранить производительность, нужно примерять геометрическое масштабирование. Другой вариант — открытая адресация, при котором все пары хранятся подряд, хеш ключа используется для определения начального индекса, а коллизии разрешаются последовательным просмотром. В обоих случаях легко организовать итератор по всем парам. Если вдобавок нужно, чтобы в линеаризации пары были упорядочены, для хранения можно воспользоваться двоичным деревом поиска — правда, тогда сложность put() и get() возрастёт до O(log N). |
get() |
O(1) |
||
iterator() |
O(N) |
||
is_empty() |
O(1) |
||
Приоритетная очередь |
add() |
O(log N) |
Подойдёт новая структура данных — куча — которая хранит пары (значение, приоритет). Если N заранее неизвестно, стоит предусмотреть геометрическое масштабирование, иначе хранилище кучи может переполниться. Внутренние операции sink() и swim(), описанные в четвёртой главе, определяют общее быстродействие — O(log N). |
remove_max() |
O(log N) |
||
is_empty() |
O(1) |
||
Индексированная приоритетная очередь |
add() |
O(log N) |
Используем модифицированную кучу, в которой дополнительно хранится хеш-таблица с позициями значений, так что время поиска такой позиции оказывается O(1). Применение хеш-таблицы позволяет достичь логарифмической — O(log N) — производительности действий с очередью. В книге описана приоритетная очередь с порядком возрастания. |
remove_min() |
O(log N) |
||
decrease_priority() |
O(log N) |
||
is_empty() |
O(1) |
Таблица 8-1. Быстродействие абстрактных типов данных
Встроенные типы данных Python
Язы программирования Python активно развивается более тридцати лет, и за это время встроенные типы данных стали весьма удобны и эффективны. Разработчики постоянно — буквально в каждом выпуске Python — пробуют улучшить быстродействие, даже если и ненамного. Довольно показателен текст «The Design and History FAQ» («Часто задаваемые вопросы про историю разработки») с официального сайта Python3.
Непосредственно в синтаксис языка встроено четыре типа для хранения данных — кортеж (tuple), список (list), словарь (dict) и множество (set):
- tuple (кортеж)
Неизменяемая последовательность произвольных объектов Python, с которой можно обращаться как с массивом, только элементам нельзя присваивать новые значения. Реализован как таблица — массив ссылок на объекты Python. Часто используется для объединения элементов в группы например, попарно, или для того, чтобы вернуть из функции несколько значений.
- list (список)
Динамический массив произвольных объектов Python, один из главных типов данных. Удивительно гибкий инструмент, в котором, как и в кортеже, есть очень прозрачно оформленное секционирование — изготовление новых объектов-подпоследовательностей. Если нужно пройти такую подпоследовательность элементов циклом, с помощью объекта типа range() создаётся вычислимая последовательность индексов и применяется индексирование. Список тоже реализован как массив ссылок на объекты, но эти ссылки можно изменять, а также добавлять и удалять. Константную сложность операциям удаления и добавления (в конце списка) придаёт геометрическое масштабирование.
- dict (словарь)
Второй главный тип данных в Python — хеш-таблица, которая хранит данные в соответствие с их ключами. Всё, что мы знаем по третьей главе о хеш-таблицах, применимо и к dict. В реализации Python для разрешения коллизий используется открытая адресация с повторным хешированием. Тройки полный хеш ключа, ключ, значение хранятся (динамическом массиве), а их индексы (равные остатку от деления хеша на M) — в списке заранее заданного размера M, равного степени двойки. Это несколько отличается от обычной структуры хеш-таблиц. Размер словаря (массива индексов) начинается с M = 8, а при превышении порога заполнения в ⅔ применяется геометрическое масштабирования: размер удваивается, и хеши вычисляются заново. Размер меньше 8 смысла не имеет — слишком часто происходило бы масштабирование, и так-то в массиве хранится не более пяти элементов. Если большинство элементов таблицы не занято, список объектов достаточно мал, что обеспечивает эффективность по памяти в среднем. Поскольку значения хранятся в списке, всегда известен порядок их добавления в словарь; чтобы сохранить константную сложность удаления произвольного элементов словаря, по соответствующему индексу делается пометка о том, что элемент удалён, а сам элемент остаётся в списке-хранилище (таким образом удаление не уменьшает размер хранилища до следующего масштабирования).
Python — свободное программное обеспечение с открытыми исходными текстами, так что реализацию любого типа данных (Python написан на языке Си) всегда можно посмотреть в исходниках4. Вместо последовательного просмотра цепочки, в случае коллизии хеш-суммы hc следующий индекс вычисляется как ((5 × hc) + perturb 1) % 2ⁿ, где 2ⁿ — это M, размер массива с индексами, а perturb — небольшая числовая константа, помогающая равномерно рассеивать хеши по области значений. Довольно интересно поизучать, как небольшое изменение в формуле приводит к более эффективной реализации хеширования. Остаток от деления на степень двойки, как известно, можно взять и без деления — с помощью побитовой конъюнкции (в Python обозначается знаком «&»).
Таким образом можно значительно увеличить быстродействие. Например, остаток от деления на M на 2ⁿ — это M & (2ⁿ - 1)5. В таблице 8-2 показано время выполнения десяти миллионов операций взятия остатка отделения на 2ⁿ и побитовой конъюнкции с 2ⁿ - 1 в Python и Си: на Си написана основная реализация словарей в Python, и там быстродействие отличается более, чем впятеро!
Язык
Выражение
Время выполнения
Python
1989879384 % M
0.6789181 secs
Python
1989879384 & (M - 1)
0.3776672 secs
Си
1989879384 % M
0.1523320 secs
Си
1989879384 & (M - 1)
0.0279260 secs
Таблица 8-2. Остаток деления на степень двойки M = 2ⁿ быстрее вычислять с помощью побитовой конъюнкции
- set (множество)
Набор различающихся хешируемых (в случае Python это означает «неизменяемых») данных. Попросту говоря, множество — это хеш-таблица, в которой ничего не хранится6. В отличие от словаря, основное назначение которого — накопление элементов и поиск конкретного значения по (скорее всего) существующему в словаре ключу, множество обычно нужно для того, чтобы проверить, принадлежит ли ему некоторый ключ или нет. Соответственно, в реализации Python оптимизирован не только успешный поиск ключа в множестве (когда ключ в множестве есть), но и неуспешный (когда его там нет). Кроме того, множества в Python поддерживают теоретико-множественные операции, такие как объединение, пересечение, дополнение и т. п.
Реализация стека в Python
В седьмой главе мы довольно подробно обсудили, как именно Python-овский тип list реализует абстрактный тип данных «стек». Если коротко: будучи динамическим массивом с геометрическим масштабированием, list предоставляет методы .append(), который добавляет элемент в конец списка, и .pop(), который снимает оттуда элемент. Оба метода имеют константную в среднем сложность, как это видно, например, из таблицы 6-1.
В состав дистрибутива Python входит модуль queue, в котором реализованы и стек («последним вошёл, первым вышел», LIFO), и очередь («первым вошёл, первым вышел», FIFO), причём основной упор сделан на структуры данных фиксированного размера и их использование в многопоточных окружениях. Такой стек или очередь поддерживает несколько потоков выполнения, часть из которых может добавлять туда элементы, а часть — снимать их оттуда. Например, queue.LifoQueue() создаст стек актуально бесконечного размера, который отличается от обычного списка те, что методу .get() (аналог .pop()) по умолчанию передаётся дополнительный параметр block=True, тогда попытка снять значение из пустого стека не вызовет исключения, а приостановится до тех пор, пока в каком-нибудь другом потоке выполнения в этот стек не положат хотя бы одно значение. Если создать стек или очередь фиксированного размера, то и операция .put() (аналог .append()) также будет приостанавливаться, если соответствующая структура данных полна, и ждать, пока кто-то другой не снимет оттуда хотя бы одно значение.
В частности, такая программа зависнет на операции .put(), и нам придётся остановить её средствами операционной системы:
Если многопоточность не нужна, но вдобавок мы хотим использовать наше хранилище и как стек, и как очередь (то есть наш алгоритм часто удаляет или добавляет элементы и в начало, и в конец хранилища), самый быстрый вариант — это упоминавшаяся в третьей главе «двусторонняя очередь» (double-ended queue) deque из модуля collections7. Операции добавления и в начало, и в конец deque имеют сложность O(1) в среднем, и при этом его быстродействие раз в 30 выше LifoQueue (хотя порядок сложности остаётся константным).
Реализация очередей в Python
Очередь в принципе можно было бы смоделировать на базе Python-овского list: использовать метод .append() для добавления в конец очереди (последний элемент списка), и .pop(0) — для снятия элемента из её начала (нулевой элемент списка). Однако мы знаем, что работа с началом списка требует уже не константного, а линейного времени, и как это видно из таблицы 6-1, лучше так вообще никогда не делать.
Очередь с поддержкой многопоточности (несколько потоков выполнения добавляют в очередь, несколько снимают) и блокировок при переполнении и опустошении реализована классом queue.Queue, но, как уже говорилось выше относительно класса queue.LifoQueue, если все эти свойства не нужны, он оказывается слишком мощным инструментом с большими накладными расходами. Как и в LifoQueue, методы .put() и .get() используются для добавления и удаления элементов, и если ограничить размер хранилища, возникает блокировка. Например, такой диалог в командной строке Python придётся прерывать вручную средствами операционной системы:
Классы queue.Queue и queue.LifoQueue можно использовать для создания очередей с заданиями: каждый поток выполнения—задание должен вызвать метод .task_done() после того, как выполнит последнюю по его мнению операцию с очередью, а поток выполнения, запустивший эти задания, вызовет метод .join(), который приостановится до тех пор, пока все задания не завершатся. Если вы не пользуетесь абстракцией «задание», можно задействовать класс queue.SimpleQueue — в нём нет этих двух методов (и механизма их реализации), и работает он несколько эффективнее. Ещё раз напомним, что классы из модуля queue обладают впечатляющим набором свойств, которые, возможно, не будут нужны в простых программах. Они не только адаптированы к многопоточным средам (тредобезопасны), но и допускают использование в повторно-входимых процедурах (сопрограммах) и т. п. — всё это за счёт падения производительности. Используйте эти классы если вам в самом деле нужны их свойства.
Сноску про Python 3.7 я удалил: сейчас уже Python 3.11, нет смысла размечать текст комментариями, когда в Python какие особенности появились -- FrBrGeorge 2023-02-24 15:16:48
Самая быстрая реализация очереди в Python — это класс collections.dequeue: в нём неплохо оптимизирована производительность, и если важно быстродействие очереди, стоит использовать именно его. В таблице 8-3 приведено сравнение быстродействия очередей на базе различных типов — dequeue оказывается лучшим, а list — худшим, причём операция снятия из очереди на нём имеет сложность O(N) — в отличие от остальных классов; так что ещё раз напомним: для очередей списки использовать не надо.
N |
list |
deque |
SimpleQueue |
Queue |
1024 |
0.012 |
0.004 |
0.114 |
0.005 |
2048 |
0.021 |
0.004 |
0.115 |
0.005 |
4096 |
0.043 |
0.004 |
0.115 |
0.005 |
8192 |
0.095 |
0.004 |
0.115 |
0.005 |
16384 |
0.187 |
0.004 |
0.115 |
0.005 |
Таблица 8-3. Быстродействие снятия элемента из очереди для различных реализаций
Базовые типы данных Python можно использовать для решения самых разнообразных задач, но в каждом случае стоит тщательно выбирать, с помощью чего моделировать ту или иную структуру данных — чтобы программа заработала быстрее.
Реализация кучи и приоритетной очереди
В базовом дистрибутиве Pythopn есть модуль heapq, который реализует кучу с порядком возрастания — примерно так, как мы это сделали в четвёртой главе, но в качестве хранилища используется обычный список с индексацией элементов от 0, а не от 1, как в нашем варианте.
Более того: никакой специальной структуры данных для кучи создавать не надо. Достаточно передать процедуре heapq.heapify(h) список исходных значений h, и она превратит h в кучу с порядком возрастания, после чего можно будет пользоваться им и как кучей, и как списком (только не стоит нарушать порядок). Другой способ — завести пустой список h=[], и добавлять значения в кучу с помощью heapq.heappush(h, значение). Наименьшее значение можно снять с кучи при помощи heapq.heappop(h). Вдобавок к этому в heapq имеются две полезный функции:
- heapq.heappushpop(h, value)
Сначала добавляет в кучу новый элемент, а затем снимает с её вершины наименьшее значение (если новый элемент и был минимальным, ничего добавлять и снимать не надо, достаточно просто возвращает его).
- heapq.heapreplace(h, value)
Сначала снимает с кучи минимальный элемент, а затем добавляет туда новый (если он оказался не больше предыдущего минимума, достаточно просто заменить один на другой).
Если изучить содержимое h — списка, в котором поддерживается порядок кучи, мы увидим в нём прямое сходство с хранилищем элементов кучи из нашей реализации в главе 4.
Приоритизированную очередь реализует класс PriorityQueue из модуля queue (https://oreil.ly/sUiZd — как и другие структуры данных этого модуля, он поддерживает многопоточность и блокировку операций при выходе за размер хранилища). Нетрудно заметить, что это просто «обёртка» queue.Queue вокруг кучи из heapq: метод .put(item), добавляющий в очередь item — кортеж (приоритет, значение), просто хранит этот кортеж в куче, а метод .get(), снимающий элемент из начала очереди, просто выполняет heappop().
А вот реализации индексированной приоритетной очереди среди стандартных структур данных Python нет — что неудивительно, ибо чаще всего она нужна при обработке графов — например, в Алгоритме Дейкстры, который мы рассматривали в седьмой главе. Метод IndexedMinPQ.decrease_priority() из нашей реализация в этой главе оказался коротким и эффективным как раз за счёт задействованной в нём совокупности различных структур данных.
Что изучать дальше?
В нашей книге мы только пробежались по верхам необъятного пространства — науки об алгоритмах. Углубляться в это пространство можно по нескольким направлениям, в различных предметных областях и с различными подходами к пониманию алгоритмов:
- Вычислительная геометрия
Великое множество практических задач включают в себя обработку наборов точек в двумерных или даже многомерных координатах. Алгоритмы для такой обработки весьма разнообразны, например, нередко используется подход «разделяй и властвуй», описанный в нашей книге, и для эффективной их реализации создаются специализированные структуры данных. Самые распространённые — это K-мерные деревья и их подвиды: дерево квадратов (для разбиения двумерного пространства), октодерево (для разбиения трёхмерного пространства) и R-дерево (для индексации многомерной информации). Как видим, идея двоичных деревьев живёт и побеждает повсеместно, в различных предметных областях.
- Динамическое программирование
Наглядный представитель этого класса алгоритмов — Алгоритм Флойда-Уоршелла (поиск кратчайших путей в графе от заданной вершины). Теория, лежащая в основе динамического программирования, позволила выработать целый спектр эффективных алгоритмов. Подробнее об этом можно прочитать в книге «Введение в алгоритмы», которая также вышла в издательстве О'Райлли (https://oreil.ly/1lXRF).
- Параллельные и распределённые алгоритмы
В нашей книги мы изучали алгоритмы, использующие единственный поток выполнения на единственном компьютере. Если решение задачи можно разбить на несколько независимо вычисляемых подзадач, эти решения можно запустит одновременно, например, на одном компьютере с несколькими процессорами или не нескольких вычислительных узлах, объединённых сетью. Параллелизмом довольно непросто управлять, и сами алгоритмы становятся сложнее, но игра стоит свеч: выигрыш в быстродействии от умело спроектированного параллелизма может быть очень значительным.
- Численные методы
Вычисления в практических задачах совершенно не обязаны быть алгебраически точными. Более того, для некоторых важных задач вычислительная сложность точного решения может быть слишком большой для необходимого набора данных, или даже точного решения может не существовать вообще. Такие задачи можно решить приближённо, с заданной степенью точности — и на этот счёт существует множество алгоритмов.
- Вероятностные методы
Иногда для решения задачи невозможно построить алгоритм в собственном смысле: невозможно (или вычислительно сложно) написать программу, которая на одном и том же наборе входных данных давала одинаковый удовлетворительный результат. Можно применить принципиально иной подход: ввести в вычисления случайный компонент и запустить программу довольно много раз, получив множество отличающихся результатов. В некоторых случаях подходящим ответом будет среднее из полученных значений, в некоторых — если есть функция, позволяющая оценить «правильность» результата — процесс повторяется, пока проверочная функция не даст добро (в действительности подходов, конечно, больше).
Конечно же, ни одна книга не может объять необъятного — всего многообразия возможных алгоритмов. В 1962 году Дональд Кнут, один из столпов программирования, начал писать грандиозную книгу — «Искусство программирования» (выходит в издательстве Addison-Wesley8. К нынешнему времени — более, чем шестьдесят лет спустя — вышло три первых тома этой книги (в 1968, 1969 и 1973 годах), а также первая часть четвёртого тома (в 2011-м). Впереди ещё три тома, так что проект далёк от завершения!
Изучать и применять алгоритмы можно до бесконечности, и мы надеемся, что знания, которые мы вложили в эту книгу, помогут в разработке быстрых и эффективных программ.
Об авторе
Джордж Хайнеман (George Heineman) — профессор Computer Science с более чем двадцатилетним стажем разработки программного обеспечения и исследования эффективности алгоритмов. Хайнеман — автор книги «Введение в алгоритмы» и множества учебных курсов О'Райлли, таких как «Исследование алгоритмов на языке Python» и «Работа с алгоритмами на языке Python». С давних пор он интересуется логическими и математическими головоломками, и даже изобрёл несколько: например Sujiken® (вариант Судоку) и Trexagon.
Об обложке
Животное на обложке книги «Изучаем алгоритмы» — чесапикский голубой краб (Callinectes sapidus). Название рода Callinectes происходит от древнегреческого «хороший пловец», а название вида — sapidus — на латыни значит «вкусный», «пикантный». Голубой цвет крабу придают пигменты в панцире, в частности ярко-синий альфа-крустацианин, который взаимодействуя с красным астаксантином даёт зелёно-голубой окрас. Если краба сварить, альфа-крустацианин разрушается и панцирь приобретает яркий оранжево-красный оттенок.
Родина голубого краба — атлантическое побережье Северной и Южной Америки и Мексиканский залив. В Европу и Японию он попал, по-видимому, с водным балластом, и уже в 1901 году его можно было встретить в тамошних водах. Прежде считалось, что причина расширения ареала голубого краба — повышение температуры океана вследствие глобального потепления.
Эти крабы откладывают икру на мелководье, после чего приливные течения относят её на глубину. Планктонная личинка краба проходит восемь стадий развития, прежде чем станет похожей на взрослую особь. Как и все ракообразные, голубые крабы растут во время линьки: старый панцирь сбрасывают и отращивают новый — побольше. Считается, что за свою жизнь краб успевает перелинять около 25 раз, и дорасти примерно до 9 дюймов в ширину. Брюшко самцов — плоское, самок — обширное и округлое; окраской они почти не различаются.
Многие животные с обложек О'Райлли находятся под угрозой исчезновения, и все животные без исключения — важный элемент биосферы.
Иллюстрация на обложке выполнена Karen Montgomery: за основу взята чёрно-белая гравюра из книги 1887 года «Animal Life in the Sea and on the Land» («Жизнь животных в море и на суше. Зоология для юношества»). Шрифты английского издания: на обложке — Gilroy Semibold и Guardian Sans; основной текст — Adobe Minion Pro; заголовки — Adobe Myriad Condensed; текст программ — Ubuntu Mono (автор этого шрифта — Dalton Maag).
В русскоязычной программистской традиции нет единой абстрактной структуры данных с такими свойствами. В частности, если набор реализован массивом, быстродействие некоторых операций значимо отличается от варианта, где та же абстракция реализована связным списком — и это побуждает разделать соответствующие абстракцию (1)
Таблица показывает свойства моделей, сделанных в этой книге. В практическом программировании стоит пользоваться готовыми типами данных — самого Python или в составе специализированных модулей. (2)
Хороший пример — как в Python 3.6 переработали реализацию типа dict, совершенно прозрачно для сообщества и при этом значительно увеличили быстродействие и снизили потребление памяти. (3)
Например, реализацию словарей можно увидеть тут: https://oreil.ly/jpI8F (прим. автора). (4)
Поясним: 2ⁿ — это число, двоичное представление которого состоит из единицы и n нулей, а двоичное представление 2ⁿ - 1, соответственно, — это n единиц подряд. Остаток от деления M на 2ⁿ — это число от 0 до 2ⁿ - 1, то есть n младших битов числа M без изменения. Тот же самый результат мы получим, если взять побитовую конъюнкцию M и 2ⁿ - 1: младшие n битов числа M не изменятся, а остальные превратятся в 0. (5)
Исходный текст реализации множеств на Си можно посмотреть в репозитории Python, https://oreil.ly/FWttm (прим. автора). (6)
По сходству звучания «deque» и «deck», deque иногда называют «колодой». (7)
В Советском Союзе книга выходила в издательстве «Мир». (8)
