# Резюме ### Ключевые выводы - Массивы и связные списки - это две базовые структуры данных, представляющие два способа хранения данных в памяти компьютера: хранение в непрерывном пространстве и хранение в разрозненном пространстве. Их свойства во многом взаимно дополняют друг друга. - Массив поддерживает произвольный доступ и занимает меньше памяти. Однако вставка и удаление элементов в нем неэффективны, а длина после инициализации фиксирована. - Связный список позволяет эффективно вставлять и удалять узлы путем изменения ссылок (указателей), а также гибко менять длину. Однако доступ к узлам менее эффективен, а памяти он занимает больше. Распространенные типы списков включают односвязные, циклические и двусвязные списки. - Список - это упорядоченная коллекция элементов, поддерживающая добавление, удаление, поиск и изменение, и обычно реализуемая на основе динамического массива. Он сохраняет преимущества массива и при этом может гибко менять длину. - Появление списка значительно повысило практическую ценность массива, хотя это и может приводить к потере части памяти. - Во время работы программы данные в основном хранятся в оперативной памяти. Массив обеспечивает более высокую эффективность использования пространства памяти, а связный список дает большую гибкость в использовании памяти. - Кэш, используя строки кэша, механизм предвыборки, а также пространственную и временную локальность, предоставляет CPU быстрый доступ к данным и заметно повышает эффективность выполнения программ. - Поскольку массивы обычно имеют более высокий коэффициент попадания в кэш, они в большинстве случаев работают эффективнее списков. При выборе структуры данных нужно исходить из конкретных требований и сценариев. ### Q & A **Q**: Влияет ли хранение массива в стеке или в куче на временную и пространственную эффективность? Массивы, расположенные и в стеке, и в куче, все равно хранятся в непрерывной области памяти, поэтому эффективность операций с данными у них в целом одинакова. Однако у стека и кучи есть собственные особенности, из-за которых возникают следующие различия. 1. Эффективность выделения и освобождения: стек представляет собой относительно небольшой участок памяти, а выделение в нем обычно выполняется автоматически компилятором. Куча же обычно больше, может выделяться динамически из кода и легче фрагментируется. Поэтому выделение и освобождение памяти в куче обычно медленнее, чем в стеке. 2. Ограничение размера: объем стека относительно невелик, а размер кучи обычно ограничивается доступной памятью. Поэтому куча лучше подходит для хранения больших массивов. 3. Гибкость: размер массива в стеке должен быть известен во время компиляции, а размер массива в куче может определяться динамически во время выполнения. **Q**: Почему для массива требуется, чтобы все элементы были одного типа, а для связного списка это не подчеркивается? Связный список состоит из узлов, а узлы соединяются между собой через ссылки (указатели), поэтому каждый узел в принципе может хранить данные разного типа, например `int` , `double` , `string` , `object` и т.д. Напротив, элементы массива должны быть одного типа, иначе нельзя будет вычислять адрес элемента через смещение. Например, если массив одновременно содержит `int` и `long` , один элемент занимает 4 байта, а другой - 8 байт. В этом случае формула ниже уже не позволит вычислить смещение, потому что в массиве будут присутствовать элементы разной длины. ```shell # Адрес элемента в памяти = адрес массива в памяти (адрес первого элемента) + длина элемента * индекс элемента ``` **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 - это «неизменяемые объекты». Когда мы изменяем некоторый элемент, на самом деле происходит переключение ссылки на другой объект, а не изменение исходного объекта. Однако если элементами списка являются «изменяемые объекты» (например списки, словари или экземпляры классов), то изменение одного элемента прямо меняет сам объект, и все элементы, ссылающиеся на него, увидят одно и то же изменение.