Разлика между алчно и динамично програмиране

Динамичното програмиране е много специфична тема в състезанията по програмиране. Без значение колко проблеми сте решили с помощта на DP, той все още може да ви изненада. Но както всичко останало в живота, практиката ви прави по-добри ;-)

Други отговори в тази тема споменават някои хубави уводни текстове, които ще ви помогнат да разберете какво е DP и как работи. В следващите няколко абзаца ще се опитам да ви покажа как да излезете с решения за проблеми с DP.

Забележка: процесът на създаване на DP решение, описано по-долу, е пряко приложим за всички проблеми Div1-250 и много от Div1–500 проблеми в TopCoder, които могат да бъдат решени с DP. По-трудните проблеми обикновено изискват известно редуване в процеса, което ще можете да направите, след известна практика. Забележка 2: Пробите от изходния код по-долу са написани на C ++. Ако не знаете езика или не сте сигурни в нещо, моля, попитайте ме в коментари.

Итерация срещу рекурсия

След като прочетете някои въвеждащи текстове за динамичното програмиране (което горещо препоръчвам), почти всички примери на изходния код в тях използват техника отдолу нагоре с итерация (т.е. използване на цикли). Например изчисляването на дължината на най-дългата обща последователност от два низа A и B с дължина N, ще изглежда така:

int dp [N + 1] [N + 1];
за (int i = 0; i <= N; ++ i)
  dp [0] [i] = dp [i] [0] = 0;
за (int i = 1; i <= N; ++ i)
  за (int j = 1; j <= N; ++ j) {
    dp [i] [j] = max (dp [i-1] [j], dp [i] [j-1]);
    ако (A [i-1] == B [j-1])
      dp [i] [j] = max (dp [i] [j], dp [i-1] [j-1] +1);
  }

int отговор = dp [N] [N];

Има няколко причини, поради които е кодиран по този начин:

  1. итерацията е много по-бърза от рекурсията
  2. лесно се вижда сложността на времето и пространството на алгоритъма
  3. изходният код е кратък и чист

Поглеждайки такъв изходен код, човек може да разбере как и защо работи, но е много по-трудно да разбере как да го измисли. Най-големият пробив в обучението ми на динамично програмиране беше, когато започнах да мисля за проблемите по начин отгоре надолу, вместо отдолу нагоре. На пръв поглед не изглежда като толкова революционно прозрение, но тези два подхода директно се превеждат в два различни изходни кода. Единият използва итерация (отдолу нагоре), а другият използва рекурсия (мода отгоре надолу). Последният също се нарича техника на запомняне. Двете решения са повече или по-малко еквивалентни и винаги можете да преобразувате едното в другото. В следващите параграфи ще ви покажа как да излезете с решение за запомняне на проблем.

Проблем с мотивацията

Представете си, че имате колекция от N вина, поставени един до друг на рафт. За простота нека номерираме вината отляво надясно, тъй като стоят на рафта с цели числа съответно от 1 до N. Цената на i-тото вино е pi (цените на различните вина могат да бъдат различни). Тъй като вината стават все по-добри с всяка година, ако предположим, че днес е годината 1, на година y цената на i-тото вино ще бъде * y ** pi, т.е. y-пъти по-голяма от стойността за текущата година.

Искате да продадете всички вина, които имате, но искате да продавате точно едно вино годишно, като започнете от тази година. Още едно ограничение - всяка година можете да продавате само най-лявото или най-дясното вино на рафта и нямате право да пренареждате вината на рафта (т.е. те трябва да останат в същия ред, както са в началото ).

Искате да разберете каква е максималната печалба, която можете да получите, ако продавате вината в оптимален ред. Така например, ако цените на вината са (в реда, както са поставени на рафта, отляво надясно): p1 = 1, p2 = 4, p3 = 2, p4 = 3 Оптималното решение би било да продават вината от порядъка p1, p4, p3, p2 за обща печалба 11 + 32 + 23 + 44 = 29

Грешно решение

След като играете с проблема за известно време, вероятно ще получите чувството, че в оптималното решение искате да продадете скъпите вина възможно най-късно. Вероятно можете да измислите следната алчна стратегия:

Всяка година продавайте по-евтините от двете налични вина (най-вляво и вдясно).

Въпреки че в стратегията не се споменава какво да се прави, когато двете вина струват едно и също, тази стратегия се чувства добре. Но за съжаление не е така, както показва следващият пример. Ако цените на вината са: p1 = 2, p2 = 3, p3 = 5, p4 = 1, p5 = 4 Алчната стратегия би ги продала в ред p1, p2, p5, p4, p3 за обща печалба 21 + 32 + 43 + 14 + 55 = 49 Но можем да направим по-добре, ако продаваме вината от порядъка p1, p5, p4, p2, p3 за обща печалба 21 + 42 + 13 + 34 + 55 = 50 Този брояч - Примерът трябва да ви убеди, че проблемът не е толкова лесен, тъй като може да изглежда на пръв поглед и ще ви кажа, че може да бъде решен чрез DP.

Напишете заден план

Когато идвам с решението за запомняне на даден проблем, винаги започвам с решение за обратна връзка, което намира правилния отговор. Backtrack решението изброява всички валидни отговори за проблема и избира най-добрия. За повечето проблеми е лесно да се намери такова решение. Ето някои ограничения, които поставям на обратното решение:

  • тя трябва да бъде функция, изчисляване на отговора с помощта на рекурсия
  • тя трябва да върне отговора с изявление за връщане, т.е. да не го съхранява някъде
  • всички нелокални променливи, които функцията използва, трябва да се използват само за четене, т.е. функцията може да променя само локални променливи и нейните аргументи.

Така че за проблема с вината, решението за връщане ще изглежда така:

int p [N]; // масив с цени само за четене

