Динамическое программирование и мемуаризация: восходящий и нисходящий подходы


Я не уверен, что я понимаю подход "сверху вниз" с мемоизацией и "снизу-вверх" правильно способ.

снизу вверх: Это то, где вы сначала смотрите на "меньшие" подзадачи, а затем решаете большие подзадачи, используя решение меньшей проблемы.

сверху вниз: Решите проблему естественным образом и проверьте, если вы рассчитали решение подзадачи раньше.

Я немного запутался. Может кто-нибудь объяснить это? И что это такое разница?

7 129

7 ответов:

rev4: очень красноречивый комментарий пользователя Sammaron отметил, что, возможно, этот ответ ранее путал сверху вниз и снизу вверх. Хотя первоначально этот ответ (rev3) и другие ответы говорили, что "снизу вверх-это мемуаризация" ("предположим подзадачи"), он может быть обратным (то есть "сверху вниз" может быть "предположить подзадачи" и "снизу вверх" может быть "составить подзадачи"). Ранее я читал, что memoization - это другой вид динамического программирования, а не a подтип динамического программирования. Я цитировал эту точку зрения, несмотря на то, что не подписывался на нее. Я переписал этот ответ, чтобы быть агностиком терминологии, пока соответствующие ссылки не будут найдены в литературе. Я также преобразовал этот ответ в вики-сообщество. Пожалуйста, отдавайте предпочтение академическим источникам. Список литературы: {Web:1,2} {литература: 5}

резюме

динамическое программирование-это заказ ваши вычисления таким образом, чтобы избежать пересчета повторяющихся работ. У вас есть основная проблема (корень вашего дерева подзадач) и подзадачи (поддеревья). подзадач, как правило, повторяются и пересекаются.

например, рассмотрим ваш любимый пример Fibonnaci. Это полное дерево подзадач, если мы сделали наивный рекурсивный вызов:

TOP of the tree
fib(4)
 fib(3)...................... + fib(2)
  fib(2)......... + fib(1)       fib(1)........... + fib(0)
   fib(1) + fib(0)   fib(1)       fib(1)              fib(0)
    fib(1)   fib(0)
BOTTOM of the tree

(в некоторых других редких задачах это дерево может быть бесконечным в некоторых ветвях, представляющих не-прекращение, и таким образом дно дерева может быть бесконечно большим. Кроме того, в некоторых проблемах вы можете не знать, как выглядит полное дерево раньше времени. Таким образом, вам может понадобиться стратегия/алгоритм, чтобы решить, какие подзадачи выявить.)


Memoization, Tabulation

существует по крайней мере два основных метода динамического программирования, которые не являются взаимоисключающими:

  • мемоизация - это невмешательство подход: вы предполагаете, что вы уже вычислили все подзадачи и что вы понятия не имеете, что такое оптимальный порядок оценки. Как правило, вы выполняете рекурсивный вызов (или некоторый итерационный эквивалент) из корня и либо надеетесь, что вы приблизитесь к оптимальному порядку оценки, либо получите доказательство того, что вы поможете вам достичь оптимального порядка оценки. Вы бы гарантировали, что рекурсивный вызов никогда не пересчитывает подзадачу, потому что вы кэш результаты, и таким образом, дубликаты поддеревьев не пересчитываются.

    • пример: если вы вычисляете последовательность Фибоначчи fib(100), вы просто назовете это, и это вызовет fib(100)=fib(99)+fib(98), и fib(99)=fib(98)+fib(97), ...так далее..., что бы назвать fib(2)=fib(1)+fib(0)=1+0=1. Тогда бы он окончательно разрешился fib(3)=fib(2)+fib(1), но это не нужно пересчитывать fib(2), потому что мы кэшировать его.
    • это начинается в верхней части дерева и оценивает подзадачи из листьев / поддеревьев обратно вверх к корню.
  • табуляция-вы также можете думать о динамическом программировании как об алгоритме "заполнения таблицы" (хотя обычно многомерная, эта "таблица" может иметь неевклидову геометрию в очень редких случаях*). Это, как мемоизация, но более активный, и включает в себя один дополнительный шаг: вы должны выбрать заранее, в каком порядке вы будете делать свои расчеты. Это не должно означать, что порядок должен быть статическим, но что у вас есть гораздо больше гибкость, чем мемуаризация.

    • пример: если вы выполняете Фибоначчи, вы можете рассчитать числа в следующем порядке:fib(2),fib(3),fib(4)... кэширование каждого значения, так что вы можете вычислить следующие более легко. Вы также можете думать об этом как о заполнении таблицы (другая форма кэширования).
    • лично я не часто слышу слово "табуляция", но это очень приличный термин. Некоторые люди считают это " динамичным программирование."
    • перед запуском алгоритма программист рассматривает все дерево, затем записывает алгоритм для оценки подзадач в определенном порядке к корню, обычно заполняя таблицу.
    • *сноска: иногда "таблица"не является прямоугольной таблицей с сеткой, как таковой. Скорее, он может иметь более сложную структуру, такую как дерево, или структуру, специфичную для проблемной области (например, города в пределах расстояния полета на a карта), или даже решетчатая диаграмма, которая, хотя и похожа на сетку, не имеет структуры связи вверх-вниз-влево-вправо и т. д. Например, user3290797 связал динамический пример программирования поиска максимальный независимый набор в дереве, что соответствует заполнению пробелов в дереве.

