Разделяне и сортиране на масиви с много повтарящи се записи с примери за Java

1. Общ преглед

Сложността на алгоритмите по време на изпълнение често зависи от естеството на входа.

В този урок ще видим как тривиалната реализация на алгоритъма Quicksort има лоша производителност за повтарящи се елементи .

Освен това ще научим няколко варианта Quicksort за ефективно разделяне и сортиране на входове с висока плътност на дублиращи се ключове.

2. Тривиална Quicksort

Quicksort е ефективен алгоритъм за сортиране, базиран на парадигмата разделяне и завладяване. Функционално казано, той работи на място върху входния масив и пренарежда елементите с прости операции за сравнение и размяна .

2.1. Разделяне с едно завъртане

Тривиална реализация на алгоритъма Quicksort разчита в голяма степен на процедура за разделяне с една ос. С други думи, разделянето разделя масива A = [a p , a p + 1 , a p + 2 , ..., a r ] на две части A [p..q] и A [q + 1..r] такива че:

  • Всички елементи в първия дял, A [p..q] са по-малки или равни на стойността на ос A [q]
  • Всички елементи във втория дял, A [q + 1..r], са по-големи или равни на осната стойност A [q]

След това двата дяла се третират като независими входни масиви и се подават към алгоритъма Quicksort. Нека да видим Quickort на Lomuto в действие:

2.2. Изпълнение с повтарящи се елементи

Да приемем, че имаме масив A = [4, 4, 4, 4, 4, 4, 4], който има всички равни елементи.

При разделянето на този масив със схемата за разделяне с една ос ще получим два дяла. Първият дял ще бъде празен, докато вторият дял ще има N-1 елементи. Освен това, всяко следващо извикване на процедурата за разделяне ще намали размера на входа само с един . Нека да видим как работи:

Тъй като процедурата за разделяне има линейна времева сложност, в този случай общата времева сложност е квадратна. Това е най-лошият сценарий за нашия входен масив.

3. Трипосочно разделяне

За да сортираме ефективно масив с голям брой повтарящи се ключове, можем да изберем да боравим с по-отговорно с еднакви ключове. Идеята е да ги поставим в правилната позиция, когато ги срещнем за първи път. И така, това, което търсим, е състояние на три дяла на масива:

  • Най-левият дял съдържа елементи, които са строго по-малко от ключа за разделяне
  • В средата дял съдържа всички елементи, които са равни на разделящ ключ
  • Най-десният дял съдържа всички елементи, които са строго по-големи от ключа за разделяне

Сега ще се потопим по-дълбоко в няколко подхода, които можем да използваме, за да постигнем трипосочно разделяне.

4. Подходът на Дейкстра

Подходът на Dijkstra е ефективен начин за извършване на трипосочно разделяне. За да разберем това, нека разгледаме класически проблем с програмирането.

4.1. Проблем с холандското национално знаме

Вдъхновен от трицветния флаг на Холандия, Edsger Dijkstra предложи проблем с програмирането, наречен Холандски национален флаг (DNF).

Накратко, това е проблем с пренареждането, при който ни се дават топки от три цвята, поставени на случаен принцип в линия, и ние сме помолени да групираме едноцветните топки заедно . Освен това пренареждането трябва да гарантира, че групите следват правилния ред.

Интересното е, че проблемът с DNF прави поразителна аналогия с трипосочното разделяне на масив с повтарящи се елементи.

Можем да категоризираме всички номера на масив в три групи по отношение на даден ключ:

  • Червената група съдържа всички елементи, които са строго по-малки от ключовите
  • Бялата група съдържа всички елементи, които са равни на ключа
  • Синята група съдържа всички елементи, които са строго по-големи от ключа

4.2. Алгоритъм

Един от подходите за решаване на проблема с DNF е да изберете първия елемент като ключ за разделяне и да сканирате масива отляво надясно. Докато проверяваме всеки елемент, ние го преместваме в правилната му група, а именно по-малък, равен и по-голям.

За да следим напредъка си при разделянето, ще ни е необходима помощта на три указателя, а именно lt , current и gt. Във всеки момент от време елементите вляво от lt ще бъдат строго по-малки от ключа за разделяне, а елементите вдясно от gt ще бъдат строго по-големи от ключа .

Освен това ще използваме текущия указател за сканиране, което означава, че всички елементи, разположени между текущия и gt указателите, тепърва ще бъдат изследвани:

Като начало можем да зададем lt и текущи указатели в самото начало на масива и gt указателя в самия му край:

За всеки елемент, прочетен чрез текущия указател, ние го сравняваме с ключа за разделяне и предприемаме едно от трите съставни действия:

  • Ако вход [текущ] <ключ , тогава обменяме вход [текущ] и вход [lt] и увеличаваме както текущите, така и 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.

Както винаги, пълният изходен код за внедряването на Java, използван в тази статия, е достъпен на GitHub.