Що таке динамічне програмування? [зачинено]


276

Що таке динамічне програмування ?

Чим вона відрізняється від рекурсії, запам'ятовування тощо?

Я прочитав статтю у Вікіпедії , але ще не розумію.


1
Ось один підручник Майкла А. Тріка з КМУ, який мені здався особливо корисним: mat.gsia.cmu.edu/classes/dynamic/dynamic.html Звичайно, крім усіх ресурсів, які рекомендували інші (всі інші ресурси, зокрема CLR і Kleinberg, Tardos - це дуже добре!). Причина, чому мені подобається цей підручник, полягає в тому, що він вводить передові поняття досить поступово. Це трохи старуватий матеріал, але він є хорошим доповненням до списку представлених тут ресурсів. Ознайомтесь також зі сторінкою Стівена Скіени та лекціями щодо динамічного програмування: cs.sunysb.edu/~algorith/video-lectures http:
Едмон,

11
Я завжди вважав "Динамічне програмування" заплутаним терміном - "Динамічне" підказує нестатичне, але що таке "Статичне програмування"? І "... Програмування" приводить до уваги "Об'єктно-орієнтоване програмування" та "Функціональне програмування", припускаючи, що DP є парадигмою програмування. У мене насправді немає кращої назви (можливо, "Динамічні алгоритми"?), Але шкода, що ми застрягли з цим.
dimo414

3
@ dimo414 "Програмування" тут більше пов'язане з "лінійним програмуванням", яке підпадає під клас математичних методів оптимізації. Див. Статтю Математична оптимізація для переліку інших методів математичного програмування.
syockit

1
@ dimo414 "Програмування" в цьому контексті стосується табличного методу, а не написання комп'ютерного коду. -
Coreman

Проблема мінімізації вартості квитків на автобус, описана в cs.stackexchange.com/questions/59797/… , найкраще вирішується в динамічному програмуванні.
truthadjustr

Відповіді:


210

Динамічне програмування - це коли ви використовуєте минулі знання, щоб полегшити вирішення майбутньої проблеми.

Хорошим прикладом є розв’язування послідовності Фібоначчі для n = 1,000,002.

Це буде дуже тривалим процесом, але що робити, якщо я дам вам результати за n = 1 000 000 і n = 1 000 001? Раптом проблема просто стала більш вирішеною.

Динамічне програмування багато використовується в рядкових проблемах, таких як проблема редагування рядків. Ви вирішуєте підмножину (и) проблеми, а потім використовуєте цю інформацію для вирішення більш складної вихідної проблеми.

За допомогою динамічного програмування ви зазвичай зберігаєте результати в якійсь таблиці. Коли вам потрібна відповідь на проблему, ви посилаєтесь на таблицю і бачите, чи вже ви знаєте, що це таке. Якщо ні, то використовуйте дані таблиці, щоб дати собі перехід до відповіді.

У книзі «Алгоритми Кормена» є чудова глава про динамічне програмування. І це безкоштовно в Google Книгах! Перевірте це тут.


50
Ти не просто описав запам'ятовування?
dreadwail

31
Я б сказав, що запам'ятовування - це форма динамічного програмування, коли запам'ятована функція / метод є рекурсивною.
Даніель Хакстеп

6
Хороша відповідь, додала б лише згадку про оптимальну підструктуру (наприклад, кожен підмножина будь-якого шляху по найкоротшому шляху від А до В є самим коротким шляхом між двома кінцевими точками, припускаючи метрику відстані, яка дотримується нерівності трикутника).
Ши

5
Я б не сказав "легше", але швидше. Поширене непорозуміння полягає в тому, що dp вирішує проблеми, які наивні алгоритми не можуть, і це не так. Це не питання функціональності, а продуктивності.
andandandand

6
Використовуючи запам'ятовування, проблеми з динамічним програмуванням можна вирішити зверху вниз. тобто викликає функцію для обчислення кінцевого значення, а ця функція в свою чергу викликає її саморекурсивно для вирішення підпроблем. Без цього проблеми з динамічним програмуванням можна вирішити лише знизу вгору.
Пранів

175

Динамічне програмування - це техніка, що використовується для уникнення обчислення декількох разів однієї і тієї ж підпрограми в рекурсивному алгоритмі.

Візьмемо простий приклад чисел Фібоначчі: знаходження n- го числа Фібоначчі, визначеного

F n = F n-1 + F n-2 і F 0 = 0, F 1 = 1

Рекурсія

Очевидний спосіб зробити це рекурсивно:

