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

Sqrt-дерево

Нехай задано масив aa з nn елементів та операцію \circ, що задовольняє властивість асоціативності: (xy)z=x(yz)(x \circ y) \circ z = x \circ (y \circ z) виконується для будь-яких xx, yy, zz.

Отже, такі операції, як gcd\gcd, min\min, max\max, ++, and\text{and}, or\text{or}, xor\text{xor} тощо, задовольняють ці умови.

Також ми маємо деякі запити q(l,r)q(l, r). Для кожного запиту нам потрібно обчислити alal+1ara_l \circ a_{l+1} \circ \dots \circ a_r.

Sqrt-дерево може обробляти такі запити за O(1)O(1) часу з O(nloglogn)O(n \cdot \log \log n) часу на попередню обробку та O(nloglogn)O(n \cdot \log \log n) пам'яті.

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

Опис

Побудова кореневого розбиття

Зробімо кореневе розбиття. Ми ділимо наш масив на n\sqrt{n} блоків, кожен блок має розмір n\sqrt{n}. Для кожного блоку ми обчислюємо:

  1. Відповіді на запити, що лежать у блоці й починаються з початку блоку (prefixOp\text{prefixOp})
  2. Відповіді на запити, що лежать у блоці й закінчуються в кінці блоку (suffixOp\text{suffixOp})

А ще ми обчислимо додатковий масив:

  1. betweeni,j\text{between}_{i, j} (для iji \le j) — відповідь на запит, що починається на початку блоку ii і закінчується в кінці блоку jj. Зауважимо, що ми маємо n\sqrt{n} блоків, тож розмір цього масиву буде O(n2)=O(n)O(\sqrt{n}^2) = O(n).

Розгляньмо приклад.

Нехай \circ — це ++ (ми обчислюємо суму на відрізку) і ми маємо такий масив aa:

{1, 2, 3, 4, 5, 6, 7, 8, 9}

Він буде поділений на три блоки: {1, 2, 3}, {4, 5, 6} і {7, 8, 9}.

Для першого блоку prefixOp\text{prefixOp} — це {1, 3, 6}, а suffixOp\text{suffixOp} — це {6, 5, 3}.

Для другого блоку prefixOp\text{prefixOp} — це {4, 9, 15}, а suffixOp\text{suffixOp} — це {15, 11, 6}.

Для третього блоку prefixOp\text{prefixOp} — це {7, 15, 24}, а suffixOp\text{suffixOp} — це {24, 17, 9}.

Масив between\text{between}:

{
{6, 21, 45},
{0, 15, 39},
{0, 0, 24}
}

(ми вважаємо, що некоректні елементи, де i>ji > j, заповнені нулями)

Очевидно, що ці масиви можна легко обчислити за O(n)O(n) часу та пам'яті.

Ми вже можемо відповідати на деякі запити, використовуючи ці масиви. Якщо запит не вміщається в один блок, ми можемо поділити його на три частини: суфікс блоку, потім деякий відрізок із суміжних блоків, а далі префікс якогось блоку. Ми можемо відповісти на запит, поділивши його на три частини й узявши нашу операцію від деякого значення з suffixOp\text{suffixOp}, потім деякого значення з between\text{between}, а потім деякого значення з prefixOp\text{prefixOp}.

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

Будуємо дерево

Ми не можемо відповідати лише на ті запити, що повністю вміщаються в один блок. Але що, якщо ми побудуємо ту саму структуру, що описана вище, для кожного блоку? Так, ми можемо це зробити. І робимо ми це рекурсивно, доки не досягнемо розміру блоку 11 або 22. Відповіді для таких блоків можна легко обчислити за O(1)O(1).

Отже, ми отримуємо дерево. Кожна вершина дерева представляє деякий відрізок масиву. Вершина, що представляє відрізок масиву розміру kk, має k\sqrt{k} дітей — по одній на кожен блок. Також кожна вершина містить три масиви, описані вище, для відрізка, який вона містить. Корінь дерева представляє весь масив. Вершини з довжинами відрізків 11 або 22 є листками.

