0Pricing
Coding Interview Prep · Урок

Грабитель домов: рекуррентное решение «взять или пропустить»

Моделируйте решение «ограбить или пропустить» как рекуррентное соотношение 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 — локальная установка не требуется.

Все уроки этого курса

  1. Грабитель домов: рекуррентное решение «взять или пропустить»
  2. Максимальный подмассив и подмассив с максимальным произведением
  3. Разбиение слов и сегментация строки
  4. Декодирование способов и подсчёт путей
← Назад к Coding Interview Prep