mirror of
https://github.com/krahets/hello-algo.git
synced 2026-05-05 03:24:21 +08:00
776 lines
82 KiB
Markdown
776 lines
82 KiB
Markdown
# Массивы и списки
|
||
|
||
{width="3.71875in" height="4.8125in"}
|
||
|
||
#### массивы
|
||
|
||
> *Массив* представляет собой линейную структуру данных, в которой элементы одного типа хранятся в непрерывной области памяти. Положение элемента в массиве называется его индексом. На рис. 4.1. изображены основные поня- тия и способ хранения массивов.
|
||
|
||
**Элемент**
|
||
|
||
> Память для хранения **массива**
|
||
>
|
||
> является **непрерывной**
|
||
>
|
||
> **Массив**
|
||
>
|
||
> Индекс
|
||
>
|
||
> **Адрес памяти**
|
||
>
|
||
> Доступная память
|
||
>
|
||
> Память, выделенная для массива
|
||
>
|
||
> **Рис. 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.
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 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.
|
||
>
|
||
> Следует отметить, что длина массива фиксирована, поэтому вставка эле- мента неизбежно приведет к потере элемента в конце массива. Решение этой проблемы будет рассмотрено в разделе «Списки».
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 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\]
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 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-кода символа в качестве индекса, а соот- ветствующий элемент хранить в соответствующем месте массива.
|
||
|
||
- **Машинное обучение**: в нейронных сетях широко используются опе- рации линейной алгебры между векторами, матрицами и тензорами, которые реализуются в виде массивов. Массивы являются наиболее часто используемой структурой данных в программировании ней- ронных сетей.
|
||
|
||
- **Реализация структур данных**: массивы могут использоваться для ре- ализации стека, очереди, хеш-таблицы, кучи, графа и других структур данных. Например, представление графа в виде матрицы смежности фактически является двумерным массивом.
|
||
|
||
#### Связные списки
|
||
|
||
> Память -- это общий ресурс всех программ, и в сложной системной среде вы- полнения участки свободной памяти могут быть разбросаны по всему про- странству памяти. Нам уже известно, что память для хранения массива должна быть непрерывной, и когда массив очень велик, в памяти может не оказаться столь большого непрерывного участка. В этом случае проявляется преимуще- ство гибкости связного списка.
|
||
>
|
||
> *Связный список* -- это линейная структура данных, в которой каждый элемент является объектом-узлом. При этом узлы соединяются друг с другом с помо- щью ссылок. В ссылке хранится адрес памяти следующего узла, по которому можно перейти от текущего узла к следующему.
|
||
>
|
||
> Структура связного списка позволяет узлам храниться в различных местах памяти, а их адреса памяти не обязаны быть последовательными.
|
||
|
||

|
||
|
||
> **Рис. 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*), что менее эффективно при больших объемах данных.
|
||
>
|
||
> 
|
||
>
|
||
> **Рис. 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**
|
||
>
|
||
> указывает на **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 \# Ссылка на предыдущий узел.
|
||
|
||

|
||
|
||
> **Рис. 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. Чем ближе к вершине пирамиды на- ходится устройство хранения, тем выше его скорость, меньше емкость и выше стоимость. Такой многоуровневый дизайн не случаен, а является результатом тщательных размышлений компьютерных ученых и инженеров.
|
||
|
||

|
||
|
||
> **Рис. 4.9.** Система хранения данных в компьютере
|
||
|
||
- **Жесткий диск трудно заменить оперативной памятью**. Во-первых, данные в оперативной памяти теряются после отключения питания, по- этому она не подходит для долговременного хранения. Во-вторых, сто- имость оперативной памяти в десятки раз выше, чем у жесткого диска, что затрудняет ее распространение на потребительском рынке.
|
||
|
||
- **Большая емкость и высокая скорость кеша трудно совместимы**. С увеличением емкости кеша уровней L1, L2, L3 его физические разме- ры увеличиваются. Вместе с этим растет физическое расстояние до ядра процессора, что приводит к увеличению времени передачи данных и за- держке доступа к элементам. В текущих условиях многоуровневая струк- тура кеша является оптимальным балансом между емкостью, скоростью и стоимостью.
|
||
|
||
> В целом **жесткий диск используется для долговременного хранения большого объема данных**, **оперативная память** -- **для временного хране- ния данных**, **обрабатываемых во время выполнения программы**, **а кеш -- для хранения часто запрашиваемых данных и инструкций**, чтобы повы- сить эффективность выполнения программы. Все три компонента работают совместно, обеспечивая эффективную работу компьютерной системы.
|
||
>
|
||
> Во время выполнения программы данные считываются с жесткого диска в оперативную память для обработки процессором, как показано на рис. 4.10. Кеш-память можно рассматривать как часть процессора, в которую по слож- ным алгоритмам загружаются данные из оперативной памяти. Это обеспечи- вает высокоскоростное чтение данных процессором, значительно повышает эффективность выполнения программы и снижает зависимость от более мед- ленной оперативной памяти.
|
||
|
||

|
||
|
||
> **Рис. 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
|