Амортизирана сложност
Накратко: Амортизираната сложност е средната цена на операция в дълга поредица: добавянето в динамичен масив понякога копира всичко, но щом масивът расте с множител (например ×2), е средно O(1).
Проблем
Вносът, който копира всичко
Магазинът внася поръчките от стара система по една. Някой е написал вноса така, че при всяка поръчка масивът става с едно място по-голям:
function importOrders(n) { let orders = []; let copies = 0; for (let i = 0; i < n; i++) { copies += orders.length; orders = [...orders, 100000 + i]; } return copies;}Изглежда невинно: един ред на поръчка. Но всеки нов масив е копие на стария плюс едно място. Програмата брои копиранията:
for (const n of [1000, 10000]) { console.log(`${n} поръчки: ${importOrders(n)} копирания`);}1000 поръчки: 499500 копирания10000 поръчки: 49995000 копиранияПри 10 пъти повече поръчки копиранията са 100 пъти повече. При 100 000 поръчки са 4 999 950 000, и вносът, който на теста трае миг, сега върви с минути. А push на същите 100 000 поръчки свършва веднага, макар че и той понякога копира целия масив. Как?
Идеята
Списъкът не расте с едно място
Отвътре списъкът е масив с повече места, отколкото елементи. Когато се напълни, прави нов, по-голям масив и копира всичко в него:
const cart = ["Хляб", "Мляко", "Кафе"];cart.push("Масло");cart.splice(cart.indexOf("Мляко"), 1);console.log(cart);console.log(`Продукти: ${cart.length}`);Стъпка по стъпка, с думи
- cart сочи масив. Отвътре той има length и отделен масив elements с 3 места.
- Продуктите от кода заемат местата един по един. Масивът е пълен: 3 от 3.
- Няма място. push прави масив с 22 места и копира елементите в него.
- elements вече сочи новия масив, а старият не трябва на никого. „Масло“ влиза на свободното място.
- splice маха „Мляко“, а всичко след него се мести с едно място наляво. Дупка не остава.
- Отвън се вижда само масив с три продукта. Колко места има отвътре, решава двигателят V8: езикът не го обещава.
Тайната е колко по-голям е новият масив. Ако е с едно място повече, всяко добавяне след пълния масив копира всичко, както вносът горе. Ако е два пъти по-голям, следващото копиране идва чак след още толкова добавяния.
Виж го
Свой масив, който се удвоява
Ето същият механизъм, написан на ръка, с брояч на копиранията. Масивът започва с едно място:
class OrderList { constructor() { this.items = new Array(1); this.size = 0; this.copies = 0; }
add(order) { if (this.size === this.items.length) { const bigger = new Array(this.items.length * 2); for (let i = 0; i < this.size; i++) { bigger[i] = this.items[i]; this.copies++; } this.items = bigger; } this.items[this.size] = order; this.size++; }}for (const n of [9, 1000]) { const orders = new OrderList(); for (let i = 0; i < n; i++) { orders.add(100000 + i); } console.log(`${n} поръчки: ${orders.copies} копирания, 2n = ${2 * n}`);}9 поръчки: 15 копирания, 2n = 181000 поръчки: 1023 копирания, 2n = 2000class OrderList { constructor() { this.items = new Array(1); this.size = 0; this.copies = 0; }
add(order) { if (this.size === this.items.length) { const bigger = new Array(this.items.length * 2); for (let i = 0; i < this.size; i++) { bigger[i] = this.items[i]; this.copies++; } this.items = bigger; } this.items[this.size] = order; this.size++; }}
const orders = new OrderList();for (let i = 0; i < 9; i++) { orders.add(100000 + i);}Стъпка по стъпка, с думи
- Масивът започва с едно място, а size е 0. Броим само копиранията на стари елементи.
- Първата поръчка: има място, add я записва. Копирания: 0.
- Втората: масивът е пълен. Нов масив с 2 места, а старата поръчка се копира в него: 1 копиране.
- Третата: пак е пълно, 4 места и 2 копирания. Четвъртата влиза без копиране.
- Петата копира 4, деветата 8. Копиранията растат, но между тях идват все повече добавяния без копиране.
- 9 добавяния, 1 + 2 + 4 + 8 = 15 копирания, под 2n = 18. Разпределени по всички добавяния, са под 2 на добавяне: O(1) амортизирано.
Добавяне, което намери масива пълен, копира всичко: 1, 2, 4, 8, 16 … Но след него идват толкова добавяния без копиране, колкото е копирало. Сборът 1 + 2 + 4 + … + 2ᵏ е винаги под 2 · 2ᵏ, а последното копиране 2ᵏ е под n. Значи копиранията са под 2n, колкото и да е голямо n.
Идеята
Амортизирана сложност
Едно добавяне може да струва O(n): онова, което копира всичко. Но n добавяния струват общо под 3n операции: n записа и под 2n копирания. Разделено на n, това е под 3 на добавяне: O(1).
Цената, разпределена по цяла поредица от операции, се казва амортизирана сложност. Добавянето в края на списък е O(1) амортизирано.
Това не е „средно при случаен вход“. Обещанието важи за всяка поредица добавяния, без късмет: скъпите са рядко и винаги толкова рядко.
Пробвай
Предвиди изхода
Пет поръчки в масив, който започва с едно място и се удвоява. Колко копирания?
class OrderList { constructor() { this.items = new Array(1); this.size = 0; this.copies = 0; }
add(order) { if (this.size === this.items.length) { const bigger = new Array(this.items.length * 2); for (let i = 0; i < this.size; i++) { bigger[i] = this.items[i]; this.copies++; } this.items = bigger; } this.items[this.size] = order; this.size++; }}
for (const n of [5]) { const orders = new OrderList(); for (let i = 0; i < n; i++) { orders.add(100000 + i); } console.log(`${n} поръчки: ${orders.copies} копирания`);}Виж го
С едно място повече: O(n²)
Същият клас, само че новият масив е с едно място повече:
const bigger = new Array(this.items.length + 1);for (const n of [9, 1000]) { const orders = new OrderList(); for (let i = 0; i < n; i++) { orders.add(100000 + i); } console.log(`${n} поръчки: ${orders.copies} копирания`);}9 поръчки: 36 копирания1000 поръчки: 499500 копиранияПри 1000 поръчки удвояването копира 1023 пъти, а растежът с едно място 499 500 пъти: 1 + 2 + … + 999. Пусни двата растежа и виж кривите. Броим само копиранията:
- Растеж ×2: копирания
O(n) - Растеж +1: копирания
O(n²)
| Алгоритъм | n = 10 | n = 1000 | n = 100 000 |
|---|---|---|---|
| Растеж ×2: копирания | 15 | 1023 | 131 071 |
| Растеж +1: копирания | 45 | 499 500 | ≈ 4 995 000 000 |
Удвояването расте като права: O(n) общо, O(1) на добавяне. Растежът с едно място расте като парабола: O(n²) общо, O(n) на добавяне.
Пробвай
Попълни
Колко места има новият масив, за да е добавянето O(1) амортизирано?
if (this.size === this.items.length) {
const bigger = new Array();Новият масив е два пъти по-голям от стария. Така следващото растене идва чак след още толкова добавяния, колкото са елементите сега.
Пробвай
Намери грешката
Колега е сменил удвояването, „за да не хаби памет“. На 1000 поръчки тестът минава бързо, а нощният внос на 100 000 се влачи:
for (const n of [1000, 100000]) { const orders = new OrderList(); for (let i = 0; i < n; i++) { orders.add(100000 + i); } console.log(`${n} поръчки: ${orders.copies} копирания`);}1000 поръчки: 4510 копирания100000 поръчки: 49951000 копиранияПри 100 пъти повече поръчки копиранията са 10 000 пъти повече. На кой ред е грешката?
Избери реда с грешката.
Масивът расте със 100 места наведнъж. Това отлага растенето, но не го прави рядко: на всеки 100 добавяния идва копиране на всичко досега, и сборът пак е квадратичен, около n²/200. Растежът трябва да е умножение (×2, ×1,5), а не прибавяне, колкото и голямо да е прибавеното.
Идеята
Как расте всеки език
Нито един език не удвоява точно, но всеки умножава:
-
Java
ArrayList: ×1,5; -
C#
List<T>: ×2; - Python (CPython): около ×1,125;
- JavaScript (V8 в Node и Chrome): новото е n + n/2 + 16, около ×1,5. Езикът не го обещава, решава го двигателят.
Всеки множител над 1 дава O(1) амортизирано. Колкото е по-малък, толкова повече копирания (при ×1,5 са под 3n, при ×1,125 под 9n) и толкова по-малко празни места след растене. Изборът е размяна между време и памет, както в урока „Сложност по памет“.
По-точно, за любопитните
O(1) амортизирано не значи, че всяко добавяне е бързо. Добавянето, което копира, е O(n). В повечето програми това не се усеща, но в програма, която не бива да спира (каса, игра, обработка на звук), едно добавяне на милионния елемент може да е бавното. Ако знаеш колко ще са елементите, задай местата отначало: new Array(n). Тогава списъкът не копира никога.
new Array(n) прави масив с дупки, който V8 пази по-бавно от плътен. Обикновено push е по-добрият избор, а дупки не се правят.Сметката за ×2 има и друг вид: всяко добавяне „плаща“ 2 копирания напред и ги спестява. Когато масивът с k елемента се напълни, последните k/2 добавяния са спестили k, точно колкото струва копирането. Спестеното никога не става отрицателно, затова сборът е под 2n.
Упражнение
Растеж като в Java
Реши задачата в редактора си.
Растеж с половината. Смени растежа на OrderList така, че новият масив да има старите места плюс половината (поне едно място повече). Преброй копиранията при 1000 и при 100 000 поръчки. Под 2n ли са? А под 3n?
1000 поръчки: 2137 копирания, 2n = 2000100000 поръчки: 276521 копирания, 2n = 200000Още задачи
- Махане от края. Добави
removeLast. Кога масивът да се свива, така че махането също да е O(1) амортизирано? Пробвай „наполовина, когато е пълен до половината“ и редувай добавяне и махане точно на границата. После пробвай „наполовина, когато е пълен до една четвърт“. Защо второто е O(1) амортизирано, а първото не? - Колко места хаби. След 1000 добавяния с удвояване колко от местата са празни? А с растеж ×1,5?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Времева сложност: броене на стъпки · Big-O: как расте работата · Най-добър, среден и най-лош случай · Сложност по памет
- Продължава в: Динамичен масив отвътре · Свързан списък · Стек и опашка · Хеш таблица
- Задачи по темата: Сложност