// python interview / collections

Вопросы на собеседовании: Коллекции и структуры данных

list, tuple, dict и set изнутри: хешируемость, копирование, генераторы и стандартная библиотека — вопросы почти каждого собеса. Здесь — топ-15 по частоте на реальных собесах: у первых вопросов открыт полный разбор, у остальных — устный эталон. Весь банк темы (29 вопросов) с разборами — в приложении.

Открыть тему в приложении каждый день бесплатно: 3 эталона и 3 проверки арбитром

junior: база, с которой начинают

Расскажи, чем list отличается от tuple и в каких случаях ты выберешь кортеж вместо списка?

как ответить List — изменяемый динамический массив: можно добавлять, удалять и заменять элементы. Tuple неизменяем: после создания состав и длина фиксированы, зато он легче по памяти и хешируем, поэтому может быть ключом словаря или элементом множества. Tuple беру для фиксированных «записей» вроде координат или возврата нескольких значений из функции, list — когда коллекция будет меняться или её длина заранее неизвестна.

разбор

Обе структуры — массивы указателей на объекты, поэтому доступ по индексу в обеих O(1). Разница в контракте: list поддерживает append, pop, присваивание по индексу и потому перевыделяет память с запасом; tuple создаётся один раз ровно нужного размера, из-за чего компактнее и чуть быстрее создаётся.

Ключевое практическое следствие — хешируемость: tuple можно класть в set и использовать ключом dict, list — нельзя. Но tuple хешируем, только если хешируемы все его элементы: (1, [2]) в ключ словаря не пойдёт.

Типичная ловушка, которой добивают на follow-up: неизменяемость tuple — поверхностная (shallow). Кортеж хранит ссылки, и если внутри лежит список, содержимое этого списка менять можно — «заморожены» только сами ссылки.

Есть и семантическая конвенция: tuple — гетерогенная запись фиксированной структуры ((lat, lon), строка из БД), list — однородная последовательность произвольной длины. Если нужны именованные поля, следующая ступень — namedtuple или dataclass(frozen=True).

чтобы прозвучать сильнее Упомяни, что неизменяемость tuple — поверхностная (внутри могут лежать изменяемые объекты), и что list перевыделяет память с запасом, а tuple — нет, поэтому на больших объёмах однотипных «записей» кортежи заметно экономят память.

python-collections-001 · junior · high

Как dict устроен внутри и за счёт чего доступ по ключу работает за O(1)?

как ответить Dict — это хеш-таблица: от ключа берётся hash(), по нему вычисляется слот в массиве, коллизии разрешаются открытой адресацией — пробированием других слотов. Поэтому поиск, вставка и удаление в среднем O(1), в худшем случае при массовых коллизиях — O(n). Ключи обязаны быть хешируемыми, а сам dict гарантированно сохраняет порядок вставки.

разбор

Схема поиска: hash(key) → индекс слота → если слот занят чужим ключом, пробируем следующие слоты по детерминированной последовательности (open addressing с «пертурбацией» битов хеша). Сравнение ключей идёт сначала по identity, потом через ==.

CPython использует компактную раскладку: отдельно разреженный индексный массив, отдельно плотный массив записей в порядке вставки — отсюда и гарантированный порядок ключей при итерации, и экономия памяти. Когда таблица заполняется примерно на две трети, происходит resize с перехешированием — поэтому вставка тоже амортизированная O(1).

Типичные follow-up'ы:

  • почему list не может быть ключом — он изменяемый и не хешируемый: если бы ключ «уехал» после вставки, его было бы не найти;
  • контракт __hash__/__eq__: равные объекты обязаны иметь равный хеш; если в классе переопределить __eq__ и не задать __hash__, объект перестаёт быть хешируемым;
  • что деградирует до O(n): все ключи с одинаковым хешем попадают в одну цепочку проб — на этом строятся hash-DoS-атаки, поэтому хеш строк в Python рандомизирован между запусками.

чтобы прозвучать сильнее Расскажи про compact dict (индексный массив + плотный массив записей — отсюда порядок вставки и экономия памяти), порог заполнения ~2/3 с resize и рандомизацию хеша строк как защиту от hash-DoS.

