Prosto Python | вопросы с собесов
363 subscribers
184 photos
1 video
2 files
558 links
🚀 Python-собесы без сюрпризов! Разбираем реальные вопросы, ошибки кандидатов и лайфхаки, которые помогают пройти интервью. Джун → мидл → сеньор — прокачивайся и разнеси следующий собес! 🔥
Download Telegram
🎭 Red Flag

Плохой пример
def get_config(path):
try:
with open(path) as f:
return json.load(f)
except Exception:
pass
return {}


Выглядит «надёжно»: что бы ни случилось — функция не упадёт, вернёт пустой конфиг. Программа продолжит работать.
Именно в этом и проблема.

Что не так
except Exception ловит всё подряд, а pass молча проглатывает. Ты теряешь информацию о том, что вообще пошло не так:

файла нет           → молчим, отдаём {}
битый JSON → молчим, отдаём {}
опечатка в коде → молчим, отдаём {} ← а вот это уже баг!
нет прав на чтение → молчим, отдаём {}


Все эти разные беды слиплись в один тихий «всё нормально». Приложение поедет на пустом конфиге, а настоящую причину ты будешь искать часами — потому что в логах пусто.
Хуже того: except Exception способна поймать KeyError/AttributeError от твоей же опечатки внутри try — и баг превратится в невидимку.

Как надо
Лови конкретные ожидаемые исключения, остальное пусть летит наверх:

def get_config(path):
try:
with open(path) as f:
return json.load(f)
except FileNotFoundError:
logging.warning("Конфиг %s не найден, беру дефолт", path)
return {}
except json.JSONDecodeError as e:
logging.error("Конфиг %s битый: %s", path, e)
raise # это уже серьёзно — не глотаем


Теперь видно: чего ты ожидал (файла может не быть — ок) и что должно громко падать (битый JSON — это ошибка деплоя, а не норма).

🐍Вопросы с собесов -> ProstoPython
👍4
⏱️ Big O Breakdown

def running_medians(stream):
seen = []
result = []
for x in stream:
seen.append(x)
s = sorted(seen)
result.append(s[len(s) // 2])
return result


Для каждого нового числа считаем медиану по всему, что пришло. n — длина потока.

Какая сложность по времени?
A) O(n)
B) O(n log n)
C) O(n²)
D) O(n² log n)

Правильный ответ: D — O(n² log n)
Почти все говорят C, забывая про логарифм от сортировки.

Разбор
Считаем по слоям.
Цикл крутится n раз. На каждой итерации внутри прячется sorted(seen):

итерация i:  сортируем список длины i  →  O(i · log i)


Суммируем по всем итерациям:

i=1:  1·log1
i=2: 2·log2
...
i=n: n·log n

каждое слагаемое ≤ n·log n, а слагаемых n штук
→ всего ≤ n · (n log n) = O(n² log n)


Две ловушки сразу:

1) sorted внутри цикла   → лишний множитель n
2) сама сортировка → лишний множитель log n


C (O(n²)) получился бы, будь внутри линейная операция (sum, max). Но сортировка дороже — отсюда log n сверху.

Где это легко прозевать
sorted(seen) — короткая строка, читается как «ну, отсортировали». Но она пересортировывает весь накопленный список заново на каждом шаге, хотя добавился всего один элемент.

🐍Вопросы с собесов -> ProstoPython
👍4
⚖️ This vs That: is vs ==

Путают постоянно. А разница — фундаментальная, и на ней ловят на собесах.

Что делает ==
Сравнивает значения: «равны ли эти объекты по содержимому». Вызывает метод __eq__.

a = [1, 2, 3]
b = [1, 2, 3]
a == b # True — содержимое одинаковое


Что делает
is
Сравнивает идентичность: «это один и тот же объект в памяти». Сравнивает id(), никаких методов не зовёт.

a is b      # False — два разных списка, просто с одинаковым содержимым
a ──► [1, 2, 3] id = 0xAAA
b ──► [1, 2, 3] id = 0xBBB

a == b → сравнивает содержимое → True
a is b → сравнивает адреса → False


Главное отличие в одной фразе


==
спрашивает «равны ли они?»,


is

— «это вообще один и тот же объект?».


🐍Вопросы с собесов -> ProstoPython
🔥4
📈 From O(n·m) to O(n+m)

