Два указателя
Введение в метод двух указателей
Введение в метод двух указателей
Интуиция
Как следует из названия, метод двух указателей — это алгоритм, использующий два указателя. Но что такое указатель? Это переменная, которая представляет индекс или позицию внутри структуры данных, например массива или связного списка. Многие алгоритмы используют лишь один указатель для отслеживания одного элемента:
Введение второго указателя открывает новые возможности. Самое главное — теперь мы можем выполнять сравнения. Имея указатели в двух разных позициях, мы можем сравнивать элементы в этих позициях и принимать решения на основе результатов сравнения:
Во многих случаях такие сравнения выполняются с помощью двух вложенных циклов for, что занимает O(n^2) времени, где n — длина структуры данных. В фрагменте кода ниже i и j — два указателя, используемых для сравнения каждых двух элементов массива:
for i in range(n):
for j in range(i + 1, n):
compare(nums[i], nums[j])
Зачастую этот подход не использует предсказуемые свойства, которые могут существовать в структуре данных. Пример структуры данных с предсказуемыми свойствами — отсортированный массив: когда мы перемещаем указатель в отсортированном массиве, мы можем предсказать, будет ли следующее значение больше или меньше. Например, перемещение указателя вправо в массиве, отсортированном по возрастанию, гарантирует переход к значению, большему или равному текущему:
Как видно, структуры данных с предсказуемыми свойствами позволяют перемещать указатели логичным образом. Использование этой предсказуемости может привести к улучшению временной и пространственной сложности, что мы продемонстрируем на реальных задачах для собеседований в этой главе.
Стратегии двух указателей
Алгоритмы с двумя указателями обычно занимают O(n) времени, устраняя необходимость в вложенных циклах. Существует три основные стратегии использования двух указателей.
Встречный обход При этом подходе указатели начинают с противоположных концов структуры данных и движутся навстречу друг другу:
Указатели движутся к центру, корректируя свои позиции на основе сравнений, пока не выполнится определённое условие или они не встретятся/пересекутся. Это идеально для задач, где нужно сравнивать элементы с разных концов структуры данных.
Однонаправленный обход При этом подходе оба указателя начинают с одного конца структуры данных (обычно с начала) и движутся в одном направлении:
Эти указатели обычно служат двум разным, но взаимодополняющим целям. Распространённое применение — когда один указатель ищет информацию (обычно правый), а другой отслеживает информацию (обычно левый).
Поэтапный обход При этом подходе мы обходим структуру одним указателем, и когда он попадает на элемент, соответствующий определённому условию, начинаем обход вторым указателем:
Аналогично однонаправленному обходу, оба указателя служат разным целям. Здесь первый указатель используется для поиска чего-либо, а после нахождения второй указатель находит дополнительную информацию, связанную со значением у первого указателя.
Все эти техники подробно рассматриваются в задачах данной главы.
Когда использовать два указателя?
Алгоритм с двумя указателями обычно требует линейной структуры данных, например массива или связного списка. Признак того, что задача решается с помощью этого алгоритма, — когда входные данные подчиняются предсказуемой логике, например представляют собой отсортированный массив.
Предсказуемые свойства могут проявляться по-разному. Возьмём, например, палиндромную строку. Её симметричная структура позволяет логично перемещать два указателя к центру. По мере решения задач в этой главе вы научитесь лучше распознавать такие предсказуемые свойства.
Ещё один возможный признак применимости двух указателей — когда задача требует найти пару значений или результат, получаемый из двух значений.
Пример из реальной жизни
Алгоритмы сборки мусора: при уплотнении памяти — ключевой части сборки мусора — цель состоит в освобождении непрерывного пространства памяти путём устранения пробелов, оставленных освобождёнными (мёртвыми) объектами. Техника двух указателей помогает добиться этого эффективно: указатель «scan» обходит кучу для обнаружения живых объектов, а указатель «free» отслеживает следующее доступное место, куда следует переместить живые объекты. По мере движения указателя «scan» он пропускает мёртвые объекты и перемещает живые на позицию, указанную указателем «free», уплотняя память путём группировки всех живых объектов и освобождения непрерывных блоков памяти.
Обзор главы
Метод двух указателей очень универсален и, следовательно, достаточно широк. Поэтому более специализированные варианты этого алгоритма рассматриваются в отдельных главах, например быстрый и медленный указатели (Fast and Slow Pointers) и скользящее окно (Sliding Windows).
Сумма пары — отсортированный массив
Сумма пары — отсортированный массив
Дан массив целых чисел, отсортированных в порядке возрастания, и целевое значение. Верните индексы любой пары чисел в массиве, сумма которых равна целевому значению. Порядок индексов в результате не важен. Если пара не найдена, верните пустой массив.
Пример 1:
Input: nums = [-5, -2, 3, 4, 6], target = 7
Output: [2, 3]
Пояснение: nums[2] + nums[3] = 3 + 4 = 7
Пример 2:
Input: nums = [1, 1, 1], target = 2
Output: [0, 1]
Пояснение: другими допустимыми ответами могут быть [1, 0], [0, 2], [2, 0], [1, 2] или [2, 1].
Интуиция
Метод грубой силы для этой задачи предполагает проверку всех возможных пар. Это делается с помощью двух вложенных циклов: внешний цикл обходит массив в поисках первого элемента пары, а внутренний — оставшуюся часть массива для поиска второго элемента. Ниже представлен код для этого подхода:
from typing import List
def pair_sum_sorted_brute_force(nums: List[int], target: int) -> List[int]:
n = len(nums)
for i in range(n):
for j in range(i + 1, n):
if nums[i] + nums[j] == target:
return [i, j]
return []
Этот подход имеет временную сложность O(n^2), где n — длина массива. Данный подход не учитывает, что входной массив отсортирован. Можно ли использовать этот факт для разработки более эффективного решения?
Здесь стоит рассмотреть подход с двумя указателями, поскольку отсортированный массив позволяет логично перемещать указатели. Посмотрим, как это работает на примере ниже:
Хорошей отправной точкой является рассмотрение наименьшего и наибольшего значений: первого и последнего элементов соответственно. Сумма этих двух значений равна 1.
Поскольку 1 меньше целевого значения, нам нужно переместить один из указателей, чтобы найти новую пару с большей суммой.
- Левый указатель: левый указатель всегда будет указывать на значение, меньшее или равное значению у правого указателя, поскольку массив отсортирован. Его увеличение приведёт к сумме, большей или равной текущей сумме 1.
- Правый указатель: уменьшение правого указателя приведёт к сумме, меньшей или равной 1.
Следовательно, нам следует увеличить левый указатель, чтобы найти большую сумму:
Снова сумма значений у этих двух указателей (4) слишком мала. Увеличим левый указатель:
Теперь сумма (9) слишком велика. Нам следует уменьшить правый указатель, чтобы найти пару значений с меньшей суммой:
Наконец, мы нашли два числа, дающих сумму, равную целевому значению. Вернём их индексы:
Выше мы продемонстрировали алгоритм с двумя указателями, используя встречный обход. Подведём итог этой логики. Для любой пары значений у left и right:
- Если их сумма меньше целевого значения, увеличиваем left, стремясь приблизить сумму к целевому значению.
- Если их сумма больше целевого значения, уменьшаем right, стремясь приблизить сумму к целевому значению.
- Если их сумма равна целевому значению, возвращаем [left, right].
Мы можем прекратить перемещение указателей left и right, когда они встретятся, поскольку это указывает на отсутствие пары с суммой, равной целевому значению.
Реализация
from typing import List
def pair_sum_sorted(nums: List[int], target: int) -> List[int]:
left, right = 0, len(nums) - 1
while left < right:
sum = nums[left] + nums[right]
# If the sum is smaller, increment the left pointer, aiming to increase the
# sum towards the target value.
if sum < target:
left += 1
# If the sum is larger, decrement the right pointer, aiming to decrease the
# sum towards the target value.
elif sum > target:
right -= 1
# If the target pair is found, return its indexes.
else:
return [left, right]
return []
Анализ сложности
Временная сложность: временная сложность pair_sum_sorted равна O(n), поскольку в худшем случае мы выполняем примерно n итераций с использованием техники двух указателей.
Пространственная сложность: мы выделяем лишь константное количество переменных, поэтому пространственная сложность равна O(1).
Тестовые случаи
В дополнение к уже рассмотренным примерам, вот несколько других тестовых случаев. Эти дополнительные тесты охватывают различные контексты, чтобы убедиться, что код хорошо работает на разных входных данных. Тестирование важно, поскольку оно помогает выявить ошибки в коде, убедиться в работоспособности решения для нестандартных входных данных и обратить внимание на упущенные случаи.
| Input | Expected output | Description |
|---|---|---|
nums = [] target = 0 | [] | Tests an empty array. |
nums = [1] target = 1 | [] | Tests an array with just one element. |
nums = [2, 3] target = 5 | [0, 1] | Tests a two-element array that contains a pair that sums to the target. |
nums = [2, 4] target = 5 | [] | Tests a two-element array that doesn’t contain a pair that sums to the target. |
nums = [2, 2, 3] target = 5 | [0, 2] or [1, 2] | Testing an array with duplicate values. |
nums = [-1, 2, 3] target = 2 | [0, 2] | Tests if the algorithm works with a negative number in the target pair. |
nums = [-3, -2, -1] target = -5 | [0, 1] | Tests if the algorithm works with both numbers of the target pair being negative. |
Совет для собеседования
Совет: учитывайте всю предоставленную информацию. Когда интервьюеры ставят задачу, они иногда предоставляют только минимум информации, необходимой для её решения. Поэтому крайне важно тщательно оценить всю эту информацию, чтобы определить, какие детали необходимы для эффективного решения задачи. В данной задаче ключ к оптимальному решению — осознание того, что входные данные отсортированы.
Сумма тройки
Сумма тройки
Дан массив целых чисел. Верните все тройки [a, b, c] такие, что a + b + c = 0. Решение не должно содержать повторяющихся троек (например, [1, 2, 3] и [2, 3, 1] считаются дубликатами). Если таких троек не найдено, верните пустой массив.
Каждая тройка может быть упорядочена произвольно, и результат может быть возвращён в произвольном порядке.
Пример:
Input: nums = [0, -1, 2, -3, 1]
Output: [[-3, 1, 2], [-1, 0, 1]]
Интуиция
Метод грубой силы предполагает проверку каждой возможной тройки в массиве на предмет суммирования до нуля. Это можно сделать с помощью трёх вложенных циклов, перебирая каждую комбинацию из трёх элементов.
Дублирования троек можно избежать путём сортировки каждой тройки, что обеспечивает единообразный порядок для идентичных троек с разным представлением (например, [1, 3, 2] и [3, 2, 1] → [1, 2, 3]). После сортировки тройки можно добавить в хеш-множество. Тогда при повторном обнаружении той же тройки хеш-множество сохранит лишь один экземпляр. Ниже представлен код для этого подхода:
from typing import List
def triplet_sum_brute_force(nums: List[int]) -> List[List[int]]:
n = len(nums)
# Use a hash set to ensure we don't add duplicate triplets.
triplets = set()
# Iterate through the indexes of all triplets.
for i in range(n):
for j in range(i + 1, n):
for k in range(j + 1, n):
if nums[i] + nums[j] + nums[k] == 0:
# Sort the triplet before including it in the hash set.
triplet = tuple(sorted([nums[i], nums[j], nums[k]]))
triplets.add(triplet)
return [list(triplet) for triplet in triplets]
Это решение довольно неэффективно: его временная сложность равна O(n^3), где n — длина входного массива. Как можно сделать лучше?
Посмотрим, можем ли мы найти хотя бы одну тройку, дающую в сумме 0. Заметим, что если зафиксировать одно из чисел тройки, задача сводится к нахождению двух других. Это приводит к следующему наблюдению:
Для любой тройки [a, b, c], если зафиксировать 'a', можно сосредоточиться на поиске пары [b, c] с суммой '-a' (a + b + c = 0 → b + c = -a).
Звучит знакомо? Потому что задача поиска пары чисел с заданной суммой уже рассмотрена в задаче «Сумма пары — отсортированный массив». Однако этот алгоритм применим только к отсортированному массиву. Поэтому первым делом следует отсортировать входные данные. Рассмотрим следующий пример:
Теперь, начиная с первого элемента -2 (то есть 'a'), применим метод pair_sum_sorted к оставшейся части массива, чтобы найти пару с суммой 2 (то есть '-a'):
Как видно, при вызове pair_sum_sorted мы не нашли пару с суммой 2. Это означает, что не существует троек, начинающихся с -2, которые в сумме дают 0.
Итак, увеличим основной указатель i и попробуем снова.
На этот раз мы нашли одну пару, образующую допустимую тройку.
Если продолжить этот процесс для оставшейся части массива, окажется, что [-1, -1, 2] — единственная тройка, сумма которой равна 0.
Между реализацией pair_sum_sorted в задаче «Сумма пары — отсортированный массив» и этой задачей есть важное отличие: в данной задаче мы не останавливаемся при нахождении одной пары, а продолжаем поиск всех целевых пар.
Обработка дубликатов троек Ранее мы упустили из виду вопрос о том, как избежать добавления дубликатов троек. Существует два случая, когда это происходит. Рассмотрим пример ниже:
Случай 1: повторяющиеся значения 'a'
Первый случай появления дубликатов — при поиске пар для троек, начинающихся с одного и того же значения 'a':
Поскольку pair_sum_sorted будет искать пары с суммой '-a' в обоих случаях, мы неизбежно получим одинаковые пары и, следовательно, одинаковые тройки.
Чтобы избежать выбора одного и того же значения 'a', мы продолжаем увеличивать i (где num[i] представляет значение 'a'), пока оно не достигнет числа, отличного от предыдущего. Мы делаем это перед поиском пар методом pair_sum_sorted. Эта логика работает, поскольку массив отсортирован, а значит, одинаковые числа стоят рядом. Код для проверки дубликатов значений 'a' выглядит следующим образом:
# To prevent duplicate triplets, ensure 'a' is not a repeat of the previous element
# in the sorted array.
if i > 0 and nums[i] == nums[i - 1]:
continue
... Find triplets ...
Случай 2: повторяющиеся значения 'b'
Что касается второго случая, рассмотрим, что происходит в pair_sum_sorted при аналогичной ситуации. Для фиксированного целевого значения ('-a') пары, начинающиеся с одного числа 'b', всегда будут одинаковыми:
Решение то же, что и прежде: убедиться, что текущее значение 'b' не совпадает с предыдущим.
Важно отметить, что нам не нужно явно обрабатывать дубликаты значений 'c'. Корректировки для устранения дубликатов значений 'a' и 'b' гарантируют уникальность каждой пары [a, b]. Поскольку 'c' определяется уравнением c = -(a + b), каждая уникальная пара [a, b] даст уникальное значение 'c'. Следовательно, избегая дубликатов в 'a' и 'b', мы автоматически избегаем дубликатов в тройках [a, b, c].
Оптимизация Интересное наблюдение: тройки с суммой 0 нельзя составить только из положительных чисел. Поэтому можно прекратить поиск троек, как только мы достигнем положительного значения 'a', поскольку это означает, что 'b' и 'c' тоже будут положительными.
Реализация
Исходя из вышесказанного, нам нужно немного изменить функцию pair_sum_sorted для устранения дубликатов троек. Также необходимо передавать начальное значение, указывающее начало подмассива, к которому применяется алгоритм поиска пар. В остальном логика двух указателей практически идентична задаче «Сумма пары — отсортированный массив».
from typing import List
def triplet_sum(nums: List[int]) -> List[List[int]]:
triplets = []
nums.sort()
for i in range(len(nums)):
# Optimization: triplets consisting of only positive numbers will never sum
# to 0.
if nums[i] > 0:
break
# To avoid duplicate triplets, skip 'a' if it's the same as the previous
# number.
if i > 0 and nums[i] == nums[i - 1]:
continue
# Find all pairs that sum to a target of '-a' ('-nums[i]').
pairs = pair_sum_sorted_all_pairs(nums, i + 1, -nums[i])
for pair in pairs:
triplets.append([nums[i]] + pair)
return triplets
def pair_sum_sorted_all_pairs(nums: List[int], start: int, target: int) -> List[int]:
pairs = []
left, right = start, len(nums) - 1
while left < right:
sum = nums[left] + nums[right]
if sum == target:
pairs.append([nums[left], nums[right]])
left += 1 # To avoid duplicate '[b, c]' pairs, skip 'b' if it’s the same as the # previous number.
while left < right and nums[left] == nums[left - 1]:
left += 1
elif sum < target:
left += 1
else:
right -= 1
return pairs
Анализ сложности
Временная сложность: временная сложность triplet_sum равна O(n^2). Вот почему:
- Сначала мы сортируем массив, что занимает O(n log n) времени.
- Затем для каждого из n элементов массива мы вызываем pair_sum_sorted_all_pairs не более одного раза, что выполняется за O(n) времени.
Таким образом, общая временная сложность равна O(log n) + O(n²) = O(n²).
Пространственная сложность: пространственная сложность равна O(n) из-за пространства, занимаемого алгоритмом сортировки Python. Важно отметить, что эта сложность не включает выходной массив triplets, поскольку нас интересует только дополнительное пространство, используемое алгоритмом, а не пространство для самого результата.
Если интервьюер спросит о пространственной сложности с учётом выходного массива, она будет равна O(n²). Это объясняется тем, что функция pair_sum_sorted_all_pairs в худшем случае может добавить примерно n пар в результат. Поскольку эта функция вызывается примерно n раз, общая пространственная сложность равна O(n²).
Тестовые случаи
В дополнение к примерам, уже рассмотренным в этом объяснении, ниже приведены некоторые другие для тестирования вашего кода.
| Input | Expected output | Description |
|---|---|---|
nums = [] | [] | Tests an empty array. |
nums = [0] | [] | Tests a single-element array. |
nums = [1, -1] | [] | Tests a two-element array. |
nums = [0, 0, 0] | [0, 0, 0] | Tests an array where all three of its values are the same. |
nums = [1, 0, 1] | [] | Tests an array with no triplets that sum to 0. |
nums = [0, 0, 1, -1, 1, -1] | [-1, 0, 1] | Tests an array with duplicate triplets. |
Проверка палиндрома
Проверка палиндрома
Палиндром — это последовательность символов, которая читается одинаково слева направо и справа налево.
Дана строка. Определите, является ли она палиндромом после удаления всех небуквенно-цифровых символов. Символ является буквенно-цифровым, если это буква или цифра.
Пример 1:
Input: s = 'a dog! a panic in a pagoda.'
Output: True
Пример 2:
Input: s = 'abc123'
Output: False
Ограничения:
- Строка может содержать комбинацию строчных букв английского алфавита, цифр, пробелов и знаков препинания.
Интуиция
Определение палиндромов Строка является палиндромом, если она остаётся идентичной при чтении слева направо или справа налево. Иными словами, если перевернуть строку, она должна читаться так же, без учёта пробелов и знаков препинания:
Важное наблюдение: если строка является палиндромом, первый символ совпадает с последним, второй — со вторым с конца и т.д.:
Палиндром нечётной длины отличается тем, что у него есть средний символ. В этом случае средний символ можно игнорировать, поскольку у него нет «зеркального» символа в другом месте строки.
Палиндромы представляют идеальный сценарий для использования двух указателей (left и right). Установив указатели в начало и конец строки, мы можем сравнивать символы в этих позициях. Игнорируя на мгновение небуквенно-цифровые символы, логику можно обобщить следующим образом:
- Если буквенно-цифровые символы у left и right совпадают, перемещаем оба указателя внутрь для обработки следующей пары символов.
- Если нет — строка не является палиндромом: возвращаем false.
Если мы успешно сравним все пары символов, не вернув false, строка является палиндромом, и следует вернуть true.
Обработка небуквенно-цифровых символов Теперь рассмотрим, как искать палиндромы, содержащие небуквенно-цифровые символы.
Поскольку небуквенно-цифровые символы не влияют на то, является ли строка палиндромом, их следует пропускать. Этого можно достичь следующим подходом, который гарантирует, что указатели left и right корректируются для работы только с буквенно-цифровыми символами:
- Увеличиваем left до тех пор, пока символ, на который он указывает, не станет буквенно-цифровым.
- Уменьшаем right до тех пор, пока символ, на который он указывает, не станет буквенно-цифровым.
С учётом этого проверим, является ли строка ниже палиндромом, используя всю имеющуюся у нас информацию:
Как показано выше, когда указатели left и right встречаются, это сигнализирует об условии выхода. Когда эти указатели встречаются, мы достигли среднего символа палиндрома, и в этой точке можно выйти из цикла, поскольку средний символ не требует оценки. Однако следует иметь в виду, что выход при равенстве left и right не всегда будет достаточным условием выхода. Например, если количество буквенно-цифровых символов чётно, указатели не встретятся. Это можно наблюдать ниже:
Следовательно, нам нужно обеспечить выход из цикла, когда left равно right или когда left проходит мимо right. Иными словами, алгоритм продолжает работу, пока left меньше right:
while left < right:
Реализация
В Python можно использовать встроенный метод isalnum для проверки того, является ли символ буквенно-цифровым.
def is_palindrome_valid(s: str) -> bool:
left, right = 0, len(s) - 1
while left < right:
# Skip non-alphanumeric characters from the left.
while left < right and not s[left].isalnum():
left += 1
# Skip non-alphanumeric characters from the right.
while left < right and not s[right].isalnum():
right -= 1
# If the characters at the left and right pointers don’t match, the string is
# not a palindrome.
if s[left] != s[right]:
return False
left += 1
right -= 1
return True
Анализ сложности
Временная сложность: временная сложность is_palindrome_valid равна O(n), где n — длина строки. Это объясняется тем, что мы выполняем примерно n итераций с использованием техники двух указателей.
Пространственная сложность: мы выделяем лишь константное количество переменных, поэтому пространственная сложность равна O(1).
Тестовые случаи
В дополнение к рассмотренным примерам, ниже приведены другие примеры для тестирования вашего кода.
| Input | Expected output | Description |
|---|---|---|
s = "" | True | Tests an empty string. |
s = "a" | True | Tests a single-character string. |
s = "aa" | True | Tests a palindrome with two characters. |
s = "ab" | False | Tests a non-palindrome with two characters. |
s = "!, (?)" | True | Tests a string with no alphanumeric characters. |
s = "12.02.2021" | True | Tests a palindrome with punctuation and numbers. |
s = "21.02.2021" | False | Tests a non-palindrome with punctuation and numbers. |
s = "hello, world!" | False | Tests a non-palindrome with punctuation. |
Советы для собеседования
Совет 1: уточните ограничения задачи. Обычно интервьюер не сообщает все детали задачи. Например, вас могут попросить просто «проверить, является ли строка палиндромом». Но прежде чем приступать к решению, важно уточнить у интервьюера детали: наличие небуквенно-цифровых символов, их обработку, роль цифр, чувствительность букв к регистру и другие важные аспекты.
Совет 2: подтвердите использование встроенных функций. Эту задачу упрощает использование встроенных функций, таких как .isalnum (или аналогичных). Прежде чем использовать встроенную функцию, упрощающую реализацию, спросите интервьюера, можно ли её использовать, или он предпочтёт, чтобы вы реализовали её самостоятельно.
Скорее всего, интервьюер разрешит использование встроенной функции или попросит реализовать её самостоятельно в качестве упражнения в ходе собеседования. Если вы используете встроенную функцию, убедитесь, что понимаете её временну́ю и пространственную сложность.
Помните, что интервьюеры ищут командных игроков, и это показывает им, что вы внимательны к их предпочтениям и можете адаптировать свой подход в соответствии с требованиями.
Наибольший контейнер
Наибольший контейнер
Дан массив чисел, каждое из которых представляет высоту вертикальной линии на графике. Контейнер можно сформировать из любой пары этих линий вместе с осью x графика. Верните объём воды, который может удержать наибольший контейнер.
Пример:
Input: heights = [2, 7, 8, 3, 7, 6]
Output: 24
Интуиция
Если у нас есть две вертикальные линии heights[i] и heights[j], количество воды между ними равно min(heights[i], heights[j]) * (j - i), где j - i — ширина контейнера. Берём минимальную высоту, потому что заполнение выше неё привело бы к переполнению.
Иными словами, площадь контейнера зависит от двух вещей:
- Ширина прямоугольника.
- Высота прямоугольника, определяемая более короткой из двух линий.
Метод грубой силы для этой задачи предполагает проверку всех пар линий и возврат наибольшей найденной площади между каждой парой:
from typing import List
def largest_container_brute_force(heights: List[int]) -> int:
n = len(heights)
max_water = 0
# Find the maximum amount of water stored between all pairs of lines.
for i in range(n):
for j in range(i + 1, n):
water = min(heights[i], heights[j]) * (j - i)
max_water = max(max_water, water)
return max_water
Перебор всех возможных пар значений занимает O(n^2) времени, где n — длина массива. Поищем более эффективное решение.
Мы хотели бы, чтобы и высота, и ширина были как можно больше для получения наибольшего контейнера.
Не сразу очевидно, как найти контейнер с наибольшей высотой, поскольку высоты линий в массиве не следуют чёткой закономерности. Однако мы знаем контейнер с максимальной шириной: тот, что начинается с индекса 0 и заканчивается на индексе n - 1.
Итак, можно начать с максимизации ширины, установив указатель у каждого конца массива. Затем можно постепенно уменьшать ширину, перемещая два указателя внутрь в надежде найти контейнер с большей высотой, который потенциально даст большую площадь. Это позволяет применить метод двух указателей для решения задачи.
Перемещение указателя внутрь означает сдвиг левого указателя вправо или правого указателя влево, что эффективно сужает разрыв между ними.
Рассмотрим следующий пример:
Наибольший по ширине контейнер может удержать воды на площадь 10. Поскольку это наибольший найденный нами контейнер, установим max_water = 10.
Как нам продолжать? Перемещение любого указателя внутрь даёт контейнер с меньшей шириной. Это оставляет высоту определяющим фактором. В данном случае левая линия короче правой, что означает: левая линия ограничивает высоту воды. Чтобы найти больший контейнер, переместим левый указатель внутрь:
Текущий контейнер может удержать 24 единицы воды — это наибольшее значение на данный момент. Обновим max_water до 24. Здесь правая линия короче, ограничивая высоту воды. Для поиска большего контейнера переместим правый указатель внутрь:
После этого мы встречаем ситуацию, когда высоты левой и правой линий равны. В этой ситуации какой указатель следует переместить внутрь? Независимо от выбора, следующий контейнер гарантированно удержит меньше воды, чем текущий. Попробуем понять почему.
Перемещение любого указателя внутрь даёт контейнер с меньшей шириной, оставляя высоту определяющим фактором. Однако независимо от того, какой указатель мы перемещаем внутрь, другой остаётся у той же линии. Поэтому, даже если указатель перемещается к более высокой линии, другой ограничит высоту воды, поскольку мы берём минимум из двух линий.
Следовательно, поскольку мы не можем увеличить высоту, перемещая только один указатель, можно просто переместить оба указателя внутрь:
Теперь правая линия ограничивает высоту воды. Переместим правый указатель внутрь:
Наконец, указатели left и right встречаются. Мы можем завершить поиск и вернуть max_water:
На основе решений, принятых в примере, можно обобщить логику:
- Если левая линия меньше, перемещаем левый указатель внутрь.
- Если правая линия меньше, перемещаем правый указатель внутрь.
- Если обе линии имеют одинаковую высоту, перемещаем оба указателя внутрь.
Реализация
from typing import List
def largest_container(heights: List[int]) -> int:
max_water = 0
left, right = 0, len(heights) - 1
while (left < right):
# Calculate the water contained between the current pair of lines.
water = min(heights[left], heights[right]) * (right - left)
max_water = max(max_water, water)
# Move the pointers inward, always moving the pointer at the shorter line. If
# both lines have the same height, move both pointers inward.
if (heights[left] < heights[right]):
left += 1
elif (heights[left] > heights[right]):
right -= 1
else:
left += 1
right -= 1
return max_water
Анализ сложности
Временная сложность: временная сложность largest_container равна O(n), поскольку мы выполняем примерно n итераций с использованием техники двух указателей.
Пространственная сложность: мы выделяем лишь константное количество переменных, поэтому пространственная сложность равна O(1).
Тестовые случаи
В дополнение к примерам, рассмотренным в данном объяснении, ниже приведены другие примеры для тестирования вашего кода.
| Input | Expected output | Description |
|---|---|---|
heights = [] | 0 | Tests an empty array. |
heights = [1] | 0 | Tests an array with just one element. |
heights = [0, 1, 0] | 0 | Tests an array with no containers that can contain water. |
heights = [3, 3, 3, 3] | 9 | Tests an array where all heights are the same. |
heights = [1, 2, 3] | 2 | Tests an array with strictly increasing heights. |
heights = [3, 2, 1] | 2 | Tests an array with strictly decreasing heights. |
Перенос нулей в конец
Перенос нулей в конец
Дан массив целых чисел. Измените массив на месте, перемещая все нули в конец и сохраняя относительный порядок ненулевых элементов.
Пример:
Input: nums = [0, 1, 0, 3, 2]
Output: [1, 3, 2, 0, 0]
Интуиция
У этой задачи три основных требования:
- Переместить все нули в конец массива.
- Сохранить относительный порядок ненулевых элементов.
- Выполнить изменение на месте.
Наивный подход к этой задаче — построить результат с помощью отдельного массива (temp). Можно добавить все ненулевые элементы из левой части nums в этот временный массив и оставить остаток нулями. Затем просто присвоить входному массиву значение temp.
Определяя и перемещая ненулевые элементы из левой части массива в первую очередь, мы гарантируем сохранение их порядка при добавлении в результат:
def shift_zeros_to_the_end_naive(nums: List[int]) -> None:
temp = [0] * len(nums)
i = 0
# Add all non-zero elements to the left of 'temp'.
for num in nums:
if num != 0:
temp[i] = num
i += 1
# Set 'nums' to 'temp'.
for j in range(len(nums)):
nums[j] = temp[j]
К сожалению, это решение нарушает третье требование — изменение входного массива на месте.
Тем не менее из этого подхода можно извлечь ценное понимание. В частности, заметим, что это решение сосредоточено на ненулевых элементах, а не на нулях. Это означает, что если изменить цель на перемещение всех ненулевых элементов в левую часть массива, нули окажутся справа. Следовательно, нужно сосредоточиться только на ненулевых элементах:
Если бы существовал способ итерировать по указанной части массива, где должны находиться ненулевые элементы, мы могли бы итеративно размещать каждый ненулевой элемент в этом диапазоне.
Два указателя Для этого можно использовать два указателя:
- Левый указатель для итерации по левой части массива, куда должны быть помещены ненулевые элементы.
- Правый указатель для поиска ненулевых элементов.
Рассмотрим пример ниже. Начнём с размещения указателей left и right в начале массива. Прежде чем перемещать ненулевые элементы влево, правый указатель должен указывать на ненулевой элемент. Поэтому игнорируем ноль у первого элемента и увеличиваем right:
Помните, левый указатель используется только для отслеживания того, куда должны быть помещены ненулевые элементы. Поэтому до нахождения ненулевого элемента этот указатель не следует перемещать.
Теперь значение у правого указателя ненулевое. Обсудим, как обрабатывать этот случай.
1. Поменяем местами элементы у left и right: сначала переместим элемент у правого указателя в левую часть массива. Для этого поменяем его местами с элементом у левого указателя.
2. Увеличим указатели:
- После завершения обмена следует увеличить левый указатель, чтобы установить его на позицию, куда должен идти следующий ненулевой элемент.
- Также увеличим правый указатель в поисках следующего ненулевого элемента.
Мы можем применить эту логику к остальной части массива, увеличивая правый указатель на каждом шаге для поиска следующего ненулевого элемента:
После завершения всех обменов все нули окажутся в правой части массива, как и задумано, не нарушая порядка ненулевых элементов. Стратегия двух указателей, использованная в этой задаче, — однонаправленный обход.
Реализация
Вы могли заметить, что мы всегда перемещаем правый указатель вперёд независимо от того, указывает ли он на ноль или ненулевой элемент. Это позволяет использовать цикл for для итерации правого указателя.
def shift_zeros_to_the_end(nums: List[int]) -> None:
# The 'left' pointer is used to position non-zero elements.
left = 0
# Iterate through the array using a 'right' pointer to locate non-zero
# elements.
for right in range(len(nums)):
if nums[right] != 0:
if right != left:
nums[left], nums[right] = nums[right], nums[left]
# Increment 'left' since it now points to a position already occupied
# by a non-zero element.
left += 1
Анализ сложности
Временная сложность: временная сложность shift_zeros_to_the_end равна O(n), где n — длина массива, поскольку мы выполняем один проход по входному массиву.
Пространственная сложность: пространственная сложность равна O(1), поскольку перестановка выполняется на месте.
Тестовые случаи В дополнение к рассмотренным примерам, ниже приведены другие примеры для тестирования вашего кода.
| Input | Expected output | Description |
|---|---|---|
nums = [] | [] | Tests an empty array. |
nums = [0] | [0] | Tests an array with one 0. |
nums = [1] | [1] | Tests an array with one 1. |
nums = [0, 0, 0] | [0, 0, 0] | Tests an array with all 0s. |
nums = [1, 3, 2] | [1, 3, 2] | Tests an array with all non-zeros. |
nums = [1, 1, 1, 0, 0] | [1, 1, 1, 0, 0] | Tests an array with all zeros already at the end. |
nums = [0, 0, 1, 1, 1] | [1, 1, 1, 0, 0] | Tests an array with all zeros at the start. |
Следующая лексикографическая перестановка
Следующая лексикографическая перестановка
Дана строка строчных букв английского алфавита. Переставьте символы, чтобы получить новую строку, представляющую следующую перестановку в лексикографическом (алфавитном) порядке. Если данная строка уже является последней в лексикографическом порядке среди всех возможных перестановок, верните перестановку, которая является первой в лексикографическом порядке.
Пример 1:
Input: s = 'abcd'
Output: 'abdc'
Пояснение: «abdc» — следующая перестановка в лексикографическом порядке после перестановки «abcd».
Пример 2:
Input: s = 'dcba'
Output: 'abcd'
Пояснение: поскольку «dcba» — последняя перестановка в лексикографическом порядке, возвращаем первую: «abcd».
Ограничения:
- Строка содержит как минимум один символ.
Интуиция
Прежде чем разрабатывать решение, убедимся, что понимаем, что такое следующая лексикографическая последовательность строки.
Важная деталь: следующая лексикографическая последовательность строки лексикографически больше исходной строки. Рассмотрим строку «abc» и все её перестановки в лексикографически упорядоченной последовательности:
Мы видим возрастающий порядок строк в последовательности, когда переводим каждую букву в её позицию в алфавите:
Из этого мы также замечаем, что следующая строка в последовательности после «abc» — это «acb», первая строка, большая исходной:
Это даёт нам некоторое представление о том, что нужно найти. Следующая лексикографическая строка:
- Имеет наименьшее возможное лексикографическое увеличение по сравнению с исходной строкой.
- Использует те же буквы, что и исходная строка.
Определение символов для перестановки Поскольку наша цель — сделать наименьшее возможное увеличение, нам нужно каким-то образом переставить символы в правой части строки.
Чтобы понять почему, представьте попытку «увеличить» значение строки. Увеличение самой правой буквы приводит к тому, что результирующая строка ближе к исходной лексикографически, чем при увеличении самой левой буквы:
Следовательно, по возможности следует сначала сосредоточиться на перестановке символов в правой части строки.
Ключевое наблюдение: последняя строка в лексикографической последовательности (то есть наибольшая перестановка) всегда следует в невозрастающем порядке. Это можно увидеть на примере строки «abcc», например, где наибольшая возможная перестановка — «ccba»:
Как это нам помогает? Мы знаем, что нужно переставить символы в правой части строки, но не знаем, сколько именно. Далее будем называть крайние правые символы, которые следует переставить, «суффиксом».
Возьмём строку «abcedda» в качестве примера. Мы обходим её справа налево с целью найти кратчайший суффикс, который можно переставить для получения большей перестановки. Последние 4 символа образуют невозрастающий суффикс и не могут быть переставлены так, чтобы строка стала больше:
Однако следующий символ «c» нарушает невозрастающую последовательность:
Назовём этот символ опорным (pivot):
Если опорный символ не найден, строка уже является последней лексикографической последовательностью. В этом случае, как указывает условие задачи, нужно получить её первую лексикографическую перестановку. Это можно сделать, перевернув строку:
Перестановка символов Определив кратчайший суффикс для перестановки, следующая задача — переставить его для наименьшего возможного увеличения. Необходимо начать перестановку с позиции опорного символа, поскольку оставшаяся часть суффикса уже находится в наибольшей перестановке.
Чтобы увеличить символ в позиции опорного, нужно поменять опорный местами с более крупным символом справа.
В нашем примере с каким символом должен поменяться местами опорный ('c')? Мы хотим поменять его с символом, большим 'c', но ненамного больше, поскольку увеличение должно быть минимальным. Разберёмся, как найти такой символ.
Поскольку подстрока после опорного символа лексикографически невозрастающая, мы можем найти ближайший символ, больший 'c', обходя этот суффикс справа налево и останавливаясь на первом символе, большем него. Иными словами, мы ищем правого преемника опорного символа — 'd':
Теперь меняем местами опорный символ и правого преемника:
Символ в позиции опорного увеличился, поэтому для получения следующей перестановки нужно сделать подстроку после опорного как можно меньше. После показанного обмена видим, что подстрока «edca» не находится в наименьшей перестановке. Поэтому нужно минимизировать перестановку этой подстроки.
Важное наблюдение: после предыдущего обмена подстрока после опорного символа по-прежнему лексикографически невозрастающая.
Это означает, что можно минимизировать перестановку этой подстроки, перевернув её:
И вот так мы нашли следующую лексикографическую последовательность! Стратегия двух указателей в этой задаче — поэтапный обход: сначала находим опорный символ, а затем правого преемника относительно него.
Поиск следующей лексикографической последовательности включает несколько шагов. Вот краткое изложение:
- Найдите опорный символ (pivot).
- Опорный символ — это первый символ, нарушающий невозрастающую последовательность справа строки.
- Если опорный символ не найден, строка уже находится в последней лексикографической перестановке, и результат — просто перевёрнутая строка.
- Найдите правого преемника опорного символа.
- Поменяйте местами правого преемника и опорный символ, чтобы увеличить лексикографический порядок суффикса.
- Переверните суффикс после опорного символа, чтобы минимизировать его перестановку.
Реализация
def next_lexicographical_sequence(s: str) -> str:
letters = list(s)
# Locate the pivot, which is the first character from the right that breaks
# non-increasing order. Start searching from the second-to-last position.
pivot = len(letters) - 2
while pivot >= 0 and letters[pivot] >= letters[pivot + 1]:
pivot -= 1
# If pivot is not found, the string is already in its largest permutation. In
# this case, reverse the string to obtain the smallest permutation.
if pivot == -1:
return ''.join(reversed(letters))
# Find the rightmost successor to the pivot.
rightmost_successor = len(letters) - 1
while letters[rightmost_successor] <= letters[pivot]:
rightmost_successor -= 1
# Swap the rightmost successor with the pivot to increase the lexicographical
# order of the suffix.
letters[pivot], letters[rightmost_successor] = (letters[rightmost_successor], letters[pivot])
# Reverse the suffix after the pivot to minimize its permutation.
letters[pivot + 1:] = reversed(letters[pivot + 1:])
return ''.join(letters)
Анализ сложности
Временная сложность: временная сложность next_lexicographical_sequence равна O(n), где n — длина входной строки, поскольку мы выполняем максимум два прохода по строке: один для нахождения опорного символа и другой для нахождения крайнего правого символа суффикса, большего по значению, чем опорный. Мы также выполняем одно обращение, которое занимает O(n) времени.
Пространственная сложность: пространственная сложность равна O(n) из-за пространства, занимаемого списком letters. В Python эта дополнительная память используется потому, что строки неизменяемы — для их изменения входная строка сохраняется в виде списка.
Тестовые случаи
В дополнение к рассмотренным примерам, ниже приведены другие примеры для тестирования вашего кода.
| Input | Expected output | Description |
|---|---|---|
| s = 'a' | 'a' | Tests a string with a single character. |
| s = 'aaaa' | 'aaaa' | Tests a string with a repeated character. |
| s = 'ynitsed' | 'ynsdeit' | Tests a string with a random pivot character. |
Совет для собеседования
Совет: будьте точны в формулировках. На собеседовании крайне важно точно выбирать слова, особенно для технических описаний. Например, в этой задаче мы используем «невозрастающий» вместо «убывающий», поскольку «убывающий» подразумевает, что каждый элемент строго меньше предыдущего, что в данном случае неверно, так как соседние символы могут быть равны.
Hash Maps и множества
Введение в Hash Maps и множества
Введение в Hash Maps и множества
Интуиция
Представьте, что вы работаете в продуктовом магазине. Покупатель спрашивает цену фрукта. Если у вас есть только бумажный список всех фруктов, вам придётся просматривать его, чтобы найти нужный — это может занять немало времени. Однако, зная список наизусть, вы мгновенно назовёте цену любого фрукта. Именно так работают Hash Maps, обеспечивая быстрый доступ к информации.
Hash Maps Hash Map — также известная как хеш-таблица или словарь, в зависимости от языка программирования — это структура данных, сопоставляющая ключи со значениями. Проиллюстрируем это на примере фруктов и их цен:
Мысленная карта цен на фрукты концептуально похожа на мгновенный доступ к ценам через Hash Map, где название фрукта — ключ, а цена — значение. При поиске цены по названию фрукта Hash Map мгновенно вернёт результат:
Hash Maps чрезвычайно эффективны для поиска, вставки и удаления: как правило, все эти операции выполняются за константное время O(1). Это одна из наиболее универсальных и широко используемых структур данных в информатике — для подсчёта частоты элементов, кэширования данных и многого другого.
Свойства Hash Maps:
- Данные хранятся в виде пар ключ-значение.
- Hash Maps не хранят дубликаты. Каждый ключ уникален, что позволяет однозначно идентифицировать и получать доступ к каждому значению.
- Hash Maps — неупорядоченные структуры данных: ключи не хранятся в каком-либо определённом порядке.
Анализ временной сложности Ниже n обозначает количество записей в Hash Map.
| Operation | Average case | Worst case | Description |
|---|---|---|---|
| Insert | Add a key-value pair to the hash map. | ||
| Access | Find or retrieve an element. | ||
| Delete | Delete a key-value pair. |
На собеседованиях принято считать операции с Hash Map быстрыми со средней временной сложностью O(1), а не наихудшей. Это основано на предположении, что эффективная хеш-функция минимизирует коллизии [HashCollision]. Однако в наихудшем случае — когда плохо оптимизированная хеш-функция вызывает частые коллизии — временная сложность может деградировать до O(n), требуя линейного поиска по всем записям.
Хеш-множества Хеш-множества — упрощённая форма Hash Maps. Вместо пар ключ-значение они хранят только ключи. По аналогии с магазином: хеш-множество похоже на мысленный список фруктов без цен. Оно полезно для быстрой проверки наличия или отсутствия элемента — например, есть ли конкретный фрукт в наличии.
Когда использовать Hash Maps или множества Типичные сценарии применения Hash Maps: реализация словарей, подсчёт частот, хранение пар ключ-значение и ситуации, требующие быстрого поиска.
Типичные сценарии применения хеш-множеств: хранение уникальных элементов, отметка элементов как использованных или посещённых, проверка на дубликаты.
В условии задачи обращайте внимание на ключевые слова: «частота», «уникальный», «отображение», «словарь» или «быстрый поиск» — они часто указывают на то, что Hash Maps или множества могут оказаться полезными.
Ключевые концепции для освоения Hash Maps и множеств В этой главе рассматриваются практические применения Hash Maps и множеств. Для более полного понимания их устройства и эффективности изучите темы, выходящие за рамки главы, например:
- Хеш-функции: изучите тонкости отображения ключей на конкретные значения в хеш-таблице [1].
- Коллизии и методы их разрешения: разберитесь с такими подходами, как цепочки или открытая адресация [2].
- Коэффициент заполнения и рехеширование: помогают понять, как хеш-таблицы растут и меняют размер [3].
Пример из реальной жизни
Кеш веб-браузера: Hash Maps и множества широко применяются в реальных системах. Классический пример — кеширующие системы в веб-браузерах. Когда вы посещаете сайт, браузер сохраняет данные — изображения, HTML и CSS — в кеше, чтобы при следующих посещениях загрузка происходила значительно быстрее.
Структура главы
Глава включает задачи, охватывающие наиболее популярные случаи применения Hash Maps и множеств, и направлена на улучшение вашего понимания и эффективности их использования.
Сумма пары — несортированный массив
Сумма пары — несортированный массив
Дан массив целых чисел. Верните индексы любых двух чисел, сумма которых равна целевому значению. Порядок индексов в результате не важен. Если пара не найдена, верните пустой массив.
Пример:
Input: nums = [-1, 3, 4, 2], target = 3
Output: [0, 2]
Пояснение: nums[0] + nums[2] = -1 + 4 = 3
Ограничения:
- Один и тот же индекс нельзя использовать дважды в результате.
Интуиция
Наивный подход — перебрать все возможные пары в массиве и проверить, равна ли их сумма целевому значению. Это то же самое, что перебор в задаче «Сумма пары — отсортированный массив», с временной сложностью O(n²), где n — длина массива. Можно также отсортировать массив и применить алгоритм двух указателей из той задачи — это займёт O(n·log(n)) из-за сортировки. Посмотрим, можно ли найти ещё более быстрое решение.
Дополнение Нам нужно найти пару (x, y) такую, что x + y == target. В этом уравнении два неизвестных: x и y. Важное наблюдение: зная одно из чисел, мы легко вычислим второе.
Для каждого числа x из nums нужно найти другое число y такое, что x + y = target, иными словами, y = target - x. Это число называется дополнением x.
Помните: нам нужно вернуть индексы пары чисел, а не саму пару. Значит, нужен способ найти дополнение числа и его индекс.
Один из способов — перебрать массив, чтобы найти дополнение каждого числа и соответствующий индекс. Но это займёт O(n²), поскольку для каждого числа потребуется линейный поиск его дополнения. Лучше иметь эффективный способ определить индекс любого числа без поиска по массиву. Есть ли подходящая структура данных?
Hash Map Hash Map идеально подходит, так как позволяет хранить и искать значения за O(1). Каждое число и его индекс можно хранить в Hash Map как пару ключ-значение:
Это позволяет эффективно получать индекс дополнения любого числа. Заметьте, что дубликаты здесь не учитываются, поскольку нужно найти только одну корректную пару.
Наиболее интуитивный способ использования Hash Map:
- В первом проходе заполнить Hash Map каждым числом и соответствующим индексом.
- Во втором проходе сканировать массив и проверять, существует ли дополнение каждого числа в Hash Map. Если да — вернуть индексы этого числа и его дополнения.
Ниже приведён код для этого двухпроходного подхода:
from typing import List
def pair_sum_unsorted_two_pass(nums: List[int], target: int) -> List[int]:
num_map = {}
# First pass: Populate the hash map with each number and its index.
for i, num in enumerate(nums):
num_map[num] = i
# Second pass: Check for each number's complement in the hash map.
for i, num in enumerate(nums):
complement = target - num
if complement in num_map and num_map[complement] != i:
return [i, num_map[complement]]
return []
Этот алгоритм требует двух проходов. Можно ли обойтись одним? Однопроходное решение означает, что нужно одновременно заполнять Hash Map и искать дополнения. Возможно ли это? Рассмотрим пример:
Начнём с индекса 0. Его дополнение: 3 - (-1) = 4. Есть ли 4 в Hash Map? Нет, он пуст. Добавим -1 и его индекс в Hash Map:
Далее — индекс 1. Дополнение (0) отсутствует в Hash Map. Добавим 3 и его индекс:
На индексе 2 обнаруживаем, что дополнение 4 (то есть -1) уже есть в Hash Map. Значит, пара найдена:
Теперь можно вернуть индексы двух значений. Берём индекс 4 из входного массива и индекс его дополнения из Hash Map:
Реализация
from typing import List
def pair_sum_unsorted(nums: List[int], target: int) -> List[int]:
hashmap = {}
for i, x in enumerate(nums):
if target - x in hashmap:
return [hashmap[target - x], i]
hashmap[x] = i
return []
Анализ сложности
Временная сложность: O(n), поскольку мы однократно проходим по каждому элементу массива nums и выполняем операции с Hash Map за константное время.
Пространственная сложность: O(n), так как Hash Map может вырасти до размера n.
Совет для собеседования
Совет: перебирайте решения. Не стоит сразу прыгать к наиболее оптимальному решению — это не даёт интервьюеру представления о вашем процессе мышления. Рассматривайте несколько подходов, начиная с более простых, и постепенно улучшайте их. Так вы демонстрируете ход рассуждений и то, как приходите к более оптимальному решению.
Проверка доски судоку
Проверка доски судоку
Дана частично заполненная доска судоку 9×9. Определите, соответствует ли текущее состояние доски правилам игры:
- Каждая строка и столбец должны содержать уникальные числа от 1 до 9, либо быть пустыми (обозначаются как 0).
- Каждый из девяти квадратов 3×3 должен содержать уникальные числа от 1 до 9, либо быть пустым.
Примечание: требуется определить, является ли текущее состояние доски допустимым, а не разрешимым.
Пример:
Output: False
Ограничения:
- Считается, что каждое целое число на доске находится в диапазоне [0, 9].
Интуиция
Основная цель — проверить каждую строку, столбец и каждый из девяти квадратов 3×3 на наличие дубликатов. Сначала разберём механизм поиска дубликатов, затем применим его к строкам, столбцам и подсеткам.
Проверка на дубликаты Начнём с проверки одной строки доски на дубликаты:
Наивный способ — взять каждое число в строке и проверить, встречается ли оно ещё раз. Линейный поиск для каждого числа даст O(n²) для проверки одной строки — весьма неэффективно.
Хеш-множество улучшает временную сложность. Перебирая строку, мы отслеживаем ранее встреченные числа. При встрече нового числа проверяем его наличие в множестве за O(1). Если оно уже там — это дубликат:
Если завести по одному хеш-множеству для каждой из 9 строк, можно отслеживать дубликаты в каждой строке отдельно. Аналогично — для столбцов и подсеток: по одному множеству на каждый столбец и каждую подсетку. Задача — понять, какие хеш-множества соответствуют строке, столбцу и подсетке конкретной ячейки:
Разберём, как определить строку, столбец и подсетку ячейки.
Определение строк и столбцов Строки определяются просто — у каждой есть индекс. То же касается столбцов. Поэтому создадим массив из 9 хеш-множеств — по одному на строку — и обращаемся к нужному множеству по индексу строки. Аналогично для столбцов.
Определение подсеток Подсетки представляют собой интересную задачу: в отличие от строк и столбцов, принадлежность ячейки к подсетке не очевидна из её индекса.
Тем не менее, как и строк и столбцов, подсеток тоже 9. Визуализируя подсетки, видим их в виде сетки 3×3:
Нам нужен способ индексировать подсетки как матрицу 3×3. Для этого необходимо преобразовать индексы от 0 до 8 в соответствующие скорректированные индексы от 0 до 2:
Поскольку скорректированные индексы получаются сжатием сетки 9×9 до 3×3 — то есть делением числа строк и столбцов на 3 — новые индексы подсетки для строки и столбца получаются целочисленным делением на 3:
С этими скорректированными индексами девять хеш-множеств организуются в таблицу 3×3 — по одному на каждую подсетку. Доступ к множеству подсетки для любой ячейки осуществляется через скорректированные индексы: subgrid_sets[r // 3][c // 3] (оператор // выполняет целочисленное деление).
Однопроходная проверка судоку Теперь у нас есть всё необходимое для однопроходного решения. Инициализируем хеш-множества: 9 для строк, 9 для столбцов и 9 для подсеток в виде массива 3×3.
Перебирая каждую ячейку сетки, проверяем через соответствующие хеш-множества, встречалось ли число уже в текущей строке, столбце или подсетке:
- Если число уже есть в любом из этих множеств — вернуть false.
- Иначе — добавить его в соответствующие множества строки, столбца и подсетки.
Это позволяет отслеживать числа в каждой строке, столбце и подсетке. Если удалось обойти всю доску без дубликатов — доска допустима, возвращаем true.
Реализация
from typing import List
def verify_sudoku_board(board: List[List[int]]) -> bool:
# Create hash sets for each row, column, and subgrid to keep track of numbers
# previously seen on any given row, column, or subgrid.
row_sets = [set() for _ in range(9)]
column_sets = [set() for _ in range(9)]
subgrid_sets = [[set() for _ in range(3)] for _ in range(3)]
for r in range(9):
for c in range(9):
num = board[r][c]
if num == 0:
continue
# Check if 'num' has been seen in the current row, column, or subgrid.
if num in row_sets[r]:
return False
if num in column_sets[c]:
return False
if num in subgrid_sets[r // 3][c // 3]:
return False
# If we passed the above checks, mark this value as seen by adding it to
# its corresponding hash sets.
row_sets[r].add(num)
column_sets[c].add(num)
subgrid_sets[r // 3][c // 3].add(num)
return True
Анализ сложности
В данной задаче длина доски фиксирована — 9, что сводит все подходы к O(1) по времени и памяти. Однако для лучшего понимания эффективности алгоритма обозначим длину доски через n и оценим производительность для произвольных размеров.
Временная сложность: O(n²), поскольку однократно проходим по каждой ячейке доски и выполняем операции с хеш-множествами за константное время.
Пространственная сложность: O(n²) из-за массивов row_sets, column_sets и subgrid_sets. Каждый содержит n хеш-множеств, каждое из которых может вырасти до размера n.
Зачёркивание нулей
Зачёркивание нулей
Для каждого нуля в матрице m×n обнулите всю его строку и столбец на месте.
Интуиция — хеш-множества
Грубый подход: сначала запомнить позиции всех нулей, затем для каждого из них пройти по его строке и столбцу, обнуляя их. Однако в наихудшем случае, при множестве нулей, это займёт O(m·n·(m+n)), где m·n — количество нулей, а (m+n) — общее число ячеек в строке и столбце. Подход неэффективен — ищем лучшее решение.
Рассмотрим любую ячейку матрицы. После преобразования она либо сохранит исходное значение, либо станет нулём. Можно ли заранее определить, станет ли ячейка нулём?
Ключевое наблюдение: если ячейка находится в строке или столбце, содержащем ноль, она тоже станет нулём.
Можно проверять строку и столбец каждой ячейки на наличие нуля — каждая проверка займёт O(m+n). Но эффективнее делать это за константное время с помощью хеш-множеств. Создадим два множества: одно для строк с нулями, другое для столбцов — и будем проверять принадлежность за O(1).
Заполняем множества в ходе обхода матрицы: при встрече нулевой ячейки:
- Добавляем индекс её строки в zero_rows.
- Добавляем индекс её столбца в zero_cols.
Затем находим ячейки, чьи индексы строки или столбца присутствуют в соответствующих множествах, и обнуляем их:
Общая стратегия:
- В одном проходе матрицы находим каждую нулевую ячейку и добавляем её строку и столбец в zero_rows и zero_cols соответственно.
- Во втором проходе обнуляем ячейку, если её строка есть в zero_rows или столбец в zero_cols.
Реализация — хеш-множества
from typing import List
def zero_striping_hash_sets(matrix: List[List[int]]) -> None:
if not matrix or not matrix[0]:
return
m, n = len(matrix), len(matrix[0])
zero_rows, zero_cols = set(), set()
# Pass 1: Traverse through the matrix to identify the rows and columns
# containing zeros and store their indexes in the appropriate hash sets.
for r in range(m):
for c in range(n):
if matrix[r][c] == 0:
zero_rows.add(r)
zero_cols.add(c)
# Pass 2: Set any cell in the matrix to zero if its row index is in 'zero_rows'
# or its column index is in 'zero_cols’.
for r in range(m):
for c in range(n):
if r in zero_rows or c in zero_cols:
matrix[r][c] = 0
Анализ сложности
Временная сложность: O(m·n) — два прохода по матрице с константными операциями в каждом.
Пространственная сложность: O(m+n) из-за хеш-множеств: одно растёт пропорционально строкам, другое — столбцам. В наихудшем случае каждая строка и каждый столбец содержат ноль.
Интуиция — отслеживание нулей на месте
Предыдущее решение было эффективным по времени благодаря хеш-множествам. Но это потребовало дополнительной памяти. Можно ли отслеживать строки и столбцы с нулями иначе?
Ключевое наблюдение: если строка или столбец содержат ноль, все ячейки в этой строке или столбце в итоге будут заменены нулём. Значит, сохранять их значения не нужно.
Стратегия: использовать первую строку и первый столбец (строку 0 и столбец 0) в качестве маркеров.
Для строк: если в строке есть ноль, устанавливаем соответствующую ячейку в первом столбце в ноль. Этот ноль служит маркером, указывающим, что всю строку нужно обнулить.
Аналогично маркируем столбцы с нулями с помощью первой строки:
Для простановки маркеров обходим подматрицу — всю матрицу, кроме первой строки и первого столбца. При обнаружении нуля устанавливаем соответствующую ячейку первой строки и первого столбца в ноль:
Теперь обнуляем ячейки подматрицы на основе маркеров. Для каждой ячейки проверяем:
- Равен ли нулю соответствующий маркер в первом столбце.
- Равен ли нулю соответствующий маркер в первой строке.
Если выполняется хотя бы одно условие — обнуляем ячейку:
Для обновления подматрицы итерируемся, начиная со второй строки и второго столбца:
Обработка нулей в первой строке и столбце После предыдущего шага остаётся один нюанс. Что если первая строка или столбец изначально содержали ноль?
В этом случае нельзя различить: ноль в первой строке был там изначально или является маркером. Значит, мы не поймём, нужно ли обнулять первую строку:
Решение: перед использованием первой строки и столбца в качестве маркеров, флагом отмечаем наличие нуля в каждом из них.
После простановки маркеров в первой строке и столбце (матрица X) и обнуления подматрицы (матрица Y), отдельно обрабатываем первую строку и первый столбец. Первая строка изначально содержала ноль — обнуляем её (матрица Z). Первый столбец нулей не содержал — оставляем как есть:
Стратегия обнуления на месте Обобщим подход в следующие шаги:
- Флагом отметить, содержит ли первая строка хотя бы один ноль.
- Флагом отметить, содержит ли первый столбец хотя бы один ноль.
- Обойти подматрицу: при нахождении нуля выставить маркеры в первой строке и первом столбце.
- Применить нули по маркерам: пройти по подматрице, начиная со второй строки и второго столбца. Для каждой ячейки проверить маркер в первой строке или столбце. Если маркер равен нулю — обнулить ячейку.
- Если первая строка изначально содержала ноль — обнулить все элементы первой строки.
- Если первый столбец изначально содержал ноль — обнулить все элементы первого столбца.
Реализация — отслеживание нулей на месте
from typing import List
def zero_striping(matrix: List[List[int]]) -> None:
if not matrix or not matrix[0]:
return
m, n = len(matrix), len(matrix[0])
# Check if the first row initially contains a zero.
first_row_has_zero = False
for c in range(n):
if matrix[0][c] == 0:
first_row_has_zero = True
break
# Check if the first column initially contains a zero.
first_col_has_zero = False
for r in range(m):
if matrix[r][0] == 0:
first_col_has_zero = True
break
# Use the first row and column as markers. If an element in the submatrix is zero,
# mark its corresponding row and column in the first row and column as 0.
for r in range(1, m):
for c in range(1, n):
if matrix[r][c] == 0:
matrix[0][c] = 0
matrix[r][0] = 0
# Update the submatrix using the markers in the first row and column.
for r in range(1, m):
for c in range(1, n):
if matrix[0][c] == 0 or matrix[r][0] == 0:
matrix[r][c] = 0
# If the first row had a zero initially, set all elements in the first row to
# zero.
if first_row_has_zero:
for c in range(n):
matrix[0][c] = 0
# If the first column had a zero initially, set all elements in the first column
# to zero.
if first_col_has_zero:
for r in range(m):
matrix[r][0] = 0
Анализ сложности
Временная сложность: O(m·n). Вот почему:
- Проверка первой строки на нули — O(m), первого столбца — O(n).
- Затем два прохода по всей матрице — O(m·n) каждый.
- Наконец, однократный проход по первой строке O(m) и первому столбцу O(n).
Итоговая сложность: O(m)+O(n)+O(m·n) = O(m·n).
Пространственная сложность: O(1), так как мы используем первую строку и столбец в качестве маркеров, не прибегая к вспомогательным структурам данных.
Наидлиннейшая цепочка последовательных чисел
Наидлиннейшая цепочка последовательных чисел
Найдите наидлиннейшую цепочку последовательных чисел в массиве. Два числа являются последовательными, если их разность равна 1.
Пример:
Input: nums = [1, 6, 2, 5, 8, 7, 10, 3]
Output: 4
Пояснение: наидлиннейшая цепочка последовательных чисел — 5, 6, 7, 8.
Интуиция
Наивный подход — отсортировать массив. В отсортированном порядке последовательные числа окажутся рядом, и можно будет найти самую длинную цепочку за один проход.
Этот подход требует сортировки за O(n·log(n)), где n — длина массива. Попробуем сделать лучше.
Важно понимать: каждое число в массиве может быть началом некоторой последовательной цепочки. Один подход — считать каждое число началом цепочки и искать её продолжение в массиве.
Для этого воспользуемся тем, что следующее число в цепочке всегда равно num + 1. Код для такого подхода:
from typing import List
def longest_chain_of_consecutive_numbers_brute_force(nums: List[int]) -> int:
if not nums:
return 0
longest_chain = 0
# Look for chains of consecutive numbers that start from each number.
for num in nums:
current_num = num
current_chain = 1
# Continue to find the next consecutive numbers in the chain.
while (current_num + 1) in nums:
current_num += 1
current_chain += 1
longest_chain = max(longest_chain, current_chain)
return longest_chain
Этот грубый подход занимает O(n³) из-за вложенных операций:
- Внешний цикл for перебирает каждый элемент — O(n).
- Для каждого элемента внутренний цикл while может выполниться до n итераций при длинной последовательности.
- На каждой итерации while выполняется проверка наличия следующего числа — O(n).
Это медленнее подхода с сортировкой, но можно применить ряд оптимизаций.
Оптимизация — хеш-множество Для поиска следующего числа в цепочке выполняется линейный поиск по массиву. Если сохранить все числа в хеш-множестве, можно проверять наличие числа за константное время.
Это снижает сложность с O(n³) до O(n²).
Оптимизация — определение начала цепочки В грубом подходе каждое число считается началом цепочки. Это дорого: для каждого числа выполняется линейный поиск:
Ключевое наблюдение: не нужно выполнять этот поиск для каждого числа в цепочке. Достаточно делать это только для наименьшего числа — оно определяет начало цепочки:
Определить, является ли число наименьшим в своей цепочке, можно, проверив отсутствие числа curr_num - 1 в массиве. Для этой проверки тоже используем хеш-множество.
Это снижает сложность с O(n²) до O(n): теперь каждая цепочка обрабатывается только один раз.
Реализация
from typing import List
def longest_chain_of_consecutive_numbers(nums: List[int]) -> int:
if not nums:
return 0
num_set = set(nums)
longest_chain = 0
for num in num_set:
# If the current number is the smallest number in its chain, search for
# the length of its chain.
if num - 1 not in num_set:
current_num = num
current_chain = 1
# Continue to find the next consecutive numbers in the chain.
while current_num + 1 in num_set:
current_num += 1
current_chain += 1
longest_chain = max(longest_chain, current_chain)
return longest_chain
Анализ сложности
Временная сложность: O(n). Несмотря на два цикла, внутренний выполняется только при нахождении начала цепочки. Это гарантирует однократный обход каждой цепочки во внутреннем while. Суммарное число итераций обоих циклов — O(n): внешний for выполняется n раз, внутренний while в сумме по всем итерациям — n раз. Итого O(n+n) = O(n).
Пространственная сложность: O(n), так как хеш-множество хранит каждое уникальное число из массива.
Тройки геометрической прогрессии
Тройки геометрической прогрессии
Тройка геометрической прогрессии — последовательность трёх чисел, где каждое следующее получается умножением предыдущего на константу, называемую знаменателем прогрессии.
Рассмотрим три тройки:
- (1, 2, 4): геометрическая прогрессия со знаменателем 2 (то есть [1, 1·2 = 2, 2·2 = 4]).
- (5, 15, 45): геометрическая прогрессия со знаменателем 3 (то есть [5, 5·3 = 15, 15·3 = 45]).
- (2, 3, 4): не является геометрической прогрессией.
Дан массив целых чисел и знаменатель r. Найдите все тройки индексов (i, j, k), образующих геометрическую прогрессию при i < j < k. Дубликаты троек допустимы.
Пример:
Input: nums = [2, 1, 2, 4, 8, 8], `r` = 2
Output: 5
Пояснение:
- Тройка [2, 4, 8] встречается на индексах (0, 3, 4), (0, 3, 5), (2, 3, 4), (2, 3, 5).
- Тройка [1, 2, 4] встречается на индексах (1, 2, 3).
Интуиция
Для образования геометрической прогрессии тройка должна удовлетворять двум правилам:
- Три значения образуют геометрическую прогрессию со знаменателем r.
- Три значения тройки должны встречаться в массиве в том же порядке, что и в прогрессии. То есть для тройки (nums[i], nums[j], nums[k]) индексы должны удовлетворять i < j < k.
Как представить геометрическую прогрессию, удовлетворяющую правилу 1? Пусть первое число равно x. Второе — x·r, третье — x·r·r = x·r². Тройку можно записать как (x, x·r, x·r²).
Грубый подход — перебрать все возможные тройки и проверить каждую. Это потребует трёх вложенных циклов — O(n³), где n — длина массива. Можно ли сделать лучше?
Важное наблюдение: зная одно значение тройки, можно вычислить два других.
Все три значения связаны через знаменатель r. Для любого числа x нужно найти x·r и x·r². Однако использование представления (x, x·r, x·r²) может вызвать проблемы: хотя x·r и x·r² должны стоять правее x, важен их порядок — нельзя случайно принять за тройку (x, x·r², x·r):
Эту проблему решает представление (x/r, x, x·r): порядок всегда соблюдается — ищем x/r слева от x и x·r справа:
Значения x/r и x·r можно искать линейным поиском в левом и правом подмассивах — O(n) для каждого числа, итого O(n²). Это лучше грубого подхода, но можно ещё быстрее.
Hash Maps Hash Map идеально подходит: позволяет искать конкретные значения за константное время в среднем.
Нам потребуются два Hash Map:
- left_map — числа левее текущего x.
- right_map — числа правее текущего x.
Hash Maps позволяют запрашивать x/r из left_map и x·r из right_map за константное время. Hash Map предпочтительнее хеш-множества, так как хранит частоту каждого значения — это важно при дубликатах в массиве.
Поиск всех троек (x/r, x, x·r) Цель — найти все тройки геометрической прогрессии, рассматривая каждое число в массиве как средний элемент (x).
Перед поиском x/r нужно проверить, делится ли x на r. Если нет — тройку из текущего x построить невозможно. Иначе ищем тройку.
Для любого x может быть несколько экземпляров x/r в left_map и x·r в right_map — значит, несколько троек с x в центре. Для подсчёта перемножаем частоты x·r и x/r:
Общая методология:
Если x/r или x·r не найдены в соответствующих Hash Map — их частота по умолчанию равна 0.
Применим стратегию на примере:
Для корректности Hash Map нужна динамическая стратегия с обновлением по ходу обхода, поскольку содержимое обоих Hash Map меняется в зависимости от позиции x.
Поскольку обходим массив слева направо, изначально заполним right_map всеми значениями массива — до начала итерации каждый элемент является потенциальным x·r. left_map изначально пуст — предшествующих элементов ещё нет:
Начинаем поиск троек. Рассматриваем первое значение как средний элемент (x).
Сначала обновляем right_map: удаляем текущее значение (2), поскольку оно не находится правее себя. В right_map два экземпляра 2 — уменьшаем частоту до 1:
Проверяем, является ли x/r целым числом. Да — ищем количество троек с x в центре, умножая частоты x/r и x·r из соответствующих Hash Map. Поскольку left_map пуст, частота x/r равна 0:
Перед переходом к следующему элементу добавляем текущее число в left_map — теперь оно становится потенциальным x/r для будущих троек:
Повторяя этот процесс для всего массива, находим все тройки геометрической прогрессии со знаменателем r. В следующих диаграммах Hash Map отображают своё состояние в текущей позиции x: left_map содержит значения левее x, right_map — правее.
Реализация
from typing import List
from collections import defaultdict
def geometric_sequence_triplets(nums: List[int], r: int) -> int:
# Use 'defaultdict' to ensure the default value of 0 is returned when
# accessing a key that doesn’t exist in the hash map. This effectively sets
# the default frequency of all elements to 0.
left_map = defaultdict(int)
right_map = defaultdict(int)
count = 0
# Populate 'right_map' with the frequency of each element in the array.
for x in nums:
right_map[x] += 1
# Search for geometric triplets that have x as the center.
for x in nums:
# Decrement the frequency of x in 'right_map' since x is now being
# processed and is no longer to the right.
right_map[x] -= 1
if x % r == 0:
count += left_map[x // r] * right_map[x * r]
# Increment the frequency of x in 'left_map' since it'll be a part of the
# left side of the array once we iterate to the next value of `x`.
left_map[x] += 1
return count
Анализ сложности
Временная сложность: O(n), поскольку однократно проходим по массиву nums и выполняем операции с Hash Map за константное время на каждой итерации.
Пространственная сложность: O(n), так как Hash Map могут вырасти до размера n.
Связные списки
Введение в связные списки
Введение в связные списки
Интуиция
Связный список — структура данных, представляющая собой последовательность узлов, где каждый узел ссылается на следующий. Узел связного списка имеет два основных компонента: хранимые данные (val) и ссылку на следующий узел (next) в последовательности:
Узел определяется с помощью класса ListNode:
class ListNode:
def __init__(self, val: int, next: ListNode):
self.val = val
self.next = next
Односвязный список
Простейшая форма связного списка — односвязный список, где каждый узел указывает на следующий, а последний узел указывает на null, обозначая конец списка. Начало списка называется «головой» (head) — как правило, это единственный узел, к которому у нас изначально есть прямой доступ:
Чтобы получить доступ к другим узлам, нужно обойти список, начиная с головы.
Односвязные списки используются для хранения коллекций данных. Одно из главных их преимуществ — динамическое изменение размера: список может свободно расти или уменьшаться, в отличие от массивов фиксированного размера. Кроме того, односвязные списки эффективны при частых вставках и удалениях — эти операции выполняются эффективнее, чем в массивах, где нужно сдвигать элементы.
Эффективность этих операций достигается ценой невозможности произвольного доступа: узлы нельзя получить по индексу, как в массиве. Этот компромисс оправдан во многих сценариях, где преимущества динамического размера и эффективности вставки/удаления перевешивают выгоды произвольного доступа.
Двусвязный список Двусвязный список — расширенная версия, где каждый узел содержит две ссылки: на следующий узел (next) и на предыдущий (prev). В большинстве реализаций двусвязный список предоставляет прямой доступ как к головному, так и к хвостовому узлу.
Главное преимущество двусвязного списка — возможность двунаправленного обхода. Кроме того, удаление узлов проще, так как у нас есть ссылки и на следующий, и на предыдущий узлы:
Работа с указателями
Многие задачи на связные списки требуют обхода или перестройки структуры. Уверенное владение манипуляциями с указателями — ключевое требование. Полезный приём: визуализировать указатели как стрелки между узлами и наблюдать, как их нужно перенаправить для отражения структурных изменений. Например, вставка узла:
Пример из реальной жизни
Плейлист музыкального плеера: приложения для воспроизведения музыки часто используют связные списки для реализации плейлистов — особенно двусвязные, где каждый узел-песня связан с предыдущей и следующей. Такая структура позволяет эффективно добавлять, удалять и переупорядочивать песни: достаточно обновить указатели между узлами, не перемещая данные в памяти.
Структура главы
В этой главе рассматриваются задачи с односвязными и двусвязными списками, а также уникальная задача реструктуризации многоуровневого связного списка.
Разворот связного списка
Разворот связного списка
Разверните односвязный список.
Пример:
Интуиция — итеративный подход
Наивная стратегия — сохранить значения связного списка в массиве и перестроить список, обходя массив в обратном порядке. Однако это не разворачивает исходный список — лишь создаёт новый. Попробуем выполнить разворот на месте.
Рассмотрим задачу через призму манипуляций с указателями. Ключевое наблюдение: если «перевернуть» направление указателей, мы фактически разворачиваем список:
Теперь нужно понять, как выполнить эту манипуляцию с указателями. Рассмотрим пример:
Для разворота направления всех указателей перебираем узлы один за другим. В процессе нужен доступ к текущему узлу (curr_node) и предыдущему (prev_node), чтобы изменить указатель next текущего узла на предыдущий. Изначально prev_node указывает на null, так как у первого узла нет предшественника:
Для перемещения указателей curr_node и prev_node на один узел вперёд нужно сдвинуть prev_node на позицию curr_node. Однако переместить curr_node к узлу 2 не получится — мы потеряли ссылку на него:
Это означает, что нужно было сохранить ссылку на узел 2 до разворота curr_node. Создадим переменную next_node и присвоим ей curr_node.next. Теперь можно сдвинуть prev_node и curr_node вперёд на один узел:
Обратите внимание: next_node не нужно сдвигать — он будет установлен через curr_node.next на следующей итерации.
Логику можно обобщить в три шага. На каждом узле связного списка:
- Сохранить ссылку на следующий узел (next_node = curr_node.next).
- Изменить указатель next текущего узла на предыдущий (curr_node.next = prev_node).
- Сдвинуть prev_node и curr_node вперёд на один узел (prev_node = curr_node, curr_node = next_node).
Повторяем эти шаги для остальных узлов:
Разворот останавливается, когда curr_node становится null — это означает, что больше нет узлов для обработки.
Последний шаг — вернуть голову развёрнутого списка, на которую указывает prev_node в момент, когда curr_node равен null.
Реализация — итеративный подход
from ds import ListNode
def linked_list_reversal(head: ListNode) -> ListNode:
curr_node, prev_node = head, None
# Reverse the direction of each node's pointer until 'curr_node' is null.
while curr_node:
next_node = curr_node.next
curr_node.next = prev_node
prev_node = curr_node
curr_node = next_node # 'prev_node' will be pointing at the head of the reversed linked list.
return prev_node
Анализ сложности
Временная сложность: O(n), где n — длина связного списка. Выполняются константные манипуляции с указателями в каждом узле.
Пространственная сложность: O(1).
Интуиция — рекурсивный подход
Иногда интервьюер просит решить задачу с помощью рекурсии. Посмотрим, как выглядит рекурсивное решение.
В рекурсивном подходе задача решается через меньшие экземпляры той же задачи. Для этого используем функцию linked_list_reversal в её собственной реализации. Рекурсивные вызовы продолжаются до достижения наименьшей версии задачи.
Наименьшая версия — разворот списка размером 0 или 1. Такие списки совпадают со своим разворотом. Это и есть базовые случаи.
Попробуем построить логику рекурсивной функции на примере:
Подумаем, какую подзадачу решать. Для разворота всего списка воспользуемся linked_list_reversal для разворота подсписка после текущего узла. Тогда нужно только развернуть указатель текущего узла.
Сначала рекурсивно вызовем linked_list_reversal для подсписка, начинающегося с head.next. Предположим, что вызов разворачивает подсписок и возвращает его голову.
При проектировании рекурсивной функции считайте, что любой рекурсивный вызов выполнится корректно — даже если функция ещё не полностью реализована.
Далее нужно, чтобы хвост развёрнутого подсписка (узел 2) указывал на узел 1. Узел 2 доступен через head.next. Достаточно установить head.next.next = head:
Список почти полностью развёрнут, но узел 1 всё ещё указывает на узел 2. Чтобы убрать эту ссылку, установим head.next = null. Затем вернём new_head — голову развёрнутого списка:
Реализация — рекурсивный подход
from ds import ListNode
def linked_list_reversal_recursive(head: ListNode) -> ListNode:
# Base cases.
if (not head) or (not head.next):
return head
# Recursively reverse the sublist starting at the next node.
new_head = linked_list_reversal_recursive(head.next)
# Connect the reversed sublist to the head node to fully reverse the entire linked list.
head.next.next = head
head.next = None
return new_head
Анализ сложности
Временная сложность: O(n) — однократный рекурсивный обход списка с посещением каждого узла ровно один раз.
Пространственная сложность: O(n) из-за стека рекурсивных вызовов, который растёт до n уровней в глубину.
Совет для собеседования
Совет: визуализируйте манипуляции с указателями. При работе со связными списками зачастую сложно сразу понять, что нужно сделать. Рисование указателей в виде стрелок между узлами очень помогает. Наблюдая за тем, как нужно переориентировать стрелки для отражения изменений в структуре, можно вывести логику манипуляций с указателями. Этот подход также помогает определить, к каким узлам нужны ссылки при внесении изменений.
Удаление k-го узла с конца связного списка
Удаление k-го узла с конца связного списка
Верните голову односвязного списка после удаления k-го узла с конца.
Пример:
Ограничения:
- Связный список содержит хотя бы один узел.
Интуиция
Разобьём задачу на две цели:
- Найти позицию k-го узла с конца.
- Удалить этот узел.
Сначала поймём, как работает удаление узла. Рассмотрим пример, где нужно удалить узел b. Для этого нужен доступ к предшествующему узлу (a), чтобы перенаправить его указатель next через узел b. Так узел b становится недостижимым при обходе списка:
Значит, нужно найти узел, непосредственно предшествующий k-му узлу с конца, чтобы удалить его.
Наивное решение: сначала определить длину списка (n), обходя его. Затем по длине вычислить количество шагов до узла перед k-м с конца — это n - k - 1 шагов. Решение требует двух циклов — нет ли более элегантного подхода?
Сложность с обходом односвязного списка в одном цикле: не зная длины списка, трудно понять, как далеко мы от последнего узла. Единственный способ узнать — достичь последнего узла, у которого next равен null. Как воспользоваться этой информацией?
Рассмотрим использование двух указателей. Можно ли создать сценарий, где к моменту достижения одним указателем конца списка другой окажется перед k-м узлом с конца? Изучим логику на примере:
Назовём первый указатель leader, второй — trailer. Когда leader достигнет последнего узла, trailer должен быть у узла 4 (узел перед k-м с конца). Иными словами, leader должен быть на k узлов впереди trailer, когда достигнет последнего узла.
Для этого сначала продвинем leader на k шагов. Когда leader на k узлов впереди trailer, двигаем оба указателя одновременно до достижения leader последнего узла.
Важный граничный случай: а что если нужно удалить сам head? В таком случае перед ним нет узла для перенаправления указателя. Чтобы обойти это, создадим фиктивный узел (dummy) перед head и начнём обход с него.
Применим стратегию на примере.
Сначала продвинем leader на k (2) шагов:
С leader на k узлов впереди двигаем оба указателя до достижения leader последнего узла:
Указатель trailer находится в нужной позиции — удаляем узел 7:
После удаления возвращаем dummy.next — голову изменённого списка.
Реализация
from ds import ListNode
def remove_kth_last_node(head: ListNode, k: int) -> ListNode:
# A dummy node to ensure there's a node before 'head' in case we need to remove
# the head node.
dummy = ListNode(-1)
dummy.next = head
trailer = leader = dummy
# Advance 'leader' k steps ahead.
for _ in range(k):
leader = leader.next
# If k is larger than the length of the linked list, no node needs to be
# removed.
if not leader:
return head
# Move 'leader' to the end of the linked list, keeping 'trailer' k nodes behind.
while leader.next:
leader = leader.next
trailer = trailer.next
# Remove the kth node from the end.
trailer.next = trailer.next.next
return dummy.next
Анализ сложности
Временная сложность: O(n). Алгоритм сначала обходит не более n узлов, затем два указателя проходят список не более одного раза каждый.
Пространственная сложность: O(1).
Пересечение связных списков
Пересечение связных списков
Верните узел, в котором пересекаются два односвязных списка. Если пересечения нет — верните null.
Пример:
Output: Node 8
Интуиция
Сначала разберёмся, что такое пересечение двух связных списков.
Пересечение происходит, когда два связных списка сходятся в общем узле и с этой точки разделяют все последующие узлы.
Это пересечение не связано со значениями узлов.
Наивный подход — использовать хеш-множество. Обойти первый список, сохраняя каждый узел в множество. Затем обходить второй список до нахождения первого узла из множества — это точка пересечения. Подход работает за линейное время, но можно ли решить задачу с константной памятью?
Рассмотрим пример:
Рассматривать их как два отдельных списка удобнее так: визуализируем входные данные раздельно, помня, что хвостовые узлы по-прежнему общие:
Задача проще, если списки одинаковой длины: узел пересечения находится на одной позиции от голов обоих списков. При обходе двух списков одинаковой длины гарантированно достигнем узла пересечения одновременно:
Можно ли воспроизвести это поведение для списков разной длины? Ключевое наблюдение: хотя списки A и B могут быть разной длины, «список A → список B» имеет ту же длину, что и «список B → список A». При этом оба объединённых списка разделяют одинаковые хвостовые узлы:
Теперь есть сценарий с двумя объединёнными списками одинаковой длины, разделяющими хвостовые узлы. Обходя их, мы одновременно достигнем узла пересечения (если он существует).
Для этого обходим оба объединённых списка двумя указателями и останавливаемся, когда узлы под обоими указателями совпадут — это и есть узел пересечения:
Если пересечения нет, оба указателя остановятся на null:
Обход объединённых списков Важное наблюдение: для обхода «список A → список B» не нужно физически соединять их. Достаточно обойти список A и по достижении его конца продолжить обход списка B:
Этот приём позволяет обходить оба списка последовательно, как если бы они были соединены.
Реализация
from ds import ListNode
def linked_list_intersection(head_A: ListNode, head_B: ListNode) -> ListNode:
ptr_A, ptr_B = head_A, head_B
# Traverse through list A with 'ptr_A' and list B with 'ptr_B' until they meet.
while ptr_A != ptr_B:
# Traverse list A -> list B by first traversing 'ptr_A' and then, upon
# reaching the end of list A, continue the traversal from the head of list B.
ptr_A = ptr_A.next if ptr_A else head_B
# Simultaneously, traverse list B -> list A.
ptr_B = ptr_B.next if ptr_B else head_A
# At this point, 'ptr_A' and 'ptr_B' either point to the intersection node or both
# are null if the lists do not intersect. Return either pointer.
return ptr_A
Анализ сложности
Временная сложность: O(n+m), где n и m — длины списков A и B соответственно. Указатели линейно обходят оба списка последовательно.
Пространственная сложность: O(1).
LRU-кеш
LRU-кеш
Спроектируйте и реализуйте структуру данных для кеша по принципу вытеснения давно неиспользуемых элементов (LRU — Least Recently Used), поддерживающую следующие операции:
- LRUCache(capacity: int): инициализировать LRU-кеш с заданной ёмкостью.
- get(key: int) -> int: вернуть значение по ключу. Вернуть -1, если ключ не существует.
- put(key: int, value: int) -> None: добавить ключ и его значение в кеш. Если добавление превысит ёмкость — вытеснить наиболее давно неиспользуемый элемент. Если ключ уже существует — обновить его значение.
Пример:
Input: [
put(1, 100),
put(2, 250),
get(2),
put(4, 300),
put(3, 200),
get(4),
get(1),
],
capacity = 3
Output: [250, 300, -1]
Пояснение:
put(1, 100) # cache is[1: 100]
put(2, 250) # cache is[1: 100, 2: 250]
get(2) # return 250
put(4, 300) # cache is[1: 100, 2: 250, 4: 300]t
put(3, 200) # cache is[2: 250, 4: 300, 3: 200]
get(4) # return 300
get(1) # key 1 was evicted when adding key 3 due to the capacity
# limit: return -1
Ограничения:
- Все ключи и значения — положительные целые числа.
- Ёмкость кеша положительна.
Интуиция
При проектировочных задачах первые шаги — понять задачу и выбрать структуры данных. Разберёмся, как работает LRU-кеш на высоком уровне.
Рассмотрим LRU-кеш ниже: он содержит 3 элемента и заполнен до предела. В этом представлении пары ключ-значение упорядочены от наиболее давно использованных (слева) до наиболее недавно использованных (справа):
Попробуем добавить новую пару ключ-значение:
Новая пара — самая свежая в кеше, поэтому она добавляется в конец (наиболее недавно использованный конец). Поскольку кеш заполнен, сначала вытесняем наиболее давно неиспользуемую пару:
На основе этого обзора обобщим операции для функции put:
- Удалить пару ключ-значение с наиболее давно неиспользуемого конца кеша.
- Добавить пару ключ-значение в наиболее недавно использованный конец кеша.
Теперь попробуем получить значение из кеша. При get(2) ожидаем 250. Обращение к паре делает её наиболее недавно использованной — переместим её в соответствующий конец:
Из этого примера выделим две ключевые операции для функции get:
- Переместить пару ключ-значение на наиболее недавно использованный конец кеша.
- Получить значение по ключу.
Итого четыре основные операции. Они помогут выбрать подходящие структуры данных.
Выбор структур данных Операции 1 и 2
Первые две операции связаны с добавлением и удалением пар ключ-значение: нужна возможность удалять с одного конца (наиболее давно неиспользуемый) и добавлять к другому.
Подходящая структура данных — связный список: добавление и удаление узла за O(1) при наличии ссылки на него. Но одно- или двусвязный?
Односвязный vs. двусвязный список
Добавление и удаление у головы занимают O(1) для обоих типов. Однако удаление хвостового узла из односвязного списка занимает O(n) даже при наличии ссылки на хвост — нужно обойти список до узла перед хвостом. Двусвязный список позволяет удалять хвост за O(1), так как у каждого узла есть ссылка на предыдущий. Выбираем двусвязный список.
Для удобства работы с обоими концами определим:
- Хвост списка — наиболее недавно использованный узел.
- Голова списка — наиболее давно неиспользуемый узел.
Для доступа к концам создадим узлы head и tail:
Операции 3 и 4
Операция 3 требует перемещения узла на наиболее недавно использованный конец — и этот узел может находиться в середине списка. Обход для его поиска займёт O(n). Как получить доступ за O(1)? Поскольку узел связан с ключом, используем Hash Map для хранения пар ключ-узел — это даёт доступ за константное время. Ниже показано, как значения Hash Map ссылаются на узлы списка:
Hash Map также решает операцию 4 — эффективный доступ к значениям по ключу.
Определившись с двусвязным списком и Hash Map для представления LRU-кеша, рассмотрим реализацию функций put и get.
put(key: int, val: int) -> None: Схема добавления новой пары ключ-значение в кеш — корректное обновление списка и Hash Map с соблюдением ёмкости:
Пример добавления нового узла в заполненный двусвязный список:
Понадобятся функции: удаление узла (remove_node) и добавление узла в хвост (add_to_tail).
get(key: int) -> int: Процесс получения значения из кеша:
Пример обновления двусвязного списка при вызове get:
Теперь рассмотрим детали реализации, включая вспомогательные методы remove_node и add_to_tail.
Реализация
Используем класс для узла двусвязного списка:
class DoublyLinkedListNode:
def __init__(self, key: int, val: int):
self.key = key
self.val = val
self.next = self.prev = None
Пример добавления узла в хвост. Назовём узел перед хвостом prev_node:
Новый узел должен стоять после prev_node и перед хвостом. Установим его указатели prev и next:
Теперь соединим prev_node и хвост с новым узлом:
Реализация функции:
def add_to_tail(self, node: DoublyLinkedListNode) -> None:
prev_node = self.tail.prev
node.prev = prev_node
node.next = self.tail
prev_node.next = node
self.tail.prev = node
Пример удаления узла из двусвязного списка:
Для удаления узла делаем его два соседних узла ссылающимися друг на друга, исключая удаляемый узел из списка:
Реализация функции:
def remove_node(self, node: DoublyLinkedListNode) -> None:
node.prev.next = node.next
node.next.prev = node.prev
Полная реализация LRU-кеша:
class DoublyLinkedListNode:
def __init__(self, key: int, val: int):
self.key = key
self.val = val
self.next = self.prev = None
class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
# A hash map that maps keys to nodes.
self.hashmap = {}
# Initialize the head and tail dummy nodes and connect them to
# each other to establish a basic two-node doubly linked list.
self.head = DoublyLinkedListNode(-1, -1)
self.tail = DoublyLinkedListNode(-1, -1)
self.head.next = self.tail
self.tail.prev = self.head
def get(self, key: int) -> int:
if key not in self.hashmap:
return -1
# To make this key the most recently used, remove its node and
# re-add it to the tail of the linked list.
self.remove_node(self.hashmap[key])
self.add_to_tail(self.hashmap[key])
return self.hashmap[key].val
def put(self, key: int, value: int) -> None:
# If a node with this key already exists, remove it from the
# linked list.
if key in self.hashmap:
self.remove_node(self.hashmap[key])
node = DoublyLinkedListNode(key, value)
self.hashmap[key] = node
# Remove the least recently used node from the cache if adding
# this new node will result in an overflow.
if len(self.hashmap) > self.capacity:
del self.hashmap[self.head.next.key]
self.remove_node(self.head.next)
self.add_to_tail(node)
def add_to_tail(self, node: DoublyLinkedListNode) -> None:
prev_node = self.tail.prev
node.prev = prev_node
node.next = self.tail
prev_node.next = node
self.tail.prev = node
def remove_node(self, node: DoublyLinkedListNode) -> None:
node.prev.next = node.next
node.next.prev = node.prev
Анализ сложности
Временная сложность: вспомогательные функции remove_node и add_to_tail работают за O(1) — константные операции с двусвязным списком. Функции put и get используют их совместно с константными операциями Hash Map. Итоговая сложность — O(1).
Пространственная сложность: O(n), где n — ёмкость кеша. Двусвязный список и Hash Map занимают по O(n) памяти.
Совет для собеседования
Совет: изучите, как комбинирование структур данных помогает реализовать нужную функциональность. Возможны ситуации, когда ни одна отдельная структура данных не обеспечивает всю необходимую функциональность. В таких случаях попробуйте объединить несколько структур. Например, в этой задаче двусвязный список в паре с Hash Map обеспечивают всю функциональность LRU-кеша.
Палиндромный связный список
Палиндромный связный список
Дана голова односвязного списка. Определите, является ли он палиндромом.
Пример 1:
Output: True
Пример 2:
Output: False
Интуиция
Связный список является палиндромом, если его значения читаются одинаково в обоих направлениях. Наивный способ — сохранить все значения в массив и свободно обходить его в обоих направлениях. Однако это требует линейной памяти. Лучше найти способ обходить список в обратном порядке для проверки. Есть ли такой способ?
Если список является палиндромом, его разворот даст ту же последовательность значений.
Можно создать копию списка, развернуть её и сравнить значения с оригиналом. Но это всё равно займёт линейную память. Можно ли скорректировать идею, обойдясь без нового списка?
Важное наблюдение: достаточно сравнить первую половину оригинального списка с разворотом второй половины (при нечётном числе элементов средний узел можно включить в обе половины):
Перед сравнением нужно:
- Найти середину списка — это голова второй половины.
- Развернуть вторую половину, начиная с этого среднего узла.
Обратите внимание: шаг 2 модифицирует входные данные. В данной задаче будем считать это допустимым. Всегда уточняйте у интервьюера, разрешено ли изменять входные данные.
Начинаем с нахождения среднего узла (mid) списка:
Для нахождения середины связного списка смотрите объяснение задачи «Середина связного списка» в главе про быстрый и медленный указатели.
Затем разворачиваем вторую половину, начиная с mid. Последний узел оригинального списка становится головой второй половины (second_head) — он используется для обхода развёрнутой второй половины:
Для разворота связного списка за O(n) смотрите объяснение задачи «Разворот связного списка» в этой главе.
Наконец, проверяем, совпадает ли первая половина с развёрнутой второй. Одновременно обходим обе половины узел за узлом, сравнивая значения. Если в какой-то момент значения не совпадают — список не является палиндромом.
Используем два указателя (ptr1 и ptr2) для обхода первой и развёрнутой второй половин:
Реализация
from ds import ListNode
def palindromic_linked_list(head: ListNode) -> bool:
# Find the middle of the linked list and then reverse the second half of the # linked list starting at this midpoint.
mid = find_middle(head)
second_head = reverse_list(mid)
# Compare the first half and the reversed second half of the list
ptr1, ptr2 = head, second_head
res = True
while ptr2:
if ptr1.val != ptr2.val:
res = False
ptr1, ptr2 = ptr1.next, ptr2.next
return res
# From the 'Reverse Linked List' problem.
def reverse_list(head: ListNode) -> ListNode:
prevNode, currNode = None, head
while currNode:
nextNode = currNode.next
currNode.next = prevNode
prevNode = currNode
currNode = nextNode
return prevNode
# From the 'Linked List Midpoint' problem.
def find_middle(head: ListNode) -> ListNode:
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
Анализ сложности
Временная сложность: O(n), где n — длина списка. Три прохода: нахождение середины, разворот второй половины и сравнение двух половин.
Пространственная сложность: O(1).
Совет для собеседования
Совет: уточняйте, допустимо ли изменять связный список. В нашем решении мы развернули вторую половину, нарушив исходную структуру входных данных. Почему это важно? Входные структуры данных нередко нельзя изменять — особенно если они разделяются или используются параллельно. Поэтому важно уточнить у интервьюера, допустимо ли изменение, и кратко обозначить его последствия.
Сглаживание многоуровневого связного списка
Сглаживание многоуровневого связного списка
В многоуровневом связном списке каждый узел имеет указатель next и указатель child. Указатель next связывает с последующим узлом того же уровня, а указатель child указывает на голову нового связного списка под ним. Так создаются несколько уровней связных списков. Если у узла нет дочернего списка — его атрибут child равен null.
Сгладьте многоуровневый связный список в одноуровневый, связав конец каждого уровня с началом следующего.
Пример:
Интуиция
Рассмотрим два условия для формирования сглаженного списка:
- Порядок узлов на каждом уровне должен быть сохранён.
- Все узлы одного уровня должны быть подключены прежде, чем добавляются узлы следующего уровня.
Сложность задачи — в обработке нижних уровней. Можно подумать об обходе в ширину (BFS), но BFS требует очереди — как минимум линейная память. Как слить уровни на месте?
Ключевое наблюдение: на любом уровне у нас есть прямой доступ ко всем узлам следующего уровня, поскольку дочерние узлы каждого узла уровня L обеспечивают доступ к уровню L+1:
Как подключить узлы уровня L+1 к концу уровня L? Через дочерние указатели текущего уровня можно добавить каждый дочерний связный список к концу текущего уровня — это эффективно объединяет два уровня в один.
После добавления узлов уровня L+1 к уровню L продолжаем: добавляем узлы уровня L+2 к L+1 и так далее.
Применим стратегию на примере:
Начинаем с добавления узлов уровня 2 к концу уровня 1. Нужна ссылка на хвостовой узел уровня 1 — создадим указатель tail и продвигаем его до последнего узла (где tail.next равен null):
Добавим дочерние списки (5→6 и 7→8) к хвостовому узлу. Зафиксируем tail в конце списка, введём отдельный указатель curr для обхода. Когда curr встречает узел с ненулевым дочерним списком — мы нашли дочерний список для добавления к хвосту. В примере первый узел (1) имеет дочерний список:
Для добавления дочернего списка к хвосту устанавливаем tail.next на голову дочернего списка:
Перед переходом curr к следующему узлу с дочерним списком, перемещаем tail на последний узел расширенного списка (узел 6). Снова итерируем tail до null:
Теперь продолжаем процесс:
- Находим следующий узел с дочерним списком через curr.
- Добавляем дочерний список к хвосту.
- Продвигаем tail к последнему узлу сглаженного списка.
После завершения процесса возвращаем head — голову сглаженного списка.
Важная деталь: после добавления любого дочернего списка к хвосту обнуляем атрибут child, чтобы список был полностью сглажен.
Реализация
Определение класса MultiLevelListNode:
class MultiLevelListNode:
def __init__(self, val, next, child):
self.val = val
self.next = next
self.child = child
from ds import MultiLevelListNode
def flatten_multi_level_list(head: MultiLevelListNode) -> MultiLevelListNode:
if not head:
return None
tail = head
# Find the tail of the linked list at the first level.
while tail.next:
tail = tail.next
curr = head
# Process each node at the current level. If a node has a child linked list,
# append it to the tail and then update the tail to the end of the extended linked
# list. Continue until all nodes at the current level are processed.
while curr:
if curr.child:
tail.next = curr.child
curr.child = None
while tail.next:
tail = tail.next
curr = curr.next
return head
Анализ сложности
Временная сложность: O(n), где n — количество узлов в многоуровневом связном списке. Каждый узел обрабатывается не более двух раз: один раз при итерации tail и один раз при итерации curr.
Пространственная сложность: O(1) — выделяем константное число переменных.
Быстрый и медленный указатели
Введение в технику быстрого и медленного указателей
Введение в технику быстрого и медленного указателей
Интуиция
Техника быстрого и медленного указателей — это специализированный вариант паттерна двух указателей, отличительной чертой которого является разная скорость продвижения двух указателей по структуре данных. В этой технике выделяют быстрый указатель и медленный указатель:
- Обычно медленный указатель продвигается на один шаг за каждую итерацию.
- Обычно быстрый указатель продвигается на два шага за каждую итерацию.
Это создаёт динамику, при которой быстрый указатель перемещается вдвое быстрее медленного:
Следует учитывать, что эти указатели не ограничены одним и двумя шагами. Главное условие — быстрый указатель продвигается на большее число шагов, чем медленный; при этом логика техники быстрого и медленного указателей сохраняется.
Пример из реальной жизни
Обнаружение циклов в символических ссылках: символические ссылки (symlinks) — это ярлыки, указывающие на файлы или каталоги в файловой системе. Реальный пример применения техники быстрого и медленного указателей — обнаружение циклов в символических ссылках.
В этом процессе медленный указатель следует по каждой символической ссылке шаг за шагом, тогда как быстрый указатель перемещается по два шага за раз. Если быстрый указатель догоняет медленный, это говорит о наличии цикла в символических ссылках, который может вызвать бесконечные циклы или ошибки при обращении к файлам. Чтобы понять, как быстрый и медленный указатели обнаруживают циклы таким образом, изучите задачу «Цикл в связном списке».
Содержание главы
Быстрый и медленный указатели особенно полезны в таких структурах данных, как связные списки, где индексный доступ недоступен. Они позволяют собирать важную информацию об элементах структуры данных через относительные позиции двух указателей, а не через индексирование. Это станет понятнее по мере изучения типичных сценариев применения в данной главе.
Цикл в связном списке
Цикл в связном списке
Дан односвязный список. Определите, содержит ли он цикл. Цикл возникает, если указатель next некоторого узла ссылается на более ранний узел в связном списке, образуя петлю.
Пример:
Output: True
Интуиция
Простой подход — итерироваться по связному списку, отслеживая уже посещённые узлы в хэш-множестве. Если в процессе обхода встретился ранее посещённый узел, это свидетельствует о наличии цикла. Ниже приведён фрагмент кода для этого подхода:
from ds import ListNode
def linked_list_loop_naive(head: ListNode) -> bool:
visited = set()
curr = head
while curr:
# Cycle detected if the current node has already been visited.
if curr in visited:
return True
visited.add(curr)
curr = curr.next
return False
Это решение требует O(n)O(n)O(n) времени, где nnn — количество узлов в связном списке, поскольку каждый узел посещается один раз. Однако оно использует O(n)O(n)O(n) дополнительной памяти для хэш-множества. Можно ли достичь линейной временной сложности при константном использовании памяти?
Представьте гоночный трек в виде кольцевого связного списка (то есть связного списка с идеальным циклом), где два бегуна стартуют с одного и того же узла.
Если оба бегуна движутся с одинаковой скоростью, они всегда будут находиться в одном узле. Однако рассмотрим, что происходит, когда один бегун (медленный) перемещается на один шаг за раз, а другой (быстрый) — на два шага. В этом случае быстрый бегун в какой-то момент обгонит медленного, поскольку трасса циклическая. Но как использовать эту информацию для обнаружения цикла?
В связном списке определить, обогнал ли быстрый бегун медленного, сложно из-за отсутствия позиционных индикаторов (как индексы в массиве). Лучший способ найти цикл — проверить, воссоединится ли быстрый бегун с медленным, то есть окажутся ли они в одном узле в какой-то момент. Это будет явным признаком того, что в связном списке есть цикл.
Вопрос в том, воссоединится ли быстрый бегун с медленным, или есть вероятность, что быстрый постоянно будет обгонять медленного, так и не попав в один узел? Для ответа рассмотрим несколько примеров.
Идеальный цикл Сначала проверим, встретятся ли два бегуна — медленный указатель и быстрый указатель — в связном списке, образующем идеальный цикл. Как видно из рисунка ниже, указатели в конечном счёте встретятся.
Отложенный цикл Что насчёт случая, когда цикл начинается не с первого узла связного списка? Рассмотрим симуляцию техники быстрого и медленного указателей на следующем примере:
И снова указатели в конечном счёте встретились в цикле, несмотря на то что быстрый и медленный вошли в цикл в разное время.
Всегда ли быстрый догонит медленного? В обоих случаях может показаться, что быстрый указатель способен постоянно обгонять медленный, так и не встретившись с ним, однако это не так. Вот более простое объяснение того, почему они обязательно встретятся.
Быстрый указатель перемещается на 2 шага за итерацию, а медленный — на 1 шаг, поэтому быстрый сокращает расстояние до медленного на 1 узел при каждой итерации. Это можно наблюдать ниже: расстояние между быстрым и медленным указателями уменьшается на единицу с каждой итерацией, пока они неизбежно не встретятся.
Таким образом, максимальное количество шагов, необходимых быстрому указателю для того, чтобы догнать медленный, составляет k шагов (когда оба уже находятся в цикле), где k — длина цикла. В худшем случае цикл содержит все узлы связного списка, и указатели встретятся через n шагов.
Нет цикла Последний случай — когда цикла нет. В этом случае быстрый указатель в конечном счёте достигнет конца списка и выйдет из цикла while:
Реализация
Этот алгоритм формально известен как алгоритм обнаружения цикла Флойда [1].
from ds import ListNode
def linked_list_loop(head: ListNode) -> bool:
slow = fast = head
# Check both 'fast' and 'fast.next' to avoid null pointer exceptions when we
# perform 'fast.next' and 'fast.next.next'.
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if fast == slow:
return True
return False
Анализ сложности
Временная сложность: временная сложность linked_list_loop составляет O(n)O(n)O(n), поскольку быстрый указатель встретит медленный за линейное число шагов, как описано выше.
Пространственная сложность: пространственная сложность равна O(1)O(1)O(1).
Середина связного списка
Середина связного списка
Дан односвязный список. Найдите и верните его средний узел. Если средних узлов два, верните второй из них.
Пример 1:
Output: Node 4
Пример 2:
Output: Node 4
Ограничения:
- Связный список содержит хотя бы один узел.
- Связный список содержит уникальные значения.
Интуиция
Наиболее интуитивный подход к решению этой задачи — пройти по связному списку для определения его длины (n), а затем пройти повторно, чтобы найти средний узел (n / 2):
Этот подход решает задачу за O(n)O(n)O(n) времени, но требует двух проходов для нахождения середины. Однако существует более элегантный подход, использующий лишь один проход.
При итерировании по связному списку наша точная позиция неизвестна. Мы узнаём её лишь когда достигаем последнего узла. Можно ли создать такой сценарий, при котором, как только один указатель достигает конца списка, другой оказывается у среднего узла?
Если один указатель будет перемещаться вдвое медленнее другого, то к тому моменту, когда более быстрый указатель достигнет конца списка, более медленный окажется на середине связного списка.
Этого можно добиться с помощью техники быстрого и медленного указателей:
- Медленный указатель продвигается на один узел за шаг.
- Быстрый указатель продвигается на два узла за шаг.
Важно следить за тем, когда нужно прекратить продвижение быстрого указателя. Остановиться нужно тогда, когда медленный указатель достигает среднего узла. Если длина списка нечётная, это происходит, когда fast.next равно null, как видно выше.
А что если длина связного списка чётная? Рассмотрим пример ниже:
Как видно, чтобы медленный указатель указывал на второй средний узел, нужно остановить быстрый указатель, когда он достигает null-узла.
Реализация
from ds import ListNode
def linked_list_midpoint(head: ListNode) -> ListNode:
slow = fast = head
# When the fast pointer reaches the end of the list, the slow pointer will be at
# the midpoint of the linked list.
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
Анализ сложности
Временная сложность: временная сложность linked_list_midpoint составляет O(n)O(n)O(n), поскольку мы линейно обходим связный список с помощью двух указателей.
Пространственная сложность: пространственная сложность равна O(1)O(1)O(1).
Остановитесь и подумайте
Что если вас попросят изменить алгоритм так, чтобы он возвращал первый средний узел при чётной длине связного списка?
Ответ: следуя тому же методу, нужно остановить продвижение быстрого указателя, когда fast.next.next равно null. Тогда медленный окажется на первом среднем узле:
Совет для собеседования
Совет: будьте готовы выявлять пробелы в предоставленной информации. На собеседовании интервьюер может не уточнить, какой из средних узлов следует возвращать для связных списков чётной длины, оставив это на ваше усмотрение. Возможно, вам следует самостоятельно выявить такие неоднозначности и активно обсуждать с интервьюером подходящее решение.
Счастливое число
Счастливое число
В теории чисел счастливое число определяется как число, которое при многократном применении процесса возведения его цифр в квадрат и суммирования этих квадратов в конечном итоге приводит к 1. Несчастливое число никогда не достигнет 1 в этом процессе и попадёт в бесконечный цикл [1].
Дано целое число. Определите, является ли оно счастливым.
Пример:
Input: n = 23
Output: True
Пояснение: 222^222 + 323^232 = 13 ⇒ 121^212 + 323^232 = 10 ⇒ 121^212 + 020^202 = 1
Интуиция
Мы можем симулировать процесс определения счастливого числа, многократно суммируя квадраты каждой цифры числа, а затем применяя тот же процесс к полученной сумме.
Согласно условию задачи, этот процесс может завершиться одним из двух способов:
- Случай 1: процесс продолжается до тех пор, пока итоговое число не равно 1.
- Случай 2: процесс попадает в бесконечный цикл.
Если изобразить оба сценария графически, можно заметить кое-что интересное:
Это очень похоже на задачу со связным списком. В частности, на задачу определения наличия цикла в связном списке (случай 2) или его отсутствия (случай 1).
Мы можем свести эту задачу к той же задаче обнаружения цикла, что и в задаче «Цикл в связном списке». Применив технику быстрого и медленного указателей (алгоритм обнаружения цикла Флойда), мы можем эффективно определить наличие цикла.
Однако в этой задаче у нас нет реального связного списка для применения алгоритма. Поэтому нам нужно найти способ обходить последовательность чисел, генерируемую в процессе проверки счастливого числа.
К счастью, мы уже знаем, каким будет «следующее» число в последовательности для любого числа x. Как описано в условии задачи, следующее число вычисляется суммированием квадрата каждой цифры числа x. Таким образом, если каждое число будет узлом связного списка, мы можем получить «следующий» узел, вычислив следующее число в последовательности.
Получение следующего числа в последовательности Чтобы вычислить следующее число для x, нам нужно обращаться к каждой его цифре. Это можно сделать в два шага:
- Операция взятия остатка от деления (x % 10) используется для извлечения последней цифры числа x.
- Делим x на 10 (x = x / 10), чтобы отбросить последнюю цифру и сделать следующую цифру новой последней.
Ниже показан полный процесс для x = 123:
Теперь, имея способ обходить последовательность, мы можем реализовать алгоритм обнаружения цикла Флойда. Для начала установим быстрый и медленный указатели в начало последовательности. Затем продвигаем указатели следующим образом:
- Медленный указатель продвигается на одно число за шаг (slow = get_next_num(slow)).
- Быстрый указатель продвигается на два числа за шаг (fast = get_next_num(get_next_num(fast))).
Если быстрый и медленный указатели встретятся в процессе, это свидетельствует о наличии цикла, то есть число не является счастливым. В противном случае алгоритм завершится, когда мы достигнем 1 — тогда число является счастливым.
Реализация
def happy_number(n: int) -> bool:
slow = fast = n
while True:
slow = get_next_num(slow)
fast = get_next_num(get_next_num(fast))
if fast == 1:
return True
# If the fast and slow pointers meet, a cycle is detected. Hence, 'n' is not
# a happy number.
elif fast == slow:
return False
def get_next_num(x: int) -> int:
next_num = 0
while x > 0:
# Extract the last digit of 'x'.
digit = x % 10
# Truncate (remove) the last digit from 'x' using floor division.
x //= 10
# Add the square of the extracted digit to the sum.
next_num += digit ** 2
return next_num
Анализ сложности
Временная сложность: временная сложность happy_number составляет O(log(n))O(\log(n))O(log(n)). Полный анализ этой временной сложности довольно сложен и выходит за рамки собеседований. Для заинтересованных читателей подробный анализ приведён ниже.
Пространственная сложность: пространственная сложность равна O(1)O(1)O(1).
Совет для собеседования
Совет: визуализируйте задачу. На первый взгляд эта задача кажется требующей математического обоснования для решения. Однако, когда мы визуализировали её, нам удалось сформулировать решение с помощью уже известного алгоритма (обнаружения цикла Флойда). Визуализация задачи может помочь выявить скрытые паттерны или структуры данных, ведущие к решению.
Анализ временной сложности задачи «Счастливое число»
Следующий анализ временной сложности устанавливает верхнюю границу числа шагов, необходимых для определения счастливого числа. В этом анализе «следующим числом» числа nnn называется результат суммирования квадратов цифр nnn.
1) Верхняя граница следующего числа Для любого числа nnn с фиксированным количеством цифр максимальное значение его преемника достигается, когда все цифры равны 9. Например, максимальное следующее число для трёхзначного числа достигается при числе 999.
2) Размер следующего числа относительно количества цифр
- Если число nnn имеет 1 или 2 цифры, следующее число может быть больше nnn (например, следующее число для 99 равно 162, что длиннее на одну цифру).
- Если число nnn имеет 3 или более цифр, следующее число всегда меньше исходного значения nnn. Таблица ниже показывает, что наибольшее число с 3 или более цифрами имеет меньшее следующее число.
| Digits | Largest Number | Next Number |
|---|---|---|
| 1 | 9 | 81 |
| 2 | 99 | 162 |
| 3 | 999 | 243 |
| 4 | 9999 | 324 |
| 5 | 99999 | 405 |
| 6 | 999999 | 486 |
| ... | ... | ... |
3) Следствия для циклов Поскольку следующее число для чисел с тремя и более цифрами всегда меньше, цикл в процессе определения счастливого числа может возникнуть только после того, как число опускается ниже 243 (следующее число от 999). Это потому, что любое nnn больше 243 будет иметь следующее число, меньшее nnn. Однако как только число опустится ниже 243, следующее число может стать больше, потенциально возвращаясь к предыдущему числу.
4) Временная сложность для чисел меньше 243 Как только число опускается ниже 243, алгоритм потребует менее 243 шагов, чтобы либо сойтись к 1, либо обнаружить цикл с предыдущим числом. Следовательно, поскольку длина цикла или число шагов до достижения 1 ограничено 243, временная сложность алгоритма обнаружения цикла Флойда для чисел меньше 243 составляет O(1)O(1)O(1).
5) Временная сложность для чисел больше 243 Количество цифр в числе приблизительно равно log(n)\log(n)log(n) (по основанию 10). Поэтому вычисление следующего числа для nnn займёт приблизительно log(n)\log(n)log(n) шагов (то есть get_next_num выполняется за log(n)\log(n)log(n) шагов).
Назовём следующее число nnn как n2n^2n2. Следующее число после n2n^2n2 (n3n^3n3) потребует приблизительно log(n2)\log(n^2)log(n2) шагов для вычисления. Следующее число после n3n^3n3 (n4n^4n4) потребует приблизительно log(n3)\log(n^3)log(n3) шагов и так далее. Из этого следует, что временная сложность процесса составляет O(log(n)+log(n2)+log(n3)+…)O(\log(n)+\log(n^2)+\log(n^3)+\ldots)O(log(n)+log(n2)+log(n3)+…). Поскольку мы установили, что n>n2>…>nkn>n^2>\ldots>n^kn>n2>…>nk, где nkn^knk — последнее число, большее 243, доминирующим компонентом этой временной сложности является O(log(n))O(\log(n))O(log(n)). Таким образом, временная сложность для чисел больше 243 составляет O(log(n))O(\log(n))O(log(n)).
Заключение Когда nnn меньше 243, временная сложность равна O(1), а когда nnn больше 243 — O(log(n))O(\log(n))O(log(n)). Следовательно, общая временная сложность алгоритма составляет O(log(n))O(\log(n))O(log(n)).
Совет для собеседования
Совет: не тратьте время на сложные доказательства, если это не является важной частью собеседования.
На собеседовании обычно не ожидается точное определение временной сложности алгоритма, подобного использованному в этой задаче. В таких ситуациях можно сделать обоснованное предположение о том, как будет расти время выполнения алгоритма при увеличении входных данных, исходя из поведения алгоритма. Упоминайте любые допущения, которые вы делаете при обсуждении оценок. Также может быть полезно указать, какие аспекты задачи или решения затрудняют анализ временной сложности. В данной задаче изначально неясно, сколько шагов займёт процесс определения счастливого числа до достижения 1 или обнаружения цикла.
Скользящее окно
Введение в технику скользящего окна
Введение в технику скользящего окна
Интуиция
Техника скользящего окна является подмножеством паттерна двух указателей: она использует два указателя (обычно левый и правый), задающих границы «окна» в итерируемых структурах данных, таких как массивы. Окно определяет подкомпонент структуры данных (например, подмассив или подстроку) и скользит по структуре данных в одном направлении, как правило, в поисках подкомпонента, удовлетворяющего некоторому условию.
Скользящие окна особенно ценны в сценариях, где алгоритмы иначе использовали бы два вложенных цикла для перебора всех возможных подкомпонентов, что давало бы временную сложность O(n2)O(n^2)O(n2) или хуже. При использовании скользящего окна нужный подкомпонент (или подкомпоненты) обычно находится за O(n)O(n)O(n) времени. Прежде чем разбираться, как это работает, установим необходимую терминологию:
- Расширить окно — значит продвинуть правый указатель:
- Сузить окно — значит продвинуть левый указатель:
- Сдвинуть окно — значит продвинуть оба указателя одновременно: Заметим, что сдвиг эквивалентен одновременному расширению и сужению окна.
Теперь рассмотрим два основных типа алгоритмов скользящего окна:
- Фиксированное скользящее окно.
- Динамическое скользящее окно.
Фиксированное скользящее окно
Фиксированное скользящее окно сохраняет постоянную длину при скольжении по структуре данных.
Техника фиксированного скользящего окна применяется, когда задача требует найти подкомпонент определённой длины. Если эта длина известна, окно фиксируется на этой длине и скользит по массиву.
Если фиксированное окно длиной k пройдёт по структуре данных от начала до конца, оно гарантированно увидит каждый подкомпонент длиной k в этой структуре данных.
Ниже приведён общий шаблон того, как фиксированное скользящее окно обходит структуру данных.
left = right = 0
while right < n:
# If the window has reached the expected fixed length, we slide the window (move
# both left and right).
if right - left + 1 == fixed_window_size:
# Process the current window.
result = process_current_window()
left += 1
right += 1
Динамическое скользящее окно
В отличие от фиксированных, динамические окна могут расширяться или сужаться при обходе структуры данных.
Как правило, динамические скользящие окна применяются к задачам, требующим найти наидлиннейший или наикратчайший подкомпонент, удовлетворяющий условию (например, условие может состоять в том, что числа в окне должны быть больше 10).
При поиске наидлиннейшего подкомпонента логика расширения и сужения обычно такова:
- Если окно удовлетворяет условию, расширяем его, чтобы найти более длинное окно, также удовлетворяющее условию.
- Если условие нарушается, сужаем окно до тех пор, пока условие не будет выполнено снова.
Ниже приведён общий шаблон, демонстрирующий принцип работы:
left = right = 0
while right < n:
# While the condition is violated, the window is invalid, so shrink the window by
# advancing the left pointer.
while condition is violated:
left += 1
# Once the window is valid, process it and then expand the window by advancing the
# right pointer.
result = process_current_window()
right += 1
Обратите внимание, что приведённые шаблоны для фиксированных и динамических окон в первую очередь демонстрируют движение левого и правого указателей, а не детали обновления самого окна. Если задача требует обновления окна, соответствующая логика будет сильно зависеть от контекста. Это подробно рассматривается на конкретных задачах в данной главе.
Пример из реальной жизни
Буферизация при потоковой передаче видео: при потоковой передаче видео динамическое скользящее окно может использоваться для управления буферизацией и обеспечения плавного воспроизведения.
Например, при стриминге видео плеер скачивает фрагменты видеоданных и сохраняет их в буфере. Скользящее окно управляет тем, какая часть видео буферизована, «скользя» вперёд по мере воспроизведения. Скользящее окно позволяет видеоплееру адаптироваться к изменяющимся условиям сети, динамически регулируя размер и положение буфера, что обеспечивает более плавный стриминг для зрителя.
Содержание главы
Анаграммы в подстроке
Анаграммы в подстроке
Даны две строки s и t, состоящие из строчных букв латинского алфавита. Верните количество подстрок строки s, являющихся анаграммами строки t.
Анаграмма — это слово или фраза, образованная перестановкой букв другого слова или фразы с использованием всех исходных букв ровно по одному разу.
Пример:
Input: s = 'caabab', t = 'aba'
Output: 2
Пояснение: есть анаграмма t, начинающаяся с индекса 1 ("caabab"), и ещё одна, начинающаяся с индекса 2 ("caabab").
Интуиция
Можно переформулировать понятие анаграммы, изменив приведённое определение. Подстрока строки s является анаграммой t, если она содержит ровно те же символы, что и t, в любом порядке.
Чтобы подстрока s была анаграммой t, она должна иметь ту же длину, что и t (обозначим её len_t). Это означает, что достаточно рассматривать только подстроки s длиной len_t, что избавляет от необходимости проверять каждую возможную подстроку.
Для перебора всех подстрок фиксированной длины len_t можно использовать технику фиксированного скользящего окна, поскольку окно длиной len_t гарантированно пройдёт через каждую подстроку этой длины. Это видно на примере ниже:
Теперь нам нужен способ проверить, является ли окно анаграммой t. Вспомним, что в анаграмме порядок букв не важен — важна только частота каждой буквы. Сравнивая частоту каждого символа в окне с частотами символов строки t, можно определить, является ли данное окно анаграммой t.
Рассмотрим это рассуждение на примере. Перед запуском алгоритма скользящего окна нужен способ хранить частоты символов строки t. Можно использовать хэш-карту или массив expected_freqs для хранения частот каждого символа строки t.
expected_freqs — это целочисленный массив размером 26, где каждый индекс соответствует одной из строчных букв латинского алфавита (0 для 'a', 1 для 'b' и так далее вплоть до 25 для 'z').
Для настройки алгоритма скользящего окна определим следующие компоненты:
- Левый и правый указатели: инициализируются в начале строки для задания границ окна.
- window_freqs: массив размером 26 для отслеживания частот символов внутри окна.
- count: переменная для подсчёта обнаруженных анаграмм.
Перед скольжением окна сначала нужно расширить его до фиксированной длины len_t. Это делается продвижением правого указателя до тех пор, пока длина окна не станет равной len_t. При расширении необходимо обновлять window_freqs для отражения частот символов в окне:
После того как окно достигло фиксированной длины len_t, можно проверить, является ли оно анаграммой t, сравнив массивы expected_freqs и window_freqs. Это делается за константное время, поскольку требует всего 26 сравнений — по одному для каждой строчной буквы латинского алфавита.
Чтобы сдвигать это окно по строке, необходимо продвигать левый и правый указатели на один шаг за каждую итерацию. Следует обновлять window_freqs: увеличивать частоту каждого нового символа у правого указателя и уменьшать частоту символа у левого указателя при продвижении мимо него:
После обработки всех подстрок длиной len_t можно вернуть count — количество найденных анаграмм.
Небольшая оптимизация: возвращать 0, если длина t превышает длину s, поскольку сформировать анаграмму t из подстрок s невозможно, если t длиннее.
Реализация
В Python можно использовать ord(character) - ord('a') для нахождения индекса строчной буквы латинского алфавита в массиве размером 26. Функция ord принимает символ и возвращает его ASCII-код. Эта формула вычисляет расстояние данного символа от 'a', давая индекс в диапазоне от 0 до 25.
def substring_anagrams(s: str, t: str) -> int:
len_s, len_t = len(s), len(t)
if len_t > len_s:
return 0
count = 0
expected_freqs, window_freqs = [0] * 26, [0] * 26
# Populate 'expected_freqs' with the characters in string 't'.
for c in t:
expected_freqs[ord(c) - ord('a')] += 1
left = right = 0
while right < len_s:
# Add the character at the right pointer to 'window_freqs' before sliding the
# window.
window_freqs[ord(s[right]) - ord('a')] += 1
# If the window has reached the expected fixed length, we advance the left
# pointer as well as the right pointer to slide the window.
if right - left + 1 == len_t:
if window_freqs == expected_freqs:
count += 1
# Remove the character at the left pointer from 'window_freqs' before
# advancing the left pointer.
window_freqs[ord(s[left]) - ord('a')] -= 1
left += 1
right += 1
return count
Анализ сложности
Временная сложность: временная сложность substring_anagrams составляет O(n)O(n)O(n), где nnn — длина s. Вот почему:
- Заполнение массива expected_freqs занимает O(m)O(m)O(m) времени, где m — длина t. Поскольку mmm гарантированно не превышает nnn на этом этапе, это не является доминирующим членом временной сложности.
- Затем мы линейно обходим строку s двумя указателями, что занимает O(n)O(n)O(n) времени.
- Заметим, что на каждой итерации сравнение двух массивов частот (expected_freqs и window_freqs) выполняется за O(1)O(1)O(1), поскольку каждый массив содержит лишь 26 элементов.
Пространственная сложность: пространственная сложность равна O(1)O(1)O(1), поскольку каждый массив частот содержит лишь 26 элементов.
Наидлиннейшая подстрока из уникальных символов
Наидлиннейшая подстрока из уникальных символов
Дана строка. Определите длину её наидлиннейшей подстроки, состоящей только из уникальных символов.
Пример:
Input: s = 'abcba'
Output: 3
Пояснение: подстрока "abc" — наидлиннейшая подстрока длиной 3, содержащая уникальные символы ("cba" также подходит).
Интуиция
Подход «в лоб» предполагает перебор всех возможных подстрок и проверку, состоят ли они исключительно из уникальных символов. Разберём этот подход:
- Проверка подстроки на уникальность выполняется за O(n)O(n)O(n) времени путём сканирования подстроки с использованием хэш-множества для отслеживания каждого символа, где n — длина s. Если встречается символ, уже находящийся в хэш-множестве, значит, это дубликат.
- Перебор всех возможных подстрок занимает O(n2)O(n^2)O(n2) времени.
Это означает, что подход «в лоб» в целом потребует O(n3)O(n^3)O(n3) времени. Это довольно медленно, во многом потому что мы просматриваем каждую подстроку. Можно ли сократить количество просматриваемых подстрок?
Скользящее окно Техники скользящего окна весьма полезны для задач, связанных с подстроками. В частности, поскольку мы ищем наидлиннейшую подстроку, удовлетворяющую конкретному условию (содержит уникальные символы), алгоритм с динамическим скользящим окном может быть правильным выбором, как обсуждалось во введении.
Любое окно можно охарактеризовать одним из двух способов. Окно либо:
- Состоит только из уникальных символов (окно без дублирующихся символов).
- Содержит хотя бы один символ с частотой больше 1.
Если верно условие A, следует расширить окно, продвинув правый указатель, чтобы найти более длинное окно, также не содержащее дубликатов.
Если верно условие B из-за наличия дублирующегося символа в окне, следует сузить окно, продвигая левый указатель, пока оно не перестанет содержать дубликат.
Применим эту стратегию к следующему примеру. Инициализируем хэш-множество для отслеживания символов в окне.
Для реализации техники скользящего окна определим следующее:
- Левый и правый указатели: инициализируются в начале строки для задания границ окна.
- hash_set: хэш-множество для хранения уникальных символов в окне, обновляемое по мере его расширения. Заметим, что хэш-множество на диаграмме отражает своё состояние до добавления в него символа у правого указателя.
Теперь начнём искать наидлиннейшее окно. Расширяем окно от начала строки, продвигая правый указатель. Продолжаем расширение до обнаружения дублирующегося символа:
Выше видно, что символ 'b' с индексом 3 является дублирующимся в окне, поскольку 'b' уже находится в хэш-множестве.
Обнаружив дубликат, следует сужать окно, продвигая левый указатель, пока окно не перестанет содержать дублирующийся 'b'. Как только окно снова становится корректным, продолжаем расширение:
Дальнейшее расширение окна приведёт к выходу правого указателя за границу строки, на этом поиск заканчивается. Наидлиннейшая найденная подстрока без дубликатов имеет длину 3. Можно использовать переменную max_len для отслеживания этой длины в процессе поиска.
Реализация
def longest_substring_with_unique_chars(s: str) -> int:
max_len = 0
hash_set = set()
left = right = 0
while right < len(s):
# If we encounter a duplicate character in the window, shrink the window until
# it's no longer a duplicate.
while s[right] in hash_set:
hash_set.remove(s[left])
left += 1
# Once there are no more duplicates in the window, update 'max_len' if the
# current window is larger.
max_len = max(max_len, right - left + 1)
hash_set.add(s[right])
# Expand the window.
right += 1
return max_len
Анализ сложности
Временная сложность: временная сложность longest_substring_with_unique_chars составляет O(n)O(n)O(n), поскольку мы линейно обходим строку двумя указателями.
Пространственная сложность: пространственная сложность равна O(m)O(m)O(m), поскольку для хранения уникальных символов используется хэш-множество, где m — общее количество уникальных символов в строке.
Оптимизация
Описанный подход решает задачу, но его можно оптимизировать. Оптимизация касается способа сужения окна при обнаружении дублирующегося символа. Рассмотрим следующий пример, где правый указатель встречает дублирующийся символ 'c':
В предыдущем подходе мы реагировали на обнаружение дубликата непрерывным продвижением левого указателя для сужения окна до тех пор, пока оно не перестанет содержать дубликат:
Ключевое наблюдение: мы продвигали левый указатель до тех пор, пока он не прошёл мимо предыдущего вхождения 'c' в окне. Это означает, что зная индекс предыдущего вхождения 'c', можно сразу переместить левый указатель на позицию после этого индекса, удалив его из окна:
Это даёт нам новую стратегию продвижения левого указателя: если правый указатель встречает символ, предыдущий индекс которого (предыдущее вхождение) находится в окне, необходимо переместить левый указатель на один индекс дальше этого предыдущего индекса.
Для хранения предыдущего индекса каждого символа строки можно использовать хэш-карту (prev_indexes).
Теперь нужно убедиться, что предыдущий индекс символа находится в окне. Для этого сравниваем его индекс с левым указателем:
- Если этот индекс находится после левого указателя, он внутри окна.
- Если он находится до левого указателя, он вне окна.
Ниже показано, как проверить, находится ли символ внутри окна:
Реализация — оптимизированный подход
def longest_substring_with_unique_chars_optimized(s: str) -> int:
max_len = 0
prev_indexes = {}
left = right = 0
while right < len(s):
# If a previous index of the current character is present in the current
# window, it's a duplicate character in the window.
if s[right] in prev_indexes and prev_indexes[s[right]] >= left:
# Shrink the window to exclude the previous occurrence of this character.
left = prev_indexes[s[right]] + 1
# Update 'max_len' if the current window is larger.
max_len = max(max_len, right - left + 1)
prev_indexes[s[right]] = right
# Expand the window.
right += 1
return max_len
Анализ сложности
Временная сложность: временная сложность оптимизированной реализации составляет O(n)O(n)O(n), поскольку мы линейно обходим строку двумя указателями.
Пространственная сложность: пространственная сложность равна O(m)O(m)O(m), поскольку для хранения уникальных символов используется хэш-карта, где mmm — общее количество уникальных символов в строке.
Наидлиннейшая однородная подстрока после замен
Наидлиннейшая однородная подстрока после замен
Однородная подстрока — это подстрока, в которой все символы одинаковы. Дана строка. Определите длину наидлиннейшей однородной подстроки, которую можно получить, заменив не более k символов.
Пример:
Input: s = 'aabcdcca', k = 2
Output: 5
Пояснение: если можно заменить только 2 символа, наидлиннейшая однородная подстрока, которую можно получить — "ccccc", заменив 'b' и 'd' на 'c'.
Интуиция
Определение однородности подстроки Прежде чем искать наидлиннейшую однородную подстроку, сначала выясним наиболее эффективный способ сделать строку однородной с минимальным количеством замен символов. Рассмотрим пример ниже:
Какие символы следует заменить, чтобы минимизировать количество замен при приведении строки к однородному виду? Есть три основных варианта: сделать строку всей из 'a', всей из 'b' или всей из 'c'. Наиболее эффективный выбор, требующий наименьшего числа замен, — сделать все символы 'a', что потребует лишь трёх замен:
Ключевое наблюдение: минимальное количество замен для достижения однородности получается заменой всех символов, кроме наиболее часто встречающегося.
Это означает, что зная максимальную частоту какого-либо символа в подстроке, можно определить, достаточно ли значения k для приведения этой подстроки к однородному виду. Количество символов, которые нужно заменить (num_chars_to_replace), находится вычитанием этой максимальной частоты из общего количества символов в подстроке:
Вычислив num_chars_to_replace для данной подстроки, можно оценить, можно ли привести её к однородному виду:
- Если num_chars_to_replace ≤ k, подстроку можно сделать однородной.
- Если num_chars_to_replace > k, подстроку нельзя сделать однородной.
Для вычисления num_chars_to_replace нужно знать значение highest_freq. Для этого требуется отслеживать частоту каждого символа, что можно эффективно делать с помощью хэш-карты (freqs). Она позволяет обновлять highest_freq при обнаружении символа с большей частотой. Ниже показано, как обновляется freqs:
Теперь, имея инструменты для определения того, можно ли сделать подстроку однородной, следующий шаг — выяснить, как найти наидлиннейшую однородную подстроку. Рассмотрим технику, позволяющую это сделать.
Динамическое скользящее окно Мы знаем, что скользящие окна полезны для решения задач с подстроками. Данная задача требует найти наидлиннейшую подстроку, удовлетворяющую конкретному условию:
num_chars_to_replace <= k
Поэтому динамическое скользящее окно может быть уместным, как обсуждалось во введении к главе.
Это условие можно использовать для определения того, расширять или сужать окно:
- Если условие выполнено (окно корректно), расширяем его в поисках более длинного окна, также удовлетворяющего условию.
- Если условие нарушено (окно некорректно), сужаем его до тех пор, пока условие не будет выполнено снова.
Посмотрим, как это работает на примере ниже:
Начинаем с определения левой и правой границ окна на индексе 0. Продолжаем расширять окно, пока оно удовлетворяет нашему условию (nums_char_to_replace <= k):
Как только окно расширится до пятого символа ('d'), оно будет содержать 3 символа, которые нужно заменить для его приведения к однородному виду. Поскольку можно заменить не более k = 2 символов, окно становится некорректным. Следовательно, сужаем окно:
Заметим, что после сужения окна значение highest_freq по-прежнему равно 2, что уже неверно. Напомним, что наш текущий метод обновления highest_freq лишь увеличивает его при обнаружении символа с большей частотой — это означает, что highest_freq может только сохраняться или расти, но никогда не уменьшаться.
Один из способов обойти это — разработать новый метод обновления highest_freq, который точно уменьшает его при снижении максимальной частоты в окне. Однако наша цель — найти наидлиннейшую подстроку, удовлетворяющую условию, поэтому сужение окна может оказаться вовсе не нужным. Ключевой момент здесь: обнаружив корректное окно определённой длины, никакое более короткое окно не даст более длинной однородной подстроки.
Это означает, что при обнаружении некорректного окна можно просто сдвигать его, а не сужать, фактически сохраняя длину текущего окна.
Учитывая это наблюдение, скорректируем предыдущую логику:
- Если окно удовлетворяет условию: расширяем.
- Если окно не удовлетворяет условию: сдвигаем.
Исправим действие, предпринятое для первого некорректного окна выше: вместо сужения сдвигаем. Затем продолжаем обработку оставшейся строки.
Вышеуказанное окно является конечным, поскольку его нельзя ни расширить, ни сдвинуть дальше. Наидлиннейшее корректное окно, встреченное в этом процессе, имеет длину 5.
Реализация
def longest_uniform_substring_after_replacements(s: str, k: int) -> int:
freqs = {}
highest_freq = max_len = 0
left = right = 0
while right < len(s):
# Update the frequency of the character at the right pointer and the highest
# frequency for the current window.
freqs[s[right]] = freqs.get(s[right], 0) + 1
highest_freq = max(highest_freq, freqs[s[right]])
# Calculate replacements needed for the current window.
num_chars_to_replace = (right - left + 1) - highest_freq
# Slide the window if the number of replacements needed exceeds 'k'.
# The right pointer always gets advanced, so we just need to advance 'left'.
if num_chars_to_replace > k:
# Remove the character at the left pointer from the hash map before
# advancing the left pointer.
freqs[s[left]] -= 1
left += 1
# Since the length of the current window increases or stays the same, assign
# the length of the current window to 'max_len'.
max_len = right - left + 1
# Expand the window.
right += 1
return max_len
Анализ сложности
Временная сложность: временная сложность longest_uniform_substring_after_replacements составляет O(n)O(n)O(n), где nnn — длина входной строки. Это обусловлено линейным обходом строки двумя указателями.
Пространственная сложность: пространственная сложность равна O(m)O(m)O(m), где mmm — количество уникальных символов строки, хранящихся в хэш-карте freqs.
Кучи
Введение в кучи
Введение в кучи
Интуиция
Куча — структура данных, организующая элементы по приоритету и гарантирующая, что элемент с наивысшим приоритетом всегда доступен немедленно.
- Мин-куча: приоритет отдаётся наименьшему элементу — он находится на вершине.
- Макс-куча: приоритет отдаётся наибольшему элементу — он находится на вершине.
Эффективная приоритизация возможна благодаря структуре кучи. Куча — это двоичное дерево, в котором каждый родительский узел имеет более высокий приоритет, чем его дочерние узлы.
Анализ временной сложности распространённых операций с кучей:
В этой главе рассматриваются практические применения куч. Для более глубокого понимания работы кучи обратитесь к разделу «Основные понятия» в конце.
Очередь с приоритетом: очередь с приоритетом — специальный тип кучи, следующий структуре мин-кучи или макс-кучи. Она позволяет добавлять элементы с произвольным приоритетом и извлекать элемент с наивысшим приоритетом.
Пример из реальной жизни
Управление задачами в операционных системах: операционные системы часто используют очередь с приоритетом для управления выполнением процессов.
Например, при работе нескольких процессов на компьютере каждому процессу может быть назначен приоритет. Операционная система использует очередь с приоритетом для планирования: процесс с наивысшим приоритетом выполняется первым.
Обзор главы
K наиболее частых строк
K наиболее частых строк
Найдите k наиболее часто встречающихся строк в массиве и верните их, отсортированных по убыванию частоты. При одинаковой частоте строки сортируются лексикографически.
Пример:
Input: strs = ['go', 'coding', 'byte', 'byte', 'go', 'interview', 'go'], k = 2
Output: ['go', 'byte']
Пояснение: строки "go" и "byte" встречаются наиболее часто — 3 и 2 раза соответственно.
Ограничения:
- k ≤ n, где n — длина массива.
Интуиция — Макс-куча
Две основные задачи:
- Определить k наиболее частых строк.
- Отсортировать эти строки сначала по частоте, затем лексикографически.
Сначала нужно отслеживать частоты каждой строки. Для этого используется хеш-таблица.
Наиболее очевидный подход — получить массив строк из хеш-таблицы, отсортировать его и вернуть первые k элементов.
Главная неэффективность — сортировка всех n строк, хотя нам нужны только k.
Полезное наблюдение: после удаления наиболее частой строки новой наиболее частой становится следующая по частоте.
Для реализации этой идеи нужна структура данных, обеспечивающая эффективный доступ к наиболее частой строке. Здесь идеально подходит макс-куча.
Один из способов наполнить кучу — поместить в неё все n строк по одной, что займёт O(n log n) времени. В качестве альтернативы можно построить кучу за O(n) с использованием heapify.
Для получения k наиболее частых строк извлекайте верхний элемент из кучи k раз и сохраняйте результаты.
Необходимо обеспечить, что при равной частоте двух строк та, что идёт первой лексикографически, имеет более высокий приоритет в куче.
Реализация — Макс-куча
Создаётся класс Pair для пар строка-частота, позволяющий задать приоритет через пользовательский компаратор.
from typing import List
from collections import Counter
import heapq
class Pair:
def __init__(self, str, freq):
self.str = str
self.freq = freq
def __lt__(self, other):
if self.freq == other.freq:
return self.str < other.str
return self.freq > other.freq
def k_most_frequent_strings_max_heap(strs: List[str], k: int) -> List[str]:
freqs = Counter(strs)
max_heap = [Pair(str, freq) for str, freq in freqs.items()]
heapq.heapify(max_heap)
return [heapq.heappop(max_heap).str for _ in range(k)]
Анализ сложности
Временная сложность: O(n + k log n). Пространственная сложность: O(n).
Интуиция — Мин-куча
В качестве продолжения интервьюер может попросить изменить решение для уменьшения используемого пространства.
Важное наблюдение: когда куча превышает размер k, можно отбросить строку с наименьшей частотой, так как она никогда не попадёт в итоговый результат.
Однако эту стратегию нельзя реализовать с макс-кучей, поскольку доступа к наименее частой строке нет. Вместо этого используется мин-куча: при её размере более k извлекается элемент с наименьшей частотой.
from typing import List
from collections import Counter
import heapq
class Pair:
def __init__(self, str, freq):
self.str = str
self.freq = freq
def __lt__(self, other):
if self.freq == other.freq:
return self.str > other.str
return self.freq < other.freq
def k_most_frequent_strings_min_heap(strs: List[str], k: int) -> List[str]:
freqs = Counter(strs)
min_heap = []
for str, freq in freqs.items():
heapq.heappush(min_heap, Pair(str, freq))
if len(min_heap) > k:
heapq.heappop(min_heap)
return [heapq.heappop(min_heap).str for _ in range(k)][::-1]
Анализ сложности
Временная сложность: O(n log k). Пространственная сложность: O(n) для хеш-таблицы, O(k) для кучи.
Объединение отсортированных связных списков
Объединение отсортированных связных списков
Даны k отсортированных по возрастанию связных списков. Объедините их в один отсортированный связный список и верните его голову.
Интуиция
Хорошее начало — понять, как объединить два отсортированных связных списка. Использование двух указателей позволяет сравнивать головы обоих списков и добавлять меньший узел в выходной список за O(n+m) времени.
Но что если связных списков больше двух? Объединение двух списков предполагает сравнение двух узлов одновременно. При k списках это k сравнений для каждого из n узлов, что в сумме даёт O(n·k).
Причина многочисленных сравнений в том, что неизвестно, у какого узла наименьшее значение. Мин-куча решает эту проблему, предоставляя эффективный доступ к минимальному элементу.
Для начала заполним кучу головными узлами всех связных списков.
Затем реализуем стратегию добавления узла с наименьшим значением в выходной список с помощью кучи.
Когда куча пуста, возвращаем dummy.next — голову объединённого списка.
Реализация
from typing import List
from ds import ListNode
import heapq
def combine_sorted_linked_lists(lists: List[ListNode]) -> ListNode:
ListNode.__lt__ = lambda self, other: self.val < other.val
heap = []
for head in lists:
if head:
heapq.heappush(heap, head)
dummy = ListNode(-1)
curr = dummy
while heap:
smallest_node = heapq.heappop(heap)
curr.next = smallest_node
curr = curr.next
if smallest_node.next:
heapq.heappush(heap, smallest_node.next)
return dummy.next
Анализ сложности
Временная сложность: O(n log k), где n — общее число узлов. Пространственная сложность: O(k).
Медиана потока целых чисел
Медиана потока целых чисел
Разработайте структуру данных, поддерживающую добавление целых чисел из потока данных и получение медианы всех добавленных чисел.
- add(num: int) -> None: добавляет целое число в структуру данных.
- get_median() -> float: возвращает медиану всех добавленных чисел.
Пример:
Input: [add(3), add(6), get_median(), add(1), get_median()]
Output: [4.5, 3.0]
Интуиция
Медиана всегда находится в середине отсортированного списка значений. Сложность задачи — в поддержании актуальной медианы при добавлении новых чисел.
Используется комбинация мин-кучи и макс-кучи:
- Макс-куча управляет левой половиной; её верхнее значение — первое значение медианы.
- Мин-куча управляет правой половиной; её верхнее значение — второе значение медианы.
Если общее число элементов нечётное, медиана одна — макс-куча назначается хранить дополнительный элемент.
Два правила управления кучами:
- Максимальное значение макс-кучи (левая половина) должно быть меньше или равно минимальному значению мин-кучи (правая половина).
- Кучи должны быть равного размера, но макс-куча может содержать на один элемент больше.
Реализация
import heapq
class MedianOfAnIntegerStream:
def __init__(self):
self.left_half = [] # max-heap (negated)
self.right_half = [] # min-heap
def add(self, num: int) -> None:
if not self.left_half or num <= -self.left_half[0]:
heapq.heappush(self.left_half, -num)
if len(self.left_half) > len(self.right_half) + 1:
heapq.heappush(self.right_half, -heapq.heappop(self.left_half))
else:
heapq.heappush(self.right_half, num)
if len(self.right_half) > len(self.left_half):
heapq.heappush(self.left_half, -heapq.heappop(self.right_half))
def get_median(self) -> float:
if len(self.left_half) == len(self.right_half):
return (-self.left_half[0] + self.right_half[0]) / 2
return float(-self.left_half[0])
Анализ сложности
Временная сложность add: O(log n). Временная сложность get_median: O(1). Пространственная сложность: O(n).
Сортировка k-отсортированного массива
Сортировка k-отсортированного массива
Дан целочисленный массив, в котором каждый элемент находится не более чем на k позиций от своей позиции в отсортированном массиве. Отсортируйте массив за O(n log k) времени.
Пример:
Input: nums = [5, 1, 9, 4, 7, 10], k = 2
Output: [1, 4, 5, 7, 9, 10]
Интуиция
В k-отсортированном массиве каждый элемент находится не более чем на k индексов от своего положения в полностью отсортированном массиве.
Тривиальное решение — отсортировать массив стандартным алгоритмом. Однако, используя свойство k-отсортированности, можно достичь O(n log k).
Для любого индекса i элемент, принадлежащий позиции i в отсортированном массиве, находится в диапазоне [i-k, i+k]. Следовательно, минимальный элемент из первых k+1 элементов является наименьшим во всём массиве.
Для улучшения подхода нужна мин-куча для эффективного доступа к минимальному значению на каждом диапазоне [i, i+k].
Реализация
from typing import List
import heapq
def sort_a_k_sorted_array(nums: List[int], k: int) -> List[int]:
min_heap = nums[:k+1]
heapq.heapify(min_heap)
insert_index = 0
for i in range(k + 1, len(nums)):
nums[insert_index] = heapq.heappop(min_heap)
insert_index += 1
heapq.heappush(min_heap, nums[i])
while min_heap:
nums[insert_index] = heapq.heappop(min_heap)
insert_index += 1
return nums
Анализ сложности
Временная сложность: O(n log k). Пространственная сложность: O(k).
Intervals
Введение в Intervals
Введение в Intervals
Интуиция
Interval состоит из двух значений: start point и end point. Он представляет непрерывный отрезок на числовой оси, включающий все значения между этими двумя точками. Intervals часто используются для обозначения отрезка, временного периода или непрерывного диапазона значений.
- Start point interval'а указывает, где interval начинается.
- End point interval'а указывает, где interval заканчивается.
Intervals бывают закрытыми, открытыми или полуоткрытыми — в зависимости от того, включены ли их start или end point в interval.
- Closed intervals: и start, и end point включены в interval.
- Open intervals: start и end point не включены в interval.
- Half-open intervals: либо start, либо end point включён, а другой — нет.
При решении задачи на intervals на собеседовании важно уточнить, являются ли intervals открытыми, закрытыми или полуоткрытыми — это может изменить характер overlap'а между ними.
Overlapping intervals Два interval'а overlap'ятся, если у них есть хотя бы одно общее значение.
Главная сложность большинства задач на intervals — эффективная работа с overlapping intervals. Независимо от того, нужно ли их выявлять или merge'ить, важно понимать, как overlap между intervals влияет на желаемый результат. Задачи этой главы посвящены обработке overlapping intervals в различных ситуациях.
Sorting intervals В большинстве задач на intervals предварительная сортировка intervals очень полезна, поскольку позволяет обрабатывать их в определённом порядке.
Обычно intervals сортируют по start point, чтобы обходить их в хронологическом порядке. Когда у двух или более intervals одинаковый start point, при сортировке также может потребоваться учитывать end point каждого interval'а.
Разделение start и end points В некоторых сценариях бывает полезно обрабатывать start и end points intervals по отдельности. Обычно это подразумевает создание двух отсортированных массивов: одного — со всеми start points, другого — со всеми end points. Например, это необходимо в алгоритме sweeping line, который рассматривается в задаче Largest Overlap of Intervals.
Определение класса Interval В задачах этой главы intervals представлены с помощью приведённого ниже класса.
class Interval:
def __init__(self, start, end):
self.start = start
self.end = end
Пример из реальной жизни
Системы планирования: intervals широко применяются в системах расписания. Например, в системе бронирования переговорных комнат каждое бронирование представлено как interval. Это представление используется, если система должна поддерживать функциональность — например, определять максимальное число overlapping бронирований для обеспечения достаточного количества свободных комнат. Анализируя эти intervals, система может эффективно распределять ресурсы и предотвращать двойные бронирования.
Содержание главы
Merge Overlapping Intervals
Merge Overlapping Intervals
Merge массив intervals так, чтобы среди них не осталось overlapping intervals, и верните результирующие merged intervals.
Пример:
Input: intervals = [[3, 4], [7, 8], [2, 5], [6, 7], [1, 4]]
Output: [[1, 5], [6, 8]]
Ограничения:
- Входные данные содержат хотя бы один interval.
- Для каждого индекса i в массиве: intervals[i].start ≤ intervals[i].end.
Интуиция
В данной задаче есть две основные сложности:
- Определить, какие intervals overlap'ятся друг с другом.
- Merge'ить эти intervals.
Начнём с первой сложности. Рассмотрим два interval'а, A и B, где interval A начинается раньше B. На рисунке ниже показан случай, когда эти два interval'а не overlap'ятся:
Пунктирная линия показывает, что interval A заканчивается раньше, чем начинается interval B, что исключает возможность overlap'а B с A. Это означает, что intervals A и B никогда не будут overlap'иться, если A.end < B.start.
Теперь рассмотрим несколько случаев, когда эти два interval'а всё же overlap'ятся:
В этих случаях видно, что B начинается до (или в момент) окончания A (A.end ≥ B.start). Иными словами, часть B overlap'ится с A, так как interval A не успел закончиться до начала interval'а B. Следовательно, intervals A и B overlap'ятся при A.end ≥ B.start.
Мы определили два случая, охватывающих все сценарии overlap'а и его отсутствия для двух intervals при условии, что interval A начинается раньше B:
- Если A.end < B.start, intervals не overlap'ятся.
- Если A.end ≥ B.start, intervals overlap'ятся.
Чтобы применить эти условия к любым двум intervals из входных данных, удобно иметь способ определить, какой interval начинается первым. Один из вариантов — отсортировать intervals по их start value, что явно покажет, какой из каждой пары соседних intervals начинается первым.
Merging intervals С учётом приведённой логики разберём пример. Рассмотрим следующие intervals:
Первый шаг — отсортировать эти intervals по start value:
Для наглядности представим intervals визуально:
Будем добавлять/merge'ить каждый interval в новый массив merged, начиная с первого — его можно сразу добавить в массив merged, так как он первый:
После добавления первого interval'а начинается процесс merging. Определим A как последний interval в массиве merged, а B — как текущий interval во входных данных.
Замечаем, что B начинается до окончания A, что указывает на overlap. Выполним merge:
При merge A и B берём наименьший start value и наибольший end value из двух.
Применим ту же логику к следующему interval'у:
Когда мы доходим до четвёртого interval'а, видим, что B начинается после окончания A — overlap'а нет.
Поэтому просто добавляем его как новый interval в массив merged:
Следующий interval B overlap'ится с последним interval'ом в массиве merged — A, так как B начинается в момент окончания A (A.end == B.start).
Выполним merge A с B:
Обработав последний interval, мы успешно merge'или все intervals.
Реализация
from typing import List
from ds import Interval
def merge_overlapping_intervals(intervals: List[Interval]) -> List[Interval]:
intervals.sort(key=lambda x: x.start)
merged = [intervals[0]]
for B in intervals[1:]:
A = merged[-1]
if A.end < B.start:
merged.append(B)
else:
merged[-1] = Interval(A.start, max(A.end, B.end))
return merged
Анализ сложности
Временная сложность: O(nlog(n)) из-за сортировки. Сам merge занимает O(n).
Пространственная сложность: O(n) для Tim sort в Python.
Совет для собеседования
Совет: визуализируйте intervals, чтобы раскрыть логику и граничные случаи. Рисование примеров помогает интервьюеру следить за вашими рассуждениями.
Identify All Interval Overlaps
Identify All Interval Overlaps
Верните массив всех overlap'ов между двумя массивами intervals: intervals1 и intervals2. Каждый отдельный массив intervals отсортирован по start value и не содержит overlapping intervals внутри себя.
Пример:
Input: intervals1 = [[1, 4], [5, 6], [9, 10]],
intervals2 = [[2, 7], [8, 9]]
Output: [[2, 4], [5, 6], [9, 9]]
Ограничения:
- Для каждого индекса i в intervals1: intervals1[i].start < intervals1[i].end.
- Для каждого индекса j в intervals2: intervals2[j].start < intervals2[j].end.
Интуиция
Нам даны два массива intervals, каждый из которых содержит non-overlapping intervals. Это означает, что overlap может возникнуть только между interval'ом из первого массива и interval'ом из второго массива.
Определение overlap'а двух overlapping intervals Из задачи Merge Overlapping Intervals мы знаем, что два interval'а — A и B — overlap'ятся при A.end >= B.start, при условии что A начинается раньше B.
Чтобы извлечь overlap этих двух overlapping intervals, нужно определить, где он начинается и где заканчивается.
- Overlap начинается в наибольшей start point, которой всегда является B.start.
- Overlap заканчивается в наименьшей end point (min(A.end, B.end)).
Таким образом, при overlap двух intervals их пересечение определяется диапазоном [B.start, min(A.end, B.end)].
Нахождение всех overlap'ов Вернёмся к двум массивам intervals.
Начнём с рассмотрения первого interval'а из каждого массива:
Чтобы проверить, overlap'ятся ли эти intervals, определим, какой из них начинается первым — назначим его как A, а другой — как B:
if intervals1[i].start <= intervals2[j].start:
A, B = intervals1[i], intervals2[j]
else:
A, B = intervals2[j], intervals1[i]
A и B overlap'ятся при A.end >= B.start — здесь это условие выполняется. Запишем overlap: [B.start, min(A.end, B.end)]:
После определения overlap'а продвинем указатель того interval'а, который заканчивается раньше.
Мы сформулировали процесс, позволяющий выявлять и записывать все overlap'ы при обходе массивов intervals:
- Назначить A как interval, который начинается первым, а B — как другой interval.
- Проверить, выполняется ли A.end >= B.start, чтобы определить overlap. Если да — записать overlap как [B.start, min(A.end, B.end)].
- Interval, который заканчивается первым, — продвинуть соответствующий указатель на следующий interval.
Продолжать, пока i или j не выйдут за пределы своего массива.
Реализация
from typing import List
from ds import Interval
def identify_all_interval_overlaps(intervals1: List[Interval], intervals2: List[Interval]) -> List[Interval]:
overlaps = []
i = j = 0
while i < len(intervals1) and j < len(intervals2):
if intervals1[i].start <= intervals2[j].start:
A, B = intervals1[i], intervals2[j]
else:
A, B = intervals2[j], intervals1[i]
if A.end >= B.start:
overlaps.append(Interval(B.start, min(A.end, B.end)))
if intervals1[i].end < intervals2[j].end:
i += 1
else:
j += 1
return overlaps
Анализ сложности
Временная сложность: O(n+m), где n и m — длины intervals1 и intervals2.
Пространственная сложность: O(1).
Largest Overlap of Intervals
Largest Overlap of Intervals
Дан массив intervals. Определите максимальное количество intervals, которые overlap'ятся в какой-либо точке. Каждый interval является half-open: он включает start point, но исключает end point.
Пример:
Input: intervals = [[1, 3], [5, 7], [2, 6], [4, 8]]
Output: 3
Ограничения:
- Входные данные содержат хотя бы один interval.
- Для каждого индекса i в списке: intervals[i].start < intervals[i].end.
Интуиция
Подумайте, что означает overlap x intervals в определённый момент времени. Это значит, что в данный момент существует x «активных» intervals, где interval считается активным, если он начался, но ещё не закончился.
В примере ниже видно три активных interval'а в момент времени 5:
Чтобы определить количество активных intervals в любой момент времени, нужно отслеживать, когда interval начинается и когда заканчивается. Start point сигнализирует о начале нового активного interval'а, тогда как end point — о завершении активного interval'а.
Обработка start points и end points Пройдём по каждой точке в хронологическом порядке и определим количество активных intervals в каждой из них.
Первая точка — start point, увеличиваем счётчик:
Следующая точка — ещё один start point, увеличиваем счётчик до 2:
Следующая точка — end point, уменьшаем счётчик до 1:
Мы определили поведение для каждого типа точек:
- При встрече start point: увеличить active_intervals.
- При встрече end point: уменьшить active_intervals.
Финальный ответ — наибольшее значение active_intervals, зафиксированное в процессе обхода.
Граничный случай: обработка одновременных start и end points Когда start point и end point совпадают, следует обрабатывать end points раньше start points — иначе interval будет засчитан как активный в момент своего завершения.
Если обрабатывать end point первым, эта проблема не возникает:
Таким образом, для start и end points, совпадающих по значению, end points обрабатываются раньше start points.
Итерация по точкам intervals в порядке Объедините все start и end points в один массив и отсортируйте его, обеспечив приоритет end points перед start points при одинаковых значениях.
Алгоритм, используемый для решения этой задачи, называется «sweeping line algorithm». Он работает путём обработки start и end points intervals по порядку, как если бы вертикальная линия «проходила» сквозь них.
Реализация
from typing import List
from ds import Interval
def largest_overlap_of_intervals(intervals: List[Interval]) -> int:
points = []
for interval in intervals:
points.append((interval.start, 'S'))
points.append((interval.end, 'E'))
points.sort(key=lambda x: (x[0], x[1]))
active_intervals = 0
max_overlaps = 0
for time, point_type in points:
if point_type == 'S':
active_intervals += 1
else:
active_intervals -= 1
max_overlaps = max(max_overlaps, active_intervals)
return max_overlaps
Анализ сложности
Временная сложность: O(n*log(n)) из-за сортировки массива points размером 2n.
Пространственная сложность: O(n) из-за массива points.
Префиксные суммы
Введение в префиксные суммы
Введение в префиксные суммы
Интуиция
Представьте, что вы отслеживаете, сколько денег тратите на еду навынос каждый день на протяжении нескольких дней.
Массив префиксных сумм хранит накопленную сумму значений до каждого индекса массива. Общая сумма расходов до среды включительно составляет $45 ($10+$15+$20).
Чтобы получить префиксную сумму на каждом индексе, нужно прибавить текущее число из входного массива к префиксной сумме предыдущего индекса.
В коде этот процесс выглядит следующим образом:
def compute_prefix_sums(nums):
prefix_sum = [nums[0]]
for i in range(1, len(nums)):
prefix_sum.append(prefix_sum[-1] + nums[i])
Построение массива префиксных сумм занимает O(n) времени и O(n) памяти, где n — длина массива.
Применение префиксных сумм Помимо доступа к накопленным суммам за константное время, префиксные суммы широко применяются для эффективного вычисления суммы подмассивов. Интересной разновидностью являются префиксные произведения, которые заполняют массив накопленным произведением вместо суммы.
Пример из реального мира
Финансовый анализ: одним из практических применений префиксных сумм является финансовый анализ, в частности вычисление накопленных доходов или расходов за определённый период. Предварительно рассчитав префиксные суммы, компания может мгновенно определить выручку за любой заданный период, не суммируя доходы за каждый день по отдельности.
Обзор главы
Сумма в диапазоне
Сумма в диапазоне
Дан целочисленный массив. Напишите функцию, которая возвращает сумму значений между двумя индексами.
Пример:
Input: nums = [3, -7, 6, 0, -2, 5],
[sum_range(0, 3), sum_range(2, 4), sum_range(2, 2)]
Output: [2, 4, 6]
Ограничения:
- nums содержит хотя бы один элемент.
- Каждый вызов sum_range будет запрашивать корректный диапазон входного массива.
Интуиция
Нам нужно реализовать функцию sum_range(i, j). Наивное решение итерационно суммирует элементы от индекса i до j за линейное время на каждый вызов. Использование префиксных сумм позволяет повысить эффективность.
Рассмотрим целочисленный массив и его префиксные суммы:
Префиксная сумма до любого индекса j даёт ответ на sum_range(0, j). Например, сумма диапазона [0, 3] равна prefix_sum[3].
Следовательно, при i == 0:
sum_range(0, j) = prefix_sum[j]
Для диапазона [2, 4] сумма равна prefix_sum[4] - prefix_sum[1]:
Сумма диапазона [2, 4] = prefix_sum[4] - prefix_sum[1]: вычитаем сумму [0, 1] из суммы [0, 4].
Следовательно, при i > 0:
sum_range(i, j) = prefix_sum[j] - prefix_sum[i - 1]
Реализация
from typing import List
class SumBetweenRange:
def __init__(self, nums: List[int]):
self.prefix_sum = [nums[0]]
for i in range(1, len(nums)):
self.prefix_sum.append(self.prefix_sum[-1] + nums[i])
def sum_range(self, i: int, j: int) -> int:
if i == 0:
return self.prefix_sum[j]
return self.prefix_sum[j] - self.prefix_sum[i - 1]
Анализ сложности
Временная сложность: конструктор — O(n). sum_range — O(1).
Пространственная сложность: O(n) для массива prefix_sum.
Подмассивы с суммой K
Подмассивы с суммой K
Найдите количество подмассивов целочисленного массива, сумма которых равна k.
Пример:
Input: nums = [1, 2, -1, 1, 2], k = 3
Output: 3
Интуиция
Метод грубой силы перебирает все возможные подмассивы за O(n^3). Использование префиксных сумм позволяет сократить сложность.
Сумма подмассива [i,j] = prefix_sum[j] - prefix_sum[i-1] (при i>0), или просто prefix_sum[j] при i=0.
Объединим оба случая, добавив 0 в начало массива префиксных сумм — тогда prefix_sum[i-1]=0 при i=0 будет корректным.
from typing import List
def k_sum_subarrays(nums: List[int], k: int) -> int:
n = len(nums)
count = 0
prefix_sum = [0]
for i in range(0, n):
prefix_sum.append(prefix_sum[-1] + nums[i])
for j in range(1, n + 1):
for i in range(1, j + 1):
if prefix_sum[j] - prefix_sum[i - 1] == k:
count += 1
return count
Это решение O(n^2) можно оптимизировать с помощью хеш-таблицы. Для каждой текущей префиксной суммы (curr_prefix_sum) находим, сколько раз curr_prefix_sum - k встречалась ранее.
Для каждого элемента проверяем, есть ли curr_prefix_sum - k в хеш-таблице. Если да — добавляем её частоту к счётчику.
Стратегия: 1) Обновить curr_prefix_sum. 2) Если curr_prefix_sum-k есть в таблице — добавить частоту к count. 3) Добавить curr_prefix_sum в таблицу.
Реализация
from typing import List
def k_sum_subarrays_optimized(nums: List[int], k: int) -> int:
count = 0
prefix_sum_map = {0: 1}
curr_prefix_sum = 0
for num in nums:
curr_prefix_sum += num
if curr_prefix_sum - k in prefix_sum_map:
count += prefix_sum_map[curr_prefix_sum - k]
prefix_sum_map[curr_prefix_sum] = prefix_sum_map.get(curr_prefix_sum, 0) + 1
return count
Анализ сложности
Временная сложность: O(n) — один проход по nums.
Пространственная сложность: O(n) для хеш-таблицы.
Произведение массива без текущего элемента
Произведение массива без текущего элемента
Дан массив целых чисел. Верните массив res, где res[i] равно произведению всех элементов, кроме nums[i].
Пример:
Input: nums = [2, 3, 1, 4, 5]
Output: [60, 40, 120, 30, 24]
Пояснение: значение на индексе 0 — произведение всех чисел, кроме nums[0] (3*1*4*5 = 60).
Интуиция
Простое решение делит общее произведение на каждый элемент (total=120). Но что если деление запрещено?
Без деления результат для любого индекса = произведение всех чисел слева * произведение всех чисел справа.
Нам нужны два массива:
- left_products: left_products[i] — произведение всех значений левее i.
- right_products: right_products[i] — произведение всех значений правее i.
Префиксные произведения Префиксные произведения используют накопленное умножение (инициализируются значением 1 вместо 0).
- Вместо накопленного сложения используем накопленное умножение.
- Инициализируем массив префиксных произведений значением 1 вместо 0.
right_products строится аналогично справа налево. Когда оба массива готовы, res[i] = left_products[i] * right_products[i].
Оптимизация по памяти: заполним res левыми произведениями, затем умножим на текущее правое произведение на месте.
Реализация
from typing import List
def product_array_without_current_element(nums: List[int]) -> List[int]:
n = len(nums)
res = [1] * n
for i in range(1, n):
res[i] = res[i - 1] * nums[i - 1]
right_product = 1
for i in range(n - 1, -1, -1):
res[i] *= right_product
right_product *= nums[i]
return res
Анализ сложности
Временная сложность: O(n) — два прохода по nums.
Пространственная сложность: O(1). Массив res не учитывается в анализе пространственной сложности.
Деревья
Введение в деревья
Введение в деревья
Интуиция
Дерево — иерархическая структура данных, состоящая из узлов, где каждый узел связан с одним или несколькими дочерними узлами. Каждый узел дерева содержит хранимые данные (val) и ссылки на дочерние узлы. Наиболее распространённым типом является бинарное дерево, в котором каждый узел имеет не более двух дочерних: левый и правый.
Ниже приведена реализация класса TreeNode:
class TreeNode:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
Терминология:
- Родительский узел (Parent): узел, имеющий одного или нескольких дочерних.
- Дочерний узел (Child): узел, у которого есть родительский.
- Поддерево (Subtree): дерево, образованное узлом и его потомками.
- Путь (Path): непрерывная последовательность узлов, соединённых рёбрами.
- Глубина (Depth): количество рёбер от корня до данного узла.
Атрибуты дерева:
- Корень (Root): верхний узел дерева, единственный без родителя.
- Промежуточный узел: узел, имеющий родителя и хотя бы одного потомка.
- Лист (Leaf): узел без дочерних узлов.
- Ребро (Edge): связь между двумя узлами. Рёбра деревьев обычно направленные — от родителя к дочернему узлу.
- Высота (Height): длина наидлиннейшего пути от корня до листа.1
В дальнейшем рассмотрении основное внимание уделяется бинарным деревьям.
Обходы дерева
Поиск в глубину (DFS) DFS — метод обхода всех узлов дерева, при котором начинают с корня и продвигаются как можно глубже по ветке, прежде чем возвращаться для обхода других ветвей.
DFS обычно реализуется рекурсивно по структуре, похожей на код ниже:
def dfs(node: TreeNode):
if node is None:
return
process(node) # Process the current node.
dfs(node.left) # Traverse the left subtree.
dfs(node.right) # Traverse the right subtree.
Рекурсивная реализация выше следует порядку прямого обхода (preorder). Два других распространённых метода DFS — симметричный (inorder) и обратный (postorder). Различия между ними:
| Прямой обход (Preorder) | Симметричный обход (Inorder) | Обратный обход (Postorder) |
|---|---|---|
process(node) | dfs(node.left) | dfs(node.left) |
dfs(node.left) | process(node) | dfs(node.right) |
dfs(node.right) | dfs(node.right) | process(node) |
DFS имеет множество применений и является наиболее распространённым выбором для обхода дерева.
- Прямой обход — наиболее часто используемый тип DFS. Он применяется, когда нужно обработать корневой узел каждого поддерева до его потомков.
- Симметричный обход применяется, когда нужно обрабатывать узлы дерева слева направо.
- Обратный обход используется реже всего, но важен в случаях, когда поддеревья каждого узла должны быть обработаны до их корня.
Задачи этой главы подробно раскрывают варианты применения DFS, приводя практические примеры того, когда и как применять эти методы обхода.
Поиск в ширину (BFS) BFS обходит узлы дерева уровень за уровнем. Сначала обрабатываются узлы текущего уровня, затем переходят к следующему.
def bfs(root: TreeNode):
if root is None:
return
queue = deque([root])
while queue:
node = queue.popleft()
process(node) # Process the current node.
if node.left:
queue.append(node.left) # Add the left child to the queue.
if node.right:
queue.append(node.right) # Add the right child to the queue.
BFS широко применяется для поиска кратчайшего пути до заданного узла или для обработки дерева уровень за уровнем. Когда важно знать конкретный уровень каждого узла в процессе обхода, используют разновидность BFS — обход по уровням (level-order traversal), которая подробно рассматривается в этой главе.
Анализ сложности Ниже n обозначает количество узлов в дереве, h — высоту дерева.
Пример из реального мира
Файловые системы: во многих операционных системах файловая система организована в виде иерархической древовидной структуры. Корневой каталог — это корень дерева, а каждый файл или папка — узел. Папки могут содержать подпапки или файлы как дочерние узлы. Такая структура обеспечивает эффективную организацию, навигацию и поиск файлов. Просматривая папки на компьютере, вы фактически перемещаетесь по структуре дерева.
Обзор главы
Обратите внимание, что некоторые задачи, отнесённые к разделу DFS, также можно решить с помощью BFS или других алгоритмов обхода, и наоборот.
Примечания
- Некоторые источники могут определять высоту дерева иначе. В этой книге используется определение, делающее проектирование рекурсивных алгоритмов более интуитивным. Здесь высота дерева с единственным узлом равна 1. ↩
Инвертирование бинарного дерева
Инвертирование бинарного дерева
Инвертируйте бинарное дерево и верните его корень. При инвертировании дерево становится своим зеркальным отражением.
Пример:
Интуиция — рекурсивный подход
Инвертировать бинарное дерево — значит «отразить» его по вертикальной оси.
from ds import TreeNode
def invert_binary_tree_recursive(root: TreeNode) -> TreeNode:
if not root:
return None
root.left, root.right = root.right, root.left
invert_binary_tree_recursive(root.left)
invert_binary_tree_recursive(root.right)
return root
Реализация — итеративный подход
from ds import TreeNode
def invert_binary_tree_iterative(root: TreeNode) -> TreeNode:
if not root:
return None
stack = [root]
while stack:
node = stack.pop()
node.left, node.right = node.right, node.left
if node.left:
stack.append(node.left)
if node.right:
stack.append(node.right)
return root
Проверка сбалансированности бинарного дерева
Проверка сбалансированности бинарного дерева
Определите, является ли бинарное дерево сбалансированным по высоте, то есть у каждого узла разница высот левого и правого поддеревьев не превышает 1.
from ds import TreeNode
def balanced_binary_tree_validation(root: TreeNode) -> bool:
return get_height_imbalance(root) != -1
def get_height_imbalance(node: TreeNode) -> int:
if not node:
return 0
left_height = get_height_imbalance(node.left)
right_height = get_height_imbalance(node.right)
if left_height == -1 or right_height == -1:
return -1
if abs(left_height - right_height) > 1:
return -1
return 1 + max(left_height, right_height)
Крайние правые узлы бинарного дерева
Крайние правые узлы бинарного дерева
Верните массив со значениями крайних правых узлов на каждом уровне бинарного дерева.
from ds import TreeNode
def rightmost_nodes_of_a_binary_tree(root: TreeNode) -> List[int]:
if not root:
return []
res = []
queue = deque([root])
while queue:
level_size = len(queue)
for i in range(level_size):
node = queue.popleft()
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
if i == level_size - 1:
res.append(node.val)
return res
Наиболее широкий уровень бинарного дерева
Наиболее широкий уровень бинарного дерева
Верните ширину наиболее широкого уровня в бинарном дереве, где ширина уровня определяется как расстояние между крайним левым и крайним правым ненулевыми узлами.
from ds import TreeNode
def widest_binary_tree_level(root: TreeNode) -> int:
if not root:
return 0
max_width = 0
queue = deque([(root, 0)])
while queue:
level_size = len(queue)
leftmost_index = queue[0][1]
rightmost_index = leftmost_index
for _ in range(level_size):
node, i = queue.popleft()
if node.left:
queue.append((node.left, 2*i + 1))
if node.right:
queue.append((node.right, 2*i + 2))
rightmost_index = i
max_width = max(max_width, rightmost_index - leftmost_index + 1)
return max_width
Проверка корректности двоичного дерева поиска
Проверка корректности двоичного дерева поиска
Проверьте, является ли бинарное дерево корректным двоичным деревом поиска (BST).
from ds import TreeNode
def binary_search_tree_validation(root: TreeNode) -> bool:
return is_within_bounds(root, float('-inf'), float('inf'))
def is_within_bounds(node: TreeNode, lower_bound: int, upper_bound: int) -> bool:
if not node:
return True
if not lower_bound < node.val < upper_bound:
return False
if not is_within_bounds(node.left, lower_bound, node.val):
return False
return is_within_bounds(node.right, node.val, upper_bound)
Наименьший общий предок
Наименьший общий предок
Верните наименьшего общего предка (LCA) двух узлов p и q в бинарном дереве.
from ds import TreeNode
def lowest_common_ancestor(root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
dfs(root, p, q)
return lca
def dfs(node: TreeNode, p: TreeNode, q: TreeNode) -> bool:
global lca
if not node:
return False
node_is_p_or_q = node == p or node == q
left_contains_p_or_q = dfs(node.left, p, q)
right_contains_p_or_q = dfs(node.right, p, q)
if node_is_p_or_q + left_contains_p_or_q + right_contains_p_or_q == 2:
lca = node
return node_is_p_or_q or left_contains_p_or_q or right_contains_p_or_q
Построение бинарного дерева по прямому и симметричному обходам
Построение бинарного дерева по прямому и симметричному обходам
Постройте бинарное дерево, используя массивы значений, полученных при прямом и симметричном обходах дерева.
from ds import TreeNode
from typing import List
preorder_index = 0
inorder_indexes_map = {}
def build_binary_tree(preorder: List[int], inorder: List[int]) -> TreeNode:
global inorder_indexes_map
for i, val in enumerate(inorder):
inorder_indexes_map[val] = i
return build_subtree(0, len(inorder) - 1, preorder, inorder)
def build_subtree(left, right, preorder, inorder):
global preorder_index, inorder_indexes_map
if left > right:
return None
val = preorder[preorder_index]
inorder_index = inorder_indexes_map[val]
node = TreeNode(val)
preorder_index += 1
node.left = build_subtree(left, inorder_index - 1, preorder, inorder)
node.right = build_subtree(inorder_index + 1, right, preorder, inorder)
return node
Максимальная сумма пути в бинарном дереве
Максимальная сумма пути в бинарном дереве
Верните максимальную сумму непрерывного пути в бинарном дереве.
from ds import TreeNode
max_sum = float('-inf')
def max_path_sum(root: TreeNode) -> int:
global max_sum
max_path_sum_helper(root)
return max_sum
def max_path_sum_helper(node: TreeNode) -> int:
global max_sum
if not node:
return 0
left_sum = max(max_path_sum_helper(node.left), 0)
right_sum = max(max_path_sum_helper(node.right), 0)
max_sum = max(max_sum, node.val + left_sum + right_sum)
return node.val + max(left_sum, right_sum)
Симметрия бинарного дерева
Симметрия бинарного дерева
Определите, является ли бинарное дерево симметричным по вертикали.
from ds import TreeNode
def binary_tree_symmetry(root: TreeNode) -> bool:
if not root:
return True
return compare_trees(root.left, root.right)
def compare_trees(node1: TreeNode, node2: TreeNode) -> bool:
if not node1 and not node2:
return True
if not node1 or not node2:
return False
if node1.val != node2.val:
return False
if not compare_trees(node1.left, node2.right):
return False
return compare_trees(node1.right, node2.left)
Столбцы бинарного дерева
Столбцы бинарного дерева
Дан корень бинарного дерева. Верните список массивов, где каждый массив представляет вертикальный столбец дерева.
from ds import TreeNode
from typing import List
from collections import defaultdict, deque
def binary_tree_columns(root: TreeNode) -> List[List[int]]:
if not root:
return []
column_map = defaultdict(list)
leftmost_column = rightmost_column = 0
queue = deque([(root, 0)])
while queue:
node, column = queue.popleft()
if node:
column_map[column].append(node.val)
leftmost_column = min(leftmost_column, column)
rightmost_column = max(rightmost_column, column)
queue.append((node.left, column - 1))
queue.append((node.right, column + 1))
return [column_map[i] for i in range(leftmost_column, rightmost_column + 1)]
K-е наименьшее число в двоичном дереве поиска
K-е наименьшее число в двоичном дереве поиска
Дан корень двоичного дерева поиска (BST) и целое число k. Найдите k-е наименьшее значение узла.
def kth_smallest_number_in_BST_iterative(root: TreeNode, k: int) -> int:
stack = []
node = root
while stack or node:
while node:
stack.append(node)
node = node.left
node = stack.pop()
k -= 1
if k == 0:
return node.val
node = node.right
Сериализация и десериализация бинарного дерева
Сериализация и десериализация бинарного дерева
Напишите функцию для сериализации бинарного дерева в строку и другую функцию для десериализации строки обратно в исходную структуру дерева.
from ds import TreeNode
def serialize(root: TreeNode) -> str:
serialized_list = []
preorder_serialize(root, serialized_list)
return ','.join(serialized_list)
def preorder_serialize(node, serialized_list) -> None:
if node is None:
serialized_list.append('#')
return
serialized_list.append(str(node.val))
preorder_serialize(node.left, serialized_list)
preorder_serialize(node.right, serialized_list)
def deserialize(data: str) -> TreeNode:
node_values = iter(data.split(','))
return build_tree(node_values)
def build_tree(values):
val = next(values)
if val == '#':
return None
node = TreeNode(int(val))
node.left = build_tree(values)
node.right = build_tree(values)
return node
Префиксные деревья (Tries)
Введение в префиксные деревья
Введение в префиксные деревья
Интуиция
Префиксные деревья (Tries), также известные как деревья префиксов, — специализированные древовидные структуры данных, которые эффективно хранят строки, используя общие префиксы.
TrieNode Каждый узел TrieNode должен иметь два атрибута.
1. Потомки (Children): у каждого TrieNode есть структура данных для хранения ссылок на дочерние узлы. Обычно для этого используется хеш-таблица, где ключом является символ, а значением — соответствующий дочерний узел.
2. Индикатор конца слова: каждый TrieNode должен иметь атрибут, указывающий, является ли он концом слова.
- Булев атрибут (is_word) — подтверждает, является ли узел концом слова.
- Строковая переменная (word) — хранит само слово в узле.
Когда использовать Trie Основное применение trie — эффективный поиск по префиксу.
Пример из реального мира
Автодополнение: когда вы начинаете вводить слово, некоторые системы используют trie для быстрого предложения возможных вариантов завершения.
Обзор главы
Разработка Trie
Разработка Trie
Спроектируйте и реализуйте структуру данных Trie, поддерживающую следующие операции:
- insert(word: str) -> None: вставляет слово в trie.
- search(word: str) -> bool: возвращает true, если слово существует в trie, иначе false.
- has_prefix(prefix: str) -> bool: возвращает true, если trie содержит слово с заданным префиксом, иначе false.
Реализация
class TrieNode:
def __init__(self):
self.children = {}
self.is_word = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word: str) -> None:
node = self.root
for c in word:
if c not in node.children:
node.children[c] = TrieNode()
node = node.children[c]
node.is_word = True
def search(self, word: str) -> bool:
node = self.root
for c in word:
if c not in node.children:
return False
node = node.children[c]
return node.is_word
def has_prefix(self, prefix: str) -> bool:
node = self.root
for c in prefix:
if c not in node.children:
return False
node = node.children[c]
return True
Вставка и поиск слов с подстановочными символами
Вставка и поиск слов с подстановочными символами
Спроектируйте и реализуйте структуру данных, поддерживающую операции вставки и поиска с использованием подстановочных символов.
- insert(word: str) -> None: вставляет слово в структуру данных.
- search(word: str) -> bool: возвращает true, если слово существует в структуре, иначе false. Слово может содержать подстановочные символы ('.'), представляющие любую букву.
Реализация
class InsertAndSearchWordsWithWildcards:
def __init__(self):
self.root = TrieNode()
def insert(self, word: str) -> None:
node = self.root
for c in word:
if c not in node.children:
node.children[c] = TrieNode()
node = node.children[c]
node.is_word = True
def search(self, word: str) -> bool:
return self.search_helper(0, word, self.root)
def search_helper(self, word_index: int, word: str, node: TrieNode) -> bool:
for i in range(word_index, len(word)):
c = word[i]
if c == '.':
for child in node.children.values():
if self.search_helper(i + 1, word, child):
return True
return False
else:
if c not in node.children:
return False
node = node.children[c]
return node.is_word
Поиск всех слов на доске
Поиск всех слов на доске
Дана двумерная доска символов и массив слов. Найдите все слова из массива, которые можно образовать, прослеживая путь через соседние клетки доски.
Input: board = [['b', 'y', 's'], ['r', 't', 'e'], ['a', 'i', 'n']],
words = ['byte', 'bytes', 'rat', 'rain', 'trait', 'train']
Output: ['byte', 'bytes', 'rain', 'train']
Реализация
class TrieNode:
def __init__(self):
self.children = {}
self.word = None
from typing import List
def find_all_words_on_a_board(board: List[List[str]], words: List[str]) -> List[str]:
root = TrieNode()
for word in words:
node = root
for char in word:
if char not in node.children:
node.children[char] = TrieNode()
node = node.children[char]
node.word = word
res = []
for r in range(len(board)):
for c in range(len(board[0])):
if board[r][c] in root.children:
dfs(board, r, c, root.children[board[r][c]], res)
return res
def dfs(board, r, c, node, res):
if node.word:
res.append(node.word)
node.word = None
temp = board[r][c]
board[r][c] = '#'
directions = [(0,1),(0,-1),(1,0),(-1,0)]
for dr, dc in directions:
nr, nc = r + dr, c + dc
if 0 <= nr < len(board) and 0 <= nc < len(board[0]) and board[nr][nc] in node.children:
dfs(board, nr, nc, node.children[board[nr][nc]], res)
board[r][c] = temp
Графы
Введение в графы
Введение в графы
Интуиция
Граф — структура данных, состоящая из узлов (вершин), соединённых рёбрами. Графы используются для моделирования отношений, где рёбра определяют связи между объектами.
Ниже приведена реализация класса GraphNode:
class GraphNode:
def __init__(self, val):
self.val = val
self.neighbors = []
Терминология:
- Смежный узел/сосед: два узла являются смежными, если между ними есть ребро.
- Степень (Degree): количество рёбер, соединённых с узлом.
- Путь (Path): последовательность узлов, соединённых рёбрами.
Атрибуты:
- Ориентированный/неориентированный: в ориентированном графе рёбра имеют направление.
- Взвешенный/невзвешенный: во взвешенном графе рёбра имеют вес, например расстояние или стоимость.
- Циклический/ациклический: циклический граф содержит хотя бы один цикл — путь, начинающийся и заканчивающийся в одном узле.
Представления В некоторых задачах граф не задан напрямую. В таких случаях обычно необходимо создавать собственное представление. Два наиболее распространённых варианта — список смежности и матрица смежности.
В списке смежности соседи каждого узла хранятся в виде списка.
В матрице смежности граф представлен двумерной матрицей, где matrix[i][j] указывает на наличие ребра между узлами i и j.
Списки смежности — наиболее распространённый выбор. Они предпочтительны для разреженных графов и в случаях, когда нужно эффективно перебирать всех соседей узла.
Матрицы смежности предпочтительны для плотных графов и когда часто требуется проверить наличие конкретного ребра.
Обходы Основные методы обхода графа — DFS и BFS.
def dfs(node: GraphNode, visited: Set[GraphNode]):
visited.add(node)
process(node)
for neighbor in node.neighbors:
if neighbor not in visited:
dfs(neighbor, visited)
def bfs(node: GraphNode):
visited = set()
queue = deque([node])
while queue:
node = queue.popleft()
if node not in visited:
visited.add(node)
process(node)
for neighbor in node.neighbors:
queue.append(neighbor)
DFS и BFS имеют временную сложность O(n+e), где n — количество узлов, e — количество рёбер.
Пример из реального мира
Социальные сети: пользователи представлены узлами, а связи между ними — рёбрами.
Обзор главы
Глубокое копирование графа
Глубокое копирование графа
Дана ссылка на узел внутри неориентированного графа. Создайте глубокую копию (клон) графа. Скопированный граф должен быть полностью независим от оригинала.
Пример:
Ограничения:
- Значение каждого узла уникально.
- Каждый узел графа достижим из заданного узла.
Интуиция
Стратегия: обходим исходный граф и создаём глубокую копию в процессе обхода с помощью DFS.
dfs(node):
cloned_node = new GraphNode(node)
for neighbor in node.neighbors:
cloned_neighbor = dfs(neighbor)
cloned_node.neighbors.add(cloned_neighbor)
return cloned_node
Используем хеш-таблицу для предотвращения повторного клонирования уже скопированных узлов.
Реализация
from ds import GraphNode
def graph_deep_copy(node: GraphNode) -> GraphNode:
if not node:
return None
return dfs(node)
def dfs(node: GraphNode, clone_map = {}) -> GraphNode:
if node in clone_map:
return clone_map[node]
cloned_node = GraphNode(node.val)
clone_map[node] = cloned_node
for neighbor in node.neighbors:
cloned_neighbor = dfs(neighbor, clone_map)
cloned_node.neighbors.append(cloned_neighbor)
return cloned_node
Анализ сложности
Временная сложность: O(n+e). Пространственная сложность: O(n).
Подсчёт островов
Подсчёт островов
Дана бинарная матрица, где 1 — суша, 0 — вода. Верните количество островов.
Остров образуется соединёнными по 4 направлениям (вверх, вниз, влево, вправо) клетками суши.
Пример:
Output: 2
Интуиция
Перебираем матрицу, и когда обнаруживаем клетку суши, исследуем её остров с помощью DFS, помечая посещённые клетки как -1.
Реализация
from typing import List
def count_islands(matrix: List[List[int]]) -> int:
if not matrix:
return 0
count = 0
for r in range(len(matrix)):
for c in range(len(matrix[0])):
if matrix[r][c] == 1:
dfs(r, c, matrix)
count += 1
return count
def dfs(r: int, c: int, matrix: List[List[int]]) -> None:
matrix[r][c] = -1
dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)]
for d in dirs:
next_r, next_c = r + d[0], c + d[1]
if is_within_bounds(next_r, next_c, matrix) and matrix[next_r][next_c] == 1:
dfs(next_r, next_c, matrix)
Анализ сложности
Временная сложность: O(m*n). Пространственная сложность: O(m*n).
Заражение матрицы
Заражение матрицы
Дана матрица, где каждая клетка имеет значение 0 (пустая), 1 (незаражённая) или 2 (заражённая). Каждую секунду каждая заражённая клетка заражает своих незаражённых соседей по 4 направлениям. Определите, через сколько секунд все незаражённые клетки станут заражёнными. Если это невозможно — верните -1.
Пример:
Input: matrix = [[1, 1, 1, 0], [0, 0, 2, 1], [0, 1, 1, 0]]
Output: 3
Интуиция
Используем многоисточниковый BFS (обход по уровням). Добавляем все изначально заражённые клетки в очередь. Каждый обработанный уровень = 1 секунда. Считаем незаражённые клетки и уменьшаем счётчик по мере заражения; возвращаем -1, если остались незаражённые.
Реализация
from typing import List
from collections import deque
def matrix_infection(matrix: List[List[int]]) -> int:
dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)]
queue = deque()
ones = seconds = 0
for r in range(len(matrix)):
for c in range(len(matrix[0])):
if matrix[r][c] == 1:
ones += 1
elif matrix[r][c] == 2:
queue.append((r, c))
while queue and ones > 0:
seconds += 1
for _ in range(len(queue)):
r, c = queue.popleft()
for d in dirs:
next_r, next_c = r + d[0], c + d[1]
if (0 <= next_r < len(matrix) and 0 <= next_c < len(matrix[0])
and matrix[next_r][next_c] == 1):
matrix[next_r][next_c] = 2
ones -= 1
queue.append((next_r, next_c))
return -1 if ones > 0 else seconds
Анализ сложности
Временная сложность: O(m*n). Пространственная сложность: O(m*n).
Проверка двудольности графа
Проверка двудольности графа
Дан неориентированный граф. Определите, является ли он двудольным. Граф двудольный, если его узлы можно раскрасить в два цвета так, чтобы никакие два смежных узла не имели одинакового цвета.
Пример:
Input: graph = [[1, 4], [0, 2], [1], [4], [0, 3]]
Output: True
Интуиция
Используем раскраску графа с помощью DFS: окрашиваем смежные узлы поочерёдно в цвета 1 и -1. Если два смежных узла имеют одинаковый цвет — граф не двудольный. Обрабатываем несвязные компоненты, запуская DFS из каждого нераскрашенного узла.
Реализация
def bipartite_graph_validation(graph: List[List[int]]) -> bool:
colors = [0] * len(graph)
for i in range(len(graph)):
if colors[i] == 0 and not dfs(i, 1, graph, colors):
return False
return True
def dfs(node: int, color: int, graph: List[List[int]], colors: List[int]) -> bool:
colors[node] = color
for neighbor in graph[node]:
if colors[neighbor] == color:
return False
if colors[neighbor] == 0 and not dfs(neighbor, -color, graph, colors):
return False
return True
Анализ сложности
Временная сложность: O(n+e). Пространственная сложность: O(n).
Наидлиннейший возрастающий путь
Наидлиннейший возрастающий путь
Найдите длину наидлиннейшего строго возрастающего пути в матрице положительных целых чисел. Путь — последовательность клеток, в которой каждая следующая клетка соседствует с предыдущей по 4 направлениям.
Пример:
Output: 5
Интуиция
Моделируем как ОАГ (направленный ациклический граф). DFS из каждой клетки для нахождения наидлиннейшего пути. Используем мемоизацию для избежания повторных вызовов DFS.
Реализация
from typing import List
def longest_increasing_path(matrix: List[List[int]]) -> int:
if not matrix:
return 0
res = 0
m, n = len(matrix), len(matrix[0])
memo = [[0] * n for _ in range(m)]
for r in range(m):
for c in range(n):
res = max(res, dfs(r, c, matrix, memo))
return res
def dfs(r: int, c: int, matrix: List[List[int]], memo: List[List[int]]):
if memo[r][c] != 0:
return memo[r][c]
max_path = 1
dirs = [(-1, 0), (1, 0), (0, -1), (0, 1)]
for d in dirs:
next_r, next_c = r + d[0], c + d[1]
if (0 <= next_r < len(matrix) and 0 <= next_c < len(matrix[0])
and matrix[next_r][next_c] > matrix[r][c]):
max_path = max(max_path, 1 + dfs(next_r, next_c, matrix, memo))
memo[r][c] = max_path
return max_path
Анализ сложности
Временная сложность: O(m*n). Пространственная сложность: O(m*n).
Кратчайшая последовательность преобразований
Кратчайшая последовательность преобразований
Даны два слова — start и end — и словарь. Верните длину кратчайшей последовательности преобразований из start в end, где каждое слово отличается от предыдущего ровно на одну букву. Если такой последовательности не существует, верните 0.
Пример:
Input: start = 'red', end = 'hit',
dictionary = ['red', 'bed', 'hat', 'rod', 'rad', 'rat', 'hit', 'bad', 'bat']
Output: 5
Интуиция
Моделируем как граф: слова — узлы, рёбра существуют между словами, отличающимися на одну букву. Используем BFS от start для нахождения кратчайшего пути до end. Соседей генерируем, перебирая все замены символов алфавита.
Реализация
def shortest_transformation_sequence(start: str, end: str, dictionary: List[str]) -> int:
dictionary_set = set(dictionary)
if start not in dictionary_set or end not in dictionary_set:
return 0
if start == end:
return 1
lower_case_alphabet = 'abcdefghijklmnopqrstuvwxyz'
queue = deque([start])
visited = set([start])
dist = 0
while queue:
for _ in range(len(queue)):
curr_word = queue.popleft()
if curr_word == end:
return dist + 1
for i in range(len(curr_word)):
for c in lower_case_alphabet:
next_word = curr_word[:i] + c + curr_word[i+1:]
if next_word in dictionary_set and next_word not in visited:
visited.add(next_word)
queue.append(next_word)
dist += 1
return 0
Анализ сложности
Временная сложность: O(n*L^2). Пространственная сложность: O(n*L).
Объединение сообществ
Объединение сообществ
Есть n человек с номерами от 0 до n-1, каждый изначально в отдельном сообществе. Когда два человека соединяются, их сообщества объединяются. Реализуйте connect(x, y) и get_community_size(x).
Интуиция
Используем структуру данных Union-Find (Система непересекающихся множеств). Операция union объединяет два сообщества. Операция find возвращает представителя сообщества. Оптимизируем объединением по размеру и сжатием пути.
Реализация
class UnionFind:
def __init__(self, size: int):
self.parent = [i for i in range(size)]
self.size = [1] * size
def union(self, x: int, y: int) -> None:
rep_x, rep_y = self.find(x), self.find(y)
if rep_x != rep_y:
if self.size[rep_x] > self.size[rep_y]:
self.parent[rep_y] = rep_x
self.size[rep_x] += self.size[rep_y]
else:
self.parent[rep_x] = rep_y
self.size[rep_y] += self.size[rep_x]
def find(self, x: int) -> int:
if x == self.parent[x]:
return x
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def get_size(self, x: int) -> int:
return self.size[self.find(x)]
class MergingCommunities:
def __init__(self, n: int):
self.uf = UnionFind(n)
def connect(self, x: int, y: int) -> None:
self.uf.union(x, y)
def get_community_size(self, x: int) -> int:
return self.uf.get_size(x)
Анализ сложности
connect и get_community_size: амортизированное O(1). Конструктор: O(n). Пространственная сложность: O(n).
Пререквизиты
Пререквизиты
Даны n курсов с метками от 0 до n-1 и пары пререквизитов. Определите, возможно ли записаться на все курсы.
Пример:
Input: n = 3, prerequisites = [[0, 1], [1, 2], [2, 1]]
Output: False
Интуиция
Используем алгоритм Кана (топологическая сортировка). Наличие цикла означает невозможность записи. Сначала обрабатываем узлы с нулевой входящей степенью; если обработаны все n узлов — запись возможна.
Реализация
from typing import List
from collections import defaultdict, deque
def prerequisites(n: int, prerequisites: List[List[int]]) -> bool:
graph = defaultdict(list)
in_degrees = [0] * n
for prerequisite, course in prerequisites:
graph[prerequisite].append(course)
in_degrees[course] += 1
queue = deque()
for i in range(n):
if in_degrees[i] == 0:
queue.append(i)
enrolled_courses = 0
while queue:
node = queue.popleft()
enrolled_courses += 1
for neighbor in graph[node]:
in_degrees[neighbor] -= 1
if in_degrees[neighbor] == 0:
queue.append(neighbor)
return enrolled_courses == n
Анализ сложности
Временная сложность: O(n+e). Пространственная сложность: O(n+e).
Кратчайший путь
Кратчайший путь
Даны n узлов, рёбра с неотрицательными весами и начальный узел. Верните массив длин кратчайших путей от start до каждого узла. Недостижимые узлы получают значение -1.
Пример:
Input: n = 6, edges = [[0,1,5],[0,2,3],[1,2,1],[1,3,4],[2,3,4],[2,4,5]], start = 0
Output: [0, 4, 3, 7, 8, -1]
Интуиция
Используем алгоритм Дейкстры (жадный алгоритм). Всегда обрабатываем непосещённый узел с наименьшим известным расстоянием с помощью минимальной кучи. Работает для неотрицательных весов.
Реализация
from typing import List
from collections import defaultdict
import heapq
def shortest_path(n: int, edges: List[int], start: int) -> List[int]:
graph = defaultdict(list)
distances = [float('inf')] * n
distances[start] = 0
for u, v, w in edges:
graph[u].append((v, w))
graph[v].append((u, w))
min_heap = [(0, start)]
while min_heap:
curr_dist, curr_node = heapq.heappop(min_heap)
if curr_dist > distances[curr_node]:
continue
for neighbor, weight in graph[curr_node]:
neighbor_dist = curr_dist + weight
if neighbor_dist < distances[neighbor]:
distances[neighbor] = neighbor_dist
heapq.heappush(min_heap, (neighbor_dist, neighbor))
return [-1 if d == float('inf') else d for d in distances]
Анализ сложности
Временная сложность: O((n+e)log(n)). Пространственная сложность: O(n+e).
Соединить точки
Соединить точки
Дано множество точек на плоскости. Определите минимальную стоимость соединения всех точек. Стоимость = манхэттенское расстояние между двумя точками.
Пример:
Input: points = [[1, 1], [2, 6], [3, 2], [4, 3], [7, 1]]
Output: 15
Интуиция
Задача о минимальном остовном дереве (MST). Используем алгоритм Крускала: сортируем все рёбра по стоимости, применяем Union-Find для добавления рёбер с наименьшей стоимостью, не создающих циклов. Останавливаемся после добавления n-1 рёбер.
Реализация
from typing import List
def connect_the_dots(points: List[List[int]]) -> int:
n = len(points)
edges = []
for i in range(n):
for j in range(i + 1, n):
cost = (abs(points[i][0] - points[j][0]) + abs(points[i][1] - points[j][1]))
edges.append((cost, i, j))
edges.sort()
uf = UnionFind(n)
total_cost = edges_added = 0
for cost, p1, p2 in edges:
if uf.union(p1, p2):
total_cost += cost
edges_added += 1
if edges_added == n - 1:
break
return total_cost
class UnionFind:
def __init__(self, size):
self.parent = [i for i in range(size)]
self.size = [1] * size
def union(self, x, y) -> bool:
rep_x, rep_y = self.find(x), self.find(y)
if rep_x != rep_y:
if self.size[rep_x] > self.size[rep_y]:
self.parent[rep_y] = rep_x
self.size[rep_x] += self.size[rep_y]
else:
self.parent[rep_x] = rep_y
self.size[rep_y] += self.size[rep_x]
return True
return False
def find(self, x) -> int:
if x == self.parent[x]:
return x
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
Анализ сложности
Временная сложность: O(n^2 log n). Пространственная сложность: O(n^2).
Метод с возвратом (Backtracking)
Введение в метод с возвратом
Введение в метод с возвратом
Интуиция
Представьте, что вы стоите на развилке в лабиринте и знаете, что один из трёх путей впереди ведёт к выходу:
Однако вы не знаете, какой путь выбрать. Чтобы найти выход, вы решаете пробовать каждый вариант по очереди, начиная с пути A. Пройдя по коридору A, вы обнаруживаете новую развилку с двумя путями: D и E:
Вы пробуете путь D, но он ведёт в тупик. Возвращаетесь на вторую развилку:
Затем пробуете путь E, но он тоже ведёт в тупик — возвращаетесь на вторую развилку. Убедившись, что ни D, ни E не работают, возвращаетесь на первую развилку:
Убедившись, что путь A не ведёт к выходу, вы переходите к пути B и обнаруживаете, что он ведёт к выходу:
Этот метод перебора всех возможных путей с возвратом при неудаче называется «методом с возвратом» (backtracking).
Дерево состояний В методе с возвратом дерево состояний, также известное как дерево решений, — концептуальное дерево, построенное с учётом всех возможных решений на каждом шаге процесса.
Например, вот как выглядит дерево состояний для сценария с лабиринтом:
Краткое описание дерева состояний:
- Рёбра: каждое ребро представляет возможное решение, ход или действие.
- Корневой узел: представляет начальное состояние до принятия каких-либо решений.
- Промежуточные узлы: узлы, представляющие частично завершённые состояния.
- Листовые узлы: представляют полные или недействительные решения.
- Путь: путь от корня до любого листа представляет последовательность решений, ведущую к полному или недействительному решению.
Построение дерева состояний помогает визуализировать всё пространство решений и все возможные варианты. Кроме того, это отличный способ понять, как работает алгоритм. Разобравшись, как обходить это дерево, мы фактически создаём алгоритм с возвратом.
Алгоритм с возвратом Обход дерева состояний обычно выполняется с помощью рекурсивного DFS. Рассмотрим высокоуровневую реализацию.
Условие завершения: определите условие, указывающее, когда путь должен завершиться. Это условие определяет, когда найдено корректное и/или некорректное решение.
Перебор решений: перебираем все возможные решения в текущем узле с текущим состоянием задачи. Для каждого решения:
- Принимаем это решение и обновляем текущее состояние.
- Рекурсивно исследуем все пути, ветвящиеся из обновлённого состояния.
- Возврат: отменяем принятое решение и восстанавливаем состояние.
Ниже приведён общий шаблон для метода с возвратом:
def dfs(state):
# Termination condition.
if meets_termination_condition(state):
process_solution(state)
return
# Explore each possible decision that can be made at the current state.
for decision in possible_decisions(state):
make_decision(state, decision)
dfs(state)
undo_decision(state, decision) # Backtrack.
Анализ временной сложности
Анализ временной сложности алгоритмов с возвратом включает понимание коэффициента ветвления и глубины дерева состояний:
- Коэффициент ветвления: количество дочерних узлов у каждого узла. Обычно представляет максимальное количество решений для данного состояния.
- Глубина: длина наидлиннейшего пути в дереве состояний. Соответствует количеству решений или шагов для достижения полного решения.
Временная сложность часто оценивается как O(b^d), где b — коэффициент ветвления, d — глубина.
Когда применять метод с возвратом Метод с возвратом полезен, когда нужно найти все возможные решения задачи. Например, для нахождения всех перестановок, подмножеств или комбинаций элементов.
Пример из реального мира
ИИ-алгоритмы для игр: метод с возвратом используется в алгоритмах искусственного интеллекта для игр, таких как шахматы и го, для анализа возможных ходов и стратегий. Программы рассматривают каждый потенциальный ход, симулируют развитие партии и оценивают результат. Если ход ведёт к невыгодной позиции, программа возвращается к предыдущему ходу и пробует альтернативы, систематически исследуя дерево игры в поисках оптимальной стратегии.
Обзор главы
Найти все перестановки
Найти все перестановки
Верните все возможные перестановки заданного массива уникальных целых чисел. Порядок вывода произвольный.
Пример:
Input: nums = [4, 5, 6]
Output: [[4, 5, 6], [4, 6, 5], [5, 4, 6], [5, 6, 4], [6, 4, 5], [6, 5, 4]]
Интуиция
Задача проста: найти все перестановки заданного массива. Ключевое слово — «все». Для этого нужен алгоритм, который генерирует каждую возможную перестановку по одной. Для этого отлично подходит метод с возвратом. Как и в любом решении с возвратом, полезно сначала визуализировать дерево состояний.
Дерево состояний Разберёмся, как построить одну перестановку. Рассмотрим массив [4, 5, 6]. Начнём с выбора одного числа для первой позиции.
Найдя одну перестановку, выполним возврат для поиска других. Удаляем последнее добавленное число, 6, возвращаясь к [4, 5]:
Есть ли другие числа, которые можно добавить к [4, 5]? Только 6, которую мы уже рассмотрели. Делаем ещё один возврат, удаляя 5 и возвращаясь к [4]:
Есть ли числа кроме 5, которые можно добавить к [4]? Да, можно использовать 6, поэтому добавляем её:
Единственное доступное число — 5, добавляем её к [4, 6], получая ещё одну перестановку:
Продолжая этот процесс с возвратом до исследования всех ветвей, получаем все перестановки:
Каждый раз, когда строимая перестановка достигает размера n (длины входного массива), добавляем её в результат.
Обход дерева состояний Генерация всех перестановок достигается обходом дерева состояний.
Каждый узел дерева, кроме листовых, представляет кандидата перестановки — частично построенную перестановку. Корень — пустая перестановка, на каждом уровне к кандидату добавляется элемент. Листовые узлы — завершённые перестановки.
Начиная с корня, обходим дерево методом с возвратом:
- Выбираем неиспользованное число и добавляем его к текущему кандидату. Отмечаем это число как использованное, добавляя его в хеш-множество used.
- Делаем рекурсивный вызов с обновлённым кандидатом для исследования его ветвей.
- Возврат: удаляем последнее добавленное число из кандидата и из хеш-множества.
Когда кандидат достигает длины n — добавляем его в результат.
Реализация
from typing import List, Set
def find_all_permutations(nums: List[int]) -> List[List[int]]:
res = []
backtrack(nums, [], set(), res)
return res
def backtrack(nums: List[int], candidate: List[int], used: Set[int], res: List[List[int]]) -> None:
# If the current candidate is a complete permutation, add it to the result.
if len(candidate) == len(nums):
res.append(candidate[:])
return
for num in nums:
if num not in used:
# Add 'num' to the current permutation and mark it as used.
candidate.append(num)
used.add(num)
# Recursively explore all branches using the updated permutation
# candidate.
backtrack(nums, candidate, used, res)
# Backtrack by reversing the changes made.
candidate.pop()
used.remove(num)
Анализ сложности
Временная сложность: O(n * n!). Начиная с корня, рекурсивно исследуем n кандидатов. Для каждого из n кандидатов исследуем ещё n-1, затем n-2 и т.д. Для каждой из n! перестановок создаём копию и добавляем в результат — это занимает O(n).
Пространственная сложность: O(n), так как максимальная глубина дерева рекурсии равна n. Алгоритм также поддерживает структуры candidate и used, каждая из которых требует O(n) памяти.
Найти все подмножества
Найти все подмножества
Верните все возможные подмножества заданного множества уникальных целых чисел. Порядок элементов в подмножестве и порядок вывода произвольны.
Пример:
Input: nums = [4, 5, 6]
Output: [[], [4], [4, 5], [4, 5, 6], [4, 6], [5], [5, 6], [6]]
Интуиция
Ключевая идея: каждое подмножество формируется конкретным решением для каждого числа входного массива — включить или исключить его. Например, из массива [4, 5, 6] подмножество [4, 6] создаётся включением 4, исключением 5 и включением 6.
Дерево состояний Рассмотрим входной массив [4, 5, 6]. Начнём с корневого узла — пустого подмножества:
Рассматриваем решение о включении или исключении первого элемента 4:
Для каждого из этих подмножеств повторяем процесс для второго элемента:
Наконец, для третьего элемента продолжаем ветвление:
Используем индекс i, чтобы отслеживать, для какого элемента входного массива принимается решение:
Финальный уровень дерева (i == n) содержит все подмножества входного массива. Для их получения необходимо обойти дерево — для этого отлично подходит метод с возвратом.
Реализация
from typing import List
def find_all_subsets(nums: List[int]) -> List[List[int]]:
res = []
backtrack(0, [], nums, res)
return res
def backtrack(i: int, curr_subset: List[int], nums: List[int], res: List[List[int]]) -> None:
# Base case: if all elements have been considered, add the current subset to the
# output.
if i == len(nums):
res.append(curr_subset[:])
return
# Include the current element and recursively explore all paths that branch from
# this subset.
curr_subset.append(nums[i])
backtrack(i + 1, curr_subset, nums, res)
# Exclude the current element and recursively explore all paths that branch from
# this subset.
curr_subset.pop()
backtrack(i + 1, curr_subset, nums, res)
Анализ сложности
Временная сложность: O(2^n * n). Дерево состояний имеет глубину n и коэффициент ветвления 2. Для каждого из 2^n подмножеств создаём копию и добавляем в результат — это занимает O(n).
Пространственная сложность: O(n), так как максимальная глубина дерева рекурсии равна n.
Задача N ферзей
Задача N ферзей
Дана шахматная доска размером n x n. Расставьте n ферзей так, чтобы ни один из них не атаковал другого. Верните количество различных конфигураций, при которых это возможно.
Пример:
Input: n = 4
Output: 2
Интуиция
Ферзи ходят по вертикали, горизонтали и диагонали:
Таким образом, поставить ферзя на клетку можно только если:
- На той же строке нет другого ферзя.
- На том же столбце нет другого ферзя.
- На обеих диагоналях данной клетки нет другого ферзя.
Расстановка ферзей — метод с возвратом Простая стратегия: ставить по одному ферзю за раз, следя за тем, чтобы каждый новый ферзь располагался на безопасной клетке. Если безопасную клетку найти невозможно, значит, нужно переставить одного из ранее расставленных ферзей.
Для повышения эффективности размещаем каждого ферзя в новой строке. Тогда не нужно проверять конфликты по строкам — достаточно проверять столбец и диагонали.
Частичное дерево состояний для n = 4:
Обнаружение атак Для эффективной проверки атак используем хеш-множества. Для столбцов: при размещении ферзя на (r, c) добавляем c в множество столбцов. Для диагоналей: диагональ определяется по идентификатору r - c, антидиагональ — по r + c.
Ключевое наблюдение: для любой клетки (r, c) диагональ определяется как r - c, а антидиагональ — как r + c.
Размещение и удаление ферзя «Поставить» ферзя означает добавить его столбец, диагональ и антидиагональ в соответствующие хеш-множества. «Убрать» ферзя — удалить эти же идентификаторы из множеств.
Реализация
from typing import Set
res = 0
def n_queens(n: int) -> int:
dfs(0, set(), set(), set(), n)
return res
def dfs(r: int, diagonals_set: Set[int], anti_diagonals_set: Set[int], cols_set: Set[int], n: int) -> None:
global res
# Termination condition: If we have reached the end of the rows, we've placed all
# 'n' queens.
if r == n:
res += 1
return
for c in range(n):
curr_diagonal = r - c
curr_anti_diagonal = r + c
# If there are queens on the current column, diagonal or anti-diagonal, skip
# this square.
if (c in cols_set or curr_diagonal in diagonals_set or curr_anti_diagonal in anti_diagonals_set):
continue
# Place the queen by marking the current column, diagonal, and anti-diagonal
# as occupied.
cols_set.add(c)
diagonals_set.add(curr_diagonal)
anti_diagonals_set.add(curr_anti_diagonal)
# Recursively move to the next row to continue placing queens.
dfs(r + 1, diagonals_set, anti_diagonals_set, cols_set, n)
# Backtrack by removing the queen's marks.
cols_set.remove(c)
diagonals_set.remove(curr_diagonal)
anti_diagonals_set.remove(curr_anti_diagonal)
Анализ сложности
Временная сложность: O(n!). Для первого ферзя n вариантов. Для второго — n-a, где a — количество клеток второй строки, атакуемых первым ферзём. Это приближённо даёт факториальный рост пространства поиска.
Пространственная сложность: O(n), так как максимальная глубина дерева рекурсии равна n. Хеш-множества также вносят вклад — каждое хранит до n значений.
Комбинации с заданной суммой
Комбинации с заданной суммой
Дан целочисленный массив и целевое значение. Найдите все уникальные комбинации из элементов массива, сумма которых равна целевому значению. Каждое число может использоваться неограниченное количество раз.
Пример:
Input: nums = [1, 2, 3], target = 4
Output: [[1, 1, 1, 1], [1, 1, 2], [1, 3], [2, 2]]
Ограничения:
- Все целые числа в nums положительны и уникальны.
- Целевое значение положительно.
- Результат не должен содержать дублирующихся комбинаций. Например, [1, 1, 2] и [1, 2, 1] считаются одной комбинацией.
Интуиция
Так как каждое целое число можно использовать любое количество раз, потенциальных комбинаций бесконечно много. Нам нужно ограничить поиск.
Важное наблюдение: все значения в массиве положительные, поэтому по мере добавления элементов сумма растёт. Следовательно, нужно прекращать построение комбинации, как только её сумма достигает или превышает целевое значение.
Для предотвращения дубликатов используем start_index, который обеспечивает единообразное представление комбинаций в порядке элементов исходного массива.
Дерево состояний Рассмотрим массив [1, 2, 3] и target = 4.
Проблема: подход привёл к дублирующимся комбинациям. Для их устранения используем start_index:
Изначально для корневой комбинации start_index = 0. По мере рекурсивного построения start_index обновляется до индекса текущего добавляемого элемента.
Реализация
Перерабатываем целевое значение: при добавлении числа уменьшаем target. При target = 0 нашли корректную комбинацию. Если target < 0 — прерываем текущую ветвь.
from typing import List
def combinations_of_sum_k(nums: List[int], target: int) -> List[List[int]]:
res = []
dfs([], 0, nums, target, res)
return res
def dfs(combination: List[int], start_index: int, nums: List[int], target: int,
res: List[List[int]]) -> None:
# Termination condition: If the target is equal to 0, we found a combination
# that sums to 'k'.
if target == 0:
res.append(combination[:])
return
# Termination condition: If the target is less than 0, no more valid
# combinations can be created.
if target < 0:
return
# Starting from start_index, explore all combinations after adding nums[i].
for i in range(start_index, len(nums)):
# Add the current number to create a new combination.
combination.append(nums[i])
# Recursively explore all paths that branch from this new combination.
dfs(combination, i, nums, target - nums[i], res)
# Backtrack.
combination.pop()
Анализ сложности
Временная сложность: O(n^(target/m)), где n — длина массива, m — наименьший кандидат. Дерево рекурсии ветвится до тех пор, пока сумма наименьших кандидатов не достигнет или не превысит target, что даёт глубину target/m.
Пространственная сложность: O(target/m) с учётом стека рекурсии и списка комбинации.
Комбинации клавиш телефона
Комбинации клавиш телефона
Дана строка с цифрами от 2 до 9 включительно. Каждой цифре соответствует набор букв, как на традиционной телефонной клавиатуре:
| 1 | 2 abc | 3 def |
| 4 ghi | 5 jkl | 6 mno |
| 7 pqrs | 8 tuv | 9 wxyz |
Верните все возможные буквенные комбинации, которые могут представлять введённые цифры.
Пример:
Input: digits = '69'
Output: ['mw', 'mx', 'my', 'mz', 'nw', 'nx', 'ny', 'nz', 'ow', 'ox', 'oy', 'oz']
Интуиция
На каждой цифре строки нужно принять решение: какую букву будет представлять эта цифра?
Дерево состояний Рассмотрим строку "69". Начнём с корневого узла — пустой строки:
На первой цифре 6 мы выбираем начало: 'm', 'n' или 'o':
Для каждой из созданных комбинаций принимаем новое решение: какую букву из цифры 9 ('w', 'x', 'y', 'z') выбрать?
Используем индекс i для отслеживания, для какой цифры принимается решение в каждом узле:
Сопоставление цифр с буквами Для определения соответствия цифр буквам используем хеш-таблицу.
Реализация
def phone_keypad_combinations(digits: str) -> List[str]:
keypad_map = {
'2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
'6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
}
res = []
backtrack(0, [], digits, keypad_map, res)
return res
def backtrack(i: int, curr_combination: List[str], digits: str,
keypad_map: Dict[str, str], res: List[str]) -> None:
# Termination condition: if all digits have been considered, add the
# current combination to the output list.
if len(curr_combination) == len(digits):
res.append("".join(curr_combination))
return
for letter in keypad_map[digits[i]]:
# Add the current letter.
curr_combination.append(letter)
# Recursively explore all paths that branch from this combination.
backtrack(i + 1, curr_combination, digits, keypad_map, res)
# Backtrack by removing the letter we just added.
curr_combination.pop()
Анализ сложности
Временная сложность: O(n * 4^n). Дерево состояний ветвится до тех пор, пока не будет принято решение для всех n элементов, с коэффициентом ветвления до 4. Для каждой из 4^n комбинаций преобразуем её в строку — O(n).
Пространственная сложность: O(n) за счёт стека рекурсии глубиной до n.
Совет для интервью
Совет: проверьте, можно ли пропустить тривиальные реализации.
На собеседовании важно эффективно распоряжаться временем. Если вы столкнулись с тривиальной и трудоёмкой задачей — например, созданием keypad_map в этой задаче — интервьюер может разрешить пропустить её или реализовать позже, если останется время.
Dynamic Programming
Введение в Dynamic Programming
Введение в Dynamic Programming
Dynamic programming (DP) поначалу может показаться сложным, но мы разберём её на понятные концепции и приёмы. Для начала разберёмся, что именно решает DP, взглянув на задачу в целом.
Некоторые задачи можно разбить на subproblem'ы, где каждый subproblem — это уменьшенная версия основной задачи. Эти subproblem'ы, в свою очередь, тоже могут дробиться на ещё более мелкие subproblem'ы. Для решения подобных задач часто применяется рекурсия, при которой мы делаем рекурсивные вызовы для каждого subproblem'а.
Однако в процессе рекурсии один и тот же subproblem может генерироваться и решаться многократно, что приводит к излишним вычислительным затратам.
Именно здесь на помощь приходит DP. Это техника, которая сохраняет решения каждого subproblem'а, чтобы использовать их повторно при необходимости. Иными словами, это эффективный инструмент, гарантирующий, что каждый subproblem решается не более одного раза.
- Оптимальная подструктура (optimal substructure): оптимальное решение задачи строится из оптимальных решений её subproblem'ов.
- Перекрывающиеся subproblem'ы (overlapping subproblems): одни и те же subproblem'ы встречаются многократно в ходе решения задачи.
- Рекуррентное соотношение (recurrence relation): формула, выражающая решение задачи через решения её subproblem'ов.
- Базовые случаи (base cases): простейшие экземпляры задачи, решение которых уже известно и не требует дальнейшего разбиения на subproblem'ы.
Первые два пункта — обязательные свойства задачи, которые необходимы для применения DP. Последние два пункта — обязательные компоненты любого DP-решения.
Пример из реального мира
Сегментация слов: поисковые системы применяют DP в процессе «сегментации слов». Когда пользователь вводит поисковый запрос без пробелов, DP используется для определения того, можно ли расставить пробелы так, чтобы получились корректные слова.
Структура главы
Каждая DP-задача в этой главе по-своему уникальна, но для удобства мы разбили их на две категории: одномерный DP (1D-DP) и двумерный DP (2D-DP).
Подъём по лестнице
Подъём по лестнице
Определите количество различных способов подняться на лестницу из n ступенек, делая за один раз 1 или 2 ступеньки.
Пример:
Input: n = 4
Output: 5
Интуиция — подход сверху вниз (Top-Down)
Метод грубой силы перебирает все возможные комбинации шагов по 1 или 2 ступеньки. Чтобы достичь ступеньки i, нужно прийти либо со ступеньки i-1, либо со ступеньки i-2.
climbing_stairs(n) = climbing_stairs(n - 1) + climbing_stairs(n - 2)
Base cases: если n равно 1, вернуть 1. Если n равно 2, вернуть 2.
Данное решение является top-down решением: оно начинается с основной задачи и рекурсивно разбивает её на всё меньшие subproblem'ы.
Обратите внимание, что в дереве рекурсии некоторые subproblem'ы вычисляются повторно (например, climbing_stairs(4) вызывается дважды). Это наглядно демонстрирует наличие overlapping subproblems. Именно здесь применяется memoization.
Для memoization используется хэш-таблица, в которой хранятся результаты subproblem'ов — это обеспечивает доступ к ним за константное время.
Реализация — Top-Down
memo = {}
def climbing_stairs_top_down(n: int) -> int:
if n <= 2:
return n
if n in memo:
return memo[n]
memo[n] = climbing_stairs_top_down(n - 1) + climbing_stairs_top_down(n - 2)
return memo[n]
Анализ сложности
Временная сложность: без memoization O(2^n), с memoization O(n). Пространственная сложность: O(n) — за счёт стека рекурсивных вызовов и массива memoization.
Интуиция — подход снизу вверх (Bottom-Up)
Как правило, любую задачу, решаемую с помощью top-down memoization, можно также решить с помощью bottom-up DP, преобразовав массив memoization в массив DP.
dp[n] = dp[n - 1] + dp[n - 2]
Base cases: dp[1] = 1, dp[2] = 2. Вернуть dp[n].
Реализация — Bottom-Up
def climbing_stairs_bottom_up(n: int) -> int:
if n <= 2:
return n
dp = [0] * (n + 1)
dp[1], dp[2] = 1, 2
for i in range(3, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
Оптимизация — Bottom-Up
Нам требуются только два предыдущих значения DP-массива. Используем две переменные: one_step_before и two_steps_before.
def climbing_stairs_bottom_up_optimized(n: int) -> int:
if n <= 2:
return n
one_step_before, two_steps_before = 2, 1
for i in range(3, n + 1):
current = one_step_before + two_steps_before
two_steps_before = one_step_before
one_step_before = current
return one_step_before
Совет для собеседования
Если вам сложно придумать bottom-up решение, начните с top-down. Разработать top-down решение зачастую проще: сначала определяем recurrence relation, а затем применяем memoization для оптимизации.
Минимальная комбинация монет
Минимальная комбинация монет
Дан массив номиналов монет и целевая сумма. Верните минимальное количество монет, необходимых для получения целевой суммы. Если это невозможно, верните -1. Можно считать, что каждой монеты неограниченное количество.
Пример 1:
Input: coins = [1, 2, 3], target = 5
Output: 2
Пояснение: используем одну монету номиналом 2 и одну монету номиналом 3, чтобы получить 5.
Пример 2:
Input: coins = [2, 4], target = 5
Output: -1
Интуиция — Top-Down
В этой задаче нет ограничения на количество используемых монет, что делает перебор перебором. Использование монеты создаёт новый subproblem с уменьшенной целевой суммой.
min_coin_combination(target) = 1 + min(min_coin_combination(target - coin_i) | coin_i in coins)
Base case: когда target равен 0, вернуть 0. Memoization сохраняет решения subproblem'ов, чтобы избежать повторных вычислений.
Реализация — Top-Down
Анализ сложности
Временная сложность: без memoization O(n^(target/m)), с memoization O(target * n). Пространственная сложность: O(target).
Интуиция — Bottom-Up
Используя тот же приём, что и в задаче «Подъём по лестнице», преобразуем top-down решение в bottom-up.
for t in range(1, target + 1):
for coin in coins:
if coin <= t:
dp[t] = min(dp[t], 1 + dp[t - coin])
Реализация — Bottom-Up
def min_coin_combination_bottom_up(coins, target):
dp = [float('inf')] * (target + 1)
dp[0] = 0
for t in range(1, target + 1):
for coin in coins:
if coin <= t:
dp[t] = min(dp[t], 1 + dp[t - coin])
return dp[target] if dp[target] != float('inf') else -1
Анализ сложности
Временная сложность: O(target * n). Пространственная сложность: O(target).
Совет для собеседования
Если задача просит найти минимум или максимум чего-либо, вероятно, это DP-задача. Ключевые слова «минимальный», «максимальный», «наидлиннейший» или «наикратчайший» указывают на применимость DP.
Пути в матрице
Пути в матрице
Вы находитесь в левом верхнем углу матрицы m x n и можете двигаться только вниз или вправо. Определите количество уникальных маршрутов, ведущих в правый нижний угол.
Пример:
Input: m = 3, n = 3
Output: 6
Интуиция
Количество путей до любой клетки равно сумме путей из клетки выше и клетки слева.
dp[r][c] = dp[r - 1][c] + dp[r][c - 1]
Base cases: все клетки в строке 0 и столбце 0 имеют ровно один путь (устанавливаются в 1).
Реализация
def matrix_pathways(m, n):
dp = [[1] * n for _ in range(m)]
for r in range(1, m):
for c in range(1, n):
dp[r][c] = dp[r - 1][c] + dp[r][c - 1]
return dp[m - 1][n - 1]
Анализ сложности
Временная сложность: O(m*n). Пространственная сложность: O(m*n).
Оптимизация
Достаточно хранить только две строки: prev_row и curr_row. Пространственная сложность снижается до O(n).
def matrix_pathways_optimized(m, n):
prev_row = [1] * n
for r in range(1, m):
curr_row = [1] * n
for c in range(1, n):
curr_row[c] = prev_row[c] + curr_row[c - 1]
prev_row = curr_row
return prev_row[n - 1]
Ограбление по соседству
Ограбление по соседству
Вы планируете обворовывать дома на улице, в каждом из которых хранится определённая сумма денег. В районе установлена система безопасности, которая срабатывает, если ограблены два соседних дома. Верните максимальную сумму, которую можно похитить без срабатывания сигнализации.
Пример:
Input: houses = [200, 300, 200, 50]
Output: 400
Пояснение: ограбление домов с индексами 0 и 2 даёт 200 + 200 = 400.
Интуиция
Жадный алгоритм здесь не подходит, так как не учитывает долгосрочные последствия. Для дома i есть два варианта: пропустить его (прибыль = dp[i-1]) или ограбить (прибыль = houses[i] + dp[i-2]).
dp[i] = max(dp[i - 1], houses[i] + dp[i - 2])
Base cases: dp[0] = houses[0], dp[1] = max(houses[0], houses[1]).
Реализация
def neighborhood_burglary(houses):
if not houses: return 0
if len(houses) == 1: return houses[0]
dp = [0] * len(houses)
dp[0] = houses[0]
dp[1] = max(houses[0], houses[1])
for i in range(2, len(houses)):
dp[i] = max(dp[i - 1], houses[i] + dp[i - 2])
return dp[len(houses) - 1]
Анализ сложности
Временная сложность: O(n). Пространственная сложность: O(n).
Оптимизация
Нужны только два предыдущих значения. Используем переменные prev_max_profit и prev_prev_max_profit. Пространственная сложность снижается до O(1).
def neighborhood_burglary_optimized(houses):
if not houses: return 0
if len(houses) == 1: return houses[0]
prev_max_profit = max(houses[0], houses[1])
prev_prev_max_profit = houses[0]
for i in range(2, len(houses)):
curr_max_profit = max(prev_max_profit, houses[i] + prev_prev_max_profit)
prev_prev_max_profit = prev_max_profit
prev_max_profit = curr_max_profit
return prev_max_profit
Наибольшая общая подпоследовательность
Наибольшая общая подпоследовательность
Даны две строки. Найдите длину их наибольшей общей подпоследовательности (LCS). Подпоследовательность — это последовательность символов, полученная из строки удалением нуля или более элементов без изменения порядка оставшихся.
Пример:
Input: s1 = 'acabac', s2 = 'aebab'
Output: 3
Интуиция
Два случая: (1) символы совпадают: включаем их, LCS(i,j) = 1 + LCS(i+1, j+1). (2) символы различаются: берём max(LCS без s1[i], LCS без s2[j]).
if s1[i] == s2[j]: LCS(i, j) = 1 + LCS(i + 1, j + 1)
if s1[i] != s2[j]: LCS(i, j) = max(LCS(i + 1, j), LCS(i, j + 1))
DP-формула: dp[i][j]. Base cases: dp[len(s1)][j] = 0 и dp[i][len(s2)] = 0. Вернуть dp[0][0].
Реализация
def longest_common_subsequence(s1, s2):
dp = [[0] * (len(s2) + 1) for _ in range(len(s1) + 1)]
for i in range(len(s1) - 1, -1, -1):
for j in range(len(s2) - 1, -1, -1):
if s1[i] == s2[j]:
dp[i][j] = 1 + dp[i + 1][j + 1]
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j + 1])
return dp[0][0]
Анализ сложности
Временная сложность: O(m*n). Пространственная сложность: O(m*n).
Оптимизация
Достаточно двух строк: curr_row и prev_row. Пространственная сложность снижается до O(n).
def longest_common_subsequence_optimized(s1, s2):
prev_row = [0] * (len(s2) + 1)
for i in range(len(s1) - 1, -1, -1):
curr_row = [0] * (len(s2) + 1)
for j in range(len(s2) - 1, -1, -1):
if s1[i] == s2[j]:
curr_row[j] = 1 + prev_row[j + 1]
else:
curr_row[j] = max(prev_row[j], curr_row[j + 1])
prev_row = curr_row
return prev_row[0]
Наибольший палиндром в строке
Наибольший палиндром в строке
Верните наибольшую палиндромную подстроку данной строки.
Пример:
Input: s = 'abccbaba'
Output: 'abccba'
Интуиция
Подстрока s[i:j] является палиндромом, если: (1) s[i] == s[j] и (2) s[i+1:j-1] тоже является палиндромом.
dp[i][j] = True if s[i] == s[j] and dp[i + 1][j - 1]
Base cases: все подстроки длины 1 являются палиндромами (dp[i][i] = True). Подстроки длины 2 являются палиндромами, если оба символа одинаковы.
Отслеживаем наибольший палиндром с помощью start_index и max_len. Возвращаем s[start_index : start_index + max_len].
Реализация
def longest_palindrome_in_a_string(s):
n = len(s)
if n == 0: return ''
dp = [[False] * n for _ in range(n)]
max_len = 1
start_index = 0
for i in range(n):
dp[i][i] = True
for i in range(n - 1):
if s[i] == s[i + 1]:
dp[i][i + 1] = True
max_len = 2
start_index = i
for substring_len in range(3, n + 1):
for i in range(n - substring_len + 1):
j = i + substring_len - 1
if s[i] == s[j] and dp[i + 1][j - 1]:
dp[i][j] = True
if substring_len > max_len:
max_len = substring_len
start_index = i
return s[start_index : start_index + max_len]
Анализ сложности
Временная сложность: O(n^2). Пространственная сложность: O(n^2).
Оптимизированный подход
Расширяемся от центра каждого базового случая. Два типа центров: односимвольный (нечётный) и двухсимвольный (чётный).
def longest_palindrome_in_a_string_expanding(s):
n = len(s)
start, max_len = 0, 0
for center in range(n):
odd_start, odd_length = expand_palindrome(center, center, s)
if odd_length > max_len:
start = odd_start
max_len = odd_length
if center < n - 1 and s[center] == s[center + 1]:
even_start, even_length = expand_palindrome(center, center + 1, s)
if even_length > max_len:
start = even_start
max_len = even_length
return s[start : start + max_len]
Анализ сложности
Временная сложность: O(n^2). Пространственная сложность: O(1).
Алгоритм Манакера
Алгоритм Манакера работает за O(n), однако является специализированным алгоритмом и редко встречается на собеседованиях. Большинство интервьюеров ожидают решений, демонстрирующих фундаментальные навыки решения задач.
Максимальная сумма подмассива
Максимальная сумма подмассива
Дан массив целых чисел. Верните сумму подмассива с наибольшей суммой.
Пример:
Input: nums = [3, 1, -6, 2, -1, 4, -9]
Output: 5
Пояснение: подмассив [2, -1, 4] имеет наибольшую сумму равную 5.
Интуиция
Для каждого числа выбираем max(curr_sum + num, num): продолжаем текущий подмассив или начинаем новый с текущего элемента.
Отслеживаем max_sum — наибольшее встреченное значение curr_sum. Это алгоритм Кадане.
Реализация
def maximum_subarray_sum(nums):
if not nums: return 0
max_sum = current_sum = float('-inf')
for num in nums:
current_sum = max(current_sum + num, num)
max_sum = max(max_sum, current_sum)
return max_sum
Анализ сложности
Временная сложность: O(n). Пространственная сложность: O(1).
Интуиция — DP
Каждый подмассив заканчивается на определённом индексе. dp[i] = максимальная сумма подмассива, заканчивающегося на индексе i.
max_subarray(i) = max(max_subarray(i - 1) + nums[i], nums[i])
dp[i] = max(dp[i - 1] + nums[i], nums[i])
Base case: dp[0] = nums[0]. Вернуть максимум всех значений dp.
Реализация — DP
def maximum_subarray_sum_dp(nums):
n = len(nums)
if n == 0: return 0
dp = [0] * n
dp[0] = nums[0]
max_sum = dp[0]
for i in range(1, n):
dp[i] = max(dp[i - 1] + nums[i], nums[i])
max_sum = max(max_sum, dp[i])
return max_sum
Оптимизация
Используем одну переменную вместо полного DP-массива. Пространственная сложность снижается до O(1).
def maximum_subarray_sum_dp_optimized(nums):
n = len(nums)
if n == 0: return 0
current_sum = nums[0]
max_sum = nums[0]
for i in range(1, n):
current_sum = max(nums[i], current_sum + nums[i])
max_sum = max(max_sum, current_sum)
return max_sum
Задача о рюкзаке 0/1
Задача о рюкзаке 0/1
Вы — вор, планирующий ограбление магазина. Вы можете нести рюкзак с максимальной вместимостью cap единиц. Каждый предмет (i) имеет вес (weights[i]) и стоимость (values[i]). Найдите максимальную суммарную стоимость, которую можно унести.
Пример:
Input: cap = 7, weights = [5, 3, 4, 1], values = [70, 50, 40, 10]
Output: 90
Пояснение: предметы 1 и 2 имеют суммарную стоимость 50 + 40 = 90 и суммарный вес 3 + 4 = 7.
Интуиция
Для каждого предмета принимается бинарное решение: взять или не взять (отсюда название «0/1 Knapsack»). Рекуррентное соотношение:
knapsack(i, c) = max(values[i] + knapsack(i + 1, c - weights[i]), knapsack(i + 1, c))
Если предмет не помещается: knapsack(i, c) = knapsack(i + 1, c).
if weights[i] <= c:
dp[i][c] = max(values[i] + dp[i + 1][c - weights[i]], dp[i + 1][c])
else:
dp[i][c] = dp[i + 1][c]
Base cases: dp[n][c] = 0 для всех c (нет предметов), dp[i][0] = 0 для всех i (нет вместимости). Вернуть dp[0][cap].
Реализация
def knapsack(cap, weights, values):
n = len(values)
dp = [[0 for x in range(cap + 1)] for x in range(n + 1)]
for i in range(n - 1, -1, -1):
for c in range(1, cap + 1):
if weights[i] <= c:
dp[i][c] = max(values[i] + dp[i + 1][c - weights[i]], dp[i + 1][c])
else:
dp[i][c] = dp[i + 1][c]
return dp[0][cap]
Анализ сложности
Временная сложность: O(n*cap). Пространственная сложность: O(n*cap).
Оптимизация
Достаточно двух строк: curr_row и prev_row. Пространственная сложность снижается до O(cap).
def knapsack_optimized(cap, weights, values):
n = len(values)
prev_row = [0] * (cap + 1)
for i in range(n - 1, -1, -1):
curr_row = [0] * (cap + 1)
for c in range(1, cap + 1):
if weights[i] <= c:
curr_row[c] = max(values[i] + prev_row[c - weights[i]], prev_row[c])
else:
curr_row[c] = prev_row[c]
prev_row = curr_row
return prev_row[cap]
Наибольший квадрат в матрице
Наибольший квадрат в матрице
Определите площадь наибольшего квадрата из единиц в бинарной матрице.
Пример:
Output: 9
Интуиция
Квадраты содержат внутри себя меньшие квадраты. Длина стороны квадрата, заканчивающегося в точке (i,j), зависит от квадратов, заканчивающихся слева (i,j-1), сверху (i-1,j) и по диагонали сверху-слева (i-1,j-1).
if matrix[i][j] == 1: dp[i][j] = 1 + min(dp[i - 1][j], dp[i - 1][j - 1], dp[i][j - 1])
Base cases: клетки строки 0 и столбца 0 получают значение DP, равное значению матрицы (0 или 1).
Отслеживаем max_len. Возвращаем max_len^2 в качестве площади.
Реализация
def largest_square_in_a_matrix(matrix):
if not matrix: return 0
m, n = len(matrix), len(matrix[0])
dp = [[0] * n for _ in range(m)]
max_len = 0
for j in range(n):
if matrix[0][j] == 1:
dp[0][j] = 1
max_len = 1
for i in range(m):
if matrix[i][0] == 1:
dp[i][0] = 1
max_len = 1
for i in range(1, m):
for j in range(1, n):
if matrix[i][j] == 1:
dp[i][j] = 1 + min(dp[i-1][j], dp[i-1][j-1], dp[i][j-1])
max_len = max(max_len, dp[i][j])
return max_len ** 2
Анализ сложности
Временная сложность: O(m*n). Пространственная сложность: O(m*n).
Оптимизация
Достаточно двух строк: prev_row и curr_row. Пространственная сложность снижается до O(m).
def largest_square_in_a_matrix_optimized(matrix):
if not matrix: return 0
m, n = len(matrix), len(matrix[0])
prev_row = [0] * n
max_len = 0
for i in range(m):
curr_row = [0] * n
for j in range(n):
if i == 0 or j == 0:
curr_row[j] = matrix[i][j]
else:
if matrix[i][j] == 1:
curr_row[j] = 1 + min(curr_row[j-1], prev_row[j-1], prev_row[j])
max_len = max(max_len, curr_row[j])
prev_row, curr_row = curr_row, [0] * n
return max_len ** 2
Жадные алгоритмы
Введение в жадные алгоритмы
Введение в жадные алгоритмы
Интуиция
Жадные алгоритмы — класс алгоритмов, принимающих серию решений, каждое из которых является наилучшим непосредственным выбором в данный момент.
Представьте, что вы планируете автопробег из города A в город E и хотите посетить города B, C и D по дороге.
Вам нужен самый быстрый маршрут. Вместо того чтобы проверять каждый возможный маршрут, вы выбираете участок с наименьшим временем в пути на каждом шаге.
Это по сути и есть жадный подход к задаче: вы выбираете лучший вариант на каждом шаге, стремясь найти оптимальное общее решение.
Как работает жадный алгоритм? Формально жадный алгоритм следует принципу жадного выбора, согласно которому глобально оптимальное решение может быть получено путём локально оптимальных выборов.
Каждое решение принимается исходя только из текущего контекста и не учитывает его влияние на будущие шаги. Этот процесс продолжается до тех пор, пока задача не будет решена.
Как определить, решается ли задача жадным подходом Жадные подходы, как правило, применяются к задачам оптимизации, где требуется найти наилучшее решение с учётом некоторых ограничений.
Пример из реальной жизни
Кодирование Хаффмана при сжатии данных: кодирование Хаффмана присваивает переменные коды входным символам на основе их частоты встречаемости, обеспечивая более короткие коды для часто встречающихся символов. Этот алгоритм использует жадный подход, неоднократно выбирая два узла с наименьшей частотой для формирования нового узла.
Обзор главы
Создать универсальный шаблон для решения жадных задач нецелесообразно, поскольку каждая из них уникальна. В этой главе рассматриваются три жадных задачи, демонстрирующих, как жадный подход может приводить к оптимальным решениям.
Прыжок к концу
Прыжок к концу
Вам дан целочисленный массив, в котором вы изначально находитесь в индексе 0. Каждое число представляет максимальную длину прыжка вперёд из данной позиции. Верните true, если можно достичь последнего индекса, и false в противном случае.
Пример 1:
Input: nums = [3, 2, 0, 2, 5]
Output: True
Пример 2:
Input: nums = [2, 1, 0, 3]
Output: False
Интуиция
Из индекса i самый дальний достижимый индекс равен i + nums[i]. Проблема возникает с нулями (тупиками). Думайте в обратном направлении: установите destination = последний индекс. Если можно достичь destination из i, обновите destination = i.
если i + nums[i] >= destination, из индекса i можно добраться до destination.
Жадный выбор: первый допустимый индекс справа становится новым destination. Все остальные индексы, которые могут достичь исходного destination, теперь могут достичь его через новое destination.
Реализация
def jump_to_the_end(nums):
destination = len(nums) - 1
for i in range(len(nums) - 1, -1, -1):
if i + nums[i] >= destination:
destination = i
return destination == 0
Анализ сложности
Время: O(n). Пространство: O(1).
Заправочные станции
Заправочные станции
На круговом маршруте расположены заправочные станции. На каждой станции можно залить gas[i] единиц бензина, а движение до следующей станции стоит cost[i] единиц. Верните начальную станцию, с которой можно проехать весь круговой маршрут, или -1, если это невозможно.
Пример:
Input: gas = [2, 5, 1, 3], cost = [3, 2, 1, 4]
Output: 1
Интуиция
Случай 1: sum(gas) < sum(cost) — невозможно, вернуть -1. Случай 2: sum(gas) >= sum(cost) — допустимая начальная точка существует.
Используйте чистый газ (gas[i] - cost[i]) на каждой станции. Двигайтесь вперёд, накапливая значение в переменной tank. Если tank < 0 на станции i, мы не можем начать движение ни с одной из предыдущих станций; устанавливаем следующую станцию как стартовую и сбрасываем tank.
Если не удаётся добраться от станции a до станции b, нельзя начать движение ни с одной из станций между a и b, не исчерпав бензин.
Доказательство: если sum(gas) >= sum(cost), начальная точка, найденная этим жадным процессом, гарантированно завершит маршрут.
Реализация
def gas_stations(gas, cost):
if sum(gas) < sum(cost):
return -1
start = tank = 0
for i in range(len(gas)):
tank += gas[i] - cost[i]
if tank < 0:
start, tank = i + 1, 0
return start
Анализ сложности
Время: O(n). Пространство: O(1).
Совет для собеседования
Совет: демонстрируйте жадное решение на примерах, если формальное доказательство слишком сложно. Доказательство корректности с помощью наглядных примеров часто более чем достаточно на практическом собеседовании.
Конфеты
Конфеты
Вы преподаёте классу детей, сидящих в ряд, каждый из которых имеет рейтинг успеваемости. Правила: (1) каждый ребёнок должен получить хотя бы одну конфету; (2) ребёнок с более высоким рейтингом, чем непосредственный сосед, должен получить больше конфет. Верните минимальное количество конфет для раздачи.
Пример 1:
Input: ratings = [4, 3, 2, 4, 5, 1]
Output: 12
Пояснение: раздайте конфеты как [3, 2, 1, 2, 3, 1].
Пример 2:
Input: ratings = [1, 3, 3]
Output: 4
Интуиция
Двухпроходный жадный алгоритм: первый проход (слева направо): если ratings[i] > ratings[i-1], устанавливаем candies[i] = candies[i-1] + 1. Второй проход (справа налево): если ratings[i] > ratings[i+1], устанавливаем candies[i] = max(candies[i], candies[i+1] + 1).
Это жадное решение: локально оптимальный выбор для каждого ребёнка с учётом только непосредственных соседей. Два прохода обеспечивают соблюдение обоих ограничений.
Реализация
def candies(ratings):
n = len(ratings)
candies = [1] * n
for i in range(1, n):
if ratings[i] > ratings[i - 1]:
candies[i] = candies[i - 1] + 1
for i in range(n - 2, -1, -1):
if ratings[i] > ratings[i + 1]:
candies[i] = max(candies[i], candies[i + 1] + 1)
return sum(candies)
Анализ сложности
Время: O(n). Пространство: O(n).
Sort and Search
Введение в Sort and Search
Введение в Sort and Search
Сортировка и поиск — две наиболее фундаментальные операции в информатике. В этой главе мы рассмотрим паттерны и техники для эффективной сортировки данных и поиска по ним.
Сортировка
Сортировка — это упорядочивание элементов в определённом порядке (обычно по возрастанию или убыванию). Распространённые алгоритмы сортировки:
- Merge Sort: время O(n log n), память O(n) — стабильный, разделяй и властвуй
- Quick Sort: среднее время O(n log n), память O(log n) — сортировка на месте, разделяй и властвуй
- Counting Sort: время O(n + k), память O(k) — для целых чисел в известном диапазоне
Поиск
Поиск — это нахождение конкретного элемента или значения в структуре данных. Binary Search — наиболее эффективный алгоритм поиска в отсортированных данных:
- Binary Search: время O(log n) — работает только с отсортированными массивами
- Linear Search: время O(n) — работает с неотсортированными массивами
Задачи
В этой главе мы решим следующие задачи:
- Sort Linked List
- Sort Array
- Kth Largest Integer
- Dutch National Flag
Sort Linked List
Sort Linked List
Дан head односвязного списка. Отсортируйте его по возрастанию и верните head отсортированного списка.
Пример:
Input: head = [4, 2, 1, 3]
Output: [1, 2, 3, 4]
Идея
Для сортировки связного списка можно использовать Merge Sort, который хорошо подходит для связных списков, поскольку не требует произвольного доступа (в отличие от Quick Sort). Алгоритм работает следующим образом:
- Разделить список на две половины с помощью техники медленного и быстрого указателей.
- Рекурсивно отсортировать каждую половину.
- Слить две отсортированные половины.
Реализация
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
def sort_linked_list(head: ListNode) -> ListNode:
if not head or not head.next:
return head
# Split the list into two halves
left, right = split_list(head)
# Recursively sort each half
left = sort_linked_list(left)
right = sort_linked_list(right)
# Merge the two sorted halves
return merge(left, right)
def split_list(head: ListNode):
slow, fast = head, head.next
while fast and fast.next:
slow = slow.next
fast = fast.next.next
right = slow.next
slow.next = None
return head, right
def merge(left: ListNode, right: ListNode) -> ListNode:
dummy = ListNode(0)
curr = dummy
while left and right:
if left.val <= right.val:
curr.next = left
left = left.next
else:
curr.next = right
right = right.next
curr = curr.next
curr.next = left if left else right
return dummy.next
Анализ сложности
Временная сложность: O(n log n) — каждое разбиение занимает O(n), а уровней рекурсии O(log n).
Пространственная сложность: O(log n) — под стек рекурсивных вызовов.
Sort Array
Sort Array
Дан массив целых чисел nums. Отсортируйте его по возрастанию и верните результат. Задачу необходимо решить без использования встроенных функций сортировки за время O(nlog(n)) с минимально возможной пространственной сложностью.
Пример:
Input: nums = [5, 2, 3, 1]
Output: [1, 2, 3, 5]
Идея
Можно использовать Quick Sort со средней временной сложностью O(n log n) и пространственной сложностью O(log n). Quick Sort работает следующим образом:
- Выбрать pivot-элемент из массива.
- Разбить остальные элементы на две группы: элементы меньше pivot и элементы больше pivot.
- Рекурсивно применить ту же логику к обеим группам.
Альтернативно можно использовать Counting Sort, когда диапазон элементов ограничен — временная сложность O(n + k), где k — диапазон входных значений.
Реализация
from typing import List
import random
def sort_array(nums: List[int]) -> List[int]:
quicksort(nums, 0, len(nums) - 1)
return nums
def quicksort(nums: List[int], low: int, high: int) -> None:
if low < high:
pivot_idx = partition(nums, low, high)
quicksort(nums, low, pivot_idx - 1)
quicksort(nums, pivot_idx + 1, high)
def partition(nums: List[int], low: int, high: int) -> int:
# Randomly select a pivot to avoid worst case
rand_idx = random.randint(low, high)
nums[rand_idx], nums[high] = nums[high], nums[rand_idx]
pivot = nums[high]
i = low - 1
for j in range(low, high):
if nums[j] <= pivot:
i += 1
nums[i], nums[j] = nums[j], nums[i]
nums[i + 1], nums[high] = nums[high], nums[i + 1]
return i + 1
Анализ сложности
Временная сложность: O(n log n) в среднем — худший случай O(n²), но редкий при случайном выборе pivot.
Пространственная сложность: O(log n) — под стек рекурсивных вызовов.
Kth Largest Integer
Kth Largest Integer
Дан неотсортированный массив целых чисел nums и целое число k. Верните k-й наибольший элемент массива.
Пример:
Input: nums = [3, 2, 1, 5, 6, 4], k = 2
Output: 5
Идея
Существует два эффективных подхода к решению этой задачи:
Подход 1: Min-Heap
Можно поддерживать min-heap размера k. Для каждого элемента: если он больше минимума кучи, заменяем минимум этим элементом. После обработки всех элементов минимум кучи является k-м наибольшим элементом.
Временная сложность подхода — O(n log k), пространственная — O(k).
Подход 2: Quickselect
Quickselect — алгоритм выбора, который находит k-й наименьший (или наибольший) элемент в неотсортированном списке. Он основан на шаге разбиения из Quick Sort.
Средняя временная сложность подхода — O(n), пространственная — O(log n).
Реализация
import heapq
from typing import List
def kth_largest(nums: List[int], k: int) -> int:
min_heap = []
for num in nums:
heapq.heappush(min_heap, num)
if len(min_heap) > k:
heapq.heappop(min_heap)
return min_heap[0]
import random
from typing import List
def kth_largest_quickselect(nums: List[int], k: int) -> int:
target = len(nums) - k # kth largest = (n-k)th smallest
def quickselect(low: int, high: int) -> int:
pivot_idx = random.randint(low, high)
nums[pivot_idx], nums[high] = nums[high], nums[pivot_idx]
pivot = nums[high]
i = low
for j in range(low, high):
if nums[j] <= pivot:
nums[i], nums[j] = nums[j], nums[i]
i += 1
nums[i], nums[high] = nums[high], nums[i]
if i == target:
return nums[i]
elif i < target:
return quickselect(i + 1, high)
else:
return quickselect(low, i - 1)
return quickselect(0, len(nums) - 1)
Анализ сложности
Min-Heap: временная сложность O(n log k), пространственная сложность O(k).
Quickselect: средняя временная сложность O(n), худший случай O(n²), пространственная сложность O(log n).
Dutch National Flag
Dutch National Flag
Дан массив из 0, 1 и 2, представляющих красный, белый и синий цвета соответственно. Отсортируйте массив на месте так, чтобы он воспроизводил флаг Нидерландов: сначала все красные (0), затем белые (1) и наконец синие (2).
Пример:
Input: nums = [0, 1, 2, 0, 1, 2, 0]
Output: [0, 0, 0, 1, 1, 2, 2]
Идея
По сути задача сводится к сортировке трёх чисел по возрастанию. Простое решение — воспользоваться встроенной функцией сортировки. Однако это подход O(nlog(n)), где n — длина массива, и он не использует важное ограничение задачи: в массиве присутствуют только три типа элементов.
Чтобы упорядочить эти числа, нужно разместить все 0 слева, все 2 справа, а 1 — в середине. Ключевое наблюдение: если поставить 0 и 2 на правильные позиции, 1 окажутся на своих местах автоматически:
Это позволяет сосредоточиться лишь на размещении двух чисел.
Одна из стратегий — проходить по массиву и перемещать встречающиеся 0 влево, а встречающиеся 2 вправо.
Заведём левый указатель для перемещения 0 влево и правый указатель для перемещения 2 вправо. Для итерации по массиву используем отдельный указатель i:
- При встрече 0 по индексу i меняем его местами с nums[left].
- При встрече 2 по индексу i меняем его местами с nums[right].
Разберём на примере, как следует перемещать указатели после каждой перестановки:
Первый элемент равен 2, поэтому меняем его местами с nums[right]. Затем сдвигаем правый указатель внутрь, чтобы он указывал на позицию следующей 2:
Обратите внимание: после этой перестановки по индексу i появился новый элемент. Поэтому i пока не продвигаем — нужно определить, требует ли этот элемент перемещения.
Указатель i теперь указывает на 1. Единицы обрабатывать не нужно, поэтому просто продвигаем указатель i:
Теперь указатель i указывает на 0, поэтому меняем его местами с nums[left]:
После этой перестановки по индексу i появился новый элемент. Поскольку i расположен правее левого указателя, этот элемент может быть только 1 по следующим причинам:
- До перестановки все 0, находившиеся левее i, уже были перемещены левее индекса left.
- До перестановки все 2, находившиеся левее i, уже были перемещены правее индекса right.
Следовательно, можно одновременно продвигать и указатель i, и левый указатель.
Теперь мы знаем, как действовать при встрече 0, 1 или 2. Продолжаем применять эту логику до тех пор, пока указатель i не превысит правый указатель — это означает, что все элементы расставлены правильно:
Обратите внимание: процесс не останавливается при i == right, так как указатель i может по-прежнему указывать на 0, которую необходимо переставить.
Реализация
from typing import List
def dutch_national_flag(nums: List[int]) -> None:
i, left, right = 0, 0, len(nums) - 1
while i <= right:
# Swap 0s with the element at the left pointer.
if nums[i] == 0:
nums[i], nums[left] = nums[left], nums[i]
left += 1
i += 1
# Swap 2s with the element at the right pointer.
elif nums[i] == 2:
nums[i], nums[right] = nums[right], nums[i]
right -= 1
else:
i += 1
Анализ сложности
Временная сложность: O(n) — каждый элемент массива nums обрабатывается ровно один раз.
Пространственная сложность: O(1).
Bit Manipulation
Введение в bit manipulation
Введение в bit manipulation
Интуиция
Bit manipulation — техника программирования, позволяющая выполнять операции на уровне отдельных битов. Это нередко приводит к более эффективным и быстрым алгоритмам.
Когда полезен bit manipulation? Bit manipulation позволяет работать непосредственно с двоичным представлением чисел, делая ряд операций более эффективными. Такие типовые задачи, как установка, сброс, инвертирование и проверка битов, быстро выполняются с помощью bitwise-операторов.
Например, одна из наиболее распространённых техник оптимизации памяти — использование беззнакового 32-битного целого числа для хранения набора булевых значений, где каждый bit соответствует отдельному булевому значению. Это позволяет хранить и управлять до 32 состояниями без использования массива булевых значений или хэш-множества.
Bitwise-операторы
Существует несколько фундаментальных bitwise-операций, каждая из которых служит определённой цели. Они показаны ниже вместе с таблицей истинности для каждой операции:
Полезные свойства оператора XOR:
- a ^ 0 = a
- a ^ a = 0
Помимо этого, важно понимать фундаментальные операторы shift:
- Left shift (<< n): сдвигает биты числа влево на n позиций, добавляя нули справа. Это эквивалентно умножению числа на 2n.
- Right shift (>> n): сдвигает биты числа вправо на n позиций, отбрасывая биты справа. Это эквивалентно делению числа на 2n (целочисленное деление).
Используя эти операторы, полезно знать следующие техники bit manipulation:
- Установить i-й bit числа x в 1: x |= (1 << i)
- Сбросить i-й bit числа x в 0: x &= ~(1 << i)
- Инвертировать i-й bit числа x (из 0 в 1 или из 1 в 0): x ^= (1 << i)
- Проверить, установлен ли i-й bit числа x: (x >> i) & 1
- Убрать младший установленный bit числа x: x & (x - 1)
- Извлечь младший установленный bit числа x: x & (-x)
Пример из реальной жизни
Передача данных в сетях: во многих сетевых протоколах bit manipulation применяется для эффективного кодирования, сжатия и передачи данных. Например, IP-адреса и маски подсети используют bitwise AND для определения, находятся ли два устройства в одной сети. Аналогично, в алгоритмах обнаружения и исправления ошибок — таких как контрольные суммы или биты чётности — bit manipulation обеспечивает целостность данных при передаче, позволяя выявлять и исправлять ошибки в двоичных данных.
Структура главы
Для наилучшего усвоения основ bit manipulation данная глава исследует разнообразные задачи, в которых применяется широкий спектр сложных техник bit manipulation, а также рассматривает, как выбрать подходящий bitwise-оператор исходя из конкретных требований.
Веса Хэмминга целых чисел
Веса Хэмминга целых чисел
Вес Хэмминга числа — это количество установленных битов (единичных битов) в его двоичном представлении. Дано положительное целое число n; верните массив, где i-й элемент равен весу Хэмминга целого числа i для всех целых чисел от 0 до n.
Пример:
Input: n = 7
Output: [0, 1, 1, 2, 1, 2, 2, 3]
Пояснение:
| Number | Binary representation | Number of set bits |
| 0 | 0 | 0 |
| 1 | 1 | 1 |
| 2 | 10 | 1 |
| 3 | 11 | 2 |
| 4 | 100 | 1 |
| 5 | 101 | 2 |
| 6 | 110 | 2 |
| 7 | 111 | 3 |
Интуиция — подсчёт битов для каждого числа
Наиболее прямолинейная стратегия — поочерёдно подсчитывать количество битов для каждого числа от 0 до n.
Рассмотрим число x = 25 и его двоичное представление:
Чтобы подсчитать количество установленных битов (единиц) в числе, можно проверять каждый bit и увеличивать счётчик при обнаружении установленного бита.
Для проверки LSB (least significant bit) числа x можно применить оператор AND с 1: x & 1. Если результат равен 1, LSB установлен.
Как проверить следующий bit? При выполнении bitwise right-shift над x все биты числа x сдвигаются на одну позицию вправо. Это делает следующий bit новым LSB:
Теперь у нас есть процесс, который можно повторять для подсчёта установленных битов в числе:
- Проверить LSB числа x с помощью x & 1, увеличив счётчик, если он равен 1.
- Выполнить right shift числа x.
Продолжать эти два шага до тех пор, пока x не станет равным 0 — это означает, что больше нет установленных битов для подсчёта. Выполнение этого для каждого числа от 0 до n даёт ответ.
Реализация — подсчёт битов для каждого числа
from typing import List
def hamming_weights_of_integers(n: int) -> List[int]:
result = []
for i in range(n + 1):
count = 0
x = i
while x:
count += x & 1
x >>= 1
result.append(count)
return result
Анализ сложности
Временная сложность: временная сложность hamming_weights_of_integers составляет O(n log(n)), поскольку для каждого целого числа x от 0 до n подсчёт установленных битов занимает логарифмическое время — в числе x приблизительно log2(x) битов. Если предположить, что все целые числа занимают 32 бита, временная сложность упрощается до O(n).
Пространственная сложность: пространственная сложность составляет O(1), так как не используется дополнительная память, кроме той, что занята выходными данными.
Интуиция — динамическое программирование
В предыдущем подходе важно заметить, что к моменту обработки целого числа x мы уже вычислили результаты для всех целых чисел от 0 до x - 1. Если найти способ использовать эти предыдущие результаты, можно повысить эффективность построения выходного массива.
Было бы разумно найти способ воспользоваться оптимальной подструктурой, рассматривая результаты для чисел от 0 до x - 1 как потенциальные подзадачи для x. Это является отправной точкой для решения с помощью динамического программирования. Пусть dp[x] обозначает количество установленных битов в целом числе x.
Предсказуемый способ обратиться к подзадаче dp[x] — выполнить right shift числа x на 1, тем самым фактически убрав его LSB. Это подзадача dp[x >> 1], и единственное отличие между ней и dp[x] — это только что удалённый LSB.
Как упоминалось ранее, LSB числа x можно найти с помощью x & 1. Следовательно:
- Если LSB числа x равен 0, количество битов у x и x >> 1 одинаково:
- Если LSB числа x равен 1, разница в количестве битов между x и x >> 1 составляет 1:
Таким образом, dp[x] можно получить, используя результат dp[x >> 1] и добавив к нему LSB:
dp[x] = dp[x >> 1] + (x & 1)
Теперь нужно определить базовый случай.
Базовый случай Простейший вариант задачи — когда n равно 0. В этом случае установленных битов нет, поэтому их количество равно 0. Базовый случай задаётся установкой dp[0] = 0.
После определения базового случая заполняем остальную часть массива DP, применяя формулу от dp[1] до dp[n]. Ответом на задачу служат значения в массиве DP, содержащем количество установленных битов для каждого числа от 0 до n.
Реализация — динамическое программирование
from typing import List
def hamming_weights_of_integers_dp(n: int) -> List[int]:
dp = [0] * (n + 1)
for i in range(1, n + 1):
dp[i] = dp[i >> 1] + (i & 1)
return dp
Анализ сложности
Временная сложность: временная сложность hamming_weights_of_integers_dp составляет O(n), так как каждый элемент массива DP заполняется ровно один раз.
Пространственная сложность: пространственная сложность составляет O(1), поскольку дополнительная память не используется — не считая места, занятого выходными данными, которыми в данном случае является сам массив DP.
Одинокое целое число
Одинокое целое число
Дан массив целых чисел, в котором каждое число встречается дважды, за исключением одного. Найдите уникальное число.
Пример:
Input: nums = [1, 3, 3, 2, 1]
Output: 2
Ограничения:
- nums содержит хотя бы один элемент.
Интуиция
Решение с хэш-таблицей Прямолинейный способ решения — использование хэш-таблицы. Идея состоит в подсчёте вхождений каждого элемента массива. Для этого перебираем массив и увеличиваем счётчик вхождений каждого встреченного элемента в хэш-таблице.
После заполнения перебираем хэш-таблицу в поисках элемента с частотой 1 — это и есть наше одинокое целое число. Подход работает за O(n) по времени, но требует O(n) дополнительной памяти, где n — длина входного массива. Попробуем найти способ решить задачу без дополнительных структур данных.
Решение с сортировкой Другой подход — сначала отсортировать массив, затем найти одинокое целое число, перебирая массив и сравнивая каждый элемент с соседями. Одинокое целое число — то, рядом с которым нет дубликата.
Этот метод требует O(n log(n)) времени из-за сортировки, но не нуждается в дополнительных структурах данных (не считая тех, что используются при сортировке). Можно ли достичь линейной временной сложности при постоянном использовании памяти?
Bit manipulation Способ избежать использования дополнительной памяти — применить bit manipulation. Операция XOR особенно полезна при работе с дублирующимися целыми числами. Напомним два свойства оператора XOR:
- a ^ a == 0
- a ^ 0 == a
Как видно, XOR двух одинаковых чисел даёт 0. Поскольку каждое число, кроме одинокого, встречается в массиве дважды, применение XOR ко всем числам отменит все пары одинаковых чисел до 0. Это изолирует одинокое целое число: когда все дубликаты сводятся к 0, XOR нуля с одиноким числом даёт само это число.
Это работает независимо от расположения чисел в массиве, поскольку XOR обладает коммутативным и ассоциативным свойствами:
- Коммутативность: a ^ b == b ^ a
- Ассоциативность: (a ^ b) ^ c == a ^ (b ^ c)
Таким образом, пока в массиве есть два одинаковых числа, они будут взаимно уничтожаться при применении XOR ко всем элементам. Пример показан ниже:
Это позволяет найти одинокое целое число за линейное время без использования дополнительной памяти.
Реализация
from typing import List
def lonely_integer(nums: List[int]) -> int:
res = 0
# XOR each element of the array so that duplicate values will cancel each other
# out (x ^ x == 0).
for num in nums:
res ^= num
# 'res' will store the lonely integer because it would not have been canceled out
# by any duplicate.
return res
Анализ сложности
Временная сложность: временная сложность lonely_integer составляет O(n), поскольку над каждым элементом nums выполняется операция XOR за константное время.
Пространственная сложность: пространственная сложность составляет O(1).
Перестановка чётных и нечётных битов
Перестановка чётных и нечётных битов
Дано беззнаковое 32-битное целое число n; верните целое число, в котором все чётные биты числа n переставлены с соседними нечётными битами.
Пример 1:
Input: n = 41
Output: 22
Пример 2:
Input: n = 23
Output: 43
Интуиция
Перестановка чётных и нечётных битов означает, что каждый bit на чётной позиции меняется местами с битом на следующей нечётной позиции, и наоборот. Обратите внимание, что нумерация позиций начинается с 0 — позиции least significant bit.
Ключевое наблюдение: для выполнения перестановки необходимо:
- Сдвинуть все биты на чётных позициях на одну позицию влево.
- Сдвинуть все биты на нечётных позициях на одну позицию вправо.
Это говорит о том, что если научиться извлекать биты на чётных и нечётных позициях по отдельности, их можно сдвинуть соответствующим образом, а затем объединить — чтобы биты с нечётных позиций оказались на чётных, и наоборот.
Разберёмся, как получить чётные и нечётные биты числа n.
Получение всех чётных битов Для извлечения всех чётных битов числа n можно использовать mask, в котором все чётные позиции установлены в 1:
Применение bitwise AND этого mask к числу n даёт целое число, в котором все биты на нечётных позициях сброшены в 0 — таким образом сохраняются только биты n на чётных позициях:
Получение всех нечётных битов Аналогично, для извлечения всех нечётных битов числа n используется mask, в котором все нечётные позиции установлены в 1:
Применение bitwise AND этого mask к числу n даёт целое число, в котором все биты на чётных позициях сброшены в 0 — таким образом сохраняются только биты n на нечётных позициях:
Теперь, когда чётные и нечётные биты извлечены по отдельности, воспользуемся ими для получения результата, в котором биты на нечётных и чётных позициях переставлены.
Сдвиг и объединение битов чётных и нечётных позиций Применим оператор shift для сдвига битов на чётных позициях влево на один, а битов на нечётных позициях — вправо на один:
Затем для объединения применим bitwise OR, поскольку он сливает два набора битов в конечный результат.
Теперь биты с нечётных позиций находятся на чётных, и наоборот.
Реализация
def swap_odd_and_even_bits(n: int) -> int:
# Mask to extract even bits (positions 0, 2, 4, ...): 0x55555555 = 01010101...
even_mask = 0x55555555
# Mask to extract odd bits (positions 1, 3, 5, ...): 0xAAAAAAAA = 10101010...
odd_mask = 0xAAAAAAAA
# Extract even bits and shift left, extract odd bits and shift right
even_bits = (n & even_mask) << 1
odd_bits = (n & odd_mask) >> 1
# Merge the two
return even_bits | odd_bits
Анализ сложности
Временная сложность: временная сложность swap_odd_and_even_bits составляет O(1).
Пространственная сложность: пространственная сложность составляет O(1).
Math and Geometry
Введение в Math and Geometry
Введение в Math and Geometry
Интуиция
В этой главе рассматривается ряд задач, связанных с важными математическими и геометрическими концепциями в программировании, которые регулярно встречаются на технических собеседованиях.
Мы изучаем такие темы, как:
- Наибольший общий делитель (GCD).
- Модульная арифметика.
- Работа с точностью чисел с плавающей запятой.
- Обработка переполнения и антипереполнения целых чисел.
- Распознавание паттернов.
Пример из реального мира
Компьютерная графика: в трёхмерном рендеринге геометрия используется для представления объектов в виде фигур, таких как полигоны, а математические преобразования — вращение, масштабирование и перенос — применяются к этим объектам для их анимации или изменения перспективы. Алгоритмы, использующие тригонометрию, векторную математику и операции с matrix, играют ключевую роль в определении того, как объекты движутся, взаимодействуют со светом или отбрасывают тени в виртуальных средах.
Содержание главы
Спиральный обход
Спиральный обход
Верните элементы matrix в порядке обхода по спирали по часовой стрелке.
Пример:
Output: [0, 1, 2, 3, 4, 9, 14, 19, 18, 17, 16, 15, 10, 5, 6, 7, 8, 13, 12, 11]
Интуиция
Чтобы получить ожидаемый результат, попробуем смоделировать именно то, что описывает задача: обойти matrix по спирали, добавляя каждое значение в результат по мере продвижения. Как это сделать?
Спиральный обход предполагает движение по matrix в одном направлении до упора, затем смену направления и продолжение движения. Конкретно последовательность направлений такова: вправо, вниз, влево и вверх — и так повторяется до тех пор, пока не будут пройдены все элементы. Для этого нужно определить точные условия смены направления.
На первый взгляд подход кажется простым: начинаем движение вправо, пока не достигнем крайнего правого столбца matrix, после чего меняем направление. Можно совершить три таких перехода без каких-либо проблем:
Однако, как показано ниже, если двигаться вверх до верхней строки matrix, мы вернёмся к стартовой позиции и добавим в результат значение из уже посещённой ячейки:
Одно из возможных решений — отслеживать все посещённые ячейки с помощью хеш-множества. Это позволяет остановить движение в текущем направлении при обнаружении уже посещённой ячейки. Подход эффективен, однако требует O(m*n) памяти. Можно ли избежать повторного посещения ячеек без использования дополнительной структуры данных?
Обратите внимание на приведённые выше диаграммы: при движении в определённом направлении мы продолжаем его до достижения одной из граничных строк или столбцов (верхней или нижней строки, крайнего левого или крайнего правого столбца).
Что если динамически корректировать эти границы по мере обхода matrix, чтобы избежать повторного посещения ячеек?
Корректировка границ Инициализируем четыре границы (top, bottom, left, right) их начальными значениями:
- top = 0
- bottom = m - 1
- left = 0
- right = n - 1
Начинаем обход, двигаясь вправо по первой строке от левой границы до правой. Поскольку все ячейки первой строки посещены, необходимо заблокировать к ней доступ. Для этого сдвигаем верхнюю границу вниз на 1 (top += 1), тем самым исключая верхнюю строку из дальнейшего обхода:
Затем двигаемся вниз от верхней границы до нижней. Чтобы исключить этот столбец из повторного посещения, обновляем правую границу (right -= 1):
Затем двигаемся влево от правой границы до левой. Чтобы исключить эту строку из повторного посещения, обновляем нижнюю границу (bottom -= 1):
Затем двигаемся вверх от нижней границы до верхней. Чтобы исключить этот столбец из повторного посещения, обновляем левую границу (left += 1):
Мы рассмотрели обход во всех четырёх направлениях с соответствующим обновлением границ. Эти обходы повторяются до тех пор, пока верхняя граница не превысит нижнюю или левая не превысит правую. Любое из этих условий означает, что обходить больше нечего.
Подводя итог, обход matrix по спирали осуществляется путём повторения следующей последовательности:
- Движение слева направо вдоль верхней границы, затем обновление верхней границы (top += 1)
- Движение сверху вниз вдоль правой границы, затем обновление правой границы (right -= 1)
- Движение справа налево вдоль нижней границы, затем обновление нижней границы (bottom -= 1)
- Движение снизу вверх вдоль левой границы, затем обновление левой границы (left += 1)
Это продолжается при условии top <= bottom и left <= right.
Важный момент: после обновления верхней границы она может превысить нижнюю (top > bottom). Поэтому перед обходом по нижней границе необходимо проверить условие top <= bottom. Аналогично, перед обходом по левой границе нужно проверить left <= right, чтобы убедиться, что границы не пересеклись.
По мере движения по matrix каждое встречаемое значение добавляется в выходной массив. Таким образом, значения matrix записываются в спиральном порядке.
Реализация
from typing import List
def spiral_matrix(matrix: List[List[int]]) -> List[int]:
if not matrix:
return []
result = []
# Initialize the matrix boundaries.
top, bottom = 0, len(matrix) - 1
left, right = 0, len(matrix[0]) - 1
# Traverse the matrix in spiral order.
while top <= bottom and left <= right:
# Move from left to right along the top boundary.
for i in range(left, right + 1):
result.append(matrix[top][i])
top += 1
# Move from top to bottom along the right boundary.
for i in range(top, bottom + 1):
result.append(matrix[i][right])
right -= 1
# Check that the bottom boundary hasn't passed the top boundary before
# moving from right to left along the bottom boundary.
if top <= bottom:
for i in range(right, left - 1, -1):
result.append(matrix[bottom][i])
bottom -= 1
# Check that the left boundary hasn't passed the right boundary before
# moving from bottom to top along the left boundary.
if left <= right:
for i in range(bottom, top - 1, -1):
result.append(matrix[i][left])
left += 1
return result
Анализ сложности
Временная сложность: временная сложность spiral_matrix составляет O(m*n), поскольку каждая ячейка matrix обходится ровно один раз.
Пространственная сложность: пространственная сложность равна O(1). Массив res не учитывается при оценке пространственной сложности.
Разворот 32-битного целого числа
Разворот 32-битного целого числа
Разверните цифры знакового 32-битного целого числа. Если развёрнутое число выходит за пределы диапазона [-2^31, 2^31 - 1], верните 0. Предполагается, что среда исполнения позволяет хранить только целые числа в диапазоне знакового 32-битного целого.
Пример 1:
Input: n = 420
Output: 24
Пример 2:
Input: n = -15
Output: -51
Интуиция
Основная сложность этой задачи — обработка граничных случаев. Прежде чем разбираться с ними, рассмотрим базовые случаи и выясним, какие корректировки потребуются в дальнейшем.
Разворот положительных чисел Рассмотрим n = 123. Попробуем строить развёрнутое целое число по одной цифре за раз. Первое, что нужно выяснить, — как перебирать цифры числа n для построения развёрнутого числа (изначально равного 0):
Один из способов — начать с последней цифры числа n и последовательно добавлять каждую цифру к reversed_n:
Разберём, как это реализовать. Для извлечения последней цифры можно использовать оператор остатка от деления: n % 10. Эта операция находит остаток от деления n на 10:
После извлечения последней цифры можно удалить её, разделив n на 10. Это сдвигает предпоследнюю цифру на последнюю позицию, подготавливая её к следующей итерации:
После этого добавим извлечённую последнюю цифру к нашему развёрнутому числу:
Для обработки следующей цифры извлечём её из n с помощью операции остатка, затем удалим её делением n на 10:
Чтобы добавить эту цифру к reversed_n, умножим reversed_n на 10 — это сдвинет его цифры влево, освобождая место для новой цифры. Затем добавим новую цифру как прежде:
Описанный процесс повторяется до тех пор, пока все цифры n не будут добавлены в reversed_n (то есть пока n не станет равным 0). Вот разбивка этого алгоритма:
- Извлечь последнюю цифру: digit = n % 10.
- Удалить последнюю цифру: n = n // 10.
- Добавить цифру: reversed_n = reversed_n * 10 + digit.
Разворот отрицательных чисел Прежде чем рассматривать отдельную стратегию для отрицательных чисел, проверим, работает ли описанный выше алгоритм для них. Применим эти шаги к n = -15:
Как видим, алгоритм работает и для отрицательных чисел. Теперь разберём ситуации, в которых разворот числа может привести к переполнению или антипереполнению целого числа.
Обнаружение переполнения Если разворот положительного числа даёт результат, превышающий 2^31 - 1, произойдёт переполнение и следует вернуть 0. Назовём это максимальное значение INT_MAX.
На первый взгляд может показаться достаточным полностью развернуть число, проверить, не превышает ли оно 2^31 - 1, и вернуть 0 если превышает. Однако в среде, где нельзя хранить числа больше 2^31 - 1, попытка развернуть такое целое число уже вызовет переполнение:
Нужен другой способ обнаружения переполнения.
Мы строим reversed_n по одной цифре за раз, поэтому необходимо убедиться, что каждая новая добавляемая цифра не вызывает переполнения. Рассмотрим, при каких условиях добавление новой цифры может привести к тому, что reversed_n станет слишком большим:
Если reversed_n равно 214748364 (то есть INT_MAX // 10), то последняя добавляемая цифра должна быть не больше 7, иначе произойдёт переполнение (поскольку 2147483647 == INT_MAX):
Важно учесть: когда reversed_n == INT_MAX // 10, к нему можно добавить ещё только одну цифру. Ключевое наблюдение состоит в том, что эта цифра может быть только 1, поскольку большая последняя цифра была бы невозможна:
Это означает, что когда reversed_n == INT_MAX // 10, последняя добавляемая к нему цифра не вызовет переполнения, и проверять её в этом случае не нужно.
Если reversed_n уже больше INT_MAX // 10, добавление любой цифры вызовет переполнение. Этот случай обрабатывается следующим условием:
if reversed_n > INT_MAX // 10: return 0
Обнаружение антипереполнения Аналогичную логику можно применить для обработки антипереполнения. Здесь достаточно проверить, что reversed_n не опускается ниже INT_MIN // 10:
if reversed_n < INT_MIN // 10: return 0
Реализация
В Python оператор остатка (%) при работе с отрицательными числами возвращает положительный результат. Чтобы избежать этого, можно использовать math.fmod и привести результат к целому числу для получения отрицательного остатка.
При делении оператор // в Python выполняет деление с округлением вниз, что может давать нежелательный результат при работе с отрицательными числами (например, -15 // 10 равно -2 вместо желаемого -1). Чтобы получить нужное поведение, используйте / для деления и приводите результат к целому числу.
import math
def reverse_32_bit_integer(n: int) -> int:
INT_MAX = 2**31 - 1
INT_MIN = -2**31
reversed_n = 0
# Keep looping until we've added all digits of 'n' to 'reversed_n' in reverse
# order.
while n != 0:
# digit = n % 10
digit = int(math.fmod(n, 10))
# n = n // 10
n = int(n / 10)
# Check for integer overflow or underflow.
if reversed_n > int(INT_MAX / 10) or reversed_n < int(INT_MIN / 10):
return 0
# Add the current digit to 'reversed_n'.
reversed_n = reversed_n * 10 + digit
return reversed_n
Анализ сложности
Временная сложность: временная сложность reverse_32_bit_integer составляет O(log(n)), поскольку мы перебираем каждую цифру числа n, которых приблизительно log(n) штук. Так как данная среда поддерживает только 32-битные целые числа, временную сложность также можно считать O(1).
Пространственная сложность: пространственная сложность равна O(1).
Максимальное количество коллинеарных точек
Максимальное количество коллинеарных точек
Дано множество точек на двумерной плоскости. Найдите максимальное количество точек, лежащих на одной прямой.
Пример:
Input: points = [[1, 1], [1, 3], [2, 2], [3, 1], [3, 3], [4, 4]]
Output: 4
Ограничения:
- Входные данные не содержат дублирующихся точек.
Интуиция
Две или более точек являются коллинеарными, если они лежат на одной прямой. Иными словами, наклон между любой парой таких точек одинаков:
Вспомним, как вычисляется наклон прямой по двум точкам (xa, ya) и (xb, yb):
slope = rise / run = (yb - ya) / (xb - xa)
По этой формуле можно вычислить наклон для всех пар точек из входных данных и найти наибольшее количество пар с одинаковым значением наклона. Однако этот подход ошибочен: две пары точек с одинаковым значением наклона могут не быть коллинеарными:
Рассмотрим другой подход. Что если попробовать найти максимальное количество точек, коллинеарных с конкретной точкой? Назовём такую точку фокусной.
Можно вычислить наклон между фокусной точкой и каждой другой точкой из входных данных, используя хеш-таблицу для подсчёта количества точек, соответствующих каждому значению наклона. Обратите внимание, что частоты точек в хеш-таблице не включают саму фокусную точку.
Это позволяет найти максимальное количество точек, коллинеарных с данной фокусной точкой:
Здесь наклон с наибольшей частотой встречается 3 раза, что означает: количество точек на прямой, определяемой этим наклоном, равно 3 + 1 = 4, где +1 учитывает саму фокусную точку.
Повторяя этот процесс для каждой точки, можно определить максимальное количество точек, коллинеарных с каждой фокусной точкой. Окончательный ответ — наибольшее из этих максимумов.
Граничный случай: две точки на одной вертикальной прямой Когда две коллинеарные точки имеют одинаковое значение координаты x, знаменатель формулы наклона равен нулю. Это проблематично, поскольку деление на ноль не определено:
Для решения этой проблемы можно проверять, равно ли значение run нулю. Если да, используем бесконечность (float('inf')) для представления значения такого наклона.
Избежание ошибок точности Важный момент — точность представления наклонов при хранении их в виде чисел с плавающей запятой. Рассмотрим следующие наклоны:
Как видим, несмотря на то что дроби представляют разные наклоны, представление их в виде числа с плавающей запятой не обеспечивает достаточной точности для их различения. Это может привести к ошибочному отождествлению различных наклонов.
Чтобы избежать этого, можно представлять наклон в виде пары целых чисел, хранимой как кортеж: (rise, run). Например, наклон с rise равным 1 и run равным 2 можно представить как (1, 2) вместо 1 / 2 = 0.5.
Обеспечение согласованного представления наклонов Осталась ещё одна задача: обеспечить согласованность представления наклона для всех эквивалентных дробей:
Если сводить дроби к несократимому виду, можно единообразно представлять равные дроби, имеющие разные исходные записи.
Как это сделать? Можно сократить дроби, разделив числитель и знаменатель на их наибольший общий делитель (GCD).
Наибольший общий делитель GCD двух чисел — это наибольшее число, на которое оба числа делятся без остатка. Разделив rise и run на их GCD, мы приводим наклон к несократимому виду:
Это гарантирует единообразное представление всех равных дробей.
Реализация
from typing import List, Tuple
from collections import defaultdict
def maximum_collinear_points(points: List[List[int]]) -> int:
res = 0
# Treat each point as a focal point, and determine the maximum number of points
# that are collinear with each focal point. The largest of these maximums is the
# answer.
for i in range(len(points)):
res = max(res, max_points_from_focal_point(i, points))
return res
def max_points_from_focal_point(focal_point_index: int, points: List[List[int]]) -> int:
slopes_map = defaultdict(int)
max_points = 0
# For the current focal point, calculate the slope between it and every other
# point. This allows us to group points that share the same slope.
for j in range(len(points)):
if j != focal_point_index:
curr_slope = get_slope(points[focal_point_index], points[j])
slopes_map[curr_slope] += 1
# Update the maximum count of collinear points for the current focal
# point.
max_points = max(max_points, slopes_map[curr_slope])
# Add 1 to max_points to account for the focal point itself.
return max_points + 1
def get_slope(point_a: List[int], point_b: List[int]) -> Tuple[int, int]:
rise = point_b[1] - point_a[1]
run = point_b[0] - point_a[0]
# Handle vertical lines.
if run == 0:
return (1, 0)
# Reduce the slope fraction to its simplest form.
common_divisor = gcd(abs(rise), abs(run))
# Normalize the sign of the slope.
sign = -1 if (rise < 0) != (run < 0) else 1
return (sign * abs(rise) // common_divisor, abs(run) // common_divisor)
def gcd(a: int, b: int) -> int:
while b != 0:
a, b = b, a % b
return a
Хотя некоторые языки программирования, в том числе Python, имеют встроенную реализацию функции GCD, ниже приведена её реализация для справки. Этот алгоритм широко известен как алгоритм Евклида:
# The Euclidean algorithm.
def gcd(a, b):
while b != 0:
a, b = b, a % b
return a
Анализ сложности
Временная сложность: временная сложность maximum_collinear_points составляет O(n^2 * log(m)), где n — количество точек, m — наибольшее значение среди координат.
- Временная сложность функции gcd(rise, run) составляет O(log(min(rise, run))), что приблизительно равно O(log(m)) в худшем случае.
- Вспомогательная функция max_points_from_focal_point вызывает функцию gcd в общей сложности n-1 раз: по одному разу для каждой точки, кроме фокусной, что даёт временную сложность O(n log(m)).
- Функция max_points_from_focal_point вызывается в общей сложности n раз, что даёт итоговую временную сложность O(n^2 log(m)).
Пространственная сложность: пространственная сложность составляет O(n) из-за хеш-таблицы, которая в худшем случае хранит n-1 пар «ключ — значение»: по одной для каждой точки, кроме фокусной.
Задача Иосифа
Задача Иосифа
В круге стоят n человек, пронумерованных от 0 до n - 1. Начиная с человека 0, считайте k человек по часовой стрелке и удалите k-го из них. После удаления начинайте отсчёт заново со следующего оставшегося в круге человека. Повторяйте этот процесс до тех пор, пока не останется один человек, и верните его номер.
Пример:
Input: n = 5, k = 2
Output: 2
Ограничения:
- В круге будет как минимум один человек
- k будет не менее 1.
Интуиция
Наивный подход к решению этой задачи — пошаговая симуляция удаления людей. Можно создать кольцевой связный список из n узлов. Начиная с узла 0, перебираем список, удаляя каждого k-го человека. Последний оставшийся узел после всех удалений представляет последнего выжившего.
Такой подход имеет временную сложность O(n*k), поскольку для удаления узла необходимо пройти k узлов в связном списке. Следовательно, для каждого из n узлов выполняется k итераций. Посмотрим, можно ли найти более быстрое решение.
Рассмотрим пример с n = 12 и k = 4. При первом удалении начинаем отсчёт k узлов от узла 0 и удаляем человека, на котором остановились после подсчёта.
Как видим, после первого удаления в круге остался на одного человека меньше. Кроме того, после удаления новая стартовая позиция находится на k-й позиции (человек 4).
Теперь фактически нужно найти последнего оставшегося в круге из n - 1 человек, начиная отсчёт с человека k. Это говорит о том, что решение подзадачи josephus(n - 1, k) поможет получить ответ на задачу josephus(n, k). Обратите внимание, что ответ на подзадачу josephus(i, k) — это номер последнего выжившего в круге из i человек при начале отсчёта с человека 0.
Чтобы учесть сдвинутую стартовую позицию, нужно прибавить k к ответу, возвращённому josephus(n - 1, k). Это необходимо, поскольку в данной подзадаче начало отсчёта не с позиции 4, а с позиции 0 по умолчанию. Таким образом, прибавление k компенсирует разницу в начальной позиции.
Это можно выразить следующим рекуррентным соотношением:
josephus(n, k) = josephus(n - 1, k) + k
Последнее соображение — убедиться, что значение josephus(n - 1, k) + k не превышает n - 1, поскольку это представляет позицию последнего человека. Это достигается применением оператора остатка (% n) к выражению josephus(n - 1, k) + k. В результате получается следующее обновлённое рекуррентное соотношение:
josephus(n, k) = (josephus(n - 1, k) + k) % n
Теперь осталось только определить базовый случай.
Базовый случай Простейший вариант задачи — когда в круге остался только один человек: n = 1. В этом случае последний выживший — человек 0, поэтому для базового случая просто возвращаем 0.
Реализация
def josephus(n: int, k: int) -> int:
# Base case: If there's only one person, the last person is person 0.
if n == 1:
return 0
# Calculate the position of the last person remaining in the reduced problem
# with 'n - 1' people. We use modulo 'n' to ensure the answer doesn't exceed
# 'n - 1'.
return (josephus(n - 1, k) + k) % n
Анализ сложности
Временная сложность: временная сложность josephus составляет O(n), поскольку выполняется в общей сложности n рекурсивных вызовов функции до достижения базового случая.
Пространственная сложность: пространственная сложность составляет O(n) из-за стека рекурсивных вызовов, глубина которого достигает n.
Оптимизация
Рекурсивное решение «сверху вниз», описанное выше, можно реализовать итеративным подходом «снизу вверх». Пусть res — массив, хранящий решение каждой подзадачи, где res[i] содержит решение подзадачи для круга из i человек. Используя этот массив, формула принимает вид:
res[i] = (res[i - 1] + k) % i
Ключевое наблюдение: для вычисления результата текущей подзадачи (с индексом i) нам нужен только предыдущий элемент массива res (с индексом i - 1). Это означает, что хранить весь массив не нужно.
Вместо этого можно использовать одну переменную для хранения решения предыдущей подзадачи и обновлять её для текущей подзадачи:
res = (res + k) % i
Обратите внимание, что значение res в правой части уравнения представляет результат предыдущей подзадачи.
Реализация
def josephus_optimized(n: int, k: int) -> int:
res = 0
for i in range(2, n + 1):
# res[i] = (res[i - 1] + k) % i.
res = (res + k) % i
return res
Анализ сложности
Временная сложность: временная сложность josephus_optimized составляет O(n), поскольку выполняется перебор n подзадач.
Пространственная сложность: пространственная сложность равна O(1).
Треугольные числа
Треугольные числа
Рассмотрим треугольник, составленный из чисел, вершина которого равна 1. Каждое последующее число в треугольнике равно сумме трёх чисел над ним: числа сверху слева, числа сверху и числа сверху справа. Если какое-либо из этих трёх чисел отсутствует, считается, что оно равно 0.
Дано число, соответствующее строке треугольника. Верните позицию первого чётного числа в этой строке. Считается, что первое число в каждой строке находится на позиции 1.
Пример:
Input: n = 4
Output: 3
Ограничения:
- n будет не менее 3.
Интуиция
Наивное решение — сгенерировать весь треугольник со всеми значениями до строки n включительно. Затем перебирать элементы строки n до нахождения первого чётного числа. Однако этот подход неэффективен: построение всего треугольника требует избыточных затрат времени и памяти. Для поиска более оптимального решения рассмотрим, как упростить представление нашего треугольника.
Упрощение треугольника Первое ключевое наблюдение: треугольник симметричен. Это означает, что правую половину треугольника можно не рассматривать: если чётное число существует в правой половине, оно обязательно существует и в левой:
Для удобства визуализации позиций чисел в каждой строке нарисуем треугольник так, чтобы числа на одинаковых позициях были выровнены:
Следующее ключевое наблюдение: нас не интересуют сами значения — нас интересует только чётность каждого числа (то есть является ли оно чётным или нечётным). Исходя из этого, можно ещё больше упростить треугольник, представив его как бинарный, где 0 — чётное число, а 1 — нечётное:
Теперь, когда треугольник упрощён, проще выявить паттерны в позициях первого чётного числа в каждой строке.
Выявление паттернов Пропустим строки 1 и 2, поскольку чётные числа начинают появляться только с 3-й строки. Хорошим отправным пунктом для поиска паттерна является выделение первого чётного числа в каждой строке и наблюдение за их позициями:
Анализируя строки с 3 по 5, можно заметить, что в нечётных строках первое чётное число находится на позиции 2. Можно также предположить, что в чётных строках первое чётное число находится на позиции 3.
Чтобы проверить это наблюдение, рассмотрим ещё несколько строк:
Наша гипотеза для нечётных строк пока подтверждается, однако чётные строки, похоже, следуют другому паттерну. Пока трудно его определить.
Добавим ещё несколько строк, чтобы разобраться в этом паттерне:
Теперь видно, что первые четыре бинарных значения строк с 3 по 6 повторяются в строках с 7 по 10. Если продолжить, обнаружится, что этот паттерн продолжается.
По существу, начиная со строки 3, последовательно повторяется следующий паттерн:
Чтобы понять, почему этот паттерн повторяется, важно осознать, что первые четыре значения строки вычисляются исключительно на основе четырёх значений предыдущей строки. Это можно наглядно увидеть ниже, используя исходное представление треугольника для большей ясности:
Таким образом, когда в начале строки встречается определённая последовательность из четырёх чисел, она порождает предсказуемую последовательность из четырёх чисел в следующей строке. Распространяя это наблюдение на весь паттерн, можно заключить, что поскольку паттерн уже повторился один раз (со строк 3–6 к строкам 7–10), он будет повторяться бесконечно.
Это даёт следующее циклическое правило:
- Если n нечётное (n % 2 != 0), вернуть 2.
- Если n кратно 4 (n % 4 == 0), вернуть 3.
- В остальных случаях вернуть 4.
Эта задача демонстрирует, как распознавание паттернов и упрощение задачи может превратить затратное по времени решение в быстрое решение за константное время.
Реализация
def triangle_numbers(n: int) -> int:
# If n is an odd-numbered row, the first even number always starts at position
# 2.
if n % 2 != 0:
return 2
# If n is a multiple of 4, the first even number always starts at position 3.
elif n % 4 == 0:
return 3
# For all other rows, the first even number always starts at position 4.
return 4
Анализ сложности
Временная сложность: временная сложность triangle_numbers составляет O(1).
Пространственная сложность: пространственная сложность равна O(1).