Т-Банк

Алгоритмы.

Мини-курс по C++

Лекция 3. Асимптотика и базовые алгоритмы

Асимптотика

Зачем это нужно?

Представим, что у нас есть два алгоритма, которые решают одну и ту же задачу. Как понять, какой из них быстрее, без запуска? Или как оценить, успеет ли программа выполниться за 1 секунду, или мы получим Time Limit? Асимптотика — способ примерно оценить, насколько долго выполняется программа в зависимости от входных данных.

Примеры

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

Что такое логарифм?

Логарифм по основанию 2 (пишут log2n или просто logn) — это сколько раз нужно разделить число n на 2, чтобы получить 1. Формально: logn = k, если 2k ≥ n и 2k − 1 < n.

Примеры, которые стоит запомнить: - log2 103 = 10 - log2 106 = 20 - log2 109 = 30

Мы видим, что даже для миллиарда логарифм всего 30 - очень мало. Однако учитывать его в подсчете асимптотики все равно нужно!


Базовые алгоритмы в C++

Все эти алгоритмы находятся в заголовке <algorithm>.

1. Сортировка: std::sort

Сортирует элементы по возрастанию. Работает за O(n log n), где n - количество сортируемых элементов.

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

int main() {
    std::vector<int> arr = {5, 2, 9, 1, 5, 6};
    
    std::sort(arr.begin(), arr.end()); // сортировка всего вектора
    
    // arr будет равен 1 2 5 5 6 9
    return 0;
}

Итератор — это такой объект, который указывает на какой-либо элемент коллекции (в нашем случае, vector) и позволяет перемещаться между элементами. - arr.begin() — итератор, указывающий на первый элемент. - arr.end() — итератор, указывающий после последнего элемента.

Можно сортировать только часть массива, передвинув указатели на нужный нам диапазон: std::sort(arr.begin() + 1, arr.begin() + 4) отсортирует элементы с 1 по 3 включительно. Для вектора {5, 2, 9, 1, 5, 6} мы получим {5, 1, 2, 9, 5, 6}. В таком случае сортировка будет работать за O(k log k), где k - длина сортируемого диапазона.

2. Реверс: std::reverse

Меняет порядок элементов на обратный. Работает за O(n), где n - количество реверсируемых элементов.


std::vector<int> v = {1, 2, 3, 4, 5};
std::reverse(v.begin(), v.end());
// v = {5, 4, 3, 2, 1}

Также можно реверсировать часть массива — так же, как и в случае сортировки.

3. Сортировка по компаратору

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

Пример компаратора для сортировки по убыванию:

bool comparator(int a, int b) {
    return a > b; // если a больше b, то a должен быть раньше
}

int main() {
    std::vector<int> v = {3, 1, 4, 1, 5};
    std::sort(v.begin(), v.end(), comparator);
    
    // v = {5, 4, 3, 1, 1}
    
    return 0;
}

Можно написать то же самое короче, используя лямбда-функцию (лямбда функция - безымянная функция, которая используется в коде только один раз):

std::sort(v.begin(), v.end(), [&](int a, int b) {
    return a > b;
});

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

bool cmp(int a, int b) {
    if (a < 0 && b > 0) {
        return true;
    }
    if (a > 0 && b < 0) {
        return false;
    }
    if (a > 0 && b > 0) {
        return a < b;
    }
    if (a < 0 && b < 0) {
        return a > b;
    }
}

4. Сортировка структур

Часто нужно хранить и сортировать не просто числа, а более сложные объекты. Например, у нас есть список машин, у каждой из которых три характеристики: скорость, масса и номер. Вместо того, чтобы заводить три массива, создадим структуру, которая хранит всю информацию об одной машине:

struct Car {
    int speed;
    int mass;
    int num;
};

Теперь мы можем использовать эту структуру:

Car aboba;

