← Параллель CВсе параллели⬇ Скачать конспект

Занятие 2. Параллель C — Оценка сложности и сортировки

Асимптотическая сложность алгоритмов ($O$-нотация) и базовые сортировки: пузырёк, выбором, вставками, подсчётом, слиянием. В конце — разбор вводного контеста по C++.
Лекция + разбор контеста ≈ 1 ч 55 мин Язык кода: C++

1. Оценка сложности: модель и $O$-нотация 0:09

1.1. Модель RAM

Когда говорят про «сложность программы», имеют в виду сложность в модели RAM (Random Access Machine). Суть модели:

Тогда цикл, который проходит по массиву длины $n$ и читает каждый элемент, делает $O(n)$ операций.

2:27 Цель всей оценки: по размерам входных данных понять, сколько действий сделает программа, то есть узнать, «зайдёт» ли решение по времени, ещё до запуска.

1.2. Формальное определение $O$

Запись «программа работает за $O(n)$» не означает, что она делает ровно $n$ действий.

$T(n) = O(n)$  означает  $\exists\, C: \quad T(n) \le C \cdot n$  для всех достаточно больших $n$.

То есть существует константа $C$, такая что если умножить $n$ на $C$, то реальное число действий программы при любом (достаточно большом) $n$ будет не больше этой величины.

Следствие
Если программа делает $2n$, $3n$, $1000n$ или $n$ действий — всё это считается $O(n)$. Константный множитель в $O$-нотации не важен.

1.3. Простейшие примеры

$O(1)$. Программа, которая читает два числа $a$ и $b$ и выводит их сумму:

C++
int a, b;
cin >> a >> b;
cout << a + b;

Независимо от входных данных она делает какое-то константное количество действий (например, не больше 100). Это $O(1)$: есть константа (например, $100$), которую можно умножить на $1$, чтобы она накрыла число действий программы.

$O(n)$. Программа вводит число $n$, потом $n$ чисел и выводит их:

C++
int n; cin >> n;
vector<int> a(n);
for (int i = 0; i < n; ++i) cin >> a[i];
for (int i = 0; i < n; ++i) cout << a[i] << " ";

Здесь два цикла по $n$, плюс немного операций «единичного» порядка. Итог: $n \cdot (\text{константа}) + (\text{константа}) = O(n)$.

$O(n^2)$. Пусть есть два массива $a$ и $b$ длины $n$ каждый, нужно посчитать сумму $a_i \cdot b_j$ по всем парам $(i,j)$:

C++
long long s = 0;
for (int i = 0; i < n; ++i)
    for (int j = 0; j < n; ++j)
        s += a[i] * b[j];

Внешний цикл $n$ раз, и на каждой итерации внутренний цикл делает $n$ действий. Итого $n \cdot n = O(n^2)$.

1.4. Слагаемые низших порядков отбрасываются

В предыдущем примере программа разбивается на три куска: $O(1)$ (ввод $n$), $O(n)$ (ввод массивов) и $O(n^2)$ (двойной цикл). Итоговая сложность — $n^2 + n + 1$.

Но по определению мы можем взять $C = c + 1$ и увидеть, что

$n^2 + n + 1 \le (c+1)\cdot n^2 \quad\Longrightarrow\quad O(n^2)$.

Суть: мы «вырезали» слагаемое $n^2$ и добавили ещё одно $n^2$; а $n^2 > n + 1$ при больших $n$.

Правило
В асимптотике всегда оставляют только слагаемое с максимальной степенью. Все линейные и константные куски из формулы выбрасываются.

1.5. Не всё, что «похоже» на $n^2$, — это $n^2$ 14:11

Важный приём, который будет встречаться часто: два вложенных цикла не всегда дают $n^2$. Рассмотрим идею на примере.

C++
for i = 0..n:
    S += a[i] * a[j]        // j — ОБЩИЙ указатель, объявленный вне цикла
    while j < n and a[j] < a[i]:
        j += 1

С первого взгляда кажется, что это $O(n^2)$: внешний цикл до $n$, внутренний while до $n$. Но это не так. Ключевое наблюдение:

Итог: $O(n)$ инкрементов $i$ + $O(n)$ инкрементов $j$ = $O(n)$, а не $O(n^2)$.

Интуиция через «монетки» 21:06: представьте, что у вас $2n$ монеток, и вы платите монетку каждый раз, когда заходите внутрь циклов. За каждый $i$ вы платите монетку, и за каждый $j$ платите монетку. $i$ растёт до $n$ (это $n$ монеток), $j$ суммарно растёт до $n$ (ещё $n$ монеток). Всего вы потратите не больше $2n$ монеток.

