Привет, хабр! Сегодня мы поговорим о динамическом программировании.

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

И часто в заданиях, олимпиадах, или даже просто в жизни, попадаются задания на динамическое программирование.

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

(С) Доктор Аргентум, 2022 год нашей эры

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

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

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

recursion_vs_dynamic_prog

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

В самых простых случаях эта таблица будет состоять только из одной строки — аналогично обычному массиву. Эти случаи будут называться одномерным динамическим программированием, и потреблять O(n) памяти. Например, алгоритм эффективного вычисления чисел Фибоначчи использует обычный массив для запоминания вычисленных промежуточных результатов. Классический рекурсивный алгоритм делает очень много бессмысленный работы — он по миллионному разу рассчитывает то, что уже было рассчитано в соседних ветках рекурсии.

В самых распространённых случаях эта таблица будет выглядеть привычно и состоять из строчек и столбиков. Обычная таблица, похожая на таблицы из Excel. Это называется двумерным динамическим программированием, которое при n строках и n столбцах таблицы потребляет O(n*n) = O(n^2) памяти. Например, квадратная таблица из 10 строк и 10 столбцов будет содержать 100 ячеек. Чуть ниже будет подробно разобрана именно такая задача.

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

1d_vs_2d_vs_3d

Что нужно, чтобы решить задачу динамически, помимо ее исходных данных? Всего три вещи:

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

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

  • Правило выбора финального решения после заполнения таблицы

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

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

Представим хакера, который пытается взломать какой-то пароль перебором всех комбинаций символов. Если пароль допускает 10 цифр, 26 маленьких букв, 26 больших букв и 32 специальных символа (например, значок доллара), то для каждого символа в пароле есть 94 кандидата. Значит, чтобы взломать перебором пароль, состоящий из одного символа, потребуется 94 проверки. Если в пароле два символа — 94 кандидата на первую позицию, 94 кандидата на вторую позицию — то придется перебрать аж 94*94 = 8836 возможных пар. Для пароля из десяти символов потребуется уже перебор 94^10 комбинаций.

Если обобщить, то для взлома пароля с произвольной длиной N требуется O(94^N) операций. Такие затраты часто называют «экспоненциальными»: появление каждой новой позиции влечёт за собой увеличение количества операций в 94 раза. Взлом пароля может показаться чем-то экзотическим, но задачи, требующие полного перебора всех вариантов — совсем не экзотика, скорее угрюмая реальность.

Экспоненциальное время — это долго. Даже O(2^N) — это просто непозволительно долго. Настолько долго, что никому и в голову не придет запустить подобный алгоритм даже на простых данных — ведь на решение такой задачи с сотней элементов потребуются тысячи, миллионы или миллиарды лет вычислений. А в реальной жизни нужно решать задачи с намного большим количеством элементов. Как же быть?

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

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

Процесс разработки алгоритмов динамического программирования

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

  1. Описать структуру оптимального решения.

  2. Рекурсивно определить значение оптимального решения.

  3. Вычислить значение оптимального решения с помощью метода восходящего анализа.

  4. Составить оптимальное решение на основе полученной информации.

Динамика на примере чисел Фибоначчи

Один из легких примеров для демонстрации силы динамического программирования - известные числа Фибоначчи.

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

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

Формула для вычисления числа Фибоначчи представлена ниже:

F_{0} = 1F_{1} = 1F_{n} = F_{n-1} + F_{n-2}

Давайте создадим простейший алгоритм построения последовательности Фибоначчи на Python:

import timeit # замер времени


def fib(n):
    return 1 if n == 0 or n == 1 else fib(n - 1) + fib(n - 2)


# вывод времени работы 
print(timeit.timeit('fib(20)', number=100, globals=globals()))

>>> 2.2218473450011516

Отвратительно получилось. Алгоритм очень медленный. Так давайте улучшим его!

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

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

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

Но как же улучшить наш алгоритм?

Ответ простой - мемоизация, запоминание, или же кеширование.

Один из способов избежать постоянного пересчитывания одних и тех же данных – кешировать результаты вычислений. Технически это реализуется так:

  • Если кеш содержит результат для полученных входных данных, использовать значение из кеша.

  • В противном случае, вычислить результат и сохранить его в кеше, поставив в соответствие с входными данными.

import timeit


def fib(n, cache=None):
    if n == 0 or n == 1: return 1

    if cache is None: cache = {}
    if n in cache: return cache[n]

    result = fib(n - 1, cache) + fib(n - 2, cache)
    cache[n] = result
    
    return result


print(timeit.timeit('fib(20)', number=100, globals=globals()))

>>> 0.00414187600108562

Невероятно! Скорость выполнения повысилась в 2 раза!

