Динамический двоичный подъем на слоях (SBLD) для запросов на отрезках
Изначально у меня была идея сделать многослойную декомпозицию, где на первом слое блоки имеют размер $$$(\sqrt{N})$$$, на втором — $$$(\sqrt{\sqrt{N}})$$$ и так далее. Думал, что получится интересная альтернатива стандартным подходам. Но когда начал изучать архивы Codeforces, оказалось, что про подобное уже писали, но сейчас статью не нашел.
В итоге я решил отказаться от корней и посмотреть в сторону степеней двойки, но не через Sparse Table (который не поддерживает обновления и требует много памяти), а через фиксированную сетку слоев, совмещенную с логикой двоичного подъема. Назвал эту штуку SBLD (Segment Binary Lifting Data-structure). Получился гибрид, который выполняет запросы и точечные обновления за $$$2 \log N$$$) действий в худшем случае, используя строгие O(N) памяти (ровно 2N элементов вместе с базовым массивом).
Устройство сетки слоев
Структура не хранит все пересекающиеся отрезки. Массив разбивается на независимые блоки на каждом слое i, где длина блока равна $$$2^{i+1}$$$:
Слой 2 (len=8): [----------------------- ans -----------------------]
Слой 1 (len=4): [--------- ans ---------] [--------- ans ---------]
Слой 0 (len=2): [--- ans ---] [--- ans ---] [--- ans ---] [--- ans ---]
base_a (len=1): [ L0 ] [ L1 ] [ L2 ] [ L3 ] [ L4 ] [ L5 ] [ L6 ] [ L7 ]
Суммарная память по всем слоям: $$$N/2 + N/4 + N/8 + \dots = O(N)$$$. Вместе с исходным массивом base_a (который можно считать слоем -1) выходит ровно $$$2N$$$ элементов. Построение линейное — просто попарно объединяем соседей снизу вверх.
Алгоритм запроса
Мы ищем ответ на полуинтервале $$$[L, R)$$$. Стартуем в точке cur_l = L и жадно двигаемся вправо по степеням двойки, выбирая максимально возможный шаг.
Размер шага $$$i$$$ (длина шага $$$2^{i+1}$$$) ограничен двумя факторами: 1. Выравнивание левой границы: мы можем сделать шаг на слой $$$i$$$, только если текущий индекс cur_l делится на $$$2^{i+1}$$$. Это ограничение вычисляется через __builtin_ctz(cur_l). 2. Расстояние до правой границы: чтобы не вылететь за пределы $$$R$$$, шаг должен быть $$$\le R - cur_l$$$. Это ограничение определяется старшим битом: 31 - __builtin_clz(R - cur_l).
Итоговая формула выбора слоя на текущей итерации:
Если $$$i \lt 0$$$, значит, мы не можем взять даже блок длины 2 (например, когда cur_l нечетный или остался всего 1 элемент). В этом случае берем значение напрямую из base_a и сдвигаем cur_l на 1.
Почему это работает за $$$2 \log N$$$ шагов?
Процесс можно разделить на две фазы. Сначала идет подъем: пока cur_l выравнивается, значения ctz строго растут, и шаги увеличиваются (максимум $$$\log N$$$ итераций). Затем мы упираемся в ограничение по расстоянию до $$$R$$$, и начинается спуск: куски уменьшаются, что эквивалентно разложению длины оставшегося отрезка в двоичную систему (еще максимум $$$\log N$$$ шагов).
Главное отличие и продвинутые кейсы
Важное свойство SBLD — мы всегда двигаемся строго слева направо. Классическое итеративное дерево отрезков снизу-вверх сжимает границы с двух сторон одновременно. Из-за этого на некоммутативных операциях (например, динамическое перемножение матриц на отрезке) там приходится аккуратно следить за порядком склейки аккумуляторов. В SBLD порядок операций никогда не нарушается, поэтому некоммутативные структуры работают «из коробки» за $$$O(D^3 \log N)$$$.
Также вместо одного значения в блоке можно хранить отсортированный std::vector, мержить их при построении и получить Merge Sort Tree на плоских слоях. Память составит $$$O(N \log N)$$$ без оверхеда на структуру дерева и указатели, а запросы будут работать через upper_bound за $$$O(\log^2 N)$$$.
Код на C++ (пример для матриц 2х2)
#include <vector>
#include <algorithm>
#include <iostream>
using namespace std;
const int MOD = 1e9 + 7;
struct Matrix {
long long mat;
Matrix() {
mat = 1; mat = 0;
mat = 0; mat = 1;
}
};
typedef Matrix T;
T combine(const T& a, const T& b) {
T res;
res.mat = (a.mat * b.mat + a.mat * b.mat) % MOD;
res.mat = (a.mat * b.mat + a.mat * b.mat) % MOD;
res.mat = (a.mat * b.mat + a.mat * b.mat) % MOD;
res.mat = (a.mat * b.mat + a.mat * b.mat) % MOD;
return res;
}
const T NEUTRAL = Matrix();
struct SBLD {
int n;
int max_log;
vector<T> base_a;
vector<vector<T>> ans;
SBLD(vector<T> a) {
n = a.size();
base_a = std::move(a);
max_log = (n > 0) ? (32 - __builtin_clz(n)) : 0;
ans.resize(max_log);
if (n == 0) return;
ans.resize((n + 1) / 2, NEUTRAL);
for (int i = 0; i < n; i++) {
ans[i >> 1] = combine(ans[i >> 1], base_a[i]);
}
for (int i = 1; i < max_log; i++) {
int sz = (ans[i - 1].size() + 1) / 2;
ans[i].resize(sz, NEUTRAL);
for (int j = 0; j < ans[i - 1].size(); j++) {
ans[i][j >> 1] = combine(ans[i][j >> 1], ans[i - 1][j]);
}
}
}
void update(int idx, T val) {
base_a[idx] = std::move(val);
int l0 = idx >> 1;
T left0 = base_a[l0 * 2];
T right0 = (l0 * 2 + 1 < n) ? base_a[l0 * 2 + 1] : NEUTRAL;
ans[l0] = combine(left0, right0);
for (int i = 1; i < max_log; i++) {
int cur_l = idx >> (i + 1);
int left_son = cur_l * 2;
int right_son = left_son + 1;
T val_left = ans[i - 1][left_son];
T val_right = (right_son < ans[i - 1].size()) ? ans[i - 1][right_son] : NEUTRAL;
ans[i][cur_l] = combine(val_left, val_right);
}
}
T query(int L, int R) {
T cur_ans = NEUTRAL;
int cur_l = L;
while (cur_l < R) {
int limit_r = 31 - __builtin_clz(R - cur_l);
int limit_l = (cur_l == 0) ? limit_r : __builtin_ctz(cur_l);
int i = min(limit_l, limit_r) - 1;
if (i < 0) {
cur_ans = combine(cur_ans, base_a[cur_l]);
cur_l += 1;
} else {
cur_ans = combine(cur_ans, ans[i][cur_l >> (i + 1)]);
cur_l += (1 << (i + 1));
}
}
return cur_ans;
}
};
Конечно, стандартное итеративное ДО на массиве пишется короче и на базовых операциях вроде суммы выиграет по константе за счет симметричного сжатия границ. Но для тяжелых или некоммутативных объектов, где критически важен строгий порядок объединения слева-направо, такая явная разбивка по слоям двоичного подъема выглядит довольно нативно.
Кто-нибудь кодил подобные структуры или тестировал их производительность на реальных задачах? Интересно услышать мысли в комментариях.
Upd: урааа я не зря потратил столько времени разбираться с MathJax, это было очень трудно)))) Upd: спасибо сообществу за активное внимание к моей структуре! благодаря вам она стала намного более проработанной.