Развёрнутое объяснение
Этот приём называется техникой двух указателей (two pointers) или амортизированным анализом. Инвариант: внутренний указатель монотонно двигается в одну сторону, поэтому его суммарное движение по всем итерациям внешнего цикла ограничено $n$. Он работает, только если внутренний указатель не сбрасывается в начало на каждой итерации внешнего цикла. Если бы вы писали for j = 0..n внутри — это был бы честный $O(n^2)$.

2. Практический ориентир: сколько операций в секунду 22:49

Оценка сложности нужна не ради оценки, а чтобы понять, «зайдёт ли решение». Есть простой лайфхак:

Тестирующий компьютер делает $\approx 2 \cdot 10^8$ простейших операций в секунду (консервативная оценка; в других лекциях встречается и $3\cdot10^8$).

Это грубая оценка: деление дороже сложения, но порядок величины такой. Что отсюда следует:

Ограничение на $n$Сложность, которая точно зайдётКомментарий
$n \le 10^5$$O(n)$миллисекунды, огромный запас
$n \le 10^4$$O(n^2)$$\approx 10^8$, около секунды
$n \le 10^3$$O(n^2)$$10^6$, запас ~200×
$n \le 500$$O(n^3)$$1.25\cdot 10^8$, на грани
$n \le 100$$O(n^3)$$10^6$, заходит с запасом
Откуда берётся $n$
Обычно в условии задачи написано ограничение вида «$1 \le n \le 10^5$». Именно это ограничение и подставляется в асимптотику. Если оценка получилась порядка $10^8$ — надо задуматься, решение ли это.

3. Константа имеет значение 27:38

Два решения с одной и той же асимптотикой могут работать совершенно по-разному, потому что в $O$-нотации «спрятана» константа.

Пример: цикл, который делает фиксированные $1000$ действий, и цикл, который делает $1$ действие, — оба $O(1)$, но первый в $1000$ раз дольше. Асимптотически они одинаковы, но «разумность» константы никто не отменял.

C++
// Вариант 1: внутри цикла по n — фиксированные 10000 действий
for i = 0..n:
    x += 10000          // 10^4 действий на каждую из n итераций

// Вариант 2: два честных цикла по n
for i = 0..n: x += a[i]
for i = 0..n: y += a[i]

Оба варианта работают за $O(n)$. Но пусть $n = 10^6$:

Вывод
Асимптотика — это только оценка, она не показывает все нюансы. Когда оцениваете «пройдёт / не пройдёт», помните, что перед асимптотикой может стоять большая константа, из-за которой решение не зайдёт.

4. Квадратичные сортировки 33:20

Квадратичные сортировки — это сортировки, работающие за $O(n^2)$ времени от размера массива $n$. Разбираем три: пузырёк, выбором, вставками.

4.1. Сортировка пузырьком (bubble sort) 34:12

Идея. Идём слева направо и смотрим на пары соседних элементов. Если левый больше правого — меняем их местами. Так проходим весь массив.

Интерактив: двигайте ползунок или нажмите ▶ — сравниваемая пара жёлтая, обмен красный, отсортированный хвост зелёный.

Ключевой факт. После первого прохода максимальный элемент стоит на своём месте — в конце массива. Почему? Когда мы доходим до максимума, он больше всех, кто стоит справа от него, поэтому на каждом следующем шаге меняется с соседом и «проплывает» в самый конец.

Тогда на втором проходе то же самое верно для массива без последнего элемента — второй по величине элемент встанет на предпоследнее место. И так далее: на $i$-м проходе $i$-й по убыванию элемент встаёт на своё место.

После $n-1$ прохода все элементы на местах. Проход — $O(n)$, проходов — $O(n)$  ⇒  итого $O(n^2)$.
C++
// bubble sort
for (int i = 0; i < n - 1; ++i) {          // номер прохода
    for (int j = 0; j < n - 1 - i; ++j) {   // проход слева направо
        if (a[j] > a[j + 1]) {              // соседи в неправильном порядке
            swap(a[j], a[j + 1]);           // меняем
        }
    }
}

4.2. Сортировка выбором (selection sort) 40:30

Идея. На первое место надо поставить минимальный элемент всего массива. Проходим по всему массиву, находим минимум, ставим его на место. Дальше на оставшемся суффиксе снова ищем минимум и ставим на второе место, и так далее.

Интерактив: ползунок/▶ — ищем минимум суффикса (жёлтая пара), ставим его в начало (красный обмен), отсортированный префикс зелёный.

Сложность. Проходов $O(n)$; на каждом ищем минимум на всё уменьшающемся суффиксе: $n$, затем $n-1$, затем $n-2$, … Сумма:

$n + (n-1) + \dots + 1 = \dfrac{n(n+1)}{2} = O(n^2)$.
C++
// selection sort
for (int i = 0; i < n; ++i) {        // с какого индекса начинается суффикс
    int pos = i;                       // позиция текущего минимума
    for (int j = i + 1; j < n; ++j) {  // ищем минимум на суффиксе [i..n)
        if (a[j] < a[pos]) pos = j;
    }
    swap(a[i], a[pos]);                // ставим минимум на место i
}

4.3. Сортировка вставками (insertion sort) 46:51

Идея и инвариант. Введём понятие инварианта — свойства, которое не меняется на протяжении всего алгоритма.

Инвариант: после $i$-го шага отсортирован префикс длины $i$.

База: префикс длины $1$ (один элемент) отсортирован. Переход: если префикс длины $i$ отсортирован, то единственное, что может «мешать» префиксу длины $i+1$ быть отсортированным, — последний (новый) элемент. Вставим его на своё место — и весь префикс длины $i+1$ станет отсортирован.

Чтобы «вставить», не обязательно использовать явную функцию вставки: достаточно менять элемент с предыдущим, пока он не встанет на своё место (пока предыдущий не станет $\le$ текущего).

C++
// insertion sort
for (int i = 1; i < n; ++i) {        // первый элемент уже «на месте»
    int j = i;
    while (j > 0 && a[j] < a[j - 1]) {
        swap(a[j], a[j - 1]);
        --j;
    }
}

Интерактив: ползунок/▶ — новый элемент вставляется на место, сдвигая большие вправо (красные обмены).

Сложность. $n$ элементов, вставка каждого в худшем случае $O(n)$ ⇒ $O(n^2)$.

4.4. Зачем знать квадратичные сортировки 54:26

5. Сортировка подсчётом (counting sort) 56:56

Идея. Пусть все числа в массиве неотрицательные и не очень большие: $a_i \le k$ (например, $k \le 10^5$). Заведём массив cnt размера $k+1$, где будем подсчитывать, сколько раз встретилось каждое число. Потом пройдёмся по cnt и выведем каждое число столько раз, сколько оно встретилось.

Интерактив: ползунок/▶ — сначала считаем частоты (cnt), затем выводим значения по возрастанию.

Сложность. Нужно пройти по исходному массиву длины $n$ и в конце по массиву cnt размера $k+1$:

$O(n + k)$.

Если $k$ маленькое (например, $k \le 100$), алгоритм эффективен — работает за линейное от $n$ время, быстрее обычной сортировки. Вывод: если знаете, что различных значений немного и все они небольшие, — используйте подсчёт.

Если же элементы могут быть произвольными (например, до $10^{18}$), сортировка подсчётом невыгодна (массив cnt такого размера не создать).

C++
// counting sort: числа от 0 до k включительно
vector<int> cnt(k + 1, 0);
for (int x : a) cnt[x]++;          // подсчёт

for (int i = 0; i <= k; ++i)
    for (int t = 0; t < cnt[i]; ++t)
        cout << i << " ";          // выводим i ровно cnt[i] раз

6. Сортировка слиянием (merge sort) 1:05:45

6.1. Слияние двух отсортированных массивов

Подзадача. Есть два уже отсортированных массива $a$ и $b$ (возможно, разной длины). Нужно получить из них один отсортированный массив.

Тривиально можно склеить их и вызвать сортировку, но это даст $O(n\log n)$ (или $O(n^2)$ квадратичной). Но эту задачу можно решить за линейное время $O(|a| + |b|)$.

Идея (два указателя). Кто может быть первым (минимальным) элементом результата? Либо первый элемент $a$, либо первый элемент $b$: всё, что правее первого элемента массива, не меньше его. Сравниваем их, меньший выписываем, двигаем соответствующий указатель вправо. Повторяем. Когда один массив закончится — дописываем остаток другого.

Интерактив: ползунок/▶ — два указателя (a зелёный, b оранжевый) по очереди выписывают меньший элемент.

C++
// слить два отсортированных вектора в один
vector<int> merge(const vector<int>& a, const vector<int>& b) {
    vector<int> res;
    int i = 0, j = 0;
    while (i < a.size() && j < b.size()) {
        if (a[i] <= b[j]) res.push_back(a[i++]);
        else              res.push_back(b[j++]);
    }
    // один из массивов уже закончился — дописываем остаток второго
    while (i < a.size()) res.push_back(a[i++]);
    while (j < b.size()) res.push_back(b[j++]);
    return res;
}

Каждый элемент $a$ и каждый элемент $b$ обрабатывается ровно один раз, поэтому сложность — линейная от суммарной длины.

6.2. Рекурсивная схема 1:17:42

Теперь решим исходную задачу — отсортировать произвольный массив.

