Говорят, все боятся задач на динамическое программирование (aka ДП), потому что их решения выглядят как из задачника по матанализу. А мне оно всегда нравилось. Одна изящная формула вроде
— и задача решена.
В этой статье разберем три задачи по динамическому программированию с LeetCode и попробуем каждый раз прийти к изящной формуле интуитивно. Также обсудим, по каким признакам можно понять, что задача — на динамическое программирование.
Задача 62. Unique Paths (Medium)
Робот двигается по доске размером m x n из верхнего левого угла (0, 0) в правый нижний (m − 1, n − 1). Допускаются шаги либо вниз, либо вправо. Найти количество всех возможных уникальных маршрутов, по которым он может достичь цели.
С точки зрения математика ответ можно дать за полминуты. Обратите внимание, что любой путь — это просто последовательность шагов вниз (обозначим их D) и вправо (R). Количество этих шагов соответствует размерам доски: вниз придется сделать m-1 шагов, а вправо – n-1 шагов, а в сумме – m+n-2 шага. А вот последовательность этих шагов может меняться:
DDRRR DRDRR RRDDD …
Фактически выбрать путь – значит выбрать, в какие из m+n-2 позиций поставить символы D, заняв остальные символами R (или наоборот). Сколькими способами можно выбрать m - 1 позиций для символа D из m + n - 2 доступных? Как известно из комбинаторики, выбор k элементов из n без учета порядка вычисляется по формуле
В нашем случае количество путей равно
Но у нас собеседование не по математике, а по алгоритмам, поэтому давайте писать алгоритм.
Если доска состоит из одной строки, то есть m=1, то в ней возможен всего один путь. То же самое верно для n=1.

Перейдем к более общей задаче. Сколькими способами можно попасть в клетку (i, j)? По условию, в нее можно прийти либо сверху из (i-1, j), либо слева из (i, j-1). И эти пути гарантированно различаются как минимум последним шагом (сверху/слева).

Если пути гарантированно различаются, то применимо правило сложения: если множество A содержит n вариантов, множество B содержит m вариантов и A ∩ B = ∅, то общее число вариантов равно n + m. Для нашего случая из него следует, что число путей, которыми можно прийти в точку (i, j), равно сумме числа путей, которыми можно прийти в точку (i-1, j), и числа путей, которыми можно прийти в точку (i, j-1).
Чтобы воспользоваться этим соотношением на практике, придется хранить количество путей для уже рассмотренных клеток. Введем матрицу dp размером m x n, в которой элемент dp[i][j] обозначает количество различных путей из стартовой клетки (0, 0) в клетку (i, j) на доске.
Запишем в эту матрицу то, что мы уже обсудили. В любую клетку первого столбца и первой строки можно попасть ровно одним способом, поэтому сразу заполняем
Как вычислить значение dp[i][j], мы уже вывели:
Теперь матрица dp может быть заполнена последовательно — строка за строкой или столбец за столбцом — так как для вычисления каждой ячейки используются только уже известные значения.
Таким образом, исходная задача сводится к заполнению матрицы динамического программирования, а искомое количество уникальных путей равно значению в правом нижнем углу таблицы:
Формула готова, а написать по ней код очень просто:
class Solution: def uniquePaths(self, m: int, n: int) -> int: dp = [[1] * n for _ in range(m)] for i in range(1, m): for j in range(1, n): dp[i][j] = dp[i - 1][j] + dp[i][j - 1] return dp[m - 1][n - 1]
Runtime 0ms
Beats 100.00%
Алгоритм проходит по всем клеткам доски ровно один раз, поэтому его временная сложность равна O(m · n).
Динамическое программирование — один из самых изящных способов решать олимпиадные задачи. Но его можно применять только при двух условиях:
1. Оптимальная подструктура. Оптимальное решение задачи содержит в себе оптимальные решения подзадач. Например, в данном случае число путей в клетку (i, j) определялось из суммы числа путей в две соседние клетки (i-1, j) и (i, j-1).
2. Перекрывающиеся подзадачи. Одни и те же подзадачи возникают многократно. В данной задаче одна и та же клетка (i, j) может быть достигнута множеством различных способов. При переборе всех путей количество путей до этой клетки пришлось бы вычислять снова и снова.
Задача 64. Minimum Path Sum (Medium)
Нам дана прямоугольная таблица m × n, заполненная неотрицательными числами.
Мы стартуем в левом верхнем углу (0, 0) и за один шаг можем двигаться только вправо или вниз. Необходимо дойти до правого нижнего угла (m − 1, n − 1) так, чтобы сумма всех чисел на пути (назовем ее стоимостью) была минимальной.
Первая мысль: на каждом шаге выбирать из двух возможных вариантов (вправо/вниз) клетку с меньшим значением, то есть локальный минимум (жадный алгоритм).
Например, в этом массиве первым оптимальным шагом кажется движение вправо, потому что в клетке (0, 1) стоит число меньше, чем в клетке (1, 0). Но на следующем шаге мы попадем в ловушку, потому что и вправо, и вниз стоят клетки с числом 1000.

