В этой статье будут разобраны классические задачи на последовательности, одномерную и двумерную динамику, будет дано обоснование решениям и описаны разные подходы к их реализации. Весь приведённый в статье код написан на Java. О чём вообще речь? Что такое динамическое программирование? Динамическое программирование — метод решения задачи путём её разбиения на несколько одинаковых подзадач, рекуррентно связанных между собой. Самым простым примером будут числа Фибоначчи — чтобы вычислить некоторое число в этой последовательности, нам нужно сперва вычислить третье число, сложив первые два, затем четвёртое таким же образом на основе второго и третьего, и так далее (да, мы слышали про замкнутую формулу). Хорошо, как это использовать?
Решение задачи динамическим программированием должно содержать следующее: И что, мне для решения рекурсивный метод писать надо? Я слышал, они медленные. Конечно, не надо, есть и другие подходы к реализации динамики.
Разберём их на примере следующей задачи: Вычислить n- й член последовательности, заданной формулами: a. Единственная сложность, которая может возникнуть — понимание того, что 2n — условие чётности числа, а 2n+1 — нечётности. Иными словами, нам нужно проверять, чётно ли число, и считать его в зависимости от этого по разным формулам. Рекурсивное решение.
Очевидная реализация состоит в написании следующего метода: private static int f(int n). Если мы захотим вычислить f(1. В то же время, f(6)=f(3)+f(2) и f(5)=f(2)- f(1), т.
Спасение от этого есть — мемоизация (кеширование значений). Рекурсивное решение с кэшированием значений. Идея мемоизации очень проста — единожды вычисляя значение, мы заносим его в какую- то структуру данных. Перед каждым вычислением мы проверяем, есть ли вычисляемое значение в этой структуре, и если есть, используем его. Карта Кореличского Района Гродненской Области тут.
В качестве структуры данных можно использовать массив, заполненный флаговыми значениями. Если значение элемента по индексу N равно значению флага, значит, мы его ещё не вычисляли. Это создаёт определённые трудности, т. Лично я предпочитаю использовать хэш- таблицу — все действия в ней выполняются за O(1), что очень удобно. Однако, при большом количестве значений два числа могут иметь одинаковый хэш, что, естественно, порождает проблемы. В таком случае стоит использовать, например, красно- чёрное дерево.
Для уже написанной функции f(int) кэширование значений будет выглядеть следующим образом: private static Hash. Map< Integer, Integer> cache = new Hash. Map< Integer, Integer> (). Зато это избавляет от огромного числа операций.
Платите вы за это лишним расходом памяти. Последовательное вычисление. Теперь вернёмся к тому, с чего начали — рекурсия работает медленно. Не слишком медленно, чтобы это приносило действительные неприятности в настоящей жизни, но на соревнованиях по спортивному программированию каждая миллисекунда на счету. Метод последовательного вычисления подходит, только если функция ссылается исключительно на элементы перед ней — это его основной, но не единственный минус. Наша задача этому условию удовлетворяет. Инструкция По Ремонту Ниссан Примера.
Суть метода в следующем: мы создаём массив на N элементов и последовательно заполняем его значениями. Вы, наверное, уже догадались, что таким образом мы можем вычислять в том числе те значения, которые для ответа не нужны. В значительной части задач на динамику этот факт можно опустить, так как для ответа часто бывают нужны как раз все значения. Например, при поиске наименьшего пути мы не можем не вычислять путь до какой- то точки, нам нужно пересмотреть все варианты. Но в нашей задаче нам нужно вычислять приблизительно log. N) значений (на практике больше), для 9. Max. Long/1. 0) нам потребуется 1.
Идея состоит в следующем — сначала мы проходим «вниз» от N до начальных состояний, запоминая аргументы, функцию от которых нам нужно будет вычислять. Затем возвращаемся «вверх», последовательно вычисляя значения от этих аргументов, в том порядке, который мы записали. Зависимости вычисляются следующим образом: Linked. List< Integer> stack = new Linked.
List< Integer> (). Именно так я получил упомянутое выше число 1. Теперь мы поочередно извлекаем индексы и вычисляем для них значения по формулам – гарантируется, что все необходимые значения уже будут вычислены. Хранить будем как раньше – в хэш- таблице. Hash. Map< Integer,Integer> values = new Hash. Map< Integer,Integer> (). Важно добавить начальные состояния.
А что с задачами, в которых не всё дано? Для больше ясности разберём следующую задачу на одномерную динамику: На вершине лесенки, содержащей N ступенек, находится мячик, который начинает прыгать по ним вниз, к основанию. Мячик может прыгнуть на следующую ступеньку, на ступеньку через одну или через 2. На вторую ступеньку можно попасть сделав прыжок длиной 2, или с первой ступеньки — всего 2 варианта. На третью ступеньку можно попасть сделав прыжок длиной три, с первой или со втрой ступенек. Теперь рассмотрим четвёртую ступеньку. На неё можно попасть с первой ступеньки — по одному маршруту на каждый маршрут до неё, со второй или с третьей — аналогично.
Иными словами, количество путей до 4- й ступеньки есть сумма маршрутов до 1- й, 2- й и 3- й ступенек. Математически выражаясь, F(N) = F(N- 1)+F(N- 2)+F(N- 3). Первые три ступеньки будем считать начальными состояниями. Реализация через рекурсиюprivate static int f(int n). В приведённом выше коде мы записываем новое значение на место самого старого, не нужного больше. Цикличность остатка от деления на 3 помогает нам избежать кучи условных операторов.
Просто, компактно, элегантно. Там вверху ещё было написано про какую- то двумерную динамику? С двумерной динамикой не связано никаких особенностей, однако я, на всякий случай, рассмотрю здесь одну задачу и на неё. В прямоугольной таблице Nx. M в начале игрок находится в левой верхней клетке. За один ход ему разрешается перемещаться в соседнюю клетку либо вправо, либо вниз (влево и вверх перемещаться запрещено). Посчитайте, сколько есть способов у игрока попасть в правую нижнюю клетку.
Идея решения. Логика решения полностью идентична таковой в задаче про мячик и лестницу — только теперь в клетку (x,y) можно попасть из клеток (x- 1,y) или (x, y- 1). Итого F(x,y) = F(x- 1, y)+F(x,y- 1). Дополнительно можно понять, что все клетки вида (1,y) и (x,1) имеют только один маршрут — по прямой вниз или по прямой вправо.
Реализация через рекурсию. Ради всего святого, не нужно делать двумерную динамику через рекурсию. Уже было упомянуто, что рекурсия менее выгодна, чем цикл по быстродействию, так двумерная рекурсия ещё и читается ужасно. Это только на таком простом примере она смотрится легко и безобидно. На чём мне проверить свои навыки?
В заключение приведу ряд типичных задач на одномерную и двумерную динамику, разборы прилагаются. Взрывоопасность. При переработке радиоактивных материалов образуются отходы двух видов — особо опасные (тип A) и неопасные (тип B). Для их хранения используются одинаковые контейнеры.
После помещения отходов в контейнеры последние укладываются вертикальной стопкой. Стопка считается взрывоопасной, если в ней подряд идет более одного контейнера типа A. Стопка считается безопасной, если она не является взрывоопасной. Для заданного количества контейнеров N определить количество возможных типов безопасных стопок. Решение. Ответом является (N+1)- е число Фибоначчи. Догадаться можно было, просто вычислив 2- 3 первых значения.
Строго доказать можно было, построив дерево возможных построений. Каждый основной элемент делится на два — основной (заканчивается на B) и побочный (заканчивается на A).
Побочные элементы превращаются в основные за одну итерацию (к последовательности, заканчивающейся на A, можно дописать только B). Это характерно для чисел Фибоначчи. Реализация. Например, так: //Ввод числа N с клавиатуры. Big. Integer. Чтобы наступить на любую ступеньку, нужно заплатить указанную на ней сумму.