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

82 KiB
Raw Blame History

Массивы и списки

{width="3.71875in" height="4.8125in"}

массивы

Массив представляет собой линейную структуру данных, в которой элементы одного типа хранятся в непрерывной области памяти. Положение элемента в массиве называется его индексом. На рис. 4.1. изображены основные поня- тия и способ хранения массивов.

Элемент

Память для хранения массива

является непрерывной

Массив

Индекс

Адрес памяти

Доступная память

Память, выделенная для массива

Рис. 4.1. Определение и способ хранения массива

  1. Основные операции с массивом
Инициализация массива

Существует два способа инициализации массива: без начальных значений и с за- данными начальными значениями. Если начальные значения не указаны, боль- шинство языков программирования инициализируют элементы массива нулями.

# === File: array.py === # Инициализация массива.

arr: list[int] = [0] * 5 # [ 0, 0, 0, 0, 0 ]

nums: list[int] = [1, 3, 2, 5, 4]

Доступ к элементам массива

Элементы массива хранятся в непрерывной области памяти, что упрощает вы- числение их адресов. Зная адрес массива (адрес первого элемента) и индекс элемента, можно вычислить адрес этого элемента по формуле, показанной на рис. 4.2, и получить к нему доступ.

Как видно из рис. 4.2, индекс первого элемента массива равен 0, что может показаться неочевидным, так как отсчет с 1 кажется более естественным. Од- нако с точки зрения формулы вычисления адреса индекс является смеще- нием адреса в памяти. Смещение адреса первого элемента равно 0, поэтому и его индекс равен 0.

Рис. 4.2. Вычисление адреса элемента массива

Доступ к элементам массива осуществляется очень эффективно, так как по- зволяет за время O(1) произвольно обращаться к любому элементу.

# === File: array.py ===

def random_access(nums: list[int]) -> int: """ Случайный доступ к элементу."""

# Случайно выбирается число из диапазона [0, len(nums)-1]. random_index = random.randint(0, len(nums) - 1)

# Получение и возврат случайного элемента. random_num = nums[random_index]

return random_num

Вставка элемента

Элементы массива в памяти расположены вплотную, между ними нет места для хранения других данных. Для вставки элемента в середину массива не- обходимо сдвинуть все последующие элементы на одну позицию вправо, а затем присвоить значение элементу по заданному индексу, как показано на рис. 4.3.

Следует отметить, что длина массива фиксирована, поэтому вставка эле- мента неизбежно приведет к потере элемента в конце массива. Решение этой проблемы будет рассмотрено в разделе «Списки».

Рис. 4.3. Пример вставки элемента в массив

# === File: array.py ===

def insert(nums: list[int], num: int, index: int):

""" Вставка элемента num в массив по индексу index."""

# Сдвиг всех элементов, начиная с индекса index, на одну позицию вправо. for i in range(len(nums) - 1, index, -1):

nums[i] = nums[i - 1]

# Присвоение num элементу по индексу index. nums[index] = num

Удаление элемента

Аналогично для удаления элемента по индексу i необходимо сдвинуть все по- следующие элементы на одну позицию влево, как показано на рис. 4.4.

Обратите внимание, что после удаления элемента последний элемент ста- новится бессмысленным, поэтому его можно не изменять.

# === File: array.py ===

def remove(nums: list[int], index: int):

""" Удаление элемента по индексу index."""

# Сдвиг всех элементов, начиная с индекса index, на одну позицию влево. for i in range(index, len(nums) - 1):

nums[i] = nums[i + 1]

Рис. 4.4. Пример удаления элемента из массива

В целом операции вставки и удаления в массиве имеют следующие недо- статки.

  • Высокая временная сложность: средняя временная сложность опера- ций вставки и удаления в массиве составляет O(n), где n -- длина массива.

  • Потеря элементов: так как длина массива фиксирована, при вставке элемента элементы, выходящие за пределы длины массива, теряются.

  • Расточительность памяти: можно инициализировать длинный массив и использовать только его часть, но это приведет к потере памяти, так как неиспользуемые элементы в конце массива будут бессмысленными.

Обход массива

В большинстве языков программирования массив можно обходить как по ин- дексам, так и непосредственно по элементам.

# === File: array.py ===

def traverse(nums: list[int]): """ Обход массива.""" count = 0

# Обход массива по индексам. for i in range(len(nums)):

count += nums[i]

# Прямой обход элементов массива. for num in nums:

count += num

# Одновременный обход индексов и элементов.

for i, num in enumerate(nums): count += nums[i]

count += num

Поиск элемента

Для поиска заданного элемента в массиве необходимо обойти массив и на каждой итерации проверить, совпадает ли значение элемента с искомым. Если совпадает, вывести соответствующий индекс. Поскольку массив яв- ляется линейной структурой данных, этот процесс называется линейным поиском.

# === File: array.py ===

def find(nums: list[int], target: int) -> int: """ Поиск заданного элемента в массиве.""" for i in range(len(nums)):

if nums[i] == target: return i

return -1

Расширение массива

В сложных системных средах нельзя гарантировать, что ячейки памяти, расположенные после массива, являются свободными. Это делает невоз- можным безопасное расширение его размера. Поэтому в большинстве язы- ков программирования длина массива фиксирована. Если необходимо уве- личить массив, нужно создать новый, больший массив и последовательно скопировать в него элементы исходного массива. Эта операция имеет слож- ность O(n) и при больших массивах занимает много времени. Пример кода представлен ниже.

# === File: array.py ===

def extend(nums: list[int], enlarge: int) -> list[int]: """ Увеличение длины массива."""