def fibonacci(n):
    if n == 0:
        return 0
    if n == 1:
        return 1

    return fibonacci(n - 1) + fibonacci(n - 2)

Динамічне програмування

  • Вгору вниз - Пам'ять

Рекурсія робить багато непотрібних обчислень, оскільки задане число Фібоначчі буде обчислено в кілька разів. Простий спосіб покращити це кешування результатів:

cache = {}

def fibonacci(n):
    if n == 0:
        return 0
    if n == 1:
        return 1
    if n in cache:
        return cache[n]

    cache[n] = fibonacci(n - 1) + fibonacci(n - 2)

    return cache[n]
  • Знизу вгору

Кращий спосіб зробити це - позбутися від рекурсії разом, оцінивши результати в правильному порядку:

cache = {}

def fibonacci(n):
    cache[0] = 0
    cache[1] = 1

    for i in range(2, n + 1):
        cache[i] = cache[i - 1] +  cache[i - 2]

    return cache[n]

Ми навіть можемо використовувати постійний простір і зберігати лише необхідні часткові результати по дорозі:

def fibonacci(n):
  fi_minus_2 = 0
  fi_minus_1 = 1

  for i in range(2, n + 1):
      fi = fi_minus_1 + fi_minus_2
      fi_minus_1, fi_minus_2 = fi, fi_minus_1

  return fi
  • Як застосовувати динамічне програмування?

    1. Знайдіть рекурсію в проблемі.
    2. Зверху вниз: зберігайте відповідь на кожну підпроблему в таблиці, щоб уникнути необхідності їх перерахування.
    3. Знизу вгору: знайдіть правильний порядок для оцінки результатів, щоб часткові результати були доступні при необхідності.

Динамічне програмування зазвичай працює для проблем, які мають властивий порядок зліва направо, такі як рядки, дерева або цілі послідовності. Якщо наївний рекурсивний алгоритм не обчислює одну і ту ж підпроблему кілька разів, динамічне програмування не допоможе.

Я створив збірку проблем, щоб допомогти зрозуміти логіку: https://github.com/tristanguigue/dynamic-programing


3
Це чудова відповідь, і колекція проблем на Github також дуже корисна. Дякую!
p4sh4

Тільки з цікавості, щоб уточнити речі - на вашу думку, рекурсивна реалізація, що використовує відношення рецидивів та запам'ятовування, - це динамічне програмування?
Кодор

Дякую за пояснення. Чи відсутня умова знизу вгору: if n in cacheяк у прикладі зверху вниз чи я щось пропускаю.
DavidC

Чи правильно я розумію тоді, що будь-який цикл, у якому значення, обчислені при кожній ітерації, використовуються в наступних ітераціях, є прикладом динамічного програмування?
Олексій

Чи можете ви надати якісь посилання на тлумачення, яке ви давали, включаючи спеціальні випадки зверху вниз та знизу?
Олексій

37

Пам'ять - це коли ви зберігаєте попередні результати функціонального виклику (реальна функція завжди повертає одне і те ж, з урахуванням однакових входів). Це не має значення для алгоритмічної складності перед збереженням результатів.

Рекурсія - це метод виклику функції, як правило, з меншим набором даних. Оскільки більшість рекурсивних функцій можна перетворити на подібні ітеративні функції, це також не має значення для алгоритмічної складності.

Динамічне програмування - це процес вирішення більш легких для вирішення підзадач і побудови відповіді з цього. Більшість алгоритмів DP будуть у часі роботи між алгоритмом жадібного (якщо такий існує) та експоненціальним (перерахувати всі можливості та знайти найкращий) алгоритм.

  • Алгоритми DP можуть бути реалізовані з рекурсією, але вони не повинні бути.
  • Алгоритми DP не можуть бути прискорені запам'ятовуванням, оскільки кожна підпроблема вирішується лише колись (або називається функція "вирішити" один раз).

Дуже чітко кажучи. Я б хотів, щоб інструктори з алгоритму могли це добре пояснити.
Келлі С. Французька

21

Це оптимізація вашого алгоритму, що скорочує час роботи.

Хоча жадібний алгоритм зазвичай називають наївним , оскільки він може працювати кілька разів над одним і тим самим набором даних, Динамічне програмування дозволяє уникнути цієї невдачі через глибше розуміння часткових результатів, які необхідно зберегти, щоб допомогти побудувати остаточне рішення.

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

Ось приклад проблеми, яка підходить для динамічного програмування, від інтернет-судді UVA: Edit Steps Ladder.

Я збираюся зробити короткий інструктаж важливої ​​частини аналізу цієї проблеми, взятої із книги Програми програмування, я пропоную вам це перевірити.

