Академический Документы
Профессиональный Документы
Культура Документы
Динамическое программирование
Динамическое программирование
trehleb
21 сен 2018 в 16:00
Динамическое программирование или «Разделяй и Властвуй»
9 мин
32K
Open source
*
JavaScript
*
Программирование
*
Алгоритмы
*
В этой статье рассматриваются сходства и различия двух подходов к решению
алгоритмических задач: динамического программирования (dynamic programing) и
принципа «разделяй и властвуй» (divide and conquer). Сравнение будем производить на
примере, соответственно, двух алгоритмов: бинарного поиска (как быстро найти число
в отсортированном массиве) и расстояния Левенштейна (как преобразовать одну строку
в другую с минимальным количеством операций).
Итак, приступим…
image
Проблема
Когда я начал изучать алгоритмы мне было сложно понять основную идею динамического
программирования (далее DP, от Dynamic Programming) и чем оно отличается от подхода
«разделяй и властвуй» (далее DC, от divide-and-conquer). Когда дело доходит до
сравнения этих двух парадигм обычно многие успешно используют функцию Фибоначчи для
иллюстрации. И это отличная иллюстрация. Но мне кажется, что когда мы используем
одну и ту же задачу для иллюстрации DP и DC, то мы теряем одну важную деталь,
которая может помочь нам уловить различие между двумя подходами быстрее. И эта
деталь состоит в том, что эти две техники наилучшим образом проявляются при решении
разного типа задач.
Сходство DP и DC
То, как я вижу эти две концепции сейчас, могу заключить, что DP является
расширенной версией DC.
Итак, почему же тогда мы все еще имеем два разных подхода (DP и DC) и почему я
назвал динамическое программирование расширением. Это потому, что динамическое
программирование может быть применено к задачами, которые обладают определенными
характеристиками и ограничениями. И только в этом случае DP расширяет DC
посредством двух техник: мемоизации (memoization) и табуляции (tabulation).
Как мы только что с вами выяснили, есть две ключевых характеристики, которыми
должна обладать задача/проблема для того, чтобы мы могли попытаться решить ее с
помощью динамического программирования:
Как только мы сможем найти эти две характеристики в рассматриваемой нами задаче, мы
можем сказать, что она может быть решена с использованием динамического
программирования.
memFib(n) {
if (mem[n] is undefined)
if (n < 2) result = n
else result = memFib(n-2) + memFib(n-1)
mem[n] = result
return mem[n]
}
tabFib(n) {
mem[0] = 0
mem[1] = 1
for i = 2...n
mem[i] = mem[i-2] + mem[i-1]
return mem[n]
}
Основная мысль, которую необходимо уловить в этих примерах, заключается в том, что
поскольку у нашей DC проблемы есть пересекающиеся подпроблемы, то мы можем
использовать кеширование решений подпроблем с помощью одной из двух техник
кеширования: мемоизации и табуляции.
image
Пример
image
image
Обычно, всякий раз, когда дерево решений выглядит именно как дерево (а не как
граф), это скорее всего означает отсутствие пересекающихся подпроблем,
Имплементация алгоритма
Здесь вы можете найти полный исходный код алгоритма бинарного поиска с тестами и
объяснениями.
Пример
Применение алгоритма
Объяснение
Давайте попробуем разобраться, о чем нам говорит эта формула. Возьмем простой
пример поиска минимальной дистанции редактирования между строками ME и MY.
Интуитивно вы уже знаете, что минимальная дистанция редактирования равна одной (1)
операции замены (заменить «E» на «Y»). Но давайте формализуем наше решение и
превратим его в алгоритмическую форму, для того, чтобы иметь возможность решать
более сложные версии этой задачи, такой как трансформация слова Saturday в Sunday.
Для того, чтобы применить формулу к трансформации ME→MY мы сначала должны узнать
минимальную дистанцию редактирования между ME→M, M→MY и M→M. Далее мы должны
выбрать из трех дистанций минимальную и добавить к ней одну операцию (+1)
трансформации E→Y.
Итак, мы уже можем увидеть рекурсивную природу этого решения: минимальная дистанция
редактирования ME→MY вычисляется на основании трех предыдущих возможных
трансформаций. Таким образом мы уже можем сказать, что это алгоритм «разделяй и
властвуй».
Для дальнейшего объяснения алгоритма давайте поместим две наших строки в матрицу:
image
Ячейка (0,1) содержит красное число 1. Это означает, то нам необходимо выполнить 1
операцию для того, чтобы преобразовать M в пустую строку — удалить M. Поэтому мы
обозначили это число красным цветом.
Ячейка (0,2) содержит красное число 2. Это означает, что нам надо выполнить 2
операции для того, чтобы трансформировать строку ME в пустую строку — удалить E,
удалить M.
Ячейка (1,0) содержит зеленое число 1. Это означает, что нам необходима 1 операция,
чтобы трансформировать пустую строку в M — вставить M. Операцию вставки мы отметили
зеленым цветом.
Ячейка (2,0) содержит зеленое число 2. Это означает, что нам необходимо выполнить 2
операции для того, чтобы преобразовать пустую строку в строку MY — вставить Y,
вставить M.
Ячейка (1,1) содержит число 0. Это означает, что нам не надо делать ни одно
операции, для того, чтобы преобразовать строку M в M.
Ячейка (1,2) содержит красное число 1. Это означает, что нам необходимо выполнить 1
операцию, чтобы трансформировать строку ME в М — удалить E.
И так далее…
Это выглядит не сложно для маленьких матриц, таких как наша (всего 3х3). Но как мы
можем рассчитать значения всех ячеек для больших матриц (как например для матрицы
9х7 при трансформации Saturday→Sunday)?
Хорошая новость в том, что, согласно формуле, все, что нам необходимо для расчета
значения любой ячейки с координатами (i,j) — это всего-лишь значения 3-х соседних
ячеек (i-1,j), (i-1,j-1), и (i,j-1). Все, что нам необходимо сделать это найти
минимальное значение трех соседних ячеек и добавить к этому значению единицу (+1) в
случае, если у нас разные буквы в i-м ряду и j-й колонке.
image
Мы так же увидели, что имеем дело с задачей типа «разделяй и властвуй». Но, можем
ли мы применить динамическое программирование для решения этой задачи?
Удовлетворяет ли данная задача упомянутым выше условиям "пересекающихся проблем" и
"оптимальных подструктур"? Да. Давайте построим дерево принятий решений.
image
Во-первых вы можете заметить, что наше дерево решений выглядит скорее не как
дерево, а как граф решений. Вы так же можете заметить несколько пересекающихся
подзадач. Так же видно, что невозможно уменьшить количество операций и сделать его
меньшим, чем количество операций с тех трех соседних ячейках (подпроблемах).
Применяя все эти принципы, мы можем решать более сложные задачи, например задачу
трансформации Saturday→Sunday:
image
Пример кода
function levenshteinDistance(a, b) {
const distanceMatrix = Array(b.length + 1)
.fill(null)
.map(
() => Array(a.length + 1).fill(null)
);
for (let i = 0; i <= a.length; i += 1) {
distanceMatrix[0][i] = i;
}
for (let j = 0; j <= b.length; j += 1) {
distanceMatrix[j][0] = j;
}
for (let j = 1; j <= b.length; j += 1) {
for (let i = 1; i <= a.length; i += 1) {
const indicator = a[i - 1] === b[j - 1] ? 0 : 1;
distanceMatrix[j][i] = Math.min(
distanceMatrix[j][i - 1] + 1, // deletion
distanceMatrix[j - 1][i] + 1, // insertion
distanceMatrix[j - 1][i - 1] + indicator, // substitution
);
}
}
return distanceMatrix[b.length][a.length];
}
Выводы
Я надеюсь эта статья скорее прояснила, а не усложнила ситуацию для тех из вас, кто
пытался разобраться с такими важными концепциями как динамическое программирование
и «разделяй и властвуй» :)
Успешного кодинга!