# Инициализация массива с увеличенной длиной. res = [0] * (len(nums) + enlarge)

# Копирование всех элементов исходного массива в новый массив. for i in range(len(nums)):

res[i] = nums[i]

# Возврат нового массива с увеличенной длиной. return res

Преимущества и ограничения массивов

Массивы хранятся в непрерывном пространстве памяти, а его элементы име- ют одинаковый тип. Этот подход содержит богатую априорную информацию, которую система может использовать для оптимизации эффективности опе- раций с данной структурой данных.

  • Высокая эффективность использования пространства: массивы вы- деляют непрерывные блоки памяти для данных без дополнительных структурных затрат.

  • Поддержка произвольного доступа: массивы позволяют получить до- ступ к любому элементу за время O(1).

  • Локальность кеширования: при доступе к элементам массива компью- тер загружает не только его, но и кеширует окружающие данные, что по- зволяет ускорить выполнение последующих операций за счет использо- вания высокоскоростного кеша.

Непрерывное хранение в пространстве -- это палка о двух концах, имеющая следующие ограничения.

  • Низкая эффективность вставки и удаления: при большом количестве элементов в массиве операции вставки и удаления требуют перемеще- ния множества элементов.

  • Неизменная длина: после инициализации длина массива фиксируется, а увеличение массива требует копирования всех данных в новый массив, что влечет за собой значительные затраты.

  • Расточительность пространства: если размер выделенного массива превышает фактические потребности, избыточное пространство оказы- вается потраченным впустую.

Типичные сценарии применения массивов

Массивы -- это базовая и распространенная структура данных, часто исполь- зуемая в различных алгоритмах и для реализации сложных структур данных.

  • Произвольный доступ: если требуется случайный выбор элементов, можно использовать массив для хранения и генерации случайной по- следовательности, осуществляя случайную выборку по индексу.

  • Сортировка и поиск: массивы являются наиболее часто используемой структурой данных для алгоритмов сортировки и поиска. Быстрая сорти- ровка, сортировка слиянием, двоичный поиск и другие алгоритмы в ос- новном работают с массивами.

  • Таблица поиска: когда необходимо быстро найти элемент или его со- ответствие, можно использовать массив в качестве таблицы поиска. На- пример, для реализации отображения символов в ASCII-коды можно использовать значение ASCII-кода символа в качестве индекса, а соот- ветствующий элемент хранить в соответствующем месте массива.

  • Машинное обучение: в нейронных сетях широко используются опе- рации линейной алгебры между векторами, матрицами и тензорами, которые реализуются в виде массивов. Массивы являются наиболее часто используемой структурой данных в программировании ней- ронных сетей.

  • Реализация структур данных: массивы могут использоваться для ре- ализации стека, очереди, хеш-таблицы, кучи, графа и других структур данных. Например, представление графа в виде матрицы смежности фактически является двумерным массивом.

Связные списки

Память -- это общий ресурс всех программ, и в сложной системной среде вы- полнения участки свободной памяти могут быть разбросаны по всему про- странству памяти. Нам уже известно, что память для хранения массива должна быть непрерывной, и когда массив очень велик, в памяти может не оказаться столь большого непрерывного участка. В этом случае проявляется преимуще- ство гибкости связного списка.

Связный список -- это линейная структура данных, в которой каждый элемент является объектом-узлом. При этом узлы соединяются друг с другом с помо- щью ссылок. В ссылке хранится адрес памяти следующего узла, по которому можно перейти от текущего узла к следующему.

Структура связного списка позволяет узлам храниться в различных местах памяти, а их адреса памяти не обязаны быть последовательными.

Рис. 4.5. Определение и способ хранения связного списка

На рис. 4.5 изображена структура связного списка. Составным элементом является объект узла. Каждый узел содержит две части данных: значение узла и ссылку на следующий узел.

  • Первый узел связного списка называется головным узлом, а последний узел -- хвостовым узлом.

  • Хвостовой узел указывает на пустое значение, которое в Java, C++ и Py- thon обозначается как null, nullptr и None соответственно.

  • В языках, поддерживающих указатели, таких как C, C++, Go и Rust, вы- шеупомянутая ссылка заменена на указатель.

В следующем примере кода показано, что узел связного списка ListNode, помимо значения, должен дополнительно хранить ссылку (указатель). Поэтому при одина- ковом объеме данных связный список занимает больше памяти, чем массив.

class ListNode:

""" Класс узла связного списка.""" def init (self, val: int):

self.val: int = val # Значение узла.

self.next: ListNode | None = None # Ссылка на следующий узел.

Основные операции со связным списком

Инициализация связного списка

Создание связного списка состоит из двух этапов: первый этап -- иници- ализация каждого объекта узла, второй этап -- построение ссылочных от- ношений между узлами. После завершения инициализации можно начать с головного узла связного списка и последовательно посетить все узлы че- рез ссылку next.

# === File: linked_list.py ===

# Инициализация связного списка 1 -> 3 -> 2 -> 5 -> 4. # Инициализация каждого узла.

n0 = ListNode(1) n1 = ListNode(3) n2 = ListNode(2) n3 = ListNode(5) n4 = ListNode(4)

# Построение ссылок между узлами. n0.next = n1

n1.next = n2 n2.next = n3 n3.next = n4

Массив в целом является переменной, например массив nums содержит эле- менты nums[0] и nums[1] и т. д., в то время как связный список состоит из множе- ства независимых объектов узлов. Обычно головной узел используется как обозначение связного списка, например связный список в приведенном выше коде можно обозначить как связный список n0.

Вставка узла

