Tietorakenteet ja algoritmit

syksy 2023

5. Järjestäminen

Järjestäminen (sorting) on klassinen algoritmiikan ongelma, jossa lista alkioita tulee järjestää suuruusjärjestykseen. Moderneissa ohjelmointikielissä on valmiita tapoja järjestämiseen, kuten Pythonin metodi sort ja funktio sorted.

Järjestämiseen on olemassa tehokkaita algoritmeja, joiden aikavaativuus on \(O(n \log n)\). Tämän ansiosta järjestämistä voidaan käyttää osana tehokasta algoritmia. Usein ideana on järjestää ensin algoritmille annetut tiedot ja käsitellä niitä sitten järjestystä hyödyntäen.

Järjestäminen Pythonissa

Pythonissa listassa on metodi sort, joka järjestää sen sisällön:

numbers = [4, 2, 1, 2, 5, 4, 5, 2]
numbers.sort()
print(numbers) # [1, 2, 2, 2, 4, 4, 5, 5]

Pythonissa on myös funktio sorted, joka palauttaa listan järjestettynä:

numbers = [4, 2, 1, 2, 5, 4, 5, 2]
print(sorted(numbers)) # [1, 2, 2, 2, 4, 4, 5, 5]

Näissä tavoissa erona on, että metodi sort muuttaa olemassa olevaa listaa, kun taas funktio sorted palauttaa uuden listan.

Metodi sort ja funktio sorted on toteutettu tehokkaasti, ja niiden aikavaativuus on \(O(n \log n)\).

Esimerkki: Pienin ero

Tehtävä

Annettuna on lista lukuja. Mikä on pienin ero kahden luvun välillä?

Esimerkiksi kun lista on \([25,1,11,19,33,13,4,40]\), haluttu vastaus on \(2\), koska pienin ero on lukujen \(11\) ja \(13\) välillä.

Voimme ratkaista tämän tehtävän tehokkaasti järjestämisen avulla, koska järjestämisen jälkeen listassa ovat vierekkäin luvut, joiden välillä on pienin ero. Tämän ansiosta pienin ero voidaan löytää helposti käymällä läpi lista vasemmalta oikealle ja vertaamalla joka kohdassa vierekkäisiä lukuja.

Yllä olevassa esimerkissä lista järjestettynä on \([1,4,11,13,19,25,33,40]\) ja luvut \(11\) ja \(13\) ovat vierekkäin.

Seuraava funktio min_diff toteuttaa algoritmin:

def min_diff(numbers):
    numbers = sorted(numbers)
    n = len(numbers)
    result = numbers[1] - numbers[0]
    for i in range(2, n):
        result = min(result, numbers[i] - numbers[i - 1])
    return result

Algoritmi järjestää ensin listan ajassa \(O(n \log n)\) ja käy sitten läpi kaikki vierekkäiset luvut silmukalla ajassa \(O(n)\), joten algoritmin aikavaativuus on \(O(n \log n)\).

Huomaa, että voisimme käyttää funktion alussa myös listan metodia sort:

def min_diff(numbers):
    numbers.sort()
    ...

Tässä on kuitenkin ongelmana, että parametrina annetun listan järjestäminen heijastuu 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 = [25, 1, 11, 19, 33, 13, 4, 40]
print(min_diff(numbers)) # 2
print(numbers) # [1, 4, 11, 13, 19, 25, 33, 40]

Käytössä sorted

numbers = [25, 1, 11, 19, 33, 13, 4, 40]
print(min_diff(numbers)) # 2
print(numbers) # [25, 1, 11, 19, 33, 13, 4, 40]

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.

Yleensä hyvä valinta on käyttää funktiota sorted, jolloin järjestäminen ei aiheuta sivuvaikutuksia.

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, n):
        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 seuraavaksi 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 tehokkuuteen, luodaan lista niin, että jokainen luku on satunnainen luku väliltä \(1 \dots k\). Tämän avulla voimme tutkia, miten \(k\):n muuttuminen 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

Tämän ansiosta 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 = sorted(events)
    
    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 ajan 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 (insertion sort), joka käy listan läpi vasemmalta oikealle ja siirtää kunkin alkion oikeaan kohtaan listan alkuosassa.

