Что такое быстрая сортировка? Как это работает? Какова его временная и пространственная сложность? Это лучший алгоритм сортировки? В этой статье мы дадим ответы на все эти вопросы.

Быстрая сортировка – это эффективный и широко используемый алгоритм сортировки, основанный на принципе разделяй и властвуй. Этот подход предполагает разбиение сложной проблемы на более мелкие, более управляемые части, которые можно легко решить.

Рекомендуемые предварительные знания:

  • Рекурсия
  • Массивы

Как работает быстрая сортировка

Быстрая сортировка основана на концепции функции partition. Фундаментальная идея этой функции состоит в том, чтобы выбрать основной элемент из массива для сортировки и переупорядочить другие элементы таким образом, чтобы все значения, большие или равные опорному, были помещается после него, а все значения, меньшие опорного, помещаются перед ним.

Выбирая подходящую опорную точку, QuickSort эффективно делит массив на два подмассива, каждый из которых можно сортировать независимо. Процесс разделения повторяется рекурсивно для подмассивов, пока не будет отсортирован весь массив.

Важно отметить, что основная обязанность функции секционирования — не сортировка всего массива, а правильное определение и позиционирование опорной точки. Именно через все последующие рекурсивные вызовы массив постепенно сортируется.

Другими словами, мы используем функцию распределения, чтобы выбрать опорный элемент и переупорядочить массив таким образом, чтобы все элементы после опорного элемента были больше или равны ему, а все элементы перед ним были меньше. Функция разбиения возвращает положение оси. Затем мы рекурсивно вызываем функцию QuickSort для подмассивов до и после поворота, чтобы отсортировать их. При многократном применении этого процесса весь массив эффективно сортируется.

Как выбрать хороший разворот

Ключ к эффективности быстрой сортировки заключается в тщательном выборе опорной точки. Идеальный свод — это тот, который равномерно делит массив, создавая два подмассива примерно одинакового размера. Сбалансированное разделение сводит к минимуму количество необходимых рекурсивных вызовов и обеспечивает эффективную сортировку. Как мы увидим, когда опорная точка выбрана неправильно, алгоритм может столкнуться со сценариями, в которых созданные разделы сильно несбалансированы.

Наиболее распространенные методы выбора точки опоры:

Первый элемент, последний элемент или средний элемент

Самый простой подход — выбрать либо первый элемент, либо последний элемент, либо средний элемент массива в качестве опорного. Этот метод легко реализовать, но не всегда можно получить сбалансированные разделы, особенно если массив уже отсортирован или почти отсортирован.

Случайный элемент

Другой подход состоит в том, чтобы выбрать случайный элемент из массива в качестве опорной точки. Этот метод помогает смягчить проблему сценариев наихудшего случая и повышает производительность быстрой сортировки в среднем случае.

При случайном выборе опорной точки вероятность встретить отсортированный или почти отсортированный массив снижается, что приводит к более сбалансированным разбиениям.

Медиана из трех

Метод медианы трех направлен на выбор опорной точки, которая ближе к истинной медиане массива. Он включает в себя сравнение и выбор медианного значения среди первого элемента, среднего элемента и последнего элемента массива. Такой подход помогает получить более сбалансированные разделы и во многих случаях повышает общую производительность QuickSort.

Стоит отметить, что существуют и другие продвинутые стратегии выбора разворота для оценки хорошего разворота. Эти методы выходят за рамки базовой быстрой сортировки, но могут дополнительно оптимизировать процесс сортировки для определенных сценариев или специализированных приложений.

В нашем случае для простоты мы будем реализовывать первый метод. Мы выберем в качестве точки опоры средний элемент массива.

Помните, что то, как мы выбрали опорную точку, не влияет на результат, которым является отсортированный массив, а только на его эффективность.

Как работает раздел

После выбора опорной точки с использованием одного из методов выбора опорной точки, упомянутых ранее, мы можем приступить к процессу разбиения. Рассмотрим следующий пример:

После применения алгоритма разбиения к массиву мы видим, что ни левые, ни правые элементы свода не отсортированы. Однако в чем мы можем быть уверены, так это в том, что точка поворота правильно расположена внутри массива.

Когда дело доходит до схем разделов, нам представлены два популярных варианта: Lomuto и Hoare.

Схема разделения Lomuto обычно считается более простой в реализации из-за ее простой логики и простоты.

С другой стороны, схема разделения Хоара немного сложнее, но на практике часто обеспечивает лучшую производительность.

