Pretpostavimo da je zadat skup poslova u vezi sa čijim redosledom izvršavanja postoje neka ograničenja. Neki poslovi zavise od drugih, odnosno ne mogu se započeti pre nego što se ti drugi poslovi završe. Sve zavisnosti su poznate, a cilj je napraviti takav redosled izvršavanja poslova koji zadovoljava sva zadata ograničenja; drugim rečima, traži se redosled izvršavanja za koji važi da svaki posao započinje tek kad su završeni svi poslovi od kojih on zavisi. Na primer, želimo da sagradimo kuću. Da bismo to uspeli potrebno je da postavimo temelj, da sazidamo zidove, da stavimo krov, da uvedemo struju i vodu. Pritom, naravno, nije moguće, na primer, sazidati zidove dok se ne postavi temelj, niti uvesti vodu dok se ne stavi krov na kuću. Zadatak je odrediti neki ispravan redosled izvršavanja ovih poslova.
Dati problem ima primenu u raznim domenima: na primer, za određivanje redosleda u kom je potrebno izvršiti ponovno izračunavanje vrednosti formula u programima za tabelarna izračunavanja, za utvrđivanje redosleda u kom treba izvršiti zadatke u mejkfajlu, za unapređenje paralelizma instrukcija i slično. Zadatak je osmisliti efikasan algoritam za formiranje takvog redosleda.
Opisani problem može se formulisati u terminima grafova i naziva se topološko sortiranje grafa (engl. topological sort). Naime, zadatim poslovima i njihovim međuzavisnostima može se na prirodan način pridružiti usmereni graf \(G=(V,E)\): svakom poslu pridružuje se čvor, a usmerena grana od čvora \(x\) do čvora \(y\) postoji ako se posao \(y\) ne može započeti pre završetka posla \(x\).1 Zadatak je odrediti topološki poredak čvorova, odnosno numeraciju čvorova brojevima od \(1\) do \(n=|V|\) (ili od \(0\) do \(n-1\)) tako da za svaku granu grafa \((u, v)\) važi da je polazni čvor \(u\) grane numerisan manjom vrednošću nego završni \(v\) čvor te grane. Graf nad kojim razmatramo ovaj problem mora biti bez usmerenih ciklusa, jer se u protivnom neki poslovi nikada ne bi mogli započeti.
Problem. U zadatom usmerenom acikličkom grafu \(G=(V,E)\) sa \(n\) čvorova numerisati čvorove brojevima od \(1\) do \(n\), tako da ako je proizvoljan čvor \(v\) numerisan brojem \(k\), onda su svi čvorovi do kojih postoji usmerena grana iz čvora \(v\) numerisani brojevima većim od \(k\).
Primer 2.4.1. Razmotrimo graf prikazan na slici 1.
U njemu postoji samo jedno ispravno topološko uređenje čvorova i to je \(B,A,D,C,E\). Čvor \(D\), recimo, mora da bude numerisan većim brojem od čvorova \(B\) i \(A\) jer postoje grane do \(D\) iz čvorova \(A\) i \(B\). Slično čvor \(D\) mora biti numerisan manjim brojem od čvorova \(C\) i \(E\) jer postoje grane od čvora \(D\) do čvorova \(C\) i \(E\). Dakle, u ovom grafu redni broj čvora \(D\) mora biti 3. Graf sa slike 1 možemo predstaviti i pogodnije, tako da čvorovi budu poređani duž jedne prave uređeni u odnosu na topološki poredak. Tada su sve grane grafa usmerene udesno (slika 2).
U opštem slučaju može postojati veći broj ispravnih topoloških uređenja grafa.
Primer 2.4.2. Ako razmotrimo graf sa slike 3, levo, u njemu postoje dva ispravna topološka uređenja: \(B,A,D,C,E\) i \(B,D,A,C,E\). Oni su prikazani na slici 3, desno.
U narednom apletu možete proveriti svoje razumevanje topološkog redosleda čvorova. Razmislite o tome koji algoritam primenjujete dok određujete topološki redosled.
Razmotrićemo dva različita algoritma za određivanje topološkog uređenja u acikličkom usmerenom grafu: Kanov algoritam (engl. Kahn’s algorithm) i algoritam zasnovan na pretrazi grafa u dubinu.
Prirodno je započeti algoritam tako što uradimo onaj posao koji ne zavisi ni od jednog drugog posla. Nakon toga nam problem postaje jednostavniji, jer neki poslovi koji sa zavisili od tog prvog posla više ne zavise od njega. Dakle, problem rešavamo svođenjem na manji potproblem istog tipa, tj. induktivno-rekurzivnom konstrukcijom.
Prirodna je, dakle, naredna induktivna hipoteza.
Induktivna hipoteza. Umemo da numerišemo na zahtevani način čvorove svih usmerenih acikličkih grafova sa manje od \(n\) čvorova.
Bazni slučaj je slučaj praznog grafa, koji ne sadrži čvorove i on se trivijalno rešava. Kao i obično, posmatrajmo proizvoljni graf sa \(n\) čvorova, uklonimo jedan čvor iz njega, primenimo induktivnu hipotezu na preostale čvorove u grafu i pokušajmo da proširimo numeraciju na polazni graf. Ono što je važno primetiti jeste da imamo slobodu izbora čvora koji uklanjamo pa, kao što smo rekli, biramo čvor sa ulaznim stepenom nula; njemu se može dodeliti broj \(1\). Postavlja se pitanje da li u proizvoljnom usmerenom acikličkom grafu uvek postoji čvor sa ulaznim stepenom nula? Intuitivno se nameće potvrdan odgovor, jer se sa označavanjem negde mora započeti. Sledeća lema potvrđuje ovu činjenicu.
Lema 2.4.1. Usmereni aciklički graf uvek ima čvor ulaznog stepena nula.
Dokaz. Ako bi svi čvorovi grafa imali pozitivne ulazne stepene, mogli bismo da krenemo iz nekog čvora “unazad” prolazeći grane u suprotnom smeru. Međutim, broj čvorova u grafu je konačan, pa se u tom obilasku mora u nekom trenutku naići na neki čvor po drugi put, što znači da u grafu postoji usmereni ciklus. Ovo je suprotno pretpostavci da se radi o acikličkom grafu. Dakle u usmerenom acikličkom grafu uvek postoji čvor čiji je ulazni stepen nula.2 \(\Box\)
Pretpostavimo da smo pronašli čvor čiji je ulazni stepen nula. Numerišimo ga sa \(1\), uklonimo sve grane koje vode iz njega, i numerišimo ostatak grafa (koji je takođe aciklički) brojevima od \(2\) do \(n\): prema induktivnoj hipotezi oni se mogu numerisati brojevima od \(1\) do \(n-1\), a zatim se svaki redni broj može povećati za jedan. Napomenimo još u ovom trenutku da nije neophodno efektivno izbacivati grane iz grafa, jer je to operacija koja se ne izvršava efikasno ako je graf predstavljen listom povezanosti. Naime, odgovarajući efekat moguće je realizovati i efikasnije, smanjivanjem za \(1\) ulaznog stepena čvora u koji data grana ulazi.
Dakle, problem se može rešiti sukcesivnim pronalaženjem čvorova sa ulaznim stepenom \(0\). Pri realizaciji ovog algoritma treba pronalaziti čvor sa ulaznim stepenom nula i ažurirati ulazne stepene čvorova posle uklanjanja grana koje polaze iz datog čvora. Drugi problem možemo rešiti tako što alociramo niz ulazniStepen
dimenzije jednake broju čvorova u grafu i inicijalizujemo ga na vrednosti ulaznih stepena čvorova. Ulazne stepene čvorova u grafu možemo jednostavno odrediti prolaskom kroz skup svih grana u proizvoljnom redosledu i povećavanjem za jedan vrednosti ulazniStepen[w]
svaki put kad se naiđe na neku granu \((v,w)\). Ukoliko je graf zadat listom povezanosti, sve grane su navedene u listi povezanosti kojom je predstavljen i ovaj korak je linearne složenosti po broju grana u grafu. Prilikom uklanjanja čvora \(v\), svim njegovim susedima \(w\) vrednost ulaznog stepena koja se nalazi u nizu na poziciji \(w\) smanjujemo za \(1\) (što je operacija složenosti \(O(1)\)). Svaka grana će biti uklonjena tačno jednom, tako da će ukupna složenost opet biti linearna po broju grana u grafu.
Pronalaženje čvora sa ulaznim stepenom nula može se sprovesti iteracijom kroz niz ulaznih stepena, međutim, složenost tog koraka je linearna u odnosu na broj čvorova, tako da bi ukupna složenost algoritma bila kvadratna. Problem možemo rešiti i efikasnije tako što tokom izvršavanja algoritma održavamo spisak svih čvorova čiji je ulazni stepen \(0\) – primetimo da takvih čvorova u svakom koraku može biti više. Dakle, potrebno je čvorove sa ulaznim stepenom nula čuvati u nekoj kolekciji u koju je efikasno umetati i iz koje je efikasno uklanjati elemente. U ove svrhe može se koristiti red (ili stek, što je jednako dobro). Prema prethodnoj lemi u acikličkom grafu postoji bar jedan čvor sa ulaznim stepenom nula, a pošto je graf sve vreme izvršavanja algoritma aciklički, red će u svakom trenutku, do samog kraja, biti neprazan. Svaki put kada uklonimo i numerišemo čvor \(v\) iz reda, uklanjamo i sve njegove grane \((v,w)\) tako što vrednost ulazniStepen[w]
smanjujemo za jedan. Ako vrednost ulaznog stepena čvora \(w\) pri tome postane nula, čvor \(w\) upisuje se u red, čime se postiže da će se svi čvorovi stepena nula u smanjenom grafu nalaziti u redu (za čvorove koji nisu bili susedni sa \(v\) od ranije znamo da su u redu ako i samo ako im je ulazni stepen bio nula, a taj ulazni stepen se nije promenio nakon uklanjanja čvora \(v\), dok smo za čvorove susedene čvoru \(v\) upravo efektivno proverili da li im je stepen u smanjenom grafu postao nula i stavili smo ih u red ako i samo ako jeste). Algoritam završava sa radom kada red koji sadrži čvorove stepena nula postane prazan, jer su u tom trenutku svi čvorovi numerisani. Opisani algoritam zove se Kanov algoritam, po njegovom autoru Arturu Kanu.
Primer 2.4.3. U tabeli 1 ilustrovano je izvršavanje Kanovog algoritma na primeru grafa sa slike 3. Pretpostavljamo da je graf zadat listama povezanosti, tako da su za svaki čvor njegovi susedi poređani u leksikografski rastućem poretku. Za svaki od koraka algoritma prikazane su trenutne vrednosti ulaznih stepena čvorova grafa, sadržaj reda koji sadrži čvorove ulaznog stepena nula koji još uvek nisu numerisani i poslednji numerisani čvor u grafu. Primetimo da se u drugom koraku moglo desiti da se u red najpre doda čvor \(D\), a zatim čvor \(A\) i u tom slučaju bilo bi dobijeno drugačije topološko uređenje: \(B,D,A,C,E\).
\(d(A)\) | \(d(B)\) | \(d(C)\) | \(d(D)\) | \(d(E)\) | Red | Naredni numerisani čvor |
---|---|---|---|---|---|---|
\(1\) | \(0\) | \(2\) | \(1\) | \(2\) | \(B\) | |
\(0\) | \(2\) | \(0\) | \(2\) | \(A,D\) | \(B:1\) | |
\(1\) | \(2\) | \(D\) | \(A:2\) | |||
\(0\) | \(1\) | \(C\) | \(D:3\) | |||
\(0\) | \(E\) | \(C:4\) | ||||
\(E:5\) |
Naredni aplet ilustruje rad Kanovog algoritma.
Ako nakon završetka rada algoritma za neke čvorove važi da nisu bili dodati u red, to znači da postoji podskup skupa čvorova takav da u odgovarajućem indukovanom podgrafu svi čvorovi imaju ulazni stepen veći od nula. Stoga indukovani podgraf (a time i polazni graf) sadrži usmereni ciklus, suprotno pretpostavci da je graf aciklički.
void topolosko_sortiranje() {
int brojCvorova = listaSuseda.size();
// niz koji cuva ulazne stepene cvorova
int> ulazniStepen(brojCvorova,0);
vector<// niz koji cuva redne brojeve cvorova u topoloskom uredjenju
int> topoloskoUredjenje;
vector<// broj posecenih cvorova
int brojPosecenih = 0;
// inicijalizujemo niz ulaznih stepena cvorova
for (int i = 0; i < listaSuseda.size(); i++)
for (int j = 0; j < listaSuseda[i].size(); j++)
ulazniStepen[listaSuseda[i][j]]++;
// red koji cuva cvorove ulaznog stepena nula
int> cvoroviStepenaNula;
queue<
// cvorove koji su ulaznog stepena 0 dodajemo u red
for (int i = 0; i < brojCvorova; i++)
if (ulazniStepen[i] == 0)
cvoroviStepenaNula.push(i);
while(!cvoroviStepenaNula.empty()) {
// cvor sa pocetka reda numerisemo narednim brojem
int cvor = cvoroviStepenaNula.front();
cvoroviStepenaNula.pop();
topoloskoUredjenje.push_back(cvor);
brojPosecenih++;
// za sve susede tog cvora azuriramo ulazne stepene
for (int i = 0; i < listaSuseda[cvor].size(); i++) {
int sused = listaSuseda[cvor][i];
ulazniStepen[sused]--;// ako je ulazni stepen suseda postao 0, dodajemo ga u red
if (ulazniStepen[sused] == 0)
cvoroviStepenaNula.push(sused);
}
}
// ako smo numerisali sve cvorove u grafu
if (brojPosecenih == brojCvorova) {
// stampamo dobijeno topolosko uredjenje
"Redosled cvorova u topoloskom uredjenju je:" << endl;
cout << for (int i = 0; i < brojCvorova; i++)
": " << i+1 << endl;
cout << topoloskoUredjenje[i] <<
}else
// zakljucujemo da graf sadrzi usmereni ciklus
"Graf nije aciklicki" << endl;
cout <<
}
int main() {
topolosko_sortiranje();return 0;
}
U slučaju kada je graf zadat listama povezanosti vremenska složenost inicijalizacije niza ulazniStepen
je \(O(|V|+|E|)\). U petlji while
(kroz koju se prolazi \(|V|\) puta) za pronalaženje čvora sa ulaznim stepenom nula potrebno je konstantno vreme (pristup redu). Svaka grana \((v,w)\) razmatra se tačno jednom, u petlji kroz koju se prolazi nakon uklanjanja čvora \(v\) iz reda. Prema tome, ukupan broj promena vrednosti elemenata niza ulazniStepen
u svim izvršavanjima spoljašnje petlje while
jednak je broju grana u grafu. Vremenska složenost Kanovog algoritma je dakle \(O(|V|+|E|)\), odnosno linearna je funkcija od veličine grafa.
Zamislimo na trenutak da je graf zadat tako da su mu grane okrenute od poslova koji zavise ka poslovima od kojih zavise i definišimo rekurzivnu proceduru koja obrađuje posao tj. dodeljuje mu redni broj u topološkom redosledu. Da bi posao za koji je funkcija pozvana mogao da bude urađen, potrebno je da se obezbedi da su svi poslovi od kojih on zavisi završeni pre njega, što znači da je potrebno da se obiđe graf krenuvši od tog posla (čvora) i da se urade svi dostupni poslovi (tj. preskoče ako su već ranije urađeni). Kada se to uradi, tek tada tekućem čvoru može biti dodeljen naredni slobodan broj. Primećujemo, dakle, da ovaj algoritam može da vrši klasičan obilazak grafa u dubinu, dodeljujući traženi broj čvoru u sklopu odlazne numeracije.
Dokažimo da se na ovaj način dobija ispravan topološki redosled.
Lema 2.4.2. Za svaku granu \((u, v)\) acikličnog grafa važi \(u.\mathit{Post} > v.\mathit{Post}\).
Dokaz. Kao što smo ranije zaključili u grafu \(G=(V,E)\) važi:
ako je grana \((u,v)\in E\) grana DFS drveta, direktna ili poprečna grana, za nju važi \(u.Post > v.Post\),
ako je grana \((u,v)\in E\) povratna grana u odnosu na DFS drvo, za nju važi \(u.Post \le v.Post\).
Primetimo da ciklus u usmerenom grafu postoji ako i samo ako u DFS drvetu postoje povratne grane. U usmerenom acikličkom grafu ne postoji ciklus, pa ne postoje povratne grane u odnosu na DFS drvo. Dakle, za svaku granu \((u,v)\) acikličkog grafa važi uslov \(u.Post > v.Post\). \(\Box\)
Dakle, ako su grane usmerene od zavisnog ka nezavisnom čvoru, odlazna numeracija daje ispravan topološki redosled. Implementacija je sasvim jednostavna: u klasičnom DFS obilasku prilikom izlazne obrade čvoru treba dodati redni broj u topološkom redosledu tj. dodati ga na kraj niza u kom se čvorovi čuvaju u skladu sa topološkim redosledom. U glavnoj funkciji pokrećemo DFS obilazak svakog čvora (u proizvoljnom redosledu), preskačući čvorove koji su već ranije obrađeni (slično kao kod određivanja komponenata povezanosti).
Vratimo se na standardnu postavku problema u kom su grane grafa orijentisane od nezavisnog ka zavisnom čvoru. Ako sa \(t(x)\) označimo redni broj čvora \(x\) u topološkom poretku grafa \(G\), za svaku granu \((u,v)\) potrebno je da važi \(t(u) < t(v)\). Pošto za svaku granu \((u, v)\) važi \(u.\mathit{Post} > v.\mathit{Post}\), ako čvorove grafa uredimo u opadajućem redosledu u odnosu na odlaznu numeraciju čvorova, dobićemo jedno topološko uređenje grafa. Implementaciju je potrebno izmeniti samo tako što se u odlaznoj obradi čvorovima dodeljuju brojevi unazad, od \(n\) do \(1\). Ako čvorove ne numerišemo, već ih stavljamo u niz u skladu sa topološkim redosledom, oni će u nizu biti složeni naopako i niz je nakon obrade potrebno obrnuti tj. čvorove obrađivati od kraja ka početku tog niza. Alternativno, čvorove možemo postavljati na namenski stek i obrađivati ih u topološkom redosledu tako što ćemo ih jedan po jedan skidati sa tog steka. Moguće je koristiti i neku strukturu podataka koja dopušta efikasno dodavanje elemenata na početak (npr. listu ili red sa dva kraja).
Primer 2.4.4. Razmotrimo graf sa slike 4: on je usmeren i aciklički. Ako pokrenemo DFS pretragu iz čvora \(B\) redosled čvorova u kojima napuštamo čvorove je \(C,D,A,B\). Dakle, topološko uređenje grafa dobijamo obrtanjem ovog redosleda, odnosno redosled čvorova u topološkom poretku biće \(B,A,D,C\). Primetimo da to odgovara i redosledu čvorova sleva nadesno u prikazu grafa kod koga su sve grane usmerene sleva udesno.
Naredni aplet ilustruje topološko sortiranje zasnovano na obilasku grafa u dubinu..
void dfs(int cvor, vector<bool> &posecen, vector<int> &odlazna) {
true;
posecen[cvor] =
// rekurzivno prolazimo kroz sve susede koje nismo obisli
for (int sused : listaSuseda[cvor]) {
if (!posecen[sused])
dfs(sused,posecen,odlazna);
}
// u vektor odlazna dodajemo na kraj naredni cvor
// koji napustamo pri DFS obilasku
odlazna.push_back(cvor);
}
void topolosko_sortiranje() {
int brojCvorova = listaSuseda.size();
bool> posecen(brojCvorova);
vector<// niz koji sadrzi redom cvorove prema redosledu napustanja
int> odlazna;
vector<
for (int cvor = 0; cvor < brojCvorova; cvor++)
if (!posecen[cvor])
dfs(cvor,posecen,odlazna);
// cvorove ispisujemo u opadajućem redosledu odlazne numeracije
"Redosled cvorova u topoloskom uredjenju je:" << endl;
cout << for (int i = brojCvorova - 1; i >= 0; i--)
": " << brojCvorova - i << endl;
cout << odlazna[i] <<
}
int main() {
topolosko_sortiranje();return 0;
}
S obzirom na to da se prikazani algoritam svodi na DFS pretragu i određivanje odlazne numeracije čvorova, njegova vremenska složenost iznosi \(O(|E|+|V|)\).
Dakle, grane vode od posla koji je nezavisan, ka poslu koji je zavisan. Moguće je formirati graf tako da grane vode od zavisnog ka nezavisnom poslu. Različitim algoritmima odgovaraju različite organizacije grana, pa prilikom konstrukcije algoritma za topološko sortiranje treba obratiti pažnju na to kako su grane usmerene.↩︎
Analogno se pokazuje da u usmerenom acikličkom grafu uvek postoji čvor izlaznog stepena nula.↩︎