(в самом общем виде, в парадигме "динамического программирования", я бы сказал, что программист рассматривает все дерево,затем пишет алгоритм, реализующий стратегию оценки подзадач, которые могут оптимизировать любые свойства, которые вы хотите (обычно сочетание временной сложности и пространственной сложности). Ваша стратегия должна где-то начинаться, с какой-то конкретной подзадачи, и, возможно, может адаптироваться на основе результатов этих оценок. В общем смысле "динамического программирования", вы можете попытаться кэшировать эти подзадачи, и в более общем плане, старайтесь избегать повторного просмотра подзадач с тонким различием возможно при графиков в различных структурах данных. Очень часто эти структуры данных находятся в своей основе, как массивы или таблицы. Решения подзадач могут быть отброшены, если они нам больше не нужны.)

[ранее этот ответ сделал заявление о терминологии сверху вниз и снизу вверх; ясно, что есть два основных подхода, называемых Memoization и Tabulation, которые могут быть в биекции с этими терминами (хотя и не полностью). Общий термин, который использует большинство людей, по-прежнему " динамичен Программирование", и некоторые люди говорят" Memoization "для обозначения этого конкретного подтипа" динамического программирования."Этот ответ отказывается говорить, что сверху вниз и снизу вверх, пока сообщество не сможет найти правильные ссылки в академических работах. В конечном счете, важно понимать различие, а не терминологию.]


плюсы и минусы

легкость кодирования

Memoization очень легко кодировать (вы можете вообще * написать " memoizer" аннотация или функция-оболочка, которая автоматически делает это за вас), и должна быть вашей первой линией подхода. Недостатком табуляции является то, что вы должны придумать порядок.

*(на самом деле это просто, если вы сами пишете функцию и/или кодируете на нечистом/нефункциональном языке программирования... например, если кто-то уже написал precompiled fib функция, она обязательно делает рекурсивные вызовы к себе, и вы не можете волшебно запомнить функция без обеспечения этих рекурсивных вызовов вызовите новую memoized функцию (а не оригинальную unmemoized функцию))

Recursiveness

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

практические проблемы

С memoization, если дерево очень глубоко (например fib(10^6)), у вас закончится пространство стека, потому что каждое задержанное вычисление должно быть положить на стек, и у вас будет 10^6 из них.

оптимальности

любой подход не может быть оптимальным по времени, если порядок, в котором вы находитесь (или пытаетесь) посетить подзадачи, не является оптимальным, особенно если существует более одного способа вычисления подзадачи (обычно кэширование разрешило бы это, но теоретически возможно, что кэширование не может в некоторых экзотических случаях). Мемоизация, как правило, будут добавить на ваше время-сложности в вашем пространстве-сложность (например, с подведением вы имейте больше свободы выбрасывать вычисления, например, использование табуляции с Fib позволяет использовать O(1) пространство, но memoization с Fib использует O (N) пространство стека).

дополнительно оптимизации

