Уровень алгоритма

Участник:Yaselivoxin/Алгоритм: различия между версиями

Материал из Алговики
Перейти к навигации Перейти к поиску
Нет описания правки
Нет описания правки
 
(не показаны 4 промежуточные версии этого же участника)
Строка 1: Строка 1:
{{algorithm
| name              = Алгоритм быстрой сортировки Quicksort
| serial_complexity = <math>O(n^2)</math>
| pf_height        = <math>O(log n)</math>
| pf_width          = <math>O(n)</math>
| input_data        = <math>n</math>
| output_data      = <math>n</math>
}}
Основные авторы описания: [[Участник:Yaselivoxin|Я.А.Селивохин]].
Алгоритм быстрой сортировки QuickSort
Алгоритм быстрой сортировки QuickSort
== Свойства и структура алгоритмов ==
== Свойства и структура алгоритмов ==
=== Общее описание алгоритма ===
=== Общее описание алгоритма ===


Алгоритм быстрой сортировки (Quicksort) — это один из наиболее известных и часто применяемых алгоритмов сортировки с использованием стратегии "разделяй и властвуй". Алгоритм работает следующим образом: выбирается опорный элемент массива (pivot), и все элементы массива разделяются на две части: те, что меньше опорного, и те, что больше или равны ему. Затем рекурсивно сортируются эти две части. Сортировка продолжается, пока каждый подмассив не будет отсортирован, что приводит к полному упорядочиванию элементов массива.
Алгоритм быстрой сортировки (Quicksort) — один из наиболее известных алгоритмов сортировки, основанный на стратегии «разделяй и властвуй». Его работа заключается в следующем: выбирается опорный элемент массива (pivot), после чего массив разделяется на две части — элементы, меньшие опорного, и элементы, большие или равные ему. Затем обе части рекурсивно сортируются.
Этот процесс продолжается до тех пор, пока подмассивы не станут единичными, что приводит к полному упорядочению массива.


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


=== Математическое описание алгоритма ===
=== Математическое описание алгоритма ===