Процесс вставки узла в связный список очень прост. Предположим, что не- обходимо вставить новый узел P между двумя соседними узлами n0 и n1, для этого достаточно изменить две ссылки (указателя), а время выполнения составит O(1), см. рис. 4.6.

Напомним, что вставка элемента в массив имеет временную сложность O(n), что менее эффективно при больших объемах данных.

Рис. 4.6. Пример вставки узла в связный список

# === File: linked_list.py ===

def insert(n0: ListNode, P: ListNode):

""" Вставка узла P после узла n0 в связный список.""" n1 = n0.next

P.next = n1 n0.next = P

Удаление узла

Удаление узла в связном списке также является очень простой операцией, как показано на рис. 4.7. Достаточно изменить всего одну ссылку (указатель).

Обратите внимание, что, хотя после завершения операции удаления узел P все еще указывает на узел n1, фактически при обходе этого связного списка доступ к P уже невозможен. То есть фактически P больше не принадлежит это- му списку.

# === File: linked_list.py ===

def remove(n0: ListNode):

""" Удаление первого узла после узла n0 в связном списке.""" if not n0.next:

return

# n0 -> P -> n1

P = n0.next n1 = P.next n0.next = n1

Пример

Удаление узла P из связного списка

(Элемент n0 P

указывает на n1 )

После удаления элемент P все еще указывает на n1. Но P больше не доступен при обходе списка, поэтому можно считать, что P был удален

Доступ к узлу

Рис. 4.7. Удаление узла в связном списке

Эффективность доступа к узлам в связном списке ниже. Как упоминалось в предыдущем разделе, доступ к любому элементу массива можно получить за время O(1). В случае связного списка программа должна начать с головно- го узла и последовательно проходить по узлам, пока не будет найден целевой узел. Это означает, что для доступа к i-му узлу связного списка необходимо выполнить i -- 1 итераций, что соответствует временной сложности O(n).

# === File: linked_list.py ===

def access(head: ListNode, index: int) -> ListNode | None: """ Доступ к узлу с индексом index в связном списке.""" for _ in range(index):

if not head:

return None head = head.next

return head

Поиск узла

Поиск узла заключается в обходе связного списка для поиска узла со значени- ем target и выводе его индекса в списке. Этот процесс также является линей- ным поиском. Ниже приведен пример кода.

# === File: linked_list.py ===

def find(head: ListNode, target: int) -> int:

""" Поиск первого узла со значением target в связном списке.""" index = 0

while head:

if head.val == target: return index

head = head.next index += 1

return -1

Сравнение массивов и связных списков

В табл. 4.1 приведены характеристики массивов и связных списков, а также сравнение эффективности операций с ними. Поскольку они используют две противоположные стратегии хранения, их свойства и эффективность опера- ций также имеют противоположные характеристики.

+------------------------+----------------------------------------------------------------------+----------------------------------------+ | | > Массив | > Связный список | +========================+======================================================================+========================================+ | > Способ хранения | > Непрерывное пространство памяти | > Распределенное про- странство памяти | +------------------------+----------------------------------------------------------------------+----------------------------------------+ | > Расширение емкости | > Длина фиксирована | > Возможность гибкого расширения | +------------------------+----------------------------------------------------------------------+----------------------------------------+ | > Эффективность памяти | > Элементы занимают меньше памяти, но могут расходовать пространство | > Элементы занимают больше памяти | +------------------------+----------------------------------------------------------------------+----------------------------------------+ | > Доступ к элементу | > O(1) | > O(n) | +------------------------+----------------------------------------------------------------------+----------------------------------------+ | > Добавление элемента | > O(n) | > O(1) | +------------------------+----------------------------------------------------------------------+----------------------------------------+ | > Удаление элемента | > O(n) | > O(1) | +------------------------+----------------------------------------------------------------------+----------------------------------------+

впустую

Основные типы связных списков

Существуют три основных типа связных списков (см. рис. 4.8).

  • Односвязный список: это обычный связный список, описанный ранее. Узлы однонаправленного связного списка содержат значение и ссылку на следующий узел. Первый узел называется головным, а последний -- хвостовым, хвостовой узел указывает на пустое значение None.

  • Кольцевой (циклический) список: если сделать так, чтобы хвостовой узел односвязного списка указывал на головной узел (соединение начала и конца), получится кольцевой список. В кольцевом списке любой узел можно рассматривать как головной.

  • Двусвязный список: в отличие от односвязного двусвязный список хранит ссылки в двух направлениях. Определение узла двусвязного спи- ска включает ссылки (указатели) на следующий и предыдущий узлы. По сравнению с односвязным списком двусвязный список обладает большей гибкостью, позволяя обходить список в обоих направлениях, но требует больше памяти.

class ListNode:

""" Класс узла двусвязного списка.""" def init (self, val: int):

self.val: int = val # Значение узла.

self.next: ListNode | None = None # Ссылка на следующий узел. self.prev: ListNode | None = None # Ссылка на предыдущий узел.

Рис. 4.8. Виды связных списков

Типичные сценарии применения списков

Односвязные списки обычно используются для реализации таких структур данных, как стек, очередь, хеш-таблица и граф.

  • Стек и очередь: когда операции вставки и удаления выполняются с од- ного конца списка, он ведет себя как стек (принцип «последний при- шел -- первый вышел»). Когда операция вставки выполняется с одного конца, а операция удаления -- с другого, он ведет себя как очередь (прин- цип «первый пришел -- первый вышел»).

  • Хеш-таблица: метод цепочек является одним из основных способов ре- шения коллизий в хеш-таблицах, при котором все конфликтующие эле- менты помещаются в один список.

  • Граф: списки смежности -- это распространенный способ представления графов, где каждая вершина графа связана со списком, элементы кото- рого представляют другие вершины, соединенные с данной.

