БІТ: Яка інтуїція стоїть за двійковим індексованим деревом і як про нього думали?


99

Двійкове індексоване дерево має майже меншу або відносно відсутність літератури порівняно з іншими структурами даних. Єдине місце, де його викладають - це навчальний посібник із кращих кодерів . Хоча підручник є повним у всіх поясненнях, я не можу зрозуміти інтуїцію за таким деревом? Як це було винайдено? Що є фактичним доказом його правильності?


4
Стаття у Вікіпедії стверджує, що це називаються деревами Фенвік .
Девід Харкнесс

2
@ DavidHarkness - Пітер Фенвік винайшов структуру даних, тому їх іноді називають деревами Fenwick. У своєму оригінальному документі (знайденому на сайті citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.14.8917 ) він назвав їх як двійкові індексовані дерева. Два терміни часто використовуються як взаємозамінні.
templatetypedef

1
Наступна відповідь передає дуже приємну "візуальну" інтуїцію бінарних індексованих дерев cs.stackexchange.com/questions/42811/… .
Рабіх Кодейх

1
Я знаю, як ви почуваєтесь, коли я вперше прочитав статтю про кодери, це просто здалося магією.
Rockstar5645

Відповіді:


168

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

Припустимо, наприклад, що ви хочете зберігати кумулятивні частоти в цілому 7 різних елементів. Ви можете почати, виписавши сім відер, в які будуть розподілені цифри:

[   ] [   ] [   ] [   ] [   ] [   ] [   ]
  1     2     3     4     5     6     7

Тепер припустимо, що кумулятивні частоти виглядають приблизно так:

[ 5 ] [ 6 ] [14 ] [25 ] [77 ] [105] [105]
  1     2     3     4     5     6     7

Використовуючи цю версію масиву, ви можете збільшити кумулятивну частоту будь-якого елемента, збільшивши значення числа, збереженого на цьому місці, а потім збільшивши частоти всього, що приходить згодом. Наприклад, щоб збільшити кумулятивну частоту 3 на 7, ми могли б додати 7 до кожного елемента в масиві в позиції 3 або після нього, як показано тут:

[ 5 ] [ 6 ] [21 ] [32 ] [84 ] [112] [112]
  1     2     3     4     5     6     7

Проблема в цьому полягає в тому, що для цього потрібно O (n) час, який досить повільний, якщо n великий.

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

Before:
[ 5 ] [ 6 ] [21 ] [32 ] [84 ] [112] [112]
  1     2     3     4     5     6     7

After:
[ +5] [ +1] [+15] [+11] [+52] [+28] [ +0]
  1     2     3     4     5     6     7

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

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

Один із способів зробити це - змінити представлення з масиву відро до бінарного дерева вузлів. Кожен вузол буде анотований із значенням, яке представляє сукупну суму всіх вузлів зліва від даного вузла. Наприклад, припустимо, що ми побудуємо з цих вузлів таке бінарне дерево:

             4
          /     \
         2       6
        / \     / \
       1   3   5   7

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

Before:
[ +5] [ +1] [+15] [+11] [+52] [+28] [ +0]
  1     2     3     4     5     6     7

After:
                 4
               [+32]
              /     \
           2           6
         [ +6]       [+80]
         /   \       /   \
        1     3     5     7
      [ +5] [+15] [+52] [ +0]

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

Наприклад, припустимо, що ми хочемо шукати суму за 3. Для цього робимо наступне:

  • Почніть з кореня (4). Лічильник 0.
  • Перехід ліворуч до вузла (2). Лічильник 0.
  • Переходьте праворуч до вузла (3). Лічильник 0 + 6 = 6.
  • Знайдіть вузол (3). Лічильник 6 + 15 = 21.

Ви можете собі уявити, що цей процес також працює в зворотному порядку: починаючи з заданого вузла, ініціалізуйте лічильник до значення цього вузла, а потім підняти дерево до кореня. Кожен раз, коли ви переходите на правильне дочірнє посилання вгору, додайте значення у вузлі, до якого ви приїжджаєте. Наприклад, щоб знайти частоту для 3, ми могли б зробити наступне:

  • Почніть з вузла (3). Лічильник 15.
  • Перехід вгору до вузла (2). Лічильник 15 + 6 = 21.
  • Перехід вгору до вузла (4). Лічильник 21.

Щоб збільшити частоту вузла (і, мається на увазі, частоту всіх вузлів, які приходять після нього), нам потрібно оновити набір дерев, які включають цей вузол у його лівому піддіреві. Для цього робимо наступне: збільшуємо частоту для цього вузла, а потім починаємо підходити до кореня дерева. Кожен раз, коли ви переходите за посиланням, яке сприймає вас як дочірню дитину, збільшуйте частоту вузла, з яким ви стикаєтесь, додаючи поточне значення.

Наприклад, щоб збільшити частоту вузла 1 на п'ять, ми зробимо наступне:

                 4
               [+32]
              /     \
           2           6
         [ +6]       [+80]
         /   \       /   \
      > 1     3     5     7
      [ +5] [+15] [+52] [ +0]

Починаючи з вузла 1, збільшуйте його частоту на 5, щоб отримати

                 4
               [+32]
              /     \
           2           6
         [ +6]       [+80]
         /   \       /   \
      > 1     3     5     7
      [+10] [+15] [+52] [ +0]

Тепер перейдіть до його батька:

                 4
               [+32]
              /     \
         > 2           6
         [ +6]       [+80]
         /   \       /   \
        1     3     5     7
      [+10] [+15] [+52] [ +0]

