Чому push_back у векторах C ++ постійно амортизується?


23

Я вивчаю C ++ і помітив, що час роботи функції push_back для векторів є постійним "амортизованим". Документація додатково зазначає, що "Якщо відбувається перерозподіл, перерозподіл сам по собі є лінійним у всьому розмірі".

Чи не повинно це означати, що функцією push_back є , де n - довжина вектора? Зрештою, нас цікавить аналіз гіршого випадку, правда?O(n)n

Я здогадуюсь, головне, я не розумію, як прикметник "амортизований" змінює час роботи.


На машині оперативної пам'яті виділення байтів пам'яті не є операцією O ( n ) - це вважається майже постійним часом. nO(n)
usul

Відповіді:


24

Тут важливе слово - "амортизований". Амортизований аналіз - це метод аналізу, який вивчає послідовність операцій. Якщо вся послідовність працює в T ( n ) час, то кожна операція в послідовності виконується в T ( n ) / n . Ідея полягає в тому, що, хоча кілька операцій у послідовності можуть бути дорогими, вони не можуть траплятися досить часто, щоб зважити програму. Важливо зазначити, що це відрізняється від середнього аналізу випадків над деяким вхідним розподілом або рандомізованим аналізом. Амортизований аналіз встановив найгірший випадокnT(n)T(n)/nпризначається для виконання алгоритму незалежно від входів. Найчастіше використовується для аналізу структур даних, які мають стійкий стан у всій програмі.

Один з найпоширеніших прикладів, що подається, - це аналіз стека з операціями з множиною спливаючих елементів, які з'являються елементів. Наївний аналіз мультипопу би сказав, що в гіршому випадку мультипоп повинен зайняти O ( n ) час, оскільки, можливо, доведеться спливати всі елементи стеку. Однак якщо ви подивитеся на послідовність операцій, ви помітите, що кількість спливів не може перевищувати кількість натискань. Таким чином, в будь-якій послідовності n операцій кількість спливаючих вікон не може перевищувати O ( n ) , і тому мультипоп працює в O ( 1 ), амортизований час, хоча іноді один виклик може зайняти більше часу.kO(n)nO(n)O(1)

Тепер, як це стосується векторів С ++? Вектори реалізовані з масивами, тому для збільшення розміру вектора потрібно перерозподілити пам'ять та скопіювати весь масив. Очевидно, ми б не хотіли цього робити дуже часто. Отже, якщо ви виконаєте операцію push_back і вектору потрібно виділити більше місця, це збільшить розмір на коефіцієнт . Тепер для цього потрібно більше пам’яті, яку ви можете використовувати не в повному обсязі, але наступні декілька операцій push_back працюють у постійному часі.m

Тепер, якщо ми зробимо амортизований аналіз операції push_back (який я знайшов тут ), ми виявимо, що він працює в постійному амортизованому часі. Припустимо, у вас елементів, а ваш коефіцієнт множення m . Тоді кількість переїздів приблизно log m ( n ) . Я й Перерозподіл коштуватиме пропорційно м I , про розмір поточного масиву. Таким чином, загальний час для n відштовхування становить log m ( n ) i = 1 m in mnmlogm(n)imin , оскільки це геометричний ряд. Розділимо це наnоперацій і отримаємо, що кожна операція займаєmi=1logm(n)minmm1n , константа. Нарешті, ви повинні бути обережними щодо вибору коефіцієнтаm. Якщо вона занадто близька до1,то ця константа стає занадто великою для практичних застосувань, але якщоmзанадто велика, скажімо, 2, то ви починаєте витрачати багато пам'яті. Ідеальний темп зростання залежить від застосування, але я думаю, що деякі реалізації використовують1,5.mm1m1m1.5


12

Хоча @Marc дав (що, на мою думку, є) чудовий аналіз, деякі люди можуть вважати за краще розглянути речі з дещо іншого кута.

Потрібно розглянути дещо інший спосіб перерозподілу. Замість того, щоб миттєво копіювати всі елементи зі старого сховища до нового сховища, розгляньте можливість копіювання лише одного елемента за один раз - тобто кожного разу, коли ви робите push_back, він додає новий елемент у новий простір та копіює рівно один існуючий елемент від старого простору до нового простору. Припускаючи коефіцієнт зростання 2, досить очевидно, що коли новий простір заповнений, ми б закінчили копіювати всі елементи зі старого простору в новий простір, і кожен push_back був точно постійним часом. У цей момент ми відкинемо старий простір, виділимо новий блок пам'яті, який був удвічі більшим посиленням, і повторимо процес.

Досить чітко, ми можемо продовжувати це нескінченно (або поки у вас є пам'ять у будь-якому разі), і кожен push_back передбачає додавання одного нового елемента та копіювання одного старого елемента.

Типова реалізація все ще має точно стільки ж копій, але замість того, щоб робити копії по черзі, вона копіює всі існуючі елементи одразу. З одного боку, ви праві: це означає, що якщо ви подивитесь на окремі виклики push_back, деякі з них будуть значно повільнішими, ніж інші. Якщо ми подивимось на середньострокове середнє значення, то кількість копіювання, виконаної за виклик push_back, залишається постійною, незалежно від розміру вектора.

Хоча це не має значення для складності обчислень, я думаю, що варто зазначити, чому вигідно робити такі речі, як вони, замість того, щоб копіювати один елемент на push_back, тому час на push_back залишається постійним. Існує щонайменше три причини.

Перший - просто наявність пам'яті. Стару пам'ять можна звільнити для іншого використання лише після закінчення копіювання. Якщо ви копіювали лише один елемент за один раз, старий блок пам'яті залишатиметься значно довше. Насправді у вас був би весь старий блок і один новий блок, по суті, весь час виділений. Якщо ви визначилися з коефіцієнтом росту, меншим за два (який зазвичай ви хочете), вам буде потрібно весь час виділяти більше пам'яті.

По-друге, якби ви скопіювали лише один старий елемент за один раз, індексування в масив було б трохи складніше - кожна операція індексації повинна була б розібратися, чи перебуває елемент у вказаного індексу в старому блоці пам'яті чи новий. Це не дуже страшно будь-яким способом, але для елементарної операції, такої як індексація в масив, практично будь-яке уповільнення може бути суттєвим.

По-третє, скопіювавши всі одразу, ви скористаєтеся значно кращою перевагою кешування. Скопіювавши все одразу, ви можете очікувати, що і джерело, і цільове місце у більшості випадків будуть у кеші, тому вартість пропуску кешу амортизується на кількість елементів, які вмістяться в рядок кешу. Якщо ви копіюєте один елемент одночасно, ви можете легко пропустити кеш-пам'ять для кожного елемента, який ви копіюєте. Це змінює лише постійний коефіцієнт, а не його складність, але він все ще може бути досить значним - для типової машини можна легко очікувати коефіцієнта від 10 до 20.

Напевно, варто також на мить розглянути інший напрямок: якщо ви проектували систему з вимогами в режимі реального часу, цілком може бути сенсом копіювати лише один елемент одночасно, а не всі одразу. Хоча загальна швидкість може (або не може бути) нижчою, у вас все ще буде чітка верхня межа часу, необхідного для одного виконання push_back - припускаючи, що у вас є розподільник у реальному часі (хоча, звичайно, багато в реальному часі системи просто забороняють динамічне розподіл пам’яті взагалі, принаймні, у частині, що потребує реального часу).


2
+1 Це прекрасне пояснення у стилі Фейнмана .
Відновіть Моніку
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.