Tietorakenteet ja algoritmit

kevät 2024

10. Dynaaminen ohjelmointi

Dynaaminen ohjelmointi (dynamic programming) on algoritmitekniikka, jonka avulla voidaan ratkaista tehokkaasti monia hakuongelmia. Tutustumme tässä luvussa dynaamiseen ohjelmointiin seuraavan tehtävän kautta:

Tehtävä

Käytössäsi on rajaton määrä kolikkoja, joiden arvot annetaan listassa. Jokaisen kolikon arvo on positiivinen kokonaisluku ja pienin kolikko on arvoltaan 11. Tavoitteena on muodostaa kolikoista summa xx.

  1. Optimiratkaisun haku: Montako kolikkoa tarvitaan vähintään summan xx muodostamiseen?
  2. Optimiratkaisun muodostus: Anna esimerkki, miten summa xx voidaan muodostaa niin, että kolikoiden määrä on pienin.
  3. Ratkaisujen laskeminen: Montako eri tapaa on muodostaa summa xx kolikoista?

Esimerkiksi kun kolikot ovat [1,2,5][1,2,5] ja x=13x=13, vastaukset ovat:

  1. Pienin määrä kolikoita on 44.
  2. Pienin ratkaisu saadaan valitsemalla kolikot [1,2,5,5][1,2,5,5].
  3. Erilaisia tapoja on 634634 (kuten [1,2,5,5][1,2,5,5], [2,2,2,2,5][2,2,2,2,5] ja [1,1,1,5,5][1,1,1,5,5]).

Edellisessä luvussa ratkaisimme tehtävän tehokkaasti ahneella algoritmilla tapauksessa, jossa kolikot ovat [1,2,5][1,2,5]. Ahne algoritmi ei kuitenkaan toimi kaikissa tapauksissa: esimerkiksi jos kolikot ovat [1,4,5][1,4,5], algoritmi voi antaa väärän vastauksen. Lisäksi ahneella algoritmilla ei ole mahdollista ratkaista tehtävän kohtaa 3, jossa tulee laskea kaikkien tapojen määrä.

Näemme seuraavaksi, miten saamme ratkaistua tehtävän kaikki kohdat tehokkaasti dynaamisen ohjelmoinnin avulla. Tässä tehtävässä esille tulevia ideoita pystyy käyttämään myös muiden tehtävien ratkaisemisessa.

Optimiratkaisun haku

Dynaamisessa ohjelmoinnissa on ideana ratkaista ongelma saman ongelman pienempien tapausten eli osaongelmien avulla. Esimerkiksi kun halutaan muodostaa summa xx kolikoista, osaongelmat ovat tapauksia, joissa halutaan muodostaa summat 0x10 \dots x-1 kolikoista.

Tarkastellaan esimerkkiä, jossa halutaan muodostaa summa x=13x=13 kolikoilla [1,2,5][1,2,5]. Voimme lähteä muodostamaan summaa xx valitsemalla ensimmäisen summaan kuuluvan kolikon. Vaihtoehdot ovat:

Jos tiedossa on pienin kolikoiden määrä tapauksille x=8x=8, x=11x=11 ja x=12x=12, saamme pienimmän kolikoiden määrän tapaukselle x=13x=13 valitsemalla näistä pienimmän ja lisäämällä tulokseen yhden. Niinpä seuraavaksi tulee ratkaista kolme osaongelmaa x=8x=8, x=11x=11 ja x=12x=12. Nämä osaongelmat voi ratkaista samalla tavalla jakamalla ne edelleen pienempiin osaongelmiin.

Voimme toteuttaa dynaamisen ohjelmoinnin algoritmin käytännössä käymällä läpi osaongelmat pienimmästä suurimpaan:

def min_coins(x, coins):
    result = {}

    result[0] = 0
    for s in range(1, x + 1):
        result[s] = s
        for c in coins:
            if s - c >= 0:
                result[s] = min(result[s], result[s - c] + 1)

    return result[x]