Давайте улучшим и сделаем полный ее рефакторинг, но сам смысл оставим тот же - мемоизация, но применим также декораторы Python:

import timeit
from inspect import signature
 

class memoize(dict):
    def __init__(self, func):
        self.func = func
        self.signature = signature(func)
 
    def __missing__(self, key):
        (arg, kwarg) = key
        self[key] = val = self.func(*arg,  
                          **dict(kwarg))
        return val
 
    def __call__(self, *arg, **kwarg):
        key = self.signature.bind(*arg, 
                                  **kwarg)
        return self[key.args, 
                    frozenset(key.kwargs.items())]
 
 
@memoize
def Fibonacci(n): 
 
    if n<0: 
        print("Неверный ввод") 
 
    elif n == 0: 
        return 0
    elif n == 1: 
        return 1
 
    else: 
        return Fibonacci(n-1)+Fibonacci(n-2)
 
if __name__ == "__main__":
    print(Fibonacci(20)) 
    print(timeit.timeit('Fibonacci(20)', number=100, globals=globals()))

Зависимость от n практически линейна!

Но что насчет пространственной сложности? Для вычисления Fn нужно вычислить Fn-1 и Fn-2, и так далее до F0. Следовательно, придется кэшировать O(n) результатов. Значит, потребуется O(n) памяти.

Можно ли сделать лучше? В этом случае можно!

Динамическое программирование снизу вверх

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

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

Такая точка зрения позволяет сделать сразу несколько важных заключений. Прежде всего, у нас есть O(n) подпроблем. Кроме того, эта диаграмма является направленным ациклическим графом (DAG), что означает:

  • есть узлы (задачи) и ребра (зависимости между ними);

  • ребра имеют направление, одна подзадача зависит от другой;

  • нет циклов, значит нельзя начать с одной подзадачи и, следуя по стрелкам, вернуться к ней же.

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

Для ряда Фибоначчи этот порядок соответствует увеличению входных данных. То есть сначала мы должны вычислить F0, затем F1, F2 и так далее до Fn.

Есть еще одна важная вещь, которую мы можем вывести из DAG. Каждая подзадача зависит только от двух других. Если вы вычисляете Fm, то вам нужны только результаты Fm-1 и Fm-2, и совершенно не нужно Fm-10. То есть вы можете спокойно выбрасывать значения, которые не участвуют в текущем вычислении.

Формализация алгоритма

  1. Заведем две локальные переменные, которые будут представлять последние два числа Фибоначчи, с которыми мы работаем.

  2. Поместим в них F0(=1) и F1(=1)

  3. Изменяя i от 2 до n вычислим Fi. Каждая операция зависит только от Fi-1 и Fi-2, которые хранятся в локальных переменных. Получая результат, мы выбрасываем Fi-2, заменяем его на Fi-1, а в оставшуюся переменную записываем Fi.

В Python это выглядит так:

import timeit


def fib(n):
    a = 1  # f(i - 2)
    b = 1  # f(i - 1)
    for i in range(2, n + 1): 
        a, b = b, a + b
    return b
    

print(timeit.timeit('fib(20)', number=100, globals=globals()))

>>> 0.0005998830019962043

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

Этот подход называется "снизу-вверх" (bottom-up approach), так как основывается на решении небольших подзадач с целью построения более крупных.

Задача о рюкзаке

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

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

  1. Создаем матрицу dp размером (n + 1) x (W + 1), где n - количество предметов, W - вместимость рюкзака.

  2. Инициализируем первую строку и первый столбец матрицы dp нулями.

  3. Проходимся по каждому предмету i от 1 до n.

  4. Проходимся по каждой вместимости w от 1 до W.

  5. Если вес предмета i меньше или равен w, то dp[i][w] равно максимуму из значения dp[i-1][w] и стоимости предмета i плюс значения dp[i-1][w-вес предмета i].

  6. Иначе, dp[i][w] равно значению dp[i-1][w].

  7. Возвращаем значение в правом нижнем углу матрицы dp как результат.

import timeit


def knapsack(weights, values, capacity):
    n = len(weights)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        for w in range(1, capacity + 1):
            if weights[i - 1] <= w:
                dp[i][w] = max(dp[i - 1][w], values[i - 1] + dp[i - 1][w - weights[i - 1]])
            else:
                dp[i][w] = dp[i - 1][w]

    return dp[n][capacity]


weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
capacity = 7
print(timeit.timeit('knapsack(weights, values, capacity)', number=100, globals=globals()))

>>> 0.007788784998410847

Пример использования:

weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
capacity = 7
result = knapsack(weights, values, capacity)
print(result) # Вывод: 9