Ми йшли лівою дочірньою лінією вгору, тому збільшуємо також частоту цього вузла:

                 4
               [+32]
              /     \
         > 2           6
         [+11]       [+80]
         /   \       /   \
        1     3     5     7
      [+10] [+15] [+52] [ +0]

Тепер ми переходимо до його батьків:

               > 4
               [+32]
              /     \
           2           6
         [+11]       [+80]
         /   \       /   \
        1     3     5     7
      [+10] [+15] [+52] [ +0]

Це було ліве дочірнє посилання, тому ми також збільшуємо цей вузол:

                 4
               [+37]
              /     \
           2           6
         [+11]       [+80]
         /   \       /   \
        1     3     5     7
      [+10] [+15] [+52] [ +0]

І ось ми закінчили!

Останнім кроком є ​​перетворення від цього до бінарного індексованого дерева, і саме тут ми отримуємо цікаві речі з двійковими числами. Давайте перепишемо кожен індекс відра у цьому дереві у двійковій формі:

                100
               [+37]
              /     \
          010         110
         [+11]       [+80]
         /   \       /   \
       001   011   101   111
      [+10] [+15] [+52] [ +0]

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

              (empty)
               [+37]
              /     \
           0           1
         [+11]       [+80]
         /   \       /   \
        00   01     10   11
      [+10] [+15] [+52] [ +0]

Ось справді дуже цікаве спостереження: якщо ви вважаєте, що 0 означає «ліворуч», а 1 - «праворуч», решта бітів на кожному номері чітко визначають, як починати з кореня, а потім переходити до цього числа. Наприклад, вузол 5 має двійковий візерунок 101. Останній 1 - це заключний біт, тому ми відкидаємо його, щоб отримати 10. Дійсно, якщо ви починаєте в корені, ідіть праворуч (1), потім переходите вліво (0), ви закінчуєте вгору у вузлі 5!

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

Основна хитрість полягає в наступній властивості цього ідеального бінарного дерева:

З огляду на вузол n, наступний вузол на шляху доступу назад до кореня, в якому ми йдемо праворуч, задається, взявши двійкове представлення n та видаливши останній 1.

Наприклад, погляньте на шлях доступу до вузла 7, який становить 111. Вузли на шляху доступу до кореня, який ми беремо, що включають наступний правий вказівник вгору, це

  • Вузол 7: 111
  • Вузол 6: 110
  • Вузол 4: 100

Все це - правильні посилання. Якщо ми візьмемо шлях доступу до вузла 3, який є 011, і подивимось на вузли, куди ми йдемо правильно, ми отримаємо

  • Вузол 3: 011
  • Вузол 2: 010
  • (Вузол 4: 100, за яким слід ліве посилання)

Це означає, що ми можемо дуже, дуже ефективно обчислити сукупну суму до вузла наступним чином:

  • Випишіть вузол n у двійковій формі.
  • Встановіть лічильник на 0.
  • Повторіть наступне, коли n ≠ 0:
    • Додайте значення у вузлі n.
    • Очистіть крайній правий біт від n.

Аналогічно давайте подумаємо про те, як би ми зробили крок оновлення. Для цього ми хотіли б слідувати шлях доступу до кореня, оновлюючи всі вузли, де ми йшли лівою ланкою вгору. Ми можемо це зробити, по суті, виконавши вищевказаний алгоритм, але переключивши всі 1 на 0 і 0 на 1.

Останнім кроком у двійковому індексованому дереві є зауваження, що через цю побітну хитрість нам навіть більше не потрібно зберігати дерево. Ми можемо просто зберегти всі вузли в масиві довжиною n, а потім використовувати побітові методи перекручування для навігації по дереву неявно. Насправді це саме те, що робить побитове індексоване дерево - воно зберігає вузли в масиві, а потім використовує ці побізні трюки, щоб ефективно імітувати ходьбу вгору по цьому дереву.

Сподіваюся, це допомагає!



Ви втратили мене у другому абзаці. Що ви маєте на увазі кумулятивні частоти 7 різних елементів?
Джейсон Джейм

20
Це, безумовно, найкраще пояснення, яке я прочитав до цього часу, серед усіх джерел, які я знайшов в Інтернеті. Молодці!
Anmol Singh Jaggi

2
Як Фенвік став таким розумним?
Rockstar5645

1
Це дуже велике пояснення, але страждає від тієї ж проблеми, що і будь-яке інше пояснення, а також власний папір Фенвіка, не дає доказів!
DarthPaghius

3

Я думаю, що оригінальний папір Fenwick набагато чіткіше. Відповідь, подана вище @templatetypedef, вимагає «дуже крутих спостережень» щодо індексації ідеального бінарного дерева, які для мене заплутані і магічні.

Фенвік просто сказав, що діапазон відповідальності кожного вузла в дереві допитів буде відповідати його останньому встановленому біту:

Обов'язки вузлів дерев Фенвік

Наприклад, коли останній встановлений біт 6== 00110є "2-бітним", він буде відповідати за діапазон у 2 вузли. Для 12== 01100це "4-бітний", тому він буде відповідати за діапазон у 4 вузли.

Тож при запиті F(12)== F(01100), ми знімаємо біти по одному, отримуючи F(9:12) + F(1:8). Це майже не суворий доказ, але я думаю, що це очевидніше, коли ставити так просто на вісь чисел, а не на ідеальне двійкове дерево, які обов'язки кожного вузла, і чому вартість запиту дорівнює кількості встановити біти.

Якщо це все ще незрозуміло, папір дуже рекомендується.

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