Funktio muodostaa sanakirjan result, johon tallennetaan jokaiselle summalle 0x0 \dots x pienin kolikoiden määrä, jolla summa voidaan muodostaa. Funktio merkitsee ensin, että summan 00 muodostamiseen tarvitaan 00 kolikkoa. Tämän jälkeen funktio laskee muiden tapausten vastaukset silmukalla. Jokaisen summan kohdalla funktio käy läpi kolikot ja valitsee vaihtoehdon, jossa kolikoiden määrä on pienin.

Huomaa, että koska yksi kolikoista on aina arvoltaan 11, kaikki summat 0x0 \dots x voidaan muodostaa kolikoista. Silmukassa kohtaan result[s] asetetaan alkuarvo s, joka vastaa ratkaisua, jossa jokainen kolikko on 11.

Funktiota voidaan käyttää näin:

print(min_coins(13, [1, 2, 5])) # 4
print(min_coins(13, [1, 4, 5])) # 3
print(min_coins(42, [1, 5, 6, 17])) # 5

Kun x=13x=13 ja kolikot ovat [1,2,5][1,2,5], funktio laskee seuraavat tulokset:

Summa xx 0 1 2 3 4 5 6 7 8 9 10 11 12 13
Pienin määrä 0 1 1 2 2 1 2 2 3 3 2 3 3 4

Tästä näkee, että summan x=13x=13 muodostamiseen tarvitaan 44 kolikkoa ja kaikissa pienemmissä tapauksissa riittää enintään 33 kolikkoa.

Kun x=13x=13 ja kolikot ovat [1,4,5][1,4,5], tulokset ovat:

Summa xx 0 1 2 3 4 5 6 7 8 9 10 11 12 13
Pienin määrä 0 1 2 3 1 1 2 3 2 2 2 3 3 3

Tässä tapauksessa ahne algoritmi ei antaisi oikeaa vastausta, mutta dynaamisen ohjelmoinnin algoritmi toimii, koska se käy läpi kaikki tavat valita kolikot.

Algoritmin aikavaativuus on O(nx)O(nx), missä nn on kolikoiden määrä ja xx on muodostettava summa. Tämän ansiosta voidaan käsitellä tehokkaasti myös suuria tapauksia, mikä ei olisi mahdollista raa’an voiman algoritmeilla.

Optimiratkaisun muodostus

Laajennetaan seuraavaksi dynaamisen ohjelmoinnin algoritmia niin, että se etsii pienimpään ratkaisuun tarvittavat kolikot. Esimerkiksi kun x=13x=13 ja kolikot ovat [1,2,5][1,2,5], algoritmin tulisi palauttaa ratkaisu [1,2,5,5][1,2,5,5] eikä vain ilmoittaa, että pienimmässä ratkaisussa on 44 kolikkoa.

Tällainen algoritmi voidaan toteuttaa tallentamalla jokaiselle summalle 0x0 \dots x lista kolikoista, joilla summa voidaan muodostaa ja jossa kolikoiden määrä on mahdollisimman pieni. Tällöin listan pituus on yhtä suuri kuin summaa xx vastaava pienin kolikoiden määrä. Tämä voidaan toteuttaa seuraavasti:

def min_coins(x, coins):
    result = {}

    result[0] = []
    for s in range(1, x + 1):
        result[s] = [1] * s
        for c in coins:
            if s - c >= 0:
                new_result = result[s - c] + [c]
                if len(new_result) < len(result[s]):
                    result[s] = new_result

    return sorted(result[x])

Silmukassa lista new_result muodostetaan ottamalla pohjaksi lyhin lista, jonka kolikot muodostavat summan scs - c. Tämän listan perään lisätään kolikko cc, jolloin saadaan lyhin lista, jonka kolikot muodostavat summan ss ja jossa viimeinen kolikko on cc. Jos lista on aiempaa lyhempi, siitä tulee ratkaisu summalle ss.