Погляньте на цю проблему, якщо ми визначимо функцію витрат, яка говорить нам про те, наскільки далеко розташовані два рядки, у нас два розглянемо три природні типи змін:

Заміна - зміна одного символу з шаблону "s" на інший символ у тексті "t", наприклад зміна "shot" на "spot".

Вставка - вставити один символ у шаблон "s", щоб допомогти йому відповідати тексту "t", наприклад, змінити "назад" на "agog".

Видалення - видаліть один символ із шаблону "s", щоб допомогти йому відповідати тексту "t", наприклад, змінивши "годину" на "наше".

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

Ми можемо визначити рекурсивний алгоритм, використовуючи зауваження, що останній символ у рядку повинен бути або узгодженим, заміщеним, вставленим або видаленим. Відсікання символів в останній операції редагування залишає, що пара операції залишає пару менших рядків. Нехай i і j є останнім символом відповідного префікса та і відповідно. є три пари коротших рядків після останньої операції, що відповідають рядку після відповідності / заміни, вставки або видалення. Якби ми знали вартість редагування трьох пар менших рядків, ми могли б вирішити, який варіант призводить до найкращого рішення, і вибрати відповідний варіант відповідно. Ми можемо дізнатися цю ціну через приголомшливу річ:

#define MATCH 0 /* enumerated type symbol for match */
#define INSERT 1 /* enumerated type symbol for insert */
#define DELETE 2 /* enumerated type symbol for delete */


int string_compare(char *s, char *t, int i, int j)

{

    int k; /* counter */
    int opt[3]; /* cost of the three options */
    int lowest_cost; /* lowest cost */
    if (i == 0) return(j * indel(’ ’));
    if (j == 0) return(i * indel(’ ’));
    opt[MATCH] = string_compare(s,t,i-1,j-1) +
      match(s[i],t[j]);
    opt[INSERT] = string_compare(s,t,i,j-1) +
      indel(t[j]);
    opt[DELETE] = string_compare(s,t,i-1,j) +
      indel(s[i]);
    lowest_cost = opt[MATCH];
    for (k=INSERT; k<=DELETE; k++)
    if (opt[k] < lowest_cost) lowest_cost = opt[k];
    return( lowest_cost );

}

Цей алгоритм правильний, але також неможливо повільний.

Працюючи на нашому комп’ютері, потрібно кілька секунд, щоб порівняти два 11-символьних рядки, і обчислення зникають у ніколи-ніколи не приземляються ні на що більше.

Чому алгоритм такий повільний? Це займає експоненціальний час, тому що він перераховує значення знову і знову і знову. При кожній позиції в рядку рекурсія розгалужується трьома способами, тобто зростає зі швидкістю щонайменше 3 ^ n - дійсно, навіть швидше, оскільки більшість викликів зменшують лише один з двох індексів, а не обидва.

Тож як ми можемо зробити алгоритм практичним? Важливе зауваження полягає в тому, що більшість цих рекурсивних дзвінків обчислюють речі, які вже були обчислені раніше. Звідки ми знаємо? Ну, там можуть бути лише | s | · | Т | можливі унікальні рекурсивні виклики, оскільки існує лише стільки різних пар (i, j), які служать параметрами рекурсивних викликів.

Зберігаючи значення для кожної з цих пар (i, j) у таблиці, ми можемо уникнути їх перерахунку та просто переглянути їх у міру необхідності.

Таблиця - двовимірна матриця m, де кожен із | s | · | t | комірки містять вартість оптимального рішення цієї підпрограми, а також батьківський вказівник, що пояснює, як ми дісталися до цього місця:

typedef struct {
int cost; /* cost of reaching this cell */
int parent; /* parent cell */
} cell;

cell m[MAXLEN+1][MAXLEN+1]; /* dynamic programming table */

Динамічна версія програмування має три відмінності від рекурсивної.

По-перше, він отримує свої проміжні значення, використовуючи пошук таблиці замість рекурсивних викликів.

** По-друге, ** він оновлює батьківське поле кожної комірки, що дозволить нам реконструювати послідовність редагування пізніше.

** По-третє, ** По-третє, він інструментується за допомогою більш загальної cell()функції цілі, а не просто повернення m [| s |] [| t |] .cost. Це дасть нам змогу застосувати цю процедуру до ширшого класу проблем.

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

Ось альтернативне повне рішення тієї ж проблеми. Це також "динамічний", хоча його виконання відрізняється. Я пропоную вам перевірити, наскільки ефективно це рішення, подавши його онлайн-судді UVA. Мені здається дивним, як так важко вирішували таку важку проблему.