если вы также делаете чрезвычайно сложные проблемы, у вас может не быть выбора, кроме как сделать табуляцию (или, по крайней мере, взять на себя более активную роль в управлении memoization, где вы хотите, чтобы он пошел). Также если вы находитесь в ситуации, когда оптимизация абсолютно критична и вы должны оптимизация, табуляция позволит вам делать оптимизации, которые memoization не позволит вам сделать в здравом уме. По моему скромному мнению, в обычной программной инженерии ни один из этих двух случаев никогда не возникает, поэтому я бы просто использовал memoization ("функция, которая кэширует свои ответы"), если что-то (например, пространство стека) не делает необходимым табулирование... хотя технически, чтобы избежать выброса стека, вы можете 1) увеличить ограничение размера стека в языках, которые позволяют это, или 2) съесть константу фактор дополнительной работы по виртуализации вашего стека (ick), или 3) программа в стиле продолжения-передачи, которая по сути также виртуализирует ваш стек (не уверен в сложности этого, но в основном вы эффективно возьмете отложенную цепочку вызовов из стека размера N и де-факто вставите ее в N последовательно вложенных функций thunk... хотя в некоторых языках без оптимизации хвостового вызова вам, возможно, придется прыгать на батуте, чтобы избежать выброса стека).


сложнее примеры

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

  • алгоритм расчета расстояния редактирования[4], интересный как нетривиальный пример двумерного заполнения таблицы алгоритм

сверху вниз и снизу вверх DP-это два разных способа решения одних и тех же проблем. Рассмотрим мемуарное (сверху вниз) vs динамическое (снизу вверх) программное решение для вычисления чисел Фибоначчи.

fib_cache = {}

def memo_fib(n):
  global fib_cache
  if n == 0 or n == 1:
     return 1
  if n in fib_cache:
     return fib_cache[n]
  ret = memo_fib(n - 1) + memo_fib(n - 2)
  fib_cache[n] = ret
  return ret

def dp_fib(n):
   partial_answers = [1, 1]
   while len(partial_answers) <= n:
     partial_answers.append(partial_answers[-1] + partial_answers[-2])
   return partial_answers[n]

print memo_fib(5), dp_fib(5)

Я лично считаю мемоизация гораздо более естественным. Вы можете взять рекурсивную функцию и запомнить ее механическим процессом (сначала найдите ответ в кэше и верните его, если это возможно, в противном случае вычислите его рекурсивно, а затем, прежде чем вернуться, сохраните вычисление в кэш для будущего использования), в то время как выполнение динамического программирования снизу вверх требует, чтобы вы кодировали порядок, в котором вычисляются решения, так что никакая "большая проблема" не вычисляется перед меньшей проблемой, от которой она зависит.

ключевой особенностью динамического программирования является наличие перекрывающихся подзадач. То есть проблема, которую вы пытаетесь решить, может быть разбита на подзадачи, и многие из этих подзадач разделяют подзадачи. Это похоже на" Разделяй и властвуй", но вы в конечном итоге делаете то же самое много, много раз. Пример, который я использовал с 2003 года при обучении или объяснении этих вопросов: вы можете вычислить числа Фибоначчи рекурсивно.

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

используйте свой любимый язык и попробуйте запустить его для fib(50). Это займет очень, очень много времени. Примерно столько же времени, сколько ! Однако делается очень много ненужной работы. fib(50) будем называть fib(49) и fib(48), но тогда оба они в конечном итоге вызовут fib(47), хотя значение одно и то же. На самом деле,fib(47) будет вычисляться три раза: по прямой связи от fib(49), по прямому звонку от fib(48), а также по прямой связи от другого fib(48), который был порожден расчета fib(49)... Так что вы видите, у нас есть перекрывающихся подзадач.

отличная новость: нет необходимости вычислять одно и то же значение много раз. Как только вы вычислите его один раз, кэшируйте результат, а в следующий раз используйте кэшированное значение! В этом и заключается суть динамического программирования. Вы можете назвать это "сверху вниз", "memoization" или что-то еще, что вы хотите. Этот подход очень интуитивно понятен и очень прост в реализации. Просто сначала напишите рекурсивное решение, протестируйте его на небольших тестах, добавьте memoization (кэширование уже вычисленных значений) и --- bingo! --- ты закончил.

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

fib[0] = 0
fib[1] = 1
for i in range(48):
  fib[i+2] = fib[i] + fib[i+1]

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

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

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

Я бы лично использовал top-bottom для оптимизации абзаца a. k. a слово оберните задачу оптимизации (посмотрите алгоритмы линейного взлома Knuth-Plass; по крайней мере, TeX использует его, а некоторые программы Adobe Systems используют аналогичный подход). Я бы использовал "снизу вверх" к Быстрое Преобразование Фурье.

