Алгоритмы с нуля
Время и память
Ты изучил, как измерять временную сложность: сколько шагов делает алгоритм при росте входа. Но есть ещё одно измерение: память. Каждый алгоритм расходует память, не только время. Одни алгоритмы быстрые, но жадные (запоминают много). Другие медленные, но бережливые (запоминают мало). Этот урок учит измерять второе измерение: пространственную сложность, дополнительную память, которую использует алгоритм. И ты узнаешь основной компромисс: часто ты можешь потратить память, чтобы сэкономить время, или потратить время, чтобы сэкономить память.
После этого урока ты можешь определить и измерить пространственную сложность алгоритма, читая его код, понять разницу между входной памятью и вспомогательной памятью, объяснить, почему рекурсия использует память (стек вызовов), и узнать компромисс между временем и памятью, чтобы решить, что важнее в контексте.
Пространственная сложность измеряет, сколько дополнительной памяти использует алгоритм как функция размера входа n. Как и временная сложность, мы выражаем память нотацией Big-O: O(1), O(n), O(n²), и так далее.
Вспомогательная память — это дополнительная память, которую алгоритм использует сверх самого входа. Когда ты слышишь «пространственная сложность», мы почти всегда имеем в виду вспомогательную память — новые структуры данных, которые ты создаёшь, переменные, которые объявляешь, кадры стека, добавляемые рекурсией.
Вот главная идея: ты часто можешь менять память на время, или время на память.
Пример 1: найти дублирующийся элемент в массиве
Подход с вложенным циклом (медленный, бережливый):
function hasDuplicate(numbers) {
for (let i = 0; i < numbers.length; i++) {
for (let j = i + 1; j < numbers.length; j++) {
if (numbers[i] === numbers[j]) return true;
}
}
return false;
}Временная сложность: O(n²) (вложенные циклы). Пространственная сложность: O(1) (только пара переменных, i и j).
Алгоритм сравнивает каждую пару элементов. Медленный, почти не использует дополнительную память.
Подход со множеством (быстрый, жадный):
function hasDuplicate(numbers) {
const seen = new Set();
for (let num of numbers) {
if (seen.has(num)) return true;
seen.add(num);
}
return false;
}Временная сложность: O(n) (один цикл, каждый поиск мгновенен). Пространственная сложность: O(n) (множество хранит до n значений).
Алгоритм создаёт структуру, которая запоминает все значения, которые ты видел. Когда встречаешь новое значение, спроси: «видел ли я тебя раньше?» — множество ответит мгновенно. Время падает до линейного. Память растёт до n.
Это компромисс между временем и памятью: потратить O(n) памяти, чтобы сэкономить O(n²) времени. Это выгодная сделка.
Чтение памяти из кода:
Считай новые данные, которые ты создаёшь:
- Несколько переменных (x, y, count) → O(1) (постоянная память).
- Новый массив размером n → O(n) памяти.
- Новый двумерный массив размером n × n → O(n²) памяти.
- Рекурсия, которая вызывает себя n раз в глубину → O(n) памяти стека.
Рекурсия трудна. Каждый вызов функции резервирует памяти стека для переменных этого вызова. Если твоя рекурсия n в глубину, то у тебя есть n вызовов на стеке, поэтому O(n) памяти.
Пример: отсчёт рекурсивно.
function countdown(n) {
if (n === 0) return;
console.log(n);
countdown(n - 1);
}Для countdown(1000) стек вызовов растёт в глубину на 1000. Каждый уровень хранит переменные (n, адрес возврата). Итого: O(n) памяти стека. Если переписать это как цикл, стек остаётся мелким — O(1) памяти.
Спектр компромисса между временем и памятью:
Большинство алгоритмов живут где-то в спектре:
- O(1) время, O(1) память — редко (простой поиск или проверка).
- O(n) время, O(1) память — бережливо (просканировать вход, не запоминать).
- O(n) время, O(n) память — сбалансировано (просканировать и запомнить).
- O(n²) время, O(1) память — медленно, но бережливо (перебор без помощи).
- O(n log n) время, O(n) память — эффективно (merge sort, с дополнительным массивом).
Ты не всегда можешь добиться одновременно быстрого времени и низкой памяти. Выбирай мудро, основываясь на своих ограничениях. Если память тесна (встроенная система, мобильный телефон), выбери бережливый подход. Если скорость критична (система реального времени, пользователь ждёт ответа), потрать память, чтобы ускориться.
Давай посмотрим пространственную сложность в реальном коде.
Паттерн 1: O(1) память — никаких новых структур
function findMax(numbers) {
let max = numbers[0];
for (let i = 1; i < numbers.length; i++) {
if (numbers[i] > max) max = numbers[i];
}
return max; // Только max и i новые
}Использует: одна переменная max, одна переменная i. Итого: O(1) вспомогательной памяти.
Паттерн 2: O(n) память — новый массив или множество
function countFrequency(numbers) {
const freq = new Map();
for (let num of numbers) {
freq.set(num, (freq.get(num) || 0) + 1);
}
return freq; // freq может хранить до n записей
}Использует: карту, которая хранит до n уникальных значений. Итого: O(n) вспомогательной памяти.
Паттерн 3: O(n) память — глубина рекурсии
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1); // Рекурсивный вызов добавляет кадр стека
}Для factorial(1000) стек вызовов держит 1000 кадров (по одному на уровень). Итого: O(n) памяти стека.
Паттерн 4: O(n²) память — двумерный массив
function createGrid(n) {
const grid = [];
for (let i = 0; i < n; i++) {
grid[i] = [];
for (let j = 0; j < n; j++) {
grid[i][j] = i * j;
}
}
return grid; // Сетка n × n
}Использует: двумерный массив размером n × n. Итого: O(n²) вспомогательной памяти.
Давай проследим, как растёт стек в рекурсивной функции. Смотри, как память строится при углублении:
1
function countdown(n) {
2
if (n === 0) return;
3
console.log(n);
4
countdown(n - 1);
5
}
Каждый уровень стека расходует память на переменные этой функции (n, адрес возврата и т. д.). При максимальной глубине n, память O(n).
Вот как пространственная сложность влияет на практические выборы:
| Алгоритм | Время | Память | Используй, когда |
|---|---|---|---|
| Линейный поиск | O(n) | O(1) | Память мала |
| Бинарный поиск | O(log n) | O(1) | Память тесна; скорость достаточна |
| Поиск в хеш-множестве | O(n) to build, O(1) per lookup | O(n) | Много поисков; память есть |
| Вложенный цикл для дубликатов | O(n²) | O(1) | Массив мал или память критична |
| Множество для дубликатов | O(n) | O(n) | Скорость важна; память в избытке |
| Merge sort | O(n log n) | O(n) | Скорость важна; дополнительный массив в порядке |
| Bubble sort | O(n²) | O(1) | Массив крошечный; скорость сортировки не важна |
Заметь закономерность: более быстрые алгоритмы часто используют больше памяти. Вопрос не в том, «что объективно лучше?», а в том, «какое ограничение я встречаю?» Если мобильное приложение с лимитом ОЗУ, выбери O(1) память. Если веб-сервер с достаточной памятью и ждущие пользователи, выбери быстрое время.
Компромисс реален. Ты не можешь иметь одновременно O(1) память и O(n) время для проверки дубликатов — ты должен пожертвовать одним измерением.
У тебя есть функция, которая использует цикл для проверки каждого элемента (O(n) время) и объявляет только пару переменных (max, i). Какова её пространственная сложность?
Ты создаёшь новый массив размером n внутри функции. Какова пространственная сложность?
Рекурсивная функция вызывает себя n раз перед возвратом. Какова её пространственная сложность стека?
Ты хочешь найти дублирующийся элемент в массиве. Подход с вложенным циклом занимает O(n²) времени и O(1) памяти. Подход со множеством занимает O(n) времени и O(n) памяти. Какой это компромисс между временем и памятью?
Ты пишешь код для встроенной системы с 1 МБ ОЗУ. Алгоритм с O(n) временем и O(n) памятью не влезет. Какая альтернатива имеет шанс?
Граничные случаи
Вспомогательная память против общей памяти. Некоторые определения считают сам вход частью пространственной сложности. Поэтому они различают: общая память = входная память + вспомогательная память. Когда мы говорим «пространственная сложность» в курсах алгоритмов, мы обычно имеем в виду только вспомогательную память — дополнительную память, которую ты выделяешь, не память, которую вход уже занимает. Знай оба термина.
Частая ошибка
Путаница циклов времени с памятью. Если функция имеет цикл, который выполняется n раз, её временная сложность O(n). Но пространственная сложность не O(n), если она не выделяет что-то размером n. Цикл, выполняемый n раз, но использующий только пару переменных, имеет O(1) память. Память — это о структурах памяти, а не о шагах выполнения.
Почему компромисс между временем и памятью важен в разработке алгоритмов?
Пространственная сложность измеряет дополнительную память, которую использует алгоритм, написанная нотацией Big-O, как и временная сложность.
Ключевые точки:
- Вспомогательная память — это то, что мы измеряем: новые структуры данных, которые ты создаёшь, а не сам вход.
- Чтение памяти из кода: Считай новые данные. Несколько переменных = O(1). Массив размером n = O(n). Двумерный массив = O(n²). Рекурсия n в глубину = O(n) памяти стека.
- Компромисс между временем и памятью: Часто ты можешь потратить память, чтобы сэкономить время (проверка дубликатов со множеством), или время, чтобы сэкономить память (проверка вложенным циклом). Выбирай на основе своих ограничений.
- Обычные паттерны:
- O(1) память: один цикл, никаких новых структур
- O(n) память: новый массив или множество, или рекурсия n в глубину
- O(n²) память: двумерные массивы
- На практике: Если память ограничена (встроенные системы, мобильные), приоритет низкая память. Если скорость критична (серверы, системы реального времени), приоритет быстрое время и позволь память.
Сложность имеет два измерения. Овладение обоими позволяет писать алгоритмы, которые подходят твоему миру.