Эта статья — третья и последняя в мини-серии об алгоритмах диапазонов. Мы рассмотрим некоторые алгоритмы сортировки, поиска и другие, а также познакомимся с готовящимися крутыми улучшениями этих алгоритмов в версии C++23. Поехали! Подробности — к старту курса по разработке на С++.


Прежде чем мы начнём


Ключевые наблюдения для алгоритмов std::ranges:


  • Алгоритмы диапазонов определяются в заголовке <algorithm>, а инфраструктура диапазонов и основные типы определяются в заголовке <ranges>.
  • Существует как минимум две перегрузки алгоритмов диапазона: с парой итераторов и перегрузка с одним аргументом диапазона.
  • Версия, которая возвращает поддиапазон или итератор и принимает диапазон, возвращает заимствованный диапазон или заимствованный итератор. Это помогает обнаруживать итераторы для временных диапазонов.
  • Версии диапазона имеют проекции, что обеспечивает большую гибкость; например, вы можете выполнить сортировку по некоторым выбранным элементам или выполнить дополнительные преобразования перед сравнением.
  • В версии с диапазонами нет опции параллельного выполнения (вы не можете передать политику std::execution).
  • Алгоритмы диапазонов, как и стандартные алгоритмы C++20, также являются constexpr.
  • Начиная с версии C++20, не существует алгоритмов числовых диапазонов, соответствующих заголовку <numeric>.

Ниже вы можете найти примеры, демонстрирующие стандартный алгоритм и альтернативную версию с диапазонами. В них мы иллюстрируем некоторые основные концепции, стараясь не использовать расширенную композицию диапазонов или представления. Мы пойдём в порядке, указанном в cppreference/algorithms.


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


Это третья статья об алгоритмах диапазонов. Смотрите также:


Разбиение на разделы и сортировка


sort и is_sorted


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


std::ranges::sort(myContainer);

Вот пример для лучшего понимания:


#include <iostream>
#include <algorithm>
#include <ranges>
#include <vector>

struct Product {
    std::string name;
    double value { 0.0 };
};

void print(std::string_view intro, const std::vector<Product>& container) {
    std::cout << intro << '\n';
    for (const auto &elem : container)
        std::cout << elem.name << ", " << elem.value << '\n';
}

int main() {
    const std::vector<Product> prods {
        { "box", 10.0 }, {"tv", 100.0}, {"ball", 30.0},
        { "car", 1000.0 }, {"toy", 40.0}, {"cake", 15.0},
        { "book", 45.0}, {"pc game", 35.0}, {"wine", 25}
    };

    print("input", prods);

    // the standard version:
    std::vector<Product> copy = prods;   
    std::sort(begin(copy), end(copy), [](const Product& a, const Product& b)
        { return a.name < b.name; }
    );

    print("after sorting by name", copy);

    // the ranges version:
    copy = prods;   
    std::ranges::sort(copy, {}, &Product::name);    
    print("after sorting by name", copy);           
    std::ranges::sort(copy, {}, &Product::value);    
    print("after sorting by value", copy);     
    auto sorted = std::ranges::is_sorted(copy, {}, &Product::value);
    std::cout << "is sorted by value: " << sorted << '\n';
}

Запустить @Compiler Explorer


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


Другие версии алгоритмов сортировки:


  • partial_sort — сортирует первые N элементов диапазона.
  • stable_sort — порядок эквивалентных элементов гарантированно сохраняется.

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


Подробнее: ranges::sort @Cppreference.


partition


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


#include <iostream>
#include <algorithm>
#include <ranges>
#include <vector>

void print(std::string_view intro, const std::vector<auto>& container) {
    std::cout << intro << '\n';
    for (const auto &elem : container)
        std::cout << elem << ", ";
    std::cout << '\n';
}

int main() {
    const std::vector vec { 11, 2, 3, 9, 5, 4, 3, 8, 4, 1, 11, 12, 10, 4};

    print("input", vec);

    // the standard version:
    auto copy = vec;   
    auto it = std::partition(begin(copy), end(copy), [](int a)
        { return a < 7; }
    );

    print("partition till 7", copy);
    std::cout << "pivot at " << std::distance(begin(copy), it) << '\n';

    // ranges version:
    copy = vec;   
    auto sub = std::ranges::partition(copy, [](int a)
        { return a < 7; }
    );

    print("partition till 7", copy);
    std::cout << "pivot at " << std::distance(begin(copy), sub.begin()) << '\n';
}

