Tömbök particionálása és rendezése sok ismételt bejegyzéssel Java példákkal
1. Áttekintés
Az algoritmusok futásidejű bonyolultsága gyakran függ a bemenet jellegétől.
Ebben az oktatóanyagban meglátjuk, hogyan a Quicksort algoritmus triviális megvalósítása gyenge teljesítményt mutat az ismételt elemek számára.
Megtanulunk néhány Quicksort-változatot a bemenetek hatékony particionálásához és rendezéséhez nagy sűrűségű duplikált kulcsokkal.
2. Triviális Quicksort
A Quicksort egy hatékony rendezési algoritmus, amely a divide and conquer paradigmán alapul. Funkcionálisan szólva a helyén működik a bemeneti tömbön, és egyszerű összehasonlítással és csere műveletekkel átrendezi az elemeket.
2.1. Egycsuklós particionálás
A Quicksort algoritmus triviális megvalósítása nagymértékben támaszkodik az egycsuklós particionálási eljárásra. Más szavakkal, a particionálás elosztja az A = [ao, ap + 1, ap + 2,…, Ar] két részre A [p..q] és A [q + 1..r] úgy, hogy:
- Az összes elem az első partícióban, A [p..q] kisebb vagy egyenlő az A [q] forgási értékkel
- A második partíció összes eleme, A [q + 1..r] nagyobb vagy egyenlő az A [q] forgási értékkel

Ezt követően a két partíciót független bemeneti tömbként kezelik, és betáplálják a Quicksort algoritmusba. Lássuk Lomuto Quicksortját működés közben:

2.2. Teljesítmény ismételt elemekkel
Tegyük fel, hogy van egy A = [4, 4, 4, 4, 4, 4, 4] tömb, amelynek minden eleme egyenlő.
Ha ezt a tömböt az egycsuklós particionálási sémával particionáljuk, két partíciót kapunk. Az első partíció üres lesz, míg a második partíció N-1 elemeket tartalmaz. További, a partíciós eljárás minden további meghívása csak egyrel csökkenti a bemeneti méretet. Nézzük meg, hogyan működik:

Mivel a partíciós eljárás lineáris időbonyolultságú, az idő időbeli összetettsége ebben az esetben kvadratikus. Ez a legrosszabb forgatókönyv bemeneti tömbünkhöz.
3. Háromirányú felosztás
A nagy számú ismételt kulcsot tartalmazó tömb hatékony rendezéséhez választhatunk, hogy az egyenlő kulcsokat felelősségteljesebben kezeljük. Az ötlet az, hogy megfelelő helyzetbe hozzuk őket, amikor először találkozunk velük. Tehát, amit keresünk, a tömb három partíciós állapota:
- A bal oldali partíció olyan elemeket tartalmaz, amelyek szigorúan kisebbek, mint a particionálási kulcs
- AA középső partíció minden elemet tartalmaz, amely megegyezik a partíciós kulccsal
- A jobb oldali partíció minden olyan elemet tartalmaz, amely szigorúan nagyobb, mint a particionálási kulcs

Most mélyebben belemerülünk néhány olyan megközelítésbe, amelyekkel háromirányú particionálást érhetünk el.
4. Dijkstra megközelítése
Dijkstra megközelítése a háromirányú felosztás hatékony módja. Ennek megértéséhez nézzünk meg egy klasszikus programozási problémát.
4.1. Holland nemzeti lobogó probléma
A holland trikolor zászló inspirálta Edsger Dijkstra programozási problémát javasolt a Holland Nemzeti Zászló Probléma (DNF) néven.
Dióhéjban az átrendeződési probléma, ahol véletlenszerűen egy vonalba helyezett három színű golyót kapunk, és arra kérnek bennünket, hogy ugyanazokat a színes golyókat csoportosítsák össze. Ezenkívül az átrendezésnek biztosítania kell, hogy a csoportok a helyes sorrendet kövessék.
Érdekes, hogy a DNF probléma szembetűnő analógiát mutat egy ismételt elemeket tartalmazó tömb háromirányú felosztásával.
Egy tömb összes számát három csoportba sorolhatjuk egy adott kulcs szempontjából:
- A Piros csoport minden olyan elemet tartalmaz, amely szigorúan kisebb, mint a kulcs
- A Fehér csoport minden elemet tartalmaz, amely megegyezik a kulccsal
- A Kék csoport minden olyan elemet tartalmaz, amely szigorúan nagyobb, mint a kulcs