// година представлява текущата година (започва с 1)
// [be, en] представлява интервалът на непродадените вина на рафта
int печалба (int година, int be, int en) {
  // на рафта няма повече вина
  ако (бъде> bg)
    връщане 0;

  // опитайте се да продадете най-лявото или най-дясното вино, рекурсивно изчислявайте
  // отговорете и върнете по-доброто
  връщане макс (
    печалба (година + 1, бъде + 1, en) + година * p [бъде],
    печалба (година + 1, бъдете, en-1) + година * p [en]);
}

Можем да получим отговора, като се обадим на:

int answer = печалба (1, 0, N-1); // N е общият брой вина

Това решение просто изпробва всички възможни валидни поръчки за продажба на вината. Ако в началото има N вина, ще се пробват 2 ^ N възможности (всяка година имаме 2 възможности за избор). Така че въпреки че сега получаваме правилния отговор, времевата сложност на алгоритъма нараства експоненциално.

Правилно написаната функция за обратна връзка винаги трябва да представлява отговор на добре посочен въпрос. В нашия случай функцията за печалба представлява отговор на въпрос: „Коя е най-добрата печалба, която можем да получим от продажбата на вина с цени, съхранявани в масива p, когато текущата година е година и интервалът на непродадените вина се простира през [be, ** ** bg], включително? “

Винаги трябва да се опитвате да създадете такъв въпрос за функцията за връщане назад, за да видите дали сте го правили и да разберете какво точно прави.

Минимизирайте пространството на състоянието на аргументите на функциите

В тази стъпка искам да помислите кой от аргументите, които предавате на функцията, е излишен. Или можем да ги изградим от другите аргументи или изобщо не ни трябват. Ако има такива аргументи, не ги предавайте на функцията. Просто ги изчислете във функцията.

В горната функция от печалбата от годината на аргумента е излишна. Той е еквивалентен на броя вина, които вече сме продали плюс едно, което е еквивалентно на общия брой вина от началото минус броя на вината, които не сме продали плюс едно. Ако създадем глобална променлива N, само за четене, представляваща общия брой вина в началото, можем да пренапишем функцията си, както следва:

int N; // брой вина само в началото
int p [N]; // масив с цени само за четене

int печалба (int be, int en) {
  ако (бъде> bg)
    връщане 0;

  // (en-be + 1) е броят на непродадените вина
  int година = N - (en-be + 1) + 1; // както в описанието по-горе
  връщане макс (
    печалба (бъде + 1, en) + година * p [бъде],
    печалба (бъдете, en-1) + година * p [en]);
}

Искам също да помислите за обхвата на възможните стойности, които аргументите на функцията могат да получат от валиден вход. В нашия случай всеки от аргументите be и en може да съдържа стойности от 0 до N-1. При валидни данни очакваме също да бъде <= en + 1. Използвайки big-O нотация, можем да кажем, има O (N²) различни аргументи, с които може да се извика нашата функция.

Сега го кеширайте!

Вече сме готови на 99%. За да трансформираме функцията за обратно изтегляне с времева сложност O (2 ^ N) в решение за запомняне с времева сложност O (N²), ще използваме малък трик, който не изисква почти никакво мислене. Както бе отбелязано по-горе, има само O (N²) различни аргументи, с които може да се извика нашата функция. С други думи, има само O (N²) различни неща, които всъщност можем да изчислим. И така, откъде идва сложността на O (2 ^ N) и от какво се изчислява ?! Отговорът е - експоненциалната времева сложност идва от многократната рекурсия и поради това тя изчислява едни и същи стойности отново и отново. Ако стартирате горния код за произволен масив от N = 20 вина и изчислите колко пъти е била извикана функцията за аргументи be = 10 и en = 10, ще получите число 92378. Това е огромна загуба на време за изчисляване на същото отговорете на това много пъти. Това, което можем да направим, за да подобрим това, е да кешираме стойностите, след като сме ги изчислили и всеки път, когато функцията поиска вече кеширана стойност, не е необходимо да стартираме цялата рекурсия отново. Вижте кода по-долу:

int N; // брой вина само в началото
int p [N]; // масив с цени само за четене
int кеш [N] [N]; // всички стойности, инициализирани до -1 (или всичко, което изберете)

int печалба (int be, int en) {
  ако (бъде> bg)
    връщане 0;

  // тези два реда спасяват деня
  if (кеш [be] [en]! = -1)
    връщане на кеша [be] [en];

  int година = N - (en-be + 1) + 1;
  // когато изчислявате новия отговор, не забравяйте да го кеширате
  връща кеш [be] [bg] = max (
    печалба (бъде + 1, en) + година * p [бъде],
    печалба (бъдете, en-1) + година * p [en]);
}

И това е! С този малък трик той изпълнява O (N²) време, тъй като има O (N²) различни аргументи, с които може да се извика функцията и за всеки от тях функцията работи само веднъж с O (1) времева сложност. Забележка: когато стойностите са кеширани, можете да третирате всеки рекурсивен разговор във функцията, тъй като той ще се изпълнява в O (1) времева сложност.

резюме

За да обобщим, ако установите, че проблемът може да бъде решен с помощта на DP, опитайте се да създадете функция за обратна връзка, която изчислява правилния отговор. Опитайте се да избягвате излишните аргументи, минимизирайте обхвата на възможните стойности на аргументите на функциите и също така се опитайте да оптимизирате сложността на времето на едно извикване на функция (не забравяйте, че можете да третирате рекурсивните повиквания, тъй като те биха се изпълнявали в O (1) време). Накрая кеширайте стойностите и не изчислявайте едни и същи неща два пъти. Крайната времева сложност на решението е: range_of_possible_values_the_function_can_be_called_with x time_complexity_of_one_function_call.