Від Плоских Масивів до Дерев Кусок
Простий рядок, чи то в низькорівневій мові як масив символів, чи в високорівневій мові як незмінна послідовність, зберігається як один безперервний блок пам'яті. Читання символу за заданим положенням миттєве, оскільки комп’ютер може напряму перестрибнути до цього адреси пам’яті. Однак редагування є дорогим: вставка або видалення символу посередині означає, що кожен символ після нього повинен бути зміщений, операція, яка потребує часу пропорційного довжині рядка. Для короткого рядка це непомітно. Для документа з мільйонами символів це стає серйозною вузькою плямою, особливо коли редагування відбувається повторно під час друку людини. Rope використовує зовсім інший підхід. Він розбиває текст на багато невеликих частин, часто лише кілька десятків або кількох сотень символів кожна, і зберігає кожну частину в листовому вузлі бінарного дерева. Листки, читаються зліва направо, відтворюють повний документ у порядку. Внутрішні вузли дерева не зберігають жодних символів; їхня єдина мета – описувати, як організовано дерево, переважно записуючи вагу свого лівого дитини, тобто загальну кількість символів, знайдених у всіх листках під цим лівим піддеревом. Це переорганізація повністю змінює профіль витрат. Оскільки документ розподілений між багатьма незалежними частинами, з’єднаними деревом, редагування поблизу початку тексту більше не вимагає впливу на частини, розташовані біля кінця. Потрібно лише пройтися від ураженого листка до кореня, і в розумно збалансованому дереві цей шлях пропорційний логарифму кількості частин, а не кількості символів. Перевагою є те, що пошук будь-якого окремого символу тепер займає короткий похід вниз по дереву замість одноразового пошуку в пам’яті, але цей похід дешевий, і виграш у швидкості редагування величезний для великих документів. Це основна ідея, яка лежить в основі кожної операції, яку підтримує Rope.
Індексація: Ходіння вниз із кумулятивними вагами
Незважаючи на те, що мотузка розкидає свій текст по багатьох листках, вона все одно повинна швидко відповісти на дуже звичайне питання: який символ знаходиться за позицією n у документі, або, відповідно, який листок і який зсув всередині цього листка відповідає заданому індексу. Тут вага, що зберігається в кожному внутрішньому вузлі, приносить свої плоди. Пошук починається від кореня з заданим індексом. У кожному внутрішньому вузлі алгоритм порівнює цільовий індекс із вагою цього вузла – кількістю символів у всьому лівому піддереві. Якщо індекс менший за вагу, то бажаний символ має знаходитися десь у лівому піддереві, тому пошук переміщується вліво і індекс не змінюється, оскільки ліве піддерево починає нумерування з позиції 0. Якщо індекс більший або дорівнює вазі, то бажаний символ знаходиться в правому піддереві, тому пошук переміщується вправо, але спочатку віднімається вага лівого піддерева від індексу, оскільки власне нумерування правого піддерева починається з нуля і символи, пораховані в лівій стороні, потрібно пропустити концептуально. Цей процес повторюється вузол за вузлом, доки пошук не досягне листка. У цьому випадку залишкове значення індексу просто є відступом у короткому фрагменті символів цього листка, і символ читається безпосередньо. Оскільки кожен крок переміщується на один рівень вниз дерева, висота якого пропорційна логарифму кількості листків, весь пошук займає приблизно логарифмічний час замість постійного часу плоского масиву, але він залишається швидким навіть для величезних документів і ніколи не вимагає сканування нерелевантних частин тексту. Той самий внизхідний шлях, відстежуючи кумулятивні ваги, повторюється як основний будівельний блок для розрізання мотузки за заданою позицією.
Розділяти та об'єднувати: Дві операції, які роблять усе
Майже будь-яка дія редагування, яку підтримує мотузка, може бути побудована з двох основних операцій: розділення однієї мотузки на дві частини за заданим індексом і об'єднання двох мотузок в одну. Розуміння цих двох операцій є ключем до розуміння всієї структури. Розділення використовує той самий механізм, що й пошук за вагою. Коли алгоритм спускається до точки розриву, він відокремлює частини дерева, які повністю знаходяться зліва від цієї точки, і окремо збирає частини, які повністю знаходяться справа, іноді розділяючи шматок тексту одного листка на дві коротші частини, якщо точка розриву потрапляє посередині цього тексту. В результаті утворюються дві незалежні, добре сформовані мотузки: одна представляє все, що було до точки розриву, а інша - все, що було після неї. Оскільки потрібно перевіряти та будувати лише вузли вздовж єдиного шляху від кореня до точки розриву, ця операція займає час пропорційний висоті дерева, приблизно логарифмічний у кількості шматочків. Об'єднання йде в протилежному напрямку: за двох мотузок він створює одну нову мотузку, що представляє об'єднаний текст. Найпростіший підхід полягає у створенні нового кореневого вузла, лівим діти якого є перша мотузка, а правий - друга, з встановленим вагою нового кореня рівним загальній кількості символів першої мотузки. Це дуже дешева операція, яка займає постійний час, оскільки вона не потребує торкатися або копіювати будь-який текст всередині мотузок; вона лише виділяє один новий вузол. За допомогою цих двох інструментів вставка на позицію n стає: розділіть мотузку на n, об'єднайте ліву частину з новою маленькою мотузкою, побудованою з вставленого тексту, а потім об'єднайте цей результат із правою частиною. Видалення діапазону стає двома поділами, за якими слідує об'єднання двох уцілівших зовнішніх частин. Вилучення підрядка стає двома поділами, які ізолюють потрібний середній шматочок. Жодна операція ніколи не потребує копіювання або переміщення великої частини оригінального документа.
Чому це перевершує копіювання всього масиву
Це допомагає сформувати інтуїцію щодо твердження, що мотузки швидші для редагування. Уявіть документ з одиничного мільйона символів, збережений у вигляді плоского масиву, і уявіть, що потрібно вставити один символ біля самого початку. Реалізація повинна змістити приблизно один мільйон символів на одну позицію або ж, у багатьох керованих мовах, виділити повністю новий масив з одиничного мільйона плюс один символ і скопіювати все це. У будь-якому разі вартість масштабується безпосередньо з розміром всього документа, і вона оплачується знову для кожної наступної вставки, незалежно від того, де відбуваються зміни. Тепер уявіть той самий документ як мотузку, побудовану з листків, що містять приблизно сто символів кожен, даючи близько десяти тисяч листків, розташованих у дереві висотою приблизно чотирнадцять рівнів, оскільки висота збалансованого бінарного дерева зростає з логарифмом кількості листків. Вставка символу вимагає спуститися приблизно на чотирнадцять рівнів, щоб розірвати мотузку, виділити невеликий новий листок для вставленого тексту та піднятися назад, щоб відбудувати близько чотирнадцятеро батьківських вузлів, лише їх внутрішні номери обліку, а не вміст символів. Інші приблизно десять тисяч листків, що містять переважну більшість фактичного тексту документа, ніколи не торкаються, не копіюються і часто діляться посиланням між старою версією мотузки та новою. Це розрив збільшується зі зростанням документів. Подвоєння розміру документа приблизно подвоює вартість вставки у плоскому масиву, але лише додає один додатковий рівень до висоти збалансованої мотузки, змінюючи вже невелику логарифмічну вартість на незначний відсоток. Це пояснює, чому програмне забезпечення, яке має обробляти дуже великі або часто редаговані тексти, включаючи професійні текстові процесори, редактори джерел мов програмування та бекенди для спільного редагування, віддає перевагу структурам, подібним до мотузок, над простими безперервними рядками, коли документи перевищують скромний розмір. Той самий аргумент пояснює, чому деякі системи переходять лише на представлення мотузки після того, як рядок перетинає певний поріг, оскільки для справді малих рядків простота та керованість пам'яттю плоского масиву все ще можуть виграти в практиці.
Підтримка Балансу Дерева
Логістична продуктивність, яку обіцяє мотузка, повністю залежить від того, щоб дерево залишалося відносно збалансованим, тобто жоден шлях від кореня до листка не був набагато довшій, ніж будь-який інший. Якщо мотузку будують безтурботливо, наприклад, повторно конкатенуючи окремі символи в кінець один за одним без будь-якого балансування, дерево може деградувати до вигляду довгої ланцюга, де кожен вузол має практично лише одного значущого дитиння. У такому деградованому стані операції, які повинні тривати логістичний час, починають повертатися до лінійного часу, який уникли мотузки, оскільки переміщатися по довгому тонкому ланцюгу відвідується майже стільки ж вузлів, скільки й символів. Щоб запобігти цьому, реалізації мотузок використовують стратегії балансування. Деякі балансують з нетерпінням, перевіряючи після кожного розщеплення або конкатенації, чи відхилився від балансу вигляд отриманого дерева набагато занадто сильно, і якщо так, то структурують його, часто використовуючи техніки, подібні до тих, що використовуються в самовирівнюючих бінарних пошукових деревах, такі як обертання, які переміщують піддерева, зберігаючи лівосторонній порядок листків. Інші балансують неохоче, дозволяючи деякому дисбалансу накопичуватися протягом спалаху редагувань і потім виконують один етап очищення, іноді збираючи всі листки в порядку та будуючи свіже, ідеально збалансоване дерево з них, що само по собі є швидкою операцією, оскільки потрібно обробити листки лише один раз. Корисною ментальною відправною точкою є числа Фібоначчі: мотузку вважають незабалансованою, якщо її загальна кількість символів менша за число Фібоначчі, що відповідає її висоті, порогове значення, яке гарантує мінімальну щільність дерева та зберігає гарантовану логістичну висоту. Добре спроектовані бібліотеки мотузок також зливаються сусідні маленькі листки назад разом і розділяють великі листки на частини, підтримуючи розміри чашок у розумному діапазоні, щоб дерево не стало занадто багатьма вузлами, що уповільнює перебіг, або надто мало, що ризикує великими копіями на рівні листків. Підтримка балансу дозволяє мотузці підтримувати швидку продуктивність протягом тривалого сеансу редагування, а не лише в перших операціях.
Часті запитання
Чому не використовувати просто рядок або масив символів для текстового редактора?
Простий масив зберігає кожен символ послідовно в пам'яті, що робить читання будь-якого символу миттєвим, але вставляти або видаляти символ стає дорогим, оскільки кожен символ після зміни положення має зміщуватися. Для коротких рядків ця вартість непомітна, але для великих документів, які часто редагуються, цей зсув накопичується і масштабується з розміром всього документа, тому редактори, що працюють із великими файлами, часто перемикаються на мотузку.
Що саме означає вага, що зберігається у внутрішньому вузлі?
Вага у внутрішньому вузлі — це загальна кількість символів, що містяться в усіх листіях лівого піддерева цього вузла. Вона не враховує правий піддеревний вузол. Це одне число дозволяє алгоритму вирішувати на кожному кроці під час обходу дерева, чи знаходиться позиція, яку шукають, зліва чи справа, без необхідності перевіряти будь-який фактичний текст.
Як мотузка знаходить символ за певною позицією?
Починаючи з кореня, алгоритм порівнює цільовий індекс із вагою поточного вузла. Якщо індекс менший, він переміщується в ліве піддерево без змін. Якщо індекс дорівнює або більший, він віднімає вагу з індексу та переміщується в правий піддеревний вузол. Повторюючи це на кожному рівні, досягається лист, де залишається індекс просто як зміщення в короткому фрагменті символів цього листа.
Як вставляти текст у мотузку насправді працює?
Вставка побудована з двох простіших операцій. По-перше, мотузка ділиться на дві мотузки в точці вставки. Потім невелика нова мотузка, що містить вставлений текст, конкатенується в кінцівку лівої частини, і нарешті ця об'єднана мотузка конкатенується з правою частиною. Жодна з цих кроків не вимагає копіювання більшості оригінального документа, лише побудову кількох вузлів вздовж відповідних шляхів дерева.
Чому дереву потрібно перебалансувати?
Перевага мотузки в швидкості залежить від того, що її висота залишається близькою до логарифма кількості фрагментів, які вона містить. Якщо розбиття та конкатенації відбуваються у візерунку, який створює довге, тонке ланцюгоподібне дерево, висота може зрости ближче до загальної кількості фрагментів, і операції сповільнюються приблизно до того ж лінійного часу, щоб уникнути. Періодичне перебалансування, яке іноді запускається шляхом порівняння кількості символів із пороговими значеннями на основі чисел Фібоначчі, підтримує дерево пухким та операції швидкими.
Спробуйте наживо
Усе, що вище, працює прямо у вашому браузері — відкрийте Rope Data Structure: Editing Giant Strings Without Copying Them і змінюйте параметри під час роботи. Нічого не встановлюється, нічого не завантажується на сервер, уся модель живе в одній вкладці.
▶ Відкрити симуляцію Rope Data Structure: Editing Giant Strings Without Copying Them