Двусвязные списки часто используются в ситуациях, где требуется быстрое нахождение предыдущего и следующего элемента.

  • Расширенные структуры данных: например, в красно-черных дере- вьях и B-деревьях необходимо иметь доступ к родительскому узлу, что можно реализовать, сохраняя ссылку на родительский узел аналогично двусвязному списку.

    • История браузера: в веб-браузере, когда пользователь нажимает кнопки Вперед или Назад, браузеру необходимо знать предыдущую и следую- щую страницы. Свойства двусвязного списка упрощают выполнение та- ких операций.

    • Алгоритм LRU: в алгоритме вытеснения из кеша (LRU) необходимо бы- стро находить наименее используемые данные, а также поддерживать быстрое добавление и удаление узлов. В этом случае идеально подходит двусвязный список.

Кольцевые списки часто применяются в ситуациях, требующих цикличе- ских операций, например в планировании ресурсов операционной системы.

  • Алгоритм циклического планирования: в операционных системах ал- горитм циклического планирования -- это распространенный алгоритм планирования процессорного времени, который требует циклического об- хода группы процессов. Каждому процессу назначается временной квант, и по его истечении процессор переключается на следующий процесс. Такие циклические операции можно реализовать с помощью кольцевого списка.

  • Буфер данных: в некоторых реализациях буферов данных также может использоваться кольцевой список. Например, в аудио- и видеоплеерах по- ток данных может разделяться на несколько буферных блоков и помещать- ся в кольцевой список для обеспечения непрерывного воспроизведения.

Списки

Список -- это абстрактное понятие структуры данных, представляющее собой упорядоченное множество элементов, поддерживающее операции доступа, изменения, добавления, удаления и обхода элементов без необходимости учи- тывать ограничения по объему. Списки могут быть реализованы на основе связных списков или массивов.

  • Связные списки естественным образом можно рассматривать как спи- ски, поддерживающие операции добавления, удаления, поиска и изме- нения элементов, а также динамическое расширение.

  • Массивы также поддерживают операции добавления, удаления, поиска и изменения элементов, но из-за фиксированной длины их можно рас- сматривать только как списки с ограничением по длине.

При использовании массива для реализации списка неизменяемая длина приводит к снижению его практичности. Это связано с тем, что зачастую невозможно заранее определить, сколько данных потребуется хранить, что за- трудняет выбор подходящей длины списка. Если длина слишком мала, это, веро- ятно, не удовлетворит потребности. Если длина слишком велика, это приведет к неэффективному использованию памяти. Для решения этой проблемы можно использовать динамический массив. Он сохраняет все преимущества массива и может динамически расширяться в процессе выполнения программы.

На самом деле списки, предоставляемые стандартными библиотеками многих языков программирования, реализованы на основе динамиче- ских массивов, например list в Python, ArrayList в Java, vector в C++ и List в C#. В дальнейшем мы будем рассматривать список и динамический массив как эквивалентные понятия.

Основные операции со списком

Инициализация списка

Обычно используются два метода инициализации: без начальных значений и с заданием начальных значений.

# === File: list.py === # Инициализация списка.

# Без начальных значений. nums1: list[int] = []

# С начальными значениями.

nums: list[int] = [1, 3, 2, 5, 4]

Доступ к элементам

Список по своей сути является массивом, поэтому доступ и обновление эле- ментов возможны за время O(1), что очень эффективно.

# === File: list.py === # Доступ к элементам.

num: int = nums[1] # Доступ к элементу по индексу 1. # Изменение элемента.

nums[1] = 0 # Изменение значения элемента с индексом 1 на 0.

Вставка и удаление элементов

В отличие от массива в списке можно свободно добавлять и удалять элемен- ты. Сложность добавления элемента в конец списка составляет O(1), но вставка и удаление элементов имеют ту же сложность O(n), что и в массиве.

# === File: list.py ===

# Очистка списка. nums.clear()

# Добавление элементов в конец. nums.append(1)

nums.append(3) nums.append(2) nums.append(5) nums.append(4)

# Вставка элемента в середину.

nums.insert(3, 6) # Вставка числа 6 по индексу 3. # Удаление элемента

nums.pop(3) # Удаление элемента по индексу 3.

Обход списка

Как и массив, список можно обходить по индексу или напрямую по эле- ментам.

# === File: list.py ===

# Обход списка по индексу. count = 0

for i in range(len(nums)): count += nums[i]

# Прямой обход элементов списка. for num in nums:

count += num

Конкатенация списков

Создав новый список nums1, его можно присоединить в конец исходного списка.

# === File: list.py ===

# Конкатенация двух списков.

nums1: list[int] = [6, 8, 7, 10, 9]

nums += nums1 # Конкатенация списка nums1 с nums.

Сортировка списка

После сортировки списка можно использовать такие алгоритмы, как двоич- ный поиск и два указателя, которые часто встречаются в задачах с массивами.

# === File: list.py === # Сортировка списка.

nums.sort() # После сортировки элементы списка расположены по возрастанию.

Реализация списка

