Перейти до основного вмісту

Розріджена таблиця

Розріджена таблиця — це структура даних, яка дозволяє відповідати на запити на відрізках. Вона може відповідати на більшість запитів на відрізках за O(logn)O(\log n), але її справжня сила — у відповідях на запити мінімуму на відрізку (або еквівалентні запити максимуму на відрізку). Для таких запитів вона обчислює відповідь за O(1)O(1).

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

Коли підходить цей алгоритм?
  • Чи масив незмінний між запитами (немає оновлень елементів)? (якщо потрібні оновлення → дерево відрізків або дерево Фенвіка)
  • Чи це ідемпотентна операція (на кшталт min\min/max\max/gcd\gcd), для якої потрібна відповідь за O(1)O(1) при перекритті відрізків?
  • Якщо операція не ідемпотентна (наприклад, сума), чи влаштовує відповідь за O(logn)O(\log n), або ж потрібна O(1)O(1) для асоціативної операції? (для O(1)O(1)Sqrt-дерево)

Інтуїція

Будь-яке невід'ємне число можна однозначно подати як суму спадних степенів двійки. Це просто варіант двійкового представлення числа. Наприклад, 13=(1101)2=8+4+113 = (1101)_2 = 8 + 4 + 1. Для числа xx буде щонайбільше log2x\lceil \log_2 x \rceil доданків.

За тими ж міркуваннями будь-який інтервал можна однозначно подати як об'єднання інтервалів, довжини яких є спадними степенями двійки. Наприклад, [2,14]=[2,9][10,13][14,14][2, 14] = [2, 9] \cup [10, 13] \cup [14, 14], де весь інтервал має довжину 13, а окремі інтервали мають довжини 8, 4 і 1 відповідно. І тут також об'єднання складається щонайбільше з log2(довжина інтервалу)\lceil \log_2(\text{довжина інтервалу}) \rceil інтервалів.

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

Попередні обчислення

Для зберігання відповідей на попередньо обчислені запити ми використаємо двовимірний масив. st[i][j]\text{st}[i][j] зберігатиме відповідь для відрізка [j,j+2i1][j, j + 2^i - 1] довжини 2i2^i. Розмір двовимірного масиву буде (K+1)×MAXN(K + 1) \times \text{MAXN}, де MAXN\text{MAXN} — найбільша можлива довжина масиву. K\text{K} має задовольняти Klog2MAXN\text{K} \ge \lfloor \log_2 \text{MAXN} \rfloor, бо 2log2MAXN2^{\lfloor \log_2 \text{MAXN} \rfloor} — найбільший відрізок-степінь двійки, який нам потрібно підтримувати. Для масивів розумної довжини (107\le 10^7 елементів) K=25K = 25 — хороше значення.

Вимір MAXN\text{MAXN} стоїть другим, щоб дозволити (дружні до кешу) послідовні звернення до пам'яті.

int st[K + 1][MAXN];

Оскільки відрізок [j,j+2i1][j, j + 2^i - 1] довжини 2i2^i гарно розбивається на відрізки [j,j+2i11][j, j + 2^{i - 1} - 1] та [j+2i1,j+2i1][j + 2^{i - 1}, j + 2^i - 1], обидва довжини 2i12^{i - 1}, ми можемо ефективно згенерувати таблицю за допомогою динамічного програмування:

std::copy(array.begin(), array.end(), st[0]);

for (int i = 1; i <= K; i++)
for (int j = 0; j + (1 << i) <= N; j++)
st[i][j] = f(st[i - 1][j], st[i - 1][j + (1 << (i - 1))]);

Функція ff залежатиме від типу запиту. Для запитів суми на відрізку вона обчислюватиме суму, для запитів мінімуму на відрізку — мінімум.

Часова складність попередніх обчислень становить O(NlogN)O(\text{N} \log \text{N}).

Запити суми на відрізку

