Splitt og hersk deler en instans i delinstanser uten noe felles og løser hver av dem én gang. I mange problemer trengs den samme delinstansen av flere større instanser, og en rekursiv prosedyre løser den på nytt hver gang. Dynamisk programmering dekomponerer på samme måte som den rekursive prosedyren, men lagrer hvert delsvar første gang det regnes ut. Da er kjøretiden som regel lineær i antall forskjellige delinstanser og avhengighetene mellom dem, ikke i antall kall, og det kan være forskjellen på polynomisk og eksponentiell tid.
Stavkapping
En stav med lengde skal kappes i biter med heltallige lengder og selges. En bit med lengde gir prisen , og målet er størst mulig totalpris . Prisene i eksemplene er . Staven kan kappes eller ikke i hvert av de heltallige punktene, så det finnes kappinger. For er to biter med lengde 2 best, til kr.
Dekomponer ved å velge det første kuttet. Gir det en bit med lengde , får du for den, og resten er en stav med lengde , en mindre instans av samme problem. Totalprisen er pluss prisen for resten, så den blir størst når resten kappes optimalt: var resten kappet dårligere, kunne du byttet ut den kappingen og fått en bedre kapping av hele staven. Det beste med første bit er altså . En av de lengdene er den første biten i en optimal kapping, så du prøver alle og tar den beste:
Cut-Rod er denne rekurrensen skrevet som rekursiv prosedyre. Den er riktig, men treg. Kallet på lengde kaller seg selv på alle lengder fra ned til 0, og hvert av dem gjør det samme. Antall kall er ett for rota pluss alle kallene under de barna:
Trekk fra den samme ligningen for . Da faller 1 og leddene bort, og bare står igjen:
Det siste steget bruker , for kallet på lengde 0 kaller ingenting. Cut-Rod gjør altså like mye arbeid som å prøve alle kappingene.
Overlappende delinstanser og memoisering
Cut-Rod løser de samme delinstansene om og om igjen. Kallet på lengde 4 kaller lengde 2 direkte, og kallet på lengde 3 kaller lengde 2 én gang til. I hele kalltreet for kalles lengde 1 fire ganger og lengde 0 åtte ganger. Det finnes bare forskjellige delinstanser, lengdene 0 til , men kall.
Delinstansene er overlappende når flere instanser bygger på den samme delinstansen, direkte eller indirekte. Skjer det på mange nivåer, blir den rekursive løsningen eksponentiell. I splitt og hersk skjer det ikke. Merge-Sort deler tabellen i to halvdeler uten felles elementer, og hver delinstans trengs av bare én større instans.
Delinstansgrafen har én node per forskjellig delinstans, og en kant fra til når svaret for bygger på svaret for . For stavkapping er nodene lengdene 0 til , med en kant fra til for hver . Læreboka tegner kantene motsatt vei, og retningen spiller ingen rolle så lenge det er klart hva som bygger på hva. Grafen må være asyklisk, og hver kjede av avhengigheter må ende i et grunntilfelle. Ellers bygger en instans til slutt på seg selv, og rekursjonen stopper aldri. Kalltreet har én node for hver sti i grafen som ender i instansen, så en delinstans med mange stier til instansen står mange steder i treet.
Memoisering gir prosedyren hukommelse. Første
gang den kalles med et argument, regner den ut svaret og lagrer det. Senere kall
med samme argument slår opp svaret og returnerer med en gang. For stavkapping
holder en tabell der betyr at delinstansen ikke er løst.
Uten de tre linjene som leser og skriver , er aux under nøyaktig Cut-Rod.
from math import inf
def memoized_cut_rod(p, n): # p[0] er ubrukt, p[i] er prisen for lengde i
r = [-inf] * (n + 1)
return aux(p, n, r)
def aux(p, n, r):
if r[n] >= 0:
return r[n]
if n == 0:
q = 0
else:
q = -inf
for i in range(1, n + 1):
q = max(q, p[i] + aux(p, n - i, r))
r[n] = q
return q Treet øverst er alle kallene Cut-Rod gjør uten memo, i den rekkefølgen den memoiserte versjonen går gjennom dem. Et oransje kall er et oppslag i , og de bleke kallene under det blir aldri gjort. Raden nederst er , buene over den er kantene i delinstansgrafen, og grønt er løst og lagret.
Med memo beregnes hver lengde én gang, og den beregningen har runder i løkka. Til sammen er det kall utover det første, ett per kant i delinstansgrafen.
Nedenfra og opp
Rekursjonen i den memoiserte versjonen sørger bare for at en delinstans er løst før noen bygger på den. Du kan like gjerne sørge for det direkte, ved å løse delinstansene i en rekkefølge der hver av dem kommer etter alle den bygger på. En slik rekkefølge er en topologisk sortering av delinstansgrafen, og for stavkapping er det bare . Denne formen kalles iterasjon nedenfra og opp, bottom-up, og memoisering kalles tilsvarende ovenfra og ned, top-down. Bottom-Up-Cut-Rod fyller fra venstre, og når den kommer til , er ferdige.
Tabellen gir bare den beste prisen. For å finne kappingen lagrer du også valget: er lengden på den første biten i en optimal kapping av lengde . Kappingen leses av bakfra. Skriv ut , trekk den fra , og gjenta til . For er og , altså én bit med lengde 1 og én med lengde 6, til kr.
from math import inf
def bottom_up_cut_rod(p, n):
r = [0] * (n + 1)
s = [0] * (n + 1)
for j in range(1, n + 1):
q = -inf
for i in range(1, j + 1):
if p[i] + r[j - i] > q:
q = p[i] + r[j - i]
s[j] = i
r[j] = q
return r, s
def print_cut_rod_solution(p, n):
r, s = bottom_up_cut_rod(p, n)
while n > 0:
print(s[n])
n = n - s[n] De to løkkene gir , som den memoiserte versjonen. Bottom-up slipper kallene og er som regel raskere med en konstant faktor. Memoisering løser bare delinstansene som faktisk trengs, og de kan være færre enn alle.
Generelt er kjøretiden summen av arbeidet med hver delinstans. Koster hver kant i delinstansgrafen konstant tid, er kjøretiden lineær i antall noder og kanter. Stavkapping har noder og kanter, altså .
Optimal delstruktur
I en optimal kapping er resten etter den første biten selv kappet optimalt, ellers kunne du byttet den ut og fått en bedre kapping av hele staven. En optimal løsning består altså av optimale løsninger på delinstansene. Den egenskapen kalles optimal delstruktur, og den gjør rekurrensen for riktig. I beviset bytter du som regel ut en del av en optimal løsning med en bedre løsning på delinstansen. Da blir hele løsningen bedre, men den var optimal.
Korteste vei har optimal delstruktur. Går en korteste vei fra 1 til 4 innom 2, er delen fra 1 til 2 en korteste vei, ellers kunne den byttes ut med en kortere. Lengste enkle vei har ikke optimal delstruktur. Ta en firkant med kantene 1–2, 2–4, 4–3 og 3–1. En lengste enkel vei fra 1 til 4 er 1, 2, 4, men delen fra 1 til 2 er ingen lengste enkel vei, for 1, 3, 4, 2 er lengre. De lengste enkle veiene fra 1 til 2 og fra 2 til 4 er 1, 3, 4, 2 og 2, 1, 3, 4, og satt sammen besøker de 1, 3 og 4 to ganger. De to delene konkurrerer om de samme nodene, og en node den ene bruker, kan ikke den andre bruke. Ingen kjenner en effektiv algoritme for lengste enkle vei.
Dynamisk programmering trenger begge egenskapene: optimal delstruktur for at dekomponeringen skal gi riktig svar, og overlappende delinstanser for at det skal lønne seg å lagre delsvarene.
Læreboka gir en oppskrift i fire trinn, og stavkapping gikk gjennom alle fire.
-
Struktur
Beskriv hvordan en optimal løsning er bygd opp, og hvilket valg som deler den i delinstanser som selv må løses optimalt. For stavkapping er valget lengden på den første biten, og resten er en stav med lengde .
-
Rekurrens
Definer verdien av en optimal løsning rekursivt, med grunntilfeller. For stavkapping er rekurrensen og .
-
Verdi
Regn ut verdien, med memoisering eller nedenfra og opp. Memoized-Cut-Rod og Bottom-Up-Cut-Rod fyller begge i .
-
Løsning
Lagre valgene underveis, og les løsningen av bakfra. Bottom-Up-Cut-Rod lagrer den første biten i , og kappingen leses av fra .
Innen fagfeltet rettferdig fordeling (fair allocation) finnes det et bestemt problem, der man vil fordele nodene i en graf på et sett med mottakere, så rettferdig som mulig, slik at nodene hver mottaker får utgjør en sammenhengende graf. Det kan f.eks. være snakk om å fordele kontor på ulike grupper, der kantene i grafen angir hvilke kontor som er nabokontor, og hver gruppe vil ha et sett med kontor som ligger samlet.
Du skal løse et spesialtilfelle av dette, der alle kontorene ligger på samme side i en korridor, så grafen utgjør en sti.
For enkelhets skyld, antar vi at alle gruppene er enige om hvor fine kontorene er, så hvert kontor får en verdi i form av et positivt tall. Vi ønsker å finne en maksimin-fordeling, som er rettferdig i den forstand at den gruppen som får den laveste totalverdien likevel får en så høy totalverdi som mulig.
Hvordan vil du løse dette problemet? Forklar.
Merk at vi her kun ser på totalverdien hver gruppe får, og ikke har noe krav til antallet kontor. Vi kan f.eks. anta at verdien henger sammen med størrelsen, dvs. antall personer det er plass til, så færre og bedre kontor er like ønskelig som flere og dårligere.
Vis løsning Skjul løsning
Nummerer kontorene langs korridoren, med verdier , og la være antall grupper. Hver gruppe får et sammenhengende stykke av stien, så en fordeling er kutt, som i stavkapping.
Delinstansene trenger to parametre, antall grupper og antall kontorer fra starten av korridoren. La være den beste laveste totalverdien, og prefikssummene, regnet ut på forhånd i lineær tid.
Velg det siste stykket: kontorene går til én gruppe, og de første fordeles på de andre gruppene. Den laveste totalverdien er da den minste av , verdien til det siste stykket, og den laveste blant de andre gruppene. Det siste stykket ligger fast, så minimum blir størst når den laveste blant de andre er størst, altså når de første kontorene fordeles maksimin. Den laveste totalverdien er altså . Hver av de andre gruppene trenger minst ett kontor, og det siste stykket også, så går fra til , og du tar den beste:
Fyll nedenfra og opp for , eller memoiser rekursjonen. Det er delinstanser med høyst valg hver, så kjøretiden er . Lagre det beste for hver delinstans, så kan fordelingen leses av bakfra fra .
Lengste felles delsekvens
En delsekvens får du ved å stryke null eller flere elementer fra en sekvens og
beholde rekkefølgen på resten. «baker» er en delsekvens av både «bakverk» og
«basker», og ingen lengre felles delsekvens finnes. Problemet
LCS er å finne en lengste felles
delsekvens av og
. LCS er grunnlaget for blant annet diff
og for sammenligning av DNA-sekvenser. har delsekvenser, så å prøve
alle er uaktuelt.
Delinstansene er prefikser, og , og du dekomponerer ved å se på de siste elementene. La være en LCS av og . Er , må slutte på dette elementet, for ellers kunne du lagt det til bakerst og fått en lengre felles delsekvens. Resten av er da en felles delsekvens av og , og den er en lengste, for ellers kunne du byttet den ut og fått en lengre . Er , kan ikke det siste elementet i være lik begge, så er en felles delsekvens av og eller av og . Du løser begge delinstansene og tar den beste. Med som lengden av en LCS av og :
Delinstansene overlapper, for både og bygger på . Delinstansgrafen er et rutenett med noder, der hver celle bygger på cellen over, cellen til venstre og cellen diagonalt over til venstre. Rad for rad fra venstre er derfor en topologisk rekkefølge.
LCS-Length fyller tabellen i den rekkefølgen og lagrer valget i som en pil mot cellen svaret kom fra. Print-LCS følger pilene bakfra fra og skriver ut for hver diagonal pil. Den skriver etter det rekursive kallet, så bokstavene kommer forfra selv om pilene følges bakfra.
def lcs_length(X, Y):
m, n = len(X), len(Y)
c = [[0] * (n + 1) for _ in range(m + 1)]
b = [[""] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if X[i - 1] == Y[j - 1]: # x_i og y_j
c[i][j] = c[i - 1][j - 1] + 1
b[i][j] = "↖"
elif c[i - 1][j] >= c[i][j - 1]:
c[i][j] = c[i - 1][j]
b[i][j] = "↑"
else:
c[i][j] = c[i][j - 1]
b[i][j] = "←"
return c, b
def print_lcs(b, X, i, j):
if i == 0 or j == 0:
return
if b[i][j] == "↖":
print_lcs(b, X, i - 1, j - 1)
print(X[i - 1], end="")
elif b[i][j] == "↑":
print_lcs(b, X, i - 1, j)
else:
print_lcs(b, X, i, j - 1) Tabellen med nedover og bortover, og pilen fra i hjørnet av hver celle. Oransje er cellene den nye cellen leser, og fylt er cellen svaret kommer fra. Grønne celler har diagonal pil og ligger på veien Print-LCS følger, og bokstavene deres er LCS-en.
LCS-Length bruker konstant tid per celle, altså . Print-LCS tar høyst steg, for hvert kall minsker eller eller begge.
Dette er den rekursive formuleringen av lengden til den lengste felles delsekvensen (longest common subsequence, LCS) for prefiksene og av X og Y.
Hva er det som mangler? Forklar kort.
Vis løsning Skjul løsning
Linjen gjelder tilfellet der de siste elementene i prefiksene er like. Da må en LCS slutte på det elementet, for ellers kunne det legges til bakerst. Resten er en LCS av og , for en lengre kunne byttet den ut, og lengden øker med 1.
Det binære ryggsekkproblemet
Du har gjenstander, der gjenstand har verdi og vekt , og en ryggsekk med kapasitet , alle positive heltall. Velg gjenstander med størst mulig totalverdi og totalvekt høyst . Hver gjenstand er med eller ikke med, og det er derfor problemet kalles binært.
Dekomponer på spørsmålet om gjenstand skal være med. Nei gir det samme problemet med gjenstandene og hele kapasiteten. Ja gir verdien pluss det beste med gjenstandene og kapasitet , og er bare mulig hvis . Delinstansene trenger derfor to parametre: hvor mange gjenstander som er med i betraktningen, og hvor mye kapasitet som er igjen. Med som den største verdien med gjenstandene og kapasitet :
Rad bygger bare på rad , så tabellen fylles rad for rad. Cellen som gir verdien med gjenstand , ligger kolonner lenger til venstre, så kantene i delinstansgrafen avhenger av vektene. Valgene trenger ikke lagres for seg. Er , var gjenstand med, for uten den er det beste . Da fortsetter du fra . Ellers fortsetter du fra .
def knapsack(v, w, W): # gjenstand i har verdi v[i - 1] og vekt w[i - 1]
n = len(v)
K = [[0] * (W + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(W + 1):
x = K[i - 1][j]
if j < w[i - 1]:
K[i][j] = x
else:
y = K[i - 1][j - w[i - 1]] + v[i - 1]
K[i][j] = max(x, y)
return K
def chosen(K, w, W):
items, j = [], W
for i in range(len(K) - 1, 0, -1):
if K[i][j] != K[i - 1][j]:
items.append(i)
j = j - w[i - 1]
return items Tabellen med én rad per gjenstand og én kolonne per kapasitet, og vekt og verdi til høyre. Oransje er cellene den nye cellen leser, rett over og kolonner lenger til venstre i raden over, og den som gir størst verdi, er fylt. Grønt er gjenstandene som er med.
Tabellen har celler med konstant arbeid per celle, så kjøretiden er . Det ser polynomisk ut, men er det ikke som funksjon av inputstørrelsen, som måles i bits. Tallet tar bare bits å skrive, så vokser eksponentielt med plassen tallet tar:
Ett bit til i kan altså doble kjøretiden. En kjøretid som er polynomisk i et tall fra inputen, men ikke i antall bits, kalles pseudopolynomisk. Er begrenset av et polynom i , er kjøretiden polynomisk. Med vilkårlig store heltall er ryggsekkproblemet NP-hardt, og ingen kjenner en algoritme som er polynomisk i antall bits.
Løsningen på det binære ryggsekkproblemet (0-1 knapsack) har kjøretid , der er antall gjenstander og er kapasiteten til ryggsekken. Er dette en polynomisk algoritme? Forklar kort.
Vis løsning Skjul løsning
Polynomisk betyr polynomisk i inputstørrelsen, antall bits. tar bits, og med bits kan være nesten . Da er , som er eksponentielt i . Kjøretiden er pseudopolynomisk.