Мы будем работать по схеме Ломуто.

int partition(int array[], int low, int high)
{
    int pivot, pivotIndex, i;

    swap(array, low, (low+high)/2);       // 1

    pivot = array[low];                   // 2
    pivotIndex = low;                     // 2
    
    for(i = low+1; i <= high; i++)        // 3
        if(array[i] < pivot)
            swap(array, ++pivotIndex, i); // 3

    swap(array, low, pivotIndex);         // 4

    return pivotIndex;                    // 5
}

Принцип работы с разделами довольно прост.

  1. Чтобы упростить манипулирование массивом и сосредоточиться исключительно на остальных элементах, мы заменяем первый элемент средним элементом. Следовательно, опорная точка, ранее располагавшаяся посередине, теперь располагается в начале массива. .
  2. Мы определяем первый элемент как опорный и сохраняем его начальное положение как поворотный индекс.
  3. Начиная с элемента сразу после поворота, мы итерируем оставшиеся элементы. На каждом шаге мы сравниваем значение текущего элемента (в позиции i) с опорным значением. Если текущий элемент меньше опорного, мы заменяем его новой позицией опорного элемента (pivotPosition, которая увеличивается на единицу перед обменом). Важно отметить, что позиция опорной точки больше не указывает на ее положение в массиве, а указывает, куда ее следует поместить, чтобы гарантировать, что все предыдущие значения будут меньше (и все последующие значения будут больше после завершения цикла).
  4. Мы заменяем значение в начале массива значением в опорной позиции. Этот шаг имеет решающее значение, поскольку он гарантирует, что точка поворота, которая изначально находилась в начале массива, теперь правильно расположена в точке поворота. Такое размещение гарантирует, что все значения, предшествующие опорной точке, будут меньше, а все значения, следующие за ней, будут больше. Стоит отметить, что конкретное значение, хранящееся в массиве[pivotPosition], не имеет значения; единственное требование состоит в том, что он должен быть меньше, чем стержень. Как только опорная точка правильно расположена в массиве, она остается фиксированной в этом месте на протяжении всего остального процесса сортировки. Его окончательное размещение гарантирует, что он действует как точка отсчета для разделения массива на два раздела; элементы меньше, чем стержень слева, и элементы больше, чем стержень справа.
  5. На этом этапе мы возвращаем pivotPosition. Это значение имеет решающее значение для последующих шагов функции QuickSort, поскольку оно обеспечивает правильную адресацию двух подмассивов, которые необходимо отсортировать.
void swap(int array[], int index1, int index2)
{
    int temp = array[index1];

    array[index1] = array[index2];
    array[index2] = temp;
}

Как работает быстрая сортировка

Теперь, когда мы поняли, как работает процесс разбиения, мы можем углубиться в функцию быстрой сортировки. Эта функция посредством рекурсивных обращений к самой себе гарантирует сортировку массива, разбивая его на две половины на основе сводного индекса.

void quickSort(int array[], int low, int high)
{
    int pivotIndex;

    if(low < high)                                // 1
    {
        pivotIndex = partition(array, low, high); // 2
        quickSort(array, low, pivotIndex-1);      // 3
        quickSort(array, pivotIndex+1, high);     // 4
    }
    // 5
}

Давайте разберем шаги работы быстрой сортировки:

  1. Алгоритм быстрой сортировки работает посредством серии рекурсивных вызовов. Рекурсивная цепочка завершается, когда первый индекс превышает или становится равным последнему индексу. Это условие означает, что у нас остался только один элемент для сортировки, который не требует дальнейших действий.
  2. Мы получаем значение pivotIndex, которое представляет собой позицию пивота. Поворотный элемент уже находится в правильном положении и на этом этапе считается отсортированным.
  3. Мы вызываем быструю сортировку массива от начала до pivotIndex-1. Поскольку опорный элемент уже отсортирован; мы сосредоточимся на сортировке оставшихся элементов, которые меньше, чем опорный элемент в этом подмассиве.
  4. Мы вызываем QuickSort для массива от privotIndex+1 до lastIndex.

5. Происходят все рекурсивные вызовы; массив отсортирован.

Сложность

Космос

При анализе пространственной сложности быстрой сортировки мы смотрим на максимальное количество рекурсивных вызовов, присутствующих в стеке (максимальное количество записей, присутствующих в стеке одновременно) как в лучшем, так и в худшем случае.

