Модуль: 11.1a Динамическое программирование. Часть 1_ДП и рекурсия


Задача

1 /11


Динамическое программирование. Основные понятия


Динамическое программирование — метод решения задачи путём разбиения её на меньшие подзадачи,
которые имеют такую же структуру.
Для того чтобы задача решалась методом динамического программирования, она должна обладать следующими свойствами:

  • оптимальная подструктура (оптимальное решение задачи может быть составлено из оптимальных решений ее подзадач)
  • пересекающиеся подзадачи (одна и та же подзадача нужна для решения большого количества других подзадач)
  • возможность мемоизации (ограничения на память позволяют запоминать ответы на все решённые подзадачи)

Первое свойство необходимо как для задач на динамическое программирование, так и для задач на рекурсию.
Второе и третье свойства отличают задачи на динамическое программирование от задач на рекурсию и, как правило,
позволяют сократить время работы с экспоненциального до полиномиального.

Рекурсивные методы решения задач, как правило, производят полный перебор всех вариантов.
Динамическое программирование также производит полный перебор всех вариантов,
но делает это оптимизированно за счёт мемоизации (каждая подзадача решается только один раз и её решение сохраняется).


Задача. Нахождение N-го числа Фибоначчи.
Числа Фибоначчи — это ряд чисел, который определён первыми двумя членами F0=0, F1=1 и рекуррентным соотношением 
FN=FN−1+FN−2, N≥2.
Можно выписать несколько первых членов этого ряда:
F0=0
F1=1
F2=F1+F0=1+0=1
F3=F2+F1=1+1=2
F4=F3+F2=2+1=3
F5=F4+F3=3+2=5
F6=F5+F4=5+3=8
F7=F6+F5=8+5=13
F8=F7+F6=13+8=21

Рекурсивный алгоритм нахождения N-го числа Фибоначчи выглядит следующим образом:

def F(n):
    if n < 2:
        return n
    return F(n - 1) + F(n - 2)

Этот рекурсивный алгоритм работает за экспоненциальное время.
Можно показать, что время работы этого алгоритма есть O(aN), где a≈1.6 (наверное \(a=\frac{\sqrt{5}+1}{2}\).)



Можно немного модифицировать этот алгоритм так, чтобы он работал по принципу динамического программирования за время O(N).
Для этого необходимо добавить запоминание тех подзадач, которые уже были решены.
Необходимо завести массив/список, назовем его dp, который изначально заполнен значениями [0,1]+[None]*(N-1).
Если dp[i] равно None, то значит задача для значения i ещё не была решена, и мы будем решать её,
как в рекурсивном случае, в противном случае сразу вернём значение dp[i], которое уже было найдено ранее и
сохранено в нашем массиве/списке. Код имеет следующий вид:

def F(n):
    if dp[n] != None:
        return dp[n]
    dp[n] = F(n - 1) + F(n - 2)
    return dp[n]

Такой подход к реализации алгоритма принято называть нисходящим (Top-Down), потому что решая задачу,
мы переходим к её подзадачам. Есть и восходящий (Bottom-Up)подход, который состоит в решении задач подряд
от меньших к большим с запоминанием результатов. Реализация такого алгоритма для n>0 имеет следующий вид:

n = int(input())
dp = [None] * (n + 1)
dp[0] = 0
dp[1] = 1
for i in range (2, n + 1):
    dp[i] = dp[i - 1] + dp[i - 2]

Для данного примера может показаться, что восходящий способ  проще, чем нисходящий.
Но если задача зависит не от одного параметра, а от двух или трёх, то нисходящий способ, как правило, оказывается проще.



Нетрудно убедиться, что мемоизация значительно повышает производительность,
но она не решает всех проблем рекурсивного подхода (расход памяти, связанный с глубиной рекурсии) 

При использовании мемоизации, для хранения промежуточных значений можно использовать словарь.
Это может значительно упростить организацию программы в случае нескольких параметров.
Решите задание 46616
Решите задание 46617

time 10000 ms
memory 1024 Mb

Комментарий учителя