Сортировка и разделение (серия «114 алгоритмов C++»)
Добро пожаловать во вторую статью цикла. Стандартная библиотека C++ предлагает набор высокопроизводительных алгоритмов сортировки, частичной сортировки, разделения и выбора.
Сегодня мы начнем с std::sort
, обсудим лексикографическое сравнение и оператор spaceship (космический корабль) из C++20. Затем мы пройдемся по всем вариантам сортировок, частичным сортировкам и алгоритмам разделения, предлагаемым в стандартной библиотеке.
sort
Неудивительно, что основным алгоритмом сортировки является std::sort
.
sort | |
constexpr | начиная с C++20 |
параллельность | начиная с C++17 |
использование диапазонов | начиная с C++20 |
Ограничения | |
область применения | random_access_range |
функтор | строгое слабое упорядочение |
Сложность | |
сравнения | O(n*logn) начиная с C++11, в среднем O(n*logn) до C++11 |
Хотя фактическая реализация std::sort
может отличаться (обычно это интроспективная сортировка (introsort)), она гарантированно будет выполнять O(n*logn) сравнений (начиная с C++11). В результате она работает только с диапазонами произвольного доступа. Это означает, что мы не можем использовать std::sort
с std::list
, который предоставляет метод sort
со сложностью O(n*logn).
{
std::vector<int> data = {9, 1, 8, 2, 7, 3, 6, 4, 5};
std::sort(data.begin(), data.end());
// 1, 2, 3, 4, 5, 6, 7, 8, 9
}
{
std::list<int> data = {9, 1, 8, 2, 7, 3, 6, 4, 5};
//std::sort(data.begin(), data.end()); // не компилируется
data.sort();
// 1, 2, 3, 4, 5, 6, 7, 8, 9
}
В C++20 мы можем использовать проекции для сортировки по методу или члену данных:
struct Account {
double value();
};
std::vector<Account> fetch_accounts();
int main() {
std::vector<Account> data = fetch_accounts();
std::ranges::sort(data, std::greater<>(), &Account::value);
}
Здесь мы сортируем счета по их вычисленной стоимости в порядке убывания (технически в порядке невозрастания). std::greater<>
– это специализация std::greater
в C++14, основанная на выводе типа для определения типов параметров. До C++14 вам нужно было указать тип (в данном случае std::greater<double>()
).
Делаем ваш тип сравнимым
В примерах выше мы использовали std::sort
для типов, которые изначально сравнимы в базовом языке, или использовали проекцию, чтобы сузить пользовательский тип до double
. Однако иногда вы можете захотеть сделать сравнимым свой пользовательский тип.
Во-первых, вернемся к требованиям std::sort
. Он ожидает strict_weak_ordering
, что означает, что функтор сравнения (или оператор «меньше») должен быть
- антирефлексивным: f(a,a) = false,
- антисимметричным: f(a,b) = true => f(b, а) = false,
- и транзитивным: f(a,b) = true && f(b,c) = true => f(a,c) = true.
Если у вас нет специфичного для предметной области порядка для вашего конкретного типа данных, по умолчанию используется лексикографический порядок. Лексикографический порядок – это порядок, также обеспечиваемый стандартными контейнерами.
В C++20 с введением оператора космического корабля реализация сравнения для вашего типа стала намного проще:
struct Point {
int x;
int y;
// лексикографическое «меньше чем», до C++20
friend bool operator<(const Point& left, const Point& right) {
if (left.x != right.x)
return left.x < right.x;
return left.y < right.y;
}
// версия лексикографического сравнения с космическим кораблем,
// стандартная в C++20
friend auto operator<=>(const Point&, const Point&) = default;
// ручная реализация версии лексикографического сравнения
// с космическим кораблем в C++20
friend auto operator<=>(const Point& left, const Point& right) {
if (left.x != right.x)
return left.x <=> right.x;
return left.y <=> right.y;
}
};
Лексикографический порядок по умолчанию (строка 14) работает рекурсивно. Сначала он начинается с баз объектов, слева направо, сначала в глубину, а затем до нестатических членов в порядке объявления (обработка массивов поэлементно, слева направо).
Тип, возвращаемый оператором космического корабля, является общим типом категории сравнения для баз и элементов: strong_ordering
, weak_ordering
или partial_ordering
.
lexicographical_compare
, lexicographical_compare_three_way
Обсуждая лексикографическое сравнение, мы должны упомянуть о двух алгоритмах, предлагающих эту функциональность для диапазонов.
lexicographical_compare | |
constexpr | начиная с C++20 |
параллельность | начиная с C++17 |
использование диапазонов | начиная с C++20 |
Ограничения | |
область применения | (input_range, input_range) |
область применения при параллельных вычислениях | (forward_range, forward_range) |
функтор | строгое слабое упорядочение |
std::vector<int> range1{1, 2, 3}, range2{1, 3}, range3{1, 3, 1};
assert(std::lexicographical_compare(range1.begin(), range1.end(),
range2.begin(), range2.end()));
assert(std::lexicographical_compare(range2.begin(), range2.end(),
range3.begin(), range3.end()));
assert(range1 < range2); // то же, что и lexicographical_compare
assert(range2 < range3); // то же, что и lexicographical_compare
Как я уже упоминал, все стандартные контейнеры предлагают лексикографическое сравнение с помощью своих операторов сравнения (до C++20) или оператора космического корабля (начиная с C++20). Основное использование ручного вызова lexicographical_compare
– это использование массивов в стиле C или, когда вы хотите указать пользовательский компаратор элементов:
int x[] = {1, 2, 3}, y[] = {1, 4}; // только для демонстрации, используйте std::array
assert(std::lexicographical_compare(&x[0], &x[3], &y[0], &y[2]));
std::vector<std::string> names1{"Zod", "Celeste"}, names2{"Adam", "Maria"};
assert(std::ranges::lexicographical_compare(names1, names2,
[](const std::string& left, const std::string& right) {
return left.length() < right.length();
}));
lexicographical_compare_three_way (начиная с C++20) | |
constexpr | да |
параллельность | недоступно |
использование диапазонов | недоступно |
Ограничения | |
область применения | (input_range, input_range) |
функтор | сильное упорядочение, слабое упорядочение или частичное упорядочение |
lexicographical_compare_three_way
– это оператор космического корабля, эквивалентный lexicographical_compare
. Он возвращает один из типов strong_ordering
, weak_ordering
или partial_ordering
(в зависимости от предоставленного функтора).
stable_sort
std::sort
может свободно переупорядочивать эквивалентные элементы, что может быть нежелательно при повторной сортировке уже отсортированного диапазона. std::stable_sort
обеспечивает дополнительную гарантию сохранения относительного порядка одинаковых элементов. Если доступна дополнительная память, то stable_sort
остается O(n*logn). Однако если ему не удастся выделить память, он ухудшится до алгоритма O(n*logn*logn).
stable_sort | |
constexpr | недоступно |
параллельность | начиная с C++17 |
использование диапазонов | начиная с C++20 |
Ограничения | |
область применения | random_access_range |
функтор | строгое слабое упорядочение |
Сложность | |
сравнения | O(n*logn), O(n*logn*logn), если не может выделить дополнительную память |
Хотя сортировка stable_sort
не особенно полезна, когда мы уже сортируем по каждому аспекту типа (например, полагаясь на лексикографическое сравнение), она часто возникает, когда сортировка основана на вводе пользователя (например, повторная сортировка по столбцу в пользовательском интерфейсе).
struct Record {
std::string label;
int rank;
};
std::vector<Record> data {{"q", 1}, {"f", 1}, {"c", 2}, {"a", 1}, {"d", 3}};
std::ranges::stable_sort(data, {}, &Record::label);
std::ranges::stable_sort(data, {}, &Record::rank);
// Гарантированный порядок: a-1, f-1, q-1, c-2, d-3
is_sorted
, is_sorted_until
Чтобы проверить, отсортирован ли диапазон в соответствии с предоставленным предикатом, мы можем использовать is_sorted
. Предикат по умолчанию – std::less
.
is_sorted (начиная с C++11) | |
constexpr | начиная с C++20 |
параллельность | начиная с C++17 |
использование диапазонов | начиная с C++20 |
Ограничения | |
область применения | forward_range |
функтор | строгое слабое упорядочение |
std::vector<int> data1{1, 2, 3, 4, 5}, data2{5, 4, 3, 2, 1};
assert(std::is_sorted(data1.begin(), data1.end()));
assert(std::ranges::is_sorted(data2, std::greater<>()));
В качестве альтернативы мы можем использовать is_sorted_until
, который возвращает итератор к первому неупорядоченному элементу.
is_sorted_until (начиная с C++11) | |
constexpr | начиная с C++20 |
параллельность | начиная с C++17 |
использование диапазонов | начиная с C++20 |
Ограничения | |
область применения | forward_range → forward_iterator |
функтор | строгое слабое упорядочение |
std::vector<int> data{1, 5, 9, 2, 4, 6};
auto it = std::is_sorted_until(data.begin(), data.end());
// *it == 2
partial_sort
, partial_sort_copy
Часто нас интересует только верхняя пара элементов в порядке сортировки, что и обеспечивают алгоритмы частичной сортировки. Преимущество использования частичной сортировки заключается в более быстром выполнении – примерно O(n*logk), где k – количество отсортированных элементов.
partial_sort | |
constexpr | начиная с C++20 |
параллельность | начиная с C++17 |
использование диапазонов | начиная с C++20 |
Ограничения | |
область применения | (random_access_range, random_access_iterator) |
функтор | строгое слабое упорядочение |
Сложность | |
сравнения | O(n*logk) |
Поддиапазон для сортировки указывается с помощью среднего итератора с семантикой [begin, middle)
, представляющей отсортированную часть диапазона.
std::vector<int> data{9, 8, 7, 6, 5, 4, 3, 2, 1};
std::partial_sort(data.begin(), data.begin()+3, data.end());
// 1, 2, 3, -неопределенный порядок-
std::ranges::partial_sort(data, data.begin()+3, std::greater<>());
// 9, 8, 7, -неопределенный порядок-
Вариант partial_sort
, выгодный при работе с неизменяемыми исходными данными, – partial_sort_copy
.
partial_sort_copy | |
constexpr | начиная с C++20 |
параллельность | начиная с C++17 |
использование диапазонов | начиная с C++20 |
Ограничения | |
область применения | input_range → random_access_range |
функтор | строгое слабое упорядочение |
Сложность | |
сравнения | O(n*logk) |
Этот вариант требует только, чтобы диапазон назначения был изменяемым и имел произвольный доступ.
struct ScoreRecord {
std::string display;
uint16_t score;
};
const std::vector<ScoreRecord>& get_records();
int main() {
std::vector<ScoreRecord> top10(10, ScoreRecord{});
std::ranges::partial_sort_copy(get_records(), top10,
std::greater<>(), &ScoreRecord::score, &ScoreRecord::score);
// обработка 10 верхних элементов
}
partition
, stable_partition
, partition_copy
, is_partitioned
Алгоритмы разделения «разбивают» диапазон на два поддиапазона. В первый идут все элементы, удовлетворяющие заданному предикату, а во второй – все элементы, не удовлетворяющие предикату. Примечательно, что трехстороннее разделение является основным строительным блоком QuickSort.
partition | |
constexpr | начиная с C++20 |
параллельность | начиная с C++17 |
использование диапазонов | начиная с C++20 |
Ограничения | |
область применения | bidirectional_range → bidirectional_iterator (до C++11)forward_range → forward_iterator (начиная с C++11) |
функтор | унарный предикат |
Сложность | |
двунаправленное | N сравнений, N/2 перестановок местами |
однонаправленное | N сравнений, N перестановок местами |
параллельное | O(n*logn) перестановок местами, O(n) сравнений |
Если нас интересует просто группировка элементов по определенному свойству, std::partition
будет подходящим решением.
struct ExamResult {
std::string student_name;
uint16_t score;
};
std::vector<ExamResult> get_results();
int main() {
std::vector<ExamResult> results = get_results();
auto pp = std::partition(results.begin(), results.end(),
[limit = 49](const ExamResult& result) {
return result.score >= limit;
});
for (auto it = results.begin(); it != pp; it++) {
// обработка студентов с хорошей отметкой
}
for (auto it = pp; it != results.end(); it++) {
// обработка студентов с неудовлетворительной отметкой
}
}
Здесь мы делим результаты экзамена на сдавших и не сдавших студентов. Алгоритм разбиения возвращает точку разбиения (строка 11). Мы можем использовать эту точку раздела для обработки двух частей диапазона (строки 16 и 19).
По аналогии с is_sorted
, мы можем проверить, разделен ли диапазон (в соответствии с заданным предикатом), используя is_partitioned
.
is_partitioned (начиная с C++11) | |
constexpr | начиная с C++20 |
параллельность | начиная с C++17 |
использование диапазонов | начиная с C++20 |
Ограничения | |
область применения | input_range |
область применения при параллельных вычислениях | forward_range |
функтор | унарный предикат |
std::vector<int> data{2, 4, 6, 1, 3, 5};
auto is_even = [](int v) { return v % 2 == 0; };
assert(std::ranges::is_partitioned(data, is_even));
Стабильное разделение гарантирует, что оно не изменит относительный порядок элементов.
stable_partition | |
constexpr | недоступно |
параллельность | начиная с C++17 |
использование диапазонов | начиная с C++20 |
Ограничения | |
область применения | bidirectional_range → bidirectional_iterator |
функтор | унарный предикат |
Сложность | |
линейное | N сравнений, O(n) перестановок местами, O(n*logn) перестановок местами без дополнительной памяти |
параллельное | O(n*logn) перестановок местами, O(n) сравнений |
struct Item {
std::string label;
bool is_selected() const;
};
std::vector<Item> widget = get_widget();
std::ranges::stable_partition(widget, std::identity{}, &Item::is_selected);
// выбранные элементы перемещаются в начало диапазона
Этот код может сбивать с толку, потому что мы используем std::identity
вместо предиката. Однако обратите внимание, что результатом проекции является логическое значение. Следовательно, всё, что мы требуем от предиката, – это передать это значение.
Наконец, partition_copy
не будет переупорядочивать входной диапазон, а вместо этого скопирует два раздела в предоставленные выходные диапазоны.
partition_copy (начиная с C++11) | |
constexpr | начиная с C++20 |
параллельность | начиная с C++17 |
использование диапазонов | начиная с C++20 |
Ограничения | |
область применения | input_range → (output_range, output_range) |
область применения при параллельных вычислениях | forward_range → (forward_range, forward_range) |
функтор | унарный предикат |
std::vector<int> data{2, 4, 6, 1, 3, 5};
auto is_even = [](int v) { return v % 2 == 0; };
std::vector<int> even, odd;
std::partition_copy(data.begin(), data.end(),
std::back_inserter(even),
std::back_inserter(odd),
is_even);
// even == {2, 4, 6}
// odd == {1, 3, 5}
Мы используем адаптер back_inserter
для заполнения двух векторов без необходимости предварительного выделения достаточной емкости (адаптер внутри вызывает push_back
).
nth_element
Иногда нам нужно выбрать из диапазона только один конкретный элемент (например, при выборе медианы). Сортировка (даже частичная) может оказаться излишней из-за сложности O(n*logn). Для сложности O(n) нам нужно использовать алгоритм выбора, и nth_element
– один из таких алгоритмов.
nth_element | |
constexpr | начиная с C++20 |
параллельность | начиная с C++17 |
использование диапазонов | начиная с C++20 |
Ограничения | |
область применения | (random_access_range, random_access_iterator) |
функтор | строгое слабое упорядочение |
Сложность | |
последовательный | O(n) в среднем |
параллельный | O(n) сравнений, O(n*logn) перестановок местами |
Выбранный элемент указывается с помощью среднего итератора. Алгоритм переупорядочивает диапазон так, чтобы этот элемент был в отсортированной позиции. Более того, алгоритм слабо разбивает диапазон (каждый элемент до середины ≤ каждого элемента после середины).
std::vector<int> data{9, 1, 8, 2, 7, 3, 6, 4, 5};
std::nth_element(data.begin(), data.begin()+4, data.end());
// data[4] == 5
std::nth_element(data.begin(), data.begin()+7, data.end(), std::greater<>());
// data[7] == 2
В зависимости от вашего варианта использования, partial_sort
иногда может быть быстрее, чем nth_element
, несмотря на худшую теоретическую сложность.
Стандартная библиотека C: qsort
Поскольку стандартная библиотека C является частью стандартной библиотеки C++, у нас также есть доступ к qsort
.
int data[] = {2, 1, 9, -1, 7, -8};
int size = sizeof data / sizeof(int);
qsort(data, size, sizeof(int), [](const void* left, const void* right){
int vl = *(const int*)left;
int vr = *(const int*)right;
if (vl < vr) return -1;
if (vl > vr) return 1;
return 0;
});
// -8, -1, 1, 2, 7, 9
Я бы настоятельно рекомендовал избегать qsort
, так как std::sort
и std::ranges::sort
должны лучше подходить в любой ситуации. Более того, qsort
допустим только для тривиально копируемых типов, и они будут правильно оптимизированы для операций memcpy
даже при использовании std::sort
(при желании).
int data[] = {2, 1, 9, -1, 7, -8};
int size = sizeof data / sizeof(int);
std::sort(&data[0], &data[size], std::less<>());
// -8, -1, 1, 2, 7, 9
Спасибо за чтение
Следующая статья будет посвящена алгоритмам, работающим с отсортированными или секционированными диапазонами.