Что такое алгоритм и как читать задачу
На соревновании шесть участников получили штрафы: [7, 4, 9, 2, 8, 3]. Нужно назвать наименьший штраф. На таком списке ответ легко увидеть глазами: это 2. Но если участников 200 000, хочется поручить ту же работу программе. С чего начать, чтобы она выдала нужный результат?
Штрафы участников: [7, 4, 9, 2, 8, 3]. Какой штраф минимален?
Сначала договоримся о задаче
В условии ищем три вещи: что программе дают, что она должна вернуть и какие данные допустимы. Здесь вход — список штрафов, выход — одно минимальное число, ограничение — от 1 до 200 000 участников. Это наша договорённость о поведении программы; её также называют контрактом.
| Вход | n = 6; штрафы 7 4 9 2 8 3 |
|---|---|
| Выход | 2 — минимальный штраф |
| Ограничения | 1 ≤ n ≤ 200 000; штрафы — целые числа от 0 до 10⁹ |
Только после этого выбираем способ решения. Пройдём по штрафам слева направо: запомним первый, а затем будем заменять его, если встретится меньшее значение. Последовательность таких точно заданных шагов и есть алгоритм. Одну задачу можно решать разными алгоритмами; одна и та же идея может быть записана на разных языках.
Алгоритм — конечная последовательность понятных исполнителю действий, которая для каждого допустимого входа приводит к требуемому результату.
Проверяем граничные случаи
Прежде чем писать код, попробуем идею на самых маленьких и необычных допустимых входах. Такие примеры называют граничными случаями: они проверяют предположения, на которых держится решение. Для одного штрафа ответом будет он сам; для одинаковых штрафов — их общее значение. Если минимум встречается несколько раз, его значение от этого не меняется.
У пустого списка минимального элемента нет. Наше условие запрещает такой вход, потому что n ≥ 1. В другой задаче пустой список может быть разрешён — тогда нужно заранее договориться об отдельном результате. Такие решения принимает условие задачи, а не язык программирования.
От слов к блок-схеме и псевдокоду
Опишем алгоритм независимо от языка. Сначала сохраняем первый элемент. Затем для каждого оставшегося числа проверяем: меньше ли оно сохранённого? Если да — обновляем ответ. Когда числа закончатся, возвращаем ответ. В блок-схеме прямоугольник показывает действие, ромб — проверку, стрелки — порядок шагов; овал обозначает начало или конец, параллелограмм — ввод или вывод.
Псевдокод (pseudocode) — короткая запись шагов алгоритма для человека. Она передаёт идею без синтаксических подробностей конкретного языка.
function minimum(A):
result := A[0]
for i := 1; i < len(A); i++:
if A[i] < result:
result := A[i]
return resultA[0] — первый элемент; индексы начинаются с нуля. len(A) — количество элементов. result — наименьшее из уже просмотренных значений.
Для углубления: точность условия и завершение
«Найти лучший путь» недостаточно: нужно назвать критерий, например минимальную сумму весов рёбер. Для сортировки требуются и порядок, и сохранение исходных элементов с кратностями. Классический алгоритм должен завершаться на каждом допустимом входе; для длительно работающих процедур отдельно задают условие остановки или требуемое качество приближения.
Мы умеем описать решение и проверить его на маленьких примерах. Теперь сравним, сколько работы делают разные решения. Для этого сначала договоримся, что считать одним действием компьютера.
Что считать одной операцией
Для 1 000 участников можно один раз просмотреть штрафы — около 1 000 шагов. А можно многократно сравнивать участников друг с другом — уже сотни тысяч сравнений. Чтобы увидеть эту разницу, не нужно сначала писать обе программы и измерять секунды.
Секунды зависят от процессора, языка и того, как устроена память. Поэтому для начала считаем простые действия: прочитать число, сравнить два числа, сохранить результат, увеличить счётчик. Договоримся, что каждое такое действие над числом фиксированного размера имеет постоянную стоимость: она не растёт вместе с длиной входного массива.
RAM-модель (Random Access Machine) — упрощённая модель компьютера с пронумерованными ячейками памяти. Чтение или запись одной ячейки по известному адресу, сравнение и простая арифметика над машинными словами считаются элементарными действиями.
Если прочитать ячейку 1, затем 4, затем 7, получатся три чтения. Нам не нужно последовательно проходить все адреса между ними. Но копирование массива из n элементов затрагивает n ячеек: это уже целая последовательность действий.
Попробуйте прочитать и изменить память
В модели ниже сначала выберите ячейку мышью или клавишами Tab и Enter. Кнопка «Прочитать» покажет её значение и увеличит счётчик чтений. Чтобы выполнить запись, введите новое число и нажмите «Записать». Сбросьте модель и прочитайте ячейки 1, 4 и 7: проверьте, что чтений ровно три.
Кликните по ячейке: адрес выбирается непосредственно, независимо от его величины.
Для углубления: машинные слова, кэш и длинные числа
Ячейка модели хранит машинное слово, например 32 или 64 бита. Постоянная стоимость относится к операциям над такими словами, а не к произвольно длинным числам. Целые Python могут расти, и стоимость арифметики зависит от числа разрядов; тип i64 в Rust ограничен 64 битами, поэтому возможное переполнение учитывают отдельно.
В теоретических моделях размер слова иногда выбирают порядка log n бит, чтобы в нём помещался адрес. Реальное чтение памяти зависит от кэша и размещения данных. RAM скрывает эти детали, помогая сравнить алгоритмы на одном уровне. После анализа измерения по-прежнему полезны.
Теперь у действий есть понятная модель стоимости. Посчитаем, как их количество зависит от размера входа на уже знакомом поиске минимума.
Как оценить время работы программы
В RAM-модели мы договорились считать элементарные действия. Теперь посмотрим, как их число меняется при увеличении входа. Обозначим размер входа буквой n: для массива это число элементов, для строки — число символов. Если на входе одно большое число, размером может быть количество цифр или битов в его записи. Выбор параметра всегда нужно объяснять.
Вернёмся к штрафам [7, 4, 9, 2, 8, 3]. Здесь n = 6. Сохраним первый штраф 7 без сравнения. Затем сравним с ответом 4 — ответ станет 4; число 9 его не изменит; число 2 уменьшит до 2; значения 8 и 3 оставят ответ прежним. Получается пять сравнений, а не шесть.
Проследите весь проход
Модель начинает с уже сохранённого первого элемента. На каждом шаге она сравнивает следующий кандидат с текущим минимумом. Перед нажатием кнопки попробуйте предсказать, изменится ли ответ. Затем вернитесь на шаг назад и проверьте, какие элементы уже просмотрены.
Сохранили первый элемент 7. Сравнений пока нет.
Изменится ли минимум после сравнения с 4?
От конкретного прохода к оценке
Первое значение задаёт ответ, остальные n − 1 могут его уточнить. Для 10 элементов будет 9 сравнений, для 1 000 — 999, для 200 000 — 199 999. Чтения, обновления и проверки цикла тоже требуют работы, но на каждый элемент приходится ограниченное число таких действий. При удвоении длины массива объём работы растёт примерно вдвое.
Функцией T(n) обозначают количество учитываемых действий при размере входа n. Для нашего алгоритма оно растёт линейно; коротко пишут O(n). Это описание роста, а не обещание ровно n операций или n секунд. Мы храним лишь текущий ответ и индекс — их число не растёт с n. Поэтому дополнительная память, то есть память сверх входных данных, равна O(1).
Те же шаги в программе
В реализациях ниже предполагается непустой массив. Сначала берём его первый элемент, затем начинаем цикл со второго. Поэтому в цикле ровно n − 1 сравнений. В Python return возвращает результат; в Rust последнее выражение функции играет ту же роль.
Минимум непустого массива
Первый элемент задаёт ответ. Цикл сравнивает с ним оставшиеся n−1 значений. Вход не изменяется; условие требует хотя бы один элемент.
def minimum(a):
result = a[0]
for i in range(1, len(a)):
if a[i] < result:
result = a[i]
return result
print(minimum([7, 4, 9, 2, 8, 3]))fn minimum(a: &[i64]) -> i64 {
let mut result = a[0];
for &value in &a[1..] {
if value < result {
result = value;
}
}
result
}
fn main() {
println!("{}", minimum(&[7, 4, 9, 2, 8, 3]));
}2
Напишите функцию, которая получает список и возвращает число. Тесты сами передают аргументы: читать ввод и печатать ответ не нужно. Здесь тренируем проход по элементам, поэтому встроенный min() использовать нельзя.
Для углубления: пустой вход и другое начальное значение
Можно расширить контракт: для пустого массива возвращать специальное значение None. В Rust это выражается типом Option. Ниже сохранён такой вариант реализации.
Минимум за один проход
Для непустого массива запоминаем первый элемент, затем сравниваем с ним каждый из оставшихся. Получаем n−1 сравнение и Θ(1) дополнительной памяти. Если массив пуст, функция возвращает None: в Python это отдельное значение, в Rust — вариант Option<i64>. Входной массив не изменяется.
def minimum(values: list[int]) -> int | None:
if not values:
return None
result = values[0]
for i in range(1, len(values)):
value = values[i]
if value < result:
result = value
return result
print(minimum([7, 4, 9, 2, 8, 3]))
print(minimum([]))fn minimum(values: &[i64]) -> Option<i64> {
let (&first, rest) = values.split_first()?;
let mut result = first;
for &value in rest {
if value < result {
result = value;
}
}
Some(result)
}
fn main() {
println!("{}", minimum(&[7, 4, 9, 2, 8, 3]).unwrap());
println!("{:?}", minimum(&[]));
}2 None
В СПОРТИВНОМ ПРОГРАММИРОВАНИИ
Минимум среди результатов
В задаче даны n результатов участников; нужно выбрать минимальный. При n ≤ 200 000 достаточно одного прохода, а сортировать весь массив ради одного значения необязательно.
Оценка: Θ(n) времени и Θ(1) дополнительной памяти. Если вместе с минимумом нужен номер участника, храните и значение, и его индекс; отдельно уточните правило при равенстве.
Ещё один вариант псевдокода начинает с +∞. Тогда цикл сравнивает все n элементов. Это всё ещё линейный алгоритм, но точное число сравнений отличается от основного варианта.
function minimum(A):
result := +Infinity
for i := 0; i < len(A); i++:
result := min(result, A[i])
return result+∞ больше любого элемента. Для пустого массива это значение само по себе не является найденным минимумом: результат нужно определить отдельным контрактом.
Один проход растёт линейно. Но алгоритмы могут выполнять и фиксированное число действий, и все пары сравнений. Дадим этим различиям общий язык.
Как растёт количество операций: O-нотация
Допустим, один алгоритм делает около 5n действий, а другой — n². При n = 10 это 50 и 100; при n = 100 — 500 и 10 000; при n = 1 000 — 5 000 и 1 000 000. Разница увеличивается вместе с входом. Для выбора идеи обычно важнее увидеть этот рост, чем точно сосчитать каждую служебную операцию.
O-нотация описывает верхнюю границу роста количества работы с точностью до постоянного множителя, начиная с достаточно больших входов. У 5n и 5n + 2 линейный рост O(n); добавление постоянного числа действий не меняет его. Когда говорят о сложности алгоритма, стремятся назвать достаточно точную, полезную оценку.
| Оценка | Что происходит | Пример |
|---|---|---|
| O(1) | Объём работы не растёт с n | Прочитать a[5] |
| O(log n) | Каждый шаг уменьшает задачу в несколько раз | Отбрасывать половину кандидатов |
| O(n) | Пройти все элементы | Найти минимум |
| O(n log n) | Линейная работа на логарифмическом числе уровней | Сортировка слиянием, которую разберём далее |
| O(n²) | На каждый элемент просмотреть ещё порядка n | Сравнить все пары |
Что означает логарифм
Число log₂ n отвечает на вопрос: сколько раз нужно поделить n пополам, чтобы получить 1? Для 1 024 это десять делений: 1 024 → 512 → 256 → … → 2 → 1. Основание логарифма в O-нотации обычно не пишут: смена постоянного основания меняет лишь постоянный множитель.
| Функция | 1 | ⌈log₂ n⌉ | n | n log₂ n | n² |
|---|---|---|---|---|---|
| При n = 100 | 1 | 7 | 100 | 664,4 | 10 000 |
Выберите две функции для сравнения. Шкала логарифмическая: длина полосы пропорциональна log₁₀(число действий + 1), чтобы были видны и малые значения.
Увеличивайте n и смотрите на числа в таблице. Полосы помогают сравнить масштаб; они не показывают точное время программы.
При удвоении n линейная работа примерно удваивается, квадратичная — увеличивается в четыре раза, а логарифмическая получает ещё один шаг. В интерактиве сравниваются модельные функции. Например, n log₂ n может быть нецелым, хотя реальное число действий всегда целое.
Для углубления: строгие O, Ω, Θ и доказательства через константы
Функции 5n+2 и 100n+17 отличаются коэффициентами, но растут линейно. Функция n² при достаточно больших n обгонит любую из них. Асимптотические обозначения позволяют формально говорить об этом поведении на больших входах.
O(g(n)) (верхняя оценка) — f(n) = O(g(n)), если существуют C > 0 и n₀ такие, что для всех n ≥ n₀ выполняется 0 ≤ f(n) ≤ Cg(n).
Ω(g(n)) (нижняя оценка) — f(n) = Ω(g(n)), если существуют C > 0 и n₀ такие, что для всех n ≥ n₀ выполняется f(n) ≥ Cg(n) ≥ 0.
Θ(g(n)) (точный порядок) — f(n) = Θ(g(n)), если одновременно f(n) = O(g(n)) и f(n) = Ω(g(n)).
Верхняя оценка не обязана быть тесной: для линейного поиска минимума верны и O(n), и O(n²). Запись Θ(n) точнее — она утверждает одновременно линейную верхнюю и нижнюю границы. Буквы O и Ω сами по себе не означают худший и лучший случай; вид рассматриваемой функции времени нужно указать отдельно.
Условие выполняется на показанных целых n от 2 до 40. Это эксперимент на конечном отрезке, а не доказательство для всех n.
Правила, которыми пользуются постоянно
| Функция | Порядок роста | Причина |
|---|---|---|
| 5n + 2 | Θ(n) | Линейный член доминирует. |
| 7n² + 3n + 4 | Θ(n²) | Доминирует старшая степень. |
| n + n log n | Θ(n log n) | Логарифм растёт неограниченно. |
| log₂ n и log₃ n | Одинаковый порядок | Основание меняет лишь постоянный множитель. |
Почему основание логарифма обычно опускают?
Чтобы доказать, например, 5n+2 = Θ(n), для n ≥ 1 можно использовать неравенства 5n ≤ 5n+2 ≤ 7n. Здесь константы 5 и 7 ограничивают функцию снизу и сверху на всём нужном хвосте. Ползунки иллюстрируют такие сравнения, но совпадение на конечном наборе точек само по себе не является доказательством для всех n.
В таблице выше старшая растущая часть определяет порядок суммы. Это правило помогает упростить уже полученную оценку; сначала всё равно нужно посчитать, сколько действий выполняет сам алгоритм. Сделаем это для алгоритма, в котором время зависит не только от длины, но и от порядка элементов.
Теперь применим язык роста к конкретной сортировке. Начнём с идеи, которую можно выполнить руками, раскладывая карточки.
Сортировка вставками: учимся на примере
Представьте карточки [4, 2, 4, 1]. Первую карточку считаем уже упорядоченной. Берём следующую и вставляем в нужное место среди взятых ранее. Это сортировка вставками: отсортированное начало массива растёт на один элемент за шаг.
Вставляем 2 перед первой 4: получается [2, 4, 4, 1]. Следующая 4 уже стоит после значений, не превышающих её, поэтому не двигается. Наконец, 1 проходит влево через все три карточки: [1, 2, 4, 4]. Обе четвёрки сохранены.
Часть массива от начала до текущей границы называют префиксом. После каждого завершённого шага он отсортирован. Свойство, которое остаётся верным от шага к шагу, называют инвариантом. Здесь оно объясняет правильность: в конце префикс охватывает весь массив.
function insertion_sort(A):
for i := 1; i < len(A); i++:
j := i
while j > 0 and A[j] < A[j-1]:
swap(A[j], A[j-1])
j := j - 1Внутренний цикл двигает очередное число влево. Когда оно встало на место, отсортированный префикс становится длиннее.
Перед каждым новым элементом в модели выберите индекс, на котором он окажется. Индекс — номер позиции, начиная с нуля. Затем выполняйте соседние обмены по одному и следите за зелёным префиксом. Если ответ пока неверный, сравните вставляемое число с его будущими соседями.
Префикс из первого элемента уже отсортирован.
Куда вставится следующий элемент 2? Выберите его будущий индекс.
Когда вставки делают много работы
В обратном порядке каждый новый элемент проходит через всё отсортированное начало. Число обменов равно 1 + 2 + … + (n − 1), поэтому худшее время O(n²). На уже отсортированном входе обменов нет, но внешний цикл и сравнения остаются: время O(n). Ноль обменов не означает ноль работы.
Вставки с обменом соседей
Здесь используется тот же вариант, что в пошаговой модели: очередной элемент движется влево соседними обменами. После итерации i префикс длины i + 1 отсортирован. Строгое сравнение сохраняет порядок равных элементов. Список Python и изменяемый срез Rust обновляются на месте; дополнительная память — Θ(1).
def insertion_sort(values: list[int]) -> None:
for i in range(1, len(values)):
j = i
while j > 0 and values[j] < values[j - 1]:
values[j], values[j - 1] = values[j - 1], values[j]
j -= 1
data = [5, 2, 4, 2, 1]
insertion_sort(data)
print(data)fn insertion_sort(values: &mut [i64]) {
for i in 1..values.len() {
let mut j = i;
while j > 0 && values[j] < values[j - 1] {
values.swap(j, j - 1);
j -= 1;
}
}
}
fn main() {
let mut data = [5, 2, 4, 2, 1];
insertion_sort(&mut data);
println!("{:?}", data);
}[1, 2, 2, 4, 5]
В СПОРТИВНОМ ПРОГРАММИРОВАНИИ
Сколько обменов нужно для упорядочивания?
Разрешён обмен только соседних элементов. Минимальное число таких обменов до сортировки равно числу инверсий. На n = 200 000 их может быть 19 999 900 000.
Вставки показывают смысл каждого обмена, но моделировать все обмены на большом входе дорого. Для подсчёта инверсий используют сортировку слиянием за O(n log n); сам ответ храните в 64-битном целом.
Для углубления: инверсии, устойчивость и формальное доказательство
Поиск минимума выполнял один и тот же проход для любого порядка данных. Сортировка вставками интереснее: уже упорядоченные элементы почти не требуют работы, а маленький элемент в конце может пройти через весь массив. Сначала разберём, почему алгоритм сортирует правильно, затем оценим число перемещений.
Сортировка вставками (insertion sort) — обход массива слева направо с поддержанием отсортированного префикса и вставкой каждого нового элемента на подходящее место.
Перед внешней итерацией с индексом i префикс A[0:i) уже отсортирован. Берём следующий элемент A[i] и меняем его с большим левым соседом, пока он не займёт своё место. Получившийся префикс длины i+1 снова отсортирован; остальная часть пока может иметь любой порядок.
Инвариант цикла (loop invariant) — свойство состояния алгоритма, истинное перед первой итерацией и сохраняющееся после каждого шага.
function insertionSort(A):
for i := 1; i < len(A); i++:
j := i
while j > 0 and A[j] < A[j-1]:
swap(A[j], A[j-1])
j := j - 1
return AОбмен соседних элементов соответствует демонстрации из лекции. Для сортировки по неубыванию равные элементы не меняются местами.
Почему алгоритм корректен?
До первой итерации префикс состоит из одного элемента и уже отсортирован. Вставка проходит только через элементы, которые больше текущего, и сохраняет порядок остальных. После обработки последнего индекса префикс становится всем массивом. Эти три шага — инициализация, сохранение и завершение — связывают инвариант с требуемым результатом.
Оценить работу помогает число инверсий: пар индексов i < j, для которых A[i] > A[j]. Обмен соседней инвертированной пары уменьшает это число ровно на один. После сортировки инверсий нет, значит число обменов совпадает с их исходным количеством I.
Кроме обменов, остаются внешний проход и проверки, поэтому время равно Θ(n+I). У отсортированного массива I = 0, у обратного порядка различных элементов I = n(n−1)/2. Строгое сравнение в коде также сохраняет взаимный порядок равных значений: такая сортировка устойчива.
Время вставок зависит от порядка данных. Уточним, что такое лучший и худший случай, и научимся считать шаги вложенных циклов без догадок.
Лучший и худший случай. Вложенные циклы
Два массива одной длины могут требовать разного времени. При сортировке вставками массив [1, 2, 3, 4] не требует перестановок, а [4, 3, 2, 1] заставляет каждый новый элемент идти в начало. Лучший случай — наименьшая работа среди допустимых входов размера n; худший — наибольшая. Говоря о худшем случае, мы не утверждаем, что каждый запуск будет таким долгим.
Считаем вложенные циклы
Само наличие двух циклов ещё не даёт ответ. Нужно понять, сколько раз выполняется их общее внутреннее действие. Если оба цикла проходят все n элементов, на каждый из n внешних шагов приходится n внутренних. Для n = 5 это 5 × 5 = 25 действий, а в общем случае n².
for i in range(n):
for j in range(n):
process(i, j)Теперь пусть j перебирает только индексы меньше i. При i = 0 действий нет; при i = 1 — одно; при i = 2 — два. Для n = 5 сумма равна 0 + 1 + 2 + 3 + 4 = 10. В общем виде это n(n − 1)/2. Множитель 1/2 уменьшает точное число действий, но рост остаётся квадратичным: O(n²).
for i in range(n):
for j in range(i):
process(j, i)range(i) — индексы от 0 до i−1. Это ровно i внутренних шагов.
Внутренние шаги по порядку i: 0 + 1 + 2 + 3 + 4 = 10.
Для i = 0 нет меньших индексов. Затем 1, 2, …, n−1. Сумма n(n−1)/2.
В третьем режиме внешний цикл всё ещё имеет n шагов, а внутренний каждый раз уменьшает k вдвое. Он делает порядка log n шагов, поэтому вместе получается O(n log n). Анализируем границы и изменение переменных, а не количество строк с for.
Подсчёт работы треугольного цикла
Счётчик увеличивается один раз для каждой пары 0 ≤ j < i < n. При n = 5 внутренний цикл делает 0 + 1 + 2 + 3 + 4 = 10 шагов. Проверять формулу полезно на малых n, но доказательство для любого n даёт сумма арифметической прогрессии: n(n − 1)/2 = Θ(n²).
def count_pairs(n: int) -> int:
operations = 0
for i in range(n):
for _ in range(i):
operations += 1
return operations
print(count_pairs(5))
print(count_pairs(0))fn count_pairs(n: u64) -> u64 {
let mut operations = 0;
for i in 0..n {
for _ in 0..i {
operations += 1;
}
}
operations
}
fn main() {
println!("{}", count_pairs(5));
println!("{}", count_pairs(0));
}10 0
В СПОРТИВНОМ ПРОГРАММИРОВАНИИ
Перебор пар: что разрешает ограничение?
Если надо проверить каждую пару i < j, то при n = 2 000 выполняется 1 999 000 проверок. При n = 200 000 та же идея требует уже почти 20 миллиардов.
Само условие может быть одинаковым, но допустимый масштаб разный. Оцените стоимость одной проверки: операция над числами и сравнение длинных строк — разный объём работы.
В примере выше циклы помогают проверить подсчёт на малых n. В задании ниже нужно вернуть только количество пар. При n = 200 000 перечислять почти 20 миллиардов пар неразумно; используйте выведенную формулу. Проверьте и n = 0, и n = 1: в обоих случаях пар нет.
Для углубления: средний случай и сложные границы циклов
Для вставок длины n недостаточно, чтобы назвать точное число действий: нужен ещё порядок значений. Чтобы получить гарантию только через n, возьмём максимальное время среди всех допустимых массивов этой длины. Это и есть время в худшем случае. Минимум по тем же входам задаёт лучший случай.
| Случай | Как определён | Сортировка вставками |
|---|---|---|
| Лучший | min по всем входам длины n | Θ(n) при уже отсортированном массиве |
| Худший | max по всем входам длины n | Θ(n²) при обратном порядке |
| Общий вход | Зависит от структуры данных | Θ(n + I), где I — число инверсий, для варианта с соседними обменами |
Инверсия (inversion) — пара индексов i < j, для которой A[i] > A[j]. Каждый соседний обмен инвертированной пары устраняет ровно одну инверсию.
В спортивном программировании верхняя оценка худшего времени полезна тем, что тест не обязан быть удобным. Уже отсортированный пример не подтверждает скорость вставок на обратном порядке. Средний случай тоже возможен, но для него нужно явно задать распределение входов — слово «обычно» не заменяет такую модель.
Внутренние шаги по порядку i: 1 + 1 + 1 + 2 + 2 = 7.
Для i от 1 до n число шагов равно ⌊√i⌋. Это дополнительный пример с другим порядком роста.
Циклы как суммы
Вложенные циклы удобнее превращать в суммы. При n внешних итерациях и n внутренних шагах получаем n² действий. Если внутренняя граница равна i, суммируем i по всем внешним итерациям. Если она задаётся условием j·j ≤ i, число внутренних шагов равно ⌊√i⌋, и сумма уже имеет другой порядок.
for i := 1; i <= n; i++:
for j := 1; j*j <= i; j++:
// действие за O(1)Число внутренних шагов: ⌊√i⌋. Сумма по i от 1 до n равна Θ(n√n).
Откуда берётся Θ(n√n)?
Для условия с квадратом сверху можно ограничить каждый внутренний цикл через √n, получив O(n√n). Для нижней границы достаточно последних примерно n/2 внешних итераций: на каждой i ≥ n/2 и внутренних шагов порядка √n. Вместе это даёт Θ(n√n).
В примере Python и Rust ниже посчитана другая, треугольная сумма: 0+1+…+(n−1). Сравните её с моделью циклов и объясните, почему обе задачи имеют вложенные циклы, но не обязаны иметь одинаковую сложность.
Цикл явно повторяет действия. Есть и другой способ повторения: функция вызывает саму себя на меньшей задаче. Посмотрим, как такой способ останавливается и сколько работы создаёт.
Рекурсия: уменьшаем задачу
Нужно сложить числа от 1 до 4. Можно заметить: сумма до 4 — это 4 плюс сумма до 3. Та, в свою очередь, равна 3 плюс сумма до 2. Мы сводим задачу к такой же задаче меньшего размера. Если функцию записать через вызов самой себя, получится рекурсия.
function sum_to(n):
if n = 0:
return 0
return n + sum_to(n - 1)Цепочка вызовов: 4 → 3 → 2 → 1 → 0. На нуле сразу возвращаем 0 — это базовый случай, который не требует нового вызова. Затем ответы собираются обратно: 1 + 0 = 1, 2 + 1 = 3, 3 + 3 = 6, 4 + 6 = 10. Без базового случая функция продолжала бы вызывать себя и после нуля.
Сумма через меньшую задачу
База — n = 0. После неё каждый ожидающий вызов получает ответ и прибавляет своё n. Для примера n = 4 результат равен 10. Для больших n предпочтителен цикл или формула: стек рекурсии ограничен.
def sum_to(n):
if n == 0:
return 0
return n + sum_to(n - 1)
print(sum_to(4))fn sum_to(n: u64) -> u64 {
if n == 0 {
return 0;
}
n + sum_to(n - 1)
}
fn main() {
println!("{}", sum_to(4));
}10
Что происходит при каждом вызове
Каждый вызов хранит свои переменные и ждёт ответа меньшей задачи. В модели ниже видны вход в вызов, достижение базы и возврат к родителю. Начните с n − 1, затем сравните с делением пополам и с двумя вызовами на половинах. Счётчики отдельно показывают глубину текущей ветви и число всех сделанных вызовов.
Вошли в вызов с n = 4. Сначала решим меньшую задачу.
В этой модели базовый случай — размер 1. В примере sum_to ниже остановимся на 0: границу всегда задаёт задача. Глубина — число переходов по одной ветви; вызовы считаются по всему дереву.
При уменьшении на 1 путь имеет порядка n переходов. Если каждый раз брать половину, путь короче: 32 → 16 → 8 → 4 → 2 → 1, всего пять переходов. Такая идея используется в бинарном поиске, лекция 05: после сравнения можно отбросить половину кандидатов. Реализовывать его сейчас не требуется.
Но короткий путь ещё не означает мало работы всего. Если каждый вызов создаёт две подзадачи, нужно посчитать обе ветви, а не только одну. Это различие вскоре понадобится для сортировки слиянием.
Для углубления: рекуррентности, несколько ветвей и память стека
Вместо последовательного повторения шага можно выразить задачу через меньшие экземпляры той же задачи. Например, поиск в отсортированном массиве после одного сравнения оставляет только одну половину кандидатов. Функция может передать себе этот меньший интервал.
Рекурсия (recursion) — вычисление, в котором функция вызывает себя на меньшем аргументе. Корректная рекурсия требует базового случая и продвижения к нему.
Для деления пополам округление может менять точное число узлов, но не асимптотику. Разветвление на 3 вызова даёт степень log₂3 ≈ 1,585.
Четыре формы
| Рекурсивные вызовы | Число узлов / время |
|---|---|
| 1 × f(n−1) | Θ(n) |
| 1 × f(⌊n/2⌋) | Θ(log n) |
| 2 × f(⌊n/2⌋) | Θ(n) |
| 3 × f(⌊n/2⌋) | Θ(n^log₂3) |
В таблице предполагается постоянная работа внутри вызова, не считая его потомков. Один вызов на n−1 создаёт цепочку из Θ(n) узлов. Один вызов на n/2 даёт Θ(log n) узлов. Два вызова на половинах дают уже Θ(n) узлов, хотя максимальная цепочка всё ещё логарифмическая. Поэтому глубина стека и общее время описывают разные свойства.
Почему 3^log₂n = n^log₂3?
Для завершения рекурсии нужны база и мера, которая строго уменьшается до базы. У поиска такой мерой служит длина интервала: пустой интервал завершает поиск, непустой передаёт в следующий вызов меньшую половину. В коде ниже массив уже отсортирован, а границы задаются полуинтервалом; подробно разные варианты поиска разберём в лекции 05.
Пока достаточно общего правила анализа: представьте вызовы деревом, подпишите размер задачи и работу без дочерних вызовов в каждом узле. Если эта работа не постоянна, одного подсчёта узлов уже недостаточно. Следующая сортировка даст именно такой пример.
Рекурсивный двоичный поиск
Предусловие: массив отсортирован. Интервал [left, right) содержит возможный ответ; каждый вызов оставляет одну половину. Получаем T(n) = T(n/2) + Θ(1) и глубину Θ(log n). Индексы позволяют избежать копирования половин. Память стека — O(log n); на пустом интервале возвращается None.
def binary_search(values: list[int], target: int) -> int | None:
def search(left: int, right: int) -> int | None:
if left >= right:
return None
mid = left + (right - left) // 2
if values[mid] == target:
return mid
if values[mid] < target:
return search(mid + 1, right)
return search(left, mid)
return search(0, len(values))
print(binary_search([1, 3, 5, 7, 9], 7))
print(binary_search([], 7))fn binary_search(values: &[i64], target: i64) -> Option<usize> {
fn search(a: &[i64], target: i64, left: usize, right: usize)
-> Option<usize>
{
if left >= right { return None; }
let mid = left + (right - left) / 2;
if a[mid] == target { return Some(mid); }
if a[mid] < target {
search(a, target, mid + 1, right)
} else {
search(a, target, left, mid)
}
}
search(values, target, 0, values.len())
}
fn main() {
println!("{}", binary_search(&[1, 3, 5, 7, 9], 7).unwrap());
println!("{:?}", binary_search(&[], 7));
}3 None
В СПОРТИВНОМ ПРОГРАММИРОВАНИИ
Много запросов к отсортированным данным
Есть отсортированный массив длины n и q запросов поиска. Повторять линейный обход для каждого запроса — O(qn). Двоичный поиск даёт O(q log n).
При n = q = 200 000 вместо 4 · 10¹⁰ просмотров получаем не более примерно 3,6 · 10⁶ проверок середин. Если массив изначально не отсортирован, сначала учтите O(n log n) на подготовку; на одном запросе сортировка может быть лишней.
Мы умеем уменьшать задачу и возвращаться с ответом. Теперь разделим сортировку на две меньшие сортировки. Останется понять, как быстро объединить их результаты.
Сортировка слиянием
Сначала решим маленькую задачу: есть два уже отсортированных списка A = [1, 4, 7] и B = [2, 3, 8]. Как получить один отсортированный список? Сравним первые числа: 1 меньше 2, берём 1. Затем сравниваем 4 и 2 — берём 2; далее 4 и 3 — берём 3. Продолжая, получаем [1, 2, 3, 4, 7, 8].
У каждого списка держим указатель — индекс первого ещё не перенесённого элемента. Меньший из двух кандидатов и будет следующим в результате: все следующие за ним числа в его списке не меньше. После переноса двигается только один указатель. Если один список кончился, остаток другого можно дописать без сравнений двух кандидатов.
function merge(A, B):
i := 0; j := 0; C := []
while i < len(A) and j < len(B):
if A[i] <= B[j]:
append(C, A[i]); i := i + 1
else:
append(C, B[j]); j := j + 1
append remaining A[i:] and B[j:] to C
return CВ режиме предсказания выберите, из какого списка взять следующий элемент. Неверный выбор не сдвигает указатели: прочитайте объяснение и попробуйте ещё раз. В конце сравните число сравнений с числом переносов — это разные величины.
A · i = 0
B · j = 0
Результат
Сравните первые ещё не перенесённые элементы A и B.
От слияния к сортировке
Если списки имеют длины n и m, каждый из n + m элементов переносится один раз. Поэтому слияние занимает O(n + m) времени. Это позволяет построить сортировку слиянием: разделить массив на две части, рекурсивно отсортировать каждую, затем слить результаты. База рекурсии — длина 0 или 1: такой массив уже упорядочен.
function merge_sort(A):
if len(A) <= 1:
return A
mid := floor(len(A) / 2)
left := merge_sort(A[:mid])
right := merge_sort(A[mid:])
return merge(left, right)Полная сортировка слиянием
Обе половины сначала сортируются, затем два указателя собирают результат. Сравнение ≤ выбирает левый элемент при равенстве, сохраняя устойчивость. Эта учебная реализация возвращает новый массив и выделяет буферы в каждом вызове: суммарное время Θ(n log n), пиковая дополнительная память Θ(n).
def merge_sort(values: list[int]) -> list[int]:
if len(values) <= 1:
return values.copy()
mid = len(values) // 2
left = merge_sort(values[:mid])
right = merge_sort(values[mid:])
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
print(merge_sort([8, 3, 6, 2, 7, 1, 5, 4]))fn merge_sort(values: &[i64]) -> Vec<i64> {
if values.len() <= 1 { return values.to_vec(); }
let (a, b) = values.split_at(values.len() / 2);
let left = merge_sort(a);
let right = merge_sort(b);
let mut result = Vec::with_capacity(values.len());
let (mut i, mut j) = (0, 0);
while i < left.len() && j < right.len() {
if left[i] <= right[j] {
result.push(left[i]);
i += 1;
} else {
result.push(right[j]);
j += 1;
}
}
result.extend_from_slice(&left[i..]);
result.extend_from_slice(&right[j..]);
result
}
fn main() {
println!("{:?}", merge_sort(&[8, 3, 6, 2, 7, 1, 5, 4]));
}[1, 2, 3, 4, 5, 6, 7, 8]
В СПОРТИВНОМ ПРОГРАММИРОВАНИИ
Сортировка без готовой функции
В LeetCode 912 нужно самостоятельно упорядочить до 50 000 чисел за O(n log n). Квадратичная сортировка в худшем случае делает порядка 2,5 · 10⁹ действий.
Слияние гарантирует O(n log n) независимо от исходного порядка. Проверьте повторы, нечётную длину и дополнительный буфер O(n); задача позволяет потренировать управление памятью, например повторно использовать один буфер.
Сортировка слиянием имеет O(n log n) времени даже в худшем случае и требует O(n) дополнительной памяти в обычной реализации с буфером. Почему возникает именно n log n, разберём на дереве в следующей главе. А пока в задании достаточно написать само слияние двух готовых списков.
Для углубления: устойчивость и выделение памяти
При равенстве кандидатов выбираем левый. Если входы уже устойчиво отсортированы, это сохраняет исходный порядок равных ключей и делает всю сортировку устойчивой. Можно переиспользовать один буфер и передавать границы диапазонов, чтобы уменьшить число выделений памяти. Срезы в Python копируют ссылки на элементы; учитывать их стоимость нужно вместе с рекурсией.
Для углубления: единый цикл слияния из исходного конспекта
function merge(A, B):
i := 0; j := 0; C := []
while i < len(A) or j < len(B):
if j == len(B) or
(i < len(A) and A[i] <= B[j]):
C.push(A[i]); i := i + 1
else:
C.push(B[j]); j := j + 1
return C
function sort(A):
n := len(A)
if n <= 1: return A
m := floor(n / 2)
L := sort(A[0:m])
R := sort(A[m:n])
return merge(L, R)Каждое слияние линейно по размеру своих списков. Чтобы оценить всю сортировку, посмотрим на все задачи одного уровня одновременно.
Почему слияние работает за O(n log n)
Возьмём массив из восьми элементов. Сначала есть одна задача размера 8. После разделения — две по 4, затем четыре по 2, затем восемь по 1. На каждом уровне в сумме остаются те же восемь элементов: 8 = 2 × 4 = 4 × 2 = 8 × 1.
Чтобы соединить результаты одного уровня, нужно перенести суммарно порядка восьми элементов. Слияния становятся меньше, но их становится больше — эти изменения компенсируются. Поэтому работа одного уровня растёт пропорционально n.
Нажимайте «Разделить» и проверяйте сумму размеров. Для n = 8 до единичных частей нужны три деления пополам; обратно будет три уровня слияния. Для n = 16 таких уровней уже четыре. Удвоение n добавило один уровень, а не удвоило их число.
Уровень 0: 1 × 8 = 8 элементов
Листья — 8 задач размера 1. Уровней слияния — 3; если считать и листья, уровней дерева 4. Эта разница на 1 не меняет порядок роста.
Складываем работу уровней
Теперь обобщим: делить n пополам до единицы нужно порядка log₂ n раз. На каждом уровне работа порядка n. Значит, вся сортировка требует O(n log n). Если считать ещё и базовые вызовы на единичных частях, добавится линейная работа, которая не меняет эту оценку.
Сумма размеров на уровнях дерева
Для n, равного степени двойки, на глубине d находится 2^d задач размера n/2^d. Программа перечисляет сумму размеров на каждом уровне, включая листья. Это иллюстрация для рекуррентности с линейной локальной работой: сама функция не выполняет сортировку. При n = 8 имеем четыре уровня по восемь единиц.
def level_work(n: int) -> list[int]:
assert n > 0 and n & (n - 1) == 0
result = []
calls, size = 1, n
while True:
result.append(calls * size)
if size == 1:
return result
calls *= 2
size //= 2
print(level_work(8))fn level_work(n: usize) -> Vec<usize> {
assert!(n.is_power_of_two());
let (mut calls, mut size) = (1, n);
let mut result = Vec::new();
loop {
result.push(calls * size);
if size == 1 { return result; }
calls *= 2;
size /= 2;
}
}
fn main() {
println!("{:?}", level_work(8));
}[8, 8, 8, 8]
В СПОРТИВНОМ ПРОГРАММИРОВАНИИ
Подсчёт пар при слиянии
Можно посчитать пары внутри левой и правой половины рекурсивно, а затем отдельно — пары между половинами. Если межполовинная обработка линейная, T(n) = 2T(n/2) + O(n).
Дерево даёт O(n log n), только если указатели проходят половины суммарно за O(n). Если для каждого левого элемента заново обходить всю правую часть, стоимость узла станет квадратичной.
Для углубления: рекуррентность и математическое доказательство
Запись T(n)=2T(n/2)+Θ(n) говорит: время текущей задачи равно времени двух половин плюс собственной работе. Чтобы развернуть эту зависимость, представим один вызов вершиной, а его дочерние вызовы — двумя детьми. Для ясности сначала считаем n степенью двойки.
Рекуррентное соотношение (recurrence relation) — формула, связывающая время T(n) с временем решения подзадач меньшего размера.
Как сложить работу?
В корне выполняется cn собственной работы. На следующем уровне две задачи размера n/2 дают вместе 2·c(n/2)=cn. На глубине d задач уже 2ᵈ, размер каждой n/2ᵈ, и их суммарная собственная работа снова cn. При подсчёте узла дочерние вызовы сюда не включаем, иначе посчитаем одну работу несколько раз.
После log₂n делений размеры достигают единицы. Есть Θ(log n) уровней по Θ(n) работы и Θ(n) суммарных затрат в листьях. В итоге получаем Θ(n log n). Округления для нечётных размеров меняют детали дерева, но не порядок этой оценки.
Уточнение про индукционное доказательство
Пример в основной части главы перечисляет суммарные размеры задач по уровням; для n=8 получается [8, 8, 8, 8]. Это иллюстрация линейной собственной работы, а не ещё одна сортировка. Если бы каждый узел делал лишь постоянную работу, суммы по уровням росли бы как 1, 2, 4, …, и итог определяли бы листья.
Этого объяснения достаточно, чтобы пользоваться оценкой сортировки. Следующий необязательный блок обобщает анализ деревьев. Его можно пропустить и сразу перейти к выбору алгоритма по ограничениям.
10 / Мастер-теорема · Для углубления, необязательно
Мастер-теорема
Этот раздел необязателен: можно сразу перейти к ограничениям. Мы уже объяснили O(n log n) у слияния через уровни дерева. Мастер-теорема помогает получить оценку для целого семейства похожих рекурсий.
Сначала узнаем знакомую сортировку: она создаёт две подзадачи половинного размера и делает линейную работу на слияние. Поэтому a = 2, b = 2, c = 1. В модели выберите MergeSort и сопоставьте результат с предыдущей главой. Только затем меняйте число ветвей и показатель собственной работы.
Пусть каждый вызов создаёт a подзадач размера n/b, а вне рекурсии делает Θ(nᶜ) работы. Здесь a ≥ 1 и b > 1 — постоянные. Дерево имеет около logᵦn уровней и nᵖ листьев, где p=logᵦa. Сравнение показателей c и p позволяет понять, какая часть дерева задаёт основной вклад.
На глубине d собственная работа всех узлов равна nᶜ·(a/bᶜ)ᵈ с точностью до постоянных множителей. Если a/bᶜ < 1, вклады убывают и доминирует верх. Если отношение равно 1, все уровни одного порядка и появляется log n. Если отношение больше 1, основной вклад дают нижние уровни. Так получаются три случая в таблице.
| Соотношение | Кто доминирует | Итог |
|---|---|---|
| c < p | Много задач внизу дерева | Θ(nᵖ) |
| c = p | Все уровни одного порядка | Θ(nᵖ log n) |
| c > p | Работа верхних уровней | Θ(nᶜ) |
Диаграмма построена для n = b⁴. Правило применяется к форме a ≥ 1, b > 1 и нерекурсивной работе Θ(nᶜ), при c ≥ 0.
Для MergeSort имеем a=2, b=2, c=1, p=1. Показатели совпадают, поэтому ответ Θ(n log n). Для четырёх половинных подзадач с линейной подготовкой p=2 > c, и ответ Θ(n²); для трёх p=log₂3, и ответ Θ(n^log₂3). Эти два случая скоро пригодятся для сравнения способов умножения.
Применять таблицу можно только после проверки формы рекуррентности. У T(n)=T(n−1)+n нет постоянного коэффициента уменьшения b>1, а у неравных долей подзадач другая структура. Здесь нужно возвращаться к суммам, индукции или более общим методам.
Теперь применим оценки к ограничениям задач и самостоятельной практике. В дополнительных материалах в конце лекции можно разобрать умножение многочленов и увидеть, как уменьшение числа рекурсивных вызовов улучшает порядок времени.
Справочник терминов
Открыть общий справочник всех лекций ↗
Как выбрать алгоритм по ограничениям
Участники прислали результаты, и нужно понять, встречается ли одно значение дважды. Самая прямая идея — сравнить каждую пару. Для шести чисел это 15 сравнений; для 200 000 — уже 19 999 900 000, почти 20 миллиардов. На таком входе нужна другая идея.
Отсортируем значения. Равные результаты окажутся рядом, поэтому после сортировки достаточно одного прохода по соседям. Получится O(n log n) вместо O(n²). Оценка помогла выбрать способ до написания большого решения.
Четыре шага перед кодом
- Прочитайте ограничения. Что обозначает n и чему оно может быть равно? Длина массива и величина его элементов — разные параметры.
- Придумайте простое решение. Уточните, почему оно верно: например, перебор точно проверит все пары.
- Подставьте максимальный n в объём работы. Для 200 000 один проход — порядка 200 000 шагов, n log₂ n — около 3,52 миллиона, все пары — почти 20 миллиардов.
- Если работа слишком велика — меняйте идею. Попробуйте сортировку, уменьшение подзадачи или использование уже найденных результатов. Затем оцените память и проверьте код на неудобных входах.
Калькулятор ниже сравнивает масштабы. Он не переводит действия в секунды: сравнение чисел, сравнение строк и выделение памяти имеют разную стоимость, а языки и компьютеры различаются. Подходящий порядок роста — основание проверить реализацию, а не гарантия прохождения лимита.
log₂ nДвоичный поиск≈ 18Небольшой объём работыnОдин проход≈ 200 000Небольшой объём работыn log₂ nСортировка слиянием≈ 3 521 929Небольшой объём работыn²Полный перебор пар≈ 40 000 000 000Вероятно слишком дорого для такого nСравниваем ведущие члены с учебным бюджетом 50 000 000 условных действий. Это оценка масштаба: константы, язык, ввод и лимит времени проверяют отдельно. Гарантию прохождения она не даёт.
При n ≤ 20 перебор пар вполне может быть удобным. При n ≤ 200 000 для минимума достаточно прохода, а для поиска повторов пригодится сортировка. Ограничение само по себе не называет алгоритм: оно помогает оценить идею для конкретной задачи. Если |aᵢ| ≤ 10⁹, это не означает миллиард элементов — проход по-прежнему касается только n чисел.
Для углубления: память, несколько тестов, несколько параметров и инверсии
Мы получили оценки для прохода, вставок, слияния и рекурсивного умножения. Теперь используем их до написания программы: ограничения задачи помогают отсеять слишком дорогие идеи. Но из записи «n ≤ 200 000» нельзя вывести единственный правильный алгоритм: сначала нужно понять, что требуется вычислить, а затем оценить время и память нескольких кандидатов.
От ограничения к объёму работы
- Определите параметры. n — число элементов, q — запросов, m — рёбер, L — длина строки. Если проверка пары сравнивает строки, её стоимость может быть O(L), и полный перебор станет O(n²L).
- Подставьте максимумы. Для n = 200 000 один проход — 2 · 10⁵ посещений, n log₂ n — примерно 3,52 · 10⁶, полный квадрат — 4 · 10¹⁰. Для пар i < j коэффициент вдвое меньше, но порядок остаётся квадратичным.
- Сопоставьте с лимитами. Количество условных действий — фильтр идей, а не перевод в секунды. Операции с памятью, длинными числами, строки, интерпретатор, ввод и накладные расходы различаются. Проверяйте подход на больших и неудобных данных в условиях, близких к проверяющей системе.
- Проверьте память и ответ. Массив из 200 000 чисел i64 хранит 1 600 000 байт полезных данных. Таблица n × n из i64 — 320 000 000 000 байт, около 298 GiB, без накладных расходов. Python list[int] хранит ссылки на объекты и требует больше памяти, чем плотно упакованный массив i64. Число пар уже может не поместиться в 32 бита.
Ориентиры, которые стоит проверить числами
Длина массива и величина его элементов — разные параметры. При n ≤ 200 000 и |aᵢ| ≤ 10⁹ линейный проход посещает 200 000 элементов, а не миллиард. Диапазон значений становится размером работы или памяти только если вы явно создаёте массив частот, перебираете все значения или используете арифметику, зависящую от разрядности.
| Максимальный вход | Пробная идея | Ведущий объём работы |
|---|---|---|
| n = 20 | Перебор подмножеств | 2²⁰ ≈ 10⁶; с O(n) на подмножество — уже ≈ 2 · 10⁷ |
| n = 2 000 | Перебор пар | n² = 4 · 10⁶; отдельно оцените работу внутри |
| n = 200 000 | Сортировка и проход | n log₂ n ≈ 3,52 · 10⁶, затем ещё n |
| n = 10⁶ | Линейная обработка | 10⁶ посещений; часто заметны ввод и хранение данных |
| n = 10⁹ | Формула, логарифмический поиск | Около 30 делений пополам; массив из n элементов уже дорог |
Последняя строка имеет смысл, если n — числовая граница или размер неявного пространства. Если во входе реально перечислены n элементов, их чтение само требует Ω(n): двоичный поиск не отменяет стоимость загрузки массива. Экспоненциальный перебор из первой строки — ориентир для будущих тем.
Несколько тестов и несколько параметров
Для t независимых тестов складывайте стоимости: Σ f(nᵢ). Если каждый тест допускает n ≤ 2 000 и t ≤ 100, полный квадрат может дать 100 · 2 000² = 4 · 10⁸ действий. Оценить только один тест недостаточно.
Если дополнительно гарантировано Σ nᵢ ≤ 200 000, линейные решения суммарно делают O(200 000) работы, а сортировки — O(N log N), где N = Σ nᵢ. Для квадратов сумма не становится линейной: при nᵢ ≤ M верно Σ nᵢ² ≤ M · N; без ограничения M один большой тест даёт до N².
Для n = q = 200 000 поиск каждого запроса полным проходом требует до 4 · 10¹⁰ проверок. Сортировка один раз и двоичный поиск для каждого запроса дают O(n log n + q log n). Если же данных всего 100, более простой алгоритм может быть удобнее — сначала подставьте числа.
РАЗБОР / ПОДСЧЁТ ИНВЕРСИЙ
Почему слияние появляется в задаче про обмены
Нужно узнать минимальное число соседних обменов до сортировки массива. Один обмен исправляет одну инверсию. Но ответ при n = 200 000 может достигать 19 999 900 000 — симуляция будет квадратичной.
При слиянии двух уже отсортированных половин, если правый элемент меньше текущего левого, он меньше и всех оставшихся слева. К ответу можно сразу прибавить число этих элементов. Такой подсчёт добавляет O(n) работы к узлу дерева и сохраняет общую оценку O(n log n). В Rust для счётчика используйте достаточно широкий тип.
Перед отправкой решения
Назовите размер входа, выпишите худший случай, учтите все тесты и память. Проверьте, что код действительно реализует оценённую идею: копирование срезов, сортировка внутри цикла или повторное начало указателя могут изменить стоимость. Корректность на примерах и приемлемая асимптотика нужны одновременно.
Осталось собрать навыки вместе: написать короткие функции, обосновать выбор сложности и попробовать задачи с настоящими условиями. Внешние отправки не нужны для зачёта этой лекции.
Практика и проверка навыков
Уровень 1. Закрепите три небольших алгоритма
Это те же задания, что в главах выше: черновики и зачёт общие. Для каждого есть три ступени подсказок — идея, порядок действий и фрагмент псевдокода. Открывайте их последовательно, если нужна помощь.
Уровень 2. Перенесите идею в задачу
Ниже сохранён набор внешних задач с тремя уровнями сложности. После разминки начните с Helpful Maths и Contains Duplicate. Binary Search лучше решить после лекции 05. Звёздочная Reverse Pairs — необязательное продолжение: для завершения лекции она не нужна.
Сначала запишите свой контракт и оценку времени, затем решайте задачу. После успешной отправки объясните, почему идея выдерживает максимальный вход. Подсказки ниже раскрываются отдельно.
Уровни задают рекомендуемый порядок практики после этой лекции. Для LeetCode они совпадают с Easy / Medium / Hard; уровень задачи CSES выбран для этого курса. Ссылки ведут на исходные условия.
Лёгкие
Три задачи: освоить контракт, сортировку и поиск.
- Codeforces · 339A
Helpful Maths
Разобрать строку и упорядочить слагаемые. Начните с сортировки, затем подумайте, что меняет алфавит из трёх значений.
Ограничения. Длина строки ≤ 100; слагаемые — только 1, 2 и 3.
Идея и оценка сложности
Для такой длины даже O(n²) невелико. Сортировка даёт O(n log n); три счётчика позволяют собрать результат за O(n). Сравните оба решения и объясните, что именно обозначает n.
- LeetCode · 704
Binary Search
Границы интервала, уменьшение подзадачи и базовый случай.
Вернитесь после лекции 05 о бинарном поиске.
Ограничения. 1 ≤ n ≤ 10⁴; числа различны, массив уже отсортирован. В условии требуется O(log n).
Идея и оценка сложности
На одном запросе линейный поиск выглядит небольшим по объёму работы, но не выполняет явное требование условия. Поддерживайте полуинтервал [left, right), проверьте отсутствие цели и массив из одного элемента. Контракт этой задачи требует вернуть −1 вместо None.
- LeetCode · 217
Contains Duplicate
Заменить сравнение всех пар сортировкой и одним проходом.
Ограничения. 1 ≤ n ≤ 10⁵; значения от −10⁹ до 10⁹.
Идея и оценка сложности
Почти 5 · 10⁹ пар в худшем случае заставляют искать другую идею. После сортировки достаточно сравнивать соседей: O(n log n) времени. Массив счётчиков на весь диапазон значений слишком велик; хеширование — альтернативный путь за рамками этой лекции.
Средние
Две задачи: самостоятельно собрать алгоритм и обосновать стоимость.
- LeetCode · 912
Sort an Array
Реализовать слияние, доказать порядок роста и посчитать память.
Ограничения. 1 ≤ n ≤ 5 · 10⁴; значения от −5 · 10⁴ до 5 · 10⁴. Требуется O(n log n), без готовой сортировки.
Идея и оценка сложности
Напишите merge sort: O(n log n) времени и O(n) буфер. Модификация с одним повторно используемым буфером сокращает выделения памяти. Вставки в худшем случае квадратичны; корректности на малом примере недостаточно.
- CSES · 1619
Restaurant Customers
Превратить интервалы в события: отсортировать их и пройти со счётчиком.
Ограничения. 1 ≤ n ≤ 2 · 10⁵; моменты времени до 10⁹. Все времена прихода и ухода различны.
Идея и оценка сложности
Сформируйте 2n событий «приход +1» и «уход −1». Сортировка и проход дают O(n log n) времени, O(n) памяти. Перебирать каждую единицу времени до 10⁹ или сравнивать каждый интервал со всеми остальными дорого. Подход «сканирующая прямая» — небольшое расширение материала лекции.
Тяжёлая
Одна задача: перенести идею слияния на подсчёт пар.
- LeetCode · 493
★ Reverse Pairs
Расширить divide and conquer: считать пары между отсортированными половинами за линейное время.
Для углубления; не требуется для зачёта.
Ограничения. 1 ≤ n ≤ 5 · 10⁴; значения — знаковые 32-битные. Нужны пары i < j, где a[i] > 2 · a[j].
Идея и оценка сложности
Это условие отличается от обычной инверсии. Перед слиянием посчитайте подходящие пары двумя указателями, не начиная правый указатель заново для каждого левого элемента. Получится O(n log n). Проверяйте отрицательные числа; в Rust переводите значение в i64 до умножения на 2.
Уровень 3. Проверьте понимание без подсказки в условии
Пять коротких проверок ниже объединяют минимум, циклы, рекурсию, слияние и ограничения. Если что-то пока не получается, итог подскажет, какую главу повторить. Повторные попытки не отнимают уже полученный зачёт.
Дополнительные материалыДля углублённого разбора · не влияют на прохождение
Умножение многочленов
Возьмём умножение многочленов как пример, где легко получить простое решение и затем искать улучшение. Многочлен представим массивом коэффициентов: позиция задаёт степень x. Например, [2, 5, 3] означает 2+5x+3x².
Многочлен (polynomial) — выражение P(x) = p₀ + p₁x + … + pₘxᵐ; массив [p₀, p₁, …, pₘ] содержит коэффициенты по возрастанию степени.
Произведение многочленов — коэффициент при xᵏ равен сумме всех произведений pᵢqⱼ, для которых i + j = k.
При раскрытии скобок каждая пара коэффициентов P[i] и Q[j] вносит произведение в позицию i+j результата. Для двух непустых массивов длины n и m нужен массив из n+m−1 коэффициентов. Индексная формула выше суммирует только те пары, у которых индексы допустимы.
function multiply(P, Q):
R := array_of_zeros(len(P)+len(Q)-1)
for i := 0; i < len(P); i++:
for j := 0; j < len(Q); j++:
R[i+j] := R[i+j] + P[i]*Q[j]
return RДля массивов длины n и m время Θ(nm); при n = m — Θ(n²).
Изменяйте коэффициенты от младшего p₀, q₀ к старшему p₂, q₂.
Q(x) = 4x^2 + 2x + 1
| × | q0 | q1 | q2 |
|---|---|---|---|
| p0 | 2 | 4 | 8 |
| p1 | 5 | 10 | 20 |
| p2 | 3 | 6 | 12 |
Прямой способ выполняет n² попарных произведений коэффициентов и их накопление: Θ(n²) для двух массивов длины n.
В произведении (2+5x+3x²)(1+2x+4x²) коэффициент при x² равен 2·4+5·2+3·1=21. В модели выберите эту степень и проверьте три соответствующие пары. Вложенные циклы перебирают все nm пар, поэтому при равных длинах получаем Θ(n²) арифметических операций.
Здесь пока считаем операцию над коэффициентом постоянной. Для больших целых коэффициентов нужно дополнительно учитывать их разрядность. Пустой массив в реализациях ниже отдельно трактуется как нулевой многочлен; основной псевдокод описывает непустые входы.
Произведение массивов коэффициентов
Индекс хранит степень: [2, 5, 3] означает 2 + 5x + 3x². Произведение p[i]·q[j] добавляется к коэффициенту степени i + j. Для длин m и n выполняется mn умножений. Пустой массив обозначает нулевой многочлен. В Rust используем i64 и небольшие коэффициенты, чтобы все промежуточные значения помещались в тип.
def multiply(p: list[int], q: list[int]) -> list[int]:
if not p or not q:
return []
result = [0] * (len(p) + len(q) - 1)
for i, a in enumerate(p):
for j, b in enumerate(q):
result[i + j] += a * b
return result
print(multiply([2, 5, 3], [1, 2, 4]))fn multiply(p: &[i64], q: &[i64]) -> Vec<i64> {
if p.is_empty() || q.is_empty() { return vec![]; }
let mut result = vec![0; p.len() + q.len() - 1];
for (i, &a) in p.iter().enumerate() {
for (j, &b) in q.iter().enumerate() {
result[i + j] += a * b;
}
}
result
}
fn main() {
println!("{:?}", multiply(&[2, 5, 3], &[1, 2, 4]));
}[2, 9, 21, 26, 12]
В СПОРТИВНОМ ПРОГРАММИРОВАНИИ
Сколько пар дают каждую возможную сумму?
Для двух наборов чисел можно построить частоты: p[i] и q[j]. Тогда коэффициент r[k] произведения многочленов хранит число пар с суммой k = i + j.
Если значения лежат в диапазоне 0…2 000, прямое перемножение двух массивов частот требует около 4 · 10⁶ умножений независимо от числа элементов после подсчёта частот. Для широкого диапазона такой массив уже не подходит; число пар может превышать предел i32.
Механическое разбиение каждого многочлена пополам ещё не гарантирует ускорения: раскрытие скобок даст четыре половинных произведения и ту же квадратичную оценку. Посмотрим, как алгебра позволяет убрать одно из них.
Три произведения вместо четырёх
Разделим коэффициенты на младшую и старшую части: P=P₁xᵐ+P₀, Q=Q₁xᵐ+Q₀. После раскрытия скобок появляются четыре произведения половин. По мастер-теореме T(n)=4T(n/2)+Θ(n) всё ещё даёт Θ(n²). Сам факт рекурсии не сделал алгоритм быстрее.
P(x) = p₁x + p₀, Q(x) = q₁x + q₀. Параметры можно менять.
4 произведения
3 произведения + сложения
Главная алгебраическая хитрость
В готовом ответе два смешанных произведения нужны только в сумме. Поэтому вычислим A=P₁Q₁, B=P₀Q₀ и C=(P₁+P₀)(Q₁+Q₀). В C уже содержатся A, B и оба смешанных слагаемых. Вычитая A и B, получаем нужную середину без четвёртого умножения.
Всего осталось три рекурсивных умножения. Сложение половин, вычитания и размещение коэффициентов результата требуют Θ(n) операций. В терминах мастер-теоремы a меняется с 4 на 3, b остаётся 2, а c=1. Поэтому меняется показатель степени итоговой оценки.
Что опущено в учебной модели?
Та же идея работает для длинных целых, если вместо xᵐ использовать основание системы счисления в степени m. В коде ниже x=a·10ᵐ+b и y=c·10ᵐ+d; дорогие произведения ac, bd и (a+b)(c+d) заменяют четыре обычных. Демонстрация ограничивает входы, а оценка через число цифр относится к длинной арифметике.
На маленьких входах дополнительные разбиения и сложения могут стоить дороже сэкономленного умножения. Поэтому практические реализации часто переключаются на простой метод ниже выбранного порога. Асимптотика объясняет рост на больших размерах, но не назначает этот порог автоматически.
Карацуба для целых чисел
Та же алгебра работает для записи числа по основанию 10: x = a·10^m + b. Вычисляем ac, bd и (a + b)(c + d), затем восстанавливаем середину вычитанием. В этом примере входы ограничены диапазоном 0…10⁹; в Rust вычисления идут в u128. Оценка Θ(n^log₂3) относится к числу цифр при работе с длинной арифметикой, а для машинных чисел этот код служит демонстрацией разбиения.
def karatsuba(x: int, y: int) -> int:
assert 0 <= x <= 10**9 and 0 <= y <= 10**9
def product(x: int, y: int) -> int:
if x < 10 or y < 10:
return x * y
m = max(len(str(x)), len(str(y))) // 2
base = 10**m
a, b = divmod(x, base)
c, d = divmod(y, base)
high = product(a, c)
low = product(b, d)
cross = product(a + b, c + d) - high - low
return high * base**2 + cross * base + low
return product(x, y)
print(karatsuba(1234, 5678))fn karatsuba(x: u128, y: u128) -> u128 {
assert!(x <= 1_000_000_000 && y <= 1_000_000_000);
fn product(x: u128, y: u128) -> u128 {
if x < 10 || y < 10 { return x * y; }
let digits = (x.ilog10() + 1).max(y.ilog10() + 1);
let base = 10_u128.pow(digits / 2);
let (a, b) = (x / base, x % base);
let (c, d) = (y / base, y % base);
let high = product(a, c);
let low = product(b, d);
let cross = product(a + b, c + d) - high - low;
high * base * base + cross * base + low
}
product(x, y)
}
fn main() {
println!("{}", karatsuba(1234, 5678));
}7006652
В СПОРТИВНОМ ПРОГРАММИРОВАНИИ
Умножение длинных чисел
В задачах с собственной длинной арифметикой число имеет n цифр и не помещается в машинный тип. Школьное умножение использует O(n²) операций над цифрами.
Карацуба меняет рекуррентность на 3T(n/2) + O(n). Выигрыш относится к длинному представлению; для обычных машинных чисел дополнительные разбиения не обязаны ускорять программу. Сравнивайте реализации на одинаковых входах и учитывайте порог переключения.
Карацуба показывает связь между идеей алгоритма и формой рекуррентности: один удалённый вызов на каждом уровне меняет степень времени. Завершим теоретическую часть, выделив общий порядок такого анализа.
Дальнейшие связи
Путь от обычного умножения к Карацубе повторяется и в других задачах: дорогие рекурсивные операции иногда можно заменить дополнительными дешёвыми. Например, при блочном умножении матриц приём Штрассена уменьшает число половинных умножений с восьми до семи. Здесь достаточно увидеть аналогию, не изучая сами семь формул.
размера n/2
размера n/2
Для массивов коэффициентов подготовка и сборка стоят Θ(n), а три половинных произведения задают степень log₂3.
умножений
умножений
Для матриц сложение блоков стоит Θ(n²), а семь половинных произведений дают степень log₂7, которая меньше 3.
При разборе нового алгоритма начните с контракта и размера входа. Затем зафиксируйте стоимость базовых действий, объясните корректность через инвариант или рекурсивное предположение и только после этого суммируйте работу. Для рекурсии отдельно укажите число подзадач, их размеры и затраты на подготовку и объединение.
Оценка должна относиться к определённому случаю и модели. Логарифмическая глубина не означает логарифмического времени, два вложенных цикла не всегда дают квадрат, а O(n²) не утверждает точный порядок. Эти различия помогут выбирать решения по ограничениям входных данных.
Дополнительный разбор завершён. Вернитесь к практике, чтобы применить оценки самостоятельно, или переходите ко второй лекции: там тот же анализ пригодится для последовательностей операций над данными.