Задача
Даны два отсортированных массива. Слей их в один отсортированный.
Пример: [1, 3, 5] и [2, 4, 6][1, 2, 3, 4, 5, 6].

Наивное решение
«Соединю и отсортирую — делов-то»

def merge(a, b):
return sorted(a + b)


Работает, коротко. Но мы выбросили главное — то, что массивы уже отсортированы. sorted об этом не знает и сортирует с нуля: O((n+m)·log(n+m)).

Проблема
Платим за сортировку, хотя данные уже почти готовы. Логарифмический множитель здесь лишний — порядок внутри каждого массива нам ничего не стоил, а мы его проигнорировали.

Оптимизированное решение
Два указателя. Идём по обоим массивам одновременно, на каждом шаге берём меньший из текущих элементов.

def merge(a, b):
i = j = 0
result = []
while i < len(a) and j < len(b):
if a[i] <= b[j]:
result.append(a[i])
i += 1
else:
result.append(b[j])
j += 1
# один из массивов кончился — добиваем хвост второго
result.extend(a[i:])
result.extend(b[j:])
return result


Каждый элемент трогаем ровно один раз → O(n+m) время.

Объяснение
Трассировка a = [1, 3, 5], b = [2, 4, 6]:

a: [1, 3, 5]     b: [2, 4, 6]
i j

1 ≤ 2 → берём 1, i→ result: [1]
3 > 2 → берём 2, j→ result: [1, 2]
3 ≤ 4 → берём 3, i→ result: [1, 2, 3]
5 > 4 → берём 4, j→ result: [1, 2, 3, 4]
5 ≤ 6 → берём 5, i→ result: [1, 2, 3, 4, 5]
a кончился → добиваем хвост b: [6]
result: [1, 2, 3, 4, 5, 6]


Указатели только движутся вперёд и никогда не откатываются — отсюда линейность.

🐍Вопросы с собесов -> ProstoPython
👍3
Rookie Mistakes

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


Кажется логичным: не передал корзину — получи пустую. Но запусти дважды:

add_item("apple")    # ['apple']
add_item("banana") # ['apple', 'banana'] ← откуда яблоко?!


Банан попал в ту же корзину, что и яблоко. Хотя мы её вроде не передавали.

Почему это ошибка
Значение по умолчанию вычисляется один раз — в момент определения функции, а не при каждом вызове.

def add_item(...basket=[]):   ← здесь создаётся ОДИН список
и привязывается к функции навсегда


Все вызовы без аргумента используют этот единственный список. Мутируешь его в одном вызове — изменения видны во всех последующих.

вызов 1: basket → [тот самый список] → append("apple")  → ['apple']
вызов 2: basket → [тот же список!] → append("banana") → ['apple', 'banana']


Список живёт между вызовами, как глобальная переменная, о которой ты не просил.
Кусаются именно мутабельные дефолты: [], {}, set(). С неизменяемыми (None, числа, строки, кортежи) проблемы нет — их и так нельзя поменять на месте.

Исправленный вариант
Дефолтом ставь None, а реальный объект создавай внутри — он будет свежим на каждый вызов:

def add_item(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
add_item("apple") # ['apple']
add_item("banana") # ['banana'] ← теперь каждый раз новая корзина


🐍Вопросы с собесов -> ProstoPython
🏆4
🧰 Code Cleanup

Плохой код
def get_discount(user):
if user is not None:
if user.is_active:
if user.subscription is not None:
if user.subscription.is_premium:
return 0.2
else:
return 0.1
else:
return 0.0
else:
return 0.0
return 0.0


Логика простая, а читать невозможно: четыре уровня вложенности, «лесенка», и где какой else относится — глаза ломаются. Главное действие утонуло в самой глубине.

Чистый вариант

def get_discount(user):
if user is None:
return 0.0
if not user.is_active:
return 0.0
if user.subscription is None:
return 0.0

return 0.2 if user.subscription.is_premium else 0.1


Те же условия — но «плохие» случаи отсекаем сразу и выходим. Что осталось до конца функции — это счастливый путь.

Объяснение
Приём называется guard clauses (защитные проверки): вместо того чтобы заворачивать основную логику во вложенные if, ты в начале функции отбрасываешь всё, что мешает, через ранний return.

Что меняется:
было:  условие → углубляемся → условие → углубляемся → …
(читать надо, держа в голове весь стек вложенности)

стало: не то? → выход.
не то? → выход.
дошли сюда — значит всё ок, делаем дело.


🐍Вопросы с собесов -> ProstoPython
👍3🔥1
🧠 Что выведет код

grid = [[0] * 3] * 3
grid[0][0] = 1
print(grid)


Варианты:

A) [[1, 0, 0], [0, 0, 0], [0, 0, 0]]
B) [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
C) [[1, 1, 1], [0, 0, 0], [0, 0, 0]]
D) Ошибка

