
Динамическому программированию посвящено много статей и разборов в математическом программировании, потому что объяснить его непосвященным – крайне непросто. Основная проблема объяснения подхода динамического программирования – не в самое его сложности, а в том, что непонятно, зачем методы динамического программирования нужны. А непонятно – потому что все привыкли к линейному программированию, не видят разницы между ДП/рекурсивным алгоритмом/«разделяй и властвуй», слабо представляют себе вычисление сложности и основы математической оптимизации. Ниже мы постараемся дать простые и понятные объяснения всем этим терминам, покажем разницу сложности у разных математических методов и на примерах разберем динамическое планирование/программирование.
В истории динамического программирования нет ничего интересного – его создали математики, чтобы решить проблему сложности рекурсивных алгоритмов (обо всем этом – ниже). Единственный момент – поскольку динамическое программирование придумали математики, оно в большей мере является математическим методом, и пусть слово «программирование» не вводит вас в заблуждение.
Официальная формулировка: теория динамического программирования предполагает решение сложной задачи разбитием ее на более простые задачи. Эта формулировка объясняет все и ничего одновременно – если вы знакомы с олимпиадным программированием, вы можете задать логичный вопрос: а чем динамическое программирование в таком случае отличается от рекурсии, которая тоже разбивает задачу на подзадачи?
Чтобы отметить на этот вопрос, нам сначала нужно разобрать, что в программировании означают нотации сложности. Сложность – это сколько ресурсов уйдет на решение задачи выбранным алгоритмом. Вообще, сложность можно высчитывать по количеству операций и по выделяемому месту, но обычно считают именно количество операций. Сложность записывается как O(n). Под n обычно подразумевается количество входных данных. Например, если сложность алгоритма сортировки – O(n), то массив из 100 элементов будет отсортирован за 100 действий. Если же сложность сортировки – O(n^2), то массив из 100 элементов будет отсортирован за 10 000 действий. Особая нотация O(1) означает, что требуется ровно одно действие.
Еще одна особенность сложности – считаются только степени сложности. Сложность может считаться как n, как степень n, как логарифм по основанию 2 от n, как факториал n и в других больших множителях/делителях. Например, нужно отсортировать массив на 1 000 переменных:
Как видите, разница здесь – в степенях, то есть очень существенная. Поэтому если ваш алгоритм выполняет свою работу за 2*n действий, то его сложность – O(n), с точки зрения степеней 2 000 действий и 1 000 действий – практически одно и то же.
Итак, алгоритмы имеют разную сложность. Хороший показатель – линейная сложность (O(n)), желательно по возможности доводить сложность до логарифма по основанию 2, O(n^2) – плохо, O(n^3) – ужасно медленно, O(n!) – ждите результат лет через 200. Если мы начнем рассматривать в этом контексте алгоритмы, разбивающие задачу на подзадачи, то самым эффективным будет «разделяй и властвуй» – алгоритм, который делит некоторое количество данных на 2 части, затем каждую часть снова делит на 2 части – и так, пока не дойдет до атомарного (неделимого) куска данных. В процессе разделения алгоритм делает еще что-то – сортирует, отбрасывает ненужное и так далее. В среднем «разделяй и властвуй» работает за логарифм по основанию 2, но имеет очень узкую сферу применения – нужно, чтобы каждая подзадача (половина, оставшаяся после разделения) решалась ровно так же, как и изначальная задача.
Если в процессе разбиения задачи на подзадачи условия могут меняться, то используются методы рекурсии. Рекурсия – это когда мы пишем функцию, разбивающую задачу на подзадачи, задаем точку остановки и затем вызываем функцию из самой себя, пока вызовы не дойдут до точки остановки. В теле рекурсии можно прописать различные действия, которые будут производиться при том или ином условии подзадачи, что дает гибкость. В итоге у нас получится дерево рекурсии, для чисел Фибоначчи (об этом будет ниже) оно выглядит так:

Числа Фибоначчи – это числовой ряд, и для того, чтобы вычислить 6-е число, мы вызываем функцию вычисления 4 и 5 числа, чтобы вычислить 4-е число, мы вызываем функцию вычисления 2 и 3 числа, и так далее – пока не упремся в 0 или 1, здесь мы уже ничего не вызываем, а просто возвращаем ноль или единицу. Таким образом, подзадача нахождения числа разбивается на нахождение двух чисел младше, и это продолжается, пока не будет достигнута точка остановки для каждой ветки дерева рекурсии.
Но на картинке выше вы можете заметить одну проблему: рекурсивный метод жрет очень много ресурсов, потому что f0 и f1 вычисляются очень много раз (как и f2, f3 и f4 – только f5 выполняется единожды). Сложность рекурсивного метода вычисления числа Фибоначчи – 2 в степени n, то есть для сотого числа понадобится 1 267 650 600 228 229 401 496 703 205 376 вызовов функции. Так себе, честно говоря.
Решить проблему, описанную выше, может динамическое программирование. Вкратце суть его можно описать так: давайте заведем дополнительные структуры данных, которые будут хранить уже вычисленные ответы, и этим мы решим задачу оптимизации алгоритма по скорости путем жертвования местом в стэке процесса. Подход, при котором данные запоминаются для дальнейшего переиспользования, называется «мемоизацией» – запоминанием.
Если мы будем решать задачу на числа Фибоначчи по принципу динамического программирования, код на Python будет выглядеть так:
def fibonacci(n): f = [0, 1] for i in range(2, n+1): f.append(f[i-1] + f[i-2]) return f[n]Мы заводим массив, в котором хранятся уже вычисленные числа. Сначала в нем лежат 0 и 1 – эти значения числового ряда известны изначально. Потом мы просто запускаем цикл от 2 до n включительно, который в f[2] кладет f[1] + f[0], в f[3] кладет f[2] + f[1] и так далее. Когда цикл закончился – просто возвращаем число под номером n из списка. Алгоритм можно оптимизировать еще лучше:
for i in range(2,n+1): c = a + b a = b b = cВместо того, чтобы хранить числа в списке, мы просто запоминаем 2 последних вычисленных, так как они нужны нам для вычисления следующего, а все остальные отбрасываем. Это не единственное оптимальное планирование, по принципу динамического программирования можно придумать еще 3-4 решения, но суть у них у всех одинаковая – сложность составляет O(n), что означает оптимальную сложность = оптимальное решение.
Динамическое программирование чаще всего используется в задачах на графы (поиск оптимального пути в связных графах, в которых мемоизация предотвращает образование колец), в задачах на числовые ряды (как в случае с Фибоначчи) и задачах на поиск в многомерном массиве (мемоизация позволяет вычислять только те значения, которые необходимы для дальнейшего решения). В целом можете запомнить следующее правило: если есть рекурсия, которая делает повторяющиеся запросы с одинаковыми аргументами, вы можете заменить ее на динамическое программирование.
Здесь мы разберем решение 2 задач с помощью динамического программирования. Каждый подраздел будет разбит на 4 части: формулировка задачи; поиск оптимальной последовательности (для применения динамического или рекурсивного подхода нам нужно найти последовательность, которая разбивает задачу на ряд просты подзадач); код решения на Python, разбор решения.
Задача: уродливыми числами являются те, которые делятся на 2, 3 или 5. 1 тоже включена в ряд уродливых чисел. Нужно найти n-ное уродливое число.
Последовательность: поскольку уродливые числа делятся на 2, 3 или 5, нам не обязательно перебирать по порядку все числа – можем сразу умножать число на 2/3/5 и добавлять его в список уродливых.
Код:
def getNthUglyNo(n): ugly = [0] * n ugly[0] = 1 i2 = i3 = i5 = 0 next_multiple_of_2 = 2 next_multiple_of_3 = 3 next_multiple_of_5 = 5 for l in range(1, n): ugly[l] = min(next_multiple_of_2, next_multiple_of_3, next_multiple_of_5) if ugly[l] == next_multiple_of_2: i2 += 1 next_multiple_of_2 = ugly[i2] * 2 if ugly[l] == next_multiple_of_3: i3 += 1 next_multiple_of_3 = ugly[i3] * 3 if ugly[l] == next_multiple_of_5: i5 += 1 next_multiple_of_5 = ugly[i5] * 5 return ugly[-1]Разбор: сначала мы заводим список уродливых чисел и кладем туда 1, заводим 3 переменные для индексов (пока они равны 0), заводим 3 переменные для хранения результата умножения на 2/3/5. Далее запускаем цикл от 1 до n (последовательное добавление уродливых чисел), в цикле сначала ищем минимальное число, делящееся на 2/3/5 (для начала цикла это 2), заносим это число в список. Далее делаем проверку того, какое число оказалось минимальным: если в список попало next_multiple_of_2, то увеличиваем индекс i2 на 1, записываем в next_multiple_of_2 умноженное уродливое число на 2; такие же проверки делаем для 3 и 5. Индексы нужны для того, чтобы всегда выбирать следующее минимальное число. В конце возвращаем последнее уродливое число.
Задача: есть золотая шахта – матрица размером m*n. В каждой ячейке матрицы находится 0 или положительное число – количество золота для этой клетки. Шахтер стартует в нулевой колонке, в любом ряду. Двигаться шахтер может только вправо или по диагонали – вправо+вверх, вправо+вниз. Найдите путь, который обеспечит самое большое количество золота.
Последовательность: поскольку шахтер двигается только вправо, мы можем высчитать, сколько золота будет получено на каждом следующем шаге, начиная с конца. Для последнего ряда в любой строке это будет 0; для любой ячейки в предпоследнем ряду это будет большее из трех ячеек справа (справа и выше, справа, справа и ниже) и так далее. Если мы пройдемся таким образом справа налево, то по итогу в ячейках первого ряда у нас будут максимальные суммы золота, если мы будем стартовать с этой ячейки – нужно будет только выбрать самую большую.
Код:
def getMaxGold(gold, m, n): goldTable = [[0 for i in range(n)] for j in range(m)] for col in range(n-1, -1, -1): for row in range(m): if (col == n-1): right = 0 else: right = goldTable[row][col+1] if (row == 0 or col == n-1): right_up = 0 else: right_up = goldTable[row-1][col+1] if (row == m-1 or col == n-1): right_down = 0 else: right_down = goldTable[row+1][col+1] goldTable[row][col] = gold[row][col] + max(right, right_up, right_down) res = goldTable[0][0] for i in range(1, m): res = max(res, goldTable[i][0]) return resРазбор: получаем в функцию двумерный массив и размерность этого массива. Создаем такой же массив, но забитый нулями. Проходимся двумя циклами по всему пришедшему двумерному массиву, от конца до начала. Тремя if вычисляем right, right_up и right_down – золото, которое мы получим, если перейдем на клетку справа. В клетку, в которой мы сейчас находимся, записываем максимум из этих трех right. Все, когда массив заполнен – используем переменную res для того, чтобы найти в первом ряду максимальное значение. Нашли – возвращаем.
Практически никогда – 99.9% алгоритмов, которые могут вам понадобиться, уже реализованы в библиотеках – вам только нужно найти подходящую.
Везде, где можно, стоит использовать динамическое программирование вместо рекурсии, потому что обычно сложность ДП – O(n).
Тезисно: