Алгоритмы с нуля
Читаем ограничения
Ты уже знаешь нотацию Big-O и семь классов сложности. Но как выбрать, какая сложность нужна для задачи? Ответ: прочитай ограничения входа. Перед тем как написать хоть одну строку кода, условие задачи говорит тебе лимиты. Эти лимиты указывают прямо на класс сложности, к которому ты должен стремиться. Этот урок учит тебя читать этот сигнал и использовать его, чтобы исключить невозможные подходы ещё до начала.
После этого урока ты сможешь прочитать ограничения задачи (особенно n, размер входа), оценить, сколько операций ты можешь позволить (~10⁸ в секунду), соединить границы входа с требуемыми классами сложности (например n ≤ 10⁶ → O(n) или O(n log n)) и использовать это соответствие, чтобы решить, какой алгоритмический подход пытаться.
Золотое правило: компьютеры делают примерно 10⁸ (100 миллионов) простых операций в секунду.
Большинство онлайн-судей (LeetCode, Codeforces и т. д.) дают 1–2 секунды на тест. Значит, ты можешь позволить себе около 10⁸ – 2×10⁸ операций. Это твой бюджет времени.
Соответствие: размер входа n говорит тебе, какая сложность нужна.
Вот стандартная таблица, которую используют соревнующиеся программисты:
| Размер входа n | Возможная сложность | Пример |
|---|---|---|
| n ≤ 10–12 | O(n!) или O(2ⁿ) | Перебор всех подмножеств или перестановок |
| n ≤ 20–25 | O(2ⁿ) | Рекурсивный поиск, битовая маска |
| n ≤ 500 | O(n³) | Тройной цикл, алгоритм Флойда–Уоршелла |
| n ≤ 5 000 | O(n²) | Вложенный цикл, сортировка пузырьком, простая ДП |
| n ≤ 10⁶ | O(n log n) или O(n) | Сортировка слиянием, двоичный поиск, один цикл |
| n ≥ 10⁷ | O(n) или O(log n) | Однопроходный скан, логарифмический поиск |
Как её использовать: работай в обратном направлении от ограничения.
Когда ты видишь «n ≤ 10⁶», ты знаешь, что:
- O(n²) невозможна — это будет 10¹² операций, далеко за пределами бюджета.
- O(n log n) возможна — это ~2×10⁷ операций, хорошо в пределах бюджета.
- O(n) также возможна — ~10⁶ операций, тривиально.
Так что если n ≤ 10⁶, твой алгоритм должен быть по крайней мере такой же быстрый, как O(n log n). Медленные подходы (как O(n²)) исключены, прежде чем ты начал.
Соответствие ограничение → подход — это мост от анализа к проектированию.
Вместо «я не знаю, с чего начать», ограничение говорит тебе: «Тебе нужен O(n) скан ИЛИ O(n log n) разделяй-и-властвуй (например двоичный поиск или сортировка слиянием)». Это не полное решение, но это сильно сужает пространство поиска.
Почему ограничения важны: реальный пример.
Предположим, задача спрашивает: «Посчитай пары (i, j), где i < j и arr[i] + arr[j] = target».
Твой первый инстинкт может быть: «Вложенный цикл. Для каждого i просканируй все j > i». Это O(n²).
Но потом ты читаешь ограничение: n ≤ 100 000.
O(n²) будет 10¹⁰ операций. Timeout. Исключено.
Значит, ты должен использовать O(n log n) или O(n). Ограничение заставляет тебя думать иначе. Один подход: отсортируй и используй два указателя (O(n log n)). Другой: используй хеш-множество (O(n) в среднем). Оба остаются в пределах бюджета. Идея O(n²), несмотря на её простоту, больше не вариант.
Замечание: константы важны, но правило верно.
Точный порог варьируется:
- Современные процессоры могут делать ~10⁸ – 10⁹ простых операций в секунду.
- Соревнующиеся программисты часто используют 10⁸ как безопасную оценку.
- Сложные операции (выделение памяти, рекурсивный оверхед) могут понизить порог.
- Очень простые операции (битовые сдвиги, сравнения) могут поднять его выше.
Но таблица выше, основанная на 10⁸ операций/сек, это то, на что опирается сообщество. Она работает.
Давайте посмотрим, как ограничения меняют алгоритм, который ты бы написал.
Сценарий 1: n ≤ 10
Задача: «Найди максимальную сумму любого подмножества массива».
Ограничение: n ≤ 10.
Подход: Перебор. Попробуй все 2^n подмножеств. Это 2^10 = 1024 операции. Мгновенно.
function maxSubsetSum(arr) {
let max = 0;
// Пройди все 2^n подмножеств, используя битовую маску
for (let mask = 0; mask < (1 << arr.length); mask++) {
let sum = 0;
for (let i = 0; i < arr.length; i++) {
if (mask & (1 << i)) sum += arr[i];
}
max = Math.max(max, sum);
}
return max; // O(2^n)
}При n ≤ 10 это решение O(2^n) хорошо. Ты не пытаешься быть умным. Ограничение даёт тебе разрешение на перебор.
Сценарий 2: n ≤ 1 000
Задача: «Найди самую длинную общую подпоследовательность (LCS) двух строк».
Ограничение: n ≤ 1 000 (длина каждой строки).
Подход: Динамическое программирование. Построй таблицу 2D размером n × n. Это 10⁶ операций (заполнение таблицы). Возможно.
function lcs(s1, s2) {
const n = s1.length, m = s2.length;
const dp = Array(n + 1).fill(0).map(() => Array(m + 1).fill(0));
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (s1[i - 1] === s2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[n][m]; // O(n²)
}При n ≤ 1 000 O(n²) безопасна. Если бы n было 10⁶, это не сработало бы.
Сценарий 3: n ≤ 10⁶
Задача: «Посчитай, сколько элементов в массиве больше среднего значения».
Ограничение: n ≤ 10⁶.
Подход: Один проход. Вычисли сумму (O(n)), вычисли среднее, посчитай (O(n)). Итого: O(n). ~10⁶ операций. Мгновенно.
function countAboveAverage(arr) {
const sum = arr.reduce((a, b) => a + b, 0);
const avg = sum / arr.length;
return arr.filter(x => x > avg).length; // O(n)
}При n ≤ 10⁶ требуется один проход. O(n²) это 10¹² операций — невозможно. O(n log n) это ~2×10⁷ операций — излишне для этой задачи (хотя всё ещё приемлемо). O(n) идеален.
Эти три сценария показывают один принцип: ограничение сужает, какая сложность вообще стоит рассматривать.
Давайте проследим, как ограничение исключает варианты. Представь, что ты читаешь задачу:
Условие задачи: «Дан массив размером n, найди, существует ли пара элементов, чья сумма равна target».
Теперь ты читаешь ограничение: «1 ≤ n ≤ 100 000».
1
Ограничение: n ≤ 100 000
2
Бюджет: ~10^8 операций
3
Работай в обратном направлении, чтобы найти нужную сложность...
Ограничение не говорит тебе, какой O(n log n) алгоритм использовать, но оно говорит, что всё медленнее не сработает. Это бесценно.
Вот полная справочная таблица, с объяснением:
| n | Возможная сложность | Типичные алгоритмы | Почему |
|---|---|---|---|
| ≤ 10–12 | O(n!), O(2ⁿ) | Перебор перестановок, подмножеств, backtracking | 2^12 = 4096 ops; n! ≤ 10^6 ops |
| ≤ 20 | O(2ⁿ) | Битовая маска ДП, перечисление подмножеств | 2^20 ≈ 10^6 ops |
| ≤ 100–300 | O(n³) | Тройной вложенный цикл, Флойд–Уоршелл | n³ ≈ 10^7 ops для n=300 |
| ≤ 1 000–5 000 | O(n²) | Вложенный цикл, простая ДП, сортировка пузырьком | n² ≈ 10^7 ops для n=3000 |
| ≤ 100 000 | O(n log n) | Сортировка слиянием, quicksort, разделяй-и-властвуй | n log n ≈ 1.7M ops для n=100K |
| ≤ 1 000 000 | O(n) | Однопроходный скан, хеш-множество, жадный алгоритм, BFS/DFS | n ≈ 10^6 ops |
| ≥ 10^7 | O(log n), O(1) | Двоичный поиск, хеш-поиск, таблицы поиска | log n ≈ 30 ops для n=10^9 |
Заметь логарифмические скачки. Разница между n ≤ 1 000 и n ≤ 10⁶ заставляет тебя перейти от O(n²) к O(n log n). Разница между n ≤ 10⁶ и n ≤ 10⁹ заставляет тебя от O(n) к O(log n).
Ось x не линейна — она показывает, как порог смещается. Маленькие ограничения позволяют квадратичный; большие ограничения требуют линейный или логарифмический.
В задаче написано: 'n ≤ 10'. Какая сложность безопасна?
В задаче написано: 'n ≤ 1 000 000'. Алгоритм O(n²) сделает примерно сколько операций?
Ограничение: n ≤ 100. Какая сложность *невозможна*?
Ты решаешь задачу с n ≤ 5 000. Наивный перебор O(n³). Стоит ли его пытаться?
Ограничение: n ≤ 10⁶. Какой алгоритм *требуется*?
Граничные случаи
Константы имеют значение. Правило «10⁸ операций в секунду» это оценка. Плотный цикл с битовыми сдвигами может работать на 10⁹ ops/sec. Функция с промахами кэша и malloc() может работать на 10⁷ ops/sec. Соревнующиеся программисты корректируют: если ограничение плотное (n ≤ 1 000 000 и time limit 1 секунда), они могут избежать O(n log n) и стремиться к O(n). Если ограничение щедрое и time limit 2 секунды, они могут использовать алгоритм O(n log n) с большими константами.
Частая ошибка
Путаница между time limit и бюджетом операций. Time limit это 1–2 секунды. Но не все операции занимают одинаковое время. Сравнение занимает ~1 нс. Выделение памяти занимает ~100 нс. Рекурсивный оверхед накапливается. Когда ты видишь «1 секунда», думай «10⁸ простых операций», а не «у меня неограниченное время писать сложный код». Соревнующиеся программисты это помнят и предпочитают простые, плотные циклы рекурсивным или сложным подходам.
Почему соревнующиеся программисты всегда читают ограничения в первую очередь?
Ограничения это сигнал, который говорит, какой алгоритм попытаться.
Ключевые выводы:
-
Правило большого пальца: Компьютеры делают ~10⁸ простых операций в секунду. Большинство судей дают 1–2 секунды. Значит, у тебя есть бюджет ~10⁸ – 2×10⁸ операций.
-
Соответствие: Размер входа n говорит, какая сложность возможна:
- n ≤ 10–12 → O(2ⁿ) или O(n!) хорошо
- n ≤ 20–25 → O(2ⁿ) хорошо
- n ≤ 500 → O(n³) хорошо
- n ≤ 5 000 → O(n²) хорошо
- n ≤ 10⁶ → O(n log n) требуется
- n ≥ 10⁷ → O(n) или O(log n) требуется
-
Как её использовать: Перед тем как писать код, вычисли стоимость своей первой идеи. Если она не вписывается в бюджет, исключи её и проектируй новый подход.
-
Ограничение не предложение; это барьер. Алгоритм O(n²) для n = 10⁶ будет timeout. Никакая оптимизация это не исправит — ты должен использовать более быстрый алгоритм.
-
Работай в обратном направлении: Прочитай ограничение, выбери самую быструю возможную сложность, потом проектируй алгоритм, который её достигает. Ограничение сужает пространство поиска и спасает тебя от тупиков.
Этот мост от ограничений к алгоритму твоя суперспособность. Перед тем как написать хоть одну строку кода, задача говорит, что ты должен построить.