Автокомментарий: текст был обновлен пользователем fogsmozetvse (предыдущая версия, новая версия, сравнить).
Автокомментарий: текст был обновлен пользователем fogsmozetvse (предыдущая версия, новая версия, сравнить).
Auto comment: topic has been translated by fogsmozetvse (original revision, translated revision, compare)
Isn't matrix multiplication associative? Any segtree should be able to handle associative operations.
Yes, it is associative, but non-commutative.
With standard bottom-up segtree you process pointers from both sides, meaning you have to maintain separate left/right accumulators and merge them in correct order at the end.
SBLD just goes strictly left-to-right, so you can sequentially multiply into a single accumulator without overthinking the order.
It's extremely easy to make a segment tree with a non-commutative operation (see this blog, you only need 1 extra line), so is there any reason to use this instead?
Standard bottom-up segtree is shorter, no doubt(but not faster). The main structural difference is cache locality and memory layout. SBLD stores elements in flat, predictable layers (like a Sparse Table grid). When you do a point update, you look up indices using sequential bit shifts, and the memory access pattern is strictly linear within contiguous vectors. Also, for structures like a Merge Sort Tree on flat layers, SBLD allows building via sequential std::merge without allocating a full binary tree with pointers, keeping the memory overhead at strict O(N log N). It’s more of a different perspective on binary lifting than a drop-in replacement for standard segtree.
Also, it's worth noting that in that standard snippet, you have to carefully combine(resl, tree[l++]) for the left pointer, but combine(tree[--r], resr) for the right one. Shifting the operands order is easy to mess up during a contest. SBLD avoids this completely since you always combine in the exact same order.
In contest you are allowed to use templates, and if you can't code a segment tree right that's just skill issue
Автокомментарий: текст был обновлен пользователем fogsmozetvse (предыдущая версия, новая версия, сравнить).
True for CF, but on onsite contests like ICPC you only have a printed Team Reference Document and have to type everything from scratch under stress. The same applies to many high-level high school olympiads and technical interviews where no templates are allowed. In those scenarios, having a clean, bug-free implementation logic from memory is a huge life saver.
segment tree is one of the most intuitive data structures to write, somebody who knows it will not be struggling to write it in contest from scratch
Auto comment: topic has been updated by fogsmozetvse (previous revision, new revision, compare).