Також очевидно, що висота цього дерева — O(loglogn)O(\log \log n), тому що якщо деяка вершина дерева представляє масив довжини kk, то її діти мають довжину k\sqrt{k}. log(k)=logk2\log(\sqrt{k}) = \frac{\log{k}}{2}, тож logk\log k зменшується вдвічі на кожному шарі дерева, а отже, його висота — O(loglogn)O(\log \log n). Час на побудову та використання пам'яті будуть O(nloglogn)O(n \cdot \log \log n), тому що кожен елемент масиву з'являється рівно один раз на кожному шарі дерева.

Тепер ми можемо відповідати на запити за O(loglogn)O(\log \log n). Ми можемо спускатися деревом, доки не зустрінемо відрізок довжини 11 або 22 (відповідь для нього можна обчислити за O(1)O(1) часу) або не зустрінемо перший відрізок, у якому наш запит не вміщається повністю в один блок. Дивіться перший розділ, як відповідати на запит у цьому випадку.

Гаразд, тепер ми можемо робити O(loglogn)O(\log \log n) на запит. Чи можна зробити це швидше?

Оптимізація складності запиту

Одна з найочевидніших оптимізацій — це бінарний пошук вершини дерева, яка нам потрібна. Використовуючи бінарний пошук, ми можемо досягти складності O(logloglogn)O(\log \log \log n) на запит. Чи можемо ми зробити це ще швидше?

Відповідь — так. Припустімо такі дві речі:

  1. Кожен розмір блоку є степенем двійки.
  2. Усі блоки рівні на кожному шарі.

Щоб цього досягти, ми можемо додати деякі нульові елементи до нашого масиву, щоб його розмір став степенем двійки.

Коли ми це використовуємо, деякі розміри блоків можуть стати вдвічі більшими, щоб бути степенем двійки, але вони все одно будуть O(k)O(\sqrt{k}) за розміром, і ми зберігаємо лінійну складність для побудови масивів у відрізку.

Тепер ми можемо легко перевірити, чи вміщається запит повністю в блок розміру 2k2^k. Запишемо межі запиту, ll та rr (ми використовуємо 0-індексацію), у двійковій формі. Наприклад, припустімо k=4,l=39,r=46k=4, l=39, r=46. Двійкове представлення ll та rr:

l=3910=1001112l = 39_{10} = 100111_2

r=4610=1011102r = 46_{10} = 101110_2

Пам'ятаймо, що один шар містить відрізки рівного розміру, а блоки на одному шарі також мають рівний розмір (у нашому випадку їхній розмір — 2k=24=162^k = 2^4 = 16). Блоки повністю покривають масив, тож перший блок покриває елементи (015)(0 - 15) ((00000020011112)(000000_2 - 001111_2) у двійковому вигляді), другий покриває елементи (1631)(16 - 31) ((01000020111112)(010000_2 - 011111_2) у двійковому вигляді) і так далі. Ми бачимо, що індекси позицій, покритих одним блоком, можуть відрізнятися лише в kk (у нашому випадку 44) останніх бітах. У нашому випадку ll та rr мають рівні біти, окрім чотирьох найнижчих, тож вони лежать в одному блоці.

Отже, нам потрібно перевірити, чи відрізняється не більше ніж kk найменших бітів (або чи l xor rl\ \text{xor}\ r не перевищує 2k12^k-1).

Використовуючи це спостереження, ми можемо знайти шар, придатний для швидкої відповіді на запит. Як це зробити:

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

  2. Тепер для кожного q(l,r)q(l, r) ми знаходимо найвищий біт l xor rl\ \text{xor}\ r і, використовуючи цю інформацію, легко обираємо шар, на якому ми можемо легко обробити запит. Тут ми також можемо використати попередньо обчислений масив.

Детальніше дивіться код нижче.

Отже, використовуючи це, ми можемо відповідати на запити за O(1)O(1) кожен. Ура! :)

Оновлення елементів

Ми також можемо оновлювати елементи в sqrt-дереві. Підтримуються як оновлення окремого елемента, так і оновлення на відрізку.

Оновлення окремого елемента

Розгляньмо запит update(x,val)\text{update}(x, val), що виконує присвоєння ax=vala_x = val. Нам потрібно виконати цей запит достатньо швидко.

Наївний підхід

Спочатку погляньмо, що змінюється в дереві, коли змінюється окремий елемент. Розгляньмо вершину дерева з довжиною ll та її масиви: prefixOp\text{prefixOp}, suffixOp\text{suffixOp} та between\text{between}. Легко бачити, що лише O(l)O(\sqrt{l}) елементів із prefixOp\text{prefixOp} та suffixOp\text{suffixOp} змінюються (лише всередині блоку зі зміненим елементом). O(l)O(l) елементів змінюються в between\text{between}. Отже, O(l)O(l) елементів у вершині дерева оновлюються.

