Лекции / 4_Динамическое_программирование_Жадные_алгоритмы_ipynb_Colab
.pdf
Динамическое программирование
История возникновения термина
Термин был предложен в 1950 Ричардом Беллманом. Этот термин не имеет прямого отношения к программированию. Ему пришлось буквально выдумывать этот термин потому, что чиновник перед которым ему нужно было отчитываться не любил (и не понимал) математику. Вот и пришлось выбрать термин максимально далекий от математики. Отчет был сдан. Впоследствии в 1953 году термин был дополнен до своего текущего значения.
Ричард Эрнест Беллман (1920-1984) - американский прикладной математик, представивший в 1953г. динамическое программирование и внесший важный вклад в другие области математики, например, в биоматематику.
Динамическое программирование — методология решения задач путем разбиения задачи на более простые подзадачи. При этом разбиение на подзадачи должно удовлетворять следующим критериям:
Подзадачи должны иметь общую структуру и для их решения используется однотипный алгоритм.
Существует эффективный способ запоминать и использовать результат полученный при решении подзадачи.
При решении задач методом динамического программирования используются следующие подходы:
Нисходящее динамическое программирование — запоминание результатов
решенных подзадач и их использование для рекурсивного решения общей задачи. (Мемоизация + рекурсия)
Восходящее динамическое программирование — рекурсивное разбиение на более простые однотипные задачи, сортировка подзадач по «размеру», решение подзадач в порядке возрастания их размера. (Итеративный подход с таблицей)
Сравнение подходов
Характеристика |
Нисходящий (Top-Down) |
Восходящий (Bottom-Up) |
Реализация |
Рекурсия + мемоизация |
Итерация + таблица |
Характеристика |
Нисходящий (Top-Down) |
Восходящий (Bottom-Up) |
Память |
Кэш (стек вызовов) |
Таблица (массив) |
Скорость |
Быстро (с кэшем) |
Быстро |
Сложность кода |
Интуитивно понятно |
Может быть сложнее |
Риск переполнения Возможен при большой глубине |
Отсутствует |
|
Выбор подхода: Нисходящий подход удобен для понимания и реализации, но может столкнуться с ограничением глубины рекурсии. Восходящий
подход более эффективен по памяти и безопасен для больших входных данных.
Критерии задачи решаемой с помощью динамического программирования
Динамическое программирование обычно применяется к задачам оптимизации. Это означает, что задача может иметь множество решений, с каждым из решений можно сопоставить какое-то значение (результат). Среди всех решений нужно найти оптимальное (с максимальным или минимальным значением результата).
Задача, которая может быть решена методом динамического программирования, отличается наличием двух критериев:
1.Наличие оптимальной подструктуры
2.Наличие пересекающихся подзадач
Иллюстрация критериев
Критерий |
Описание |
|
Оптимальная подструктура Оптимальное решение задачи содержит оптимальные решения подзадач |
|
|
Пересекающиеся подзадачи |
Одни и те же подзадачи решаются многократно |
Числа Фибон |
Оптимальная подструктура
Задача считается содержащей оптимальную подструктуру в том случае, если ее
оптимальное решение содержит оптимальные решения подзадач. Однако само наличие
оптимальной подструктуры не относит автоматически задачу к задачам динамического программирования (это могут быть и иные подходы — «жадные алгоритмы», «алгоритмы разделяй и властвуй»). Косвенным признаком будет, что оптимальное решение задачи конструируется из оптимальных решений подзадач.
Алгоритм проверки и поиска оптимальной подструктуры в задаче:
Проверка наличия выбора ведущего к оптимальному решению
Результат выбора должен определять количество полученных подзадач и количество вариантов выбора возникающее в полученной подзадаче Решение подзадач, возникающее при оптимальном решении задачи, сами являются
оптимальными
Перекрытие подзадач
Второй критерий, который относится к решаемой с помощью динамического программирования, это наличие пересекающихся подзадач.
Перекрытие подзадач означает, что одна и та же подзадача встречается при решении более одного раза. В таком случае однократное решение этой подзадачи позволит в дальнейшем сразу получить ее ответ, без повторного решения. Для этого используют любую структуру данных (массив, список и т.д.) позволяющий однозначно соотнести подзадачу с ее решением. При первом вычислении подзадачи ее результат заносится в эту структуру, когда эта подзадача встретится еще раз, то ее результат просто извлекается из структуры данных без необходимости повторного вычисления.
Сравнение подходов к решению задач
Подход |
Оптимальная подструктура |
Пересекающиеся подзадачи |
|
Динамическое программирование |
|
|
Задача о рюкзаке |
Разделяй и властвуй |
|
|
Быстрая с |
Жадные алгоритмы |
(локально) |
|
Алгоритм Дей |
Ключевой момент: Именно сочетание оптимальной подструктуры и пересекающихся подзадач делает динамическое программирование эффективным — мы можем решать каждую подзадачу один раз и использовать результат многократно.
Примерный алгоритм решения
После определения факта, что задание может быть решено с помощью динамического программирования, можно придерживаться следующего алгоритма решения задачи.
1.Описать структуру оптимального решения — определить, как оптимальное решение задачи строится из оптимальных решений подзадач.
2.Определить значения соответствующие оптимальному решению с помощью рекурсии — записать рекуррентное соотношение для вычисления оптимального значения.
3.Вычислить значения, соответствующие оптимальному решению — реализовать вычисления (нисходящим или восходящим способом).
Пример задачи динамического программирования
Задача о оптимальном разрезании стержня
Имеется стержень определенной длины. Его можно разрезать на (n) частей. Стоимость части зависит от ее длины и задана в виде табличного значения. Требуется определить максимальную стоимость, которую можно получить разрезая (или не разрезая вообще) этот стержень на определенное количество частей.
Предположим что стержень можно разрезать на (n = 4) одинаковые части. Стоимость частей разной длины приведена в таблице.
Применимость динамического программирования
Для оценки того можно ли использовать динамическое программирование для решения этой задачи проведем небольшое исследование. Предположим на первом шаге мы отрезаем кусок слева от стержня. Построим граф пространства решений в зависимости
от этого выбора. Значение узла равно длине части стержня.
Применимость динамического программирования
Как можно видеть, присутствуют все факторы указывающие на то, что эта задача
относится к задачам динамического программирования.
На каждом шаге присутствует выбор влияющий на оптимальность полученного решения (от выбора длины стержня который стоит отрезать). Результат этого выбора определяет количество возникающих подзадач.
Сами же подзадачи очень часто пересекаются. Так разбиение вида (1, 1, 2) встречается несколько раз.
Возможные способы решения задачи
Рассмотрим решение с позиции нисходящего динамического программирования.
Обозначим длину куска стержня как |
. Стоимость куска обозначим как . |
|
Количество кусков, которое получилось при разбиении, обозначим как |
||
( |
). Также очевидным условием будет, что сумма длин всех полученных кусков |
|
должна быть равна длине стержня. |
|
|
В таком случае можно записать, что суммарная стоимость стержня равна
Поиск оптимального решения будем проводить следующим образом. Отрезаем слева от стержня кусок длиной , тогда кусок который остался имеет длину . Левый кусок больше не разрезаем, дальнейшим разрезам подвергается только остаток. Таким образом
оптимальная стоимость будет равна
где |
— оптимальная стоимость для стержня длины |
. |
Схема рекурсивного решения
Видно, что решение одних подзадач используется в других, поэтому при решении стоит использовать мемоизацию, что бы существенно увеличить скорость выполнения.
Реализация алгоритма на Python
Функция поиска максимального решения
def cut_rod(p, n): |
|
|
|
# Хранилищедлямемоизации(кэшрезультатов |
подзадач) |
|
|
# Ключдлинаостаткастержнязначение, - |
максимальнаястоимостьдляэтойдлины |
||
solve_subtask= {} |
|
|
|
def find_max_profit(p, n): |
|
|
|
# Базовыйслучайстержень: нулевойдлиныдаёт |
нулевуюстоимость |
||
if n == 0: |
|
|
|
return 0 |
|
|
|
max_profit= |
-1 # Инициализируеммаксимальнуюприбыль |
|
|
# Перебираемвсевозможныедлиныпервого |
отрезаемогокуска |
||
for i in range(1, n + 1): |
|
|
|
# Проверяемрешали, ли мы ужеподзадачудлядлины |
n-i |
||
if n - i in solve_subtask: |
|
|
|
# Еслирешалиберёмрезультатиз кэша |
|
|
|
last_part_profit= solve_subtask.g |
et(n- i) |
|
|
else: |
|
|
|
# Еслине решаливычисляемрекурсивно |
|
||
last_part_profit= find_max_profit |
(p,n - i) |
|
|
# И сохраняемрезультатв кэш |
|
|
|
solve_subtask[n- i] = last_part_p |
rofit |
|
|
# Обновляеммаксимумстоимость: текущегокуска+ |
оптимальнаястоимость |
||
max_profit= |
max(max_profit,p[i]+ last_part_profit) |
|
|
return max_profit
# Запускаемрешениедляисходнойдлиныn return find_max_profit(p,n)
# Таблицацен:индекс= длинакуска,значение= |
цена |
|
# p[0]не используется(длина0) |
|
|
p = [ 0, 1, 5, 8, 9] # ценыдлядлин1, 2, 3, 4 |
|
|
n = 4 # длинастержня |
|
|
print(f"Максимальнаястоимостьдлястержнядлины |
{n}: {cut_rod(p,n)} |
") |
|
|
|
Максимальная стоимость для стержня длины 4: 10 |
|
|
Решение с помощью восходящего подхода
Для решения с помощью восходящего подхода стоит рассортировать подзадачи по их
«размеру». Сформулируем это задание в виде задач возрастающего размера. Сначала
исследуем максимальную выгоду от разрезания стрежня длиной 1, потом стержня длиной 2 и так далее.
Для решения с помощью восходящего подхода стоит рассортировать подзадачи по их «размеру». Сформулируем это задание в виде задач возрастающего размера. Сначала исследуем максимальную выгоду от разрезания стрежня длиной 1, потом стержня длиной 2 и так далее.
Функция поиска максимального решения
def upper_cut_rod(p, n): |
|
|
|
||
# Создаёммассивr дляхранениямаксимальной |
прибылидлякаждойдлины |
||||
# r[i]будетсодержатьоптимальнуюстоимостьдля |
стержнядлиныi |
||||
r = [ |
0 for i |
in range(n + |
1)] |
|
|
# Внешнийцикл:перебираемвседлиныстержняот 1 |
до n |
||||
for j |
in range(1, n + |
1): |
|
|
|
max_profit= |
-1 |
# Инициализируеммаксимумдлятекущейдлины |
|||
# Внутреннийцикл:перебираемдлинупервого |
отрезаемогокуска |
||||
for i in range(1, j + |
1): |
|
|||
|
# Сравниваемтекущиймаксимум вариантом: |
|
|||
|
# ценакускадлиныi + оптимальнаястоимость |
остаткадлиныj-i |
|||
|
max_profit= |
max(max_profit,p[i]+ r[j- i]) |
|||
# Сохраняемнайденныймаксимумдлядлиныj |
|
||||
r[j]= max_profit |
|
|
|
||
# Возвращаемоптимальнуюстоимостьдляисходной |
длиныn |
||||
return r[n] |
|
|
|
|
|
Задача о золотоискателе
На вершине горы стоит золотоискатель (смайлик наверху). Цифра в клетке обозначает количество золотых слитков, которые в ней хранятся. Золотоискатель может за один раз или спуститься на одну клетку вниз, или спуститься на одну клетку вниз и вправо.
Определить максимальное количество золотых слитков, которые может найти золотоискатель, двигаясь от вершины горы вниз.
Проверка принадлежности задачи к задачам динамического программирования
Это явно задача оптимизации (нужно найти оптимальный, с точки зрения максимума золотых слитков, путь).
Проверка наличия оптимальной подструктуры. Каждый шаг приводит к возникновению нового набора подзадач, каждая из подзадач должна быть решена таким же способом и ее решение также должно быть оптимальным. Ниже изображены подзадачи в зависимости от выбора на первом шаге.
Также эта задача содержит весьма значительное количество перекрывающихся подзадач. Даже первый шаг приводит в наличию перекрывающихся подзадач.
Решение с помощью нисходящего динамического программирования
Рекурсивное нисходящее решение буквально формируется самой постановкой задачи. Пронумеруем строки и столбцы (сверху-вниз, и слева-направо). В начальной позиции у
золотоискателя 0 слитков. Максимальная выгода для любой позиции вычисляется как