Многие языки программирования, такие как Java, C++, Python и др., име- ют встроенные списки. Их реализация довольно сложна, а параметры тща- тельно продуманы, например начальная емкость, коэффициент расшире- ния и т. д. Заинтересованные читатели могут изучить исходный код само- стоятельно. Чтобы углубить понимание принципов работы списка, попро- буем реализовать его упрощенную версию, включающую следующие три ключевых аспекта.

  • Начальная емкость: выбор разумной начальной емкости массива. В нашем примере выбрано значение 10 в качестве начальной емкости.

  • Учет количества: объявление переменной size для учета текущего ко- личества элементов в списке. Эта переменная обновляется при вставке и удалении элементов. На ее основе можно определить конец списка и необходимость расширения.

  • Механизм расширения: если при вставке элемента емкость списка оказывается исчерпанной, необходимо расширение. Сначала созда- ется новый массив большего размера на основе коэффициента рас- ширения, затем все элементы текущего массива последовательно перемещаются в новый массив. В нашем примере массив каждый раз расширяется двукратно.

# === File: my_list.py ===

class MyList:

""" Класс списка.""" def init (self):

""" Конструктор."""

self._capacity: int = 10 # Емкость списка.

self._arr: list[int] = [0] * self._capacity # Массив (хранение

№ элементов списка). self._size: int = 0 # Длина списка (текущее количество элементов). self._extend_ratio: int = 2 # Коэффициент расширения списка.

def size(self) -> int:

""" Получение длины списка (текущего количества элементов).""" return self._size

def capacity(self) -> int:

""" Получение емкости списка.""" return self._capacity

def get(self, index: int) -> int: """ Доступ к элементу."""

# Если индекс выходит за границы, выбрасывается исключение, далее аналогично. if index < 0 or index >= self._size:

raise IndexError(" Индекс выходит за границы.") return self._arr[index]

def set(self, num: int, index: int): """ Обновление элемента."""

if index < 0 or index >= self._size:

raise IndexError(" Индекс выходит за границы.") self._arr[index] = num

def add(self, num: int):

""" Добавление элемента в конец."""

# При превышении количества элементов емкости срабатывает механизм расширения. if self.size() == self.capacity():

self.extend_capacity() self._arr[self._size] = num self._size += 1

def insert(self, num: int, index: int): """ Вставка элемента в середину.""" if index < 0 or index >= self._size:

raise IndexError(" Индекс выходит за границы.")

# При превышении количества элементов емкости срабатывает механизм расширения. if self._size == self.capacity():

self.extend_capacity()

# Все элементы начиная с индекса index смещаются на одну позицию вправо. for j in range(self._size - 1, index - 1, -1):

self._arr[j + 1] = self._arr[j] self._arr[index] = num

# Обновление количества элементов. self._size += 1

def remove(self, index: int) -> int: """ Удаление элемента."""

if index < 0 or index >= self._size:

raise IndexError(" Индекс выходит за границы.") num = self._arr[index]

# Перемещение всех элементов после индекса index на одну позицию вперед. for j in range(index, self._size - 1):

self._arr[j] = self._arr[j + 1] # Обновление количества элементов. self._size -= 1

# Возвращение удаленного элемента. return num

def extend_capacity(self): """ Расширение списка."""

# Создание нового массива длиной в _extend_ratio раз больше исходного

№ и копирование в него исходного массива.

self._arr = self._arr + [0] * self.capacity() * (self._extend_ratio - 1) # Обновление емкости списка.

self._capacity = len(self._arr)

def to_array(self) -> list[int]:

""" Возвращение списка с фактической длиной.""" return self._arr[: self._size]

память и кеш*

В первых двух разделах этой главы были рассмотрены массивы и связные спи- ски -- две базовые и важные структуры данных, представляющие собой соот- ветственно непрерывное хранение и распределенное хранение.

Фактически физическая структура в значительной степени определяет эффективность использования памяти и кеша программой, что, в свою очередь, влияет на общую производительность алгоритма.

Устройства хранения в компьютере

В компьютере существует три типа устройств хранения: жесткий диск (HDD/ SSD), оперативная память (RAM) и кеш-память (cache). В табл. 4.2 приведены их различные роли и характеристики.

Таблица 4.2. Устройства хранения в компьютере

+-------------+----------------------------+--------------------------+----------------------------+ | | > Жесткий диск | > Оперативная память | > Кеш | +=============+============================+==========================+============================+ | > Назначе- | > Долговременное хра- | > Временное хранение | > Хранение часто запраши- | +-------------+----------------------------+--------------------------+----------------------------+ | > ние | > нение данных, включая | > запущенных программ | > ваемых данных и инструк- | +-------------+----------------------------+--------------------------+----------------------------+ | | > операционную систему, | > и обрабатываемых | > ций, сокращение числа | +-------------+----------------------------+--------------------------+----------------------------+ | | > программы, файлы и т. д. | > данных | > обращений к оперативной | +-------------+----------------------------+--------------------------+----------------------------+ | | | | > памяти процессором | +-------------+----------------------------+--------------------------+----------------------------+ | > Энергоза- | > Данные не теряются по- | > Данные теряются после | > Данные теряются после | +-------------+----------------------------+--------------------------+----------------------------+ | > висимость | > сле отключения питания | > отключения питания | > отключения питания | +-------------+----------------------------+--------------------------+----------------------------+ | > Емкость | > Большая, на уровне | > Меньше, на уровне | > Очень малая, на уровне | +-------------+----------------------------+--------------------------+----------------------------+ | | > терабайтов | > гигабайтов | > мегабайтов | +-------------+----------------------------+--------------------------+----------------------------+ | > Скорость | > Медленная, от несколь- | > Быстрая, десятки гига- | > Очень быстрая, от десят- | +-------------+----------------------------+--------------------------+----------------------------+ | | > ких сотен до нескольких | > байт в секунду | > ков до сотен гигабайт | +-------------+----------------------------+--------------------------+----------------------------+ | | > тысяч мегабайт в секунду | | > [в]{.smallcaps} секунду | +-------------+----------------------------+--------------------------+----------------------------+ | > Цена | > Низкая, несколько цен- | > Высокая, несколько | > Очень высокая, цена | +-------------+----------------------------+--------------------------+----------------------------+ | | > тов за гигабайт | > долларов за гигабайт | > включена в стоимость | +-------------+----------------------------+--------------------------+----------------------------+ | | | | > процессора | +-------------+----------------------------+--------------------------+----------------------------+