Чи дійсно потрібно зберігання для динамічного програмування? Чи не міг би будь-який пропуск роботи кваліфікувати алгоритм як динамічний?
Nthalk

Вам потрібно зібрати оптимальні крок за кроком результати, щоб зробити алгоритм «динамічним». Динамічне програмування випливає з роботи Беллмана в АБО, якщо ви говорите, що "пропуск будь-якої кількості слів - це динамічне програмування", ви знецінюєте цей термін, так як будь-яке евристичне пошук буде динамічним програмуванням. en.wikipedia.org/wiki/Dynamic_programming
andandandand

12

Основними бітами динамічного програмування є "перекриття підпроблем" та "оптимальна підструктура". Ці властивості проблеми означають, що оптимальне рішення складається з оптимальних рішень її підзадач. Наприклад, проблеми найкоротшого шляху виявляють оптимальну підструктуру. Найкоротший шлях від А до С - це найкоротший шлях від А до деякого вузла В, а потім найкоротший шлях від цього вузла В до С.

Більш детально, щоб вирішити найкоротший шлях, ви:

  • знайти відстань від початкового вузла до кожного вузла, який торкається його (скажімо, від А до В і С)
  • знайти відстані від цих вузлів до вузлів, що торкаються їх (від B до D і E, і від C до E і F)
  • тепер ми знаємо найкоротший шлях від A до E: це найкоротша сума Ax і xE для деякого вузла x, який ми відвідали (або B, або C)
  • повторюйте цей процес, поки не досягнемо кінцевого вузла призначення

Оскільки ми працюємо знизу вгору, у нас вже є рішення підпроблем, коли настає час їх використання, запам'ятовуючи їх.

Пам'ятайте, що проблеми з динамічним програмуванням повинні мати як підпроблеми, що перекриваються, так і оптимальну підструктуру. Створення послідовності Фібоначчі не є проблемою динамічного програмування; він використовує запам'ятовування, оскільки він має підпроблеми, що перекриваються, але не має оптимальної підструктури (оскільки немає жодної проблеми з оптимізацією).


1
IMHO, це єдина відповідь, яка має сенс з точки зору динамічного програмування. Мені цікаво з тих пір, коли люди почали пояснювати DP за допомогою цифр Фібоначчі (навряд чи актуально).
Террі Лі

@TerryLi, Це може бути "сенсом", але це не просто зрозуміти. Проблема чисел Фібоначчі відома і легко зрозуміла.
Аджай

5

Динамічне програмування

Визначення

Динамічне програмування (DP) - це загальна методика проектування алгоритму для вирішення задач із перекриваючими підзадачами. Цю методику винайшов американський математик "Річард Беллман" у 1950-х роках.

Основна ідея

Ключова ідея - зберегти відповіді на перекриття менших підпроблем, щоб уникнути перерахунку.

Властивості динамічного програмування

  • Екземпляр вирішується за допомогою рішень для менших екземплярів.
  • Рішення для меншого екземпляра можуть знадобитися кілька разів, тому зберігайте їх результати в таблиці.
  • Таким чином, кожен менший екземпляр вирішується лише один раз.
  • Для економії часу використовується додатковий простір.

4

Я також дуже новачок у динамічному програмуванні (потужний алгоритм для певного типу проблем)

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

Попередні знання - це те, що тут найбільше важливо. Слідкуйте за вирішенням проблем, які вже є.

Розглянемо це, найосновніший приклад для dp з Вікіпедії

Знаходження послідовності фільтрів

function fib(n)   // naive implementation
    if n <=1 return n
    return fib(n − 1) + fib(n − 2)

Дозволяє розбивати виклик функції із скажімо n = 5

fib(5)
fib(4) + fib(3)
(fib(3) + fib(2)) + (fib(2) + fib(1))
((fib(2) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))
(((fib(1) + fib(0)) + fib(1)) + (fib(1) + fib(0))) + ((fib(1) + fib(0)) + fib(1))

Зокрема, фіб (2) обчислювались тричі з нуля. У більших прикладах перераховується набагато більше значень fib або субпроблем, що веде до експоненціального алгоритму часу.

Тепер давайте спробуємо це, зберігаючи значення, які ми вже виявили у структурі даних, наприклад, Map

var m := map(0 → 0, 1 → 1)
function fib(n)
    if key n is not in map m 
        m[n] := fib(n − 1) + fib(n − 2)
    return m[n]

