О чём лекция

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

Как читать

Доказательства и асимптотики в блоках «Не из лекции» дописаны к конспекту: на лекции их не было. Всё остальное — с доски.

Маршрут

  1. Что такое ДП → Динамическое программирование
  2. Числа Фибоначчи тремя способами → Рекурсия
  3. Сравнение способов → Асимптотические оценки
  4. Одномерная динамика: кузнечик, лесенка, панда
  5. Двумерная динамика: число сочетаний
  6. Сложная динамика: разбиения на слагаемые
  7. Жадность против ДП → Жадный алгоритм

1. Определение

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

— @неизвестный автор

Примеры: MergeSort, Qsort, бинпоиск.

Это «разделяй и властвуй», а не ДП

Сортировка слиянием, Быстрая сортировка и Бинарный поиск действительно разбивают задачу на подзадачи, но подзадачи у них не повторяются: каждая половина массива сортируется один раз. Такой подход называют «разделяй и властвуй» (divide and conquer).

ДП нужно, когда подзадачи перекрываются — одна и та же встречается много раз, как в вычислении и , и . Тогда ответ выгодно запомнить. У сортировки слиянием запоминать нечего.

Не из лекции: из чего состоит решение динамикой

Все задачи лекции решаются по одной схеме:

  1. Состояние — что лежит в F[N]: «число способов добраться до клетки », «минимум ударов до ступени ».
  2. Переход — формула через меньшие состояния. На доске она названа инвариантом ДП.
  3. База — граничные условия: значения, которые известны без формулы.
  4. Порядок — считать так, чтобы к моменту F[N] всё нужное уже было посчитано.
  5. Ответ — какое состояние (или их комбинация) отвечает на вопрос.

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

2. Числа Фибоначчи

Каждое число — сумма двух предыдущих.

Рекуррентная формула (инвариант ДП):

Граничные условия: , .

1) Рекурсивный способ

функция fib(n)
    если n ≤ 2
        return 1
    иначе
        return fib(n − 1) + fib(n − 2)

Ответ: fib(n).

2) Табличный способ

table = [ ... ]
table[1] = table[2] = 1
for i = 3 ... N
    table[i] = table[i − 1] + table[i − 2]

Ответ: table[N].

3) Линейный способ

a = b = 1
c = 0
for i = 3 ... N
    c = a + b
    print(c)
    a = b
    b = c

c ← ответ.

Для следующего числа нужны только два предыдущих — их и хранят. На рисунке: A и B стоят на , C = A + B даёт , затем A = B, B = C — окно из двух чисел сдвигается вправо.

Не из лекции: граничный случай линейного способа

При и цикл не выполняется, и c остаётся 0, хотя . Надёжнее отвечать b: после цикла это всегда при (при — тоже 1).

Не из лекции: почему рекурсия здесь медленная

Вызов fib(n) вызывает fib(n − 1) и fib(n − 2), а fib(n − 1) снова вызывает fib(n − 2). Одни и те же значения пересчитываются заново.

Число вызовов. Пусть — сколько вызовов делает fib(n). , . Индукцией проверяется : база , переход . Проверено запуском: fib(30) = 832 040 делает 1 664 079 вызовов.

Сколько это. растёт как , где . Хватает и грубой оценки: , значит, — время рекурсии экспоненциальное.

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

3. Сравнение методов

ПамятьВремяПростотаХранение промежуточных данных
Рекурсивный±−+−
Табличный−±±+
Линейный++−−

Не из лекции: оценки за плюсами и минусами

ВремяПамять
Рекурсивный — экспонента на стек
Табличный на таблицу
Линейный

«Хранение промежуточных данных» — плюс таблицы: после одного прогона известны все , и на много запросов можно отвечать сразу. Линейный способ помнит только последние два.

Рекурсию можно ускорить, не меняя её вида: запоминать уже посчитанные значения в таблице и не вызывать функцию повторно (мемоизация). Это ДП «сверху вниз», табличный способ — «снизу вверх».

4. Примеры

а) Кузнечик

Задача. Кузнечик прыгает на или . Сколько способов добраться из 0 в ?

 ⌒⌒⌒⌒ ⌒ . . .     ⌒⌒ ▶
●───────────────────●────▶
0                   N

В клетку можно попасть только из (прыжок ) или из (прыжок ):

Не из лекции: почему формула верна

Разделим все способы попасть в по последнему прыжку. Если он , до него кузнечик был в и попал туда любым из способов. Если — из , способов. Группы не пересекаются (последние прыжки разные) и покрывают всё — отсюда сумма.

: в 0 кузнечик уже стоит — один «пустой» путь. Без этого вышло бы 1 вместо 2 (прыжки и ).

Это числа Фибоначчи со сдвигом: . Проверено: .

б) Кузнечик +1, +2, +3

Нужны три граничных значения: формула заглядывает на три назад.

в) Лесенка

Задача: минимизировать сумму ударов плетью. На ступени — cost[N] ударов, шаг или .

                 ▶
            2  ▄▄┃      ← ступень N
          7 ┃
        1 ┃
     3 ┃
   2 ┃
  ━━━┛

F[N] — накопительное значение: минимум ударов, чтобы встать на ступень . На неё приходят с или :

cost[N] — сколько ударов на -ой ступени.

Не из лекции: почему можно брать минимум

Оптимальный путь до приходит с или , и его часть до предпоследней ступени — тоже оптимальный путь до неё: иначе её можно было бы заменить на более дешёвую и улучшить весь путь. Значит, .