Правильный ответ: B
Меняли одну ячейку — «поплыла» вся колонка. На этом теряют часы отладки.

Разбор
Тут две разные * 3, и они делают не одно и то же.

[0] * 3        →  [0, 0, 0]   три НОВЫХ нуля (числа неизменяемы, копий не надо)
[...] * 3 → три ссылки на ОДИН И ТОТ ЖЕ список


* 3
для внешнего списка не копирует внутренний. Он трижды кладёт ссылку на один объект:

grid ──► [ • , • , • ]
│ │ │
└───┴───┘

[0, 0, 0] ← одна строка на всех


Поэтому grid[0], grid[1], grid[2] — это один и тот же список. Меняешь через любой — видишь во всех.

🐍Вопросы с собесов -> ProstoPython
🔥3
🧠 Interview Thinking

Задача
Дан массив. Передвинь все нули в конец, сохранив порядок остальных элементов. Меняй на месте, без нового массива.
Пример: [0, 1, 0, 3, 12][1, 3, 12, 0, 0].

Как думает junior
«Соберу ненулевые в новый список, потом добью нулями.»

def move_zeroes(nums):
result = [x for x in nums if x != 0]
result += [0] * (len(nums) - len(result))
return result

Логика верная. Но условие сказало на месте — а мы завели новый массив, O(n) доп. памяти. И ничего не меняем в исходном (вернули новый).
На собесе тут же спросят: «А без выделения второго массива?»

Как думает сильный кандидат
Инсайт: не «двигать нули», а подтягивать ненулевые вперёд. Нули окажутся в конце сами.

Два указателя, оба в одном массиве:
insert — куда класть следующий ненулевой элемент;
i — бежит по массиву и ищет ненулевые.

def move_zeroes(nums):
insert = 0
for i in range(len(nums)):
if nums[i] != 0:
nums[insert], nums[i] = nums[i], nums[insert]
insert += 1


O(n)
время, O(1) память. Меняем исходный массив.

🐍Вопросы с собесов -> ProstoPython
👍4
⏱️ Big O Breakdown

def process_queue(tasks):
while tasks:
task = tasks.pop(0) # берём из начала
handle(task)


Обрабатываем задачи по очереди — берём из начала списка, пока не опустеет. n — число задач.

Какая общая сложность по времени?
A) O(n)
B) O(n log n)
C) O(n²)
D) O(1)

Правильный ответ: C — O(n²)
Цикл выглядит линейным, но pop(0) всё портит.

Разбор
list.pop(0) берёт первый элемент. Но список в Python — это массив: элементы лежат подряд в памяти, индексы привязаны к позиции.
Убрал нулевой элемент → дырка в начале → все остальные надо сдвинуть на одну ячейку влево:

[A, B, C, D]
↑ pop(0)

убираем A, сдвигаем хвост:
[B, C, D]
└─ B, C, D переехали ← это O(n)


🐍Вопросы с собесов -> ProstoPython
👍4
⚖️ This vs That: yield vs return
Оба «отдают» значение из функции. Но yield превращает функцию в совсем другого зверя.

Что делает return
Возвращает значение и завершает функцию. Всё, она отработала, состояние стёрто.

def get_numbers():
return [1, 2, 3] # посчитали ВЕСЬ список сразу, отдали, вышли


Что делает
yield
Отдаёт значение и ставит функцию на паузу, запоминая, где остановилась. При следующем запросе — продолжает с того же места.

def get_numbers():
yield 1 # отдал 1, замер
yield 2 # продолжил, отдал 2, замер
yield 3


Функция с yield — это генератор: значения выдаются по одному, лениво, по требованию.

