1. Gambaran keseluruhan
Kerumitan jangka masa algoritma sering bergantung pada sifat input.
Dalam tutorial ini, kita akan melihat bagaimana pelaksanaan sepintas lalu algoritma Quicksort mempunyai prestasi yang buruk untuk elemen berulang .
Selanjutnya, kami akan mempelajari beberapa varian Quicksort untuk memartisi dan menyusun input dengan cekap dengan kepadatan kunci pendua yang tinggi.
2. Trivial Quicksort
Quicksort adalah algoritma penyortiran yang cekap berdasarkan paradigma pembahagi dan penaklukan. Secara fungsional, ia beroperasi di tempat pada array input dan menyusun semula elemen dengan perbandingan dan operasi pertukaran mudah .
2.1. Pembahagian Pivot Tunggal
Pelaksanaan sepintas lalu algoritma Quicksort sangat bergantung pada prosedur partisi pivot tunggal. Dengan kata lain, partition membahagi array A = [a p , a p + 1 , p + 2 ,…, a r ] menjadi dua bahagian A [p..q] dan A [q + 1..r] seperti bahawa:
- Semua elemen dalam partisi pertama, A [p..q] lebih kecil daripada atau sama dengan nilai pangsi A [q]
- Semua elemen dalam partisi kedua, A [q + 1..r] lebih besar daripada atau sama dengan nilai pangsi A [q]

Selepas itu, kedua-dua partisi dianggap sebagai susunan input bebas dan memberi makan kepada algoritma Quicksort. Mari lihat Quicksort Lomuto beraksi:

2.2. Persembahan dengan Elemen Berulang
Katakan kita mempunyai susunan A = [4, 4, 4, 4, 4, 4, 4] yang mempunyai semua elemen yang sama.
Semasa mempartisi array ini dengan skema partition pivot tunggal, kita akan mendapat dua partition. Partition pertama akan kosong, sementara partition kedua akan mempunyai elemen N-1. Selanjutnya, setiap pemakaian prosedur partisi berikutnya akan mengurangkan ukuran input hanya dengan satu . Mari lihat bagaimana ia berfungsi:

Oleh kerana prosedur partisi mempunyai kerumitan waktu linear, kerumitan masa keseluruhan, dalam kes ini, adalah kuadratik. Ini adalah senario terburuk untuk susunan input kami.
3. Pembahagian Tiga Hala
Untuk menyusun array dengan berkesan dengan bilangan kekunci berulang yang tinggi, kita dapat memilih untuk mengendalikan kekunci yang sama dengan lebih bertanggungjawab. Ideanya ialah meletakkan mereka di kedudukan yang betul ketika pertama kali kita bertemu. Oleh itu, apa yang kami cari ialah keadaan tiga partisi array:
- Partition paling kiri mengandungi unsur-unsur yang jauh lebih sedikit daripada kunci partition
- The partition tengah mengandungi semua unsur-unsur yang sama dengan kekunci pembahagian
- Partition paling kanan mengandungi semua elemen yang jauh lebih besar daripada kunci partition

Kami sekarang akan menyelami lebih mendalam beberapa pendekatan yang boleh kita gunakan untuk mencapai pemisahan tiga arah.
4. Pendekatan Dijkstra
Pendekatan Dijkstra adalah kaedah yang berkesan untuk melakukan partition tiga arah. Untuk memahami perkara ini, mari kita lihat masalah pengaturcaraan klasik.
4.1. Masalah Bendera Negara Belanda
Terinspirasi oleh bendera tiga warna Belanda, Edsger Dijkstra mencadangkan masalah pengaturcaraan yang disebut Masalah Bendera Nasional Belanda (DNF).
Ringkasnya, ini adalah masalah penyusunan semula di mana kita diberi bola tiga warna yang diletakkan secara rawak dalam satu baris, dan kita diminta untuk mengumpulkan bola berwarna yang sama . Lebih-lebih lagi, penyusunan semula mesti memastikan bahawa kumpulan mengikut urutan yang betul.
Menariknya, masalah DNF membuat analogi yang mencolok dengan pemisahan 3-arah dari array dengan elemen berulang.
Kita boleh mengkategorikan semua nombor array menjadi tiga kumpulan berkenaan dengan kunci yang diberikan:
- Kumpulan Merah mengandungi semua elemen yang jauh lebih rendah daripada kunci
- Kumpulan Putih mengandungi semua elemen yang sama dengan kunci
- Kumpulan Biru mengandungi semua elemen yang jauh lebih besar daripada kunci