Пример по рисунку (ступени снизу вверх, похоже, 2, 3, 1, 7, 2): . Ответ 5: путь (). Ступени с тремя и семью ударами перепрыгиваем.

Время , память , а если нужен только ответ — , как у линейного Фибоначчи.

г) Панда и пельмешки

Больше пельменей!!! Больших!

Та же лесенка, но теперь на ступенях пельмени и их нужно собрать как можно больше:

Не из лекции

Если все cost положительные, задача вырождается: выгодно наступить на каждую ступень, и ответ — просто сумма. По рисунку , и формула даёт то же. Интересной задача становится, когда бывают отрицательные значения — ступени, которые лучше перепрыгнуть.

5. Двумерная динамика

Число сочетаний:

А) По формуле

— цикл, .

Б) Рекуррентно

функ. c(n, k)
    если k = 0 OR n = k
        return 1
    return c(n − 1, k) + c(n − 1, k − 1)

Не из лекции: зачем второй способ

Переполнение. Формула с факториалами считает огромные промежуточные числа: , и long long переполняется, хотя сам помещается даже в int. Рекуррентный способ только складывает, и промежуточные значения не больше ответа.

Почему формула верна. Выбираем предметов из . Последний предмет либо не взят — тогда все выбраны из остальных (), либо взят — тогда из остальных выбрано ().

Почему «двумерная». Состояние — пара , и таблица — двумерная: треугольник Паскаля. Но на доске записана рекурсия без таблицы, и она медленная по той же причине, что fib: вызовов . Проверено: делает 310 235 039 вызовов. С таблицей table[i][j] = table[i−1][j] + table[i−1][j−1] по строкам — времени.

6. Сложная динамика

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

4 = 1 + 1 + 1 + 1
4 = 2 + 1 + 1 = 1 + 2 + 1 = 1 + 1 + 2
4 = 2 + 2
4 = 1 + 3
4 = 4

Строка 2 + 1 + 1 = 1 + 2 + 1 = 1 + 1 + 2 — это одно разбиение, записанное в разном порядке. Всего у 4 пять разбиений.

а) Одномерная попытка

F[k] — количество способов разбить число :

Слагаемые подписаны как первое слагаемое разбиения: , , …, ; единица — само .

Учитываем порядок ⇒ не даёт ответ.

Не из лекции: что считает эта формула

Она считает упорядоченные суммы (композиции): и идут как разные. Из формулы , то есть . Для 4 выходит 8 вместо 5.

б) Двумерная

F[i][k] ← количество способов разбить на слагаемые, где максимальное слагаемое .

Пример на доске: , , . Убираем максимальное слагаемое 3, остаётся 2 — его разбиения с максимумом не больше 3.

Не из лекции: доказательство и ответ

Порядок перестаёт мешать, если записывать слагаемые по невозрастанию: каждое разбиение тогда записывается единственным образом.

Переход. Разбиение с максимумом ровно при — это плюс разбиение остатка , у которого максимум . Наоборот, к любому такому разбиению остатка можно приписать — получится разбиение с максимумом . Это взаимно однозначно, поэтому .

База. : максимум больше суммы — 0 способов. : само число, один способ.

Ответ: разбиений числа всего . Проверено запуском для : 1, 2, 3, 5, 7, 11, 15, 22, 30, 42.

Порядок и время. Строки по возрастанию : использует только меньшие . Состояний , на каждое сумма из слагаемых: . Если хранить префиксные суммы , переход занимает , и всё — .

7. Жадное программирование vs динамическое

Пример: номиналы монет, выдать клиенту сумму — меньше монет. 6 рублей, номиналы .

Жадно:

  1. сортируем по убыванию;
  2. беру максимально, пока имеет смысл.

Динамика. Монеты . F[N] — количество монет:

Не из лекции: в чём пример

На доске выписан только жадный ответ — три монеты. Динамика находит две: . Проверено запуском: .

Жадный алгоритм берёт лучшее на текущем шаге (самую крупную монету) и никогда не пересматривает решение. Здесь первая же четвёрка портит ответ: остаток 2 набирается только единицами. Динамика перебирает все варианты последней монеты и берёт лучший — поэтому не ошибается.

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

Детали. Минимум берётся только по ; если сумму набрать нельзя, . Время .

Для обычных номиналов вроде жадность даёт оптимум, но это свойство конкретного набора, а не алгоритма. См. Жадный алгоритм.

Грабли

  • Рекурсия без запоминания на перекрывающихся подзадачах — экспоненциальное время: fib(n), .
  • Граничных условий столько, на сколько назад смотрит формула: у кузнечика — три.
  • в задачах на число способов — пустой путь тоже способ.
  • Линейный Фибоначчи с ответом c неверен при .
  • Одномерная динамика для разбиений считает упорядоченные суммы.
  • Факториалы в формуле сочетаний переполняются задолго до ответа.
  • Жадный выбор монет не всегда оптимален: , сумма 6.
  • MergeSort и бинпоиск — не ДП, а «разделяй и властвуй»: подзадачи не повторяются.

После этой лекции должно получаться

  • объяснить, чем ДП отличается от простой рекурсии;
  • посчитать тремя способами и сравнить их по времени и памяти;
  • доказать, что рекурсивный fib экспоненциален;
  • вывести формулу кузнечика через последний прыжок и объяснить ;
  • решить лесенку на минимум и на максимум;
  • посчитать без переполнения;
  • объяснить, почему разбиения нужно считать двумерной динамикой, и вывести формулу для F[i][k];
  • показать набор монет, на котором жадность ошибается, и решить задачу динамикой.