Що таке динамічне програмування ?
Чим вона відрізняється від рекурсії, запам'ятовування тощо?
Я прочитав статтю у Вікіпедії , але ще не розумію.
Що таке динамічне програмування ?
Чим вона відрізняється від рекурсії, запам'ятовування тощо?
Я прочитав статтю у Вікіпедії , але ще не розумію.
Відповіді:
Динамічне програмування - це коли ви використовуєте минулі знання, щоб полегшити вирішення майбутньої проблеми.
Хорошим прикладом є розв’язування послідовності Фібоначчі для n = 1,000,002.
Це буде дуже тривалим процесом, але що робити, якщо я дам вам результати за n = 1 000 000 і n = 1 000 001? Раптом проблема просто стала більш вирішеною.
Динамічне програмування багато використовується в рядкових проблемах, таких як проблема редагування рядків. Ви вирішуєте підмножину (и) проблеми, а потім використовуєте цю інформацію для вирішення більш складної вихідної проблеми.
За допомогою динамічного програмування ви зазвичай зберігаєте результати в якійсь таблиці. Коли вам потрібна відповідь на проблему, ви посилаєтесь на таблицю і бачите, чи вже ви знаєте, що це таке. Якщо ні, то використовуйте дані таблиці, щоб дати собі перехід до відповіді.
У книзі «Алгоритми Кормена» є чудова глава про динамічне програмування. І це безкоштовно в Google Книгах! Перевірте це тут.
Динамічне програмування - це техніка, що використовується для уникнення обчислення декількох разів однієї і тієї ж підпрограми в рекурсивному алгоритмі.
Візьмемо простий приклад чисел Фібоначчі: знаходження 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
Як застосовувати динамічне програмування?
Динамічне програмування зазвичай працює для проблем, які мають властивий порядок зліва направо, такі як рядки, дерева або цілі послідовності. Якщо наївний рекурсивний алгоритм не обчислює одну і ту ж підпроблему кілька разів, динамічне програмування не допоможе.
Я створив збірку проблем, щоб допомогти зрозуміти логіку: https://github.com/tristanguigue/dynamic-programing
if n in cache
як у прикладі зверху вниз чи я щось пропускаю.
Пам'ять - це коли ви зберігаєте попередні результати функціонального виклику (реальна функція завжди повертає одне і те ж, з урахуванням однакових входів). Це не має значення для алгоритмічної складності перед збереженням результатів.
Рекурсія - це метод виклику функції, як правило, з меншим набором даних. Оскільки більшість рекурсивних функцій можна перетворити на подібні ітеративні функції, це також не має значення для алгоритмічної складності.
Динамічне програмування - це процес вирішення більш легких для вирішення підзадач і побудови відповіді з цього. Більшість алгоритмів DP будуть у часі роботи між алгоритмом жадібного (якщо такий існує) та експоненціальним (перерахувати всі можливості та знайти найкращий) алгоритм.
Це оптимізація вашого алгоритму, що скорочує час роботи.
Хоча жадібний алгоритм зазвичай називають наївним , оскільки він може працювати кілька разів над одним і тим самим набором даних, Динамічне програмування дозволяє уникнути цієї невдачі через глибше розуміння часткових результатів, які необхідно зберегти, щоб допомогти побудувати остаточне рішення.
Простий приклад - проходження дерева або графіка лише через вузли, які сприяли б вирішенню, або введення в таблицю рішень, які ви знайшли досі, щоб уникнути проходження одних і тих же вузлів знову і знову.
Ось приклад проблеми, яка підходить для динамічного програмування, від інтернет-судді 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. Мені здається дивним, як так важко вирішували таку важку проблему.
Основними бітами динамічного програмування є "перекриття підпроблем" та "оптимальна підструктура". Ці властивості проблеми означають, що оптимальне рішення складається з оптимальних рішень її підзадач. Наприклад, проблеми найкоротшого шляху виявляють оптимальну підструктуру. Найкоротший шлях від А до С - це найкоротший шлях від А до деякого вузла В, а потім найкоротший шлях від цього вузла В до С.
Більш детально, щоб вирішити найкоротший шлях, ви:
Оскільки ми працюємо знизу вгору, у нас вже є рішення підпроблем, коли настає час їх використання, запам'ятовуючи їх.
Пам'ятайте, що проблеми з динамічним програмуванням повинні мати як підпроблеми, що перекриваються, так і оптимальну підструктуру. Створення послідовності Фібоначчі не є проблемою динамічного програмування; він використовує запам'ятовування, оскільки він має підпроблеми, що перекриваються, але не має оптимальної підструктури (оскільки немає жодної проблеми з оптимізацією).
Динамічне програмування
Визначення
Динамічне програмування (DP) - це загальна методика проектування алгоритму для вирішення задач із перекриваючими підзадачами. Цю методику винайшов американський математик "Річард Беллман" у 1950-х роках.
Основна ідея
Ключова ідея - зберегти відповіді на перекриття менших підпроблем, щоб уникнути перерахунку.
Властивості динамічного програмування
Я також дуже новачок у динамічному програмуванні (потужний алгоритм для певного типу проблем)
Найпростіше кажучи, просто уявляйте динамічне програмування рекурсивним підходом із використанням попередніх знань
Попередні знання - це те, що тут найбільше важливо. Слідкуйте за вирішенням проблем, які вже є.
Розглянемо це, найосновніший приклад для 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]
Тут ми зберігаємо рішення субпроблем на карті, якщо у нас його ще немає. Ця методика збереження значень, яку ми вже обчислили, називається «Пам'ять».
Нарешті, для проблеми спочатку спробуйте знайти стани (можливі субпроблеми та спробуйте продумати кращий рекурсійний підхід, щоб ви могли використовувати рішення попередньої підпроблеми на подальші).
Динамічне програмування - це методика вирішення задач із перекриваючими підзадачами. Алгоритм динамічного програмування вирішує кожну підзадачу лише один раз, а потім зберігає свою відповідь у таблиці (масиві). Уникаючи роботи над перерахунком відповіді щоразу, коли виникає додаткова проблема. Основна ідея динамічного програмування: Уникайте обчислення одного і того ж матеріалу вдвічі, зазвичай, зберігаючи таблицю відомих результатів підзадач.
Сім кроків у розробці алгоритму динамічного програмування такі:
6. Convert the memoized recursive algorithm into iterative algorithm
обов'язковий крок? Це означало б, що його остаточна форма є нерекурсивною?
коротше кажучи, різниця між рекурсійним запам'ятовуванням та динамічним програмуванням
Динамічне програмування, як підказує ім'я, використовує попереднє обчислене значення для динамічної побудови наступного нового рішення
Де застосовувати динамічне програмування: Якщо ваше рішення базується на оптимальній підструктурі та перекриваючій підзадачі, то в цьому випадку корисне використання раніше обчисленого значення, тому вам не доведеться перераховувати його. Це підхід знизу вгору. Припустимо, вам потрібно обчислити fib (n), у цьому випадку все, що вам потрібно зробити, це додати попереднє обчислене значення fib (n-1) і fib (n-2)
Рекурсія: В основному ви підрозділяєте проблему на меншу частину, щоб вирішити її легко, але майте на увазі, що це не дозволяє уникнути повторних обчислень, якщо у нас є те саме значення, яке було обчислено раніше в іншому виклику рекурсії.
Пам'ять: В основному зберігання старого обчисленого значення рекурсії в таблиці відоме як запам'ятовування, що дозволить уникнути повторних обчислень, якщо його вже було обчислено деяким попереднім викликом, тому будь-яке значення буде обчислено один раз. Отже перед тим, як обчислити, ми перевіряємо, чи вже це значення було обчислено чи ні, якщо воно вже було обчислене, то повертаємо те саме з таблиці замість перерахунку. Це також підхід зверху вниз
Ось простий код пітона приклад Recursive
, Top-down
, Bottom-up
підхід для ряду Фібоначчі:
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))
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))
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))