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

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

Материал из Алговики
Перейти к навигации Перейти к поиску
Обновлен 1.7
Нет описания правки
 
(не показано 7 промежуточных версий этого же участника)
Строка 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); // Разбиение массива
Строка 80: Строка 119:


[[Файл:structured_quick_sort_graph.png|thumb|center|300px|Рис.1. Информационная структура алгоритма быстрой сортировки]]
[[Файл:structured_quick_sort_graph.png|thumb|center|300px|Рис.1. Информационная структура алгоритма быстрой сортировки]]
Вводимые замены:
* ОЭ - опорный элемент
* ЛЧ - левая часть
* ПЧ - правая часть
Граф зависит от структуры данных:
для различных входов он имеет различную форму — граф недетерминирован.
Ключевые особенности:
* высота дерева рекурсии равна глубине алгоритма;
* ширина уровней может доходить до 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) над массивом целых чисел. Используется классическая схема разделения массива (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.
Тем не менее часть вычислений может быть перенесена при переработке алгоритма.


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

Текущая версия от 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.