Лекция 3. Асимптотика и базовые алгоритмы
Асимптотика
Зачем это нужно?
Представим, что у нас есть два алгоритма, которые решают одну и ту же задачу. Как понять, какой из них быстрее, без запуска? Или как оценить, успеет ли программа выполниться за 1 секунду, или мы получим Time Limit? Асимптотика — способ примерно оценить, насколько долго выполняется программа в зависимости от входных данных.
Примеры
- O(1) — время не зависит от входных данных. Например, взять первый элемент массива:
arr[0]. Сколько бы ни было элементов, это одно действие. - O(n) — время растёт линейно вместе с
n. Например, найти максимум в неотсортированном массиве: нужно просмотреть каждый элемент. Если элементов 10, действий примерно 10; если 1000, действий примерно 1000. - O(n2) — время растёт квадратично. Например, если мы перебираем все возможные пары элементов: для каждого элемента мы просматриваем все остальные (вложенный цикл). При n=1000 действий уже 1 000 000.
- O(log n) — время растёт очень медленно. Это обычно бывает, когда на каждом шаге мы уменьшаем задачу вдвое. При n=1 000 000 действий всего около 20. Подробнее обсудим, что такое логарифм, чуть дальше.
Правило: когда n становится большим, самые “сильные” функции растут быстрее. Поэтому O(n2) хуже, чем O(n), а O(n) хуже, чем O(log n).
Что такое логарифм?
Логарифм по основанию 2 (пишут log2 n или просто log n) — это сколько раз нужно разделить число n на 2, чтобы получить 1. Формально: log n = 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(log n). Для этого в 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(условие) {тело} тем, что сначала выполняется тело цикла, а только потом проверяется условие.