Компьютерную систему хранения можно представить в виде пирамидаль- ной структуры, как показано на рис. 4.9. Чем ближе к вершине пирамиды на- ходится устройство хранения, тем выше его скорость, меньше емкость и выше стоимость. Такой многоуровневый дизайн не случаен, а является результатом тщательных размышлений компьютерных ученых и инженеров.

Рис. 4.9. Система хранения данных в компьютере

  • Жесткий диск трудно заменить оперативной памятью. Во-первых, данные в оперативной памяти теряются после отключения питания, по- этому она не подходит для долговременного хранения. Во-вторых, сто- имость оперативной памяти в десятки раз выше, чем у жесткого диска, что затрудняет ее распространение на потребительском рынке.

    • Большая емкость и высокая скорость кеша трудно совместимы. С увеличением емкости кеша уровней L1, L2, L3 его физические разме- ры увеличиваются. Вместе с этим растет физическое расстояние до ядра процессора, что приводит к увеличению времени передачи данных и за- держке доступа к элементам. В текущих условиях многоуровневая струк- тура кеша является оптимальным балансом между емкостью, скоростью и стоимостью.

В целом жесткий диск используется для долговременного хранения большого объема данных, оперативная память -- для временного хране- ния данных, обрабатываемых во время выполнения программы, а кеш -- для хранения часто запрашиваемых данных и инструкций, чтобы повы- сить эффективность выполнения программы. Все три компонента работают совместно, обеспечивая эффективную работу компьютерной системы.

Во время выполнения программы данные считываются с жесткого диска в оперативную память для обработки процессором, как показано на рис. 4.10. Кеш-память можно рассматривать как часть процессора, в которую по слож- ным алгоритмам загружаются данные из оперативной памяти. Это обеспечи- вает высокоскоростное чтение данных процессором, значительно повышает эффективность выполнения программы и снижает зависимость от более мед- ленной оперативной памяти.

Рис. 4.10. Поток данных между жестким диском, оперативной памятью и кешем

Эффективность использования памяти структурами данных

С точки зрения использования памяти массивы и связные списки имеют свои преимущества и ограничения.

С одной стороны, память ограничена, и одну и ту же область памяти нельзя разделить между несколькими программами, поэтому желательно,

чтобы структуры данных максимально эффективно использовали простран- ство. Элементы массива расположены плотно, также не требуется дополни- тельное пространство для хранения ссылок (указателей) между узлами, что делает его более эффективным с точки зрения использования памяти. Одна- ко массивы требуют выделения сразу достаточного количества непрерывного пространства памяти, что может привести к ее растрате, а расширение мас- сива также требует дополнительных временных и пространственных затрат. В отличие от этого списки осуществляют динамическое распределение и осво- бождение памяти на уровне узлов, что обеспечивает большую гибкость.

С другой стороны, во время выполнения программы по мере многократного выполнения запросов и освобождения памяти степень фрагментации свобод- ной памяти будет увеличиваться, что снижает эффективность ее использова- ния. Благодаря непрерывному способу хранения массивы относительно менее подвержены фрагментации памяти. Элементы списка, напротив, хранятся разрозненно, и при частых операциях вставки и удаления они более подвер- жены фрагментации памяти.

Эффективность кеширования структур данных

Хотя объем кеша значительно меньше объема памяти, он намного быстрее и играет решающую роль в скорости выполнения программы. Объем кеша ограничен, и он может хранить лишь небольшую часть часто запрашиваемых данных. Поэтому при попытке процессора получить доступ к данным, отсут- ствующим в кеше, происходит промах кеша, и процессор вынужден загружать необходимые данные из более медленной памяти.

Очевидно, что чем меньше промахов кеша, тем выше эффективность чтения и записи данных процессором и тем лучше производительность программы. Доля данных, успешно полученных процессором из кеша, называ- ется коэффициентом попадания в кеш. Этот показатель обычно используется для оценки эффективности кеша.

Для достижения максимальной эффективности кеш использует следующие механизмы загрузки данных.

  • Кеш-линия: кеш не хранит и не загружает данные по байтам, а исполь- зует в качестве единицы кеш-линии. По сравнению с передачей по бай- там передача кеш-линий более эффективна.

  • Механизм предвыборки: процессор пытается предсказать шаблоны доступа к данным (например, последовательный доступ, доступ с фик- сированным шагом и т. д.) и загружает данные в кеш в соответствии с этими шаблонами, чтобы повысить коэффициент попадания.

  • Пространственная локальность: если к данным был осуществлен доступ, то, вероятно, в ближайшее время будет осуществлен доступ и к данным, на- ходящимся поблизости. Поэтому при загрузке данных кеш также загружает данные, находящиеся рядом, чтобы повысить коэффициент попадания.

  • Временная локальность: если к данным был осуществлен доступ, то в ближайшем будущем, вероятно, к ним будет осуществлен повторный доступ. Кеш использует этот принцип, сохраняя недавно запрашивае- мые данные, чтобы повысить коэффициент попадания.