python-collections-002 · junior · high

Что такое set, как он устроен и в каких задачах ты его реально используешь вместо списка?

как ответить Set — коллекция уникальных хешируемых элементов, внутри та же хеш-таблица, что у dict, только без значений. Проверка x in s, добавление и удаление — в среднем O(1), тогда как у списка in — это O(n) линейный проход. Беру его для дедупликации, быстрых проверок принадлежности и операций над множествами: пересечение, объединение, разность.

полный разбор и проверка ответа арбитром — в приложении python-collections-003 · junior · high

Что может быть ключом словаря или элементом множества, а что — нет, и почему вообще есть такое ограничение?

как ответить Ключом dict и элементом set может быть только хешируемый объект: у него есть __hash__, возвращающий одно и то же значение всю жизнь объекта, и согласованный с ним __eq__. Хешируемы неизменяемые встроенные типы — числа, строки, bytes, кортежи из хешируемых элементов, frozenset; изменяемые list, dict, set — нет. Ограничение нужно потому, что dict ищет ключ по его хешу: изменился хеш — ключ уже не найти.

полный разбор и проверка ответа арбитром — в приложении python-collections-004 · junior · high

Смотри на функцию с аргументом по умолчанию — что выведут два вызова и почему?

def add_item(item, bucket=[]):
    bucket.append(item)
    return bucket

print(add_item(1))
print(add_item(2))

как ответить Выведет [1], потом [1, 2]. Значение по умолчанию вычисляется один раз — в момент выполнения def, и один и тот же список живёт в объекте функции между вызовами. Оба вызова мутируют его. Правильный паттерн — сентинел: bucket=None и внутри if bucket is None: bucket = [].

полный разбор и проверка ответа арбитром — в приложении python-collections-005 · junior · high

Чем поверхностная копия отличается от глубокой — copy vs deepcopy — и когда ты берёшь какую?

как ответить Shallow copy — copy.copy, list(a), a[:], dict.copy — создаёт новый внешний контейнер, но элементы в нём те же самые объекты, копируются только ссылки. deepcopy рекурсивно копирует и вложенные объекты, так что структуры полностью независимы. Shallow хватает, когда элементы иммутабельны или общие вложенные объекты — это ок; deepcopy нужен для вложенных изменяемых структур, но он заметно дороже.

полный разбор и проверка ответа арбитром — в приложении python-collections-006 · junior · high

Присвоил b = a, где a — список, добавил элемент в b — и он появился в a. Расскажи, что тут происходит и как получить независимую копию.

как ответить Присваивание в Python никогда не копирует: оно привязывает ещё одно имя к тому же объекту. a и b — два алиаса одного списка, id(a) == id(b), поэтому мутация через любое имя видна через оба. Независимую копию даёт a.copy(), list(a) или срез a[:] — это shallow copy; для вложенных структур — copy.deepcopy.

полный разбор и проверка ответа арбитром — в приложении python-collections-007 · junior · high

Чем генераторное выражение отличается от list comprehension и когда на практике берёшь одно, а когда другое?

как ответить List comprehension сразу строит весь список в памяти, генераторное выражение возвращает ленивый итератор — элементы вычисляются по одному, когда их запрашивают, память почти константная. Генератор беру для одного прохода по большим данным и передачи в sum, any, max; список — когда нужны len, индексация или несколько проходов. Важно: генератор одноразовый — после исчерпания он пуст.

полный разбор и проверка ответа арбитром — в приложении python-collections-008 · junior · high

Расскажи, как под капотом работает цикл for в Python — что такое iterable и iterator и чем они отличаются?

как ответить for вызывает у объекта iter(), получает итератор и дальше зовёт на нём next(), пока тот не бросит StopIteration — это и есть протокол итерации. Iterable — всё, у чего есть __iter__ и что можно обходить (список, строка, dict); iterator — объект с __next__, который сам хранит позицию и отдаёт элементы по одному. Список можно обойти много раз, потому что iter() каждый раз даёт свежий итератор, а генератор — сам итератор, поэтому он одноразовый.