Vain vierekkäisiä alkioita käsittelevä järjestämisalgoritmi ei voi koskaan olla tehokas, koska tarvittava vaihtojen määrä voi olla suuri. Esimerkiksi jos lista on käänteisessä järjestyksessä, tarvitaan luokkaa \(n^2\) vaihtoa. Yleisemmin tarvittava vierekkäisten alkioiden vaihtojen määrä on yhtä suuri kuin listan inversioiden määrä. Inversio tarkoittaa listalla olevaa alkioparia, jotka ovat väärässä järjestyksessä.

Järjestämiseen on olemassa myös tehokkaita algoritmeja, joiden aikavaativuus on \(O(n \log n)\). Yksi tällainen algoritmi on lomitusjärjestäminen (merge sort), joka järjestää ensin listan vasemman ja oikean puoliskon erikseen ja yhdistää sitten järjestetyt puoliskot kokonaiseksi järjestetyksi listaksi. Algoritmi toimii rekursiivisesti niin, että se järjestää myös puoliskot vastaavalla tavalla, jne.

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.

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

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ämisen alaraja

On mahdollista osoittaa, että \(O(n \log n)\) on paras mahdollinen aikavaativuus yleiskäyttöiselle järjestämisalgoritmille. Tässä oletuksena on, että algoritmi vertailee alkioita toisiinsa ja muuttaa sen perusteella niiden järjestystä.

Tarkastellaan tilannetta, jossa algoritmille annetaan \(n\) alkion lista, jossa jokainen luku väliltä \(1 \dots n\) esiintyy tasan kerran. Listan alkiot voivat olla \(n!\) eri järjestyksessä, ja kussakin tapauksessa algoritmin tulee toimia eri tavalla, jotta lista tulee järjestykseen.

Kun algoritmi vertailee kahta alkiota toisiinsa, tämä antaa algoritmille tietoa, jota se voi käyttää järjestämisessä. Tarkastellaan tilannetta, jossa \(n=3\) ja algoritmi ei ole vielä tehnyt vertailuja. Koska algoritmilla ei ole vielä tietoa listasta, algoritmin näkökulmasta lista voi olla mikä tahansa seuraavista:

\[[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]\]

Oletetaan sitten, että algoritmi vertailee listan kahta ensimmäistä alkiota toisiinsa ja saa tietää, että ensimmäinen alkio on suurempi kuin toinen alkio. Tämän perusteella algoritmi pystyy rajaamaan tilannetta niin, että lista on jokin seuraavista:

\[[2,1,3], [3,1,2], [3,2,1]\]

Jotta algoritmi toimii oikein, sen täytyy päästä vertailuja tekemällä tilanteeseen, jossa on vain yksi mahdollinen lista ja algoritmi voi järjestää sen oikealla tavalla. Listojen määrä alussa on \(n!\) ja jokaisen vertailun jälkeen ainakin puolet listoista voi jäädä jäljelle. Niinpä tarvitaan ainakin \(\log_2(n!)\) vertailua, ennen kuin algoritmi on saanut varmasti riittävästi tietoa listasta. Tästä saadaan alaraja

\[\log_2(n!) = \log_2(1)+\log_2(2)+\dots+\log_2(n) \ge (n/2) \log_2(n/2)\]

eli algoritmin täytyy tehdä luokkaa \(n \log n\) vertailua.

Huomaa, että järjestäminen voi olla mahdollista toteuttaa tehokkaammin, jos sen riittää toimia vain tietyn tyyppisillä alkioilla. Jos esimerkiksi alkiot ovat pieniä kokonaislukuja, järjestäminen on mahdollista toteuttaa ajassa \(O(n)\). Yksi tällainen algoritmi on laskemisjärjestäminen (counting sort).

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 = [1, 2, 3, 11, 12, 13];
numbers.sort();
console.log(numbers); // [1, 11, 12, 13, 2, 3]

Syynä tähän on, että metodi sort käsittelee alkioita oletuksena merkkijonoina, minkä vuoksi esimerkiksi 11 on pienempi kuin 2. Metodille voi kuitenkin antaa seuraavasti vertailufunktion lukujen järjestämistä varten:

numbers = [1, 2, 3, 11, 12, 13];
numbers.sort((a, b) => a - b);
console.log(numbers); // [1, 2, 3, 11, 12, 13]