Funktiota voidaan käyttää näin:

print(min_coins(13, [1, 2, 5])) # [1, 2, 5, 5]
print(min_coins(13, [1, 4, 5])) # [4, 4, 5]
print(min_coins(42, [1, 5, 6, 17])) # [1, 1, 6, 17, 17]

Ratkaisujen laskeminen

Dynaamisen ohjelmoinnin avulla voidaan myös laskea, montako erilaista ratkaisua tehtävään on olemassa. Esimerkiksi kun x=13x=13 ja kolikot ovat [1,2,5][1,2,5], mahdollisia ratkaisuja on yhteensä 634634. Tässä on joitakin mahdollisia ratkaisuja:

Kaikkien ratkaisujen määrä voidaan etsiä melko samalla tavalla kuin pienimmän ratkaisun kolikkojen määrä. Haku voidaan toteuttaa seuraavasti:

def count_coins(x, coins):
    result = {}

    result[0] = 1
    for s in range(1, x + 1):
        result[s] = 0
        for coin in coins:
            if s - coin >= 0:
                result[s] += result[s - coin]

    return result[x]

Erona aiempaan funktioon min_coins verrattuna on, että funktio laskee minimien sijasta summia. Tapauksessa x=0x=0 tulos on 11, koska on yksi tapa muodostaa summa 00 (ei valita mitään kolikkoja). Muissa tapauksissa funktio käy läpi vaihtoehdot, mikä on summan viimeinen kolikko, ja laskee mukaan nämä tapaukset.

Funktiota voidaan käyttää näin:

print(count_coins(13, [1, 2, 5])) # 634
print(count_coins(13, [1, 4, 5])) # 88
print(count_coins(42, [1, 5, 6, 17])) # 1103532

Huomaa, että funktio laskee erikseen kaikki tavat järjestää kolikot listaan. Esimerkiksi funktio laskee erikseen mukaan ratkaisut [1,2,5,5][1,2,5,5], [1,5,2,5][1,5,2,5] ja [1,5,5,2][1,5,5,2], vaikka ne muodostuvat samoista kolikoista. Vaikeampi tehtävä, joka on myös mahdollista ratkaista dynaamisella ohjelmoinnilla, on laskea mukaan jokainen samojen kolikoiden yhdistelmä vain kerran.

Esimerkki: Alijonot

Tehtävä

Annettuna on lista, jossa on nn kokonaislukua. Tehtäväsi on laskea, kuinka pitkä on listan pisin nouseva alijono eli montako lukua listalta voidaan kerätä vasemmalta oikealle kulkien niin, että jokainen luku on edellistä suurempi. Voit ohittaa lukuja keräämisen aikana.

Esimerkiksi listassa [4,1,5,6,3,4,3,8][4,1,5,6,3,4,3,8] pisin nouseva alijono on [1,3,4,8][1,3,4,8], jonka pituus on 44.

Voimme lähestyä tehtävää dynaamisen ohjelmoinnin avulla laskemalla jokaiselle listan kohdalle, kuinka pitkä on pisin kyseiseen kohtaan päättyvä alijono. Esimerkissä laskettavat luvut ovat seuraavat:

Listan kohta 0 1 2 3 4 5 6 7
Pisin alijono 1 1 2 3 2 3 1 4

Esimerkiksi pisin kohtaan 55 päättyvä alijono on [1,3,4][1,3,4], jonka pituus on 33.

Seuraava funktio find_longest etsii annetun listan pisimmän nousevan alijonon dynaamisen ohjelmoinnin avulla:

def find_longest(items):
    result = {}

    max_len = 0
    for i in range(len(items)):
        result[i] = 1
        for j in range(i):
            if items[j] < items[i]:
                result[i] = max(result[i], result[j] + 1)
        max_len = max(max_len, result[i])

    return max_len