Запустить @Compiler Explorer


Вывод:


input
11, 2, 3, 9, 5, 4, 3, 8, 4, 1, 11, 12, 10, 4,
partition till 7
4, 2, 3, 1, 5, 4, 3, 4, 8, 9, 11, 12, 10, 11,
pivot at 8
partition till 7
4, 2, 3, 1, 5, 4, 3, 4, 8, 9, 11, 12, 10, 11,
pivot at 8

Как видите, мы легко можем разделить контейнер на две группы: первая часть содержит элементы меньше 7, а вторая часть — элементы >= 7. Относительный порядок между элементами может быть изменен (для сохранения этого порядка нужно использовать stable_partition).


Интерфейс для partition относительно прост. Версия алгоритма дополнительно принимает проекцию, но в примере она не использовалась. Отличие состоит в том, что ranges::partition возвращает поддиапазон, а не итератор (как в версии std::).


Подробнее об алгоритмах: ranges::is_partitioned и ranges::partition @C++Reference.


Операции бинарного поиска


Если контейнер уже отсортирован, можно выполнять операции логарифмического бинарного поиска.



#include <iostream>
#include <algorithm>
#include <ranges>
#include <vector>
#include <numeric>

void print(std::string_view intro, const auto& container) {
    std::cout << intro << '\n';
    for (const auto &elem : container)
        std::cout << elem << ", ";
    std::cout << '\n';
}

int main() {
    std::vector<int> vec(100, 0);
    std::iota(begin(vec), end(vec), 0);

    print("first ten elements of input", vec | std::views::take(10));

    // the standard version:
    auto copy = vec;   
    auto found = std::binary_search(begin(copy), end(copy), 13);
    std::cout << "found 13: " << found << '\n';

    // ranges version:
    copy = vec;   
    found = std::ranges::binary_search(copy, 13);
    std::cout << "found 13: " << found << '\n';
}

Запустить @Compiler Explorer


Читать подробнее:ranges::binary_search @C++Reference.


Кроме того, можно использовать связанные алгоритмы:



Операции с множествами


В библиотеке есть много функций, связанных с множествами, вот некоторые из них:


  • ranges::merge — объединяет два отсортированных диапазона
  • ranges::inplace_merge — объединяет два упорядоченных диапазона in-place
  • ranges::includes — возвращает true, если одна отсортированная последовательность является подпоследовательностью другой отсортированной последовательности
  • ranges::set_difference — вычисляет разницу между двумя множествами
  • ranges::set_intersection — вычисляет пересечение двух множеств
  • ranges::set_symmetric_difference — вычисляет симметричную разность двух множеств
  • ranges::set_union — вычисляет объединение двух множеств

Рассмотрим один пример с includes:


includes


Возвращает true, если отсортированный диапазон является подпоследовательностью другого отсортированного диапазона.


#include <iostream>
#include <algorithm>
#include <ranges>
#include <vector>
#include <string>

struct Product {
    std::string name;
    double value { 0.0 };
};

void print(std::string_view intro, const std::vector<Product>& container) {
    std::cout << intro << '\n';
    for (const auto &elem : container)
        std::cout << elem.name << ", " << elem.value << '\n';
}

int main() {
    std::vector<Product> prods {
        { "box", 10.0 }, {"tv", 100.0}, {"ball", 30.0},
        { "car", 1000.0 }, {"toy", 40.0}, {"cake", 15.0},
        { "book", 45.0}, {"pc game", 35.0}, {"wine", 25}
    };
    std::vector<Product> vecToCheck {
        {"ball", 30.0}, { "box", 10.0 }, {"wine", 25}
    };
    std::ranges::sort(prods, {}, &Product::name);
    std::vector<std::string> namesToCheck {"ball", "box", "wine"};

    print("input", prods);

    // the standard version:      
    auto ret = std::includes(begin(prods), end(prods),
                            begin(vecToCheck), end(vecToCheck),
                            [](const Product& a, const Product& b)
        { return a.name < b.name; }
    );
    std::cout << "contains the name set: " << ret << '\n';

    // the ranges version:
    ret = std::ranges::includes(prods, namesToCheck, {}, &Product::name);
    std::cout << "contains the name set: " << ret << '\n';
}