return:  [█████████]  всё посчитано и лежит в памяти целиком
yield: █ → █ → █ по одному, считается ровно когда нужно


Главное отличие в одной фразе

return
отдаёт всё сразу и забывает функцию.

yield
отдаёт по одному и помнит, где остановился.



🐍Вопросы с собесов -> ProstoPython
🔥4
Rookie Mistakes

Сравнение float через ==

total = 0.1 + 0.2

if total == 0.3:
print("оплата прошла")
else:
print("ошибка суммы")


Логично же: 0.1 + 0.2 это 0.3. Запускаем:

ошибка суммы


Python считает, что 0.1 + 0.2 != 0.3. Проверим в лоб:

>>> 0.1 + 0.2
0.30000000000000004


Почему это ошибка

Компьютер хранит дробные числа в двоичной системе. А 0.1, 0.2, 0.3 в двоичной — это бесконечные дроби (как 1/3 = 0.333... в десятичной). Их приходится обрезать под размер float.

0.1  →  хранится как ≈ 0.1000000000000000055...
0.2 → хранится как ≈ 0.2000000000000000111...
сумма → ≈ 0.3000000000000000444...

а 0.3 само по себе → ≈ 0.2999999999999999888...

0.30000...04 ≠ 0.29999...88 → False


Это не баг Python — так устроена арифметика с плавающей точкой везде (стандарт IEEE 754). В Java, C, JS — ровно то же самое.

🐍Вопросы с собесов -> ProstoPython
👍4
📈 From O(n²) to O(n)

Задача
Дана строка. Найди длину самой длинной подстроки, в которой нет повторяющихся символов.
Пример: "abcabcbb"3 (это "abc").

Наивное решение
«Проверю все подстроки: для каждого начала тяну конец, пока символы не повторятся.»

def longest_unique(s):
best = 0
for i in range(len(s)):
seen = set()
for j in range(i, len(s)):
if s[j] in seen:
break
seen.add(s[j])
best = max(best, j - i + 1)
return best


Корректно. Но для каждого старта i мы заново бежим вправо — O(n²).

Проблема
Когда натыкаемся на повтор, мы выбрасываем всю работу и начинаем со следующего символа с нуля. А ведь часть уже проверенного окна всё ещё валидна — её незачем пересчитывать.

Оптимизированное решение
Скользящее окно (sliding window). Держим окно [left..right] без повторов. Двигаем right вперёд; если символ уже в окне — подтягиваем left, выкидывая «хвост» до дубликата.

def longest_unique(s):
seen = {} # символ → его последний индекс
left = 0
best = 0
for right, ch in enumerate(s):
if ch in seen and seen[ch] >= left:
left = seen[ch] + 1 # сдвигаем левую границу за дубликат
seen[ch] = right
best = max(best, right - left + 1)
return best


O(n)
время: каждый символ обрабатывается один раз. left и right только движутся вперёд.

🐍Вопросы с собесов -> ProstoPython
🔥3
🧠 Что выведет код

def f():
print(x)
x = 10

x = 5
f()


Варианты:

A) 5
B) 10
C) None
D) UnboundLocalError

Правильный ответ: D)

Снаружи x = 5 существует. А функция всё равно падает. Почему?

Разбор

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

def f():
print(x) # ← пытаемся прочитать x
x = 10 # ← из-за этой строки x ЛОКАЛЬНА во всей f


Из-за x = 10 ниже Python ещё при компиляции решает: «x здесь локальная». Глобальная x = 5 для функции больше не видна — её заслонила локальная.
А на строке print(x) локальной x ещё не присвоили значение:

f() вызвана

x объявлена локальной (из-за присваивания ниже)

print(x) → читаем локальную x, которой ещё нет → 💥 UnboundLocalError


Ключевой момент: дело не в порядке строк сверху вниз. Даже если print(x) идёт первым — наличие присваивания где угодно в функции делает переменную локальной везде.

🐍Вопросы с собесов -> ProstoPython
👍4
🧰 Code Cleanup

Плохой код
def get_handler(command):
if command == "start":
return start_game()
elif command == "pause":
return pause_game()
elif command == "resume":
return resume_game()
elif command == "stop":
return stop_game()
else:
return unknown_command()


Работает. Но это «лестница»: каждая новая команда — ещё два elif. И вся структура — однотипная: сравнили строку → вызвали функцию.