Тут ми зберігаємо рішення субпроблем на карті, якщо у нас його ще немає. Ця методика збереження значень, яку ми вже обчислили, називається «Пам'ять».

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


Прямий відрив від Вікіпедії. Захищений !!
солідак

3

Динамічне програмування - це методика вирішення задач із перекриваючими підзадачами. Алгоритм динамічного програмування вирішує кожну підзадачу лише один раз, а потім зберігає свою відповідь у таблиці (масиві). Уникаючи роботи над перерахунком відповіді щоразу, коли виникає додаткова проблема. Основна ідея динамічного програмування: Уникайте обчислення одного і того ж матеріалу вдвічі, зазвичай, зберігаючи таблицю відомих результатів підзадач.

Сім кроків у розробці алгоритму динамічного програмування такі:

  1. Встановіть рекурсивну властивість, яка дає рішення для екземпляра проблеми.
  2. Розробіть рекурсивний алгоритм відповідно до рекурсивної властивості
  3. Перевірте, чи той самий екземпляр проблеми знову вирішується в рекурсивних дзвінках
  4. Розробити запам'ятовуваний рекурсивний алгоритм
  5. Дивіться схему зберігання даних у пам'яті
  6. Перетворіть запам'ятовуваний рекурсивний алгоритм в ітеративний алгоритм
  7. Оптимізуйте ітеративний алгоритм, використовуючи накопичувач у міру необхідності (оптимізація зберігання)

Це 6. Convert the memoized recursive algorithm into iterative algorithmобов'язковий крок? Це означало б, що його остаточна форма є нерекурсивною?
truthadjustr

не обов'язковий, його необов’язковий
Аднан Куреші

Завдання полягає в заміні рекурсивного алгоритму, який використовується для зберігання даних в пам'яті, ітерацією над збереженими значеннями, оскільки ітеративне рішення економить створення функціонального стека для кожного здійсненого рекурсивного виклику.
Девід К. Ранкін

1

коротше кажучи, різниця між рекурсійним запам'ятовуванням та динамічним програмуванням

Динамічне програмування, як підказує ім'я, використовує попереднє обчислене значення для динамічної побудови наступного нового рішення

Де застосовувати динамічне програмування: Якщо ваше рішення базується на оптимальній підструктурі та перекриваючій підзадачі, то в цьому випадку корисне використання раніше обчисленого значення, тому вам не доведеться перераховувати його. Це підхід знизу вгору. Припустимо, вам потрібно обчислити fib (n), у цьому випадку все, що вам потрібно зробити, це додати попереднє обчислене значення fib (n-1) і fib (n-2)

Рекурсія: В основному ви підрозділяєте проблему на меншу частину, щоб вирішити її легко, але майте на увазі, що це не дозволяє уникнути повторних обчислень, якщо у нас є те саме значення, яке було обчислено раніше в іншому виклику рекурсії.

Пам'ять: В основному зберігання старого обчисленого значення рекурсії в таблиці відоме як запам'ятовування, що дозволить уникнути повторних обчислень, якщо його вже було обчислено деяким попереднім викликом, тому будь-яке значення буде обчислено один раз. Отже перед тим, як обчислити, ми перевіряємо, чи вже це значення було обчислено чи ні, якщо воно вже було обчислене, то повертаємо те саме з таблиці замість перерахунку. Це також підхід зверху вниз


-2

Ось простий код пітона приклад Recursive, Top-down, Bottom-upпідхід для ряду Фібоначчі:

Рекурсивна: O (2 n )

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


print(fib_recursive(40))

Зверху вниз: O (n) Ефективний для більшого введення

def fib_memoize_or_top_down(n, mem):
    if mem[n] is not 0:
        return mem[n]
    else:
        mem[n] = fib_memoize_or_top_down(n-1, mem) + fib_memoize_or_top_down(n-2, mem)
        return mem[n]


n = 40
mem = [0] * (n+1)
mem[1] = 1
mem[2] = 1
print(fib_memoize_or_top_down(n, mem))

Знизу вгору: O (n) Для простоти та невеликих розмірів вводу

def fib_bottom_up(n):
    mem = [0] * (n+1)
    mem[1] = 1
    mem[2] = 1
    if n == 1 or n == 2:
        return 1

    for i in range(3, n+1):
        mem[i] = mem[i-1] + mem[i-2]

    return mem[n]


print(fib_bottom_up(40))

Перший випадок не має час роботи п ^ 2, час його складність O (2 ^ п): stackoverflow.com/questions/360748 / ...
Sam

оновлено спасибі @Sam
0xAliHn
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.