Files
hello-algo/ru/chapters/chapter_04.md
2026-03-25 16:54:42 +08:00

776 lines
82 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Массивы и списки
![](ru/docs/assets/media/image121.jpeg){width="3.71875in" height="4.8125in"}
#### массивы
> *Массив* представляет собой линейную структуру данных, в которой элементы одного типа хранятся в непрерывной области памяти. Положение элемента в массиве называется его индексом. На рис. 4.1. изображены основные поня- тия и способ хранения массивов.
**Элемент**
> Память для хранения **массива**
>
> ![](ru/docs/assets/media/image123.jpeg)является **непрерывной**
>
> **Массив**
>
> Индекс
>
> **Адрес памяти**
>
> Доступная память
>
> Память, выделенная для массива
>
> **Рис. 4.1.** Определение и способ хранения массива
1. **Основные операции с массивом**
##### Инициализация массива
> Существует два способа инициализации массива: без начальных значений и с за- данными начальными значениями. Если начальные значения не указаны, боль- шинство языков программирования инициализируют элементы массива нулями.
>
> \# === File: array.py === \# Инициализация массива.
>
> arr: list\[int\] = \[0\] \* 5 \# \[ 0, 0, 0, 0, 0 \]
>
> nums: list\[int\] = \[1, 3, 2, 5, 4\]
##### Доступ к элементам массива
> Элементы массива хранятся в непрерывной области памяти, что упрощает вы- числение их адресов. Зная адрес массива (адрес первого элемента) и индекс элемента, можно вычислить адрес этого элемента по формуле, показанной на рис. 4.2, и получить к нему доступ.
>
> Как видно из рис. 4.2, индекс первого элемента массива равен 0, что может показаться неочевидным, так как отсчет с 1 кажется более естественным. Од- нако с точки зрения формулы вычисления адреса **индекс является смеще- нием адреса в памяти**. Смещение адреса первого элемента равно 0, поэтому и его индекс равен 0.
>
> ![](ru/docs/assets/media/image125.jpeg)
>
> **Рис. 4.2.** Вычисление адреса элемента массива
>
> Доступ к элементам массива осуществляется очень эффективно, так как по- зволяет за время *O*(1) произвольно обращаться к любому элементу.
>
> \# === File: array.py ===
>
> def random_access(nums: list\[int\]) -\> int: \"\"\" Случайный доступ к элементу.\"\"\"
>
> \# Случайно выбирается число из диапазона \[0, len(nums)-1\]. random_index = random.randint(0, len(nums) - 1)
>
> \# Получение и возврат случайного элемента. random_num = nums\[random_index\]
>
> return random_num
##### Вставка элемента
> Элементы массива в памяти расположены вплотную, между ними нет места для хранения других данных. Для вставки элемента в середину массива не- обходимо сдвинуть все последующие элементы на одну позицию вправо, а затем присвоить значение элементу по заданному индексу, как показано на рис. 4.3.
>
> Следует отметить, что длина массива фиксирована, поэтому вставка эле- мента неизбежно приведет к потере элемента в конце массива. Решение этой проблемы будет рассмотрено в разделе «Списки».
>
> ![](ru/docs/assets/media/image127.jpeg)
>
> **Рис. 4.3.** Пример вставки элемента в массив
>
> \# === File: array.py ===
>
> def insert(nums: list\[int\], num: int, index: int):
>
> \"\"\" Вставка элемента num в массив по индексу index.\"\"\"
>
> \# Сдвиг всех элементов, начиная с индекса index, на одну позицию вправо. for i in range(len(nums) - 1, index, -1):
>
> nums\[i\] = nums\[i - 1\]
>
> \# Присвоение num элементу по индексу index. nums\[index\] = num
##### Удаление элемента
> Аналогично для удаления элемента по индексу *i* необходимо сдвинуть все по- следующие элементы на одну позицию влево, как показано на рис. 4.4.
>
> Обратите внимание, что после удаления элемента последний элемент ста- новится бессмысленным, поэтому его можно не изменять.
>
> \# === File: array.py ===
>
> def remove(nums: list\[int\], index: int):
>
> \"\"\" Удаление элемента по индексу index.\"\"\"
>
> \# Сдвиг всех элементов, начиная с индекса index, на одну позицию влево. for i in range(index, len(nums) - 1):
>
> nums\[i\] = nums\[i + 1\]
>
> ![](ru/docs/assets/media/image129.jpeg)
>
> **Рис. 4.4.** Пример удаления элемента из массива
>
> В целом операции вставки и удаления в массиве имеют следующие недо- статки.
- **Высокая временная сложность**: средняя временная сложность опера- ций вставки и удаления в массиве составляет *O*(*n*), где *n* -- длина массива.
- **Потеря элементов**: так как длина массива фиксирована, при вставке элемента элементы, выходящие за пределы длины массива, теряются.
- **Расточительность памяти**: можно инициализировать длинный массив и использовать только его часть, но это приведет к потере памяти, так как неиспользуемые элементы в конце массива будут бессмысленными.
##### Обход массива
> В большинстве языков программирования массив можно обходить как по ин- дексам, так и непосредственно по элементам.
>
> \# === File: array.py ===
>
> def traverse(nums: list\[int\]): \"\"\" Обход массива.\"\"\" count = 0
>
> \# Обход массива по индексам. for i in range(len(nums)):
>
> count += nums\[i\]
>
> \# Прямой обход элементов массива. for num in nums:
>
> count += num
>
> \# Одновременный обход индексов и элементов.
>
> for i, num in enumerate(nums): count += nums\[i\]
>
> count += num
##### Поиск элемента
> Для поиска заданного элемента в массиве необходимо обойти массив и на каждой итерации проверить, совпадает ли значение элемента с искомым. Если совпадает, вывести соответствующий индекс. Поскольку массив яв- ляется линейной структурой данных, этот процесс называется линейным поиском.
>
> \# === File: array.py ===
>
> def find(nums: list\[int\], target: int) -\> int: \"\"\" Поиск заданного элемента в массиве.\"\"\" for i in range(len(nums)):
>
> if nums\[i\] == target: return i
>
> return -1
##### Расширение массива
> В сложных системных средах нельзя гарантировать, что ячейки памяти, расположенные после массива, являются свободными. Это делает невоз- можным безопасное расширение его размера. Поэтому в большинстве язы- ков программирования длина массива фиксирована. Если необходимо уве- личить массив, нужно создать новый, больший массив и последовательно скопировать в него элементы исходного массива. Эта операция имеет слож- ность *O*(*n*) и при больших массивах занимает много времени. Пример кода представлен ниже.
>
> \# === File: array.py ===
>
> def extend(nums: list\[int\], enlarge: int) -\> list\[int\]: \"\"\" Увеличение длины массива.\"\"\"
>
> \# Инициализация массива с увеличенной длиной. res = \[0\] \* (len(nums) + enlarge)
>
> \# Копирование всех элементов исходного массива в новый массив. for i in range(len(nums)):
>
> res\[i\] = nums\[i\]
>
> \# Возврат нового массива с увеличенной длиной. return res
### Преимущества и ограничения массивов
> Массивы хранятся в непрерывном пространстве памяти, а его элементы име- ют одинаковый тип. Этот подход содержит богатую априорную информацию, которую система может использовать для оптимизации эффективности опе- раций с данной структурой данных.
- **Высокая эффективность использования пространства**: массивы вы- деляют непрерывные блоки памяти для данных без дополнительных структурных затрат.
- **Поддержка произвольного доступа**: массивы позволяют получить до- ступ к любому элементу за время *O*(1).
- **Локальность кеширования**: при доступе к элементам массива компью- тер загружает не только его, но и кеширует окружающие данные, что по- зволяет ускорить выполнение последующих операций за счет использо- вания высокоскоростного кеша.
> Непрерывное хранение в пространстве -- это палка о двух концах, имеющая следующие ограничения.
- **Низкая эффективность вставки и удаления**: при большом количестве элементов в массиве операции вставки и удаления требуют перемеще- ния множества элементов.
- **Неизменная длина**: после инициализации длина массива фиксируется, а увеличение массива требует копирования всех данных в новый массив, что влечет за собой значительные затраты.
- **Расточительность пространства**: если размер выделенного массива превышает фактические потребности, избыточное пространство оказы- вается потраченным впустую.
### Типичные сценарии применения массивов
> Массивы -- это базовая и распространенная структура данных, часто исполь- зуемая в различных алгоритмах и для реализации сложных структур данных.
- **Произвольный доступ**: если требуется случайный выбор элементов, можно использовать массив для хранения и генерации случайной по- следовательности, осуществляя случайную выборку по индексу.
- **Сортировка и поиск**: массивы являются наиболее часто используемой структурой данных для алгоритмов сортировки и поиска. Быстрая сорти- ровка, сортировка слиянием, двоичный поиск и другие алгоритмы в ос- новном работают с массивами.
- **Таблица поиска**: когда необходимо быстро найти элемент или его со- ответствие, можно использовать массив в качестве таблицы поиска. На- пример, для реализации отображения символов в ASCII-коды можно использовать значение ASCII-кода символа в качестве индекса, а соот- ветствующий элемент хранить в соответствующем месте массива.
- **Машинное обучение**: в нейронных сетях широко используются опе- рации линейной алгебры между векторами, матрицами и тензорами, которые реализуются в виде массивов. Массивы являются наиболее часто используемой структурой данных в программировании ней- ронных сетей.
- **Реализация структур данных**: массивы могут использоваться для ре- ализации стека, очереди, хеш-таблицы, кучи, графа и других структур данных. Например, представление графа в виде матрицы смежности фактически является двумерным массивом.
#### Связные списки
> Память -- это общий ресурс всех программ, и в сложной системной среде вы- полнения участки свободной памяти могут быть разбросаны по всему про- странству памяти. Нам уже известно, что память для хранения массива должна быть непрерывной, и когда массив очень велик, в памяти может не оказаться столь большого непрерывного участка. В этом случае проявляется преимуще- ство гибкости связного списка.
>
> *Связный список* -- это линейная структура данных, в которой каждый элемент является объектом-узлом. При этом узлы соединяются друг с другом с помо- щью ссылок. В ссылке хранится адрес памяти следующего узла, по которому можно перейти от текущего узла к следующему.
>
> Структура связного списка позволяет узлам храниться в различных местах памяти, а их адреса памяти не обязаны быть последовательными.
![](ru/docs/assets/media/image131.jpeg)
> **Рис. 4.5.** Определение и способ хранения связного списка
>
> На рис. 4.5 изображена структура связного списка. Составным элементом является объект узла. Каждый узел содержит две части данных: значение узла и ссылку на следующий узел.
- Первый узел связного списка называется головным узлом, а последний узел -- хвостовым узлом.
- Хвостовой узел указывает на пустое значение, которое в Java, C++ и Py- thon обозначается как null, nullptr и None соответственно.
- В языках, поддерживающих указатели, таких как C, C++, Go и Rust, вы- шеупомянутая ссылка заменена на указатель.
> В следующем примере кода показано, что узел связного списка ListNode, помимо значения, должен дополнительно хранить ссылку (указатель). Поэтому при одина- ковом объеме данных **связный список занимает больше памяти, чем массив**.
>
> class ListNode:
>
> \"\"\" Класс узла связного списка.\"\"\" def init (self, val: int):
>
> self.val: int = val \# Значение узла.
>
> self.next: ListNode \| None = None \# Ссылка на следующий узел.
### Основные операции со связным списком
##### Инициализация связного списка
> Создание связного списка состоит из двух этапов: первый этап -- иници- ализация каждого объекта узла, второй этап -- построение ссылочных от- ношений между узлами. После завершения инициализации можно начать с головного узла связного списка и последовательно посетить все узлы че- рез ссылку next.
>
> \# === File: linked_list.py ===
>
> \# Инициализация связного списка 1 -\> 3 -\> 2 -\> 5 -\> 4. \# Инициализация каждого узла.
>
> n0 = ListNode(1) n1 = ListNode(3) n2 = ListNode(2) n3 = ListNode(5) n4 = ListNode(4)
>
> \# Построение ссылок между узлами. n0.next = n1
>
> n1.next = n2 n2.next = n3 n3.next = n4
>
> Массив в целом является переменной, например массив nums содержит эле- менты nums\[0\] и nums\[1\] и т. д., в то время как связный список состоит из множе- ства независимых объектов узлов. **Обычно головной узел используется как обозначение связного списка**, например связный список в приведенном выше коде можно обозначить как связный список n0.
##### Вставка узла
> Процесс вставки узла в связный список очень прост. Предположим, что не- обходимо вставить новый узел P между двумя соседними узлами n0 и n1, **для этого достаточно изменить две ссылки (указателя)**, а время выполнения составит *O*(1), см. рис. 4.6.
>
> Напомним, что вставка элемента в массив имеет временную сложность *O*(*n*), что менее эффективно при больших объемах данных.
>
> ![](ru/docs/assets/media/image133.jpeg)
>
> **Рис. 4.6.** Пример вставки узла в связный список
>
> \# === File: linked_list.py ===
>
> def insert(n0: ListNode, P: ListNode):
>
> \"\"\" Вставка узла P после узла n0 в связный список.\"\"\" n1 = n0.next
>
> P.next = n1 n0.next = P
##### Удаление узла
> Удаление узла в связном списке также является очень простой операцией, как показано на рис. 4.7. **Достаточно изменить всего одну ссылку (указатель)**.
>
> Обратите внимание, что, хотя после завершения операции удаления узел P все еще указывает на узел n1, фактически при обходе этого связного списка доступ к P уже невозможен. То есть фактически P больше не принадлежит это- му списку.
>
> \# === File: linked_list.py ===
>
> def remove(n0: ListNode):
>
> \"\"\" Удаление первого узла после узла n0 в связном списке.\"\"\" if not n0.next:
>
> return
>
> \# n0 -\> P -\> n1
>
> P = n0.next n1 = P.next n0.next = n1
>
> **Пример**
>
> Удаление узла **P** из связного списка
>
> (Элемент **n0 P**
>
> ![](ru/docs/assets/media/image135.jpeg)указывает на **n1 )**
>
> После удаления элемент **P** все еще указывает на **n1**. Но **P** больше не доступен при обходе списка, поэтому можно считать, что **P** был удален
##### Доступ к узлу
> **Рис. 4.7.** Удаление узла в связном списке
>
> **Эффективность доступа к узлам в связном списке ниже**. Как упоминалось в предыдущем разделе, доступ к любому элементу массива можно получить за время *O*(1). В случае связного списка программа должна начать с головно- го узла и последовательно проходить по узлам, пока не будет найден целевой узел. Это означает, что для доступа к *i*-му узлу связного списка необходимо выполнить *i* -- 1 итераций, что соответствует временной сложности *O*(*n*).
>
> \# === File: linked_list.py ===
>
> def access(head: ListNode, index: int) -\> ListNode \| None: \"\"\" Доступ к узлу с индексом index в связном списке.\"\"\" for \_ in range(index):
>
> if not head:
>
> return None head = head.next
>
> return head
##### Поиск узла
> Поиск узла заключается в обходе связного списка для поиска узла со значени- ем target и выводе его индекса в списке. Этот процесс также является линей- ным поиском. Ниже приведен пример кода.
>
> \# === File: linked_list.py ===
>
> def find(head: ListNode, target: int) -\> int:
>
> \"\"\" Поиск первого узла со значением target в связном списке.\"\"\" index = 0
>
> while head:
>
> if head.val == target: return index
>
> head = head.next index += 1
>
> return -1
### Сравнение массивов и связных списков
> В табл. 4.1 приведены характеристики массивов и связных списков, а также сравнение эффективности операций с ними. Поскольку они используют две противоположные стратегии хранения, их свойства и эффективность опера- ций также имеют противоположные характеристики.
+------------------------+----------------------------------------------------------------------+----------------------------------------+
| | > **Массив** | > **Связный список** |
+========================+======================================================================+========================================+
| > Способ хранения | > Непрерывное пространство памяти | > Распределенное про- странство памяти |
+------------------------+----------------------------------------------------------------------+----------------------------------------+
| > Расширение емкости | > Длина фиксирована | > Возможность гибкого расширения |
+------------------------+----------------------------------------------------------------------+----------------------------------------+
| > Эффективность памяти | > Элементы занимают меньше памяти, но могут расходовать пространство | > Элементы занимают больше памяти |
+------------------------+----------------------------------------------------------------------+----------------------------------------+
| > Доступ к элементу | > *O*(1) | > *O*(*n*) |
+------------------------+----------------------------------------------------------------------+----------------------------------------+
| > Добавление элемента | > *O*(*n*) | > *O*(1) |
+------------------------+----------------------------------------------------------------------+----------------------------------------+
| > Удаление элемента | > *O*(*n*) | > *O*(1) |
+------------------------+----------------------------------------------------------------------+----------------------------------------+
> впустую
### Основные типы связных списков
> Существуют три основных типа связных списков (см. рис. 4.8).
- **Односвязный список**: это обычный связный список, описанный ранее. Узлы однонаправленного связного списка содержат значение и ссылку на следующий узел. Первый узел называется головным, а последний -- хвостовым, хвостовой узел указывает на пустое значение None.
- **Кольцевой (циклический) список**: если сделать так, чтобы хвостовой узел односвязного списка указывал на головной узел (соединение начала и конца), получится кольцевой список. В кольцевом списке любой узел можно рассматривать как головной.
- **Двусвязный список**: в отличие от односвязного двусвязный список хранит ссылки в двух направлениях. Определение узла двусвязного спи- ска включает ссылки (указатели) на следующий и предыдущий узлы. По сравнению с односвязным списком двусвязный список обладает большей гибкостью, позволяя обходить список в обоих направлениях, но требует больше памяти.
> class ListNode:
>
> \"\"\" Класс узла двусвязного списка.\"\"\" def init (self, val: int):
>
> self.val: int = val \# Значение узла.
>
> self.next: ListNode \| None = None \# Ссылка на следующий узел. self.prev: ListNode \| None = None \# Ссылка на предыдущий узел.
![](ru/docs/assets/media/image137.jpeg)
> **Рис. 4.8.** Виды связных списков
### Типичные сценарии применения списков
> Односвязные списки обычно используются для реализации таких структур данных, как стек, очередь, хеш-таблица и граф.
- **Стек и очередь**: когда операции вставки и удаления выполняются с од- ного конца списка, он ведет себя как стек (принцип «последний при- шел -- первый вышел»). Когда операция вставки выполняется с одного конца, а операция удаления -- с другого, он ведет себя как очередь (прин- цип «первый пришел -- первый вышел»).
- **Хеш-таблица**: метод цепочек является одним из основных способов ре- шения коллизий в хеш-таблицах, при котором все конфликтующие эле- менты помещаются в один список.
- **Граф**: списки смежности -- это распространенный способ представления графов, где каждая вершина графа связана со списком, элементы кото- рого представляют другие вершины, соединенные с данной.
> Двусвязные списки часто используются в ситуациях, где требуется быстрое нахождение предыдущего и следующего элемента.
- **Расширенные структуры данных**: например, в красно-черных дере- вьях и B-деревьях необходимо иметь доступ к родительскому узлу, что можно реализовать, сохраняя ссылку на родительский узел аналогично двусвязному списку.
- **История браузера**: в веб-браузере, когда пользователь нажимает кнопки **Вперед** или **Назад**, браузеру необходимо знать предыдущую и следую- щую страницы. Свойства двусвязного списка упрощают выполнение та- ких операций.
- **Алгоритм LRU**: в алгоритме вытеснения из кеша (LRU) необходимо бы- стро находить наименее используемые данные, а также поддерживать быстрое добавление и удаление узлов. В этом случае идеально подходит двусвязный список.
> Кольцевые списки часто применяются в ситуациях, требующих цикличе- ских операций, например в планировании ресурсов операционной системы.
- **Алгоритм циклического планирования**: в операционных системах ал- горитм циклического планирования -- это распространенный алгоритм планирования процессорного времени, который требует циклического об- хода группы процессов. Каждому процессу назначается временной квант, и по его истечении процессор переключается на следующий процесс. Такие циклические операции можно реализовать с помощью кольцевого списка.
- **Буфер данных**: в некоторых реализациях буферов данных также может использоваться кольцевой список. Например, в аудио- и видеоплеерах по- ток данных может разделяться на несколько буферных блоков и помещать- ся в кольцевой список для обеспечения непрерывного воспроизведения.
#### Списки
> *Список* -- это абстрактное понятие структуры данных, представляющее собой упорядоченное множество элементов, поддерживающее операции доступа, изменения, добавления, удаления и обхода элементов без необходимости учи- тывать ограничения по объему. Списки могут быть реализованы на основе связных списков или массивов.
- Связные списки естественным образом можно рассматривать как спи- ски, поддерживающие операции добавления, удаления, поиска и изме- нения элементов, а также динамическое расширение.
- Массивы также поддерживают операции добавления, удаления, поиска и изменения элементов, но из-за фиксированной длины их можно рас- сматривать только как списки с ограничением по длине.
> При использовании массива для реализации списка **неизменяемая длина приводит к снижению его практичности**. Это связано с тем, что зачастую невозможно заранее определить, сколько данных потребуется хранить, что за- трудняет выбор подходящей длины списка. Если длина слишком мала, это, веро- ятно, не удовлетворит потребности. Если длина слишком велика, это приведет к неэффективному использованию памяти. Для решения этой проблемы можно использовать динамический массив. Он сохраняет все преимущества массива и может динамически расширяться в процессе выполнения программы.
>
> На самом деле **списки**, **предоставляемые стандартными библиотеками многих языков программирования**, **реализованы на основе динамиче- ских массивов**, например list в Python, ArrayList в Java, vector в C++ и List в C#. В дальнейшем мы будем рассматривать список и динамический массив как эквивалентные понятия.
### Основные операции со списком
##### Инициализация списка
> Обычно используются два метода инициализации: без начальных значений и с заданием начальных значений.
>
> \# === File: list.py === \# Инициализация списка.
>
> \# Без начальных значений. nums1: list\[int\] = \[\]
>
> \# С начальными значениями.
>
> nums: list\[int\] = \[1, 3, 2, 5, 4\]
##### Доступ к элементам
> Список по своей сути является массивом, поэтому доступ и обновление эле- ментов возможны за время *O*(1), что очень эффективно.
>
> \# === File: list.py === \# Доступ к элементам.
>
> num: int = nums\[1\] \# Доступ к элементу по индексу 1. \# Изменение элемента.
>
> nums\[1\] = 0 \# Изменение значения элемента с индексом 1 на 0.
##### Вставка и удаление элементов
> В отличие от массива в списке можно свободно добавлять и удалять элемен- ты. Сложность добавления элемента в конец списка составляет *O*(1), но вставка и удаление элементов имеют ту же сложность *O*(*n*), что и в массиве.
>
> \# === File: list.py ===
>
> \# Очистка списка. nums.clear()
>
> \# Добавление элементов в конец. nums.append(1)
>
> nums.append(3) nums.append(2) nums.append(5) nums.append(4)
>
> \# Вставка элемента в середину.
>
> nums.insert(3, 6) \# Вставка числа 6 по индексу 3. \# Удаление элемента
>
> nums.pop(3) \# Удаление элемента по индексу 3.
##### Обход списка
> Как и массив, список можно обходить по индексу или напрямую по эле- ментам.
>
> \# === File: list.py ===
>
> \# Обход списка по индексу. count = 0
>
> for i in range(len(nums)): count += nums\[i\]
>
> \# Прямой обход элементов списка. for num in nums:
>
> count += num
##### Конкатенация списков
> Создав новый список nums1, его можно присоединить в конец исходного списка.
>
> \# === File: list.py ===
>
> \# Конкатенация двух списков.
>
> nums1: list\[int\] = \[6, 8, 7, 10, 9\]
>
> nums += nums1 \# Конкатенация списка nums1 с nums.
##### Сортировка списка
> После сортировки списка можно использовать такие алгоритмы, как двоич- ный поиск и два указателя, которые часто встречаются в задачах с массивами.
>
> \# === File: list.py === \# Сортировка списка.
>
> nums.sort() \# После сортировки элементы списка расположены по возрастанию.
### Реализация списка
> Многие языки программирования, такие как Java, C++, Python и др., име- ют встроенные списки. Их реализация довольно сложна, а параметры тща- тельно продуманы, например начальная емкость, коэффициент расшире- ния и т. д. Заинтересованные читатели могут изучить исходный код само- стоятельно. Чтобы углубить понимание принципов работы списка, попро- буем реализовать его упрощенную версию, включающую следующие три ключевых аспекта.
- **Начальная емкость**: выбор разумной начальной емкости массива. В нашем примере выбрано значение 10 в качестве начальной емкости.
- **Учет количества**: объявление переменной size для учета текущего ко- личества элементов в списке. Эта переменная обновляется при вставке и удалении элементов. На ее основе можно определить конец списка и необходимость расширения.
- **Механизм расширения**: если при вставке элемента емкость списка оказывается исчерпанной, необходимо расширение. Сначала созда- ется новый массив большего размера на основе коэффициента рас- ширения, затем все элементы текущего массива последовательно перемещаются в новый массив. В нашем примере массив каждый раз расширяется двукратно.
> \# === File: my_list.py ===
>
> class MyList:
>
> \"\"\" Класс списка.\"\"\" def init (self):
>
> \"\"\" Конструктор.\"\"\"
>
> self.\_capacity: int = 10 \# Емкость списка.
>
> self.\_arr: list\[int\] = \[0\] \* self.\_capacity \# Массив (хранение
>
> № элементов списка). self.\_size: int = 0 \# Длина списка (текущее количество элементов). self.\_extend_ratio: int = 2 \# Коэффициент расширения списка.
>
> def size(self) -\> int:
>
> \"\"\" Получение длины списка (текущего количества элементов).\"\"\" return self.\_size
>
> def capacity(self) -\> int:
>
> \"\"\" Получение емкости списка.\"\"\" return self.\_capacity
>
> def get(self, index: int) -\> int: \"\"\" Доступ к элементу.\"\"\"
>
> \# Если индекс выходит за границы, выбрасывается исключение, далее аналогично. if index \< 0 or index \>= self.\_size:
>
> raise IndexError(\" Индекс выходит за границы.\") return self.\_arr\[index\]
>
> def set(self, num: int, index: int): \"\"\" Обновление элемента.\"\"\"
>
> if index \< 0 or index \>= self.\_size:
>
> raise IndexError(\" Индекс выходит за границы.\") self.\_arr\[index\] = num
>
> def add(self, num: int):
>
> \"\"\" Добавление элемента в конец.\"\"\"
>
> \# При превышении количества элементов емкости срабатывает механизм расширения. if self.size() == self.capacity():
>
> self.extend_capacity() self.\_arr\[self.\_size\] = num self.\_size += 1
>
> def insert(self, num: int, index: int): \"\"\" Вставка элемента в середину.\"\"\" if index \< 0 or index \>= self.\_size:
>
> raise IndexError(\" Индекс выходит за границы.\")
>
> \# При превышении количества элементов емкости срабатывает механизм расширения. if self.\_size == self.capacity():
>
> self.extend_capacity()
>
> \# Все элементы начиная с индекса index смещаются на одну позицию вправо. for j in range(self.\_size - 1, index - 1, -1):
>
> self.\_arr\[j + 1\] = self.\_arr\[j\] self.\_arr\[index\] = num
>
> \# Обновление количества элементов. self.\_size += 1
>
> def remove(self, index: int) -\> int: \"\"\" Удаление элемента.\"\"\"
>
> if index \< 0 or index \>= self.\_size:
>
> raise IndexError(\" Индекс выходит за границы.\") num = self.\_arr\[index\]
>
> \# Перемещение всех элементов после индекса index на одну позицию вперед. for j in range(index, self.\_size - 1):
>
> self.\_arr\[j\] = self.\_arr\[j + 1\] \# Обновление количества элементов. self.\_size -= 1
>
> \# Возвращение удаленного элемента. return num
>
> def extend_capacity(self): \"\"\" Расширение списка.\"\"\"
>
> \# Создание нового массива длиной в \_extend_ratio раз больше исходного
>
> № и копирование в него исходного массива.
>
> self.\_arr = self.\_arr + \[0\] \* self.capacity() \* (self.\_extend_ratio - 1) \# Обновление емкости списка.
>
> self.\_capacity = len(self.\_arr)
>
> def to_array(self) -\> list\[int\]:
>
> \"\"\" Возвращение списка с фактической длиной.\"\"\" return self.\_arr\[: self.\_size\]
#### память и кеш\*
> В первых двух разделах этой главы были рассмотрены массивы и связные спи- ски -- две базовые и важные структуры данных, представляющие собой соот- ветственно непрерывное хранение и распределенное хранение.
>
> Фактически **физическая структура в значительной степени определяет эффективность использования памяти и кеша программой**, что, в свою очередь, влияет на общую производительность алгоритма.
### Устройства хранения в компьютере
> В компьютере существует три типа устройств хранения: жесткий диск (HDD/ SSD), оперативная память (RAM) и кеш-память (cache). В табл. 4.2 приведены их различные роли и характеристики.
>
> **Таблица 4.2.** Устройства хранения в компьютере
+-------------+----------------------------+--------------------------+----------------------------+
| | > **Жесткий диск** | > **Оперативная память** | > **Кеш** |
+=============+============================+==========================+============================+
| > Назначе- | > Долговременное хра- | > Временное хранение | > Хранение часто запраши- |
+-------------+----------------------------+--------------------------+----------------------------+
| > ние | > нение данных, включая | > запущенных программ | > ваемых данных и инструк- |
+-------------+----------------------------+--------------------------+----------------------------+
| | > операционную систему, | > и обрабатываемых | > ций, сокращение числа |
+-------------+----------------------------+--------------------------+----------------------------+
| | > программы, файлы и т. д. | > данных | > обращений к оперативной |
+-------------+----------------------------+--------------------------+----------------------------+
| | | | > памяти процессором |
+-------------+----------------------------+--------------------------+----------------------------+
| > Энергоза- | > Данные не теряются по- | > Данные теряются после | > Данные теряются после |
+-------------+----------------------------+--------------------------+----------------------------+
| > висимость | > сле отключения питания | > отключения питания | > отключения питания |
+-------------+----------------------------+--------------------------+----------------------------+
| > Емкость | > Большая, на уровне | > Меньше, на уровне | > Очень малая, на уровне |
+-------------+----------------------------+--------------------------+----------------------------+
| | > терабайтов | > гигабайтов | > мегабайтов |
+-------------+----------------------------+--------------------------+----------------------------+
| > Скорость | > Медленная, от несколь- | > Быстрая, десятки гига- | > Очень быстрая, от десят- |
+-------------+----------------------------+--------------------------+----------------------------+
| | > ких сотен до нескольких | > байт в секунду | > ков до сотен гигабайт |
+-------------+----------------------------+--------------------------+----------------------------+
| | > тысяч мегабайт в секунду | | > [в]{.smallcaps} секунду |
+-------------+----------------------------+--------------------------+----------------------------+
| > Цена | > Низкая, несколько цен- | > Высокая, несколько | > Очень высокая, цена |
+-------------+----------------------------+--------------------------+----------------------------+
| | > тов за гигабайт | > долларов за гигабайт | > включена в стоимость |
+-------------+----------------------------+--------------------------+----------------------------+
| | | | > процессора |
+-------------+----------------------------+--------------------------+----------------------------+
> Компьютерную систему хранения можно представить в виде пирамидаль- ной структуры, как показано на рис. 4.9. Чем ближе к вершине пирамиды на- ходится устройство хранения, тем выше его скорость, меньше емкость и выше стоимость. Такой многоуровневый дизайн не случаен, а является результатом тщательных размышлений компьютерных ученых и инженеров.
![](ru/docs/assets/media/image139.jpeg)
> **Рис. 4.9.** Система хранения данных в компьютере
- **Жесткий диск трудно заменить оперативной памятью**. Во-первых, данные в оперативной памяти теряются после отключения питания, по- этому она не подходит для долговременного хранения. Во-вторых, сто- имость оперативной памяти в десятки раз выше, чем у жесткого диска, что затрудняет ее распространение на потребительском рынке.
- **Большая емкость и высокая скорость кеша трудно совместимы**. С увеличением емкости кеша уровней L1, L2, L3 его физические разме- ры увеличиваются. Вместе с этим растет физическое расстояние до ядра процессора, что приводит к увеличению времени передачи данных и за- держке доступа к элементам. В текущих условиях многоуровневая струк- тура кеша является оптимальным балансом между емкостью, скоростью и стоимостью.
> В целом **жесткий диск используется для долговременного хранения большого объема данных**, **оперативная память** -- **для временного хране- ния данных**, **обрабатываемых во время выполнения программы**, **а кеш -- для хранения часто запрашиваемых данных и инструкций**, чтобы повы- сить эффективность выполнения программы. Все три компонента работают совместно, обеспечивая эффективную работу компьютерной системы.
>
> Во время выполнения программы данные считываются с жесткого диска в оперативную память для обработки процессором, как показано на рис. 4.10. Кеш-память можно рассматривать как часть процессора, в которую по слож- ным алгоритмам загружаются данные из оперативной памяти. Это обеспечи- вает высокоскоростное чтение данных процессором, значительно повышает эффективность выполнения программы и снижает зависимость от более мед- ленной оперативной памяти.
![](ru/docs/assets/media/image141.jpeg)
> **Рис. 4.10.** Поток данных между жестким диском, оперативной памятью и кешем
### Эффективность использования памяти структурами данных
> С точки зрения использования памяти массивы и связные списки имеют свои преимущества и ограничения.
>
> С одной стороны, **память ограничена**, **и одну и ту же область памяти нельзя разделить между несколькими программами**, поэтому желательно,
>
> чтобы структуры данных максимально эффективно использовали простран- ство. Элементы массива расположены плотно, также не требуется дополни- тельное пространство для хранения ссылок (указателей) между узлами, что делает его более эффективным с точки зрения использования памяти. Одна- ко массивы требуют выделения сразу достаточного количества непрерывного пространства памяти, что может привести к ее растрате, а расширение мас- сива также требует дополнительных временных и пространственных затрат. В отличие от этого списки осуществляют динамическое распределение и осво- бождение памяти на уровне узлов, что обеспечивает большую гибкость.
>
> С другой стороны, во время выполнения программы по мере многократного выполнения запросов и освобождения памяти степень фрагментации свобод- ной памяти будет увеличиваться, что снижает эффективность ее использова- ния. Благодаря непрерывному способу хранения массивы относительно менее подвержены фрагментации памяти. Элементы списка, напротив, хранятся разрозненно, и при частых операциях вставки и удаления они более подвер- жены фрагментации памяти.
### Эффективность кеширования структур данных
> Хотя объем кеша значительно меньше объема памяти, он намного быстрее и играет решающую роль в скорости выполнения программы. Объем кеша ограничен, и он может хранить лишь небольшую часть часто запрашиваемых данных. Поэтому при попытке процессора получить доступ к данным, отсут- ствующим в кеше, происходит промах кеша, и процессор вынужден загружать необходимые данные из более медленной памяти.
>
> Очевидно, что **чем меньше промахов кеша, тем выше эффективность чтения и записи данных процессором** и тем лучше производительность программы. Доля данных, успешно полученных процессором из кеша, называ- ется коэффициентом попадания в кеш. Этот показатель обычно используется для оценки эффективности кеша.
>
> Для достижения максимальной эффективности кеш использует следующие механизмы загрузки данных.
- **Кеш-линия**: кеш не хранит и не загружает данные по байтам, а исполь- зует в качестве единицы кеш-линии. По сравнению с передачей по бай- там передача кеш-линий более эффективна.
- **Механизм предвыборки**: процессор пытается предсказать шаблоны доступа к данным (например, последовательный доступ, доступ с фик- сированным шагом и т. д.) и загружает данные в кеш в соответствии с этими шаблонами, чтобы повысить коэффициент попадания.
- **Пространственная локальность**: если к данным был осуществлен доступ, то, вероятно, в ближайшее время будет осуществлен доступ и к данным, на- ходящимся поблизости. Поэтому при загрузке данных кеш также загружает данные, находящиеся рядом, чтобы повысить коэффициент попадания.
- **Временная локальность**: если к данным был осуществлен доступ, то в ближайшем будущем, вероятно, к ним будет осуществлен повторный доступ. Кеш использует этот принцип, сохраняя недавно запрашивае- мые данные, чтобы повысить коэффициент попадания.
> Фактически **эффективность использования кеша массивами и списка- ми различается**, что проявляется в следующих аспектах.
- **Занимаемое пространство**: элементы списка занимают больше про- странства, чем элементы массива, что приводит к уменьшению объема полезных данных, которые могут быть помещены в кеш.
- **Кеш-линии**: данные списка распределены по всей памяти, а кеш за- гружает данные по линиям, поэтому доля загружаемых неэффективных данных выше.
- **Механизм предвыборки**: шаблоны доступа к данным массива более предсказуемы, чем у списка, т. е. системе легче угадать, какие данные могут быть загружены.
- **Пространственная локальность**: массивы хранятся в сконцентриро- ванном пространстве памяти, поэтому данные, находящиеся рядом с за- груженными, с большей вероятностью будут запрошены.
- В целом **массивы обладают более высоким коэффициентом попа- дания в кеш**, **поэтому они обычно превосходят списки по эффек- тивности операций**. Это делает структуры данных, реализованные на основе массивов, более предпочтительными при решении алгоритми- ческих задач.
> Следует отметить, **что высокая эффективность использования кеша не означает, что массивы всегда предпочтительнее списков**. Выбор струк- туры данных в реальных приложениях должен основываться на конкретных требованиях. Например, структуру данных «стек» можно реализовать на осно- ве и массивов, и списков (детали этого будут рассмотрены в следующей главе), но они предназначены для различных сценариев.
- При решении алгоритмических задач предпочтение отдается стеку, реализованному на основе массива, поскольку он обеспечивает более высокую эффективность операций и возможность случайного доступа. Но платой за это является необходимость заранее выделить определен- ное количество памяти для массива.
- Если объем данных очень велик, динамичность высока, и размер стека трудно предсказать, то более предпочтителен стек, реализованный на основе списка. Список позволяет распределенно хранить большое коли- чество данных в различных частях памяти и избегать дополнительных затрат на расширение массива.
#### резюме
##### Ключевые моменты
- Массивы и списки -- это две основные структуры данных, представляю- щие два способа хранения данных в памяти компьютера: хранение в не- прерывном пространстве и хранение в распределенном пространстве. Их характеристики дополняют друг друга.
- Массивы поддерживают случайный доступ и занимают меньше памяти. Однако эффективность вставки и удаления элементов низкая, а длина после инициализации фиксированная.
<!-- -->
- Списки обеспечивают эффективную вставку и удаление узлов путем из- менения ссылок (указателей) и могут гибко изменять свою длину. Одна- ко доступ к узлам менее эффективен, и они занимают больше памяти. К распространенным типам списков относятся односвязные, кольцевые и двусвязные списки.
- Список -- это упорядоченная коллекция элементов, поддерживающая операции добавления, удаления, поиска и изменения и обычно реали- зуемая на основе динамического массива. Он сохраняет преимущества массива, одновременно позволяя гибко изменять длину.
- Появление списка значительно повысило практическую ценность мас- сива, но может привести к частичной потере памяти.
- Во время выполнения программы данные в основном хранятся в памя- ти. Массивы могут обеспечить более высокую эффективность использо- вания памяти, тогда как списки более гибки в ее использовании.
- Кеш, используя кеш-линии, механизм предвыборки, а также механизмы пространственной и временной локальности данных, обеспечивает для процессора быстрый доступ к данным, значительно повышая эффектив- ность выполнения программы.
- Поскольку массивы обладают более высокой вероятностью попадания в кеш, они обычно более эффективны, чем списки. При выборе струк- туры данных следует принимать во внимание конкретные требования и сценарии.
##### Вопросы и ответы
> **Вопрос**. Влияет ли хранение массива в стеке или в куче на временную и про- странственную эффективность?
>
> **Ответ**. Массивы, хранящиеся в стеке и в куче, размещаются в непрерывном пространстве памяти, и эффективность операций с данными в основном оди- накова. Однако стек и куча имеют свои особенности, что приводит к следую- щим различиям:
1) эффективность выделения и освобождения: стек -- это небольшая об- ласть памяти, выделение которой выполняется автоматически компи- лятором, в то время как память кучи относительно больше и может вы- деляться динамически в коде, что делает ее более подверженной фраг- ментации. Поэтому операции выделения и освобождения в куче обычно медленнее, чем в стеке;
2) ограничение размера: память стека относительно мала, размер кучи обычно ограничен доступной памятью. Поэтому куча более подходит для хранения больших массивов;
3) гибкость: размер массива в стеке должен быть определен на этапе ком- пиляции, тогда как размер массива в куче может быть динамически определен во время выполнения.
> **Вопрос**. Почему для массива требуется, чтобы элементы были одного типа, а для списка это не обязательно?
>
> **Ответ**. Список состоит из узлов, которые соединяются между собой с помо- щью ссылок (указателей), и каждый узел может хранить данные различных типов, например int, double, string, object и т. д.
>
> Элементы массива, напротив, должны быть одного типа, чтобы можно было вычислить смещение для получения позиции соответствующего элемента. На- пример, если массив одновременно содержит элементы типов int и long, которые занимают 4 и 8 байт соответственно, то нельзя использовать следующую форму- лу для вычисления смещения, поскольку массив содержит две длины элемента.
>
> \# Адрес памяти элемента = Адрес памяти массива (адрес памяти первого элемента)
>
> \+ Длина элемента \* Индекс элемента
>
> **Вопрос**. После удаления узла P нужно ли устанавливать P.next в значение None? **Ответ**. Не обязательно изменять P.next. С точки зрения данного связного списка при обходе от головного узла до хвостового узла узел P больше не будет встречен. Это означает, что узел P уже удален из списка, и в этом случае, куда
>
> бы ни указывал узел P, это не повлияет на данный список.
>
> С точки зрения структуры данных и алгоритмов (решение задач) разрыв связи не имеет значения, главное -- чтобы логика программы была правиль- ной. С точки зрения стандартной библиотеки разрыв связи более безопасен и логически ясен. Если связь не разрывается и память удаленного узла не будет корректно освобождена, это может повлиять на освобождение памяти после- дующих узлов.
>
> **Вопрос**. Временная сложность операций вставки и удаления в связном спи- ске составляет *O*(1). Однако для поиска элемента перед добавлением или уда- лением требуется время *O*(*n*). Почему же временная сложность не *O*(*n*)?
>
> Если сначала производится поиск элемента, а затем его удаление, времен- ная сложность действительно составляет *O*(*n*). Однако преимущество *O*(1) для операций добавления и удаления в связном списке проявляется в других ситуациях. Например, двусторонняя очередь подходит для реализации с по- мощью связного списка, когда поддерживаются указатели, всегда указыва- ющие на головной и хвостовой узлы, и каждая операция вставки и удаления выполняется за *O*(1).
>
> **Вопрос**. На рис. 4.5 «Определение и способ хранения связного списка» голу- бые указатели на узлы занимают один блок памяти или они делят его со зна- чениями узлов?
>
> **Ответ**. Данная схема является качественным представлением, количествен- ное представление требует анализа в зависимости от конкретной ситуации.
- Различные типы значений узлов занимают разный объем, например int, long, double и экземпляры объектов.
- Размер области памяти, занимаемой переменной-указателем, зависит от используемой операционной системы и среды компиляции и обычно составляет 8 или 4 байта.
> **Вопрос**. Всегда ли добавление элемента в конец списка имеет временную сложность *O*(1)?
>
> **Ответ**. Если при добавлении элемента превышается длина списка, то сна- чала необходимо расширить список, а затем добавить элемент. Система за- просит новый блок памяти и перенесет в него все элементы исходного списка, в этом случае временная сложность будет *O*(*n*).
>
> **Вопрос**. Фраза «Появление списка значительно повысило практическую ценность массива, но может привести к частичной потере памяти» означает, что потеря памяти связана с дополнительными переменными, такими как ем- кость, длина и коэффициент расширения?
>
> **Ответ**. Потеря памяти здесь имеет два значения. Во-первых, у списка уста- навливается начальная длина, но нам не всегда требуется столько места. Во- вторых, чтобы избежать частого расширения, обычно используется опреде- ленный коэффициент, например ×1.5. Это приводит к появлению множества пустых мест, которые обычно не удается полностью заполнить.
>
> **Вопрос**. После инициализации массива в Python n = \[1, 2, 3\] адреса этих трех элементов связаны, но при инициализации m = \[2, 1, 3\] обнаруживается, что идентификатор каждого элемента не является последовательным, а совпадает с таковым в n. Если адреса элементов не последовательны, является ли m массивом? **Ответ**. Если заменить элементы списка на узлы связного списка n = \[n1, n2, n3, n4, n5\], то в большинстве случаев эти 5 объектов узлов также будут хра- ниться в разных местах памяти. Однако, имея индекс списка, можно за время *O*(1) получить адрес памяти узла и получить доступ к соответствующему узлу. Это происходит потому, что в массиве хранятся ссылки на узлы, а не сами узлы. В отличие от многих других языков в Python числа также упакованы в объ- екты, и в списке хранятся не сами числа, а ссылки на них. Поэтому можно об- наружить, что одинаковые числа в двух массивах имеют один и тот же иден-
>
> тификатор, а адреса памяти этих чисел не обязаны быть последовательными. **Вопрос**. В C++ в библиотеке STL std::list уже реализован двусторонний связный список, но в некоторых книгах по алгоритмам его не используют на-
>
> прямую. Есть ли у него какие-то ограничения?
>
> **Ответ**. С одной стороны, мы часто предпочитаем использовать массивы для реализации алгоритмов и прибегаем к связным спискам только при необходи- мости. Основные причины этого следующие.
- пространственные затраты: так как каждый элемент требует двух до- полнительных указателей (одного для предыдущего элемента и одного для следующего элемента), std::list обычно занимает больше места, чем std::vector;
- неоптимально для кеширования: поскольку данные не хранятся не- прерывно, std::list имеет низкую эффективность использования кеша. В общем случае производительность std::vector будет лучше.
> С другой стороны, необходимость использования связных списков в основ- ном возникает в случае двоичных деревьев и графов. Стек и очередь часто ис- пользуют предоставляемые языком программирования типы stack и queue, а не связные списки.
>
> **Вопрос**. Операция res = \[\[0\]\] \* n создает двумерный список, в котором каж- дый элемент \[0\] является независимым?
>
> **Ответ**. Нет, они не являются независимыми. В этом двумерном списке все элементы \[0\] фактически являются ссылками на один и тот же объект. Если из- менить один из элементов, все соответствующие элементы также изменятся.
>
> Если требуется, чтобы каждый \[0\] в двумерном списке был независимым, можно использовать инструкцию res = \[\[0\] for \_ in range(n)\]. Этот способ ос- нован на инициализации *n* независимых объектов списка \[0\].
>
> **Вопрос**. Операция res = \[0\] \* n создает список, в котором все целые 0 явля- ются независимыми?
>
> **Ответ**. В этом списке все целые 0 являются ссылками на один и тот же объ- ект. Это связано с тем, что в Python используется механизм пула для малых целых чисел (обычно от --5 до 256), чтобы максимизировать повторное исполь- зование объектов и повысить производительность.
>
> Хотя они ссылаются на один и тот же объект, мы все же можем независимо изменять каждый элемент списка, поскольку целые числа в Python являются неизменяемыми объектами. При изменении какого-либо элемента фактиче- ски происходит переключение на ссылку на другой объект, а не изменение са- мого исходного объекта.
>
> Однако, когда элементы списка являются изменяемыми объектами (напри- мер, списки, словари или экземпляры классов и т. д.), изменение какого-либо элемента напрямую изменяет и сам объект, и все элементы, ссылающиеся на этот объект, претерпевают те же изменения.
>
> Глава 5