Arrays met veel herhaalde vermeldingen partitioneren en sorteren met Java-voorbeelden

1. Overzicht

De runtime-complexiteit van algoritmen is vaak afhankelijk van de aard van de invoer.

In deze tutorial zullen we zien hoe de triviale implementatie van het Quicksort-algoritme heeft een slechte prestatie voor herhaalde elementen.

Verder zullen we enkele Quicksort-varianten leren om invoer efficiënt te partitioneren en te sorteren met een hoge dichtheid aan dubbele sleutels.

2. Trivial Quicksort

Quicksort is een efficiënt sorteeralgoritme gebaseerd op het verdeel en heers-paradigma. Functioneel gezien is het werkt in-place op de invoerarray en herschikt de elementen met eenvoudige vergelijkings- en wisselbewerkingen.

2.1. Partitionering met één draaipunt

Een triviale implementatie van het Quicksort-algoritme is sterk afhankelijk van een single-pivot partitioneringsprocedure. Met andere woorden, partitionering verdeelt de array A = [ap, eenp + 1, eenp + 2,…, eenr] in twee delen A [p..q] en A [q + 1..r] zodat:

  • Alle elementen in de eerste partitie, A [p..q] zijn kleiner dan of gelijk aan de spilwaarde A [q]
  • Alle elementen in de tweede partitie, A [q + 1..r] zijn groter dan of gelijk aan de spilwaarde A [q]

Daarna worden de twee partities als onafhankelijke invoerarrays behandeld en aan het Quicksort-algoritme toegevoerd. Laten we Lomuto's Quicksort in actie zien:

2.2. Prestaties met herhaalde elementen

Laten we zeggen dat we een array A = [4, 4, 4, 4, 4, 4, 4] hebben die allemaal gelijke elementen heeft.

Bij het partitioneren van deze array met het single-pivot partitioneringsschema, krijgen we twee partities. De eerste partitie is leeg, terwijl de tweede partitie N-1-elementen heeft. Verder, elke volgende aanroep van de partitieprocedure zal de invoergrootte met slechts één verkleinen. Laten we eens kijken hoe het werkt:

Aangezien de partitieprocedure een lineaire tijdcomplexiteit heeft, is de totale tijdcomplexiteit in dit geval kwadratisch. Dit is het worstcasescenario voor onze invoerarray.

3. Drie-weg partitionering

Om een ​​array met een groot aantal herhaalde sleutels efficiënt te sorteren, kunnen we ervoor kiezen om op een verantwoorde manier met de gelijke sleutels om te gaan. Het idee is om ze op de juiste positie te plaatsen als we ze voor het eerst tegenkomen. Dus wat we zoeken is een status met drie partities van de array:

  • De meest linkse partitie bevat elementen die strikt kleiner zijn dan de partitiesleutel
  • Demiddelste partitie bevat alle elementen die gelijk zijn aan de partitiesleutel
  • De meest rechtse partitie bevat alle elementen die strikt groter zijn dan de partitiesleutel

We zullen nu dieper ingaan op een aantal benaderingen die we kunnen gebruiken om drievoudige partitionering te bereiken.

4. Dijkstra's aanpak

Dijkstra's aanpak is een effectieve manier om drievoudige partitionering te doen. Laten we, om dit te begrijpen, eens kijken naar een klassiek programmeerprobleem.

4.1. Nederlandse Nationale Vlag Probleem

Geïnspireerd door de driekleurenvlag van Nederland, stelde Edsger Dijkstra een programmeerprobleem voor, het Dutch National Flag Problem (DNF).

In een notendop is het een herschikkingsprobleem waarbij we ballen van drie kleuren willekeurig in een lijn krijgen, en ons wordt gevraagd om dezelfde gekleurde ballen bij elkaar te groeperen. Bovendien moet de herschikking ervoor zorgen dat groepen de juiste volgorde volgen.

Interessant is dat het DNF-probleem een ​​opvallende analogie maakt met de 3-weg partitionering van een array met herhaalde elementen.

We kunnen alle nummers van een array in drie groepen indelen met betrekking tot een bepaalde sleutel:

  • De rode groep bevat alle elementen die strikt kleiner zijn dan de sleutel
  • De witte groep bevat alle elementen die gelijk zijn aan de toonsoort
  • De blauwe groep bevat alle elementen die strikt groter zijn dan de sleutel

4.2. Algoritme

Een van de manieren om het DNF-probleem op te lossen, is door het eerste element als partitioneringssleutel te kiezen en de array van links naar rechts te scannen. Als we elk element controleren, verplaatsen we het naar de juiste groep, namelijk Lesser, Equal en Greater.

Om de voortgang van onze partitionering bij te houden, hebben we de hulp van drie tips nodig, namelijk lt, actueel, en gt. Op elk moment kunnen de elementen links van lt zal strikt kleiner zijn dan de partitioneringssleutel en de elementen rechts van gt zal strikt groter zijn dan de sleutel.

Verder gebruiken we de actueel aanwijzer voor scannen, wat betekent dat alle elementen tussen de actueel en gt aanwijzingen moeten nog worden onderzocht:

Om te beginnen kunnen we instellen lt en actueel verwijzingen helemaal aan het begin van de array en de gt aanwijzer helemaal aan het einde ervan:

Voor elk element gelezen via de actueel pointer, we vergelijken het met de partitioneringssleutel en nemen een van de drie samengestelde acties:

  • Als input [huidig] <sleutel, dan wisselen we invoerstroom] en invoer [lt] en verhoog beide actueel en lt aanwijzingen
  • Als invoerstroom] == sleutel, dan verhogen we actueel wijzer
  • Als input [huidig]> sleutel, dan wisselen we invoerstroom] en invoer [gt] en decrement gt

Uiteindelijk, we zullen stoppen wanneer de actueel en gt wijzers kruisen elkaar. Daarmee wordt de grootte van het onontgonnen gebied teruggebracht tot nul, en blijven er slechts drie vereiste partities over.

Laten we tot slot eens kijken hoe dit algoritme werkt op een invoerarray met dubbele elementen:

4.3. Implementatie

Laten we eerst een hulpprogramma schrijven met de naam vergelijken() om een ​​driewegvergelijking tussen twee getallen te maken:

public static int Compare (int num1, int num2) {if (num1> num2) return 1; anders if (num1 <num2) return -1; anders retourneren 0; }

Laten we vervolgens een methode toevoegen met de naam ruilen () om elementen op twee indices van dezelfde array uit te wisselen:

openbare statische ongeldige swap (int [] array, int position1, int position2) {if (position1! = position2) {int temp = array [position1]; array [position1] = array [position2]; array [position2] = temp; }}

Om een ​​partitie in de array uniek te identificeren, hebben we de linker en rechter boundary-indices nodig. Dus laten we doorgaan en een Partitie klasse:

openbare klasse Partitie {private int left; privé int recht; }

Nu zijn we klaar om onze drie-weg te schrijven partitie () procedure:

openbare statische partitie partitie (int [] input, int begin, int end) {int lt = begin, current = begin, gt = end; int partitioningValue = input [begin]; while (current <= gt) {int CompareCurrent = Compare (input [current], partitioningValue); switch (CompareCurrent) {case -1: swap (input, current ++, lt ++); breken; case 0: huidige ++; breken; case 1: swap (input, current, gt--); breken; }} retourneer nieuwe partitie (lt, gt); }

Laten we tot slot een Snel sorteren() methode die gebruikmaakt van ons 3-weg partitioneringsschema om de linker- en rechterpartities recursief te sorteren:

openbare statische ongeldige quicksort (int [] input, int begin, int end) {if (end <= begin) return; Partitie middlePartition = partitie (invoer, begin, einde); quicksort (invoer, begin, middlePartition.getLeft () - 1); quicksort (input, middlePartition.getRight () + 1, end); }

5. Bentley-McIlroy's aanpak

Jon Bentley en Douglas McIlroy waren co-auteur van een geoptimaliseerde versie van het Quicksort-algoritme. Laten we deze variant in Java begrijpen en implementeren:

5.1. Partitieschema

De kern van het algoritme is een op iteratie gebaseerd partitioneringsschema. In het begin is de hele reeks getallen een onontgonnen terrein voor ons:

We beginnen dan met het verkennen van de elementen van de array vanuit de linker- en rechterrichting. Telkens wanneer we de verkenningslus betreden of verlaten, we kunnen de array visualiseren als een samenstelling van vijf regio's:

  • Aan de uiterste twee uiteinden liggen de gebieden met elementen die gelijk zijn aan de partitiewaarde
  • Het onontgonnen gebied blijft in het midden en de omvang blijft bij elke iteratie kleiner worden
  • Aan de linkerkant van het onontgonnen gebied liggen alle elementen kleiner dan de partitioneringswaarde
  • Aan de rechterkant van het onontgonnen gebied zijn elementen groter dan de partitioneringswaarde

Uiteindelijk eindigt onze verkenningslus als er geen elementen meer zijn om te verkennen. In dit stadium is het de grootte van het onontgonnen gebied is in feite nul, en we hebben nog maar vier regio's:

Vervolgens we verplaats alle elementen van de twee gelijke gebieden in het midden zodat er slechts één gelijk gebied in het midden is, omgeven door het minder gebied aan de linkerkant en het grotere gebied aan de rechterkant. Om dit te doen, verwisselen we eerst de elementen in het linker gelijke gebied met de elementen aan de rechterkant van het minder gebied. Evenzo worden de elementen in het rechter gelijke gebied verwisseld met de elementen aan de linkerkant van het grotere gebied.

Eindelijk zullen we zijn links met slechts drie partities, en we kunnen verder dezelfde benadering gebruiken om de kleinere en de grotere regio's te verdelen.

5.2. Implementatie

In onze recursieve implementatie van de drieweg Quicksort, moeten we onze partitieprocedure aanroepen voor sub-arrays die een andere set onder- en bovengrenzen hebben. Zo onze partitie () methode moet drie invoer accepteren, namelijk de array samen met zijn linker- en rechtergrenzen.

