Algoritmusok és adatszerkezetek jegyzet [PDF]

  • 0 0 0
  • Gefällt Ihnen dieses papier und der download? Sie können Ihre eigene PDF-Datei in wenigen Minuten kostenlos online veröffentlichen! Anmelden
Datei wird geladen, bitte warten...
Zitiervorschau

ALGORITMUSOK ÉS ADATSZERKEZETEK Jegyzet az egyetemi informatikus alapképzéshez

Szerkesztők:

F EKETE I STVÁN ÉS H UNYADVÁRI L ÁSZLÓ A fejezetek szerzői: F E K E TE I S TV ÁN : H UN Y AD V Á RI L ÁS Z L Ó : 9., 10., 35. fejezet F E K E TE I S TV ÁN

ÉS

N A G Y T I BO R

G I A C H E T T A R O B E R T O : 22–30. fejezet

ÉS

H U N Y AD V Á RI L ÁS Z L Ó :

BARTHA DÉNES

É S I L O N CZ AI

F E K E TE I S TV ÁN

ÉS

Z S O L T : 31–34. fejezet

D A N Y L UK T A M ÁS :

Az ábrákat készítették: N A G Y T I BO R , O RG O V Á N K RI S Z TI N A

ÉS

F E K E TE I S T VÁ N

Készült az Európai Szociális Alap társfinanszírozásával a Társadalmi Megújulás Operatív Program "ELTE – PPKE informatika tananyagfejlesztési projekt" pályázatának (TÁMOP-4.1.2.A/1-11/1-2011-0052) támogatásával.

ELTE Informatikai Kar Budapest, 2014.

TARTALOM I. HATÉKONYSÁG ÉS ABSZTRAKCIÓ 1. Algoritmusok műveletigénye 2. Az adattípus absztrakciós szintjei II. ALAPVETŐ ADATSZERKEZETEK 3. Tömb 4. Verem 5. Sor 6. Listák 7. Bináris fa 8. Elsőbbségi sor III. KIVÁLASZTÁSOK 9. Maximum és szimultán minimum-maximum kiválasztás 10. Medián és k-adik elem kiválasztás IV. KERESŐFÁK 11. Bináris keresőfák 12. AVL fák 13. 2-3 fák és B-fák V. ÖSSZEHASONLÍTÓ RENDEZÉSEK 14. A buborék, beszúró és maximum kiválasztó rendezés 15. Verseny rendezés 16. Kupacrendezés 17. Gyorsrendezés 18. Összefésülő rendezés 19. Az összehasonlító rendezések alsókorlát-elemzése VI. HASÍTÁSOS TECHNIKÁK ALKALMAZÁSAI 20. Hasítás 21. Edényrendezések VII. GRÁFALGORITMUSOK 22. Alapfogalmak, gráfok ábrázolásai 23. Szélességi bejárás 24. Minimális költségű utak egy forrásból I. 25. Minimális költségű utak egy forrásból II. 26. Minimális költségű utak minden csúcspárra 27. Minimális költségű feszítőfák 28. Mélységi bejárás, élek osztályozása 29. DAG topologikus rendezése 30. Erősen összefüggő komponensek VIII. MINTAILLESZTÉS (STRING KERESÉS) 31. Egyszerű mintaillesztés 32. Knuth-Morris-Pratt algoritmus 33. Gyorskeresés (Boyer-Moore algoritmus) 34. Rabin-Karp algoritmus 35. Mintaillesztés automatával

ELŐSZÓ Az egyetemi informatikus képzések tantervében a világ országaiban mindenütt megtalálható az a kurzus, amely az alapvető adatstruktúrák és algoritmusok ismertetésére vállalkozik. Az Eötvös Loránd Tudományegyetemen folyó Programtervező informatikus alapképzésben az Algoritmusok és adatszerkezetek című két féléves tantárgy keretében sajátíthatják el a hallgatók a témakör ismereteit. Az Alkalmazott matematikus szakirányon az Algoritmusok tervezése és elemzése című, ugyancsak két féléves, az előzőhöz hasonló tematikájú tantárgy valósítja meg ezt a képzési célt. A jegyzet, amelyet az Olvasó megnyitott, az informatikus képzéshez illeszkedően az Algoritmusok és adatszerkezetek címet viseli, azonban a matematikus alapképzés céljaihoz is illeszkedik. A jegyzet két szerkesztője Fekete István és Hunyadvári László (akik egyben a legtöbb fejezet szerzői is) évek óta tartják az említett két előadást. A két kurzus egymáshoz képest „keresztfélévben” kerül meghirdetésre, ami azért is lényeges, mert a két tantárgy hivatalosan is kredit-ekvivalens, így a hallgatóknak mindkét félévben alkalmuk nyílik a kezdésre, illetve a hosszabb szünet nélküli folytatásra. Az elkészült harmincöt fejezet nyolc nagyobb témakörbe sorolható be, amelyek a műveletigényről és az adatabsztrakcióról szóló két bevezető fejezet után sorrendben a következők: alapvető adatszerkezetek, kiválasztások, keresőfák, rendezések, hasítás és edényrendezés, gráfalgoritmusok és a mintaillesztés. Ez így együtt valamivel több is, mint egy két féléves kurzus aktuális tananyaga, másrészt jelenleg az előadásokon szereplő adattömörítés fejezetei még nem készültek el. A pótlással együtt újabb fejezetek (pl. pirosfekete fák), illetve újabb témakörök (pl. geometriai algoritmusok, kriptográfia alapjai) is megjelenhetnek az anyagban. A nagyobb témakörök és a fejezetek megválasztásában és megírásában a szerzők tekintettel voltak az ELTE informatikus képzésének többi tantárgyára, a programozás, a formális nyelvek, a logika, a számítástudomány és a haladó algoritmusok kurzusaira. A tárgyalásmód erős kölcsönös kapcsolatban áll a programozás oktatásával. A jegyzet támaszkodik az onnan jövő előzményekre: pl. az algoritmusok leírása „dobozos ábrákkal” (struktogram), az alapvető, sokszor előforduló kisebb feladatok általános megoldásai (pl. maximum kiválasztás, adott tulajdonságú elemek megszámolása), vagy az elő-, utófeltételes specifikáció. Másrészt azonban, az alapvető adatszerkezetek és reprezentációik ismertetését magára vállalja a jegyzet, ami a legtöbb oktatásban csak a programozási tárgyak feladata (mint pl. a tömb, verem, sor, listák esetében). A logika, valamint a formális nyelvek és automaták kurzusaira is alapozhattak a szerzők (pl. axiomatikus specifikáció, mintaillesztés automatával). Az önálló tantárgyként jelenlévő számításelméletre való tekintettel, az anyag nem tartalmaz bonyolultság-elméleti fejezeteket (Turing-gép, NP-teljesség). Az ELTE informatikus képzésének MSc szintjén szerepel egy két féléves haladó algoritmusok kurzus is. Ennek tematikájában megjelennek a fontos algoritmustervezési módszerek (oszd meg és uralkodj elve, mohó módszer, matroidok, dinamikus programozás, korlátozás és szétválasztás, visszalépéses és más keresések, véletlent használó módszerek, közelítő algoritmusok stb.). Ezek a módszerek ennélfogva nem szerepelnek a jegyzet anyagban (elméleti részletességű és alapos ismertetésüknek egyébként sem a BSc szint a legmegfelelőbb helye).

A jegyzet két speciális bevezető fejezettel indul. Az első fejezet példákon keresztül mutatja be az algoritmusok műveletigényének elemzését és az „ordó matematikájának” tömör összefoglalását is tartalmazza. A második fejezet az adattípus absztrakciós szintjeiről szól. A két meghatározó eszköznek a következetes alkalmazása végig vonul a jegyzet fejezetein. A fejezetek teljes anyaga több forrásból állt össze, mivel a szerzők a korábbi oktatási segédanyagokat is felhasználták munkájuk során. A gráfalgoritmusok, valamint a mintaillesztés fejezetei külön stílus- és ábravilágot képviselnek a többihez képest. Három elméletibb látásmódú (a 9-10. és 35.) fejezettel még egy negyedik szín is megjelenik. Távolabbi célként fogalmazható meg a négy eltérő stílusú rész közelítése egymáshoz. A teljes anyag megírásában Fekete István és Hunyadvári László mellett többen is részt vettek. A gráfalgoritmusokról szóló korábbi oktatási segédanyag szerzője Nagy Tibor, aki most a jegyzet számára, Giachetta Roberto együttműködésével átdolgozta ezt a nagyobb önálló témakört. A mintaillesztő algoritmusokhoz Sike Sándor írt korábban az oktatást támogató anyagot, amelyet most a jegyzet rendelkezésére bocsátott. Ezt a négy fejezetet Bartha Dénes és Ilonczai Zsolt dolgozták át. Két fejezet kidolgozásában Danyluk Tamás végzett értékes munkát. A jegyzetnek talán szembeötlő jellemzője az, hogy nagyszámú ábra illusztrálja a szöveges leírást. Összesen 275 számozott és címmel ellátott ábra szerepel a fejezetekben. Egy ábra gyakran több megrajzolt részből tevődik össze. Az elkészített ábrák száma jó közelítéssel négyszázra tehető. Az ábrák egy része az algoritmusok leírását tartalmazza „dobozos” struktogramok formájában. Az ábrák nagyobb hányada azonban az algoritmusok működését szemlélteti, alapvető adatszerkezeteik állapotáról készült „pillanatfelvételek” formájában. Az ábrák döntő többségét Nagy Tibor és Orgován Krisztina készítette. Az Algoritmusok és adatszerkezet, valamint az Algoritmusok tervezése és elemzése előadásokhoz, mindkét szakon gyakorlatok is tartoznak. Az előadók és gyakorlatvezetők együttműködő oktatói közössége az évek során hatékony szakmai hátteret nyújtott a tananyagfejlesztéshez. Az utóbbi években a gyakorlatokat Ásványi Tibor, Giachetta Roberto, Izsák Rudolf, Kőhegyi János, Nagy Sára, Tichler Krisztián és Veszprémi Anna oktató kollégák, valamint Danyluk Tamás, Kovács Péter, Máriás Zsigmond, Nagy Tibor és Tamaga István megbízott gyakorlatvezetők tartották. Az elkészült jegyzetben mindannyiuk munkája megjelenik. A szerzők köszönetet mondanak Friedl Katalinnak a BME Számítástudományi és Információelméleti Tanszéke egyetemi docensének, aki a kézirat lektoraként, javító észrevételeivel és értékes tanácsaival jelentős mértékben hozzájárult jegyzet színvonalának emeléséhez. Budapest, 2014. április 30. Fekete István és Hunyadvári László Algoritmusok és Alkalmazásaik Tanszék ELTE Informatikai Kar

1. ALGORITMUSOK MŰVELETIGÉNYE Az ismertetésre kerülő adatszerkezeteket és algoritmusokat mindig jellemezzük majd a hatékonyság szempontjából. Az adatszerkezetek egyes ábrázolásairól megállapítjuk a helyfoglalásukat, az algoritmusoknál pedig a műveletigényt becsüljük, mindkettőt az input adatok méretének függvényében. Általában megelégszünk mindkét adat nagyságrendben közelítő értékével. A nagyságrendi értékek közelítő ereje annál nagyobb, minél nagyobb méretű adatokra értelmezzük azokat. Amint látjuk majd, egy sajátos matematikai határértékfogalmat vezetünk be és alkalmazunk a hatékonyságra irányuló számításainkban. A műveletigény számításakor eleve azzal a közelítéssel élünk, hogy csak az algoritmus meghatározó műveleteit vesszük számításba. Egy műveletet meghatározónak (dominánsnak) mondunk, ha a többihez képest jelentős a végrehajtási ideje, valamint a végrehajtásainak száma. Általában kijelölhető egyetlen meghatározó művelet, amelyre a számítást elvégezzük. A műveletigényt a kiszemelt művelet végrehajtásainak számával adjuk meg, mivel az egyes műveletek végrehajtási ideje gépről-gépre változhat. A lépésszám közelítéssel kiszámolt nagyságrendje – gyakorlati tapasztalatok szerint is – jól jellemzi az algoritmus tényleges futási idejét. 1.1. A buborékrendezés műveletigénye A rendezés általános feladata jól ismert. A tömbökre megfogalmazott változat egyik legkorábbi (kevéssé hatékony) megoldása a buborékrendezés. Az eljárás működésének alapja a szomszédos elemek cseréje, amennyiben az elől álló nagyobb, mint az utána következő. Az első menetben a szomszédos elemek cseréjével „felbuborékoltatjuk” a legnagyobb elemet a tömb végére. A következő iterációs lépésben ugyanezt tesszük az „eggyel rövidebb” tömbben, és így tovább. Utoljára még a tömb első két elemét is megcseréljük, ha szükséges. Az (n–1)edik iteráció végére elérjük, hogy az elemek nagyság szerint növekvő sorban követik egymást. A rendezés algoritmusa az 1.1. ábrán látható. (Az egyszerűség kedvéért a Csere műveletét nem bontottuk három értékadásra.)

1.1. ábra. A buborékrendezés algoritmusa 1

Az algoritmus műveleteit két kategóriába sorolhatjuk. Az egyikbe tartoznak a ciklusváltozókra vonatkozó műveletek, a másikba pedig az A[1..n] tömb szomszédos elemeinek összehasonlítása és cseréje. Nyilvánvaló, hogy az utóbbiak a meghatározó műveletek. Egyrészt végrehajtásuk lényegesen nagyobb időt vesz igénybe, mint a ciklust adminisztráló utasításoké (különösen, ha a tömb elemeinek mérete jelentős), másrészt mindkét utasítás a belső ciklusban van (még ha a csere feltételhez kötött is), így végrehajtásukra minden iterációs lépésben sor kerülhet. Ezért az összehasonlítások, illetve a cserék számát fogjuk számolni. Ha ezt a döntést meghoztuk, érdemes az algoritmusban szereplő ciklusokat tömörebb, áttekinthetőbb formában, for-ciklusként újra felírni. Ez a változat látható az 1.2. ábrán.

1.2. ábra. Buborékrendezés (for-ciklusokkal)

A számolást az egyszerűség kedvéért külön-külön végezzük. Nézzük először az összehasonlítások Ö(n)-nel jelölt számát. A külső ciklus magja (n–1)-szer hajtódik végre, a belső ciklus ennek megfelelően rendre n–1, n–2, …, 1 iterációt eredményez. Mivel a két szomszédos elem összehasonlítása a belső ciklusnak olyan utasítása, amely mindig végrehajtódik, ezért

Ö(n)  (n  1)  (n  2)   1  n(n  1) 2  n 2 2  n 2 . Az 1.3. pontban precízen bevezetjük majd azokat az aszimptotikus fogalmakat és jelöléseket, amelyekkel a műveletigény nagyságrendjét jellemezzük. Most elégedjünk meg annyival, hogy általában elegendő az, ha csak a kifejezés domináns nagyságrendű tagját tartjuk meg, az együttható elhagyásával. Az összehasonlítások számára ennek megfelelően azt mondjuk majd, hogy értéke nagyságrendben n2 és ezt így jelöljük:

Ö(n)  (n 2 ). Megjegyezzük, hogy az összehasonlítások száma a bemenő adatok bármely permutációjára ugyanannyi. Az elemzett műveletek végrehajtási száma a legtöbbször azonban változó az adatok függvényében. Ilyenkor elsősorban a végrehajtások maximális és átlagos számára vagyunk kíváncsiak, de a minimális végrehajtási számot is gyakran meghatározzuk, bár ennek általában nincs jelentősége. Ha T(n)-nel jelöljük a műveletigényt, akkor annak minimális, maximális és átlagos értékét rendre mT(n), MT(n) és AT(n) jelöli. Vizsgáljuk meg most a cserék Cs(n)-nel jelölt számát. Ez a szám már nem állandó, hanem a bemenő adatok függvénye. Nevezetesen, a cserék száma megegyezik az A[1..n] tömb elemei között fennálló inverziók számával: Cs(n)  inv( A).

Valóban, minden csere pontosan egy inverziót szüntet meg a két szomszédos elem között, újat viszont nem hoz létre. A rendezett tömbben pedig nincs inverzió. Ha a tömb eleve rendezett, 2

akkor egyetlen cserét sem kell végrehajtani, így a cserék száma a legkedvezőbb esetben nulla, azaz mCs(n)  0.

A legtöbb cserét akkor kell végrehajtani, ha minden szomszédos elempár inverzióban áll, azaz akkor, ha a tömb éppen fordítva, nagyság szerint csökkenő módon rendezett. Ekkor

MCs(n)  n(n  1) 2  (n 2 ). A cserék átlagos számának meghatározásához először is feltesszük, hogy a rendezendő számok minden permutációja egyformán valószínű. (Az átlagos műveletigény számításához mindig ismerni kell a bemenő adatok valószínűségi eloszlását, vagy legalább is feltételezéssel kell élni arra nézve!) Az általánosság megszorítása nélkül vehetjük úgy, hogy az 1, 2, ..., n számokat kell rendeznünk, ha elfogadjuk azt a szokásos egyszerűsítést, hogy a rendezendő értékek mind különbözők. A cserék számának átlagát nyilvánvalóan úgy kapjuk, hogy az 1, 2, ..., n elemek minden permutációjának inverziószámát összeadjuk és osztjuk a permutációk számával:

ACs(n) 

1 inv( p), n! pPerm( n)

ahol Perm(n) az n elem összes permutációinak halmazát jelöli. Az összeg meghatározásánál nehézségbe ütközünk, ha megpróbáljuk azt megmondani, hogy adott n-re hány olyan permutáció van, amelyben i számú inverzió található ( 0  i  n(n  1) 2 ). Ehelyett célszerű párosítani a permutációkat úgy, hogy mindegyikkel párba állítjuk az inverzét, pl. az p = 1423 és a pR = 3241 alkot egy ilyen párt. Egy ilyen párban az inverziók száma együtt éppen a lehetséges n2 -t teszi ki, pl. inv(1423)  inv(3241)  4  2  6. Az állítás igazolására gondoljuk



meg, hogy egy permutációban két elem pontosan akkor áll inverzióban, hogy ha az inverz permutációban nincs közöttük inverzió. Írjuk le gondolatban mind az n! permutációt kétszer egymás mellé egy-egy oszlopba, a mondott párosításnak megfelelően. Ekkor minden sorban a két permutáció inverzióinak száma együtt n2 , így



 n  n n!     inv( p)   inv( p )   2    2   n(n  1)  (n 2 ). ACs(n)  2n! 2n! 2 4 R

A cserék számának átlaga tehát a legnagyobb érték fele, de nagyságrendben ez így is n2-es. Az elemzés eredménye az, hogy a buborékrendezés a legrosszabb és az átlagos esetben is – mindkét meghatározó művelete szerint – nagyságrendben n2-es algoritmus. 1.2. A Hanoi tornyai probléma megoldásának műveletigénye A következő algoritmus, amelyet elemzünk, a Hanoi tornyai probléma megoldása. A probléma régről ismert. Adott három rúd és az elsőn n darab, felfelé egyre csökkenő méretű korong, ahogyan az 1.3. ábrán látható n = 4 esetére. A feladat az, hogy a korongokat át kell helyezni az A rúdról a B-re, a C rúd felhasználásával, oly módon, hogy egyszerre csak egy korongot szabad mozgatni és csak nála nagyobb korongra, vagy üres rúdra lehet áthelyezni.

3

A feladatnak több különböző megoldása van, köztük olyan (iteratív) heurisztikus algoritmusok, amelyek a mesterséges intelligencia területére tartoznak. Itt a szokásos rekurzív algoritmust ismertetjük.

1.3. ábra. Hanoi tornyai probléma

Számítógépes programok, algoritmusok esetén akkor beszélünk rekurzióról, hogyha az adott eljárás a működése során „meghívja önmagát”, vagyis a számítás egyik lépéseként önmagát hajtja végre, más bemeneti adatokkal, paraméterekkel. Sok hasznos algoritmus rekurzív szerkezetű, és többnyire az ún. „oszd meg és uralkodj” elv alapján működnek. Ennek a lényege az, hogy a feladatot több részfeladatra bontjuk, amelyek egyenként az eredetihez nagyon hasonlóak, de kisebb méretűek, így könnyebben megoldhatók. A definiált működésnek az lesz a hatása, hogy a részfeladatokat hasonlóan, további egyre kisebb és kisebb részekre osztja az eljárás, amíg el nem éri az elemei feladatok megadott méretét. A már kellően kicsi részfeladatokat közvetlen módon megoldjuk. Ez az elemi lépés gyakran egészen egyszerű. Ezután a rekurzív hívások rendszerében „visszafelé” haladva minden lépésben összegezzük, összevonjuk a részfeladatok megoldását. Az utolsó lépésben, amikor a működés visszaér a kiinduló szintre, megkapjuk az eredeti feladat megoldását. A módszer általános elnevezése onnan ered, hogy a rekurzió minden szintjén három alapvető lépést hajt végre:  felosztja a feladatot több részfeladatra,  „uralkodik” a részfeladatokon, rekurzív módon való megoldásukkal; amennyiben a feladat mérete kellően kicsiny, közvetlenül megoldja,  összevonja a részfeladatok megoldását az eredeti feladat megoldásává. Ennek az általános elvnek megfelelően a Hanoi tornyai probléma rekurzív megoldási elve a következő: 1. A felső n – 1 korongot helyezzük át az A rúdról a C-re a megengedett lépésekkel úgy, hogy a B rudat vesszük segítségül. (Ez az eredetihez hasonló, de kisebb méretű feladat.) 2. Az egyedül maradt alsó korongot tegyük át az A rúdról az üres B-re. (Ez az elemi méretű feladat közvetlen megoldása.) 3. Vigyük át a C rúdon található n – 1 korongot a B-re az A rúd igénybe vételével, hasonlóan ahhoz, ahogyan az 1. pontban eljártunk. A rekurzív eljárás működése 1. és 3. lépésben szereplő n – 1 korongot hasonló szemlélettel n – 2 korong kétszeri mozgatására, valamint egy korong áthelyezésére bontja, és így tovább. 4

A rekurzióból való "kijárat" megfogalmazása természetes módon egyetlen korongra adódna: ha n = 1, akkor egyszerűen tegyük át a korongot a rendeltetési helyére. Egyszerűbb formájú algoritmust kapunk, amelyet elemezni is könnyebb, ha "még lejjebb" adjuk meg a kijáratot a rekurzióból: ha n = 0, akkor nem kell semmit sem tennünk. Erre az ad lehetőséget, hogy így az n = 1 eset is kezelhető a fenti három lépéssel: az 1. és a 3. lépés üressé válik, a 2. lépés pedig tartalmazza az elemi áthelyezés műveletét. Általános szabályként jegyezzük meg azt, hogy a rekurzióból való kijáratot „engedjük minél lejjebb”, azaz a szóban forgó változó minél kisebb értékére próbáljuk azt megadni (pl. a kínálkozó n = 1 helyett az n = 0 esetre). Arra törekszünk tehát, hogy minél kisebb legyen az a részfeladat, amelyet már közvetlenül oldunk meg. Írjuk meg ezután az eljárást! A Hanoi (n, i, j, k) rekurzív eljárás n számú korongot átvisz az i-edik rúdról a j-edikre, a k-adik rúd felhasználásával. Az eljárást "kívülről" a konkrét feladatnak megfelelő paraméterekkel kell meghívni, pl. az ábrán látható esetben így: Hanoi (4, 1, 2, 3). Az eljárás struktogramja a 1.4. ábrán látható. A rekurzív eljárás önmagát hívja egészen addig, amíg n nagyobb nullánál. Amikor n = 1, akkor is ez történik, de a rekurzív hívások végrehajtása az n = 0 ágon már egy-egy üres utasítást eredményez. Az Átrak (i, j) utasítás jelöli azt az elemi műveletet, amellyel egyetlen korongot átteszünk az iedik rúdról a j-edikre.

1.4.ábra. A Hanoi tornyai probléma megoldása

Meghatározzuk az n korong átpakolásához szükséges lépésszámot. Az algoritmus rekurzív megfogalmazása szinte kínálja az átrakások számára vonatkozó rekurzív egyenletet:

2T (n  1)  1 , ha n  1, T ( n)   , ha n  0. 0 Ha kiszámítjuk T(n) értékét néhány n-re, akkor azt sejthetjük, hogy T (n)  2n  1. Ezt teljes indukcióval könnyen bebizonyíthatjuk. Azt kaptuk, hogy a Hanoi tornyai egy exponenciális műveletigényű probléma: T (n)  (2n ) . (A legenda szerint Indiában, egy ősi város templomában a szerzetesek ezt a feladatot 64 korongra kezdték el valamikor megoldani azzal, hogy amikor a rakosgatás végére érnek, elkövetkezik a világ vége. Ha minden korongot 1 mp alatt helyeznek át, akkor a 64 korong teljes átpakolása nagyságrendben 600 milliárd évet venne igénybe. Ha 106 mp-cel számolunk, ami már a számítógépek sebessége, akkor is nem egészen 600 ezer évre lenne szükség a korongok átrendezéséhez. A nagyságrendek érzékeltetésére: az univerzumról úgy tudjuk, hogy kevesebb, mint 14 milliárd éves, a másodiknak kiszámolt időtartam pedig a homo sapiens megjelenése óta eltelt idővel egyezik meg nagyságrendben.) 5

1.3. Függvények aszimptotikus viselkedése* (Azt javasoljuk, hogy az olvasó ne tekintse befejezettnek alapszintű tanulmányait az algoritmusok témakörében, amíg ezzel az alfejezettel meg nem ismerkedett! Célszerű most átolvasni ezt a *-gal jelölt 1.3. részt, majd később – talán többször is – visszatérni ide a mélyebb megértés céljával.) Kiszámítottuk két algoritmus műveletigényét a bemenő adatok méretének függvényében. Szeretnénk pontosabb matematikai fogalmakra támaszkodva beszélni az algoritmusok hatékonyságáról. Ebben a pontban ennek megalapozása történik. Először is alkalmasan megválasztjuk az algoritmusok műveletigényét, illetve lépésszámát jelentő függvények értelmezési tartományát és értékkészletét. A továbbiakban legyen f olyan függvény, amelyet a természetes számok halmazán értelmezünk és nem-negatív valós értékeket vesz fel:

f : N  R 0 , ahol N  {0,1,2,} . Definiáljuk egy adott g függvény esetén azon függvények osztályait, amelyek nagyságrendben rendre nem nagyobbak, kisebbek, nem kisebbek, nagyobbak, mint g, illetve g-vel azonos nagyságrendűek. Az osztályok jelölései és nevei: O „nagy ordó” vagy „ordó”,  ”kis ordó”,  „nagy omega”,  „kis omega” és  theta. 1.3.1. Definíciók 1. Egy adott g függvény esetén O(g)-vel jelöljük függvényeknek azt a halmazát, amelyre

O( g)  { f : létezik c  0 és n0  0 úgy, hogy minden n  n0 esetén f (n)  cg(n)} teljesül. Ha f  O(g ) , akkor azt mondjuk, hogy g aszimptotikus felső korlátja f-nek. Ebben az esetben szokásos módon inkább az f  O(g ) jelölést alkalmazzuk. Valamint, használjuk a következő függvényhalmazt is:

 ( g)  { f : minden c  0 esetén létezik n0  0 , hogy minden n  n0 esetén f (n)  cg(n)} 2. Hasonlóan, jelölje (g ) azon függvények halmazát, amelyekre

( g)  { f : létezik c  0 és n0  0 úgy, hogy minden n  n0 esetén f (n)  cg(n)} áll fenn. Ha f  (g ) – szokásos jelöléssel f  (g ) –, akkor azt mondjuk, hogy g aszimptotikus alsó korlátja f-nek. Az itt megjelenő másik függvényhalmaz pedig:

( g)  { f : minden c  0 esetén létezik n0  0, hogy minden n  n0 esetén f (n)  cg(n)} 3. Végül, (g ) jelöli azt a függvényosztályt, amelyet a

( g)  { f : létezik c1 , c2  0 és n0  0, hogy minden n  n0 esetén c1 g(n)  f (n)  c2 g(n)} összefüggés ír le. Ha f  (g ) – szokásos jelöléssel f  (g ) –, akkor azt mondjuk, hogy g aszimptotikusan éles korlátja f-nek. A definíciók alapján teljesül a következő egyenlőség: ( g )  ( g )  ( g ) . 6

Ha egy tetszőleges, rögzített g függvényt tekintünk, akkor a többi függvény általában besorolható a most definiált osztályok némelyikébe. (Megjegyezzük, hogy vannak nem összehasonlítható függvények is, pl. g (n)  1 és f (n)  n(1  sin n) , ill. g (n)  n és f (n)  n 1sinn minden n-re. Könnyen belátható ezekre, hogy sem f  O(g ) , sem pedig f  (g ) nem teljesül.) A nagyságrendi viszonyokról az 1.5. ábra ad szemléletes képet.

1.5. ábra. A függvényosztályok egymáshoz való viszonya

A függvények aszimptotikus viszonyainak jelen vizsgálatához az szolgál alapul, hogy kellően nagyméretű bemenet esetén egy algoritmus futási idejének (ill. az azt leíró függvénynek) csak a nagyságrendje lényeges. Viszonylag kisméretű bemeneteket leszámítva tehát az aszimptotikusan leghatékonyabb algoritmus lesz a ténylegesen leggyorsabb! 1.3.2. Visszavezetés határértékekre Valamely adott f és g függvény aszimptotikus viszonyát a lim

n 

f ( n) határérték – g ( n)

amennyiben létezik – a következőképpen határozza meg: 1. Ha lim

f (n) 0, vagy  , akkor f  (g ) . g (n) c  0

lim

f ( n)  0 esetén f   (g ) . g ( n)

2. Ha lim

f (n) , vagy  , akkor f  (g ) . g (n) c  0

lim

f ( n)   esetén f   (g ) . g ( n)

3. Ha lim

f ( n)  c  0 , akkor f  (g ) . g ( n)

n

n

n 

Megjegyzés: A lim

n 

n 

n 

f ( n) határérték általában létezik, de pl. ha g ( n)

1, ha n  1(mod 2) f ( n)   2, ha n  0(mod 2) és g (n)  1 minden n-re, akkor nem létezik a határérték, de a két függvény aszimptotikus viszonya mégis megállapítható, ebben az esetben f  (g ) . 7

1.3.3. L’Hospital szabály alkalmazása Ha az adott f és g függvényekre lim f (n)   és lim g (n)   , és mindkét függvény n

n

valós kiterjesztése differenciálható, akkor gyakran alkalmazzuk a L’Hospital szabályt a

lim

n 

f ( n) határérték meghatározására: g ( n)

lim

n

f (n) f ' ( n)  lim g (n) n g ' (n)

1.3.4. Tulajdonságok A , , ,,  jelöléseket függvények közötti bináris relációként is felfoghatjuk (pl.   {( f , g ) : f  ( g )} ). Így a relációkra vonatkozó ismert definíciók értelmezhetők rájuk, és beláthatók a következő állítások: 1. ,  , , ,  mind tranzitív: f, g, h függvényekre pl.: f   ( g )  g   (h)  f   (h) . 2. , ,  mindegyike reflexív: pl. f  ( f ) . 3.  szimmetrikus: f  ( g )  g  ( f ) . 4.  és  , valamint  és  „felcserélten szimmetrikusak: pl. f  ( g )  g  ( f ) . 5. Rögzített h függvény mellett (h),  (h), (h), (h), (h) halmazok zártak az összeadásra és a (pozitív) számmal való szorzásra nézve: pl. f   (h)  g   (h)  f  g   (h) ; és f   (h)  c  0  cf   (h) . 6. Összegben a nagyobb függvény határozza meg az aszimptotikát: max{ f , g}  ( f  g ) . (Itt a max jelölés az aszimptotikusan nagyobb függvényt jelenti.) Ha ezt a szokásos alakot átírjuk az f  g  (max{ f , g}) formába, akkor könnyen kiolvasható belőle az, hogy egy szekvencia műveletigényének nagyságrendjét a nagyobb műveletigényű tag határozza meg. 1.3.5. Függvényosztályok A fenti 1., 2. és 3. tulajdonságok miatt  – mint bináris reláció – ekvivalenciareláció a függvények halmazán, tehát osztályokra bontja azt. Az egyes osztályok reprezentálhatók a legegyszerűbb függvényükkel, pl.: (1), (n), (n2 ), ( n ), Megjegyzés: A gyakorlatban – amennyiben nem okoz félreértést – az egyszerűség kedvéért a g , (g ),  (g ), (g ),  (g ), (g ) jelölések helyett gyakran a g (n), ( g (n)),  ( g (n)), ( g (n)), ( g (n)), ( g (n)) jelöléseket használjuk. Így pl. a 3n 2  7n  (n 2 ) jelentése: f  (h) , ahol f (n)  3n 2  7n és h(n)  n2 minden n-re. Néhány további könnyen belátható tulajdonság: •

Polinom esetén a legnagyobb fokú tag a meghatározó (lásd: fenti 6. tulajdonság):

ak n k  ak 1n k 1    a1n  a0  (n k ).

8



Bármely két (1-nél nagyobb) alapszámú logaritmikus függvény aszimptotikusan egyenértékű: log a n  (log b n) (a>1 és b>1). Ezért az alap feltüntetése nem szükséges: log a n  (log n)



Hatványfüggvények esetén különböző kitevők különböző függvényosztályokat jelölnek ki: a  0 és   0 esetén na  (na ) és na  (na ) , vagyis na   (na ) .

1.3.6. Az aszimptotikus jelölések használata Az aszimptotikus jelölések használatában elterjedt néhány gyakori hiba. Ezek általában nem zavarók, mégis fel kell hívni a figyelmet rájuk. A kritikával azonban már csak azért is óvatosan kell bánni, mert a szerzők egy része kizárólag az  -t használja. 1. Leggyakoribb „hiba” az, hogy algoritmusok műveletigényének jellemzésekor  -t használnak, de  -t értenek alatta. Az így leírt egyenlőség általában igaz, de mást (kevesebbet) fejez ki, mint amit általában közölni szeretnének általa. Pl. a buborékrendezésre igaz, hogy MT (n)  (n 2 ) , de többet mond az, hogy MT (n)  (n 2 ) . 2. A T (n) jelölés használata is hibát rejthet. T (n) az összes lehetséges futási időt magában foglalja, amelyek nagyságrendben különbözőek lehetnek. Pl. ahogy korábban láttuk, a buborékrendezésre igaz (Cs helyett T-vel), hogy: MT (n)  (n 2 ) és AT (n)  (n2 ). A legkedvezőbb esetben azonban nincs csere, vagyis mT (n)  0 . Ekkor a T (n)  (n 2 ) (felületes) kijelentés nem igaz, helyette a T (n)  (n 2 ) írásmódot kellene használni, mely az összes esetre helyes lesz. Ez azonban kevesebb információt tartalmaz, mint a fenti két egyenlőség, ezért ajánlatos MT (n) -t és AT (n) -t használni. 3. Néhány tipikus használat: a) 2n 2  3n  1  (n 2 ) ,

3n  1  (n 2 ) , de 3n  1  (n 2 )

b) 2n 2  3n  1  2n 2  (n)  (n 2 ) c)

A logaritmikus keresés futási idejére fennáll a következő (közelítő) rekurzív egyenlet: n T (n)  T    (1) .  2

1.4. Algoritmusok műveletigényének tipikus nagyságrendjei Néhány jellegzetes, és a gyakorlatban gyakran előforduló függvény menetét és egymáshoz viszonyított aszimptotikus nagyságrendjét szemlélteti az 1.6. ábra (nem mérethűen, főleg nem az origó közelében). Ezek a függvények aszimptotikusan mind különböző nagyságrendűek. A hatványfüggvények – az ábrán is látható – egyik fontos tulajdonsága, hogy bármely   1 kitevő esetén n az n  log n és az a n (a  1) közé, míg ha 0    1 , akkor n a log n és az n közé esik aszimptotikusan.

9

1.6. ábra. A gyakran előforduló nagyságrendek grafikonja Megadunk néhány példát az algoritmusok aszimptotikus futási idejére. • • • • •

Verem vagy sor bármely művelete Logaritmikus (=bináris) keresés Prímszámteszt (𝑛1/2 -ig) Lineáris keresés Kupacrendezés

(1) (log n)  (𝑛1/2 ) (n) (n log n)

• • • • •

Shell rendezés Buborékrendezés Mátrixszorzás Hanoi tornyai Utazó ügynök probléma

(n3 / 2 ) (n 2 ) (n3 ) (2n ) (n!)

A műveletigények gyakran előforduló nagyságrendjeinek értékeit az 1.7. ábrán látható tartalmazza néhány jellegzetes n értékre, mindössze 1024-ig. Az n=12 nevezetes érték az n! függvény esetében. A 12! még egészként ábrázolható, a 13! már csak lebegőpontos számként, és egy n! műveletigényű probléma egzakt teljes körű megoldása általában az n=12 értékre még kivárható egy erős asztali gépen, de nagyobb értékekre már nem. Ezt a 10 8 nagyságrendű határt a 2n függvény 29-nél éri el, az n3-ös műveletigényű algoritmusok pedig 500 felett nem sokkal. Figyelemre méltó még az is, hogy az n2 és az n log(n) értékek n=1024 esetén (ami a gyakorlatban csekély adatmennyiség) már két nagyságrendben térnek el. (Néhány olyan értéket, amely a Matlab programban túlcsordulást okozott, nem helyettesítettük kiszámolt értékekkel, hiszen ezeknek a nagyságrendeknek nincs valós értelmük; nem csak az algoritmusok világában, de a földi tér-idő mennyiségek szempontjából sem.) Látszik, miért mondják az informatikusok, hogy legfeljebb az n3-ös algoritmusoknak vesszük hasznát a gyakorlatban. Az is nyilvánvaló, hogy nagyobb n-ekre miért érdemes n log(n) műveletigényű rendezéseket használni az n2-es eljárásokkal szemben, illetve miért érdemes a gráfos algoritmusokat gyorsítani – például minimumválasztó kupac adatszerkezet alkalmazásával –, hiszen ott is ez a két utóbbi nagyságrend állítható egymással szembe.

10

1.7. ábra. A gyakran előforduló nagyságrendek értékeinek táblázata A táblázat valós értékei jól érzékeltetik azt, hogy mennyire fontos az algoritmusok műveletigényével tisztában lenni, és azt a lehetőség szerint minél alacsonyabbra választani.

11

2. AZ ADATTÍPUS ABSZTRAKCIÓS SZINTJEI Ebben a bevezető jellegű fejezetben arra a kérdésre próbálunk válaszolni, hogy az adattípusok, illetve adatszerkezetek milyen absztrakciós szinten jelennek meg az elméleti szintű megfontolások, a tervezés és a gyakorlati problémamegoldás során. Az itt következő meggondolások nem alkotnak egzakt elméletet, inkább csak egy ajánlható szemléletet tükröznek. Az ismertetésben, amely felvázolja ezt a szemléletet, előzetesen hivatkoznunk kell olyan fogalmakra, amelyeket a következő fejezetekben vezetünk be részletesebben, a maguk helyén. Ebben a fejezetben mintegy „előrehozott” intuitív fogalmakra támaszkodunk. Úgy gondoljuk, hogy ezzel nem okozunk nehézséget az olvasóknak. A következő adattípusok szerepelnek példáinkban: tömb, verem, sor, listák, bináris fa és az elsőbbségi sor, valamint két speciális bináris fa: a kupac és bináris keresőfa. Ezekről az adatszerkezetekről a jegyzet későbbi külön fejezetei szólnak. Egy elméletileg igényes programozó, aki specifikál, tervez és fejleszt, továbbá – mondjuk hiba esetén – a nyomkövetés eredményeként az adatszerkezet memóriabeli bitképét tanulmányozza, felfogásunk szerint az alábbi öt különböző absztrakciós szinten találkozik az adattípusokkal: 1. Absztrakt adattípus (ADT) 2. Absztrakt adatszerkezet (ADS) 3. Reprezentáció (ábrázolás) 4. Implementáció (fejlesztés, programnyelvi megvalósítás) 5. Fizikai (memória) szint Ennek a tantárgynak a keretében az ismertetés az első három szinten marad, a programnyelvi implementáció és elemei adatszerkezetek (különféle számok, pointerek) bitképe nem része a tárgyalásnak. A szóhasználatról előzetesen annyit, hogy az adatszerkezet bizonyos típusú adatelemek struktúrájára utal. Az adattípus kifejezés az adatszerkezet műveleteit is magában foglalja. Például, egy (láncolt) lista önmagában egy adatszerkezet, amely egyaránt megvalósíthat – többek között – vermet és sort. Azt a listát azonban, amely saját műveletekkel is rendelkezik, adattípusnak nevezzük. (Megjegyezzük, hogy a szóhasználat nem mindig konzekvens; olykor adatszerkezetet mondunk, de a műveleteket is hozzá értjük. Az adattípus és az adatszerkezet kifejezéseket gyakran szinonimaként használjuk, noha a típus elvileg többet fejez ki. Ez a szövegkörnyezet révén általában nem zavaró.) Az ábrázolás, reprezentáció és a megvalósítás kifejezéseket nagyjából szinonimaként használjuk, közel azonos jelentéssel. Miután a nyelvi implementációs szint nem része az anyagnak, ez nem okoz félreértést.

2.1. Absztrakt adattípus (ADT) Ez az adattípus leírásának legmagasabb absztrakciós szintje. Az adattípust úgy specifikáljuk ezen a szinten, hogy a szerkezetére (még) nem teszünk megfontolásokat. A leírásban kizárólag matematikai fogalmak használhatók. A specifikáció eredménye az absztrakt adattípus. Az ADT szintű specifikáció lehet formális, mint a következő két példában, de lehet informális is: természetes magyar nyelven is elmondhatjuk, hogy mit várunk el például egy veremtől. A lényeg nem a leírás formalizáltsága, hanem az, hogy a specifikációban „nem látjuk” az adattípus belső struktúráját. Mire jó az ADT szintű leírás? A programozásról szóló könyvek és kurzusok azt tanácsolják, hogy ha egy feladatot szeretnénk megoldani, akkor előbb specifikáljuk az elvárásokat algoritmikus megfontolások nélkül, és csak utána keressük meg a megfelelő megoldó eljárásokat. Az ELTE-n a Programozás című tantárgy keretében egy – Dijkstra-tól eredő – specifikációs technikát használunk. Ebben megadjuk a feladat állapotterén (A), illetve paraméterterén (B) a bemenő adatokra fennálló előfeltételeket (Q), majd az utófeltétel (R) formájában megfogalmazzuk az eredményre vonatkozó elvárásainkat. Az elő-, és utófeltétel lényegében statikus logikai állításokat tartalmaz, így a specifikáció valóban nélkülözi az algoritmikus elemeket. Az adatszerkezetek világában is adódhatnak hasonló természetű feladatok. Amikor például egy reprezentálási mód megválasztása a kérdés, célszerű, ha úgy tudjuk leírni az adattípussal kapcsolatos elvárásainkat, hogy nem teszünk megfontolásokat a szerkezetre nézve. Ilyen például az elsőbbségi (prioritásos) sor hatékony megvalósításának a problémája. Míg a legtöbben „eleve” tudják, hogy milyen a verem, vagy a sor adatszerkezet, addig kevesen „hozzák magukkal” köznapi ismereteik részeként az elsőbbségi sor reprezentálásáról szóló tudást. Mindenképpen hasznos tehát, ha szerkezeti összefüggések nélkül definiáljuk az elsőbbségi sor fogalmát. Az ADT szintű leírás közvetíti az „enkapszuláció” gondolatát is. Ha valaki egy típust implementál, akkor az ezt tartalmazó modult várhatóan úgy írja meg, hogy magához az adatszerkezethez a felhasználó közvetlenül ne férhessen hozzá, hanem csak a műveleteken keresztül érhesse el azt. A másik oldalról, ugyanebben a szellemben, a program felhasználója is elfogadja, hogy közvetlenül „nem nyúl bele” egy adatszerkezetbe, hanem csak a műveletein keresztül használja és módosítja azt. Alapvetően két leírási mód terjedt el: (1) az algebrai specifikáció, amely logikai axiómák megadásával definiálja az absztrakt adattípust, illetve (2) a funkcionális specifikáció, amely matematikai reprezentációval az elő-, utófeltételes módszerrel teszi ugyanezt. 2.1.1. Algebrai specifikáció Ebben a specifikációs módszerben először megadjuk az adattípus műveleteit, mint leképezéseket, az értelmezési tartományukra vonatkozó esetleges megszorításokkal együtt. Utána a műveletek egymásra hatásának értelmes összefüggéseit rögzítjük axiómák formájában. (Ez a leírási módszer bizonyára nem mondható mindenkihez közel állónak.) A módszer alkalmazását a verem adattípus példáján mutatjuk be. A verem intuitív fogalma ismerős: olyan tároló struktúra, amelyből az utoljára betett elemet tudjuk kivenni. Ehhez nyilvánvalóan szerkezeti kép is társul, amelyről most tudatosan „elfeledkezünk” átmenetileg.

Először megadjuk a verem műveleteit, mint leképezéseket. Ezek közül talán csak az Üres művelet értelmezése lehet szokatlan: egyrészt létrehoz egy vermet, amely nem tartalmaz elemeket (lásd: deklaráció a programnyelvekben), másrészt az üres verem konstans neve is. Az Üres tehát egy konstans, ezért, mint leképezés nulla-argumentumú. Az alábbi műveleteket vezetjük be. Üres:  V

Üres verem konstans; az üres verem létrehozása

Üres-e: V  L

A verem üres voltának lekérdezése

Verembe: V  E  V

Elem betétele a verembe

Veremből: V  V  E

Elem kivétele a veremből

Felső: V  E

A felső elem lekérdezése

Megadjuk a leképezések megszorításait. A Veremből és a Felső műveletek értelmezési tartományából ki kell vennünk az üres vermet, arra ugyanis ez a két művelet nem értelmezhető. DVeremből = DFelső = V \ {Üres} Az algebrai specifikáció logikai axiómák megadásával valósul meg. Sorra vesszük a lehetséges művelet-párokat és mindkét sorrendjükről megnézzük, hogy értelmes állításhoz jutunk-e. Az alábbi axiómákat írjuk fel; magyarázatukat alább adjuk meg. 1. Üres-e (Üres)

vagy

v = Üres  Üres-e (v)

2. Üres-e (v)  v = Üres 3. ¬Üres-e (Verembe (v, e)) 4. Veremből (Verembe (v, e)) = (v, e) 5. Verembe (Veremből (v)) = v 6. Felső (Verembe (v, e)) = e 7. Felső (v) = Veremből (v).2 Az 1. axióma azt fejezi ki, hogy az üres verem konstansra teljesül az üresség. Ezt változó használatával egyenlőségjelesen is megfogalmaztuk. A 2. axióma az üres verem egyértelműségét mondja ki. A 3. állítás szerint, ha a verembe beteszünk egy elemet, akkor az már nem üres. A 4-5. axiómapár mindkét sorrend esetén leírja a verembe történő elhelyezés és az elem kivétel egymásutánjának a hatását. Mindkét esetbe a kiinduló helyzetet kapjuk vissza. (Az utóbbiban a Verembe művelet argumentum-száma helyes, ugyanis a belső Veremből művelet eredménye egy (verem, elem) pár.) Az utolsó két állítás a felső elem és a vermet módosító két művelet kapcsolatát adja meg. Egy ilyen axiómarendszertől először is azt várjuk, hogy helyes állításokat tartalmazzon. Természetes igény a teljesség is. Ez azt jelenti, hogy ne hiányozzon az állítások közül olyan, amely nélkül a verem meghatározása nem lenne teljes. Végül, a redundancia kérdése is felvethető: van-e olyan állítás a specifikációban, amely a többiből levezethető?

2.1.2. Funkcionális specifikáció A funkcionális specifikáció módszerében először megadjuk az adattípus matematikai reprezentációját, amelyre azután az egyes műveletek elő-, utófeltételes specifikálása épül. A módszert a sor adattípusra mutatjuk be. Absztrakt szinten úgy tekinthetjük a sort, mint (elem, időpont) rendezett párok halmazát. Az időpontok azt jelzik, hogy az egyes elemek mikor kerültek a sorba. Kikötjük, hogy az időpontok mind különbözők. Ezek után tudunk a legrégebben bekerült elemre hivatkozni. A 2.1. ábrán szereplő absztrakt sornak öt eleme van és először (legrégebben) a 40-es érték került a sorba. Ez az absztrakt reprezentáció a veremre is megfelelő lenne! A verem esetén azt az elemet választjuk ki, amelyikhez a legnagyobb időpont tartozik, a sor esetében viszont éppen a legkisebb időponttal rendelkező érték az, amely aktuálisan kivételre kerül.

2.1. ábra. A sor (és a verem) absztrakciója, mint érték-időpont párok halmaza (ADT)

Formálisan ez például a következőképpen írható le: {(

)|

{

}



{

}

}

Ha a sor műveleteit szeretnék specifikálni, akkor azt most már külön-külön egyesével is megtehetjük, nem kell az egymásra való hatásuk axiómáiban gondolkodni. Definiáljuk például a Sorból műveletet. A programozás módszertanából ismert, említett elő-, utófeltételes specifikációval írjuk le formálisan, hogy ez a művelet a sorból az előkét betett elemet veszi ki, vagyis azt, amelyikhez a legkisebb időérték tartozik. (Ha az olvasónak nem lenne ismerős az alábbi jelölésrendszer, akkor elég, ha a módszer lényegét informális módon érti meg.) A = S E s

e

B= S s

Q = (s = s’  s’  ) R = ( s  s' \ e j , t j   e  e j  e j , t j  v'  i ei , t i   v'  i  j  : t j  t i ) A sor fenti absztrakt reprezentációja matematikai jellegű és nem tartalmaz semmiféle utalást a sor adattípus programnyelvi megvalósításának a módjára!

2.2. Absztrakt adatszerkezet (ADS) Az ADS szinten megmondjuk azt, hogy alapvetően (esetleg nem teljes részletességgel) milyen struktúrával rendelkezik a szóban forgó adattípus. Közelebbről ez azt jelenti, hogy megadjuk az adatelem közötti legfontosabb rákövetkezési kapcsolatokat, és ezt egy irányított gráf formájában le is rajzoljuk. Az absztrakt adatszerkezetet egy szerkezeti gráf és az ADT szinten bevezetett műveletek alkotják együttesen. Ez az absztrakciós szint illeszkedik legjobban az ember kognitív sajátosságaihoz. Egy veremről nem az axiómák formájában őrzünk képet emlékezetünkben, hanem egy (feltehetően függőleges helyzetű) tömbszerű tároló jelenik meg előttünk. A verem kognitív sémája, amelyet gondolatainkban felidézünk, tehát nem gráf formájú, hanem inkább egy tömbre emlékeztet. A kétféle megjelenítés nyilvánvalóan megfelel egymásnak, ahogyan ezt a 2.2-es ábrán láthatjuk. Általában ezen a szinten beszélünk és gondolkodunk az adatszerkezetekről. Az ADS szint felel meg legjobban a magyarázat, a megértés, a felidézés és az algoritmizálás, a tervezés és általában a kreatív problémamegoldás tevékenységének. Az egyes adatszerkezetek említésekor egy ilyen szintű ábra megjelenik meg gondolatainkban. Az ADS szinten az adattípus legfontosabb szerkezeti összefüggéseit adjuk meg egy irányított gráffal. A gráf csúcspontjai adatelemeket azonosítanak, az irányított élek pedig a közöttük fennálló rákövetkezési relációt ábrázolják. A 2.2. ábrán egy nagyon egyszerű gráf látható, amely egyetlen lineáris élsorozatot tartalmaz. Ez egyaránt ábrázolhat vermet, sort vagy listát. A szövegkörnyezet dönti el, hogy melyik adattípus absztrakt szerkezetét láthatjuk az ábrán. Ha veremről van szó, akkor említésre kerül, hogy a 40-es a felső elem. Sor esetén a 40-es az első, a 80-as pedig az utolsó elem. Ha egy lista ADS szintű ábráját látjuk, akkor viszont az aktuális elem fogalmát kell szóba hozni és meg kell mondani, hogy a melyik a lista aktuális eleme; lehet az például a 20-as.

2.2. ábra. Lista (verem és sor) absztrakt szerkezeti ábrája (ADS)

Tegyük fel, hogy most egy absztrakt sorral van dolgunk. Az ábrán látható kétféle megjelenítés (az egzakt és az informális) megfeleltethető egymásnak. Az utóbbi a sor tömbös ábrázolása felé mutat, míg az első a láncolt ábrázolás kiindulópontjának tekinthető. Jellegzetes a bináris fa ábrája ezen a szinten, amellyel gyakran találkozhatunk. A 2.3. ábrán látható (gyökeres) bináris fa eredetileg irányított éleket tartalmaz, ám legtöbbször az irányítás „lemarad” az ábrákról. A rákövetkezéseket mutató nyilakat azonban „odaértjük” az élekre, szülőgyerek irányban mindenképpen, de esetleg a fordított irányban is.

A bináris fák és általában a fák meghatározó szerepet játszanak az adatszerkezetek témakörében. Számos speciális fa adatstruktúrával találkozunk, amelyeket széles körben alkalmaznak az informatikában (ilyen például a kupac vagy a bináris keresőfa). Erről külön is szó lesz ennek a fejezetnek a végén. A bináris fákat általában láncoltan ábrázoljuk (erre mutat az ADS szintű ábra is, de speciális esetekben a tömbös ábrázolás adja a használható megoldást.

2.3. ábra. Bináris fa absztrakt szerkezeti ábrája (ADS)

Az ADS szint előnyeit vegyük röviden sorra. Amint említettük, ez a szint illeszkedik legjobban az ember kognitív adottságaihoz. Ennek az lehet a magyarázata, hogy éppen „kellően” absztrakt: már megjelenik a struktúra, de még nem kell döntést hozni az ábrázolás módjáról. A szerkezeti összefüggések lényegét emeli ki, amelyeket majd a reprezentáció szintjén teszünk teljessé. Az ADS szint szemléletes, ami nem csak a struktúrára, hanem adattípushoz tartozó műveletek illusztrálására is vonatkozik. 2.3. Adatszerkezetek reprezentálása Ezen a szinten arról hozunk döntést, hogy az ADS-szinten megjelenő gráf rákövetkezési relációit – kiegészítve azokat további szükséges szerkezeti összefüggésekkel – milyen módon ábrázoljuk. Két tiszta reprezentálási módot alkalmazunk. Ezek a következők: (1) az aritmetikai (tömbös) reprezentáció, illetve (2) a láncolt (pointeres) ábrázolás. Az így kapott ábrázolás már az implementációhoz közeli és a számítógépes megvalósítást modellezi. 2.3.1. Tömbös ábrázolás Ha egy adattípust tömbösen ábrázolunk, akkor az adatszerkezet elemeit – alkalmas sorrendben – egy tömbben helyezzük el, a szerkezeti, rákövetkezési összefüggéseket pedig külön függvények formájában adjuk meg. Az adattípus részét képezik a tömb mellet bizonyos további attribútumok, amelyek általában indexekkel és más típusú (például logikai) változókkal fejezhetők ki. A 2.4. ábrán egy verem tömbös ábrázolása látható.

2.4. ábra. Verem tömbös reprezentálása

A reprezentációhoz tartozik a verem k mérete is. A verem elemei a tömb első és k-adik cellája között helyezkednek el. Az utoljára elhelyezett (a felső) elem éppen a k indexű tömbelem. Az üres vermet k = 0 azonosítja, míg k = n esetén betelt a verem. Egy logikai hiba változót is a verem ábrázolásának a részévé tehetünk. A verem műveleteinek megírása is hozzá tartozik a tömbös reprezentáció elkészítéséhez. Ezt a veremről szóló fejezet tartalmazza. A 2.4 ábrán egy teljes bináris fa és annak tömbös ábrázolása látható. A bináris és az általános fákat általában pointeresen reprezentálják. A teljes bináris fa esetében azonban hasznos lehet a tömbös ábrázolás is. A fa csúcsaiban található adatelemeket szintfolytonosan helyezzük el a tömbben.

2.4. ábra. teljes bináris fa tömbös ábrázolása

A szülő – gyerek kapcsolatok szerencsére kezelhetők maradnak. Belátható ugyanis, hogy bármely c belső szülő csúcs és a bal(c) és jobb(c) gyerekcsúcsok tömbbeli indexei között fennáll a következő kapcsolat: ind(bal(c)) = 2*ind(c) ind(jobb(c)) = 2*ind(c) + 1

Megfordítva, bármely nem-gyökér c gyerek csúcs és a szülő(c) csúcs indexei között érvényes az alábbi összefüggés: ind(szülő(c)) = ind(c) A tömbös ábrázolás kulcsfontosságú a kupac adatszerkezetnél, ami ADS szinten nézve egy majdnem teljes balra tömörített bináris fa, reprezentálása azonban mindig tömbösen történik. Az absztrakciót előtérbe helyező szemléletünk számára fontos alátámasztást hordoz a kupac példája, amire visszatérünk ennek a fejezetnek a végén, illetve az elsőbbségi sorról és a kupacról szóló fejezetben. 2.3.1. Láncolt ábrázolás Ehhez az ábrázoláshoz bevezetjük az absztrakt pointer (mutató) fogalmát. Ha p egy pointer típusú változó, akkor p jelöli az általa mutatott adatelemet. Ennek az adatelemnek a részeit, például az adat és mutató komponenseit a padat, illetve a pmut hivatkozások választják ki. A sehová sem mutató pointer értéke NIL. A 4.2. ábrán látható láncolt struktúra részletben érvényesek például a következő egyenlőségek: padat = 100; pmut = q; pmutadat = 200 stb.

2.5. ábra. Láncolt ábrázolás

Megegyezünk abban, hogy az absztrakt mutató típusos, azaz mindig valamilyen meghatározott típusú adatszerkezetre mutat. A láncolt ábrázolás algoritmusaiban tipikus helyzet, hogy egy új adatelemet kell létrehozni. Ezt a new(p) absztrakt utasítással tehetjük meg. Ennek hatására létrejön egy adott típusú, definiálatlan tartalmú adatelem, amelyre a p pointer változó mutat. Egy ilyen adatelem felszabadítása a dispose(p) absztrakt utasítással történik. Hatására az adatelem visszakerül a szabad helyek közé és p tartalma meghatározatlan lesz (általában még mindig az eldobott adatra mutat). A 2.6. ábra illusztrálja a létrehozás és a törlés műveletpárját.

2.6. ábra. Láncolt adatelem létrehozása és törlése

Példaként tekintsük egy fejelemes lista láncolt ábrázolását, amelyet a 2.7. ábra mutat. A fejelemről röviden annyit, hogy az mindig szerepel egy ilyen típusú listában, még akkor is, ha a lista (logikai szinten) üres. A listának az az elemét, amelyre az akt pointer mutat, aktuálisnak nevezzük. A lista műveletei általában erre vonatkoznak. Ha akt = NIL, akkor éppen nincs

kitüntetett aktuális elem a listában. A listákról szóló fejezetben több ábrázolási módot is bemutatunk és a lista műveleteit is bevezetjük.

2.7. ábra. Fejelemes lista láncolt ábrázolása

Következő példánk legyen a 2.3. ábrán látható bináris fa reprezentálása pointeres módon. A láncolt ábrázolás egy részletét láthatjuk a 2.8. ábrán. A fa egyes csúcsainak megfelelő rekordok két pointer mezőt tartalmaznak. Ezek a bal és a jobb gyerekre mutató értékeket tárolják. A bináris fákról és a keresőfákról szóló fejezetekben olyan ábrázolási módot vezetünk be, amelyben a gyerek csúcsokból a szülőkre mutató pointerek is helyet kapnak.

2.8. Bináris fa láncolt ábrázolása

2.4. Algoritmusok megjelenése az egyes absztrakciós szinteken Az adatszerkezeteket algoritmusokban használjuk. Erre nagyszámú példát látunk a jegyzet egészében, de már az alapvető adatszerkezetekről szóló részben is. Az algoritmusok bármely szinten megjelenhetnek. Az absztrakt adattípus (ADT) és az absztrakt adatszerkezet (ADS) szintjén csak a típusműveletek felhasználásával férünk hozzá az adatstruktúrához. Számos példát látunk majd az ADT szintű algoritmus leírásra. Például a helyes zárójelezés feldolgozásának algoritmusa műveleteivel használja a vermet (lásd: 4. fejezet). Érdekes példát láthatunk a műveletek szintjén való gondolkodásra az elsőbbségi sornál (lásd: 8. fejezet). Ott szerepel az elsőbbségi sorral való rendezés algoritmusa. Az eljárás két nagyobb iteráció egymásutánja: először egyesével betesszük a rendezendő elemeket az elsőbbségi sorba, ezt követően pedig egymás után kivesszük és kiírjuk őket. A prioritásos sor – belső szerkezetétől független – működése garantálja azt, hogy az elemeket nagyság szerint csökkenő sorrendben kapjuk meg, egymás után. Ez egy magas absztrakciós szinten megfogalmazott rendező eljárás.

Ha a prioritásos sor három különböző reprezentációját tekintjük, akkor három ismert rendező algoritmushoz (pontosabban azokhoz igen közeli eljárásokhoz) jutunk; ezek a maximum kiválasztó, a beszúró és a kupacos rendezés. Nincs olyan formai jegy, amely alapján egyértelműen el tudnánk dönteni, hogy egy absztrakt algoritmus-leírás ADT vagy ADS szintűnek mondható-e. Azt kellene ehhez megválaszolni, hogy az algoritmus megadásában kifejeződik-e annak ismerete, hogy milyen a benne szereplő adattípusok struktúrája. Ha például egy algoritmus bináris fát használ és hivatkozik egy csúcs bal vagy jobb gyerekére, szülőjére, akkor gyanítható, hogy a szerző „látja maga előtt” a bináris fa szerkezetét. Ám az alkalmazott függvények bevezethetők ADT szinten, a strukturális szemlélet nélkül is. szerencsére, ez az eldöntetlenség egyáltalán nem zavaró. Az ADS szinten megfogalmazott eljárások jellegzetes példáját szolgáltatják a gráfalgoritmusok (lásd: VII. rész). A gráf absztrakt adatszerkezet megegyezik a gráf szemléletes matematikai fogalmával. A gráfalgoritmusok ilyen szinten történő leírása is szemléletes marad, ami részben elveszne, ha a mátrixos reprezentáció vagy a láncolt éllistás ábrázolást vennénk alapul. Számos algoritmus leírását az ábrázolás szintjén adjuk meg. Az összes alapvető adatszerkezet műveleteit mind a tömbös, mind a láncolt reprezentációban megadjuk (lásd: II. rész). A későbbi anyagrészből merítve egy jellegzetes példát, a 11. fejezetben a bináris keresőfa műveleteinek algoritmusaival találkozhatunk a láncolt ábrázolás szintjén.

3. TÖMBÖK Az egyszerű adattípusok ismertetését a tömbökkel kezdjük. Az sem okozna zavart, ha ezt a fejezet nem szerepelne jegyzetünkben, tekintettel arra, hogy a tömböket ismerjük a programozási kurzusokból. Mégis, célszerű néhány jellemző megállapítást tenni ezzel az alapvető struktúrával kapcsolatban. 

A tömbök legfontosabb tulajdonsága az, hogy elemei – indexeléssel – közvetlenül elérhetők. Ezt ADT szintű tulajdonságnak tekinthetjük.



A tömbről ADS szinten „tömbszerű” képünk van, de az elemek rákövetkezősége alapján irányított gráfként is felfoghatunk egy tömböt. Ez az absztrakció a láncolt ábrázolás felé mutat.



Az megvalósítás során, az ADS szinttel összhangban, ritkán választjuk a tömb láncolt ábrázolását (ahogyan- fordítva – a listák esetében sem gyakori a tömbös megvalósítás).



A tömbökről nem könnyű megmondani a felhasználás egy körében vagy konkrét esetében, hogy saját műveletekkel rendelkező önálló típusnak, vagy csupán a reprezentáció adatszerkezetének tekinthetők.



A tömbök dimenziószámmal rendelkeznek; a vektor egydimenziós, míg a mátrix kétdimenziós ismert struktúra; de magasabb dimenziós tömbök használata sem ritka. A tömb erősen szemléletes fogalom; három dimenzióig könnyű elképzelni, lerajzolni a szerkezetüket.



Ma már egy többdimenziós tömb, például egy mátrix nem jutattja eszünkbe, hogy a további lépésként egydimenziós tömbbel kellene reprezentálni. Ez annak köszönhető, hogy a programozási nyelvek elemi lehetőségként kínálják a többdimenziós tömbök használatát. Érdemes azonban tudatosítani, hogy többdimenziós tömbök a számítógép memóriában egydimenziósként ábrázolódnak.



Speciális több dimenziós tömbök (például alsóháromszög-mátrix) helytekerékos egydimenziós ábrázolásáról olyakor magunk gondoskodunk.

Ezeket a gondolatokat valamivel részletesebben is kifejtjük a következő pontokban. 3.1. A tömb absztrakt adattípus Legyen T az E alaptípus feletti k (≥ 1) dimenziós tömb típus. Vezessük be az I  I1  ...  I k indexhalmazt, ahol I j = [1 .. nj] (1  j  k ) . (Megjegyezzük, hogy az indexelés 1 helyett kezdődhetne általában mj-vel is, de az egyszerűség kedvéért 1-et fogunk használni.) Az A  T tömbnek így n = n1  n2  ...  nk elemet tárol, amelyek halmazát a1 ,..., an  jelöli. A T tömbhöz tartozik, mint a típus meghatározó komponense, egy f : I  1..n indexfüggvény, amely kölcsönösen egyértelmű leképezést létesít az indexhalmaz és a elemek halmazbeli indexei között, ezáltal egyértelmű leképzést valósít meg az indexhalmaz és a tömb elemei között. A tömbelemek egyértelműen és közvetlenül elérhetővé válnak az indexfüggvény alkalmazásával. Bevezetjük az A [1 .. n1, 1 .. n2, …, 1 .. nk] jelölést az A tömbre, amely magában foglalja az indexhalmazát és utal arra, hogy az indexkifejezések és a tömelemek közötti kapcsolat is adott, így annak alapján az elemekre – indexeléssel – lehet közvetlenül hivatkozni.

Bevezetjük az A i1 , i2 ,..., ik  jelölést a tömbelemek indexelésére. Ha a fenti indexfüggvény szerint f ( i1 , i2 ,..., ik ) = j, akkor ez az indexelés az aj elemet választja ki A i1 , i2 ,..., ik  = aj Az indexelés mechanizmusát (absztrakt megközelítésben) a 3.1. ábra szemlélteti.

3.1. ábra. Tömb absztrakt adattípus

A tömb műveleteinek köre szerény: a most bevezetett indexeléssel lekérdezhetjük a tömb elemeit, emellett módosíthatjuk is azokat. A tömb a mérete nem változik; nem lehet a tömbbe egy új elemet beszúrni, és nem lehet a tömbből egy elemet kitörölni. A szokás elnevezések szerint k = 1 esetén a vektorról, k = 2 esetén a mátrixról beszélünk. 3.2. A tömb absztrakt adatszerkezet A tömböktől elválaszthatalan a szerkezetükről alkotott kép, amely jórészt a matematikai tanulmányaink során alakult ki. Ezen a kép alapján például egy cellákból álló lineáris vagy „négyzethálós” sémában helyezzük el (az egy, illetve kétdimenziós) tömb elemeit. A 3.2. ábrán egy mátrix szokásos ábrája látható.

3.2. ábra. Tömb szemléletes képe (ADS)

Az absztrakt adatszerkezet bevezetésével a fenti f indexfüggvény a háttérbe húzódik, hiszen azt lehet mondani például a fenti kétdimenziós tömb esetén, hogy mondjuk az A [2, 3] = 40 elem a 2. sor 3. eleme, vagyis az indexkifejezést vizuálisan megjelenítettük az ADS szintű sémával.

2

Szemléletünk számára tehát a vektor egy beosztásokkal ellátott szalag, a 2-dimenziós tömb egy mátrix, a 3-dimenziós tömb egy cellákra osztott téglatest alakját ölti gondolatainkban. Az előző, bevezető jellegű 2. fejezet szerint, az absztrakt adatszerkezetet általában egy olyan irányított gráf szemlélteti, amelyben az élek az adatelemek közötti rákövetkezéseket jelenítik meg. Egy k-dimenziós tömb elemeinek általában, a dimenziók határát kivéve, k számú rákövetkezőjük van. Formálisan isbevezethetjük a j szerinti rákövetkezés fogalmát: kövj A [ i1 ,..., i j ,..., ik ] = A [ i1 ,..., i j 1 ,..., ik ]

(ij  nj )

A 3.3. ábra egy kétdimenziós tömb, az A [1..3, 1..4] mátrix absztrakt gráfszerkezetét mutatja. Ez egy olyan ortogonális struktúra, amelyben minden csúcsból két él vezet a tömbbeli rákövetkezőkhöz.

3.3. ábra. Tömb gráfja (ADS)

Valójában a tömbről nem ilyen képet őrzünk fejünkben, ahogyan a verem sem egy lineáris gráf formájában rögzül a memóriánkban. Annyiban azonban mégsem fölösleges a gráfos szemlélet, mert közelebb hozza a ritka mátrixok láncolt ábrázolásának ötletét. 3.3. A tömb megvalósításai Ebben a pontban két ritkán felmerülő kérdést érintünk. Az egyik a többdimenziós tömbök egydimenziós ábrázolására vonatkozik. A magasszintű programnyelvek elfedik előlünk ennek szükségességét, azonban hasznos lehet megismerni a fordítóprogramokba épített tárolási elvet. A másik kérdés a nagyméretű, de kevés adatot tartalmazó tömbök helytakarékos tárolására vonatkozik. Bemutatjuk a ritka kitöltöttségű mátrixok láncolt ábrázolásának módszerét. 3.3.1. Aritmetikai reprezentáció Egy adatszerkezet aritmetikai ábrázolása során az adatelemeket egy tömbben helyezzük el, az eredti strukturális összefüggéseket pedig függvények formájában adjuk meg. Az adatokat tároló tömb lehet egy- vagy többdimenziós. Szemléletünk számára egy többdimenziós tömb már annyira egyszerű adatszerkezet, hogy nem szükséges mindig újra meggondolni az egydimenziós elhelyezés lehetőségét. A programnyelvek is megerősítenek ebben, hiszen a többdimenziós tömbök használatát az alapvető lehetőségek között nyújtják.

3

A tömb adattípus ismertetésekor azonban, legalább ezen a helyen egyszer, érdemes szóba hozni azt, hogy a többdimenziós tömbök elemeit még el kell helyezni a szalagszerű egydimenziós memóriában. A szekvenciális tárolást általában a sorfolytosnos vagy oszlopfolytonos módszerrel szokás megoldani. (Azzal még itt sem foglalkozunk, hogy a tárolás végállomása egy bájtokból álló vektor, és a bájtokat – még tovább finomítva – bitek sorozata alkotja.) A 3.4. ábrán a korábban is szereplő mátrixnak az egydimenziós tárolást illusztrálja, mindkét elhelyezési stratégia szerint.

3.4. ábra. Kétdimenziós tömb egydimenziós tárolása

A kapcsolatot az elemek mátrixban elfoglalt pozíciója és az újy helye között indexfüggvényekkel adjuk meg. Egy kétdimenziós tömbre, például a sorfolytonos esetben ez a következő: ( ) ( ) ahol és . Esetünkben például, ha az sorfolytonosan kialakított B tömbben, akkor azt a ( )

elemet keressük a indexű helyen találjuk:

Ezzel a kérdéssel a további fejezetekben nem foglalkozunk. Ezután már egy többdimenziós tömböt is az ábrázolás eszközének tekintünk. 3.3.2. Láncolt ábrázolás Bizonyos (jobbára gazdasági) problémák modellezése nagyméretű mátrixok alkalmazásához vezet. Előfordul azonban, hogy a mátrix csekély értékes adatot tárol. Ilyenkor gondolhatunk arra a reprezentálási módra, amelyet a 3.5. ábrán láthatunk. A ritka mátrixok láncolt ábrázolásában csak az értékes elemek szerepelnek. Alkalmazhatunk egyirányú láncolást, amelyben minden elemtől a sor- és oszlopbeli rákövetkezőjéhez irányít a két tárolt pointer. A sorok és az oszlopok „bejárataira”,

4

pontosabban az első bennük szereplő elemre, egy-egy fejelem mutat, amelyek maguk is egyegy litát alkotnak. A két fejelem lista egy közös fejelemből érhető el.

3.5. ábra. Ritka mátrix láncolt ábrázolása

Az ábra nem csak a helytakarékosság lehetőségét érzékelteti, hanem azt is, hogy ezzel az ábrázolási móddal feladtuk az elemek közvetlen indexelhetőségét és csak meglehetős nehézkességgel tudunk eljutni az elemekhez. Az elemek elérését, illetve a struktúrában való mozgást valamelyest javítja, ha kétirányú láncolást alkalmazunk, mind a sorokban és az oszlopokban, mind pedig a két fejelem-listában. Azt a kérdést, hogy alkalmazzuk-e adott esetben ezt a tárolási formát, két szempont dönti el. Egyik a helytakarékosság kérdése: az értékes mátrixelemek (várható) számának és méretének ismerete esetén könnyen kiszámítható, hogy előnyösebb-e ez az ábrázolás, mint a hagyományos. Ehhez csak a pointerek számát kell meghatározni, amelyet a memóriacím helyfoglalásával (ami általában 2 vagy 4 bájt) szorozva kell a memóriaigény számításában figyelembe venni. A másik mérlegelendő szempont az, hogy mennyire támogatják a feldolgozó eljárások megvalósíthatóságát a láncolt adatszerkezeten való közlekedés korlátozott lehetőségei. A mai memóriakapacitások mellett lehet, hogy ez a súlyosabb szempont. Ha arra gondolunk például, hogy hogyan kellene két ritka mátrix összegét előállítani, akkor látható, hogy a láncolt ábrázolás mellett a legegyszerűbb feladadása megoldása is körülményessé válhat. 5

3.4. Speciális tömbök A tömbök egydimenziós ábrázolásával azért sem fölösleges megismerkedni, mert találkozhatunk olyan adatszerkezetekkel, amelyeknél azt magunk valósítjuk meg. Ezek az adatszerkezetek általában olyan mátrixok, amelyeknek jelentősen kisebb helyen is tárolhatók, például azért, mert az elemek jó része hiányzik, és a hiány, ezzel együtt értékes rész is szabályos tartományt képez.

3.6. ábra. Alsó-háromszögmátrix sorfolytonos ábrázolása

A 3.6. ábrán látható alsóháromszög-mátrixban a főátló fölötti elemek hiányoznak. Az értékes elemeket sorfolytonos módon elhelyezve egy egydimenziós tömbbe memóriatakarékos tárolást valósítunk meg, hiszen így a helyfoglalás közelítőleg a felére csökken. Egy száma: ( elhelyezünk.

-es négyzetes mátrix esetén az alsó háromszög tárolásához szükséges cellák )⁄ . Megállapodás szerint, az értékes elemek után még egy nullát is

Ez az átpakolási stratégia az alábbi index-függvénnyel válik követhetővé: (

)

( {

) (

)⁄

(A nulla elem tárolása egy általánosabb eljárás következménye. Ha ugyanis a mátrixban lenne néhány olyan elem, amelyek nagyobb számban ismétlődnek egy-egy szabályos tartományban, akkor ezeket rendre elegendő egy-egy példányban tárolni, amennyiben az eredeti előfordulási helyeik egy indexfüggvénybe foglalhatók.)

6

4. VEREM A mindennapokban is találkozunk verem alapú tároló struktúrákkal. Legismertebb példa a névadó, a mezőgazdaságban használt verem. Az informatikában legismertebb veremalkalmazások az eljáráshívások végrehajtása során a vezérlésátadások kezelése, valamint a kifejezések lengyelformára alakítása, illetve ennek alapján a kifejezések kiértékelése. 4.1. A verem absztrakt adattípus Az E alaptípus feletti V = V(E) halmazban mindazon vermek megtalálható, amelyek véges sok Ebeli elemből épülnek fel. Ide értjük az üres vermet is, amely nem tartalmaz elemet; ezt ennek ellenére, mint V(E)-beli adattípust, típusosnak tekintjük. A verem műveletei közé soroljuk az üres verem létrehozását (Üres), a verem üres állapotának a lekérdezését (Üres-e), adat betételét (Verembe), adat kivételét (Veremből) és annak a verembeli elemnek a lekérdezését (Felső), amely kivételre következik. Az utolsó művelet neve (Felső) utal arra, amit intuitív módon tudunk a veremről: az utoljára betett elemet lehet kivenni, amely függőleges elrendezésű verem esetén felül helyezkedik el. A veremből való kivétel és a felső elem lekérdezésének műveletét ahhoz az előfeltételhez kötjük, hogy a verem nem lehet üres. Absztrakt szinten úgy tekintünk a veremre, mint amelynek a befogadóképessége nincs korlátozva, vagyis a Tele-e lekérdezést itt nem vezetjük be. A verem adattípus absztrakt leírása során nem támaszkodhatunk szerkezeti összefüggésekre, azok nélkül kell specifikálnunk ezt az adattípust. Ennek kétféle módját ismertetjük. 4.1.1. Algebrai specifikáció Először megadjuk a verem műveleteit, mint leképezéseket. Ezek közül talán csak az Üres művelet értelmezése lehet szokatlan: egyrészt létrehoz egy vermet, amely nem tartalmaz elemeket (lásd: deklaráció a programnyelvekben), másrészt az üres verem konstans neve is. Az Üres tehát egy konstans, ezért, mint leképezés nulla argumentumú. Az alábbi műveleteket vezetjük be. Üres:  V

Üres verem konstans; az üres verem létrehozása

Üres-e: V  L

A verem üres voltának lekérdezése

Verembe: V  E  V

Elem betétele a verembe

Veremből: V  V  E

Elem kivétele a veremből

Felső: V  E

A felső elem lekérdezése

Megjegyezzük, hogy a Veremből műveletet úgy is lehetne definiálni, hogy a kivett elemet nem adja vissza, hanem „eldobja”. Az a művelet, amelyet így vezetnénk be, egy törlő utasítás lenne; ennek eredménye nem egy (verem, elem) rendezett pár, hanem csak az új verem lenne. Megadjuk a leképezések megszorításait. A Veremből és a Felső műveletek értelmezési tartományából ki kell vennünk az üres vermet, arra ugyanis ez a két művelet nem értelmezhető. DVeremből = DFelső = V \ {Üres}

Az algebrai specifikáció logikai axiómák megadásával valósul meg. Sorra vesszük a lehetséges művelet-párokat és mindkét sorrendjükről megnézzük, hogy értelmes állításhoz jutunk-e. Az alábbi axiómákat írjuk fel; magyarázatukat alább adjuk meg. 1. Üres-e (Üres)

vagy

v = Üres  Üres-e (v)

2. Üres-e (v)  v = Üres 3. ¬Üres-e (Verembe (v, e)) 4. Veremből (Verembe (v, e)) = (v, e) 5. Verembe (Veremből (v)) = v 6. Felső (Verembe (v, e)) = e 7. Felső (v) = Veremből (v).2 Az 1. axióma azt fejezi ki, hogy az üres verem konstansra teljesül az ürességet állító predikátum. Ezt változó használatával egyenlőségjelesen is megfogalmaztuk. A 2. állítás az üres verem egyértelműségéről szól. Ezt követi annak formális megfogalmazása, hogy ha a verembe beteszünk egy elemet, akkor az már nem üres. A 4-5. axiómapár a verembe történő elhelyezés és az elem kivétel egymásutánját írja le, mindkét irányból. Mindkét esetbe a kiinduló helyzetet kapjuk vissza. (Megjegyzendő, hogy a két axióma közül a másodikban a Verembe művelet argumentum-száma helyes, ugyanis a belső Veremből művelet eredménye egy (verem, elem) pár.) Végül, az utolsó két állítás a felső elem és a két vermet módosító művelet kapcsolatát adja meg. Egy ilyen axiómarendszertől először is azt várjuk, hogy helyes állításokat tartalmazzon. Természetes igény a teljesség is. Ez azt jelenti, hogy ne hiányozzon az állítások közül olyan, amely nélkül a verem meghatározása nem lenne teljes. Végül, a redundancia kérdése is felvethető: van-e olyan állítás a specifikációban, amely a többiből levezethető? 4.1.2. Funkcionális specifikáció A funkcionális specifikáció módszerében pusztán matematikai eszközök használatával olyan verem-fogalmat vezetünk be, amely talán közelebb áll a szemléletünkhöz, mint az axiomatikus leírás eredménye. Absztrakt szinten úgy tekintjük a vermet, mint (elem, idő) rendezett párok halmazát. Az időpontok azt jelzik, hogy az egyes elemek mikor kerültek a verembe. Kikötjük, hogy az időpontok mind különbözők. Ezek után tudunk a legutoljára bekerült elemre hivatkozni. A 4.1. ábrán szereplő absztrakt veremnek öt eleme van és utoljára a 80-as került a verembe.

4.1. ábra. A verem, mint érték-időpont párok halmaza (ADT)

Formálisan ez például a következő módon írható le: {(

)|

{

}



{

}

}

Ha a verem műveleteit szeretnék specifikálni, akkor azt most már külön-külön egyesével is megtehetjük, nem kell az egymásra való hatásuk axiómáit meggondolni. Definiáljuk például a Veremből műveletet. Az ismert elő-, utófeltételes specifikációs módszerrel azt írjuk le formálisan, hogy ez a művelet a veremből az utoljára betett elemet veszi ki, vagyis azt, amelyikhez a legnagyobb időérték tartozik. (Ha az olvasónak még sem lenne ismerős az alábbi jelölésrendszer, akkor elég, ha a módszer lényegét informális módon érti és jegyzi meg.) A = V E v

e

B=V

v'

Q = (v = v’  v’  )

R = ( v  v' \ e j , t j   e  e j  e j , t j  v'  i ei , t i   v'  i  j  : t j  t i ) Hangsúlyozzuk, hogy a fenti absztrakt reprezentáció csupán matematikai és nem tartalmaz semmiféle utalást a verem adattípus implementálásának a módjára. 4.2. A verem absztrakt adatszerkezet A verem, ha az absztrakt szerkezetét nézzük, elemeinek lineáris struktúrájaként mutatkozik. A 4.2. ábra szemlélteti a verem ADS-et, mint egy lineáris gráfot, valamint azt a megjelenési formát, ahogyan a veremre gondolunk, illetve, ahogyan a szakmai kommunikációban hivatkozunk rá. A két szerkezet lényegében nem különbözik egymástól; szemmel láthatóan megfeleltethetők egymásnak.

4.2. ábra. A verem, mint rákövetkező elemek (speciális lineáris gráf, ADS)

Az ADS szinten természetesen a műveletek is változatlanul jelen vannak. Ennek a szintnek a lényeget kifejező ábrázolási módja felhasználható arra, hogy a műveletek hatását szemléletesen bemutassuk. A 4.3. ábra a Verembe és a Veremből műveletek hatását illusztrálja.

4.3. ábra. Verem-műveletek szemléltetése: Verembe és Veremből (ADS)

4.3. A verem reprezentációja A verem adattípust egyaránt lehet tömbösen és láncoltan ábrázolni. Sorra vesszük ezt a két reprezentálási módot. 4.3.1. Tömbös ábrázolás A verem tömbös ábrázolásában a v[1..n] tömb mellett a felső elem k indexét, valamint a hiba logikai változót használjuk. A verem v jelölése ezeket a komponenseket együttesen jelenti. A 4.4. ábrán látható veremben tömbös ábrázolásba ugyanaz, mint amelyet a 4.1. ábra jelenített meg.

4.4. ábra. Verem tömbös reprezentálása

A tömbös reprezentáció is alkalmas a műveletek hatásának bemutatására. A 4.5.a és a 4.5.b ábra a Verembe és a Veremből műveletek eredményét mutatja be.

4.5.a. ábra. Verem-műveletek szemléltetése: Verembe (tömbös reprezentáció)

4.5.b. ábra. Verem-műveletek szemléltetése: Veremből (tömbös reprezentáció) Megadjuk a verem műveleteinek algoritmusait a tömbös reprezentációra. Mivel az elemeket tároló tömb betelhet, ezért bevezetjük a Tele-e műveletet is, amely még ADT és ADS szinten nem szerepelt. Az üres vermet k = 0 jelenti, míg k = n utal a tele veremre. A műveletek elvégzése után a hiba változó mindig értéket kap, sikeres művelet esetén hamisat, ellenkező esetben pedig a hibára utaló igaz értéket. A műveletek algoritmusai a 4.6. ábrán láthatók.

4.6 ábra. Verem műveletei tömbös reprezentáció esetén Könnyen látható, hogy a tömbös reprezentációban a verem minden művelete – függetlenül a verem méretétől – konstans időben végrehajtható: ( )

( )

ahol op a fenti verem-műveletek bármelyikét jelentheti.

4.3.2. Láncolt ábrázolás A verem láncolt reprezentációjában a v pointer típusú változó nem csak az adatstruktúrához biztosít hozzáférést, hanem egyben a verem felső elemére mutat. Ezért nem kell külön bevezetni egy felső elem mutatót. Üres verem esetén v = NIL.

4.7. ábra. Verem láncolt ábrázolása A 4.7. ábrán látható verem megegyezik azzal, mint amelyet absztrakt szinten, illetve tömbösen vezettünk be. Az elemek sorrendje értelemszerűen olyan, hogy a verem utoljára betett felső eleme a lánc elején található. A beszúrás és kivétel ilyen módon mindig a lista első elemére vonatkozik. Ennek a két műveletnek a hatását mutatja be a 4.8.a és a 4.8.b ábra.

4.8.a. ábra. Verem-műveletek szemléltetése: Verembe (láncolt ábrázolás)

4.8.b. ábra. Verem-műveletek szemléltetése: Veremből (láncolt ábrázolás) A láncolt ábrázolású verem műveletei között ismét nem szerepel a betelt állapot lekérdezése, mivel ebben a reprezentációban nem számolunk a tárolókapacitás gyakorlati felső korlátjával. A 4.9. ábrán látható algoritmusokban a verembe beszúrandó értéket adjuk meg a Verembe utasítás paramétereként, illetve a kiláncolt felső elem értékét kapjuk meg a Veremből utasítás paraméterében. Ennek megfelelően a művelete részeként a beszúrandó listaelemet létre kell hozni (new), illetve a kiláncolt listaelemet fel kell szabadítani (dispose).

4.9. ábra. A verem műveletei láncolt ábrázolás esetén

Úgy is meg lehet írni az utóbbi két műveletet, hogy a Verembe egy kész listaelem pointerét kapja meg, míg a Veremből a kiláncolt listaelem mutatóját adja vissza. Ezzel a paraméter átadásátvételi móddal majd a bináris keresőfa műveleteinél találkozunk. A láncolt ábrázolás esetén is fennáll az, hogy a verem minden művelete – függetlenül a verem méretétől – konstans időben végrehajtható: ( ) ahol op a verem-műveletek bármelyikét jelentheti.

( )

4.4. A verem alkalmazásai A verem adatszerkezetnek számos alkalmazásával találkozhatunk az algoritmusok és programok világában. Alapvetően egy sorozat megfordítására alkalmas: ABCD  DCBA. Ha azonban a verembe írást és a kivételt nem elkülönítve, egymás után két blokkban, hanem váltakozva alkalmazzuk, akkor egy sorozatnak nem csak a megfordítottját tudjuk képezni, hanem számos átrendezését meg tudjuk valósítani. Itt most két jellegzetes alkalmazást mutatunk be. 4.4.1. Kifejezések lengyelformája Lukasewich lengyel matematikus az 50-es években a matematikai formulák olyanfajta átalakítását dolgozta ki, amelynek segítségével a fordítóprogram könnyen ki tudja számítani a kifejezés értékét. (Pontosabban: olyan kódot generál, amely – végrehajtva – kiszámítja a kifejezés értékét.) Erre azért volt szükség, mert az ember által megszokott „infix” és zárójeles írásmód nem látszott alkalmas struktúrának a kiértékelés céljára. A bevezetett új ábrázolási formát a szerző tiszteletére lengyelformának is nevezik. Másik elnevezés a posztfix forma. (Valójában fordított lengyelformáról kellene beszélnünk, de ez a jelző gyakran elmarad a napi szóhasználatban.) Mind a lengyelformára hozás, mind pedig annak kiértékelése egy vermes algoritmus. Nézzük először a lengyelformára hozást. Egy aritmetikai kifejezés lengyelformájára a következők jellemzők:  nincs benne zárójel,  az operandusok sorrendje egymáshoz képest változatlan,  minden műveleti jel közvetlenül az operandusai után áll. Az utóbbi egyszerű állításban az, hogy egy műveleti jelről meg tudjuk állapítani, hogy a kifejezés mely egységei az operandusai, feltételezi az aritmetikai kifejezések felépítésének és értelmezésének ismeretét. Ezt előtanulmányaink sok éve alatt megbízhatóan elsajátítottuk. Néhány egyszerű, ám jellegzetes példán mutatjuk be a lengyelformát: a+b



ab+

a*b+c



ab*c+

(eltérő precedenciájú műveletek)

a * (b + c)



abc+*

(zárójel hatása)

a+b–c



ab+c−

(azonos precedenciájú műveletek)

a^2^3



a23^^

(hatványozás esetén fordítva: a ^ 2 ^ 3 = a ^ 8)

A példák alapján olyan vermes algoritmust tudunk létrehozni, amely megfelelően jár el az operandusokkal, a műveleti jelekkel, figyelembe véve precedenciájukat, illetve a zárójeleket is jól kezeli. Az algoritmust nem írjuk fel a szokásos formában, hanem csak a főbb pontjait fogalmazzuk meg az olyan kifejezésekre, mint amelyet a 4.10. ábrán láthatunk. Ezekben szerepelhet az értékadás, a négy alapművelet mellett a hatványozás jele is, valamint érvényesülhet a zárójelek módosító hatása. A műveleti jelek precedenciája a következő: :=, ^, {*, /}, {+, -},

ahol a * és a /, valamint a + és a – műveleti jelek precedenciára rendre azonos. Ha két azonos precedenciájú művelet kerül egymás után, akkor általában balról-jobbra kiértékelési szabály érvényes, kivéve, ha két hatványozás követi egymást: ott jobbról-balra kell a hatvány értékét kiszámítani. A vermes algoritmus inputja egy aritmetikai kifejezés, amelyet egységenként olvas. Outputja a kifejezés lengyelformája, amelyben az eredetitől eltérő sorrendben jelennek meg a műveleti jelek és zárójeleket nem tartalmaz. Az eljárás ezt egy verem használatával éri el. A vermes algoritmus alapvető jellemzői a következők:  az operandusok (változók, számok) az inputról közvetlenül az outputra kerülnek, vagyis nem kerülnek be a verembe;  a nyitó zárójel kötelezően beíródik a verembe, mintegy új vermet nyit az eredetin belül egy részkifejezés lengyelformájának létrehozására;  a csukó zárójel a verem tartalmát az első nyitó zárójelig kiüríti, és a lengyelformába írja, majd a veremben lévő nyitó zárójelet kiveszi a veremből és „eldobja”;  a műveleti jelek a precedencia-szabály figyelembe vételével a verembe íródnak: egy műveleti jel a nála kisebb precedenciájú műveleti jel fölé tehető a veremben, a nagyobb precedenciájút azonban ki kell előbb venni a veremből és a lengyelformába ki kell írni;  azonos precedenciák találkozása esetén – a hatványjel kivételével – a bejövő műveleti jel csak úgy helyezhető a verembe, ha előtte a felső elemet kivesszük onnan; két hatványjel esetében fordítva járunk el: a bejövő a másik fölé bekerül a verembe;  a kifejezés vége kiüríti a vermet és az elemeket a lengyelformába írja ki.

4.10. ábra. Kifejezés lengyelformára hozása

A lengyelformára hozás algoritmusának illusztrációja látható a 4.10. ábrán. A verem-tartalmakat mindig abban a pillanatban tünteti fel az ábra, amikor az operandusok kiírásra kerültek a lengyelformába.

4.4.2. Helyes zárójelezés feldolgozása (egymásba ágyazott folyamatok kezelése) Az egymásba ágyazott folyamatok kezelésre példát nyújt az eljáráshívások láncolata a programokban. Ennek legegyszerűbb modellje a helyes zárójelezés feldolgozása. A nyitó zárójel egy folyamat kezdetét, míg a csukó zárójel a folyamat befejezését jelenti. Egy beágyazott folyamat elkezdése kor a befoglaló folyamat megáll, és csak a hívott folyamat befejeződése után folytatódik. Definiáljuk először a helyes zárójelezés H nyelvét. Kétféle meghatározást is adunk. 1. Definíció: A helyes zárójelezések H  { (, ) }* nyelvére teljesülnek az alábbiak: (1)   H (2) Ha h  H, akkor (h)  H (3) Ha h1, h2  H, akkor h1h2  H Hozzá szokták tenni, hogy csak az (1), (2) és (3) pontok alkalmazásával nyert sorozatok a helyes zárójelezések, más nem az. 2. Definíció: A helyes zárójelezések H nyelvét pontosan azok a h  { (, ) }* sorozatok alkotják, amelyekre a következő két feltétel teljesül: (1) a nyitó és a csukó zárójelek száma a teljes sorozatban megegyezik; (2) a sorozat bármely kezdőszeletében legalább annyi nyitó zárójel található, mint amennyi csukó zárójel fordul elő. A definíció formálisan is felírható: h  H  l( (h) = l) (h)  u  Pre (h): l( (u)  l) (u) Az általánosan elterjedt l (s) jelölés az s sorozat hosszát (a benne lévő karakterek számát) jelöli, ennek általánosításaként bevezetjük a lx (s) jelölést: lx (s): s szövegben előforduló x karakterek száma. A másik jelölést az s sorozat kezdőszeleteinek halmazára vezetjük be: Pre (s): s karaktersorozat összes prefixuma (az üres karaktertől a teljes s-ig). Megfogalmazunk egy egyszerű feladatot, amely erősen egyszerűsített formában az egymásba ágyazott folyamatok kezelésének a lényegét tartalmazza. Ez nem más, mint egy folyamat kezdetének és a befejezésének az összepárosítása. Egy folyamat befejezése esetén ugyanis meg kell keresnünk a folyamat elkezdésére utaló bejegyzést és azt törölni kell. A folyamatok kezdetét és befejezését absztrakt formában egy nyitó és csukó zárójelpár azonosítja.

4.11. ábra. Helyes zárójelezés: összetartozó nyitó és csukó zárójelpárok

Feladat: Adott egy olyan input karaktersorozat, amely garantáltan helyes zárójelezést tartalmaz. Azonosítsuk az összetartozó nyitó és csukó zárójeleket olyan módon, hogy egymás után, párban írjuk ki az összetartozó zárójelpárok sorszámait. A 4.11. ábrán egy példát láthatunk az összetartozó nyitó és csukó zárójel-párok azonosítására. Ezek után megfogalmazzuk a helyes zárójelezés feldolgozásának algoritmusát, amely a 4.12. ábrán látható.

4.12. ábra. Helyes zárójelezés: összetartozó zárójelpárok meghatározása

A nyitó zárójeleket – pontosabban annak sorszámát – mindig beírjuk a verembe. Ha csukó zárójelet olvasunk, akkor a verem tetején találjuk a hozzá tartozó nyitó zárójel sorszámát, amelyet kiveszünk a veremből és a csukó zárójel sorszámával együtt kiírjuk az outputra.

5. SOR A sor adatszerkezet is ismerős a mindennapokból, például a várakozási sornak számos előfordulásával van dolgunk, akár emberekről akár tárgyakról (pl. munkadarabokról) legyen szó. A sor adattípus informatikai alkalmazásai talán nem annyira sokszínűek, mint a verem felhasználásai, de a lényegesebbek között említhető a fák és a gráfok szélességi bejárása. Ezzel azonban majd a későbbi fejezetekben találkozunk. 5.1. A sor absztrakt adattípus Az E alaptípus feletti sorok S = S(E) halmazát azon sorok alkotják, amelyek véges sok E-beli elemet tartalmaznak. Ide soroljuk az üres sort is, amely nem tartalmaz elemet; ezt ennek ellenére, mint S(E)-beli sort, típusosnak tekintjük. A sor műveletei között szerepel az üres sor létrehozása (Üres), a sor üres állapotának a lekérdezése (Üres-e), adat betétele (Sorba), adat kivétele (Sorból) és annak az elemnek a lekérdezése (Első), amely kivételre következik. Az utóbbi művelet neve (Első) utal arra, amit intuitív módon tudunk a sorról: az először, azaz legrégebben behelyezett elemet lehet kivenni, amely a sorban elől áll, vagyis az első elem. A sorból való kivétel és az első elem lekérdezésének műveletét ahhoz az előfeltételhez kötjük, hogy a sor nem üres. Absztrakt szinten úgy tekintünk a sorra, akár csak a veremre, mint amelynek befogadóképessége nincs korlátozva, így a Tele-e lekérdezést itt nem vezetjük be. A sor adattípus absztrakt leírása során nem támaszkodhatunk szerkezeti összefüggésekre, ezért azok nélkül kell specifikálnunk ezt az adattípust is. A specifikációnak itt is ugyanazt a kétféle módját ismertetjük, amelyeket már láttunk a veremnél. 5.1.1. Algebrai specifikáció Megadjuk a sor műveleteit, mint leképezéseket. Az Üres művelet egyrészt létrehoz egy sort, amely nem tartalmaz elemeket (lásd: deklaráció a programnyelvekben), másrészt az üres sor konstans neve is. Az Üres egy nulla argumentumú leképezés. Az alábbi műveleteket vezetjük be. Üres:  S

Üres sor konstans; az üres sor létrehozása

Üres-e: S  L

A sor üres voltának lekérdezése

Sorba: S  E  S

Elem betétele a sorba

Sorból: S  S  E

Elem kivétele a sorból

Első: S  E

A sor első elemének lekérdezése

Ha a Sorból műveletet úgy definiálnánk, hogy a kivett elemet nem adja vissza, hanem „eldobja” be, akkor ez a törlésre szorítkozna. Ebben az esetben az első elem előzetes lekérdezésével ismerhetnék meg a sor elejéről törölt elem értékét. A leképezések megszorításaihoz tartozik, hogy a Sorból és az Első műveletek nem értelmezhetők az üres sorra: DSorból = DElső = S \ {Üres} Az algebrai specifikáció logikai axiómák megadását jelenti. A veremhez hasonlóan, itt is sorra vesszük a lehetséges művelet-párokat és mindkét sorrendjükről megnézzük, hogy értelmes

állításhoz vezetnek-e. A következő axiómákat írjuk fel. az 1-es, illetve a 2-es index a pár első, illetve második komponensét jelöli: rendre egy sort, illetve egy elemet. s = Üres  Üres-e (s)

1.

Üres-e (Üres)

2.

Üres-e (s)  s = Üres

3.

†res-e (Sorba (s,e))

4.

Sorból (Sorba (Üres,e)) = (Üres, e)

5.

¬Üres-e (s)  Sorból (Sorba (s,e))2 = Sorból (s)2

6.

¬Üres-e (s)  Sorba (Sorból (s)1, e) = Sorból (Sorba (s, e))1

7.

Első (s) = Sorból (s)1

vagy

Az 1. axióma azt fejezi ki, hogy az üres sor konstansra teljesül az üresség predikátuma. Ezt változó használatával, egyenlőségjelesen is megfogalmaztuk. A 2. axióma az üres sor egyértelműségéről szól. A 3. állítás szerint, ha a sorba beteszünk egy elemet, akkor az már nem üres. A 4. axiómával ki tudjuk venni a következő két állítás értelmezési tartományából azt az esetet, amikor az egymás után alkalmazott műveletpár üres sorból indul ki. Az 5-6. axiómapár a nem üres sorba történő elhelyezés és az elem kivétel egymásutánját írja le, mindkét irányból. Végül, az utolsó állítás az első elem és a sorból való kivétel kapcsolatát adja meg. A helyesség, a teljesség és a redundancia kérdésében itt is érvényes az, amit a verem algebrai specifikációjánál mondtunk. 5.1.2. Funkcionális specifikáció A funkcionális specifikáció módszerével, amely pusztán matematikai eszközöket használ, hasonló sor-fogalmat vezetünk be ahhoz, mint a verem esetében. Absztrakt szinten úgy tekinthetjük a sort, mint (elem, idő) rendezett párok halmazát. Az időpontok azt jelzik, hogy az egyes elemek mikor kerültek a sorba. Kikötjük, hogy az időpontok mind különbözők. Ezek után tudunk a legrégebben bekerült elemre hivatkozni. A 4.1. ábrán szereplő absztrakt sor megegyezik a verem ADT 4.1. ábráján láthatóval. A sornak öt eleme van és először (legrégebben) a 40-es érték került a sorba. Az absztrakt reprezentáció formálisan ugyanaz, vagyis megegyezik a veremre és a sorra, de a műveletek különbséget tesznek közöttük. A verem esetén azt az elemet választjuk ki, amelyikhez a legnagyobb időpont tartozik, a sor esetében viszont éppen a legkisebb időponttal rendelkező érték az, amely aktuálisan kivételre kerül.

5.1. ábra. A sor, mint érték-időpont párok halmaza (ADT)

Formálisan ez például a következőképpen írható le: {(

)|

{

}



{

}

}

Ha a sor műveleteit szeretnék specifikálni, akkor azt most már külön-külön egyesével is megtehetjük, nem kell az egymásra való hatásuk axiómáiban gondolkodni. Definiáljuk például a Sorból műveletet. A programozás módszertanából ismert elő-, utófeltételes specifikációval írjuk le formálisan, hogy ez a művelet a sorból az előkét betett elemet veszi ki, vagyis azt, amelyikhez a legkisebb időérték tartozik. (Ha az olvasónak nem lenne ismerős az alábbi jelölésrendszer, akkor elég, ha a módszer lényegét informális módon érti meg.) A = S E s

e

B= S s

Q = (s = s’  s’  )

R = ( s  s' \ e j , t j   e  e j  e j , t j  v'  i ei , t i   v'  i  j  : t j  t i ) A sor fenti absztrakt reprezentációja matematikai jellegű és nem tartalmaz semmiféle utalást a sor adattípus implementálásának a módjára. 5.2. A sor absztrakt adatszerkezet A sor absztrakt szerkezete elemeinek lineáris struktúrájaként jelenik meg. Az 5.2. ábra úgy szemlélteti a sor ADS-t, mint egy speciális lineáris gráfot. Az ábrán az a megjelenési forma is látható, ahogyan a sor gondolatainkban jelentkezik, ahogyan a szakmai kommunikációban hivatkozunk rá. A két absztrakt szerkezet nyilvánvalóan megfeleltethető egymásnak.

5.2. ábra. A sor, mint rákövetkező elemek speciális gráfja (ADS)

Az ADT szinten bevezetett műveletek az ADS szinten természetesen is változatlanul jelen vannak. Az a lényeget kifejező ábrázolási mód, amely ennek a szintnek a sajátja, jól használható arra, hogy a műveletek eredményét szemléletesen bemutassuk. Az 5.3. ábra a Sorba és a Sorból műveletek hatását illusztrálja.

5.3. ábra. Sor-műveletek szemléltetése (ADS)

5.3. A sor reprezentációja A sor adattípust egyaránt lehetséges tömbösen és láncoltan ábrázolni. Alább ismertetjük ezt a két reprezentálási módot. 5.3.1. Tömbös ábrázolás Ha az 5.4. ábrára tekintünk, akkor egy sor három állapotát láthatjuk. Kezdetben a sor a tömb elején kezdődik, később azonban, ahogy kiveszünk belőle elemeket, a tömb „belsejébe húzódik”, majd elérve a tömb végét, a beszúrás ciklikusan folytatódik a tömb elején. Ennek megfelelően kétféle megoldás adódik a tömb eleje és vége jelzésére. (1) Kézenfekvő az, hogy jelölje két index a sor elejét és végét. Ábráinkon e és j ez a két index. Ennek az ábrázolásnak az a hátránya, hogy az üres sor és a tele sor nem különböztethető meg a két index alapján. Ezen például úgy lehet segíteni, hogy a tömbben egy elemet kötelezően üresen hagyunk, vagyis a sor kapacitása egy elemmel kevesebb lesz, mint a tömb mérete.

(2) Egy másik lehetőség az, ha egy index jelzi a sor elejét és emellett megadjuk a sor hosszát. Ábráinkon e és h jelzi ezt a két értéket. Ekkor nem veszítünk elemet a tömbből. A sor tömbös ábrázolásában az s[1..n] tömb mellett tehát (1) az első és utolsó elem indexét vagy (2) az első elem indexét és a sorhosszat használjuk, valamint a hiba logikai változót is a reprezentáció részének tekintjük. A sor s jelölése ezeket a komponenseket együttesen jelenti. Az 5.4. ábrán látható sor néhány állapotának tömbös ábrázolásban, a sor határainak mindkét fajta jelzése mellett.

5.4. ábra. Sor tömbös reprezentálása

Az ADS szintű ábra mellett a tömbös reprezentáció is alkalmas a műveletek hatásának bemutatására. Az 5.5.a és az 5.5.b ábra a Sorból és a Sorba műveletek eredményét illusztrálja szemléletes módon.

5.5.a. ábra. Sor-műveletek szemléltetése: Sorból (tömbös reprezentáció)

5.5.b. ábra. Sor-műveletek szemléltetése: Sorba (tömbös reprezentáció)

Megadjuk a sor műveleteinek algoritmusait a tömbös reprezentációra. Ezen belül az első elem indexét és a sorhosszat használjuk a sor tömbbeli elhelyezkedésének azonosítására. Mivel az elemeket tároló tömb betelhet, ezért bevezetjük a Tele-e műveletet is, amely még ADT és ADS szinten nem szerepelt. Az üres vermet h = 0 jelenti, míg h = n utal a tele veremre. A műveletek elvégzése után a hiba változó mindig értéket kap, sikeres művelet esetén hamisat, ellenkező esetben pedig a hibára utaló igaz értéket. A műveletek algoritmusai az 5.6. ábrán láthatók.

5.6. ábra. Sor műveletei tömbös reprezentáció esetén Látható, hogy a tömbös reprezentációban a sor bármely – op-pal jelölt – művelete, függetlenül a sor méretétől, konstans időben végrehajtható: ( )

( )

5.3.2. Láncolt ábrázolás A sor láncolt reprezentációjában a s pointer típusú változó nem csak az adatstruktúrához biztosít hozzáférést, hanem egyben a sor első elemére is mutat. Ezért nem kell külön bevezetni egy első elem mutatót. Üres verem esetén s = NIL. Az s pointer mellett bevezetjük az u pointert is, amely a sor utolsó elemére mutat. Ez azért hasznos, mert ekkor egy új elem befűzéséhez nem kell s-től indulni, hanem közvetlenül, konstans időben elvégezhető a Sorba művelet is.

5.7. ábra. Sor láncolt ábrázolása Az 5.7. ábrán látható sor megegyezik azzal, amelyet absztrakt szinten, illetve tömbösen vezettünk be. Az elemek sorrendje értelemszerűen olyan, hogy a verem utoljára betett felső eleme a lánc elején található. A kivétel ilyen módon mindig a lista első elemére vonatkozik, a beszúrás viszont az utolsó elem után fűzi be az új elemet. Ennek a két műveletnek a hatását mutatja be az 5.8.a és a 5.8.b ábra. A szemléletes képre gyakran szükség van a pointeres algoritmusok megírásakor.

5.8.a. ábra. Sor-műveletek szemléltetése: Sorból (láncolt ábrázolás)

5.8.b. ábra. Sor-műveletek szemléltetése: Sorba (láncolt ábrázolás) Megadjuk a láncolt ábrázolású sor műveleteinek algoritmusait. A műveletek között ismét nem szerepel a betelt állapot lekérdezése, mivel ebben a reprezentációban nem számolunk a tárolókapacitás gyakorlati felső korlátjával.

5.9. ábra. A sor műveletei láncolt ábrázolás esetén Az 5.9. ábrán látható algoritmusokban a sorba beszúrandó értéket adjuk meg a Sorba utasítás

paramétereként, illetve a kiláncolt első elem értékét kapjuk meg a Sorból utasítás paraméterében. Ennek megfelelően a műveletek részeként a beszúrandó listaelemet létre kell hozni (new), illetve a kiláncolt listaelemet fel kell szabadítani (dispose). Az utóbbi két műveletet úgy is meg lehet írni, hogy a Sorba egy kész listaelem pointerét kapja meg, míg a Sorból a kiláncolt listaelem mutatóját adja vissza. Ezzel a paraméter átadásátvételi móddal majd a bináris keresőfa műveleteinél találkozunk. A láncolt ábrázolás esetén is fennáll az, hogy a sor minden művelete – függetlenül a sor méretétől – konstans időben végrehajtható: ( )

( )

ahol op a sor műveleteinek bármelyikét jelentheti.

5.4. A sor alkalmazásai A sor adatszerkezet alkalmazásai közül kiemeljük a bináris fák szintfolytos és a gráfok szélességi bejárását. A sor adatszerkezet arra szolgál, hogy ezekben a struktúrákban a gyökértől, illetve a startcsúcstól induló és az élek mentén távolodó bejárást mintegy arra ortogonális irányúvá módosítsa. Ezzel a két algoritmussal későbbi fejezetekben találkozunk.

6. LISTÁK Az előző fejezetekben megismerkedtünk a láncolt ábrázolással. Láttuk a verem és a sor, valamint – előre tekintve – a keresőfa pointeres megvalósításának a lehetőségét és előnyeit. A láncolt ábrázolással egy olyan dinamikus adatszerkezetet hozhatunk létre, amelyben az egyes rekordoktól a rákövetkezőikhez pointerek vezetnek. Lineáris adattípusok esetén erre a pointeres reprezentációra gyakran azt mondjuk, hogy ez a láncolt ábrázolás vagy a listával történő megvalósítás. A „lista” kifejezés a szakmai szóhasználatban kettős jelentésű; vonatkozhat a láncolt ábrázolásra, de utalhat egy önálló adattípusra is, ha értelmezzük a műveleteit. Ebben a fejezetben főként az utóbbi értelemben beszélünk a listákról. 6.1. A listák absztrakciós szintjei A lista egy önálló adattípus, amelyhez hozzá tartoznak saját műveletei. A listát, a többi típushoz hasonlóan lehet az absztrakt adattípus (ADT) szintjén is definiálni, ettől azonban most eltekintünk. Az absztrakt lista adatszerkezet (ADS) bevezetésének sincs akadálya, de nem okoz hiányt a tárgyalásban, ha ezt a szintet – az egyetlen erre utaló 6.1. ábrával – lényegében átlépjük most, és közvetlenül az ábrázolás szintjén kezdjük a listák tárgyalást.

6.1. ábra. A lista absztrakt adatszerkezet (ADS)

A reprezentáció szintjén két alapvető ábrázolási módot alkalmazunk ezúttal is: a pointeres és a tömbös megvalósítást. Ha megkülönböztetjük a – reprezentáció eszközeként használt – lista adatszerkezet és a – saját műveletekkel rendelkező – lista adattípust, akkor felvetődik a következő kérdés: beszélhetünk-e a lista típus láncolt ábrázolásáról, illetve lehete a listákat tömbösen ábrázolni? A válasz mindkét kérdésre „igen”, azzal a megjegyzéssel, hogy az alkalmazások döntő többségében láncoltan megvalósított listákkal találkozunk. (A listák láncolt ábrázolása annyira természetes, hogy ezt a reprezentációt nem is tünteti fel külön cím, hanem majd csak a listák tömbös ábrázolását, ami viszonylag ritka megoldás.) Az itt következő tárgyalásban először megismerjük a listák lehetséges fajtáit, amelyeket esetünkben három tényező (fejelem, láncolás irányai, ciklikusság) alapján alakítunk ki. Az egyik tipikus listafajtára megadjuk a lista-műveleteket. Látni fogjuk, hogy itt már az egymáshoz is illeszkedő műveletek összehangolt rendszerére van szükség. A listákat gyakran alkalmazzuk feladatok megoldásában. A listák tartalmával kapcsolatos tevékenységeket összeállíthatjuk a típusműveletekből, illetve megvalósíthatjuk alacsonyabb szintű listakezeléssel, amelyben kívülről „látjuk” a pointereket, és segítségükkel magunk kezeljük az listán történő lépkedést, az elemek tartalmának elérését. Ha a lista műveleteit használjuk, akkor a pointereihez közvetlenül nem férhetünk hozzá, csak a műveleteken keresztül tudjuk elérni az elemeket. Az algoritmikus szempontból egyszerűbb feladatok megoldásban járhatunk el így, vagyis a lista-műveletek alkalmazásával. A feladatok megoldásban gyakrabban találkozunk a közvetlen listakezeléssel. Példaként egy lista helyben történő megfordítását látjuk majd. Utalhatunk a beszúró rendezés listás változatára is (lásd: 12. fejezet), amely már összetettebb feladatnak számít.

A listákat lehet tömbösen is ábrázolni, ahol a rákövetkező elemhez nem egy pointer, hanem egy index érték vezet. A megvalósítás tömbös eszközéhez folyamodunk például egy olyan programnyelv esetén, amely nem tartalmazza pointer nyelvi elemét. 6.2. A listák fajtái A lista adattípust az ábrázolás szintjén ismertetjük. A fejezet nagyobb részében a láncolt megvalósítást részletezzük. Először a szerkezeti lehetőségeket vesszük sorra, utána bevezetjük a lista műveleteit. A lista leggyakoribb megvalósításában a rekordok közötti kapcsolatot pointerek biztosítják. Ahhoz, hogy a lista első elemét is elérjük, egy arra mutató pointerre is szükségünk van. Ezt vagy egy közvetlen mutató biztosítja, vagy a listát kiegészítjük egy fizikai első elemmel, a fejelemmel, amelynek pointere mutat a logikailag első listaelemre. A listát azonosító önálló pointer ebben az esetben a fejelemre mutat. Egy lista ebből a szempontból lehet fejelem nélküli, vagy fejelemes. A pointerek láncán végighaladva sorban érhetjük el a lista elemeit, az elsőtől az utolsóig. Ha nem kell a memóriával erősen takarékoskodnunk, akkor kiegészíthetjük a listaelemeket visszafelé irányuló, a megelőző elemre mutató pointerekkel. A lista láncolása ennek megfelelően lehet egyirányú, vagy kétirányú. A lista utolsó elemének előre mutató pointere szokás szerint NIL, mivel a rákövetkező elemek sora nem folytatódik tovább. A bejárást még rugalmasabbá teszi az, ha az utolsó elem pointere visszamutat az első elemre. Az ilyen listát nevezzük ciklikusnak, míg az eredetit nemciklikusnak (ha hangsúlyozni szeretnénk a ciklikusság hiányát). A felsorolt három tényező mindegyike két-két lehetőséget kínál. Ezeket tetszőlegesen össze lehet párosítani, így nyolc lista fajtához jutunk. Ezek közül jobbára a következő hármat használják: 

az egyszerű listának nevezett valóban legegyszerűbb szerkezetet, amely nem tartalmaz fejelemet, egyirányú és nem ciklikus;



a fejelemes egyszerű listát, amely annyiban különbözik az előzőtől, hogy tartalmaz fejelemet;



a legtöbb lehetőséget támogató listát, amely tartalmaz fejelemet, kétirányú láncolás köti össze elemeit és ciklikus.

A felsorolt listafajtákat a 6.2. ábra szemlélteti.

6.2. ábra. A listák néhány fajtája

A fejelemes, kétirányú, ciklikus lista esetén meg kell fontolni annak a két mutatónak az irányítását, amelyek a ciklikusságot biztosítják. A fejelem visszafelé mutató pointere természetes módon az utolsó listaelemre mutat. Az utolsó elem előre irányuló pointerét irányítsuk a fejelemre. (Irányíthatnánk az első elemre is, de célszerűbb, ha az utolsó elemtől a fejelemhez vezet az út.) Két érv, ami emellett szól: a szimmetria, ami a többi közvetlen „odavissza” pointeres kapcsolatban lévő elempár között fennáll; valamint az, hogy így könnyebb ellenőrizni, hogy a lista végére értünk. 6.3. Listák műveletei Kiválasztunk egy listafajtát, és megadjuk rá a listaműveleteket, pontosabban a műveletek egy lehetséges halmazát, hiszen több megoldás is lehetséges. Általában, a listaműveletek témakörében erős sokféleség uralkodik, ami nem zavaró. Az alábbi tárgyalásban néhol – zárójelben – rámutatunk az alternatív lehetőségekre. A műveleteknek egy ilyen halmaza már átgondolt tervezést kíván. A műveleteknek ugyanis illeszkedniük kell egymáshoz, valamint egységes arculatot is kell mutatniuk. Legyen az egyszerű fejelemes lista az, amelynek megadjuk a műveleteit. A műveletek bevezetésének az alapja az, hogy értelmezzük a lista aktuális elemét, amelyet az akt pointer azonosítja. A műveletek legtöbbje az aktuális elemre vonatkozik. A műveletek általában módosítják is azt, hogy melyik elem lesz ezután az aktuális. Az akt pointer lehetséges értékeit a következőképpen határozzuk meg: 

mutathat a lista elemeire;



nem mutathat azonban a fejelemre, viszont

 „leléphet” az utolsó elemről és ekkor NIL az értéke. (Az utóbbi két esetben ellenkezőleg is dönthettünk volna.) A műveletek egy részének a végrehajtása hibához vezet, így szükségessé válik a hibakezelés. Ez megoldható például egy hibaváltozó logikai értékének a beállításával. Ekkor bevezethetünk egy olyan műveletet, amely a hibaállapotot kérdezi le, és hiba esetén törli ezt a státuszt. (A lista adattípus részeként bevezethetők további változók is, pl. az akt pointer mögött járó mutató, vagy az utolsó elem pointere. Olyan megoldást is lehet látni, amelyben az akt pointer fizikailag az aktuális elem előtti elemre mutat.) Nem foglalkozunk a nyelvi implementáció kérdésével. Szemléletünk az osztály és az objektum fogalmai felé mutat. Az alábbi leírásokban az l pointer a lista fejelemére mutat, a listaelemek adatrészét adat, a pointer mezőt mut azonosítja. A teljes lista adatstruktúra részét képező akt és hiba változókra közvetlenül hivatkozunk, és ezek nem szerepelnek az eljárások paraméterei között; a listát csak l azonosítja. Ha egy művelet a lista egy elemének az adatrészével végez műveletet, akkor az eljárás input vagy output paramétere lesz a megfelelő rekordtípusú változó. (Az adatmozgatás nem return utasítással és nem pointeres hozzáférés biztosításával történik, hanem a paraméter-átadás segítségével.) Összességében az alábbi tizenkét műveletet vezetjük be. Üres (l). Üres lista létrehozása; egyúttal az üres lista-konstans neve. A létrehozott fejelem pointere NIL, vagyis maga a lista üres, nem tartalmaz rekordot. Üres-e (l). Annak lekérdezése, hogy a lista üres-e. A logikai függvény az ennek megfelelő logikai értékkel tér vissza.

Hiba-e (l). Történt-e hiba az utolsó hiba-lekérdezés óta? A logikai függvény visszaadja a hiba-változó értékét, egyúttal törli a hiba-státuszt. Ebben a szemléletben a műveletek felhasználójának kell rákérdeznie a hibára. Elsőre (l). A lista első eleme lesz az aktuális elem. Üres lista esetén hibajelzést vált ki ez a művelet. Következőre (l). A lista következő eleme lesz az aktuális. Üres lista, vagy nem definiált aktuális esetén: hibajelzés. Ha az utolsó listaelem az aktuális, akkor a művelet hatására „lelép” erről az aktuális elem mutatója és értéke NIL lesz (nem számít hibának). Utolsó-e (l). Annak lekérdezése, hogy a lista utolsó eleme-e az aktuális? Üres lista, vagy nem definiált aktuális elem esetén hibajelzést kapunk. Vége-e (l). Annak lekérdezése, hogy az aktuális pointer „lelépett-e” a listáról? (EOFjellegű állapot.) Üres listára is teljesül (definíció szerint), hogy a végén vagyunk. AktÉrték (l, x). Hozzáférés az aktuális elem tartalmához. A művelet üres lista, vagy nem definiált aktuális elem esetén hibajelzést vált ki. AktMod (l, d). Az aktuális elem adat-tartalmának felülírása. Üres lista, vagy nem definiált aktuális elem esetén a művelet hibajelzést vált ki. BeszúrElsőnek (l, d). Rekord beszúrása első elemként a listába. Üres vagy nem-üres lista esetén egyaránt működik. A beszúrt első elem lesz az aktuális. BeszúrUtán (l, d). Rekord beszúrása az aktuális elem után. Üres lista, vagy nem definiált aktuális elem esetén ez a művelet hibajelzéshez vezet. Sikeres végrehajtás esetén a beszúrt rekord lesz a lista aktuális eleme. Töröl (l, x). A lista aktuális elemének törlése. Üres lista, vagy nem definiált aktuális elem esetén a művelet hibajelzést ad. Sikeres végrehajtás esetén a törölt elem utáni elem lesz az aktuális. A törölt rekord adattartalmát visszaadja az eljárás (e nélkül előbb az aktuális elem értékének lekérdezését kellene szükség esetén végrehajtani). Az egyszerű fejelemes listára specifikált műveleteink egy kivételével konstans időben végrehajthatók, függetlenül a lista méretétől. A kivételt az aktuális elem törlése jelenti, ugyanis a fejelemtől indulva, a pointerek láncán el kell jutni az aktuális elemet megelőző elemhez, mivel annak pointerét módosítani kell: ugyanaz az érték kerül oda, mint ami a törlendő aktuális elem pointer-mezőjében található. Formálisan kifejezve: ( )

( ),

illetve ( )

( ),

ahol op az egyszerű fejelemes lista bármely más (az aktuális elem törlésétől különböző) műveletét jelöli. A törlés lineáris műveletigénye – és számos feladat – vezetett arra a gyakorlatra, hogy ma már jobbára a legösszetettebb lista fajtát alkalmazzák. Mivel a memóriával józan keretek között lényegében nem kell takarékoskodni, egy visszafelé mutató pointer bevezetése a rekordokba nem okoz észrevehető terhet. Viszont, az aktuális elemet megelőző elem közvetlenül elérhetővé válik.

A műveletek algoritmusai a 6.3.a és a 6.3.b ábrán láthatók.

6.3.a ábra. Listaműveletek (1. rész)

6.3.b ábra. Listaműveletek (2. rész)

6.4. A művelek szintjén megoldott feladatok A tapasztalat azt mutatja, hogy a lista bevezetett műveletei kevésbé rugalmasak, mint maga a pointeres lista-struktúra. A mutatók közvetlen átállításával olyan feladatokat is meg lehet oldani, amelyeket a műveletek szintjén csak körülményesen tudunk kezelni. A lista műveleteiből alapvetően olyan egyszerű eljárások állíthatók össze, mint például az elemek a feldolgozása a listában elfoglalt helyük szerinti sorrendben. A 6.4. ábrán látható eljárás ezt a tevékenységet valósítja meg.

6.4. ábra. Lista feldolgozása (műveletek szintjén)

Az algoritmus hasonlóságot mutat a szekvenciális input fájlok feldolgozásához, tartalmazza az ott gyakran alkalmazott előre olvasás technikáját: egy iterációs lépésben először az aktuális elem feldolgozására kerül sor, utána a következő elem válik aktuálissá. Az iteráció így minden lépésében az előző lépésben előkészített elemet dolgozza fel, majd kiválasztja a rákövetkező elemet feldolgozásra a következő iterációs lépés számára. 6.5. A reprezentáció szintjén megoldott feladatok A listákkal kapcsolatos feladatok között könnyű olyanokat találni, amelyek megoldó algoritmusa jóval könnyebben adható meg saját pointer-kezelés mellett, mint a listaműveletek alkalmazásával. Egy ilyen példa egy egyszerű lista megfordítása helyben. Adott az l egyszerű (fejelem nélküli, egyirányú) lista. Az a feladat, hogy fordítsuk meg a lista elemeinek a sorrendjét helyben, azaz legfeljebb konstans méretű további memória felhasználásával. A megoldás elve a 6.5. ábrán látható. Az ábra első listája a kiinduló állapotot mutatja. Arra gondolunk, hogy alkalmazhatjuk az elemenkénti feldolgozás elvét. A listát mindenkor két részre lehet vágni: 1. a már megfordított sorrendű elejére és 2. a többi elem részlistájára. Kezdetben az első rész üres és a második részt a teljes lista alkotja. Végül az első rész kiterjed a teljes listára, míg a második rész kiürül, vagyis végül minden elemet megfordítottunk. Minden lépésben leválasztunk egy elemet a lista második feléből és bevonjuk a sorrend megfordításába. A második ábra egy pillanatfelvétel a megoldás folyamatának egy közbülső állapotáról. A lista első két elemének a sorrendjét már megfordítottuk. A még fel nem dolgozott részből az ábrán már leválasztottuk az első elemet (a 10-eset), amelyet a q pointer

mutat, míg a további elemek kezdetére a p mutat. A leválasztott elem befűzése a már megfordított rész elejére két mutató átállításával megoldható.

6.5. ábra. Egyszerű lista megfordításának fázisai

Az algoritmust, amelyet az ábra és annak elemzése alapján már könnyen el lehet készíteni, a 6.6. ábra illusztrálja.

6.6. ábra. Egyszerű lista megfordítása (reprezentáció szintjén)

Érdemes felfigyelni arra, hogy a megfordított sorrendű első részt üres listával inicializáltuk, nem pedig egy-elemű listával. Ez több szempontból is így javasolható: az eljárás működik üres listára is, egyszerűbb és elegánsabb a kód és könnyebben bizonyítható annak helyessége.

6.6. Listák tömbös ábrázolása A lista típus természetes megvalósítása pointerek alkalmazásával történik. A pointerek valójában memóriacímeket tartalmaznak, amelyeket a számítógépes végrehajtás elfed előlünk. A lista tömbös ábrázolása esetén magunk kezeljük az elemek közötti rákövetkezést biztosító indexeket. A 6.7. ábra egy olyan tömbös lista-ábrázolást mutat, amelyben a tömb szabad helyei is láncolt kapcsolatban állnak.

6.7. ábra. Tömbben elhelyezett lista

A kétdimenziós tömbben elhelyezett lista ugyanaz, amely a 6.1. és 6.2. ábrákon is szerepel. Az l = 4 indexérték a lista első elemére mutat, az sz = 2 érték pedig a szabad helyek listájának kezdő indexe. Ebben az ábrázolásban a két lista terjedelme együttesen teszi ki a tömb helyfoglalását. Mindkét lista a másik „rovására” terjeszkedik. Ha a listába új elemet szúrunk be, akkor az a szabad helyek listájából láncoljuk ki és töltjük ki a megfelelő tartalommal. Ha törlünk egy elemet a listából, akkor azt a szabad helyek közé láncoljuk be. A szabad helyek listájába célszerű első elemként beszúrni a törléskor felszabaduló elemet és megfordítva: beszúrás esetén legjobb az első szabad elemet kiláncolni, és azt a lista megfelelő helyére átfűzni. Ebben a működésben az adattartalmak a helyükön maradnak a tömbben. Az adatszerkezet dinamizmusát, az egyes tömbelemek „közlekedését” a két lista között az indexek értékeinek megfelelő beállításával valósítjuk meg.

7. BINÁRIS FÁK Az előző fejezetekben már találkoztunk bináris fákkal. Ezt a központi fontosságú adatszerkezetet most vezetjük be a „saját helyén” és az általános fák szerepét szűkítve, csak a bináris fát mutatjuk be. Jelentőségét alapvetően az adja, hogy több olyan adatszerkezetet is használunk, amelyek speciális bináris fákként értelmezhetők. Ilyen adatstruktúra például a kupac vagy a bináris keresőfa. 7.1. A bináris fa absztrakt adattípus A bináris fát, mint adattípust ebben a fejezetben lényegében csak két absztrakciós szinten definiáljuk, azokon sem teljes körűen. Az ADT szintre ezúttal nem fordítunk túl nagy figyelmet. Ennek az indoka kettős. Az ADT szinten olyan meghatározást várunk egy típusra, amely nem utal szerkezeti összefüggésekre. A két ismert matematikai definíció közül az egyik a speciális gráfként vezeti be a bináris fát, tehát a gráfok révén a szerkezetről beszél, így ezt inkább az ADS szinthez sorolnánk. A másik definíciót már jobban ehhez a szinthez tartozónak lehet mondani. Ez úgy határozza meg a t bináris fát, mint ami vagy az üres fa (ezt  jelöli), vagy pedig egy olyan (c, t1, t2) hármast, ahol c egy csúcs, t1 és t2 bináris fák; itt c-t a t fa gyökerének nevezzük. Ebben a rekurzív definícióban nem nehéz felismerni a struktúrára való utalást. Ezért is szokták t1-et bal(t)-vel vagy 0(t)-vel, míg t2-t jobb(t)-vel vagy 1(t)-vel jelölni. A bináris fának annyira erős attribútuma a szerkezet, hogy nem érdemes anélkül beszélni róla. Az ADT szinthez tartozik a műveletek megadása is. Az itt szóba jövő műveletek elemi szinten teszik lehetővé a bináris fák felépítését, részeinek lekérdezését, a fa módosítását és lebontását. Ezek az elemi műveletek nem játszanak hangsúlyos szerepet a továbbiakban, ezért – ennél az egy adatszerkezetnél – nem is határozzuk meg pontosan a körüket. 7.2. A bináris fa absztrakt adatszerkezet Az ADS szinten megállapítjuk a szóban forgó típus absztrakt szerkezetét. A bináris fa struktúrája ezen a szinten lényegében a matematikai fa-fogalommal írható le. Az a definíció, amelyre az előző pontban utaltunk, speciális gráfként határozza meg a bináris fát. Eszerint a fa olyan körmentes irányított gráf, amelyre igazak az alábbiak: pontosan egy csúcsba nem vezet él (ez a gyökér); a többi csúcsba pontosan egy él vezet és minden csúcs elérhető a gyökérből, mégpedig egyértelműen. Az egyes csúcsokból kivezető élek száma minden fára korlátos (r > 0). Az elnevezése ekkor: r-áris fa. Gyakorlatban az élek rendezett szelektornevekkel címkézettek. Az r = 2 érték esetén beszélünk bináris fákról. Ha a fenti másik definíciót vesszük alapul és meglátjuk benne a bináris fa struktúráját, akkor tekinthetjük azt az ADS szintre tartozó specifikációnak. Annyiban hasznosabb ez a rekurzív meghatározás, mint az előző gráf-alapú definíció, hogy a legtöbb fákon értelmezett algoritmus, amelyek általában rekurzívan működnek, jó illeszkedik a bináris fa rekurzív szerkezetéhez. A 7.1. ábrán megadtuk a bináris fa két leggyakrabban előforduló ADS szintű képét. Az elsőn az élek irányítottak szülő-gyerek irányban. Ha a gyerek-szülő irányra is szükségünk lenne, akkor kétirányú éleket kellene felvenni, ilyen ábrázolással azonban nem ADS szinten alig találkozunk. A második, amelyen az élek irányítása nem szerepel, a bináris fa szokásos absztrakt ábrázolása. Az irányítás hiánya ellenére az élek kifejezik mindkét irányú szülőgyerek kapcsolatot.

7.1. ábra. Bináris fa absztrakt adatszerkezet (két változat)

A következő ábrákon három speciális bináris fa látható. A majdnem teljes bináris fának csak az alsó szinten lehet hiánya: megengedett, hogy egy levélcsúcsnak üresen maradjon a helye. Például a 7.2. ábrán szereplő fában a C belső csúcsnak nincsen bal gyereke.

7.2. ábra. Majdnem teljes bináris fa (ADS)

A majdnem teljes balra tömörített bináris fának az alsó szintjén a levelek balról-jobbra hézagmentesen helyezkednek el. Ilyen tulajdonságú fát illusztrál a 7.3. ábra.

7.3. ábra. Majdnem teljes balra tömörített bináris fa (ADS)

A teljes bináris fa minden előforduló szintjének teljes a kitöltöttsége. Vezessük be a famagasság fogalmát, amely az első szinten nulla értéket vesz fel, vagyis a magasság értéke 1-gyel kisebb, mint a szintek száma. Ekkora h = h(t) magasságú teljes bináris fa csúcsainak a száma 2h+1-1. A 7.4. ábrán egy 3-szintű vagy, ami ugyanaz, egy 2-magasságú teljes bináris fa látható, amely 7 csúcsot tartalmaz.

7.4. ábra. Teljes bináris fa (ADS)

Speciális bináris fa a keresőfa és a kupac. A bináris keresőfa alakja általános, olyan jellegű, mint amilyen a 7.1. ábrán szerepel. A kupac – alakját tekintve – majdnem teljes és balra tömörített bináris fa. Mindkét fát jellemzik még a csúcsokban tárolt értékek között fennálló összefüggések is. 7.3. A bináris fa reprezentálása A bináris fa esetén is kétféle ábrázolást mutatunk be. A láncolt ábrázolás az általános alakú fákhoz illeszkedik, míg a tömbös ábrázolás a teljes, illetve a majdnem teljes bináris fáknál jelenik meg. 4.3.1. Láncolt ábrázolás A bináris fa pointeres ábrázolásában is mutatók valósítják meg a rákövetkezési relációkat. A 7.5. ábrán olyan láncolt ábrázolás részletét láthatunk, amelyben csak a bal és jobb gyerek pointerek szerepelnek (a gyerek csúcsokból nem mutat vissza pointer a szülőre). .

7.5. ábra. Bináris fa pointeres ábrázolása (szülő pointerek nélkül, részlet)

A 7.6. ábrán szereplő láncolt ábrázolásban a szülő pointerek is megjelennek. A gyökérelemben ennek a pointernek kötelezően NIL az értéke.

7.6. ábra. Bináris fa láncolt ábrázolása (szülő pointerekkel, részlet)

Mindkét pointeres ábra ugyanazt a részletet tartalmazza. Ez a részlet olyan, hogy a 7.1-4. ábra négy bináris fája bármelyikéhez illik (azzal a megjegyzéssel, hogy az E csúcs bal pointere az utolsó három fa esetében NIL.) 4.3.2. Tömbös ábrázolás A teljes és a majdnem teljes balra tömörített bináris fa sajátossága az, hogy ha az elemeit szintfolytonosan érintjük, akkor egyetlen hiányzó csúcsot sem fedezünk fel, mert – egy bizonyos határpontig a levelek szintjén – minden csúcs szerepel a fában. Ha a csúcsokat szintfolytonos sorrendben egy tömbbe helyezzük, akkor hiánymentes kitöltéshez jutunk, ahogyan a 7.7. és a 7.8. ábrán látható.

7.7. ábra. Teljes bináris fa tömbös ábrázolása

7.8. ábra. majdnem teljes bináris fa tömbös ábrázolása

A tömbben a szülő-gyerek és a gyerek-szülő kapcsolatok az alábbi egyszerű képletek által kezelhetők maradnak: ind(bal(c)) = 2*ind(c)

és

ind(jobb(c)) = 2*ind(c) + 1, illetve

ind(szülő(c)) = ind(c) Az első két összefüggés értelemszerűen nem-levél csúcsokra alkalmazható, míg a harmadik képlet a gyökértől különböző csúcsokra érvényes. 7.4. A bináris fa bejárásai A fák jellegzetes algoritmusai a bejárások. A bináris fa rekurzív jellegéhez illeszkedően rekurzív módon könnyű definiálni a három féle bejárást, amelyek abban különböznek, hogy a gyökérelemet mikor érintjük a bejárás során.

7.9. ábra. Bináris fa preorder bejárása (ADT/ADS)

Ha a gyökérelemet először, a bal és jobb oldali részfa bejárásai előtt közvetlenül érintjük, akkor preorder bejárásról beszélünk. Ennek algoritmusát a 7.9. ábra mutatja, méghozzá ADT/ADS szinten. Amikor a gyökérelemet a bal és jobb oldali részfa bejárásai között érjük el, akkor inorder bejárást valósítunk meg. A 7.10 ábrán látható ez a bejárási stratégia, a fa láncolt ábrázolását véve alapul.

7.10. ábra. Bináris fa inorder bejárása (pointeres reprezentáció)

Ha a gyökérelemhez a bal és jobb oldali részfa bejárásai után jutunk el, akkor postorder bejárást valósítunk meg. A 7.11 ábrával illusztrált eljárást speciálisan a tömbös ábrázolásra adtuk meg.

7.11. ábra. Bináris fa postorder bejárása (tömbös reprezentáció)

Érdekes feladat egy bináris fa szintfolytonos bejárásának a megvalósítása. A csúcsok szintfolytonos sorrendű elérése ugyanis – szemléletesen szólva – ortogonális arra az irányra, amelyet az élek meghatároznak. Szintfolytonos bejárást készíthetünk egy sor adatszerkezet alkalmazásával. Az algoritmust néhány szabály alapján ezután már könnyen kialakíthatjuk. (1) A bejárás során a gyökérelem legyen az első érintett csúcs. (2) Amint egy csúcsot elértünk, akkor a gyerekeit helyezzük el a sorban. (3) A bejárás következő elemét vegyük a sorból. (4) Az eljárás addig folytatódik, ameddig a sor ki nem ürül.

7.12. ábra. Bináris fa szintfolytonos bejárása sor adatszerkezet segítségével (pointeres reprezentáció)

A 7.12. ábrán láthatjuk a teljes eljárást, amely üres fára is helyesen működik. Az iteráció feltételét (p ≠ NIL) ezzel összhangban választottuk meg és a sor kiürülésekor az eljárás ennek megfelelően (p = NIL) állítja be a terminálás feltételét. Az algoritmus jellemzője az is, hogy a Q sorba nem a csúcsok tartalmát, hanem csak azok pointereit helyezi be. Mind a négy fabejárásra teljesül az, hogy műveletigényük a bináris fa csúcsainak számában lineáris, ugyanis a bináris fa minden csúcsát pontosan egyszer érintik. 7.5. Rekurzív algoritmusok bináris fákon Az imént szereplő fabejáró algoritmusok közül az első három rekurzív hívási szerkezettel rendelkezik. A működés megfogalmazása illeszkedik a struktúra rekurzív szemléletéhez. Számos további, bináris fákon értelmezett algoritmussal találkozhatunk a különféle feladatok megoldása során, amelyeket szintén rekurzív módon a legkönnyebb leírni. Példaként tekintsük azt a feladatot, amely egy bináris fa leveleinek megszámolását tűzi ki. (Ebben felismerhetnénk az adott tulajdonságú elemek megszámolásának sztenderd feladatát, de most gondolkodjunk e nélkül.) A megoldást ismét fogalmazzuk meg néhány szabályban. Rekurzív megoldást keresünk. (1) Az üres bináris fa leveleinek száma nulla. (2) Az egyetlen csúcsból álló bináris fa egy levelet tartalmaz. (3) Egy összetett bináris fa leveleinek száma megegyezik a gyökérpont alatti bal és jobb oldali részfa levél-számának összegével. Az utolsó pontban szereplő összetett bináris fát az a tulajdonság azonosítja, hogy a gyökerének legalább az egyik oldali részfája nem üres.

A megoldó algoritmus három változatát láthatjuk a következő ábrákon. A 7.13. ábrán szereplő eljárás egy globális input-output típusú számláló változót tartalmaz a paraméterlistáján. Az eljárás külső hívása helyén, a vívás előtt ezt a számlálót a nulla kezdőértékkel inicializálni kell. Amikor a rekurzív hívások láncolata egy levélhez ér, akkor az eljárás még meghívja önmagát a bal és a jobb oldali üres részfákra, ami nem módosítja a számláló értékét. Ez után kerül lekérdezésre az, hogy üres volt-e mindkét oldali részfa. Ha igen, akkor a számláló értékét növelni kell eggyel, mert levél csúcsnál tart az eljárás.

7.13. ábra. Bináris fa leveleinek száma globális számlálóval

Az eljárás következő változata a 7.14. ábrán, amely lokális számláló kimenő paraméterrel működik, tisztább szerkezetűnek mondható. Az algoritmus logikája megfelel előzetes megfontolásunk (1)-(3) pontjainak. A külső hívás helyén s-et nem kell inicializálni.

7.14. ábra. Bináris fa leveleinek száma lokális számlálóval

A 7.15. ábrán bemutatott függvényeljárás követi az előző változat világos szerkezetét. A visszaadott függvényérték használata és a return utasítás szerkezete tömör, matematikai írásmódot tesz lehetővé.

7.15. ábra. Bináris fa leveleinek száma rekurzív függvény visszatérő értékével

Megjegyezzük, hogy számos olyan gyakori egyszerű feladat, amelyekkel jobbára tömbökre megfogalmazva találkozunk (például adott tulajdonságú elem keresése vagy a maximum kiválasztás feladata), értelmezhetők más struktúrákra, így bináris fákra is. A bináris keresőfákról önálló fejezet szól ebben a tananyagban. A keresőfa műveletei is megfogalmazhatók rekurzív módon, egy helyen meg is adjuk ezt a változatot, azonban a műveletek iteratív változatát részesítjük előnyben (lásd: 11. fejezet).

8. ELSŐBBSÉGI SOR Az elsőbbségi sor köznapi előfordulásának megfelel minden olyan tároló, amelyből mindig a legnagyobb (vagy a legkisebb) elemet vesszük ki következő lépésben. Amíg például a háziorvosi rendelőben a páciensek várakozási sort alkotnak, addig egy sürgősségi ügyeleten a betegek ellátása az állapotuk súlyossága szerint történik; azt is mondhatjuk, hogy ebben a helyzetben egy elsőbbségi sor működik. Másik példa lehet a nagygépes operációs rendszerekben a prioritás-értékekkel rendelkező job-ok feldolgozása. Az elsőbbségi sor, amelyet prioritásos sornak nevezünk, egy maximum vagy minimum kiválasztó struktúra. Keressük azt az adatszerkezetet, amelyben az elsőbbségi sor tárolása, műveleteinek megvalósítása hatékony módon lehetséges. Olyan struktúrát szeretnénk találni, amelyben az az elemek és a hozzájuk tartozó prioritások elhelyezése, illetve kivétele minél rövidebb idő alatt végezhető el. Nem zárjuk ki, hogy céljainknak több adatszerkezet is megfelel. Előre vetítve megfontolásaink eredményét, három adatszerkezetet vizsgálunk meg és közülük egyet találunk igazán alkalmasnak az elsőbbségi sor ábrázolására. (Vizsgálataink eredményét általánosítva akár további hatékony adatszerkezeteket is feltételezhetünk, mint ahogy azok valóban rendelkezésünkre is állnak, a haladó tankönyvek és kurzusok tanulsága szerint.) Ha eltekintünk maguktól az objektumoktól, amelyekhez az elsőbbségi értékeket rendeljük, és pusztán a prioritások halmazán végezzük a beszúrás és a kivétel (törlés) műveleteit, akkor a célunk úgy is értelmezhető, mint egy hatékony maximum kiválasztó struktúra megtalálása. Ennek jelentőségét az adja, hogy számos algoritmus működésének a lényegét egy iterált maximum- vagy minimum-kiválasztás jelenti, így a hatékonyság meghatározó tényezője a legnagyobb vagy legkisebb érték gyors kiválasztása. A legtöbb gráfos algoritmusra érvényes ez a megjegyzés. 8.1. Az elsőbbségi sor és az absztrakciós szintek Célszerű, ha először szerkezeti elképzelések nélkül specifikáljuk az elsőbbségi sor fogalmát. Annál is inkább az, mivel egy hatékony reprezentáció megtalálása a célunk és könnyen lehet, hogy csak az ADT szint az, ami a különböző megvalósításokban közös. Kézenfekvő lehetőség az, hogy a prioritásokat egy tömbben tároljuk, rendezetlen módon. Ha végig gondoljuk, hogy az elsőbbségi sor – alább bevezetésre kerülő – műveletei hogyan működnek, akkor kínálkozik az az ötlet, hogy próbálkozzunk rendezett tömbös tárolással is. Ebben a két esetben azonos a reprezentáló adatszerkezet, így absztrakt szinten sem különböznek. Látni fogjuk azonban, hogy célszerű megpróbálkozni egy sajátos adatszerkezettel, a kupaccal is, amelyet absztrakt szinten bináris faként szemlélünk. Ezek szerint az ADS szint esetünkben nem mutat egyértelműen a megfelelő ábrázolás irányába. Az ADS szintet, mivel ott még nem tudunk egyértelmű absztrakt szerkezetet kialakítani, lényegében átlépjük. Mégis, különleges szerepe és jelentősége lesz az ADS szintnek a kupac adatszerkezet tanulmányozásában. A kupacot mindig tömbben tároljuk, de egy fastruktúrában gondoljuk el az elemeit! Nem lenne könnyű a kupac műveleteinek működését szigorúan a tömbön megtervezni vagy megérteni. A fa adatszerkezet azonban roppant szemléletes hátteret ad ehhez. Ezek előre bocsátása után térjünk vissza az ADT szinthez, éppen csak annyira, hogy nem formálisan specifikáljuk az elsőbbségi sort. Az algebrai specifikációnak nincs elvi akadálya, de a logikai axiómák ezúttal, az elsőbbségi sor definiálásánál, nehézkes eszközzé válnak.

A funkcionális specifikációhoz tartozott az adattípus matematikai reprezentálása. Ez most egyszerűen a prioritások halmaza, vagyis a p elsőbbségi sor absztrakt szinten az alábbi halmazzal definiáljuk: p = {k1, k2,…, kn} Feltesszük továbbá, hogy az elsőbbségi értékek természetes számok: j (1 ≤ j ≤ n): kj  ℕ Az üres sort az n = 0 érték jelzi. Ha felidézzük a verem és a sor matematikai reprezentációit, akkor azok is halmazok voltak, mégpedig (elem, időpont) rendezett párokból alkotott halmazok. Az időpont az elem beillesztésének idejét jelzi. Most, az elsőbbségi sornál, az időpont helyére a prioritás lép. Így (elem, prioritás) rendezett párokból kellene halmazt formálnunk, azonban az elemet magát – a modell egyszerűsítése jegyében – elhagyjuk. Bevezetjük a p  P prioritásos sorok műveleteit, ahol P jelöli az összes olyan p prioritásos sor halmazát, amely eleget tesz a fenti definíciónak. Megismételjük, hogy az egyszerűség kedvéért az elsőbbségi sorokba csak az egyes elemek prioritását tesszük be, az magukat az elemeket nem. Az elő-, utófeltételes specifikáció formalizmusát most mellőzzük, a műveletekre informális meghatározást adunk. 

Az Üres (p) művelet létrehoz egy üres prioritásos sort, amelyre a p azonosítóval hivatkozhatunk.



Az Üres-e (p) művelet a logikai igaz vagy hamis értéket adja vissza, annak megfelelően, hogy p üres, illetve tartalmaz elemet.



A PrSorból (p, x) művelet kiválasztja a p halmaz maximális elemét (az egyiket, ha több van) az eltávolítja onnan; az értéket az x változó kapja.



PrSorba (p, k) művelet elhelyezi p-ben a megadott k prioritást.



A MaxPrior (p) művelet lekérdezi a legnagyobb p-beli értéket és azt úgy adja vissza, hogy p változatlan marad.

Két művelet értelmezési tartományára kell megszorítást adnunk. A PrSoról és a MaxPrior művelet csak a nem-üres elsőbbségi sorra értelmezhetők. A p halmaz tehát nem lehet üres. A PrSorba művelet esetén a p elsőbbségi sor befogadóképességére nem adunk meg felső korlátot.

8.1. ábra. Elem kivétele az elsőbbségi sorból (ADT)

A PrSorból műveletet a 8.1. ábra szemlélteti, míg a 8.2. ábra a PrSorba művelethez nyújt illusztrációt.

8.2. ábra. Elem beszúrása az elsőbbségi sorba (ADT)

Az absztrakt adattípus informális leírása után az ADS szintre térünk át. Amint jeleztük, ezt a szintet most célszerű átlépni és a reprezentáció szintjén folytatni a meggondolásainkat. A fentiek szerint az ADS szint most nem elegendő támpontot megfelelő reprezentáció megtalálásához. Három adatszerkezetet tanulmányozunk, ezek közül az első kettő tömb, míg a harmadik, a kupac, ADS-szinten fastruktúrájú. A kupacos reprezentáció vizsgálatakor különleges szerepet nyer az ADS szint, ugyanis a kupacot mindig tömbben tároljuk, de az elemeit absztrakt szinten egy fastruktúrában helyezzük el. A kupac műveleteit így szemléletesen értelmezhetjük, és utána már csak vissza kell képezni a tömb szintjére. 8.2. Az elsőbbségi sor tömbös megvalósításai A prioritásokat kézenfekvő módon egy tömbben tároljuk. Először a rendezetlen tömbbel próbálkozunk, utána megvizsgáljuk, hogy a rendezett állapotban tartott tömbbel jobb lehetőséghez jutunk-e. 8.2.1. Rendezetlen tömb Tekintsük először azt a kézenfekvő lehetőséget, hogy az elsőbbségi értékeket egy A[1..n] tömbben tároljuk, rendezetlen módon, például annak megfelelően, ahogyan a beírás sorrendjében érkeznek az elemek. A tömbben tárolt k számú prioritás folyamatosan helyezkedik el az 1,... k indexű helyeken. Az üres, illetve a tele sort k = 0, illetve k = n jelzi. Az új prioritás beszúrását végző PrSorba művelet ezek szerint elhelyezi az új értéket a tömbben tárolt elemek utáni első szabad helyre. A 8.3. ábra szemlélteti ezt a lépést. A művelet egyszerű algoritmusa a 8.4. ábrán látható. Hiba abban az esetben lép fel, ha a sor betelt és a tömbben már nincs hely új elem befogadására. A beillesztés konstans időben elvégezhető: TPrSorba (k) = Θ(1) A legnagyobb prioritás megkeresését és kivételét végző PrSorból művelet először végrehajtja a sokszor alkalmazott maximum-kiválasztást. A megtalált maximális elem kivétele után annak helyre teszi a „jobb szélső” elsőbbségi értéket, így folytonos marad a tárolt elemek sora. A 8.5. ábra illusztrálja ez elsőbbségi sorból való kivételt. Az algoritmus a 8.6. ábrán látható. Hiba akkor lép fel, ha üres sorból próbálunk elemet kivenni. A műveletigény a maximum-kiválasztás által végrehajtott k-1 összehasonlítás miatt időben lineáris lesz: TPrSorból (k) = Θ(k)

8.3. ábra. Elem beszúrása az elsőbbségi sorba (rendezetlen tömb)

8.4. ábra. Elem beszúrása az elsőbbségi sorba, algoritmus (rendezetlen tömb)

8.5. ábra. Elem kivétele az elsőbbségi sorból (rendezetlen tömb)

8.6. ábra. Elem kivétele elsőbbségi sorból, algoritmus (rendezetlen tömb)

Összefoglalva azt mondhatjuk, hogy a rendezetlen tömbös megvalósítás esetén a két meghatározó művelet egyike konstans, míg a másik lineáris idejű. 8.2.2. Rendezett tömb Próbálkozzunk most azzal, hogy a prioritásokat rendezett állapotban tarjuk a tömbben. Ha a legnagyobb elsőbbségi érték a sorban az utolsó, akkor közvetlenül elérhető.

8.7. ábra. Elem kivétele az elsőbbségi sorból (rendezett tömb)

8.8. ábra. Elem kivétele elsőbbségi sorból, algoritmus (rendezett tömb)

A PrSorból művelet végrehajtását a 8.7. ábra mutatja be. A 8.8 ábrán az a művelet algoritmusa olvasható. Hiba abban az esetben lép fel, ha az üres sorból próbálunk elemet kivenni. Az elem kivétele a közvetlen elérhetőség miatt konstans időben végrehajtható: TPrSorból (k) = Θ(1)

8.9. ábra. Elem beszúrása az elsőbbségi sorba (rendezett tömb)

8.10. ábra. Elem beszúrása az elsőbbségi sorba, algoritmus (rendezett tömb)

A tömb kitöltött részének rendezettségét a PrSorba művelet biztosítja. Az új elsőbbségi értéket a nagyság szerint helyére kell beszúrni. Ezt a nagyobb elemek jobbra történő léptetésével érhetjük el, ahogyan a 8.9. ábrán láthatjuk. Az algoritmus megfogalmazását a 8.10. ábra tartalmazza. Az elemek rendezett sorába történő beillesztéshez szükséges összehasonlítások száma 1 és k közötti érték, így a műveletigény kifejezésében az „ordót” kell használnunk: TPrSorba (k) = Ο(k), illetve felírhatjuk a maximális műveletigényt: MTPrSorba (k) = Θ(k) Összefoglalva azt mondhatjuk, hogy a rendezett tömbös reprezentáció esetén – a rendezetlen tömbös tároláshoz hasonlóan – azt kaptuk, hogy a két meghatározó művelet egyike konstans, míg a másik (legfeljebb) lineáris idejű (az átlagos műveletigényt intuitív módon szintén lineárisnak gondoljuk). A rendezett tömbös megvalósítást halványan jobbnak érezhetjük, mert ha nem is nagyságrendben, de kevesebb lépést végez. Ráadásul a lineáris idejű művelet nem a kiválasztásra, hanem a beillesztésre fordítódik, ami valós idejű feladatoknál előny lehet. A hatékonyság egészen kis mértékben tovább javítható azzal, ha a maximális prioritás helyét megjegyezzük. Az elsőbbségi sor nagyságrendben hatékonyabb megvalósításához a kupacos reprezentáció vezet. 8.3. A kupac adatszerkezet Bevezetjük a kulcsfontosságú kupac (heap) adatszerkezetet. Ezt ADS szinten tesszük, aminek itt komoly szerep jut, mivel a kupacot absztrakt szinten bináris fának látjuk, viszont mindig tömbben tároljuk. A kupac absztrakt adatszerkezet speciális bináris fa, amelynek az alakjára és a csúcsokban tárolt értékek közötti összefüggésekre invariáns állításokat mondunk ki.

8.11. ábra. Kupac absztrakt képe (bináris fa) és tárolása tömbben

A kupac absztrakt adatszerkezet egy olyan speciális bináris faként definiáljuk, amely (i) majdnem teljes, azaz, legfeljebb a levelek szintjén hiányozhat csúcs; (ii) balra tömörített, ami a levélszint csúcsaira vonatkozik és (iii) minden belső csúcsokban tárolt érték nagyobb vagy egyenlő, mint a gyerekeinek az értékei. A 8.11. ábra egy kupacot szemléltet. Az absztrakt ábrázolású kupac bináris fára valóban teljesül az (i) és a (ii) előírás, a csúcsokban tárolt értékek között pedig fennáll a (iii) összefüggés. A kupac bináris fájában – a balra tömörítés miatt – egyetlen olyan belső csúcs lehet, közvetlenül a levelek szintje fölött, amelynek csak bal gyereke van. Egy n csúcsot tartalmazó majdnem teljes bináris fa magassága már nem csökkenthető tovább; értéke ℎ(𝑡𝑛 ) = ⌊𝑙𝑜𝑔2 𝑛⌋ A fában bármely olyan útvonalon, amely a gyökértől indul, és valamelyik levélben végződik, az elemek monoton csökkenő (illetve nem növekedő) sorozatot alkotnak. Az ábrán a kupac elemeinek a tömbös elhelyezése is látható. A tömbben látható sorrend ugyanaz, mint amelyet a bináris fa szintfolytonos bejárásával kapunk. A tömbben a szülőgyerek és a gyerek-szülő kapcsolatok az előző fejezetben megismert, alábbi képletek írják le: ind(bal(c)) = 2*ind(c) és ind(jobb(c)) = 2*ind(c) + 1 ind(szülő(c)) = ind(c) Az első két összefüggés értelemszerűen nem-levél csúcsokra alkalmazható, míg a harmadik képlet a gyökértől különböző csúcsokra érvényes. A fában említett rendezett útvonalak, amelyek a gyökértől valamely levélig tartanak, a tömbben is megtalálhatók; ezeket nevezhetjük leszálló rendezett láncoknak. A teljes tömb nem rendezett ugyan, de az elemeit tartalmazó speciális láncok azok. Ezzel a részleges rendezettséggel a kupac egy „kompromisszumos” középutat valósít meg a rendezetlen és a rendezett tömb között. 8.4. Az elsőbbségi sor kupacos megvalósítása Nézzük most az elsőbbségi sornak azt a reprezentációját, amikor a prioritásokat egy kupac adatszerkezetben tároljuk. A két meghatározó tevékenységet, a PrSorból és a PrSorba műveleteket szemléletes módon, a bináris fán értelmezzük, anélkül, hogy a tömbös ábrázolás szintjén megírnánk a precíz algoritmusaikat. (Megjegyezzük, hogy a kupacos rendezésről szóló fejezetben nem állunk meg az ADS szint vizualitásánál, hanem tömbös algoritmusokat tervezünk, amelyek közül az egyik éppen az itteni PrSorból művelet lesz.) A PrSorból művelet a legnagyobb értéket kiveszi a kupacból; lásd a 8.12. ábrán az (1)gyel jelölt lépést. A maximális elemet a fa gyökéreleme tartalmazza, ami közvetlenül elérhető, mint a tömbben első (1-es indexű) eleme. Ha ezt az értéket eltávolítjuk, akkor sérül a kupac tulajdonság, amit két nagyobb lépésben állítunk helyre. Először a fa alakjára vonatkozó fenti (i) és (ii) feltételeket juttatjuk érvényre azzal, hogy az alsó szint jobb szélső elemét felvisszük a gyökérbe, amint azt az ábra (2)-es lépése illusztrálja. A felvitt érték a tömb utolsó eleme, így a tárolt adatok folytonossága megmarad. A gyökérelembe került érték aligha lehet a legnagyobb új prioritás, ezért a nagyság szerint helyére „süllyesztjük” a fában. Ahogyan az ábrán a (3)-as és (4)-es lépés mutatja, a gyökeret addig cseréljük a nagyobb értékű gyerekkel, amíg helyre nem áll a fenti (iii) tulajdonság.

8.12. ábra. Elem kivétele elsőbbségi sorból; az eljárás (kupac, ADS)

A kupac új állapota, amely a maximális prioritás kivétele és a kupac tulajdonság helyreállítása után kialakult, a 8.13. ábrán látható.

8.13. ábra. Elem kivétele utáni új elsőbbségi sor (kupac, ADS)

A PrSorból művelet lépésszámát nagyságrendben felülről becsüli bináris fa magassága, ugyanis az első két értékadás után legfeljebb annyi cserét hajtunk végre, amennyivel a gyökérelem a levelek szintjére jut. A cseréket megelőzi annak a vizsgálata, hogy kell-e cserélni, illetve, ha igen, akkor melyik gyerekkel. Ezzel együtt nyilvánvalóan helyesek a 𝑇(𝑛) = 𝛰(log 𝑛) és 𝑀𝑇(𝑛) = 𝛩(log 𝑛) nagyságrendi becslések a lépésszámra az egyedi, illetve a legrosszabb esetben.

A PrSorba művelet egy új értéket helyez az elsőbbségi sorba. A kupac tulajdonságokat éppen úgy két nagyobb lépésben juttatjuk érvényre, mint ahogyan azt a PrSorból műveletnél láttuk. Az új érték helye az alsó szint jobb szélén lesz, ami a tömbös ábrázolásban hasonlóan a jobb szélső elemre következő cella hozzá vételét jelenti a sorhoz. Az új elemet – a szülő csúccsal esetleg többször megcserélve – addig „szivárogtatjuk fel”, amíg a nagyság szerinti helyére nem kerül.

8.14. ábra. Elem beszúrása elsőbbségi sorba; az eljárás (kupac, ADS)

Az új érték beszúrásának lépéseit a 8.14. ábra mutatja be. Az így kialakult kupac a következő, 8.15. ábrán látható.

8.15. ábra. Elem beszúrása utáni új elsőbbségi sor (kupac, ADS)

A PrSorba művelet lépésszámát szintén felülről becsüli nagyságrendben bináris fa magassága, hiszen a levelek szintjéről legfeljebb a gyökér pozícióig lehet emelni az új elemet. A cseréket még egy vizsgálat is megelőzi. A művelet lépésszáma nyilvánvalóan 𝑇(𝑛) = 𝛰(log 𝑛) és 𝑀𝑇(𝑛) = 𝛩(log 𝑛) nagyságrendű az általános, illetve a legrosszabb esetben.

Azt kaptuk, hogy a kupacos reprezentáció esetén mindkét vizsgált műveletnek egyaránt log 𝑛-es a futási ideje. Azt gondolhatjuk, hogy a kupaccal ábrázolt elsőbbségi sor nem csak kiegyensúlyozottabb, hanem összességében hatékonyabb is, mint a két tömbös megvalósítás. Ezt akkor látjuk igazán, ha egy olyan művelet sorozatot hajtunk végre, amelyben vegyesen szerepel a prioritási értékek beszúrás és kivétele. 8.5. Rendezés elsőbbségi sorral Találtunk egy olyan egyszerű algoritmust, amely a vizsgált műveletet egyenlő számban hajtja végre működése során. Az eljárás, amint a 8.17. ábráról leolvasható, az s szekvenciális inputról beolvasott értékeket rendre beteszi egy (kezdetben üres) elsőbbségi sorba, azután pedig sorban kiveszi és kiírja onnan az elemeket. A kiírt sorozat nyilvánvalóan rendezett lesz!

8.17. ábra. Rendezés elsőbbségi sorral, algoritmus (ADT)

Meglepőnek tűnhet, hogy ez a rendező eljárás nem tartalmaz egymásba ágyazott ciklust. Ennek az a magyarázata, hogy az ADT szintű algoritmus leírása elrejti előlünk a felhasznált adatszerkezet megvalósításának módját. Érdekes kérdés az, hogy ha az elsőbbségi sor három reprezentációját is figyelembe vesszük, akkor milyen rendezési eljárásokat kapunk ebből a közös absztrakt eljárásból származtatva. Azt javasoljuk, hogy a jegyzetnek a rendezésekről szóló fejezeteit áttanulmányozva, térjen vissza az Olvasó ehhez a kérdéshez! Akkor már biztosan maga is ellenőrizni tudja az alábbi megállapítások helyességét. Ha rendezetlen tömbben tároljuk az elsőbbségi értékeket, akkor lényegét tekintve a maximum kiválasztó rendezéshez jutunk. A rendezett tömbös elsőbbségi sor a beszúró rendezéshez vezet. A kupacos megvalósítás lényegében a kupacrendezést eredményezi, csekély eltéréssel. (A beillesztett n elemből a kupac nem úgy alakul ki, hogy összevárjuk az elemeket és utána kupacot formálunk belőlük, hanem a bejövő adatok kezdettől kupacot alkotnak, amelybe minden bejövő prioritást beszúrunk. Míg az előző módszer időben lineáris eljárás, addig az utóbbi 𝑛 log 𝑛-es futási idejű. (Miért?)) Elemezzük most algoritmusunk műveletigényét mindhárom reprezentáció esetén! Az elemzés n elem beszúrására és kivételére terjed, mindannyiszor a PrSorba és a PrSorból

műveletek lépésszámát figyelembe véve, rendre a három reprezentációban. A fentiek alapján érthető, hogy az egységesség kedvéért meggondolásaink a maximális lépésszámra (legrosszabb eset) vonatkoznak. Az ábrázolási módtól függetlenül érvényes az alábbi összefüggés: 𝑀𝑇(𝑛) = 𝑇Ü𝑟𝑒𝑠 + ∑

𝑛−1

𝑛

𝑘=0

𝑘=1

𝑀𝑇𝑃𝑟𝑆𝑜𝑟𝑏𝑎 (𝑘) + ∑

𝑀𝑇𝑃𝑟𝑆𝑜𝑟𝑏ó𝑙 (𝑘)

Számítsuk ki a fenti kifejezés nagyságrendjét az elsőbbségi sor három megvalósításában. 

Rendezetlen tömbös reprezentációban: 𝑀𝑇(𝑛) = 𝛩(1) + 𝑛𝛩(1) + 𝑛𝛩(𝑛) = 𝛩(𝑛2 )



Rendezett tömbös ábrázolás mellett: 𝑀𝑇(𝑛) = 𝛩(1) + 𝑛𝛩(𝑛) + 𝑛𝛩(1) = 𝛩(𝑛2 )



Kupacos megvalósítás esetén: 𝑀𝑇(𝑛) = 𝛩(1) + 2𝛩(log 2 1 + log 2 2 + ⋯ + log 2 𝑛) = 𝛩(𝑛 log 𝑛)

A nagyságrendek elhanyagolják a konstans szorzókat, amelyek bizonyos esetekben (csekély n elemszám, vagy igen nagy konstans) nem hagyhatók figyelmen kívül. Most vehetjük úgy, hogy a nagyságrendi értékek megfelelően tájékoztatnak a reprezentáció hatékonyságáról. Gondoljunk például arra, hogy ha ezer körüli rendezendő számunk van (n = 1000), akkor az első két tömbös esetben a rendezés milliós nagyságrendű lépést igényel, míg a kupacos reprezentációban ez a ráfordítás tízezres nagyságrendűre csökken. A ma már mindennaposnak számító ezres elemszám mellet két nagyságrendet nyerünk a kupac alkalmazásával. Az elemzés alapján joggal mondhatjuk, hogy az elsőbbségi sornak kupaccal megvalósítása hatékonyabb a másik két ábrázolási módnál. **** Megjegyzés. Az utóbbi a számításban az összegzés eredményének a nagyságrendjét még bizonyítani kell. Az egyes tagok felső becslése alapján, a jobb oldalon 𝛰(𝑛 log 𝑛) azonnal írható. Belátjuk azonban, hogy nagyságrendi egyenlőség áll fenn. A bal oldalon a zárójelben szereplő összeget, mint a log 2 𝑥 függvény kívül írt téglalapjainak területösszegét, tekintsük integrál közelítő összegnek. Ekkor 𝑛

𝑛

log 2 1 + log 2 2 + ⋯ + log 2 𝑛 ≥ ∫ log 2 𝑥 𝑑𝑥 = 1⁄ ∫ ln 𝑥 𝑑𝑥 = ln 2 1 1 𝑛 = 1⁄ [𝑥 ln 𝑥 − 𝑥] = 1⁄ [𝑛 ln 𝑛 − 𝑛 + 1] ≥ 𝑛log 2 𝑛 ln 2 ln 2 1

Ez a becslés másik iránya, amely alapján a nagyságrendi egyenlőség már valóban állítható.

9. KIVÁLASZTÁSOK Az algoritmusok tervezése során igen gyakran találkozunk a legkisebb vagy a legnagyobb elem kiválasztásának feladatával. Az sem ritka, hogy az ezektől eltérő sorszámú k-adik elemet kell megkeresnünk. Ebben a fejezetben a kiválasztások feladatát nézzük meg közelebbről, és nem csak megoldó algoritmusokat adunk – köztük egy véletlenített eljárást is –, hanem néhány alsó korlátot is bizonyítunk a lehetséges megoldások lépésszámára. 9.1 A kiválasztás feladata és néhány alsó korlát Ebben a fejezetben X legyen egy megszámlálható számosságú halmaz, melyen adott a ≤ teljes rendezés: reflexív, antiszimmetrikus, tranzitív és bármely két elem összehasonlítható. (Példáinkban X az egész számok halmaza lesz a szokásos rendezéssel.) Használjuk még az x < y jelölést arra az esetre, amikor x ≤ y és x ≠ y; ez egy úgynevezett erős rendezés. Jelölje X* az X-ből képzett véges sorozatok halmazát. Definíció: Egy u ∈ X*-beli sorozat növekvően rendezett, ha ∀i ∈ [1 … |𝑢| − 1] esetén (𝑢i ≤ 𝑢i+1 ). Hasonlóan definiálhatjuk a csökkenően rendezett sorozat fogalmát is. Megjegyzés. A továbbiakban rendezettség alatt mindig a növekvő rendezettséget értjük. Definíció: Legyen u ∈ X* tetszőleges sorozat és 1 ≤ k ≤ |u| tetszőleges egész. Az x ∈ X elemet az u sorozat k–adik elemének hívjuk, ha megegyezik az u rendezettjének k-adik elemével. Ha k = 1 akkor minimális, ha k = |u| maximális elemről, ha k = ⌈ |𝑢|/2⌉, akkor mediánról beszélünk. Például az u = 31, 45, 13, 24, 7, 12, 8 sorozat minimuma 7, maximuma 45 és a mediánja 13 (a rendezett sorozat ⌈ 7/2⌉ = 4 indexű eleme). A fejezet során azzal foglalkozunk, hogy milyen módon lehet megtalálni egy s ∈ X* sorozat k– adik elemét, illetve speciálisan a minimumát, maximumát és mediánját. Algoritmusaink az s sorozat elemeit nem ismerik. Csak annyit tudnak tenni, hogy feltesznek si ≤ sj alakú kérdéseket, és pusztán ezek eredménye alapján adják meg, hogy melyik az l index, melyre sl a kívánt tulajdonságú elem. Gyakran az l index csak implicit módon van benne az algoritmus eredményében, a tényleges eredmény (sl) egy x változóban áll elő. A fejezet során csak ilyen, úgynevezett összehasonlításos algoritmusokkal foglalkozunk. A szemléletesség kedvéért az összehasonlításban szereplő elemekre a kieséses sportversenyekhez hasonló terminológiát használunk. Ha valamely x ≤ y alakú kérdésre a válasz „igen”, akkor xi-t vesztesnek, míg yj-t győztesnek nevezzük. Ha még azt is tudjuk, hogy x ≠ y, akkor erős vesztesről, illetve erős győztesről beszélünk. Legyen u és v két egyforma hosszú, X elemeiből képzett sorozat. Az u-t és v-t hasonlónak nevezzük, ha minden 1 ≤ i, j ≤ |u| párra ui ≤ uj akkor és csak akkor, ha vi ≤ vj. Világos, hogy az összehasonlításos algoritmusok a hasonló sorozatokon egyformán működnek, emiatt ugyanazon kiválasztási eredményt is kell adniuk. Mielőtt a megoldó algoritmusok elkészítésébe belefognánk, bizonyítunk két tételt arról, hogy legalább hány összehasonlítást kell tenni ahhoz, hogy egy sorozat maximumát, illetve a k–adik elemét megtaláljuk.

Tétel. Legyen MaxKiv egy minden bemenetre jól működő összehasonlításos maximumkereső eljárás. Ekkor ahhoz, hogy MaxKiv bármely n hosszúságú bemenetre a garantáltan megtalálja a legnagyobb elemet, mindig legalább (n – 1) összehasonlítást kell végeznie. Természetesen hasonló tétel igaz a minimum kiválasztására is. Bizonyítás. Az állítást teljes n-re vonatkozó indukcióval látjuk be. Az n = 1 esetben nyilván nincs szükség összehasonlításra, hiszen a maximum megegyezik a sorozat egyetlen elemével. Tegyük fel, hogy az állítás n-nél (n ≥ 2) rövidebb sorozatok esetében igaz, és legyen s egy n hosszú sorozat. Vizsgáljuk először azt az esetet, amikor az összehasonlítások legalább egyikében volt erős vesztes. Legyen az első ilyen összehasonlítás si ≤ sj. Vesztesként 𝑠𝑗 nem lehet a maximum, de nyilván azok az elemek sem, melyek a korábbi összehasonlítások alapján vesztők voltak 𝑠𝑗 -vel szemben. Jelöljük az így kizárt elemek számát 𝑚-el (nyilván 𝑚 < 𝑛). Világos, hogy ezen kizárt elemek mindegyikére van legalább egy olyan összehasonlítás, melyben ők vesztők voltak. 𝑀𝑎𝑥𝐾𝑖𝑣 tehát legalább 𝑚 összehasonlítást használ csak arra, hogy ezt az 𝑚 elemet kizárja. A többi összehasonlítás a maradék 𝑛 − 𝑚 elemből választja ki maximumot, amihez indukciós feltételünk alapján szükséges legalább 𝑛 − 𝑚 − 1 darab összehasonlítás. Az összes összehasonlítások száma tehát legalább 𝑚 + 𝑛 − 𝑚 − 1 = 𝑛 − 1. Térjünk rá arra az esetre, amikor egyik összehasonlítás sem eredményez vesztest. Tegyük fel, hogy 𝑀𝑎𝑥𝐾𝑖𝑣 legfeljebb 𝑛 − 2 összehasonlítással helyesen meghatározta a maximumot; legyen ez 𝑥 = 𝑠𝑙 . Az 𝑛 − 2 összehasonlítás legfeljebb 𝑛 − 2 vesztest eredményezhet. Ezek közül legalább az egyik különbözik 𝑠𝑙 -től, legyen 𝑠𝑟 az egyik ilyen. Tekintsük most azt a 𝑧 sorozatot, ahol minden elem egyenlő, kivéve 𝑧𝑟 -t, mely határozottan nagyobb a többinél. Világos, hogy erre a módosított sorozatra az algoritmus pontosan ugyanazokat az összehasonlításos eredményeket adja, ezért most is az 𝑙-et indexűt kell megneveznie maximumként. Ez pedig nyilván nem helyes, hiszen a módosított sorozatban nem 𝑧𝑙 , hanem 𝑧𝑟 a maximum. ■ Tétel. Legyen 𝐾𝑖𝑣 egy minden bemenetre jól működő, összehasonlításos, 𝑘 –adik elemet kiválasztó algoritmus. Ekkor 𝑀Ö𝐾𝑖𝑣 (𝑛) ≥ 𝑛 − 1 + min(𝑛 − 1, 𝑛 − 𝑘). Átfogalmazva, a tétel azt mondja ki, hogy van olyan 𝑛 hosszúságú 𝑠 ∈ 𝑋 ∗ sorozat, melyre 𝐾𝑖𝑣 a 𝑘-adik elemet legalább 𝑛 − 1 + min(𝑛 − 1, 𝑛 − 𝑘) összehasonlítással tudja csak megtalálni. Bizonyítás. Csak a csupa különböző elemet tartalmazó sorozatok között keresünk ilyen 𝑠-et. Ha a 𝐾𝑖𝑣 eljárás egy 𝑠 sorozatban helyesen megnevezi 𝑥 = 𝑠𝑙 -et, mint az 𝑠 sorozat k-adik elemét, akkor ez az elem a nála kisebb (𝑘 − 1) elemmel olyan 𝑘 elemű halmazt alkot, melynek 𝑥 a maximuma. Ehhez az előző tétel szerint szükséges közöttük legalább 𝑘 − 1 összehasonlítás. Hasonló gondolattal láthatjuk be, hogy 𝑥 és a nála nagyobb elemek között lennie kell 𝑛 − 𝑘 összehasonlításnak. Ez összesen 𝑛 − 1 összehasonlítás. ■ Nevezzük nem lényeginek az olyan összehasonlításokat, melyeket a Kiv algoritmus a kis és nagy elemek között végezett. Ezek továbbiakat jelentenek az előbbi 𝑛 − 1 összehasonlítás mellett. A kérdés az, hogy akármilyen kérdezési stratégia mellett van-e olyan 𝑛 hosszú sorozat, mely esetében a kérdező nem lényegi kérdéseket is feltesz. Ha igen, akkor milyen kérdezési stratégia mellett lesz ezek száma a lehető legkisebb?

A kérdés megválaszolására az irodalomban „ellenfél-módszernek” nevezett technikát használják. Véleményünk szerint a „furfangos válaszoló” kifejezés jobban kifejezi a módszer lényegét, ezért a továbbiakban ezt elnevezést használjuk. A technika és elnevezése a barkochba játékra vezethető vissza. Ennek lényegéhez tartozik az, hogy közösen rögzítenek egy véges halmazt, a keresési teret. Ezek után egyikük, a válaszoló kiválaszt ebből a halmazból egy elemet. A másik, a kérdező, igen-nem kérdésekkel megpróbálja ezt az elemet a lehető leghamarabb kitalálni. Egy kérdés az éppen aktuális keresési teret két részre bontja, és a válaszoló megmondja, a keresett elem melyik részben van. A kérdező ezt a részt tekintve aktuális keresési térnek ugyanígy halad tovább. Ha az aktuális keresési tér már csak egy elemű, akkor ezt az elemet adja válaszként. A furfangos válaszoló a kérdezőt a lehető legtöbb kérdésre akarja kényszeríteni. Ebből a célból a játék elején nem rögzíti le előre a kitalálandó elemet, hanem a kérdező szempontjából lehető legkellemetlenebb módon folyamatosan változtatja. Ha valamely kérdés az aktuális keresési teret nem pontosan felezi, akkor furfangosan azt a választ adja, hogy a kitalált elem a nagyobbik részben van (persze ügyelnie kell válaszainak konzisztenciájára, különben a játék ellentmondásokhoz vezethet). A 9.1. ábra a két kérdés utáni helyzetet mutatja, ahol a + jel a mindig a nagyobbik térfelet jelöli. A válaszoló a kitalálandó elemet előbb a + jelű, majd a (+, +)-os részbe helyezi át (ha eleve nem ott lett volna).

9.1. ábra. A keresési tér

A furfangos válaszoló a kérdező stratégiájának legrosszabb esetét konstruálja meg. A kérdező a furfangos válaszoló ellen úgy védekezhet, hogy lehetőleg mindig olyan kérdést tesz fel, amely az aktuális keresési teret felezi. Ez a kérdezőnek a legrosszabb esetre nézve optimális (minimális kérdésszámmal járó) stratégiája. Mi köze van mindennek az összehasonlításos algoritmusokhoz? Minden összehasonlításos algoritmust tekinthetünk kérdezőként, míg a kérdésekre adott válaszokat interpretálhatjuk úgy is, hogy azokat egy, a kérdezőtől független válaszoló adja meg. Az, hogy a válaszoló furfangos módon minél több kérdésre kényszeríti a kérdezőt, azt jelenti, hogy az adott algoritmus számára megkonstruál egy rossz esetet. Ha a kérdező (az algoritmus) – tudva mindezt – a lehető legjobb (optimális) kérdezési stratégiát használja, akkor ennek legrosszabb esete az összes, a feladatot megoldó algoritmus legrosszabb esetére vonatkozó alsó korlát lesz.

Térjünk vissza most az eredeti, 𝑘 -adik elemet kiválasztó feladathoz. Lássuk először a furfangos válaszolónak a – bármely 𝑘-adik elemet kiválasztó algoritmusra működő – legrosszabb esetet konstruáló stratégiáját. Rögzít egy 𝑥 ∈ 𝑋 értéket, mely ebben a legrosszabb esetben a sorozat 𝑘-adik eleme lesz. Ezt az értéket majdan a kérdező által utoljára kérdezett elemnek adja. Egyébként az 𝑠 sorozatot nem rögzíti előre, csak a kérdező kérdéseinek függvényében. A konzisztencia biztosítására készít magának egy táblázatot, melyben az adott indexű sorozatelem státuszát és pillanatnyi értékét tárolja. A státuszok:    

N: nem volt még rá vonatkozó kérdés, ilyenkor értéke még nem rögzített. L: volt már rá kérdés és értéke 𝑥-nél nagyobb értékre már rögzítve van. S: volt már rá kérdés és értéke 𝑥-nél kisebb értékre már rögzítve van. U: az utoljára kérdezett elem, értéke x

A furfangos válaszoló nem lényegi kérdésekre kényszerítő stratégiája – a később említendő megszorításokkal – a következő: 1. N, N típusú kérdés esetében az elsőt 𝑥-nél kisebbre, a másodikat 𝑥-nél nagyobbra rögzíti a megfelelő bejegyzéssel együtt, és megadja a rögzítésnek megfelelő választ. 2. L, N ,illetve S,N típusú kérdésnél az eddig nem kérdezett elemet a másik oldalra rögzíti, kiadva az ennek megfelelő választ. 3. S, L típusú kérdésnél a már rögzített értékeknek megfelelő választ ad. 4. Az utoljára rögzített elem értékét 𝑥-re, bejegyzését U-ra állítja. A megszorítás az, hogy nem keletkezhet (𝑘 − 1)-nél több S bejegyzésű és (𝑛 − 𝑘)-nál több L bejegyzésű elem. Kicsit másképp fogalmazva, ha az egyik irány telítődött, akkor a bejegyzést és az értékét a másik oldalra kell állítani; az utolsót pedig U-ra. Minden olyan hasonlítás, melyben van N bejegyzésű elem és még egyik irány sem telített, nem lényegi lesz. Ezek száma a kérdező stratégiájától függő. Ha nem szerencsésen kérdez, akkor akár (𝑛 − 2) is lehet az ilyen kérdések száma. (Az első kérdéstől eltekintve, mely mindkét irányt telíti, a többi kérdésnél 1-el telítődik valamelyik oldal). Ha viszont a kérdező ügyes és N, N bejegyzésű párokat kérdez, akkor minden kérdés telíti mindkét oldalt, ezért a kikényszerített nem lényegi kérdések száma optimális esetben min(𝑘 − 1, 𝑛 − 𝑘) lesz. Azt kaptuk tehát, hogy legrosszabb esetben a nem lényegi kérdések száma még az optimális stratégiánál is min(𝑘 − 1, 𝑛 − 𝑘). Ezzel a bizonyítást befejeztük. Maximum, illetve minimum esetében (𝑘 = 𝑛, illetve 𝑘 = 1) a minimum egyik tagja 0, ezért visszakapjuk ez előző tétel állítását. A medián esetében, vagyis ha 𝑘 = ⌈𝑛/2⌉, pedig min(𝑘 − 1, 𝑛 − 𝑘) = min ( ⌈𝑛/2⌉ − 1, ⌊𝑛/2⌋ , egyenlő.

ami könnyen látható módon ⌊(𝑛 − 1)/2⌋ -vel

Összeadva ezt (𝑛 − 1)-gyel, az alsó korlátra ⌊3 (𝑛 − 1)/2⌋ adódik. Például 𝑛 = 5 esetében ez az alsó korlát ⌊3 ∗ 4/2⌋ = 6. Foglalkozzunk először a maximum kiválasztással (a minimum kiválasztása ennek analogonja)!

9.2 Maximum kiválasztás Formálisan is felírjuk a maximum-kiválasztás feladatát, majd megadjuk a jól ismert és sokszor használt megoldó algoritmust.

Feladat: Valamely 𝑠 ∈ 𝑋 ∗ sorozat maximumának megkeresése. Deklaráció: 𝑠, 𝑠 ′ ∈ 𝑋 ∗ , 𝑥 ∈ 𝑋 Előfeltétel: 𝑠 = 𝑠 ′ ∧ 𝑠 ≠ 𝜀 Utófeltétel: 𝑠 = 𝑠 ′ ∧ 𝑥 az 𝑠 maximális eleme. Egy megoldó algoritmus – legyen a neve MaxKiv – a következő:

9.2. ábra. Maximum kiválasztás algoritmusa

Elemzés. A ciklus |𝑠| − 1 -szer fut le, minden iterációs lépés egy összehasonlítást tartalmaz. Ö𝑀𝑎𝑥𝐾𝑖𝑣 (𝑠) = |𝑠| − 1, 𝑀Ö𝑀𝑎𝑥𝐾𝑖𝑣 (𝑛) = 𝑛 − 1. Ez a maximum-kiválasztó eljárás optimális, hiszen a maximum-tétel miatt ennél jobb nem készíthető. 9.3 A k-adik elem kiválasztása Természetes gondolat, hogy a k-adik elemet annak definícióját követve úgy keressük meg, hogy 𝑠-et valamilyen rendezési módszerrel rendezzük, majd a rendezett sorozat k indexű tagját adjuk eredményül. Mivel egy 𝑛 elemű sorozat rendezésénél az összehasonlítások száma legalább 𝑛 log 𝑛 nagyságrendű, ez a megoldás távol van a kiválasztási tételben megadott lineáris alsó korláttól. Ennek magyarázata kézenfekvő. Ahhoz, hogy egy 𝑥 elemről detektáljuk, hogy az 𝑠 sorozat k-adik eleme, valójában elegendő volna találni a sorozatban (𝑘 − 1) nála kisebb-egyenlő és |𝑠| − 𝑘 nála a nagyobb-egyenlő elemet. A teljes rendezés felesleges többletmunkával jár, hiszen nem csak a kisebb-egyenlő és nagyobb-egyenlő elemeket adja meg, hanem azokat még – a feladat szempontjából feleslegesen – rendezi is. A következő algoritmus ezen a meggondoláson alapul.

Feladat: Valamely 𝑠 ∈ 𝑋 ∗ sorozat k-adik elemének megkeresése. Deklaráció: 𝑠, 𝑠 ′ ∈ 𝑋 ∗ , 𝑥 ∈ 𝑋, 𝑖, 𝑗 ∈ 𝑍 Előfeltétel: 𝑠 = 𝑠 ′ ∧ 𝑠 ≠ 𝜀 Utófeltétel: 𝑠 = 𝑃𝑒𝑟𝑚(𝑠 ′ ) és 𝑥 az 𝑠 ′ 𝑘 –adik eleme A 𝑘 -adik elemet kiválasztó 𝐾𝑖𝑣 (𝑠, 𝑘, 𝑥) algoritmus a 𝑆𝑧𝑒𝑡𝑣𝑎𝑔 (𝑠, 𝑖) eljárással 𝑠 -et úgy rendezi át, hogy a sorozat eredetileg legutolsó elemét a helyére viszi olyan módon, hogy tőle balra csak nálánál kisebb-egyenlő elemek, míg tőle jobbra nagyobb elemek kerülnek. Ha ez az elem a 𝑖 –edik helyre került, akkor 𝑘 = 𝑖 esetén készen vagyunk, 𝑥 = 𝑠𝑖 a keresett elem. Ha 𝑘 < 𝑖, akkor az első felének, 𝑠𝑘𝑖𝑐𝑠𝑖 -nek a 𝑘 –adik elemét kell tovább keresni, egyébként pedig a második felében, 𝑠𝑛𝑎𝑔𝑦 -ban a (𝑘 − 𝑖) –ediket kell meghatározni, ugyanezzel a módszerrel. Ennek megfelelően algoritmusunk rekurzív lesz. A 𝐾𝑖𝑣(𝑠, 𝑘, 𝑥) eljárás programja a következő:

9.3. ábra. A 𝐾𝑖𝑣(𝑠, 𝑘, 𝑥) algoritmusa

Elemzés. Most is az összehasonlítások számát számoljuk. A 𝑆𝑧𝑒𝑡𝑣𝑎𝑔 (𝑠, 𝑖) eljárás nyilván (𝑛 − 1) összehasonlítással működik. A 𝐾𝑖𝑣 a legrosszabb esete az, ha a szétvágás mindig 1-gyel csökkenti azt a méretet, melyben a tovább kell keresnünk. Ilyenkor az összehasonlítások száma az első (𝑛 − 1) egész szám összege. 𝑀Ö𝐾𝑖𝑣 (𝑛, 𝑘) = 𝑛(𝑛 − 1)/2 A legjobb eset az, amikor a sorozat utolsó eleme pontosan a 𝑘 –adik (𝑘 = 𝑖), hiszen ekkor azonnal leállhatunk: 𝑚Ö𝐾𝑖𝑣 (𝑛, 𝑘) = 𝑛 − 1 Mivel a legrosszabb és legrosszabb eset összehasonlításainak a száma nagyságrendben különböző, ezért érdekes megvizsgálni, hogy melyik az, amelyik inkább dominál. Az egyszerűség kedvéért tegyük fel, hogy 𝑠 –et az 1, 2, … , 𝑛 permutációi alkotják, egyforma 1/𝑛! valószínűséggel. Arra vagyunk kíváncsiak, hogy mit mondhatunk az 𝐸Ö𝐾𝑖𝑣 (𝑛, 𝑘) várható értékről.

Tétel. Tetszőleges 𝑘 ∈ [1 … 𝑛] esetén a 𝐾𝑖𝑣(𝑠, 𝑘, 𝑥) eljárás végrehajtott összehasonlításainak várható számára 𝐸Ö𝐾𝑖𝑣 (𝑛, 𝑘) ≤ 4𝑛. Bizonyítás. (𝑛-re vonatkozó indukcióval) Kis 𝑛-ekre (𝑛 =0, 1) az állítás nyilvánvalóan igaz. Igazoljuk az állítást 𝑛 ≥ 2-re, feltételezve, hogy kisebb elemszámra az állítás már igaz. Vezessük be a következő függvényeket: 𝑓(𝑛, 𝑘) = 𝐸Ö𝐾𝑖𝑣 (𝑛, 𝑘) 𝑓𝑗 (𝑛, 𝑘) legyen az összehasonlítások várható értéke olyan feltétel mellett, hogy az utolsó helyen 𝑗 volt. Ennek a feltételnek a valószínűsége 1/𝑛. Amennyiben 𝑗 = 𝑘, akkor 𝑓𝑗 (𝑛, 𝑘) = 𝑛 − 1. Ha 𝑗 ≠ 𝑘, akkor a 𝑗-nél kisebb, illetve 𝑗 -nél nagyobb elemek egymás közötti sorrendje is egyforma valószínűségű. Ezért 𝑘 < 𝑗 esetén az (𝑛 − 1)-hez hozzáadódik még 𝑓(𝑗 − 1, 𝑘), míg 𝑘 > 𝑗 esetén 𝑓(𝑛 − 𝑗, 𝑘 − 𝑗). Összefoglalva az alábbi összefüggést kapjuk a feltételes várható értékekre: 𝑛 − 1, 𝑓𝑗 (𝑛, 𝑘) = { (𝑛 − 1) + 𝑓(𝑗 − 1, 𝑘), (𝑛 − 1) + 𝑓(𝑛 − 𝑗, 𝑘 − 𝑗),

ha 𝑗 = 𝑘 ha 𝑘 < 𝑗 ha 𝑘 > 𝑗

A teljes várható érték tétele alapján: 𝑛

1 𝑓(𝑛, 𝑘) = ∑ 𝑓𝑗 (𝑛, 𝑘). 𝑛 𝑗=1

Beírva ide 𝑓𝑗 (𝑛, 𝑘) előbbi alakját, az esetszétválasztás és átrendezés után a következőt kapjuk: 𝑛

𝑘−1

𝑗=𝑘+1

𝑗=1

1 1 𝑓(𝑛, 𝑘) = (𝑛 − 1) + ∑ 𝑓(𝑗 − 1, 𝑘) + ∑ 𝑓(𝑛 − 𝑗, 𝑘 − 𝑗) 𝑛 𝑛 Mind 𝑗 − 1 , mind 𝑘 − 𝑗 kisebb 𝑛 –nél, ezért ide beírhatjuk az indukciós feltétel szerinti becsléseket: 𝑛

𝑘−1

𝑗=𝑘+1

𝑗=1

1 1 𝑓(𝑛, 𝑘) ≤ (𝑛 − 1) + ∑ 4(𝑗 − 1) + ∑ 4(𝑛 − 𝑗) = 𝑛 𝑛 𝑛−1

𝑘−1

𝑛−1

𝑘−1

𝑘−1

𝑗=𝑘

𝑗=1

𝑗=1

𝑗=1

𝑗=1

1 1 4 4 4 = (𝑛 − 1) + ∑ 4𝑗 + ∑ 4(𝑛 − 𝑗) = (𝑛 − 1) + ∑ 𝑗 − ∑ 𝑗 + ∑(𝑛 − 𝑗) 𝑛 𝑛 𝑛 𝑛 𝑛 =

𝑘−1

4 𝑛(𝑛 − 1) 4 = (𝑛 − 1) + ∗ + ∑(𝑛 − 2𝑗) = 𝑛 2 𝑛 𝑗=1

(𝑛 − 2) + (𝑛 − 2(𝑘 − 1)) 4 = 3(𝑛 − 1) + (𝑘 − 1) = 𝑛 2

4 4 𝑛−1 2 4 𝑛2 (𝑘 = 3(𝑛 − 1) + − 1)(𝑛 − 𝑘) ≤ 3(𝑛 − 1) + ( ) ≤ 3𝑛 + = 4𝑛. 𝑛 𝑛 2 𝑛4 Közben alkalmaztuk a számtani sorozatok összegképletét, illetve a számtani és mértani közép közötti egyenlőtlenséget. ■ 9.4 A k-adik elem kiválasztás véletlenített algoritmusa A 𝐾𝑖𝑣(𝑠, 𝑘, 𝑥) algoritmus összehasonlításainak várható számánál feltételeztük, hogy 𝑠 -ben minden permutáció egyformán valószínű. A gyakorlatban ez sokszor nincs így, például a bemenő sorozat már közel rendezett is lehet. Ha ilyenkor valamilyen kis értékre keressük a 𝑘-adik elemet, akkor a méret csak lassan csökken. Ennek kivédésére véletlenített algoritmust alkalmazunk, nem az utolsó elemet, hanem a sorozat véletlenül választott tagját használja a szétvágás során.

9.4. ábra. A véletlenített algoritmus működése

Példa: Tekintsük a 10, 20, … , 120 sorozatot, és keressük a 𝑘 = 8-adik elemet! 1.

2.

3.

A véletlen választás eredménye legyen 40, így ennek megfelelően bontsuk szét a sorozatot: 𝐴 = 10, 20, 30 é𝑠 𝐵 = 50, 60, … , 120. Mivel |𝐴| = 3, tudjuk, hogy a 40 a 4. elem a számsorozatban, ezért a 8-adik elemet az 𝐴 sorozatban kell keresnünk. Mivel a számsorozat első 4 elemét leválasztottuk, ezért a 𝐵 sorozatban már nem a 8-adik, hanem a 4. elemet keressük. A bemenő sorozatunk 50, 60, … , 120, a véletlen választás eredménye legyen 100, így 𝐴 = 50, … , 90 é𝑠 𝐵 = 110, 120 sorozatok keletkeznek, így a 100 a 6. legkisebb elem, tovább kell tehát keresnünk a 𝐴 halmazban, továbbra is a 4. elemet. A bemenő sorozatunk 50, … , 90, a véletlen választás eredménye legyen 80, így 𝐴 = 50, … ,90 és 𝐵 = 90, tehát a 80 itt a 4. elem, vagyis megtaláltuk a 𝑘 = 8 -adik elemet.

Az algoritmus működését a 9.4. ábrával illusztráltuk. Foglalkozzunk most azzal az esettel, amikor a sorozat típust az 𝐴[1. . 𝑛] tömbben reprezentáljuk. Most az 𝑠𝑘𝑖𝑐𝑠𝑖 és 𝑠𝑛𝑎𝑔𝑦 sorozatok a tömb darabjai, ezért eljárásunk további lépéseiben már nem az egész tömbön működik, hanem általában egy 𝑢 és 𝑣 közötti darabján (1 ≤ 𝑢 ≤ 𝑣 ≤ 𝑛). Emiatt az algoritmust kicsit általánosabban írjuk meg, a tömb 𝐴[𝑢. . 𝑣] szeletén. Olyan függvényeljárást készítünk, mely a 𝑘-adik elem értékét adja vissza. Legyen 𝑉𝑒𝑙𝑒𝑡𝑙𝑒𝑛𝑆𝑧𝑒𝑡𝑣𝑎𝑔 olyan eljárás, mely a 𝑆𝑧𝑒𝑡𝑣á𝑔 eljárást az 𝐴[𝑢. . 𝑣] tömbdarabon egy onnan véletlenül választott elemmel végzi el úgy, hogy 𝑗-ben adja vissza a szétválasztó elem helyét. Világos, hogy ez az 𝐴[𝑢. . 𝑣]-nek az 𝑖 = ( 𝑗 − 𝑢 + 1)-edik eleme lesz. Ekkor 𝑖-t kell összehasonlítanunk 𝑘-val. Ha egyenlők, akkor készen vagyunk és ezt az értéket adjuk vissza. Ha 𝑖 > 𝑘, akkor 𝐴[𝑢. . 𝑗 − 1]-ben keressük tovább a 𝑘 –adik elemet, míg 𝑖 < 𝑘 esetén 𝐴[𝑗 + 1. . 𝑣]ben keressük a (𝑘 − 𝑖)-edik elemet. A 𝐾𝑖𝑣(𝐴[𝑢. . 𝑣], 𝑘) rekurzív algoritmus külső hívása a teljes 𝐴[1. . 𝑛] tömbbel és az eredetileg megadott 𝑘 értékkel történik. Később az eljárás önmagát egy résztömbre hívja meg, esetleg módosított k értékkel. Az eljárás működésének leírását a 9.5. ábra tartalmazza.

9.5. ábra. A VelKiv algoritmus

Elemzés. A véletlenített kiválasztás elemzése formálisan ugyanúgy történhet, mint a determinisztikus változaté. Az elvi eltérés abban van, hogy míg a 𝐾𝑖𝑣 eljárás esetében az input sorozat a véletlenszerű, addig a véletlenített változatban a véletlen magában az algoritmusban, nevezetesen a 𝑉𝑒𝑙𝑒𝑡𝑙𝑒𝑛𝑆𝑧𝑒𝑡𝑣𝑎𝑔(𝐴[𝑢. . 𝑣], 𝑗) eljárás működésében jelenik meg.

10. Szimultán kiválasztások Ebben a fejezetben két újabb alsókorlát-elemzés következik. Mindkettőben egy szimultán algoritmus műveletigényére adunk alsó korlátot. Pontosabban, egy-egy feladat megoldásához minimálisan szükséges lépésszámot határozzuk meg. Ezek az ötletes és szép meggondolásokat hozzá tartoznak az algoritmuselmélet színes világához, egyébként is, az algoritmusok lépésszámára ritkán állapítható meg alsókorlát, így ezen a téren minden eredményt „meg kell becsülni”. 10.1 Szimultán minimum-maximum kiválasztás Specifikáljuk azt a feladatot, amely egy 𝑠 sorozat minimális és maximális elemének a meghatározását tűzi ki célul. Feladat. Valamely 𝑠 ∈ 𝑋 ∗ sorozat minimális és maximális elemének megkeresése. Deklaráció: 𝑠, 𝑠 ′ ∈ 𝑋 ∗ , 𝑥, 𝑦 ∈ 𝑋 Előfeltétel: 𝑠 = 𝑠 ′ ∧ 𝑠 ≠ 𝜀 Utófeltétel: 𝑠 = 𝑃𝑒𝑟𝑚(𝑠 ′ ) és 𝑥, 𝑦 az 𝑠 ′ maximális, illetve minimális eleme Természetes ötlet, hogy válasszuk ki a sorozat maximumát, aztán a többi elem közül a minimumot. Ennek az algoritmusnak a műveleti igénye az összehasonlítások számával mérve 𝑛 − 1 + 𝑛 − 2 = 2𝑛 − 3. Látszólag nem lehet jobb algoritmust készíteni, hiszen a maximum kiválasztáshoz 𝑛 elem esetén mindig szükséges legalább 𝑛 − 1 összehasonlítás, majd a maradék 𝑛 − 1 elem közül legalább 𝑛 − 2 összehasonlítással tudjuk csak kiválasztani a minimumot. Hatékonyabb szimultán minimum-maximum kiválasztó algoritmushoz juthatunk úgy, ha bizonyos összehasonlítások eredményét (lehetőleg minél többet) a maximum és a minimum kiválasztása során egyaránt felhasználunk. Ezt a gondolatot felhasználva a következő 𝑀𝑎𝑥𝑀𝑖𝑛𝐾𝑖𝑣 nevű absztrakt algoritmust készíthetjük (lásd: 10.1. ábra).

10.1. ábra. A MaxMinKiv algoritmus

Párokba rendezzük 𝑠 elemeit, és minden páron elvégezzük az összehasonlítást (ha 𝑛 páratlan, akkor ebből egy elem kimarad). Ezek után a győztesek közül – hozzávéve az esetleg kimaradó elemet – kiválasztjuk a legnagyobbat, és hasonlóan járunk el a vesztesekkel is, a minimum megtalálására. 𝑛

𝑛

Elemzés. A párok száma ⌊ 2⌋, innen adódik ⌊ 2⌋ összehasonlítás. Az esetleg hozzájuk vett 𝑛

𝑛

elemmel együtt a nagyok és a kicsik száma egyaránt ⌈ 2⌉, amiből összesen 2 (⌈ 2⌉ − 1) összehasonlítás adódik. Könnyen beláthatjuk, hogy ezek összege ⌊ Ha n páros, azaz 𝑛 = 2𝑚 esetén, az összeg 3𝑚 − 2 és ⌊

3(𝑛−1) 2

⌋.

3(2𝑚−1)

Ha n páratlan, vagyis 𝑛 = 2𝑚 + 1 esetén, az összeg 3𝑚 és ⌊

3

⌋ = 3𝑚 − ⌈2⌉ = 3𝑚 − 2.

2

3(2𝑚) 2

⌋ = 3𝑚.

A szimultán eljárás által végrehajtott összehasonlítások száma: Ö𝑀𝑎𝑥𝑀𝑖𝑛𝐾𝑖𝑣 (𝑠) = ⌊

3(𝑛−1) 2

⌋.

Azt kaptuk, hogy a 𝑀𝑎𝑥𝑀𝑖𝑛𝐾𝑖𝑣 algoritmus ¾ részére csökkenti az összehasonlítások számát. Felmerül ezek után a kérdés, hogy vajon lehet-e szükséges a lépésszámon tovább javítani. A válasz tagadó, ugyanis fennáll a következő tétel. Tétel. Legyen 𝑀𝑎𝑥𝑀𝑖𝑛𝐾𝑖𝑣 egy minden bemenetre jól működő összehasonlításos szimultán 3(𝑛−1) maximum-minimum kiválasztó algoritmus. Ekkor 𝑀Ö𝑀𝑎𝑥𝑀𝑖𝑛𝐾𝑖𝑣 (𝑛) ≥ ⌊ 2 ⌋. Más szóval, létezik olyan 𝑛 hosszúságú 𝑠 ∈ 𝑋 ∗ sorozat, melyre 𝑀𝑎𝑥𝑀𝑖𝑛𝐾𝑖𝑣-nek a 3(𝑛−1) maximum és minimum együttes megtalálásához legalább ⌊ 2 ⌋ összehasonlítást kell végeznie. Bizonyítás. Az 𝑠 sorozatot mintegy „menet közben” konstruáljuk meg, a kérdező által feltett kérdések tisztázó hatásának a késleltetése céljából. A válaszoló nem rögzíti tehát előre 𝑠 elemeit, hanem az előző fejezetben látott „furfangos válaszoló módszerével” eljárva a kérdező, vagyis algoritmus stratégiájához idomulva, próbálja a lehető legtöbb összehasonlításra késztetni a megoldó eljárást. A kérdező akkor lehet biztos a dolgában, ha talált egy olyan elemet, mely minden összehasonlításban győztes volt (ez a maximum), valamint egy olyat, amelyik mindig vesztes volt (ez a minimum); a többiek lehetnek vegyesen győztesek és vesztesek is. Az összehasonlításokból adódó „győztes-vesztes” információ nyilvántartására a kérdező egy 𝑛 elemű táblázatot használ, amelynek i-edik elemébe bejegyzi 𝑠𝑖 sorozatelem státuszát. A táblázat folyamatosan módosul; a bejegyzések végső állapotából már kiolvasható az elemek viselkedése a kérdések (az algoritmus működése) során. Az elemek státuszai a következő lehetőségek közül kerülnek ki:    

N: nem volt még rá vonatkozó kérdés; L: szerepelt kérdésben és minden összehasonlításban vesztes volt; W: szerepelt kérdésben és minden összehasonlításban győztes volt; LW: vonatkoztak rá kérdések, és az összehasonlításokban volt győztes és vesztes is.

Az algoritmusnak addig kell kérdeznie, amíg a táblázatban megjelenik 𝑛 − 2 számú (L, W) bejegyzésű, egy L státuszú és egy W státuszú elem. Ez összesen 2 ∗ (𝑛 − 1) információdarabka. Egy kérdésre adott válaszból 0, 1 vagy 2 információelem nyerhető ki. Egy kérdés legfeljebb egy győztest és egy vesztest eredményezhet, de ha valamelyikükre már vonatkozik hasonló bejegyzés, akkor ott új információ nem keletkezik.

A válaszoló konzisztencia táblája lényegében ugyanaz, mint a kérdező táblázata, csak ebben benne vannak – számára láthatók – a konstrukciónak megfelelő értékek is. A válaszoló legrosszabb esetet konstruáló stratégiája olyan, hogy csak akkor ad információt, ha feltétlenül szükséges. Ha a kérdezett pár 1. 2.

3.

4.

5.

mindkét tagja (N, N) bejegyzésű, akkor mindkét elemet rögzíti, az egyik elemet kisebb értékkel L-esként, a másikat nagyobb értékkel W-vel (2 információ); egyik tagja N státuszú és a másik státusza 2.1. L, akkor az N-es elemet W bejegyzésével, a másiknál nagyobb értékkel rögzíti (1 információ); 2.2. W vagy LW akkor az N-es elemet L bejegyzéssel a másiknál kisebb értékkel rögzíti (1 információ); Ha a pár egyik tagja L státuszú és a másik státusza 3.1. W vagy LW, akkor az L-es elem bejegyzését megtartva, annak értékét megfelelően csökkenti (0 információ); 3.2. L, akkor a rögzítésnek megfelelő választ adja, a nagyobb értékű elem bejegyzését LW-re állítva (1 információ); Ha a pár egyik tagja W státuszú és a másik státusza 4.1. LW, akkor W-es elem bejegyzését megtartva, annak értékét megfelelően megnöveli (0 információ) 4.2. W, akkor a rögzítésnek megfelelő választ adja, a kisebb értékű elem bejegyzését LW-re állítva (1 információ); Ha a pár mindegyik tagja LW státuszú, akkor a rögzítésnek megfelelő választ adja (0 információ).

Ez a stratégia nem vezethet ellentmondáshoz, hiszen az értékek rögzítésénél a válaszoló megfelelő bejegyzéseket teszi, míg egy – már rögzített – elem értékét csak abba az irányba változtatja, amilyen annak bejegyzése. Bármely bemenetre az a legjobb kérdező stratégia, hogy maximális számú, két információt adó kérdést tesz fel, majd amikor azok elfogytak, akkor csupa egy információt eredményező kérdést fogalmaz meg. Az így adódó kérdésszám lesz a keresett alsó korlát. A két információt adó kérdések az (N, N) alakú párokhoz tartoznak, ilyenből legfeljebb ⌊𝑛/2⌋-t lehet feltenni. A többi 2(𝑛 − 1) − 2⌊𝑛/2⌋ információ megszerzéséhez legalább ennyi kérdés kell. Emiatt a kérdések száma a legcélratörőbb esetben is legalább Ö(𝑛) = ⌊𝑛/2⌋ + 2(𝑛 − 1) − 2⌊𝑛/2⌋ = 2(𝑛 − 1) − ⌊𝑛/2⌋. Tudjuk, hogy 𝑚 pozitív egészre fennáll: ⌊𝑚/2⌋ + ⌈𝑚/2⌉ és ⌊𝑚/2⌋ = ⌈(𝑚 − 1)/2⌉. Innen Ö(𝑛) = 𝑛 − 1 + ⌊(𝑛 − 1)/2⌋ + ⌈(𝑛 − 1)/2⌉ − ⌈(𝑛 − 1)/2⌉ = ⌊3 (𝑛 − 1)/2⌋. Ezzel a bizonyítást befejeztük. 10.2 Az r legnagyobb elem rendezett kiválasztása Specifikáljuk azt a feladatot, amely egy 𝑛 elemű sorozatra, növekvő sorrendben rendre az (𝑛 − 𝑟 + 1)-edik, (𝑛 − 𝑟 + 2)-edik, .., 𝑛-edik elem megadását tűzi ki. Feladat. Valamely 𝑠 ∈ 𝑋 ∗ sorozat 𝑟 legnagyobb elemének rendezett kiválasztása Deklaráció: 𝑠, 𝑠 ′ , 𝑢 ∈ 𝑋 ∗ Előfeltétel: 𝑠 = 𝑠 ′ ∧ 𝑠 ≠ 𝜀 ∧ 1 ≤ 𝑟 ≤ |𝑠| Utófeltétel: 𝑠 = 𝑃𝑒𝑟𝑚(𝑠 ′ ) és 𝑢 az 𝑠 rendezettjének 𝑟 hosszú suffixe

Ez a feladat első pillantásra egyszerűen megoldható. Először kiválasztjuk a maximális elemet és kiírjuk 𝑢-ba. Ezek után a maradékból is kiválasztjuk a legnagyobbat, és kiírjuk 𝑢–ba az előző maximum elé, és így tovább 𝑟-szer. Ehhez szükséges rendre 𝑛 − 1, 𝑛 − 2, … 𝑛 − 𝑟 𝑟+1 összehasonlítás, melyek összege 𝑟(𝑛 − 2 ). Vajon lehet-e ennél kevesebb összehasonlítással működő algoritmust találni erre a feladatra? Látszólag nem, hiszen a maximumhoz kell legalább 𝑛 − 1 összehasonlítás, a második maximumhoz maradék 𝑛 − 2 összehasonlítás, és így tovább. Ha azonban felhasználnánk az első maximum kiválasztásánál, a maradék 𝑛 − 1 elemen végzett összehasonlítások eredményét, akkor ezek számával csökkenthető a második legnagyobb elem kiválasztásához szükséges összehasonlítások száma. Ehhez meg kell jegyezni a maximum kiválasztásához elvégzett összehasonlításokat és azok eredményét. Az információ tárolásához az úgynevezett tournament (versenyfa) adatszerkezetet fogjuk használni. A tournament a nevét a kieséses sportversenyek eredménytáblázata nyomán kapta. Egy 𝑡 tournament olyan teljes bináris fa, amelynek leveleiben egy 𝑛 = 2𝑚 (ahol m a fa magassága) hosszú sorozat helyezkedik el, belső pontjaiban pedig a két gyerekcsúcs értékének maximuma található (lásd: 10.2. ábra).

10.2. ábra. Egy kitöltött tournament

A 𝑡 tournament gyökerében mindig a leveleinek maximuma áll. Nevezzük tournament váznak az olyan teljes bináris fát, melynek csak a leveleiben találhatók értékek. Egy ilyen vázat touranmentté alakító eljárás nyilván maga is maximum kiválasztó algoritmus lesz.

10.3. ábra. A KTour eljárás

Egy 𝑡 fa akkor és csak akkor tournament, ha a baloldala és jobboldala eggyel kisebb magasságú tournamentek, és a gyökerében a bal és jobb oldali részfa gyökerének maximuma található. Ezt a tulajdonságot felhasználva könnyen készíthetünk olyan rekurzív eljárást, amely egy vázat átalakít tournamentté (lásd: 10.3. ábra). Elemzés. A 𝐾𝑇𝑜𝑢𝑟 eljárás összehasonlításainak száma pontosan a fa belső pontjaninak száma. A belső pontokat könnyen számba vehetjük. A levelek fölötti szinten 2𝑚−1 belső pont van, a felette lévőn 2𝑚−2 , és így tovább a gyökérig. Ezek m elemű mértani sorozatot alkotnak, melynek összege 2𝑚 − 1, tehát 𝑛 − 1. Ezek szerint a 𝐾𝑇𝑜𝑢𝑟 optimális maximum kiválasztást megvalósító algoritmus. A második legnagyobb elemet (majd pedig a továbbiakat) úgy lehet meghatározni, hogy a mindenkori maximális elemet – az 𝑢 sorozatba való kiírása után – a lehető legkisebb elemmel, vagy egy extremális elemmel helyettesítjük. Vezessük be erre a célra a −∞ szimbólumot. A helyettesítés után ez még nem tournament, de a maximum ágának újraszámolásával könnyen azzá alakítható (lásd: 10.4. ábra).

10.4. ábra. Tournament a maximum ág újrarajzolásával

A további maximumokat kiválasztó 𝑀𝑇𝑜𝑢𝑟 eljárás is felhasználja a tournamentek rekurzív felépítését, valamint azt is, hogy csak az eddigi maximumot tartalmazó ágat kell újra számolni (hiszen a másik fele változtatás nélkül megmaradt tournamentnek).

10.5. ábra. Az MTour eljárás

Elemzés. Az első fordulós eredményt az eddigi maximum ágán már tudjuk is, hiszen a −∞ ellenfele biztos győztes. Ennek megfelelően a második (és minden további) maximum kiválasztása az 𝑀𝑇𝑜𝑢𝑟 eljárással 𝑚 − 1 = log 2 𝑛 − 1 összehasonlítással történhet meg. Legyen 𝑃á𝑟ℎ𝑅𝑒𝑛𝑑𝐾𝑖𝑣(𝑠, 𝑟, 𝑢) most már az az eljárás, mely 𝐾𝑇𝑜𝑢𝑟 egyszeri, majd az 𝑀𝑇𝑜𝑢𝑟 eljárás (𝑟 − 1)-szeri alkalmazásával rendezetten kiválasztja az 𝑠 sorozat 𝑟 számú legnagyobb elemét.

10.6. ábra. A ParhRendKiv eljárás

Elemzés. Az r számú legnagyobb elemet rendezetten kiválasztó 𝑃á𝑟ℎ𝑅𝑒𝑛𝑑𝐾𝑖𝑣 eljárás (lásd: 10.6. ábra) összehasonlításainak számat 𝐾𝑇𝑜𝑢𝑟 és 𝑀𝑇𝑜𝑢𝑟 összehasonlítás száma alapján könnyen becsülhető: Ö𝑃á𝑟ℎ𝑅𝑒𝑛𝑑𝐾𝑖𝑣 (𝑡) ≤ 𝑛 − 1 + (𝑟 − 1)(lg(𝑛) − 1) Ha 𝑛 nem kettő hatvány, akkor kiegészítjük a megfelelő 𝑞 = 2⌈lg(𝑛)⌉ − 𝑛 számú (− ∞) extremális értékkel kettő hatvánnyá; mindet egy-egy valódi elemmel párosítjuk, amelyek így „erőnyerők” lesznek. Az erőnyerők összehasonlítás nélkül jutnak be a következő fordulóba és így az összehasonlítások száma 𝑞–val kevesebb a (2⌈lg(𝑛)⌉ − 1) lépésszámnál. Innen 𝐾𝑇𝑜𝑢𝑟 összehasonlítás-száma, nem kettő hatványok esetében (2⌊lg(𝑛)⌋ − 1) − 𝑞 = 𝑛 − 1, tehát optimális algoritmus marad. Az 𝑀𝑇𝑜𝑢𝑟 eljárás most ⌈lg(𝑛)⌉ − 1 összehasonlítással működik. Emiatt nem kettőhatványokra a 𝑃á𝑟ℎ𝑅𝑒𝑛𝑑𝐾𝑖𝑣(𝑠, 𝑟) műveletigénye az 𝑛 − 1 + (𝑟 − 1)(⌈lg(𝑛)⌉ − 1) kifejezéssel becsülhető felülről. Kettő hatványokra innen kiadódnak speciális esetként a rájuk vonatkozó képletek. Az 𝑟 elem rendezett kiválasztásának eljárását 𝑟 = 𝑛 mellett alkalmazva, az outputon megjelenik a levelekben elhelyezet teljes kiinduló sorozat rendezettje. A 𝑃á𝑟ℎ𝑅𝑒𝑛𝑑𝐾𝑖𝑣(𝑠, 𝑛) algoritmus tehát egyben egy rendezési módszer is, melyet tournament rendezésnek (versenyrendezésnek) nevezünk. (Erre a rendező eljárásra visszatérünk a 15. fejezetben.) A tournament rendezés műveletigénye az előzőek szerint 𝑛 − 1 + (𝑛 − 1)⌈lg(𝑛)⌉. Amint azt a rendezések ismertetése során látni fogjuk, az összehasonlítások nagyságrendje szempontjából ez optimális rendező módszer. Arra a kérdésre, hogy lehet-e a 𝑅𝑒𝑛𝑑𝐾𝑖𝑣(𝑠, 𝑟) eljárásnál jobb algoritmust találni erre a feladatra, nemleges választ ad Kiszlicin tétele, melyet bizonyítás nélkül közlünk. Tétel. Bármely olyan algoritmus, amely minden n hosszúságú bemenetre az 𝑟 legnagyobb elemet rendezett sorrendben garantáltan megtalálja, mindig legalább (𝑛 − 𝑟 + ∑𝑟−1 𝑖=1 ⌈lg(𝑛 − 𝑟)⌉) számú összehasonlítást végez.

11. BINÁRIS KERESŐFÁK A bináris keresőfa a kulcsos adatrekordok tárolásának egyik elsőként kialakult eszköze. Egyszerű tárolási elvet valósít meg: a legelső, a gyökérben elhelyezett rekord utáni kulcsokat a „kisebb balra, nagyobb jobbra” elv alapján illesztjük be a fába. A kiegyensúlyozással kiegészítve a tárolás hatékony adatszerkezetét kapjuk (AVL-fa, piros-fekete fa). A tárolási elvet pedig viszontlátjuk a ma leginkább használatos B-fáknál is. 11.1. A bináris keresőfa és alapvető tulajdonsága Tekintsük adatrekordoknak azt a sorozatát, amelyben a kulcsok sorrendje a következő: 40, 110, 30, 60, 90, 10, 50, 20, 120, 80, 70, 100. Ezekből az adatokból bináris fát építünk olyan módon, hogy az első elem, a 40-es alkotja a fa gyökerét, a további kulcsokat pedig az imént említett „kisebb balra, nagyobb jobbra” elv szerint szúrjuk be a keresőfába. A 110-es kulcs a gyökér jobb gyereke lesz, a 30-as pedig a bal gyereke. Ha már terjedelmesebb az épülő fa, akkor az aktuális kulcsnak a helyét a gyökértől indulva általában egy törött-vonal mentén keressük meg. Tekintsük a teljes fát bemutató 11.1. ábrán például a 80-as kulcsértéket, amellyel a gyökértől indulva jobbra-balra-jobbra-balra léptünk ahhoz, hogy el tudjuk helyezni a fában.

11.1. ábra. Bináris keresőfa

Az ábrán látható bináris keresőfa az összes kulcsot tartalmazza. Jegyezzük meg, hogy a kulcsok más sorrendje is előállíthatja ugyanezt a fát. Ha például megcseréljük a 110-es és a 30as kulcsok sorrendjét, nem lesz változás a kialakult keresőfában. 1

Az ábra az ADS szintű szemléletnek is megfelel, de a pointeres reprezentáció illusztrálására is alkalmas. A bináris keresőfát ugyanis általában láncolással valósítjuk meg. Az ábrán látható t, p1, …, p11 változók pointer értékűek, Ezek a bal és jobb gyerekcsúcsokra mutatnak, továbbá a fa minden adatelemében helyet kap a szülőre mutató pointer is, ahogyan a 11.2. ábrán látható (eredetileg a 7. fejezet, 7.2. ábrája). A szülő-pointereket az előző ábra nem tartalmazza, hogy a rajz könnyebben áttekinthető maradjon. (Eltekintünk az adatrekordoknak a kulcstól különböző mezőitől.)

11.2. ábra. Bináris keresőfa láncolt ábrázolása

Adjuk meg a bináris keresőfa definícióját. A felépítés dinamikus szabálya után statikus meghatározást keresünk. A t bináris fát pontosan akkor mondjuk egyúttal bináris keresőfának, ha t bármely x csúcsára igaz az, hogy amennyiben y az x bal oldali részfájának egy csúcsa, illetve ha a z pont az x jobb oldali részfájának egy csúcsa, akkor kulcs(y) < kulcs(x) < kulcs(z) Az adatfeldolgozásban általában nem engedjük meg azonos kulcsok előfordulását. (Vegyük észre, hogy a definíció három univerzális kvantort tartalmaz. A meghatározás így arra az esetre is értelmes, ha az x csúcsnak nincsen bal vagy jobb oldali részfája.) Figyeljünk fel arra, hogy nem lenne elég a fenti egyenlőtlenségeket csupán szülő-gyerek viszonylatban megkövetelni, hiszen akkor három szinten belül ellentmondásra juthatnánk a bináris keresőfa felépítésével. (Gondoljuk meg, hogy ha y és z csak gyerekcsúcsai lennének xnek, akkor az ábrán a p11 által mutatott 70-es kulcsértéket például 55-re változtatva, a definíció teljesülne, holott az 55 nem kerülhet a 60-as csúcs jobb oldalára!) A bináris keresőfa nevezetes tulajdonsága az, hogy inorder bejárással a kulcsokat rendezett sorozatként érjük el. Ez következik az inorder bejárás azon tulajdonságaiból, hogy (1) (2) (3)

a gyökeret „középen”, a bal oldali és a jobb oldali részfa bejárása között érintjük, a bal oldali részfa minden kulcsa kisebb, a jobb oldali minden kulcsa nagyobb, mint a gyökérben tárolt kulcs és mindkét oldali részfát inorder módon járjuk be.

Szemléletünk nem teszi szükségessé, hogy formálisan teljes indukciós bizonyítással lássuk be a bináris keresőfáknak ezt az alapvető tulajdonságát. (Az előbbi indoklás azonban már a bizonyításban alkalmazandó strukturális indukció lényegét tartalmazza.) Ha rendezésre használnánk a bináris keresőfát, akkor abban az alkalmazásban nevezhetnénk rendezőfának. Mivel a rendezendő elemek között lehetnek egyenlők is, a fenti definícióban ≤ jeleket alkalmaznánk.) 2

11.2. A bináris keresőfák műveletei A bináris keresőfára a keresés, a beszúrás és a törlés szokásos műveletei mellet bevezetjük a legkisebb kulcsérték megkeresését, valamint az adott kulcsértékre nagyság szerint rákövetkező kulcs megkeresésének műveletét is, hogy sorban végig tudjunk menni a kulcsok rendezett sorozatán, az elsőtől az utolsóig. Az ismertetés során jellemző példákat adunk meg a keresőfa műveleteire, mindig a 11.1. ábrán látható adatszerkezetet véve alapul. Ezen az ábrán a keresőfa ADS szintű rajzát láthatjuk. A gyakorlatban a bináris fa láncolt megvalósítását használják, amelyet a 7. fejezetben ismertettünk. A műveletek algoritmusait is erre a pointeres reprezentációra adjuk meg, de a jelölésben élünk azzal a (tipográfiai) könnyítéssel, hogy a pointerre utaló  szimbólum helyett az ADS-szinten szokásos zárójeles írásmódot használjuk. A műveleteknek egységes arculatot adunk. Mindegyik pointer típusú visszaadott értéket szolgáltat, ami adott esetben a hiba jelzésére is alkalmas (NIL pointer). Az eljárások paraméterlistáján mindig szerepel a bináris keresőfa t pointere. Ha szerepel további paraméter, akkor az egy eset kivételével szintén pointer típusú: vagy a fában mutat egy csúcsban elhelyezett rekordra, vagy a fán kívül összeállított adatrekordot címez. A kivételes eset a keresés művelete, amely egy kulcsértéket vár bemenő paraméterként. 11.2.1. Adott kulcsérték keresése A bináris keresőfa műveletei között alapvető egy adott k kulcsú rekord megkeresése. A keresés módja a keresőfa felépítésének elvén alapul. A gyökérnél kezdve összehasonlítjuk a keresett k értéket a csúcsban tárolt kulccsal. Ha az aktuális kulcs éppen megegyezik k-val, akkor megtaláltuk a keresett rekordot. Ha k kisebb, mint az aktuális kulcs, akkor balra lépve keresünk tovább, fordított esetben pedig a jobb oldalon folytatjuk a keresést. Ha olyan kulcsot keresünk, amely nem található a fában, akkor az eljárás egy levélcsúcsba található NIL pointeren áll meg. Az adott kulcsérték keresésének algoritmusát kivételesen két változatban is megadjuk, először a bináris fákhoz jól illeszkedő rekurzív eljárás formájában (11.3. ábra). Az eljárás hívása a következő értékadással történik: 𝑟 ≔ 𝐾𝑒𝑟𝑒𝑠(𝑡, 𝑘) ahol t a bináris keresőfa pointere és k a keresendő kulcs. Az eljárás az r pointer típusú változónak visszaadja a k kulcsú rekord címét, ha ilyet tartalmaz a keresőfa, illetve NIL-t ad vissza ellenkező esetben, ha a t nem tartalmazza a k kulcsot.

11.3. ábra. A keresés műveletének rekurzív algoritmusa 3

Példák a 11.1. ábrára való hivatkozással: (1) 𝑟 ≔ 𝐾𝑒𝑟𝑒𝑠(𝑡, 90)  (2) 𝑟 ≔ 𝐾𝑒𝑟𝑒𝑠(𝑡, 55) 

𝑟 = 𝑝8 𝑟 = NIL

A keresés eljárásának iteratív változatát a 11.4. ábra tartalmazza. A további műveletek esetén az iteratív változatot részesítjük előnyben.

11.4. ábra. A keresés műveletének iteratív algoritmusa

11.2.2. A legkisebb kulcs keresése Egy nem üres bináris keresőfában úgy jutunk el a minimális kulcsot tároló csúcshoz, hogy a gyökértől indulva mindig a bal oldali pointeren lépünk tovább. Ha már nem vezet tovább balra út, akkor megtaláltuk a legkisebb kulcsot. Az eljárás hívása az 𝑟 ≔ 𝑀𝑖𝑛𝑖𝑚𝑢𝑚(𝑡) értékadással történik. Esetünkben az eljárás mindig balra lépve megtalálja a minimális 10-es kulcsértéket, és visszaadja annak pointerét az r változónak, azaz 𝑟 = 𝑝3 lesz. Az algoritmus a 11.5. ábrán adtuk meg.

11.5. ábra. A minimális kulcs megkeresése

Megjegyezzük, hogy a minimális kulcsot tartalmazó csúcsnak lehet jobb oldali gyereke, de abban nagyobb kulcsérték található. 4

11.2.3. A következő kulcsérték megkeresése Egy adatokat tároló struktúrában, ha csak lehet, biztosítani kell azt, hogy a rekordokat kulcsaik növekvő sorrendjében érjük el. Ehhez szükséges az, hogy ki tudjuk választani a minimális kulcsú rekordot. Ezt a műveletet vezettük be az előző pontban. Most a rákövetkező kulcs megkeresésének műveletét adjuk meg. Ha a bináris keresőfában a p pointer adott kulcsú rekordra mutat (és az nem a legnagyobb kulcsérték), akkor a következő kulcsérték megtalálásának két esetét kell észrevennünk. A most következő meggondolások alapján írtuk meg a 11.6. ábrán szereplő algoritmust. Tekintsük először a 60-as kulcsérték rákövetkezőjének, a 70-es kulcsnak a megkeresését. Az megfelelő művelet meghívása és annak eredménye: (1) 𝑟 ≔ 𝐾ö𝑣𝑒𝑡𝑘𝑒𝑧ő(𝑡, 𝑝4 )  𝑟 = 𝑝11 A következő kulcsot úgy találjuk meg, hogy a 60-as kulcsérték jobb oldali részfájában, ahol a „közvetlen” nagyobb kulcsokat találjuk, megkeressük a legkisebb kulcsot az előzőleg bevezetett Minimum művelettel. Ha a 100-as kulcsérték rákövetkezőjét szeretnénk megkeresni, akkor a következő utasítást adjuk ki: (2) 𝑟 ≔ 𝐾ö𝑣𝑒𝑡𝑘𝑒𝑧ő(𝑡, 𝑝10 )

 𝑟 = 𝑝2

Most ezzel előző stratégiával nem élhetünk, mivel a kulcsnak nincs jobb oldali leágazása. Ekkor „inverz” szemlélettel azt a pontot keressük meg a fában, amelynek a szóban forgó 100as kulcs a megelőzője. Annak a csúcsnak a 100-as kulcs a baloldali részfájában a maximális érték, amelyhez a részfában jobb pointerek sorozatán jut el. Ezt a keresési utat kell a 100-as csúcsból indulva megfordítani. Általában, az adott pontból szülő pointereken megyünk addig, amíg azok – a szülőből nézve – jobb gyerekre mutató pointerek (ez a sorozat lehet üres is). Utána még egy lépést kell tennünk felfelé egy szülő pointeren, amely – ismét a szülő csúcshoz viszonyítva – bal gyerekhez vezet.

11.6. ábra. A nagyság szerint következő kulcs megkeresése 5

A bináris fában található maximális kulcsnak nincs rákövetkezője. Ilyenkor a keresés, ezzel összhangban, NIL pointert ad vissza: (3) 𝑟 ≔ 𝐾ö𝑣𝑒𝑡𝑘𝑒𝑧ő(𝑡, 𝑝5 )

 𝑟 = NIL

Ezt az esetet az előző, második programág kezeli azzal, hogy a nulla-hosszúságú „balra fölfelé” vezető út után nem képes egy lépést tenni „jobbra fölfelé”. 11.2.4. Adott kulcsérték beszúrása A bináris keresőfába úgy illeszthetünk be – csúcs formájában - egy új kulcsos rekordot, hogy összeállítjuk az új tartalmat egy pontosan olyan szerkezetű rekordban, mint amilyen többi rekord. Az új rekord rendelkezik azzal a három pointer mezővel, amellyel a fában mindegyik fel van szerelve; ezek a bal és a jobb gyerekre, valamint a szülőre mutatnak. Az új rekordra mutató pointert „adjuk oda” a beszúrást végző eljárásnak, amely a már többször látott, balra-jobbra összehasonlító és lépegető stratégiával megkeresi az új kulcs helyét és létrehozza a bináris keresőfa egy új levelét. Ha a beillesztendő kulcs különbözik a fa mindegyik kulcsától, akkor sikeres lesz az elhelyezés (ezt az jelzi, hogy az eljárás a p pointer étékét adja vissza), ha viszont megegyezik valamely kulcsértékkel a fában, akkor a sikertelen beszúrást a visszaadott NIL érték jelzi. A beszúró algoritmus működése a 11.7. ábráról olvasható le.

11.7. ábra. Kulcsos rekord beszúrása bináris keresőfába

Ha összeállítunk egy olyan új rekordot, amelynek a kulcsa a 45 érték és a p pointer mutat a rekordra, akkor a beszúrás hívása és eredménye a következő: (1) 𝑟 ≔ 𝐵𝑒𝑠𝑧ú𝑟(𝑡, 𝑝)

 𝑟=𝑝

A keresőfába beillesztett új, 45-ös kulcsértékű csúcsot megtaláljuk a 11.9. ábrán. 6

11.2.5. Adott kulcsérték törlése Ha a bináris keresőfa adott kulcsú rekordját törölni szeretnénk, akkor rá kell állni egy pointerrel ahhoz, hogy a törlés műveletét meghívhassuk. A törlésnek három esetét különböztetjük meg, ahogyan ez a 11.8. ábrán elhelyezett algoritmus-leírásban követhető.

11.8. ábra. Adott kulcsérték törlése bináris keresőfából

Az első esetben a törlendő elem egy levél, tehát nincs sem bal, sem jobb oldali leágazása. Töröljük például a p10 pointer által mutatott 100-as kulcsú elemet: (1)

𝑟 ≔ 𝑇ö𝑟ö𝑙 (𝑡, 𝑝10 )

 𝑟 = 𝑝10

A törlés ebben az esetben a legegyszerűbb. A törlendő elemet és a szülőjét „szétláncoljuk”, és a fából így eltávolított csúcs pointerét a felhasználó rendelkezésére bocsátjuk. Esetünkben az eljárás a 100 kulcsértékű rekord szülőjének, a 90-es csúcsnak a jobb oldali pointerét NIL-re állítja, a p10-es mutató értékét pedig visszaadja a hívás helyére (lásd: 11.9. ábra). A második eset olyan elem törléséről szól, amelynek egy gyereke van, azaz egyik oldalon egy részfa kapcsolódik hozzá, de a másik oldala üres. Töröljük például a p3 pointer által azonosított 10-es kulcsú elemet: (2)

𝑟 ≔ 𝑇ö𝑟ö𝑙 (𝑡, 𝑝3 )

 𝑟 = 𝑝3

A törlés ebben az esetben sem nehéz. Ezúttal a törlendő elemet nem csak a szülőjétől, hanem gyerekétől is függetlenné tesszük, és a fából így eltávolított csúcs pointerét visszaadjuk a művelet hívásának a helyére. Ezután a törölt elemnek a szülő és gyerek csúcsát még össze kell kapcsolni, hogy ne maradjon szakadás a fában. Esetünkben az eljárás a 10-es kulcsértékű rekord szülőjének, a 30-as csúcsnak a bal oldali pointerét 20-as csúcsra állítja, ennek szülő pointerét pedig a 30-asra, a p3-as mutató értékét pedig visszaadja (lásd: 11.9. ábra). 7

11.9. ábra. A bináris keresőfa három művelet (egy beszúrás és két törlés) végrehajtása után

11.10. ábra. A bináris keresőfa az újabb (harmadik) törlés végrehajtása után 8

A törlés harmadik esete egy olyan csúcsra vonatkozik, amely mindkét oldali gyerekcsúccsal (részfával) rendelkezik. Olyan megoldást keresünk, amely nem növeli a keresőfa magasságát. A törlésre vonatkozó előírást logikainak fogjuk fel, és eltávolítjuk ugyan a megjelölt kulcsú rekordot, de ezt fizikailag egy másik elemet törlésével oldjuk meg. Megkeressük a törlésre váró elem jobb oldali részfájában a minimális kulcsot és fizikailag azt a csúcsot töröljük, előbb azonban a benne tárolt értékkel felülírjuk a törlésre kijelölt csúcs tartalmát. Azért választjuk ezt a megoldást, mert a fizikailag törölt csúcsnak, mint egy részfa minimumának, nem lehet bal oldali gyereke, így az előzőleg tárgyalt első vagy második típusú törlés alkalmazható rá. Töröljük például a p4 pointer által mutatott 60-as kulcsot a t keresőfából: (3)

𝑟 ≔ 𝑇ö𝑟ö𝑙 (𝑡, 𝑝4 )

 𝑟 = 𝑝11 (!)

A 11.10. ábra szemléletesen mutatja be a törlés imént ismertetett módszerét. Láthatjuk azt is, hogy az eljárás által visszaadott mutató érték ebben a harmadik esetben nem a törlésre eredetileg kijelölt rekordra mutat, hanem annak a csúcsnak a pointere, amelyik fizikailag törlésre került. 11.3. A keresőfa műveleteinek hatékonysága A bináris keresőfa műveleteinek lépésszámát összefüggésbe hozzuk a fa magasságával. Meggondolásaink arra vezetnek, hogy famagasság nagyságrendben felülről korlátozza a műveletigényt. Eredményünk azt a célt vetíti elénk, hogy előnyös lenne a keresőfát minél inkább „tömörített” formában tartani, a műveletek hatékonysága miatt. A bináris keresőfa várható magasságára próbálunk ezután becslést adni. Elméletileg érdekes kérdés, hogy nagyszámú, véletlen jellegű művelet elvégzése után milyen famagasságra számíthatunk. A válasz a gyakorlat számára is értelmezhető. 11.3.1. A műveletek költsége és a keresőfa magassága Ha sorra áttekintjük azt az öt műveletet, amelyet a bináris keresőfákra bevezettünk, akkor azt láthatjuk, hogy mindegyiknek a lényegi részét egy útvonal bejárása adja a fában. Ez az útvonal egy olyan útnak részét képezi, amely a fa gyökerétől valamely levélig terjed. Ennek a befoglaló útvonalnak a hossza attól függ, hogy milyen mélységben található az a levél, amelyben végződik. Minden esetre, a keresőfa magassága felső korlátját képezi a teljes útnak, így a szóban forgó művelet úthosszának is. Ez minden egyes példánkon ellenőrizhető. Nézzük például a 90-es kulcsú rekord megkeresését, vagy a 60-as kulcsérték törlését. Ennek a két műveletnek a végrehajtása során bejárt két út ugyannak a 40 → 70 gyökér-levél útvonalnak a részét képezi. A műveletek lépésszáma lényegében megegyezik a fában bejárt útvonal hosszával, amelyhez még hozzá számítunk néhány (konstans számú) lépést. Azt mondhatjuk tehát, hogy bináris keresőfa mindegyik op műveletére érvényes az az állítás, hogy lépésszámát nagyságrendben a fa ℎ(𝑡) magassága felülről korlátozza: 𝑇𝑜𝑝 (𝑛) = 𝛰 (ℎ(𝑡)) A bináris fa a magasságának az alsó korlátját a majdnem teljes fa „összenyomott” állapotában veszi fel, a magasság legnagyobb értékét pedig egy láncszerű fa esetén kapjuk. Érvényes a következő összefüggés: ⌊𝑙𝑜𝑔2 𝑛⌋ ≤ ℎ(𝑡) ≤ 𝑛 − 1 A két összefüggés alapján a bináris fa műveleteire a következőket állíthatjuk az általános (tetszőleges egyedi) esetben, valamint a legkedvezőbb, illetve a legkedvezőtlenebb esetben: 9

𝑇𝑜𝑝 (𝑛) = 𝛰 (𝑛),

𝑚𝑇𝑜𝑝 (𝑛) = 𝛰 (log 𝑛),

𝑀𝑇𝑜𝑝 (𝑛) = 𝛰 (𝑛)

A meggondolások és az eredmények alapján kitűzhetjük azt a programot, amelynek a megoldását a következő fejezetek adják. A műveletek hatékonysága érdekében jó lenne a bináris keresőfa magasságát karbantartani; ha lehetséges, akkor log 𝑛-es nagyságrendben korlátozni. 11.3.2. A véletlen bináris keresőfa felépítésének várható költsége Az a természetes módon adódó kérdés, hogy nagyszámú véletlen jellegű művelet elvégzése után mennyi lesz a bináris keresőfa várható magassága, túl általános ahhoz, hogy meg tudjuk válaszolni. A famagasság helyett egy hasonló és könnyen számolható speciális értéket vizsgálunk. A 11.1. ábrán látható t fa magassága ℎ(𝑡) = 5, míg átlagos csúcsmagassága 30⁄12 = 2,5. Érdemes külön megnevezni azt, hogy a számlálóban szereplő 30-as érték a t fa csúcsmagasságösszege (a 12 pedig a t fa pontjainak a száma, amit általában n-nel jelölünk.) Ha nagyszámú bináris fára számolnánk átlagos csúcsmagasságot, akkor ez két átlagszámítást jelentene: egyet magukon a fákon, egyet pedig a fák teljes populációján. Áttekinthetőbb lesz a számítás, ha bevezetjük az n pontot tartalmazó bináris fák átlagos (vagy várható) csúcsmagasság-összegét, amit 𝑓(𝑛)-nel jelölünk. Az átlagos csúcsmagasság-összeget a következő módon értelmezhetjük: vegyük az 1, 2, …, n kulcsok összes permutációját, mindből építsünk bináris keresőfát és számoljuk ki a csúcsok magasságösszegét is. Végül, képezzük az így meghatározott 𝑛! számú érték átlagát; erre vezettük be az 𝑓(𝑛) jelölést. A valószínűségszámítás fogalmaival ez máshogyan is elmondható. Elméletben sorsoljuk ki véletlenszerűen egy permutáció1, 2, …, n kulcsok összes permutációjából, ha mind egyformán valószínű. A magasságösszeg várható értékét ekkor egy elméleti kalkulussal számítjuk, és ugyanazt az értéket kapjuk, mintha az összes tényleges esetre átlagolnánk. Végül, az 𝑓(𝑛)/𝑛 érték az egyes fákra értelmezhető átlagos csúcsmagasságok átlaga, illetve várható értéke, az összes 𝑛! számú kulcssorozatot alapul véve. A nagyszámú véletlen művelet helyett egy speciális tevékenység-sorozatot tekintünk: adott n számú kulcsértékből felépítjük a keresőfát. Ennek során egymás után n-szer hajtjuk végre a fába történő beszúrás műveletét. A kulcsértékek – az általánosság megsértése nélkül – vehetők az az 1, 2, …, n értékeknek. Minden átlagszámítás mögött az adatoknak egy eloszlása húzódik meg, amelyet vagy ismernünk kell, vagy valamilyen feltevéssel kell élni arról, gyakorlati átlagot vagy elméleti várható értéket csak így tudunk számolni. Tételezzük fel, hogy az 1, 2, …, n kulcsok minden sorrendje (permutációja) egyformán valószínű! Ennyi előkészítés után megfogalmazzuk a feladatot. Számítsuk ki bináris keresőfa várható 𝑓(𝑛) csúcsmagasság-összegét minden olyan 𝑡𝑛 fát alapul véve, amelyet az 1, 2, …, n kulcsok sorozatából építünk fel. A véletlen sorozat elnevezés egyezményesen a permutáció egyenletes eloszlását jelenti. Ha meghatároztuk a várható csúcsmagasság-összeget, akkor azt n-nel osztva az egy fában mért csúcsmagasságok átlagának az 𝑓(𝑛)/𝑛 várható értékét kapjuk, ami már jellemzi a fa átlagos magasságát is: 𝑓(𝑛)/𝑛 ~ 𝐴ℎ(𝑡𝑛 )

10

A bináris keresőfa felépítése során egy kulcs beépítése éppen annyi összehasonlítással jár, mint amilyen magasságban végül a megfelelő csúcs a fában elhelyezkedik. Például a 𝑘 = 90 kulcs beszúrása a t fába három összehasonlítást igényelt és valóban ℎ(90) = 3. A keresett várható értéket nem egyszerre az összes kulcssorrendre számoljuk, hanem az összes permutációt n egyenlő számosságú részhalmazra bontjuk. Tekintsük az összes 𝑛! számú permutáció közül azokat, amelyekben a 𝑘 (1 ≤ 𝑘 ≤ 𝑛) kulcsérték áll az első helyen. Azokat a fákat, amelyek ilyen sorozatokból épülnek fel, általánosan illusztrálja a 11.11. ábra.

11.11. ábra. Véletlen építésű bináris keresőfa átlagos létrehozási költsége

Ha minden ilyen (𝑛 − 1)! számú kulcssorozatra kiszámítjuk a csúcsmagasság-összeg várható értékét, akkor azok átlagaként kapjuk a keresett 𝑓(𝑛) értéket. Jelölje 𝑓(𝑛|𝑘) a várható csúcsmagasság-összeget abban az esetben, ha a k kulcs érkezik első helyen és így azt a fa gyökerében helyezzük el. Ekkor 𝑓(𝑛) =

𝑛 1 ∑ 𝑓(𝑛|𝑘) 𝑛 𝑘=1

A 11.11. ábra alapján látható, hogy erre a fára az átlagos csúcsmagasság-összeg számításában figyelembe lehet venni azt, hogy a k-tól különböző csúcsok melyik oldali részfába kerültek (az 1, 2, … , 𝑘 − 1 balra, a 𝑘 + 1, … , 𝑛 jobbra), miután egy összehasonlítás a gyökérrel ezt eldöntötte. 𝑓(𝑛|𝑘) = (𝑘 − 1) + 𝑓(𝑘 − 1) + (𝑛 − 𝑘) + 𝑓(𝑛 − 𝑘) Helyettesítsük ezt az összefüggést a fenti egyenlőségbe és végezzünk átalakításokat:

1 n  (n  1)  f (k  1)  f (n  k )  n k 1 1 n  (n  1)  k 1  f (k  1)  f (n  k )  n

f ( n) 

1  (n  1)  [  f (0)  f (n  1)  f (1)  f (n  2)  ...  f (n  1)   f ( 0) ]  n 0 0  (n  1) 

2 n  f (k ) n k 1 11

Végül, a nyilvánvaló f (1)  0 összefüggés figyelembe vételével a következő rekurzív egyenletet kapjuk: 𝑓(1) = 0 𝑛 2 { 𝑓(𝑛) = (𝑛 − 1) + ∑ 𝑓(𝑘) 𝑛 𝑘=1 Azt állítjuk, hogy ennek a rekurzív egyenletnek a megoldására teljesül a következő felső becslés:

f (n)  2n ln n

(n  1)

A bizonyítást teljes indukcióval végezzük. Az n  1 esetben egyetlen pontból álló bináris fáról van szó, amelyben a csúcs elhelyezése összehasonlítás nélkül történt, tehát valóban igaz, hogy f (1)  0 . A bizonyítandó becslés fennáll, mivel 2 ln 1  0 . (Az n  2 esetet már nem kellene ellenőrizni, ám ha engedünk a „kíváncsiságunknak”, akkor megnyugtató, hogy f (2)  1  2 ln 2  2 * 0,69  1,38 .) Tegyük fel, hogy igaz a bizonyítandó felső becslés 1, 2, …, (n  1) -re. Ekkor f (n)  (n  1) 

2 n 1 2 n 1 f ( k )  ( n  1 )    2k ln k  n k 1 n k 1

 (n  1) 

4 n 1 4 k ln k  (n  1)   x ln x dx   n k 2 n2 n

n  4   x 2 ln x x 2   4 n2 4   (n  1)    ( n  1 )  2 ln n  1   2 ln 2  1       n  2 4 2  n 4 n  0,3863   

 2n ln n  (n  1)  n 

4 0,39  2n ln n n

Azt kaptuk, hogy f (n)  2n ln n  1,3863n log 2 n , ami átrendezve az f (n) / n  2 ln n  1,39 log 2 n

eredményre vezet. A bebizonyított becslést úgy értelmezhetjük, hogy a véletlen kulcssorozatból felépített bináris keresőfa várható csúcsmagasság-átlaga – kis konstans szorzóval – a log n -es nagyságrendű marad. A tényleges famagasság átlaga szimulációval becsülhető. Ennek eredményeképpen hasonló állítás fogalmazható meg 2 körüli konstanssal. Eredményünk nagyon megnyugtatónak tűnik, de csak abban az esetben garantált az, hogy a keresőfa várhatóan „nem nyúlik meg”, ha a kulcssorozat valóban véletlen, vagyis egyenletes eloszlásból származik. Ez a gyakorlati életben egyáltalán nem garantált. Gondoljunk arra, hogy mondjuk, egy cég életében az adatok (például napi kereskedelmi adatok) összegyűjtése adott rendet követ, így a véletlenszerűség biztosan torzul. Ezért a bináris keresőfa karbantartásának célkitűzését változatlanul érvényesnek tekintjük. 12

12. AVL-FÁK Az AVL-fák részletes ismertetése kidolgozás alatt áll. 12.1. Az AVL-fa 12.2. Az AVL-fa karbantartása beszúrás esetén 12.2.1. A (++,+) címkéjű forgatás

12.1. ábra. Az AVL-fa kiegyensúlyozottságának elromlása, (++,+) eset

12.2. ábra. Az AVL-tulajdonság helyreállítása (++,+) forgatással

12.3. ábra. Az AVL-tulajdonság helyreállításának sémája, (++,+) eset

12.2.2. A (++,-) címkéjű forgatás

12.4. ábra. Az AVL-fa kiegyensúlyozottságának elromlása, (++,–)

12.5. ábra. Az AVL-tulajdonság helyreállítása (++,–) forgatással

12.6. ábra. Az AVL-tulajdonság helyreállításának (++,–) sémája

12.3. Az AVL-fa magassága 12.3.1. Fibonacci-fák

12.7. ábra. Fibonacci-fák

12.3.2. A famagasság becslése 12.4. Törlés az AVL-fából

13. 2-3-fák, B-fák A 2-3-fák és a B-fák részletes ismertetése kidolgozás alatt áll. 4.1. A 2-3-fa

13.1. ábra. A 2-3-fa absztrakt adatszerkezet

13.2. ábra. A 2-3-fa belső csúcsainak tartalma (ábrázolás szintje)

13.3. ábra. Kulcsértékek a 2-3-fa belső csúcsaiban (3 leágazás)

13.4. ábra. Kulcsértékek a 2-3-fa belső csúcsaiban (2 leágazás)

4.2. Beszúrás a 2-3-fába

13.5. ábra. A 2-3-fa beszúrások előtt

13.6. ábra. A 2-3-fa két beszúrás után, a csúcsvágás jelensége

13.7. ábra. A 2-3-fa a további két beszúrás után, csúcsvágás és a famagasság növekedése

4.3. Törlés a 2-3-fából

13.8. ábra. Két törlés a 2-3-fából, gyerekcsúcs átadása

13.9. ábra. Törlés a 2-3-fából, a csúcsösszevonás jelensége

13.10. ábra. Törlés a 2-3-fából, csúcsösszevonás és a famagasság csökkenése

13.11. ábra. A törlések utáni 2-3-fa

4.3. A B-fa

13.11. ábra. A B-fa elhelyezése a mágneslemezes háttértárolón

13.12. ábra. A B-fa legnagyobb magassága (1 millió rekord legritkább elhelyezése)

13.13. ábra. A B-fa legkisebb magassága (1 millió rekord legsűrűbb elhelyezése)

14. HÁROM HAGYOMÁNYOS (NÉGYZETES) RENDEZÉS Jegyzetünk 19. fejezetben tárgyaljuk azt a sokat által ismert tényt, hogy n tetszőleges elem összehasonlítás alapú rendezése leggyorsabban θ(𝑛 log 𝑛) összehasonlítással végezhető el. Bár nagy bemenetre jóval lassabbak, mégis hasznos megismerni az alábbi θ(𝑛2 ) összehasonlítást használó algoritmusokat. Nagy előnyük az egyszerűségük, ami nem csak a programozó számára jelent könnyebbséget, hanem abban is megnyilvánul, hogy rövid bemenetre gyorsabb futásidőt eredményeznek, mint az θ(𝑛 log 𝑛)-es társaik. Például, n = 8 elem esetén egy 2𝑛2 lépésszámú algoritmus 2 ∙ 64 = 128 időegység alatt fut le, míg egy 10 ∙ 𝑛 log 2 𝑛 futásidejű algoritmus 10 ∙ 8 ∙ 3 = 240 időegység alatt. Látni fogjuk, hogy sok esetben a legjobb konstans szorzó a beszúró rendezés esetén érhető el, míg a legkevesebb mozgatásra a maximumkiválasztó rendezésnek van szüksége. A buborékrendezést könnyű érthetősége és elterjedtsége miatt ismertetjük. 14.1. Buborékrendezés A buborékrendezés az egyik legrégebbi ismert rendezés. Lényege az, hogy a maximális elemet cserékkel „felbuborékoltatjuk” a tömb végére, és így visszavezetjük a problémát egy 1-el rövidebb rendezési feladatra. A buborékoltatás úgy működik, hogy párosával (mintha egy két elem szélességű ablakot léptetnénk) haladunk végig az elemeken és a rossz sorrendben lévő párokat megcseréljük. Ezt az eljárást a 14.1. ábra szemlélteti.

14.1. ábra. A buborékrendezés működése

Belátható, hogy így a legnagyobb elem eljut egészen a tömb végéig. (Ha több maximális elem is van a tömbben, akkor közülük a jobboldali jut el a tömb végére, mert egy egyenlő elempár esetén nem hajtunk végre cserét.) A második felbuborékoltatásnál már a tömb utolsó elemét nem kell figyelembe vennünk, hiszen tudjuk, hogy az jó helyen van.

Indukcióval látható, hogy a k-adik felbuborékoltatáskor az utolsó k-1 elem a tömb jobb szélén helyezkedik el, rendezve. A rendezés algoritmusa a 14.2. ábrán látható. (A működés leírásában saját ciklusszervezést használtunk.)

14.2. ábra. A buborékrendezés algoritmusa

Ebben a megfogalmazásban az indexek változtatásának a követése elvonhatja a figyelmet a lényegről, ezért tekintsük inkább a for ciklusos változatot, amely a 14.3. ábrán szerepel. A for ciklus, mint tudjuk, a by után megadott mértékben automatikusan növeli a ciklusváltozót minden iteráció végén.

14.3. ábra. A buborékrendezés algoritmusa for ciklusokkal

Az algoritmus egy külső és egy belső ciklusból áll. A külső ciklus j ciklusváltozója azt jelöli, hogy hányadik elemig rendezetlen még a tömb. Kezdetben természetesen mind az n elemet rendezetlennek vesszük, majd „buborékoltatásonként” eggyel rövidebb lesz a rendezetlen rész.

A belső ciklus i változója az aktuálisan vizsgált pár első elemének indexét jelöli. A ciklusmagban azt vizsgáljuk, hogy az i-edik és az i+1-edik elem jó sorrendben van-e, ezért az i változót csak j-1-ig növelhetjük. A cserét most röviden jelöltük, valójában ez 3 mozgatást jelentene, egy külső változó felhasználásával. Könnyen észrevehetjük, hogy a buborékrendezés szükségtelenül sok mozgatást hajt végre, ezért nagyméretű adatoknál általában nem a legjobb választás. A műveletigény részletes számítása megtalálható az 1. fejezetben. Az elemzést itt is megismételjük. A számolást az egyszerűség kedvéért külön-külön végezzük az összehasonlításokra és a cserékre. Nézzük először az összehasonlítások Ö(n)-nel jelölt számát. A külső ciklus magja (n–1)szer hajtódik végre, a belső ciklus ennek megfelelően rendre n–1, n–2, …, 1 iterációt eredményez. Mivel a két szomszédos elem összehasonlítása a belső ciklusnak olyan utasítása, amely mindig végrehajtódik, ezért

Ö(n)  (n  1)  (n  2)    1  n(n  1) 2  n 2 2  n 2  (n 2 ) Az összehasonlítások száma ennek megfelelően négyzetes nagyságrendű. Vizsgáljuk meg most a cserék Cs(n)-nel jelölt számát. Ez a szám már nem állandó, hanem a bemenő adatok függvénye. Nevezetesen, a cserék száma megegyezik az A[1..n] tömb elemei között fennálló inverziók számával: Cs(n)  inv ( A).

Valóban, minden csere pontosan egy inverziót szüntet meg a két szomszédos elem között, újat viszont nem hoz létre. A rendezett tömbben pedig nincs inverzió. Ha a tömb eleve rendezett, akkor egyetlen cserét sem kell végrehajtani. A legtöbb cserét akkor kell végrehajtani, ha minden szomszédos elempár inverzióban áll, azaz akkor, ha a tömb éppen fordítva, nagyság szerint csökkenő módon rendezett. Ekkor a cserék maximális száma:

MCs(n)  n(n  1) 2  (n 2 ). A cserék átlagos számának meghatározásához először is feltesszük, hogy a rendezendő számok minden permutációja egyenlő valószínűséggel fordul elő. (Az átlagos műveletigény számításához mindig ismerni kell a bemenő adatok valószínűségi eloszlását, vagy legalább is feltételezéssel kell élni arra nézve!) Az általánosság megszorítása nélkül vehetjük úgy, hogy az 1, 2, ..., n számokat kell rendeznünk, ha elfogadjuk azt a szokásos egyszerűsítést, hogy a rendezendő értékek mind különbözők. A cserék számának átlagát nyilvánvalóan úgy kapjuk, hogy az 1, 2, ..., n elemek minden permutációjának inverziószámát összeadjuk és osztjuk a permutációk számával: ACs (n) 

1  inv( p), n! pPerm ( n )

ahol Perm(n) az n elem összes permutációinak halmazát jelöli. Az összeg meghatározásánál nehézségbe ütközünk, ha megpróbáljuk azt megmondani, hogy adott n-re hány olyan permutáció van, amelyben i számú inverzió található ( 0  i  n(n  1) 2 ). Ehelyett célszerű párosítani a permutációkat úgy, hogy mindegyikkel párba állítjuk az inverzét, például a p = 1423 és a pR = 3241 alkot egy ilyen párt. Egy ilyen párban az inverziók száma együtt éppen a lehetséges n2 -t teszi ki, például inv(1423)  inv (3241)  4  2  6.



Az állítás igazolására gondoljuk meg, hogy egy permutációban két elem pontosan akkor áll inverzióban, hogy ha az inverz permutációban nincs közöttük inverzió. A mondott párosításnak megfelelően minden két permutáció inverzióinak száma együttesen n2 , így az átlagos



csereszám: n n n!     inv ( p)   inv ( p )   2    2   n(n  1)  (n 2 ). ACs (n)  2n! 2n! 2 4 R

A cserék számának átlaga tehát a legnagyobb érték fele, de nagyságrendben ez így is n2-es. 14.2. Beszúró rendezés A beszúró rendezés sok esetben a leggyorsabb négyzetes rendezés. Működése hasonló ahhoz, mint amikor lapjainkat rendezzük egy kártyajáték során. A rendezés fő lépése az, hogy az asztalon lévő rendezetlen saját pakliból elvesszük a felső lapot és beszúrjuk a kezünkben tartott rendezett lapok közé. Kezdetben a rendezett rész az első felvett lapból áll, majd n-1 beszúrás után lapjainkat már rendezett módon tartjuk a kezünkben. A beszúró rendezés még nem említett előnye az, hogy nem csak tömbben tárolt elemek rendezésére alkalmas, hanem a láncolt listákra is könnyen alkalmazható. 14.2.1. Tömbös megvalósítás A tömbben tárolt adatok rendezésére alkalmazott beszúró rendezés működését, egy-egy jellemző lépés megjelenítésével a 14.4. ábra szemlélteti.

14.4. ábra. A beszúró rendezés működése tömbös megvalósítás esetén

Tömbös megvalósítás esetén a rendezett részt a tömb elején tároljuk, a rendezetlent pedig utána. Kezdetben csak a tömb első eleme rendezett. Minden iterációban a következőnek beszúrandó elemet elmentjük egy w változóba, majd az eddig rendezett rész nála nagyobb elemeit jobbra csúsztatjuk egy pozícióval. A megfelelő számú léptetés után felszabadul a félretett beszúrandó elem számára a megfelelő hely. Ezután a beszúrandó elemet w-ből bemásoljuk a megfelelő helyre. A teljes algoritmus a 14.5. ábrán látható.

14.5. ábra. A beszúró rendezés algoritmusa tömbös megvalósítás esetén

Algoritmusunk egy külső és egy belső ciklusból áll. A külső ciklus beszúrásonként lép egyet, míg a belső ciklus a beszúrás közbeni jobbra másolásokért felelős. A külső ciklus addig halad, amíg minden elemet be nem illesztettünk a helyére, a belső pedig addig, amíg meg nem találtuk az aktuálisan beszúrandó elem helyét. A külső ciklus mindig n-1 alkalommal fut le, hiszen ennyi elemet kell beszúrnunk a rendezett részbe, ahhoz hogy az egész tömb rendezve legyen. A belső ciklus műveletigénye változó. Legjobb esetben egyetlen összehasonlítást végez. Ez akkor fordul elő, ha a beszúrandó elem nagyobb vagy egyenlő az összes eddig rendezettnél. Legrosszabb esetben annyiszor hasonlítunk össze, ahány rendezett elem van és ugyanennyi másolást is végrehajtunk. Ez akkor történik, ha a beszúrandó elem kisebb az összes addig rendezettnél. Összefoglalva, a műveletigény a következőképpen alakul: 𝑛−1

𝑀Ö(𝑛) = ∑ 𝑖 = 𝑖=1

(1 + (𝑛 − 1))(𝑛 − 1) 𝑛(𝑛 − 1) = = θ(𝑛2 ) 2 2 𝑀𝑀(𝑛) = θ(𝑛2 )

A legjobb eset az, ha a tömb eleve rendezet, ekkor elérhető az alábbi minimum: 𝑚Ö(𝑛) = 𝑛 − 1 = θ(n) 𝑚𝑀(𝑛) = 2(𝑛 − 1) = θ(𝑛) Átlagos műveletigényt itt nem számolunk. Mivel minden beszúrás annyi inverziót szűntet meg, ahány elemet jobbra toltunk, és nem hoz létre új inverziót, ezért könnyen látható, hogy: Ö(𝐴[1. . 𝑛]) = 𝑖𝑛𝑣(𝐴) + 𝑛 − 1 𝑀(𝐴[1. . 𝑛]) = 𝑖𝑛𝑣(𝐴) + 2(𝑛 − 1) Alkalmas megvalósítással elérhető: 𝑀(A[1. . 𝑛]) = 𝑖𝑛𝑣(𝐴) Ez az utolsó tulajdonság különösen gyorssá teszi a beszúró rendezést abban az esetben, ha a tömb eleve „nagyjából rendezett” volt. Az algoritmus minden esetben gyorsabb a buborékrendezésnél. Azért is, mert egy elemet cserékkel helyretenni körülbelül 3-szor annyi másolást jelent, mint ha jobbra másolásokkal tennénk ezt. A következő megjegyzés azért lényeges, mert kijelöli ennek a rendezésnek a helyét a többi között. Bár a beszúró rendezés 𝑐𝑛2 idejű, rendkívül alacsony „konstansa” miatt szívesen használják, az oszd meg és uralkodj elvű, 𝜃(𝑛 log 𝑛) futásidejű algoritmusok gyorsítására. Amikor kellően kicsi (log 𝑛 méretű) részproblémákra osztják a feladatot, akkor már nem darabolják tovább, hanem a részeket beszúró rendezéssel rendezik. A nagyobb részproblémák megoldása az eredeti módon történik. Belátható, hogy így a rendezés 𝜃(𝑛 log 𝑛) futásidejű 𝑛 marad, hiszen legfeljebb log 𝑛 𝜃(log 2 𝑛) = 𝜃(𝑛 log 𝑛) pluszlépést hajtunk végre emiatt. Valójában azonban a kis konstans miatt így még sokat gyorsul is az algoritmus. 14.2.2. Láncolt megvalósítás A beszúró rendezés további jó tulajdonsága, hogy láncolt listára is viszonylag könnyen megvalósítható. A 14.6. ábra a rendezés három fázisát (kezdő, egy közbülső és befejező állapotát) illusztrálja.

14.6. ábra. A beszúró rendezés működése fejelemes láncolt lista esetén

Az algoritmus működése során a lista mindig két részt tartalmaz: az első fele már rendezett, míg a második felében még az eredeti sorrendben következnek az elemek. Úgy célszerű inicializálni ezt a helyzetet, hogy a rendezett rész kezdetben legyen az üres lista. Az eljárás végére viszont a rendezetlen rész fogy el. Egy közbülső állapot mutat az ábra második, középső listája. Az algoritmus egy lépésében a második részből kiláncoljuk az első elemet és a rendezett részben befűzzük a helyére. Az algoritmus leírását a 14.7. és 14.8. ábrákon láthatjuk.

15.7. ábra. A beszúró rendezés algoritmusa fejelemes láncolt lista esetén

A listát kezdetben – a fentiek szerint – úgy vágjuk ketté, hogy l egy üres fejelemes listára, míg p az eredeti elemek fejelem nélküli listájára mutat. Az algoritmus során végig p mutat a rendezetlen elemek listájára, míg l a már rendezett elemek fejelemes listáját azonosítja. Az algoritmus második része egy ciklus, amely p-t lépteti és a q által mutatott kiláncolt elemre meghívja a beillesztés eljárását.

14.8. ábra. A rendezettséget megtartó beillesztés algoritmusa fejelemes láncolt listára

A beillesztés algoritmusa úgy működik, hogy egy r mutatóval annyit lép l elemein, hogy az utolsó olyan elemre mutasson, amelyben még kisebb érték található, mint a beszúrandó elemben, amelyre a q pointer mutat. Ez után a q által mutatott listaelemet befűzi az r pointerű elem után.

A két megvalósítás között fennáll egy olyan különbség, a láncolt esetben a beszúrást balról jobbra hajtjuk végre, nem jobbról balra, mint a tömbös rendezésnél. A láncolt megvalósítás futásideje közel azonos a tömbös megvalósítás futásidejével, ezért az elemzést nem részletezzük. 14.3. Maximum kiválasztó rendezés A maximum kiválasztó rendezés alapötlete – hasonlóan a buborékrendezéshez – az, hogy a legnagyobb elemnek a tömb végén van a helye, a rendezés szerint. Ezért kiválasztjuk a tömb maximális elemét, majd kicseréljük az utolsó elemével. Ezután már eggyel rövidebb tömbre alkalmazhatjuk a maximum kiválasztást és cserét. Ezt addig ismételjük, míg az egész tömb rendezetté válik. A 14.9. ábrán az eljárás működésnek néhány fázisa látható.

14.9. ábra. A maximum kiválasztó rendezés működése

Az algoritmus, amely leírásában hivatkozik a sokszor használt maximum kiválasztásra, a 14.10. ábrán látható.

14.10. ábra. A maximum kiválasztó rendezés algoritmusa

Az algoritmusban j-vel jelöljük a tömb rendezetlen részének hosszát. Ez kezdetben n, hiszen még az egész tömb rendezetlen. A ciklus minden lépésében kiválasztjuk a rendezetlen résztömb maximumát és kicseréljük az utolsó elemével. Így a rendezetlen rész j hosszát 1-el csökkentettük. Ha a rendezetlen rész hossza 2, akkor az utolsó iterációra kerülhet sor, hiszen egyetlen elem önmagában rendezettnek számít. Érdekes eset az, amelyet a (c) ábrarész mutat: a maximális elem és a rendezetlen rész jobb szélső eleme ugyanaz, ilyenkor az elemet „önmagával cseréljük meg”. Az algoritmus futásideje a következőképp áll elő: Az összehasonlítások száma az n-1 maximumkeresés összehasonlítás-számainak összege. 𝑛−1

Ö(𝑛) = ∑ 𝑖 = 𝑖=1

𝑛(𝑛 − 1) = θ(𝑛2 ) 2

𝐶𝑠(𝑛) = 𝑛 − 1 Ezek fix értékek minden lehetséges bemenetre. Ez a maximumkiválasztó rendezés egyik hátránya a beszúróval szemben, hogy nem tudja kihasználni a bemenet tulajdonságait, ahhoz, hogy kicsit gyorsabban fusson. Nagy előnye viszont, hogy 3(𝑛 − 1) mozgatást végez, nem pedig θ(𝑛2 )-et. Ez akkor igazán előnyös, ha az elemek nagy mérete miatt a mozgatás lassabb művelet, mint az összehasonlítás.

15. A VERSENYRENDEZÉS A versenyrendezés (tournament sort) a maximum-kiválasztó rendezések közé tartozik, ugyanis az elemek közül újra és újra kiválasztja (eltávolítja és kiírja) a legnagyobbat. Az eljárás így, a kiírás sorrendjében, nagyság szerint csökkenő rendezést valósít meg. A maximum kiválasztásban jól ismert gyakorlati háttérre támaszkodik: a kieséses sportversenyek lebonyolítási rendjét követve határozza meg az elemek között a „győztest”. A módszert n = 2k elemszámra ismertetjük, ahogyan egy teniszverseny játéktáblájára is 2-hatvány számú versenyzőt írnak fel. Ahogyan a versenyen is megoldják, hogy ha a résztvevők száma nem ennyi, úgy a versenyrendezést is ki lehet terjeszteni tetszőleges n input méretre. A kiterjesztésre azonban nincs gyakorlati igény, mert egy másik, ehhez nagyon hasonló rendező eljárás, a kupacrendezés több szempontból kedvezőbb tulajdonságú, így előnyt élvez a gyakorlati felhasználásban. A versenyrendezést több szempontból is érdemes megismerni. Egyszerűsége könnyen érthetővé teszi, amihez hozzájárul a kieséses sportverseny, mint szemléletes háttér is. Az eljárás jól reprezentálja mind a maximum-kiválasztó, mind az 𝑛 log 𝑛-es műveletigényű rendezéseket. Az algoritmus és a használt adatszerkezet, a versenyfa (tournament) alkalmas arra, hogy látványos különbséget tegyünk az absztrakt adatszerkezet (teljes bináris fa) és az (tömbös) ábrázolás szintje között. 15.1. A versenyrendezés módszere A versenyrendezés a versenyfa (tournament) adatszerkezetet használja. A versenyfa olyan teljes bináris fa, amelynek a leveleiben helyezkednek el a rendezendő elemek. A 15.1. ábrán egy kitöltött levelekkel rendelkező, de a belső pontjaiban még kitöltetlen versenyfát láthatunk.

15.1. ábra. Versenyfa, leveleiben a rendezendő számokkal

A fa belső pontjait szintenként úgy töltjük ki, ahogyan egy kieséses verseny halad előre: minden belső pontban a gyerekei közül a nagyobbnak az értéke kerül (mint egy mérkőzés nyertese). Végül, a maximum (az abszolút győztes) a fa gyökerébe kerül. A kitöltött versenyfát a 15.2. ábra szemlélteti, n = 8 rendezendő szám esetére. A rendezés egy speciális első menetet, majd azután még (𝑛 − 1) egyszerűbb iterációs lépést hajt végre. Az első menetben – a versenyfa imént leírt kitöltésével – kiválasztjuk a legnagyobb elemet, amely „felkerül” a fa gyökerébe. Vegyük hozzá ehhez még a legnagyobb elem kiírását is, a rendezés kimenetére. Ezt már a következő 15.3. ábra tünteti fel.

15.2. ábra. Kitöltött versenyfa

15.3. ábra. Az első „újrajátszás” a győztes ágán

15.4. ábra. A második „újrajátszás” a következő győztes ágán

A versenyfa kitöltését követően kerül sor (𝑛 − 1) egyszerűbb menetre, a következő legnagyobb elem kiválasztására. Példánkban ez 8 − 1 = 7 iterációt jelent, amelyek közül az első kettő lépéseit mutatja be a 15.3. és a 15.4. ábra.

Miután a gyökérben megjelenő legnagyobb elemet kiírtuk, „lefelé” haladva megkeressük azt a levelet, amelyben eredetileg ez az érték helyet foglal. A levélben található értéket (−∞)re cseréljük, azaz egy abszolút vesztest teszünk a helyére. Utána „felfelé” haladva ezen az ágon „újrajátsszuk a mérkőzéseket”. Ezeket a lépéseket sorszámozott formában láthatjuk a 15.3. ábrán. Az újrajátszás eredményét már a következő 15.4. ábra tünteti fel. A második legnagyobb elem megjelent a gyökérben. Ez által azt is megtudjuk, hogy „ki nyert volna, ha a győztes nem indult volna a versenyen”. Ezen az ábrán bejelöltük a második legnagyobb elem levélszintű helyének a megkeresését és az ezt követő újrajátszás útvonalát. Így haladunk tovább, minden menetben a következő legnagyobb elemet megkeresve és kiírva. Mivel az elemeket csökkenő sorrendben vettük ki, az eljárás alkalmas a rendezés megvalósítására. 15.2 . Rekurzív algoritmus az absztrakt adatszerkezet szintjén Először ADS szinten, rekurzív módon írjuk le az algoritmust, mert az jobban illeszkedik az eddigi szemléletes meggondolásokhoz, könnyebben tudjuk algoritmus formájában rögzíteni a bináris fán leírt működést. (Később leírjuk azt a változatot is, amely a versenyfa tömbben tárolt változatán működik.) 15.2.1. A versenyfa kezdeti kitöltése Tételezzük fel, hogy a 𝑡 fa levelei már tartalmazzák a rendezendő adatsorozatot. Ahhoz, hogy ezt a teljes bináris fát versenyfává tegyük, először töltsük ki a baloldali részfáját, majd a jobboldalit is. Ha már a bal és jobb részfa egyaránt kitöltött versenyfa, akkor mindössze annyi a dolgunk, hogy a fa gyökerébe beírjuk a bal és jobb részfa gyökereiben lévő értékek maximumát. A rekurzióból való „kijáratot” az biztosítja, hogy ha a 𝑡 fa magassága 0, azaz egyelemű fáról van szó, akkor már nem kell tennünk semmit. A versenyfa kitöltésének algoritmusát a 15.5. ábrán adtuk meg.

15.5. ábra. A versenyfa kezdeti kitöltése (rekurzív)

15.2.2. A következő legnagyobb elem kiválasztása A következő maximum kiválasztása érdekében a mérkőzéseket újrajátsszuk a győztes ágán. Ezt is rekurzív módon hajtjuk végre. Ha 𝑡 az egy elemű fa, akkor azt jelenti, hogy a rekurzív hívások láncolatában elérkeztünk az aktuális „győztes” eredeti helyéhez. Állítsuk át annak értékét (−∞)-re! Egy magasabb (nagyobb méretű) fában az újrajátszás rekurzív szemlélettel úgy fogalmazható meg, hogy

(i)

ha a gyökérelem (azaz a győztes) a baloldalról származik, akkor a baloldali részfa egy ágát játsszuk újra, ha a jobboldalról, akkor a jobboldaliban tesszük ugyanezt;

(ii)

miután a megfelelő oldalon elvégeztük az újrajátszást, újból ki kell számítanunk a fa gyökérelemének értékét, ami a következő maximummal lesz egyenlő.

15.6. ábra. A győztes ág újrajátszása (rekurzív)

A rekurzív algoritmus a 15.6. ábrán látható. A rekurzív hívások láncolatának végrehajtása azt eredményezi, hogy az egymásba ágyazott hívások során az eljárás balrajobbra tájékozódva eljut a maximális elem levélszintű helyéhez és azt (−∞)-re állítja. Majd a hívásokból való visszatérések során történik a tulajdonképpeni újrajátszás a győztes ágán, amely elvezet a következő legnagyobb elem kiválasztásához a gyökérben. 15.2.3. A versenyrendezés rekurzív algoritmusa A versenyrendezés a két létrehozott rekurzív eljárás felhasználásával, a bevezetőben leírt módon működik. Az algoritmust a 15.7. ábrán adtuk meg.

15.7. ábra. A versenyrendezés rekurzív megvalósítása

Tegyük fel, hogy a 𝑡 fa levelei tartalmazzák a rendezendő sorozatunkat. Egyetlen elem rendezése nem kíván tevékenységet. Több elem esetén először kitöltjük a 𝑡 rendezőfát, majd kiírjuk a maximumát. Ezután (𝑛 − 1)-szer végre kell hajtanunk az a legnagyobb elem (a győztes) ágán az újrajátszását, amivel megvalósítjuk az egyre csökkenő maximumok egymás utáni kiválasztását, vagyis magát a rendezést. 15.3. Iteratív megvalósítás a reprezentáció szintjén A teljes bináris fa egyszerű és helytakarékos megvalósítása az, ha tömbben tároljuk. Az 𝑛 = 2𝑘 levelű fának (2𝑛 − 1) pontja van, tehát egy (2𝑛 − 1) hosszú tömbben tárolható. A tömb elemei felelnek meg a fa csúcsainak. A tömb 𝑖-edik elemének balgyereke a 2𝑖-edik elem, míg jobbgyereke a (2𝑖 + 1) indexű 𝑖 helyen található. Ha 𝑖 ≠ 1, akkor az 𝑖-edik elem szülője az ⌊ 2 ⌋ indexszel érhető el. A példánkban szereplő versenyfa tömbös tárolását és a szülő-gyerek kapcsolatokat a 15.7. ábra szemlélteti.

15.8. ábra. Versenyfa tárolása tömbben

15.3.1. A versenyfa kezdeti kitöltése Ha feltételezzük, hogy az 𝐴 tömb már tartalmazza az 𝑛 számú rendezendő elemet az 𝑛edik pozíciótól kezdve a (2𝑛 − 1)-edik helyig, akkor a tömb megelőző elemei, amelyek a fa belső pontjait reprezentálják, egyszerű iterációval kitölthetők. A (2𝑛 − 1)-edik tömbelemtől kezdve az első elemig jobbról balra lépegetünk, és minden pozícióba beírjuk a (bináris fában értelmezett) bal- és a jobbgyerek értékei közül a nagyobbat. A fa A[1]-es gyökerébe, a legnagyobb érték kerül. A versenyfa kitöltésének algoritmusát a 15.9. ábrán adtuk meg.

15.9. ábra. Az versenyfa kitöltése (iteratív)

15.3.2. A következő legnagyobb elem kiválasztása A következő maximum meghatározásának céljából két nagyobb iteratív módon szervezett lépést hajtunk végre. Az első ciklikusan szervezett tevékenység során végighaladunk a gyökértől kezdve az aktuális győztest tartalmazó levélig. Ezután a megtalált levél értékét (−∞)-re állítjuk. Az eljárás második iteratív lépéssorozatában visszafelé haladunk, a most átírt elemtől végig a gyökérig, és minden érintett tömbelem értékét a (bináris fában értelmezett) gyerekei maximumára frissítjük. 𝑗

A 𝑗 ≔ ⌊ 2 ⌋ utasítás az jelenti, hogy a 𝑗 indexű tömbelemről, amely a versenyfa egy csúcsát tárolja, annak szülőjére ugrunk. Onnan azután majd „visszanyúlunk” a két gyerekcsúcs tömbbeli helyére, amikor kitöltjük az értékét. A következő maximum kiválasztásának algoritmusát a 15.10. ábra tartalmazza.

15.10. ábra. A győztes ág újrajátszása (iteratív)

15.3.3. A versenyrendezés iteratív algoritmusa A versenyrendezés tömbös algoritmusában feltételezzük, hogy az 𝐴 tömb kezdetben tartalmazza az 𝑛 számú rendezendő elemet, az 𝑛-edik helytől kezdve a (2𝑛 − 1) indexű utolsó elemig. A rendezés – a fenti leírásnak megfelelően – a következő nagyobb lépéseket végzi, az imént létrehozott két eljárás felhasználásával, a 15.11. ábrán látható módon. Először meghívja a versenyfa kitöltését végző tömbös eljárást, majd kiírja a tömb első elemét, amely a fa gyökérnek, vagyis a maximális elemnek felel meg. Ezután (𝑛 − 1)-szer végrehajtja a következő maximum kiválasztását végző eljárást, amely mindannyiszor „felhozza” a tömb első elemébe az aktuális győztest. Ezt mindig az elem kiírása követi.

15.11. ábra. A versenyrendezés iteratív algoritmusa

15.4. A versenyrendezés műveletigénye Az algoritmus speciális első menete, a versenyfa kitöltése (𝑛 − 1) összehasonlítást alkalmaz, hiszen a versenyfának (𝑛 − 1) belső pontja van, és mindegyiket egy-egy összehasonlítás segítségével töltjük ki. A kitöltés ugyanennyi mozgatást is igénybe vesz. A versenyfa, mint teljes bináris fa magassága log 2 𝑛, ahol n, a levelek száma, egy 2hatvány. A további menetek 2 log 2 𝑛 összehasonlítást használnak, mivel kétszer kell a fát teljes magasságában „átszelni”, egyszer a maximális elemhez tartozó levél megtalálásához, azután pedig egy ág újrajátszása során. Elemmozgatás csak a második, újrajátszási lépéssorozathoz tartozik. Összefoglalva: Ö(𝑛) = 𝑛 − 1 + (𝑛 − 1) ∙ 2 log 2 𝑛 = θ(n log 𝑛) 𝑀(𝑛) = 𝑛 − 1 + (𝑛 − 1) ∙ log 2 𝑛 = θ(n log 𝑛) Az versenyrendezés egyetlen jelentős hátránya a tárigénye. Nem helyben rendez, hanem az elemeket tartalmazó 𝑛 mezőn kívül még (𝑛 − 1)-re van szüksége a fa belső csúcsai számára, és még 𝑛-re ahová kiírjuk a rendezett elemeket. A körülbelül háromszoros helyigény miatt nem alkalmazzák a gyakorlatban az algoritmust. Ezt a döntést az a körülmény is megkönnyíti, hogy ugyanebben a „műfajban” (𝑛 log 𝑛-es maximum-kiválasztó rendezés) hatékony helyben dolgozó eljárás, a kupacrendezés áll rendelkezésünkre.

16. KUPACRENDEZÉS A kupacrendezés (heap sort) részletes ismertetése kidolgozás alatt áll. 16.1. A kupacrendezés módszere 16.1.1. A kezdőkupac kialakítása

16.1. ábra. Kezdőkupac kialakítása (1)

16.2. ábra. Kezdőkupac kialakítása (2)

16.3. ábra. Kezdőkupac kialakítása (3)

16.4. ábra. A kész kezdőkupac

16.1.2. A következő legnagyobb elem kiválasztása

16.5. ábra. A maximális elem kiválasztása, elhelyezése és a kupac tulajdonság helyreállítása

16.1.3. A kupacrendezés teljes eljárása

16.6. ábra. A második legnagyobb elem kiválasztása

16.7. ábra. A következő legnagyobb elem kiválasztása

16.8. ábra. Az utolsó előtti elem kiválasztása

16.9. ábra. Az utolsó elem kiválasztása, kész kupac

16.2. A kupacrendezés algoritmusa tömbre 16.2.1. A süllyesztés eljárása

16.10. ábra. A süllyesztés eljárása (tömbre, iteratív)

16.2.2. A kezdőkupac kialakítása

16.11. ábra. A kezdőkupac kialakítása (tömbre, iteratív)

16.2.3. A kupacrendezés algoritmusa

16.12. ábra. A kupacrendezés algoritmusa (tömbre, iteratív)

16.3. A kupacrendezés rekurzív algoritmusa bináris fára 16.3.1. A süllyesztés eljárása

16.13. ábra. A süllyesztés eljárása (bináris fára, rekurzív)

16.3.2. A kezdőkupac kialakítása

15.14. ábra. A kezdő kupac kialakítása (bináris fára, rekurzív)

16.3.3. A kupacrendezés rekurzív algoritmusa

16.15. ábra. A kupacrendezés algoritmusa (bináris fára, rekurzív)

16.4. A kupacrendezés műveletigénye

16.16. ábra. A kupac szintjeinek magassága és csúcsszáma

17. GYORSRENDEZÉS A gyorsrendezés (quick sort) részletes ismertetése kidolgozás alatt áll.

17.1. A gyorsrendezés elve

17.1. ábra. A gyorsrendezés elve

1

17.2. Az algoritmus megvalósítása tömbre

27.2. ábra. A gyorsrendezés rekurzív algoritmusa (felső szint)

17.3. Az egy elemet a helyére vivő eljárás

37.3. ábra. Egy elemet a helyére vivő eljárás (a tömb bal szélső elemére)

2

47.4. ábra. A tömb bal szélső elemének a helyére vitele

17.4. A gyorsrendezés műveletigénye

57.5. ábra. A gyorsrendezés egy lehetséges hívási fája

3

18. ÖSSZEFÉSÜLŐ RENDEZÉS Az összefésülő rendezés (merge sort) részletes ismertetése kidolgozás alatt áll.

18.1. Két rendezett sorozat összefésülése

18.1. ábra. Két rendezett sorozat összefésülése

1

18.2. Az összefésülő rendezés rekurzív algoritmusa

18.2. ábra. Az összefésülő rendezés rekurzív algoritmusa (felső szint)

18.3. Az összefésülő rendezés iteratív algoritmusa

18.3. ábra. Az összefésülő rendezés iteratív változata (tömbre)

2

18.4. Az összefésülő rendezés műveletigénye

18.4. ábra. Az összefésülő rendezés hívási fája

3

19. AZ ÖSSZEHASONLÍTÁSOS RENDEZÉSEK MŰVELETIGÉNYÉNEK ALSÓ KORLÁTJAI Ebben a fejezetben aszimptotikus (nagyságrendi) alsó korlátot adunk az összehasonlításokat használó rendező eljárások lépésszámára. Pontosabban, azt látjuk be, hogy egy n méretű input rendezése nagyságrendben legalább összehasonlítást igényel. Ezt az alsó korlátot a legrosszabb és az átlagos esetre egyaránt bizonyítjuk. (Akik jártasabbak a valószínűségszámításban, szívesen mondanak várható lépésszámot az átlagos helyett.) Az eredmény nemcsak az előző fejezetekben ismertetett hét rendezésre igaz, hanem az összes ismert (és még fel nem fedezett) összehasonlító rendező módszerre is érvényes. Az 1. fejezetben láttuk, hogy az algoritmusok alapvető jellemzője a műveletigényük, vagyis az, hogy mennyire hatékonyan oldják meg a feladataikat. A hatékonyságot gyakorlati megközelítésben a futási idővel, elméletileg inkább a megtett lépések számával mérjük. A lépésszám általában egy vagy néhány meghatározó művelet végrehajtási számát jelenti. A lépésszám olykor minden azonos méretű inputra ugyanannyi, és értéke pontosan megadható. A maximum-kiválasztás ismert eljárása minden n elemű tömbre összehasonlítást végez. A buborékrendezés bármely n méretű tömböt összehasonlítással rendez. Az algoritmusok lépésszáma azonban általában inputról-inputra változik, még ha azonos is a hosszuk. Ha tekintjük egy algoritmus összes azonos n elemszámú bemenő adatát, akkor a hatékonyságát a legnagyobb, illetve az átlagos lépésszámmal szoktuk jellemezni. Ezek az értékek lehetnek pontosan számolhatók, és lehet, hogy csak becsülni tudjuk azokat. A buborékrendezésről tudjuk, hogy a legkedvezőtlenebb input, a fordítva rendezett sorozat átrendezésére cserét használ, míg az összes n hosszú bemenő sorozaton az átlagos (várható) csereszám ennek a fele: . Ha egy algoritmus lépésszáma nem adható meg pontos képlettel az n inputméret függvényében, akkor nagyságrendben próbájuk becsülni. A kupacrendezés esetében nem lenne egyszerű egzakt módon megadni az elvégzett összehasonlítások vagy cserék maximális, illetve átlagos számát. Szerencsére az ( -es nagyságrendi becslés is tájékoztat alapvető módon az eljárás hatékonyságáról. Ebben a fejezetben az összehasonlításos rendezéseket vesszük szemügyre. Nem egyedi hatékonyságukra irányul a vizsgálat, hanem általánosan érvényes alsó korlátot adunk műveletigényükre. Viszonylag ritka „szerencsés” esetben meg lehet határozni egy feladatosztályhoz olyan alsó korlátot, amely érvényes minden megoldó eljárásra. A kiválasztásokról szóló 9-10. fejezetekben több alsó korlát elemzés is szerepel. Talán a legismertebb ezek közül az, hogy n elem közül a legnagyobbnak a kiválasztásához legalább összehasonlítás szükséges. Visszatérve az összehasonlító rendezésekre, alsókorlátaik elemzéséhez bevezetünk egy szellemes elméleti adatstruktúrát, a döntési fát, amely egy algoritmus által feltett kérdések „lenyomata” minden bemenő adatra.

19.1. A döntési fa A döntési fa elkészíthető minden algoritmushoz és annak adott n méretű összes bemenő adatához, feltéve, hogy ezek az adatok felsorolhatók és számuk meghatározható. A rendező eljárásokra ez teljesül, hiszen bemenő adatoknak tekinthetjük az 1, 2, … n számok permutációit, amelyek száma közismert. A döntési fa belső pontjaiban tartalmazza az algoritmus által feltett összes igen/nem kimenetelű kérdést, minden lehetséges n méretű bemenő adatra. (Az algoritmus ciklusai az adott n méretű bemenetre történő végrehajtás során egymás utáni lépések szekvenciájává „egyenesednek ki”, így iteratív vezérlési szerkezetet nem kell megjelenítenünk a fában.) Az esetleges értékadásokat sem helyezzük el a döntési fában. A kérdésekre adott válaszok információtartalmát – az értékadások adattranszformáló hatásának figyelembe vételével – minden bemenő adat útvonalán végig haladva fában összegyűjtjük, és a levelekbe írjuk. A döntési fában a levelek tartalma a megoldást fogalmazza meg az egyes inputokra. A 19.1. ábrán látható döntési fa egy olyan algoritmus működését szemlélteti, amelyet speciálisan három elem rendezésére terveztünk. Az R eljárás az elemek összehasonlítását végzi minden lehetséges input sorrendre. A kérdésekre adott válaszokból meghatározható az elemek nagyság szerint rendezett sorrendje. Ehhez kettő vagy három kérdés szükséges. (Ebben a kis eljárásban értékadások nem szerepelnek.)

19.1. ábra. Az

rendező algoritmus

döntési fája 3 elemű sorozatokra

Ha pl. , akkor az első kérdés igaz ágán jutunk el a második kérdéshez, amelyre nem a válasz (hamis ág), majd a harmadik kérdésnek ismét a hamis ágán jövünk ki. A levélben látható az elemek nagyság szerint rendezett sorrendje. Ha az elemek bemenő sorozata rendezett, vagy fordítva rendezett, akkor két összehasonlítás is elegendő a megoldáshoz. Láthatjuk tehát, hogy a döntési fa leveleinek magassága változó, a 2 és 3 értékeket veheti fel. A levelek száma pedig , így a bemenő adatok minden sorrendjéhez tartozik az R algoritmusnak egy levélben végződő végrehajtási ága. A rendezések döntési fájának magassága és leveleinek száma között teremt összefüggést a következő állítás.

Lemma. Bármely R összehasonlító rendező eljárás döntési fájának magassága és az n elemszám között fennáll a következő összefüggés: Bizonyítás. A fa magassága a gyökértől legtávolabbi levelek magasságával azonos. A bináris fában a h magasság szintjén elem befogadására van hely. A h magasság legalább akkora kell, hogy legyen, hogy helyt tudjon adni számú levélnek, azaz teljesülnie kell a összefüggésnek. Ha mindkét oldal logaritmusát vesszük és visszaírjuk h eredeti jelentését, akkor a bizonyítandó összefüggést kapjuk. Megjegyezzük, hogy példánkban teljesül a lemma állítása, hiszen fennáll a összefüggés. Az R algoritmus a legkedvezőtlenebb inputjai éppen azok (a hatból négy ilyen), amelyek rendezésének lépései magasságban található levelekben végződnek. Egy ilyen végrehajtási út során az eljárás három összehasonlítást végez. Az R algoritmus műveletigénye a legrosszabb esetben megegyezik a csúcstól legtávolibb levelek magasságával, vagyis a döntési fa magasságával. Ezt az összefüggést felhasználjuk a következő tétel bizonyításában. 19.2. Alsó korlát az összehasonlítások számára a legkedvezőtlenebb esetben Az előző lemma alapján kimondhatjuk, és kevés számolás elvégzésével igazolhatjuk a következő állítást. Tétel. Bármely R összehasonlításos rendező eljárás a legkedvezőtlenebb bemenő adata rendezése során nagyságrendben legalább ( összehasonlítást végez, azaz Bizonyítás. A lemmához fűzött megjegyzés – a példa alapján – eljutott a következő általános érvényű összefüggésig. Egy R összehasonlító rendező algoritmus által végzett összehasonlítások maximális száma (legrosszabb eset) n méretű input esetén megegyezik az R-hez és n-hez tartozó döntési fa magasságával. Gondoljuk meg még egyszer, hogy a legtöbb összehasonlítás egy leghosszabb végrehajtási úton történik, amelynek hossza meghatározza a fa magasságát. Formálisan is kifejezve: . Összevetve ezt a lemmával: Alakítsuk át a jobb oldalon álló kifejezést: ∑ Tekintsük ezt az összeget, mint kívül írt téglalapok területének összegét, a logaritmus függvény integrál közelítő összegének és becsüljük alulról a határozott integrál értékével (lásd: 19.2. ábra). ∑

∫ [



]

A következtetési láncolat elején és végét egybevetve, a bizonyítandó állítást kapjuk.

19.2. ábra. Összeg alsó becslése határozott integrállal

19.3. Alsó korlát az összehasonlítások számára átlagos esetben Láttuk, hogy egy R rendező eljárás működését, amelyet az n méretű bemenő sorozatok rendezése során végez, a döntési fa teljes körűen rögzíti. Egy adott sorozat rendezésnek megfelel egy olyan útvonal a fában, amely a gyökértől a megfelelő levélig terjed. Ezen az úton, a belső csúcsokban az algoritmus által végzett összehasonlítások szerepelnek. Az útvonalat lezáró levélben a bemenő sorozat rendezéséhez szükséges összes ismeret kerül elhelyezésre. A végrehajtott összehasonlítások száma megegyezik az adott útvonal hosszával, azaz a végén található levél magasságával. A rendező eljárás (adott n méret melletti) átlagos összehasonlítási számához úgy jutunk, ha a döntési fa levélmagasságainak az átlagát képezzük. A levélmagasság összegre vezessük be az jelölést. A döntési fákban minden belső pontnak két gyereke van, mivel a belső pontok igen/nem kimenetelű kérdéseket reprezentálnak. Nevezzük az ilyen alakú bináris fákat tökéletesnek. (Megjegyezzük, hogy egy t tökéletes fához nem feltétlenül tartozik olyan algoritmus, amelynek t a döntési fája lenne!) Most megfogalmazunk egy olyan állítást, amely lehetőséget teremt az átlagos összehasonlítás-szám alsó becsléséhez. Lemma. Az azonos számú levelet tartalmazó tökéletes fák közül levélmagasság összeg azokra a legkisebb, amelyek egyben majdnem teljes bináris fák. Bizonyítás. Legyen t egy olyan tökéletes fa, amely nem teljesíti a majdnem teljesség követelményét, azaz levelei között legalább két szintkülönbség található. A 19.3. ábrán látható t tökéletes fában például az A és B levelek, valamint a D levél közötti szintkülönbség értéke kettő. Természetesen magasságértékeikre ugyanez mondható: Ha áttérünk egy olyan tökéletes fára, amelyet úgy kapunk, hogy az és leveleket, a szülőcsúccsal együtt, legalább két szinttel magasabbra áthelyezzük, akkor a levélmagasság összeg legalább 1-gyel csökken. Az általános eset az ábra alapján könnyen meggondolható. Az ábrán látható áthelyezéssel keletkező fában valóban csökken az érték:

Világos, hogy az alsó szintű testvér levélpárok – szülővel együtt történő – véges sokszori áthelyezésével majdnem teljes tökéletes bináris fához jutunk, amelyre az értéke kisebb, mint bármely (ugyanannyi levélcsúcsot számláló) nem majdnem teljes tökéletes fára. Könnyen meggondolható az is, hogy az érték a majdnem teljes fákra egyértelmű. Ha például az olyan majdnem teljes tökéletes fákat tekintjük, amelyek 6 levelet tartalmaznak, akkor egyértelmű a szintekre bontás, vagyis az, hogy az alsó szintű levelek száma 4, míg 2 levél e fölött helyezkedik el. Beszélhetünk tehát minimális vagy optimális értékről.

19.3. ábra. Egy tökéletes fa átalakítása

A lemmára támaszkodva most már kimondhatjuk a rendezések átlagos műveletigényére vonatkozó állítást. Tétel. Bármely R összehasonlítás alapú rendező eljárás átlagosan nagyságrendben legalább összehasonlítást végez az összes lehetséges bemenő sorozat rendezése során: Bizonyítás. Tekintsük az R összehasonlításos rendező algoritmus adott n input mérethez tartozó döntési fáját. Ez a fa számú levelet tartalmaz. Tekintsük az ugyancsak levelet tartalmazó majdnem teljes tökéletes fát. A lemma szerint fennáll az (

)

(

)

összefüggés. (Az optimális fa nem feltétlenül döntési fa, vagyis nem feltétlenül tartozik hozzá algoritmus, a becsléshez azonban nyilván felhasználható.) Az optimális fa magasságösszegét tovább becsüljük. A 19.4. ábra alapján világos, hogy (

)

( (

)

)

Az átlagos összehasonlítás-számra térve, annak meghatározása szerint: (

)

Vegyük figyelembe és építsük egybe a két előző összefüggést úgy, hogy az átlagos összehasonlítás-szám definíciójából indulunk ki.

(

(

) (

)

( (

)

)

)

Az utolsó egyenlőségnél felhasználtuk, hogy egy n! számú levéllel rendelkező bináris fa magassága nagyságrendben legalább ), ahogyan ezt az előzőekben már láttuk, hiszen a 19.2-ben bizonyított tétel állítása ezt fejezi ki.

19.4. ábra. A majdnem-teljes optimális tökéletes fa alakja

Ezzel a rendező eljárások összehasonlításai számára alsó korlátot adtunk mind a legrosszabb, mind az átlagos esetben. Ez az alsó korlát az nagyságrend. Eredményeinket néha úgy is szokták interpretálni, hogy nem létezik lineáris idejű összehasonlító rendezés, ne is fáradozzunk a keresésén.

20. HASÍTÓ TÁBLÁK A hasító táblák részletes ismertetése kidolgozás alatt áll. 20.1. A hasítás jelensége 20.2. Kulcsütközések feloldása láncolással

20.1. ábra. Hasító tábla és láncolt rekordok

20.3. Kulcsütközések feloldása nyílt címzéssel

20.2. ábra. A nyílt címzés próbálkozásai

20.3. ábra. Nyílt címzés: a rekordok törölt státusza

20.3.1. Lineáris próbálkozás

20.4. ábra. Nyílt címzés lineáris próbálkozással

20.3.2. Négyzetes próbálkozás

20.5. ábra. Nyílt címzés négyzetes próbálkozással

20.3.3. Kettős hasítás

20.6. ábra. Nyílt címzés kettős hasítással

20.4. Hasító függvények

21. RENDEZÉS LINEÁRIS IDŐBEN A lineáris időben rendező eljárások részletes ismertetése kidolgozás alatt áll. 21.1. A nem összehasonlítás alapú rendezések 21.2. Leszámoló rendezés

21.1. ábra. Leszámoló rendezés: az elhelyezés segédtömbjének kitöltése

21.2. ábra. Leszámoló rendezés: az elhelyezés pillanatfelvételei

21.3. Edényrendezés

21.3. ábra. Edényrendezés

21.4. Összetett kulcson előre haladó rendezés 21.4.1. Előre haladó rendezés listára (ötlet)

21.4. ábra. Előre haladó rendezés listára (ötlet)

21.4.2. Előrehaladó rendezés bináris számokra

21.5. ábra. Bináris számok előre haladó rendezése (RADIX, előre)

21.5. Összetett kulcson visszafelé haladó rendezés 21.5.1. Visszafelé haladó rendezés listára

21.6. ábra. Visszafelé haladó rendezés listára

21.5.2. Előrehaladó rendezés bináris számokra

21.7. ábra. Bináris számok visszafelé haladó rendezése (RADIX, vissza)

22. GRÁFOK ÁBRÁZOLÁSA A megoldandó feladatok, problémák modellezése során sokszor gráfokat alkalmazunk. A gráf fogalmát a matematikából ismertnek vehetjük. A modellezés során a gráfok több változata is szóba jöhet. A különféle útkeresési problémák természetes módon vezetnek a gráfokhoz. Ha például Budapesten gyalogosan keresünk legrövidebb utat két pont között, akkor útszakaszokat irányítatlan éleknek tekinthetjük, súlyukat pedig lényegében az útszakasz hossza adja. Ha autóval szeretnék az utat megtenni, akkor a gráf éleit már irányítottnak kell tekintenünk. Szintén irányítás nélküliek az élek, ha néhány helység között szeretnénk minimális összköltségű vezetékrendszert létesíteni, valamilyen energiával való ellátás céljából. Az élekhez természetesen a létesítés költségét hozzárendeljük. Arra is tudunk példát mondani, hogy költségmentesek a modellben alkalmazott gráf élei. Ha egy termék előállításának egymáshoz illeszkedő, de többfelé ágazó (irányított élekkel ábrázolt) lépéseit szeretnénk linearizálni, akkor a költségeknek nincs alapvető szerepe. A párosítási feladatok irányítatlan élei is gyakran költség nélküliek. Általánosabb megközelítés szerint bizonyos entitások, absztrakt objektumok közötti kapcsolatokat leíró bináris relációk (kapcsolatok) szemléletes leírásának egyik eszköze a gráf. A gráfokkal az ember számára könnyen "emészthető" formában lehet ábrázolni a relációk tulajdonságait (például szimmetria = irányítatlanság vagy kettő hosszú kör, reflexivitás = hurokél). A modell objektumainak megfeleltetjük a gráf csúcsait, a közöttük fennálló kapcsolatok leírására pedig a gráf éleit használjuk. Mivel egy olyan általános fogalom, mint a bináris reláció modellezésére használjuk, számos probléma megfogalmazható úgy, mint gráfelméleti feladat. A gráfalgoritmusok című rész fejezeteiben néhány fontos, a gyakorlati életben is gyakran előforduló általános feladatot és a megoldásukra használható algoritmust ismertetünk. 22.1. Alapfogalmak, jelölések A továbbiakban ismertnek feltételezzük az alapvető gráfelméleti fogalmakat, definíciókat és tételeket. Most nézzünk néhány fontosabb fogalmat kevésbé formálisan, inkább csak a felelevenítés szintjén. 

Irányított gráf (lásd: 22.1. ábra): 𝐺 = (𝑉, 𝐸) pár, ahol 𝑉 a csúcsok véges halmaza (általában 1, 2, … , 𝑛), 𝐸 ⊆ 𝑉 × 𝑉 pedig az élek, vagyis a csúcsokból alkotott rendezett párok halmaza. Egy él a gráfban: 𝑒 = (𝑢, 𝑣) ∈ 𝐸, ahol 𝑢, 𝑣 ∈ 𝑉 csúcsok. Ha 𝑒 = (𝑢, 𝑢), akkor hurokélnek nevezzük. A gráfban a csúcsok száma 𝑛 = |𝑉|, az élek száma 𝑒 = |𝐸|.

22.1. ábra. Egy irányított gráf hurokéllel



Irányítás nélküli gráf: 𝐺 = (𝑉, 𝐸) pár, ahol 𝑉 a csúcsok véges, 𝐸 ⊆ 𝑉 × 𝑉 pedig az élek, vagyis a csúcsokból alkotott rendezetlen párok halmaza. Vagyis, ha (𝑢, 𝑣) ∈ 𝐸, akkor (𝑣, 𝑢) ∈ 𝐸 is teljesül, amit [𝑢, 𝑣] ∈ 𝐸 módon jelölünk.



Súlyozott gráf: 𝐺 = (𝑉, 𝐸, 𝑐) hármas, ahol 𝑐: 𝑉 × 𝑉 → ℝ súlyfüggvény, 𝑐(𝑢, 𝑣) egy adott él súlya.



Egyszerű gráf: olyan gráf, amelyben nincs hurokél, illetve többszörös él.



Szomszéd/rákövetkező csúcs: Legyen 𝑢, 𝑣 ∈ 𝑉. A 𝑣 csúcs az 𝑢 rákövetkezője, ha létezik (𝑢, 𝑣) ∈ 𝐸 él. Jelölése: 𝑢 → 𝑣. Irányítás nélküli gráfban a reláció szimmetrikus.



Út: Legyen 𝑣0 , 𝑣1 , … , 𝑣𝑘 ∈ 𝑉. A 〈𝑣0 , 𝑣1 , … , 𝑣𝑘 〉 sorozat egy 𝑘 hosszúságú út, ha ∀𝑖 ∈ [1 … 𝑘]: (𝑣𝑖−1 , 𝑣𝑖 ) ∈ 𝐸. Jelölése: 𝑢 ↝ 𝑣.



Kör: egy 𝑘 hosszúságú út kör, ha 𝑣0 = 𝑣𝑘 .



Egyszerű út: Egy olyan út, amely körmentes, azaz ∀𝑖 ∈ [1 … 𝑘], 𝑗 ∈ [𝑖 + 1 … 𝑘]: 𝑣𝑖 ≠ 𝑣𝑗 .



Egyszerű kör: egy olyan kör, amelyben nincs belső kör, azaz egy olyan kör, amelyben 𝑣1 , … , 𝑣𝑘 páronként különböző csúcsokból áll (𝑣0 = 𝑣𝑘 és ∀𝑖 ∈ [1 … 𝑘 − 1], 𝑗 ∈ [𝑖 + 1 … 𝑘]: 𝑣𝑖 ≠ 𝑣𝑗 ).



Körmentes gráf: kört nem tartalmazó gráf.



Fokszám: Irányítás nélküli gráfban egy csúcs fokszáma a csúcsból kiinduló élek száma. Irányított gráf esetén megkülönböztetjük a kimenő élek számát, amely a csúcs kimenő fokszáma (kifok), illetve a bemenő élek számát, amely a bemenő fokszáma (befok), a csúcs fokszáma pedig a kettő összege.



Összefüggő gráf: egy irányítás nélküli gráf összefüggő akkor, és csak akkor, ha bármely két csúcs összeköthető úttal. Ekkor a gráf egyetlen komponensből áll (lásd: 22.2. ábra).

22.2. ábra. Egy nem összefüggő gráf három komponenssel ({𝟏, 𝟐, 𝟓}, {𝟒}, {𝟑, 𝟔})



Erősen összefüggő gráf: Egy irányított gráf erősen összefüggő akkor, és csak akkor, ha bármely két csúcs összeköthető úttal (figyelembe véve az irányítást, tehát 𝑢 ↝ 𝑣 és 𝑣 ↝ 𝑢 egyaránt kell, hogy teljesüljön).



Teljes gráf: Egy olyan irányítás nélküli gráf, amelynek bármely két csúcsa szomszédos.



Páros gráf: Egy olyan irányítás nélküli gráf, amelynek csúcsai két diszjunkt halmazra bonthatóak, és él csak a két különböző halmaz csúcsai között mehet, azonos halmazban lévő csúcsokat azonban nem köthet össze.



Erdő: Egy körmentes, irányítás nélküli gráf.



Fa: egy összefüggő, körmentes, irányítás nélküli gráf.

22.2. Gráfok ábrázolása A gráfok ábrázolására két, a gyakorlatban igen elterjedt adatszerkezetet adunk. Az egyik tisztán aritmetikai ábrázolású (szomszédsági mátrix), a másik vegyes, aritmetikai és láncolt ábrázolású (szomszédsági lista). 22.2.1. Szomszédsági mátrix Legyen 𝐺 = (𝑉, 𝐸) véges gráf, és 𝑛 a csúcsok száma. Ekkor a gráfot egy 𝑛 × 𝑛-es mátrixban ábrázoljuk, ahol az oszlopokat és a sorokat rendre a csúcsokkal indexeljük (ez leggyakrabban 1, … , 𝑛). Egy mezőben akkor van 1-es, ha a hozzá tartozó oszlop által meghatározott csúcs szomszédja a sor által meghatározott csúcsnak. 𝐶[𝑖, 𝑗] = {

1 ha (𝑖, 𝑗) ∈ 𝐸 0 ha (𝑖, 𝑗) ∉ 𝐸

Az ábrázolás eszközeként alkalmazott mátrixot nevezzük szomszédsági mátrixnak. (Találkozni lehet még az adjacencia mátrix, illetve csúcsmátrix elnevezésekkel is.) Tekintsünk két példát a 22.3. ábrán. Figyeljük meg, hogy az irányítatlan gráf esetén a mátrix szemmetrikus. 0 0 0 0 (0

1 0 0 0 0

1 1 0 0 0

0 1 1 0 1

0 1 0 0 0)

0 1 1 0 (0

1 0 1 1 1

1 1 0 1 0

0 1 1 0 1

0 1 0 1 0)

22.3. ábra. Egy irányított és egy irányítatlan gráf szomszédsági mátrixa

Ha súlyozott a gráf, akkor az élsúlyokat (élköltségeket) is el kell tárolni. Ezt is a mátrixon belül oldjuk meg. A súly valós számokat vehet fel. Természetesen adódik, hogy ahol előzőleg 1-est írtunk, azaz létezett az illető él, oda most írjuk be az él költségét. Két további eset maradt, a mátrix főátlója, és az olyan mezők, amelyek esetén nem létezik él. Vezessük be a végtelen (∞) élsúlyt, és a nem létező élek esetén a mátrix megfelelő helyére írjunk ∞-t. Egy ilyen "élen" csak végtelen nagy költséggel tudunk végighaladni (tehát nem tudunk). A mátrix főátlójába kerülnének a hurokélek költségei, de ilyen értékeket nem alkalmazunk, mivel a továbbiakban a legtöbb gyakorlati probléma leírására alkalmas egyszerű gráfokra korlátozzuk a tárgyalást. Az egyszerű gráfok nem tartalmaznak hurokéleket (valamint többszörös éleket sem).

Élsúlyozott gráf esetén a szomszédsági mátrix kitöltését a következő megállapodás szerint végezzük: 0 𝐶[𝑖, 𝑗] = {𝑐(𝑖, 𝑗) ∞

ha 𝑖 = 𝑗 ha (𝑖, 𝑗) ∈ 𝐸 ha (𝑖, 𝑗) ∉ 𝐸

A mátrixos ábrázolás helyfoglalása mindig ugyanakkora, független az élek számától, a mátrix méretével 𝑛2 -tel arányos. (Az 𝑛 pontú teljes gráfnak is ekkora a helyfoglalása.) A mátrixos reprezentációt sűrű gráfok esetén érdemes használni, hogy ne legyen túl nagy a helypazarlás. 22.2.2. Éllistás ábrázolás Ebben a reprezentációban a gráf minden csúcsához egy listát rendelünk. Ezen listában tartjuk nyilván az adott csúcsból kimenő éleket. Legyen 𝐺 = (𝑉, 𝐸) véges gráf, és 𝑛 a csúcsok száma. Vegyünk fel egy mutatókat tartalmazó 𝐴𝑑𝑗[1 … 𝑛] tömböt (a csúcsokkal indexeljük a tömböt). A tömbben lévő mutatók mutatnak az éllistákra (más néven a szomszédsági listákra). Az éllisták lehetnek egy- vagy kétirányú, fejelemes vagy fejelem nélküli listák, ez most nem lényeges a gráf szempontjából. 

Irányított gráf esetén (lásd: 22.4. ábra), az éllisták listaelemei reprezentálják az éleket. Az élnek megfelelő listaelemet abban a listában tároljuk, amelyik csúcsból kiindul az él, és a célcsúcs indexét eltároljuk a listaelemben. Tehát az (𝑖, 𝑗) ∈ 𝐸 él megvalósítása az 𝑖-edik listában egy olyan listaelem, amelyben eltároltuk 𝑗-t, mint az él célcsúcsát.

22.4. ábra. Egy irányított gráf éllistás ábrázolása



Irányítatlan gráf esetén (lásd: 22.5. ábra), egy élnek két listaelemet feleltetünk meg, azaz egy irányított élt egy oda-vissza mutató, irányított élpárral valósítunk meg a korábban említett módon. Élsúlyozott gráf esetén, az él súlyát is a listaelemben fogjuk tárolni.

22.5. ábra. Egy irányítatlan gráf éllistás ábrázolása

Az éllistás ábrázolás helyfoglalása irányítatlan gráfok esetén a csúcsok számával (𝐴𝑑𝑗 tömb), illetve az élek számával (éllista-elemek száma) arányos. Az elfoglalt memória méretének nagyságrendje (𝑛 + 𝑒). Irányított gráfok esetén az élek számának duplájával kell számolnunk, így (𝑛 + 2𝑒)-vel arányos helyfoglaláshoz jutunk.

Mivel a memóriaigény az élek számával arányos, ezért az éllistás ábrázolást ritka, illetve nem-sűrű (mondhatnánk „normál”) gráfok (𝑒 ≪ 𝑛2 ) esetén szokták használni, ugyanis sűrű gráf esetén a szomszédsági mátrixhoz képest itt jelentkezik a listák láncolásából származó helyfoglalás is, a mutatók tárolása révén.

23. SZÉLESSÉGI BEJÁRÁS A bejárási algoritmusok feladata általában a gráf csúcsainak végiglátogatása valamilyen stratégia szerint. A bejárás gyakran azért hajtjuk végre, mert adott tulajdonságú csúcsot keresünk a gráfban. Két bejárási algoritmust ismerünk meg, a szélességi és a mélységi bejárást. Nézzük először a szélességi bejárást, néhány fejezettel később pedig a mélységi stratégiát ismertetjük. 23.1. A szélességi bejárás stratégiája A bejárási stratégiákról megkapóan szemléletes leírást találhatunk a Rónyai Lajos, Ivanyos Gábor és Szabó Réka szerzőhármas Algoritmusok című könyvében. Szabadon és tömören idézzük „az öreg városka girbe-gurba utcáin bolyongó kopott emlékezetű lámpagyújtogató esetét”, illetve, most csak az egyik módszert arra, hogy végül az összes köztéri lámpa világítson. Az egyik eljárás szerint a lámpagyújtogatót egy este nagyszámú barátja elkíséri a városka főterétre, ahol együtt meggyújtják az első lámpát. Utána annyi felé oszlanak, ahány utca onnan kivezet. A különvált kis csapatok meggyújtják az összes szomszédos lámpát, majd tovább osztódnak. A városka lámpáit ilyen módon széltében terjeszkedve érik el. Ha fentről néznénk a várost, ahogy kigyulladnak a lámpák, azt látnánk, hogy a középpontból a város széle felé egyre nagyobb sugarú körben terjed a világosság. Ez a szemléletes alapelve a szélességi bejárásnak. A szélességi stratégiát a 23.1. ábrán látható néhány pillanatfelvétel illusztrálja.

23.1. ábra. Szélességi bejárás egy gráfon

A 7. fejezetben ismertettük a bináris fák szintfolytonos bejárásának algoritmusát, amelyet egy sor adatszerkezet segítségével sikerült megvalósítani. A 23.2. ábra a fa adatstruktúra a bejárásának néhány fázisát szemlélteti. Látható, hogy a szintfolytonos bejárás a szélességi bejárás speciális esete fákra alkalmazva, a fa gyökerét véve kezdőcsúcsnak.

23.2. ábra. Szintfolytonos bejárás egy fán

23.2. A szélességi bejárás algoritmusa Feladat: Adott egy 𝐺 = (𝑉, 𝐸) irányított vagy irányítás nélküli, véges gráf. Írjuk ki a csúcsokat egy 𝑠 ∈ 𝑉 kezdőcsúcstól való távolságuk növekvő sorrendjében. Minden csúcsra jegyezzük fel a kezdőcsúcstól való távolságát, és a hozzá vezető (egyik) legrövidebb úton a megelőző csúcsot. Az azonos távolságú pontok egymás közötti sorrendjére nincs megkötés, az legyen tetszőleges. Elsőként vezessük be a csúcsok s-től való távolságát, mint a bejárás alapját képező értéket. Legyen 𝐺 = (𝑉, 𝐸) gráf és 𝑠, 𝑢 ∈ 𝑉 csúcsok, és 𝑠 ↝ 𝑢 = 〈𝑣0 , 𝑣1 , … , 𝑣𝑘 〉 út, ahol 𝑠 = 𝑣0 és 𝑢 = 𝑣𝑘 . Az út hossza legyen az út mentén érintetett élek száma, azaz |𝑠 ↝ 𝑢| = |〈𝑣0 , 𝑣1 , … , 𝑣𝑘 〉| − 1 = 𝑘. Az 𝑢 csúcs 𝑠-től való távolsága legyen az 𝑠 ↝ 𝑢 utak közül a legrövidebbnek az élszáma, azaz 𝑑(𝑠, 𝑢) = min⁡{|𝑠 ↝ 𝑢|}. Ha nincs 𝑠 ↝ 𝑢 út a gráfban, akkor legyen 𝑑(𝑠, 𝑢) = ∞. Az algoritmus elvét az előzőekben már láttuk, most foglaljuk össze röviden, lépésenként. 1. 2. 3. 4.

Először elérjük a kezdőcsúcsot. Ezután elérjük a kezdőcsúcstól 1 távolságra lévő csúcsokat (a kezdőcsúcs szomszédjait) Majd elérjük a kezdőcsúcstól 2 távolságra lévő csúcsokat (a kezdőcsúcs szomszédjainak a szomszédjait), és így tovább. Ha egy csúcsot egyszer már elértünk, akkor a későbbi odajutásoktól el kell tekinteni.

Hogyan tudjuk biztosítani a fenti elérési sorrendet? Az elérési sorrendnél azt kell figyelembe venni, hogy amíg az összes, a kezdőcsúcstól 𝑘⁡(≥ 0) távolságra lévő csúcsot ki nem írtuk, addig nem szabad 𝑘-nál nagyobb távolságú csúcsokat kiírni, és amikor egy 𝑘 távolságú csúcsot kiírunk, addigra már az összes k-nál kisebb távolságú csúcsot ki kellett írnunk. Egy 𝑘 + 1 távolságú csúcs biztosan egy 𝑘 távolságú csúcs szomszédja (az egyik legrövidebb úton a megelőző csúcs biztosan k távolságra van a kezdőcsúcstól), tehát a 𝑘 + 1 távolságú csúcsokat a 𝑘 távolságú csúcsok szomszédai között kell keresni (nem biztos, hogy az összes szomszéd 𝑘 + 1 távolságú, lehet, hogy egy rövidebb úton már elértük). Használjunk sor adattípust és biztosítsuk azt az invariáns tulajdonságot, hogy a sorba csak 𝑘 vagy 𝑘 + 1 távolságú csúcsok lehetnek az elérésük sorrendjében, amely egyben az s-től való távolságuk szerint növekedő sorrendnek is megfelel. Ameddig ki nem ürül a sor, vegyünk ki az első elemet, írjuk ki és terjesszük ki, azaz a még "meg nem látogatott" szomszédjait érjük el és rakjuk be a sorba. Az állíthatjuk, hogy említett ciklust végrehajtva, teljesül a fenti invariáns és a csúcsokat távolságuk sorrendjében érjük el és írjuk ki. Az állítás könnyen igazolható teljes indukcióval (k távolságú csúcsok a sorban megelőzik a 𝑘 + 1 távolságú csúcsokat, és a sorban lévő csúcsok távolságának legfeljebb 1 az eltérése). 

𝑘 = 0: Induláskor berakjuk a sorba a kezdőcsúcsot, ami 0 távolságra van. Ki is vesszük rögtön ezt a csúcsot a sorból és kiírjuk, majd a szomszédjait, az 1 távolságra lévő csúcsokat rakjuk be a sorba.



𝑘 → 𝑘 + 1: Indukciós feltevés szerint a sorba csak 𝑘 és 𝑘 + 1⁡távolságú csúcsok vannak távolságuk szerint növekvően, és a sor invariánsa az, hogy a korábban bekerült csúcsot korábban veszi ki. Az összes k távolságú csúcsot kiterjesztjük, mielőtt egy 𝑘 + 1⁡távolságú csúcsot kivennénk, és amíg ki nem vettük az összes k távolságút, addig csak olyan csúcsokat helyezünk el a sorban, amelyek 𝑘 + 1⁡egységre vannak a kezdőcsúcstól. Csak az első 𝑘 + 1 távolságú csúcs kivételénél kerülhet a sorba egy 𝑘 + 2 távolságú, de addigra már az összes 𝑘 + 1⁡távolságú csúcs bekerül a sorba, mert az összes k távolságra lévő csúcsot kiterjesztettük. Tehát a 𝑘 + 1 távolságú csúcsok megelőzik a 𝑘 + 2 távolságúakat, és a távolság különbség is mindig legfeljebb 1 marad.

23.3. ábra. A szélességi bejárás algoritmusa (ADS szint)

Ha egy csúcsot egyszer már elértünk, akkor bejártnak tekintjük, és nem akarjuk később újra elérni. Használjunk egy olyan halmazt, amelybe az elért csúcsokat rakjuk; kezdetben csak a kezdőcsúcsot tartalmazza. Amikor egy csúcsot először elérünk, helyezzük a halmazba, és minden csúcs kiterjesztésénél csak azokat a szomszédokat tekintsük (helyezzük a sorba), amelyeket még nem értünk el, azaz nincsenek benne az elért halmazban. Így minden csúcsot csak egyszer érünk el és helyezünk a sorba). Ezzel együtt természetesen minden csúcsot csak egyszer írunk ki. Mivel a csúcsok száma véges és minden csúcsot legfeljebb egyszer érünk el és terjesztünk ki. Tehát a bejárás biztosan terminál. A teljes algoritmus a 23.3. ábrán látható. Az algoritmusban használt Szomszéd⁡(𝑢) absztrakt függvény az 𝑢 ∈ 𝑉 csúcs szomszédjainak halmazát adja meg. A többi jelölés magától értetődik. 23.3. Az algoritmus szemléltetése Az absztrakt szinten bevezetett halmazt, amelybe az elért csúcsokat helyezzük, most a csúcsok színezésével valósítjuk meg. Elegendő lenne, hogy a csúcsoknak csak két állapotát különböztessük meg, azonban a működés még szemléletesebbé tétele céljából három színt alkalmazunk. 1. 2. 3.

Amikor egy csúcsot még nem értünk el legyen fehér színű. Induláskor a kezdőcsúcs kivételével minden csúcs ilyen (𝑢 ∉ 𝑄 és 𝑢 ∉ ElértCsúcsok). Amikor egy csúcsot elérünk és beillesztjük a sorba, színezzük szürkére. A kezdőcsúcs induláskor ilyen (𝑢 ∈ 𝑄 és 𝑢 ∈ ElértCsúcsok). Amikor egy csúcsot kivettünk a sorból és kiterjesztettük (elértük a szomszédjait), a színe legyen fekete (𝑢 ∉ 𝑄 és 𝑢 ∈ ElértCsúcsok).

Az általános leírás absztrakt szintjén egy csúcs szomszédjainak az elérési sorrendjéről (a Szomszéd⁡(𝑢)\⁡ElértCsúcsok feldolgozási sorrendjéről) nem tettünk fel semmit. A szomszéd csúcsok elérése nem egyértelmű, tehát egy nem determinisztikus algoritmust kaptunk. (A gyakorlatban néha megköveteljük a szomszéd csúcsok elérésének egyértelmű sorrendjét, azért, hogy az algoritmus működése kiszámítható és ellenőrizhető legyen. Általában, a szomszédokat a csúcsok címkéje szerint növekedően rendezve veszi sorra az algoritmus.) A 23.4. ábrán tanulmányozható a szélességi keresés algoritmusa lépésenként. A csúcsokra, a címkén kívül, felírunk két pozitív egész számot. Az első szám megadja, hogy az illető csúcsot hányadikként írnánk ki, a második szám pedig a kezdőcsúcstól való távolságot tartalmazza. Kezdetben legyenek -1 extremális értékűek. A kezdőcsúcs legyen az 1-es címkéjű csúcs. Kezdetben minden csúcs fehér kivéve a 1-es csúcsot, amelynek szürke a színe. Kezdetben, a sorban is csak az 1-es csúcs van. Az első lépésben kivesszük a sorból az 1-es csúcsot, majd kiterjesztjük, azaz elérjük az 1-es csúcs még fehér szomszédjait (2, 3, 4), amelyeket szürkére színezünk, és bedobunk a sorba. Az 1-es csúcsot kiterjesztettük, ezután feketére színezzük. Figyeljük meg, hogy a sor a szürke csúcsokból áll, az elérési szám (első szám) szerint rendezve. Mindig azt a szürke csúcsot terjesztjük ki, amelyiknek az elérési száma a legkisebb, mivel ez a csúcs került be legkorábban a sorba. Az utolsó állapotban berajzoltuk a kezdőcsúcsból az illető csúcsba vezető élsorozatot, amely az algoritmus által felderített legrövidebb utat alkotja. A csúcsok és a berajzolt élek alkotta részgráf egy kezdőcsúcs gyökerű fát alkot, amelyben minden csúcs a legrövidebb úton érhető el. A fát a gráf szélességi (feszítő) fájának nevezzük. Tehát 𝐹 = (𝑉′, 𝐸′) gráf a 𝐺 = (𝑉, 𝐸) gráf szélességi fája, ha 𝑉′ elemei az s-ből elérhető csúcsok, 𝐸′ ⊆ 𝐸 és ∀𝑣 ∈ 𝑉′ csúcsra pontosan egy egyszerű út vezet 𝑠-ből 𝑣-be, és ez az út egyike az⁡𝑠-ből 𝑣-be vezető legrövidebb 𝐺-beli utaknak.

23.4. ábra. A szélességi bejárás lépésenkénti végrehajtása

23.4. Az algoritmus megvalósítása reprezentáció szinten Tekintsük az algoritmus megvalósítását egy éllistával (másik elnevezéssel: szomszédsági listával) reprezentált gráfon (23.5. ábra). A csúcsok színét a csúcsokkal indexelt 𝑠𝑧í𝑛 [1 … 𝑛]) tömbben tároljuk. További feladatunk a csúcs s-től való távolságának, és a hozzá vezető úton a megelőző csúcsnak az eltárolása. Ezt a 𝑑[1 … 𝑛] és a 𝑃[1 … 𝑛] tömbben tesszük meg. Az értékeket akkor ismerjük, amikor elérjük a csúcsot, vagyis amikor szürkére színezzük, így ekkor írjuk azokat a tömbbe. Kezdetben legyen minden csúcs végtelen távolságra a kezdőcsúcstól, és ha nincs a gráfban 𝑠 ↝ 𝑢 út, akkor 𝑢 távolsága a startcsúcstól végtelen is marad.

23.5. ábra. A szélességi bejárás algoritmusa az éllistás reprezentációra

Az algoritmust a 23.4. ábrán bemutatott gráfon lefuttatva, a következő eredményeket kapjuk: 𝑑[1. .10] = [0, 1, 1, 1, 2, 2, 3, 3, 3, 2] és 𝑃[1. .10] = [𝑁𝐼𝐿, 1, 1, 1, 2, 2, 5, 5, 5, 4]. Látható, hogy a 𝑃[1. .10] tömb tartalmából könnyen előállítható a szélességi fa, illetve bármely csúcsra kiírható a kezdőcsúcsból hozzá vezető legrövidebb út. 23.5. Műveletigény A műveletigényt összefüggő gráfra határozzuk meg. Feltesszük tehát, hogy a bejárás megadott kezdőcsúcsából a minden gráf minden csúcsa elérhető. Az algoritmus az inicializáló lépés során minden csúcsnak beállítja a színét, valamint a 𝑑 és 𝑃 tömbbeli értékét. Ez a csúcsok számával arányos műveletigényt ad, azaz Θ(𝑛).

Éllistás ábrázolás esetén minden csúcsot (amelybe a feltevés szerint vezet él) pontosan egyszer teszünk be a sorba és onnan ki is vesszük, hiszen végül kiürül a sor. Ez Θ(𝑛) műveletigényt jelent. Amikor a sorból kivesszük a csúcsot, végignézzük az éllistáján lévő csúcsokat. Mivel minden csúcs pontosan egyszer kerül be a sorba és onnan ki is vesszük, ezért minden éllistán egyszer megyünk végig, tehát összességében a gráf minden éllistán áthaladunk egyszer. Az éllisták együttes hossza 𝑒, így ennek műveletigénye Θ(𝑒). (Megjegyezzük, hogy ha egy él olyan szomszédhoz vezet, amelyet már korábban elért a bejárás, akkor ez az él csak egy kérdés feltételére készteti az algoritmust, hiszen a bejárt szomszédot már nem kell feldolgozni. Ez minimális költség, de az elméleti tisztaság érdekében nem lehet figyelmen kívül hagyni.) Összesítve azt kapjuk, hogy egy összefüggő gráf szélességi bejárásának műveletigénye éllistás reprezentáció esetén: 𝑇(𝑛) = Θ(𝑛) + Θ(𝑒) = Θ(𝑛 + 𝑒) Csúcsmátrixos ábrázolás esetén egy csúcs szomszédjainak a vizsgálata a gráfot reprezentáló mátrix egy 𝑛 hosszú sorának a végigjárását igényli. Ezt az összes csúcsra tekintetbe véve kapjuk a szélességi bejárás műveletigényét a mátrixos ábrázolás mellett: 𝑇(𝑛) = Θ(𝑛) + Θ(𝑛 ∙ 𝑛) = Θ(𝑛2 ) A továbbiakban külön nem hangsúlyozzuk, hogy 𝑒-vel arányos műveletigény csúcsmátrixos ábrázolás esetén mindig 𝑛2 -el arányos műveletet jelent.

24.

MINIMÁLIS KÖLTSÉGŰ UTAK I.

Az útvonaltervezés az egyik leggyakrabban végrehajtott eljárása a gráfok alkalmazásai körében. A feladat például a közlekedésben jelentkezik. A gráfot itt az a térkép jelenti, amely tartalmazza a kiindulási pontot és a célállomást, amellett a szóba jövő utak sem futnak le róla. Az egyes útszakaszok megtételéhez (például üzemanyag és idő) ráfordítás szükséges, ezért olyan utat szeretnénk ezekből a szakaszokból összeállítani, amelynek a költsége minél kisebb, esetleg minimális. A gráf éleihez tehát nemnegatív költségértékeket rendelünk. Az útszakaszok lehetnek mindkét irányban járhatók, ha például a navigációs probléma az ország térképén két város között merül fel, illetve lehetnek egyirányúak, ha egy városon belül keresünk legrövidebb utat két pont között. Ennek megfelelően a gráf egyaránt lehet irányítás nélküli, illetve irányított. A gráfon megfogalmazott, minimális költségű útvonal keresése során „alapesetben” nem támaszkodhatunk más ismeretre, mint az élsúlyok értékére. Nincs olyan háttértudásunk, amely eleve kizárná bármelyik csúcsot az útvonalból. A feladatot ezért az általános esetben úgy fogalmazzuk meg, hogy egy kiinduló csúcsból a gráf minden pontjához keressük az oda vezető minimális költségű utat. A kitűzött feladat megoldására Dijkstra nevezetes algoritmusát ismertetjük. Ez egy mohó eljárás, amely a startcsúcsból kiindulva, minden lépésben az addigi legkisebb költséggel elérhető csúcsot választja ki, és annak szomszédjaiba új költségeket számol. (Könnyen felismerhetjük majd, hogy a szélességi bejárásnál – egyszerűbb esetben – találkoztunk már ennek a megoldásnak az elvével.) Az eljárás ismertetésénél újra hasznát vesszük annak, hogy megkülönböztetjük az absztrakt adatszerkezet, illetve az adatok reprezentálásnak a szintjét. Az ADS szinten megadott algoritmus nem csak szemléletes, hanem a megengedhető mértékig nemdeterminisztikus, valamint az eltérő reprezentációk közös kiindulópontját képezi. Adatábrázolás szintjén mindkét gráf-reprezentációra, valamint az algoritmusban szereplő elsőbbségi sor két megvalósítására meggondoljuk a műveletigényét. 24.1. A minimális költségű utak problémája A bevezetőben körvonalazott problémát pontosabban is megfogalmazzuk, mint gráfon értelmezett feladatot. Feladat. Adott egy 𝐺 = (𝑉, 𝐸) élsúlyozott, véges gráf és egy 𝑠 ∈ 𝑉 csúcs, a kezdőcsúcs. A gráf élei lehetnek irányítatlanok, és rendelkezhetnek irányítással. Szeretnénk meghatározni minden 𝑣 ∈ 𝑉 csúcsra az 𝑠-ből 𝑣-be vezető legkisebb költségű utat, a költségértékével együtt. Elterjedt az a szóhasználat, amely a kezdőcsúcsot startcsúcsnak vagy forrásnak nevezi, a minimális költségű utat pedig optimális útnak, vagy (kissé megtévesztően) „legrövidebb” útnak mondja, amit mindjárt meg is indokolunk. Módosítsuk a szélességi bejárásnál bevezetett úthossz fogalmunkat. Legyen 𝐺 = (𝑉, 𝐸) élsúlyozott, irányított vagy irányítás nélküli gráf 𝑐: 𝑉 × 𝑉 → ℝ súlyfüggvénnyel. A 𝑣0 ↝ 𝑣𝑘 = 〈𝑣0 , 𝑣1 , … , 𝑣𝑘 〉 út költsége (összsúlya, hossza) az utat alkotó élek súlyainak az összege, azaz 𝑑(𝑝) = {



𝑘

0 𝑐(𝑣𝑖−1 , 𝑣𝑖 )

𝑖=1

ha 𝑘 = 0 egyébként

Vezessük be a legkisebb költségű (minimális költségű, optimális vagy legrövidebb) út fogalmát. Az 𝑢-ból a 𝑣-be (𝑢, 𝑣 ∈ 𝑉) vezető minimális költségű út súlya (összköltsége, hossza) legyen min{𝑑(𝑢 ↝ 𝑣)} ha ∃𝑢 ↝ 𝑣 út a gráfban 𝛿(𝑢, 𝑣) = { ∞ különben Az 𝑢 csúcsból a 𝑣-be vezető legrövidebb úton a 𝛿(𝑢, 𝑣) súlyú utak egyikét értjük. A szélességi keresésnél már találkoztunk hasonló feladattal, az ottani legrövidebb (legkisebb élszámú) utak nyilvántartásával. Most is ugyanúgy járunk el, egy 𝑃[1 … 𝑛] tömbben tartjuk nyilván minden csúcsnak a megelőzőjét, az algoritmus által talált (egyik) legrövidebb úton. A szélességi fához hasonlóan definiálhatjuk a legrövidebb utak feszítőfáját is. Az 𝐹(𝑉′, 𝐸′) gráf a 𝐺 = (𝑉, 𝐸) gráfban a legrövidebb utak fája, ha 𝑉′ elemei az s-ből elérhető csúcsok, 𝐸′ ⊆ 𝐸 és minden 𝑣 ∈ 𝑉 ′ csúcsra pontosan egy egyszerű út vezet 𝑠-ből 𝑣-be, és ez az út egyike az 𝑠-ből 𝑣-be vezető legrövidebb 𝐺-beli utaknak. 24.2. A megoldás módszere A megoldás elve a következő. Minden lépésben tartsuk nyilván az összes csúcsra, a forrástól az illető csúcsba vezető, eddig talált legkisebb összsúlyú utat. A már megismert módon a 𝑑[1. . 𝑛] tömbben a távolságot, és 𝑃[1. . 𝑛] tömbben a megelőző csúcsot tároljuk. Azt mondhatjuk, hogy egy v ∈ V csúcs már KÉSZ, ha ismert a hozzá vezető legrövidebb út. Kezdetben egyetlen csúcs sem KÉSZ, és azt szeretnénk elérni, hogy az eljárás először magát az 𝑠 kezdőcsúcsot válassza ki, hiszen innen indul a keresés és formálisan nézve az önmagából hozzá vezető legrövidebb út nulla költsége eleve ismert. 1.

Kezdetben a forrástól vett távolság legyen a kezdőcsúcsra 0, a többi csúcsra ∞.

2.

Minden lépésben a nem KÉSZ csúcsok közül tekintsük az egyik legkisebb eddigi költségű v csúcsot. A 𝑣-t terjesszük ki, azaz a szomszédjaira számítsuk ki a 𝑣-be vezető, és onnan egy kimenő éllel meghosszabbított út költségét. Amennyiben ez jobb (kisebb), mint az illető szomszédba eddig talált legrövidebb út összsúlya, akkor innen kezdve ezt az utat tekintsük az adott szomszédba vezető, eddig talált legrövidebb útnak. Ezt az eljárást szokás közelítésnek is nevezni.

24.3. Az eljárás helyessége Az algoritmus helyességét több lépésben igazoljuk. 1.

Állítás. A 𝑣0 ∈ 𝑉 csúcsból a 𝑣𝑘 ∈ 𝑉 csúcsba vezető, bármely legrövidebb 〈𝑣0 , … , 𝑣𝑘−1 , 𝑣𝑘 〉 út olyan, hogy a 〈𝑣0 , … , 𝑣𝑘−1 〉 út is egyike a 𝑣0 -ból a 𝑣𝑘−1-be vezető legrövidebb utaknak. Bizonyítás. Az úthossz definíciójából tudjuk, hogy 𝑑(〈𝑣0 , … , 𝑣𝑘−1 , 𝑣𝑘 〉) = 𝑑(〈𝑣0 , … , 𝑣𝑘−1 〉) + 𝑐(𝑣𝑘−1 , 𝑣𝑘 ) . Indirekt tegyük fel, hogy létezik 𝑞 = 〈𝑣0 , … , 𝑣𝑘−1 〉 útnál rövidebb 𝑝 út 𝑣𝑘−1 -be. Ekkor ezen az úton eljutva 𝑣𝑘−1 -be az úthossz: 𝑑(𝑝) + 𝑐(𝑣𝑘−1 , 𝑣𝑘 ) < 𝑑(𝑞) + 𝑐(𝑣𝑘−1 , 𝑣𝑘 ), mivel 𝑑(𝑝) < 𝑑(𝑞). Tehát találtunk a legrövidebb útnál rövidebb utat, ami ellentmondás.

2.

Az előző állításból következik, elegendő a legrövidebb úton csak a megelőző csúcsot eltárolni. Az állítás könnyen általánosítható "a legrövidebb út részútja is legrövidebb út" állításra.

3.

Állítás. ∀〈𝑣0 , … , 𝑣𝑘 〉 úton, a 𝑑(𝑣0 ↝ 𝑣0 ), 𝑑(𝑣0 ↝ 𝑣1 ), …, 𝑑(𝑣0 ↝ 𝑣𝑘 ) részutak költségei monoton növő sorozatot alkotnak. Bizonyítás. Mivel nincsenek negatív élsúlyok, ezért 𝑑(𝑣0 ↝ 𝑣𝑖 ), 𝑖 = 0 … 𝑘 nem negatív tagú sorozatnak tekinthető.

4.

Állítás. Az egyes lépések után, ∀ 𝑢 ∈ 𝑉\KÉSZ csúcs esetén, ha ∃ 𝑠 ↝ 𝑢 út a gráfban, akkor az 𝑢 csúcshoz vezető legrövidebb úton ∃ 𝑣 ∈ KÉSZ csúcs. Bizonyítás. Az 𝑠 ∈ KÉSZ biztosan teljesül.

5.

Állítás. Minden lépésben, ∀ 𝑢 ∈ 𝑉\KÉSZ csúcsra teljesül 𝑑𝑚𝑖𝑛 ≤ 𝛿(𝑠, 𝑢), azaz 𝑠-ből 𝑢-ba vezető utak távolsága nem csökkenhet a jelenlegi minimum alá. Bizonyítás. Az első lépésben triviálisan teljesül az állítás, mivel 𝑑𝑚𝑖𝑛 = 0 és nincs negatív élsúly. Indirekt tegyük fel, hogy ∃ 𝑢 ∈ 𝑉\KÉSZ, amelyre az eddig talált legrövidebb 𝑝 út 𝑑(𝑝) ≥ 𝑑𝑚𝑖𝑛 , de a legrövidebb 𝑝∗ = 𝑠 ↝ 𝑢 útra 𝑑𝑚𝑖𝑛 > 𝑑(𝑝∗ ). Tudjuk, hogy van KÉSZ csúcsa a 𝑝∗ útnak (2. lépéstől kezdve). Legyenek 𝑣 ∈ KÉSZ és 𝑤 ∈ Szomszéd(𝑣)\KÉSZ egymást követő csúcsai 𝑝∗ -nak (biztos létezik 𝑤, mivel 𝑢 ∈ 𝑉\KÉSZ, legfeljebb 𝑤 = 𝑢). Mivel 𝑣 ∈ KÉSZ, így már ismert 𝑣-be vezető egyik legrövidebb út, ami része az 𝑢-ba menő egyik legrövidebb útnak. Legyen 𝑢-ba menő egyik legrövidebb útnak 𝑤-ig tartó részútja 𝑝∗ 𝑤 = 𝑠 ↝ 𝑣 → 𝑤, aminek a hossza már ismert, mivel 𝑣-t már KÉSZ-nek választottuk, ezért 𝑝∗ 𝑣 ismert, továbbá 𝑣-t kiterjesztettük, így 𝑝∗ 𝑤 is ismert. Azt is tudjuk, hogy 𝑑𝑚𝑖𝑛 ≤ 𝑑(𝑝∗ 𝑤 ), mivel 𝑤 ∉ KÉSZ. A monotonitás tulajdonság miatt 𝑑(𝑝∗ 𝑤 ) ≤ 𝑑(𝑝∗ ) ⟹ 𝑑𝑚𝑖𝑛 ≤ 𝑑(𝑝∗ 𝑤 ) ≤ 𝑑(𝑝∗ ), ami ellentmond az indirekt feltevésnek.

6.

Állítás. Az egyes lépésekben az algoritmus által KÉSZ-nek kiválasztott 𝑢 ∈ 𝑉 csúcsra valóban ismert az egyik legrövidebb 𝑠 ↝ 𝑢 út. Bizonyítás. Legyen 𝑝 = 𝑠 ↝ 𝑢 a jelenleg ismert legrövidebb út (ami lehet ∞ távolságú is), amelyre tudjuk 𝑑(𝑝) = 𝑑𝑚𝑖𝑛 , és indirekt tegyük fel, hogy ∃ 𝑝∗ = 𝑠 ↝ 𝑢 út, amelyre 𝑑(𝑝∗ ) < 𝑑(𝑝). Tudjuk, hogy ∃ 𝑣 ∈ 𝑉\KÉSZ és 𝑤 ∈ Szomszéd(𝑣)\KÉSZ csúcs a 𝑝∗ úton. A korábbiakban látott módon levezethető, hogy ∗ ∗ 𝑑(𝑝) = 𝑑𝑚𝑖𝑛 ≤ 𝑑(𝑝𝑤 ) ≤ 𝑑(𝑝 ) ami ellentmond az indirekt feltevésnek. Tehát rövidebb 𝑠 ↝ 𝑣 utat a későbbiekben sem találhatunk.

7.

Állítás. A fenti algoritmus, negatív élsúlyokat nem tartalmazó 𝐺 = (𝑉, 𝐸) véges gráf esetén, 𝑠 ∈ 𝑉 forrás (kezdőcsúcs) és ∀𝑣 ∈ 𝑉 csúcsra, meghatározza 𝑠-ből 𝑣-be vezető legrövidebb utat és annak hosszát. Bizonyítás. Az algoritmus minden lépésben KÉSZ-nek választ egy csúcsot. Mivel véges sok csúcsa van a gráfnak, az algoritmus véges időn belül terminál, és ∀𝑣 ∈ 𝑉 csúcs KÉSZ-en van, azaz ismert a legrövidebb 𝑠 ↝ 𝑣 út.

24.4. Dijkstra algoritmusa A 𝑑[1. . 𝑛] és 𝑃[1. . 𝑛] tömböket, a korábban ismertetett módon, a távolság és a megelőző csúcs nyilvántartására használjuk. A KÉSZ halmazba rakjuk azokat a csúcsokat, amelyekhez már ismerjük az egyik legrövidebb utat. Ezen kívül, használunk egy minimumválasztó elsőbbségi (prioritásos) sort (𝑚𝑖𝑛𝑄), amelyben a csúcsokat tároljuk a már felfedezett, legrövidebb 𝑑(𝑠 ↝ 𝑢) távolsággal, mint kulcs értékkel. A Dijkstra algoritmusa a 24.1. ábrán látható.

24.1. ábra. A Dijkstra algoritmus és műveletigénye

24.5. Az algoritmus megvalósítása reprezentációs szinten Vizsgáljuk meg a prioritásos sor (𝑚𝑖𝑛𝑄) megvalósításának két, természetes módon adódó lehetőségét. 1. A prioritásos sort valósítsuk meg rendezetlen tömbbel, azaz a prioritásos sor legyen maga a 𝑑[1. . 𝑛] tömb. Ekkor a minimum kiválasztására egy feltételes minimum keresést kell alkalmazni, amelynek a műveletigénye Θ(𝑛). A Feltölt(𝑚𝑖𝑛𝑄) és a Helyreállít(𝑚𝑖𝑛𝑄) absztrakt műveletek megvalósítása pedig egy SKIP-pel történik. 2. Kupac adatszerkezet használatával is reprezentálhatjuk a prioritásos sort. Ekkor a Feltölt(𝑚𝑖𝑛𝑄) eljárás, egy kezdeti kupacot épít, amelynek a műveletigénye lineáris (lásd: 16. fejezet). Azonban most a d[1..n] tömb változása esetén a kupacot is karban kell tartani, mivel a kulcs érték változik. Ezt a Helyreállít(𝑚𝑖𝑛𝑄) eljárás teszi meg, amely a csúcsot a gyökér felé "szivárogtatja" fel, ha szükséges (mivel a kulcs értékek csak csökkenhetnek). Ennek a műveletigénye Θ(log 𝑛)-es. Megjegyzés. Nem szükséges kezdeti kupacot építeni, felesleges a kupacba rakni a végtelen távolságú elemeket. Kezdetben csak a kezdőcsúcs legyen a kupacban, majd amikor először elérünk egy csúcsot és a távolsága már nem végtelen, elég akkor berakni a kupacba. 24.6. Az algoritmus szemléltetése A 24.2. ábrán megfigyelhető Dijkstra algoritmusának működése lépésenként. A KÉSZ halmazhoz való tartozást színezéssel valósítjuk meg. Legyenek a nem KÉSZ csúcsok fehérek, a KÉSZ csúcsok pedig fekete színűek. A csúcsokra a címkén kívül, felírtuk az eddig talált legrövidebb út hosszát is (𝑑 tömbbeli értékeket). A végtelen nagy távolságot jelöljük '#' jellel. A forrás legyen az 1-es címkéjű csúcs. Az inicializáló lépés után a kezdőcsúcs 0, a többi csúcs végtelen súllyal szerepel az elsőbbségi sorban. Az első lépésben kivesszük a prioritásos sorból az 1-es csúcsot (mivel az ő prioritása a legkisebb). Az 1-es csúcshoz már ki van számítva a legrövidebb út, tehát ez a csúcs már elkészült, színezzük feketére. Kiterjesztjük az 1-est, azaz a szomszédjaira

kiszámítjuk az 1-esből kimenő éllel meghosszabbított utat. Ha ez javító él, azaz az 1-esen átmenő út rövidebb, mint az adott szomszédba eddig talált legrövidebb út, akkor a szomszédban ezt feljegyezzük (𝑑 és 𝑃 tömbbe). Az ábrán kiemeltük a javító éleket. Megfigyelhető, hogy a 2-es csúcsba már korábban is találtunk 10 hosszú utat 〈1, 2〉, de a második lépésben, a 4-es csúcs kiterjesztésekor, találunk, a 4-es csúcson átmenő rövidebb 8 hosszú utat. A 2-es csúcs kiterjesztésekor a 3-as csúcsba találtunk egy rövidebb utat. A negyedik lépésben még találunk rövidebb utat az 5-ös csúcsba, majd az utolsó lépésben kivesszük a prioritásos sorból az 5-ös címkéjű csúcsot is. Az utolsó lépésben berajzoltuk a legrövidebb utak fáját alkotó éleket.

24.2. ábra. A Dijkstra algoritmus lépésenkénti végrehajtása

24.7. Műveletigény A prioritásos sor fenti két megvalósítása esetén, a következőképpen alakul az algoritmus műveletigénye. A 24.1-es ábrán feltüntettük az egyes műveletek költségét a két ábrázolás estén. A belső ciklust célszerű globálisan kezelni, ekkor mondható, hogy összesen legfeljebb annyiszor fut le, ahány éle van a gráfnak. 1. Rendezetlen tömb esetén: 𝑇(𝑛) = Ο(1 + 𝑛 − 1 + 1 + 0 + 𝑛2 + 𝑛 + 𝑒) = Ο(𝑛2 + 𝑒) = Ο(𝑛2 ) 2. Kupac esetén: 𝑇(𝑛) = Ο(1 + 𝑛 − 1 + 1 + 𝑛 + 𝑛 ∙ log 𝑛 + 𝑛 + 𝑒 ∙ log 𝑛) = Ο((𝑛 + 𝑒) ∙ log 𝑛) Rendezetlen tömbbel való ábrázolás műveletigénye csak a csúcsok számától függ, míg a kupacos ábrázolás műveletigénye, az élek számának is a függvénye. Sűrű gráfnak nevezzük az olyan gráfokat, amelyre 𝑒 ≈ 𝑛2 , ritka gráfoknak pedig, amelyre 𝑒 ≈ 𝑛 (vagy "kevesebb"). Tehát a kupacos ábrázolás műveletigénye ritka gráf esetén Ο(𝑛 ∙ log 𝑛), míg sűrű gráf esetén Ο(𝑛2 ∙ log 𝑛). Az az érdekes helyzet adódott, hogy a gráf sűrűsége befolyásolja milyen ábrázolást érdemes választani. A kupac, csak a ritka gráfok esetén hatékonyabb, míg sűrű gráfok esetén a rendezetlen tömbbel való reprezentáció az olcsóbb. Tehát a reprezentáció szintjén sűrű gráf esetén csúcsmátrix és rendezetlen tömb, titka gráf esetén szomszédsági lista és kupac javasolt. 24.8. Megjegyzések A mohó algoritmus mindig az adott lépésben optimálisnak látszó döntést hozza, vagyis a lokális optimumot választja abban a reményben, hogy ez globális optimumhoz fog majd vezetni. Dijkstra algoritmusa is mohó stratégiát követ, amikor minden lépésben KÉSZ-nek választ egy csúcsot. Mivel a legrövidebb út részútja is legrövidebb út, így a lokális optimumok választásával elérhetjük a globális optimumot. Most vizsgáljuk meg a szélességi keresés és a Dijkstra algoritmus kapcsolatát. Mindkét algoritmusnál, egy kezdőcsúcsból kiinduló legrövidebb utakat állítunk elő, csak a Dijkstra algoritmusnál az utak hosszának fogalmát általánosítjuk. Legyen minden él súlya egységnyi, ekkor a Dijkstra algoritmus egy szélességi keresést hajt végre. Tehát mondhatjuk, hogy a szélességi keresés speciális esete a Dijkstra algoritmusnak, ahol a prioritásos sor helyett, egy egyszerű sort használunk, amellyel javítunk a műveletigényen. Azt kell belátni, hogy a sor használatával is mindig a legkisebb távolságú csúcsot választjuk KÉSZ-nek, de ez következik a szélességi keresésnél megvizsgált invariáns tulajdonságból.

25.

MINIMÁLIS KÖLTSÉGŰ UTAK II.

A minimális költségű utak problémája olyan esetben is felmerül, amelyben a gráf élei negatív költségértékkel (súllyal) is rendelkezhetnek. Mindössze azt zárjuk ki, hogy a gráf negatív összköltségű kört tartalmazzon. Gondoljunk egy olyan útvonal tervezési feladatra, amelyekben bizonyos útszakaszain bevételhez jutunk. A gráfos modellben egy él pozitív súlya azt jelenti, hogy azon a szakaszon a ráfordítás a nagyobb, míg a negatív költség arra utal, hogy ott a bevétel felülmúlja a kiadásainak. Ha egy negatív körön ismételten végighaladva, egyre növekedne a nyereségünk. Ezért a negatív körök kizárása a gyakorlat szempontjából is érthető feltétel. Ebben a feladatban is egy kezdőcsúcsból a gráf összes csúcsához keressük a legkisebb költségű utat. A feladat megoldására a Bellman-Ford-algoritmust ismertetjük. Ez ugyanazt a közelítést alkalmazza az éleken, mint a Dijkstra-algoritmus, vagyis ha egy csúcshoz az addigi értéknél kisebb költségű utat talál az éppen vizsgált élen keresztül, akkor a csúcshoz ezt a javított értéket jegyzi be, valamint módosítja is az elérési útvonalat. Az eljárás viszont nem követhet mohó stratégiát, szemben Dijkstra algoritmusával, ugyanis bármely már elért csúcs esetében előfordulhat az, hogy a még be nem járt csúcsok bevonásával – a negatív élkötségek folytán – javíthatunk a forrásból odavezető út addigi költségén. 25.1. A minimális költségű utak problémája (negatív élekkel) Feladat. Adott egy 𝐺 = (𝑉, 𝐸) élsúlyozott, irányított vagy irányítás nélküli, negatív összköltségű kört nem tartalmazó véges gráf, továbbá egy 𝑠 ∈ 𝑉 forrás (kezdőcsúcs). Határozzuk meg minden 𝑣 ∈ 𝑉 csúcsra az 𝑠-ből 𝑣-be vezető legrövidebb utat, annak költségével együtt. Vegyük szemügyre még egyszer a negatív kör jelenségét! A kezdőcsúcsból elérhető negatív összköltségű körön nem léteznének legkisebb költségű utak, mivel az illető körön tetszőlegesen sokszor végig menve az utak költsége mindig tovább csökkenthető lenne. Irányítatlan gráf esetén, egy (𝑢, 𝑣) negatív súlyú irányítatlan élen oda-vissza haladva az út költsége szintén korlátlanul csökkenthető lenne, azaz úgy viselkedne, mint egy negatív összköltségű kör. Tekintsük az irányítás nélküli élte tehát negatív összköltségű, két élből álló irányított körnek. Ez egybevág az ábrázolás szintjén megvalósított irányítatlan gráffal, ahol egy irányítatlan élt, egy oda-vissza irányított élpárral valósítunk meg. Tehát irányítatlan gráf esetén a megszorításunk az, hogy egyáltalán ne tartalmazzon negatív súlyú élt, mert az negatív irányított körnek tekinthető. 25.2. A Bellman-Ford algoritmus Minden csúcsra, ha létezik legrövidebb út, akkor létezik egyszerű legrövidebb út is, mivel a körök összköltsége nem negatív, így a kört elhagyva az út költsége nem nőhet. Egy 𝑛 pontú gráfban, a legnagyobb élszámú egyszerű út hossza legfeljebb 𝑛 − 1 lehet. A Bellman-Ford-algoritmus a Dijkstra algoritmusnál megismert közelítés műveletét végzi, azaz egy csúcson át a szomszédba vezető él mentén vizsgálja, hogy az illető él része-e a legrövidebb útnak, javító él-e. Egy menetben az összes élre megvizsgálja, hogy javító él-e vagy sem. Összesen 𝑛 − 1 menetet végez. (Az alfejezet végén visszatérünk az iterációk számára.)

Vizsgáljunk meg egy 𝑝∗ = 𝑠 ↝ 𝑢 legrövidebb utat. Minden menetben a 𝑝∗ minden élén végzünk közelítést. Legyen 𝑣 → 𝑤 él része 𝑝∗ -nak. Miután 𝑝∗ 𝑣-ig tartó részútja 𝑝∗ 𝑣 ismertté válik, a következő menetben a 𝑝∗ 𝑤 is ismert lesz, mivel az (𝑣, 𝑤) éllel is végzünk közelítést. Azonban az élek feldolgozásának (közelítésének) sorrendjére nem tettünk semmilyen megkötést, így csak azt tudjuk garantálni, hogy az első lépés után az 1 élszámú legrövidebb utak, a második lépés után a 2 élszámú legrövidebb utak, és így tovább, válnak ismerté. Mivel a leghosszabb egyszerű út 𝑛 − 1 élszámú, ezért szükséges lehet az 𝑛 − 1 menet. A Bellman-Ford-algoritmus ADT szintű leírása a 25.1. ábrán látható.

25.1. ábra. A Bellman-Ford algoritmus

Állítás. Ha adott egy 𝐺 = (𝑉, 𝐸) élsúlyozott, irányított vagy irányítás nélküli, negatív összköltségű irányított kört nem tartalmazó véges gráf, továbbá egy 𝑠 ∈ 𝑉 forrás (kezdőcsúcs). Ekkor a Bellman-Ford algoritmus meghatározza ∀𝑣 ∈ 𝑉 csúcsra legrövidebb utat és annak hosszát. Bizonyítás. Legyen 𝑝∗ = 〈𝑣0 , … , 𝑣𝑘−1 , 𝑣𝑘 〉 egy 𝑠-ből 𝑢-ba vezető, egyszerű legrövidebb út, ahol 𝑠 = 𝑣0 és 𝑢 = 𝑣𝑘 . Teljes indukcióval belátjuk, hogy az 𝑖-dik menet után már ismert 〈𝑣0 , … , 𝑣𝑖 〉 legrövidebb részút, azaz 𝑑[𝑣𝑖 ] = 𝛿(𝑠, 𝑣𝑖 ) és 𝑃[𝑣𝑖 ] = 𝑣𝑖−1 , és ez már nem romlik el később sem. Kezdetben az inicializáló lépés után 𝑑[𝑠] = 𝑑[𝑣0 ] = 𝛿(𝑠, 𝑣0 ) = 0. Ez fennmarad, különben létezne egy olyan s-ből elérhető 𝑢 csúcs, amelyre 𝑠 ↝ 𝑢 → 𝑠 és 𝑑(𝑠 ↝ 𝑢) + 𝑐(𝑢, 𝑠) < 0, ami azt jelenti, hogy találtunk egy negatív kört. A bizonyítás 𝑖 − 1 → 𝑖 általános lépésében tegyük fel, hogy ismert 𝑝∗ -nak 𝑠 ↝ 𝑣𝑖−1 részútja. Az 𝑖-dik menetben a (𝑣𝑖−1 , 𝑣𝑖 ) éllel is végzünk közelítést (feljegyezzük 𝑑[𝑣𝑖 ] = 𝛿(𝑠, 𝑣𝑖 ) és 𝑃[𝑣𝑖 ] = 𝑣𝑖−1 ), és ez csak akkor nem történik meg ((𝑣𝑖−1 , 𝑣𝑖 ) nem javító él), ha 𝛿(𝑠, 𝑣𝑖 ) már ismert, azaz a 𝑣𝑖 -be vezető egyik legrövidebb utat már korábban megtaláltuk. A későbbiek során ez már nem változhat, mivel ha ez változna, az azt jelentené, hogy létezik a legrövidebb útnál rövidebb út, mivel 𝑝∗ is legrövidebb út és annak bármely részútja, így 𝑝∗ nak 𝑠 ↝ 𝑣𝑖 részútja is legrövidebb út. Mivel a legnagyobb élszámú, egyszerű, legrövidebb út élszáma is legfeljebb n-1, ezért a fenti indukciós állításból következik az algoritmus helyessége. A Bellman-Ford algoritmussal gyakran abban a változatban lehet találkozni, amelyben még egy n-edik iteráció is szerepel, annak ellenőrzésére, hogy a gráf nem tartalmaz negatív kört. Ezt az jelzi, hogy a csúcsok költségértékek már nem változnak ebben az „utolsó utáni” menetben. (Negatív kör esetén a költségértékek tömbjében valahol csökkenést tapasztalnánk.)

25.3. Az algoritmus szemléltetése Nézzük meg egy példán, ADS szinten az algoritmus működését. Tegyük fel, hogy az élek feldolgozási sorrendje a csúcsok címkéje szerint rendezett: (1,2), (1,3), …, (5,3). Az inicializáló lépés során beállítjuk a 𝑑[1. . 𝑛] és 𝑃[1. . 𝑛] tömb értékeit (25.2. ábra). A végtelen értéket most is '#' jellel jelöljük. Az első 7 él ((1,2), (1,3), … , (4,2)) közelítésénél nem történik változás, mivel végtelen értékek növelésénél szintén végtelent kapunk, ami nem javít. Csak két javító élt találunk. Most állíthatjuk, hogy minden csúcshoz megtaláltuk az sből hozzá vezető, minimális költségű, 1 élszámú utat.

25.2. ábra. Az algoritmus inicializáló és 1. lépése

A 2. lépésben a 2. és 4. csúcsokhoz találunk javító élt (25.3. ábra). Ezzel minden csúcshoz meghatároztuk a legkisebb költségű, 1 vagy 2 élszámú utat. Az utolsó lépésen látható az egyes csúcsokba vezető, 1 vagy 2 élszámú legrövidebb utakból kialakult fa. Ez a fa változhat, mivel lehet, hogy egy csúcsba el lehet jutni nagyobb élszámú olcsóbb úton is.

25.3. ábra. Az algoritmus 2. lépése és a kialakult fa

A 3. lépésben az 1-be olcsóbb 3 élszámú utat találtunk (25.4. ábra). A fa változott mivel az 1-be már nem 1 élszámú, hanem 3 élszámú, de kisebb költségű úton juthatunk el a kezdőcsúcsból. A 4 megelőzője a korábban talált 1-es, csak most nem 2 élhosszú úttal, hanem 4 élhosszúval (〈5, 3, 2, 1, 4〉). Mivel az (1,4) élt korábban dolgoztuk fel, mint (2,1) élt, így a 4-es csúcsnál bejegyzett költség nem konzisztens a fával. (Az élek sorrendje hathat úgy, hogy az algoritmus az utak keresésében „előresiet” és megtalál egy hosszabb utat, mint ami az iterációk számából következne. Az ehhez számolt költség lehet helyes, és lehet helytelen, de ezt a megfelelő későbbi menetben korrigálja az eljárás.)

25.4. ábra. Az algoritmus 3. lépése és a kialakult fa

A 4. lépésben (25.5. ábra) a fa már nem változik, csak 4-es csúcsnál bejegyzett költség veszi fel a helyes értéket.

25.5. ábra. Az algoritmus 4. lépése és a kialakult fa

25.4. Műveletigény Mivel 𝑛 − 1 iteráció végzünk, és minden lépés során, minden élre végrehajtunk egy közelítést, ezért 𝑇(𝑛) = Θ((𝑛 − 1) ∙ 𝑒). Ez a műveletigény javítható a következő gyorsítással. (A buborék rendezésnél már láttunk hasonló gyorsítási lehetőséget.) Állítás. Ha egy iteráció során nem következett be változás a közelítések során, akkor megállhatunk, az eljárás megtalálta az össze legkisebb költségű utat. Bizonyítás. Indirekt módon tegyük fel, hogy létezik az algoritmus által megadott olyan legrövidebb 𝑝∗ = 𝑠 ↝ 𝑣 ⟶ 𝑤 ↝ 𝑢 út, hogy az 𝑖-dik lépésben az 𝑠 ↝ 𝑣 részutat már ismerjük, de 𝑣 ⟶ 𝑤 él még nem része a fának, vagy 𝑑[𝑤] értéke nem konzisztens, tehát mindkét esetben 𝑑[𝑤] > 𝛿(𝑠, 𝑤), továbbá az 𝑖-dik lépésben nem történik változás. Azonban 𝑑[𝑣] = 𝛿(𝑠, 𝑣) és 𝛿(𝑠, 𝑤) = 𝛿(𝑠, 𝑣) + 𝑐(𝑣, 𝑤), továbbá az 𝑖-dik lépésben a (𝑣, 𝑤) él közelítése során 𝑑[𝑤] ≤ 𝛿(𝑠, 𝑣) + 𝑐(𝑣, 𝑤) = 𝛿(𝑠, 𝑤), ami ellentmond az indirekt feltevésnek. Ha a fenti gyorsítási lehetőséget beépítjük az algoritmusba, akkor az élek feldolgozási sorrendje befolyásolja az iterációk számát. Ha példánkban így átcímkéztük a csúcsokat: 5→1, 1→4, 2→3, 3→2, 4→5, és az élek feldolgozási sorrendje továbbra is csúcsok címkéje szerint rendezettséget követi, akkor egyetlen iterációs lépésben megkapjuk a megoldást. (Ezzel valójában az élek feldolgozási sorrendjét módosítottuk.)

26. MINIMÁLIS KÖLTSÉGŰ UTAK MINDEN CSÚCSPÁRRA Az előző két fejezetben tárgyalt feladat általánosításaként a gráfban található összes csúcspárra szeretnénk meghatározni a legkisebb költségű utat. A probléma gyakorlati előfordulására példa lehet az autós térképekben előforduló táblázat, amely a városok egymástól való legkisebb távolságait tartalmazza. Ez egy négyzetes táblázat, ahol a sorok és az oszlopok címkeként egyaránt a városok neveit viselik. A táblázat 𝑥 címkéjű sorának és 𝑦 címkéjű oszlopának a metszéspontjában található az 𝑦 városnak az 𝑥 várostól való legkisebb távolsága. Modellezzük az autós térképet egy gráffal (irányított vagy irányítatlan, attól függően, hogy vannak-e egyirányú utak). A csúcsokat megfeleltetjük a városoknak, az élek pedig a városokat összekötő közvetlen utaknak. Az utak hossza legyen az élek költsége, tehát a gráf legyen élsúlyozott. Célunk a gráf alapján egy ilyen táblázat előállítása. A csúcspárok közötti legkisebb költségű utakat megkereshetnénk az előző feladatnál látott megoldó módszerek segítségével. Minden csúcsot forrásként tekintve futtassuk le a „legrövidebb utak egy forrásból” algoritmusok egyikét. 1. Amennyiben az élsúlyok nem-negatívak, akkor alkalmazhatjuk a Dijkstra-algoritmust. Ekkor a műveletigény: a) Elsőbbségi sorként rendezetlen tömböt használva az utak költségértékeinek tárolására: 𝑇(𝑛) = 𝑛 ∙ 𝑂(𝑛2 ) = 𝑂(𝑛3 ) b) Az utak költségeinek elsőbbségi sorát kupaccal reprezentálva: 𝑇(𝑛) = 𝑛 𝑂((𝑛 + 𝑒) log 𝑛) = 𝑂(𝑛2 ∙ log 𝑛 + 𝑛 ∙ 𝑒 ∙ log 𝑛), ami ritka (illetve nem-sűrű) gráfokra 𝑇(𝑛) = 𝑂(𝑛2 ∙ log 𝑛) futási időt eredményez. 2. Ha negatív élsúlyokat is megengedünk, akkor a Bellman-Ford algoritmust használhatjuk, amellyel a műveletigény 𝑇(𝑛) = 𝑛 ∙ Θ((𝑛 − 1) ∙ 𝑒) = Θ(𝑛2 ∙ 𝑒). Ez ritka gráfokra 𝑇(𝑛) = 𝑂(𝑛3 ), sűrű gráfokra 𝑇(𝑛) = Θ(𝑛4 ) nagyságrendű lépésszámot jelent. Ebben a fejezetben az összes legrövidebb út meghatározására – a megszorítások nélküli élsúlyok esetére – hatékonyabb eljárást adunk. Vizsgáljuk ennek az algoritmus speciális változatát gráfok tranzitív lezártjának a kiszámítására. 26.1. A Folyd algoritmus Feladat. Adott egy 𝐺 = (𝑉, 𝐸) élsúlyozott, irányított vagy irányítás nélküli, negatív összköltségű irányított kört nem tartalmazó véges gráf. Határozzuk meg ∀ 𝑢, 𝑣 ∈ 𝑉 csúcspárra az 𝑢-ból 𝑣-be vezető legkisebb költségű utat. A fejezet további részében az utak hosszán az út mentén szereplő élek költségeinek az összegét értjük, a csúcspárok távolságán pedig a csúcspár közötti egyik legrövidebb út hosszát értjük. Tegyük fel, hogy 𝑉 = {1, 2, … , 𝑛}, és hogy a 𝐺 gráf az 𝐶 szomszédsági mátrixával adott. A csúcspárok távolságának a kiszámítására egy szintén 𝑛 × 𝑛-es 𝐷 mátrixot fogunk használni. Vezessünk be a következő fogalmat. Legyen egy 𝑝 = 〈𝑣1 , … , 𝑣𝑘 〉 egyszerű út belső csúcsa 𝑝 minden 𝑣1 -től és 𝑣𝑘 -tól különböző csúcsa, azaz 〈𝑣2 , … , 𝑣𝑘−1 〉 halmaz elemei.

Az algoritmus elve az, hogy a megoldáshoz vezető n-lépéses iteráció során folyamatosan fenntartjuk a 𝐷(𝑘) mátrixunkra a következő invariáns tulajdonságot. Állítás. A 𝑘-adik iteráció lefutása után ∀ (𝑖, 𝑗) ∈ 𝑉 × 𝑉 csúcspárra 𝐷(𝑘) [𝑖, 𝑗] azon 𝑖 ↝ 𝑗 utak legrövidebbjeinek a hosszát tartalmazza, amelyek közbülső csúcsai 𝑘-nál nem nagyobb sorszámúak. Tehát 𝑘 = 𝑛 esetén ∀ (𝑖, 𝑗) csúcspárra 𝐷(𝑛) [𝑖, 𝑗] az 𝑖 ↝ 𝑗 utak legrövidebbjeinek a hosszát, azaz a feladat megoldását tartalmazza. Bizonyítás. Az állítást 𝑘 szerinti teljes indukcióval látjuk be. 

𝑘 = 0: ∀ (𝑖, 𝑗) csúcspárra 𝐷(0) [𝑖, 𝑗] tartalmazza azon 𝑖 ↝ 𝑗 utak közül a legkisebb költségű utak hosszát, amely belső csúcsainak sorszáma kisebb, mint 1, azaz nem tartalmaznak belső csúcsot. Ami nem más, mint a 𝐶 szomszédsági mátrixban szereplő érték. Tehát 𝐷 (0) mátrix értéke legyen 𝐶 szomszédsági mátrix.



𝑘 − 1 → 𝑘: a 𝐷(𝑘) [𝑖, 𝑗] értéket szeretnénk kiszámítani a 𝐷(𝑘−1) mátrix értékeinek a felhasználásával. Két esetet különböztetünk meg aszerint, hogy 𝑝(𝑘) = 𝑖 ↝ 𝑗 (𝑖-ből 𝑗be vezető, belső csúcsként nem nagyobb, mint 𝑘 sorszámú csúcsokat tartalmazó) egyik legrövidebb útnak, 𝑘 belső csúcsa vagy sem. (𝑝(𝑘) út legyen egyszerű út, mert ha tartalmazna kört, és nem lehet negatív összköltségű a kör, akkor a kört "kivágva" a kapott út költsége nem nő, tehát a legrövidebb utak között vannak egyszerűek.) 1.

Ha 𝑘 nem belső csúcsa 𝑝(𝑘) útnak, akkor 𝑝(𝑘) minden belső csúcsának sorszáma legfeljebb 𝑘 − 1, azaz 𝑝(𝑘) hossza azonos a legfeljebb 𝑘 − 1 belső csúcsokat tartalmazó 𝑖 ↝ 𝑗 legrövidebb út hosszával 𝐷(𝑘−1) [𝑖, 𝑗]-vel.

2.

Ha 𝑘 belső csúcs a 𝑝(𝑘) úton, akkor felbonthatjuk 𝑝1 (𝑘) = 𝑖 ↝ 𝑘 és 𝑝2 (𝑘) = 𝑘 ↝ 𝑗 legfeljebb 𝑘 − 1 sorszámú belső csúcsokat tartalmazó 𝑖-ből 𝑘-ba ill. 𝑘-ból 𝑗-be vezető legrövidebb egyszerű utakra (legrövidebb út részútja is legrövidebb út). Tehát 𝐷(𝑘) [𝑖, 𝑗] = 𝐷(𝑘−1) [𝑖, 𝑘] + 𝐷(𝑘−1) [𝑘, 𝑗].

Tehát a két eset közül az adja a rövidebb utat, ahol kisebb a számított érték, azaz a kérdéses legrövidebb út hossza megkapható az alábbi képlettel: 𝐷(𝑘) [𝑖, 𝑗] = min{𝐷(𝑘−1) [𝑖, 𝑗] , 𝐷(𝑘) [𝑖, 𝑗] = 𝐷(𝑘−1) [𝑖, 𝑘] + 𝐷(𝑘−1) [𝑘, 𝑗]} A fenti bizonyítás közvetlenül megadja az algoritmus lényegi lépését, 𝐷(𝑘) előállítását. Mivel 𝐷(𝑘) [𝑖, 𝑘] = 𝐷 (𝑘−1) [𝑖, 𝑘] és 𝐷 (𝑘) [𝑘, 𝑗] = 𝐷(𝑘−1) [𝑘, 𝑗], így elegendő egyetlen 𝐷 mátrix az algoritmus végrehajtásához. Az algoritmus a 26.1. ábrán látható.

26.1. ábra. A Floyd algoritmus

A Floyd algoritmust az ábrázolás szintjén adtuk meg mátrixos ábrázolás mellett, mint ahogy a szakirodalomban szokás. 26.2. Az algoritmus szemléltetése A 26.2. ábra szemlélteti az algoritmus működését ADS szinten. Minden iteráció után megadjuk a gráf és a költségmátrix aktuális állapotát. A kezdeti inicializáló lépés után 𝐷 mátrix megegyezik a gráf csúcsmátrixának értékével. Az első iteráció során, az 1-es csúcson átmenő utakkal próbáljuk javítani a mátrix értékeit. Amikor a 2-es csúcsból a 4-es csúcsba menő utakat vizsgáljuk, találunk az 1-esen átmenő javító utat, 𝐷[2,4] értékét 2-re javítjuk. Mivel a gráf irányítatlan, így a szimmetrikus esetben is történik javítás (𝐷[4,2]).

𝐷(0)

0 1 ∞ 1 0 2 =( ∞ 2 0 1 ∞ 1

1 ∞ ) 1 0

𝐷(1)

0 1 =( ∞ 1

1 ∞ 0 2 2 0 2 1

1 2 ) 1 0

𝐷 (2)

0 1 =( 3 1

1 0 2 2

3 2 0 1

1 2 ) 1 0

𝐷 (4)

0 1 =( 2 1

1 0 2 2

2 2 0 1

1 2 ) 1 0

26.2. ábra. A Floyd algoritmus lépésenkénti végrehajtása

A második iterációs lépésben már olyan javító utakat keresünk, amelyek belső csúcsainak a sorszáma legfeljebb 2. Vizsgáljuk az legfeljebb 1-es sorszámú belső csúcsokat tartalmazó utakat (ill. a még nem létező utakat), és megpróbáljuk közbülső csúcsnak beilleszteni a 2-es csúcsot. Az 1-ből a 3-ba, ill. 3-ból az 1-be találtunk javító utat (az eddig nem létező úthoz, a ∞ hosszú úthoz képest) a 2-esen át. A harmadik iterációban nem találunk a 3-as csúcson átmenő javító utakat, így a mátrix nem változik.

A negyedik iterációs lépésben már olyan javító utakat keresünk, amelyek belső csúcsainak a sorszáma legfeljebb 4. Vizsgáljuk a legfeljebb 3-as sorszámú belső csúcsokat tartalmazó utakat (ill. a még nem létező utakat), és megpróbálunk a 4-es csúcson átmenő, kisebb költségű "elkerülő" utat találni. Találunk a 4-es csúcson átmenő javító utat. Eddig az 1-esből a 3-mas csúcsba vezető, legfeljebb 3-as sorszámú belső csúcsokat tartalmazó legrövidebb út hossza 3 volt. Ez az út az 〈1, 2, 3〉. Most megengedjük, hogy belső pont sorszáma lehet 4 is, így megvizsgálva az 〈1, 4〉 ill. 〈4, 3〉 részutak hosszának összegét, az kevesebb mint, az 〈1, 2, 3〉 út hossza, tehát találtunk egy kisebb költségű elkerülő utat. Természetesen ez a szimmetrikus esetre is igaz. A 4. lépés után megkapjuk a végeredményt. Az algoritmus 𝑛 iterációs lépésben, az 𝑛2 -es mátrix minden elemére konstans számú műveletet végez, így 𝑇(𝑛) = Θ(𝑛3 ). Ez egy stabil algoritmus, mivel legjobb, legrosszabb és átlagos esetben is azonos a műveletigénye. A műveletigényünk tehát nagyságrendileg ugyanannyi, mintha a Dijkstra algoritmust csúcsmátrixos ábrázolású gráfon, prioritásos sorként rendezetlen tömböt használva, minden csúcsra, mint forrásra lefuttatnánk. A Dijkstra algoritmusnál már láttuk, hogy ezt a megvalósítást sűrű gráfok esetén célszerű alkalmazni. Ritka gráfok esetén éllistás ábrázolást használhatunk, prioritásos sorként pedig kupacot, így a műveletigény 𝑇(𝑛) = Ο(𝑛2 ∙ log 𝑛), ami jobb, mint a Floyd algoritmus műveletigénye. Úgy tűnhet, hogy felesleges a Floyd algoritmust használni, mivel a Dijkstra algoritmus jobb eredményt ad, vegyük azonban figyelembe, hogy a Dijkstra algoritmust csak nem negatív költségű élek esetén használhatjuk. Amennyiben előfordulhat a gráfban negatív súlyú él (de negatív összköltségű kör nem), a Bellman-Ford algoritmus használatával ritka gráfokra 𝑇(𝑛) = Ο(𝑛3 ), sűrű gráfokra 𝑇(𝑛) = Ο(𝑛3 ) műveletigénnyel tudjuk megoldani a feladatot. Látható, hogy sűrű gráfok esetén a Floyd algoritmus hatékonyabb. Amennyiben a csúcspárok közötti legrövidebb utakra is kíváncsiak vagyunk (és nem csak azok hosszára), a korábban már látott módon eltárolhatjuk a megelőző (vagy közbülső 𝑘 címkéjű) csúcsot. Mivel most csúcspárok közötti utakról van szó minden lehetséges csúcspárra (𝑛 ∙ 𝑛 csúcspár), így érdemes mátrixot használni. 26.3. Tranzitív lezárt Feladat. Adott egy 𝐺 = (𝑉, 𝐸) irányított vagy irányítás nélküli, súlyozatlan, véges gráf. Határozzuk meg ∀ 𝑢, 𝑣 ∈ 𝑉 csúcsra, hogy létezik-e 𝑢-ból 𝑣-be vezető út a gráfban. A fejezet további részében a gráfunk legyen véges, súlyozatlan, irányított vagy irányítatlan, az utak hosszán pedig az út mentén található élek számát értjük. Állítás. Legyen a 𝐺 gráf szomszédsági mátrixa 𝐶[1 … 𝑛, 1 … 𝑛]. Ekkor 𝐶 𝑘 [𝑖, 𝑗] (1 ≤ 𝑖, 𝑗 ≤ 𝑛, 𝑘 ∈ ℕ) az 𝑖-ből a 𝑗-be vezető 𝑘 hosszúságú utak számát adja meg. Bizonyítás. 𝑘 szerinti teljes indukcióval. 

𝑘 = 1: Az 1 hosszú út egy élnek felel meg. Tehát 𝐶 1 [𝑖, 𝑗] az 𝑖-ből a 𝑗-be menő élek számát adja meg, (ami megállapodás szerint legfeljebb 1 lehet), ez pedig pontosan a szomszédsági mátrix definíciója.



𝑘 − 1 → 𝑘: Tegyük fel, hogy 𝑘 − 1-ig teljesül az állítás. Kérdés, hányféleképpen juthatok el k hosszú úton 𝑖-ből 𝑗-be? Az 𝑖 ↝ 𝑗 𝑘 hosszúságú utat a következő módon tudjuk felbontani: 𝑖 ↝ ℎ 𝑘 − 1 hosszú út, majd ℎ → 𝑗 él. Kérdés az, hogy hányféleképpen juthatok el 𝑘 hosszú úton 𝑖-ből 𝑗-be úgy, hogy a 𝑗-t megelőző pont a gráfban a ℎ csúcs.

Az indukciós feltevés szerint, 𝐶 𝑘−1 [𝑖, ℎ] féle módon juthatunkk el 𝑘 − 1 hosszú úton 𝑖-ből ℎ-ba. Továbbá, ha létezik ℎ → 𝑗 él a gráfban (𝐶[ℎ, 𝑗] = 1), akkor azon már csak egyféleképpen tudunk tovább menni j-be, azaz 𝐶 𝑘−1 [𝑖, ℎ] ∙ 𝐶[ℎ, 𝑗] megadja az 𝑖ből a 𝑗-be menő 𝑘 hosszú utak számát, ahol 𝑗 előtti megelőző csúcs a ℎ. Amennyiben nem létezik ℎ → 𝑗 él, akkor a szorzat értéke 0, ami kifejezi, hogy nincs ilyen út a gráfban. Az előzőekben ℎ-n átmenő utakat számláltunk, de s tetszőleges csúcsa lehet a gráfnak, így ezeket minden ∀ℎ ∈ 𝑉 csúcsra összegezzük: 𝑛

𝐶

𝑘 [𝑖,

𝑗] = ∑(𝐶 𝑘−1 [𝑖, ℎ] ∙ 𝐶[ℎ, 𝑗]) 𝑖=1

ahol 𝑖, 𝑗 ∈ [1 … 𝑛], ami nem más, mint egy mátrix szorzat: 𝐶 𝑘 = 𝐶 𝑘−1 ∙ 𝐶. Következmény. Legyen 𝑆 𝑘 = 𝐶 + 𝐶 2 + 𝐶 3 + ⋯ + 𝐶 𝑘 mátrix. Ekkor 𝑆 𝑘 [𝑖, 𝑗] eleme az 𝑖-ből 𝑗be vezető legfeljebb 𝑘 hosszúságú utak számát adja meg. Definíció. A 𝐺 = (𝑉, 𝐸) véges gráf útmátrixa, vagy elérhetőségi mátrixa: 1 𝑈[𝑖, 𝑗] = { 0

ha ∃ 𝑖 ↝ 𝑗 út a gráfban különben

ahol 𝑖, 𝑗 ∈ [1 … 𝑛], 𝑛 = |𝑉|. Állítás. Ha létezik 𝑖-ből 𝑗-be vezető út a gráfban, akkor létezik 𝑖-ből 𝑗-be vezető egyszerű út is, továbbá minden egyszerű út legfeljebb 𝑛 − 1 hosszú, egy egyszerű kör pedig legfeljebb 𝑛 hosszú. Tehát az útmátrix előállításához számoljuk ki 𝐶 + 𝐶 2 + 𝐶 3 + ⋯ + 𝐶 𝑘 összeget, és az eredménymátrixban a nullánál nagyobb elemeket írjuk át 1-esre. Definíció. Egy 𝐺 = (𝑉, 𝐸) gráf tranzitív lezárása 𝐺′ = (𝑉′, 𝐸′) gráf, ahol 𝑉′ = 𝑉 és (𝑢, 𝑣) ∈ 𝐸 ′ ⟺ ∃ 𝑢 ↝ 𝑣 út a gráfban. Állítás. 𝐺 útmátrixa 𝐺′ szomszédsági mátrixa. Állítás. 𝐺 erősen összefüggő ⟺ 𝑈-ban nincs nulla elem ⟺ 𝐺′ teljes gráf. 26.4. A Warshall algoritmus Az előző fejezetben egy gráf tranzitív lezártját elő tudtuk állítani a szomszédsági mátrix hatványainak az összegeként, amelynek hatékonysága 𝑇(𝑛) = Θ(𝑛4 ). Most lássunk egy nagyságrenddel hatékonyabb módszert. A tranzitív lezárt meghatározására használhatnánk a Floyd algoritmust is, hiszen az algoritmus lefutása után, ha 𝐷[𝑖, 𝑗] véges, akkor létezik 𝑖 ↝ 𝑗 út, ha végtelen, akkor pedig nincs 𝑖-ből 𝑗-be vezető út. Azonban a Floyd algoritmus elvét felhasználva, szép algoritmus adható az ábrázolás szintjén a problémára (bár S. Warshall nevéhez fűződő algoritmus megelőzte Floydét). Adott a 𝐺 = (𝑉, 𝐸) véges, súlyozatlan, irányított vagy irányítatlan gráf. A Floyd algoritmushoz képest az alábbi változtatások után megkapjuk a Warshall algoritmust: 1. A 𝑊 logikai értékekből álló mátrix (a Floyd-nál 𝐷-vel jelölt mátrix) kezdeti értéke legyen 𝑈[𝑖, 𝑗] = { ahol 𝑖, 𝑗 ∈ [1 … 𝑛], 𝑛 = |𝑉|.

↑ ↓

ha 𝑖 = 𝑗, vagy (i, j) ∈ E különben

2. A ciklusban végzett művelet pedig legyen a következő: 𝑊[𝑖, 𝑗] ∶= 𝑊[𝑖, 𝑗] ∨ (𝑊[𝑖, 𝑘] ∧ 𝑊[𝑘, 𝑗]) Az algoritmus helyességének a belátása hasonlóan történhet, mint a Floyd algoritmusnál. Természetesen a műveletigény is aszimptotikusan megegyezik a Floyd algoritmus műveletigényével, azzal a különbséggel, hogy a Floyd algoritmusnál, a ciklus belsejében konstans műveletnek tekintett összeadás és minimumválasztás helyett, most logikai műveleteket végzünk, ami hatékonyabb lehet.

27. MINIMÁLIS KÖLTSÉGŰ FESZÍTŐFÁK Az irányítás nélküli élsúlyozott gráfokon megfogalmazható feladat az, amely a csúcsokat összekötő minimális költségű fa (feszítőfa) megkeresését tűzi ki célul. Ez a feladat hasonlóan egyszerű és alapvetően fontos, mint az útkeresés, szintén komoly gyakorlati alkalmazásokkal a háttérben. A probléma megjelenése egy időszakban, a villamosítás éveiben elég gyakori volt. Ha egy terület villamosítását kell megoldani a lehető legkisebb költséggel, akkor a feladat minimális összköltségű vezetékrendszer tervezése megadott helységek között. A modellünk legyen irányítás nélküli, súlyozott gráf, ahol a városoknak megfeleltetjük a gráf pontjait, az éleknek pedig a tervezett, két várost összekötő villamos vezetéket. Az élek irányítás nélküliek az elektromos áram irányítatlan tulajdonsága miatt, és súlyozottak, ahol az élek költségei legyenek a becsült építési költségek. Az itt következő egyetlen nagy fejezet – elméleti fogalmak rövid áttekintés után – három nagyobb egységből áll. A fejezet felépítése fordított sorrendet követ, mint az elmélet fejlődése. A feladat két ismert megoldásának, Prim, illetve Kruskal algoritmusának ismertetését megelőzi a piros-kék eljárás leírása, amely időben utoljára született. Az előbb említett két megoldó algoritmus a probléma megoldására közvetlenül implementálható eljárásokat ad. Az általános és többszörösen nem-determinisztikus piros-kék algoritmus viszont inkább az elméleti megközelítés műfajába tartozik, ugyanis az eddig ismert megoldó eljárásokat mind magában foglalja lehetőségként. Az általános eljárás és a két megoldó algoritmus ismertetését rövid bevezető jellegű, elméleti előkészítés előzi meg. Definíciók: 

Részgráf: Legyen 𝐺 = (𝑉, 𝐸) irányítatlan gráf. A 𝐺′ = (𝑉′, 𝐸′) gráfot a 𝐺 részgráfjának nevezzük, ha 𝑉′ ⊆ 𝑉 és 𝐸′ ⊆ 𝐸, továbbá ∀ (𝑢, 𝑣) ∈ 𝐸 ′ : 𝑢, 𝑣 ∈ 𝑉 ′ .



Feszítőfa: Legyen 𝐺 = (𝑉, 𝐸) irányítatlan, összefüggő, véges gráf. A G egy körmentes, összefüggő 𝐹 = (𝑉, 𝐸′) részgráfját a 𝐺 egy feszítőfájának nevezzük. (A jelölésből látható, hogy 𝐹 és 𝐺 csúcsainak halmaza megegyezik.)



Minimális költségű feszítőfa: Legyen 𝐺 = (𝑉, 𝐸, 𝑐) irányítatlan, összefüggő, élsúlyozott, véges gráf a 𝑐: 𝑉 × 𝑉 → ℝ költségfüggvénnyel. Ekkor 𝐹 = (𝑉, 𝐸′) feszítőfa a 𝐺 egy minimális költségű feszítőfája, ha költsége 𝐶(𝐹) = ∑(𝑢,𝑣)∈𝐸′ 𝑐(𝑢, 𝑣) minimális a 𝐺 feszítőfái között, azaz 𝐶(𝐹) = min{𝐶(𝐻) | 𝐻 a 𝐺 feszítőfája}.

Feladat: Adott egy 𝐺 = (𝑉, 𝐸) irányítatlan, összefüggő, élsúlyozott, véges gráf. Határozzuk meg 𝐺 egy minimális költségű feszítőfáját. A továbbiakban tekintsünk néhány fákkal kapcsolatos tulajdonságot, amelyek a későbbi bizonyítások során hasznosak lehetnek. Állítás: Minden legalább kétpontú fában van elsőfokú csúcs. Bizonyítás: Tekintsük 𝑢 = 〈𝑣0 , 𝑣1 , … , 𝑣𝑘 〉 egyik leghosszabb utat a fában. Ha 𝑣0 -ból menne él egy olyan csúcsba, amely nem eleme {𝑣1 , … , 𝑣𝑘 } halmaznak, akkor 𝑢 nem lenne a leghosszabb út, ha 𝑣0 -ból menne él egy olyan csúcsba, amely eleme {𝑣1 , … , 𝑣𝑘 } halmaznak, akkor az útban lenne kör, tehát nem lenne fa. Így azt kaptuk, 𝑣0 elsőfokú csúcs.

Állítás: Minden összefüggő 𝐺 = (𝑉, 𝐸) gráfnak van feszítőfája. Bizonyítás: Ha a gráfban van kör, elhagyjuk az egyik élét. Ezt véges sokszor ismételve körmentes, összefüggő 𝑉 csúcshalmazú gráfot kapunk, tehát feszítőfát. Állítás: Egy 𝑛 pontú összefüggő gráf fa akkor, és csak akkor, ha 𝑛 − 1 éle van. Bizonyítás: ⟹: Ha egy 𝑛 pontú fából törlünk egy elsőfokú csúcsot és a hozzá tartozó élt, akkor egy 𝑛 − 1 pontú fát kapunk. Ezt ismételve, 𝑛 − 1-szer lehet elsőfokú csúcsot elhagyni a hozzá tartozó éllel együtt, mivel a végén már csak egyetlen csúcs marad, tehát az eredeti fának n-1 éle volt. ⟸: Legyen 𝐹 egy 𝑛 pontú, 𝑛 − 1 élű összefüggő gráf, továbbá legyen 𝐹′ egy feszítőfája 𝐹nek. Az előbb igazoltak szerint 𝐹′-nek is 𝑛 − 1 éle van, tehát 𝐹 = 𝐹′. Állítás: Egy fa bármely két pontja között pontosan egy út vezet. Bizonyítás: Indirekt tegyük fel, hogy 𝑢-ból 𝑣-be két út vezet, ekkor 𝑢-ból 𝑣-be elmegyek az egyik úton, majd visszajövök a másik úton, akkor legkésőbb 𝑢-ba jutva találok egy olyan csúcsot, amely eleme az első útnak, tehát kört találtam. Állítás: Legyen 𝐺 = (𝑉, 𝐸) gráfnak 𝐹 = (𝑉, 𝐸′) egy minimális költségű feszítőfája, továbbá, legyen 𝑒 = (𝑢, 𝑣) ∈ 𝐸 a 𝐺-nek egy olyan éle, ami nem éle 𝐹-nek (𝑒 ∉ 𝐸′). Tegyük fel, hogy az 𝐹-beli 𝑢-ból 𝑣-be vezető úton van olyan 𝑒′ él (𝑒′ ∉ 𝐸′), amelyre 𝑐(𝑒) ≤ 𝑐(𝑒′). 𝐹-ből az 𝑒 él hozzá vételével és az 𝑒′ elhagyásával kapott 𝐹′ gráf is minimális költségű feszítőfája 𝐺-nek. Bizonyítás: Vegyük hozzá 𝐹-hez 𝑒 élt, ekkor a kapott gráfban van olyan kör, amelynek 𝑒′ éle. Az 𝑒′ törlésével kapott gráf tehát összefüggő marad és éleinek a száma is ugyanannyi, mint F éleinek a száma, így 𝐹′ is feszítőfája 𝐺-nek. Továbbá 𝑐(𝐹′) ≤ 𝑐(𝐹), mivel 𝑐(𝑒) ≤ 𝑐(𝑒′), azaz egy nem nagyobb költségű éllel cseréltünk le egy élt. Szemléltessük az utolsó állítást a 27.1. ábrán. Legyenek 𝑢 = 2, 𝑣 = 3 csúcsok, továbbá 𝑒 = (2, 3), 𝑒′ = (1, 4) az állításban említett élek. Az állítás szerint, ha 𝑒′ él helyett 𝑒 élt vesszük fel a feszítőfa éle közé, akkor áttérünk a 𝐺-nek egy másik minimális költségű feszítőfájára.

27.1. ábra. Az élcsere hatása a feszítőfára

27.1. A piros-kék eljárás A fejezetben tárgyalt algoritmusok közös vonása, hogy valamilyen módszer szerint sorra veszik a gráf éleit, és egyes éleket bevesznek a kialakuló minimális költségű feszítőfába, másokat pedig nem. Ezen algoritmusok általánosításaként Robert E. Tarjan adott egy szép, nem determinisztikus eljárást, melyet piros-kék eljárásként emlegetnek. A szemléletes tárgyalás érdekében az éleket szokás beszínezni, innen származik a módszer neve is. A módszer kékre színezi a minimális költségű feszítőfába bekerülő élt, és pirosra színezi azokat az éleket, amelyek már biztosan nem kerülnek be a fába. Az élek színezése során két

szabályt fogunk alkalmazni a piros szabályt és a kék szabályt. A két szabályt tetszőleges sorrendben és tetszőleges helyen alkalmazhatjuk, akár véletlenített módon. A később ismertetésre kerülő algoritmusokat (Prim, Kruskal) tekinthetjük úgy is, mint a piros-kék eljárás egy-egy specializált változatait. Az algoritmus ismertetése előtt bevezetjük a szükséges definíciókat. Definíciók: 

Megfelelő színezés: Tekintsük a 𝐺 = (𝑉, 𝐸) irányítatlan, súlyozott véges gráf éleinek egy színezését, amelynél egy él lehet piros, kék vagy színtelen. Ez a színezés megfelelő, ha létezik 𝐺-nek olyan minimális költségű feszítőfája, ami az összes kék élt tartalmazza, de egyetlen piros élt sem tartalmaz.



Kék szabály: Válasszunk ki egy olyan 𝑋 ⊂ 𝑉, 𝑋 ≠ ∅ csúcshalmazt, amiből nem vezet ki kék él. Ezután egy legkisebb súlyú 𝑊-ből kimenő színtelen élt fessünk kékre. Tekintsük a 27.2. ábrán szereplő példán a kék szabály egy alkalmazását. Legyen 𝑋 = {1, 2, 3, 4} halmaz. Látható, hogy 𝑋-ből nem vezet ki kék él. Színezzük kékre az 𝑋-ből "kivezető" egyik legkisebb súlyú élt, amely most a 3-as súlyú (1, 6) él.

27.2. ábra. A kék szabály alkalmazása



Piros szabály: Válasszunk 𝐺-ben egy olyan egyszerű kört, amiben nincs piros él. A kör egyik legnagyobb súlyú színtelen élét színezzük pirosra. A szabály egy alkalmazását illusztráljuk a 27.3. ábrán. A szabályban említett kör legyen 〈1, 2, 3, 4〉, amely nem tartalmaz piros élt. Keressük meg a kör egyik legnagyobb súlyú élét, amely az 5-ös súlyú (2, 4) él. Színezzük pirosra.

27.3. ábra. A piros szabály alkalmazása

A fenti szabályok ismeretében a piros-kék eljárás könnyen megfogalmazható. Legyen kezdetben a 𝐺 = (𝑉, 𝐸) irányítatlan, súlyozott, összefüggő, véges gráf minden éle színtelen. Alkalmazzunk a két szabályt tetszőleges sorrendben és helyen, amíg csak lehetséges. Állítás: Legyen 𝐺 = (𝑉, 𝐸) irányítatlan, súlyozott, összefüggő, véges gráf, és 𝑛 = |𝑉|. Ekkor 1. a piros-kék eljárás során a színezés mindig megfelelő marad; 2. a színezéssel sosem akadunk el, ameddig 𝐺 minden éle színes nem lesz;

3. ha beszíneztük 𝐺 minden élét, akkor a kék élek 𝐺 egy minimális költségű feszítőfájának éleit adják, sőt már 𝑛 − 1 kékre színezett él után is megkaptuk az említett feszítőfát. Bizonyítás: 

Teljes indukcióval lássuk be az állítást. Kezdetben, amikor minden él színtelen nyilván teljesül a megfelelő színezés. Továbbiakban tegyük fel, hogy egy olyan állapotban vagyunk, amelyre teljesül a megfelelő színezés. Legyen 𝐹 a 𝐺 egy olyan minimális költség feszítőfája, amely az összes, jelenleg kékre színezett élt tartalmazza, és egyetlen, jelenleg pirosra színezett élt sem tartalmaz. Tegyük fel, hogy az eljárás következő lépése során az 𝑒 = (𝑢, 𝑣) ∈ 𝐸 élt színeztük be. Két eset lehet attól függően, hogy melyik szabályt alkalmaztuk. 

A kék szabályt alkalmaztuk: ekkor nyilván 𝑒 színe kék lett. a)

Ha 𝑒 éle 𝐹-nek, akkor 𝐹 mutatja, hogy megfelelő a színezés.

b)

Ha 𝑒 nem éle 𝐹-nek, akkor tekintsük az 𝑋 ⊂ 𝑉 halmazt, amire a kék szabályt alkalmaztuk. Az 𝐹-ben ∃ 𝑢 ↝ 𝑣 út, hiszen 𝐹 feszítőfa, továbbá ezen az úton van olyan 𝑒′ él, ami kimegy 𝑋-ből. (Ugyanis 𝑒-t színeztük kékre, tehát a kék szabály értelmében 𝑒 egyik vége 𝑋-en belül, a másik vége 𝑋-en kívül van. Továbbá az említett 𝑢 ↝ 𝑣 𝐹-beli út, egy 𝑋-beli és egy 𝑋-en kívüli pontot köt össze, tehát valahol ki kell lépnie 𝑋-ből. Lásd 27.4. ábra.)

27.4. ábra. Gráf, amelyben a kijelölt él nem része a feszítőfának (a feszítőfa színesen jelölve)

Vizsgáljuk, milyen lehet 𝑒′ színe. Piros nem lehet, mivel része 𝐹-nek, kék sem lehet, mivel a kék szabályt alkalmaztuk, amely szerint 𝑋-nek olyannak kell lennie, amiből nem vezet ki kék él. Tehát 𝑒′ színtelen. Továbbá 𝑐(𝑒) ≤ 𝑐(𝑒′), mivel a kék szabály szerint az 𝑋-ből kimenő egyik legkisebb súlyú élt kell választani, és mi 𝑒-t választottuk. Alkalmazhatjuk a korábbi állítást, mely szerint 𝐹-ből 𝑒′ törlésével és 𝑒 hozzá vételével kapott új 𝐹 ′ gráf is a 𝐺 egy minimális költségű feszítőfája. Tehát 𝐹 ′ igazolja, hogy 𝑒 kékre színezésével a színezés továbbra is megfelelő marad (27.5. ábra).

27.5. ábra. Egy élcserével kialakított új feszítőfa (színesen jelölve)



A piros szabályt alkalmaztuk: ekkor nyilván 𝑒 színe piros lett. a)

Ha 𝑒 nem éle 𝐹-nek, akkor 𝐹 mutatja, hogy megfelelő a színezés.

b)

Ha 𝑒 éle 𝐹-nek, akkor a pirosra színezés azt jelenti, hogy 𝑒 továbbiakban már nem lehet éle az eljárás során előállítás alatt lévő minimális feszítőfának, tehát a megfelelő színezés bizonyításához át kell térni egy másik minimális feszítőfára. Az 𝑒 𝐹-ből való törlésével 𝐹 két komponensre esik szét. Tekintsük azt a kört, amelyre a piros szabályt alkalmaztuk, ennek van olyan 𝑒′ éle, amelyik a két komponenst összeköti és nem éle 𝐹-nek. (Ugyanis a két komponenst összekötő e-től különböző élnek lennie kell, mivel kör mentén vizsgálódunk, és egy körbeli él elhagyásával az összefüggőség nem szűnhet meg. Továbbá, ha nem lenne ilyen 𝑒′ él, ami nem éle 𝐹-nek, az azt jelenti, hogy a kör minden éle 𝐹 éle is, tehát kör lenne a fában. Lásd 27.6. ábra.)

27.6. ábra. Egy él elhagyásával keletkező két feszítőfa (színesen jelölve)

Vizsgáljuk, milyen lehet 𝑒′ színe. Nem lehet kék, mivel nem éle 𝐹-nek és feltettük, hogy a színezés megfelelő, amit 𝐹 mutat. Nem lehet piros, mivel a piros szabály értelmében, olyan kört kell választani, amiben nincs piros él. Tehát 𝑒′ színtelen. Továbbá 𝑐(𝑒′) ≤ 𝑐(𝑒), mivel a piros szabály alkalmazása során e-t választottuk színezésre, amely szerint a kör egyik legnagyobb súlyú élét kell pirosra színezni. Az 𝑒′ végpontjait összekötő 𝐹-beli út tartalmazza 𝑒 élt. (Ugyanis e törlése előtt 𝐹 feszítőfa volt, és a korábbi állítás szerint, bármely két pontja között pontosan egy út vezet. Azonban most két olyan részre esett szét, amelynek egyik komponensében van 𝑒′ egyik vége, a másik komponensében 𝑒 ′ másik

vége. 𝐹-ben a két komponens között az átjárást éppen az 𝑒 él biztosította, tehát az említett útnak át kell haladnia az 𝑒 élen. Lásd 27.7. ábra.)

27.7. ábra. Az él két végpontját összekötő út (zölddel jelölve)

Alkalmazhatjuk a korábban belátott állítást, mely szerint 𝐹-ből 𝑒 törlésével és 𝑒′ hozzá vételével kapott új 𝐹′ gráf is 𝐺 egy minimális költségű feszítőfája. Tehát 𝐹′ igazolja, hogy 𝑒 pirosra színezésével a színezés továbbra is megfelelő marad (27.8. ábra).

27.8. ábra. A kijelölt él pirosra színezésével kialakult új feszítőfa



Most belátjuk, hogy a színezéssel sosem akadunk el, ameddig 𝐺 minden éle színes nem lesz. Tegyük fel, hogy 𝐺-nek még nem minden éle színes. Legyen 𝑒 egy színtelen él. A színezés megfelelősége miatt a kék élek egy erdőt alkotnak (de lehet, hogy már egy fát, ekkor az alábbi 1. eset alkalmazható), az erdő fáit nevezzük kék fáknak. Két eset lehetséges: 

Ha 𝑒 két végpontja ugyanabban a kék fában van (27.9. ábra). Ekkor a piros szabályt alkalmazhatjuk arra a körre, aminek az éleit úgy kapjuk, hogy az e két végpontját összekötő egyetlen kék úthoz hozzávesszük 𝑒-t.

27.9.a ábra. Amennyiben az él két végpontja ugyanabban a fában van, a piros szabályt alkalmazzuk (zölddel jelölve a kialakult kör)



Ha 𝑒 két végpontja különböző kék fában van. Ekkor a kék szabály alkalmazható a következőképpen. 𝑋 legyen az egyik olyan kék fa csúcsainak halmaza, amelyikben benne van 𝑒 egyik vége. Ebből a kék fából, azaz 𝑋-ből biztosan megy ki él (legalább 𝑒), e kimenő élek közül az egyik legkisebb súlyú (nem biztos, hogy 𝑒) kékre színezhető.

27.9.b ábra. Amennyiben az él két végpontja más fában van, a kék szabályt alkalmazzuk



A harmadik állítás szerint, végül megkapjuk 𝐺 egy minimális költségű feszítőfáját. Ez rögtön következik abból, hogy a végső színezés is megfelelő. Az állítás második része szerint, az eljárást elegendő addig folytatni, míg 𝑛 − 1 kék él nem lesz. A korábbi állítás szerint, a feszítőfának összesen 𝑛 − 1 éle van, tehát ha már van 𝑛 − 1 kék élünk, akkor a továbbiakban több nem is keletkezhet.

Tehát a piros és kék szabályt tetszőleges helyen és sorrendben alkalmazva, végül minimális költségű feszítőfát kapunk, azonban hatékonysági szempontból megfontolandó melyik szabályt mikor és hol alkalmazzuk. A következő algoritmusokat a piros-kék eljárás egy-egy speciális esetének is tekinthetjük. 27.2. A Prim-algoritmus A Prim algoritmus minden lépésben a kék szabályt alkalmazza egy s kezdőcsúcsból kiindulva. Az algoritmus működése során egyetlen kék fát tartunk nyilván, amely folyamatosan növekszik, míg végül minimális költségű feszítőfa nem lesz.

Kezdetben a kék fa egyetlen csúcsból áll, a kezdőcsúcsból, majd minden lépés során, a kék fát tekintve a kék szabályban szereplő 𝑋 halmaznak, megkeressük az egyik legkisebb súlyú élt (mohó stratégia), amelynek egyik vége eleme a kék fának (𝑋-ben van), a másik vége viszont nem (azaz nem eleme 𝑋-nek). Az említett élt hozzá vesszük a kék fához, azaz az élt kékre színezzük, és az él 𝑋-en kívüli csúcsát hozzávesszük az 𝑋-hez. 27.2.1. Az absztrakt szintű algoritmus Az algoritmus megvalósításának a kulcsa az 𝑋-ből kimenő egyik legkisebb súlyú él meghatározása. Ehhez használjunk egy minimum választó elsőbbségi (prioritásos) sort (𝑚𝑖𝑛𝑄), amelyben a fához még nem tartozó (még nem eleme 𝑋-nek) csúcsokat tároljuk az 𝑋-től való távolsággal, mint kulcs értékkel. A távolság elnevezéséből adódóan és a korábbi algoritmusokhoz hasonlóan, jelöljük a kulcsot egy 𝑣 ∈ 𝑉 csúcs esetén 𝑑[𝑣]-vel. Egy 𝑣 ∈ 𝑉 csúcs esetén az 𝑋-től való távolság, azaz a 𝑑[𝑣] legyen azon élek közül a minimális súlyú él súlya, amely v és egy 𝑋-beli csúcs között halad. Amennyiben nem létezik él v és egy tetszőleges 𝑋-beli csúcs között, legyen 𝑑[𝑣] = ∞. A korábbi algoritmusokhoz hasonlóan, a 𝑃[1 … 𝑛] tömbbe tároljuk el egy csúcs feszítőfabeli megelőzőjét (szülőjét), amelynek segítségével bejárható a fa. Az algoritmus elvénél, azt mondtuk, hogy kezdetben a kék fa legyen egyetlen pont, a kezdőcsúcs. Most az 𝑋-től való távolság fogalmának bevezetésével, azt mondhatjuk, hogy kezdetben 𝑋 legyen az üres halmaz, amelytől a kezdőcsúcs nulla távolságra van, az összes többi csúcs pedig végtelen távolságra. Az algoritmus leírásában az 𝑋 halmazt explicite nem ábrázoljuk, hanem 𝑋 = 𝑉 \ 𝑚𝑖𝑛𝑄. Az algoritmus minden lépésében kivesszük a 𝑚𝑖𝑛𝑄 (egyik) legkisebb kulcsú elemét (az 𝑋ből kimenő egyik legkisebb súlyú él 𝑋-en kívüli csúcsát), azaz a készülő feszítőfához, 𝑋-hez hozzávesszük az illető csúcsot. Majd az 𝑋-en kívüli csúcsok 𝑋-től való távolságát, mint invariáns tulajdonságot karban kell tartani. Nyilván elegendő az 𝑋-be újonnan bekerült csúcs szomszédjainak az 𝑋-től való távolságát módosítani (ha szükséges), mivel egy 𝑣 csúcs úgy kerülhet közelebb 𝑋-hez, hogy valamelyik u szomszédja bekerül az 𝑋-be. Ekkor 𝑣 távolsága a következőképpen alakul: 

ha 𝑑[𝑣] = ∞, akkor most legyen 𝑑[𝑣] = 𝑐(𝑢, 𝑣);



ha 𝑑[𝑣] < ∞, akkor már létezik 𝑣-nek olyan 𝑤 szomszédja, amely eleme 𝑋-nek tehát 𝑑[𝑣] akkor változik, ha az (𝑢, 𝑣) élen keresztül 𝑣 közelebb van 𝑋-hez, mint (𝑣, 𝑤) él esetén.

Eközben a 𝑃[1 … 𝑛] szülőségi tömböt is karban kell tartani. Tehát a használt típusok és adatszerkezetek: 

𝑃[1 … 𝑛] tömb: egy csúcs feszítőfabeli szülőcsúcsának a tárolására.



𝑚𝑖𝑛𝑄: (𝑑[𝑣], 𝑣) párokból álló minimumválasztó elsőbbségi sor, ahol 𝑑[𝑣] értéke a kulcs.

Az algoritmus ADS szintű megvalósítása látható a 27.10. ábrán.

27.10. ábra. A Prim algoritmus és műveletigénye

27.2.2. Az algoritmus szemléltetése Nézzük meg egy példán, ADS szinten az algoritmus működését a 27.10. ábrán. A szemléltetés érdekében színezzük a csúcsokat a következőképpen: 

Fehér: a csúcs eleme a 𝑚𝑖𝑛𝑄-nak és nincs 𝑋-beli szomszédja, azaz még nem került "látótávolságba", tehát az 𝑋-től való távolsága végtelen.



Szürke: a csúcs eleme a 𝑚𝑖𝑛𝑄-nak, de létezik 𝑋-beli szomszédja, tehát a távolsága már kisebb, mint végtelen.



Fekete: a csúcs kikerült a 𝑚𝑖𝑛𝑄-ból, azaz bekerült 𝑋-be.

A példában a kezdőcsúcs legyen az 1-es csúcs. Az inicializáló lépés után az 1-es csúcs kivételével minden csúcs távolsága (az 𝑋 halmaztól) legyen végtelen, az 1-es csúcs távolsága pedig legyen 0, az 𝑋 legyen az üres halmaz.

27.11. ábra. A Prim algoritmus lépésenkénti végrehajtása

Az első lépésben kivesszük a 𝑚𝑖𝑛𝑄-ból az 1-es csúcsot (mivel az 1-es csúcs távolsága a legkisebb az 𝑋-től), tehát 𝑋 = {1}, majd az 1-es csúcs szomszédai (2 és 3) kerülnek közelebb az 𝑋-hez. Ezek távolsága 𝑑[2] = 2 és 𝑑[3] = 3. A második lépésben a 2-es csúcs kerül be az 𝑋 halmazba (feljegyezve az 1-es csúcsot, mint fabeli szülőt), mivel közelebb van 𝑋-hez, mint a 3-as csúcs. Ezután a 2-es szomszédai kerülnek "látótávolságba". Megfigyelhető, hogy a 3-as csúcs 𝑑[3] = 3 távolságra volt az 𝑋-től, de most közelebb került 𝑑[2] = 2, a (2, 3) él figyelembe vételével, 𝑋 = {1,2}. A harmadik lépésben az 𝑋 = {1, 2} halmazhoz a legközelebb lévő csúcs (𝑑[3] = 2, 𝑑[4] = 8, 𝑑[5] = 1), az 5-ös csúcs kerül az 𝑋 halmazba (feljegyezve szülőként a 2-es csúcsot). Az 5ös (még 𝑋-hez nem tartozó) szomszédai, a 4-es és 6-os csúcsok kerülnek közelebb az 𝑋-hez. A negyedik lépésben a nem fekete csúcsok közül a legkisebb távolságú, a 4-es csúcs kerül az 𝑋-be. 𝑋 = {1, 2, 4, 5}. A 4-es szomszédai, a "4-esen keresztül" már nem kerülnek 𝑋-hez közelebb. Az ötödik lépésben a 3-as csúcs kerül az 𝑋-be. A 3-asnak már nincs is nem fekete (nem 𝑋beli) szomszédja. Végül az utolsó lépésben a 6-os csúcs kerül az 𝑋 halmazba. A menetközben feljegyzett szülőcsúcsok segítségével meghatározható a feszítőfa (27.12. ábra).

27.12. ábra. A Prim algoritmus futtatása által kialakított feszítőfa

27.2.3. Az algoritmus a reprezentáció szintjén Vizsgáljuk meg a prioritásos sor (𝑚𝑖𝑛𝑄) megvalósításának két, természetes módon adódó lehetőségét, ahogy a Dijkstra algoritmusnál is már láttuk: 1. A prioritásos sort valósítsuk meg rendezetlen tömbbel, azaz a prioritásos sor legyen maga a 𝑑[1 … 𝑛] tömb. Ekkor a minimum kiválasztására egy feltételes minimum keresést kell alkalmazni, amelynek a műveletigénye Θ(𝑛). A Feltölt(𝑚𝑖𝑛𝑄) és a Helyreállít(𝑚𝑖𝑛𝑄) absztrakt műveletek megvalósítása pedig egy SKIP-pel történik. Az algoritmus ADT leírásában az szerepel, hogy a 𝑚𝑖𝑛𝑄-ból kiveszünk egy elemet, azonban a 𝑚𝑖𝑛𝑄-t egy tömbbel valósítjuk meg, amelynek a mérete nem változik. Tehát osztályozni kell a csúcsokat aszerint, hogy a 𝑚𝑖𝑛𝑄-ban vannak-e még, vagy már bekerültek az 𝑋 halmazba. Legyen egy 𝑋[1 … 𝑛] tömb az alábbi módon definiálva: 0 ha 𝑖 ∉ 𝑋 𝑋(𝑖) = { 1 ha 𝑖 ∈ 𝑋 Az 𝑋 tömböt kezdetben ki kell nullázni, majd menet közben karban kell tartani. Amint kikerül egy csúcs a 𝑚𝑖𝑛𝑄-ból, az 𝑋 tömbben a csúcsnak megfelelő helyre 1-est kell írni. 2. Kupac adatszerkezet használatával is reprezentálhatjuk a prioritásos sort. Ekkor a Feltölt(𝑚𝑖𝑛𝑄) eljárás, egy kezdeti kupacot épít, amelynek a műveletigénye lineáris. Azonban most a 𝑑[1 … 𝑛] tömb változása esetén a kupacot is karban kell tartani, mivel a kulcs érték változik. Ezt a Helyreállít(𝑚𝑖𝑛𝑄) eljárás teszi meg, amely a csúcsot a gyökér felé "szivárogtatja" fel, ha szükséges (mivel a kulcs értékek csak csökkenhetnek). Ennek a műveletigénye log 𝑛-es. Ennél az ábrázolásnál is vezessünk be egy segédtömböt, a 𝐻𝑂𝐿[1 … 𝑛] tömböt, amely megmutatja, hogy egy csúcs hol helyezkedik el a kupacban (a kupacot [1 … 2𝑛] tömbben valósítsuk meg), illetve legyen 0, ha az illető csúcs már nem eleme a minQnak. A 𝐻𝑂𝐿 tömb felhasználásával egy csúcs prioritásos sorban való keresésének műveletigényét konstansra csökkenthetjük. A 𝐻𝑂𝐿 tömböt a 𝑚𝑖𝑛𝑄 változásakor szintén karban kell tartani. Megjegyzés: Nem szükséges kezdeti kupacot építeni, felesleges a kupacba rakni a végtelen távolságú elemeket. Kezdetben csak a kezdőcsúcs legyen a kupacban, majd amikor először „elérünk” egy csúcsot és a távolsága már nem végtelen, elég akkor berakni a kupacba. 27.2.4. Műveletigény A prioritásos sor fenti két megvalósítása esetén, a 27.10. ábrán láthatóan megfelelő módon alakul az algoritmus műveletigénye. A belső ciklust célszerű globálisan kezelni, ekkor mondható, hogy összesen legfeljebb annyiszor fut le, ahány éle van a gráfnak. 1. Rendezetlen tömb esetén: 𝑇(𝑛) = Ο(1 + 𝑛 − 1 + 1 + 0 + 𝑛2 + 𝑛 + 𝑒) = Ο(𝑛2 + 𝑒) = Ο(𝑛2 ) 2. Kupac esetén: 𝑇(𝑛) = Ο(1 + 𝑛 − 1 + 1 + 𝑛 + 𝑛 ∙ log 𝑛 + 𝑛 + 𝑒 ∙ log 𝑛) = Ο((𝑛 + 𝑒) ∙ log 𝑛) A Dijkstra algoritmusnál már említett következmény itt is érvényes, azaz sűrű gráf esetén csúcsmátrix és rendezetlen tömb, ritka gráf esetén éllista és kupac.

27.3. A Kruskal-algoritmus A Kruskal algoritmus mindkét szabályt alkalmazza a feszítőfa létrehozására, itt azonban egyértelmű él kiválasztási stratégiát alkalmazunk, amely meghatározza, mely szabályt kell alkalmaznunk. Kezdetben legyen n db kék fa, azaz a gráf minden csúcsa egy-egy (egy pontból álló) kék fa, és legyen minden él színtelen. Minden lépés során kiválasztjuk az egyik legkisebb súlyú színtelen élt. Ha a kiválasztott él két végpontja különböző kék fában van, akkor színezzük kékre, különben (az él két vége azonos kék fában van, tehát a kék fa éleivel kört alkot) színezzük pirosra. A fentiekből kitűnik, hogy a Kruskal algoritmust is tekinthetjük a piros-kék eljárás egy speciális esetének, ahol az élek színezésének a sorrendje egyfajta mohó stratégia szerint történik ("még mohóbb", mint a Prim algoritmusnál). Ugyanis 

Amikor egy 𝑒 élt pirosra színezünk, akkor arra az egyszerű körre alkalmazható a piros szabály, amelynek élei az 𝑒, és az 𝑒 két végpontját összekötő kék út élei. Ez egy egyszerű kör, mivel pontosan egy 𝑒 végpontjait összekötő kék út létezik, továbbá az 𝑒 kivételével, minden éle kék, tehát 𝑒 színezése előtt nem tartalmazott piros élt. Így teljesülnek a piros szabály feltételei.



Amikor egy 𝑒 élt kékre színezünk, akkor e két kék fát köt össze, 𝐹1 -et és 𝐹2 -őt. A kék fák definíciójából következik, hogy 𝐹1 csúcsainak halmazából nem vezet ki kék él. Legyen 𝑋 = {𝐹1 csúcsainak a halmaza}, ekkor az 𝑒 él lesz az egyik legkisebb súlyú Xből kimenő színtelen él, mivel 𝑒 az egyik legkisebb súlyú (nem csak 𝑋-ből kimenő) színtelen él. Tehát teljesülnek a kék szabály feltételei.

27.3.1. Az absztrakt szintű algoritmus Az algoritmus absztrakt szintjén, diszjunkt halmazokkal való műveleteket fogunk végezni. Tekintsük a kék fák csúcsainak (diszjunkt) halmazait (ezek a halmazok osztályozzák 𝑉-t). Amikor az egyik legkisebb súlyú színtelen élt kiválasztjuk, el kell dönteni, hogy a két végpontja azonos vagy különböző halmazban vannak-e. Majd a választól függően: 

Ha azonos halmazban vannak, akkor a kiválasztott élt színezzük pirosra.



Ha különböző halmazban vannak, akkor a kiválasztott élt színezzük kékre, és a két különböző halmazt vonjuk össze, azaz a két halmaz helyett legyen egy halmaz, amely megegyezik a két halmaz uniójával.

Az algoritmust akkor áll le, ha már nincs színtelen él (leállhatna már akkor is, ha az előbb következne be, hogy beszínezett 𝑛 − 1 db kék élt). Mivel véges sok élünk van, és minden lépésben beszínezünk egyet, így |𝐸| lépés után az algoritmus biztosan befejezi a működését. Az algoritmusban a következő absztrakt műveleteket szeretnénk használni: 

HalmazokatKészít(𝐺): Elkészíti a kezdeti 𝑛 db, pontosan egy csúcsot tartalmazó diszjunkt halmazokat.



Összevon(𝑒): Az 𝑒 él két végpontja által reprezentált halmazokat összevonja.



𝑠𝑧í𝑛[𝑒] ∶= …: Az 𝑒 él színét változtatja meg az értékadás jobb oldalán szereplő színre.



VanMégSzíntelenÉl(𝐺): igazat ad vissza, ha 𝐺-ben még van színtelen él, egyébként hamisat.



VégükAzonosHalmazban(𝑒): igazat ad vissza, ha 𝑒 két végpontja azonos halmazban van, egyébként hamisat.



LegkisebbSzíntelenÉl(𝐺): Visszaadja a legkisebb súlyú színtelen élt.

Az algoritmus megvalósítása a 27.13. ábrán látható.

27.13. ábra. A Kruskal algoritmus

27.3.2. Az algoritmus szemléltetése A következő példában a csúcsok osztályokhoz (halmazokhoz/kék fához) való tartozását színezéssel illetve címkézéssel oldottuk meg. Az azonos színű csúcsok, azonos osztályba tartoznak. Tudjuk, hogy az osztályok reprezentálhatók egy-egy elemükkel, ezért az ábrán (a csúcs címkéje alatt), feltüntettük azon osztály egy reprezentáló elemének a címkéjét, amelyhez az illető csúcs tartozik. Tehát azok a csúcsok tartoznak egy osztályba (azonos kék fához), amelyeknél a címkéjük alatt megjelenő, méretét tekintve kisebb szám azonos. Az inicializáló lépés után, minden él színtelen és minden csúcs külön osztályt alkot (27.13. ábra). Az első lépésben kiválasztjuk az egyik legkisebb súlyú élt, legyen ez (1,2), és az 1-es ill. 2-es csúcsokat tartalmazó (egyelemű) halmazokat összevonjuk egyetlen 𝐻 = {1, 2} halmazzá. Az új halmaz reprezentáns eleme legyen az 1-es csúcs.

27.14. ábra. A Kruskal algoritmus inicializálása és első lépése

A következő lépésben, az első lépéshez hasonlóan járunk el az 5-ös és 6-os csúcsokkal. A harmadik lépésben, még mindig egyelemű halmazokat vonunk össze, most a 7-es és 8-as

csúcsok osztályait. A negyedik lépésben a kiválasztásra kerülő (8, 9) él, még mindig két különböző kék fát köt össze, így kékre kell színezni és a 𝐻1 = {7, 8} és 𝐻2 = {9} halmazokat össze kell vonni a 𝐻 = {7, 8, 9} halmazzá. Az ötödik lépésben már nincs 1-es súlyú él. A következő egyik legkisebb súlyú él, valamelyik 2-es súlyú él lesz. Mi most válasszuk az (1, 3) élt, amelyet kékre színezünk, és a végpontjainak megfelelő halmazokat összevonjuk. Eddig csak a kék szabályt alkalmaztuk, ahogy ez a 27.14. ábrán látható. A hatodik lépésben kiválasztott (2, 3) él két végpontja azonos kék fához tartozik, ezért színezzük pirosra (27.15. ábra). A hetedik lépésben ismét a piros szabályt alkalmazzuk, most a 〈7, 8, 9〉 körre, amelynek következtében a (7,9) él piros lesz. A nyolcadik lépésben a (2, 4) élt választjuk ki, és a kék szabályt alkalmazhatjuk az 𝑋 = {1, 2, 3} halmazra (27.16. ábra). Tehát a (2, 4)-es élt kékre színezzük, aminek következtében azonos kék fába kerülnek az {1, 2, 3, 4}-es csúcsok. A Kruskal algoritmusnak megfelelően, a kék fák nyilvántartására, vonjuk össze őket egy halmazba. A kilencedik lépésben mindenképpen az (5, 7) élt kell választanunk, mert ez az egyetlen 3-as súlyú színtelen él. Az élt színezzük kékre, és a 𝐻1 = {5, 6}, 𝐻2 = {7, 8, 9} halmazokat vonjuk össze. A tízedik lépésben az egyetlen 4-es súlyú színtelen él kerül kiválasztásra, amelynek két végpontja azonos osztályba esik, ezért pirosra színezzük. A tizenegyedik lépésben a (2,5) él a legkisebb súlyú színtelen él. Mivel a 2-es és 5-ös csúcsok különböző osztályokhoz tartoznak, így az élt színezzük kékre, és a 𝐻1 = {1, 2, 3, 4}, 𝐻2 = {5,6,7,8,9} halmazokat vonjuk össze! A halmazok összevonása után már csak egy H={1,2,3,4,5,6,7,8,9} osztályunk (kék fánk) maradt. A továbbiakban már nem alkalmazhatjuk a kék szabályt, azaz megkaptunk egy minimális költségű feszítőfát, amelynek élei: (1, 3), (1, 2), (2, 4), (2, 5), (5, 6), (5, 7), (7, 8), (8, 9)

27.15. ábra. A kék szabály alkalmazásai

Az ADT szintű leírás szerint még maradt egy lépés, mivel még van egy színtelen él (4, 6). Természetesen ezt az élt már csak pirosra színezhetjük (27.17. ábra). 27.3.3. Műveletigény Az ábrázolás szintjén nem tárgyaljuk az algoritmust. Jobban szemügyre véve a Kruskal algoritmust, a műveletigénye a diszjunkt halmazok megvalósításától függ. Amennyiben az éleket egy kupac adatszerkezetben tároljuk az élsúlyokkal, mint kulccsal, egy él kivétele 𝛰(log 𝑒), 𝑒 él kivétele 𝛰(𝑒 log 𝑒). Tehát jó lenne olyan ábrázolást választani a diszjunkt halmazoknak, hogy a teljes algoritmus műveletigénye 𝛰(𝑒 log 𝑒) maradjon. Ilyen reprezentáció létezik; ez az UNIÓ-HOLVAN adatszerkezet (nem tárgyaljuk).

27.16. ábra. A feszítőfa kialakításának további lépései

27.17. ábra. A Kruskal algoritmus záró lépése

28. MÉLYSÉGI BEJÁRÁS A gráfok két alapvető bejárási módja közül a mélységi bejárás ismertetése következik. A másik stratégiát, a szélességi bejárást a 23. fejezetben láttuk. Ott idéztük a Rónyai-Ivanyos-Szabó szerzőhármas Algoritmusok című könyvében olvasható szemléletes leírást a bejárási stratégiákról, illetve csak az egyikről: arról, amelyik a szélességi bejárást illusztrálja. Most idézzük a könyvből azt a módszert, amely „mélységében” igyekszik a városka lámpáit elérni. Az irodalmi párhuzam után természetesen szabatosan megfogalmazzuk gráfokra is a feladatot, megadjuk és elemezzük annak megoldó algoritmusát. 28.1. A mélységi bejárás stratégiája „Az öreg városka girbe-gurba utcáin bolyongó kopott emlékezetű lámpagyújtogató” másik eljárása a köztéri lámpák meggyújtására a mélységi bejárás elvét követei. Megint csak szabadon és tömören idézve a könyv szövegét, a lámpagyújtogató az első, majd a további lámpák felgyújtása után mindig egy olyan utcán indul tovább, amelyikben még nem lát fényt, és elérve a következő sarokhoz, meggyújtja az ott elhelyezett lámpát. Ha egy lámpa alól körülnézve már minden onnan kiinduló utcából fényt lát, akkor visszamegy arra, ahonnan ide érkezett, és onnan egy még meg nem gyújtott lámpa irányába indul. Ha ilyet nem lát, akkor innen is visszamegy a megelőző sarokra. Egy idő után visszaért eredeti kiinduló helyére, és ekkor már minden innen elérhető köztéri lámpa világít. A leírásból – a stratégia mellett – kiemelünk három további jellemzőt. Míg az másik lámpagyújtogatási módszer végrehajtásában segítségére volta barátai, addig ezt az eljárást egyedül végezhette. A „kopott emlékezetének” itt valóban szerep jut, hiszen csak annyit kell megjegyeznie, hogy egy lámpához melyik utcán érkezett. A szöveg utal arra is, hogy a tevékenységét bárhol elkezdheti, nincs olyan meghatározott kiinduló pont, mint a barátokkal együttműködve a főtér volt. Ha fentről néznénk a várost, ahogy kigyulladnak a lámpák, ezúttal azt látnánk, hogy egy pontból egy útvonal mentén terjed a világosság. Azután ez egy egyenletes ütemű terjedés megáll, és a visszasétálás idejét kivárva, világító lámpák útvonalának egy pontjából újra elindul a lámpagyújtási tevékenység egy útvonalon. Ez mélységi bejárásnak a szemléletes alapelve, amelyet a fejezet végén elhelyezett 28.11. ábra illusztrál egy (csaknem kisvárosi kiterjedésű) gráfon, néhány pillanatfelvétel formájában. 28.2. A mélységi bejárás szemléltetése A mélységi bejárás kidolgozása felé lépve, megkülönböztetjük a csúcsok státuszát. Erre több, szemléletében különböző módszerrel találkozhatunk, amelyek végső ugyanazt a célt érik el. Itt a színezés eszközével élünk és három színt használunk. Attól függően színezzük a csúcsokat, hogy az illető csúcsot illetően a bejárás milyen fázisban van.   

Egy csúcs legyen fehér, ha még nem jutottunk el hozzá a bejárás során (kezdetben minden csúcs fehér). Egy csúcs legyen szürke, ha a bejárás során már elértük a csúcsot, de még nem állíthatjuk, hogy az illető csúcsból elérhető összes csúcsot meglátogattuk. A csúcs legyen fekete, ha azt mondhatjuk, hogy az illető csúcsból elérhető összes csúcsot már meglátogattuk és visszamehetünk (vagy már visszamentünk) az idevezető út megelőző csúcsára

A bejárás során tároljuk el, hogy egy csúcsot hányadikként értünk el, azaz hányadikként lett szürke és tároljuk el azt is, hogy hányadikként fejeztük be a csúcs, és a belőle elérhető csúcsok bejárását, azaz a csúcs hányadikként lett fekete. Az említett számokat nevezzük mélységi, illetve befejezési számnak, amelyeket az ábrákon a csúcsok címkéi alatt jelenítünk meg. (Alternatív szóhasználat: belépési, illetve kilépési számok.) Utalunk arra, hogy ezeknek a számoknak lényeges szerep jut majd az élek osztályozásánál. A 28.1-4. ábrákon egy gráf mélységi bejárását követhetjük végig. (Az ábrasort egyben, szöveges megszakítás nélkül találjuk meg alább.) A példában szereplő gráfon a csúcsokból kimenő élek feldolgozási sorrendje legyen a rákövetkező csúcsok címkéje szerint növekedően, vagyis alfabetikusan rendezett (például a láncolt ábrázolásnál az éllista a csúcsok címkéje szerint rendezett). Nézzük a 28.1. ábrát, amelyben a kezdőcsúcs legyen az 1-es csúcs. Legyen kezdetben minden csúcs fehér, és a mélységi és befejezési számuk is legyen az extremális 0. A kezdőcsúcsot érjük el elsőként, tehát színezzük szürkére, és a mélységi számát állítsuk be 1-re.

28.1. ábra. A mélységi bejárás végrehajtása az első visszalépésig

28.2. ábra. A további csúcsok feltérképezése a bejárás során

28.3. ábra. A visszalépések végrehajtása a kiinduló csúcsig

28.4. ábra. A mélységi bejárás végső lépései

Az 1-es csúcsból három él vezet ki, de a kikötöttük, hogy az élek feldolgozási sorrendje legyen a szomszéd csúcsok címkéje szerint növekedően rendezett. Ekkor a 2-es csúcsot érjük el másodikként. Ezután harmadikként a 4-es csúcs, majd negyedikként a 8-as csúcs következik. Mivel a 8-as csúcsnak egyáltalán nincs szomszédja, bejárását befejeztük és a csúcsot feketére színezzük. Mivel a bejárás során a 8-as csúcs lett elsőként fekete, a befejezési száma 1-es lesz. A bejárás során eddig megtett utunk 〈1, 2, 4, 8〉. Most menjünk vissza az utolsó előtti 4-es csúcsra (lásd: 28.2. ábra). Mivel a 4-es csúcsnak sincs meg nem látogatott szomszédja, így ennek csúcs bejárását is befejeztük; színezzük a csúcsot feketére, és a bejárási számát állítsuk 2-re. Menjünk vissza a 2-es csúcshoz. A 2-es csúcsnak két olyan szomszédja is van, amelyet még nem látogattunk meg. Lépjünk a kisebb címkéjű csúcsba. Az 5-ös csúcs bejárását harmadikként fejezzük be. A 2-es csúcsból a bejárást a 6-os csúcs irányába folytatjuk. Tovább haladva, hetedikként elérjük a 9-es csúcsot (lásd: 28.2. ábra). Nyolcadikként következik a 7-es csúcs. Mivel a 3-as csúcs még fehér, azt érjük el kilencedikként A 3-as csúcsból a 6-os csúcsba vezet él, azonban a 6-os csúcsot már bejártuk, a színe már nem fehér, erre már nem folytatjuk a bejárást. Mivel a 3-as csúcsból már nem vezet él fehér csúcsba, így a 3-as csúcs bejárását is befejeztük (lásd: 28.3. ábra). Visszamegyünk a 7-es csúcsba, ahol a sorrendben következő él, a (7,8) mentén látjuk, hogy a 8-as csúcs színe már fekete. Mivel a 7-es csúcsnak nincs fehér szomszédja, így ötödikként befejeztük a bejárását. A 9-es csúcsnak a bejárását is befejeztük. Az úton ismét egy csúccsal visszamegyünk és befejezzük a 6-os csúcsot is. A 2-es csúcsra lépve, látható, hogy minden kimenő éle mentén már próbálkoztunk, így nyolcadikként azt is elhagyjuk. Az 1-es csúcsra lépve megvizsgáljuk a 3-as csúcsot, amely már bejárt csúcs. Ezután az előzőhöz hasonlóan még megvizsgáljuk a maradék (1,4) él mentén a 4-es csúcsot, de annak színe sem fehér. Az 1-esből kimenő összes él mentén megvizsgáltuk a továbblépési lehetőségeket, így az 1-es csúcsot utolsóként elhagyva befejeztük a bejárást.

28.3. A mélységi bejárás algoritmusa Legyen 𝐺 = (𝑉, 𝐸) irányított vagy irányítatlan véges gráf, ahol 𝑉 = {1, 2, … , 𝑛}. Továbbá, definiáljuk az alábbi tömböket: 

szín[1 … 𝑛]: az ADS szintű színezés megvalósítására;



mszám[1 … 𝑛] és bszám[1 … 𝑛]: az ADS szinten említett mélységi és befejezési számok nyilvántartására;



𝑃[1 … 𝑛]: a bejárás során, egy csúcs megelőző csúcsának a nyilvántartására (a korábban látottakhoz hasonlóan, lásd például: szélességi bejárás vagy Dijkstra-algoritmus).

Az előző példában úgy kezdtük a bejárást, hogy kijelöltünk egy kezdőcsúcsot, amelyből kiindulva történetesen az összes csúcs elérhető volt mélységi bejárással. Egy másik példában előfordulhatna, hogy lennének olyan csúcsok, amelyeket egyáltalán nem tudnánk elérni egy kiválasztott startcsúcsból. A későbbi alkalmazások érdekében a mélységi bejárást úgy definiáljuk, hogy az a gráf minden pontjához eljusson. Az algoritmus működésének nem feltétele egy kezdőcsúcs megadása, azt az eljárás keretében magunk tetszőlegesen választjuk meg, kívülről nézve véletlen jelleggel. Miután minden, ebből elérhető csúcsot bejártunk, visszajutottunk az említett kezdőpontba. Ha maradt olyan csúcs, amelyet a bejárás nem ért el, azaz színe fehér maradt, akkor választunk közülük egy következőt és abból kiindulva újra elvégezzük a mélységi bejárást. Ezt az eljárást addig folytatjuk, amíg van fehér csúcsunk. Nyilván minden ilyen menetben legalább egy csúcsot átszínezünk feketére, tehát véges számú menet után elfogynak a fehér csúcsok. Elegendő a csúcsok halmazán egyszer végigmenni (a gyakorlatban a csúcsok címkéje szerinti növekedően), és ha egy csúcs színe fehér, akkor onnan indítsunk egy bejárást. Tehát az algoritmust bontsuk két részre, vagy inkább két szintre. Az egyik szintet az a rekurzív „belső” algoritmus valósítja meg (lásd: 28.6. ábra), amely egy megadott kezdőcsúcsból indítja a bejárást, ez lesz az 𝑀𝐵(𝑢) eljárás. A másik, „külső” algoritmus, (lásd: 28.5. ábra) végigveszi a gráf pontjait, és minden fehér csúcsra elindítja az előbbi eljárást. Az pontból induló 𝑀𝐵(𝑢) mélységi bejárásra egyszerű rekurzív definíciót adni, amely szerint az u csúcsot pontosan akkor jártuk be, ha az összes szomszédját bejártuk. Ennek alapján az 𝑀𝐵(𝑢) eljárást rekurzív formában adjuk meg. A rekurzív eljárás önmagát hívja meg egy csúcs szomszédjaira, azzal a „kijárattal”, hogy csak fehér színű csúcs esetén történik hívás.

28.5. ábra. A mélységi bejárás „külső” algoritmusa

28.6. ábra. A mélységi bejárás „belső” algoritmusa

Az 𝑀𝐵(𝑢) eljárás futása során feljegyezzük a 𝑃 tömbbe egy csúcs megelőzőjét, így egy 𝑢 csúcsból kiinduló, úgynevezett mélységi feszítőfát kapunk. A 28.1. fejezet példáján az 1-es csúcsból kiinduló mélységi faként a 28.7. ábrán látható gráfot kapjuk. Összességében, pedig a 𝑃 tömbben feljegyzett megelőzési reláció, 𝐺 egy- esetleg több fából álló – részgráfját adja, amelyet mélységi erdőnek nevezünk. Definíció. Legyen 𝐺 = (𝑉, 𝐸) irányított, véges gráf. A 𝐺 mélységi bejárása után a 𝑃-ben keletkező megelőzési reláció által ábrázolható irányított 𝑇 részgráfot, a 𝐺 gráf egy mélységi erdőjének nevezzük.

28.7. ábra. A mélységi erdő

A mélységi bejárás műveletigényének meghatározásakor elegendő azt figyelembe venni, hogy egyrészt minden u csúcsra pontosan egyszer hívjuk meg az MB(u) eljárást, másrészt az eljárásban a csúcsnak minden szomszédját vizsgáljuk, ami összesen annyi vizsgálatot jelent, mint ahány éle van a gráfnak, így 𝑇(𝑛) = 𝑂(𝑛 + 𝑒).

28.4. Élek osztályozása a mélységi bejárás szerint A mélységi bejárás során felfedezett éleket különböző kategóriákba sorolhatjuk. Ezek ismertetése előtt azonban tisztáznunk kell néhány alapfogalmat és állítást. Definíció. Legyen 𝑇 a 𝐺 = (𝑉, 𝐸) gráf egy mélységi erdője, és 𝑢, 𝑣 ∈ 𝑉 csúcsok. A 𝑣 leszármazottja 𝑢-nak 𝑇-ben, ha ∃ 𝑢 ↝ 𝑣 𝑇-beli irányított út. Állítás. (a leszármazottság szükséges feltétele): Legyen 𝑇 a 𝐺 = (𝑉, 𝐸) gráf egy mélységi erdője, 𝑢, 𝑣 ∈ 𝑉 csúcsok, 𝑢 ≠ 𝑣, és 𝑣 leszármazottja 𝑢-nak 𝑇-ben, akkor mszám[𝑣] > mszám[𝑢]. Bizonyítás. Tegyük fel, hogy 𝑣 leszármazottja 𝑢-nak 𝑇-ben, tehát ∃ 𝑢 ↝ 𝑣 út 𝑇-ben. Ez az út úgy keletkezik, hogy az út (𝑢, 𝑣) éleinek vizsgálatakor, szín[𝑣] = 𝑓𝑒ℎé𝑟 csúcsok esetén a 𝑃 tömbbe bejegyzésre kerül a megelőző 𝑢 csúcs, majd meghívjuk az 𝑀𝐵(𝑣) eljárást, ahol a 𝑣 csúcs legalább egyel megnövelt mélységi szám értéket fog kapni. Állítás. (leszármazottság szükséges és elégséges feltétele): Legyen 𝑇 a 𝐺 = (𝑉, 𝐸) gráf egy mélységi erdője, 𝑢, 𝑣 ∈ 𝑉 csúcsok, 𝑢 ≠ 𝑣. Ekkor mszám[𝑦] > mszám[𝑥] és bszám[𝑦] < bszám[𝑥] akkor, és csak akkor, ha 𝑣 leszármazottja 𝑢-nak 𝑇-ben. Bizonyítás. A mélységi és befejezési számok viszonyából következik, hogy előbb meghívtuk az 𝑀𝐵(𝑢) eljárást, majd még mielőtt végett ért volna az 𝑢 szomszédainak rekurzív bejárását végző ciklus, meghívtuk 𝑀𝐵(𝑣)-t, amely teljes egészében lefutott, majd csak ez után terminálhatott az említett ciklus. Ez akkor lehetséges, ha létezik egy olyan 𝑤 ∈ 𝑉 csúcs, hogy az 𝑀𝐵(𝑤) eljárásban hívtuk meg 𝑀𝐵(𝑣)-ot, és 𝑃[𝑦] = 𝑤 bejegyzésre kerül. Továbbá a 𝑤 csúcs olyan, hogy 𝑤 = 𝑣 (azaz 𝑤 szomszédja 𝑢-nak) vagy 𝑀𝐵(𝑤) lefutása is teljesül, amit az 𝑀𝐵(𝑣)-ról az előbb említettünk. Ezt a gondolatmenetet addig folytathatjuk, míg 𝑤 = 𝑢 nem lesz, miközben láthatjuk, hogy a 𝑃 tömb bejegyzései által reprezentált irányított úton haladunk visszafelé (a rekurzív hívási fán haladunk felfelé). Tehát ez az út igazolja, hogy 𝑣 leszármazottja 𝑢-nak 𝑇-ben. Állítás. (Fehér út tétel): Legyen 𝑇 a 𝐺 = (𝑉, 𝐸) gráf egy mélységi erdője, 𝑢, 𝑣 ∈ 𝑉 csúcsok, 𝑢 ≠ 𝑣. Ekkor 𝑣 leszármazottja 𝑢-nak 𝑇-ben akkor, és csak akkor, ha 𝑢 elérésekor a 𝑣 elérhető az 𝑢-ból, az 𝑢 kivételével csak fehér csúcsokat tartalmazó úton. Bizonyítás. ⟹: Legyen 𝑣 leszármazottja 𝑢-nak. Ekkor ∃ 𝑢 ↝ 𝑣 𝑇-beli út, amelynek mentén minden csúcs leszármazottja 𝑢-nak. Ezen fabeli út minden 𝑤 csúcsára teljesül, hogy mszám[𝑤] > mszám[𝑢], azaz minden 𝑤 csúcsot később érünk el, mint az 𝑢-t. Tehát az 𝑢 elérésekor a 𝑤 leszármazott csúcsok még fehérek. ⟸: Tegyük fel, hogy 𝑢 elérésekor létezik 𝑣-be vezető fehér csúcsokból álló út, legyen 𝑤 egy ilyen út első olyan csúcsa, amelyet az 𝑢 szomszédait felsoroló ciklusban elérünk, azaz meghívjuk az 𝑀𝐵(𝑤) eljárást. Az 𝑢 csúcs már szürke és 𝑤 még fehér, ezért 𝑤-t később érjük el, mint 𝑢-et, tehát mszám[𝑤] > mszám[𝑢]. Továbbá 𝑀𝐵(𝑢) belsejéből hívtuk meg 𝑀𝐵(𝑤)-t tehát 𝑀𝐵(𝑤) előbb lefut, mint 𝑀𝐵(𝑢), azaz bszám[𝑤] < bszám[𝑢]. Tehát a korábbi állítás szerint 𝑤 leszármazottja 𝑢-nak. Azonban feltettük, hogy 𝑤-t érjük el először az 𝑣-hez vezető úton, tehát az út többi csúcsa 𝑣 elérésekor még fehér, így a 𝑣 ↝ 𝑢 útra hasonlóan alkalmazható a fenti rekurzív gondolatmenet (míg el nem jutunk az 𝑢 ↝ 𝑢 útig). Miután beláttuk, hogy 𝑤 leszármazottja 𝑢-nak és 𝑣 leszármazottja 𝑤-nek, tehát 𝑣 leszármazottja 𝑢-nak. Megjegyzés. Ha 𝑢 = 𝑣, akkor triviálisan teljesül a kölcsönös leszármazottság.

A leszármazott fogalmát kihasználva az éleket a következő osztályokba sorolhatjuk a mélységi bejárás szerint. Legyen 𝑇 a 𝐺 = (𝑉, 𝐸) gráf egy mélységi erdője, és 𝑢, 𝑣 ∈ 𝑉 csúcsok. Az (𝑢, 𝑣) ∈ 𝐸 él 1. faél, ha (𝑢, 𝑣) éle 𝑇-nek; 2. előreél, ha (𝑢, 𝑣) nem éle T-nek, de 𝑣 leszármazottja 𝑢-nak 𝑇-ben, és 𝑢 ≠ 𝑣; 3. visszaél, ha 𝑢 leszármazottja 𝑣-nek 𝑇-ben ((𝑢, 𝑣) nem éle T-nek, ide tartozik 𝑢 = 𝑣 hurokél is); 4. keresztél, ha 𝑢 és 𝑣 nem leszármazottai egymásnak T-ben. Az éltípusokat az eljárás végrehajtása közben tudjuk megállapítani, az mszám és bszám értékek segítségével, ahogy az állítások esetében láttuk. Állítás. Tegyük fel, hogy 𝐺 = (𝑉, 𝐸) irányított véges gráf mélység bejárása során éppen az (𝑢, 𝑣) ∈ 𝐸 vizsgálatánál tartunk. Ekkor (𝑢, 𝑣) él 1. faél, ha mszám[𝑣] = 0; 2. előreél, ha mszám[𝑣] > mszám[𝑢]; 3. visszaél, ha mszám[𝑣] ≤ mszám[𝑢] és bszám[𝑣] = 0; 4. keresztél, ha mszám[𝑣] < mszám[𝑢] és bszám[𝑣] = 0. Bizonyítás 1. mszám[𝑣] = 0 azt jelenti, hogy most érjük el a 𝑣 csúcsot, azaz a csúcs még fehér. Továbbá az (𝑢, 𝑣) él mentén jutunk az 𝑣 csúcsba, mint 𝑢 szomszédja, amely az 𝑀𝐵(𝑢) 𝑢 szomszédait felsoroló ciklusában, az elágazás bal oldali ágában lehetséges, ahol valóban a P tömbbe bejegyzésre kerül az él, tehát faél lesz. 2. mszám[𝑣] > mszám[𝑢] és mszám[𝑢] > 0, így mszám[𝑣] > 0, tehát 𝑣 nem fehér (így az él nem lehet faél). Továbbá a mélységi számok viszonyából következik, hogy az 𝑀𝐵(𝑣)-t később hívtuk meg, mint az 𝑀𝐵(𝑢)-t, de az 𝑀𝐵(𝑢) még nem fejeződött be. Így az 𝑀𝐵(𝑣) meghívása, az 𝑀𝐵(𝑢) eljárás (𝑢 szomszédait felsoroló ciklusában, valamely szomszédra meghívott MB eljárásban, vagy annak rekurzív leszármazottjában történt, az (𝑢, 𝑣) él vizsgálata előtt. Azonban az (𝑢, 𝑣) él vizsgálata a ciklus egy későbbi iterációjában történik, tehát az 𝑀𝐵(𝑣)-nek mostanra be kellet fejeződnie, de az 𝑀𝐵(𝑢) még csak ezek után fog befejeződni bszám[𝑣] < bszám[𝑢].

28.8. ábra. Előreél detektálása (rózsaszínnel jelölve)

Korábbi állításunkat felhasználva beláthatjuk, hogy 𝑣 leszármazottja 𝑢-nak 𝑇-ben, és láttuk, hogy nem lehet faél, tehát előreél. Láthatjuk a 28.1. fejezetben vizsgált példán, hogy az 1-es csúcsból a 3-as csúcsba vezető él feldolgozásakor a 3-as csúcshoz már eljutottunk az 〈1, 2, 6, 9, 7, 3 〉 úton. Tehát a 3-as leszármazottja az 1-nek, és az (1,3) él nem faél, tehát előreél (28.8. ábra). 3. Ha mszám[𝑣] = mszám[𝑢], akkor (𝑢, 𝑣) hurokél, ami triviálisan visszaél. Ha mszám[𝑣] < 𝑚𝑠𝑧á𝑚[𝑢] és bszám[𝑣] = 0, akkor a 𝑣 bejárása elkezdődött, de még nem fejeződött be. Elkezdtük az 𝑢 bejárását, amelynek során vizsgáljuk az (𝑢, 𝑣) élt. Tehát az 𝑣 bejárása során jutunk el az 𝑢-hoz, azaz 𝑢 leszármazottja 𝑣-nek. Az 𝑀𝐵(𝑣) eljárásban, vagy annak valamely rekurzív leszármazottjában hívtuk meg az 𝑀𝐵(𝑢)-t, tehát az 𝑀𝐵(𝑢)-nak előbb kell befejeződnie, mint az 𝑀𝐵(𝑣)-nek. A 28.9. ábrán a (3,6) él visszaél.

28.9. ábra. Visszaél detektálása (piros színnel jelölve)

4. mszám[𝑣] < mszám[𝑢] szükséges feltétele, hogy 𝑢 leszármazottja legyen 𝑣-nek, de az 𝑣 bejárása befejeződött (bszám[𝑣] > 0), míg az 𝑢 bejárása még nem, tehát a bszám[𝑢] csak nagyobb lehet bszám[𝑣]-nél, azaz az elégséges feltétel nem teljesül. Tehát 𝑢 és 𝑣 nem leszármazottai egymásnak 𝑇-ben, amiből következik, hogy (𝑢, 𝑣) él keresztél. A 28.10. ábrán a (7,8) élt, mint keresztélt detektáltuk.

28.10. ábra. Keresztél detektálása (zöld színnel jelölve)

28.5. A mélységi bejárás egy illusztrációja Az alábbi „pillanatfelvételek” inkább az idézett irodalmi leírás illusztrálásához készültek, minthogy a gráfos algoritmus működést részletesen egy másik ábrasoron mutattuk be.

28.11. ábra. Mélységi bejárás egy gráfon (illusztráció a „kisváros lámpáihoz”)

29. DAG TOPOLOGIKUS RENDEZÉSE A címben szereplő DAG (mint egy angol eredetű mozaikszó) körmentes irányított gráfot jelent. Operációs rendszerekben, adatbázis kezelő rendszerekben előfordulnak olyan esetek, hogy egyes folyamatok más folyamatokra várnak. Ilyen eset lehet egy nyomtatás várakoztatása, mert a nyomtatót egy másik folyamat használja, vagy egy adattáblán való művelet elvégzésének a várakoztatása, mert egy másik folyamat a táblát zárolta. Építsünk fel egy úgy nevezett várakozási gráfot, ahol a gráf csúcsai a folyamatok, és egy u csúcsból vezessen él egy v csúcsba, ha az u folyamat a v folyamatra várakozik. Az említett várakozási reláció nem feltétlen szimmetrikus, tehát a gráfunk legyen irányított. Ha a körbevárakozás esete lép fel, akkor a gráfban irányított kör keletkezik. Ha nem avatkoznánk közbe, akkor a folyamatok elvben akár végtelen ideig is várakozhatnának egymásra. A jelenséget a szakirodalom holtpontnak nevezi. Például a 29.1. ábrán az F1 folyamat várakozik F2-re, F2 várakozik F3-ra, és F3 várakozik F1-re.

29.1. ábra. Egymásra várakozó folyamatok gráfban ábrázolva

Az irányított gráfok körmentessége (illetve, mint hibajelenség, a körök megjelenése) további jelentős szerepet kap az irányított gráfokkal leírható összetett folyamatok „linearizálása” terén. Például, az ételek elkészítése során bizonyos lépéseket előzetesen végrehajtandó tevékenységekhez, korábbi fázisok befejeződéséhez kötnek a receptek. Ezeket a függőségeket egy irányított gráffal lehet ábrázolni, amely – helyes recept esetén – nem tartalmaz kört. Komolyabb kiterjedésű gráfra példa lehet az autógyártásban, a szerelőcsarnok egy pontján elvégzett műveletek egymásutánjának a rendszere. Mindkét példában fellép az az a feladat, hogy a gráf csúcsaiban szereplő tevékenységeket rendezzük szekvenciába úgy, hogy mire a sor hozzájuk ér, addigra már minden előzetesen végrehajtandó tevékenységen túljutottunk. A 29.2. ábrán szereplő gráf esetén egy ilyen felsorolás lehet például a következő: 1, 3, 2, 5, 4, 6. A csúcsoknak az ilyen alkalmas sorrendbe állítását nevezzük a körmentes irányított gráf topologikus rendezésének. 29.1. A topologikus rendezés jellemzői Az egymásra épülő tevékenységeket akkor tudjuk végrehajtási sorba rendezni, ha a folyamatok egy irányított körmentes gráfban (directed acyclic graph, DAG) ábrázolhatók. Ennek megfelelően a fő feladatunk ellenőrizni, hogy irányított gráfunk teljesíti-e a körmentesség feltételét, másként a DAG tulajdonságot. A topologikus rendezés azután végezhető el. A DAG tulajdonság ellenőrzése tehát nem más, mint irányított körök felderítése a gráfban. Ez a feladat pedig visszavezethető a 28. fejezetben ismertetett élosztályozásra.

Például az előző fejezet 28.9. ábráján követhető az a lépés, amelyben a mélységi bejárás a gráfon detektált egy körre utaló visszaélt ((3, 6) él). Alább megfogalmazunk néhány olyan állítást, amelyek a körmentesség, a mélységi bejárás és a topologikus rendezés között teremtenek kapcsolatot. Állítás. Legyen 𝐺 = (𝑉, 𝐸) irányított, véges gráf. Ha a 𝐺 gráf mélységi bejárása során találunk visszaélt, akkor 𝐺 nem DAG. Bizonyítás. Tegyük fel, hogy (𝑢, 𝑣) ∈ 𝐸 egy visszaél, ekkor 𝑢 leszármazottja 𝑣-nek a mélységi fában, azaz létezik 𝑣 ↝ 𝑢 irányított út. Ehhez hozzávéve (𝑢, 𝑣) élt 𝑣 ↝ 𝑢 → 𝑣 egy irányított kör. Állítás. Legyen 𝐺 = (𝑉, 𝐸) irányított, véges gráf. Ha a 𝐺 gráf nem DAG, akkor minden mélységi bejárás során találunk visszaélt. Bizonyítás. 𝐺 nem DAG, azaz van benne irányított kör, legyen 𝑣 ↝ 𝑢 → 𝑣 egy irányított kör, és legyen 𝑣 a mélységi bejárás során elsőnek elért csúcs. Ekkor a kör mentén a 𝑣 kivételével minden csúcs fehér ebben a pillanatban. A kör mentén, vagy kis kerülő úton, de fehér csúcsok mentén eljutunk 𝑣-ből 𝑢-ba (ha van 𝑣 ↝ 𝑢 út, akkor van csupa fehér csúcsból álló 𝑣 ↝ 𝑢 út is), tehát (𝑢, 𝑣) visszaél lesz. Arra a kedvező eredményre jutottunk, hogy egy gráfról O(𝑛 + 𝑒) idő alatt eldönthető, hogy DAG-e, a mélységi bejárás felhasználásával. Nem kell mást tenni, mint a mélységi keresés során figyelni az éltípusokat, ha nem találunk visszaélt, akkor a gráf DAG. Definíció. Legyen 𝐺 = (𝑉, 𝐸) irányított, véges gráf, továbbá legyen 𝑛 = |𝑉|. 𝐺 csúcsainak egy 𝑣1 , … , 𝑣𝑛 felsorolása, G egy topologikus rendezése, ha ∀ (𝑢, 𝑤) ∈ 𝐸 él esetén a felsorolásban 𝑢 előbb áll, mint 𝑤, azaz 𝑢 = 𝑣𝑖 és 𝑤 = 𝑣𝑗 esetén 𝑖 < 𝑗. Például vegyük a 29.2. ábrán látható körmentes gráfot. A gráf egy topologikus rendezése 1, 2, 3, 4, 5, 6 (ez most egy másik rendezés, mint amely előzőleg szerepelt).

29.2. ábra. Egy körmentes irányított gráf

Állítás. Ha 𝐺 = (𝑉, 𝐸) irányított gráf DAG, akkor ∃ 𝑢 ∈ 𝑉 csúcs, amelybe nem fut él. Bizonyítás. Indirekt tegyük fel, hogy nem létezik ilyen csúcs. Ekkor vegyünk egy csúcsot, és egyik befutó élén hátráljunk (lépjünk vissza a megelőző csúcsra). Azonban minden csúcsnak van befutó éle, így a hátrálást korlátlanul végrehajthatjuk, vagyis kört kapunk, ami ellentmondás. Állítás. A 𝐺 = (𝑉, 𝐸) gráfnak létezik topologikus rendezése akkor, és csak akkor, ha 𝐺 DAG. Bizonyítás. ⟹: G-nek létezik topologikus rendezése, és indirekt tegyük fel, hogy G nem DAG, azaz van benne irányított kör, ami ellentmondás, mivel a kör mentén nem lehet alkalmas sorrendet definiálni.

⟸: Konstruktív bizonyítást adunk a csúcsok száma szerinti teljes indukcióval. Az egyetlen pontból álló gráfra utaló 𝑛 = 1 esetén az állítás triviális módon igaz. Tegyük fel, hogy 𝑛 − 1 pontú DAG-okra igaz az állítás. Legyen 𝐺𝑛 = (𝑉, 𝐸) csúcsból álló DAG, ekkor ∃ 𝑢 ∈ 𝑉 csúcs, amelyben nem megy él. Töröljük a gráfból 𝑢-t és a belőle kimenő éleket, így megkapjuk 𝐺𝑛−1 𝑛 − 1 csúcsból álló DAG-ot. Ekkor az indukciós feltevés szerint 𝐺𝑛−1-nek létezik topologikus rendezése, legyen ez 𝑣1 , … , 𝑣𝑛−1 . Ekkor 𝑢, 𝑣1 , … , 𝑣𝑛−1 egy topologikus rendezése lesz 𝐺𝑛 -nek. Például legyen 𝐺𝑛 a 28.2. ábra gráfja. Ekkor az 1-es csúcs törlésével megkapjuk a 𝐺𝑛−1 gráfot (28.3. ábra), ami az indukciós feltétel szerint DAG, így létezik topologikus rendezése: 2,3,4,5,6. Tehát 𝐺𝑛 -nek topologikus rendezése az 1,2,3,4,5,6 sorozat, mivel nem megy él az 1-es csúcsba, így az 1-es csúcsot tehetjük a sorozat elejére.

29.3. ábra. Az 1-es csúcs és éleinek elhagyása a gráfból

29.2. A topologikus rendezés algoritmusa A topologikus rendezés előállítását többféle módon is megközelíthetjük. 

Az előző állítás bizonyításában használt indukció megad egy algoritmust. Legyen 𝑄 egy sort adatszerkezet, kezdetben üres. 1. 𝑄-ba berakjuk azon csúcsokat, amelybe nem megy él. 2. Ha Q üres, akkor készen vagyunk. 3. Vegyük ki 𝑄-ból az első elemet, és írjuk ki, legyen ez 𝑢. 4. Töröljük 𝐺-ből (𝑢, 𝑣) ∈ 𝐸 éleket ∀𝑣 ∈ 𝑉 csúcsra. Ha 𝑣-be most már nem megy él, akkor helyezzük 𝑣-t a sor végére. 5. Folytassuk a 2. lépéssel.  A visszaélek azonosítására szolgált a mélységi bejárás. Ennek segítségével is dolgozhatunk. Futassuk le a mélységi bejárást a gráfon, majd a csúcsokat írjuk ki a csúcsok befejezési számainak (bszám) csökkenő sorrendjében. Az algoritmusban, amikor egy csúcsot elhagyunk, rakjuk a csúcs címkéjét egy verembe, majd a bejárás befejeztével ürítsük ki vermet. A bejárás a DAG tulajdonságot is ellenőrzi. Állítás. Az eljárás 𝐺 = (𝑉, 𝐸) DAG egy topologikus rendezését állítja elő. Bizonyítás. Azt kell belátni, hogy amennyiben (𝑢, 𝑣) ∈ 𝐸, akkor bszám[𝑢] > bszám[𝑣]. Amikor (𝑢, 𝑣) élt vizsgáljuk, akkor 𝑣 fehér vagy fekete (szürke esetén visszaél lenne, de G DAG). Ha 𝑣 fehér, akkor 𝑣 leszármazottja 𝑢-nak, tehát bszám[𝑢] > bszám[𝑣]. Ha 𝑣 fekete, akkor 𝑣-t már megvizsgáltuk és elhagytuk, míg 𝑢-t még nem, tehát bszám[𝑢] > bszám[𝑣]. Azaz minden esetre fennáll az állítás. Mivel a mélységi bejárás műveletigénye O(𝑛 + 𝑒), és a veremműveletek száma a csúcsok számával arányos, azaz Θ(𝑛), 𝑇(𝑛) = O(𝑛 + 𝑒).

29.3. A topologikus rendezés szemléltetése Nézzük meg a mélységi bejárás alapú algoritmus működését a 29.4. ábrán. A mélységi bejárással elindulunk az 1-es csúcsból, majd néhány lépés után eljutunk a 6os csúcsba, miközben csupa faéleket azonosítunk.

29.4. ábra. A topologikus rendezés végrehajtása

Elsőként a 6-os csúcs bejárását fejezzük be, tehát a 6-ost bedobjuk a 𝑉 verembe, így 𝑉 = [6]. A következő lépésben a 4-es csúcsot fejezzük be. 𝑉 = [6, 4]. Majd a 2-es csúcsból ellátogatunk az 5-ös csúcsba, miközben a verem változatlan marad. Az 5-ös csúcsból kimenő élt keresztélként azonosítjuk. Befejezzük az 5-ös csúcs bejárását is, tehát a verembe dobjuk: 𝑉 = [6, 4, 5]. Következőnek befejezzük a 2-es csúcsot is, és a vermet a 2-essel bővítjük: 𝑉 = [6, 4, 5, 2]. Az 1-es csúcsból elérjük a 3-as csúcsot, és újabb keresztélt azonosítunk: (3,5). Befejezzük a 3-as csúcs bejárását is. Befejezzük a 3-as csúcs bejárását is. 𝑉 = [6, 4, 5, 2, 3]. Végezetül a bejárás utolsó csúcsaként elhagyjuk az 1-es csúcsot is. A veremben van a gráf összes csúcsa a befejezésük szerint: 𝑉 = [6, 4, 5, 2, 3, 1]. Menet közben nem találtunk visszaélt, tehát a DAG tulajdonságot rendben találtuk. Végül a verem tartalmát kiírjuk: 1,3,2,5,4,6, amellyel megkapjuk a G egy topologikus rendezését.

30. ERŐSEN ÜSSZEFÜGGŐ KOMPONENSEK A gráfos alkalmazások között is találkozunk olyan problémákkal, amelyeket megoldását a részekre bontott gráfon határozzuk meg, majd ezeket alkalmas módon teljes megoldássá egyesítjük. A részekre bontás alapja gyakran a gráfok összefüggősége: a gráfot olyan részekre bontjuk, amelyek a csúcsok közötti közlekedés szempontjából „egyben lévőnek” érzünk. Egy irányítatlan gráfot összefüggőnek mondunk, ha bármely két csúcsa összeköthető úttal. Ha egy gráf összefüggő komponensekre esik szét, akkor ez egyes részeken belül szabadon lehet közlekedni az irányítatlan éleken, de a komponensek között nincsen átjárás. Ez egy ekvivalencia osztályozás a csúcsok között, az elérhetőség relációja alapján. Az irányított gráfok esetén kétféle összefüggőséget is bevezetnek. A gráfot egyszerűen csak összefüggőnek mondjuk, ha az élek irányításától eltekintve, az előző értelemben összefüggő. Szigorúbb fogalom az erős összefüggőség, amely azt követeli meg, hogy egy ennek eleget tevő gráfban bármely csúcs elérhető bármelyik másikból, a gráf irányított élein haladva. Ha egy irányított gráf bomlik részekre az erős összefüggőség relációja szerint, akkor a gráf erősen összefüggő (erős) komponenseihez jutunk. Ebben a fejezetben az irányított gráfok erősen összefüggő komponenseinek előállítására adunk eljárást, amely két mélységi bejárás egymás utáni alkalmazásával lehetséges. 30.1. Néhány fogalom és összefüggés Definíció: Legyen 𝐺 = (𝑉, 𝐸) irányított, véges gráf. 𝐺 összefüggő, ha 𝐺 irányítás nélkül összefüggő. 𝐺 erősen összefüggő, ha ∀𝑢, 𝑣 ∈ 𝐸 esetén ∃𝑢 ↝ 𝑣 út. A definíciók közötti különbség érzékelhető, ha egy város úthálózatára egyszer a gyalogos, másszor az autós szemével tekintünk. A gyalogosok számára az egyirányú utca nem jelent megkötést, tehát ők tekinthetik irányítatlannak az utcákat, míg az autósok számára az utcák irányítottak. Egy városrész összefüggő, ha gyalogosan bármely pontjából bármely pontjába eljuthatunk, míg a városrész erősen összefüggő, ha ugyanez megtehető autóval is. Érezhető, hogy az erősen összefüggőség valóban szigorúbb követelmény, mint az (egyszerű) összefüggőség. A 30.1. ábrán látható gráf összefüggő, de nem erősen összefüggő, hiszen például a 2-es csúcsból nem lehet eljutni az 1-es csúcsba.

30.1. ábra. Egy összefüggő, de nem erősen összefüggő gráf

Definíció: Legyen 𝐺 = (𝑉, 𝐸) irányított, véges gráf, 𝑢, 𝑣 ∈ 𝑉. Ekkor legyen 𝑢 ≈ 𝑣, ha léteznek 𝑢 ↝ 𝑣, illetve 𝑣 ↝ 𝑢 utak a gráfban. Állítás: A ≈ reláció ekvivlenciareláció, tehát ≈ osztályozza a 𝑉 csúcshalmazt. Definíció: A ≈ reláció ekvivalencia osztályait nevezzük a gráf erős komponenseinek. Állítás: Egy összefüggő gráf két erős komponense között az élek csak egy irányba mehetnek. Bizonyítás: Indirekt tegyük fel, hogy két erős komponens között oda-vissza is mehetnek élek. Legyen a két komponens 𝐶1 és 𝐶2 (30.2. ábra). Ekkor 𝑢 ∈ 𝐶1 és 𝑣 ∈ 𝐶2 csúcsok között léteznek 𝑢 ↝ 𝑣 és 𝑣 ↝ 𝑢 utak, ugyanis az erős komponenseken belül definíció szerint, 𝐶1 és 𝐶2 között pedig az indirekt feltevés szerint létezik út, tehát 𝑢 ≈ 𝑣, ami ellentmondás.

30.2. ábra. Erős komponensek közötti utak

Definíció: Legyen 𝐺 = (𝑉, 𝐸) irányított, véges gráf. 𝐺 redukált gráfja olyan irányított gráf, amelynek csúcsai 𝐺 erős komponensei, és a redukált gráf két csúcsa között, akkor halad él, ha csúcsoknak megfelelő G erős komponensei között halad él, továbbá az él irányítása megegyezik az erős komponensek között haladó él(ek) irányításával. A 30.1. ábrán látható gráf redukált gráfját illusztráltuk a 30.3. ábrán.

30.3. ábra. Redukált gráf

Állítás: A redukált gráf DAG, azaz körmentes irányított gráf. Bizonyítás: Indirekt tegyük fel, hogy a redukált gráf nem DAG, azaz létezik benne irányított kör. Legyen egy ilyen kör 𝐶1 → 𝐶2 → ⋯ → 𝐶𝑘 → 𝐶1 . Ekkor a kör mentén lévő komponensek kölcsönösen elérhetők, vagyis 𝐶1 ≈ 𝐶2 ≈ ⋯ ≈ 𝐶𝑘 , ami ellentmondás. 30.2. A redukált gráf előállításának algoritmusa A redukált gráf előállításához tehát a gráf erős komponenseit kell meghatároznunk, amelyet a következő algoritmussal végezhetünk: 1. A gráfot bejárjuk mélységi bejárással, a csúcsokat a befejezési számok sorrendjében kiírjuk egy verembe. 2. Transzponáljuk a gráfot, azaz megfordítjuk az élek irányítását.

3. Bejárjuk a transzponáltat mélységi bejárással, de nem az alapvető sorrend szerint vesszük a csúcsokat kiinduló csúcsnak, hanem az 1. lépésben készített veremből történő kivétel sorrendjében. A lépések végrehajtásával egy mélységi erdőt kapunk, amelyben a fák a gráf erős komponensei lesznek. Ezeknek a fáknak a csúcsait összevonhatjuk egy csúccsá, majd a csúcsok között az éleket az eredeti gráf éleinek megfelelően megadjuk, így megkapjuk a redukált gráfot. Az algoritmus műveletigényénél figyelembe kell venni a mélységi bejárás O(𝑛 + 𝑒), a gráf megfordítás O(𝑒), illetve a verem műveletek műveletigényeit, így 𝑇(𝑛) = O(𝑛 + 𝑒). Megjegyzés: Ha a gráf DAG, akkor az algoritmus 3. lépésében említett bejárási sorrend nem más, mint a gráf egy topologikus rendezése. Állítás: Bármely mélységi bejárás során, egy erős komponens összes csúcs ugyanabba a mélységi fába kerül. Bizonyítás: Legyen 𝑢 az a csúcs, amelyet a bejárás során először érünk el az erős komponens csúcsai közül. Ekkor a komponens összes többi csúcsa még fehér, továbbá erős komponensről van szó, így az összes csúcsba vezet út. Emiatt az erős komponens összes többi csúcsába vezet fehér csúcsokból álló út. A Fehér út tétel szerint az erős komponens összes többi csúcsa 𝑢 leszármazottja lesz a mélységi fában. Állítás: Futtassuk le a fenti algoritmust a 𝐺 = (𝑉, 𝐸) gráfon. Legyenek 𝑢, 𝑣 ∈ 𝑉 a gráf csúcsai, és tekintsük az algoritmus 3. lépésében kapott mélységi fákat. Ekkor, 𝑢 ≈ 𝑣 akkor és csak akkor, ha 𝑢 és 𝑣 ugyanabban a mélységi fában vannak. Bizonyítás: ⟹: Az erős komponensek csúcsai minden mélységi bejárás során ugyanabba a fába esnek, továbbá 𝐺 és 𝐺 𝑇 erős komponensei azonosak, így 𝑢 és 𝑣 ugyanabban a mélységi fába esnek a 𝐺 𝑇 mélységi bejárása során. ⟸: Tegyük fel, hogy 𝑢 és 𝑣 ugyanabban a mélységi fában vannak. Legyen 𝑤 ennek a fának a gyökere. Mivel 𝑢 leszármazottja 𝑤-nek, ∃ 𝑤 ↝ 𝑢 út 𝐺 𝑇 -ben, tehát ∃ 𝑢 ↝ 𝑤 út 𝐺ben. Legyen 𝑢′ az 𝑢 ↝ 𝑤 útnak a legkisebb mélységi számú csúcsa z algoritmus 1. lépésbeli bejárásánál. Ekkor 𝑤 leszármazottja 𝑢′-nek (mivel 𝑢′ elérésekor 𝑢 ↝ 𝑤 minden csúcsa fehér, alkalmazható a Fehér út tétel). Tehát ∃ 𝑢′ ↝ 𝑤 út 𝐺-ben. Az 𝑢′ gyökerű részfában bszám[𝑢′] a legnagyobb befejezési szám, tehát 𝑢′-nek 𝑤-nél előbb kell lennie a fordított bszám listában, azonban 𝑤 mégis előbb van a listában, mivel 𝐺 𝑇 -ben való bejárásnál gyökér lett. Ebből következik, hogy 𝑢′ = 𝑤. Tehát az 𝑢 ↝ 𝑤 úton mszám[𝑤] a legkisebb mélységi szám, és bszám[𝑤] a legnagyobb befejezési szám (az első bejárás szerint), így 𝑢 ↝ 𝑤 csúcsai leszármazottai 𝑤-nek, tehát ∃ 𝑤 ↝ 𝑢 út 𝐺-ben. Mivel ∃ 𝑤 ↝ 𝑢 út és ∃ 𝑢 ↝ 𝑤 utak 𝐺-ben, 𝑢 ≈ 𝑤. Hasonlóan belátható, hogy 𝑣 ≈ 𝑤. Mivel a reláció ekvivalencia reláció, 𝑢 ≈ 𝑣. 30.3. Szemléltetés Tekintsük a 30.1. ábrán bemutatott gráfot. Futtassuk le a mélységi bejárást a gráfon. A csúcsok feldolgozási sorrendje legyen a csúcsok címkéje szerint rendezett. A 30.4. ábrán látható az első mélységi bejárás eredménye. A befejezési számok szerint csökkenően a csúcsok sorrendje: 1, 4, 2, 5, 3. (Alább két ábra látható, a második ábra már a tranzitív gráf bejárását szemlélteti.)

30.4. ábra. Egy gráf mélységi bejárása

30.5. ábra. A tranzitív gráf mélységi bejárása, az első két mélységi fa előállítása

A gráf transzpontáltján kell a mélységi keresést lefuttatni a csúcsok fent említett sorrendjében (lásd: 30.5. ábra), vagyis az 1. csúcstól indítjuk a mélységi bejárást. Mivel ebből a csúcsból a transzponáltban nem indult ki él, ezért a mélységi bejárás azonnal visszalép, így ki is alakul az első fa a mélységi erdőben, amely csupán az 1-es csúcsból áll. Ezt követően az algoritmus a 4-es csúcsból indítja a bejárást, de mivel csupán az 1-es csúcsba vezet él (amelyet már felfedeztünk), ismét visszalépés következik, és kialakul a második mélységi fa.

30.6. ábra. A harmadik mélységi fa előállítása

30.7. ábra. A tranzitív gráf mélységi fái

A következő lépésben a mélységi bejárás a 2-es csúcsból indul, amelyből az 1-esbe már nem, de a 3-as csúcs felé tovább tud lépni, majd onnan az 5-ös csúcsra (30.6. ábra).

Innen ismét visszalépések következnek, és kialakul a harmadik mélységi fa a gráfban, amely a 2,3,5 csúcsokat tartalmazza. A 30.7. ábrán láthatjuk színek szerint a tranzitív gráf mélységi fáit, vagyis az eredeti gráfunk erős komponenseit. Jól látható, hogy a 2,3,5 csúcsokat összevonva az eredeti gráfban visszakapjuk a 30.3. ábra redukált gráfját.

31. EGYSZERŰ MINTAILLESZTÉS A mintaillesztés feladata az, hogy egy szövegben egy szövegminta (szövegrészlet, string) előfordulását vagy előfordulásait megkeressük. A mintaillesztés elnevezés mellett találkozunk a stringkeresés elnevezéssel is. A feladat általánosítható: valamely alaptípus feletti sorozatban keressük egy másik (általában jóval rövidebb) sorozat előfordulásait (például egy DNS láncban keresünk egy szakaszt). 31.1. A mintaillesztés feladata A továbbiakban egyszerűsítjük a feladatot a minta első előfordulásának a megkeresésére, amelynek segítségével az összes előfordulás megkapható. (Keressük meg a minta első előfordulását, majd a hátralévő szövegben ismét keressük az első előfordulást stb.) Vezessük be a fejezet egészére az alábbi jelöléseket:   

Legyen H egy tetszőleges alaptípus feletti véges halmaz, a szöveg ábécéje. Legyen a szöveg, amelyben a mintát keressük: S 1..n H * , azaz egy n hosszú H feletti véges sorozat. Legyen a minta, amelyet keresünk a szövegben: M 1..m H * , egy m hosszú szintén a H feletti véges sorozat.

Továbbá, tegyük fel, hogy S-en és M-en megengedett művelet az indexelés, azaz hivatkozhatunk a szöveg vagy a minta egy i-edik elemére S[i] ( i  [1..n] ), M[i] ( i  [1..m] ). A tárgyalt algoritmusok némelyike lényeges módosítás nélkül átírható szekvenciális fájlokra is (ahol az indexelés nem megengedett), míg a más tárgyalt algoritmusok csak puffer használatával alkalmazhatók a csak szekvenciálisan olvasható hosszabb szövegekre. 31.1.1. Az illeszkedés fogalma Bevezetjük a minta előfordulásának fogalmát, több szóhasználatot is megemlítve. Legyen k  N , k  [0..n  m] . Azt mondjuk, hogy   

az M minta a k+1-dik pozíción illeszkedik az S szövegre (előfordul a szövegben), vagy az M minta k eltolással illeszkedik S-re, illetve k érvényes eltolás,

ha S[k+1..k+m] = M[1..m], azaz j  N , j  [1..m] : S[k  j ]  M [ j ] . Továbbá, az M mintának a (k+1)-edik pozíción való illeszkedése az M első előfordulása az S szövegben, ha i  N , i  [0..k  1] : S[i  1..i  m]  M [1..m] . Legyen például a szövegünk S = "ABBABCAB", és a keresett minta pedig M = "ABC". A fenti definíció szerint az M minta a 4-edik pozíción, k = 3 eltolással illeszkedik az S szövegre, ahogyan ez a 31.1. ábrán is látható.

31.1. ábra. Az M minta illeszkedése

31.1.2. A mintaillesztési feladat egyszerű megoldási elve Tekintsük megengedett műveletnek az azonos méretű sorozatok egyenlőségének a vizsgálatát, azaz esetünkben az S[k+1..k+m]=M[1..m] vizsgálatot. Ekkor az a feladat, hogy keressük meg az első olyan k pozíciót ( k  N , k  [0..n  m] ), amelyre igazat ad a fenti vizsgálat. Ezt megtehetjük egy lineáris kereséssel. A fejezett további részeiben is használt paraméterek jelentése legyen: u  igaz  k  N , k [0..n  m] : M a (k+1)-edik pozíción illeszkedik S-re, továbbá u = igaz esetén k visszatérési értéke az első érvényes eltolás. A lineáris keresésre épülő elvi megoldást, összhangban a most bevezetett paraméterekkel, a 31.2. ábrán láthatjuk.

31.2. ábra. Mintaillesztés lineáris kereséssel

31.2. Az egyszerű mintaillesztés algoritmusa A stringkeresési feladat naiv megoldást egyszerű mintaillesztésnek fogjuk nevezni. Ehhez a „nyers erő” (brute force) algoritmushoz könnyen eljuthatunk a már tanult „programozási tételekre” való visszavezetéssel. Folytassuk az előző részben elkezdett, lineáris keresésre épülő gondolatot. A vázolt megoldásban megengedett műveletnek tekintettük az S[k+1..k+m] = M[1..m] vizsgálatot. Ennek a kifejezésnek az eredményét megkaphatjuk karakterenkénti összehasonlítással is, amelynek során a minta minden karakterét összehasonlítjuk a szövegdarab megfelelő karakterével; és ha az összes vizsgált karakter egyezik, akkor a kifejezés értéke legyen igaz, különben hamis. Az S[k+1..k+m] = M[1..m] vizsgálat előbb említett megvalósítása javítható, ha visszavezetjük lineáris keresésre, amelynek során keressük az első olyan j  N , j  [1..m] pozíciót, amelyre S[k  j ]  M [ j ] . Amennyiben nem találunk ilyen j pozíciót, azaz j  N , j  [1..m] : S[k  j ]  M [ j ] , akkor az M illeszkedik S-re k eltolással, tehát S[k+1..k+m] = M[1..m] vizsgálat eredménye legyen igaz, különben pedig hamis. Ezt a megoldást nevezzük az egyszerű mintaillesztés algoritmusának, amely nem más, mint egy lineáris keresésbe ágyazott lineáris keresés. Az algoritmust szemléletesen úgy lehet elképzelni, mintha a mintát tartalmazó "sablont" tolnánk végig a szövegen, és balról jobbra ellenőrizzük, hogy a minta karakterei egyeznek-e a "lefedett" szöveg karaktereivel. Amennyiben nem egyező karakterpárt találunk, a mintát egy pozícióval jobbra "toljuk" a szövegen, és újra elkezdjük a minta elejéről az összehasonlítást. Az így kapott eljárás algoritmusát a 31.3. ábra tartalmazza.

31.3. ábra. Az egyszerű mintaillesztés algoritmusa Nézzük meg egy példán az algoritmus működését. A 31.4. ábra első sorában szerepel a szöveg, alatta a minta a megfelelő eltolásokkal. A mintán ritka pontozású háttérrel jelöltük, azokat a karaktereket, amelyeknél az összehasonlítás igaz értéket adott, és sűrű mintázatú háttérrel, ahol az illeszkedés elromlott.

31.4. ábra. Az egyszerű mintaillesztés működése Először mintát a szöveg első pozíciójára illesztjük, majd a mintán balról jobbra haladva vizsgáljuk a karakterek egyezését a szöveg megfelelő karaktereivel. A minta első illetve második karaktere ('A' és 'B') megegyezik a szöveg első és második karakterével, azonban a minta harmadik karaktere ('C') nem azonos a szöveg harmadik karakterével, tehát a minta nem illeszkedik az első pozícióra. "Toljuk el" a mintát egyel, majd a minta elejétől kezdve balról jobbra haladva ismét vizsgáljuk a karakterek egyezését a lefedett szövegrész megfelelő karaktereivel. Már a minta első karakterénél ('A') elromlik az illeszkedés. Ismét "toljuk el" eggyel jobbra a mintát, és a már ismertetett módon vizsgáljuk az illeszkedést. Tizenegy összehasonlítás után eljutunk a végeredményhez, amely szerint a minta az 5. pozíción illeszkedik először a szövegre. 31.2.1. Műveletigény Műveletigény szempontjából a legjobb eset, amikor a minta az első pozíción illeszkedik a szövegre, ekkor az összehasonlítások száma minden mintaillesztő algoritmusnál m lesz. Ezt az esetet joggal tekinthetjük érdektelennek, mivel nem ad az algoritmus sebességére vonatkozóan valósághű jellemzést. Továbbiakban a mintaillesztési algoritmusok vizsgálata során mindig feltesszük, hogy a minta nem fordul elő a szövegben, így az algoritmusnak fel kell dolgoznia a "teljes" szöveget.

A különböző algoritmusok hatékonysága abban fog különbözni, hogy ebben az esetben mennyire "gyorsan" tudnak "végig menni" a szövegen. Tegyük fel még azt is, hogy a minta hossza nagyságrendben kisebb vagy egyenlő, mint a szöveg hossza, azaz m  O(n) (a gyakorlatban a minta hossza jóval kisebb, mint a szöveg hossza). Ezek előre megadjuk az egyszerű mintaillesztés műveletigényét. A legjobb esetben a minta első karaktere egyáltalán nem szerepel a szövegben, így minden k eltolásnál már j=1 esetben mindig elromlik az illeszkedés. Tehát minden eltolásnál csak egy összehasonlítás történik, így az összehasonlítások száma megegyezik az eltolások számával, (n-m+1)-gyel. Tehát mÖ(n, m)  n  m  1  (n) .

31.5. ábra. Példa az egyszerű mintaillesztés legjobb esetére A legkedvezőtlenebb esethez akkor jutunk, ha minden eltolásánál csak a minta utolsó karakterénél romlik el az illeszkedés. Ekkor minden eltolásnál m összehasonlítást végzünk, így a műveletigény az eltolások számának m-szeresével jellemezhető. Tehát MÖ(n, m)  (n  m  1) * m  (n * m) .

31.6. ábra. Példa az egyszerű mintaillesztés legrosszabb esetére 31.2.2. Szekvenciális sorozatokra, fájlokra való alkalmazhatóság A gyakorlatban az általunk szövegnek nevezett sorozat nem egyszer igen nagyméretű is lehet, emiatt csak olyan szekvenciális formában áll rendelkezésünkre, amelyen az indexelés nem megengedett művelet. Hasznos lehet annak vizsgálata, hogy az ismertetett algoritmust mennyire egyszerű átírni szekvenciális sorozatokra, illetve fájlokra. Az egyszerű mintaillesztő algoritmus szekvenciális sorozatokra történő átírásánál kénytelenek vagyunk puffert használni, mivel a szövegben időnként vissza kell "ugrani" (akkor, ha az illeszkedés nem a minta első karakterénél romlik el).

32. A Knuth-Morris-Pratt algoritmus A „nyers erőt” használó egyszerű mintaillesztés műveletigénye legrosszabb esetben m*n-es volt. A Knuth-Morris-Pratt algoritmus (KMP-vel rövidítjük) egyike azon mintaillesztő eljárásoknak, amelyek ügyes észrevételek és mélyebb megfontolások alapján hatékonyabb módon oldják meg az stringkeresés feladatát. 32.1. Az algoritmus elve Amikor az egyszerű mintaillesztés során az illeszkedés elromlott, a mintát egy pozícióval eltoltuk és az elejétől újra kezdtük a minta és a lefedett szövegrész összehasonlítását. Nem biztos azonban, hogy a már megvizsgált szövegrész minden karakterén újra át kell haladni. Amennyiben az illeszkedés elromlik, akkor egy „hibás kezdetünk” van, de ez a kezdet ismert, mivel az elromlás előtti karakterig egyezett a mintával. Ezt az információt használjuk fel arra, hogy elkerüljük az állandó visszalépést a szövegben a minta kezdetére. Tekintsük a 32.1. ábrán látható illeszkedési feladatot.

32.1. ábra. Példa illeszkedésvizsgálatra (KMP)

A példában a minta 6. pozíciójánál romlik el az illeszkedés, hiszen a minta első 5 pozíciója illeszkedett. Kérdés, hogy hová pozícionálhatjuk a mintát a szövegben, és honnan vizsgáljuk tovább az illeszkedést, hogy a minta előfordulását megtaláljuk (ha létezik, át ne "ugorjuk") és az eddig megszerzett 5 illeszkedő karakternyi információt felhasználjuk. Látható, hogy a minta illeszkedő részének ( M [1..5] ) van olyan valódi kezdőszelete (valódi prefixe), amely egyezik ezen illeszkedő rész egy valódi végszeletével (valódi szuffixével), hiszen M [1..3]  M [3..5] ('ABA' = 'ABA'). A vizsgálatot ezért úgy is folytathatjuk, hogy a kezdőszelete „rátoljuk” a vele megegyező végszeletre, ahogyan a 32.2. ábrán látható.

32.2. ábra. A minta megfelelő eltolása (KMP)

(A továbbiakban inkább a karakterisztikusabb pefix és szuffix kifejezéseket használjuk a leírásban.) Egy prefix vagy szuffix valódi, ha hossza legalább 1, és kisebb, mint annak a sorozatnak a hossza, amelynek a prefixe vagy szuffixe. Amennyiben a mintával akkorát ugrunk, hogy a minta eleje az említett szuffixnél kezdődjön, azaz az prefix a vele egyező szuffixszel kerüljön fedésbe, a prefixet már nem kell újra vizsgálni, mivel az azonos a szuffixel, ami megegyezik a szöveg lefedett részével, mivel az részsorozata az eredetileg illeszkedő M [1..5]  S[k  1..k  5] szövegrésznek. Ezek után az illeszkedés vizsgálatot a szöveg "elromlott" S[k  6] karakterével, és az említett prefix utáni első karakterrel lehet tovább folytatni. Mi a teendő több ilyen egyező prefix és szuffix pár esetén? A példában is találhatunk egy másik párost, az M [1..1]  M [5..5] ('A'='A'). Ha annak megfelelően pozícionáljuk a mintát, ahogyan a 32.3. ábra is mutatja, majd a következő karaktertől kezdünk összehasonlítani, azt tapasztaljuk, hogy nem illeszkedik a minta a szövegre, mert "átugrottunk" egy illeszkedést.

32.3. ábra. Nem megfelelő eltolás több egyező prefix és suffix pár esetén

Tehát a legkisebb olyan ugrást kell választanunk, ahol a minta M [1..5] részsorozatának egy prefixe illeszkedik a részsorozat egy szufixére. Akkor "ugrunk" a legkisebbet, ha a legnagyobb ilyen prefixet választjuk. 32.2. Az algoritmus helyessége Ahhoz, hogy az algoritmus helyességét belássuk, a következő kérdéseket kell tisztáznunk. Tegyük fel, hogy a minta M [1.. j ] részsorozata illeszkedett a szöveg S[k  1..k  j ] részsorozatára és az illeszkedés a következő pozíción romlott el, azaz M [1.. j ]  S[k  1..k  j ] és M [ j  1]  S[k  j  1]

1. Ha létezik M [1.. j ] részsorozatnak olyan valódi prefixe ( p  M [1..x] ) és szuffixe (s  M [ j  x  1.. j ]), hogy p  s , akkor valóban állítható-e, hogy az ugrás után biztosan nem kell újra vizsgálni az M [1..x] és az általa lefedett S[k  j  x  1..k  j ] szövegrészt? Biztosan nem kell, mivel p  s , azaz M [1..x]  M [ j  x  1.. j ] , továbbá az illeszkedés az M [ j  1] pozíción romlott el, tehát M [1.. j ]  S[k  1..k  j ] , és ezek tetszőleges, jelenleg fedésben lévő részsorozatai is azonosak, azaz S[k  j  x  1..k  j ]  M [ j  x  1.. j ]  M [1..x]  S[k  j  x  1..k  j ] . Érvelésünket alátámasztja a 32.4. ábra.

32.4. ábra. Példa egyező valódi prefix-szuffix párosra

2. Mit tegyünk, ha nincs ilyen egymással megegyező valódi prefix-szuffix páros? Mivel M [1.. j ] illeszkedett és M [ j  1]  S[k  j  1] , i (k  i  k  j ) eltolásra a minta biztosan nem fog illeszkedni. Ahogyan a 32.5. ábrán is látszik, ahhoz hogy ilyen i eltolással illeszkedjen, az kellene, hogy legyen legalább 1 hosszú valódi, egymással azonos prefixszuffix páros ( p  s ), mert az M [1..x]  S[k  j  x  1..k  j ] részsorozatoknak illeszkedniük kell (ekkor i  k  j  x ) ahhoz, hogy teljes illeszkedés lehessen. Ebből pedig következik, hogy M [1..x]  M [ j  x  1.. j ] , mivel M [1.. j ]  S[k  1..k  j ] .

(Beláttuk tehát, hogy az M [1.. j ]  S[k  1..k  j ] feltétel esetén, az i (k  i  k  j ) érvényes eltolás szükséges feltételét is.) Tehát a mintával "átugorhatjuk" a már vizsgált S[k  1..k  j ] részt, és az illesztést a minta elejétől és a szöveg S[k  j  1] pozíciójától újra kezdhetjük. Ezt a konklúziót a 32.5. ábra is alátámasztja.

32.5. ábra. Példa nem egyező valódi prefix-szuffix párra

3. Mit tegyünk, ha több ilyen egymással megegyező, valódi prefix-szuffix páros is van? Ha több ilyen prefix-szuffix páros is van, akkor a leghosszabbat kell venni, mert ekkor "ugrunk" a legkisebbet. Ilyenkor nem fordulhat elő, hogy átugrunk egy előfordulást. Definiáljuk a next függvényt, amely megadja a minta egyes kezdőrészleteire a leghosszabb egymással egyező prefix-szuffix párok hosszát. Ezt felhasználva meg tudjuk adni a mintával való "ugrás" mértékét. ∀𝑗 ∈ [1. . 𝑚 − 1]: 𝑛𝑒𝑥𝑡(𝑗) ≔ max{ℎ ∈ [0. . 𝑗 − 1]} , ahol 𝑀[1. . ℎ] = 𝑀[𝑗 − ℎ + 1. . 𝑗] A next függvénnyel kapcsolatban a következő megjegyzéseket tesszük. 

A next értelmezési tartományát elegendő ( m  1 )-ig definiálni, mert ha ( j  m )-ig illeszkedik a minta, akkor találtunk egy érvényes eltolást, tehát készen vagyunk, és nem kell a mintával tovább lépkednünk.



A h = 0 legkisebb értékét, akkor veszi fel a függvény, ha nincs a minta M [1.. j ] kezdőszeletében egymással megegyező, valódi prefix-szuffix páros. Továbbá, ha létezik ilyen prefix-szuffix páros, az attól lesz valódi, hogy a hosszát ( j  1 )-gyel felülről korlátozzuk.



A next függvény csak a mintától függ, így értékeit a minta ismeretében a keresés előtt kiszámíthatjuk, és eltárolhatjuk egy next[1..m  1] vektorban.

32.3. A KMP algoritmus A minta elejétől kezdjük összehasonlítani a szöveg és a minta egymással fedésben lévő karaktereit. Amennyiben a szöveg és minta karakterei azonosak, akkor a szövegben és a mintában egyaránt eggyel továbblépünk. Azonban, ha a karakterek különböznek, a következőket tesszük. 

Ha a minta elején állunk ( j  0 esetén): a szöveg következő pozíciójától ( S[k  j  2] ) és a minta elejétől kezdve újra kezdjük az illeszkedés vizsgálatot, mivel a next függvény a valódi prefix-szuffix hosszát adja meg, de az 1 hosszú sorozatnak nincs valódi prefixe vagy szuffixe ( next [1]  0 ).



Ha nem a minta elején állunk ( j  0 esetén), akkor a next függvényben rögzített eltolást hajtjuk végre: azt mondjuk, hogy eddig j hosszon illeszkedett a minta, továbbiakban next [ j ] hosszon illeszkedik. Az összehasonlítást a minta M [next [ j ]  1] karakterétől és a szöveg S[k  j  1] karakterétől folytatjuk, azaz a szövegben onnan, ahol az illeszkedés elromlott.

Mivel a szövegben legfeljebb 1 hosszú lépésekkel haladunk végig, az egyszerűség kedvéért a k eltolásnak megfelelő változó helyett használjunk egy i változót, amellyel a szövegben szekvenciálisan haladunk ( i  k  j ), majd az algoritmus végén beállítjuk a k változó értékét. A KMP algoritmus a 32.6. ábrán látható.

32.6. ábra. A KMP algoritmus

Az initnext eljárás során töltjük fel a next vektort. A feltöltés ötlete: a minta elcsúsztatott keresése önmagán (KMP algoritmussal), miközben feljegyezzük a legnagyobb illeszkedő részek hosszát. Nézzük meg egy példán a feltöltés menetét. Legyen a minta M = 'ABABAC'. Már korábban láttuk, hogy next [1]  0 . Ezután a next [2] értékét szeretnénk meghatározni. Ekkor az M [1..2] kezdőrészletnek keressük a legnagyobb egymással megegyező, valódi prefix-szuffix párját. A legnagyobb ilyen valódi prefix-szuffix 1 hosszúságú lehet. Tehát az a kérdés, hogy az M [1]  M [2] egyenlőség teljesül-e? Ehhez a mintát csúsztassuk el eggyel, és a fedésben lévő karaktereket vizsgáljuk (lásd: 32.7. ábra):

32.7. ábra. A next vektor kiszámítása (1)

Látható, hogy a két karakter nem azonos, így next [2]  0 .

Most a next [3] meghatározása következik. Ekkor az M [1..3] kezdőrészletnek keressük a legnagyobb egymással megegyező, valódi prefix-szuffix párját. A legnagyobb ilyen valódi prefix-szuffix 2 hosszú lehet. Azaz M [1..2]  M [2..3] egyenlőség teljesül-e? Azonban ez nem teljesülhet, mivel már M [1]  M [2] sem teljesült. Ezt nem is vizsgáljuk, mivel már az előző menetben sem volt egyezés. Helyette a mintát eggyel jobbra csúsztatjuk, és az M [1]  M [3] egyenlőséget vizsgáljuk (lásd: 32.8. ábra):

32.8. ábra. A next vektor kiszámítása (2)

A vizsgált egyenlőség fennáll, ezért feljegyezzük, hogy next [3]  1. Ezután a next [4] kiszámítása a cél. Ekkor az M [1..4] kezdőrészletnek keressük a legnagyobb egymással megegyező, valódi prefix-szuffix párját. A legnagyobb ilyen valódi prefix-szuffix 3 hosszú lehetne, de M [1]  M [2] egyenlőséget már korábban is megvizsgáltuk és nem teljesült, így ez nem jöhet szóba. Azonban, az előző menetben M [1]  M [3] teljesült, így az ennek megfelelő elcsúsztatott pozíciót megtartva vizsgáljunk tovább, mert további karakter egyezés esetén ez lehetne a leghosszabb prefix-szuffix pár (lásd: 32.9. ábra):

32.9. ábra. A next vektor kiszámítása (3)

Valóban az M [2]  M [4] teljesül, így feljegyezzük next [4]  2 értéket. A next [5] meghatározásához, az előző menethez hasonlóan a mintát nem csúcstatjuk el, hanem a következő karaktert vizsgáljuk (lásd: 32.10. ábra):

32.10. ábra. A next vektor kiszámítása (4)

Azt látjuk, hogy M [3]  M [5] , így feljegyezzük next [5]  3 . Összefoglaljuk egy ábrán a next függvény kiszámítását (lásd: 32.11. ábra):

32.11. ábra. A next vektor kiszámítása (összefoglalás)

A next vektor kitöltésének részletes végigkövetése után már nem nehéz felírni az initnext eljárást, amely 32.12. ábrán látható. Ezzel teljessé vált a 32.6. ábrán megadott KMP mintaillesztő algoritmus, ugyanis az inicializáló eljárását is megalkottuk.

32.12. ábra. Az initnext algoritmus (KMP)

Az initnext eljárás különlegessége az, hogy a KMP mintaillesztés inicializálására szolgál, de a next vektor kitöltésére is lényegében a KMP algoritmust használjuk. Ezt az teszi lehetővé, hogy a kitöltés éppen olyan mértékben halad előre a next vektoron, mint ami a számítás továbblépéséhez szükséges, amivel egy saját belső inicializáló eljárást valósítunk meg. A KMP algoritmus műveletigényének megállapításához vegyük figyelemben, hogy inicializáló tevékenység, az initnext eljárás lépésszáma (m). Tegyük fel, hogy m  n ; ekkor a keresés műveletigénye legjobb és legrosszabb esetben is egyaránt (n) . A KMP algoritmust ezért stabil eljárásnak mondhatjuk. Mivel a KMP algoritmus működése során a szövegben csak legfeljebb egy pozícióval történő előre lépést teszünk (nincs visszalépés), így az algoritmus puffer használata nélkül is átírható szekvenciális sorozat, illetve fájl formában adott szövegre.

33. A BOYER-MOORE ALGORITMUS EGY VÁLTOZATA Az egyszerű mintaillesztés műveletigénye legjobb esetben n-es volt; most ezen próbálunk javítani. Az algoritmus, amelyet ismertetünk A Boyer-Moore mintaillesztés több javasolt változata körül az, amelyiknek a „Quick Search” (QS) nevet adta a szerzője (Horspool). Az előző fejezetben ismertetett KMP eljárás a mintán belüli karaktersorozat előzetes elemzése révén jutott hatékony mintaillesztéshez. A QS algoritmus más stratégiát követ. Ha az illeszkedés elromlik, akkor a szövegnek a minta utáni első karakterét (S[k+m+1]) vizsgálja. Ennek a karakternek a függvényében dönti el, hogy hány pozícióval lépteti jobbra a mintát. 33.1. A QS eljárás működési elve Az eljárás működése során az alábbi két esetet különböztetjük meg: 1. A szöveg minta utáni első karaktere nem fordul elő a mintában, azaz S[k  m  1]  M . Ekkor bármely olyan illesztés sikertelen lenne, ahol az S[k  m  1] pozíció fedésben lenne a mintával. Tehát ezt a pozíciót "átugorhatjuk" a mintával, és a szöveg következő, S[k  m  2] indexű karakterétől kezdhetjük újra, a minta elejétől kezdve, az illeszkedés vizsgálatát. Ezt az esetet illusztrálja a 33.1. ábra.

33.1. ábra. Példa: a szöveg minta utáni első karaktere nem fordul elő a mintában

2. A szöveg minta utáni első karaktere előfordul a mintában, azaz S[k  m  1]  M . Ekkor vegyük a mintabeli előfordulások közül jobbról az elsőt (balról, a minta elejétől számítva az utolsót) és annyi pozícióval léptessük jobbra a mintát, hogy ez a mintabeli karakter fedésbe kerüljön a szöveg S[k  m  1] karakterével, majd a minta elejétől kezdve vizsgáljuk meg újra az illeszkedést. Ez az eset látható a 33.2. ábrán.

33.2. ábra. Példa - szöveg minta utáni első karaktere előfordul a mintában

Az S[k  m  1] karakternek azért kell jobbról az első mintabeli előfordulását tekinteni, mert ha létezik egy ettől balra lévő előfordulás is, és amennyiben azt választanánk, akkor esetleg átlépnénk egy jó illeszkedést, mint ahogyan a 33.3. ábrán látható esetben ez megtörténik. Akárcsak a KMP algoritmusnál, itt is követni kell az óvatos legkisebb biztonságos eltolás elvét.

33.3. ábra. Példa egy jó illeszkedés átugrására

31.2. A QS algoritmus A QS algoritmusnak is a lényegi részét adja a minta alkalmas eltolásának ismételt végrehajtása. Az eltolás mértékének meghatározása bevezetjük a shift függvényt, amely az ábécé minden betűjére megadja az eltolás nagyságát, ha az illeszkedés elromlása esetén az illető betű lenne a szöveg minta utáni első karaktere. Definíció:

shift : H  [1..m  1]

m  1 shift ( x)   m  j  1

, ha x  M , ha M [ j ]  x  i [ j  1..m] : M [i]  x

Megjegyzések:  

A definícióban is jól láthatóan elválik a fent említett két eset. Mivel a shift függvény csak a mintától függ, értékeit előre kiszámíthatjuk, és eltárolhatjuk egy vektorba, amit az ábécé betűivel indexelünk.

Most nézzük a shift függvény értékeinek a kiszámítására szolgáló initshift eljárást, amelynek algoritmusát a 33.4. ábra szemlélteti.. Az első ciklus feltölti a shift tömböt az m+1 értékkel. A második ciklus adja meg a minta össze betűjére – a jobbról az első mintabeli elfordulásának megfelelő – ugrás nagyságát. Azért balról-jobbra megyünk végig a mintán, hogy a többször előforduló betűk esetén az utolsó (jobbról az első) előfordulásnak megfelelő léptetést jegyezzük fel.

33.4. ábra. Az inshift eljárás algoritmusa

A QS algoritmus a shift függvény értékeinek kiszámításával kezdődik, majd k eltolásokkal próbáljuk illeszteni a mintát. Amennyiben az illeszkedés elromlik, akkor a shift függvénynek megfelelően változtatjuk az eltolás mértékét. (Ügyelnünk kell arra is, hogy amikor a mintát a szöveg végére illesztjük, és az illeszkedés elromlik, akkor ne olvassunk túl a szövegen.)

33.5. ábra. A QS mintaillesztés algoritmusa

A műveletigény meghatározását a legjobb esettel kezdjük. Ha a minta olyan karakterekből áll, amelyek nem fordulnak elő a szövegben, akkor már a minta első karakterénél elromlik az illeszkedés. Továbbá, ha a szövegben a minta utáni karakter nem fordul elő a mintában, akkor azt átugorhatjuk. Ezt az esetet illusztrálja a 33.6. ábra.

33.6. ábra. A QS algoritmus számára legkedvezőbb eset

A legkedvezőbb esetben a műveletigény:  n  mÖ(n, m)     m 1

A legrosszabb esetben egyrészt a minta végén romlik el az illeszkedés, másrészt mindig csak kicsiket tudunk ugrani. Ilyen esetet mutat a 33.7. ábra.

33.7. ábra. A QS algoritmus számra legkedvezőtlenebb eset

A legkedvezőtlenebb esetben a műveletigény:

MÖ(n)  (n * m) A QS algoritmust szekvenciális input fájlra, illetve más, közvetlenül nem indexelhető sorozatra, csak puffer használatával lehet alkalmazni, mivel – ahogyan az a legrosszabb esetben is látható – szükség lehet a szövegben való visszalépésre.

34. A RABIN-KARP ALGORITMUS Az előző két mintaillesztő algoritmus a karakterekből álló minta minél nagyobb ugrásaival törekedett a szöveg illeszkedésre esélyes pozícióinak bejárására. Az előttünk álló Rabin-Karp eljárás a mintát, valamint a szöveg ugyanolyan hosszúságú szakaszait nagy egész számokkal kódolja, így az illeszkedés vizsgálat két szám egyenlőségének ellenőrzésére vezet. Az eljárás ezzel a szemlélettel „elveszíti” a közvetlen kapcsolatot az egyes karakterekkel, így várhatóan nem lesz képes nagyobb ugrásokra, hanem mindig csak egy pozícióval csúsztatja jobbra a mintát a szövegen, ahol minden helyzet egy új egész számot határoz meg, mint a lefedett karakterek kódját. Tekinthetjük úgy az eljárást, hogy egy egész számokból álló sorozaton lineáris kereséssel keressünk egy szám első előfordulását, ahol ez a keresett szám a mintának a kódolásából származik. Ettől a módszertől azt várhatjuk, hogy hatékony lesz, mivel az egész számokkal való műveletek gyorsan végrehajthatóak. Mivel azonban igen nagy számokról lehet szó, az egyelőségük fogalma és ellenőrzése összetettebb, mint a megszokott nagyságrendekben, így a hatékonyság nem magától értetődik. 34.1. Az algoritmus elvi alapjai Legyen az ábécé a H véges halmaz, melynek elemeit sorszámozzuk meg [0.. d  1] közötti egész számokkal. Ekkor d  H , az ábécé betűinek száma. Ekkor egy H feletti szót (karakter sorozatot) úgy is tekinthetünk, mint egy d alapú számrendszerben felírt számot. Például a "BBAC" szónak megfelelő szám tízes számrendszerben 1102, ha az 'A', 'B' és 'C' sorszámai rendre 0, 1 és 2. Vezessük be azt a függvényt, amely megadja egy karakter H-beli sorszámát: ord : H  [0.. d  1] Ekkor az M [1.. m] mintának megfelelő szám m

x   ord ( M [ j ])d m  j  ord ( M [1])d m 1  ord ( M [2])d m  2  ...  ord ( M [m])d 0 , j 1

amelyet Horner algoritmussal hatékonyabban is kiszámíthatunk

x  (...((ord (M [1]) d  ord (M [2])) d  ord (M [3])) d  ...  ord (M [m 1])) d  ord (M [m]) . A Rabin-Karp algoritmus lényege az, hogy a bemutatott módon kapott x számot hasonlítjuk össze minden egyes k eltolás esetén, a szöveg S[k  1..k  m] részsorozatával, mint egy d alapú számrendszerben felírt számmal. Jelöljük si -vel az S[i.. i  m  1] szónak megfelelő számot ( i  k  1). Ekkor a feladatot visszavezethetjük egy egész számok sorozatán való lineáris keresésre (lásd: 34.1. ábra).

34.1. ábra. Lineáris keresés egész számok sorozatán

Nézzünk egy példát új eljárásunkra. Legyen a szöveg S = "DACABBAC", a minta pedig M = "BBAC". A 34.2. ábrán követhetjük, hogy az egyes pozíciókban milyen számok kerülnek összehasonlításra.

34.2. ábra. Példa a lineáris keresés szerinti működésre

A példában az is látható, hogy a mintát kódoló x-et csak egyszer kell meghatározni (mivel nem változik). Azonban az si egész számokat minden léptetés után újra kell számolni, ami igen költséges még akkor is, ha Horner algoritmussal számítjuk. Hogyan lehetne si+1-et hatékony számolni si ismeretében? Legyen például a 23456 sorozat olyan, amelyben a 2345 szám nem egyezik a négy karakteres mintával. A 2345 szám ismeretében állítsuk elő a 3456 számot! Balról töröljük a 2es számjegyet, majd a megmaradt 345 szám jegyeit egy helyi értékkel balra léptetjük, végül hozzáadjuk a 6-ot, azaz (2345-2*1000)*10+6 = 3456 lesz az új érték. Általánosan:





si 1  si  ord (S[i]) * d m1 * d  ord (S[i  m])

A formula két szorzást tartalmaz, mivel d m1 -et előre kiszámíthatjuk, és egy változóban tárolhatjuk, így menet közben nem kell hatványozni. A módszer lényegét körvonalaztuk, mivel egy karaktersorozat kölcsönösen egyértelműen megfeleltettünk egy egész számnak, a feladatot visszavezettük egy egész számokon értelmezett lineáris keresésre, amelynek műveletigénye a fenti formula használatával lineáris marad. 34.2. Az algoritmus részletes kidolgozása A gyakorlatban hosszabb minta vagy túl nagy ábécé esetén előfordulhat, hogy egy m számjegyből álló, d alapú számrendszerbeli szám nem tárolható egy szabványos egész számként (túlcsordul). Amennyiben nem egész számtípusként tároljuk, a műveletek végrehajtási ideje megnő. A megoldás az, hogy vegyünk egy kellően nagy p prímet, amely mellett d*p még ábrázolható, és modulo p számoljuk az x-et és az si-t. Ekkor az egyértelműséget elveszítjük, mivel egyenlőség helyett a maradékosztályokhoz való tartozást vizsgáljuk, de ez a körülmény a nem-egyenlő eseteket nem teszi egyenlővé, azaz (1) x mod( p)  si mod( p)  x  si Ha viszont egyenlőséget állapít meg a vizsgálat, az csak azonos maradékosztályba való tartozást jelent, ami elvileg nem garantálja azt, hogy a két érték valóban egyenlő: (2) x mod( p)  si mod( p)  x és si azonos maradékosztályba esik, tehát további vizsgálat szükséges, azaz karakterenkénti összehasonlítással eldöntjük, hogy M [1.. m]  S[i.. i  m  1] egyenlőség teljesül-e.

Hamis találatnak szokás nevezni azt az esetet, amikor x mod( p)  si mod( p) , de M [1.. m]  S[i.. i  m  1] . Minél nagyobb p-t választunk, a maradékosztályokba annál kevesebb elem esik, így várhatóan a hamis találatok száma is kevesebb lesz. Visszatérve az si 1 érték számításának módjára, az most így módosul: si 1  ((si  ord (S[i]) * d m 1 ) * d  ord (S[i  m])) mod( p)

A modulo számítás következtében újabb probléma merült fel: si  ord (S[i]) * d m1 értéke lehet negatív is, amely megkövetelné az előjeles egész számok használatát, így viszont abszolút értékben kisebb számok lennének ábrázolhatók, miközben p-t szeretnénk minél nagyobbnak választani. Adjunk ezért si -hez d*p-t, ami biztosítja, hogy a különbség nem lesz negatív, és az osztási maradékot sem változtatja: si 1  ((si  d * p  ord (S[i]) * d m1 ) * d  ord (S[i  m])) mod( p)

A kifejezés túlcsordulásának a megelőzésére számítsuk a kifejezést két lépésben (modulo esetén megtehetjük):

s  ( si  d * p  ord ( S[i]) * d m1 ) mod( p)  si 1  ( s * d  ord ( S[i  m])) mod( p) Összes eddigi megfontolásunkat tartalmazza a Rabin-Karp algoritmus végleges formája, amely a 34.3. ábrán látható.

34.3. ábra. A Rabin-Karp algoritmus

A műveletigény a legjobb esete abból adódik, ha egyszerűen végigolvassuk a szöveget és minden esetben azt kapjuk, hogy a minta nem egyezik a lefedett szövegrésszel ( x  s ).

Ebben az esetben a mintaillesztés műveletigénye a szöveg hosszában lineáris lesz:

mÖ(n, m)  (n) A legrosszabb eset akkor áll elő, ha a választott p prímszám mellett mindig hamis találatot kapunk. Ekkor minden esetben karakterenként is megvizsgáljuk az M [1..m]  S[i  1..i  m] egyenlőség teljesülését, Ebben az (elméletileg nem kizárható) esetben lényegében visszakapnánk az egyszerű mintaillesztés algoritmusát. Tehát

MÖ(n, m)  (n * m) . A tapasztalat azt mutatja, hogy „jó” p választása esetén, nincs vagy csak nagyon kevés a hamis találat, így a Rabin-Karp algoritmus várhatóan lineáris marad:

AÖ(n, m)  (n) . A szekvenciális fájl input esetén fel kell készülni a puffer használatára, ugyanis az x  s találat esetén, a szövegben vissza kell lépni, és karakterenként meg kell vizsgálni az M [1..m]  S[i  1..i  m] egyenlőség teljesülését.

35. MINTAILLESZTÉS AUTOMATÁVAL Ha ma tudni szeretnénk, hogy mi Zimbabwe fővárosa, mikor írta Petőfi az Anyám tyúkját, mi a szinusz függvény definíciója, akkor ma már nem állunk neki a lexikonok böngészésének, hanem az Internetet hívjuk segítségül. Valamelyik kereső programba beírjuk azt a tudásmorzsát, amelyet az adott kérdéskörből ismerünk. A program az Interneten lévő dokumentumok közül felsorolja mindazokat, melyek illeszkednek az előbbi tudáselemhez. Beütve például a Google-ba a „Zimbabwe” szót, felsorolásra kerülnek a Zimbabwéről szóló dokumentumok. Ezek között előkelő helyen ott lesz a Wikipedia Zimbabwét bemutató cikke, melyben aztán megtalálhatjuk a főváros nevét. A tudásmorzsa a legtöbbször – mint előbb is – valamilyen szó, a keresés számára egy minta, és azokat a dokumentumokat kapjuk vissza valamilyen sorrendben, melyekben ez a minta előfordul. A szövegfeldolgozó rendszerek, például a Word is, kínálnak hasonló jellegű lehetőségeket. Ha ebben a jegyzetben meg szeretnénk keresni, mi a mintaillesztés fogalma, akkor a Word keresés funkciójánál begépeljük a „mintaillesztés” szót, beállítjuk az „összes” opciót. A keresés eredményként az jegyzet elejétől kezdve rendre villogva ráállhatunk a „mintaillesztés” szó előfordulásaira, melyek között ott lesz az is, amely a definíciót tartalmazza. 35.1. A mintaillesztési feladat Az bevezetőben szereplő két példa a mintaillesztési feladat két lehetséges változatát mutatja be. Az első esetben a feladat az, hogy valamely szövegről eldöntsük, hogy benne van-e részszövegként az adott minta (ezek összességét kell azután felsorolni). A második esetben nem csak el kell döntenünk, hogy szerepel-e a szövegben az adott minta, hanem annak összes lehetséges előfordulását is meg kell keresnünk. Világos, hogy a második feladat megoldásával az első feladatot is megoldjuk, hiszen a minta első előfordulása után már jelezhetjük, hogy a minta megtalálható a szövegben. Fogalmazzuk meg pontosan a feladatot. Ehhez szükségünk lesz néhány jelölésre, illetve fogalomra. A fejezetben legyen X egy rögzített ábécé (véges, nem üres szimbólumhalmaz), X* az X elemeiből képzett véges sorozatok halmaza, míg X+ a nem üreseké. Egy u  X* szó, mint sorozat hosszára |u| jelölést fogjuk használni. Az X* elemeit X ábécé feletti szavaknak, vagy X rögzítettsége esetén egyszerűen csak szavaknak hívjuk. Az üres szó jele ε. Tetszőleges u  X* és h  0 esetén definiáljuk u szó h hosszú kezdőszeletét (végszeletét), mely h  |u| esetén u első (utolsó) h jele, illetve az egész u, ha |u| < h. Az első esetben valódi kezdőszeletről (végszeletről) beszélünk. Jelölésük pre (u, h), illetve suf (u, h) (a nekik megfelelő angol szavak, prefix, illetve suffix rövidítésével). (Megjegyezzük, hogy ez a terminológia kissé megtévesztő, hiszen a h hosszú kezdőszelet (végszelet) hossza csak h  |u| esetében h hosszúságú szó, egyébként magának az u-nak a hosszával egyenlő.) Rögzítsük most X* egy m = m1m2… m|m| nem üres szavát, melyet mintának fogunk nevezni.

Azt mondjuk, hogy m illeszkedik az u  X* szóhoz, ha u felírható az u = vmw alakban, ahol v és w X feletti szavak. Az m illeszkedik az u elejéhez (végéhez), ha illeszkedik hozzá és v = ε (w = ε). Definíció: Az m minta illeszkedési függvénye, illm egy olyan illm : X*  {0,1}* leképezés, melyben tetszőleges u  X* mellett |illm (u)| = |m| +1. Továbbá, minden 0  i  |u| esetén: illm(u)i+1 = 1  az u szó i hosszú kezdőszeletének végéhez illeszkedik az m minta. Definíció: A mintaillesztés feladata valamely m  X* mintára az illm illeszkedési függvény előállítása. A feladatot egy olyan algoritmussal fogjuk megoldani, amely által megvalósított : X*  {0,1}* függvényre  = illm. Világos, hogy  = illm pontosan akkor teljesül, ha minden v  X* szóra teljesül, hogy (v)|v|+1 = 1  v végére odailleszthető az m minta. (u kezdőszeleteit választjuk rendre v-nek). A megoldás helyességének belátásához a továbbiakban ezt a tulajdonságot fogjuk ellenőrizni. A feladatot speciális algoritmussal, megjelölt állapotú, véges, determinisztikus automatával fogjuk megoldani. Később látni fogjuk, hogy ezt az automatát megvalósíthatjuk valamilyen szokásos, algoritmusokat leíró nyelven. 35.2. Megjelölt állapotú véges determinisztikus automaták A megjelölt állapotú véges, determinisztikus automata egy absztrakt matematikai gép, melynek sémája a következő 35.1. ábrán látható.

35.1. ábra. Megjelölt állapotú automata

A gép diszkrét időskálában működik. Elindul egy kezdeti állapotból és ütemenként elolvassa a bemeneti szalagján lévő jeleket. Egy ütem során aktuális állapota és az olvasott bemenő jel függvényében új állapotba megy át. Minden érintett állapot esetén (tehát a kiinduló állapotban is), kiad egy Y-beli, csak az állapottól függő kimeneti jelet. A működés eredménye a kimenetre írt, a bemeneti szónál eggyel hosszabb szó. A megjelölt állapotú véges, determinisztikus automatára adott informális leírást a következő módon formalizálhatjuk: Definíció: Az A = < A, X, Y, , a0,  > hatost megjelölt állapotú véges, determinisztikus automatának nevezzük, ahol  

A: véges halmaz, az automata állapotainak halmaza, X: ábécé, az automata bemenő jeleinek halmaza,

   

Y: ábécé, az automata kimenő jeleinek halmaza, : A  X  A alakú, mindenütt értelmezett leképezés, az állapot-átmeneti függvény, a0 : a0  A, az automata kezdőállapota, : A  Y alakú, mindenütt értelmezett leképezés, a jelölő függvény.

Adjuk meg most az automata működési módját. Egy u = u1u2…u|u|  X* bemenetre való működés leírásához először kiterjesztjük a  állapot-átmeneti függvényt : A  X*  A alakú függvénnyé:  (a, u1u2…u|u|) = a’  létezik olyan c0c1…c|u|  A+ sorozat, hogy c0 = a, cn = a’ és minden i = 0, 1, n-1 esetén  (ci, ui) = ci+1. A c0c1…cn sorozat az u = u1u2…un bemenet feldolgozása során sorozata (bele értve a kiinduló állapotot is).

érintett állapotok

Definiáljuk most a : A  X*  Y* kimeneti függvényt a következő módon: (a,u1u2…u|u|) =  (c0)  (c1)…  (cn), ahol c0c1…cn az előbb definiált, a működés során érintett állapotsorozat Világos, hogy tetszőlegesen rögzített a  A, u  X* és u = vw mellett igaz hogy |(a,u)| = |u| + 1 és (a,u) = (a,v)suf(((a,v),w),|w|). Az A által megvalósított A : X*Y* leképezés ezek után a következő: A(u) = (a0,u). A véges automaták struktúráját és működését leírhatjuk a szokásos algoritmus leíró nyelvek valamelyikével is. A  átmeneti függvény értékeit tárolhatjuk egy  nevű, |A|  |X| méretű, A típusú mátrixban, míg -t egy  nevű |A| méretű, Y típusú vektorban. Használunk még egy Aktáll nevű, állapot típusú változót, továbbá egy-egy X* típusú, illetve Y* típusú változót az Bemenet és Kimenet névvel. Az Aktáll változót a0-ra, míg Kimenet-et (a0)-ra inicializálva rendre beolvassuk u elemeit az Bemenet-ről. A  mátrixból minden beolvasott jellel aktualizáljuk Aktáll-t (tehát végrehajtjuk az átmenetet), majd az M vektor alapján Aktáll (most már új) értékéből meghatározzuk a következő Y-beli kimenetet, amit a Kimenet végére írunk. Amennyiben |A| nagy, akkor a  és M sok helyet foglalhat el. Ilyenkor a -ban és M-ben való tárolást megpróbálhatjuk elkerülni úgy, hogy  és  érékét valamilyen eljárással, képlettel számítjuk ki. Ehhez persze az állapotokon valamilyen szabályszerűségeknek kell lenniük. 35.3. Mintaillesztés megjelölt állapotú véges, determinisztikus automatákkal Az m mintánkhoz olyan A megjelölt állapotú véges, determinisztikus automatát építünk, melyre A = illm. Ehhez természetesen az Y = {0,1} kimeneti ábécét fogjuk használni. A konstrukció alapötlete, hogy A állapotaiban olyan információt tárolunk, mely alapján az addig beolvasott v  X* bemenetről eldönthető, hogy a végéhez illeszkedik-e a minta. Ha igen, 1 választ adunk, egyébként 0-t. Azt, hogy az előbb említett információ az automata minden ütemének végén ott van az állapotkomponensben, invariáns tulajdonságnak, vagy egyszerűen invariánsnak fogjuk nevezni.

Világos, hogy elegendő, ha az invariánsban szereplő információ nem a teljes v-től, csak annak |m| hosszú végszeletétől, suf (v, |m|)-től függ (hogy előtte mi volt v-ben, az nem befolyásolja m-nek a v végére való illeszkedését). Az is világos, hogy az invariánsnak teljesülnie kell az automata kezdőállapotára, továbbá az is, hogy az átmeneti-függvénynek olyannak kell lennie, hogy az x bemenet az invariáns tulajdonságot a w = suf (v, |m|) végszeletről átörökítse suf (wx, |m|)-re. A legtermészetesebb ötlet az, hogy a tárolt információ legyen maga az |m| hosszú végszelet, hiszen így a vele való összehasonlítással |m| illeszkedése könnyen megállapítható. Később látni fogjuk, hogy ennél egyszerűbb struktúrájú információ is megfelel céljainknak. 35.4. A „nyers erő” automata A nyers erő automatában magukat a lehetséges |m| hosszú végszeleteket tárolja. A nyers-erő automata ennek megfelelően a következő: Anyers = (aw, x) = asuf (wx, |m|),

(aw) = 1  w = m. Az invariáns tulajdonság Anyers-ben, hogy állapotaiban az addig elolvasott szó |m| hosszú végszeleteit (X ≤ |m| elemei) jegyzi meg. A kezdőállapotra, a-ra ez nyilván, hiszen még nem olvastunk semmit. Az átmeneti függvény (aw, x) = a suf (wx, |m|) definíciója ezt az invariánst nyilván megőrzi. A v-re adott Anyers (v) kimenet utolsó, |v| + 1-edik jele  definíciója alapján pontosan akkor 1, ha v végéhez illeszkedik az m minta. Így  Anyers = illm. Az Anyers automata megvalósításában Aktáll karakterlánc, melyet maximum |m| méretű sorként kezelünk. Az állapotváltás az olvasott szimbólumnak a sorba való berakásával történik. Annak ellenőrzéséhez, hogy Aktáll tartalma m (vagyis 1 vagy 0 kimenetet tartozik-e hozzá) a sort körbe kell pakolni. A körbepakolás ideje nyilván arányos |m|-el, ezért az egész szimuláció műveleti igénye arányos |u||m|-el. Ahogy már utaltunk rá, nem feltétlenül szükséges az invariánsban a bemenet |m| hosszú végszeletét, mint sorozatot tárolni, Más reprezentáció, esetleg kevesebb tartalom is elég lehet az m-el való illeszkedés ellenőrzéséhez. Erre példák az alább ismertetett Rabin-Karp, Dömölky és Knuth-Morris-Pratt automaták. 35.5. A Rabin-Karp automata A Rabin-Karp automata alapötlete az, hogy X ≤ |m| elemeit nemnegatív egész számokból álló párokkal kódoljuk és a nyers erő automata struktúráját és működését ezeknek a számpároknak a segítségével szimuláljuk. (Ebben az alfejezetben szám alatt mindig nemnegatív egész számot fogunk érteni.) A számpárokkal való kódolás értelme az automata megvalósításánál jelentkezik, mert ekkor az Aktáll változó értéke nem karakterlánc lesz, hanem az őt kódoló számpár, melynek az elemein végzett műveletek gyorsan, konstans (|m|-től nem függő) idő alatt megvalósíthatók. (Ez persze csak akkor igaz, ha az adott szám befér programozási nyelvünk nemnegatív egész típusának értéktartományba. Erről később meg szót ejtünk.) Először foglakozzunk a kódolással. Tekintsük X jeleit számjegyeknek, és vezessük be egy v  X* szó esetén a v jelölést a v, mint d-áris szám értékére (az üres szónak 0-t feletetve meg). A w  X ≤ |m| alakú szavakat kölcsönösen egyértelműen kódolhatjuk a (|w|, w) alakú számpárokkal. (A |w| azért szerepel, hogy a kód egyértelműen visszakódolható legyen.

Például a 0 kód minden olyan szó kódja lenne, mely nem tartalmaz a 0 jegytől különböző jegyet. Közülük a kódban tárolt hossz fog egyértelműen meghatározni egyet, nevezetesen az annyi 0-t tartalmazó szót, amennyi ez a letárolt hossz Tekintsük most azt az állapot-átmeneti függvény definíciójánál jelentkező problémát, hogy ha ismerjük valamely w  X ≤ |m| szó (e,f) kódját, akkor ebből hogyan határozhatjuk meg suf(wx,|m|) kódját. Vizsgáljuk először az egyszerűbb, |w| = e < |m| esetet. Az x jel w mögé írása a d-áris számrendszerben d-vel való szorzásnak, majd x hozzáadásának felel meg. Ekkor tehát a kód (e +1, df + x). Ha |w| = e = |m|, akkor először suf (w, |m| - 1) kódját kell megkeresnünk, visszavezetve a dolgot az előbbi esetre. A d-áris számrendszer tulajdonságai miatt az első jegy leválasztása a d|m|-1 -el való maradékos osztással történhet, ahol az eredmény a maradék. Jelölje rem (f, d|m|-1) ezt a maradékot. Ezzel a jelöléssel suf (w, |m| - 1) kódja az (e - 1, rem (f, d|m|-1)). Az első eset képletét erre alkalmazva suf (wx, |m|) kódja (e, rem (f, d|m|-1) + x) lesz. Jelöljük inc (e)-vel 0  e < |m| esetén e + 1-et, míg e = |m| esetén magát az e-t. Ezzel a jelöléssel suf (wx, |m|) kódja egységesen az (inc (e), rem (f, d|m|-1 ) + x) alakban írható. Jelöljük Inf-fel a X ≤ |m| halmazon értelmezett w  (|w|, w) kódolás értékeinek halmazát. Inf := {(e, f); 0  e ≤ |m| és f legfeljebb |m| jegyű, d alapú szám}. Az előbb bevezetett jelöléseket használva a Rabin-Karp automata a következő: ARK =

(ae,f, x) = ae’, f’, ahol (e’, f’) = ( inc(e), rem( f, d|m|-1 ) + x ) (ae,f) = 1  (e,f) = (|m|, m) Az ARK automata itt az Anyers-nek ezzel a kölcsönösen egyértelmű kódolással való megvalósítása (így készítettük el), ezért ARK = Anyers , amiből ARK = illm már következik. Térjünk rá az implementáció műveleti idejére azon feltételezés mellett, hogy a kódban szereplő e és f ábrázolható a nyelvünk nemnegatív egész típusában. Ilyenkor Aktáll új értékének meghatározása az inc (e), rem (f, dh-1) + x képletekkel konstans, |m|-től független, idő alatt történik, ezért a műveleti igény O (|u|). Mit lehet tenni akkor, mikor az ARK implementációja során az (e, f) kód második tagja, f nem fér el a nyelv nemnegatív egészeinek értéktartományába? (Hogy e se férjen el, az praktikusan úgysem fordulhat elő). Ilyenkor választunk egy olyan nagy p prímszámot, mely még ábrázolható a nyelvben egészként, és a kód második komponensében a számításokat modulo p végezzük. Ezzel kapcsolatosan két probléma lép fel. Az első probléma az, hogy f  m (mod p) akkor is teljesülhet, ha f  m. Ilyenkor nincs más, mint „visszalépve” |m| karaktert elvégezzük a nyers erő automatában látott vizsgálatot. Ehhez az állapotkomponensben tárolni kell (|w|, w) mellett a nyers erő automatában látott w-t is. Ezt a w egyenlő m plusz vizsgálatot szerencsére csak e = |m| és f  m (mod p) esetében, tehát csak ritkán kell elvégezni. (Ha az implementáció során az egész szöveg a rendelkezésünkre áll, akkor ennek a kiegészítő információnak a tárolására nincs is szükség, a visszalépést magában a szövegben is megtehetjük). A második probléma, hogy hogyan történhet modulo p végzett számítás során rem (f , d|m| - 1) modulo p értékének meghatározása. rem (f , d|m| - 1)-et úgy is kiszámíthatjuk, hogy az f kódú w első jegyét beszorozzuk d |m| - 1 -el, majd ezt a szorzatot vonjuk le f-ből. Ez a számítás már végezhető modulo p, de ismerni kell hozzá w első jegyét.

Ezt a nem modulo p számítás során f-ből visszakódolással ki tudnánk nyerni, de a modulo p értékéből már nem. Ezért most is szükséges az a |m| jellel való visszalépés. Ennek megvalósítása is a w-nek az állapot-komponensben való tárolásán alapszik. Amikor az őt tartalmazó sorból a berakás során kilép egy jel, az lesz az |m|-el való visszalépésnek megfelelő jel. Ha még nem lép ki semmi, akkor a kilépő jelnek a 0-t tekintjük. 35.6. A Dömölky automata A Dömölky automata alapötlete, hogy a lehetséges w  X ≤ |m| végszeletek helyett elegendő csak azt tárolni róluk, hogy végükre mely hosszakban illeszthető a minta eleje (illeszkedési hosszak). Az illeszkedési hosszak halmaza alapján egyértelműen eldönthetjük az m-hez való illeszkedést, hiszen ez akkor áll fenn, ha az illeszkedési hosszak között szerepel |m|. Az illeszkedési hosszak halmazát leírhatjuk a karakterisztikus vektorukkal. Ez egy olyan |m| hosszúságú bitvektor, melynek valamely eleme akkor 1, ha a vektorbeli indexe illeszkedési hossz. Az ilyen vektorokat illeszkedési vektornak fogjuk nevezni és illvek (w)-vel jelöljük. Az illeszkedési vektorok tekinthetők a w  X ≤ |m| szavak kódjaiként is, ahol persze ez a kód – a Rabin-Karp kóddal ellentétben – már nem kölcsönösen egyértelmű. Hogyan lehet valamely w  X ≤ |m| szó előbb említett illeszkedési vektorából suf (wx, |m|) illeszkedési vektorát előállítani? Mielőtt erre válaszolnánk, vezessünk be x  X mellet x karvek (x)-el jelölt karakterisztikus vektorát. Ez is |m| hosszú bitvektor, melynek j-edik bitje pontosan akkor 1, ha a minta j-edik eleme x. Rátérve az eredeti kérdésre világos, hogy suf (wx) illeszkedési bitvektorának első eleme pontosan akkor 1, ha x a minta első eleme. Az is világos továbbá, hogy az előbbi vektor j-edik komponense (1 < j  h) akkor 1, ha a minta j hosszan illeszthető wx végéhez. Ennek feltétele egyrészt, hogy az m minta (j-1) hosszan illeszkedjen az eredeti w-hez, továbbá hogy x a minta j-edik eleme legyen. Ezt a két feltételt együtt úgy fejezhetjük ki, hogy illvek (w) - t 1-gyel aritmetikailag jobbra léptetjük (az utolsó bit eltűnik, az első pedig 1), majd azt „és-eljük” karvek (x)-el (az azonos pozícióban levő bitekre alkalmazzuk a logikai „és” műveletet). Definiáljuk most a Dömölky automatát a következő módon: ADöm = ,

(ab, x) = ab’, ahol b’ = jobbrallép (b, 1)  karvek(x), (ab) = 1  b utolsó komponense 1 Az invariáns tulajdonság, hogy a Dömölky automata állapotaiban az addig elolvasott szó |m| hosszú végszeleteinek (X ≤ |m| elemei) karakterisztikus vektorát jegyzi. A kezdőállapotban levő csupa 0 vektornak ezt kielégíti, mivel ekkor még nem olvastunk semmit, ezért egyezés sem lehet. Az átmeneti függvény definíciója korábbi okfejtésünk szerint ezt az invariánst megőrzi. A v-re adott ADöm (v) kimenet utolsó, |v| + 1-edik jele  definíciója alapján pontosan akkor 1, ha v végéhez illeszkedik az m minta. Így ADöm = illm. A megvalósítás során feltételezzük, hogy az aritmetikai jobbra léptetést tetszőleges hosszú bitsorozaton konstans idő alatt, nagyon gyorsan tudjuk elvégezni, s hogy hasonló igaz az „és-elésre” és az utolsó bit kiolvasására is. Így a  és  számítása gyorsan, |m|-től független idő alatt történhet.

Emiatt a műveletigény most is O (|u|), de a Rabin-Karp automatához képest kisebb konstanssal. A karvek (x) vektorokat általában előre elkészítjük (prekondicionálás), amely folyamat |X||m| kiegészítő időt és térhelyet igényel. 35.7. A Knuth-Morris-Pratt automata A Knuth-Morris-Pratt automata alapötlete, hogy a Dömölky automatában szereplő bitvektor helyett elegendő azt tárolni, hogy abban melyik pozíción van benne az utolsó 1-es. Feltéve, hogy ez a pozíció a j-edik, akkor ebből m alapján a teljes bitvektor rekonstruálható, hiszen értéke pontosan ott 1, ahány jelre m a pre (m, j) végéhez illeszthető. A Dömölky automatára való utalás nélkül ez úgy fogalmazható meg, hogy Knuth-Morris-Pratt automata állapotában a mintának a bemenő szó végéhez való leghosszabb illeszkedésének hosszát tárolja. Hogyan lehet egy w  X ≤ |m| szó előbb említett maximális illeszkedési hosszából suf(wx, |m|) hasonló maximális illeszkedési hosszát előállítani? Legyen j a w-hez való illeszkedés maximális hossza. A wx-hez való illeszkedés maximális hosszát úgy kapjuk meg, hogy m1m2…mjx végére illesztjük maximális hosszan met (w korábbi elemeit nem kell figyelembe venni, mert ha a teljes w alapján hosszabb illeszkedést kaphatnánk, akkor j nem lenne maximális illeszkedési hossz w-hez). Definiáljuk most a Knuth-Morris-Pratt automatát a következő módon: AKMP = ,

(j, x) = aj’ , ahol j’ = Max{k; suf (m1m2…mj,k) = pre(m, k)} (aj) = 1  j = |m| Az invariáns tulajdonság, hogy a Knuth-Morris-Pratt automata állapotaiban az addig elolvasott szó |m| hosszú végszeleteinek (X ≤ |m| elemei) maximális illeszkedési hosszát jegyzi meg. A kezdőállapotban nyilván 0-nak kell lennie, hiszen még nem olvastunk semmit, ezért egyezés sem lehet. Az átmeneti függvény definíciója korábbiak szerint ezt az invariánst megőrzi. Az A KMP automata az A Döm-nek kölcsönösen egyértelmű kódolással való megvalósítása (így készítettük el), ezért AKMP = ADöm, amiből AKMP = illm már következik. A megvalósítás során Aktáll-ban csak a 0, 1,…, |m| értékeket kell tárolni, ami log2 (|m|) biten történhet. A  függvényt itt is, ahogy azt a Rabin-Karp és Dömölky automatáknál is tettük, képlettel számoljuk. Nagy eltérés viszont, hogy  definíciójában szereplő j’ = Max {k; suf (m1m2…mj,k) = pre(m,k)} számítás direkt elvégzéséhez sok, |m|2-el arányos idő kell, emiatt a műveleti igény O(|m|2|u|) lenne. Ezen segíthetünk úgy, hogy visszajátsszuk a számítást a Dömölky automatában látottakra. Ez utóbbihoz is szükséges O(|X||m|) idő, hiszen a maximális illeszkedésből az illeszkedési vektort elő kell állítani, illetve viszont. Így számolva a műveleti idő O(|m||X||u|). Megtehetjük, hogy az átmenetek ütemenkénti számítása helyett előre kiszámítjuk a  függvény összes értékét, amelyeket letárolunk a már említett  átmenet-mátrixba. A mátrix alapján az átmenet gyorsan végrehajtható, csak a mátrix megfelelő indexű elemét kell (konstans időben) elővenni. A műveleti idő ebben az esetben O(|u|) lesz, kicsi konstanssal. Ennek ára viszont, hogy prekondicionálásként szükség van a  mátrix számára (|m| + 1)|X| darab log2(|m|) bitnyi tárhelyre és összesen O(|X||m|2) időre. Megjegyezzük, hogy ha csak az állapotok számát tekintjük, akkor a Knuth-Morris-Pratt automata optimális. Nincs |m| + 1-nél határozottan kevesebb állapotot tartalmazó automata,

amely a mintaillesztést megoldására terveztek. Ennek meggondolására tegyük fel, hogy volna ilyen. Vizsgáljuk meg az m bemenet mellett ennek az automatának a működését. Feltételezésünk szerint ebben az automatában (a0, m1m2…m|m|) 1-el van megjelölve. Ha c0c1…c|m|  A+ az érintett állapotok sorozata, akkor az előbbiek miatt c0 = a0 és c|m| megjelölése 1. A skatulya elv miatt közöttük van kettő, melyek egyformák (hiszen legfeljebb |m| különböző állapotunk van). Legyenek 1  i < j  |m| ezeknek az indexei. Az m1…mimj…m|m| szóhoz ekkor a c0…cicj+1…c|m| sorozat tartozik, melyre c|m| megjelölése 1. Ez azt jelenti, hogy m1…mimj…m|m| végéhez illeszkedne m, ami lehetetlen, hiszen m1…mimj…m|m| hossza határozottan kisebb, mint |m|. 35.8. Összefoglalás Összehasonlítva a négy automata felhasználási lehetőségeit a nyers erő automata az O (|m||u|) műveleti idő miatt csak kis mintahossz esetén működtethető gazdaságosan. A többi esetben a működési idő O (|u|), tehát nem függ |m|-től. Egy ütem számítási igénye a tárgyalás sorrendje szerint csökken. Ugyanakkor Dömölky automatánál szükség van |X||m| bitnyi tárhelyre és vele arányos időre a karvek vektorok kiszámításához. A prekondicionálásos Knuth-Morris-Pratt automata esetén viszont már jelentős többlet memóriát és futási időt igényel a prekondicionálás. Emiatt a Knuth-Morris-Pratt automatát akkor gazdaságos használni, ha ugyanazzal a mintával nagyon sok bemenő szóra kell a feladatot megoldani.