В средних и лучших случаях быстрой сортировки процесс разделения равномерно разбивает массив на два вложенных массива. Это сбалансированное разбиение на разделы логарифмически уменьшает размер разделов при каждом рекурсивном вызове. В результате максимальная глубина стека вызовов пропорциональна log(n), поэтому сложность пространства в среднем и лучшем случаях быстрой сортировки составляет O(log n).

В наихудшем сценарии разделение сильно несбалансировано, что приводит к n рекурсивным вызовам. Это происходит, например, когда массив уже отсортирован или почти отсортирован. В таких случаях сложность пространства возрастает до O(n). Каждый рекурсивный вызов выполняется с подмассивом размера n-1, что приводит к линейному увеличению объема требуемого пространства.

Время

Временная сложность быстрой сортировки в значительной степени зависит от эффективности выбора опорной точки.

Наилучший сценарий возникает, когда массив делится на два массива одинакового размера (стержнем является средний элемент массива). Процесс секционирования делит массив на половины при каждом вызове. Высота дерева рекурсии логарифмическая; журнал (n). Таким образом, временная сложность для наилучшего сценария может быть выражена как O(log(n) * n), поскольку для каждого вызова функции разделения выполняются с временной сложностью O(n).

Это происходит потому, что при каждом делении массива функция разбиения вызывается столько раз, сколько разделяется массив. Глядя на диаграмму, мы видим, что когда мы достигаем второго уровня, разбиение вызывается дважды для двух массивов длины n/2, что приводит к n/2 * 2 = n операциям, что приводит к временная сложность O(n). Этот шаблон повторяется для каждого уровня подразделения.

В наихудшем сценарии массив длины n делится на два подмассива разной длины, где один подмассив содержит n-1 элементов, а другой содержит только 1 элемент. Обычно это происходит, когда стержень является самым большим или самым маленьким элементом в массиве. В результате производительность алгоритма значительно снижается из-за увеличения количества рекурсивных вызовов. Когда это происходит для каждого подмассива, мы имеем временную сложность O(n²), где один n соответствует каждому вызову функции распределения (которая имеет временную сложность O(n)) и другое n представляет общее количество рекурсивных вызовов для каждого элемента массива.

В среднем случае, учитывающем сбалансированное распределение входных данных, временная сложность обычно обозначается как O(log(n) * n).

Является ли QuickSort лучшим алгоритмом сортировки?

QuickSort часто называют одним из лучших алгоритмов сортировки из-за его общей производительности. Хотя у него есть свои недостатки, на практике он оказывается наиболее эффективным алгоритмом сортировки в различных сценариях.

Средняя производительность

Временная сложность QuickSort в среднем O(n * log(n)) делает его очень эффективным для сортировки больших наборов данных. Он часто превосходит другие популярные алгоритмы сортировки, такие как MergeSort.

Сортировка на месте

QuickSort выполняет сортировку на месте, то есть не требует дополнительной памяти помимо входного массива (например, для MergeSort требуется дополнительный массив внутри функции слияния). Это делает его эффективным с точки зрения использования памяти и особенно полезным при работе с ограниченными ресурсами памяти.

Распараллеливание

Поскольку quickSort следует рекурсивному подходу, он позволяет легко выполнять распараллеливание. Разделив массив на подмассивы, каждый раздел можно сортировать по отдельности, что обеспечивает параллельное выполнение и потенциальный прирост производительности в многопоточных или распределенных вычислительных средах.

Адаптивность

Производительность быстрой сортировки может быть улучшена с помощью различных методов оптимизации. К ним относятся осторожные стратегии выбора опорных точек (например, медиана трех, случайные опорные точки) для смягчения наихудших сценариев.

Важно отметить, что QuickSort не является стабильным алгоритмом сортировки, то есть он может изменить относительный порядок одинаковых элементов в процессе сортировки. Если сохранение исходного порядка одинаковых элементов имеет решающее значение, следует рассмотреть стабильные алгоритмы сортировки, такие как сортировка слиянием.

Спасибо, что дочитали этот пост до сюда. Эта статья должна дать только общий взгляд на QuickSort, и я надеюсь, что смог разжечь ваше любопытство к нему, чтобы вы могли более глубоко изучить его сложность и методы оптимизации.

Не забывайте оставлять аплодисменты, подписываться и комментировать свои мысли, мы будем очень признательны. Спасибо!

~ Тобиас Кандела

(Вы можете найти весь код, используемый здесь).