Фактически эффективность использования кеша массивами и списка- ми различается, что проявляется в следующих аспектах.

  • Занимаемое пространство: элементы списка занимают больше про- странства, чем элементы массива, что приводит к уменьшению объема полезных данных, которые могут быть помещены в кеш.

  • Кеш-линии: данные списка распределены по всей памяти, а кеш за- гружает данные по линиям, поэтому доля загружаемых неэффективных данных выше.

  • Механизм предвыборки: шаблоны доступа к данным массива более предсказуемы, чем у списка, т. е. системе легче угадать, какие данные могут быть загружены.

  • Пространственная локальность: массивы хранятся в сконцентриро- ванном пространстве памяти, поэтому данные, находящиеся рядом с за- груженными, с большей вероятностью будут запрошены.

  • В целом массивы обладают более высоким коэффициентом попа- дания в кеш, поэтому они обычно превосходят списки по эффек- тивности операций. Это делает структуры данных, реализованные на основе массивов, более предпочтительными при решении алгоритми- ческих задач.

Следует отметить, что высокая эффективность использования кеша не означает, что массивы всегда предпочтительнее списков. Выбор струк- туры данных в реальных приложениях должен основываться на конкретных требованиях. Например, структуру данных «стек» можно реализовать на осно- ве и массивов, и списков (детали этого будут рассмотрены в следующей главе), но они предназначены для различных сценариев.

  • При решении алгоритмических задач предпочтение отдается стеку, реализованному на основе массива, поскольку он обеспечивает более высокую эффективность операций и возможность случайного доступа. Но платой за это является необходимость заранее выделить определен- ное количество памяти для массива.

  • Если объем данных очень велик, динамичность высока, и размер стека трудно предсказать, то более предпочтителен стек, реализованный на основе списка. Список позволяет распределенно хранить большое коли- чество данных в различных частях памяти и избегать дополнительных затрат на расширение массива.

резюме

Ключевые моменты
  • Массивы и списки -- это две основные структуры данных, представляю- щие два способа хранения данных в памяти компьютера: хранение в не- прерывном пространстве и хранение в распределенном пространстве. Их характеристики дополняют друг друга.

  • Массивы поддерживают случайный доступ и занимают меньше памяти. Однако эффективность вставки и удаления элементов низкая, а длина после инициализации фиксированная.

  • Списки обеспечивают эффективную вставку и удаление узлов путем из- менения ссылок (указателей) и могут гибко изменять свою длину. Одна- ко доступ к узлам менее эффективен, и они занимают больше памяти. К распространенным типам списков относятся односвязные, кольцевые и двусвязные списки.

  • Список -- это упорядоченная коллекция элементов, поддерживающая операции добавления, удаления, поиска и изменения и обычно реали- зуемая на основе динамического массива. Он сохраняет преимущества массива, одновременно позволяя гибко изменять длину.

  • Появление списка значительно повысило практическую ценность мас- сива, но может привести к частичной потере памяти.

  • Во время выполнения программы данные в основном хранятся в памя- ти. Массивы могут обеспечить более высокую эффективность использо- вания памяти, тогда как списки более гибки в ее использовании.

  • Кеш, используя кеш-линии, механизм предвыборки, а также механизмы пространственной и временной локальности данных, обеспечивает для процессора быстрый доступ к данным, значительно повышая эффектив- ность выполнения программы.

  • Поскольку массивы обладают более высокой вероятностью попадания в кеш, они обычно более эффективны, чем списки. При выборе струк- туры данных следует принимать во внимание конкретные требования и сценарии.

Вопросы и ответы

Вопрос. Влияет ли хранение массива в стеке или в куче на временную и про- странственную эффективность?

Ответ. Массивы, хранящиеся в стеке и в куче, размещаются в непрерывном пространстве памяти, и эффективность операций с данными в основном оди- накова. Однако стек и куча имеют свои особенности, что приводит к следую- щим различиям:

  1. эффективность выделения и освобождения: стек -- это небольшая об- ласть памяти, выделение которой выполняется автоматически компи- лятором, в то время как память кучи относительно больше и может вы- деляться динамически в коде, что делает ее более подверженной фраг- ментации. Поэтому операции выделения и освобождения в куче обычно медленнее, чем в стеке;

  2. ограничение размера: память стека относительно мала, размер кучи обычно ограничен доступной памятью. Поэтому куча более подходит для хранения больших массивов;

  3. гибкость: размер массива в стеке должен быть определен на этапе ком- пиляции, тогда как размер массива в куче может быть динамически определен во время выполнения.

Вопрос. Почему для массива требуется, чтобы элементы были одного типа, а для списка это не обязательно?

Ответ. Список состоит из узлов, которые соединяются между собой с помо- щью ссылок (указателей), и каждый узел может хранить данные различных типов, например int, double, string, object и т. д.

Элементы массива, напротив, должны быть одного типа, чтобы можно было вычислить смещение для получения позиции соответствующего элемента. На- пример, если массив одновременно содержит элементы типов int и long, которые занимают 4 и 8 байт соответственно, то нельзя использовать следующую форму- лу для вычисления смещения, поскольку массив содержит две длины элемента.

# Адрес памяти элемента = Адрес памяти массива (адрес памяти первого элемента)

+ Длина элемента * Индекс элемента

Вопрос. После удаления узла P нужно ли устанавливать P.next в значение None? Ответ. Не обязательно изменять P.next. С точки зрения данного связного списка при обходе от головного узла до хвостового узла узел P больше не будет встречен. Это означает, что узел P уже удален из списка, и в этом случае, куда

бы ни указывал узел P, это не повлияет на данный список.