aboba.num = 6767; // Устанавливаем значение поля num
cin >> aboba.mass; // Вводим значение поля mass из консоли
aboba.mass *= 2; // Увеличиваем массу вдвое
cin >> aboba.speed; // Вводим speed и оставляем без изменений

cout << aboba.mass << ' ' << aboba.speed << ' ' << aboba.num << '\n';

Если мы введем 3 7, то программа выведет 6 7 6767.

Можно создать vector<Car> и сортировать его, используя компаратор:

bool cmp(Car a, Car b) {
    return a.speed > b.speed; // Сортируем по убыванию скоростей
}

int main() {
    vector<Car> arr;
    arr.push_back({100, 2, 95}); // Добавляем машину speed=100, mass=2, num=95
    arr.push_back({90, 3, 10});
    arr.push_back({200, 1, 1});
    sort(arr.begin(), arr.end(), cmp);
}

После сортировки мы получим: { {200, 1, 1}, {100, 2, 95}, {90, 3, 10}.

5. Бинарный поиск

Если нам дан отсортированный массив, то мы можем искать значение в нем не за O(n), а за O(logn). Для этого в C++ есть встроенные функции, механизм работы которых мы разберем в параллели С. А пока научимся пользоваться ими.

binary_search — проверяет, есть ли элемент

std::vector<int> v = {1, 3, 5, 7, 9};
if (std::binary_search(v.begin(), v.end(), 5)) {
    std::cout << "Найдено!";
}

lower_bound — возвращает итератор на первый элемент  заданного. upper_bound — возвращает итератор на первый элемент > заданного. Если таких элементов нет, обе функции возвращают end().

std::vector<int> v = {1, 2, 2, 3, 4, 4, 4, 5};

auto lb = std::lower_bound(v.begin(), v.end(), 4); // первый элемент >= 4
auto ub = std::upper_bound(v.begin(), v.end(), 4); // первый элемент > 4

auto автоматически выбирает нужный тип переменной — в нашем случае итератор. Название итератора слишком длинное, поэтому часто его заменяют на auto.

Как теперь из итератора получить индекс? Оказывается, итераторы можно вычитать! Вычитая, мы получаем расстояние — количество элементов между итераторами.

int count = ub - lb; // количество вхождений числа 4
int index = lb - v.begin(); // индекс первого элемента, >= 4

Можно запускать бинпоиск на подотрезке массива — аналогично сортировке и реверсу.

6. Минимум и максимум

Функции std::min_element и std::max_element находят итератор на минимальный/максимальный элемент.

std::vector<int> v = {3, 7, 2, 9, 5};

auto min_it = std::min_element(v.begin(), v.end());
auto max_it = std::max_element(v.begin(), v.end());

std::cout << "Index of min: " << min_it - v.begin() << "\n";
std::cout << "Index of max: " << max_it - v.begin() << "\n";

Можно передать компаратор третьим аргументом. Также можно искать максимум на подотрезке, аналогично предыдущим функциям.

7. Перестановки

Перестановка — упорядоченный набор всех элементов без повторений. Например, 1 2 3, 3 1 2 являются перестановками чисел от 1 до 3. Перестановки сравниваются лексикографически, так же, как строки. Наименьшая перестановка — отсортированные по возрастанию значения. Наибольшая — отсортированные по убыванию.

std::next_permutation — меняет текущую перестановку на следующую перестановку в лексикографическом порядке и возвращает true, если такая есть. Если перестановка последняя — переходит к первой и возвращает false.

Чтобы перебрать все перестановки, начнем с наименьшей перестановки и будем делать next_permutation, пока не получим наибольшую.

std::vector<int> v = {1, 2, 3};

do {
    for (int x : v) std::cout << x << " ";
    std::cout << "\n";
} while (std::next_permutation(v.begin(), v.end()));

Конструкция do {тело} while (условие) отличается от обычного while(условие) {тело} тем, что сначала выполняется тело цикла, а только потом проверяется условие.