Введение
Каждый, кто прошел путь от print("Hello, World!") до своего первого серьезного проекта на Python, знает и любит списки и словари. Но как часто мы задумываемся, почему они работают именно так, а не иначе? Эта статья — для тех, кто готов пойти дальше поверхностного использования API и заглянуть в реализацию CPython. Мы разберем, почему list — это на самом деле динамический массив, а не связанный список, и как хеш-таблицы позволяют словарям творить свою магию с амортизированной сложностью O(1). Это знание не только интересно само по себе, но и критически важно для оптимизации производительности в высоконагруженных приложениях.
Часть 1: Списки (list) — динамические массивы под прикрытием
Когда мы начинаем изучать Python, list становится одним из наших первых и самых верных инструментов. Его синтаксис интуитивно понятен, а гибкость кажется безграничной. Но что, если я скажу вам, что название "список" немного лукавит? За этим простым и дружелюбным фасадом скрывается мощная и оптимизированная структура, знание которой поможет вам избежать "бутылочных горлышек" в производительности вашего кода.
Главный секрет: list — это не список, а массив
Первое и самое важное заблуждение, которое нужно развеять: Python list — это не классический связанный список (linked list), где каждый элемент хранит ссылку на следующий. На самом деле, в CPython list реализован как динамический массив (dynamic array).
Что это значит?
Непрерывный блок памяти: Все элементы списка хранятся в памяти друг за другом, в одном сплошном блоке.
Хранение указателей: В этом блоке хранятся не сами объекты (числа, строки, другие списки), а указатели на них. Все указатели имеют одинаковый размер (обычно 8 байт на 64-битной системе), что делает навигацию по массиву очень быстрой.
Представьте себе длинную коробку с пронумерованными ячейками. В каждой ячейке лежит не сам предмет, а записка с адресом, где этот предмет можно найти. Именно так и устроен list.
Такая структура дает нам одно огромное преимущество: мгновенный доступ к элементу по индексу. Когда вы пишете my_list[i], Python не перебирает элементы с начала, а просто вычисляет адрес нужной ячейки по формуле адрес_начала_списка + i * размер_указателя. Это арифметическая операция, которая выполняется за константное время O(1).
Магия .append(): амортизированная сложность O(1)
Самая частая операция со списком — добавление элемента в конец с помощью метода .append(). Интуитивно кажется, что если массив должен быть непрерывным, то для добавления нового элемента нужно каждый раз выделять новую память и копировать все старые данные. К счастью, это не так.
Python использует механизм переаллокации (over-allocation). Когда вы создаете список, интерпретатор выделяет память с запасом. Когда вы добавляете элементы через .append(), Python просто помещает новый указатель в следующую свободную "ячейку". Это очень быстрая операция.
Но что произойдет, когда этот запас закончится?
Python выделит новый блок памяти, большего размера, чем предыдущий.
Скопирует все указатели из старого блока в новый.
Добавит указатель на новый элемент и освободит старый блок.
Копирование — это "дорогая" операция со сложностью O(n). Однако случается она довольно редко. Благодаря продуманному алгоритму роста массива, чем больше становится список, тем реже происходят эти дорогостоящие переаллокации. В результате средняя стоимость добавления элемента, или амортизированная сложность, стремится к O(1).
Давайте посмотрим на это в коде с помощью модуля sys:
import sys
# Создаем пустой список
my_list = []
previous_size = sys.getsizeof(my_list)
print(f"Элементов: 0, Размер в байтах: {previous_size}")
# Добавляем элементы в цикле и следим за размером
for i in range(25):
my_list.append(i)
current_size = sys.getsizeof(my_list)
# Печатаем информацию, только если размер изменился
if current_size != previous_size:
print(f"Элементов: {len(my_list):2d}, Размер в байтах: {current_size}")
previous_size = current_size
Примерный вывод:
Элементов: 0, Размер в байтах: 56
Элементов: 1, Размер в байтах: 88
Элементов: 5, Размер в байтах: 120
Элементов: 9, Размер в байтах: 184
Элементов: 17, Размер в байтах: 248
Обратите внимание, размер списка растет не плавно с каждым добавленным элементом, а резкими скачками. Это и есть механизм переаллокации в действии. Python выделяет память «с запасом», чтобы следующие несколько операций .append() были максимально быстрыми.
Цена остальных операций: когда списки становятся медленными
Понимание структуры динамического массива помогает моментально оценить "стоимость" других операций:
Вставка:
my_list.insert(0, 'new')
Это одна из самых дорогих операций. Чтобы вставить элемент в начало (или середину), Python должен сдвинуть все последующие элементы на одну позицию вправо. Сложность такой операции — O(n), и на больших списках она может стать серьезной проблемой.Удаление:
my_list.pop(0)илиdel my_list[0]
Аналогично вставке, удаление элемента из начала или середины требует сдвига всех оставшихся элементов влево, чтобы "заполнить" пустое место. Сложность — также O(n).Взятие среза:
new_list = my_list[a:b]
Создание среза — это не бесплатная операция. Python создает новый список и копирует в него указатели на элементы из указанного диапазона. Сложность этой операции — O(k), гдеk— это количество элементов в срезе.
Практические выводы: как писать эффективный код
Нужны частые вставки/удаления в начале? Используйте
collections.deque!
Если ваш алгоритм требует часто добавлять или удалять элементы с обоих концов коллекции, стандартныйlist— плохой выбор. Для этих целей идеально подходитcollections.deque(двусторонняя очередь), которая под капотом реализована как связанный список блоков и обеспечивает операцииappend,appendleft,popиpopleftсо сложностью O(1).Предпочитайте генераторы списков (list comprehensions).
Когда вы заранее знаете, из чего будет состоять список, конструкция[x for x in iterable]часто работает быстрее, чем цикл с.append(). Одна из причин в том, что Python может заранее вычислить итоговый размер списка и выделить нужный объем памяти за один раз, избегая многократных переаллокаций.
Итак, мы выяснили, что list — это мощная, но компромиссная структура. Он идеален для хранения упорядоченных данных и быстрого доступа по индексу, но может быть неэффективен, если вам часто приходится изменять его начало или середину.
Часть 2: Словари (dict) — магия хеш-таблиц
Если списки — это аккуратные, пронумерованные полки, то словари — это волшебный шкаф, в котором вы можете мгновенно найти любой предмет, просто назвав его имя. Независимо от того, хранятся ли в словаре десять элементов или десять миллионов, Python находит нужный практически за одно и то же время. Эта магия достигается с помощью одной из самых важных структур данных в информатике — хеш-таблицы.
Три кита, на которых держатся словари
В основе реализации словаря лежат три ключевые концепции:
Хеш-функция (
hash()): Это функция, которая принимает на вход ключ (например, строку или число) и преобразует его в целое число, называемое хешем. Важное свойство хорошей хеш-функции — она должна распределять хеши как можно более равномерно и для одного и того же ключа всегда возвращать один и тот же хеш.Внутренний массив: Словарь, как и список, использует внутри себя массив для хранения данных. Его ячейки часто называют "бакетами" (buckets) или "слотами" (slots).
Разрешение коллизий: Что произойдет, если два разных ключа (
key1иkey2) после хеширования дадут один и тот же индекс для вставки в массив? Это явление называется коллизией. У Python есть умный механизм для разрешения таких ситуаций.
Давайте разберем на простом примере, как это работает вместе.
Путь ключа: от добавления до поиска
Представим, что у нас есть пустой словарь, который внутри представляет собой массив из 8 ячеек, и мы хотим выполнить команду my_dict['name'] = 'Alice'.
Вычисление хеша: Python сначала вычисляет хеш от ключа:
hash('name'). Допустим, получилось число138783498209483.Определение индекса: Чтобы это огромное число превратить в индекс от 0 до 7, Python использует быструю битовую операцию. Он берет последние 3 бита хеша (потому что 2³ = 8). Допустим, в результате получился индекс
5.Запись данных: Python переходит к ячейке с индексом
5. Она пуста, поэтому он сохраняет туда тройку:(хеш, ключ, значение). Сохранять полный хеш и ключ необходимо для разрешения коллизий.
Теперь мы хотим получить значение: my_dict['name']. Процесс почти идентичен:
Снова вычисляется
hash('name')и определяется тот же индекс5.Python смотрит в ячейку
5, сравнивает хеш и ключ, хранящиеся там, с теми, что мы ищем. Они совпадают! Значение 'Alice' найдено и возвращено.
А что если место занято? Коллизии и "открытая адресация"
Теперь добавим my_dict['age'] = 30. Допустим, hash('age') после преобразования тоже указывает на индекс 5, но эта ячейка уже занята. Это и есть коллизия.
Python не создает вложенных списков в ячейке. Вместо этого он использует метод открытой адресации (open addressing). Интерпретатор запускает специальный алгоритм, который, основываясь на значении хеша, вычисляет новую "пробную" последовательность индексов, пока не найдет свободную ячейку. Этот процесс называется "пробированием" (probing).
Скорость операций: почему в среднем O(1)?
Добавление, получение и удаление элемента в среднем выполняются за константное время O(1). Почему? Потому что описанный выше процесс (хеширование, вычисление индекса и одна-две проверки) не зависит от количества элементов в словаре.
Худший случай O(n): Теоретически возможно создать такой набор ключей, что все они будут вызывать коллизии. В этом случае поиск превратится в линейный перебор, и сложность станет O(n). Однако на практике, благодаря качественной хеш-функции в Python, такая ситуация почти не встречается.
Рост словаря и сохранение порядка (с Python 3.7+)
Как и списки, словари не растут по одному элементу. Когда словарь заполняется примерно на 2/3, Python решает, что коллизий становится слишком много, и производит изменение размера. Создается новый, больший массив, и все существующие элементы заново вставляются в него.
Давайте, как и в случае со списками, посмотрим на это в коде:
import sys
my_dict = {}
previous_size = sys.getsizeof(my_dict)
print(f"Элементов: 0, Размер в байтах: {previous_size}")
# Добавляем 25 элементов, отслеживая рост памяти
for i in range(25):
my_dict[i] = i
current_size = sys.getsizeof(my_dict)
if current_size != previous_size:
print(f"Элементов: {len(my_dict):2d}, Размер в байтах: {current_size}")
previous_size = current_size
Примерный вывод:
Элементов: 0, Размер в байтах: 64
Элементов: 6, Размер в байтах: 240
Элементов: 11, Размер в байтах: 368
Элементов: 22, Размер в байтах: 640
Как видите, картина очень похожа на списки: размер словаря растет не плавно, а скачками. Python заранее выделяет память под будущие элементы, чтобы минимизировать количество "дорогих" операций по изменению размера и перехешированию.
Важное нововведение, начиная с версии CPython 3.6 (и ставшее официальной частью языка в 3.7), — словари сохраняют порядок вставки. Это было достигнуто за счет небольшой переработки внутренней структуры, что позволило сохранить и скорость O(1), и порядок.
Почему ключи должны быть неизменяемыми?
Теперь вы легко можете ответить на этот вопрос. Работа словаря полностью зависит от хеша ключа. Если бы ключ можно было изменить после добавления в словарь (например, если бы ключом был список), то его хеш тоже бы изменился, и Python просто не смог бы найти этот ключ в хеш-таблице по старому индексу.
Давайте посмотрим, что произойдет, если мы попытаемся нарушить это правило:
# Попытаемся использовать изменяемый тип (list) в качестве ключа
mutable_key = [1, 2]
my_dict = {}
try:
my_dict[mutable_key] = "some_value"
except TypeError as e:
print(f"Произошла ошибка: {e}")
# А теперь с неизменяемым типом (tuple)
immutable_key = (1, 2)
my_dict[immutable_key] = "it_works"
print(f"С кортежем все работает: {my_dict}")
Вывод:
Произошла ошибка: unhashable type: 'list'
С кортежем все работает: {(1, 2): 'it_works'}
Эта ошибка TypeError: unhashable type — прямое следствие внутреннего устройства словарей. Ключами могут быть только объекты неизменяемых (immutable) типов: строки, числа, кортежи (tuple) и т.д.
Итак, dict — это блестящий пример инженерной мысли, позволяющий нам работать с данными по уникальному идентификатору с невероятной скоростью.
Часть 3: Выводы и проверка себя
Мы с вами совершили глубокое погружение в две самые популярные структуры данных Python. Теперь вы знаете, что:
list— это не просто "список", а мощный динамический массив. Он обеспечивает молниеносный доступ по индексуO(1), но заставляет платить производительностьюO(n)за вставку или удаление элементов в начале или середине.dict— это магия хеш-таблиц, которая позволяет находить, добавлять и удалять элементы в среднем за константное времяO(1), практически не замечая роста объема данных.
Теперь, чтобы проверить и закрепить новые знания, предлагаю вам решить 5 небольших задач. Постарайтесь не просто найти рабочее решение, а подумать, какое из них будет самым эффективным с точки зрения изученной теории.
Задача 1: Подсчет частоты элементов
Условие: Напишите функцию, которая принимает на вход список (например, ['a', 'b', 'a', 'c', 'a', 'b']) и возвращает словарь, где ключами являются элементы списка, а значениями — количество их повторений.
Подсказка: Подумайте, какая структура данных идеально подходит для хранения уникальных ключей и их счетчиков. Можно ли решить это за один проход по списку?
Задача 2: Инвертирование словаря
Условие: Напишите функцию, которая "переворачивает" словарь — меняет ключи и значения местами. Например, из {'a': 1, 'b': 2} должен получиться {1: 'a', 2: 'b'}.
Подсказка: Пройдитесь по парам ключ-значение исходного словаря и сложите их в новый в обратном порядке. Какой важный побочный эффект может возникнуть, если значения в исходном словаре не уникальны?
Задача 3: Группировка по ключу
Условие: У вас есть список словарей, представляющих студентов:
students = [{'name': 'Alice', 'city': 'Minsk'}, {'name': 'Bob', 'city': 'Kyiv'}, {'name': 'Charlie', 'city': 'Minsk'}]
Напишите функцию, которая группирует их по городу. Результат должен быть словарем, где ключи — это города, а значения — списки имен студентов из этого города.
Подсказка: Создайте пустой словарь. Пройдитесь по списку студентов. Для каждого студента проверяйте, есть ли его город в вашем новом словаре. Если нет — создайте ключ с пустым списком, а затем добавьте имя в этот список.
Задача 4: Удаление дубликатов с сохранением порядка
Условие: Напишите функцию, которая принимает список и удаляет из него все дубликаты, сохраняя при этом порядок первого вхождения элементов. Например, из [1, 5, 2, 1, 4, 2, 5] должен получиться [1, 5, 2, 4].
Подсказка: Для эффективной проверки, встречали ли вы элемент ранее, понадобится структура данных с быстрым поиском (O(1)). Создайте пустой результирующий список и вспомогательную коллекцию для "увиденных" элементов.
Задача 5: Слияние двух словарей
Условие: Напишите функцию, которая принимает два словаря и объединяет их. Если в обоих словарях есть одинаковый ключ, значением в итоговом словаре должно стать сумма значений из первого и второго словаря.
Подсказка: Начните с создания копии первого словаря. Затем пройдитесь по второму словарю. Для каждой пары ключ-значение из второго словаря проверяйте, есть ли такой ключ в вашем новом словаре. В зависимости от этого либо добавляйте новую пару, либо обновляйте существующую.
Анонс новых статей, полезные материалы, а так же если в процессе решения возникнут сложности, обсудить их или задать вопрос по статье можно в моём Telegram-сообществе.
Уверен, у вас все получится. Вперед, к практике!
GospodinKolhoznik
Почему то, что называется list является не списком, а массивом? Ну а что вы хотели от языка, который назвали в честь комедийного цирка?