专栏文章
浅谈 Wavelet Matrix
算法·理论参与者 12已保存评论 13
文章操作
快速查看文章及其快照的属性,并进行相关操作。
- 当前评论
- 13 条
- 当前快照
- 1 份
- 快照标识符
- @mip2k928
- 此快照首次捕获于
- 2025/12/03 05:07 3 个月前
- 此快照最后确认于
- 2025/12/03 05:07 3 个月前
前几天看到了这个数据结构,觉得比较优雅,记录一下。
由于 wavelet matrix 貌似没有正式的译名,这里将其称为小波矩阵。
总的来说,小波矩阵是一种基于值域二进制分解的静态简洁数据结构,其基于小波树 wavelet tree。
关于简洁数据结构 Succinct data structure,简洁位向量 Succinct Indexable Dictionaries,以及其支持的 操作,由于竞赛中较少使用,这里仅稍作介绍,感兴趣的读者可自行查询相关资料。
在竞赛中,小波矩阵能以 的时空复杂度预处理,单次询问 时间,解决静态无权区间 k 小区间排名等问题。
一般认为(word-RAM model)计算机字长 ,所以在这种语境下完全可以认为小波矩阵做到了 的空间复杂度。
约定
下文叙述和代码实现上,对序列和值域位均使用 0-index。
默认序列长度为 ,值域为 。
的区间 ,若 则认为为 。
求 的值。
求 中 的个数(与竞赛常见定义不同)。
求 中第 个 的下标。
小波树
由于小波矩阵基于小波树且为其上位替代,这里只做简要介绍,不给出实现。
通用的小波树对字符集进行了重编码,这里由于我们只讨论竞赛中相关内容,暂且略去。
小波树是一棵 层的二叉树,记根节点为 ,记 表示结点 的左/右子节点, 表示结点 的高度。
节点 维护 0/1 序列 , 为某个序列 中 第 位的值。
具体的递归定义如下:将序列 拆分,
- 为 中,第 位为 0 的元素的子序列。
- 为 中,第 位为 1 的元素的子序列。
若子节点高度 或其维护序列 则返回。初始 ,这里的 指需要对其构建小波树的序列。
注意小波树并不存储 ,只存储 的信息。时空复杂度 。
不难发现小波树的形态与 01-trie,或者说动态开点线段树非常相似,因此其也支持相关操作。例如查询序列 中 的元素个数:从根节点开始,若 第 位为 1,则将 的元素加入答案,并进入 ,否则直接进入 。
若 ,求对应的元素在 中的下标。
不难发现就是 中 的个数,即 。
求 的值。
从根节点开始,维护 在 的下标 ,可以知道 的第 位为 ,其在子节点的下标是 。
一路向下查找即可得到 的每一位。当然,也可以在叶节点处额外维护值,直接返回。
求 中 的个数。
同样从根节点开始,记 第 位为 。可能存在的 一定在 中,且定义时的子序列保证了相对顺序不变,那么 。
递归到叶节点时答案为 ,即
求 中第 个 的下标。
考虑 的第 位 , 中第 个 在 的下标为 ,那么 的第 个元素在 的下标为 。
于是 。对于叶节点,有 。
若 为 实现,复杂度为 。
小波矩阵
小波矩阵的主要思想是,将小波树每一层的所有位向量,按照一定顺序组成一个长为 的位向量一起存储,无需维护树形结构。
记当前要构建序列 的小波矩阵 。为了便于说明,记辅助数组 。
初始有 。枚举二进制位 ,记录 为 第 位的值,然后将 按照 二元组排序得到 。
可以发现这等价于,对于小波树每一层的位向量,所有 一定在所有 前面,同时保证 内部在上一层的相对顺序不变。
注意,小波树的叶层节点是有序的,但小波矩阵最终的 不一定有序。根据小波树容易发现,在 中所有相同的元素一定只形成 1 个区间。
朴素实现的时空复杂度为 ,这里给出一份便于理解。 在实现上是不需要的,只是为了方便下文说明。
CPPvoid build(int n, int a[])
{
for(int u = A - 1; ~u; u --)
{
for(int i = 1; i <= n; i ++)
b[u][i] = a[i] >> u & 1;
stable_partition(a + 1, a + n + 1,
[&u](int x){return ~x >> u & 1;});
}
}
若 的第 位为 ,求其在 的下标。
-
若 ,那么只有 中第 位为 0 的元素在 之前,即 。
-
若 ,那么 中第 位为 0 的所有元素,和 中第 位为 1 的元素都在 之前,即 。
为了方便描述 ,除了向下转移的 ,我们再定义:
若 的第 位为 ,求其在 的下标(可以认为是 的逆运算)。
-
若 ,那么其为 的第 个 0,即 。
-
若 ,那么其为 的第 个 1,即 。
确定如何转移之后,查询的思路与小波树类似。
求 的值。
初始 ,表示 在 的下标 。查询得到 ,即 第 位的值,那么转移到 。
求 中 的个数。
若沿用小波树的思路查询 ,在 无法知道对应区间左端点,也就不能确定答案。当然可以选择在 维护每个元素段的起点,但我们可以直接维护左端点,解决区间查询。
若 的第 位为 ,根据小波树可以知道所有的 一定在同一个子节点内,由于相对顺序不变,因此可以仅排出 内第 位为 的元素。
初始 ,逐次更新 ,最终答案即为 。
求 中第 个 的下标。
同样地,查询 时若不维护起点,则在 无法给出第 个元素的下标,也就无法向上计算答案。因此维护小波矩阵上的起点,解决后缀查询。
若 的 第 位为 ,类似小波树地考虑第 个 在下一层的下标为 ,那么下一层下标为 的元素在这层的下标为 。
初始 ,先向下更新 , 后令 ,再向上更新 。
若 中的 为 实现,则复杂度为 。
优化
在上述基础操作中,维护 的内层数据结构需要支持 。
或许有读者发现,这正好是简洁位向量 Succinct Indexable Dictionaries 支持的操作。然而竞赛中 非常容易实现, 绝大部分时候用不到,且简洁位向量虽然理论更优,其常数和编码复杂度并不如下面的方法。
我们直接使用 个长 的 bitset 维护 即可,或者说将 每一行按 进行分块压位存储。
对于 ,简单位运算即可。
对于 ,块外维护 每一块的前缀和 ,块内使用
__builtin_popcountll,单次复杂度 。对于 ,块外块内二分,单次复杂度 。
于是,我们得到了预处理时间复杂度 ,空间复杂度 的小波矩阵。关于上述基础操作,这里给出笔者的示例代码,供读者参考理解。
CPPstruct bitSet
{
using ULL = unsigned long long;
#define ppcll __builtin_popcountll
int len, zero;
vector <pair <ULL, int>> bit;
bitSet(int n): len(n >> 6), zero(n)
{
bit.resize(len + 1, make_pair(0, 0));
}
void set(int k)
{
--zero;
bit[k >> 6].first |= 1ull << (k & 63);
}
void init()
{
for(int i = 1; i <= len; i ++)
bit[i].second = bit[i - 1].second + ppcll(bit[i - 1].first);
}
int access(int k)
{
return bit[k >> 6].first >> (k & 63) & 1;
}
int rank1(int k)
{
return bit[k >> 6].second + ppcll(bit[k >> 6].first << (~k & 63));
}
int rank0(int k)
{
return k - rank1(k);
}
int binarySearch(int v, int k)
{
static constexpr ULL m32 = 0xffffffff;
static constexpr ULL m16 = 0xffff;
static constexpr ULL m8 = 0xff;
static constexpr ULL m4 = 0xf;
static constexpr ULL m2 = 0x3;
static constexpr ULL m1 = 0x1;
int c, pos = 0;
if(c = ppcll(v & m32); c < k) v >>= 32, k -= c, pos |= 32;
if(c = ppcll(v & m16); c < k) v >>= 16, k -= c, pos |= 16;
if(c = ppcll(v & m8 ); c < k) v >>= 8, k -= c, pos |= 8;
if(c = ppcll(v & m4 ); c < k) v >>= 4, k -= c, pos |= 4;
if(c = ppcll(v & m2 ); c < k) v >>= 2, k -= c, pos |= 2;
if(c = ppcll(v & m1 ); c < k) v >>= 1, k -= c, pos |= 1;
return pos;
}
int select1(int k)
{
int pos = 0;
int l = 0, r = len, mid;
while(l <= r)
{
mid = (l + r) >> 1;
if(bit[mid].second < k)
l = mid + 1, pos = mid;
else r = mid - 1;
}
k -= bit[pos].second;
return pos << 6 | binarySearch(bit[pos].first, k);
}
int select0(int k)
{
int pos = 0;
int l = 0, r = len, mid;
while(l <= r)
{
mid = (l + r) >> 1;
if((mid << 6) - bit[mid].second < k)
l = mid + 1, pos = mid;
else r = mid - 1;
}
k -= (pos << 6) - bit[pos].second;
return pos << 6 | binarySearch(~bit[pos].first, k);
}
#undef ppcll
};
求 中第 小的元素。
由小波树可以知道, 时 在 只会重组为两个区间,其中一个第 位全 0,另一个第 位全 1。于是从 开始,维护答案 :
-
若 内第 位为 0 的元素个数 ,即 ,显然 这一位为 0,更新 。
-
否则 这一位为 1,去掉这一位为 0 元素的贡献,更新 。
求 中 的元素个数。
类似地,从 开始,维护答案 :
-
若 第 位为 1,将所有这一位为 0 的元素加入统计,更新 。
-
否则直接更新 。
实现
通过上面的一些操作可以发现,小波矩阵继承了小波树带来的 01-trie 相似结构,且能通过简洁位向量进行区间转移。相当于提取区间的 01-trie 进行查询,因此大部分持久化 01-trie 或者说主席树支持的操作,在小波矩阵上稍作修改即可实现。
位向量的统一存储和简洁位向量的线性空间对缓存较为友好,这使得小波矩阵可以以小常数或小空间替代主席树。当然,若是统计信息带权,空间复杂度只能做到 。
这里给出笔者的实例代码,供读者参考理解。一些建议:
-
朴素实现中的
stable_partition()虽然可以做到 ,但在没有内部临时缓冲区时,是 的复杂度,因此一般应该替换为手写版本。 -
有时初始化中的
tmp0可以不使用,直接在原数组上修改,最后原数组即保存小波矩阵叶层位向量的原始值。 -
所有涉及左端点 的操作,都可以将左闭换成左开,来省掉对 的偏移。
-
内层
bitset和外层wavelet matrix都使用 0-index,但在查询时使用 1-index,因为内层查 前缀和比 方便很多,查 时放到外层偏移掉 再换成右开,变为 。
struct waveletMatrix
{
int n, m;
vector <bitSet> bit;
waveletMatrix(int _n, int _m, int a[]): n(_n), m(_m)
{
bit.resize(m, bitSet(n));
vector <int> tmp0(a, a + n + 1), tmp1(n + 1);
for(int u = m - 1; ~u; u --)
{
int x = 0, y = 0;
for(int i = 1; i <= n; i ++)
if(tmp0[i] >> u & 1)
tmp1[++y] = tmp0[i], bit[u].set(i);
else tmp0[++x] = tmp0[i];
bit[u].init();
for(int i = 1; i <= y; i ++)
tmp0[x + i] = tmp1[i];
}
}
int id(int u, int k, int c)
{
return !c ? bit[u].rank0(k) : bit[u].zero + bit[u].rank1(k);
}
int di(int u, int k, int c)
{
return !c ? bit[u].select0(k) : bit[u].select1(k - bit[u].zero);
}
int access(int k)
{
int res = 0;
for(int u = m - 1; ~u; u --)
{
int c = bit[u].access(k);
res |= c << u;
k = id(u, k, c);
}
return res;
}
int rank(int l, int r, int v)
{
for(int u = m - 1; ~u; u --)
{
l = id(u, l - 1, v >> u & 1) + 1;
r = id(u, r, v >> u & 1);
}
return r - l + 1;
}
int select(int l, int k, int v)
{
for(int u = m - 1; ~u; u --)
l = id(u, l - 1, v >> u & 1) + 1;
l += k - 1;
for(int u = 0; u < m; u ++)
l = di(u, l, v >> u & 1);
return l;
}
int kth_min(int l, int r, int k)
{
int res = 0;
for(int u = m - 1; ~u; u --)
{
int c = bit[u].rank0(r) - bit[u].rank0(l - 1);
l = id(u, l - 1, c < k) + 1;
r = id(u, r, c < k);
if(c < k)
{
res |= 1 << u;
k -= c;
}
}
return res;
}
int ranking(int l, int r, int v)
{
int res = 0;
for(int u = m - 1; ~u; u --)
{
int c = v >> u & 1;
if(c) res += bit[u].rank0(r) - bit[u].rank0(l - 1);
l = id(u, l - 1, c) + 1;
r = id(u, r, c);
}
return res;
}
};
以上给出了一份标准化实现代码,比较繁杂且有很多不必要内容。以下则是一份简略版实现。
CPPstruct bits
{
int n;
vector <pair <ULL, int>> bit;
#define ppc __builtin_popcountll
bits() {}
bits(vector <ULL> arr)
{
n = (arr.size() >> 6) + 1, bit.resize(n);
for (int i : viota(0, ssize(arr)))
bit[i >> 6].first |= arr[i] << (i & 63);
for (int i : viota(1, n))
bit[i].second = bit[i - 1].second + ppc(bit[i - 1].first);
}
int ask(int k) {return bit[k >> 6].second +
ppc(bit[k >> 6].first & ((1ull << (k & 63)) - 1));}
#undef ppc
};
struct wave
{
int n;
array <bits, 30> bit;
array <int, 30> pos;
wave(vector <int> arr)
{
n = arr.size();
for (int u : viota(0, 30) | vreve)
{
bit[u] = bits (arr | vtran([&] (auto x)
{return x >> u & 1ull;}) | ranges::to <vector> ());
pos[u] = n - ranges::stable_partition
(arr, [&] (int x) {return ~x >> u & 1;}).size();
}
}
int kthmin(int l, int r, int k)
{
int v = 0; --l;
for (int u : viota(0, 30) | vreve)
{
int ll = bit[u].ask(l), rr = bit[u].ask(r);
if (int c = r - rr - l + ll; c >= k) l -= ll, r -= rr;
else v |= 1 << u, k -= c, l = pos[u] + ll, r = pos[u] + rr;
}
return v;
}
int rankin(int l, int r, int v)
{
int k = 0; --l;
for (int u : viota(0, 30) | vreve)
{
int ll = bit[u].ask(l), rr = bit[u].ask(r);
if (int c = r - rr - l + ll; ~v >> u & 1) l -= ll, r -= rr;
else k += c, l = pos[u] + ll, r = pos[u] + rr;
}
return k;
}
};
动态
小波矩阵是静态的数据结构,其维护的信息一般满足可减性或者说有逆元,需要插入删除时可以考虑维护一组负贡献的小波矩阵,可考虑几种经典实现:
-
定期重构,代价为单次修改均摊 ,支持插入删除。
-
二进制分组,代价为单次修改均摊 ,支持插入和特殊删除:若删除不可减且为撤销,考虑斜二进制分组,当存在三组相同大小的组时合并其中两组,删除时仍然重构,否则不支持删除。
-
平衡树维护内层位向量,代价为单次修改 ,支持插入删除。
例题
区间 kth-min
区间 kth-min 模板题,直接使用上面的模板即可。
在线写法直接对原数组修改,无需记录二分过程,返回最后端点即可。
离线则是考虑对每一位,把所有询问的移动统一处理,把小波矩阵做到 的空间复杂度,提交时为最优解 rk2。
在线2 和 主席树2 指的是,以题目的最大值域 为准,在线 和 主席树 则分别是 和 的值域。
区间 ranking
详细题解,使用区间 ranking 配合二进制分组即可。
区间 kth-min,ranking
详细题解,拆点后先做一遍 kth-min 再做一遍 ranking。
二分带权信息
详细题解,这里讲述一下实现。
要解决的问题为:给定长为 的数组 ,多次询问,求最大的 满足 ,且已发现可以二分 。
考虑类似二分答案的思想,维护可能的答案区间 ,已计算的答案 ,一定小于最终答案的元素之和 ,一定大于最终答案的元素个数 ,小波矩阵由于需要维护元素和,空间复杂度 。
记当前在第 层,若 满足条件,则 转移到 ,并更新 ,否则令 转移到 ,并更新 。
若不离散化则以 的时间直接得到答案,否则以 的时间得到原序列中的最优答案,此时 都已确定,计算不等式的最优解即可。
小波矩阵 2.51s 141.86mb 提交时是最优解 rk3,没打过 rk1 rk2 老哥的主席树。
主席树 2.46s 199.91mb 什么叫我的主席树跑到最优解了?
动态区间 kth-min,ranking
详细题解,把内层的
bitset 换成平衡树,看上去能做一车动态问题。矩形 kth-min
详细题解,树套小波矩阵,不彳亍,小波矩阵套小波矩阵,彳亍。
动态矩形 kth-min
详细题解,二进制分组合并常数太大了,根本跑不过带根号做法(悲。打不过就以后加入。
to be continued...
参考资料
相关推荐
评论
共 13 条评论,欢迎与作者交流。
正在加载评论...