С точки зрения структуры данных и алгоритмов (решение задач) разрыв связи не имеет значения, главное -- чтобы логика программы была правиль- ной. С точки зрения стандартной библиотеки разрыв связи более безопасен и логически ясен. Если связь не разрывается и память удаленного узла не будет корректно освобождена, это может повлиять на освобождение памяти после- дующих узлов.

Вопрос. Временная сложность операций вставки и удаления в связном спи- ске составляет O(1). Однако для поиска элемента перед добавлением или уда- лением требуется время O(n). Почему же временная сложность не O(n)?

Если сначала производится поиск элемента, а затем его удаление, времен- ная сложность действительно составляет O(n). Однако преимущество O(1) для операций добавления и удаления в связном списке проявляется в других ситуациях. Например, двусторонняя очередь подходит для реализации с по- мощью связного списка, когда поддерживаются указатели, всегда указыва- ющие на головной и хвостовой узлы, и каждая операция вставки и удаления выполняется за O(1).

Вопрос. На рис. 4.5 «Определение и способ хранения связного списка» голу- бые указатели на узлы занимают один блок памяти или они делят его со зна- чениями узлов?

Ответ. Данная схема является качественным представлением, количествен- ное представление требует анализа в зависимости от конкретной ситуации.

  • Различные типы значений узлов занимают разный объем, например int, long, double и экземпляры объектов.

  • Размер области памяти, занимаемой переменной-указателем, зависит от используемой операционной системы и среды компиляции и обычно составляет 8 или 4 байта.

Вопрос. Всегда ли добавление элемента в конец списка имеет временную сложность O(1)?

Ответ. Если при добавлении элемента превышается длина списка, то сна- чала необходимо расширить список, а затем добавить элемент. Система за- просит новый блок памяти и перенесет в него все элементы исходного списка, в этом случае временная сложность будет O(n).

Вопрос. Фраза «Появление списка значительно повысило практическую ценность массива, но может привести к частичной потере памяти» означает, что потеря памяти связана с дополнительными переменными, такими как ем- кость, длина и коэффициент расширения?

Ответ. Потеря памяти здесь имеет два значения. Во-первых, у списка уста- навливается начальная длина, но нам не всегда требуется столько места. Во- вторых, чтобы избежать частого расширения, обычно используется опреде- ленный коэффициент, например ×1.5. Это приводит к появлению множества пустых мест, которые обычно не удается полностью заполнить.

Вопрос. После инициализации массива в Python n = [1, 2, 3] адреса этих трех элементов связаны, но при инициализации m = [2, 1, 3] обнаруживается, что идентификатор каждого элемента не является последовательным, а совпадает с таковым в n. Если адреса элементов не последовательны, является ли m массивом? Ответ. Если заменить элементы списка на узлы связного списка n = [n1, n2, n3, n4, n5], то в большинстве случаев эти 5 объектов узлов также будут хра- ниться в разных местах памяти. Однако, имея индекс списка, можно за время O(1) получить адрес памяти узла и получить доступ к соответствующему узлу. Это происходит потому, что в массиве хранятся ссылки на узлы, а не сами узлы. В отличие от многих других языков в Python числа также упакованы в объ- екты, и в списке хранятся не сами числа, а ссылки на них. Поэтому можно об- наружить, что одинаковые числа в двух массивах имеют один и тот же иден-

тификатор, а адреса памяти этих чисел не обязаны быть последовательными. Вопрос. В C++ в библиотеке STL std::list уже реализован двусторонний связный список, но в некоторых книгах по алгоритмам его не используют на-

прямую. Есть ли у него какие-то ограничения?

Ответ. С одной стороны, мы часто предпочитаем использовать массивы для реализации алгоритмов и прибегаем к связным спискам только при необходи- мости. Основные причины этого следующие.

  • пространственные затраты: так как каждый элемент требует двух до- полнительных указателей (одного для предыдущего элемента и одного для следующего элемента), std::list обычно занимает больше места, чем std::vector;

  • неоптимально для кеширования: поскольку данные не хранятся не- прерывно, std::list имеет низкую эффективность использования кеша. В общем случае производительность std::vector будет лучше.

С другой стороны, необходимость использования связных списков в основ- ном возникает в случае двоичных деревьев и графов. Стек и очередь часто ис- пользуют предоставляемые языком программирования типы stack и queue, а не связные списки.

Вопрос. Операция res = 0 * n создает двумерный список, в котором каж- дый элемент [0] является независимым?

Ответ. Нет, они не являются независимыми. В этом двумерном списке все элементы [0] фактически являются ссылками на один и тот же объект. Если из- менить один из элементов, все соответствующие элементы также изменятся.

Если требуется, чтобы каждый [0] в двумерном списке был независимым, можно использовать инструкцию res = [[0] for _ in range(n)]. Этот способ ос- нован на инициализации n независимых объектов списка [0].

Вопрос. Операция res = [0] * n создает список, в котором все целые 0 явля- ются независимыми?

Ответ. В этом списке все целые 0 являются ссылками на один и тот же объ- ект. Это связано с тем, что в Python используется механизм пула для малых целых чисел (обычно от --5 до 256), чтобы максимизировать повторное исполь- зование объектов и повысить производительность.

Хотя они ссылаются на один и тот же объект, мы все же можем независимо изменять каждый элемент списка, поскольку целые числа в Python являются неизменяемыми объектами. При изменении какого-либо элемента фактиче- ски происходит переключение на ссылку на другой объект, а не изменение са- мого исходного объекта.

Однако, когда элементы списка являются изменяемыми объектами (напри- мер, списки, словари или экземпляры классов и т. д.), изменение какого-либо элемента напрямую изменяет и сам объект, и все элементы, ссылающиеся на этот объект, претерпевают те же изменения.

Глава 5