Динамический двоичный подъем на слоях (SBLD) для запросов на отрезках

Правка ru2, от fogsmozetvse, 2026-07-11 16:50:37

Динамический двоичный подъем на слоях (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 = \min(\text{ctz}(cur\_l), \; 31 - \text{clz}(R - cur\_l)) - 1$$$

Если $$$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, это было очень трудно))))

История

 
 
 
 
Правки
 
 
  Rev. Язык Кто Когда Δ Комментарий
en3 Английский fogsmozetvse 2026-07-11 20:31:02 417
en2 Английский fogsmozetvse 2026-07-11 20:18:10 156
ru6 Русский fogsmozetvse 2026-07-11 20:16:06 117
ru5 Русский fogsmozetvse 2026-07-11 17:59:59 11253 Возвращено к ru2
en1 Английский fogsmozetvse 2026-07-11 17:58:41 7983 Initial revision for English translation
ru4 Русский fogsmozetvse 2026-07-11 17:57:58 0 перевод на англ
ru3 Русский fogsmozetvse 2026-07-11 17:57:57 11253 перевод на англ
ru2 Русский fogsmozetvse 2026-07-11 16:50:37 2 Мелкая правка: '\sqrt{N}}\$) и так дал' -> '\sqrt{N}}\)$ и так дал'
ru1 Русский fogsmozetvse 2026-07-11 16:46:49 7146 Первая редакция (опубликовано)