Funktio luo sanakirjan result ja laskee siihen jokaiseen kohtaan pisimmän nousevan alijonon pituuden listassa. Muuttuja i käy läpi listan kohdat ja muuttuja j käy läpi mahdolliset kohdat, jossa on alijonon edellinen luku. Jos edellinen luku on pienempi kuin nykyinen luku, alijonoa voidaan laajentaa. Muuttujassa max_len pidetään yllä pisimmän alijonon pituutta.

Funktiota voidaan käyttää näin:

print(find_longest([4, 1, 5, 6, 3, 4, 1, 8])) # 4

Tämän algoritmin aikavaativuus on O(n2)O(n^2), koska siinä on kaksi sisäkkäistä silmukkaa. Koska kaikkien alijonojen määrä on O(2n)O(2^n), tämä on merkittävä tehostus verrattuna siihen, että kaikki alijonot käytäisiin läpi.

Esimerkki: Sulkulausekkeet

Joskus raa’an voiman ratkaisu on yllättävän helppoa muuttaa dynaamisen ohjelmoinnin ratkaisuksi. Näin on seuraavassa tehtävässä:

Tehtävä

Sulkulauseke on suluista ( ja ) muodostuva merkkijono, joka vastaa oikein muodostettua matemaattista kaavaa. Tehtäväsi on laskea, montako sulkulauseketta voidaan muodostaa nn merkistä.

Esimerkiksi kun n=6n=6, haluttu vastaus on 55, koska mahdolliset sulkulausekkeet ovat ((())), (())(), ()(()), (()()) ja ()()().

Edellisessä luvussa ratkaisimme tehtävän raa’an voiman avulla näin:

def count_sequences(n, d=0):
    if d < 0 or d > n:
        return 0
    if n == 0:
        return 1
    return count_sequences(n - 1, d + 1) + \
           count_sequences(n - 1, d - 1)

Tämän ratkaisun voi muuttaa tehokkaaksi dynaamisen ohjelmoinnin ratkaisuksi tallentamalla funktion tulokset muistiin sanakirjaan:

def count_sequences(n, d=0, result={}):
    if d < 0 or d > n:
        return 0
    if n == 0:
        return 1
    if (n, d) not in result:
        result[(n, d)] = count_sequences(n - 1, d + 1) + \
                         count_sequences(n - 1, d - 1)
    return result[(n, d)]

Tässä tapauksessa dynaamisen ohjelmoinnin osaongelmat ovat pareja muotoa (n,d)(n,d), missä nn on lisättävien sulkujen määrä ja dd on muodostetun lausekkeen syvyys. Sanakirja result sisältää vastaukset kaikille parametrien yhdistelmille, jotka funktio on jo laskenut. Funktio laskee vastauksen rekursiivisesti vain, jos sitä ei ole laskettu aiemmin, ja hakee vastauksen muuten suoraan sanakirjasta.

Alkuperäinen raa’an voiman funktio soveltui vain pienten tapausten laskemiseen, mutta dynaamisen ohjelmoinnin ansiosta voimme laskea myös suuria tapauksia. Esimerkiksi voimme laskea sulkulausekkeiden määrän, kun n=100n=100:

print(count_sequences(100)) # 1978261657756160653623774456

Koska jokainen funktion kutsu vie aikaa O(1)O(1) ja erilaisia parametrien yhdistelmiä on O(n2)O(n^2), algoritmin aikavaativuus on O(n2)O(n^2).

Tämä tehtävä on mahdollista ratkaista myös toisella tavalla dynaamisella ohjelmoinnilla niin, että funktiolla on pelkkä parametri nn:

def count_sequences(n, result={}):
    if n == 0:
        return 1
    if n not in result:
        count = 0
        for i in range(2, n + 1, 2):
            count += count_sequences(i - 2) * \
                     count_sequences(n - i)
        result[n] = count
    return result[n]