рассмотрим ряд Фибоначчи в качестве примера

1,1,2,3,5,8,13,21....

first number: 1
Second number: 1
Third Number: 2

еще один способ положить его,

Bottom(first) number: 1
Top (Eighth) number on the given sequence: 21

в случае первых пяти чисел Фибоначчи

Bottom(first) number :1
Top (fifth) number: 5 

теперь давайте рассмотрим рекурсивный алгоритм серии Фибоначчи в качестве примера

public int rcursive(int n) {
    if ((n == 1) || (n == 2)) {
        return 1;
    } else {
        return rcursive(n - 1) + rcursive(n - 2);
    }
}

теперь, если мы выполним эту программу со следующими командами

rcursive(5);

если мы внимательно рассмотрим алгоритм, то для того, чтобы сгенерировать пятое число, ему требуются 3-е и 4-е числа. Так что мой рекурсия на самом деле начинается сверху(5), а затем проходит весь путь до нижних/нижних чисел. Этот подход на самом деле является нисходящим подходом.

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

Сверху Вниз

давайте перепишем наш оригинальный алгоритм и добавим мемуарные методы.

public int memoized(int n, int[] memo) {
    if (n <= 2) {
        return 1;
    } else if (memo[n] != -1) {
        return memo[n];
    } else {
        memo[n] = memoized(n - 1, memo) + memoized(n - 2, memo);
    }
    return memo[n];
}

и мы выполняем этот метод следующим образом

   int n = 5;
    int[] memo = new int[n + 1];
    Arrays.fill(memo, -1);
    memoized(n, memo);

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

Снизу-Вверх

но, вопрос в том, можем ли мы начать снизу, как с первого числа Фибоначчи, а затем идти своим путем на подъем. Давайте перепишем его с помощью этой техники,

public int dp(int n) {
    int[] output = new int[n + 1];
    output[1] = 1;
    output[2] = 1;
    for (int i = 3; i <= n; i++) {
        output[i] = output[i - 1] + output[i - 2];
    }
    return output[n];
}

теперь, если мы посмотрим на этот алгоритм, он на самом деле начинается с более низких значений, а затем идет наверх. Если мне нужно 5-е число Фибоначчи, я фактически вычисляю 1-е, затем второе, затем третье вплоть до 5-го числа. Эти методы на самом деле называются методами снизу вверх.

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

динамическое программирование часто называют Memoization!

1.Запоминание-это нисходящий метод (начните решать данную проблему, разбивая ее), а динамическое программирование-это метод снизу вверх(начните решать от тривиальной подзадачи, вплоть до данной проблемы)

2.DP находит решение, начиная с базового случая(ов) и продвигается вверх. ДП решает все проблемы, потому что он делает это снизу вверх

в отличие от Мемуаризация, которая решает только необходимые подзадачи

  1. DP имеет потенциал для преобразования экспоненциально-временных решений грубой силы в алгоритмы полиномиального времени.

  2. DP может быть гораздо более эффективным, потому что его итерационный

напротив, Мемуаризация должна оплачивать (часто значительные) накладные расходы из-за рекурсии.

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

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

Ниже приводится решение на основе DP для задачи редактирования расстояния, которое сверху вниз. Я надеюсь, что это также поможет в понимании мира динамического программирования:

public int minDistance(String word1, String word2) {//Standard dynamic programming puzzle.
         int m = word2.length();
            int n = word1.length();


     if(m == 0) // Cannot miss the corner cases !
                return n;
        if(n == 0)
            return m;
        int[][] DP = new int[n + 1][m + 1];

        for(int j =1 ; j <= m; j++) {
            DP[0][j] = j;
        }
        for(int i =1 ; i <= n; i++) {
            DP[i][0] = i;
        }

        for(int i =1 ; i <= n; i++) {
            for(int j =1 ; j <= m; j++) {
                if(word1.charAt(i - 1) == word2.charAt(j - 1))
                    DP[i][j] = DP[i-1][j-1];
                else
                DP[i][j] = Math.min(Math.min(DP[i-1][j], DP[i][j-1]), DP[i-1][j-1]) + 1; // Main idea is this.
            }
        }

        return DP[n][m];
}

вы можете думать о своей рекурсивной реализации в вашем доме. Это довольно хорошо и сложно, если вы не решили что-то подобное раньше.