Перейти к содержанию
Приглашаем вас участвовать в развитии русской версии! Подробнее здесь.

# 4.5 Резюме

### 1. Ключевые выводы

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

### 2. Q & A

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

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

1. Эффективность выделения и освобождения: стек представляет собой относительно небольшой участок памяти, а выделение в нем обычно выполняется автоматически компилятором. Куча же обычно больше, может выделяться динамически из кода и легче фрагментируется. Поэтому выделение и освобождение памяти в куче обычно медленнее, чем в стеке.
2. Ограничение размера: объем стека относительно невелик, а размер кучи обычно ограничивается доступной памятью. Поэтому куча лучше подходит для хранения больших массивов.
3. Гибкость: размер массива в стеке должен быть известен во время компиляции, а размер массива в куче может определяться динамически во время выполнения.

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

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

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

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

Q: После удаления узла `P` нужно ли присваивать `P.next = None` ?

Можно и не изменять `P.next` . С точки зрения данного списка, при обходе от головы к хвосту узел `P` уже больше не встретится. Это означает, что узел `P` уже удален из списка, и то, куда он указывает после этого, на сам список больше не влияет.

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

Q: Временная сложность вставки и удаления в связном списке равна \(O(1)\) . Но до вставки или удаления обычно еще нужно потратить \(O(n)\) на поиск элемента. Почему тогда общая сложность не \(O(n)\) ?

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

Q: На рисунке «Определение связного списка и способ хранения» светло-голубой блок с указателем узла - это отдельный адрес памяти? Или он делит память пополам со значением узла?

Этот рисунок дает только качественное представление. Количественно все зависит от конкретных условий.

- Значения узлов разных типов занимают разный объем памяти, например `int` , `long` , `double` и объекты-экземпляры.
- Размер памяти, занимаемой переменной-указателем, зависит от операционной системы и среды компиляции и обычно составляет 8 байт или 4 байта.

Q: Всегда ли добавление элемента в конец списка имеет сложность \(O(1)\) ?

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

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

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

Q: В Python после инициализации `n = [1, 2, 3]` адреса этих трех элементов выглядят непрерывными, но после `m = [2, 1, 3]` можно заметить, что `id` элементов не идут подряд, а совпадают с одинаковыми числами из `n` . Если адреса элементов не непрерывны, остается ли `m` массивом?

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

В отличие от многих других языков, в Python даже числа обернуты в объекты, и в списке хранятся не сами числа, а ссылки на них. Поэтому мы и наблюдаем, что одинаковые числа в двух массивах имеют один и тот же `id` , а адреса этих чисел не обязаны быть непрерывными.

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

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

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

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

Q: Операция `res = [[0]] * n` создает двумерный список. Каждый `[0]` в нем независим?

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

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

Q: Операция `res = [0] * n` создает список. Каждый целочисленный `0` в нем независим?

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

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

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

##### Оставляйте свои идеи, вопросы и предложения в комментариях