Пространственная сложность
При анализе алгоритма важно учитывать не только время его работы. Во время выполнения программе также нужна память: для переменных, новых массивов, таблиц и других промежуточных данных.
То, как растёт объём необходимой памяти при увеличении входных данных, называют пространственной сложностью. Как и временную сложность, её описывают с помощью Big O.
Посмотрим на знакомый алгоритм поиска максимума:
function findMax(numbers): max = numbers[0] for number in numbers: if number > max: max = number return maxВ худшем случае он проходит по всем n элементам:
Но во время работы алгоритм хранит только несколько дополнительных значений – например max и текущий number. Их количество не зависит от размера массива:
Поэтому один и тот же алгоритм может иметь разные оценки времени и памяти:
| Ресурс | Сложность |
|---|---|
| Время | O(n) |
| Память | O(1) |
У алгоритма уже есть входные данные. Например, findMax получает массив:
numbers = [4, 7, 2, 9, 5]Этот массив существовал ещё до запуска функции. Во время работы алгоритм дополнительно хранит только несколько значений:
maxnumberПоэтому полезно разделять:
- входные данные – то, что алгоритм получил;
- дополнительную память – то, что он создаёт или хранит во время работы.
Для findMax размер массива может расти:
а количество дополнительной памяти остаётся примерно тем же. Поэтому:
Рассмотрим простой алгоритм обмена двух элементов:
function swapFirstLast(numbers): temp = numbers[0] numbers[0] = numbers[numbers.length - 1] numbers[numbers.length - 1] = tempДля массива из пяти элементов нужна переменная temp. Для массива из миллиона элементов – всё та же одна переменная. Количество дополнительной памяти не зависит от n, поэтому это O(1).
Как и со временем, O(1) не означает «одна ячейка памяти». Например, поиск в отсортированном массиве держит три дополнительных значения:
function search(numbers, target): left = 0 right = numbers.length - 1 while left <= right: mid = (left + right) // 2 ...Массив может быть любого размера, а дополнительных значений всё равно три: left, right и mid. Их количество не растёт вместе с входом, поэтому пространственная сложность по-прежнему O(1).
Теперь рассмотрим другой алгоритм. Нужно создать новый массив, содержащий удвоенные значения:
function doubleNumbers(numbers): result = [] for number in numbers: result.push(number * 2) return resultПосмотрим, как result заполняется для входа [:
doubleNumbers для 4 элементов.Новый массив содержит примерно столько же элементов, сколько исходный. Если n = 10, в result будет около 10 элементов. Если n = 1 000 – около 1 000.
Дополнительная память растёт вместе с размером входных данных. Поэтому это O(n).
Задача: увеличить каждый элемент массива в два раза. Можно создать новый массив – тогда дополнительная память O(n). Но можно изменить исходный:
function doubleNumbers(numbers): for i = 0; i < numbers.length; i += 1: numbers[i] *= 2 return numbersТеперь дополнительный массив не создаётся: мы изменяем элементы прямо там, где они уже находятся. Дополнительная память – O(1). Такой способ работы называют in-place: алгоритм изменяет данные на месте и не создаёт дополнительную структуру сравнимого размера.
Может показаться, что вариант с O(1) памяти всегда предпочтительнее. Но у него есть цена: исходные данные изменяются. Был массив numbers = [, после in-place алгоритма в нём [ – первоначальных значений больше нет.
Иногда именно это нам и нужно. Но иногда оригинальные данные должны сохраниться – тогда создание нового массива result = [ может быть правильным решением, даже если оно использует больше памяти.
Поэтому меньшая пространственная сложность не означает автоматически «лучший алгоритм». Всё зависит от требований задачи.
Дополнительная структура не обязательно должна быть одномерным массивом. Допустим, для каждого из n элементов мы храним информацию о его связи с каждым другим элементом. При трёх элементах получится таблица 3 × 3, при десяти – 10 × 10, при тысяче – 1 000 × 1 000.
Количество ячеек растёт примерно как n * n, то есть это O(n²) памяти.
Иногда алгоритм не создаёт явных массивов или объектов, но всё равно использует дополнительную память. Посмотрим на функцию, которая вызывает саму себя:
function countdown(n): print(n) if n == 0: return countdown(n - 1)Каждый незавершённый вызов функции должен где-то храниться. Для этого программа использует стек вызовов:
countdown(2). Вызов ещё не завершён, поэтому он лежит в стеке.Вызовы функций тоже занимают память. Если глубина вложенных вызовов растёт вместе с n, память тоже может иметь сложность O(n) – даже если алгоритм не создал ни одного массива.
Здесь есть нюанс в терминологии. Иногда под пространственной сложностью считают всю память, необходимую алгоритму, включая сами входные данные – тогда массив из n элементов уже занимает O(n) памяти.
Но очень часто при сравнении алгоритмов отдельно рассматривают auxiliary space – дополнительную память, которую алгоритм создаёт сверх входа. Вернёмся к findMax:
function findMax(numbers): max = numbers[0] for number in numbers: if number > max: max = number return maxСам массив numbers занимает O(n) – это вход. А дополнительно алгоритм хранит только max и number, поэтому его дополнительная память – O(1).
В этом курсе, если не сказано иначе, говоря о памяти алгоритма, мы будем иметь в виду именно дополнительную память: так сравнивать решения удобнее.
Иногда можно ускорить алгоритм, если согласиться использовать больше памяти. Представим, что нам много раз нужно отвечать на вопрос: есть ли это значение среди уже просмотренных?
Без дополнительной структуры можно каждый раз снова искать его среди предыдущих элементов. Но можно сохранять просмотренные значения:
seen = Set()for value in numbers: if value in seen: ... seen.add(value)Теперь мы используем дополнительную структуру seen. В худшем случае в ней может накопиться примерно n значений – это O(n) памяти. Зато такая структура позволяет выполнять нужные проверки значительно быстрее.
Эта идея называется компромиссом между временем и памятью, или time-space tradeoff. Иногда мы экономим память ценой дополнительных вычислений, иногда наоборот – используем больше памяти, чтобы сократить время работы.
На первых этапах можно использовать несколько простых ориентиров. Несколько переменных с простыми значениями – числами, строками, логическими – обычно O(1):
left = 0right = numbers.length - 1result = 0Новый массив размером примерно n – обычно O(n):
result = []for value in numbers: result.push(value)Таблица размером n × n – обычно O(n²). А рекурсия глубиной около n может потребовать O(n) памяти в стеке вызовов:
function process(n): if n == 0: return process(n - 1)Как и со временем, в реальных алгоритмах ситуации бывают сложнее. Но пока важно научиться замечать, какие дополнительные данные растут вместе с размером входа.
Теперь алгоритм можно описать не одной, а сразу двумя оценками:
| Алгоритм | Время | Память |
|---|---|---|
| Поиск максимума | O(n) | O(1) |
| Копия массива | O(n) | O(n) |
| Таблица всех пар | O(n²) | O(n²) |
function copy(numbers): result = [] for number in numbers: result.push(number) return resultЭти значения не обязаны совпадать: алгоритм вполне может иметь время O(n²) и память O(1). Временная и пространственная сложность анализируются отдельно.
Универсального ответа нет. Для одной программы критична скорость, для другой доступно очень мало памяти. В мобильном устройстве, встроенной системе или при работе с огромными объёмами данных память может быть серьёзным ограничением. В другом случае несколько дополнительных мегабайт могут быть совершенно неважны, если это заметно ускоряет работу.
Поэтому хороший алгоритм – это не обязательно алгоритм с минимальным O во всех характеристиках. Важно, чтобы его свойства соответствовали требованиям конкретной задачи.
// теперь вы знаете
- Пространственная сложность описывает, как растёт дополнительная память при увеличении
n; память самих входных данных обычно не считают (auxiliary space) O(1)– несколько переменных;O(n)– структура размером со вход (копия,result, множествоseen);O(n²)– таблицаn × n- In-place меняет данные на месте: память
O(1), но исходные значения теряются - Незавершённые вызовы функций лежат в стеке: глубина рекурсии около
n– этоO(n)памяти - Время и память оцениваются отдельно: у алгоритма может быть время
O(n²)и памятьO(1) - Дополнительная память может сокращать работу – это компромисс между временем и памятью
Алгоритм проходит по массиву и хранит только одну дополнительную переменную. Какова его дополнительная пространственная сложность?
function findMax(numbers):max = numbers[0]for number in numbers:if number > max:max = numberreturn max
Алгоритм создаёт новый массив и добавляет в него по одному значению для каждого элемента входа. Какова дополнительная пространственная сложность?
function copy(numbers):result = []for number in numbers:result.push(number)return result
Какой из двух вариантов использует меньше дополнительной памяти?
# вариант Afunction doubleNumbers(numbers):result = []for number in numbers:result.push(number * 2)return result# вариант Bfunction doubleNumbers(numbers):for i = 0; i < numbers.length; i += 1:numbers[i] *= 2return numbers
Дополнительная пространственная сложность алгоритма – O(1). Означает ли это, что входные данные тоже обязательно занимают O(1) памяти?
У алгоритма время O(n) и память O(n). Что из этого следует?
Выберите все правильные утверждения.
Выбери все верные варианты
Зачем алгоритм иногда намеренно использует дополнительную структуру размером O(n)?