В данном примере мы выбираем предметы с весами 3, 4 и 5 и получаем максимальную суммарную стоимость равную 9.

Интересные примеры задач

Внешние ссылки на примеры задач, которые могут заинтересовать вас


Вот такая небольшая статья у нас получилось. Надеюсь вам понравилось, ставьте плюсы, и комментируйте! С вами был доктор Аргентум!

Комментарии (17)


  1. Kerman
    30.11.2023 16:10
    +10

    Cпособ решения сложных задач путём разбиения их на более простые подзадачи называется декомпозицией.


    1. obabichev
      30.11.2023 16:10
      +1

      В попытке ответить за ДП, возможно, факт того, что задача разбивается на более мелкие задачи того же класса (а не просто набор задач попроще), что и сама задача, стоит отдельного названия)


  1. sswwssww
    30.11.2023 16:10
    +1

    А можно пример решения этой же задачи методом "нединамического программирования"? Или все что не рекурсия это динамическое программирование? Хочу понять в чем тогда смысл этого термина.


    1. gev
      30.11.2023 16:10
      +1

      Даже если рекурсия, то все равно динамическое =) в некоторых языках только рекурсия и есть, например!


    1. wataru
      30.11.2023 16:10
      +1

      Какой именно из задач? Если чисел фибоначчи, то можно, например, реализовать рекуррентную формулу тупо по определению, но не делать мемоизации. Или можно перебирать все битовые строки заданной длины, не ставя две единицы подряд. Будет одинаковая экспоненциальная сложность по времени.

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


  1. alan008
    30.11.2023 16:10
    +9

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

    Хотя что-то я замечтался, проще обратиться к Википедии, там всё разложено по полочкам:

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

    Словосочетание «динамическое программирование» впервые было использовано в 1940-х годах Ричардом Беллманом для описания процесса нахождения решения задачи, где ответ на одну задачу может быть получен только после решения задачи, «предшествующей» ей.


    1. wataru
      30.11.2023 16:10
      +3

      но почему "программирование"

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


  1. Cels
    30.11.2023 16:10

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


  1. wataru
    30.11.2023 16:10
    +4

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

    Подход сверху вниз хорош тем, что он не счичтает лишние состояния - только те, которые реально влияют на ответ. Но ему нужен стек. Подход снизу вверх часто получается короче в реализации и позволяет, например, сократить используемую память, за счет хранения только двух строк/срезов всей таблицы (как в фиббоначи надо только 2 переменные).

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


  1. d_ilyich
    30.11.2023 16:10
    -5

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

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


    1. wataru
      30.11.2023 16:10
      +4

      Конечно, можно и к словам придераться, но в условии все однозначно расписано:

      а их суммарный вес не превышал вместимость рюкзака


      1. d_ilyich
        30.11.2023 16:10
        -3

        Имеется:

        • рюкзак, обладающий вместимостью 0,2 м3 и грузоподъёмностью 10 кг.

        • плюшевый гремлин объёмом 1 м3 и массой 3 кг

        • металлическая болванка объёмом 0,03 м3 и массой 32 кг.

        Гремлин проходит по массе, а по габаритам - нет.
        Болванка проходит по габаритам, а по массе - нет.

        Догадаться, разумеется, можно, особенно при наличии примеров. Но лично я считаю, что нужно стараться избегать неоднозначности трактовок. Чёткое формулирование - это тоже искусство. Реальность существует


        1. wataru
          30.11.2023 16:10
          +3

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


  1. CrazyElf
    30.11.2023 16:10
    +1

    2.2218473450011516
    Отвратительно получилось. Алгоритм очень медленный. Так давайте улучшим его!
    ...
    0.00414187600108562
    Невероятно! Скорость выполнения повысилась в 2 раза!
    ...
    [вывод на печать не показан]
    Зависимость от n практически линейна!

    Что тут вообще происходит?!

    • 2/0.004 = 500 раз вообще-то, а не 2 раза

    • Как можно делать выводы о линейной зависимости от n, посчитав результат только один раз (и не показав его в статье) для конкретного n? Вы умеете рисовать линию по одной (невидимой) точке?


    1. esaulenka
      30.11.2023 16:10
      +2

      настоящий профессионал умеет рисовать целых 7 перпендикулярных линий!


      1. gev
        30.11.2023 16:10
        +1

        И котенка!


  1. Deirel
    30.11.2023 16:10

    Очень наглядно и понятно разобрана задача для ряда Фибоначчи. Но следом за ней - сразу решение для задачи рюкзака, без каких-либо объяснений. При том, что она уже двухмерная, то есть это другой уровень сложности. Это домашнее задание типа "изучи сам"? А статья тогда зачем?

    Слишком резкий скачок от максимально понятного изложения к непонятному.