Для цього типу запитів ми хочемо знайти суму всіх значень на відрізку. Тому природним означенням функції ff є f(x,y)=x+yf(x, y) = x + y. Ми можемо побудувати структуру даних так:

long long st[K + 1][MAXN];

std::copy(array.begin(), array.end(), st[0]);

for (int i = 1; i <= K; i++)
for (int j = 0; j + (1 << i) <= N; j++)
st[i][j] = st[i - 1][j] + st[i - 1][j + (1 << (i - 1))];

Щоб відповісти на запит суми для відрізка [L,R][L, R], ми перебираємо всі степені двійки, починаючи з найбільшого. Щойно степінь двійки 2i2^i стає меншим або рівним за довжину відрізка (=RL+1= R - L + 1), ми обробляємо першу частину відрізка [L,L+2i1][L, L + 2^i - 1] і продовжуємо з рештою відрізка [L+2i,R][L + 2^i, R].

long long sum = 0;
for (int i = K; i >= 0; i--) {
if ((1 << i) <= R - L + 1) {
sum += st[i][L];
L += 1 << i;
}
}

Часова складність запиту суми на відрізку становить O(K)=O(logMAXN)O(K) = O(\log \text{MAXN}).

Запити мінімуму на відрізку (RMQ)

Це ті запити, у яких розріджена таблиця сяє. Під час обчислення мінімуму на відрізку немає значення, чи обробляємо ми значення з відрізка один раз чи двічі. Тому замість розбиття відрізка на кілька відрізків ми можемо розбити його лише на два відрізки-степені двійки, що перекриваються. Наприклад, ми можемо розбити відрізок [1,6][1, 6] на відрізки [1,4][1, 4] та [3,6][3, 6]. Мінімум на відрізку [1,6][1, 6] очевидно дорівнює мінімуму з мінімуму на відрізку [1,4][1, 4] та мінімуму на відрізку [3,6][3, 6]. Отже, ми можемо обчислити мінімум на відрізку [L,R][L, R] так:

min(st[i][L],st[i][R2i+1]) де i=log2(RL+1)\min(\text{st}[i][L], \text{st}[i][R - 2^i + 1]) \quad \text{ де } i = \log_2(R - L + 1)

Це вимагає, щоб ми вміли швидко обчислювати log2(RL+1)\log_2(R - L + 1). Цього можна досягти, попередньо обчисливши всі логарифми:

int lg[MAXN+1];
lg[1] = 0;
for (int i = 2; i <= MAXN; i++)
lg[i] = lg[i/2] + 1;

Як альтернатива, логарифм можна обчислювати на льоту за сталий час і сталу пам'ять:

// C++20
#include <bit>
int log2_floor(unsigned long i) {
return std::bit_width(i) - 1;
}

// до C++20
int log2_floor(unsigned long long i) {
return i ? __builtin_clzll(1) - __builtin_clzll(i) : -1;
}

Цей замір швидкодії показує, що використання масиву lg повільніше через промахи кешу.

Після цього нам потрібно попередньо обчислити структуру розрідженої таблиці. Цього разу ми означимо ff як f(x,y)=min(x,y)f(x, y) = \min(x, y).

int st[K + 1][MAXN];

std::copy(array.begin(), array.end(), st[0]);

for (int i = 1; i <= K; i++)
for (int j = 0; j + (1 << i) <= N; j++)
st[i][j] = min(st[i - 1][j], st[i - 1][j + (1 << (i - 1))]);

А мінімум на відрізку [L,R][L, R] можна обчислити так:

int i = lg[R - L + 1];
int minimum = min(st[i][L], st[i][R - (1 << i) + 1]);

Часова складність запиту мінімуму на відрізку становить O(1)O(1).

Подібні структури даних, що підтримують більше типів запитів

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

Існують подібні структури даних, які можуть обробляти будь-який тип асоціативних функцій і відповідати на запити на відрізках за O(1)O(1). Одна з них називається Disjoint Sparse Table. Інша — це Sqrt Tree.

Задачі для практики

Відеоматеріали