Основы динамического программирования — часть 1

Есть два метода решения проблемы с помощью динамического программирования. В этом посте мы увидим метод мемоизации.

  1. Мемоизация
  2. Табулирование

Что такое динамическое программирование?

Это метод оптимизации по сравнению с простой рекурсией. Это значительно сокращает время, необходимое для решения проблемы для данного ввода. Он разбивает проблему на вложенные подзадачи и вычисляет результат для этих подзадач. Эти результаты подзадач повторно используются, чтобы избежать повторного вычисления. Это снижает временную сложность задачи с экспоненциальной до полиномиальной. Такое повторное использование результатов подзадач можно осуществить двумя способами — запоминанием и табулированием.

Что такое мемоизация?

Чтобы подойти к проблеме с помощью метода запоминания, сначала решите данную проблему с помощью рекурсии. Затем примените мемоизацию, сохранив результаты в словаре или структуре данных, которая соответствует требованиям.

Давайте продемонстрируем метод на простом примере (не регулярная генерация последовательности Фибоначчи) в Scala. В Scala недостаточно ресурсов для динамического программирования, поэтому я надеюсь, что это будет полезно.

Как решить ту же задачу методом табуляции, будет рассказано в посте «Основы динамического программирования — часть 2». Пожалуйста, не стесняйтесь проверить это.

Без мемоизации:

Постановка задачи. Следующая функция принимает два аргумента — targetSum и массив элементов Integer и возвращает значение true, если любая комбинация элементов массива может суммироваться до targetSum. Комбинация может быть любым числом из массива, рассмотренным любое количество раз.

Например, targetSum:3 , arr:Array(1,4,7) => возвращает true, поскольку 1+1+1=3

Без объяснения кода мемоизации:

Каждый раз, когда targetSum обновляется, рекурсивно вычитая элементы из массива, и смотрите, не привела ли хотя бы одна комбинация к 0 (это означает, что комбинация может суммироваться до targetSum). Если это так, возвращаемое значение функции истинно.

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

def recursion(targetSum: Int, arr: Array[Int]):Boolean={
     if(targetSum == 0) true
     else if(targetSum < 0) false
     else{
     arr.map(i=>{
     var temp=targetSum-i
     recursion(temp,arr)
     }).exists(j=>j==true)
     } 
     
recursion(7,Array(5,3,4,7))
recursion(300,Array(7,14)) // slows down the runtime

С мемоизацией:

С объяснением кода для запоминания:

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

import scala.collection.mutable.Map
def recursion_memoized(targetSum: Int, arr: Array[Int],memo:Map[Int, Boolean]):Boolean={
if(targetSum == 0) true
else if(targetSum < 0) false
else if(memo.contains(targetSum)) memo(targetSum)
else{
 arr.map(i=>{
 var temp=targetSum-i
 memo(targetSum)=recursion_memoized(temp,arr,memo)
 memo(targetSum)
}).exists(j=>j==true)
}
}
recursion_memoized(7,Array(5,3,4,7),Map())

Вывод:

Используя мемоизацию, мы сокращаем время выполнения, повторно используя значения из словаря вместо повторного вычисления значений.