Таким образом, выбирая шаг, необходимо учитывать стоимость всего пути, а не только следующего шага.
Обратите внимание, что, поскольку движение возможно только вправо и вниз, мы никогда не возвращаемся к уже пройденным клеткам. Поэтому стоимость пути до каждой клетки является окончательной и не может быть улучшена позже. Следовательно, на каждом шаге построения оптимального маршрута он должен состоять из оптимальных фрагментов (оптимальная подструктура).
При этом одни и те же подзадачи возникают многократно. Минимальная стоимость пути до каждой клетки требуется при вычислении оптимальных маршрутов ко всем клеткам, расположенным правее и ниже нее. Без сохранения результатов такие значения пришлось бы пересчитывать снова и снова при рассмотрении различных путей, что приводит к экспоненциальному перебору. Таким образом, мы обнаружили наличие перекрывающихся подзадач — второй признак, что пора применять динамическое программирование.
Пусть dp[i][j] — минимальная стоимость пути до клетки (i, j), включая текущую клетку.
По условию задачи все пути начинаются в левом верхнем углу (0, 0). Стоимость пути в эту клетку равна значению этой клетки:
Так как мы движемся только вправо или вниз, в каждую клетку первой строки можно прийти только слева. Значит, путь по всей первой строке только один, он же и кратчайший.
По той же причине в каждую клетку первого столбца можно прийти только сверху, и для j=0 выполняется
Теперь посмотрим на остальные клетки. Попасть в клетку (i, j) можно либо из клетки сверху (i − 1, j), либо из клетки слева (i, j − 1). К этому моменту минимальные стоимости путей в обе эти клетки уже вычислены. Значит, остается выбрать более дешевый из этих двух маршрутов и добавить значение текущей клетки:
Таким образом, задача сводится к последовательному заполнению таблицы dp слева направо и сверху вниз. Каждую клетку мы проходим ровно один раз, а результат для нее используется при обработке всех клеток, расположенных правее и ниже.
После заполнения всей таблицы значение в правом нижнем углу dp[m − 1][n − 1] и будет минимальной возможной стоимостью пути от стартовой клетки (0, 0) до финиша.
class Solution: def minPathSum(self, grid: List[List[int]]) -> int: m, n = len(grid), len(grid[0]) dp = [[0] * n for _ in range(m)] dp[0][0] = grid[0][0] for j in range(1, n): dp[0][j] = grid[0][j] + dp[0][j - 1] for i in range(1, m): dp[i][0] = grid[i][0] + dp[i - 1][0] for i in range(1, m): for j in range(1, n): dp[i][j] = grid[i][j] + min( dp[i - 1][j], dp[i][j - 1] ) return dp[-1][-1]
Сложность решения по времени снова равна O(m · n), так как мы прошли по всем m строкам и n столбцам ровно один раз.
Задача 70. Climbing Stairs (Easy)
Вот видите, задачка на динамическое программирование, а уровень —Easy. Даже сам LeetCode не считает ДП чем-то сложным.
Человек поднимается по лестнице с n ступенями (1 <= n <= 45). На каждом шаге он преодолевает либо 1, либо 2 ступени. Сколько различных способов у него есть, чтобы добраться до вершины?

Начнем с n=1. Подняться на 1 ступеньку можно только одним способом, преодолев ровно 1 ступеньку.
Для n=2 существует два способа: сделать два шага по 1 ступеньке (1+1) или одним махом подняться на 2.
На 3-ю ступеньку можно подняться тремя способами: 1+1+1, 1+2, 2+1.
На 4-ю – даже пятью:
1 + 1 + 1 + 1
1 + 2 + 1
2 + 1 + 1
1 + 1 + 2
2 + 2
Давайте сгруппируем слагаемые:
(1 + 1 + 1) + 1
(1 + 2) + 1
(2 + 1) + 1
(1 + 1) + 2
(2) + 2
Ничего не напоминает?

На n-ю ступеньку можно подняться либо с n-1, либо с n-2. Соответственно, число способов, которыми можно подняться на n-ю ступеньку, равно сумме способов, которыми можно подняться на две предыдущие. Фактически мы пришли к числам Фибоначчи: каждое следующее число равно сумме двух предыдущих. Только мы начинаем не с 0, а с 1.
Если мы реализуем классическую рекурсию для вычисления чисел Фибоначчи:
class Solution: def climbStairs(self, n: int) -> int: if n <= 2: return n return self.climbStairs(n - 1) + self.climbStairs(n - 2)
— то уже при n=44 упремся в Time Limit Exceeded. Хотя рекурсия глубиной 44 уровня легко помещается в стек вызовов, алгоритм повторно вычисляет одни и те же значения:
climbStairs(44) ├─ climbStairs(43) │ ├─ climbStairs(42) │ │ ├─ climbStairs(41) │ │ │ ├─ climbStairs(40) │ │ │ └─ climbStairs(39) │ │ └─ climbStairs(40) │ └─ climbStairs(41) │ ├─ climbStairs(40) │ └─ climbStairs(39) └─ climbStairs(42) ├─ climbStairs(41) │ ├─ climbStairs(40) │ └─ climbStairs(39) └─ climbStairs(40)
Это приводит к катастрофическому росту числа операций. Точнее, экспоненциальному: количество вызовов растет как O(φn), где φ ≈ 1.618 — золотое сечение). Для n=44 получается более миллиарда вызовов функции, что превышает типичный лимит времени на платформах типа LeetCode (1-2 секунды).
В проде мы могли бы просто закэшировать уже посчитанные значения, добавив строчку
@lru_cache(None)
перед сигнатурой метода climbStairs().
Но interview-friendly решение не ищет легких путей, поэтому давайте сохраним промежуточные решения вручную.
Обратите внимание, что эта задача обладает всеми признаками задач на динамическое программирование:
1. Оптимальная подструктура. Количество способов подняться на n-ю ступеньку полностью определяется решениями для меньших задач: для (n − 1)-й ступеньки и для (n − 2)-й.
2. Перекрывающиеся подзадачи. Одни и те же подзадачи вычисляются многократно в разных ветках рекурсии.
Наличие обоих признаков означает, что задачу можно решать методом динамического программирования, сохраняя результаты подзадач для повторного использования. Промежуточные решения будем хранить в массиве dp, где dp[i] — количество способов подняться на i-ю ступеньку.
Как уже говорилось, на первую ступеньку можно подняться одним способом, а на вторую – двумя:
В общем случае число способов, которыми можно подняться на i-ю ступеньку, равно сумме способов, которыми можно подняться на две предыдущие:
Заполнив этот массив на длину n, мы получим ответ:
class Solution: def climbStairs(self, n: int) -> int: if n <= 2: return n dp = [0] * (n + 1) dp[1] = 1 dp[2] = 2 for i in range(3, n + 1): dp[i] = dp[i - 1] + dp[i - 2] return dp[n]
В отличие от предыдущих задач, этот алгоритм имеет линейную временную сложность O(n), так как каждая подзадача решается единственный раз и повторных вычислений не происходит.
Runtime 0ms
Beats 100.00%
Вывод
Как видите, задачи на динамическое программирование не так уж сложны. Главное при их решении:
определить, есть ли признаки ДП — оптимальная подструктура и перекрывающиеся подзадачи
и вывести формулу.
А написание кода по готовой формуле так просто, что с ним справится даже тот, кто только вчера начал учить Python.
Кстати, если вы хотите увидеть решения этих задач на других языках программирования или если вас интересуют решения других типичных задач LeetCode — добро пожаловать на мой курс «Алгоритмы для собеседований на Python/Kotlin/C++/C#/PHP».
Комментарии (8)

wataru
31.03.2026 10:02Но его можно применять только при двух условиях:
...
Перекрывающиеся подзадачи.Вовсе нет. Эта мысль много где бездумно повторяется, но это вообще не обязательно. ДП все еще будет отлично работать. Например, найти глубину дерева, или подсчитать "хеш" дерева для проверки изоморфизма.
Да, в этом случае граф вызовов будет деревом и мемоизация по сути не нужна, но мемоизация - это лишь деталь реализации, оптимизация. Суть из рекуррентного соотношения остается.
Другие мои 5 копеек:
Динамическое программирование очень полезно формализовывать. Так понятнее, что решение работает, его так легче придумывать.
Вам надо определить:
1) Множество состояний и рекуррентное соотношение. Или же множество задач и как задача решается через подзадачи.
2) База - какие-то состояния имеют фиксированный ответ
3) Ответ. Где ответ на изначальную задачу. Не всегда задача как она есть является одной из подзадач. Иногда в решении вы вводите какие-то новые параметры, которых в условии изначальной задачи нет.

TimurZhoraev
31.03.2026 10:02Учёт предыдущих состояний даже с переключаемыми весами это фактически некий аналог БИХ-фильтра и там скорее задача уже на устойчивость пути/решения.

