Разделение и сортировка массивов со многими повторными записями с примерами Java
1. Обзор
Сложность времени времени работы алгоритмов часто зависит от характера ввода.
В этом учебнике мы увидим, как тривиальная реализация алгоритма quicksort имеет низкую производительность для повторяющихся элементов .
Кроме того, мы изучим несколько вариантов быстрого сортировки для эффективного раздела и сортировки входов с высокой плотностью дублирующих ключей.
2. Тривиальный Квиксорт
Квиксорт является эффективным алгоритмом сортировки, основанным на парадигме «разделяй и властвуй». Функционально говоря, это работает на месте на входе массива и перестраивает элементы с простым сравнением и своп операций .
2.1. Одноуговный раздел
Тривиальная реализация алгоритма Квиксорта в значительной степени зависит от односторонняя процедура раздела. Другими словами, разделение делит массив АЗА р , стр. 1 , стр. 2 ,…, г – на две части А.П. q и АЗК 1..р. такие, что:
- Все элементы в первом разделе, А.П.. q е меньше или равна значению опоры A q
- Все элементы второго раздела, АЗЗ1..р. больше или равна значению опоры АЗЗ
После этого эти два раздела рассматриваются как независимые входные массивы и подаются алгоритму Квиксорта. Давайте посмотрим Ломуто в Квиксорт в действии:
2.2. Производительность с повторными элементами
Допустим, у нас есть массив А No4, 4, 4, 4, 4, 4, 4, 4″, который имеет все равные элементы.
При разделении этого массива с одной поворотной схемой секционирования мы получим два раздела. Первый раздел будет пуст, а второй раздел будет иметь элементы N-1. Кроме того, каждое последующее вызов процедуры раздела уменьшит размер ввода только на одну . Давайте посмотрим, как это работает:
Поскольку процедура раздела имеет линейную сложность времени, общая сложность времени, в данном случае, является квадратной. Это наихудший сценарий для нашего входного массива.
3. Трехговейный раздел
Чтобы эффективно сортировать массив с большим количеством повторяющихся ключей, мы можем более ответственно обращаться с равными клавишами. Идея заключается в том, чтобы поместить их в правильном положении, когда мы впервые сталкиваемся с ними. Итак, мы ищем три состояния раздела массива:
- Самый левый раздел содержит элементы, которые строго меньше, чем ключ для секционирования
- средний раздел содержит все элементы, равные ключу секционирования
- Самый правый раздел содержит все элементы, которые строго больше, чем ключ для разделения
Теперь мы погрузимся глубже в несколько подходов, которые мы можем использовать для достижения трехготовного раздела.
4. Подход Дейкстры
Подход Дейкстры является эффективным способом трехготовного раздела. Чтобы понять это, давайте рассмотрим классическую проблему программирования.
4.1. Проблема национального флага Нидерландов
Вдохновленный триколором флаг Нидерландов , Эдсгер Дейкстра предложил проблему программирования под названием Голландский национальный флаг Проблема (DNF).
В двух словах, это проблема перестановки, когда нам дают шары трех цветов, расположенных случайным образом в линии, и нас просят сгруппить те же цветные шары вместе . Кроме того, перестановка должна обеспечивать, чтобы группы следовали правильному порядку.
Интересно, что проблема DNF делает поразительную аналогию с 3-м способом разделения массива с повторными элементами.
Мы можем классифицировать все числа массива на три группы по данному ключу:
- Красная группа содержит все элементы, которые строго меньше ключа
- Белая группа содержит все элементы, равные ключевым
- Синяя группа содержит все элементы, которые строго больше ключа
4.2. Алгоритм
Один из подходов к решению проблемы DNF заключается в том, чтобы выбрать первый элемент в качестве ключа раздела и сканировать массив слева направо. Проверяя каждый элемент, мы переместим его в правильную группу, а именно в Малый, Равный и Большой.
Чтобы отслеживать наш прогресс в разделении, нам нужна помощь трех указателей, а именно lt , текущие , и gt. В любой момент времени элементы слева от lt будет строго меньше, чем раздел ключ, и элементы справа от gt будет строго больше, чем ключевой .
Далее, мы будем использовать текущие указатель для сканирования, что означает, что все элементы, лежащие между текущие и gt указатели еще предстоит изучить:
Начнем с того, что мы можем установить lt и текущие указатели в самом начале массива и gt указатель в самом конце:
Для каждого элемента, прочитанного через текущие указатель, мы сравниваем его с ключом раздела и берем одно из трех составных действий:
- Если ввода (текущий) < ключевой , то мы обмениваемся входные данные (текущие) и входные данные и приращение как текущие и lt … Если
- входные данные (текущие) == ключевые , то мы приращения текущие указатель Если
- ввода (текущий) > ключевой , то мы обмениваемся входные данные (текущие) и входные данные и декремент gt
В конце концов, Мы остановимся, когда текущие и gt указатели пересекают друг друга . При этом размер неизведанной области сводится к нулю, и у нас остается только три необходимых раздела.
Наконец, давайте посмотрим, как этот алгоритм работает на входе массива с дублирующими элементами:
4.3. Осуществление
Во-первых, давайте напишем коммунальную процедуру под названием сравнить () для трехготовного сравнения между двумя числами:
public static int compare(int num1, int num2) { if (num1 > num2) return 1; else if (num1 < num2) return -1; else return 0; }
Далее давайте добавим метод под названием своп () для обмена элементами на двух индексах одного массива:
public static void swap(int[] array, int position1, int position2) { if (position1 != position2) { int temp = array[position1]; array[position1] = array[position2]; array[position2] = temp; } }
Чтобы однозначно определить раздел в массиве, нам понадобится его левая и правая граница-индексы. Итак, давайте идти вперед и создать Раздел класс:
public class Partition { private int left; private int right; }
Теперь мы готовы написать нашу трехготовную раздел () процедура:
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); }
Наконец, давайте напишем quicksort () метод, который использует нашу 3-ю схему секционирования для сортировки левого и правого разделов, :
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. Подход Бентли-Макилроя
Джон Бентли и Дуглас Макилрой соавтором оптимизированная версия алгоритма quicksort . Давайте поймем и реализуем этот вариант в Java:
5.1. Схема разделов
Суть алгоритма заключается в схеме секционирования на основе итерации. В начале, весь массив чисел является неизведанной территорией для нас:
Затем мы начинаем изучать элементы массива в левом и правом направлении. Всякий раз, когда мы входим или выходим из цикла разведки, мы можем визуализировать массив как состав из пяти регионов :
- На двух крайних концах лежит регионы, имеющие элементы, равные значениям раздела
- Неизведанная область остается в центре, и ее размер продолжает сокращаться с каждой итерацией
- Слева от неизведанного региона лежат все элементы меньше, чем значение раздела
- На правой стороне неизведанного региона находятся элементы, большие, чем значение раздела
В конце концов, наш цикл исследования завершается, когда нет элементов, которые будут изучены больше. На данном этапе размер неизведанного региона фактически нулевой , и у нас осталось только четыре региона:
Далее мы переместить все элементы из двух равных регионов в центре так что есть только один равный регион в центре, окружающем менее региона слева и больше региона справа. Для этого, во-первых, мы обмениваем элементы в левом равном регионе на элементы на правом конце меньшего региона. Аналогичным образом, элементы в правом равном регионе обмениваются элементами на левом конце большего региона.
Наконец, мы будем осталось только три раздела , и мы можем и далее использовать тот же подход к разделу все меньше и больше регионов.
5.2. Осуществление
В нашей рекурсивной реализации трехсторонняя компания «Квиксорт» нам необходимо вызвать нашу процедуру раздела для под массивов, которые будут иметь другой набор нижних и верхних границ. Итак, наши раздел () метод должен принимать три ввода, а именно массив вместе с его левыми и правыми границами.
public static Partition partition(int input[], int begin, int end){ // returns partition window }
Для простоты, мы можем выбрать значение раздела в качестве последнего элемента массива . Кроме того, давайте определим две переменные левый начало и право-конец исследовать массив внутрь.
Кроме того, мы также должны отслеживать количество равных элементов, лежащих на левой и правой . Итак, давайте инициализируем leftEqualKeysCount-0 и rightEqualKeysCount-0 , и теперь мы готовы изучить и разделить массив.
Во-первых, мы начинаем двигаться как с направления, так и найти инверсию где элемент слева не меньше значения раздела, а элемент справа не больше значения раздела. Затем, если два указателя влево и вправо пересекли друг друга, мы поменяем два элемента.
В каждой итерации мы переместим элементы, равные секционированиеВалютная к двум концам и приращению соответствующего счетчика:
while (true) { while (input[left] < partitioningValue) left++; while (input[right] > 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--; }
На следующем этапе мы должны перемещать все равные элементы с двух концов в центре . После выхода из цикла левый указатель будет на элементе, значение которого не менее секционированиеВалютная . Используя этот факт, мы начинаем двигаться равными элементами с двух концов к центру:
right = left - 1; for (int k = begin; k < begin + leftEqualKeysCount; k++, right--) { if (right >= begin + leftEqualKeysCount) swap(input, k, right); } for (int k = end; k > end - rightEqualKeysCount; k--, left++) { if (left <= end - rightEqualKeysCount) swap(input, left, k); }
На последнем этапе мы можем вернуть границы среднего раздела:
return new Partition(right + 1, left - 1);
Наконец, давайте посмотрим на демонстрацию нашей реализации на примере ввода
6. Алгоритмный анализ
В целом, алгоритм Квиксорта имеет среднюю сложность времени O (n’log(n)) и наихудшую сложность времени O (n 2 ). С высокой плотностью дубликатов ключей, мы почти всегда получаем наихудшие показатели с тривиальной реализацией quicksort.
Однако, когда мы используем трехсторонняя перегородка вариант Квиксорт, таких как DNF раздела или Bentley в разделе, мы в состоянии предотвратить негативный эффект дублировать ключи. Кроме того, по мере увеличения плотности дублирующих ключей повышается и производительность нашего алгоритма. В результате мы получаем лучшую производительность, когда все клавиши равны, и получаем одну секцию, содержащую все равные клавиши в линейное время.
Тем не менее, мы должны отметить, что мы по существу добавляем накладные расходы, когда переходим на трехгольную схему секционирования от тривиального одноколенного раздела.
Для подхода, основанного на DNF, накладные расходы не зависят от плотности повторяющихся ключей. Таким образом, если мы используем раздел DNF для массива со всеми уникальными ключами, то мы получим низкую производительность по сравнению с тривиальной реализацией, где мы оптимально выбираем опору.
Но, подход Бентли-Макилрой делает умные вещи, как накладные расходы перемещения равных ключей от двух крайних концов зависит от их подсчета. В результате, если мы используем этот алгоритм для массива со всеми уникальными ключами, даже тогда, мы получим достаточно хорошую производительность.
Таким образом, наихудшая сложность времени как одноявных раздельных, так и трехготовных алгоритмов секционирования O (nlog(n)) . Тем не менее, реальная выгода видна в наилучших сценариях , где мы видим, сложность времени происходит от O (nlog(n)) для одно опорного раздела на О(н) для трехготовного раздела.
7. Заключение
В этом учебнике мы узнали о проблемах с производительностью с тривиальной реализацией алгоритма quicksort, когда вход имеет большое количество повторяющихся элементов.
С мотивацией, чтобы исправить эту проблему, мы узнал различные схемы трехготовного и как мы можем реализовать их на Java.
Как всегда, полный исходный код для реализации Java, используемый в этой статье, доступен GitHub .