# Массивы и списки ![](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