4.2. Algoritmus
A DNF probléma megoldásának egyik megközelítése az, hogy az első elemet választja partíciós kulcsnak, és a tömböt balról jobbra vizsgálja. Az egyes elemek ellenőrzése során a megfelelő csoportba helyezzük, nevezetesen a Lesser, az Equal és a Greater csoportokba.
A particionálás előrehaladásának nyomon követéséhez három mutató segítségére lenne szükségünk, nevezetesen lt, jelenlegi, és gt. Az idő bármely pontján a bal oldali elemek lt szigorúan kisebb lesz, mint a particionálási kulcs, és a tőlük jobbra levő elemek gt szigorúan nagyobb lesz, mint a kulcs.
Továbbá használjuk a jelenlegi mutató a beolvasáshoz, ami azt jelenti, hogy a jelenlegi és gt a mutatókat még fel kell tárni:

Először is beállíthatjuk lt és jelenlegi mutatók a tömb legelején és a gt mutató a legvégén:

Minden elemen keresztül olvasható a jelenlegi mutató, összehasonlítjuk a particionáló kulccsal, és elvégezzük a három összetett művelet egyikét:
- Ha bemenet [aktuális] <gomb, akkor cserélünk bemenet [áram] és bemenet [lt] és növekszik mindkettő jelenlegi és lt mutatók
- Ha bemenet [áram] == kulcs, akkor növekszünk jelenlegi mutató
- Ha input [current]> gomb, akkor cserélünk bemenet [áram] és bemenet [gt] és csökkentés gt
Végül is, megállunk, amikor a jelenlegi és gt mutatók keresztezik egymást. Ezzel a feltáratlan régió mérete nullára csökken, és csak három szükséges partíció marad.
Végül nézzük meg, hogyan működik ez az algoritmus egy bemeneti tömbön, amelynek duplikált elemei vannak:

4.3. Végrehajtás
Először írjunk egy segédprogram eljárást összehasonlít () háromszámú összehasonlítást végezni két szám között:
public static int összehasonlítás (int szám1, int szám2) {if (szám1> szám2) 1-es visszatérés; else if (num1 <num2) return -1; else visszatér 0; }
Ezután adjunk hozzá egy úgynevezett metódust csere() elemeket cserélni ugyanazon tömb két indexén:
public static void swap (int [] tömb, int helyzet1, int helyzet2) {if (helyzet1! = helyzet2) {int temp = tömb [helyzet1]; tömb [helyzet1] = tömb [helyzet2]; tömb [helyzet2] = hőmérséklet; }}
A partíció egyedi azonosításához a tömbben szükségünk lesz a bal és a jobb határindexekre. Tehát folytassuk és hozzunk létre egy Partíció osztály:
public class Partíció {private int left; magán int jog; }
Most készen állunk a háromirányú írásunkra partíció () eljárás:
public static Partíció partíció (int [] bemenet, int kezdet, int vég) {int lt = kezdet, aktuális = kezdet, gt = vég; int partitioningValue = input [kezdet]; while (current <= gt) {int CompareCurrent = összehasonlítás (input [current], partitioningValue); kapcsoló (CompareCurrent) {eset -1: csere (bemenet, áram ++, lt ++); szünet; 0. eset: áram ++; szünet; 1. eset: csere (bemenet, áram, gt--); szünet; }} return new partition (lt, gt); }
Végül írjunk egy quicksort () módszer, amely a 3-utas particionálási sémánkat használja a bal és a jobb partíció rekurzív rendezéséhez:
public static void quicksort (int [] input, int begin, int end) {if (end <= begin) return; Partition middlePartition = partíció (bemenet, kezdet, vég); quicksort (input, begin, middlePartition.getLeft () - 1); quicksort (input, middlePartition.getRight () + 1, end); }
5. Bentley-McIlroy megközelítése
Jon Bentley és Douglas McIlroy társszerzője egy a Quicksort algoritmus optimalizált változata. Értsük meg és valósítsuk meg ezt a változatot a Java-ban:
5.1. Particionálási séma
Az algoritmus lényege egy iteráció alapú particionálási séma. Kezdetben a teljes számtömb számunkra feltáratlan terület:

Ezután elkezdjük felfedezni a tömb elemeit bal és jobb irányból. Amikor belépünk vagy elhagyjuk a kutatási kört, öt tömb összetételeként jeleníthetjük meg a tömböt:
- A két legvégén azok a régiók találhatók, amelyeknek elemei megegyeznek a particionálási értékkel
- A feltáratlan régió a középpontban marad, és mérete minden egyes iterációnál folyamatosan csökken
- A feltáratlan régió bal oldalán található minden elem, amely kisebb, mint a particionálási érték
- A feltáratlan régió jobb oldalán a partíciós értéknél nagyobb elemek találhatók

Végül a feltárási ciklusunk akkor szűnik meg, amikor már nincsenek elemezhető elemek. Ebben a szakaszban a a feltáratlan régió mérete gyakorlatilag nulla, és csak négy régiónk marad:

Ezután mi mozgassa az összes elemet a középen lévő két egyenlő régióból úgy, hogy csak egy egyenlő terület legyen a közepén, amelyet a bal oldali kevesebb, a jobb pedig a nagyobb régió vesz körül. Ehhez először a bal egyenlő régióban lévő elemeket cseréljük fel a kevésbé régió jobb végén lévő elemekkel. Hasonlóképpen, a jobb egyenlő régióban lévő elemeket felcseréljük a nagyobb régió bal végén lévő elemekkel.

Végül mi leszünk csak három partícióval maradt, és tovább használhatjuk ugyanazt a megközelítést a kisebb és nagyobb régiók felosztásához.
5.2. Végrehajtás
A háromutas Quicksort rekurzív megvalósításakor meg kell hívnunk a partíciós eljárást olyan résztömbök esetében, amelyeknek különböző alsó és felső határai vannak. Szóval, a mi partíció () A metódusnak három bemenetet kell elfogadnia, nevezetesen a tömböt a bal és jobb oldali határaival együtt.
public static Partíció partíció (int bemenet [], int kezdet, int vég) {// a partíció ablakát adja vissza}
Az egyszerűség kedvéért megtehetjük válassza a particionálási értéket a tömb utolsó elemeként. Adjunk meg két változót is balra = kezd és jobb = vég hogy felfedezzék a tömböt befelé.
Továbbá nekünk is meg kell kövesse nyomon a bal és a jobb szélen fekvő egyenlő elemek számát. Tehát kezdjük leftEqualKeysCount = 0 és rightEqualKeysCount = 0, és most már készen állunk a tömb feltárására és felosztására.
Először elkezdünk mozogni mindkét irányból és inverziót találni ahol a bal oldalon lévő elem nem kisebb, mint a particionálási érték, és a jobb oldali elem nem nagyobb, mint a particionálási érték. Aztán, hacsak a két bal és jobb mutató nem keresztezi egymást, kicseréljük a két elemet.
Minden iterációban egyenlő elemeket mozgatunk partitioningValue a két vége felé, és növelje a megfelelő számlálót:
while (true) {while (input [left] partitioningValue) {if (right == begin) break; jobb--; } if (balra == jobbra && input [balra] == particionálási érték) {csere (bevitel, kezdet + balEqualKeysCount, balra); leftEqualKeysCount ++; balra ++; } if (balra = = jobbra) {törés; } csere (bemenet, bal, jobb); if (input [left] == partitioningValue) {swap (input, begin + leftEqualKeysCount, left); leftEqualKeysCount ++; } if (input [right] == partitioningValue) {csere (input, right, end - rightEqualKeysCount); rightEqualKeysCount ++; } balra ++; jobb--; }
A következő szakaszban meg kell mozgassa az összes egyenlő elemet a középső két végből. Miután kiléptünk a ciklusból, a bal mutató egy olyan elemnél lesz, amelynek értéke nem kisebb, mint partitioningValue. Ezt a tényt felhasználva egyenlő elemeket kezdünk el mozgatni a két végétől a középpont felé:
jobb = bal - 1; for (int k = begin; k = begin + leftEqualKeysCount) csere (input, k, jobb); } for (int k = end; k> end - rightEqualKeysCount; k--, left ++) {if (left <= end - rightEqualKeysCount) csere (input, left, k); }
Az utolsó szakaszban visszaadhatjuk a középső partíció határait:
visszatér az új partícióhoz (jobb + 1, bal - 1);
Végül vessünk egy pillantást megvalósításunk bemutatására egy minta bemeneten