4.2. Algoritma
Salah satu pendekatan untuk menyelesaikan masalah DNF adalah memilih elemen pertama sebagai kunci partisi dan mengimbas susunan dari kiri ke kanan. Semasa kami memeriksa setiap elemen, kami memindahkannya ke kumpulannya yang betul, iaitu Kurang, Sama, dan Lebih Besar.
Untuk mengikuti perkembangan partisi kami, kami memerlukan bantuan tiga petunjuk, iaitu lt , current , dan gt. Pada bila-bila masa, elemen di sebelah kiri lt akan jauh lebih kecil daripada kekunci partition, dan elemen di sebelah kanan gt akan lebih besar daripada kunci .
Selanjutnya, kami akan menggunakan penunjuk semasa untuk mengimbas, yang bermaksud bahawa semua elemen yang terletak di antara penunjuk semasa dan gt belum dapat diterokai:

Sebagai permulaan, kita dapat menetapkan penunjuk lt dan semasa di awal array dan penunjuk gt di hujungnya:

Untuk setiap elemen yang dibaca melalui penunjuk semasa , kami membandingkannya dengan kunci partition dan mengambil salah satu daripada tiga tindakan gabungan:
- Sekiranya input [current] < , maka kita menukar input [current] dan input [lt] dan meningkatkan kedua-dua penunjuk semasa dan lt
- If input[current] == key, then we increment current pointer
- If input[current] > key, then we exchange input[current] and input[gt] and decrement gt
Eventually, we'll stop when the current and gt pointers cross each other. With that, the size of the unexplored region reduces to zero, and we'll be left with only three required partitions.
Finally, let's see how this algorithm works on an input array having duplicate elements:

4.3. Implementation
First, let's write a utility procedure named compare() to do a three-way comparison between two numbers:
public static int compare(int num1, int num2) { if (num1 > num2) return 1; else if (num1 < num2) return -1; else return 0; }
Next, let's add a method called swap() to exchange elements at two indices of the same array:
public static void swap(int[] array, int position1, int position2) { if (position1 != position2) { int temp = array[position1]; array[position1] = array[position2]; array[position2] = temp; } }
To uniquely identify a partition in the array, we'll need its left and right boundary-indices. So, let's go ahead and create a Partition class:
public class Partition { private int left; private int right; }
Now, we're ready to write our three-way partition() procedure:
public static Partition partition(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++); break; case 0: current++; break; case 1: swap(input, current, gt--); break; } } return new Partition(lt, gt); }
Finally, let's write a quicksort() method that leverages our 3-way partitioning scheme to sort the left and right partitions recursively:
public static void quicksort(int[] input, int begin, int end) { if (end <= begin) return; Partition middlePartition = partition(input, begin, end); quicksort(input, begin, middlePartition.getLeft() - 1); quicksort(input, middlePartition.getRight() + 1, end); }
5. Bentley-McIlroy's Approach
Jon Bentley and Douglas McIlroy co-authored an optimized version of the Quicksort algorithm. Let's understand and implement this variant in Java:
5.1. Partitioning Scheme
The crux of the algorithm is an iteration-based partitioning scheme. In the start, the entire array of numbers is an unexplored territory for us:

We then start exploring the elements of the array from the left and right direction. Whenever we enter or leave the loop of exploration, we can visualize the array as a composition of five regions:
- On the extreme two ends, lies the regions having elements that are equal to the partitioning value
- The unexplored region stays in the center and its size keeps on shrinking with each iteration
- On the left of the unexplored region lies all elements lesser than the partitioning value
- On the right side of the unexplored region are elements greater than the partitioning value

Eventually, our loop of exploration terminates when there are no elements to be explored anymore. At this stage, the size of the unexplored region is effectively zero, and we're left with only four regions:

Next, we move all the elements from the two equal-regions in the center so that there is only one equal-region in the center surrounding by the less-region on the left and the greater-region on the right. To do so, first, we swap the elements in the left equal-region with the elements on the right end of the less-region. Similarly, the elements in the right equal-region are swapped with the elements on the left end of the greater-region.

Finally, we'll be left with only three partitions, and we can further use the same approach to partition the less and the greater regions.
5.2. Implementation
In our recursive implementation of the three-way Quicksort, we'll need to invoke our partition procedure for sub-arrays that'll have a different set of lower and upper bounds. So, our partition() method must accept three inputs, namely the array along with its left and right bounds.
public static Partition partition(int input[], int begin, int end){ // returns partition window }
For simplicity, we can choose the partitioning value as the last element of the array. Also, let's define two variables left=begin and right=end to explore the array inward.
Further, We'll also need to keep track of the number of equal elements lying on the leftmost and rightmost. So, let's initialize leftEqualKeysCount=0 and rightEqualKeysCount=0, and we're now ready to explore and partition the array.
First, we start moving from both the directions and find an inversion where an element on the left is not less than partitioning value, and an element on the right is not greater than partitioning value. Then, unless the two pointers left and right have crossed each other, we swap the two elements.
In each iteration, we move elements equal to partitioningValue towards the two ends and increment the appropriate counter:
while (true) { while (input[left] partitioningValue) { if (right == begin) break; right--; } if (left == right && input[left] == partitioningValue) { swap(input, begin + leftEqualKeysCount, left); leftEqualKeysCount++; left++; } if (left >= right) { break; } swap(input, left, right); if (input[left] == partitioningValue) { swap(input, begin + leftEqualKeysCount, left); leftEqualKeysCount++; } if (input[right] == partitioningValue) { swap(input, right, end - rightEqualKeysCount); rightEqualKeysCount++; } left++; right--; }
In the next phase, we need to move all the equal elements from the two ends in the center. After we exit the loop, the left-pointer will be at an element whose value is not less than partitioningValue. Using this fact, we start moving equal elements from the two ends towards the center:
right = left - 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 the last phase, we can return the boundaries of the middle partition:
return new Partition(right + 1, left - 1);
Finally, let's take a look at a demonstration of our implementation on a sample input

6. Algorithm Analysis
In general, the Quicksort algorithm has an average-case time complexity of O(n*log(n)) and worst-case time complexity of O(n2). With a high density of duplicate keys, we almost always get the worst-case performance with the trivial implementation of Quicksort.
However, when we use the three-way partitioning variant of Quicksort, such as DNF partitioning or Bentley's partitioning, we're able to prevent the negative effect of duplicate keys. Further, as the density of duplicate keys increase, the performance of our algorithm improves as well. As a result, we get the best-case performance when all keys are equal, and we get a single partition containing all equal keys in linear time.
Nevertheless, we must note that we're essentially adding overhead when we switch to a three-way partitioning scheme from the trivial single-pivot partitioning.
For DNF based approach, the overhead doesn't depend on the density of repeated keys. So, if we use DNF partitioning for an array with all unique keys, then we'll get poor performance as compared to the trivial implementation where we're optimally choosing the pivot.
But, Bentley-McIlroy's approach does a smart thing as the overhead of moving the equal keys from the two extreme ends is dependent on their count. As a result, if we use this algorithm for an array with all unique keys, even then, we'll get reasonably good performance.
In summary, the worst-case time complexity of both single-pivot partitioning and three-way partitioning algorithms is O(nlog(n)). However, the real benefit is visible in the best-case scenarios, where we see the time complexity going from O(nlog(n)) for single-pivot partitioning to O(n) for three-way partitioning.
7. Conclusion
In this tutorial, we learned about the performance issues with the trivial implementation of the Quicksort algorithm when the input has a large number of repeated elements.
With a motivation to fix this issue, we learned different three-way partitioning schemes and how we can implement them in Java.
Seperti biasa, kod sumber lengkap untuk implementasi Java yang digunakan dalam artikel ini tersedia di GitHub.