'''Входные данные:''' Массив ''А'' = [''a<sub>1</sub>'', ''a<sub>2</sub>'', ... , ''a<sub>n</sub>''] длинной ''n'', где каждый элемент массива ''a''<sub>i</sub> сравнивается с другими элементами массива с помощью операции "<=" ("меньше или равно").
''Входные данные:''' Массив
''A'' = [''a<sub>1</sub>'', ''a<sub>2</sub>'', … , ''a<sub>n</sub>''],
в котором элементы сравниваются операцией ≤, задающей полный порядок.
 
'''Выходные данные:''' Отсортированный массив ''A'', удовлетворяющий:
 
''a<sub>1</sub>'' ≤ ''a<sub>2</sub>'' ≤ … ≤ ''a<sub>n</sub>''.
 
Алгоритм основывается на операции разбиения массива относительно pivot:
 
L = { aᵢ | aᵢ < pivot },
R = { aᵢ | aᵢ ≥ pivot }.
 
Рекурсивная зависимость времени работы имеет вид:
 
:T(n) = T(|L|) + T(|R|) + Θ(n).
 
В среднем случае при равномерном разделении:
 
:T(n) = 2T(n/2) + Θ(n) = Θ(n log n).


'''Выходные данные:''' Отсортированный массив ''А'', такой что для всех ''i'' и ''j'' выполняется условие:
В худшем случае (несбалансированное разбиение):


''a<sub>1</sub>'' ≤ ''a<sub>2</sub>'' ≤ ... ≤ ''a<sub>n</sub>''.
:T(n) = T(n − 1) + Θ(n) = Θ(n²).


Код алгоритма на С++:
'''Код разбиения''' (С):


<source lang="с++">
<source lang="с">  
int partition(std::vector<int>& A, int lo, int hi) {
int partition(int* A, int lo, int hi)  
    int pivot = A[hi]; // Опорный элемент
{
    int i = lo - 1; // Индекс для меньших элементов
int pivot = A[hi]; // Опорный элемент
    for (int j = lo; j < hi; ++j) {
int i = lo - 1; // Индекс для меньших элементов
        if (A[j] <= pivot) {
for (int j = lo; j < hi; ++j)
            ++i;
{ if (A[j] <= pivot)
            std::swap(A[i], A[j]); // Меняем местами элементы
{ ++i; // Меняем местами элементы
        }
int temp = A[i];
    }
A[i] = A[j];
    std::swap(A[i + 1], A[hi]); // Ставим опорный элемент на правильную позицию
A[j] = temp; } } // Ставим опорный элемент на правильную позицию
    return i + 1; // Возвращаем индекс опорного элемента
int temp = A[i + 1];
}
A[i + 1] = A[hi];
A[hi] = temp;
return i + 1; // Возвращаем индекс опорного элемента
}  
</source>
</source>


=== Вычислительное ядро алгоритма ===
=== Вычислительное ядро алгоритма ===


Вычислительное ядро алгоритма быстрой сортировки заключается в разделении массива на два подмассива относительно опорного элемента, после чего рекурсивно сортируются эти подмассивы. Основная операция, выполняющаяся за ''O''(''n'') в каждом вызове рекурсии, — это разбиение массива на две части (пункт 1.2). Таким образом, вычислительное ядро — это функция partition.
Вычислительным ядром алгоритма является процедура ''partition'', которая за время ''O''(n) выполняет:
 
* последовательный просмотр массива,
* сравнение каждого элемента с pivot,
* перестановку элементов.
 
Именно операция разбиения определяет форму рекурсивного дерева и является основной вычислительно значимой частью алгоритма.


=== Макроструктура алгоритма ===
=== Макроструктура алгоритма ===
Строка 48: Строка 88:
=== Схема реализации последовательного алгоритма ===
=== Схема реализации последовательного алгоритма ===


Пример реализации быстрой сортировки на языке псевдокода:
Пример реализации быстрой сортировки на языке С:


* Разделение массива относительно опорного элемента.
* Разделение массива относительно опорного элемента.
* Рекурсивная сортировка двух подмассивов.
* Рекурсивная сортировка двух подмассивов.


Пример кода на C++:
Пример кода на C:


<source lang="с++">
<source lang="с">
// Рекурсивная функция быстрой сортировки
void quicksort(int* A, int lo, int hi) {
void quicksort(std::vector<int>& A, int lo, int hi) {
     if (lo < hi) {
     if (lo < hi) {
         int p = partition(A, lo, hi); // Разбиение массива
         int p = partition(A, lo, hi); // Разбиение массива
Строка 85: Строка 124:
* ЛЧ - левая часть
* ЛЧ - левая часть
* ПЧ - правая часть
* ПЧ - правая часть
Граф зависит от структуры данных:
для различных входов он имеет различную форму — граф недетерминирован.
Ключевые особенности:
* высота дерева рекурсии равна глубине алгоритма;
* ширина уровней может доходить до O(n) маленьких подмасивов;
* длинные дуги возникают при обменах pivot и элементов, так как значения могут находиться далеко в массиве.


=== Ресурс параллелизма алгоритма ===
=== Ресурс параллелизма алгоритма ===


Алгоритм быстрой сортировки обладает хорошим потенциалом для параллельной реализации. Основной параллелизм можно достичь за счет выполнения рекурсивных вызовов для левых и правых подмассивов параллельно. Для этого можно использовать многозадачность, например, с помощью библиотек OpenMP или стандартной библиотеки C++. Например, с использованием <source lang="с++">std::async</source>
Параллельная сложность определяется высотой рекурсивного дерева.
 
В среднем:
 
T<sub>p</sub>=''O''(log''n'')
(высота дерева ≈ log<sub>2</sub> n уровней).
 
Максимальный параллелизм — ширина ярусов:
 
P<sub>max</sub>(k)=2n/2<sup>k</sup>,
то есть на верхних уровнях доступно больше параллелизма, чем на глубоких.
 
Важно:
 
*в среднем "идеальный параллелизм" близок к n / log n;
* в худшем случае (квадратичный сценарий):
ресурс параллелизма резко падает (цепочка из n узлов).


=== Входные и выходные данные алгоритма ===
=== Входные и выходные данные алгоритма ===


Входные данные: Массив чисел (или других объектов, для которых определено сравнение), например:
Входные данные: массив чисел из линейно упорядоченного множества (ℝ, ℤ или пользовательский тип с тотальным порядком). Например:
''A'' = [5,3,8,4,2]
''A'' = [5,3,8,4,2]


Выходные данные: Отсортированный массив:
Выходные данные: перестановка входного массива, удовлетворяющая насыщенному порядку. Например:
''A'' = [2,3,4,5,8]
''A'' = [2,3,4,5,8]


=== Свойства алгоритма ===
=== Свойства алгоритма ===
* '''Детерминированность:''' Алгоритм недетерминированный по структуре: дерево рекурсии зависит от pivot, структура обменов зависит от данных, порядок сравнений непостоянен.
* '''Балансировка:''' Алгоритм плохо сбалансирован в худшем случае. При хороших pivot'ах — сбалансирован.
* '''Сложность:''' В худшем случае ''O''(''n<sup>2</sup>''), в среднем ''O''(''n''log''n'').
* '''Сложность:''' В худшем случае ''O''(''n<sup>2</sup>''), в среднем ''O''(''n''log''n'').
* '''Устойчивость:''' Алгоритм неустойчив — элементы с одинаковыми значениями могут менять свой порядок.
* '''Устойчивость:''' Алгоритм неустойчив — элементы с одинаковыми значениями могут менять свой порядок.
* '''Память:''' Работает на месте, не требуя дополнительной памяти.
* '''Память:''' Работает на месте, не требуя дополнительной памяти.
* '''Вычислительная плотность:''' Операции: почти все операции — сравнения; обращения к памяти играют заметную роль; вычислительная мощность ≈ 1 (низкая).
* '''Параллельность:''' Хорошо поддаётся параллельной реализации с использованием многозадачности для рекурсивных вызовов.
* '''Параллельность:''' Хорошо поддаётся параллельной реализации с использованием многозадачности для рекурсивных вызовов.
* '''Особенности реализации:''' кэш-локальность важна (partition использует последовательный проход → хорошая локальность); без оптимизации хвостовой рекурсии — возможно глубокое дерево вызовов.


== Программная реализация алгоритма ==
== Программная реализация алгоритма ==


===Особенности реализации последовательного алгоритма===
===Особенности реализации последовательного алгоритма===
Алгоритм QuickSort может быть реализован как в последовательной, так и в параллельной версиях. В основе алгоритма лежит стратегия, где массив делится на два подмассива относительно опорного элемента (pivot), и затем рекурсивно сортируются эти подмассивы.
быстрой сортировки (quicksort) над массивом целых чисел. Используется классическая схема разделения массива (partition), в которой последний элемент выбирается в качестве опорного. Данный подход обеспечивает хорошую производительность в среднем случае, однако в худшем случае приводит к квадратичной сложности. На практике это компенсируется случайным наполнением исходного массива и использованием параллельной сортировки на верхних уровнях рекурсии.
 
Важной особенностью реализации является управление глубиной рекурсии, передаваемое параметром depth. В последовательной ветке алгоритма этот параметр используется для ограничения порождения OpenMP-задач в параллельной версии, однако для последовательного исполнения это формирует чёткую структуру рекурсивного дерева вызовов.
 
Для ускорения исполнения используется условный переход к блочному распараллеливанию: если размер подмассива меньше порога THRESHOLD = 10000, сортировка выполняется последовательно без порождения задач. Такой блочный подход позволяет уменьшить накладные расходы, связанные с работой планировщика OpenMP, и обеспечивает лучшую локальность данных, поскольку каждый мелкий фрагмент обрабатывается одним потоком.
 
Алгоритм работает только с целыми числами (int), что избавляет от необходимости учитывать разрядность вещественных операций. Использование типа int снижает требования к памяти и увеличивает скорость вычислений благодаря простой арифметике и лучшей кэш-локальности.
 
Структура данных максимально проста — одномерный массив, передаваемый в рекурсивную функцию сортировки. Временные массивы не используются, за исключением локальной переменной при обмене элементов. Это делает реализацию экономичной по памяти: затраты ограничиваются O(1) дополнительной памятью, а общая глубина рекурсии ограничена длиной массива.
 
Ресурс параллелизма в последовательной форме ограничивается уровнем независимости подмассивов, возникающих после операции partition. Для случайного массива подмассивы приблизительно равны по размеру, что обеспечивает высокий потенциальный параллелизм. В последовательной реализации это свойство не используется непосредственно, но формирует основу для эффективного перехода к OpenMP и MPI в параллельной версии.
 
 
=== Параллельная реализация алгоритма и её особенности ===
Параллельная версия использует гибридную модель MPI + OpenMP и включает параллелизм двух уровней:
  1 Межузловой (распределённая память — MPI)
Исходный массив делится между всеми MPI-процессами. Распределение выполняется через MPI_Scatterv, что позволяет корректно передавать неравномерные размеры блоков (из-за возможного остатка N % size). Такой подход обеспечивает почти равномерную загрузку процессов.
 
Каждый MPI-процесс сортирует свою часть массива независимо. После локальной сортировки используется MPI_Gatherv для полного сбора данных в процесс с рангом 0. Для объединения частично отсортированных блоков может быть применён доп. этап слияния, но в данной реализации весь массив собирается как есть, что подходит для исследования производительности стадии сортировки.
 
Стоимость коммуникаций определяется:
* пересылкой N элементов при Scatterv и Gatherv;
* отсутствием обменов во время сортировки — полная независимость процессов.
 
  2 Внутриузловой (общая память — OpenMP)
Внутри каждого MPI-процесса сортировка осуществляется с помощью OpenMP-задач (#pragma omp task).
Параллельный quicksort строит дерево задач, где каждая задача отвечает за сортировку подмассива.
 
Особенности:
* Параллелизм ограничен глубиной MAX_DEPTH = 5, чтобы не создавать слишком много задач.
* Задачи создаются только для крупных подмассивов (THRESHOLD = 10000), что минимизирует накладные расходы и улучшает кэш-локальность.
* Конструкция #pragma omp single обеспечивает корректный запуск рекурсии из одного потока.
* Синхронизация достигается через #pragma omp taskwait.
 
    Особенности гибридной модели MPI+OpenMP
 
Преимущества:
* Разделение работ между узлами кластера (масштабирование по N).
* Распараллеливание рекурсивных вызовов внутри узлов (масштабирование по ядрам).
* Локальность данных: каждый узел сортирует свою часть памяти.
 
Недостатки:
* Финальный массив после Gatherv не является глобально отсортированным без дополнительного параллельного слияния.
* Производительность зависит от того, как равномерно разбиение Scatterv распределило данные.
 
Количество операций на каждый MPI-процесс ≈ O((N/size) log(N/size)).
Коммуникации = 2 передачи объёма N (одна Scatterv, одна Gatherv).
=== Результаты прогонов ===
Ниже приводятся результаты выполнения гибридного алгоритма на различных вычислительных платформах.
    1 Массив из 100 млн. элементов
Локальный компьютер (8 MPI-процесса)
Команда:
mpirun -np 8 ./quicksort_res
Результат:
Local sort time (first process): 4.437600 sec
Total MPI time: 4.604241 sec
 
СК «Ломоносов-2» (8 MPI процессов, очередь test)
Команда:
sbatch -p test -n 8 ompi ./quicksort_res
Результат:
Local sort time (first process): 1.301150 sec
Total MPI time: 2.216287 sec


В простейшей последовательной версии алгоритма QuickSort, как правило, используется следующая логика:
    2 Массив из 1 млрд. элементов
# Выбирается опорный элемент (например, средний элемент массива).
Локальный компьютер (8 MPI-процесса)
# Массив разделяется на два подмассива: элементы, меньшие опорного, и элементы, большие опорного.
Команда:
# Рекурсивно сортируются эти два подмассива, пока каждый из них не станет отсортированным.
mpirun -np 8 ./quicksort_res
Результат:


Пример последовательной реализации (без параллельности):
BAD TERMINATION OF ONE OF YOUR APPLICATION PROCESSES
PID 22917 RUNNING AT yaroslav-BRG-XXX
EXIT CODE: 9
CLEANING UP REMAINING PROCESSES
YOU CAN IGNORE THE BELOW CLEANUP MESSAGES


    void quicksort(int *arr, int left, int right) {
YOUR APPLICATION TERMINATED WITH THE EXIT STRING: Killed (signal 9)
    int i = left, j = right;
Локальный компьютер уже не способен обработать массив из 1 млрд. элементов.
    int pivot = arr[(left + right) / 2];
    int temp;
    while (i <= j) {
        while (arr[i] < pivot) i++;
        while (arr[j] > pivot) j--;
        if (i <= j) {
            temp = arr[i];
            arr[i] = arr[j];
            arr[j] = temp;
            i++; j--;
        }
    }
    if (left < j) quicksort(arr, left, j);  // Рекурсивно сортируем левую часть
    if (i < right) quicksort(arr, i, right);  // Рекурсивно сортируем правую часть
    }


СК «Ломоносов-2» (8 MPI процессов, очередь test)


Замер на 1 node
Команда:
sbatch -p test -n 8 -N 1 ompi ./quicksort_res
Результат:
Local sort time (first process): 6.017083 sec
Total MPI time: 8.022983 sec


=== Параллельная реализация алгоритма ===
Замер на 2 node
Для увеличения производительности на многозадачных системах, таких как суперкомпьютеры, можно параллелить рекурсивные вызовы алгоритма QuickSort. Параллельная версия алгоритма QuickSort будет разделять массивы и обрабатывать их в несколько потоков на высоких уровнях рекурсии, используя директивы OpenMP.
Команда:
sbatch -p test -n 8 -N 2 ompi ./quicksort_res
Результат:
Local sort time (first process): 5.994353 sec
Total MPI time: 8.194636 sec


В приведенном ниже коде используется условие, что параллельные задачи создаются только для рекурсивных вызовов, где размер подмассива больше заданного порога THRESHOLD. Это позволяет избежать излишних накладных расходов на параллельную обработку маленьких подмассивов.
Замер на 4 node
Команда:
sbatch -p test -n 8 -N 4 ompi ./quicksort_res
Результат:
Local sort time (first process): 5.484756 sec
Total MPI time: 7.773029 sec


Замер на 8 node
Команда:
sbatch -p test -n 8 -N 8 ompi ./quicksort_res
Результат:
Local sort time (first process): 5.984324 sec
Total MPI time: 8.361505 sec


    #include <stdio.h>
=== Выводы для классов архитектур ===
    #include <stdlib.h>
    #include <omp.h>
    #define THRESHOLD 10000  // Минимальный размер подмассива для параллелизации
    void quicksort(int *arr, int left, int right, int depth) {
    int i = left, j = right;
    int pivot = arr[(left + right) / 2];
    int temp;
    while (i <= j) {
        while (arr[i] < pivot) i++;
        while (arr[j] > pivot) j--;
        if (i <= j) {
            temp = arr[i];
            arr[i] = arr[j];
            arr[j] = temp;
            i++; j--;
        }
    }
    // Последовательная сортировка мелких подмассивов
    if (right - left < THRESHOLD) {
        if (left < j) quicksort(arr, left, j, depth + 1);
        if (i < right) quicksort(arr, i, right, depth + 1);
    }
    else {
        // Параллельные ветви создаются только на верхних уровнях
        #pragma omp task shared(arr) if (depth < 4)
        quicksort(arr, left, j, depth + 1);
        #pragma omp task shared(arr) if (depth < 4)
        quicksort(arr, i, right, depth + 1);
    }
    }
    int main() {
    int n = 1000000000;  // Размер массива
    int *arr = malloc(n * sizeof(int));
    if (!arr) {
        printf("Memory allocation failed!\n");
        return 1;
    }
    // Заполнение массива случайными числами
    for (int i = 0; i < n; i++)
        arr[i] = rand();
    double start = omp_get_wtime();  // Начало замера времени
    // Запуск параллельного выполнения с использованием OpenMP
    #pragma omp parallel
    {
        #pragma omp single
        quicksort(arr, 0, n - 1, 0);
    }
    double end = omp_get_wtime();  // Конец замера времени
    printf("Elapsed time: %.4f seconds\n", end - start);  // Вывод времени выполнения
    free(arr);  // Освобождение памяти
    return 0;
    }


    Многоядерные SMP-системы (общая память)
* OpenMP-parallel quicksort показывает существенное ускорение при достаточном размере данных.
* Ограничение глубины задач MAX_DEPTH предотвращает деградацию производительности.
* Локальность данных в рекурсивных подмассивах хорошо соответствует кэш-иерархии современных CPU.


=== Описание параллельных аспектов реализации ===
    Кластеры с распределённой памятью (MPI)
* Статическое разбиение массива через Scatterv обеспечивает сбалансированную загрузку узлов.
* Обмены ограничиваются двумя глобальными коммуникациями, что делает алгоритм масштабируемым.
* Для полной сортировки после Gatherv требуется глобальное параллельное слияние, если нужна полноценная сортировка.


В данном коде используются директивы OpenMP:
    Гибридные системы MPI + OpenMP
* Наиболее эффективная архитектура для данного алгоритма.
* Комбинация распределённой сортировки и локального распараллеливания обеспечивает максимальный параллелизм.
* Минимальное пересечение данных между узлами снижает сетевые затраты.


* #pragma omp parallel: инициирует параллельное выполнение с использованием нескольких потоков.
    GPU-архитектуры
* #pragma omp task: создаёт параллельные задачи для сортировки левой и правой части массива на каждом уровне рекурсии. Задачи создаются только для рекурсии на уровнях, где глубина рекурсии меньше 4 (это значение можно подбирать и настраивать).
Реализация в текущем виде не ориентирована на GPU:
* #pragma omp single: гарантирует, что только один поток будет выполнять начальный вызов quicksort, в то время как остальные потоки выполнят задачи параллельно.
* рекурсивная структура и неоднородность памяти плохо ложатся на SIMD-архитектуру.
* требуются специализированные алгоритмы на основе bitonic sort или radix sort.


=== Результаты и оценка производительности ===
Тем не менее часть вычислений может быть перенесена при переработке алгоритма.


== Литература ==  
== Литература ==  

Текущая версия от 11:42, 18 ноября 2025


Алгоритм быстрой сортировки Quicksort
Последовательный алгоритм
Последовательная сложность [math]\displaystyle{ O(n^2) }[/math]
Объём входных данных [math]\displaystyle{ n }[/math]
Объём выходных данных [math]\displaystyle{ n }[/math]
Параллельный алгоритм
Высота ярусно-параллельной формы [math]\displaystyle{ O(log n) }[/math]
Ширина ярусно-параллельной формы [math]\displaystyle{ O(n) }[/math]


Основные авторы описания: Я.А.Селивохин.

Алгоритм быстрой сортировки QuickSort

Свойства и структура алгоритмов

Общее описание алгоритма

Алгоритм быстрой сортировки (Quicksort) — один из наиболее известных алгоритмов сортировки, основанный на стратегии «разделяй и властвуй». Его работа заключается в следующем: выбирается опорный элемент массива (pivot), после чего массив разделяется на две части — элементы, меньшие опорного, и элементы, большие или равные ему. Затем обе части рекурсивно сортируются. Этот процесс продолжается до тех пор, пока подмассивы не станут единичными, что приводит к полному упорядочению массива.

Алгоритм относится к сортировкам сравнением и выполняется in-place, то есть не требует дополнительной памяти для хранения подмассивов. В среднем случае время работы алгоритма составляет O(nlogn), однако сильно зависит от выбора опорного элемента.

Математическое описание алгоритма

Входные данные:' Массив A = [a1, a2, … , an], в котором элементы сравниваются операцией ≤, задающей полный порядок.

Выходные данные: Отсортированный массив A, удовлетворяющий:

a1 ≤ a2 ≤ … ≤ an.

Алгоритм основывается на операции разбиения массива относительно pivot:

L = { aᵢ | aᵢ < pivot }, R = { aᵢ | aᵢ ≥ pivot }.

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

T(n) = T(|L|) + T(|R|) + Θ(n).

В среднем случае при равномерном разделении:

T(n) = 2T(n/2) + Θ(n) = Θ(n log n).

В худшем случае (несбалансированное разбиение):

T(n) = T(n − 1) + Θ(n) = Θ(n²).

Код разбиения (С):

 
int partition(int* A, int lo, int hi) 
{
 int pivot = A[hi]; // Опорный элемент
 int i = lo - 1; // Индекс для меньших элементов
 for (int j = lo; j < hi; ++j)
 { if (A[j] <= pivot)
 { ++i; // Меняем местами элементы
 int temp = A[i];
 A[i] = A[j];
 A[j] = temp; } } // Ставим опорный элемент на правильную позицию
 int temp = A[i + 1];
 A[i + 1] = A[hi];
 A[hi] = temp;
 return i + 1; // Возвращаем индекс опорного элемента
}

Вычислительное ядро алгоритма

Вычислительным ядром алгоритма является процедура partition, которая за время O(n) выполняет:

  • последовательный просмотр массива,
  • сравнение каждого элемента с pivot,
  • перестановку элементов.

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

Макроструктура алгоритма

Алгоритм состоит из двух основных макроопераций:

  1. Выбор опорного элемента (pivot).
  2. Разделение массива на две части, элементы которых меньше и больше опорного.
  3. Рекурсивная сортировка двух частей.

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

Схема реализации последовательного алгоритма

Пример реализации быстрой сортировки на языке С:

  • Разделение массива относительно опорного элемента.
  • Рекурсивная сортировка двух подмассивов.

Пример кода на C:

void quicksort(int* A, int lo, int hi) {
    if (lo < hi) {
        int p = partition(A, lo, hi); // Разбиение массива
        quicksort(A, lo, p - 1); // Сортируем левую часть
        quicksort(A, p + 1, hi); // Сортируем правую часть
    }
}

Последовательная сложность алгоритма

Временная сложность:

  • Лучший случай: O(nlogn), когда выбор опорного элемента сбалансирован, и массив делится примерно пополам.
  • Средний случай: O(nlogn), для случайного массива.
  • Худший случай: O(n2), если опорный элемент всегда оказывается наименьшим или наибольшим элементом.

Пространственная сложность: O(logn), поскольку рекурсия в худшем случает требует глубины стека O(n), а в среднем случае - O(logn).

Информационный граф

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

Рис.1. Информационная структура алгоритма быстрой сортировки

Вводимые замены:

  • ОЭ - опорный элемент
  • ЛЧ - левая часть
  • ПЧ - правая часть

Граф зависит от структуры данных: для различных входов он имеет различную форму — граф недетерминирован.

Ключевые особенности:

  • высота дерева рекурсии равна глубине алгоритма;
  • ширина уровней может доходить до O(n) маленьких подмасивов;
  • длинные дуги возникают при обменах pivot и элементов, так как значения могут находиться далеко в массиве.

Ресурс параллелизма алгоритма

Параллельная сложность определяется высотой рекурсивного дерева.

В среднем:

Tp=O(logn) (высота дерева ≈ log2 n уровней).

Максимальный параллелизм — ширина ярусов:

Pmax(k)=2n/2k, то есть на верхних уровнях доступно больше параллелизма, чем на глубоких.

Важно:

  • в среднем "идеальный параллелизм" близок к n / log n;
  • в худшем случае (квадратичный сценарий):

ресурс параллелизма резко падает (цепочка из n узлов).

Входные и выходные данные алгоритма

Входные данные: массив чисел из линейно упорядоченного множества (ℝ, ℤ или пользовательский тип с тотальным порядком). Например: A = [5,3,8,4,2]

Выходные данные: перестановка входного массива, удовлетворяющая насыщенному порядку. Например: A = [2,3,4,5,8]

Свойства алгоритма

  • Детерминированность: Алгоритм недетерминированный по структуре: дерево рекурсии зависит от pivot, структура обменов зависит от данных, порядок сравнений непостоянен.
  • Балансировка: Алгоритм плохо сбалансирован в худшем случае. При хороших pivot'ах — сбалансирован.
  • Сложность: В худшем случае O(n2), в среднем O(nlogn).
  • Устойчивость: Алгоритм неустойчив — элементы с одинаковыми значениями могут менять свой порядок.
  • Память: Работает на месте, не требуя дополнительной памяти.
  • Вычислительная плотность: Операции: почти все операции — сравнения; обращения к памяти играют заметную роль; вычислительная мощность ≈ 1 (низкая).
  • Параллельность: Хорошо поддаётся параллельной реализации с использованием многозадачности для рекурсивных вызовов.
  • Особенности реализации: кэш-локальность важна (partition использует последовательный проход → хорошая локальность); без оптимизации хвостовой рекурсии — возможно глубокое дерево вызовов.

Программная реализация алгоритма

Особенности реализации последовательного алгоритма

быстрой сортировки (quicksort) над массивом целых чисел. Используется классическая схема разделения массива (partition), в которой последний элемент выбирается в качестве опорного. Данный подход обеспечивает хорошую производительность в среднем случае, однако в худшем случае приводит к квадратичной сложности. На практике это компенсируется случайным наполнением исходного массива и использованием параллельной сортировки на верхних уровнях рекурсии.

Важной особенностью реализации является управление глубиной рекурсии, передаваемое параметром depth. В последовательной ветке алгоритма этот параметр используется для ограничения порождения OpenMP-задач в параллельной версии, однако для последовательного исполнения это формирует чёткую структуру рекурсивного дерева вызовов.

Для ускорения исполнения используется условный переход к блочному распараллеливанию: если размер подмассива меньше порога THRESHOLD = 10000, сортировка выполняется последовательно без порождения задач. Такой блочный подход позволяет уменьшить накладные расходы, связанные с работой планировщика OpenMP, и обеспечивает лучшую локальность данных, поскольку каждый мелкий фрагмент обрабатывается одним потоком.

Алгоритм работает только с целыми числами (int), что избавляет от необходимости учитывать разрядность вещественных операций. Использование типа int снижает требования к памяти и увеличивает скорость вычислений благодаря простой арифметике и лучшей кэш-локальности.

Структура данных максимально проста — одномерный массив, передаваемый в рекурсивную функцию сортировки. Временные массивы не используются, за исключением локальной переменной при обмене элементов. Это делает реализацию экономичной по памяти: затраты ограничиваются O(1) дополнительной памятью, а общая глубина рекурсии ограничена длиной массива.

Ресурс параллелизма в последовательной форме ограничивается уровнем независимости подмассивов, возникающих после операции partition. Для случайного массива подмассивы приблизительно равны по размеру, что обеспечивает высокий потенциальный параллелизм. В последовательной реализации это свойство не используется непосредственно, но формирует основу для эффективного перехода к OpenMP и MPI в параллельной версии.


Параллельная реализация алгоритма и её особенности

Параллельная версия использует гибридную модель MPI + OpenMP и включает параллелизм двух уровней:

  1 Межузловой (распределённая память — MPI)

Исходный массив делится между всеми MPI-процессами. Распределение выполняется через MPI_Scatterv, что позволяет корректно передавать неравномерные размеры блоков (из-за возможного остатка N % size). Такой подход обеспечивает почти равномерную загрузку процессов.

Каждый MPI-процесс сортирует свою часть массива независимо. После локальной сортировки используется MPI_Gatherv для полного сбора данных в процесс с рангом 0. Для объединения частично отсортированных блоков может быть применён доп. этап слияния, но в данной реализации весь массив собирается как есть, что подходит для исследования производительности стадии сортировки.

Стоимость коммуникаций определяется:

  • пересылкой N элементов при Scatterv и Gatherv;
  • отсутствием обменов во время сортировки — полная независимость процессов.
  2 Внутриузловой (общая память — OpenMP)

Внутри каждого MPI-процесса сортировка осуществляется с помощью OpenMP-задач (#pragma omp task). Параллельный quicksort строит дерево задач, где каждая задача отвечает за сортировку подмассива.

Особенности:

  • Параллелизм ограничен глубиной MAX_DEPTH = 5, чтобы не создавать слишком много задач.
  • Задачи создаются только для крупных подмассивов (THRESHOLD = 10000), что минимизирует накладные расходы и улучшает кэш-локальность.
  • Конструкция #pragma omp single обеспечивает корректный запуск рекурсии из одного потока.
  • Синхронизация достигается через #pragma omp taskwait.
   Особенности гибридной модели MPI+OpenMP

Преимущества:

  • Разделение работ между узлами кластера (масштабирование по N).
  • Распараллеливание рекурсивных вызовов внутри узлов (масштабирование по ядрам).
  • Локальность данных: каждый узел сортирует свою часть памяти.

Недостатки:

  • Финальный массив после Gatherv не является глобально отсортированным без дополнительного параллельного слияния.
  • Производительность зависит от того, как равномерно разбиение Scatterv распределило данные.

Количество операций на каждый MPI-процесс ≈ O((N/size) log(N/size)). Коммуникации = 2 передачи объёма N (одна Scatterv, одна Gatherv).

Результаты прогонов

Ниже приводятся результаты выполнения гибридного алгоритма на различных вычислительных платформах.

   1 Массив из 100 млн. элементов

Локальный компьютер (8 MPI-процесса) Команда: mpirun -np 8 ./quicksort_res Результат: Local sort time (first process): 4.437600 sec Total MPI time: 4.604241 sec

СК «Ломоносов-2» (8 MPI процессов, очередь test) Команда: sbatch -p test -n 8 ompi ./quicksort_res Результат: Local sort time (first process): 1.301150 sec Total MPI time: 2.216287 sec

   2 Массив из 1 млрд. элементов

Локальный компьютер (8 MPI-процесса) Команда: mpirun -np 8 ./quicksort_res Результат:

BAD TERMINATION OF ONE OF YOUR APPLICATION PROCESSES PID 22917 RUNNING AT yaroslav-BRG-XXX EXIT CODE: 9 CLEANING UP REMAINING PROCESSES YOU CAN IGNORE THE BELOW CLEANUP MESSAGES

YOUR APPLICATION TERMINATED WITH THE EXIT STRING: Killed (signal 9) Локальный компьютер уже не способен обработать массив из 1 млрд. элементов.

СК «Ломоносов-2» (8 MPI процессов, очередь test)

Замер на 1 node Команда: sbatch -p test -n 8 -N 1 ompi ./quicksort_res Результат: Local sort time (first process): 6.017083 sec Total MPI time: 8.022983 sec

Замер на 2 node Команда: sbatch -p test -n 8 -N 2 ompi ./quicksort_res Результат: Local sort time (first process): 5.994353 sec Total MPI time: 8.194636 sec

Замер на 4 node Команда: sbatch -p test -n 8 -N 4 ompi ./quicksort_res Результат: Local sort time (first process): 5.484756 sec Total MPI time: 7.773029 sec

Замер на 8 node Команда: sbatch -p test -n 8 -N 8 ompi ./quicksort_res Результат: Local sort time (first process): 5.984324 sec Total MPI time: 8.361505 sec

Выводы для классов архитектур

   Многоядерные SMP-системы (общая память)
  • OpenMP-parallel quicksort показывает существенное ускорение при достаточном размере данных.
  • Ограничение глубины задач MAX_DEPTH предотвращает деградацию производительности.
  • Локальность данных в рекурсивных подмассивах хорошо соответствует кэш-иерархии современных CPU.
   Кластеры с распределённой памятью (MPI)
  • Статическое разбиение массива через Scatterv обеспечивает сбалансированную загрузку узлов.
  • Обмены ограничиваются двумя глобальными коммуникациями, что делает алгоритм масштабируемым.
  • Для полной сортировки после Gatherv требуется глобальное параллельное слияние, если нужна полноценная сортировка.
   Гибридные системы MPI + OpenMP
  • Наиболее эффективная архитектура для данного алгоритма.
  • Комбинация распределённой сортировки и локального распараллеливания обеспечивает максимальный параллелизм.
  • Минимальное пересечение данных между узлами снижает сетевые затраты.
   GPU-архитектуры

Реализация в текущем виде не ориентирована на GPU:

  • рекурсивная структура и неоднородность памяти плохо ложатся на SIMD-архитектуру.
  • требуются специализированные алгоритмы на основе bitonic sort или radix sort.

Тем не менее часть вычислений может быть перенесена при переработке алгоритма.

Литература

  1. Cormen, T. H. , Leiserson, C. E. , Rivest, R. L. , Stein, C. (2009). Introduction to Algorithms. MIT Press.
  2. Knuth, D. E. (1998). The Art of Computer Programming, Volume 3: Sorting and Searching. Addison-Wesley.
  3. Hoare, C. A. R. (1961). Quicksort. Computer Journal, 5(1), 10-15.