82 KiB
Массивы и списки
{width="3.71875in" height="4.8125in"}
массивы
Массив представляет собой линейную структуру данных, в которой элементы одного типа хранятся в непрерывной области памяти. Положение элемента в массиве называется его индексом. На рис. 4.1. изображены основные поня- тия и способ хранения массивов.
Элемент
Память для хранения массива
Массив
Индекс
Адрес памяти
Доступная память
Память, выделенная для массива
Рис. 4.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
После удаления элемент 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. Поток данных между жестким диском, оперативной памятью и кешем
Эффективность использования памяти структурами данных
С точки зрения использования памяти массивы и связные списки имеют свои преимущества и ограничения.
С одной стороны, память ограничена, и одну и ту же область памяти нельзя разделить между несколькими программами, поэтому желательно,
чтобы структуры данных максимально эффективно использовали простран- ство. Элементы массива расположены плотно, также не требуется дополни- тельное пространство для хранения ссылок (указателей) между узлами, что делает его более эффективным с точки зрения использования памяти. Одна- ко массивы требуют выделения сразу достаточного количества непрерывного пространства памяти, что может привести к ее растрате, а расширение мас- сива также требует дополнительных временных и пространственных затрат. В отличие от этого списки осуществляют динамическое распределение и осво- бождение памяти на уровне узлов, что обеспечивает большую гибкость.
С другой стороны, во время выполнения программы по мере многократного выполнения запросов и освобождения памяти степень фрагментации свобод- ной памяти будет увеличиваться, что снижает эффективность ее использова- ния. Благодаря непрерывному способу хранения массивы относительно менее подвержены фрагментации памяти. Элементы списка, напротив, хранятся разрозненно, и при частых операциях вставки и удаления они более подвер- жены фрагментации памяти.
Эффективность кеширования структур данных
Хотя объем кеша значительно меньше объема памяти, он намного быстрее и играет решающую роль в скорости выполнения программы. Объем кеша ограничен, и он может хранить лишь небольшую часть часто запрашиваемых данных. Поэтому при попытке процессора получить доступ к данным, отсут- ствующим в кеше, происходит промах кеша, и процессор вынужден загружать необходимые данные из более медленной памяти.
Очевидно, что чем меньше промахов кеша, тем выше эффективность чтения и записи данных процессором и тем лучше производительность программы. Доля данных, успешно полученных процессором из кеша, называ- ется коэффициентом попадания в кеш. Этот показатель обычно используется для оценки эффективности кеша.
Для достижения максимальной эффективности кеш использует следующие механизмы загрузки данных.
-
Кеш-линия: кеш не хранит и не загружает данные по байтам, а исполь- зует в качестве единицы кеш-линии. По сравнению с передачей по бай- там передача кеш-линий более эффективна.
-
Механизм предвыборки: процессор пытается предсказать шаблоны доступа к данным (например, последовательный доступ, доступ с фик- сированным шагом и т. д.) и загружает данные в кеш в соответствии с этими шаблонами, чтобы повысить коэффициент попадания.
-
Пространственная локальность: если к данным был осуществлен доступ, то, вероятно, в ближайшее время будет осуществлен доступ и к данным, на- ходящимся поблизости. Поэтому при загрузке данных кеш также загружает данные, находящиеся рядом, чтобы повысить коэффициент попадания.
-
Временная локальность: если к данным был осуществлен доступ, то в ближайшем будущем, вероятно, к ним будет осуществлен повторный доступ. Кеш использует этот принцип, сохраняя недавно запрашивае- мые данные, чтобы повысить коэффициент попадания.
Фактически эффективность использования кеша массивами и списка- ми различается, что проявляется в следующих аспектах.
-
Занимаемое пространство: элементы списка занимают больше про- странства, чем элементы массива, что приводит к уменьшению объема полезных данных, которые могут быть помещены в кеш.
-
Кеш-линии: данные списка распределены по всей памяти, а кеш за- гружает данные по линиям, поэтому доля загружаемых неэффективных данных выше.
-
Механизм предвыборки: шаблоны доступа к данным массива более предсказуемы, чем у списка, т. е. системе легче угадать, какие данные могут быть загружены.
-
Пространственная локальность: массивы хранятся в сконцентриро- ванном пространстве памяти, поэтому данные, находящиеся рядом с за- груженными, с большей вероятностью будут запрошены.
-
В целом массивы обладают более высоким коэффициентом попа- дания в кеш, поэтому они обычно превосходят списки по эффек- тивности операций. Это делает структуры данных, реализованные на основе массивов, более предпочтительными при решении алгоритми- ческих задач.
Следует отметить, что высокая эффективность использования кеша не означает, что массивы всегда предпочтительнее списков. Выбор струк- туры данных в реальных приложениях должен основываться на конкретных требованиях. Например, структуру данных «стек» можно реализовать на осно- ве и массивов, и списков (детали этого будут рассмотрены в следующей главе), но они предназначены для различных сценариев.
-
При решении алгоритмических задач предпочтение отдается стеку, реализованному на основе массива, поскольку он обеспечивает более высокую эффективность операций и возможность случайного доступа. Но платой за это является необходимость заранее выделить определен- ное количество памяти для массива.
-
Если объем данных очень велик, динамичность высока, и размер стека трудно предсказать, то более предпочтителен стек, реализованный на основе списка. Список позволяет распределенно хранить большое коли- чество данных в различных частях памяти и избегать дополнительных затрат на расширение массива.
резюме
Ключевые моменты
-
Массивы и списки -- это две основные структуры данных, представляю- щие два способа хранения данных в памяти компьютера: хранение в не- прерывном пространстве и хранение в распределенном пространстве. Их характеристики дополняют друг друга.
-
Массивы поддерживают случайный доступ и занимают меньше памяти. Однако эффективность вставки и удаления элементов низкая, а длина после инициализации фиксированная.
-
Списки обеспечивают эффективную вставку и удаление узлов путем из- менения ссылок (указателей) и могут гибко изменять свою длину. Одна- ко доступ к узлам менее эффективен, и они занимают больше памяти. К распространенным типам списков относятся односвязные, кольцевые и двусвязные списки.
-
Список -- это упорядоченная коллекция элементов, поддерживающая операции добавления, удаления, поиска и изменения и обычно реали- зуемая на основе динамического массива. Он сохраняет преимущества массива, одновременно позволяя гибко изменять длину.
-
Появление списка значительно повысило практическую ценность мас- сива, но может привести к частичной потере памяти.
-
Во время выполнения программы данные в основном хранятся в памя- ти. Массивы могут обеспечить более высокую эффективность использо- вания памяти, тогда как списки более гибки в ее использовании.
-
Кеш, используя кеш-линии, механизм предвыборки, а также механизмы пространственной и временной локальности данных, обеспечивает для процессора быстрый доступ к данным, значительно повышая эффектив- ность выполнения программы.
-
Поскольку массивы обладают более высокой вероятностью попадания в кеш, они обычно более эффективны, чем списки. При выборе струк- туры данных следует принимать во внимание конкретные требования и сценарии.
Вопросы и ответы
Вопрос. Влияет ли хранение массива в стеке или в куче на временную и про- странственную эффективность?
Ответ. Массивы, хранящиеся в стеке и в куче, размещаются в непрерывном пространстве памяти, и эффективность операций с данными в основном оди- накова. Однако стек и куча имеют свои особенности, что приводит к следую- щим различиям:
-
эффективность выделения и освобождения: стек -- это небольшая об- ласть памяти, выделение которой выполняется автоматически компи- лятором, в то время как память кучи относительно больше и может вы- деляться динамически в коде, что делает ее более подверженной фраг- ментации. Поэтому операции выделения и освобождения в куче обычно медленнее, чем в стеке;
-
ограничение размера: память стека относительно мала, размер кучи обычно ограничен доступной памятью. Поэтому куча более подходит для хранения больших массивов;
-
гибкость: размер массива в стеке должен быть определен на этапе ком- пиляции, тогда как размер массива в куче может быть динамически определен во время выполнения.
Вопрос. Почему для массива требуется, чтобы элементы были одного типа, а для списка это не обязательно?
Ответ. Список состоит из узлов, которые соединяются между собой с помо- щью ссылок (указателей), и каждый узел может хранить данные различных типов, например 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