Tässä ideana on käydä läpi kaikki tavat valita kohta, jossa on ensimmäistä alkusulkua ( vastaava loppusulku ). Jokaisessa tavassa näiden sulkujen väliin sekä näiden sulkujen jälkeen tulee jokin sulkulauseke, minkä ansiosta lausekkeiden määrä saadaan kertomalla väliin ja jälkeen tulevien lausekkeiden määrät.

Tässä algoritmissa jokainen funktion kutsu vie aikaa O(n)O(n) ja erilaisia parametrien yhdistelmiä on O(n)O(n). Myös tämän algoritmin aikavaativuus on siis O(n2)O(n^2), vaikka algoritmien logiikka on hyvin erilainen.

Sisäkkäinen rekursio

Rekursiolla toteutettu dynaaminen ohjelmointi voi aiheuttaa paljon sisäkkäistä rekursiota, mikä voi aiheuttaa ongelman koodin suorittamiseen. Esimerkiksi seuraava koodi ei toimi toivotulla tavalla:

print(count_sequences(2000)) # ?

Koodin suoritus aiheuttaa virheen ”RecursionError: maximum recursion depth exceeded in comparison” mikä tarkoittaa, että funktiota count_sequences kutsutaan liian monta kertaa rekursiivisesti.

Pythonissa sisäkkäisten rekursiokutsujen raja on usein oletuksena melko pieni, esimerkiksi 1000. Rajaa voidaan kuitenkin nostaa moduulin sys funktiolla setrecursionlimit seuraavasti:

import sys
sys.setrecursionlimit(5000)

Tämän jälkeen koodin suorittaminen onnistuu:

print(count_sequences(2000)) # 2046105521468021692642519982997827217179245642339057975844538099572176010191891863964968026156453752449015750569428595097318163634370154637380666882886375203359653243390929717431080443509007504772912973142253209352126946839844796747697638537600100637918819326569730982083021538057087711176285777909275869648636874856805956580057673173655666887003493944650164153396910927037406301799052584663611016897272893305532116292143271037140718751625839812072682464343153792956281748582435751481498598087586998603921577523657477775758899987954012641033870640665444651660246024318184109046864244732001962029120

Rekursiorajan kasvattaminen voi kuitenkin olla kyseenalainen ratkaisu, koska se saattaa aiheuttaa ongelmia Pythonin suoritusympäristössä. Toinen ratkaisu on muuttaa dynaamisen ohjelmoinnin toteutusta niin, että rekursion sijasta käytetäänkin silmukoita. Tarkastellaan esimerkkinä seuraavaa toteutusta:

def count_sequences(n, d=0, result={}):
    if d < 0 or d > n:
        return 0
    if n == 0:
        return 1
    if (n, d) not in result:
        result[(n, d)] = count_sequences(n - 1, d + 1) + \
                         count_sequences(n - 1, d - 1)
    return result[(n, d)]

Rekursio voidaan poistaa miettimällä, missä järjestyksessä funktio lisää osaongelmien vastaukset sanakirjaan result, ja toteuttamalla sitten vastaavan laskennan ilman rekursiota. Tässä tapauksessa laskenta voidaan toteuttaa näin:

def count_sequences(n):
    result = {}
    result[(0, 0)] = 1
    for i in range(1, n + 1):
        result[(0, i)] = 0
    for i in range(1, n + 1):
        for j in range(0, n + 1):
            result[(i, j)] = 0
            if j + 1 <= n:
                result[(i, j)] += result[(i - 1, j + 1)]
            if j - 1 >= 0:
                result[(i, j)] += result[(i - 1, j - 1)]
    return result[(n, 0)]

Tämän toteutuksen etuna on, että siinä ei ole rekursiota eikä sitä käyttäessä tarvitse huolehtia Pythonin rekursiorajasta. Kuitenkin huonona puolena tällaisessa toteutuksessa täytyy suunnitella itse, missä järjestyksessä osaongelmat käsitellään, eikä sitä voi jättää rekursion vastuulle.