Ми пам'ятаємо, що будь-який елемент xx присутній рівно в одній вершині дерева на кожному шарі. Коренева вершина (шар 00) має довжину O(n)O(n), вершини на шарі 11 мають довжину O(n)O(\sqrt{n}), вершини на шарі 22 мають довжину O(n)O(\sqrt{\sqrt{n}}) тощо. Отже, часова складність на одне оновлення — O(n+n+n+)=O(n)O(n + \sqrt{n} + \sqrt{\sqrt{n}} + \dots) = O(n).

Але це надто повільно. Чи можна зробити це швидше?

Sqrt-дерево всередині sqrt-дерева

Зауважимо, що вузьке місце оновлення — це перебудова between\text{between} кореневої вершини. Щоб оптимізувати дерево, позбудьмося цього масиву! Замість масиву between\text{between} ми зберігаємо для кореневої вершини інше sqrt-дерево. Назвімо його index\text{index}. Воно відіграє ту саму роль, що й between\text{between}, — відповідає на запити на відрізках блоків. Зауважимо, що решта вершин дерева не мають index\text{index}, вони зберігають свої масиви between\text{between}.

Sqrt-дерево є індексованим, якщо його коренева вершина має index\text{index}. Sqrt-дерево з масивом between\text{between} у його кореневій вершині є неіндексованим. Зауважимо, що index\text{index} сам є неіндексованим.

Отже, ми маємо такий алгоритм для оновлення індексованого дерева:

  • Оновити prefixOp\text{prefixOp} та suffixOp\text{suffixOp} за O(n)O(\sqrt{n}).

  • Оновити index\text{index}. Воно має довжину O(n)O(\sqrt{n}), і нам потрібно оновити в ньому лише один елемент (який представляє змінений блок). Отже, часова складність цього кроку — O(n)O(\sqrt{n}). Ми можемо використати алгоритм, описаний на початку цього розділу («повільний»), щоб це зробити.

  • Перейти у вершину-дитину, що представляє змінений блок, і оновити її за O(n)O(\sqrt{n}) «повільним» алгоритмом.

Зауважимо, що складність запиту все ще O(1)O(1): нам потрібно використати index\text{index} у запиті не більше одного разу, і це займе O(1)O(1) часу.

Отже, загальна часова складність оновлення окремого елемента — O(n)O(\sqrt{n}). Ура! :)

Оновлення відрізка

Sqrt-дерево також може робити такі речі, як присвоєння елемента на відрізку. massUpdate(x,l,r)\text{massUpdate}(x, l, r) означає ai=xa_i = x для всіх lirl \le i \le r.

Є два підходи, щоб це зробити: один із них робить massUpdate\text{massUpdate} за O(nloglogn)O(\sqrt{n}\cdot \log \log n), зберігаючи O(1)O(1) на запит. Другий робить massUpdate\text{massUpdate} за O(n)O(\sqrt{n}), але складність запиту стає O(loglogn)O(\log \log n).

Ми будемо робити відкладені оновлення так само, як це робиться в деревах відрізків: ми позначаємо деякі вершини як відкладені (lazy), маючи на увазі, що ми проштовхнемо їх, коли це буде необхідно. Але одна річ відрізняється від дерев відрізків: проштовхування вершини є дорогим, тож його не можна виконувати в запитах. На шарі 00 проштовхування вершини займає O(n)O(\sqrt{n}) часу. Отже, ми не проштовхуємо вершини всередині запитів, ми лише дивимося, чи є поточна вершина або її батько відкладеними, і просто враховуємо це під час виконання запитів.

Перший підхід

