Динамичное программирование. Динамическое программирова­ние

Допустим, есть задача, которую мы уже решили динамическим программированием, например, извечные числа Фибоначчи.
Давайте немного переформулируем её. Пусть у нас есть вектор , из которого мы хотим получить вектор . Чуть-чуть раскроем формулы: . Можно заметить, что из вектора можно получить вектор путем умножения на какую-то матрицу, ведь в итоговом векторе фигурируют только сложенные переменные из первого вектора. Эту матрицу легко вывести, вот она: . Назовём её матрицей перехода.

Это значит, что если взять вектор и умножить его на матрицу перехода n - 1 раз, то получим вектор , в котором лежит fib[n] - ответ на задачу.

А теперь, зачем всё это надо. Умножение матриц обладает свойством ассоциативности, то есть (но при этом не обладает коммутативностью, что по-моему удивительно). Это свойство даёт нам право сделать так: .

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

А теперь пример посерьёзнее:

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

Решение

Для начала решение без матрицы перехода:

1) Состояние динамики: dp[n] - количество пилообразных последовательностей длины n , заканчивающихся на цифру last . Причём если less == 0 , то последняя цифра меньше предпоследней, а если less == 1 , значит больше.
2) Начальные значения:
for last in range(10): dp = 9 - last dp = last 3) Пересчёт динамики:
for prev in range(10): if prev > last: dp[n] += dp if prev < last: dp[n] += dp 4) Порядок пересчёта: мы всегда обращаемся к предыдущей длине, так что просто пара вложенных for "ов.
5) Ответ - это сумма dp[N] .

Теперь надо придумать начальный вектор и матрицу перехода к нему. Вектор, кажется, придумывается быстро: все состояния, обозначающие длину последовательности N . Ну а матрица перехода выводится, смотря на формулы пересчёта.

Вектор и матрица перехода

Динамика по подотрезкам

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

Определим сжатую строку:
1) Строка состоящая только из букв - это сжатая строка. Разжимается она в саму себя.
2) Строка, являющаяся конкатенацией двух сжатых строк A и B . Разжимается она в конкатенацию разжатых строк A и B .
3) Строка D(X) , где D - целое число, большее 1 , а X - сжатая строка. Разжимается она в конкатенацию D строк, разжатых из X .
Пример: “3(2(A)2(B))C” разжимается в “AABBAABBAABBC” .

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

Решение

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

1) Состояние динамики: d[l][r] - сжатая строка минимальной длины, разжимающаяся в строку s
2) Начальные состояния: все подстроки длины один можно сжать только в них самих.
3) Пересчёт динамики:
У лучшего ответа есть какая-то последняя операция сжатия: либо это просто строка из заглавных букв, или это конкатенация двух строк, или само сжатие. Так давайте переберём все варианты и выберем лучший.

Dp_len = r - l dp[l][r] = dp_len # Первый вариант сжатия - просто строка. for i in range(l + 1, r): dp[l][r] = min(dp[l][r], dp[l][i] + dp[i][r]) # Попробовать разделить на две сжатые подстроки for cnt in range(2, dp_len): if (dp_len % cnt == 0): # Если не делится, то нет смысла пытаться разделить good = True for j in range(1, (dp_len / cnt) + 1): # Проверка на то, что все cnt подстрок одинаковы good &= s == s if good: # Попробовать разделить на cnt одинаковых подстрок и сжать dp[l][r] = min(dp[l][r], len(str(cnt)) + 1 + dp[l] + 1) 4) Порядок пересчёта: прямой по возрастанию длины подстроки или ленивая динамика.
5) Ответ лежит в d .

Пример №5:

Динамика по поддеревьям

Параметром состояния динамики по поддеревьям обычно бывает вершина, обозначающая поддерево, в котором эта вершина - корень. Для получения значения текущего состояния обычно нужно знать результаты всех своих детей. Чаще всего реализуют лениво - просто пишут поиск в глубину из корня дерева.
Пример №6: Логическое дерево
Дано подвешенное дерево, в листьях которого записаны однобитовые числа - 0 или 1 . Во всех внутренних вершинах так же записаны числа, но по следующему правилу: для каждой вершины выбрана одна из логических операций: «И» или «ИЛИ». Если это «И», то значение вершины - это логическое «И» от значений всех её детей. Если же «ИЛИ», то значение вершины - это логическое «ИЛИ» от значений всех её детей.

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

Решение

1) Состояние динамики: d[v][x] - количество операций, требуемых для получения значения x в вершине v . Если это невозможно, то значение состояния - +inf .
2) Начальные значения: для листьев, очевидно, что своё значение можно получить за ноль изменений, изменить же значение невозможно, то есть возможно, но только за +inf операций.
3) Формула пересчёта:
Если в этой вершине уже значение x , то ноль. Если нет, то есть два варианта: изменить в текущей вершине операцию или нет. Для обоих нужно найти оптимальный вариант и выбрать наилучший.