полный разбор и проверка ответа арбитром — в приложении python-collections-009 · junior · high

Почему склеивать строки через += в цикле — плохая идея и что использовать вместо этого?

как ответить Строки в Python иммутабельны: каждый += создаёт новую строку и копирует в неё обе части, поэтому в цикле получается квадратичная сложность O(n²). Правильный способ — накапливать куски в список и в конце склеить одним вызовом ''.join(parts): он заранее считает итоговую длину и собирает результат за O(n). Для пары-тройки строк разницы нет — там нормальны и f-строки.

полный разбор и проверка ответа арбитром — в приложении python-collections-010 · junior · high

Смотри на код: кортеж неизменяемый, внутри него список. Что выведет и почему?

t = (1, [2, 3])
try:
    t[1] += [4, 5]
except TypeError:
    print("TypeError")
print(t)

как ответить Выведет TypeError, а затем (1, [2, 3, 4, 5]) — исключение будет, но список внутри кортежа всё равно изменится. t[1] += [4, 5] — это два шага: сначала __iadd__ списка расширяет его на месте, потом Python пытается записать результат обратно в t[1], и вот это присваивание в кортеж падает. Мутация к тому моменту уже произошла.

полный разбор и проверка ответа арбитром — в приложении python-collections-011 · junior · medium

middle: где отделяют уверенных

Ты переопределяешь у своего класса __eq__. Расскажи про контракт __hash__ и __eq__ — что нужно соблюсти, чтобы объекты корректно работали в dict и set?

как ответить Главное правило: если a == b, то hash(a) обязан быть равен hash(b); обратное не требуется — равные хеши у неравных объектов это просто коллизия. Если определить __eq__ и не трогать __hash__, Python сам выставит __hash__ = None, и объект станет нехешируемым. Поэтому хеш определяют по тем же полям, что участвуют в __eq__, и только по неизменяемым — обычно hash от кортежа полей или dataclass(frozen=True).

полный разбор и проверка ответа арбитром — в приложении python-collections-013 · middle · high

Расскажи, как живёт генератор между вызовами next — и что он умеет, кроме простой выдачи значений?

как ответить Генератор хранит замороженный фрейм — локальные переменные и точку останова на yield, и каждый next() продолжает исполнение с этого места. Кроме next есть send(), который возобновляет генератор и делает yield выражением с переданным значением; throw() бросает исключение в точке остановки; close() кидает GeneratorExit, давая отработать finally. Плюс yield from делегирует вложенному генератору, прозрачно пробрасывая send и исключения.

полный разбор и проверка ответа арбитром — в приложении python-collections-014 · middle · high

Расскажи про модуль collections: чем defaultdict, Counter и deque отличаются от обычных dict и list, и когда ты их реально берёшь?

как ответить defaultdict при обращении к отсутствующему ключу вызывает default_factory и вставляет результат — удобно для группировок: d[key].append(x) без проверок. Counter — словарь-счётчик: принимает iterable, отдаёт most_common(n), поддерживает сложение и вычитание счётчиков. deque — двусторонняя очередь на связанных блоках: append/pop с обоих концов за O(1), тогда как list.pop(0) — O(n); плюс maxlen превращает её в кольцевой буфер для «последних N».

полный разбор и проверка ответа арбитром — в приложении python-collections-015 · middle · high

Что выведет этот код и почему? Какую роль здесь играет стабильность сортировки?

records = [("math", 90), ("eng", 90), ("math", 70), ("eng", 80)]
records.sort(key=lambda r: r[1], reverse=True)
records.sort(key=lambda r: r[0])
print(records)

как ответить Выведет [('eng', 90), ('eng', 80), ('math', 90), ('math', 70)]. Сортировка в Python стабильна: элементы с равным ключом сохраняют взаимный порядок. Первый проход выстраивает по баллам по убыванию, второй сортирует по предмету — и внутри каждого предмета порядок по баллам из первого прохода не разрушается. Это стандартный приём многоключевой сортировки: несколько проходов от младшего ключа к старшему.

полный разбор и проверка ответа арбитром — в приложении python-collections-016 · middle · high

Соседние темы того же собеса