heartdevil
31.03.2026 10:02Я думаю, вы сильно опустили то, что опускать как раз и не нужно. Это как научиться рисовать сову. Надо же только два овала нарисовать, а дальше все просто. Динамическое программирование -- на мой взгляд, одна из самых трудных тем, если не самая трудная. Вся проблема как раз и кроется в том, а как же определять подзадачи. Как просчитать все эти кейсы. Ну и как это все потом реализовать. Это на ваших примерах все просто и гладко получается. А чуть шаг в сторону сделать и уже проблемы с нехваткой состояний или криво подсчитанными состояниями. А часто вообще даже в упор не видно как это все применить, даже если тебе в лоб предъявят, что вот это динамическим программированием решается.

denorlov
31.03.2026 10:02Расскажите, приходилось ли кому-то решать какую-то практическую задачу с применением ДП? Не leetcode, не поступление в школу, Уни, ШАД, на работу... Именно использовать в реальном рабочем процессе.

wataru
31.03.2026 10:02Мне приходилось, аж много раз. При реализации пакетизации h264 битстрима: по спецификации там есть режим, что энкодер выдает несколько кусков, и их нельзя резать дальше при распихивании этого по пакетам. Странная идея, но так придумали кучу лет назад, а нам это реализовывать. Можно несколько кусков целиком засунуть в очередной пакет. Переставлять куски, очевидно, тоже нельзя. Надо чтобы эти группы не превышали лимит размера пакета, было как можно меньше пактов, а максимальный пакет был как можно меньше. Без последнего ограничения задача решается тупой жадностью: заполняем пакет, пока туда влезает. Потом начинаем следующий, и так пока куски не кончатся. Но так получаются очень разные пакеты и это плохо для выдерживания пропускной способности сети. Хотелось бы решить задачу с ним. С этим дополнительным условием задача решается через ДП.
Потом, писал для себя утилиту. Было это в старые времена, когда интернет был не безлимитный и за сериальчиками все ходили в гости с жесткими дисками, да качали из локальных сеток. Но место на жестких дисках ограниченно, поэтому сериалы резали на CD а потом и DVD. Десятки, сотни дисков. И опять задача - хотелось бы как-то более эффективно диски использовать. При этом есть всякие естественные ограничения: вроде файлы серий надо записывать подряд, сериал может быть по нескольким дискам, но подряд, чтобы эти диски в стопке рядом лежали. Чтобы когда решишь посмотреть, взять несколько дисков и подряд их вставлять в привод. В итоге навороченная задача о рюкзаке получилась. Моя утилита диски довольно хорошо упаковывала, гораздо лучше чем я руками это делал.
Потом, это применяется в индустриальной оптимизации. Мою программу-курсовую никуда не пристроили, конечно, но знаю, что в моем университете разработали программы и они крутится на производстве фанеры, досок и чего-то еще. По заказам размеров выдает как и что раскраивать, чтобы было как можно меньше отходов. И там помимо методов решения задач линейного программирования в частности вылезает именно динамическое программирование как подзадача.
Ну и почти любой поиск пути в графе, который весьма часто встречается и реализуется алгоритмом Дейкстры - по сути является ДП.

lightln2
31.03.2026 10:02Многие, кто работают в гугле/яндексе пишут дп регулярно. Но конкретно
использовать в реальном рабочем процессе
Вы это делаете каждый день, когда выполняете git diff - он основан на алгоритме Майерса, варианте Longest Common Subsequence Problem, являющегося классикой DP!
lightln2
Я не боюсь, но опасаюсь задач на DP, потому что сложность в деталях:
Как конкретно определять подзадачу (например, на строках - dp[i] - это задача на символе i или на префиксе длины i? Будем задавать базовые условия для всех подстрок в один символ или для только пустой строки?)
Какой брать переход из возможных опций (dp[i] - количество нужных подстрок, заканчивающихся на символе i или принадлежащих префиксу длины i)?
А если еще и надо восстановить оптимальное рещение, то это еще сильнее усложняет реализацию (будем хранить граф переходов? или потом восстановим из таблицы стоимостей?)
Чаще всего все варианты будут работать, но нужна интуиция, чтобы понять какой приведет к самому короткому и понятному коду без ошибок. Если кто-то знает какие-то эвристики, расскажите!
А мое любимое объяснение DP - лекция из цикла MIT “введение в алгоритмы” авторства Эрика Демайна - очень известный в мире computer scienсе чувак, автор структуры Tango Trees и доказавший, что тетрис NP-полон. Он подробно рассказывает именно о том, какие характеристики задачи делают ее dp, и как от них перейти к формуле.