Грабитель домов: рекуррентное решение «взять или пропустить»
Моделируйте решение «ограбить или пропустить» как рекуррентное соотношение DP, сокращайте память до двух переменных и расширяйте решение на дома, расположенные по кругу
«Грабитель домов: рекуррентное решение «взять или пропустить»» — бесплатный урок Coding Interview Prep на CoddyKit. Это урок 1 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения Coding Interview Prep, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс Coding Interview Prep содержит 4 уроков всего.
Задача о грабителе
В задаче «Грабитель» дан массив неотрицательных целых чисел, обозначающих сумму денег в каждом доме. Требуется найти максимальную сумму, которую можно украсть, не грабя два соседних дома. Например, для [2, 7, 9, 3, 1] результат равен 12: нужно ограбить дома 0, 2 и 4. Это классическая задача на одномерный DP, где на каждом шаге принимается бинарное решение.
nums = [2, 7, 9, 3, 1]
# Can't rob adjacent houses
# Options: rob index 0 and 2 and 4 → 2+9+1=12
# or rob index 1 and 3 → 7+3=10
print('Max profit:', 12) # answer is 12Определение рекуррентного соотношения
Пусть dp[i] — максимальная сумма, украденная из первых i+1 домов. В каждом доме i есть два варианта: пропустить его и взять dp[i-1] или ограбить его и взять nums[i] + dp[i-2]. Рекуррентное соотношение: dp[i] = max(dp[i-1], nums[i] + dp[i-2]). Это фундаментальный шаблон выбрать или пропустить, который встречается во многих задачах на DP.
# Recurrence: dp[i] = max(dp[i-1], nums[i] + dp[i-2])
# Base cases:
# dp[0] = nums[0] (only one house, rob it)
# dp[1] = max(nums[0], nums[1]) (take the richer of the two)
def rob(nums):
n = len(nums)
if n == 1: return nums[0]
dp = [0] * n
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, n):
dp[i] = max(dp[i-1], nums[i] + dp[i-2])
return dp[-1]
print(rob([2, 7, 9, 3, 1])) # 12Пошаговое заполнение таблицы DP
Для [2, 7, 9, 3, 1] проследим за таблицей: dp[0] = 2, dp[1] = max(2, 7) = 7, dp[2] = max(7, 9+2) = 11, dp[3] = max(11, 3+7) = 11, dp[4] = max(11, 1+11) = 12. Итоговый ответ — dp[4] = 12. Ручное прохождение таблицы подтверждает, что рекуррентное соотношение корректно обрабатывает выбор и пропуск на каждой позиции.
nums = [2, 7, 9, 3, 1]
dp = [0] * len(nums)
dp[0] = 2
dp[1] = max(2, 7) # 7
for i in range(2, len(nums)):
skip = dp[i-1]
take = nums[i] + dp[i-2]
dp[i] = max(skip, take)
print(f'dp[{i}] = max({skip}, {nums[i]}+{dp[i-2]}) = {dp[i]}')
print('Answer:', dp[-1])Уменьшение памяти до O(1)
Таблица DP обращается только к двум предыдущим позициям, поэтому весь массив можно заменить двумя переменными: prev2 — значение два шага назад и prev1 — значение один шаг назад. После каждой итерации сдвигайте их: prev2 = prev1 и prev1 = current. Это уменьшает объём памяти с O(n) до O(1), сохраняя временную сложность O(n).
def rob_optimised(nums):
if not nums: return 0
if len(nums) == 1: return nums[0]
prev2 = nums[0]
prev1 = max(nums[0], nums[1])
for i in range(2, len(nums)):
curr = max(prev1, nums[i] + prev2)
prev2 = prev1
prev1 = curr
return prev1
print(rob_optimised([2, 7, 9, 3, 1])) # 12
print(rob_optimised([1, 2, 3, 1])) # 4Обработка крайних случаев
Всегда проверяйте решение на крайних случаях: пустой массив — вернуть 0, массив из одного элемента — вернуть этот элемент, массив из двух элементов — вернуть максимум из двух. На собеседовании упоминание и обработка этих случаев демонстрируют основательность. Проверка if n == 1 предотвращает выход за границы индекса при обращении к nums[1] для dp[1].
def rob(nums):
if not nums: return 0
if len(nums) == 1: return nums[0]
prev2 = nums[0]
prev1 = max(nums[0], nums[1])
for i in range(2, len(nums)):
curr = max(prev1, nums[i] + prev2)
prev2, prev1 = prev1, curr
return prev1
print(rob([])) # 0
print(rob([5])) # 5
print(rob([3, 10])) # 10
print(rob([10, 3])) # 10Грабитель II: дома по кругу
В круговом варианте (LeetCode 213) дома расположены по кругу, поэтому первый и последний дома являются соседними. Нельзя напрямую применить линейное рекуррентное соотношение. Ключевая идея: либо Вы грабите первый дом и не грабите последний, либо не грабите первый и грабите последний. Запустите линейное решение задачи о грабителе для обоих подмассивов и возьмите максимум.
def rob_linear(nums):
prev2, prev1 = 0, 0
for n in nums:
prev2, prev1 = prev1, max(prev1, n + prev2)
return prev1
def rob_circular(nums):
if len(nums) == 1: return nums[0]
# Either include first (exclude last) or include last (exclude first)
return max(rob_linear(nums[:-1]), rob_linear(nums[1:]))
print(rob_circular([2, 3, 2])) # 3
print(rob_circular([1, 2, 3, 1])) # 4Почему жадный подход здесь не работает
Наивный жадный подход может заключаться в том, чтобы всегда грабить самый дорогой доступный дом. Однако он не работает на таких входных данных, как [2, 1, 1, 2]: жадный подход выбирает дом 0 со значением 2, затем дом 3 со значением 2, получая сумму 4, а ограбление домов 0 и 2 даёт 3. Но подождите — в этом случае жадный подход работает! Рассмотрим [1, 3, 1, 3, 100]: жадный подход выбирает дома со значениями 3 и 3, то есть с индексами 1 и 3, получая 6, и упускает оптимальные 1+1+100=102. Необходим DP, поскольку локально оптимальные решения не гарантируют глобальный оптимум.
# Greedy failure example
nums = [1, 3, 1, 3, 100]
# Greedy: pick max each step
# picks 3 (index 1), then 3 (index 3) → total 6
# DP optimal: pick 1 (index 0) + 1 (index 2) + 100 (index 4) → 102
def rob(nums):
prev2, prev1 = 0, 0
for n in nums:
prev2, prev1 = prev1, max(prev1, n + prev2)
return prev1
print(rob(nums)) # 102Распознавание шаблона «выбрать или пропустить»
Шаблон «выбрать или пропустить» применяется не только к задаче о грабителе. Каждый раз, когда Вы проходите по массиву и на каждой позиции выбираете между включением текущего элемента с пропуском предыдущего и исключением текущего элемента с сохранением предыдущего результата, перед Вами DP по шаблону «выбрать или пропустить». Ищите ограничения вроде никаких двух соседних элементов или никаких пересекающихся интервалов — они указывают на применение этого шаблона.
# General take-or-skip template
def take_or_skip(values, gap=1):
'''Max sum where selected elements must be at least gap+1 apart.'''
n = len(values)
if n == 0: return 0
# dp[i] = best up to index i
dp = [0] * (n + gap)
for i in range(n):
take = values[i] + (dp[i - 1] if i >= 1 else 0)
skip = dp[i + gap - 1] if i + gap - 1 < len(dp) else 0
dp[i + gap] = max(skip, take)
return dp[-1]
print(take_or_skip([2, 7, 9, 3, 1])) # house robber-likeВариант задачи «Удаление и получение очков»
Удаление и получение очков (LeetCode 740): за каждое выбранное число начисляется num × count(num), но при этом необходимо удалить все вхождения num-1 и num+1. Эта задача напрямую сводится к задаче о грабителе: постройте массив earn[v] = v × count(v) для всех значений, затем примените к этому массиву решение задачи о грабителе. Умение распознавать такие сведения к знакомым задачам — важный навык на собеседовании.
from collections import Counter
def delete_and_earn(nums):
if not nums: return 0
count = Counter(nums)
max_val = max(nums)
# earn[v] = total points from taking all v's
earn = [v * count[v] for v in range(max_val + 1)]
# Now run house robber on earn
prev2, prev1 = 0, 0
for e in earn:
prev2, prev1 = prev1, max(prev1, e + prev2)
return prev1
print(delete_and_earn([3, 4, 2])) # 6 (take 3+3=no, take 4+2=6)
print(delete_and_earn([2, 2, 3, 3, 3, 4])) # 9 (take all 3s)Грабитель III: двоичное дерево
В задаче «Грабитель III» дома расположены в виде двоичного дерева. Нельзя одновременно ограбить узел и его непосредственного родителя. Определите вспомогательную функцию, возвращающую два значения: rob(node) → (rob_root, skip_root). Если Вы грабите корень, сложите значения пропуска для обоих дочерних узлов. Если Вы пропускаете корень, сложите лучшие результаты для каждого дочернего узла. Это обход DFS в постпорядке с решением выбрать или пропустить на каждом узле.
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def rob_tree(root):
def dfs(node):
if not node: return (0, 0) # (rob, skip)
l_rob, l_skip = dfs(node.left)
r_rob, r_skip = dfs(node.right)
rob = node.val + l_skip + r_skip
skip = max(l_rob, l_skip) + max(r_rob, r_skip)
return (rob, skip)
return max(dfs(root))
# Tree: 3 -> 2,3 -> None,3,None,1
root = TreeNode(3, TreeNode(2, None, TreeNode(3)), TreeNode(3, None, TreeNode(1)))
print(rob_tree(root)) # 7Сложность и обсуждение на собеседовании
Линейное решение задачи о грабителе выполняется за время O(n) и использует память O(1) благодаря оптимизации с двумя переменными. Круговой вариант также выполняется за время O(n), поскольку дважды вызывает линейную версию. Вариант с деревом выполняется за время O(n) и использует память O(h), где h — высота дерева. На собеседовании всегда называйте сложность после написания кода и упоминайте оптимизацию памяти — это показывает, что Вы думаете не только о первом работающем решении.
# Summary of complexities
# Linear House Robber:
# Time: O(n), Space: O(1) with two-variable trick
# Circular House Robber:
# Time: O(n), Space: O(1) (two passes)
# Tree House Robber:
# Time: O(n), Space: O(h) call stack
# Quick benchmark
import time
import random
nums = [random.randint(0, 100) for _ in range(10**6)]
start = time.time()
prev2 = prev1 = 0
for n in nums:
prev2, prev1 = prev1, max(prev1, n + prev2)
print(f'1M elements in {time.time()-start:.3f}s, result={prev1}')Быстрая проверка
Проверьте, насколько Вы понимаете концепции курса «Структуры данных и алгоритмы — подготовка к собеседованию по программированию», рассмотренные в этом уроке.
Итоги урока
В этом уроке Вы узнали: рекуррентное соотношение «взять или пропустить» dp[i] = max(dp[i-1], nums[i] + dp[i-2]), сведение пространственной сложности O(n) к O(1) с помощью двух переменных, значения которых обновляются по ходу вычислений и расширение этого подхода на циклические массивы и двоичные деревья. Далее мы рассмотрим задачи о подмассиве с максимальной суммой и подмассиве с максимальным произведением с использованием алгоритма Кадане.
Часто задаваемые вопросы
Урок «Грабитель домов: рекуррентное решение «взять или пропустить»» бесплатный?
Да — полный текст урока «Грабитель домов: рекуррентное решение «взять или пропустить»» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс Coding Interview Prep, подпишись на CoddyKit PRO. Курс Coding Interview Prep содержит 4 уроков всего.
Чему я научусь в уроке «Грабитель домов: рекуррентное решение «взять или пропустить»»?
Моделируйте решение «ограбить или пропустить» как рекуррентное соотношение DP, сокращайте память до двух переменных и расширяйте решение на дома, расположенные по кругу Ты практикуешь Coding Interview Prep с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.
Нужен ли мне опыт, чтобы начать Coding Interview Prep?
Предыдущий опыт не требуется. Coding Interview Prep на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 1 из 4.
Сколько времени занимает урок «Грабитель домов: рекуррентное решение «взять или пропустить»»?
Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.
Можно ли писать и запускать код в этом уроке Coding Interview Prep?
Да. Каждый урок Coding Interview Prep включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.
Все уроки этого курса
- Грабитель домов: рекуррентное решение «взять или пропустить»
- Максимальный подмассив и подмассив с максимальным произведением
- Разбиение слов и сегментация строки
- Декодирование способов и подсчёт путей