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]