openbare statische partitie partitie (int input [], int begin, int end) {// retourneert partitievenster}

Voor de eenvoud kunnen we dat kies de partitioneringswaarde als het laatste element van de array. Laten we ook twee variabelen definiëren left = begin en rechts = einde om de array naar binnen te verkennen.

Verder zullen we dat ook moeten doen houd het aantal gelijke elementen bij dat uiterst links en uiterst rechts ligt. Dus laten we initialiseren leftEqualKeysCount = 0 en rightEqualKeysCount = 0, en we zijn nu klaar om de array te verkennen en te partitioneren.

Eerst beginnen we te bewegen vanuit zowel de richtingen als vind een inversie waarbij een element aan de linkerkant niet kleiner is dan de partitioneringswaarde, en een element aan de rechterkant niet groter is dan de partitioneringswaarde. Dan, tenzij de twee wijzers links en rechts elkaar kruisen, wisselen we de twee elementen.

In elke iteratie verplaatsen we elementen gelijk aan partitioningValue naar de twee uiteinden toe en verhoog de juiste teller:

while (true) {while (input [left] partitioningValue) {if (right == begin) break; Rechtsaf--; } if (left == right && input [left] == ​​partitioningValue) {swap (input, begin + leftEqualKeysCount, left); leftEqualKeysCount ++; links ++; } if (left> = right) {break; } swap (invoer, links, rechts); if (input [left] == ​​partitioningValue) {swap (input, begin + leftEqualKeysCount, left); leftEqualKeysCount ++; } if (input [right] == ​​partitioningValue) {swap (input, right, end - rightEqualKeysCount); rightEqualKeysCount ++; } left ++; Rechtsaf--; }

In de volgende fase moeten we verplaats alle gelijke elementen van de twee uiteinden in het midden. Nadat we de lus hebben verlaten, staat de linkerpointer op een element waarvan de waarde niet kleiner is dan partitioningValue. Met behulp van dit feit beginnen we gelijke elementen van de twee uiteinden naar het midden te verplaatsen:

rechts = links - 1; for (int k = begin; k = begin + leftEqualKeysCount) swap (input, k, right); } for (int k = end; k> end - rightEqualKeysCount; k--, left ++) {if (left <= end - rightEqualKeysCount) swap (input, left, k); } 

In de laatste fase kunnen we de grenzen van de middelste partitie teruggeven:

retourneer nieuwe partitie (rechts + 1, links - 1);

Laten we tot slot eens kijken naar een demonstratie van onze implementatie op basis van een voorbeeldinvoer

6. Algoritme-analyse

Over het algemeen heeft het Quicksort-algoritme een gemiddelde tijdcomplexiteit van O (n * log (n)) en worst-case tijdcomplexiteit van O (n2). Met een hoge dichtheid aan dubbele sleutels, krijgen we bijna altijd de slechtste prestaties met de triviale implementatie van Quicksort.

Als we echter de drievoudige partitioneringsvariant van Quicksort gebruiken, zoals DNF-partitionering of Bentley's partitionering, kunnen we het negatieve effect van dubbele sleutels voorkomen. Verder, naarmate de dichtheid van dubbele sleutels toeneemt, verbeteren de prestaties van ons algoritme ook. Het resultaat is dat we de beste prestaties krijgen als alle sleutels gelijk zijn, en we krijgen een enkele partitie met alle gelijke sleutels in lineaire tijd.

Desalniettemin moeten we opmerken dat we in wezen overhead toevoegen wanneer we overschakelen naar een drieweg partitioneringsschema van de triviale single-pivot partitionering.

Voor een DNF-gebaseerde benadering hangt de overhead niet af van de dichtheid van herhaalde sleutels. Dus als we DNF-partitionering gebruiken voor een array met alle unieke sleutels, krijgen we slechte prestaties in vergelijking met de triviale implementatie waarbij we de spil optimaal kiezen.

Maar de aanpak van Bentley-McIlroy is slim, aangezien de overhead van het verplaatsen van de gelijke toetsen van de twee uiterste uiteinden afhankelijk is van hun aantal. Als gevolg hiervan, als we dit algoritme gebruiken voor een array met alle unieke sleutels, krijgen we zelfs dan redelijk goede prestaties.

Samenvattend is de tijdcomplexiteit in het slechtste geval van zowel single-pivot partitioning als drieweg partitioneringsalgoritmen O (nlog (n)). Echter, het echte voordeel is zichtbaar in de beste scenario's, waar we de tijdcomplexiteit vanaf zien gaan O (nlog (n)) voor single-pivot partitionering naar Aan) voor drievoudige partitionering.

7. Conclusie

In deze tutorial hebben we geleerd over de prestatieproblemen met de triviale implementatie van het Quicksort-algoritme wanneer de invoer een groot aantal herhaalde elementen heeft.

Met een motivatie om dit probleem op te lossen, wij verschillende drieweg partitioneringsschema's geleerd en hoe we ze in Java kunnen implementeren.

Zoals altijd is de volledige broncode voor de Java-implementatie die in dit artikel wordt gebruikt, beschikbaar op GitHub.