Если операция «И» и нужно получить «0», то ответ это минимум из значений d[i] , где i - сын v .
Если операция «И» и нужно получить «1», то ответ это сумма всех значений d[i] , где i - сын v .
Если операция «ИЛИ» и нужно получить «0», то ответ это сумма всех значений d[i] , где i - сын v .
Если операция «ИЛИ» и нужно получить «1», то ответ это минимум из значений d[i] , где i - сын v .

4) Порядок пересчёта: легче всего реализуется лениво - в виде поиска в глубину из корня.
5) Ответ - d xor 1] .

Динамика по подмножествам

В динамике по подмножествам обычно в состояние входит маска заданного множества. Перебираются чаще всего в порядке увеличения количества единиц в этой маске и пересчитываются, соответственно, из состояний, меньших по включению. Обычно используется ленивая динамика, чтобы специально не думать о порядке обхода, который иногда бывает не совсем тривиальным.
Пример №7: Гамильтонов цикл минимального веса, или задача коммивояжера
Задан взвешенный (веса рёбер неотрицательны) граф G размера N . Найти гамильтонов цикл (цикл, проходящий по всем вершинам без самопересечений) минимального веса.

Решение

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

1) Состояние динамики: dp[v] - путь минимального веса из вершины 0 в вершину v , проходящий по всем вершинам, лежащим в mask и только по ним.
2) Начальные значения: dp = 0 , все остальные состояния изначально - +inf .
3) Формула пересчёта: Если i -й бит в mask равен 1 и есть ребро из i в v , то:
dp[v] = min(dp[v], dp[i] + w[i][v]) Где w[i][v] - вес ребра из i в v .
4) Порядок пересчёта: самый простой и удобный способ - это написать ленивую динамику, но можно поизвращаться и написать перебор масок в порядке увеличения количества единичных битов в ней.
5) Ответ лежит в d[(1 << N) - 1] .

Динамика по профилю

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

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

Профиль - это k (зачастую один) столбцов, являющиеся границей между уже замощённой частью и ещё не замощённой. Эта граница заполнена только частично. Очень часто является частью состояния динамики.

Почти всегда состояние - это профиль и то, где этот профиль. А переход увеличивает это местоположение на один. Узнать, можно ли перейти из одного профиля в другой можно за линейное от размера профиля время. Это можно проверять каждый раз во время пересчёта, но можно и предподсчитать. Предподсчитывать будем двумерный массив can - можно ли от одной маски перейти к другой, положив несколько фигурок, увеличив положение профиля на один. Если предподсчитывать, то времени на выполнение потребуется меньше, а памяти - больше.

Пример №8: Замощение доминошками
Найти количество способов замостить таблицу N x M с помощью доминошек размерами 1 x 2 и 2 x 1 .

Решение

Здесь профиль - это один столбец. Хранить его удобно в виде двоичной маски: 0 - не замощенная клетка столбца, 1 - замощенная. То есть всего профилей .

0) Предподсчёт (опционально): перебрать все пары профилей и проверить, что из одного можно перейти в другой. В этой задаче это проверяется так:

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

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

Примеры переходов (из верхнего профиля можно перейти в нижние и только в них):

После этого сохранить всё в массив can - 1 , если можно перейти, 0 - если нельзя.
1) Состояние динамики: dp - количество полных замощений первых pos - 1 столбцов с профилем mask .
2) Начальное состояние: dp = 1 - левая граница поля - прямая стенка.
3) Формула пересчёта:
dp += dp * can
4) Порядок обхода - в порядке увеличения pos .
5) Ответ лежит в dp.

Полученная асимптотика - .

Динамика по изломанному профилю

Это очень сильная оптимизация динамики по профилю. Здесь профиль - это не только маска, но ещё и место излома. Выглядит это так:

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

Переходы в динамике по изломанному профилю на примере задачи про замощение доминошками (пример №8):

Восстановление ответа

Иногда бывает, что просто знать какую-то характеристику лучшего ответа недостаточно. Например, в задаче «Запаковка строки» (пример №4) мы в итоге получаем только длину самой короткой сжатой строки, но, скорее всего, нам нужна не её длина, а сама строка. В таком случае надо восстановить ответ.

В каждой задаче свой способ восстановления ответа, но самые распространенные:

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

Небольшие оптимизации

Память
Зачастую в динамике можно встретить задачу, в которой состояние требует быть посчитанными не очень большое количество других состояний. Например, при подсчёте чисел Фибоначчи мы используем только два последних, а к предыдущим уже никогда не обратимся. Значит, можно про них забыть, то есть не хранить в памяти. Иногда это улучшает асимптотическую оценку по памяти. Этим приёмом можно воспользоваться в примерах №1, №2, №3 (в решении без матрицы перехода), №7 и №8. Правда, этим никак не получится воспользоваться, если порядок обхода - ленивая динамика.
Время
Иногда бывает так, что можно улучшить асимптотическое время, используя какую-нибудь структуру данных. К примеру, в алгоритме Дейкстры можно воспользоваться очередью с приоритетами для изменения асимптотического времени.