6. Algoritmuselemzés
Általánosságban elmondható, hogy a Quicksort algoritmus átlagos esete az O (n * log (n)) és a legrosszabb esetben az O (n2) idő komplexitása. A duplikált kulcsok nagy sűrűségével szinte mindig a legrosszabb teljesítményt érjük el a Quicksort triviális megvalósításával.
Ha azonban a Quicksort háromirányú particionálási változatát használjuk, például a DNF particionálást vagy a Bentley particionálását, akkor képesek vagyunk megakadályozni a duplikált kulcsok negatív hatásait. Továbbá, a duplikált kulcsok sűrűségének növekedésével az algoritmusunk teljesítménye is javul. Ennek eredményeként akkor kapjuk meg a legjobb teljesítményt, ha minden kulcs egyenlő, és egyetlen partíciót kapunk, amely lineáris időben tartalmazza az összes azonos kulcsot.
Mindazonáltal meg kell jegyeznünk, hogy lényegében általános költségeket adunk hozzá, amikor a triviális egycsuklós particionálásról háromirányú particionáló sémára váltunk.
A DNF alapú megközelítésnél a rezsi nem függ az ismételt billentyűk sűrűségétől. Tehát, ha a DNF particionálást egy tömbhöz használjuk, minden egyedi kulccsal, akkor gyenge teljesítményt fogunk elérni ahhoz a triviális megvalósításhoz képest, ahol optimálisan választjuk a forgatást.
De Bentley-McIlroy megközelítése okosan cselekszik, mivel az egyenlő kulcsok két szélső végéből való elmozdítása a számuktól függ. Ennek eredményeként, ha ezt az algoritmust minden egyedi kulccsal rendelkező tömbhöz használjuk, akkor is meglehetősen jó teljesítményt fogunk elérni.
Összefoglalva: mind az egycsuklós, mind a háromirányú particionáló algoritmusok legrosszabb időbeli összetettsége O (nlog (n)). Azonban, a valódi előny a legjobb esetekben látható, ahol látjuk az idő bonyolultságát O (nlog (n)) egycsuklós particionáláshoz Tovább) háromirányú particionáláshoz.
7. Következtetés
Ebben az oktatóanyagban megismerkedtünk a Quicksort algoritmus triviális megvalósításával kapcsolatos teljesítményproblémákkal, amikor a bemenetnek sok ismételt eleme van.
A probléma kijavításának motivációjával mi különböző háromirányú particionálási sémákat tanult meg és hogyan tudjuk ezeket megvalósítani Java-ban.
Mint mindig, a cikkben használt Java-implementáció teljes forráskódja elérhető a GitHubon.