Запустить @Compiler Explorer


Версия с диапазонами проще и предлагает способ проверки различных контейнеров. При подходе std:: итератор необходимо разыменовать, а затем неявно преобразовать в оба типа элементов входного контейнера.


Дополнительную информацию см.: std::includes @cppreference.com.


Прочее


max_element


Поиск наибольшего элемента в контейнере (без сортировки):


#include <iostream>
#include <random>
#include <iterator>
#include <algorithm>
#include <ranges>

struct Product {
    std::string name_;
    double value_ { 0.0 };
};

int main() {
    const std::vector<Product> prods {
        { "box", 10.0 }, {"tv", 100.0}, {"ball", 30.0},
        { "car", 1000.0 }, {"toy", 40.0}, {"cake", 15.0},
        { "book", 45.0}, {"PC game", 35.0}, {"wine", 25}
    };

    // the standard version:   
    auto res = std::max_element(begin(prods), end(prods),
                [](const Product& a, const Product& b) {
                    return a.value_ < b.value_;
                });

    if (res != end(prods)) {
        const auto pos = std::distance(begin(prods), res);
        std::cout << "std::max_element at pos " << pos
                  << ", val " << res->value_ << '\n';
    }

    // the ranges version:
    auto it = std::ranges::max_element(prods, {}, &Product::value_);
    if (it != end(prods)) {
        const auto pos = std::distance(begin(prods), it);
        std::cout << "std::max_element at pos " << pos
                  << ", val " << res->value_ << '\n';
    }
}

Запустить @Compiler Explorer


equal


#include <iostream>
#include <random>
#include <iterator>
#include <algorithm>
#include <ranges>

struct Product {
    std::string name;
    double value { 0.0 };
};

int main() {
    const std::vector<Product> prods {
        { "box", 10.0 }, {"tv", 100.0}, {"ball", 30.0},
        { "car", 1000.0 }, {"toy", 40.0}, {"cake", 15.0},
    };

    const std::vector<Product> moreProds {
        { "box", 11.0 }, {"tv", 120.0}, {"ball", 30.0},
        { "car", 10.0 }, {"toy", 39.0}, {"cake", 15.0}
    };

    // the standard version:   
    auto res = std::equal(begin(prods), end(prods),
                          begin(moreProds), end(moreProds),
                [](const Product& a, const Product& b) {
                    return a.name == b.name;
                });

    std::cout << "equal: " << res << '\n';

    // the ranges version:
    res = std::ranges::equal(prods, moreProds, {}, &Product::name, &Product::name);
    std::cout << "equal: " << res << '\n';
}

Запустить @Compiler Explorer


Подробнее см.: ranges::equal @C++Reference.


Ещё больше


Мой список алгоритмов не полон. Почти у всех стандартных алгоритмов есть альтернатива std::ranges::. Взгляните на следующие интересные, не упомянутые мной алгоритмы:


Операции с кучей:


  • ranges::is_heap
  • ranges::is_heap_until
  • ranges::make_heap
  • ranges::push_heap
  • ranges::pop_heap
  • ranges::sort_heap

Перестановки:


  • ranges::is_permutation
  • ranges::next_permutation
  • ranges::prev_permutation

Алгоритмы неинициализированной памяти:


  • ranges::uninitialized_copy
  • ranges::uninitialized_copy_n
  • ranges::uninitialized_fill
  • ranges::uninitialized_fill_n
  • ranges::uninitialized_move
  • ranges::uninitialized_move_n
  • ranges::uninitialized_default_construct
  • ranges::uninitialized_default_construct_n
  • ranges::uninitialized_value_construct
  • ranges::uninitialized_value_construct_n
  • ranges::destroy
  • ranges::destroy_n
  • ranges::destroy_at
  • ranges::construct_at

Numeric