Чистый вариант
HANDLERS = {
"start": start_game,
"pause": pause_game,
"resume": resume_game,
"stop": stop_game,
}

def get_handler(command):
handler = HANDLERS.get(command, unknown_command)
return handler()


Соответствие «команда → функция» стало данными, а не кодом. Добавить команду — одна строка в словарь, а не новая ветка.

Объяснение
Длинный if/elif, который сравнивает одну и ту же переменную с разными значениями — это замаскированная таблица соответствий. А таблицу естественнее хранить в словаре.

🐍Вопросы с собесов -> ProstoPython
🔥4
🧠 Interview Thinking

Задача
Даны две строки. Определи, являются ли они анаграммами — то есть состоят из одних и тех же символов в одинаковом количестве.
Пример: "listen" и "silent"True; "rat" и "car"False.

Как думает junior
«Анаграммы — это одинаковый набор букв. Отсортирую обе строки и сравню.»

def is_anagram(s, t):
return sorted(s) == sorted(t)


Коротко и работает. Это абсолютно валидный ответ — не стыдись его. Но сортировка — это O(n log n), и на собесе спросят: «А можешь за линию?»

Как думает сильный кандидат
Инсайт: мне не нужен порядок букв — нужно только сколько раз каждая встречается. А подсчёт — это O(n), без всякой сортировки.
Считаю частоты в обеих строках и сравниваю:

from collections import Counter

def is_anagram(s, t):
return Counter(s) == Counter(t)


O(n)
время. Counter строит словарь {буква: количество}, а == сравнивает их целиком.

Хочешь показать, что понимаешь, что под капотом, — можно и руками одним словарём:
def is_anagram(s, t):
if len(s) != len(t): # разной длины — точно не анаграммы
return False
counts = {}
for ch in s:
counts[ch] = counts.get(ch, 0) + 1
for ch in t:
if ch not in counts:
return False
counts[ch] -= 1
if counts[ch] == 0:
del counts[ch]
return not counts # пусто → всё сошлось


🐍Вопросы с собесов -> ProstoPython
👍4
🎭 Red Flag

Плохой пример
def process(value):
if type(value) == list:
return [v * 2 for v in value]
if type(value) == dict:
return {k: v * 2 for k, v in value.items()}
raise TypeError("неподдерживаемый тип")


Работает на обычных списках и словарях. Но проверка типа через type(x) == — хрупкая, и однажды она тихо тебя подведёт.

Что не так
type(x) == list проверяет, что тип — ровно list, и ничего больше. Наследников он не признаёт.

from collections import OrderedDict

d = OrderedDict(a=1, b=2)
type(d) == dict # False ! — а ведь это словарь по сути
isinstance(d, dict) # True


OrderedDict
, defaultdict, Counter — все наследуют dict и ведут себя как словари. Но type(x) == dict их отвергнет, и твоя функция упадёт на совершенно валидных данных.
Это нарушает подстановку Лисков: подкласс должен работать везде, где работает базовый. type() == ломает этот принцип на ровном месте.

Как надо
isinstance проверяет «является ли x этим типом или его наследником» — то есть то, что обычно и нужно:

def process(value):
if isinstance(value, list):
return [v * 2 for v in value]
if isinstance(value, dict): # ловит и OrderedDict, и defaultdict
return {k: v * 2 for k, v in value.items()}
raise TypeError("неподдерживаемый тип")


🐍Вопросы с собесов -> ProstoPython
🏆4
⏱️ Big O Breakdown

def join_words(words):
result = ""
for word in words:
result += word + " "
return result


Склеиваем слова в одну строку. n — число слов (для простоты считаем их сопоставимой длины).

Какая сложность по времени?
A) O(n)
B) O(n log n)
C) O(n²)
D) O(1)

Правильный ответ: C

Один цикл, простое += — а под капотом квадрат.

Разбор
Главная ловушка: строки в Python неизменяемы. result += word не дописывает в существующую строку — он создаёт новую, копируя в неё всё старое содержимое плюс новый кусок.

result += word  →  на самом деле:  result = result + word
(новая строка, копия всего)


А копирование строки длины k стоит O(k). На каждой итерации result всё длиннее:

итерация 1:  копируем строку длины ~1
итерация 2: копируем длины ~2
итерация 3: копируем длины ~3
...
итерация n: копируем длины ~n