Замена состояния

В решениях динамикой обязательно фигурирует состояние - параметры, однозначно задающие подзадачу, но это состояние не обязательно одно единственное. Иногда можно придумать другие параметры и получить с этого выгоду в виде снижения асимптотического времени или памяти.
Пример №9: Разложение числа
Требуется найти количество разложений числа N на различные слагаемые. Например, если N = 7 , то таких разложений 5:
  • 3 + 4
  • 2 + 5
  • 1 + 7
  • 1 + 2 + 4
  • Суть метода динамического программирования………………………..4

  • Пример решения задачи методом динамического программирования………………………………………………………...7

    Список используемых источников……………………………………...11

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

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

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

    Динамическое программирование представляет собой математический аппарат, который подходит к решению некоторого класса задач путем их разложения на части, небольшие и менее сложные задачи. При этом отличительной особенностью является решение задач по этапам, через фиксированные интервалы, промежутки времени, что и определило появление термина динамическое программирование. Следует заметить, что методы динамического программирования успешно применяются и при решении задач, в которых фактор времени не учитывается. В целом математический аппарат можно представить как пошаговое или поэтапное программирование. Решение задач методами динамического программирования проводится на основе сформулированного Р. Э. Беллманом принципа оптимальности: оптимальное поведение обладает тем свойством, что каким бы ни было первоначальное состояние системы и первоначальное решение, последующее решение должно определять оптимальное поведение относительно состояния, полученного в результате первоначального решения. Из этого следует, что планирование каждого шага должно проводиться с учетом общей выгоды, получаемой по завершении всего процесса, что и позволяет оптимизировать конечный результат по выбранному критерию.

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

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

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

    Для процессов с непрерывным временем динамическое программирование рассматривается как предельный вариант дискретной схемы решения. Получаемые при этом результаты практически совпадают с теми, которые получаются методами максимума Л. С. Понтрягина или Гамильтона-Якоби-Беллмана.

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

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

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

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

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

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

    Рассмотрим общую постановку задачи этого программирования. Пусть исследуется некоторый экономический процесс, имеющий п последовательных этапов. На каждом 7-м этапе процесс может быть в разных состояниях бы, каждый из которых характеризуется конечным множеством параметров. С каждым этапом задачи связано принятие определенного управленческого решения хи, которое переводит систему из одного состояния в другое. Предполагается, что состояние si системы в конце 7-го этапа определяется только предыдущим состоянием si_1 и управлением хи на 7-м этапе и не зависит от предыдущих состояний и управлений. Тогда состояние si системы записывается в виде зависимости

    Si = ф (в, _!, Хи), i = 1, П.

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

    При названных условиях задача динамического программирования формулируется так: определить такую допустимую последовательность управленческих решений X = {x1, x2, хп}, которая переводит систему из начального состояния 50 в завершающий состояние sn и при которой достигается максимальная эффективность управления.

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

    Максимум целевой функции на заключительном п-м этапе равна

    ^ п-О = шах / п ^ п-и хп).

    Соответственно, на (п - 1) -етапи имеем

    г * п-1 (5п-2) = ШaХ ((fn-1 (sn-2, хп-1) + г * п ^ п-1)).

    Учитывая эту закономерность, для произвольного k-этапа можем записать рекуррентную зависимость

    г * (пятый-1) = Шахи (Л (ик-1, хк) + г * + 1)).

    Такая рекуррентная зависимость представляет собой математическую запись принципа оптимальности Беллмана.

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

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

    1. Идея и метод динамического программирования больше приспособлены к дискретных задач, которыми в большинстве являются задачи управления.

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

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

    4. Метод динамического программирования дает возможность анализа чувствительности к изменению исходных данных состояний sk и их количества п. Фактически здесь на каждом шагу решается не одна задача, а множество однотипных задач для различных состояний sk и различных к (1 <к <п) . Поэтому с изменением исходных данных нельзя не решать задачу заново, а сделать только несложные добавление к уже выполненных расчетов, то есть продолжить уже решенную задачу за счет увеличения количества шагов п или количества значений sk.

    Выводы

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

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

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

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

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

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

    Динамическое программирование.

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

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

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

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

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

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

    Характеризуя динамическое программирование, как набор математических процедур для оптимального управления дискретной системой, в общем виде задачу оптимального управления можно сформулировать следующим образом. В дискретные моменты времени t = 1, 2,..., N система находится в одном из множеств s i состояний, характеризуемых вектором состояния x (t) . Переход между последовательными состояниями осуществляется с помощью вектора управления u (t) по закону:

    x ( t ) = g ( t ) (x ( t ) , u ( t )) ; t = 1, 2,..., N

    Управления u (t) выбираются из множества допустимых управлений и образуют последовательность допустимых управлений u (0) ,u (1) ,…,u (N) . Последовательность допустимых управлений при заданном начальном состоянии х (0) определяет траекторию системы х (0) ,х (1) ,х (2) ,…,х (N) .

    Всякой траектории соответствует свое значение критерия оптимальности F , или целевой функции управления, слагающегося из отдельных вкладов на каждом этапе управления:

    Задачa оптимального управления заключается в нахождении среди множества последовательностей управления такой, которая достигает минимального значения F. Такая последовательность называется оптимальной последовательностью управлений и определяет оптимальную траекторию.

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

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

    Метод динамического программирования хорошо иллюстрируется на примере поиска кратчайшего пути между крайними узлами ориентированной сети. Рассмотрим некоторую ориентированную сеть, насчитывающую 12 узлов, которую нужно пройти от начального узла (1) до конечного узла (12) за четыре шага, передвигаясь с каждым шагом от узла к узлу.

    Рис. 6.4.1. Прохождение ориентированной сети по кратчайшему пути.

    Числа, указанные при дугах (i,j ) равны длинам дуг l ij между узлами i и j (в условных единицах). Возможные состояния системы s i в данном случае связаны с нахождением в i -м узле, управление u (t) связано с выбором направления пути на каждом шаге управления. Четыре шага управления u (1) ,...,u (4) последовательно переводят систему из начального состояния s 1 в конечное состояние s 12 и, таким образом, образуют некоторую траекторию, которую необходимо отыскать. В роли критериея оптимальности F в данном случае выступает длина траектории L , слагающаяся из длин отдельных дуг:

    Если поиски кратчайшего пути, т. е. оптимальной траектории, начинать не с начала, а сконца сети и двигаться в обратном направлении к началу, то в этом случае мы имеем алгоритм «обратной прогонки». В данном случае при реализации алгоритма обратной прогонки движение осуществляется от конечного состояния s 12 к начальному состоянию s 1 .

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

    Таблица 6.4.1

    i t s 1 s 2 s 3 s 4 s 5 S 6 s 7 s 8 s 9 s 10 s 11
    12 12 6
    10 11 10
    5
    1


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

    Заполнение таблицы начинается с первой строки, где хранится информация о последнем шаге пути. Последний, в данном случае четвертый шаг пути определен однозначно при переходе из любого предпоследнего состояния, которым может быть любое из трех возможных: s 9 , s 10 , s 11 . Поэтому оптимальное управление на последнем шаге очевидно. В зависимости от предпоследнего состояния вклад в критерий оптимальности L 4 (9) = 12, L 4 (10) = 6, либо L 4 (11) = 7. Эти значения вклада в L записываются в нижней части клеток первой строки табл. 6.4.1.

    Перед предпоследним – в данном случае третьим - шагом множество возможных состояний системы есть {s 5 , s 6 , s 7 , s 8 }. Применим теперь принцип Беллмана для определения траектории на третьем и четвертом шаге. Он заключается в том, что независимо от первых двух шагов управления отрезок траектории на последних двух шагах сам по себе является оптимальной траекторией, т.е. дает минимум вклада L 3 в критерий оптимальности.

    Если состояние системы перед предпоследним шагом есть состояние s 8 , то на последних шагах вклад в L определяется соотношением

    L 3 (s 5)=min{ }.

    Поскольку из s 5 возможны переходы в s 9 и s 11 .т.е.:

    g(s 5 ,9) = s 9 ; ; L 4 (s 9) = 12,

    g(s 5 ,11) = s 11 ; ; L 4 (s 11) = 7,

    L 3 (s 5) = min{6+12, 4+7} = 11 и u (3) = 11.

    Это означает, что если система находится в состоянии s 5 , то оптимальное управление заключается сначала в переходе в состояние s 11 , затем в состояние s 12 . Длина дуги из s 5 в s 12 при этом оказывается равна 11 единиц.

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

    L 3 (s 6)=min{7+12, 6+6)=12 , u (3) =10;

    L 3 (s 7)=min{5+6, 3+7)=10, u (3) =11;

    L 3 (s 8)=min{10+6, 12+7)=16, u (3) =10;

    Полученные четыре пары чисел записываются во вторую строку Табл. 6.4.1.

    На втором шаге управления вклад в критерий оптимальности в зависимости от исходного состояния есть

    L 2 (s 2) = min{ } = min{11+11, 14+10} = 22, u (2) = 5;

    L 2 (s 3) = min{ } = min{7+11, 9+12} = 18, u (2) = 5;

    L 2 (s 4) = min{ } = min{2+16, 3+12, 6+10} = 15, u (2) = 6;

    Полученные три пары чисел записываются в третью строку Табл.6.4.1.

    Начальное состояние s 1 определено однозначно, поэтому в последней строке таблицы заполняется единственная клетка, куда носятся значения 3 и 24 поскольку:

    L 1 (s 1) = min{ } = min{5+22, 6+18, 11+15} = 24, u (1) = 3.

    Теперь можно окончательно определить последовательность оптимального многошагового управления. На первом шаге u (1) = 3, т.е. из узла 1 переходим в узел 3, на втором шаге u (2) = 5, т.е. переходим в узел 5, далее после управления u (3) = 11 - в узел 11 и, наконец, в узел 12. Окончательно получаем, что кратчайший путь по сети, изображенной на Рис. 6.4.1, проходит по последовательности состояний s 1 →s 2 →s 5 →s 11 →s 12 , а его протяженность составляет 24 условных единиц.

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

    В алгоритмах прямой и обратной прогонки, хотя и отличных по существу, предусматривается одно сложение и одно сравнение на каждую дугу. Следовательно, оба алгоритма обладают одина­ковым быстродействием. Тем не менее, существует важное различие. В алгоритме прямой прогонки рассматри­ваются дуги, исходящие из тех узлов, кратчайшие пути l i до которых уже известны.

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

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

    1. Исходная задача погружается во множество оптимизационных задач; при этом для каждого узла решается своя задача.

    2. Множество решений оптимизационных задач описывается функциональным уравнением, представляющим собой систему уравнений, которые связывают несколько оптимизационных задач. В такой системе каждое уравнение соответствует одному узлу и содержит обычно операторы типа min, mах или minimax справа от знака равенства, а переменные типа g i , и g j - по обе стороны от него.

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

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

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

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

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

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

    Множество S возможных (или наблюдаемых) состояний назы­вается пространством состояний, а элемент s из S определяет конкретное состояние. С каждым состоянием s связано множество D (s ) . Элемент d из множества D (s ) называется решением. Правило, согласно которому определяется допустимое решение для каждого состояния, называется стратегией d.

    Фактически страте­гия d ставит в соответствие каждому состоянию s некоторый эле­мент d(s ) из множества D (s ). Набор всех таких d образует про­странство стратегий D. Последнее означает, что выбор решения в некотором состоянии не ограничивает выбор во всех других состояниях. По существу, D представляет собой декартово произведение множеств D (s ) по s .

    Одна из идей динамического программирования состоит в том, каждой стратегии d должна соответствовать так называемая функция прибы­ли V d (s ), которую можно получить, исходя из состояния s и используя стратегию d. Понятие функции прибы­ли (или дохода) обобщает понятие вклада L t в общее значение критерия оптимальности L, рассматриваемое при решении задачи о кратчайшем пути.

    Выражение «используя стратегию d» означает, что в состоянии s выбирается решение d(s ); затем предполагается, что процесс перешел в состояние s " , т. е. реализуется состояние s ", в котором выбирается решение d(s "), и т. д. Функция прибыли имеет доволь­но сложную структуру, поскольку она зависит от последователь­ности состояний и решений, от вознаграждений, которые связаны с этими состояниями и решениями, а также от способа агрегиро­вания вознаграждений.

    Состояние представляет собой описание предыстории процесса со степенью подробности, позволяющей провести оценку текущих альтернативных решений. Основным свойством состояний является то, что состояние является краткой записью предыстории процесса, причем степень детализации позволяет определить локальную функцию дохода.Иными словами, локальная функция дохода может зависеть лишь от s , d и v.

    В следующей главе будут более подробно рассмотрены цепи Маркова, имеющие большое значение для моделирования временной эволюции производственных и технических систем. Существуют также Марковские модели принятия решений, в которых состояние s определяется некоторой парой чисел (n,i ) , решением является зависящая от них функция k , а локальная функция дохода определяется выражением типа h [(n , I ) , k, v ] = R k i (n ) + å j P k ij (n )v (n+ 1,j ) (n).

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

    Всякий раз, когда наблюдается некоторое состояние, выбирается реше­ние, которое уже нельзя изменять до тех пор, пока процесс не перейдет в новое состояние, где выбирается новое решение, и т. д. Данная модель представляет собой комбинацию теории цепей Маркова и теории восстановления; обычно ее называют Мар­ковской задачей о восстановлении.

    Контрольные вопросы к главе 6.

    1. Из каких компонентов состоит ориентированная сеть?

    1. Как строится матрица пропускных способностей сети?

    1. Как образуется матрица потока в сети?

    1. Для чего вычитаются матрицы пропускных способностей и потоков?

    1. Что такое и для чего служит сетевой график?

    1. Как определяются времена раннего начала и раннего окончания работ?

    1. Что представляет собой общий резерв времени для некоторого события на сетевом графике?

    1. Как определяется критический путь?

    1. Что называется вектором состояния некоторой системы?

    1. Что представляет собой траектория системы в пространстве состояний?

    1. В чем заключается задача оптимального управления?

    1. Как формулируется критерий оптимальности?

    1. Что представляет собой динамическое программирование?

    1. Сформулируйте принцип оптимальности Беллмана.

    1. В чем сущность алгоритмов прямой и обратной прогонки при поиске кратчайшего пути?

    Варианты заданий к главе 6.

    Для сетей в каждом из вариантов:

    1) Найти максимальный поток из источника (1) в конечный узел сети – сток, полагая, что одно из чисел в скобках у каждой дуги (i, j) определяет пропускную способность дуги;

    1) Полагая, что дуги (1)®(2), (1)®(3) и т. д. определяют некоторые работы, минимальная и максимальная продолжительность которых заданы числами, указанными при соответствующих дугах, найти критический путь от начального события (1) до конечного;

    1) Произвести поиск кратчайшего пути от начального узла до конечного узла сети. Считать расстояния между узлами i, j заданными одним из чисел в скобках.





    X 4

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

    О чём вообще речь? Что такое динамическое программирование?

    Метод решения задачи путём её разбиения на несколько одинаковых подзадач, рекуррентно связанных между собой. Самым простым примером будут числа Фибоначчи - чтобы вычислить некоторое число в этой последовательности, нам нужно сперва вычислить третье число, сложив первые два, затем четвёртое таким же образом на основе второго и третьего, и так далее (да, мы слышали про замкнутую формулу).

    Хорошо, как это использовать?

    Решение задачи динамическим программированием должно содержать следующее:

    И что, мне для решения рекурсивный метод писать надо? Я слышал, они медленные.

    Конечно, не надо, есть и другие подходы к реализации динамики. Разберём их на примере следующей задачи:

    Вычислить n-й член последовательности, заданной формулами:
    a 2n = a n ­+ a n-1 ,
    a 2n+1 = a n — a n-1 ,
    a 0 = a 1 = 1.

    Идея решения

    Здесь нам даны и начальные состояния (a 0 = a 1 = 1), и зависимости. Единственная сложность, которая может возникнуть - понимание того, что 2n - условие чётности числа, а 2n+1 - нечётности. Иными словами, нам нужно проверять, чётно ли число, и считать его в зависимости от этого по разным формулам.

    Рекурсивное решение

    Очевидная реализация состоит в написании следующего метода:

    Private static int f(int n){ if(n==0 || n==1) return 1; // Проверка на начальное значение if(n%2==0){ //Проверка на чётность return f(n/2)+f(n/2-1); // Вычисляем по формуле для чётных индексов, // ссылаясь на предыдущие значения }else{ return f((n-1)/2)-f((n-1)/2-1); // Вычисляем по формуле для нечётных //индексов, ссылаясь на предыдущие значения } }

    И она отлично работает, но есть нюансы. Если мы захотим вычислить f(12) , то метод будет вычислять сумму f(6)+f(5) . В то же время, f(6)=f(3)+f(2) и f(5)=f(2)-f(1) , т.е. значение f(2) мы будем вычислять дважды. Спасение от этого есть - мемоизация (кеширование значений).

    Рекурсивное решение с кэшированием значений

    Идея мемоизации очень проста - единожды вычисляя значение, мы заносим его в какую-то структуру данных. Перед каждым вычислением мы проверяем, есть ли вычисляемое значение в этой структуре, и если есть, используем его. В качестве структуры данных можно использовать массив, заполненный флаговыми значениями. Если значение элемента по индексу N равно значению флага, значит, мы его ещё не вычисляли. Это создаёт определённые трудности, т.к. значение флага не должно принадлежать множеству значений функции, которое не всегда очевидно. Лично я предпочитаю использовать хэш-таблицу - все действия в ней выполняются за O(1) , что очень удобно. Однако, при большом количестве значений два числа могут иметь одинаковый хэш, что, естественно, порождает проблемы. В таком случае стоит использовать, например, красно-чёрное дерево .

    Для уже написанной функции f(int) кэширование значений будет выглядеть следующим образом:

    Private static HashMap cache = new HashMap(); private static int fcashe(int n){ if(!cache.containsKey(n)){//Проверяем, находили ли мы данное значение cache.put(n, f(n)); //Если нет, то находим и записываем в таблицу } return cache.get(n); }

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

    Последовательное вычисление

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

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

    Суть метода в следующем: мы создаём массив на N элементов и последовательно заполняем его значениями. Вы, наверное, уже догадались, что таким образом мы можем вычислять в том числе те значения, которые для ответа не нужны. В значительной части задач на динамику этот факт можно опустить, так как для ответа часто бывают нужны как раз все значения. Например, при поиске наименьшего пути мы не можем не вычислять путь до какой-то точки, нам нужно пересмотреть все варианты. Но в нашей задаче нам нужно вычислять приблизительно log 2 (N) значений (на практике больше), для 922337203685477580-го элемента (MaxLong/10) нам потребуется 172 вычисления.

    Private static int f(int n){ if(n<2) return 1; //Может, нам и вычислять ничего не нужно? int fs = int[n]; //Создаём массив для значений fs=fs=1; //Задаём начальные состояния for(int i=2; i

    Ещё одним минусом такого подхода является сравнительно большой расход памяти.

    Создание стека индексов

    Сейчас нам предстоит, по сути, написать свою собственную рекурсию. Идея состоит в следующем - сначала мы проходим «вниз» от N до начальных состояний, запоминая аргументы, функцию от которых нам нужно будет вычислять. Затем возвращаемся «вверх», последовательно вычисляя значения от этих аргументов, в том порядке, который мы записали.

    Зависимости вычисляются следующим образом:

    LinkedList stack = new LinkedList(); stack.add(n); { LinkedList queue = new LinkedList(); //Храним индексы, для которых ещё не вычислены зависимости queue.add(n); int dum; while(queue.size()>0){ //Пока есть что вычислять dum = queue.removeFirst(); if(dum%2==0){ //Проверяем чётность if(dum/2>1){ //Если вычисленная зависимость не принадлежит начальным состояниям stack.addLast(dum/2); //Добавляем в стек queue.add(dum/2); //Сохраняем, чтобы //вычислить дальнейшие зависимости } if(dum/2-1>1){ //Проверяем принадлежность к начальным состояниям stack.addLast(dum/2-1); //Добавляем в стек queue.add(dum/2-1); //Сохрнаяем, чтобы //вычислить дальнейшие зависимости } }else{ if((dum-1)/2>1){ //Проверяем принадлежность к начальным состояниям stack.addLast((dum-1)/2); //Добавляем в стек queue.add((dum-1)/2); //Сохрнаяем, чтобы //вычислить дальнейшие зависимости } if((dum-1)/2-1>1){ //Проверяем принадлежность к начальным состояниям stack.addLast((dum-1)/2-1); //Добавляем в стек queue.add((dum-1)/2-1); //Сохрнаяем, чтобы //вычислить дальнейшие зависимости } } /* Конкретно для этой задачи есть более элегантный способ найти все зависимости, здесь же показан достаточно универсальный */ } }

    Полученный размер стека – то, сколько вычислений нам потребуется сделать. Именно так я получил упомянутое выше число 172.

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

    HashMap values = new HashMap(); values.put(0,1); //Важно добавить начальные состояния //в таблицу значений values.put(1,1); while(stack.size()>0){ int num = stack.removeLast(); if(!values.containsKey(num)){ //Эту конструкцию //вы должны помнить с абзаца о кешировании if(num%2==0){ //Проверяем чётность int value = values.get(num/2)+values.get(num/2-1); //Вычисляем значение values.add(num, value); //Помещаем его в таблицу }else{ int value = values.get((num-1)/2)-values.get((num-1)/2-1); //Вычисляем значение values.add(num, value); //Помещаем его в таблицу } }

    Все необходимые значения вычислены, осталось только написать

    Return values.get(n);

    Конечно, такое решение гораздо более трудоёмкое, однако это того стоит.

    Хорошо, математика - это красиво. А что с задачами, в которых не всё дано?

    Для больше ясности разберём следующую задачу на одномерную динамику:

    На вершине лесенки, содержащей N ступенек, находится мячик, который начинает прыгать по ним вниз, к основанию. Мячик может прыгнуть на следующую ступеньку, на ступеньку через одну или через 2. (То есть, если мячик лежит на 8-ой ступеньке, то он может переместиться на 5-ую, 6-ую или 7-ую.) Определить число всевозможных «маршрутов» мячика с вершины на землю.

    Идея решения

    На первую ступеньку можно попасть только одним образом - сделав прыжок с длиной равной единице. На вторую ступеньку можно попасть сделав прыжок длиной 2, или с первой ступеньки - всего 2 варианта. На третью ступеньку можно попасть сделав прыжок длиной три, с первой или со втрой ступенек. Т.е. всего 4 варианта (0->3; 0->1->3; 0->2->3; 0->1->2->3). Теперь рассмотрим четвёртую ступеньку. На неё можно попасть с первой ступеньки - по одному маршруту на каждый маршрут до неё, со второй или с третьей - аналогично. Иными словами, количество путей до 4-й ступеньки есть сумма маршрутов до 1-й, 2-й и 3-й ступенек. Математически выражаясь, F(N) = F(N-1)+F(N-2)+F(N-3) . Первые три ступеньки будем считать начальными состояниями.

    Реализация через рекурсию

    private static int f(int n){ if(n==1) return 1; if(n==2) return 2; if(n==3) return 4; return f(n-1)+f(n-2)+f(n-3); }

    Здесь ничего хитрого нет.

    Исходя из того, что, по большому счёту, простое решение на массиве из N элементов очевидно, я продемонстрирую тут решение на массиве всего из трёх.

    Int vars = new int; vars=1;vars=2;vars=4; for(int i=3; i

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

    Там вверху ещё было написано про какую-то двумерную динамику?..

    С двумерной динамикой не связано никаких особенностей, однако я, на всякий случай, рассмотрю здесь одну задачу и на неё.

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

    Идея решения

    Логика решения полностью идентична таковой в задаче про мячик и лестницу - только теперь в клетку (x,y) можно попасть из клеток (x-1,y) или (x, y-1) . Итого F(x,y) = F(x-1, y)+F(x,y-1) . Дополнительно можно понять, что все клетки вида (1,y) и (x,1) имеют только один маршрут - по прямой вниз или по прямой вправо.

    Реализация через рекурсию

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

    Private static int f(int i, int j) { if(i==1 || j==1) return 1; return f(i-1, j)+f(i, j-1); }

    Реализация через массив значений

    int dp = new int; for(int i=0; iКлассическое решение динамикой, ничего необычного - проверяем, является ли клетка краем, и задаём её значение на основе соседних клеток.

    Отлично, я всё понял. На чём мне проверить свои навыки?

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

    Взрывоопасность

    При переработке радиоактивных материалов образуются отходы двух видов - особо опасные (тип A) и неопасные (тип B). Для их хранения используются одинаковые контейнеры. После помещения отходов в контейнеры последние укладываются вертикальной стопкой. Стопка считается взрывоопасной, если в ней подряд идет более одного контейнера типа A. Стопка считается безопасной, если она не является взрывоопасной. Для заданного количества контейнеров N определить количество возможных типов безопасных стопок.

    Решение

    Ответом является (N+1)-е число Фибоначчи. Догадаться можно было, просто вычислив 2-3 первых значения. Строго доказать можно было, построив дерево возможных построений.


    Каждый основной элемент делится на два - основной (заканчивается на B) и побочный (заканчивается на A). Побочные элементы превращаются в основные за одну итерацию (к последовательности, заканчивающейся на A, можно дописать только B). Это характерно для чисел Фибоначчи.

    Реализация

    Например, так:

    //Ввод числа N с клавиатуры N+=2; BigInteger fib = new BigInteger; fib=fib=BigInteger.ONE; for(int i=2; i

    Подъём по лестнице

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

    Решение

    Очевидно, что сумма, которую мальчик отдаст на N-ой ступеньке, есть сумма, которую он отдал до этого плюс стоимость самой ступеньки. «Сумма, которую он отдал до этого» зависит от того, с какой ступеньки мальчик шагает на N-ую - с (N-1)-й или с (N-2)-й. Выбирать нужно наименьшую.

    Реализация

    Например, так:

    Int Imax; //*ввод с клавиатуры числа ступенек* DP = new int; for(int i=0; i

    Калькулятор

    Имеется калькулятор, который выполняет три операции:

    • Прибавить к числу X единицу;
    • Умножить число X на 2;
    • Умножить число X на 3.

    Определите, какое наименьшее число операций необходимо для того, чтобы получить из числа 1 заданное число N. Выведите это число, и, на следующей строке, набор исполненных операций вида «111231».

    Решение

    Наивное решение состоит в том, чтобы делить число на 3, пока это возможно, иначе на 2, если это возможно, иначе вычитать единицу, и так до тех пор, пока оно не обратится в единицу. Это неверное решение, т.к. оно исключает, например, возможность убавить число на единицу, а затем разделить на три, из-за чего на больших числах (например, 32718) возникают ошибки.

    Правильное решение заключается в нахождении для каждого числа от 2 до N минимального количества действий на основе предыдущих элементов, иначе говоря: F(N) = min(F(N-1), F(N/2), F(N/3)) + 1 . Следует помнить, что все индексы должны быть целыми.

    Для воссоздания списка действий необходимо идти в обратном направлении и искать такой индекс i, что F(i)=F(N) , где N - номер рассматриваемого элемента. Если i=N-1 , записываем в начало строки 1, если i=N/2 - двойку, иначе - тройку.

    Реализация
    int N; //Ввод с клавиатуры int a = new int; a= 0; { int min; for(int i=2; i1){ if(a[i]==a+1){ ret.insert(0, 1); i--; continue; } if(i%2==0&&a[i]==a+1){ ret.insert(0, 2); i/=2; continue; } ret.insert(0, 3); i/=3; } } System.out.println(a[N]); System.out.println(ret);

    Самый дешёвый путь

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

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