С версии C++20 у нас есть большинство соответствующих алгоритмов диапазонов из заголовка <algorithm>, но отсутствует заголовок <numeric>.


Скоро в C++23


Спецификация C++23 почти закончена и находится в режиме feature-freeze. На момент написания статьи мне известны следующие алгоритмы, которые появятся в новой версии C++:


  • ranges::starts_with и ranges::ends_with (по состоянию на июнь 2022 г. доступно в компиляторе MSVC)
  • ranges::contains (P2302)
  • ranges::shift_left и ranges::shift_right,
  • ranges::iota
  • ranges::fold в качестве альтернативы std::accumulate

Заключение


Эта статья завершает наше путешествие по алгоритмам C++, доступным в стандартной библиотеке (кроме числовых). У большинства алгоритмов есть аналоги ranges::, а в C++23 появится ещё больше дополнений.


А мы научим вас аккуратно работать с данными, чтобы вы прокачали карьеру и стали востребованным IT-специалистом.




Комментарии (7)


  1. excoder
    31.12.2022 10:18

    То что они "опустили" поддержку std::execution в ranges, вот так вот, это конечно просто уму непостижимо. Как если бы они сами не пользовались своим стандартом. Ведь это же очень логично, запустить read-only параллельную обработку данных. Это одна из причин, по которым я не пользуюсь ranges. Можно создать vector поверх read-only блоба и всё будет в целом ok. За исключением самой ситуации. А так было и со многим прочим, забыли то std::make_shared, то 1/sqrt(2*Pi), то ещё чего. Очень это всё странно.


    1. Kelbon
      31.12.2022 11:34
      +1

      это не "забыли".
      В поддержке параллельности очень много подводных камней - никто не мешает вам использовать сейчас обычные алгоритмы для этого, если вам нравится.
      Но вот откуда возьмётся тредпул для исполнения ваших задач вы явно не задумывались.

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

      Над интерфейсом параллельности/асинхронности уже давно работают, вот когда это будет готово тогда будет ясно как должны выглядеть многопоточные алгоритмы в ranges, какой интерфейс нужен будет екзекуторам, какие дефолтные реализации должны быть и т.д.

      Если добавить это сейчас, то в будущем придётся ломать код


      1. excoder
        31.12.2022 11:48
        +1

        Понимаю, что работают. КРАЙНЕ расслабленно. Зато — ритуальные принятия редчайших стат. распределений в Стандарт, лишь бы показать объём работы, вот мол, собираемся, трудимся! Создавать работу ради работы из-за бедности стандартной библиотеки и писать всё себе самописное — тоже так себе вариант.


        1. semenyakinVS
          01.01.2023 14:49
          +3

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


          1. excoder
            02.01.2023 22:10

            Солидарен. Но в экосистемах класса Java или .NET у нас есть нормальные штуки из коробки, и возможность подключить что-то более кастомное (производительное, ...) со стороны. В C++ по-прежнему, считать JSON это какое-то усилие — сторонняя библиотека, которую наверняка ещё и допилил сам когда-то из-за того, что она всё равно не покрывает популярных случаев. Близкие чаяния — в этой статье https://habr.com/ru/post/455726/ и в библиотеках Poco.


      1. excoder
        02.01.2023 22:16

        Наличие абстракции тред-пула в стандарте напрашивается уже лет 15. Даже с приходом std::async это всё что-то невразумительное. Так, копнём внутрь его реализации в VC++ и обнаружим, что значение а-ля system_concurrency зашито в ней как число потоков вполне себе такого тред-пула, но полностью спрятанного внутри реализации. Это крайне странно. В итоге пиши-пропало, хочешь управлять степенью конкурентности/параллельности — "пожалуйте бриться", то есть пишем своё или ищем чужое, или даже boost не дай бог. Как будто авторы Стандарта сами НИКОГДА не писали тред-пулы и прочие самые-самые-самые обычные штуки для полноценной утилизации ресурса машины.


      1. excoder
        02.01.2023 22:19

        То есть, по итогу, реализации Стандарта, в котором тред-пула нет, все ваяют внутри тот или иной тред-пул! Но которым по Стандарту нельзя никак управлять. В Стандарте ведь его нет :) Ну это на мой взгляд просто никуда не годится.