У першому підході ми кажемо, що лише вершини на шарі 11 (з довжиною O(nO(\sqrt{n}) можуть бути відкладеними. Коли ми проштовхуємо таку вершину, вона оновлює все своє піддерево, включно із собою, за O(nloglogn)O(\sqrt{n}\cdot \log \log n). Процес massUpdate\text{massUpdate} виконується так:

  • Розглядаємо вершини на шарі 11 та блоки, що їм відповідають.

  • Деякі блоки повністю покриті massUpdate\text{massUpdate}. Позначаємо їх як відкладені за O(n)O(\sqrt{n}).

  • Деякі блоки покриті частково. Зауважимо, що таких блоків не більше двох. Перебудовуємо їх за O(nloglogn)O(\sqrt{n}\cdot \log \log n). Якщо вони були відкладеними, враховуємо це.

  • Оновлюємо prefixOp\text{prefixOp} та suffixOp\text{suffixOp} для частково покритих блоків за O(n)O(\sqrt{n}) (тому що таких блоків лише два).

  • Перебудовуємо index\text{index} за O(nloglogn)O(\sqrt{n}\cdot \log \log n).

Отже, ми можемо робити massUpdate\text{massUpdate} швидко. Але як відкладені оновлення впливають на запити? Вони матимуть такі зміни:

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

  • Якщо наш запит складається з багатьох блоків, деякі з яких відкладені, нам потрібно подбати про відкладеність лише в найлівішому та найправішому блоці. Решта блоків обчислюється за допомогою index\text{index}, який уже знає відповідь на відкладеному блоці (тому що його перебудовують після кожної зміни). O(1)O(1).

Складність запиту все ще залишається O(1)O(1).

Другий підхід

У цьому підході кожна вершина може бути відкладеною (окрім кореня). Навіть вершини в index\text{index} можуть бути відкладеними. Отже, під час обробки запиту ми маємо шукати позначки відкладеності в усіх батьківських вершинах, тобто складність запиту буде O(loglogn)O(\log \log n).

Але massUpdate\text{massUpdate} стає швидшим. Він виглядає так:

  • Деякі блоки повністю покриті massUpdate\text{massUpdate}. Отже, до них додаються позначки відкладеності. Це O(n)O(\sqrt{n}).

  • Оновлюємо prefixOp\text{prefixOp} та suffixOp\text{suffixOp} для частково покритих блоків за O(n)O(\sqrt{n}) (тому що таких блоків лише два).

  • Не забуваємо оновити index. Це O(n)O(\sqrt{n}) (ми використовуємо той самий алгоритм massUpdate\text{massUpdate}).

  • Оновлюємо масив between\text{between} для неіндексованих піддерев.

  • Заходимо у вершини, що представляють частково покриті блоки, і викликаємо massUpdate\text{massUpdate} рекурсивно.

Зауважимо, що коли ми робимо рекурсивний виклик, ми робимо префіксний або суфіксний massUpdate\text{massUpdate}. Але для префіксних та суфіксних оновлень ми можемо мати не більше однієї частково покритої дитини. Отже, ми відвідуємо одну вершину на шарі 11, дві вершини на шарі 22 і дві вершини на будь-якому глибшому рівні. Тож часова складність — O(n+n+)=O(n)O(\sqrt{n} + \sqrt{\sqrt{n}} + \dots) = O(\sqrt{n}). Підхід тут схожий на масове оновлення дерева відрізків.

Реалізація

Наступна реалізація sqrt-дерева може виконувати такі операції: побудова за O(nloglogn)O(n \cdot \log \log n), відповідь на запити за O(1)O(1) та оновлення елемента за O(n)O(\sqrt{n}).

SqrtTreeItem op(const SqrtTreeItem &a, const SqrtTreeItem &b);

inline int log2Up(int n) {
int res = 0;
while ((1 << res) < n) {
res++;
}
return res;
}

class SqrtTree {
private:
int n, lg, indexSz;
vector<SqrtTreeItem> v;
vector<int> clz, layers, onLayer;
vector< vector<SqrtTreeItem> > pref, suf, between;

inline void buildBlock(int layer, int l, int r) {
pref[layer][l] = v[l];
for (int i = l+1; i < r; i++) {
pref[layer][i] = op(pref[layer][i-1], v[i]);
}
suf[layer][r-1] = v[r-1];
for (int i = r-2; i >= l; i--) {
suf[layer][i] = op(v[i], suf[layer][i+1]);
}
}

inline void buildBetween(int layer, int lBound, int rBound, int betweenOffs) {
int bSzLog = (layers[layer]+1) >> 1;
int bCntLog = layers[layer] >> 1;
int bSz = 1 << bSzLog;
int bCnt = (rBound - lBound + bSz - 1) >> bSzLog;
for (int i = 0; i < bCnt; i++) {
SqrtTreeItem ans;
for (int j = i; j < bCnt; j++) {
SqrtTreeItem add = suf[layer][lBound + (j << bSzLog)];
ans = (i == j) ? add : op(ans, add);
between[layer-1][betweenOffs + lBound + (i << bCntLog) + j] = ans;
}
}
}

inline void buildBetweenZero() {
int bSzLog = (lg+1) >> 1;
for (int i = 0; i < indexSz; i++) {
v[n+i] = suf[0][i << bSzLog];
}
build(1, n, n + indexSz, (1 << lg) - n);
}

inline void updateBetweenZero(int bid) {
int bSzLog = (lg+1) >> 1;
v[n+bid] = suf[0][bid << bSzLog];
update(1, n, n + indexSz, (1 << lg) - n, n+bid);
}

void build(int layer, int lBound, int rBound, int betweenOffs) {
if (layer >= (int)layers.size()) {
return;
}
int bSz = 1 << ((layers[layer]+1) >> 1);
for (int l = lBound; l < rBound; l += bSz) {
int r = min(l + bSz, rBound);
buildBlock(layer, l, r);
build(layer+1, l, r, betweenOffs);
}
if (layer == 0) {
buildBetweenZero();
} else {
buildBetween(layer, lBound, rBound, betweenOffs);
}
}

void update(int layer, int lBound, int rBound, int betweenOffs, int x) {
if (layer >= (int)layers.size()) {
return;
}
int bSzLog = (layers[layer]+1) >> 1;
int bSz = 1 << bSzLog;
int blockIdx = (x - lBound) >> bSzLog;
int l = lBound + (blockIdx << bSzLog);
int r = min(l + bSz, rBound);
buildBlock(layer, l, r);
if (layer == 0) {
updateBetweenZero(blockIdx);
} else {
buildBetween(layer, lBound, rBound, betweenOffs);
}
update(layer+1, l, r, betweenOffs, x);
}

inline SqrtTreeItem query(int l, int r, int betweenOffs, int base) {
if (l == r) {
return v[l];
}
if (l + 1 == r) {
return op(v[l], v[r]);
}
int layer = onLayer[clz[(l - base) ^ (r - base)]];
int bSzLog = (layers[layer]+1) >> 1;
int bCntLog = layers[layer] >> 1;
int lBound = (((l - base) >> layers[layer]) << layers[layer]) + base;
int lBlock = ((l - lBound) >> bSzLog) + 1;
int rBlock = ((r - lBound) >> bSzLog) - 1;
SqrtTreeItem ans = suf[layer][l];
if (lBlock <= rBlock) {
SqrtTreeItem add = (layer == 0) ? (
query(n + lBlock, n + rBlock, (1 << lg) - n, n)
) : (
between[layer-1][betweenOffs + lBound + (lBlock << bCntLog) + rBlock]
);
ans = op(ans, add);
}
ans = op(ans, pref[layer][r]);
return ans;
}
public:
inline SqrtTreeItem query(int l, int r) {
return query(l, r, 0, 0);
}

inline void update(int x, const SqrtTreeItem &item) {
v[x] = item;
update(0, 0, n, 0, x);
}

SqrtTree(const vector<SqrtTreeItem>& a)
: n((int)a.size()), lg(log2Up(n)), v(a), clz(1 << lg), onLayer(lg+1) {
clz[0] = 0;
for (int i = 1; i < (int)clz.size(); i++) {
clz[i] = clz[i >> 1] + 1;
}
int tlg = lg;
while (tlg > 1) {
onLayer[tlg] = (int)layers.size();
layers.push_back(tlg);
tlg = (tlg+1) >> 1;
}
for (int i = lg-1; i >= 0; i--) {
onLayer[i] = max(onLayer[i], onLayer[i+1]);
}
int betweenLayers = max(0, (int)layers.size() - 1);
int bSzLog = (lg+1) >> 1;
int bSz = 1 << bSzLog;
indexSz = (n + bSz - 1) >> bSzLog;
v.resize(n + indexSz);
pref.assign(layers.size(), vector<SqrtTreeItem>(n + indexSz));
suf.assign(layers.size(), vector<SqrtTreeItem>(n + indexSz));
between.assign(betweenLayers, vector<SqrtTreeItem>((1 << lg) + bSz));
build(0, 0, n, 0);
}
};

Задачі

CodeChef - SEGPROD