Делим массив на две половинки (примерно поровну; если длина нечётная, в одной половинке на один элемент больше).
Рекурсивно сортируем каждую половинку тем же способом.
Две отсортированные половинки сливаем функцией merge в один отсортированный массив.

База рекурсии: массив из одного элемента уже отсортирован.

Сортировка слиянием: делим пополам, затем сливаемСверху — деление до одного элемента, снизу — слияние отсортированных половинок.52819374528193745281937452819374251839471258347912345789делимlog₂ n уровнейпо одному —уже отсортированысливаемкаждый уровень —O(n) на mergelog n уровней × O(n) работы на уровне = O(n log n).
Делим до отдельных элементов, затем сливаем отсортированные половинки снизу вверх: $\log n$ уровней по $O(n)$ — итого $O(n\log n)$.

6.3. Сложность $O(n \log n)$ и логарифм 1:21:26

Обозначим уровни рекурсии: нулевой — весь массив, первый — две половинки, второй — четвертинки, и так далее.

Общее время $=$ (стоимость уровня) $\times$ (число уровней) $= O(n) \times (\text{число уровней})$.

Сколько уровней? Это число раз, которое можно поделить $n$ на $2$, пока не останется $1$. У этого числа есть название:

$\log_2 n$ — двоичный логарифм: сколько раз можно делить $n$ на $2$, пока не станет $1$.
Итоговая сложность: $O(n \log n)$.

Логарифм растёт очень медленно. Вот конкретные значения:

$n$$\log_2 n$ (примерно)
$10^5$$\approx 16$
$10^6$$\approx 20$
$10^9$$\approx 30$

Это значит, что $n \log n$ очень близко к линейному времени: $n$ умножается на совсем маленькое число. Для $n = 10^5$ это $\approx 1.6 \cdot 10^6$ — быстрое решение. Если бы было $n^2$, $n$ умножилось бы на $10^5$ — это очень много.

Вывод
Решения за $O(n)$ и $O(n\log n)$ близки по скорости. Сортировка слиянием — хорошая сортировка по асимптотике.

7. Встроенная std::sort 1:31:20

Если нужно просто отсортировать массив, руками писать сортировку не надо — в C++ есть встроенная:

C++
#include <algorithm>
sort(a.begin(), a.end());   // отсортирует вектор по неубыванию

8. Разбор вводного контеста по C++ 1:33:35

После перерыва разбирался вводный контест по C++ (4 задачи).

Задача 1. Сумма $a + b$ 1:40:14

Простейшая задача: объявить переменные, ввести их и вывести сумму.

C++
int a, b;
cin >> a >> b;
cout << a + b;
Важное дополнение про деление
В C++ есть только один вид деления целых — с отбрасыванием дробной части (для неотрицательных чисел это округление вниз). Округление вверх делается формулой (a + b - 1) / b.

Задача 2. Чтение и запись в файл 1:41:56

В некоторых задачах нужно читать из файла и писать в файл (а не в стандартный ввод/вывод). Названия файлов указаны в условии (например, divide.in и divide.out).

C++
#include <cstdio>
int main() {
    freopen("divide.in",  "r", stdin);   // читаем из divide.in
    freopen("divide.out", "w", stdout);  // пишем в divide.out
    int a, b; cin >> a >> b;
    cout << a / b;
    return 0;
}

Задача 3. Ввод до нуля и вывод в обратном порядке 1:45:00

Нужно вводить числа, пока не встретится $0$ (признак конца), и вывести их в обратном порядке.

C++
#include <vector>
#include <algorithm>

vector<int> a;
int x;
while (cin >> x && x != 0) {   // ноль — признак конца, в массив его не кладём
    a.push_back(x);
}
reverse(a.begin(), a.end());  // развернуть вектор (лежит в <algorithm>)
for (int v : a) cout << v << " ";

Задача 4. Подсчёт всех букв в строке 1:49:06

Напоминает сортировку подсчётом: заводим массив счётчиков на 26 букв.

C++
#include <string>
#include <vector>

vector<int> cnt(26, 0);   // 26 букв, изначально нули
string s; cin >> s;
for (char x : s)
    cnt[x - 'a']++;        // 'a' → 0, 'b' → 1, ..., 'z' → 25
for (int i = 0; i < 26; ++i)
    cout << cnt[i] << " ";

Как работает x - 'a':

Дополнение: несколько файлов с кодом
На олимпиаде удобно хранить код каждой задачи в отдельном файле, чтобы не стирать уже сданные решения. Для этого создают ещё один .cpp-файл и добавляют его в CMakeLists.txt (копируют строку про add_executable и меняют имя). После этого в списке запуска появляется второй вариант, который можно запускать независимо.