1. Johdanto
Kurssin Tietorakenteet ja algoritmit tavoitteena on syventää ohjelmointitaitoa ja opettaa ajattelua ja tekniikoita, joiden avulla voi toteuttaa ohjelmia, jotka toimivat oikein ja tehokkaasti kaikissa tilanteissa.
Tutustumme kurssin aiheisiin Python-kielen kautta, mutta kurssin asiat pätevät muissakin ohjelmointikielissä. Kurssilla on paljon ohjelmointia, minkä lisäksi käsittelemme myös aiheeseen liittyvää teoriaa.
Mikä on algoritmi?
Algoritmi (algorithm) on menetelmä, jonka avulla voidaan ratkaista jokin laskennallinen tehtävä. Kun algoritmi toteutetaan ohjelmointikielellä, se voidaan suorittaa tietokoneella.
Algoritmin syöte (input) tarkoittaa, mitä lähtötietoja algoritmille annetaan. Algoritmin tuloste (output) puolestaan tarkoittaa, minkä vastauksen algoritmi antaa suorituksen jälkeen. Pythonissa algoritmi voidaan toteuttaa funktiona, jolloin algoritmin syöte annetaan funktion parametreina ja algoritmin tuloste on funktion palautusarvo.
Tarkastellaan esimerkkinä tehtävää, jossa algoritmille annetaan lista lukuja ja algoritmin pitää laskea, moniko luku on parillinen. Esimerkiksi jos lista on [5, 4, 1, 7, 9, 6]
, haluttu vastaus on 2
, koska luvut 4
ja 6
ovat parillisia.
Tämä tehtävä voidaan ratkaista algoritmilla, joka käy läpi listan luvut ja pitää muuttujassa yllä tietoa, moniko luku on parillinen. Algoritmi voidaan toteuttaa seuraavasti Pythonilla funktiona count_even
:
def count_even(numbers):
result = 0
for x in numbers:
if x % 2 == 0:
result += 1
return result
Funktiota voidaan testata seuraavan pääohjelman avulla:
print(count_even([1, 2, 3])) # 1
print(count_even([2, 2, 2, 2, 2])) # 5
print(count_even([5, 4, 1, 7, 9, 6])) # 2
Tämä pääohjelma testaa funktion toimintaa kolmella eri listalla. Jokaisen testin yhteydessä kommentissa lukee, mikä vastaus testistä pitäisi tulla. Kun ohjelma suoritetaan, sen tulostus on seuraava:
1
5
2
Tämän perusteella vaikuttaa siltä, että funktio toimii halutulla tavalla eli olemme saaneet aikaan toimivan algoritmin tehtävään.
Mikä on tietorakenne?
Tietorakenne (data structure) on tapa pitää muistissa tietoa ohjelmassa. Pythonin perustietorakenne on lista, jonka lisäksi kielessä on muita valmiita tietorakenteita. Sopivien tietorakenteiden valinta on tärkeä osa algoritmien suunnittelua, koska tietorakenteet vaikuttavat paljon algoritmien tehokkuuteen.
Tutustumme tällä kurssilla moniin tietorakenteisiin ja niiden käyttötarkoituksiin algoritmien suunnittelussa. Käymme läpi Pythonin valmiita tietorakenteita, minkä lisäksi opimme toteuttamaan myös itse tietorakenteita, joita ei ole valmiina Pythonissa eikä muissa kielissä.
Algoritmin toteuttaminen
Minkä tahansa algoritmin pystyy toteuttamaan ohjelmoinnin perusasioiden avulla. Pythonissa tällaisia perusasioita ovat:
- muuttujat
- ehdot (
if
) - silmukat (
for
,while
) - listat
- funktiot
- luokat
Näiden lisäksi ohjelmointikielissä on usein paljon muita ominaisuuksia, joiden avulla voi esimerkiksi tiivistää koodia mutta jotka eivät pohjimmiltaan muuta koodin toimintalogiikkaa. Näitä ominaisuuksia voi käyttää algoritmien toteuttamisessa, mutta niitä ilmankin pärjää mainiosti.
Tarkastellaan vielä äskeistä funktiota count_even
, joka on toteutettu käyttäen ohjelmoinnin perusasioita:
def count_even(numbers):
result = 0
for x in numbers:
if x % 2 == 0:
result += 1
return result
Tämän funktion voi toteuttaa tiiviimmin käyttämällä Python-kielelle erityistä generaattorilauseketta:
def count_even(numbers):
return sum(x % 2 == 0 for x in numbers)
Tässä sum
-funktion sisällä on generaattorilauseke, joka laskee jokaiselle listan alkiolle x
lausekkeen x % 2 == 0
arvon (True
tai False
). Kun nämä arvot lasketaan yhteen, jokainen True
-arvo tulkitaan luvuksi 1
, jolloin arvojen summa on yhtä suuri kuin parillisten lukujen määrä.
Vaikka jälkimmäinen funktio on paljon lyhyempi, se kuitenkin tekee pohjimmiltaan saman asian kuin ensimmäinen funktio. Molemmat funktiot käyvät läpi listan luvut ja laskevat yhteen parillisten lukujen määrän, eikä funktioissa ole eroa algoritmin toimintalogiikan kannalta.
Ensimmäisen funktion etuna on, että se on helpompi ymmärtää henkilölle, joka ei tunne Python-kielen erikoisominaisuuksia. Funktion voisi toteuttaa melko samalla tavalla esimerkiksi JavaScriptilla:
function countEven(numbers) {
let result = 0;
for (let x of numbers) {
if (x % 2 == 0) result++;
}
return result;
}
Jälkimmäisen funktion etuna on, että se on tiiviimpi ja sen voi ajatella olevan enemmän Python-kielen tyylin mukainen. Vaikka ohjelmoinnin perusasiat riittävät kaikkeen, voi olla kiinnostavaa oppia myös kielten erikoisominaisuuksia.
Algoritmin tehokkuus
Saman tehtävän ratkaisemiseen on olemassa usein monia erilaisia algoritmeja, joiden tehokkuudessa voi olla eroja. Usein tavoitteena on löytää tehokas algoritmi, jonka avulla tehtävä voidaan ratkaista nopeasti.
Tarkastellaan tehtävää, jossa annettuna on lista lukuja ja tavoitteena on laskea, mikä on suurin ero kahden luvun välillä. Esimerkiksi kun lista on [3, 2, 6, 5, 8, 5]
, haluttu vastaus on 6
, koska suurin ero on lukujen 2
ja 8
välillä.
Seuraavassa on kolme algoritmia tämän tehtävän ratkaisemiseen:
Algoritmi 1
def max_diff(numbers):
result = 0
for x in numbers:
for y in numbers:
result = max(result, abs(x - y))
return result
Tämä algoritmi muodostuu kahdesta for
-silmukasta, jotka käyvät läpi kaikki tavat valita listalta kaksi lukua. Algoritmi laskee lukujen eron abs
-funktion (itseisarvo) avulla ja pitää muistissa tietoa, mikä on suurin löytynyt ero.
Algoritmi 2
def max_diff(numbers):
numbers = sorted(numbers)
return numbers[-1] - numbers[0]
Tämän algoritmin ideana on, että suurin ero kahden luvun välillä saadaan valitsemalla listan pienin ja suurin luku.
Algoritmi järjestää ensin listan sisällön sorted
-funktion avulla. Tämän jälkeen pienin luku on listan alussa ja suurin luku on listan lopussa, joten algoritmin riittää palauttaa listan viimeisen luvun (indeksi -1
) ja ensimmäisen luvun (indeksi 0
) erotus.
Algoritmi 3
def max_diff(numbers):
return max(numbers) - min(numbers)
Tässä on vielä toinen tapa toteuttaa äskeiseen ideaan perustuva algoritmi. Listan järjestämisen sijasta algoritmi käyttää funktioita min
ja max
, jotka palauttavat listan pienimmän ja suurimman alkion.
Tehokkuuden tutkiminen
Algoritmin tehokkuutta voidaan tutkia testiohjelmalla, joka antaa algoritmille tietyn syötteen ja mittaa algoritmin suoritusajan. Usein hyvä tapa on laatia testiohjelma niin, että se muodostaa halutun kokoisen syötteen satunnaisesti. Tämän avulla algoritmia voidaan testata helposti erikokoisilla syötteillä.
Seuraavassa on ohjelma, joka testaa funktion max_diff
tehokkuutta:
import random
import time
def max_diff(numbers):
...
n = 1000
print("n:", n)
random.seed(1337)
numbers = [random.randint(1, 10**6) for _ in range(n)]
start_time = time.time()
result = max_diff(numbers)
end_time = time.time()
print("result:", result)
print("time:", round(end_time - start_time, 2), "s")
Muuttujassa n
annetaan testissä käytettävän listan pituus. Funktio random.seed
määrittää siemenluvun (tässä 1337
), jonka ansiosta satunnaisluvut muodostetaan aina samalla tavalla. Tästä on hyötyä, kun testi suoritetaan useita kertoja, jolloin listan sisältö on aina sama. Ohjelma luo funktiolla random.randint
listan, jossa on n
satunnaista lukua väliltä \(1 \dots 10^6\).
Ohjelma mittaa funktion ajankäytön funktion time.time
avulla. Funktio palauttaa sekunteina kuluneen ajan vuoden 1970 alusta alkaen. Kun tämä aika laitetaan talteen ennen funktion kutsua ja funktion kutsun jälkeen, aikojen erotus kertoo, montako sekuntia funktion suoritus vei aikaa. Aika pyöristetään kahden desimaalin tarkkuudelle funktiolla round
.
Ohjelman suoritus voi näyttää seuraavalta:
n: 1000
result: 999266
time: 0.09 s
Tämä tarkoittaa, että algoritmin syötteenä oli 1000
-kokoinen lista, algoritmi antoi tuloksen 999266
ja algoritmin suoritukseen meni aikaa 0.09
sekuntia.
Seuraava taulukko näyttää, paljonko äskeiset algoritmit veivät aikaa erikokoisilla syötteillä testikoneella:
Listan koko n |
Algoritmi 1 | Algoritmi 2 | Algoritmi 3 |
---|---|---|---|
1000 | 0.17 s | 0.00 s | 0.00 s |
10000 | 15.93 s | 0.00 s | 0.00 s |
100000 | – | 0.01 s | 0.00 s |
1000000 | – | 0.27 s | 0.02 s |
Taulukosta näkee, että algoritmien tehokkuudessa on suuria eroja. Algoritmi 1 on hidas suurilla syötteillä, ja kaksi suurinta testiä jouduttiin keskeyttämään, koska suoritus vei liikaa aikaa. Algoritmit 2 ja 3 ovat sen sijaan tehokkaita myös suurilla syötteillä. Viimeinen testi tuo näkyviin eron myös algoritmin 2 ja 3 välille, vaikkakaan ero ei ole niin suuri kuin verrattuna algoritmiin 1.
Algoritmin analysointi
Algoritmin tehokkuutta voidaan analysoida laskemalla, montako askelta algoritmi suorittaa, kun sille annetaan tietyn kokoinen syöte. Usein voidaan ajatella, että jokainen koodissa oleva rivi vastaa yhtä algoritmin askelta.
Tarkastellaan esimerkkinä seuraavaa algoritmia, joka laskee, montako parillista lukua on annetussa listassa.
1
2
3
4
5
6
def count_even(numbers):
result = 0
for x in numbers:
if x % 2 == 0:
result += 1
return result
Merkitään \(n\):llä listan numbers
alkioiden määrää. Koska algoritmi käy listan sisällön läpi, algoritmin askelten määrä riippuu \(n\):stä.
-
Algoritmi suorittaa kerran rivit 2 ja 6, koska ne ovat silmukan ulkopuolella.
-
Algoritmi suorittaa \(n\) kertaa rivit 3 ja 4, koska ne suoritetaan jokaiselle listalla olevalle alkiolle.
-
Algoritmi suorittaa rivin 5 vähintään \(0\) kertaa ja enintään \(n\) kertaa, riippuen siitä, mitkä listan alkiot ovat parillisia.
Tämän perusteella algoritmi suorittaa vähintään \(2n+2\) askelta ja enintään \(3n+2\) askelta. Askelten tarkka määrä riippuu siitä, montako parillista lukua listalla on.
Aikavaativuus
Usein ei ole tarvetta laskea tarkasti algoritmin askelten määrää, vaan riittää määrittää algoritmin aikavaativuus (time complexity). Tämä antaa suuruusluokan sille, montako askelta algoritmi suorittaa tietyn kokoisella syötteellä.
Aikavaativuus ilmoitetaan usein muodossa \(O(\cdots)\), jossa kolmen pisteen tilalla on muuttujan \(n\) sisältävä kaava, joka antaa ylärajan algoritmin askelten määrälle. Muuttuja \(n\) kuvaa syötteen kokoa. Esimerkiksi jos syötteenä on lista, \(n\) tarkoittaa listan kokoa, ja jos syötteenä on merkkijono, \(n\) tarkoittaa merkkijonon pituutta.
Aikavaativuus voidaan usein päätellä algoritmin askelten määrän kaavasta ottamalla huomioon vain kaavan nopeiten kasvava osa ja poistamalla vakiokertoimet. Esimerkiksi äskeisen algoritmin aikavaativuus on \(O(n)\), koska algoritmi suorittaa enintään \(3n+2\) askelta.
Tarkasti määriteltynä algoritmin aikavaativuus on \(O(f(n))\), jos voidaan valita vakiot \(c\) ja \(n_0\) niin, että algoritmi suorittaa aina enintään \(c f(n)\) askelta, kun \(n \ge n_0\). Esimerkiksi äskeisen algoritmin tapauksessa aikavaativuus on \(O(n)\), koska voidaan valita \(c=5\) ja \(n_0=1\). Tämä valinta on toimiva, koska \(3n+2 \le 5n\), kun \(n \ge 1\).
Tavallisia aikavaativuuksia ovat:
Aikavaativuus | Algoritmin nimitys |
---|---|
\(O(1)\) | Vakioaikainen |
\(O(\log n)\) | Logaritminen |
\(O(n)\) | Lineaarinen |
\(O(n \log n)\) | – |
\(O(n^2)\) | Neliöllinen |
\(O(n^3)\) | Kuutiollinen |
Silmukat koodissa
Käytännössä algoritmin aikavaativuus liittyy usein siihen, millaisia silmukoita algoritmin koodissa on.
Vakioaikaisuus
Jos algoritmissa ei ole silmukkaa vaan se suorittaa samat askeleet riippumatta syötteestä, algoritmin aikavaativuus on \(O(1)\).
Esimerkiksi seuraavan algoritmin aikavaativuus on \(O(1)\):
def middle(numbers):
n = len(numbers)
return numbers[n // 2]
Yksittäinen silmukka
Jos algoritmissa on yksi silmukka, joka käy läpi syötteen alkiot, algoritmin aikavaativuus on \(O(n)\).
Esimerkiksi seuraavan algoritmin aikavaativuus on \(O(n)\):
def calc_sum(numbers):
result = 0
for x in numbers:
result += x
return result
Algoritmin aikavaativuus on \(O(n)\), koska siinä on yksi silmukka, joka käy läpi syötteen alkiot.
Sisäkkäiset silmukat
Jos algoritmissa on kaksi sisäkkäistä silmukkaa, jotka käyvät läpi syötteen alkiot, algoritmin aikavaativuus on \(O(n^2)\).
Esimerkiksi seuraavan algoritmin aikavaativuus on \(O(n^2)\):
def has_sum(numbers, x):
for a in numbers:
for b in numbers:
if a + b == x:
return True
return False
Yleisemmin jos algoritmissa on \(k\) sisäkkäistä silmukkaa, jotka käyvät läpi syötteen alkiot, algoritmin aikavaativuus on \(O(n^k)\).
Peräkkäiset osat
Jos algoritmissa on peräkkäisiä osia, algoritmin aikavaativuus on suurin yksittäisen osan aikavaativuus.
Esimerkiksi seuraavan algoritmin aikavaativuus on \(O(n)\):
def count_min(numbers):
# osa 1
min_value = numbers[0]
for x in numbers:
if x < min_value:
min_value = x
# osa 2
result = 0
for x in numbers:
if x == min_value:
result += 1
return result
Algoritmin ensimmäinen osa käy läpi syötteenä annetun listan alkiot ja etsii listan pienimmän alkion. Tämän osan aikavaativuus on \(O(n)\).
Algoritmin toinen osa käy läpi syötteen uudelleen ja laskee, montako kertaa pienin alkio esiintyy listalla. Tämän osan aikavaativuus on \(O(n)\).
Koska kummankin osan aikavaativuus on \(O(n)\), algoritmin aikavaativuus on \(O(n)\).
2. Lista
Lista (list) on monikäyttöinen tietorakenne, joka on Python-kielen perustietorakenne. Lista soveltuu usein ohjelmassa käsiteltävän tiedon tallentamiseen, mutta toisaalta on tilanteita, joissa listan sijasta kannattaa käyttää muita tietorakenteita.
Tutustumme tässä luvussa Pythonin listan toteutukseen ja ominaisuuksiin. Kiinnitämme erityisesti huomiota listan operaatioiden aikavaativuuteen: mitkä listan operaatiot ovat tehokkaita ja milloin listaa kannattaa käyttää.
Lista muistissa
Tietokoneen muisti muodostuu peräkkäisistä muistipaikoista, joihin voidaan tallentaa tietoa. Jokaisella muistipaikalla on osoite, jolla siihen voidaan viitata. Muistiin tallennetaan tiedot, joita ohjelma käsittelee suorituksensa aikana.
Tarkastellaan esimerkkinä seuraavaa Python-ohjelmaa:
a = 7
b = -3
c = [1, 2, 3, 1, 2]
d = 99
Oletetaan, että ohjelmassa määritellyt muuttujat ja lista on tallennettu muistiin osoitteesta 100 alkaen. Seuraavassa on yksinkertaistettu esitys siitä, miltä muistin sisältö voisi näyttää tässä tilanteessa:
100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 |
7 | -3 | 1 | 2 | 3 | 1 | 2 | 0 | 0 | 0 | 99 |
a |
b |
c |
d |
Tässä muuttujan a
sisältö on muistipaikassa 100 ja muuttujan b
sisältö on muistipaikassa 101. Listalle c
on varattu muistipaikat 102–109, joista tällä hetkellä on käytössä muistipaikat 102–106, koska listalla on 5 alkiota. Muuttujan d
sisältö on puolestaan muistipaikassa 110.
Listan alkiot on tallennettu peräkkäisiin muistipaikkoihin, minkä ansiosta on helppoa selvittää, missä kohdassa muistia tietty listan alkio sijaitsee. Tässä tapauksessa alkion osoite muistissa saadaan lisäämällä alkion indeksi listan alkukohtaan 102. Esimerkiksi listan kohdassa 2 oleva alkio on osoitteessa 102 + 2 = 104.
Huomaa, että listalle on varattu enemmän muistia kuin olisi tällä hetkellä tarvetta. Tämän ideana on varautua siihen, että listan alkioiden määrä saattaa kasvaa myöhemmin. Listaan liittyy siis kaksi kokoa: listan todellinen koko (tässä 5) sekä listalle muistista varatun alueen koko (tässä 8).
Listan operaatiot
Pythonissa on monia valmiita operaatioita, joiden avulla voidaan käsitellä listoja. Tarkastellaan seuraavaksi näiden operaatioiden tehokkuutta aikavaativuuksien näkökulmasta olettaen, että listalla on \(n\) alkiota.
Listan operaatioiden aikavaativuuksien tunteminen on tärkeää algoritmien suunnittelussa, koska niistä voi päätellä, mitä operaatioita voi käyttää tehokkaan algoritmin osana. Yleisimmät aikavaativuudet ovat:
- \(O(1)\): operaatio toimii aina tehokkaasti riippumatta listan koosta
- \(O(n)\): operaation tehokkuus riippuu listan koosta ja operaatio voi olla hidas, jos listan koko on suuri
Indeksointi
Listan alkioita voidaan hakea tai muuttaa indeksin perusteella []
-syntaksilla.
numbers = [4, 3, 7, 3, 2]
print(numbers[2]) # 7
numbers[2] = 5
print(numbers[2]) # 5
Alkion hakeminen tai muuttaminen vie aikaa \(O(1)\), koska alkiot ovat peräkkäin muistissa ja tietyn alkion muistiosoite voidaan laskea tehokkaasti.
Listan koko
Funktio len
kertoo, montako alkiota listalla on:
numbers = [4, 3, 7, 3, 2]
print(len(numbers)) # 5
Funktio vie aikaa \(O(1)\), koska listan yhteyteen muistissa on tallennettu sen koko.
Etsiminen
Operaattori in
kertoo, onko alkio listalla. Metodi index
antaa ensimmäisen indeksin, jossa alkio esiintyy listalla. Metodi count
laskee, montako kertaa alkio esiintyy listalla.
numbers = [4, 3, 7, 3, 2]
print(3 in numbers) # True
print(8 in numbers) # False
print(numbers.index(3)) # 1
print(numbers.count(3)) # 2
Kaikki nämä operaatiot vievät aikaa \(O(n)\), koska niiden täytyy käydä läpi listan alkiot. Esimerkiksi metodi count
vastaa logiikaltaan seuraavaa funktiota, joka käy läpi listan alkiot silmukan avulla:
def count(items, target):
result = 0
for item in items:
if item == target:
result += 1
return result
Huomaa, että operaatiot saattavat toimia tehokkaasti tietyissä tapauksissa. Esimerkiksi jos etsittävä alkio on listan alussa, operaattori in
toimii nopeasti, koska se voi lopettaa listan läpikäynnin heti alkion löydyttyä. Kuitenkin pahimmassa tapauksessa alkio ei esiinny listalla ja koko lista täytyy käydä läpi.
Alkion lisääminen
Metodi append
lisää alkion listan loppuun, ja metodi insert
lisää alkion annettuun kohtaan listalla.
numbers = [1, 2, 3, 4]
numbers.append(5)
print(numbers) # [1, 2, 3, 4, 5]
numbers.insert(1, 6)
print(numbers) # [1, 6, 2, 3, 4, 5]
Näiden metodien aikavaativuuteen vaikuttaa listan tallennustapa muistissa. Listan ensimmäinen alkio on tallennettu tiettyyn kohtaan muistia, ja kaikki muut alkiot on tallennettu peräkkäin sen jälkeen. Esimerkiksi yllä olevan koodin alussa muistin sisältö voi näyttää seuraavalta ennen lisäyksiä:
100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 |
1 | 2 | 3 | 4 | 0 | 0 | 0 | 0 |
Metodi append
toimii ajassa \(O(1)\), koska alkion lisääminen listan loppuun ei aiheuta muutoksia muiden alkioiden paikkoihin. Esimerkissä alkio 5
voidaan lisätä tallentamalla se muistipaikkaan 104:
100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 |
1 | 2 | 3 | 4 | 5 | 0 | 0 | 0 |
Jos listalle varatulla muistialueella ei ole tilaa uudelle alkiolle, listalle täytyy varata uusi suurempi muistialue ja siirtää listan sisältö sinne. Kuitenkin sopivalla toteutuksella tämä tilanne esiintyy harvoin ja metodi append
toimii keskimäärin ajassa \(O(1)\).
Metodi insert
toimii ajassa \(O(n)\), koska kun alkio lisätään muualle kuin listan loppuun, kaikkia sen jälkeisiä alkioita tulee siirtää eteenpäin. Esimerkiksi kun alkio 6
lisätään listalle kohtaan 1
, alkioita täytyy siirtää muistissa näin:
100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 |
1 | 6 | 2 | 3 | 4 | 5 | 0 | 0 |
Huomaa, että metodi insert
on kuitenkin tehokas, jos alkio lisätään listan loppuosaan ja siirrettävien alkioiden määrä on pieni.
Alkion poistaminen
Metodi pop
poistaa alkion listasta. Jos metodia kutsutaan ilman parametria, se poistaa listan viimeisen alkion. Jos metodille annetaan parametri, se poistaa alkion parametrin mukaisesta indeksistä.
numbers = [1, 2, 3, 4, 5, 6]
numbers.pop()
print(numbers) # [1, 2, 3, 4, 5]
numbers.pop(1)
print(numbers) # [1, 3, 4, 5]
Tarkastellaan taas, miten alkion poistaminen vaikuttaa muistin sisältöön. Koodin alussa muistin sisältö voi näyttää seuraavalta:
100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 |
1 | 2 | 3 | 4 | 5 | 6 | 0 | 0 |
Kuten lisäämisessä, alkion poistaminen listan lopusta vie aikaa \(O(1)\), koska tämä ei vaikuta muihin alkioihin. Alkion 6
poistaminen muuttaa muistin sisältöä näin:
100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 |
1 | 2 | 3 | 4 | 5 | 0 | 0 | 0 |
Alkion poistaminen listan keskeltä puolestaan vie aikaa \(O(n)\), koska poistetun alkion jälkeisiä alkioita täytyy siirtää taaksepäin. Alkion 2
poistaminen muuttaa muistin sisältöä näin:
100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 |
1 | 3 | 4 | 5 | 0 | 0 | 0 | 0 |
Listalla on myös metodi remove
, joka poistaa annetun alkion ensimmäisen esiintymän listasta:
numbers = [1, 2, 3, 1, 2, 3]
numbers.remove(3)
print(numbers) # [1, 2, 1, 2, 3]
Metodin aikavaativuus on \(O(n)\), koska metodin täytyy ensin etsiä poistettava alkio listalta (samaan tapaan kuin metodissa index
) ja sen jälkeen poistaa alkio ja siirtää sen jälkeen listassa tulevat alkiot.
Yhteenveto
Seuraava taulukko näyttää yhteenvedon tässä käsiteltyjen listan operaatioiden tehokkuudesta:
Operaatio | Aikavaativuus |
---|---|
Indeksointi ([] ) |
\(O(1)\) |
Alkioiden määrä (len ) |
\(O(1)\) |
Onko alkio listassa (in ) |
\(O(n)\) |
Indeksin etsiminen (index ) |
\(O(n)\) |
Esiintymien laskeminen (count ) |
\(O(n)\) |
Lisääminen loppuun (append ) |
\(O(1)\) |
Lisääminen keskelle (insert ) |
\(O(n)\) |
Poistaminen lopusta (pop ) |
\(O(1)\) |
Poistaminen keskeltä (pop ) |
\(O(n)\) |
Etsiminen ja poistaminen (remove ) |
\(O(n)\) |
Listan tehokkaita operaatioita ovat siis alkioiden indeksointi, alkioiden määrän laskeminen sekä alkion lisääminen ja poistaminen listan lopussa. Lista soveltuu erityisesti käytettäväksi algoritmeissa, joissa tehdään enimmäkseen näitä tehokkaita operaatioita ja vähän muita operaatioita.
Viittaukset ja kopiointi
Pythonissa listoja ja muita tietorakenteita käsitellään viittausten kautta, ja listan sijoittaminen muuttujaan kopioi vain viittauksen:
a = [1, 2, 3, 4]
b = a
a.append(5)
print(a) # [1, 2, 3, 4, 5]
print(b) # [1, 2, 3, 4, 5]
Tässä rivi b = a
saa aikaan, että muuttujat a
ja b
viittaavat samaan listaan muistissa. Kun listalle a
lisätään uusi alkio, tämä heijastuu myös listaan b
.
Jos halutaan kopioida viittauksen sijasta listan sisältö, voidaan käyttää metodia copy
seuraavasti:
a = [1, 2, 3, 4]
b = a.copy()
a.append(5)
print(a) # [1, 2, 3, 4, 5]
print(b) # [1, 2, 3, 4]
Tämän seurauksena muuttujat a
ja b
viittaavat eri listoihin muistissa eikä alkion lisääminen listalle a
vaikuta listan b
sisältöön.
Äskeisten koodien tehokkuudessa on merkittävä ero: viittauksen kopiointi vie aikaa \(O(1)\), kun taas sisällön kopiointi vie aikaa \(O(n)\). Niinpä rivi b = a
vie aikaa \(O(1)\), kun taas rivi b = a.copy()
vie aikaa \(O(n)\).
Funktion sivuvaikutukset
Kun funktion parametriksi annetaan tietorakenne, myös tässä tapauksessa kopioidaan vain viittaus. Tällöin funktio voi aiheuttaa sivuvaikutuksia, jos se muuttaa tietorakenteen sisältöä.
Tarkastellaan esimerkkinä seuraavaa funktiota double
, joka palauttaa listan, jossa jokainen annetun listan luku on kaksinkertainen:
def double(numbers):
result = numbers
for i in range(len(result)):
result[i] *= 2
return result
Koska vain listan viittaus kopioidaan, funktion sivuvaikutuksena on, että se muuttaa parametrina annettua listaa:
numbers = [1, 2, 3, 4]
print(double(numbers)) # [2, 4, 6, 8]
print(numbers) # [2, 4, 6, 8]
Tämä ei ole hyvä tilanne, koska funktion tulisi palauttaa uusi lista eikä tehdä muutosta annettuun listaan. Voimme korjata asian esimerkiksi metodin copy
avulla:
def double(numbers):
result = numbers.copy()
for i in range(len(result)):
result[i] *= 2
return result
Tämän jälkeen funktio ei enää muuta alkuperäisen listan sisältöä:
numbers = [1, 2, 3, 4]
print(double(numbers)) # [2, 4, 6, 8]
print(numbers) # [1, 2, 3, 4]
Listan ositus ja yhdistäminen
Pythonin slice-operaattori (syntaksi [:]
) luo olemassa olevan listan pohjalta uuden listan, joka sisältää halutun listan välin:
numbers = [1, 2, 3, 4, 5, 6, 7, 8]
print(numbers[2:6]) # [3, 4, 5, 6]
Tämä operaattori vie aikaa \(O(n)\), koska operaattori kopioi uuteen listaan vanhan listan sisällön annetulta väliltä.
Koska slice-operaattori kopioi listan välin, sen avulla voidaan myös tehdä kopio koko listasta. Seuraavat kaksi riviä tarkoittavat samaa:
result = numbers.copy()
result = numbers[:]
Operaattorin +
avulla voidaan yhdistää listoja:
first = [1, 2, 3, 4]
second = [5, 6, 7, 8]
print(first + second) # [1, 2, 3, 4, 5, 6, 7, 8]
Tämä vie myös aikaa \(O(n)\), koska operaattori kopioi uuteen listaan alkiot yhdistettäviltä listoilta.
Listat muissa kielissä
Tässä luvussa käsitelty listan toteutus tunnetaan yleisemmin nimellä taulukkolista (array list) tai dynaaminen taulukko (dynamic array).
Matalan tason kielissä (kuten C++ ja Java) perustietorakenne on yleensä taulukko (array). Listan tavoin taulukko sisältää peräkkäisiä alkioita, joita voidaan indeksoida. Kuitenkin taulukolle varataan kiinteä muistialue luontihetkellä, eikä taulukon kokoa voi muuttaa myöhemmin. Taulukon lisäksi kielissä on kuitenkin saatavilla listoja, joiden kokoa pystyy muuttamaan.
C++:ssa tietorakenne std::vector
toteuttaa listan:
std::vector<int> numbers;
numbers.push_back(1);
numbers.push_back(2);
numbers.push_back(3);
Javassa puolestaan tietorakenne ArrayList
toteuttaa listan:
ArrayList<Integer> numbers = new ArrayList<>();
numbers.add(1);
numbers.add(2);
numbers.add(3);
JavaScriptin perustietorakenne on nimeltään taulukko (Array
), mutta se on todellisuudessa lista, koska sen kokoa pystyy muuttamaan:
numbers = [];
numbers.push(1);
numbers.push(2);
numbers.push(3);
3. Tehokkaat algoritmit
Tässä ja kahdessa seuraavassa luvussa tutustumme tehokkaiden algoritmien suunnitteluun. Tavoitteemme on saada aikaan algoritmeja, jotka toimivat tehokkaasti myös silloin, kun syötteen koko \(n\) on suuri.
Tavallinen tilanne algoritmien suunnittelussa on, että on helppoa laatia suoraviivainen algoritmi, joka ratkaisee tehtävän kahdella silmukalla ajassa \(O(n^2)\). Tällaista algoritmia voidaan kutsua raa’an voiman (brute force) algoritmiksi. Tämä ei kuitenkaan riitä \(n\):n ollessa suuri, vaan tarvitaan tehokkaampi algoritmi.
Käytännössä tehokkaan algoritmin aikavaativuus on usein \(O(n)\) tai \(O(n \log n)\). Tutustumme ensin \(O(n)\)-algoritmeihin, jotka käyvät syötteen läpi yhdellä silmukalla ja pitävät muistissa sopivalla tavalla valittua tietoa. Aikavaativuus \(O(n \log n)\) liittyy usein järjestämiseen, jota käsittelemme luvussa 5.
Tehokkaan algoritmin runko
Tyypillinen tehokkaan algoritmin runko on seuraava:
# muuttujien määrittely
for ...
# tehokas koodi
# vastauksen palautus
Tällaisessa algoritmissa on yksi for-silmukka, joka käy läpi algoritmille annetun syötteen vasemmalta oikealle. Silmukan sisällä tulee olla tehokasta koodia niin, että silmukan jokainen kierros vie aikaa \(O(1)\). Tällöin koko algoritmin aikavaativuus on \(O(n)\).
Tehokkaan algoritmin silmukan sisällä saa olla seuraavia:
- muuttujan arvon päivitys muiden muuttujien tai yksittäisten syötteen alkioiden perusteella
- laskutoimitus, joka liittyy muuttujan arvon päivittämiseen
- if-komento, joka vaikuttaa sihen, miten muuttujia päivitetään
Sen sijaan silmukan sisällä ei saa olla seuraavia:
- toinen silmukka, joka käy läpi syötteen alkioita
- hidas syötettä käsittelevä operaatio (esim. metodi
count
tai slice-operaatio[:]
) - hidas funktiokutsu (esim.
sum
,min
taimax
koko syötteelle)
Monen algoritmin suunnittelussa keskeinen haaste on keksiä, miten algoritmin saa toteutettua niin, että silmukan sisällä on vain tehokasta koodia. Näemme seuraavaksi esimerkkejä siitä, miten silmukan sisällä oleva tehokas koodi voidaan toteuttaa.
Esimerkki: Osakekauppa
Tehtävä
Annettuna on osakkeen hinta \(n\) päivän ajalta. Tehtäväsi on selvittää, mikä olisi ollut suurin mahdollinen tuotto, jos olisit ostanut osakkeen yhtenä päivänä ja myynyt sen toisena päivänä.
Tarkastellaan esimerkkinä seuraavaa tilannetta:
Päivä | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
Hinta | 3 | 7 | 5 | 1 | 4 | 6 | 2 | 3 |
Tässä suurin mahdollinen tuotto saadaan ostamalla osake päivänä 3 ja myymällä se päivänä 5. Tuotoksi tulee 6 – 1 = 5.
Suoraviivainen algoritmi tehtävän ratkaisemiseen on käydä läpi kaikki vaihtoehdot osakkeen ostopäivän ja myyntipäivän valintaan kahdella silmukalla. Seuraava funktio best_profit
toteuttaa algoritmin:
def best_profit(prices):
n = len(prices)
best = 0
for i in range(n):
for j in range(i + 1, n):
best = max(best, prices[j] - prices[i])
return best
Ideana on, että muuttuja i
valitsee ostopäivän ja muuttuja j
valitsee myyntipäivän. Jokaiselle valinnalle lasketaan näin saatava tuotto osakekaupasta, ja muuttuja best
pitää muistissa parasta tuottoa. Tämä on toimiva algoritmi, mutta ongelmana on, että algoritmin aikavaativuus on \(O(n^2)\) eli se on hidas, kun \(n\) on suuri. Algoritmia tulisi tehostaa niin, että siinä ei ole kahta silmukkaa vaan vain yksi silmukka.
Mietitään nyt, millainen algoritmin tulisi olla, jotta siinä olisi vain yksi silmukka. Kun olemme tietyn päivän kohdalla, miten suuri voitto on mahdollinen, jos myymme osakkeen kyseisenä päivänä? Voitto on suurin silloin, kun olemme ostaneet osakkeen aiemmin mahdollisimman halvalla. Niinpä meidän kannattaa valita ostohinnaksi osakkeen halvin hinta kyseiseen päivään mennessä.
Voimme toteuttaa tällaisen algoritmin seuraavasti:
def best_profit(prices):
n = len(prices)
best = 0
for i in range(n):
min_price = min(prices[0:i+1])
best = max(best, prices[i] - min_price)
return best
Ideana on laskea silmukassa muuttujaan min_price
osakkeen halvin hinta päivään i
mennessä. Tämä on toteutettu hakemalla pienin alkio min
-funktiolla listan alkuosassa prices[0:i+1]
. Tämän jälkeen saamme laskettua kaavalla prices[i] - min_price
voiton, kun myymme osakkeen päivänä i
.
Tämä on toimiva algoritmi ja siinä on vain yksi silmukka, mutta algoritmi ei ole vielä tehokas. Ongelmana on, että muuttujan min_price
laskeminen on hidasta silmukassa, koska min
-funktio käy läpi listan prices
alkuosan. Tämä vie aikaa \(O(n)\), minkä vuoksi algoritmin aikavaativuus on edelleen \(O(n^2)\).
Voimme kuitenkin korjata ongelman seuraavasti:
def best_profit(prices):
n = len(prices)
best = 0
min_price = prices[0]
for i in range(n):
min_price = min(min_price, prices[i])
best = max(best, prices[i] - min_price)
return best
Nyt muuttujaa min_price
ei lasketa tyhjästä silmukan joka kierroksella, vaan uusi arvo lasketaan tehokkaasti edellisen arvon perusteella. Tämän muutoksen ansiosta silmukan jokainen kierros vie aikaa \(O(1)\), jolloin algoritmin aikavaativuus on \(O(n)\) ja algoritmi toimii tehokkaasti.
Huomaa, että funktion min
käyttäminen voi olla sekä hidasta että tehokasta. Jos funktiolla haetaan listan pienin alkio, tämä on hidasta. Jos kuitenkin funktiolla valitaan pienempi kahdesta arvosta, tämä on tehokasta.
Toimiiko algoritmi?
Tehokkaan algoritmin toimintalogiikka on yleensä monimutkaisempi kuin suoraviivaisessa raa’an voiman algoritmissa. Tämän vuoksi voi olla vaikea tietää, onko toteutettu algoritmi varmasti toimiva.
Kätevä tapa saada tietoa tehokkaan algoritmin toimivuudesta on verrata sen toimintaa suoraviivaiseen algoritmiin. Tämä voidaan automatisoida niin, että algoritmeja testataan suurella määrällä satunnaisia syötteitä. Esimerkiksi voimme testata äskeisen tehtävän algoritmeja seuraavaan tapaan:
import random
def best_profit_brute(prices):
...
def best_profit_fast(prices):
...
while True:
n = random.randint(1, 20)
prices = [random.randint(1, 10) for _ in range(n)]
result_brute = best_profit_brute(prices)
result_fast = best_profit_fast(prices)
print(prices, result_brute, result_fast)
if result_brute != result_fast:
print("ERROR")
break
Tässä funktio best_profit_brute
toteuttaa raa’an voiman algoritmin ja funktio best_profit_fast
toteuttaa tehokkaan algoritmin. Pääohjelma testaa algoritmeja luomalla satunnaisia listoja, joissa \(n\) on välillä \(1 \dots 20\) ja hinnat ovat välillä \(1 \dots 10\). Jokaisen testin jälkeen ohjelma tulostaa listan sisällön sekä funktioiden palauttamat arvot. Ohjelman tulostus voisi alkaa seuraavasti:
[2, 4, 5, 4, 2, 4, 8, 7, 5] 6 6
[8, 8, 8, 3, 6, 4, 9, 3, 2, 5, 4, 5, 2] 6 6
[9, 3, 1, 5, 8, 9, 3] 8 8
[3, 6, 7] 4 4
[6, 8, 7, 10, 8, 6, 1, 1, 2, 2, 8, 9, 10] 9 9
[4, 5, 3, 4, 5] 2 2
[3, 6, 2] 3 3
[4, 3, 8, 10, 7, 3, 4, 7, 5, 1, 7, 8, 7] 7 7
...
Tällaisen testauksen avulla saa hyvää varmuutta siitä, että tehokas algoritmi on toimiva. Jos löytyy syöte, jossa algoritmi antaa väärän tuloksen, ohjelma ilmoittaa tästä ja testaus päättyy. Tämän jälkeen ohjelman antaman syötteen avulla voi koettaa tutkia, miksi algoritmi ei toimi oikein.
Esimerkki: Bittijono
Tehtävä
Annettuna on bittijono, joka muodostuu merkeistä 0
ja 1
. Monellako tavalla voit valita bittijonosta kaksi kohtaa niin, että vasemmassa kohdassa on bitti 0
ja oikeassa kohdassa on bitti 1
?
Tarkastellaan esimerkkinä seuraavaa tilannetta:
Kohta | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
Bitti | 0 | 1 | 0 | 0 | 1 | 0 | 1 | 1 |
Tässä tilanteessa mahdollisia tapoja on 12.
Suoraviivainen tapa ratkaista tehtävä on käydä läpi kaikki tavat valita vasen ja oikea kohta ja laskea, monessako kohdassa vasen bitti on 0
ja oikea bitti on 1
:
def count_ways(bits):
n = len(bits)
result = 0
for i in range(n):
for j in range(i + 1, n):
if bits[i] == '0' and bits[j] == '1':
result += 1
return result
Tässä ongelmana on jälleen, että aikavaativuus on \(O(n^2)\) eli algoritmi on liian hidas.
Mietitään, miten selviäisimme yhdellä silmukalla. Kuten osakkeen hinnan laskemisessa, tässäkin tehtävässä hyvä lähestymistapa on käsitellä jokaisessa kohdassa kyseiseen kohtaan päättyvät ratkaisut. Tarkemmin voimme koettaa laskea kussakin bittijonon kohdassa i
tehokkaasti tavat, joissa oikea bitti 1
on kohdassa i
ja vasen bitti 0
on ennen kohtaa i
.
Jos kohdassa i
on bitti 1
, tässä kohdassa voi olla oikea kohta. Tässä tapauksessa vasen kohta voi olla mikä tahansa kohta ennen kohtaa i
, jossa on bitti 0
. Niinpä saamme aikaan tehokkaan algoritmin, kun pidämme muistissa, montako bittiä 0
on tullut vastaan silmukan aikana. Voimme toteuttaa algoritmin näin:
def count_ways(bits):
n = len(bits)
result = 0
zeros = 0
for i in range(len(bits)):
if bits[i] == '0':
zeros += 1
if bits[i] == '1':
result += zeros
return result
Silmukan sisällä suoritettava koodi riippuu siitä, onko bitti 0
vai 1
. Jos bitti on 0
, kasvatetaan muuttujan zeros
arvoa. Tämän avulla joka kohdassa tiedetään, montako bittiä 0
on tullut vastaan tähän mennessä. Jos taas bitti on 1
, lisätään muuttujaan result
muuttujan zeros
arvo. Tämä laskee tehokkaasti mukaan tulokseen kaikki tavat, joissa oikea bitti on kohdassa i
.
Algoritmissa on yksi silmukka, joka käy syötteen läpi, ja silmukan sisällä olevan koodin aikavaativuus on \(O(1)\). Tämän ansiosta algoritmin aikavaativuus on \(O(n)\) ja se toimii tehokkaasti.
Esimerkki: Listan halkaisu
Tehtävä
Annettuna on lista, jossa on \(n\) kokonaislukua. Tehtäväsi on laskea, monellako tavalla listan voi halkaista kahteen osaan niin, että molemmissa osissa lukujen summa on sama.
Tarkastellaan esimerkkinä seuraavaa listaa:
Kohta | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
Luku | 1 | -1 | 1 | -1 | 1 | -1 | 1 | -1 |
Tässä tilanteessa mahdollisia tapoja on 3. Voimme halkaista listan kohtien 1 ja 2, kohtien 3 ja 4 tai kohtien 5 ja 6 välistä.
Suoraviivainen algoritmi tehtävään on seuraava:
def count_splits(numbers):
n = len(numbers)
result = 0
for i in range(n - 1):
left_sum = sum(numbers[0:i+1])
right_sum = sum(numbers[i+1:])
if left_sum == right_sum:
result += 1
return result
Algoritmi käy läpi kaikki tavat halkaista lista ja laskee muuttujiin left_sum
ja right_sum
listan vasemman ja oikean osan summan halkaisun jälkeen. Jos summat ovat samat, muuttujan result
arvo kasvaa yhdellä. Algoritmin aikavaativuus on \(O(n^2)\), koska muuttujien left_sum
ja right_sum
laskeminen vie aikaa \(O(n)\).
Koska silmukka käy listan läpi vasemmalta oikealle, voimme tehostaa algoritmia laskemalla summaa left_sum
samaa tahtia silmukan kanssa:
def count_splits(numbers):
n = len(numbers)
result = 0
left_sum = 0
for i in range(n - 1):
left_sum += numbers[i]
right_sum = sum(numbers[i+1:])
if left_sum == right_sum:
result += 1
return result
Tämä ei ole kuitenkaan riittävä tehostus, koska muuttujan right_sum
laskeminen on edelleen hidasta eikä siihen voi tehdä vastaavaa muutosta, koska listaa käydään läpi vasemmalta oikealle. Vaikka muuttuja left_sum
lasketaan tehokkaasti, algoritmi vie edelleen aikaa \(O(n^2)\).
Voimme kuitenkin tehostaa algoritmia lisää hyödyllisen havainnon avulla: jos tiedämme vasemman osan summan lisäksi koko listan summan, voimme laskea näiden tietojen avulla tehokkaasti oikean osan summan.
def count_splits(numbers):
n = len(numbers)
result = 0
left_sum = 0
total_sum = sum(numbers)
for i in range(n - 1):
left_sum += numbers[i]
right_sum = total_sum - left_sum
if left_sum == right_sum:
result += 1
return result
Koska koko listan summa ei muutu silmukan aikana, voimme laskea sen ennen silmukkaa muuttujaan total_sum
. Tämä vie aikaa \(O(n)\) mutta se tehdään vain kerran ennen silmukkaa. Tämän jälkeen silmukassa right_sum
saadaan laskettua tehokkaasti kaavalla total_sum - left_sum
. Tuloksena on algoritmi, jonka aikavaativuus on \(O(n)\).
Esimerkki: Osalistat
Tehtävä
Annettuna on lista, jossa on \(n\) kokonaislukua. Monellako tavalla listasta voidaan valita osalista, jossa esiintyy tasan kaksi eri lukua?
Esimerkiksi listassa \([1,2,3,3,2,2,4,2]\) haluttu vastaus on \(14\).
Tämä tehtävä on selvästi vaikeampi kuin aiemmat käsitellyt tehtävät, mutta tässäkin toimii aiemmin hyväksi havaittu tekniikka: käydään läpi lista ja lasketaan jokaisessa kohdassa, montako ratkaisua päättyy kyseiseen kohtaan.
Esimerkkitapauksessa tulisi saada laskettua seuraavat tulokset:
Indeksi | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
Luku | 1 | 2 | 3 | 3 | 2 | 2 | 4 | 2 |
Tulos | 0 | 1 | 1 | 1 | 3 | 3 | 2 | 3 |
Esimerkiksi kohdassa 5 haluttu tulos on 3, koska kohtaan 5 päättyvät osalistat ovat \([2,3,3,2,2]\), \([3,3,2,2]\) ja \([3,2,2]\).
Voimme laskea kohtaan i
päättyvien osalistojen määrän tehokkaasti kahden muuttujan avulla: a
osoittaa edelliseen kohtaan, jossa on eri luku kuin kohdassa i
, ja b
osoittaa edelliseen kohtaan, jossa on eri luku kuin kohdissa i
ja a
. Nämä muuttujat ovat hyödyllisiä, koska osalistan tulee alkaa kohdan b
jälkeen ja viimeistään kohdassa a
. Niinpä kohtaan i
päättyvien osalistojen määrä saadaan laskettua kaavalla a - b
.
Esimerkiksi kun i
on kohdassa 5, tilanne on seuraava:
Indeksi | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
Luku | 1 | 2 | 3 | 3 | 2 | 2 | 4 | 2 |
b |
a |
i |
Tässä tapauksessa a
on kohdassa 3, jossa on luku 3, ja b
on kohdassa 0, jossa on luku 1. Osalistojen määrä saadaan laskettua kaavalla 3 – 0 = 3.
Silmukan edetessä muuttujia a
ja b
täytyy päivittää sopivasti aina, kun kohdassa i
on eri luku kuin kohdassa i - 1
. Tässä on kaksi tapausta:
- Jos kohdassa
i
on eri luku kuin kohdassaa
,b
siirtyy kohtaana
jaa
siirtyy kohtaani - 1
. - Jos kohdassa
i
on sama luku kuin kohdassaa
, vaina
siirtyy kohtaani - 1
.
Katsotaan nyt, miten muuttujat päivittyvät äskeisessä esimerkissä. Kun i
siirtyy kohdasta 5 kohtaan 6, kohdassa i
on eri luku kuin kohdassa a
. Niinpä kyseessä on tapaus 1, jolloin a
siirtyy kohtaan 6 – 1 = 5 ja b
siirtyy kohtaan 3:
Indeksi | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
Luku | 1 | 2 | 3 | 3 | 2 | 2 | 4 | 2 |
b |
a |
i |
Kun i
siirtyy kohdasta 6 kohtaan 7, kohdassa i
on sama luku kuin kohdassa a
. Niinpä kyseessä on tapaus 2, jolloin vain a
siirtyy kohtaan 7 – 1 = 6:
Indeksi | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
Luku | 1 | 2 | 3 | 3 | 2 | 2 | 4 | 2 |
b |
a |
i |
Tämän idean avulla saadaan tehokas algoritmi, joka voidaan toteuttaa seuraavasti:
def count_lists(numbers):
n = len(numbers)
a = b = -1
result = 0
for i in range(1, n):
if numbers[i] != numbers[i - 1]:
if numbers[i] != numbers[a]:
b = a
a = i - 1
result += a - b
return result
Tässä toteutuksessa a
ja b
ovat alussa -1
tarkoittaen, että ne eivät vielä osoita mihinkään listan kohtaan. Tämän ansiosta algoritmi laskee oikein osalistojen määrän myös listan alkuosassa.
4. Hajautus
Hajautus (hashing) on tekniikka, jota käytetään usein tehokkaiden tietorakenteiden toteuttamisessa. Pythonissa tietorakenteet joukko (set
) ja sanakirja (dict
) perustuvat hajautukseen.
Tutustumme tässä luvussa hajautukseen perustuviin tietorakenteisiin ja niiden käyttämiseen algoritmien suunnittelussa. Lisäksi tutustumme tietorakenteiden taustalla olevaan teoriaan.
Joukko
Pythonin tietorakenne set
eli joukko on hajautukseen perustuva tietorakenne, joka pitää yllä alkioiden joukkoa. Tietorakenne tarjoaa seuraavat operaatiot:
- metodi
add
lisää alkion joukkoon - operaattori
in
tarkastaa, onko alkio joukossa - metodi
remove
poistaa alkion joukosta
Joukko on toteutettu niin, että kaikki yllä olevat operaatiot tapahtuvat tehokkaasti ajassa \(O(1)\).
Esimerkki
Seuraava koodi luo joukon numbers
ja lisää sinne alkioita metodilla add
:
numbers = set()
numbers.add(1)
numbers.add(2)
numbers.add(3)
print(numbers) # {1, 2, 3}
Joukko voidaan myös luoda suoraan listan perusteella:
numbers = set([1, 2, 3])
print(numbers) # {1, 2, 3}
Operaattori in
tarkastaa, kuuluuko tietty alkio joukkoon:
print(3 in numbers) # True
print(4 in numbers) # False
Metodi remove
puolestaan poistaa alkion joukosta:
print(numbers) # {1, 2, 3}
numbers.remove(2)
print(numbers) # {1, 3}
Lista vs. joukko
Lista ja joukko ovat tietyllä tavalla samantapaisia tietorakenteita, koska molemmissa voi lisätä, etsiä ja poistaa alkioita. Kuitenkin tietorakenteiden tehokkuudessa ja ominaisuuksissa on suuria eroja.
Tehokkuus
Listassa alkion lisääminen on tehokasta, mutta on hidasta etsiä alkiota listasta sekä poistaa alkio listasta.
Joukossa on tehokasta lisätä alkio joukkoon, etsiä alkiota joukosta sekä poistaa alkio joukosta.
Operaatio | Lista | Joukko |
---|---|---|
Alkion lisääminen (append /add ) |
\(O(1)\) | \(O(1)\) |
Alkion etsiminen (in ) |
\(O(n)\) | \(O(1)\) |
Alkion poistaminen (remove ) |
\(O(n)\) | \(O(1)\) |
Indeksointi
Listassa alkioita voidaan käsitellä indeksien perusteella:
numbers = [1, 2, 3]
print(numbers[1]) # 2
Joukon alkioihin sen sijaan ei voida viitata indekseillä:
numbers = set([1, 2, 3])
print(numbers[1]) # TypeError: 'set' object is not subscriptable
Toistuvat alkiot
Listassa sama alkio voi esiintyä useita kertoja:
numbers = []
numbers.append(5)
numbers.append(5)
numbers.append(5)
print(numbers) # [5, 5, 5]
Joukossa sama alkio voi esiintyä enintään kerran. Jos alkio lisätään monta kertaa joukkoon, tällä ei ole vaikutusta:
numbers = set()
numbers.add(5)
numbers.add(5)
numbers.add(5)
print(numbers) # {5}
Esimerkki: Montako eri lukua?
Tehtävä
Annettuna on lista lukuja. Montako eri lukua listalla on?
Esimerkiksi kun lista on \([3,1,2,1,5,2,2,3]\), haluttu vastaus on \(4\), koska eri luvut ovat \(1\), \(2\), \(3\) ja \(5\).
Hidas ratkaisu (lista)
Voisimme ratkaista tehtävän listan avulla seuraavasti:
def count_distinct(numbers):
seen = []
for x in numbers:
if x not in seen:
seen.append(x)
return len(seen)
Algoritmi käy läpi luvut ja lisää luvun listaan seen
, jos lukua ei ole vielä listassa. Lopuksi listan seen
koko on yhtä suuri kuin eri lukujen määrä.
Tämä algoritmi on toimiva mutta ei tehokas, koska jokaisella silmukan kierroksella vie aikaa \(O(n)\) tarkastaa operaattorilla in
, onko luku listassa. Tämän vuoksi algoritmin aikavaativuus on \(O(n^2)\). Algoritmia on kuitenkin helppoa parantaa käyttämällä listan sijasta joukkoa.
Tehokas ratkaisu (joukko)
Voimme ratkaista tehtävän tehokkaasti joukon avulla seuraavasti:
def count_distinct(numbers):
seen = set()
for x in numbers:
if x not in seen:
seen.add(x)
return len(seen)
Funktio on muuten samanlainen kuin aiemmassa listaratkaisussa, mutta seen
on nyt joukko ja alkiot lisätään metodilla add
. Tällä muutoksella on suuri vaikutus algoritmin tehokkuuteen. Muutoksen jälkeen operaattori in
vie aikaa \(O(1)\) ja algoritmin aikavaativuus on vain \(O(n)\).
Koodia on mahdollista vielä tiivistää käyttämällä hyväksi sitä, että sama alkio ei mene useita kertoja joukkoon. Tämän ansiosta koodista voi poistaa tarkastuksen, onko alkio valmiiksi joukossa:
def count_distinct(numbers):
seen = set()
for x in numbers:
seen.add(x)
return len(seen)
Itse asiassa koodia voi tiivistää vielä lisää luomalla joukon suoraan listan pohjalta. Funktion toteutukseen riittää loppujen lopuksi yksi rivi koodia:
def count_distinct(numbers):
return len(set(numbers))
Sanakirja
Pythonin tietorakenne dict
eli sanakirja on hajautukseen perustuva tietorakenne, johon voidaan tallentaa avain-arvo-pareja. Ideana on, että avaimen perusteella voidaan hakea siihen liittyvä arvo.
Sanakirjaa voi ajatella listan yleistyksenä: listassa avaimet ovat indeksit \(0 \dots n-1\), kun taas sanakirjassa avaimet voivat olla mitä tahansa alkioita. Tietoa voi lisätä, hakea ja poistaa tehokkaasti ajassa \(O(1)\) avaimen perusteella.
Esimerkki
Seuraava koodi luo sanakirjan weights
, jossa avaimet ovat merkkijonoja ja arvot ovat lukuja.
weights = {}
weights["apina"] = 100
weights["banaani"] = 1
weights["cembalo"] = 500
Yllä olevan sanakirjan voi luoda myös näin:
weights = {"apina": 100, "banaani": 1, "cembalo": 500}
Sanakirjan arvoja voi käsitellä samaan tapaan kuin listan arvoja:
print(weights["apina"]) # 100
weights["apina"] = 150
print(weights["apina"]) # 150
Operaattori in
tarkastaa, onko sanakirjassa tiettyä avainta:
print("apina" in weights) # True
print("ananas" in weights) # False
Komento del
poistaa avaimen ja siihen liittyvän arvon sanakirjasta:
print(weights) # {'apina': 100, 'banaani': 1, 'cembalo': 500}
del weights["banaani"]
print(weights) # {'apina': 100, 'cembalo': 500}
Sanakirjan käyttäminen
Seuraavassa on kolme tavallista sanakirjan käyttötarkoitusta algoritmien suunnittelussa:
Onko alkio esiintynyt
Sanakirjaa voi käyttää joukon kaltaisesti pitämään kirjaa esiintyneistä alkioista:
seen = {}
for x in items:
seen[x] = True
Tämä koodi on toiminnaltaan suunnilleen sama kuin seuraava koodi:
seen = set()
for x in items:
seen.add(x)
Voikin ajatella, että joukko vastaa sanakirjaa, jossa avaimet ovat joukon alkioita ja jokainen arvo on True
.
Alkion esiintymiskerrat
Tavallinen sanakirjan käyttötarkoitus on laskea, montako kertaa mikäkin alkio on esiintynyt.
count = {}
for x in items:
if x not in count:
count[x] = 0
count[x] += 1
Koodi laskee esiintymiskerrat sanakirjan count
avulla. Jos alkiota x
ei ole vielä sanakirjassa, koodi asettaa sen esiintymiskertojen määräksi 0
. Tämän jälkeen koodi kasvattaa alkion x
esiintymiskertoja yhdellä.
Alkion esiintymiskohta
Joissakin algoritmeissa on hyödyllistä pitää yllä tietoa siitä, missä kohdassa mikäkin alkio on esiintynyt.
pos = {}
for i, x in enumerate(items):
pos[x] = i
Tässä sanakirjassa pos
on kunkin alkion viimeisin esiintymiskohta listassa. Funktion enumerate
avulla lista voidaan käydä läpi niin, että joka kierroksella i
sisältää alkion indeksin ja x
itse alkion.
Esimerkki: Moodi
Tehtävä
Annettuna on lista lukuja ja tehtäväsi on selvittää listan moodi eli yleisin luku. Jos moodi ei ole yksikäsitteinen, voit valita minkä tahansa yhtä yleisistä luvuista.
Esimerkiksi kun lista on \([1,2,3,2,2,3,2,2]\), haluttu vastaus on \(2\).
Voimme ratkaista tehtävän tehokkaasti hajautuksen avulla luomalla sanakirjan, johon lasketaan jokaisen alkion esiintymiskertojen määrä:
def find_mode(numbers):
count = {}
mode = numbers[0]
for x in numbers:
if x not in count:
count[x] = 0
count[x] += 1
if count[x] > count[mode]:
mode = x
return mode
Tässä count
on sanakirja, johon lasketaan lukujen esiintymiskerrat, ja muuttuja mode
sisältää moodin. Alussa mode
on listan ensimmäinen luku ja muuttujaa päivitetään aina, kun viimeksi käsitelty luku on esiintynyt useammin kuin muuttujassa oleva luku. Koska sanakirjan operaatiot toimivat ajassa \(O(1)\), algoritmin aikavaativuus on \(O(n)\).
Tässä on vielä toinen tapa toteuttaa algoritmi:
def find_mode(numbers):
count = {}
mode = (0, 0)
for x in numbers:
if x not in count:
count[x] = 0
count[x] += 1
mode = max(mode, (count[x], x))
return mode[1]
Nyt muuttuja mode
on pari, jonka ensimmäinen alkio on moodin esiintymiskerrat ja toinen alkio on itse moodi. Esimerkiksi jos muuttujan arvona on (5, 2)
, tämä tarkoittaa, että alkio 2
on esiintynyt 5
kertaa.
Tämän toteutuksen etuna on, että moodia pystyy päivittämään max
-funktion avulla. Tässä max
-funktio vertailee ensisijaisesti parin ensimmäistä alkiota ja toissijaisesti parin toista alkiota. Koska parin ensimmäinen alkio on esiintymiskertojen määrä, parin arvo on sitä suurempi mitä useammin luku on esiintynyt.
Huomaa, että yllä olevat kaksi funktiota toimivat vähän eri tavalla tilanteessa, jossa on useita mahdollisia vaihtoehtoja moodiksi. Ensimmäinen funktio valitsee moodin, jonka viimeinen esiintymiskerta tulee vastaan ensimmäisenä. Toinen funktio puolestaan valitsee moodin, joka on arvoltaan suurin, koska pareissa vertaillaan toissijaisesti luvun suuruutta.
Esimerkki: Kierrokset
Tehtävä
Annettuna on lista, joka sisältää luvut \(1,2,\dots,n\) jossakin järjestyksessä. Tehtäväsi on kerätä luvut pienimmästä suurimpaan niin, että joka kierroksella käyt läpi listan vasemmalta oikealle. Montako kierrosta tarvitaan?
Esimerkiksi kun lista on \([3,6,1,7,5,2,4,8]\), lukujen keräämiseen tarvitaan \(4\) kierrosta. Ensimmäinen kierros kerää luvut \(1\) ja \(2\), toinen kierros luvut \(3\) ja \(4\), kolmas kierros luvun \(5\) ja neljäs kierros luvut \(6\), \(7\) ja \(8\).
Oleellinen havainto on, että uusi kierros täytyy aloittaa aina silloin, kun kerättävä luku on edellisen kerätyn luvun vasemmalla puolella. Esimerkiksi yllä olevassa listassa luku \(3\) aloittaa uuden kierroksen, koska se on luvun \(2\) vasemmalla puolella.
Hidas ratkaisu (lista)
Seuraava algoritmi ratkaisee tehtävän käyttäen vain listaa:
def count_rounds(numbers):
n = len(numbers)
rounds = 1
for i in range(1, n):
if numbers.index(i + 1) < numbers.index(i):
rounds += 1
return rounds
Ideana on laskea muuttujaan rounds
tarvittava kierrosten määrä. Alussa kierrosten määrä on yksi. Sitten silmukka käy läpi luvut \(1 \dots n-1\) ja lisää kierrosten määrää yhdellä aina, kun luku \(i+1\) esiintyy listassa ennen lukua \(i\).
Tässä toteutuksessa on käytössä listan metodi index
, joka antaa luvun sijainnin listassa. Tämän takia algoritmi on kuitenkin hidas, koska metodi index
vie aikaa \(O(n)\) ja koko algoritmin aikavaativuus on \(O(n^2)\).
Tehokas ratkaisu (sanakirja)
Voimme toteuttaa saman idean tehokkaasti ottamalla käyttöön sanakirjan, joka sisältää kunkin luvun kohdan listassa:
def count_rounds(numbers):
n = len(numbers)
pos = {}
for i, x in enumerate(numbers):
pos[x] = i
rounds = 1
for i in range(1, n):
if pos[i + 1] < pos[i]:
rounds += 1
return rounds
Tämän jälkeen ei tarvitse käyttää metodia index
vaan luvun sijainnin listasta saa haettua tehokkaasti sanakirjasta. Algoritmissa on kaksi silmukkaa, jotka toimivat molemmat ajassa \(O(n)\), joten koko algoritmin aikavaativuus on \(O(n)\).
Esimerkki: Soittolista
Tehtävä
Annettuna on soittolista, jossa jokaista laulua vastaa tietty kokonaisluku. Tehtäväsi on selvittää, miten pitkä on pisin soittolistan osa, jossa ei esiinny kahta samaa laulua.
Esimerkiksi kun soittolista on \([1,2,1,3,5,4,3,1]\), haluttu vastaus on \(5\). Tämä vastaa soittolistan osaa \([2,1,3,5,4]\).
Hyvä lähestymistapa tähän tehtävään on laskea jokaiseen soittolistan kohtaan, kuinka pitkä on pisin kyseiseen kohtaan päättyvä soittolistan osa, jossa ei esiinny kahta samaa laulua. Näistä pituuksista suurin on tehtävän haluttu vastaus. Äskeisessä esimerkissä nämä pituudet ovat:
Laulu | 1 | 2 | 1 | 3 | 5 | 4 | 3 | 1 |
Pituus | 1 | 2 | 2 | 3 | 4 | 5 | 3 | 4 |
Kun olemme tietyssä soittolistan kohdassa ja vastaan tulee aiemmin listalla esiintynyt laulu, tämä rajoittaa soittolistan osan pituutta, koska kyseinen laulu ei saa esiintyä kahta kertaa. Niinpä soittolistan osan tulee alkaa laulun edellisen esiintymän jälkeen. Tämän avulla voimme päätellä, mistä kohdasta soittolistan osa voi alkaa.
Seuraava tehokas algoritmi perustuu yllä oleviin ideoihin:
def max_length(songs):
n = len(songs)
pos = {}
start = 0
length = 0
for i, song in enumerate(songs):
if song in pos:
start = max(start, pos[song] + 1)
length = max(length, i - start + 1)
pos[song] = i
return length
Sanakirja pos
kertoo kustakin laulusta, missä kohtaa soittolistaa laulu on esiintynyt viimeksi. Muuttujassa start
on kohta, josta soittolistan osa voi alkaa, ja muuttujassa length
on pisin soittolistan osan pituus.
Algoritmi käy läpi soittolistan ja päivittää muuttujaa start
aina, kun vastaan tulee laulu, joka on esiintynyt aiemmin soittolistalla. Tässä tilanteessa muuttujan start
arvo kasvaa, jos laulun edellisen esiintymän kohta on aiempaa suurempi.
Tuloksena oleva algoritmi toimii ajassa \(O(n)\) hajautuksen ansiosta.
Esimerkki: Listan summat
Tehtävä
Annettuna on lista, jossa on \(n\) kokonaislukua. Tehtäväsi on laskea, monessako listan osalistassa lukujen summa on \(x\).
Esimerkiksi kun lista on \([2,3,5,-3,4,4,6,2]\) ja \(x=5\), haluttu vastaus on \(4\). Tässä tapauksessa osalistat ovat \([2,3]\), \([5]\), \([3,5,-3]\) ja \([-3,4,4]\).
Hyödyllinen tekniikka tällaisessa tehtävässä on tarkastella listan alkuosien lukujen summia eli laskea jokaiseen kohtaan lukujen summa listan alusta kyseiseen kohtaan. Esimerkissä alkuosien summat ovat seuraavat:
Indeksi | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
Luku | 2 | 3 | 5 | –3 | 4 | 4 | 6 | 2 |
Alkuosan summa | 2 | 5 | 10 | 7 | 11 | 15 | 21 | 23 |
Esimerkiksi kohdassa \(4\) alkuosan summa on \(11\), koska listan lukujen summa kohdasta \(0\) kohtaan \(4\) on \(2+3+5-3+4=11\).
Alkuosien summien hyötynä on, että minkä tahansa osalistan lukujen summa saadaan laskettua kahden alkuosan summan perusteella. Kun osalista alkaa kohdasta \(a\) ja päättyy kohtaan \(b\), sen lukujen summa saadaan laskemalla alkuosan summa kohtaan \(b\) ja vähentämällä siitä alkuosan summa kohtaan \(a-1\).
Esimerkiksi kun osalista alkaa kohdasta \(2\) ja päättyy kohtaan \(4\), sen lukujen summa on \(5-3+4=6\). Tämä on yhtä suuri kuin alkuosan summa kohtaan \(4\), josta on vähennetty alkuosan summa kohtaan \(1\), eli \(11-5=6\).
Oletetaan nyt, että olemme listan kohdassa \(i\) ja haluamme laskea, monessako kohtaan \(i\) päättyvässä osalistassa lukujen summa on \(x\). Kun alkuosan summa kohdassa \(i\) on \(p\), alkuosan summan tulee olla \(p-x\) aiemmassa kohdassa, jotta osalistan summaksi tulee \(p-(p-x)=x\). Niinpä haluttuja osalistoja on yhtä monta kun aiempia kohtia, joissa alkuosan summa on \(p-x\).
Seuraava algoritmi perustuu yllä olevaan ideaan:
def count_sublists(numbers, x):
count = {0: 1}
prefix_sum = 0
result = 0
for i in range(len(numbers)):
prefix_sum += numbers[i]
if prefix_sum - x in count:
result += count[prefix_sum - x]
if prefix_sum not in count:
count[prefix_sum] = 0
count[prefix_sum] += 1
return result
Tässä sanakirjaan count
lasketaan, montako kertaa mikäkin summa on esiintynyt listan alkuosien summissa. Sanakirjan avulla voidaan laskea tehokkaasti jokaiseen listaan kohtaan, monessako kyseiseen kohtaan päättyvässä osalistassa summa on \(x\). Sanakirjassa on valmiina tyhjää listaa tarkoittava summa \(0\), jotta algoritmi laskee oikein myös tapaukset, joissa osalista alkaa listan alusta.
Tuloksena oleva algoritmi toimii ajassa \(O(n)\) hajautuksen ansiosta.
Miten hajautus toimii?
Tässä luvussa esitellyt Pythonin tietorakenteet set
ja dict
perustuvat hajautukseen ja niiden taustalla on tietorakenne hajautustaulu. Pythonissa hajautustaulu on toteuttu avointa hajautusta käyttäen.
Pythonissa on sisäänrakennettu funktio hash
, jolla voidaan laskea alkion hajautusarvo. Python kutsuu tätä funktiota, kun se määrittää alkion sijainnin hajautustaulussa. Funktion toimintaa voi testata näin:
> hash(42)
42
> hash(10**100)
910685213754167845
> hash("apina")
4992529190565255982
Kuten yllä olevasta testistä voi havaita, Pythonissa pienen kokonaisluvun hajautusarvo on suoraan kyseinen luku. Muuten hajautusarvot ovat yleensä satunnaisen näköisiä lukuja.
Pythonissa hajautusta käyttävät tietorakenteet toimivat yleensä aina tehokkaasti ja voidaan olettaa, että lisäykset, haut ja poistot vievät aikaa \(O(1)\). Silti on mahdollista, että Pythonin hajautus toimii hitaasti, jos syöte on valittu sopivalla tavalla.
Mitä voi hajauttaa?
Seuraava koodi ei toimi Pythonissa:
lists = set()
lists.add([1, 2, 3]) # TypeError: unhashable type: 'list'
Ongelmana on, että listalle ei voi laskea hajautusarvoa:
print(hash([1, 2, 3])) # TypeError: unhashable type: 'list'
Pythonissa on periaatteena, että hajautusarvon voi laskea vain oliolle, jonka sisältö on muuttumaton (immutable). Listan sisältö ei ole muuttumaton, koska listassa on esimerkiksi metodi append
, joka lisää siihen uuden alkion. Tämän vuoksi listalle ei voi laskea hajautusarvoa.
Muuttumattomia olioita ovat esimerkiksi luvut, merkkijonot ja näitä sisältävät tuplet. Esimerkiksi seuraava koodi toimii, koska lukuja sisältävä tuple on muuttumaton ja sille voidaan laskea hajautusarvo:
lists = set()
lists.add((1, 2, 3))
Huomaa, että sanakirjassa vain avaimelle lasketaan hajautusarvo. Tämän takia myös seuraava koodi toimii, jossa avain on merkkijono ja arvo on lista:
lists = {}
lists["apina"] = [1, 2, 3]
Oma luokka hajautuksessa
Omaa luokkaa voi käyttää hajautuksessa määrittelemällä sille seuraavat metodit:
__hash__
: palauttaa olion hajautusarvon (funktiohash
kutsuu sitä)__eq__
: vertailee, onko olion sisältö sama kuin toisen olion sisältö (operaattori==
kutsuu sitä)
Seuraavassa on esimerkki metodien määrittelystä. Tässä metodi __hash__
palauttaa olion sisältöä vastaavan tuplen hajautusarvon.
class Location:
def __init__(self, x, y):
self.x = x
self.y = y
def __hash__(self):
return hash((self.x, self.y))
def __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
Tämän jälkeen esimerkiksi seuraava koodi toimii tarkoitetulla tavalla:
locations = set()
locations.add(Location(1, 2))
locations.add(Location(3, -5))
locations.add(Location(1, 4))
Hajautus muissa kielissä
Hajautusta käyttävät tietorakenteet ovat yleisiä eri ohjelmointikielissä. Monissa kielissä Pythonin sanakirjaa vastaa tietorakenne nimeltä map, josta voidaan käyttää suomeksi nimeä hakemisto.
C++:ssa tietorakenteet std::unordered_set
ja std::unordered_map
toteuttavat hajautusta käyttävän joukon ja hakemiston.
std::unordered_set<int> numbers;
numbers.add(1);
numbers.add(2);
numbers.add(3);
std::unordered_map<std::string, int> weights;
weights["apina"] = 100;
weights["banaani"] = 1;
weights["cembalo"] = 500;
Javassa vastaavat tietorakenteet ovat HashSet
ja HashMap
:
HashSet<Integer> numbers = new HashSet<Integer>();
numbers.add(1);
numbers.add(2);
numbers.add(3);
HashMap<String, Integer> weights = new HashMap<String, Integer>();
weights.put("apina", 100);
weights.put("banaani", 1);
weights.put("cembalo", 500);
JavaScriptissä tietorakenne Set
toteuttaa joukon:
let numbers = new Set();
numbers.add(1);
numbers.add(2);
numbers.add(3);
JavaScriptin perinteinen tapa luoda hakemisto on määritellä olio:
let weights = {};
weights["apina"] = 100;
weights["banaani"] = 1;
weights["cembalo"] = 500;
Uudempi tapa on käyttää erillistä tietorakennetta Map
:
let weights = new Map();
weights.set("apina", 100);
weights.set("banaani", 1);
weights.set("cembalo", 500);
5. Järjestäminen
Järjestäminen (sorting) on klassinen algoritmiikan ongelma, jossa tehtävänä on järjestää annetut alkiot suuruusjärjestykseen. Järjestämiseen on kehitetty tehokkaita algoritmeja, jotka toimivat ajassa \(O(n \log n)\).
Tässä luvussa näemme, miten järjestämistä voi käyttää Pythonissa ja mitä hyötyä siitä voi olla tehokkaiden algoritmien toteuttamisessa. Tutustumme myös järjestämisen teoriaan ja muutamaan yleiseen järjestämisalgoritmiin.
Järjestäminen Pythonissa
Pythonissa listan sisältö voidaan järjestää metodilla sort
:
numbers = [4, 2, 1, 2]
numbers.sort()
print(numbers) # [1, 2, 2, 4]
Pythonissa on myös funktio sorted
, joka palauttaa listan järjestettynä:
numbers = [4, 2, 1, 2]
print(sorted(numbers)) # [1, 2, 2, 4]
Näissä tavoissa erona on, että metodi sort
muuttaa olemassa olevaa listaa, kun taas funktio sorted
muodostaa uuden listan.
Metodi sort
ja funktio sorted
toimivat tehokkaasti ajassa \(O(n \log n)\), minkä ansiosta niitä voidaan käyttää tehokkaiden algoritmien toteuttamisessa.
Esimerkki: Pienin ero
Tehtävä
Annettuna on lista lukuja. Mikä on pienin ero kahden luvun välillä?
Esimerkiksi kun lista on \([4,1,7,3,9]\), haluttu vastaus on \(1\), koska pienin ero on lukujen \(3\) ja \(4\) välillä.
Järjestetyssä listassa pienin ero on aina kahden vierekkäisen luvun välillä. Niinpä voimme ratkaista tehtävän tehokkaasti järjestämällä listan ja käymällä läpi kaikki vierekkäin olevat luvut. Esimerkiksi lista \([4,1,7,3,9]\) on järjestettynä \([1,3,4,7,9]\), jolloin pienimmän eron tuottavat luvut \(3\) ja \(4\) ovat vierekkäin.
Seuraava funktio min_diff
toteuttaa algoritmin:
def min_diff(numbers):
numbers = sorted(numbers)
result = numbers[1] - numbers[0]
for i in range(2, len(numbers)):
result = min(result, numbers[i] - numbers[i - 1])
return result
Algoritmi järjestää ensin listan kutsumalla funktiota sorted
ja selvittää sitten muuttujaan result
pienimmän eron kahden vierekkäisen luvun välillä.
Listan järjestäminen vie aikaa \(O(n \log n)\) ja vierekkäisten lukujen vertaileminen vie aikaa \(O(n)\), joten algoritmin aikavaativuus on \(O(n \log n)\).
Sivuvaikutusten välttäminen
Huomaa, että voisimme käyttää funktion alussa myös listan metodia sort
:
def min_diff(numbers):
numbers.sort()
...
Tässä olisi kuitenkin ongelmana, että funktio aiheuttaisi sivuvaikutuksen, koska parametrina annetun listan järjestäminen heijastuisi myös funktion min_diff
kutsukohtaan. Tämä ei ole toivottava ilmiö, koska funktion ei pitäisi muuttaa listan sisältöä. Seuraavat koodit havainnollistavat asiaa:
Käytössä sort
numbers = [4, 1, 7, 3,9]
print(min_diff(numbers)) # 1
print(numbers) # [1, 3, 4, 7, 9]
Käytössä sorted
numbers = [4, 1, 7, 3,9]
print(min_diff(numbers)) # 1
print(numbers) # [4, 1, 7, 3,9]
Kun käytössä on metodi sort
, funktion min_diff
kutsuminen järjestää sivuvaikutuksena listan numbers
. Kun käytössä on funktio sorted
, lista numbers
ei muutu, koska funktio sorted
luo uuden järjestetyn listan. Niinpä parempi ratkaisu on toteuttaa funktio alkuperäisellä tavalla käyttäen funktiota sorted
.
Hajautus vs. järjestäminen
Monessa tehtävässä on kaksi mahdollisuutta tehokkaan ratkaisun luomiseen: hajautus tai järjestäminen. Tarkastellaan esimerkkinä tehtävää, jonka ratkaisimme aiemmin hajautuksen avulla:
Tehtävä
Annettuna on lista lukuja. Montako eri lukua listalla on?
Esimerkiksi kun lista on \([3,1,2,1,5,2,2,3]\), haluttu vastaus on \(4\), koska eri luvut ovat \(1\), \(2\), \(3\) ja \(5\).
Ratkaisimme tehtävän aiemmin hajautuksen avulla näin:
Algoritmi 1
def count_distinct(numbers):
seen = set()
for x in numbers:
seen.add(x)
return len(seen)
Voimme ratkaista tehtävän myös toisella tavalla järjestämisen avulla. Koska järjestetyssä listassa yhtä suuret luvut ovat peräkkäin, voimme käydä järjestetyn listan läpi ja kasvattaa laskuria aina luvun vaihtuessa.
Tässä on järjestämistä käyttävä algoritmi:
Algoritmi 2
def count_distinct(numbers):
numbers = sorted(numbers)
result = 1
for i in range(1, len(numbers)):
if numbers[i] != numbers[i - 1]:
result += 1
return result
Hajautusta käyttävä algoritmi vie aikaa \(O(n)\) ja järjestämistä käyttävä algoritmi vie aikaa \(O(n \log n)\), mutta miten nopeita algoritmit ovat käytännössä?
Tehdään testi, joka vertailee algoritmien tehokkuutta. Koska molemmat algoritmit ovat tehokkaita, toteutetaan testi niin, että listan koko on aina suuri (\(n=10^7\)). Koska listalla olevien lukujen suuruus saattaa vaikuttaa algoritmin tehokkuuteen, lisätään listalle satunnaisia lukuja väliltä \(1 \dots k\), missä \(k\) vaihtelee. Tämän avulla voidaan tutkia, miten lukujen suuruus vaikuttaa tehokkuuteen.
Testikoneella tulokset ovat seuraavat:
Luvun yläraja \(k\) | Algoritmi 1 (hajautus) | Algoritmi 2 (järjestäminen) |
---|---|---|
\(10^3\) | 0.46 s | 3.18 s |
\(10^4\) | 0.56 s | 4.50 s |
\(10^5\) | 1.16 s | 5.74 s |
\(10^6\) | 2.56 s | 6.38 s |
\(10^7\) | 2.56 s | 6.48 s |
Tässä tapauksessa vaikuttaa, että hajautusta käyttävä algoritmi on tehokkaampi. Se on myös koodiltaan yksinkertaisempi, koska silmukassa riittää lisätä listan luvut set
-rakenteeseen eikä vertailla vierekkäisiä lukuja keskenään.
Lisää järjestämisestä
Käänteinen järjestäminen
Parametri reverse
muuttaa järjestämisen suunnan käänteiseksi:
numbers = [2, 4, 3, 5, 6, 1, 8, 7]
numbers.sort(reverse=True)
print(numbers) # [8, 7, 6, 5, 4, 3, 2, 1]
Moniosaiset alkiot
Jos järjestettävät alkiot ovat tupleja tai listoja, järjestys on ensisijaisesti ensimmäisen alkion mukaan, toissijaisesti toisen alkion mukaan, jne.
Esimerkiksi listassa olevat parit järjestyvät näin:
pairs = [(3, 5), (1, 3), (1, 2), (2, 4)]
pairs.sort()
print(pairs) # [(1, 2), (1, 3), (2, 4), (3, 5)]
Alkioiden vertailutapa
Parametrilla key
voidaan määritellä funktio, joka muuttaa alkioita ennen kuin niitä vertaillaan toisiinsa. Parametria voidaan käyttää esimerkiksi näin:
numbers = [4, -1, 6, 2, -7, 8, 3, -5]
numbers.sort(key=abs)
print(numbers) # [-1, 2, 3, 4, -5, 6, -7, 8]
Tässä funktiona on abs
eli itseisarvo, minkä seurauksena luvut järjestetään suuruuden mukaan välittämättä niiden etumerkistä.
Oma luokka
Pythonissa luokan olioita voidaan järjestää, kun luokkaan on toteutettu riittävästi metodeita olioiden vertailua varten. Esimerkiksi riittää, että on toteutettu metodit __eq__
ja __lt__
, jolloin olioita voidaan vertailla operaattoreiden ==
ja <
avulla. Seuraava koodi havainnollistaa asiaa:
class Location:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
def __lt__(self, other):
return (self.x, self.y) < (other.x, other.y)
def __repr__(self):
return str((self.x, self.y))
locations = []
locations.append(Location(1, 4))
locations.append(Location(4, 5))
locations.append(Location(2, 2))
locations.append(Location(1, 2))
locations.sort()
print(locations) # [(1, 2), (1, 4), (2, 2), (4, 5)]
Esimerkki: Ravintola
Tehtävä
Ravintolassa käy päivän aikana \(n\) asiakasta ja tiedät, milloin kukin asiakas saapuu ja lähtee. Jos asiakas lähtee samalla hetkellä kuin toinen asiakas saapuu, tulkintana on, että he ovat yhtä aikaa ravintolassa. Tehtäväsi on selvittää suurin määrä asiakkaita, jotka ovat yhtä aikaa ravintolassa.
Tarkastellaan esimerkkinä seuraavaa tilannetta:
Asiakas | Saapumisaika | Lähtöaika |
---|---|---|
#1 | 6 | 8 |
#2 | 3 | 7 |
#3 | 6 | 9 |
#4 | 1 | 5 |
#5 | 2 | 8 |
Tässä tapauksessa suurin määrä asiakkaita on 4 asiakasta: asiakkaat #1, #2, #3 ja #5 ovat yhtä aikaa ravintolassa aikavälillä 6–7.
Tällaisessa tehtävässä hyvä lähestymistapa on tarkastella tapahtumia aikajärjestyksessä. Tapahtumia on kahdenlaisia: asiakas saapuu ravintolaan tai asiakas lähtee ravintolasta. Jokaisen tapahtuman yhteydessä asiakkaiden määrä kasvaa tai vähenee yhdellä.
Esimerkin tapauksessa tapahtumat ovat:
Aika | Tapahtuma | Asiakasmäärä |
---|---|---|
1 | Asiakas #4 saapuu ravintolaan | 1 |
2 | Asiakas #5 saapuu ravintolaan | 2 |
3 | Asiakas #2 saapuu ravintolaan | 3 |
5 | Asiakas #4 lähtee ravintolasta | 2 |
6 | Asiakas #1 saapuu ravintolaan | 3 |
6 | Asiakas #3 saapuu ravintolaan | 4 |
7 | Asiakas #2 lähtee ravintolasta | 3 |
8 | Asiakas #1 lähtee ravintolasta | 2 |
8 | Asiakas #5 lähtee ravintolasta | 1 |
9 | Asiakas #3 lähtee ravintolasta | 0 |
Voimme ratkaista tehtävän tehokkaasti muodostamalla ensin asiakkaita vastaavat tapahtumat ja järjestämällä ne aikajärjestykseen. Tämän jälkeen voimme käydä läpi tapahtumat ja pitää yllä asiakkaiden määrää laskurissa.
Seuraavassa funktiossa listat arrivals
ja departures
sisältävät asiakkaiden saapumisajat ja lähtöajat. Esimerkin tapauksessa arrivals
on [6, 3, 6, 1, 2]
ja departures
on [8, 7, 9, 5, 8]
.
def max_customers(arrivals, departures):
events = []
for time in arrivals:
events.append((time, 1))
for time in departures:
events.append((time, 2))
events.sort()
counter = 0
result = 0
for event in events:
if event[1] == 1:
counter += 1
if event[1] == 2:
counter -= 1
result = max(result, counter)
return result
Funktio luo listan events
, johon lisätään jokaista asiakasta kohden kaksi tapahtumaa pareina. Asiakkaan saapumisaika lisätään muodossa (time, 1)
ja asiakkaan lähtöaika muodossa (time, 2)
. Sitten funktio järjestää listan, minkä seurauksena tapahtumat järjestyvät niiden aikojen mukaan.
Tapahtumien järjestämisen jälkeen lista events
käydään läpi ja muuttuja counter
pitää yllä asiakkaiden määrää. Muuttujaan result
tallennetaan muuttujan counter
suurin arvo tapahtumien käsittelyn aikana.
Tuloksena oleva algoritmi muodostuu kolmesta osasta. Tapahtumien luominen vie aikaa \(O(n)\), koska jokaisesta asiakkaasta muodostetaan kaksi tapahtumaa. Tapahtumien järjestäminen vie aikaa \(O(n \log n)\), ja tapahtumien läpikäynti vie aikaa \(O(n)\). Niinpä koko algoritmin aikavaativuus on \(O(n \log n)\).
Miten järjestäminen toimii?
Järjestämisalgoritmille annetaan lista alkioita ja sen tehtävänä on saada alkiot suuruusjärjestykseen. Yleensä lähtökohtana on, että algoritmi voi vertailla alkioita toisiinsa sekä vaihtaa alkioiden paikkoja listalla.
Yksinkertaiset järjestämisalgoritmit perustuvat vierekkäisten alkioiden vaihtamiseen keskenään ja niiden aikavaativuus on \(O(n^2)\). Yksi tällainen algoritmi on lisäysjärjestäminen, joka käy listan läpi vasemmalta oikealle ja siirtää kunkin alkion oikeaan kohtaan listan alkuosassa.
Järjestämiseen on olemassa myös tehokkaita algoritmeja, joiden aikavaativuus on \(O(n \log n)\). Yksi tällainen algoritmi on lomitusjärjestäminen, joka järjestää ensin listan vasemman ja oikean puoliskon rekursiivisesti erikseen ja yhdistää sitten järjestetyt puoliskot kokonaiseksi järjestetyksi listaksi.
Pythonin käyttämä järjestämisalgoritmi on Timsort, jossa on sekä lisäysjärjestämisen että lomitusjärjestämisen piirteitä. Algoritmin aikavaativuus on \(O(n \log n)\), ja se on suunniteltu niin, että se on erityisen tehokas monissa käytännössä vastaan tulevissa tilanteissa.
Yleisessä tapauksessa ei ole mahdollista luoda järjestämisalgoritmia, jonka aikavaativuus olisi tehokkaampi kuin \(O(n \log n)\). Tämä voidaan todistaa määrittämällä alaraja sille, montako kahden alkion vertailua algoritmissa tulee olla pahimmassa tapauksessa.
Järjestämisen toiminnan tutkiminen
Seuraava koodi havainnollistaa, mitä Python tekee järjestäessään listan sisällön:
import functools
def cmp(a, b):
print("compare", a, b)
return a - b
numbers = [4, 1, 3, 2]
numbers.sort(key=functools.cmp_to_key(cmp))
print(numbers)
Kun metodia sort
kutsutaan yllä olevalla tavalla, se vertailee listalla olevien alkioiden järjestystä kutsumalla funktiota cmp
. Funktio cmp
saa parametreina alkiot a
ja b
ja sen tulee palauttaa
- negatiivinen arvo, jos
a
on pienempi kuinb
, - positiivinen arvo, jos
a
on suurempi kuinb
, ja - nolla, jos
a
jab
ovat yhtä suuria.
Tässä cmp
on toteutettu niin, että se palauttaa lausekkeen a - b
arvon, minkä ansiosta se järjestää alkiot pienimmästä suurimpaan.
Metodi sort
muuttaa listan alkioiden järjestystä funktion cmp
perusteella. Funktio cmp
tulostaa kaikki tekemänsä vertailut, mikä antaa tietoa metodin sort
tekemistä vertailuista. Koodin tulostus on seuraava:
compare 1 4
compare 3 1
compare 3 4
compare 3 1
compare 2 3
compare 2 1
[1, 2, 3, 4]
Tämä tarkoittaa, että metodi sort
vertailee ensin lukuja 1 ja 4, sitten lukuja 3 ja 1, jne. Kuuden vertailun jälkeen metodi on saanut listan järjestykseen.
Järjestäminen muissa kielissä
Eri ohjelmointikielissä on valmiita menetelmiä järjestämiseen. Kielten välillä on eroja siinä, mihin algoritmeihin järjestäminen perustuu, mutta yleensä valmiit menetelmät ovat tehokkaita ja ne on toteutettu hyvin.
C++:ssa on saatavilla funktio std::sort
, jolle annetaan parametrina iteraattori järjestettävän välin alkuun ja loppuun. Funktiota voidaan käyttää seuraavasti:
std::vector<int> numbers;
...
std::sort(numbers.begin(), numbers.end());
Javassa voidaan käyttää luokan Collections
metodia sort
:
ArrayList<Integer> numbers = new ArrayList<>();
...
Collections.sort(numbers);
JavaScriptissa taulukon metodi sort
järjestää sen sisällön. Metodin käyttäminen voi kuitenkin aiheuttaa yllätyksiä:
numbers = [2, 11, 1, 3];
numbers.sort();
console.log(numbers); // [1, 11, 2, 3]
Tässä järjestettävänä on kokonaislukuja sisältävä lista, mutta luku 11 menee järjestykseen ennen lukua 2. Syynä tähän on, että metodi sort
käsittelee alkioita oletuksena merkkijonoina, jolloin 11
on pienempi kuin 2
. Metodille voi kuitenkin antaa seuraavasti vertailufunktion lukujen järjestämistä varten:
numbers = [2, 11, 1, 3];
numbers.sort((a, b) => a - b);
console.log(numbers); // [1, 2, 3, 11]
6. Omat tietorakenteet
Tietorakenteen toiminta voidaan esittää joukkona metodeita, joilla on tietyt parametrit ja joiden kutsuminen antaa tietyn tuloksen. Esimerkiksi Pythonin listassa on metodit append
, count
ja index
, joiden avulla pystyy lisäämään alkion listaan, laskemaan alkion esiintymiskerrat sekä etsimään alkion indeksin.
Luokkien avulla voimme toteuttaa itse tietorakenteita, jotka sisältävät haluttuja metodeja. Usein tällaisen luokan sisällä on jokin Pythonin tietorakenne, kuten lista tai sanakirja. Luokan etuna on, että se tarjoaa tietorakenteelle siistin rajapinnan, jonka takana on tietorakenteen sisäinen toteutus.
Esimerkki: Pino
Tehtävä
Toteuta luokka Stack
, joka toteuttaa pinotietorakenteen. Luokassa tulee olla seuraavat metodit:
push(x)
: lisää alkiox
pinon ylimmäksitop()
: hae pinon ylin alkiopop()
: poista pinon ylin alkio
Jokaisen metodin aikavaativuuden tulee olla \(O(1)\).
Luokka Stack
on helppoa toteuttaa Pythonin listan avulla, koska alkion lisääminen listan loppuun ja poistaminen listan lopusta toimivat ajassa \(O(1)\). Voimme toteuttaa luokan seuraavasti:
class Stack:
def __init__(self):
self.stack = []
def push(self, x):
self.stack.append(x)
def top(self):
return self.stack[-1]
def pop(self):
self.stack.pop()
Ideana on, että luokan sisällä määritellään lista stack
, johon tallennetaan pinon alkiot. Metodit push
ja pop
toteutetaan listan metodien append
ja pop
avulla ja metodi top
toteutetaan hakemalla listan viimeinen alkio.
Seuraava koodi testaa luokan toimintaa:
s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(s.top()) # 3
print(s.top()) # 3
s.pop()
print(s.top()) # 2
Tässä tapauksessa luokka Stack
rajoittaa listan toimintaa, koska luokan metodeilla on mahdollista käsitellä vain listan viimeistä alkiota. Luokan käyttäjän ei tarvitse tietää, miten luokka on toteutettu sisäisesti, vaan hän voi luottaa siihen, että saatavilla on metodit push
, top
ja pop
.
Huomaa, että voimme kuitenkin käsitellä luokan sisäistä tietoa, jos tiedämme, miten luokka on toteutettu. Seuraava koodi havainnollistaa asiaa:
s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(s.stack) # [1, 2, 3]
Luokka tallentaa sisäisesti pinon sisällön listaan stack
, jonka sisältöön pääsee käsiksi, vaikka luokan metodeilla voi käsitellä vain pinon ylintä alkiota.
Älä tee luokkaa näin
Seuraava tapa luokan toteuttamiseen ei ole toimiva:
class Stack:
stack = []
def push(self, x):
self.stack.append(x)
def top(self):
return self.stack[-1]
def pop(self):
self.stack.pop()
Erona aiempaan luokkaan tässä luokassa ei ole alustusmetodia __init__
vaan lista stack
luodaan luokan päätasolla. Päältä päin luokka vaikuttaa toimivalta:
s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(s.top()) # 3
Ongelmana tässä toteutuksessa on kuitenkin, että lista stack
on yhteinen kaikille luokasta luoduille olioille. Seuraava koodi havainnollistaa ongelmaa:
a = Stack()
b = Stack()
a.push(1)
b.push(2)
print(a.top()) # 2
Koodi lisää pinoon a
luvun 1
ja pinoon b
luvun 2
. Tämän jälkeen koodi hakee pinon a
ylimmän alkion. Tuloksen pitäisi olla 1
, mutta se onkin 2
, koska pinoilla on yhteinen lista stack
. Tämän seurauksena alkion lisääminen toiseen pinoon lisää sen kumpaankin pinoon eikä luokka toimi halutulla tavalla.
Lisää ominaisuuksia luokkaan
Aiempi luokka Stack
toimii sinänsä, mutta siinä on vielä joitakin puutteita. Ensinnäkään pinon sisällön tulostaminen ei anna hyödyllistä tietoa:
s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(s) # <__main__.Stack object at 0x7fd6c4e23ee0>
Voimme korjata tämän lisäämällä luokkaan metodin __repr__
, jonka tehtävänä on palauttaa tekstimuotoinen esitys luokan sisällöstä. Voimme toteuttaa metodin niin, että se palauttaa suoraan listan stack
sisällön tekstinä:
def __repr__(self):
return str(self.stack)
Tämän muutoksen jälkeen pinon sisältö tulostuu näin:
s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(s) # [1, 2, 3]
Toinen puute on, että pinon kokoa ei pysty selvittämään funktiolla len
, vaan tämä aiheuttaa virheilmoituksen. Voimme korjata tämän lisäämällä luokkaan seuraavan metodin __len__
:
def __len__(self):
return len(self.stack)
Metodia __len__
kutsutaan silloin, kun luokasta tehty olio annetaan funktion len
parametriksi. Tässä tapauksessa metodin riittää palauttaa listan stack
koko. Nyt seuraava koodi toimii järkevällä tavalla:
s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(len(s)) # 3
Metodeissa pop
ja top
on vielä puutteena, että ne eivät ota huomioon tilannetta, jossa pino on tyhjä. Tässä tilanteessa ei ole mahdollista poistaa tai hakea pinon ylintä alkiota. Voimme lisätä metodeihin virheenkäsittelyn seuraavasti:
def pop(self):
if len(self.stack) == 0:
raise IndexError("pop from empty stack")
return self.stack.pop()
def top(self):
if len(self.stack) == 0:
raise IndexError("top from empty stack")
return self.stack[-1]
Nyt jos metodeita kutsutaan pinon ollessa tyhjä, metodit tuottavat IndexError
-virheen, jossa on tekstimuotoinen kuvaus virheen syystä. Seuraava koodi havainnollistaa asiaa:
s = Stack()
s.push(1)
s.pop()
s.pop() # IndexError: pop from empty stack
Esimerkki: Tehokas lisäys
Tehtävä
Toteuta luokka SuperStack
, jossa on seuraavat metodit:
push(x)
: lisää alkiox
pinon ylimmäksipush_many(k, x)
: lisää pinon ylimmäksik
kertaa alkiox
top()
: hae pinon ylin alkiopop()
: poista pinon ylin alkio
Jokaisen metodin aikavaativuuden tulee olla \(O(1)\).
Tämä luokka vasta muuten aiempaa luokkaa Stack
, mutta siinä on uusi metodi push_many
, jonka avulla voi lisätä saman alkion useita kertoja pinoon. Luokan toteutuksessa on haasteena, että myös metodin push_many
tulee toimia ajassa \(O(1)\) riippumatta siitä, miten monta kertaa alkio lisätään.
Seuraava tapa toteuttaa metodi push_many
ei ole riittävä:
def push_many(self, x, k):
for i in range(k):
self.push(x)
Tässä ongelmana on, että metodissa on silmukka, joka vie aikaa \(O(k)\), eli metodin suoritusaika riippuu siitä, montako kertaa alkio lisätään. Kuitenkin aikavaativuuden tulisi olla \(O(1)\) eli metodissa ei saa olla silmukkaa.
Hyödyllinen näkökulma luokan suunnittelussa on, että luokan ainoa vaatimus on toteuttaa halutut operaatiot ajassa \(O(1)\). Muilta osin voimme päättää vapaasti, millainen on luokan sisäinen toteutus. Erityisesti luokan ei ole pakko tallentaa jokaista alkiota erikseen pinoon, vaan sen riittää antaa vaikutelma tällaisesta pinosta.
Tehokas tapa toteuttaa luokka on tallentaa pinoon sisäisesti pareja muotoa \((k,x)\): alkio \(x\) esiintyy \(k\) kertaa. Esimerkiksi koodi
s = SuperStack()
s.push_many(3, 8)
s.push(4)
s.push_many(2, 5)
luo pinon \([(3,8),(1,4),(2,5)]\), joka esittää tiivistetyssä muodossa pinon \([8,8,8,4,5,5]\) sisällön. Tämän ansiosta jokainen metodin push
tai push_many
kutsu lisää vain yhden alkion pinoon. Vastaavasti metodit top
ja pop
täytyy toteuttaa niin, että ne käsittelevät oikealla tavalla pinossa olevia pareja. Luokka voidaan toteuttaa seuraavasti:
class SuperStack:
def __init__(self):
self.stack = []
def push(self, x):
self.stack.append((1, x))
def push_many(self, k, x):
self.stack.append((k, x))
def top(self):
return self.stack[-1][1]
def pop(self):
last = self.stack[-1]
if last[0] == 1:
self.stack.pop()
else
self.stack[-1] = (last[0] - 1, last[1])
Metodi top
palauttaa pinon ylimmän parin toisen jäsenen, koska jokaisen parin toinen jäsen vastaa pinossa olevaa alkiota. Metodi pop
puolestaan tarkastelee pinon ylimmän parin ensimmäistä jäsentä eli toistokertojen määrää. Jos alkio toistuu kerran, metodi poistaa parin pinosta. Jos taas alkio toistuu useamman kerran, metodi vähentää toistokertojen määrää yhdellä.
Tämän toteutuksen ansiosta jokainen luokan metodi toimii ajassa \(O(1)\), koska jokaisessa metodissa on kiinteä määrä komentoja eikä silmukoita.
Esimerkki: Moodi
Tehtävä
Toteuta luokka Mode
, jossa on seuraavat metodit:
add(x)
: lisää lukux
listallemode()
: ilmoita listan moodi eli yleisin luku
Jokaisen metodin aikavaativuuden tulee olla \(O(1)\).
Tämä on muunnelma aiemmasta tehtävästä, jossa laskettavana oli annetun listan lukujen moodi. Erona on, että luokan Mode
avulla voi vuorotellen lisätä lukuja listalle sekä kysyä, mikä on tähän mennessä lisättyjen lukujen moodi. Esimerkiksi luokkaa voidaan käyttää näin:
m = Mode()
m.add(1)
m.add(1)
m.add(2)
print(m.mode()) # 1
m.add(2)
m.add(2)
print(m.mode()) # 2
Koska ainoa luokalta kysyttävä asia on listan lukujen moodi, meidän ei tarvitse tallentaa muistiin listaa vaan vain tarvittavat tiedot moodin laskemiseen. Voimme toteuttaa luokan käyttäen sanakirjaa, johon on tallennettu jokaisen luvun esiintymiskertojen määrä. Lisäksi luokassa on muistissa tämänhetkinen moodi.
class Mode:
def __init__(self):
self.count = {}
self.status = (0, 0)
def add(self, x):
if x not in self.count:
self.count[x] = 0
self.count[x] += 1
self.status = max(self.status, (self.count[x], x))
def mode(self):
return self.status[1]
Sanakirja count
sisältää kunkin luvun esiintymiskerran, ja parissa status
on tieto moodista muodossa \((k,x)\): moodi on luku \(x\), joka esiintyy listassa \(k\) kertaa. Kun listalle lisätään luku \(x\), moodia päivitetään, jos luku \(x\) on lisäämisen jälkeen listan moodi. Tämä onnistuu kätevästi max
-funktion avulla, joka vertaa ensisijaisesti parin ensimmäistä jäsentä ja toissijaisesti toista jäsentä.
Luokan kummassakin metodissa on vain yksittäisiä komentoja, joten ne toimivat vaatimusten mukaisesti ajassa \(O(1)\).
7. Puut ja rekursio
Puu (tree) on tietorakenne, joka muodostuu eri tasoilla olevista solmuista (node). Puiden avulla voidaan esittää hierarkioita, joissa eri tasoilla olevat solmut ovat yhteydessä toisiinsa.
Tarkastellaan esimerkkinä seuraavaa puuta:
Tässä puussa on seitsemän solmua, jotka ovat kolmella tasolla.
Puun ylin solmu on nimeltään juuri (root). Solmun lapsi (child) on yhtä alemmalla tasolla oleva solmu, johon pääsee kulkemaan solmusta. Solmun vanhempi (parent) on solmu, jonka lapsi solmu on. Jos solmulla ei ole lapsia, se on lehti (leaf).
Esimerkissä puun juuri on solmu 1. Solmun 1 lapset ovat solmut 4, 5 ja 2. Solmun 4 vanhempi on solmu 1. Puun lehtiä ovat solmut 3, 7, 5 ja 6.
Jokaisella solmulla juurta lukuun ottamatta on tarkalleen yksi vanhempi. Tämän vuoksi on olemassa yksikäsitteinen reitti juuresta mihin tahansa solmuun kulkemalla yhteyksiä alaspäin. Juurella ei ole vanhempaa.
Solmun alipuu (subtree) sisältää solmut, joihin pääsee kulkemalla solmusta alaspäin yhteyksiä pitkin. Esimerkissä solmun 1 alipuu sisältää puun kaikki solmut ja solmun 4 alipuu sisältää solmut 4, 3 ja 7.
Solmun syvyys (depth) tarkoittaa, miten matalalla solmu on puussa. Juuren syvyys on 0 ja muilla solmuilla syvyys on yhtä suurempi kuin niiden vanhemman syvyys. Esimerkissä solmun 1 syvyys on 0, solmun 4 syvyys on 1 ja solmun 3 syvyys on 2.
Puun korkeus (height) on suurin puussa esiintyvä solmun syvyys. Esimerkissä puun korkeus on 2, koska solmujen 3, 7 ja 6 syvyys on 2.
Puun käsittely
Voimme esittää puun solmun Pythonissa seuraavan luokan Node
avulla:
class Node:
def __init__(self, value, children=[]):
self.value = value
self.children = children
def __repr__(self):
return str(self.value)
Solmun luonnissa annetaan kaksi tietoa: solmussa oleva arvo sekä lista solmun lapsista. Jos listaa ei anneta, se on oletuksena tyhjä. Esimerkiksi seuraava koodi luo kolme solmua niin, että solmut 2 ja 3 ovat solmun 1 lapsia.
node2 = Node(2)
node3 = Node(3)
node1 = Node(1, [node2, node3])
Solmun merkkijonoesityksenä on solmussa oleva arvo:
node = Node(1)
print(node) # 1
Tämän luokan avulla puu voidaan määritellä rakentamalla sen juurisolmu. Esimerkiksi seuraava koodi luo luvun alussa olleen esimerkkipuun:
tree = Node(1, [Node(4, [Node(3), Node(7)]),
Node(5),
Node(2, [Node(6)])])
Puun läpikäynti
Luonteva tapa käsitellä puuta on käyttää rekursiota. Esimerkiksi seuraava funktio traverse
käy läpi kaikki solmut, jotka ovat solmun node
alipuussa:
def traverse(node):
print(node)
for child in node.children:
traverse(child)
Kun funktiolle annetaan viittaus puun juureen, funktio käy läpi kaikki puun solmut:
traverse(tree)
Esimerkkipuussa funktion tulostus on seuraava:
1
4
3
7
5
2
6
Funktio traverse
tulostaa ensin sille annetun solmun arvon (node.value
). Tämän jälkeen funktio käy läpi kaikki solmun lapset (node.children
) ja kutsuu jokaisen lapsen kohdalla itseään rekursiivisesti.
Voimme vielä selventää funktion toimintaa muuttamalla sitä seuraavasti:
def traverse(node):
print("enter", node.value)
for child in node.children:
traverse(child)
print("leave", node.value)
Nyt funktio tulostaa ”enter \(x\)”, kun solmun \(x\) käsittely alkaa, ja ”leave \(x\)”, kun solmun \(x\) käsittely päättyy. Esimerkkipuussa funktio tulostaa:
enter 1
enter 4
enter 3
leave 3
enter 7
leave 7
leave 4
enter 5
leave 5
enter 2
enter 6
leave 6
leave 2
leave 1
Tiedon laskeminen puusta
Puita käsitellään usein funktioilla, jotka laskevat rekursiivisesti jonkin puuhun liittyvän tiedon. Tarkastellaan esimerkkinä funktiota count_nodes
, joka laskee, montako solmua on solmun node
alipuussa:
def count_nodes(node):
result = 1
for child in node.children:
result += count_nodes(child)
return result
Funktiota voidaan käyttää näin:
tree = Node(1, [Node(4, [Node(3), Node(7)]),
Node(5),
Node(2, [Node(6)])])
print(count_nodes(tree)) # 7
Funktio laskee solmujen määrän muuttujaan result
. Muuttujan alkuarvo on 1, jolloin siihen on laskettu mukaan solmu node
. Tämän jälkeen funktio käy läpi solmun lapset ja laskee mukaan rekursiivisesti solmujen määrät lasten alipuissa. Lopuksi funktio palauttaa solmujen määrän.
Katsotaan tarkemmin, miten funktio laskee solmujen määrän esimerkissä:
Kun funktiolle annetaan viittaus solmuun 1, muuttuja result
on alussa 1 ja funktio lisää siihen silmukassa solmun 1 lasten solmujen määrät. Solmun 1 lapset ovat solmut 4, 5 ja 2. Solmun 4 alipuussa on 3 solmua, solmun 5 alipuussa on 1 solmu ja solmun 2 alipuussa on 2 solmua. Niinpä muuttujaan result
lisätään määrät 3, 1 ja 2 ja solmujen määräksi saadaan 1 + 3 + 1 + 2 = 7.
Voimme vielä selventää funktion toimintaa lisäämällä siihen tulostusta:
def count_nodes(node):
result = 1
for child in node.children:
result += count_nodes(child)
print("subtree of node", node, "has", result, "nodes")
return result
Tämän muutoksen jälkeen funktio tulostaa jokaisesta solmusta tiedon, montako solmua sen alipuussa on:
subtree of node 3 has 1 nodes
subtree of node 7 has 1 nodes
subtree of node 4 has 3 nodes
subtree of node 5 has 1 nodes
subtree of node 6 has 1 nodes
subtree of node 2 has 2 nodes
subtree of node 1 has 7 nodes
Monia puihin liittyviä asioita voidaan laskea samalla idealla: määritellään sopivat muuttujat, käydään läpi silmukassa solmun lapset rekursiivisesti ja päivitetään muuttujia sopivalla tavalla. Esimerkiksi seuraava funktio count_height
laskee puun korkeuden eli suurimman puussa olevan solmun syvyyden:
def count_height(node):
result = 0
for child in node.children:
result = max(result, count_height(child) + 1)
return result
Tässä tapauksessa silmukka käy läpi solmun lapset ja valitsee funktion max
avulla suurimman alipuun korkeuden, johon lisätään 1. Esimerkkipuussa solmun 1 lasten alipuiden korkeudet ovat 1, 0 ja 1. Näistä valitaan suurin korkeus 1, johon lisätään 1, josta saadaan solmun 1 alipuun korkeudeksi 2.
Syvyyksien käsittely
Välillä hyödyllinen tekniikka on lisätä rekursiiviseen funktioon parametri, joka pitää yllä käsiteltävän solmun syvyyttä. Esimerkiksi seuraava funktio tulostaa puun jokaisen solmun syvyyden.
def traverse(node, depth):
print("node", node, "depth", depth)
for child in node.children:
traverse(child, depth + 1)
Funktiota kutsuttaessa juuren syvyydeksi annetaan 0, ja funktio kasvattaa syvyyttä yhdellä aina liikkuessaan alempaan solmuun. Funktiota voidaan käyttää näin:
tree = Node(1, [Node(4, [Node(3), Node(7)]),
Node(5),
Node(2, [Node(6)])])
traverse(tree, 0)
Tässä tapauksessa tulostus on:
node 1 depth 0
node 4 depth 1
node 3 depth 2
node 7 depth 2
node 5 depth 1
node 2 depth 1
node 6 depth 2
Tehdään seuraavaksi monimutkaisempi funktio get_depths
, jonka tulisi palauttaa listana kaikkien puun solmujen syvyydet pienimmästä suurimpaan. Esimerkkipuussa funktion tulisi palauttaa lista [0, 1, 1, 1, 2, 2, 2]
. Hyvä tapa tehdä tällainen funktio on tehdä sen avuksi toinen funktio, jolla on enemmän parametreja:
def get_depths(node):
depths = []
get_depths_helper(node, 0, depths)
return sorted(depths)
def get_depths_helper(node, depth, depths):
depths.append(depth)
for child in node.children:
get_depths_helper(child, depth + 1, depths)
Funktio toimii nyt näin:
tree = Node(1, [Node(4, [Node(3), Node(7)]),
Node(5),
Node(2, [Node(6)])])
print(get_depths(tree)) # [0, 1, 1, 1, 2, 2, 2]
Tässä funktio get_depths
luo ensin listan depths
solmujen syvyyksille. Tämän jälkeen funktio kutsuu funktiota get_depths_helper
, joka lisää syvyydet listalle. Lopuksi funktio get_depths
palauttaa listan järjestettynä.
Funktiolla get_depths_helper
on kaksi lisäparametria: parametri depth
pitää yllä tietoa solmun syvyydestä ja parametri depths
on viittaus listaan, johon syvyydet tallennetaan. Huomaa, että koska Pythonissa lista välitetään viittauksena, kaikki syvyydet lisätään samalle listalle, joka on määritelty funktiossa get_depths
. Tämän ansiosta tiedot saadaan kerättyä samalle listalle eri funktiokutsuista.
Tässä on vielä toinen tapa toteuttaa funktio get_depths
, jossa funktiolle get_depths_helper
ei anneta parametrina listaa vaan funktio palauttaa listan.
def get_depths(node):
return sorted(get_depths_helper(node, 0))
def get_depths_helper(node, depth):
depths = [depth]
for child in node.children:
depths += get_depths_helper(child, depth + 1)
return depths
Tässä tapauksessa funktio get_depths_helper
muodostaa listan, johon lisätään ensin käsiteltävän solmun syvyys ja sitten alipuiden listat, jotka saadaan rekursiivisesti. Funktio get_depths
saa lopulta listan, jossa on kaikkien solmujen syvyydet, ja funktio palauttaa tämän listan järjestettynä.
Luokka paremmaksi
Tarkastellaan vielä luokan Node
määrittelyä:
class Node:
def __init__(self, value, children=[]):
self.value = value
self.children = children
def __repr__(self):
return str(self.value)
Luokka toimii tällaisenaan yleensä hyvin, mutta Pythonissa on yllättävä piirre, joka voi aiheuttaa ongelmia joissakin tilanteissa. Seuraava koodi esittelee asiaa:
node1 = Node(1)
node2 = Node(2)
node1.children.append(node2)
print(node1.children) # [2]
print(node2.children) # [2]
Tässä luotiin kaksi solmua, joilla ei ole lapsia, minkä jälkeen solmun 1 lapseksi lisättiin solmu 2. Yllättäen kuitenkin solmu 2 ilmestyi myös itsensä lapseksi.
Tämä ilmiö johtuu siitä, että oletusparametri []
luodaan vain kerran ja se on yhteinen kaikille metodikutsuille. Tämän vuoksi molemmat solmut viittaavat samaan tyhjään listaan, johon lisätty solmu näkyy kummallekin solmulle.
Voimme korjata asian muuttamalla alustusmetodia näin:
class Node:
def __init__(self, value, children=None):
self.value = value
self.children = children if children else []
def __repr__(self):
return str(self.value)
Nyt parametrin children
oletusarvo on None
. Jos parametria ei ole annettu, alustusmetodi luo tyhjän listan. Tämän muutoksen jälkeen jokainen olio saa oman tyhjän listan ja koodi toimii järkevästi:
node1 = Node(1)
node2 = Node(2)
node1.children.append(node2)
print(node1.children) # [2]
print(node2.children) # []
Vielä toinen puute luokassa on, että solmun tulostaminen näyttää vain solmussa olevan arvon eikä solmun lapsia:
tree = Node(1, [Node(2, [Node(3), Node(4)]), Node(5)])
print(tree) # 1
Pythonissa on periaatteena, että metodin __repr__
tulisi antaa sellainen merkkijonoesitys oliosta, joka luo olion. Tämä ei selkeästi toteudu tällä hetkellä.
Voimme korjata asian muuttamalla luokkaa näin:
class Node:
def __init__(self, value, children=None):
self.value = value
self.children = children if children else []
def __repr__(self):
if self.children == []:
return f"Node({self.value})"
else:
return f"Node({self.value}, {self.children})"
Tämän jälkeen solmun tulostaminen antaa merkkijonoesityksen, joka sisältää myös solmun lapset ja jonka perusteella olio voitaisiin luoda:
tree = Node(1, [Node(2, [Node(3), Node(4)]), Node(5)])
print(tree) # Node(1, [Node(2, [Node(3), Node(4)]), Node(5)])
Esimerkki: Työntekijät
Puiden avulla voidaan mallintaa monia hierarkisia rakenteita. Esimerkiksi organisaation henkilöstörakenne voidaan esittää puuna, jossa jokainen työntekijä on yksi puun solmuista ja solmun lapset ovat työntekijän alaiset.
Seuraavaan luokkaan voidaan tallentaa työntekijän nimi ja lista alaisista:
class Employee:
def __init__(self, name, subordinates=[]):
self.name = name
self.subordinates = subordinates
def __repr__(self):
return self.name
Luokkaa voidaan käyttää näin:
def list_employees(employee, level=0):
print(" "*(level*4), employee)
for subordinate in employee.subordinates:
list_employees(subordinate, level + 1)
staff = Employee("Emilia",
[
Employee("Antti"),
Employee("Leena", [Employee("Jussi")]),
Employee("Matti", [Employee("Sasu")])
])
list_employees(staff)
Koodin tulostus on:
Emilia
Antti
Leena
Jussi
Matti
Sasu
Esimerkki: Kuningattaret
Ongelman ratkaisuiden järjestelmällinen läpikäynti voidaan myös nähdä puun läpikäyntinä. Tämä menetelmä tunnetaan nimellä peruuttava haku (backtracking). Tarkastellaan esimerkkinä seuraavaa tehtävää:
Tehtävä
Monellako tavalla \(n \times n\) shakkilaudalle voidaan sijoittaa \(n\) kuningatarta niin, että mitkään kaksi kuningatarta eivät uhkaa toisiaan?
Esimerkiksi kun \(n=4\), ratkaisuja on \(2\):
Voimme lähestyä ongelmaa käymällä läpi puuta, jonka juurena on tyhjä ruudukko. Jokaisessa solmussa lapsia ovat ruudukot, joihin on lisätty kuningatar seuraavalle riville. Seuraava kuva näyttää osan tapaukseen \(n=4\) liittyvästä puusta:
Tällä tavalla voidaan käydä läpi kaikki mahdolliset tavat lisätä kuningattaret ruudukkoon ja laskea mukaan ratkaisut, joissa kaksi kuningatarta ei uhkaa toisiaan. Huomaa, että puuta ei ole olemassa valmiina muistissa vaan sitä rakennetaan samaan aikaan läpikäynnin kanssa.
Seuraava koodi toteuttaa läpikäynnin:
def count_queens(n):
return count(n, 0, [])
def count(n, row, queens):
if row == n:
return 1
result = 0
for col in range(n):
attacks = [attack(queen, (row, col)) for queen in queens]
if not any(attacks):
result += count(n, row + 1, queens + [(row, col)])
return result
def attack(queen1, queen2):
if queen1[0] == queen2[0] or queen1[1] == queen2[1]:
return True
if abs(queen1[0] - queen2[0]) == abs(queen1[1] - queen2[1]):
return True
return False
print(count_queens(4)) # 2
print(count_queens(8)) # 92
Funktiolle count
annetaan kolme parametria: ruudukon koko, seuraavaksi lisättävän kuningattaren rivi sekä lista lisätyistä kuningattarista. Rivit ja sarakkeet on numeroitu \(0 \dots n-1\) ja kuningattaret esitetään muodossa \((y,x)\), jossa \(y\) ja \(x\) ovat kuningattaren rivi ja sarake. Funktio käy läpi kaikki sarakkeet ja jatkaa hakua rekursiivisesti tilanteissa, joissa kuningatar voidaan lisätä sarakkeeseen ilman, että se uhkaa aiemmin lisättyä kuningatarta.
Funktio attack
tarkastaa, uhkaavatko kaksi kuningatarta toisiaan. Ensimmäinen ehto tarkastaa tapaukset, joissa kuningattaret uhkaavat toisiaan vaaka- tai pystysuuntaisesti. Toinen ehto puolestaan tarkastaa tapaukset, joissa kuningattaret uhkaavat toisiaan vinosuuntaisesti. Tämä voidaan tunnistaa siitä, että vaaka- ja pystysuuntaisten etäisyyksien itseisarvot ovat samat.
Koodissa on käytössä kätevä funktio any
, joka palauttaa arvon True
silloin, kun annetulla listalla on ainakin kerran arvo True
. Niinpä not any(attacks)
tarkoittaa, että jokainen listan attacks
arvo on False
eli lisättävä kuningatar ei uhkaa mitään aiemmin lisättyä kuningatarta.
8. Verkkoalgoritmit
Verkko (graph) on tietorakenne, joka muodostuu solmuista (node) ja kaarista (edge). Jokainen verkon kaari yhdistää kaksi solmua toisiinsa.
Esimerkiksi seuraavassa verkossa on viisi solmua ja seitsemän kaarta:
Solmun naapuri (neighbor) on toinen solmu, johon siitä pääsee kaarella. Esimerkiksi solmun 1 naapurit ovat solmut 2, 3 ja 4. Solmun aste (degree) on naapurien määrä. Esimerkiksi solmun 1 aste on 3, koska sillä on 3 naapuria.
Kahden solmun välinen polku (path) on kaaria pitkin kulkeva reitti. Tässä on joitakin mahdollisia polkuja solmusta 1 solmuun 5:
- 1 → 2 → 5
- 1 → 4 → 5
- 1 → 3 → 4 → 5
- 1 → 3 → 4 → 2 → 5
Esimerkkejä verkkojen käyttötarkoituksista:
- Tieverkko: Solmut ovat kaupunkeja ja kaaret ovat teitä. Kahden solmun välinen polku vastaa kahden kaupungin välistä reittiä.
- Kontaktiverkko: Solmut ovat ihmisiä ja kaaret ovat kontakteja. Kahden solmun välinen polku kuvaa, mitä kautta ihmiset tuntevat toisensa.
- Tietoverkko: Solmut ovat tietokoneita ja kaaret ovat yhteyksiä. Kahden solmun välinen polku kuvaa, mitä kautta voidaan välittää tietoa.
Verkot ohjelmoinnissa
Tavallinen tapa käsitellä verkkoa ohjelmoinnissa on pitää muistissa kunkin solmun vieruslistaa (adjacency list). Solmun \(x\) vieruslista sisältää kaikki solmut, joihin pääsee kaarella solmusta \(x\).
Pythonissa voimme tallentaa verkon sanakirjana, jossa avaimet ovat solmuja ja arvot vieruslistoja. Esimerkkiverkko voidaan tallentaa seuraavasti:
graph = {
1: [2, 3, 4],
2: [1, 4, 5],
3: [1, 4],
4: [1, 2, 3, 5],
5: [2, 4]
}
Usein on kätevää määritellä verkkoa varten luokka, jossa on metodi kaarien lisäämiseen. Luokka voidaan toteuttaa seuraavasti:
class Graph:
def __init__(self, nodes):
self.nodes = nodes
self.graph = {node: [] for node in nodes}
def add_edge(self, a, b):
self.graph[a].append(b)
self.graph[b].append(a)
Tässä konstruktorille annetaan lista nodes
, jossa on verkon solmut. Tämän perusteella luodaan sanakirja graph
, joka sisältää vieruslistat. Aluksi jokainen vieruslista on tyhjä, ja metodi add_edge
lisää kaaren solmujen a
ja b
välille.
Nyt voimme luoda esimerkkiverkon näin:
g = Graph([1, 2, 3, 4, 5])
g.add_edge(1, 2)
g.add_edge(1, 3)
g.add_edge(1, 4)
g.add_edge(2, 4)
g.add_edge(2, 5)
g.add_edge(3, 4)
g.add_edge(4, 5)
Syvyyshaku
Syvyyshaku (depth-first search) eli DFS on menetelmä, jonka avulla voidaan käydä läpi kaikki verkon solmut, joihin pääsee lähtösolmusta kaaria pitkin. Syvyyshaku voidaan toteuttaa rekursiivisesti verkon vieruslistojen avulla.
Seuraava koodi toteuttaa syvyyshaun:
class DFS:
def __init__(self, nodes):
self.nodes = nodes
self.graph = {node: [] for node in nodes}
def add_edge(self, a, b):
self.graph[a].append(b)
self.graph[b].append(a)
def visit(self, node):
if node in self.visited:
return
self.visited.add(node)
for next_node in self.graph[node]:
self.visit(next_node)
def search(self, start_node):
self.visited = set()
self.visit(start_node)
return self.visited
Metodi search
suorittaa syvyyshaun solmusta start_node
alkaen ja palauttaa haun löytämät solmut. Metodi luo ensin joukon visited
, johon merkitään, missä solmuissa haku on käynyt. Tämän jälkeen metodi kutsuu rekursiivista metodia visit
, joka toteuttaa haun.
Metodi visit
saa parametriksi vierailtavan solmun node
. Jos haku on käynyt solmussa, metodin suoritus päättyy, ja muuten solmu lisätään joukkoon visited
. Tämän jälkeen metodi käy läpi solmun node
vieruslistan ja kutsuu itseään rekursiivisesti jokaiselle solmun node
naapurille.
Seuraava koodi esittelee syvyyshaun toimintaa:
d = DFS([1, 2, 3, 4, 5])
d.add_edge(1, 2)
d.add_edge(1, 3)
d.add_edge(1, 4)
d.add_edge(2, 4)
d.add_edge(2, 5)
d.add_edge(3, 4)
d.add_edge(4, 5)
print(d.search(1))
Koodin tulostus on seuraava:
{1, 2, 3, 4, 5}
Tämä tarkoittaa, että solmusta 1 pääsee solmuihin 1, 2, 3, 4 ja 5 eli solmusta 1 pääsee kulkemaan kaikkiin verkon solmuihin.
Komponentit ja yhtenäisyys
Verkon komponentti (component) sisältää solmut, jotka ovat yhteydessä toisiinsa kaaria pitkin. Esimerkiksi seuraavassa verkossa on kolme komponenttia: \(\{1,2,3\}\), \(\{4,5,6,7\}\) ja \(\{8\}\).
Verkko on yhtenäinen (connected), jos siinä on tasan yksi komponentti eli minkä tahansa kahden solmun välillä on reitti kaaria pitkin. Esimerkiksi seuraava verkko on yhtenäinen, koska sen ainoa komponentti on \(\{1,2,3,4,5\}\).
Esimerkiksi tieverkossa komponentti sisältää kaupungit, joiden välillä pystyy kulkemaan teitä pitkin. Tieverkko on yhtenäinen, jos minkä tahansa kahden kaupungin välillä on olemassa reitti teitä pitkin.
Syvyyshaun avulla voidaan etsiä verkon komponentit käymällä läpi verkon solmut ja aloittamalla uusi haku aina, kun vastaan tulee solmu, joka ei ole vielä missään komponentissa. Seuraava luokka Components
toteuttaa etsinnän:
class Components:
def __init__(self, nodes):
self.nodes = nodes
self.graph = {node: [] for node in nodes}
def add_edge(self, a, b):
self.graph[a].append(b)
self.graph[b].append(a)
def visit(self, node):
if node in self.components:
return
self.components[node] = self.counter
for next_node in self.graph[node]:
self.visit(next_node)
def find_components(self):
self.counter = 0
self.components = {}
for node in self.nodes:
if node not in self.components:
self.counter += 1
self.visit(node)
return self.components
Muuttujassa counter
on komponenttien määrä. Aina kun vastaan tulee solmu, joka ei ole vielä missään komponentissa, muuttujan arvo kasvaa yhdellä. Sanakirja components
kertoo kunkin solmun komponentin, ja siihen merkitään syvyyshaun aikana nykyinen muuttujan counter
arvo.
Luokkaa voidaan käyttää seuraavasti:
c = Components([1, 2, 3, 4, 5])
c.add_edge(1, 2)
c.add_edge(3, 4)
c.add_edge(3, 5)
c.add_edge(4, 5)
print(c.find_components()) # {1: 1, 2: 1, 3: 2, 4: 2, 5: 2}
Tässä tapauksessa verkossa on kaksi komponenttia. Ensimmäinen komponentti on \(\{1,2\}\) ja toinen komponentti on \(\{3,4,5\}\).
Leveyshaku
Leveyshaku (breadth-first search) eli BFS on toinen menetelmä verkon solmujen läpikäyntiin. Syvyyshaun tavoin leveyshaku aloittaa tietystä verkon solmusta ja käy kaikissa solmuissa, joihin alkusolmusta pääsee kaaria pitkin. Menetelmien erona on järjestys, jossa solmut käydään läpi.
Leveyshaku voidaan toteuttaa seuraavasti:
class BFS:
def __init__(self, nodes):
self.nodes = nodes
self.graph = {node: [] for node in nodes}
def add_edge(self, a, b):
self.graph[a].append(b)
self.graph[b].append(a)
def search(self, start_node):
visited = set()
queue = [start_node]
visited.add(start_node)
for node in queue:
for next_node in self.graph[node]:
if next_node not in visited:
queue.append(next_node)
visited.add(next_node)
return visited
Ideana on luoda lista queue
, jossa on jono käsiteltävistä solmuista. Ensin listalle laitetaan alkusolmu start_node
. Joka askeleella haku ottaa käsittelyyn seuraavan solmun node
jonosta ja käy läpi solmun vieruslistan. Haku lisää jonon loppuun kaikki solmut, joihin solmusta node
pääsee ja joita ei ole lisätty aiemmin.
Seuraava koodi esittelee leveyshaun toimintaa:
b = BFS([1, 2, 3, 4, 5])
b.add_edge(1, 2)
b.add_edge(1, 3)
b.add_edge(1, 4)
b.add_edge(2, 4)
b.add_edge(2, 5)
b.add_edge(3, 4)
b.add_edge(4, 5)
print(b.search(1))
Koodin tulostus on seuraava:
{1, 2, 3, 4, 5}
Lyhimmät polut ja etäisyydet
Kahden verkon solmun välinen lyhin polku (shortest path) on niiden välinen polku, jossa on mahdollisimman vähän kaaria. Solmujen etäisyys (distance) on niiden välisen lyhimmän polun pituus.
Esimerkiksi seuraavassa verkossa yksi lyhin polku solmusta 1 solmuun 5 kulkee ensin solmusta 1 solmuun 4 ja sitten solmusta 4 solmuun 5. Lyhimmällä polulla on 2 kaarta, joten solmujen 1 ja 5 etäisyys on 2.
Leveyshaun ominaisuutena on, että se käy solmut läpi järjestyksessä sen mukaan, mikä on niiden etäisyys lähtösolmusta. Tämän ansiosta leveyshaulla voidaan määrittää kunkin solmun etäisyys annetusta alkusolmusta sekä etsiä lyhin polku kahden solmun välillä.
Tarkastellaan ensin etäisyyksien laskemista. Seuraava luokka laskee etäisyydet alkusolmusta kaikkiin verkon solmuihin:
class Distances:
def __init__(self, nodes):
self.nodes = nodes
self.graph = {node: [] for node in nodes}
def add_edge(self, a, b):
self.graph[a].append(b)
self.graph[b].append(a)
def find_distances(self, start_node):
distances = {}
queue = [start_node]
distances[start_node] = 0
for node in queue:
distance = distances[node]
for next_node in self.graph[node]:
if next_node not in distances:
queue.append(next_node)
distances[next_node] = distance + 1
return distances
Koodi on muuten samanlainen kuin aiempi leveyshaun koodi, mutta joukon visited
sijasta käytössä on sanakirja distances
, johon lasketaan etäisyydet solmuihin. Jos käsittelyyn tulee solmu, jonka etäisyyttä ei ole vielä laskettu, sen etäisyydeksi tulee yhtä suurempi kuin edeltävän solmun etäisyys.
Luokkaa voidaan käyttää näin:
d = Distances([1, 2, 3, 4, 5])
d.add_edge(1, 2)
d.add_edge(1, 3)
d.add_edge(1, 4)
d.add_edge(2, 4)
d.add_edge(2, 5)
d.add_edge(3, 4)
d.add_edge(4, 5)
print(d.find_distances(1)) # {1: 0, 2: 1, 3: 1, 4: 1, 5: 2}
Tässä laskettiin etäisyydet alkusolmusta 1 verkon solmuihin:
- Etäisyys solmuun 1 on 0.
- Etäisyys solmuun 2 on 1.
- Etäisyys solmuun 3 on 1.
- Etäisyys solmuun 4 on 1.
- Etäisyys solmuun 5 on 2.
Leveyshaun avulla voimme myös toteuttaa luokan, jolla voi selvittää lyhimmän polun kahden solmun välillä:
class ShortestPaths:
def __init__(self, nodes):
self.nodes = nodes
self.graph = {node: [] for node in nodes}
def add_edge(self, a, b):
self.graph[a].append(b)
self.graph[b].append(a)
def find_path(self, start_node, end_node):
distances = {}
previous = {}
queue = [start_node]
distances[start_node] = 0
previous[start_node] = None
for node in queue:
distance = distances[node]
for next_node in self.graph[node]:
if next_node not in distances:
queue.append(next_node)
distances[next_node] = distance + 1
previous[next_node] = node
if end_node not in distances:
return None
node = end_node
path = []
while node:
path.append(node)
node = previous[node]
path.reverse()
return path
Metodille find_path
annetaan solmut start_node
ja end_node
ja se etsii lyhimmän polun näiden solmujen välillä.
Sanakirja distances
sisältää etäisyydet alkusolmusta solmuihin kuten ennenkin. Lisäksi sanakirja previous
kertoo jokaisesta solmusta, mikä on sitä edeltävä solmu lyhimmällä polulla. Tämän avulla voidaan leveyshaun jälkeen etsiä askel kerrallaan lyhin polku käänteisesti loppusolmusta alkaen. Lopuksi muodostettu polku käännetään ympäri metodin reverse
avulla.
Seuraava koodi esittelee luokan käyttämistä:
s = ShortestPaths([1, 2, 3, 4, 5])
s.add_edge(1, 2)
s.add_edge(1, 3)
s.add_edge(1, 4)
s.add_edge(2, 4)
s.add_edge(2, 5)
s.add_edge(3, 4)
s.add_edge(4, 5)
print(s.find_path(2, 4)) # [2, 4]
print(s.find_path(1, 5)) # [1, 2, 5]
print(s.find_path(5, 1)) # [5, 2, 1]
Labyrintti verkkona
Tarkastellaan seuraavaa labyrinttia, jossa valkoiset ruudut ovat lattiaa ja mustat ruudut ovat seinää:
Voimme käyttää syvyys- ja leveyshakua reittien etsimiseen labyrintissa. Tässä tapauksessa voimme ajatella, että verkon solmuja ovat labyrintin lattiaruudut ja kahden solmun välillä on kaari, jos ruudut ovat vierekkäin labyrintissa.
Yksi tapa toteuttaa haku olisi rakentaa ensin verkko labyrintin kuvauksen perusteella. Kuitenkin voimme myös toteuttaa haun suoraan labyrinttiin. Seuraava koodi näyttää, miten voimme käydä läpi labyrintin ruudut syvyyshaun avulla:
def explore(grid, y, x):
if grid[y][x] != 0:
return
print("visit", y, x)
grid[y][x] = 2
explore(grid, y-1, x)
explore(grid, y+1, x)
explore(grid, y, x-1)
explore(grid, y, x+1)
grid = [[1,1,1,1,1,1,1,1],
[1,0,0,0,0,0,0,1],
[1,0,1,0,1,1,0,1],
[1,0,1,1,0,0,0,1],
[1,0,0,0,0,1,0,1],
[1,1,1,1,1,1,1,1]]
explore(grid, 1, 1)
for row in grid:
print(row)
Labyrintti on tallennettuna kaksiulotteisena listana, jossa luku 0 tarkoittaa lattiaruutua ja luku 1 tarkoittaa seinäruutua. Funktio explore
suorittaa syvyyshaun labyrintissa niin, että lattiaruutuihin merkitään luku 2.
Funktio explore
tarkastaa ensin, mikä luku annetussa ruudussa on. Jos luku ei ole 0, funktio päättyy heti, koska kyseessä on silloin seinäruutu tai lattiaruutu, jossa on jo käyty aiemmin. Jos luku on 0, funktio jatkuu ja merkitsee ruutuun luvun 2. Tämän jälkeen funktio jatkaa rekursiivisesti hakua ruudun viereisiin ruutuihin ylös, alas, vasemmalle ja oikealle.
Koodin suoritus antaa seuraavan tuloksen:
visit 1 1
visit 2 1
visit 3 1
visit 4 1
visit 4 2
visit 4 3
visit 4 4
visit 3 4
visit 3 5
visit 3 6
visit 2 6
visit 1 6
visit 1 5
visit 1 4
visit 1 3
visit 2 3
visit 1 2
visit 4 6
[1, 1, 1, 1, 1, 1, 1, 1]
[1, 2, 2, 2, 2, 2, 2, 1]
[1, 2, 1, 2, 1, 1, 2, 1]
[1, 2, 1, 1, 2, 2, 2, 1]
[1, 2, 2, 2, 2, 1, 2, 1]
[1, 1, 1, 1, 1, 1, 1, 1]