всего: 1 + 2 + 3 + ... + n = O(n²)


Строка переписывается заново снова и снова. Каждый символ из начала копируется десятки тысяч раз.

🐍Вопросы с собесов -> ProstoPython
❤‍🔥4
💾 Memory Footprint: чтение файла целиком vs построчно

def count_errors(path):
lines = open(path).readlines()
return sum(1 for line in lines if "ERROR" in line)


Считаем строки со словом ERROR в лог-файле. n — число строк в файле.

Какая сложность по ПАМЯТИ?
A) O(1)
B) O(log n)
C) O(n)
D) Зависит от числа ошибок

Правильный ответ: C

По времени тут честный O(n).

А вот по памяти — ловушка, которую почти не замечают.

Разбор
readlines() читает весь файл сразу и возвращает список всех строк:

readlines()  →  ["строка1\n", "строка2\n", ..., "строкаN\n"]
весь файл целиком лежит в памяти


Это список из n строк → O(n) памяти. На лог-файле в 10 ГБ программа просто упадёт с MemoryError, хотя нам нужно-то всего одно число — счётчик.
Ключевое наблюдение: нам не нужны все строки одновременно. Мы смотрим на каждую по разу и забываем. Зачем держать их все в памяти?

Как починить — O(1)
Файловый объект сам по себе итерируемый: можно идти по строкам, не загружая файл целиком. Каждая строка читается, обрабатывается и выбрасывается.

def count_errors(path):
with open(path) as f:
return sum(1 for line in f if "ERROR" in line)
readlines(): [строка1][строка2]...[строкаN] ← всё в памяти, O(n)
итерация по f: строка → обработали → забыли ← одна за раз, O(1)


В памяти в любой момент — одна строка.

🐍Вопросы с собесов -> ProstoPython
👍4
💾 Memory Footprint: генератор vs списковое включение

nums = range(1_000_000)

a = [x * x for x in nums]
b = (x * x for x in nums)

print(sum(a))
print(sum(b))


Обе строки «создают квадраты». sum от обеих даст одинаковый результат. Но память они едят по-разному.

Сколько памяти держит b (в круглых скобках)?
A) O(n), как и a
B) O(1)
C) O(log n)
D) Ошибка — генератор нельзя суммировать


Правильный ответ: B

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

Разбор
a = [x * x for x in nums]    # список: считает и хранит ВСЕ значения сразу
b = (x * x for x in nums) # генератор: не считает ничего, пока не спросят


a
списковое включение. Оно сразу вычисляет миллион квадратов и складывает их в список. Это O(n) памяти — все элементы лежат одновременно.
bгенераторное выражение. Оно не вычисляет ничего наперёд. Это рецепт: «при запросе выдай следующий квадрат». Значения рождаются по одному во время итерации и тут же выбрасываются.

a:  [0, 1, 4, 9, ..., n²]      ← весь миллион в памяти, O(n)
b: → 0 → 1 → 4 → ... ← по одному, O(1)
(в памяти всегда один элемент)


sum(b)
идёт по генератору: берёт квадрат, прибавляет к сумме, забывает, берёт следующий. В памяти в каждый момент — одно число.

🐍Вопросы с собесов -> ProstoPython
🔥4
🧠 Interview Thinking

Задача
Дан массив чисел. Определи, есть ли в нём хотя бы один повторяющийся элемент.
Пример: [1, 2, 3, 1]True; [1, 2, 3, 4]False.

Как думает junior
«Для каждого элемента проверю, встречается ли он дальше. Сравню все пары.»

def contains_duplicate(nums):
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] == nums[j]:
return True
return False


Корректно. Но это O(n²) — два вложенных цикла. На большом массиве собес ляжет.

Как думает сильный кандидат
Инсайт: вопрос «видел ли я это число раньше?» — это вопрос про принадлежность множеству. А in по set — это O(1).
Иду по массиву один раз, складываю увиденное в set. Если новый элемент уже там — нашли дубликат.

def contains_duplicate(nums):
seen = set()
for num in nums:
if num in seen:
return True
seen.add(num)
return False


O(n)
время, O(n) память. И ранний выход — на первом же повторе.

🐍Вопросы с собесов -> ProstoPython
👍4