UOJ Logo return20071007的博客

博客

标签

一种基于斜二进制的序列&树上数据结构

2024-02-24 10:31:05 By return20071007

作者自己胡的×要是重复造轮子了就当搬运吧,感觉挺适合往 OI 里搬。

本文将介绍一种可以在 $O(\log n)$ 的时间内支持单点修改区间查询,并能够在 $O(1)$ 时间内完成简单末尾追加的数据结构,与它的一点树上扩展。

注:本文中提及的“线段树”均指朴素递归线段树。序列版本不管是码长还是常数都打不过高级线段树。


  • 斜二进制

斜二进制是一种奇怪的进制。它从低到高第 $i\ge1$ 位位权为 $2^i-1$。用斜二进制表示一个数时,需要满足至多一位为二且低于此位者均为零,其余位不超过一。定义一个斜二进制数的最低有效位是它最低位值非零的位。

我们将使用一种归纳构造的方式来生成每个自然数的斜二进制分解。0 的斜二进制是 0。我们假设已经对于 $n$ 生成了它的斜二进制分解,那么 $n+1$ 的斜二进制将在此基础上进行分类:

  1. $n$ 的斜二进制分解最低有效位为二。

不妨这是第 $i$ 位,那么乘上二的位值即为 $2\times(2^i-1)=2^{i+1}-2$,再加上新多出来的一就是 $2^{i+1}-1$。于是我们清除这两位并向前一位进一即可。

  1. $n$ 的斜二进制分解最低有效位不为二。

在第一位上加一即可。

不难验证以此法生成的斜二进制分解符合它应有的性质。

这么讲可能比较抽象,我们来举点例子。

  • 0 的斜二进制是 0。

  • 1 的斜二进制是 1。因为 0 的最低有效位不为二。

  • 2 的斜二进制是 2。因为 1 的最低有效位不为二。

  • 3 的斜二进制是 10。因为 2 的最低有效位为二,向前进一。

  • 4 的斜二进制是 11。因为 10 的最低有效位不为二。

  • 5 的斜二进制是 12。因为 11 的最低有效位不为二。

  • 6 的斜二进制是 20。因为 12 的最低有效位为二,向前进一。

  • 7 的斜二进制是 100。因为 20 的最低有效位为二,向前进一。

  • 8 的斜二进制是 101。因为 100 的最低有效位不为二。

  • 9 的斜二进制是 102。因为 101 的最低有效位不为二。

  • 10 的斜二进制是 110。因为 102 的最低有效位为二,向前进一。

  • 11 的斜二进制是 111。因为 110 的最低有效位不为二。

  • 12 的斜二进制是 112。因为 111 的最低有效位不为二。

  • 13 的斜二进制是 120。因为 112 的最低有效位为二,向前进一。

总之大概就是这样。


  • 后树

一棵后树维护一个长度为 $2^n-1$ 的序列,其中 $n\ge 1$。

后树的根管辖整个区间,根的左子树是区间的前 $2^{n-1}-1$ 个元素构建的后树,右子树是区间的接下来 $2^{n-1}-1$ 个元素构建的后树。注意最后一个元素不属于任何一棵子树,它是特别的——也正因如此,我们可以将一棵后树的根编号为它最后一个元素的下标。

不难发现,如果假设整个序列从一开始编号并把后树的每个节点的斜二进制写出,那么根管辖的斜二进制区间将是 $(0,100\cdots0]$,左子树的管辖区间是 $ (0,10\cdots0]$,右子树的管辖区间是 $(10\cdots0,20\cdots0]$。我们不难证明,一个点 $i$ 为根的子树的管辖区间的左端点 $j=i-\textit{skew_lowbit}(i)$,其中 $\textit{skew_lowbit}(i)$ 表示 $i$ 在斜二进制下的最低有效位。


  • 数据结构

我们考虑将 $n$ 进行斜二进制分解并用一系列后树来维护整个序列。类似这样。

如果这里出现了文字那就是图挂了。

我们对于每个下标 $i$ 维护四个东西:

  • $a_i$:序列第 $i$ 个元素的值。

  • $\textit{tr}_i$:序列上 $(i-\textit{skew_lowbit}(i),i]$ 下标的元素合并后的结果(本质上是在后树上的子树“和”)。

  • $\textit{lb}_i$:$i-\textit{skew_lowbit}(i)$。

  • $\textit{tf}_i$:点 $i$ 在后树上的父亲(不存在则置零)。

(这几个东西在缩写之前分别是:arraytreeleft_boundtree_father。)

我们以下将对几个常见操作分别说明。以和为例。

  • 节点更新

在除了 $\textit{tr}_i$ 未知以外所有必要信息均已知的情况下计算 $\textit{tr}_i$,俗称 pushup。由于并不知道 $i$ 会有零个还是两个儿子,我们的手段相当暴力:遍历所有子树进行累和。

void pushup(int x) {
  tr[x] = a[x];
  for (int y = x - 1; tf[y] == x; y = lb[y]) tr[x] += tr[y];
}

有群 u 指出应该实现得更精细一点所以也写了一版没那么暴力的,效率差距不大:

void pushup(int x) {
  if (int p = x - 1; p == lb[x])
    tr[x] = a[x];
  else
    tr[x] = a[x] + tr[p] + tr[lb[p]];
}
  • 末尾追加

$a$ 可以直接把追加的值薅过去,$\textit{tr}$ 可以调用 pushup 处理,那么我们实际上要做的就是把 $\textit{tf}$ 和 $\textit{lb}$ 维护好。

注意到只有新加点的 $\textit{lb}$ 和它两个儿子(若有)的 $tf$ 可能需要变化,我们仿照斜二进制分解的归纳构造操作就行。对于最低有效位为二的情况,我们可以简单地找到新加点的两个儿子;对于最低有效位不为二的情况,新加点没有儿子。

获取一个数 $i$ 的斜二进制最低有效位可以直接 $i-\textit{lb}_i$ 简单处理。

void append(int x) {
  int p = n, q = lb[n++], r = lb[q];
  if (tr[n] = x, !q || p - q != q - r)
    lb[n] = n - 1;
  else
    lb[tf[p] = tf[q] = n] = r;
  pushup(x);
}
  • 建立

嗯做就行。本质是挨个 append

void build() {
  for (int i = 1; i <= n; ++i) {
    int p = i - 1, q = lb[p], r = lb[q];
    if (!q || p - q != q - lb[q])
      lb[i] = i - 1;
    else
      lb[tf[p] = tf[q] = i] = r;
    pushup(i);
  }
}
  • 单点修改。

注意到树的结构没有变化,$a$ 变化的只有被修改的位置。于是我们主要考虑 $\textit{tr}$ 的变化。注意到 $tr_i$ 受影响仅当 $i$ 是被修改位置在后树上的祖先。于是我们沿着 $\textit{tf}$ 一路往上遍历祖先就行。

下面这个例子是单点加。

void update(int x, int d) {
  for (a[x] += d; x; x = tf[x]) pushup(x);
}
  • 区间查询。

从待查区间 $[l,r]$ 的右端点 $r$ 开始向 $l$ 条,有 $\textit{tr}$ 跳 $\textit{tr}$ 没 $\textit{tr}$ 跳 $a$ 就行。可能讲得比较抽象,下面挂个代码。标准库函数 exchange(u, v) 的含义等同于是执行 u = v 并返回执行u

int query(int l, int r) {
  int ans = 0;
  while (r >= l)
    if (int x = lb[r]; x < l - 1)
      ans += a[r--];
    else
      ans += tr[exchange(r, x)];
  return ans;
}

接下来我们证一下这个东西的复杂度。我们定义一个位置 $i$ 的高度 $h_i$ 是以它为根的后树的大小加一以二为底的对数。不难发现每个点是不超过 $O(\log n)$ 的正整数。特别地,我们定义零的高度是全局最高高度加一。

于是我们有了这么一条事情:

  • $h_{\textit{lb}_{\textit{lb}_i}}\ge h_i+1$。

写个斜二进制,这是显然的。

这条告诉我们,在 if 的第一个分支被执行到之前,$h_r$ 至多两步一升,复杂度是 $O(\log n)$ 的。

而在此之后我们又会有这么第二条事情:

  • 若 $r$ 被执行第一个分支时 $h_r=x$,那么此后 $h_r

因为 $r$ 之前最后一个满足 $h_i\ge x$ 的位置 $i$ 是 $\textit{lb}_r\lt x-1$,它不可能进入循环体。

而根据上面的第一条,走第一个分支之后,至多再走一步第二个分支,一定会再回到第一个分支(否则连走两步之后 $h_r$ 加一,与第二条矛盾)。于是我们得知,在 if 的第一个分支被执行到之后,$h_r$ 至多两步一减,复杂度是 $O(\log n)$ 的。

综上,区间查询的复杂度是 $O(\log n)$ 的。


挂个【模板】树状数组 1 的完整代码吧:

#include <bits/stdc++.h>
using namespace std;
constexpr int N = 5e5 + 9;
int n, m, lb[N], tf[N], a[N], tr[N];
void pushup(int x) {
  tr[x] = a[x];
  for (int y = x - 1; tf[y] == x; y = lb[y]) tr[x] += tr[y];
}
void build() {
  for (int i = 1; i <= n; ++i) {
    int p = i - 1, q = lb[p], r = lb[q];
    if (!q || p - q != q - lb[q])
      lb[i] = i - 1;
    else
      lb[tf[p] = tf[q] = i] = r;
    pushup(i);
  }
}
void update(int x, int d) {
  for (a[x] += d; x; x = tf[x]) pushup(x);
}
int query(int l, int r) {
  int ans = 0;
  while (r >= l)
    if (int x = lb[r]; x < l - 1)
      ans += a[r--];
    else
      ans += tr[exchange(r, x)];
  return ans;
}
signed main() {
  cin.tie(nullptr)->sync_with_stdio(false);
  cin >> n >> m;
  for (int i = 1; i <= n; ++i) cin >> a[i];
  build();
  for (int op; m; --m)
    if (cin >> op; op == 1) {
      int x, d;
      cin >> x >> d;
      update(x, d);
    } else {
      int l, r;
      cin >> l >> r;
      cout << query(l, r) << '\n';
    }
  return cout << flush, 0;
}

不难发现,该算法在简洁方面对比线段树有较大优势,功能强度方面介于树状数组与线段树之间。常数应该不小。

它的作用?总之大概能提供一个新的选择吧。写起来比线段树快,比树状数组逻辑清晰(不需要前缀和差分的转)。


事实上这个东西还能上树。不难发现每个点拖一个区间这个事情可以直接挂在每个点上往祖先去拖。不难发现这样依然可以在 $O(\log n)$ 的时间内刻画出树上的一条路径。另一方面,序列上需要倍增的场景其实并不多见,即使有也许多为双向,但树上倍增一般都是往祖先,这使得它可以更好地适应形如 “找到第一个满足某条件”的祖先之类的问题。

这种数据结构的预处理时空是 $O(n)$,查询是 $O(\log n)$,同时代码相当简短,在大部分情况下可以作为树上倍增的上位替代。功能性除了支持动态加叶子是优势以外一般不如树剖,但时空常数和码量均更优(笔者个人实现的时间是树剖的三分之一),同时逻辑也更清晰。严格劣 ST 树。

初始化非常简单:

void dfs(int x) {
  int p = fa[x], q = lb[p], r = lb[q];
  lb[x] = d[p] - d[q] != d[q] - d[r] ? p : r;
  for (d[x] = d[p] + 1; int y : es[x])
    if (y != fa[x]) fa[y] = x, dfs(y);
}

这里给出一份查 LCA 的代码,不难发现略加修改即可查询静态链信息:

int lca(int u, int v) {
  if (d[u] < d[v]) swap(u, v);
  while (d[u] > d[v])
    if (d[lb[u]] < d[v])
      u = fa[u];
    else
      u = lb[u];
  while (u != v)
    if (lb[u] == lb[v])
      u = fa[u], v = fa[v];
    else
      u = lb[u], v = lb[v];
  return u;
}

同时可以看到其实逻辑和倍增是很类似的,易于配套理解。

如果要维护路径信息的话,以[JLOI2015] 城池攻占 为例,初始化稍微改一下:

void dfs(int x) {
  int p = fa[x], q = lb[p], r = lb[q];
  if (!q || d[p] - d[q] != d[q] - d[r])
    lb[x] = p, tr[x] = vl[x];
  else
    lb[x] = r, tr[x] = vl[x] + tr[p] + tr[q];
  for (d[x] = d[p] + 1; int y : es[x]) dfs(y);
}

(这题是输入每个点的爹所以向下搜不需要判。)

查询就是每次跳 $\textit{lb}$,跳不了就跳爹:

int calc(int x, int h) {
  while (x)
    if (func f = tr[x]; h >= f.xl && h <= f.xr)
      h = f(h), x = lb[x];
    else if (f = vl[x]; h >= f.xl && h <= f.xr)
      h = f(h), x = fa[x];
    else
      break;
  return x;
}

Upd on 2024.3.1:来活了,注意到 DAG 支配树需要支持的操作是动态加叶子和求 LCA,专业对口。目前是洛谷最优解。

#include <bits/stdc++.h>
using namespace std;
constexpr int S = 1 << 21, N = 65535;
char buf[S], *p1, *p2, obuf[S], *O = obuf;
inline char gc() {
  if (p1 == p2) {
    p2 = (p1 = buf) + cin.read(buf, S).gcount();
    if (p1 == p2) return EOF;
  }
  return *p1++;
}
inline int rd() {
  char ch;
  while (!isdigit(ch = gc()))
    ;
  int x = ch & 0xf;
  while (isdigit(ch = gc())) x = x * 10 + (ch & 0xf);
  return x;
}
inline void prtln(int x) {
  char s[7];
  int t = 0;
  if (!x)
    *O++ = '0';
  else {
    while (x) s[t++] = x % 10 | '0', x /= 10;
    while (t) *O++ = s[--t];
  }
  *O++ = '\n';
}
basic_string<int> es[N];
int n, fa[N], d[N], lb[N], dg[N], sz[N];
inline void lca(int& u, int v) {
  if (!~u) return u = v, void();
  if (d[u] < d[v]) swap(u, v);
  while (d[u] > d[v]) d[lb[u]] < d[v] ? (u = fa[u]) : (u = lb[u]);
  while (u != v)
    lb[u] == lb[v] ? (u = fa[u], v = fa[v]) : (u = lb[u], v = lb[v]);
}
inline void addf(int x) {
  int p = fa[x], q = lb[p], r = lb[q];
  d[x] = d[p] + 1, lb[x] = d[p] - d[q] != d[q] - d[r] ? p : r;
}
inline void build() {
  memset(fa + 1, -1, n * sizeof(int));
  int q[N], l = 0, r = 0, u;
  for (int i = 1; i <= n; ++i)
    if (!dg[i]) fa[q[r++] = i] = 0, d[i] = 1;
  while (l < r)
    for (addf(u = q[l++]); int v : es[u])
      if (lca(fa[v], u), !--dg[v]) q[r++] = v;
  while (r--)
    if (int f = fa[u = q[r]]) sz[f] += sz[u] + 1;
}
signed main() {
  cin.tie(nullptr)->sync_with_stdio(false);
  n = rd();
  for (int i = 1; i <= n; ++i)
    while (int j = rd()) es[j].push_back(i), ++dg[i];
  build(), for_each_n(sz + 1, n, prtln);
  return cout.write(obuf, O - obuf).flush(), 0;
}

Upd on 2024.4.10:整了个例题 https://www.luogu.com.cn/problem/U421630


同时这个东西还可以用来写全局平衡二叉树。但这个时候我们会意识到一个很难受的事情:根的位置被固定了,无法再保证每个点子树大小和父亲的关系。但是这个事情我们魔怔一下就能解决:把本来的左右儿子之间插一个中儿子就行,等于是一个点有仨儿子,其中中间那个的子树大小为一。那么我们的性质就又没问题了。写出来会比朴素 GBST 稍微少一点特判。

例题:[ZJOI2008] 树的统计。写的线性建树。

#include <bits/stdc++.h>
#define int long long
using namespace std;
constexpr int N = 3e4 + 9;
int n, m, fa[N], top[N], sn[N], sz[N], d[N], lb[N], tf[N], stk[N], sum[N], tp;
vector<int> es[N];
struct info {
  int sm, mx;
  info() : sm(0), mx(INT_MIN) {}
  info(int x) : sm(x), mx(x) {}
  info(int sm, int mx) : sm(sm), mx(mx) {}
  info& operator+=(const info& to) {
    return sm += to.sm, mx = max(mx, to.mx), *this;
  }
  info operator+(const info& to) const { return info(*this) += to; }
} a[N], tr[N];
int dfs1(int x) {
  d[x] = d[fa[x]] + 1;
  for (int y : es[x]) {
    erase(es[y], fa[y] = x);
    if (sz[x] += dfs1(y), sz[y] > sz[sn[x]]) sn[x] = y;
  }
  return ++sz[x];
}
inline void pushup(int x) {
  tr[x] = a[x];
  for (int y = fa[x]; tf[y] == x; y = lb[y]) tr[x] += tr[y];
}
int build(int l, int r) {
  int x = stk[r--];
  if (lb[x] = stk[l - 1], l <= r) {
    int i = upper_bound(sum + l, sum + r + 1, (sum[l - 1] + sum[r]) >> 1) - sum;
    int y = stk[i];
    tr[y] = a[y], lb[y] = stk[i - 1], tf[y] = x;
    l < i && (tf[build(l, i - 1)] = x), i < r && (tf[build(i + 1, r)] = x);
  }
  return pushup(x), x;
}
void dfs2(int x, int t) {
  top[stk[++tp] = x] = t, sum[tp] = sum[tp - 1] + sz[x];
  if (int z = sn[x]) {
    for (sum[tp] -= sz[z], dfs2(z, t); int y : es[x])
      if (y != z) dfs2(y, y);
  } else
    build(tp - (d[x] - d[t]), tp);
  --tp;
}
inline int lca(int u, int v) {
  while (top[u] != top[v]) {
    if (d[top[u]] < d[top[v]]) swap(u, v);
    u = fa[top[u]];
  }
  return d[u] < d[v] ? u : v;
}
inline void update(int x, int t) {
  for (a[x] = t; x; x = tf[x]) pushup(x);
}
inline info qlink(int u, int z) {
  info ans;
  while (d[u] >= z)
    if (int x = lb[u]; d[x] < z - 1)
      ans += a[exchange(u, fa[u])];
    else
      ans += tr[exchange(u, x)];
  return ans;
}
inline info query(int u, int v) {
  int z = d[lca(u, v)];
  return qlink(u, z) + qlink(v, z + 1);
}
signed main() {
  cin.tie(nullptr)->sync_with_stdio(false);
  cin >> n;
  for (int i = 1, u, v; i < n; ++i)
    cin >> u >> v, es[u].push_back(v), es[v].push_back(u);
  for (int i = 1, x; i <= n; ++i) cin >> x, a[i] = x;
  dfs1(1), dfs2(1, 1);
  for (cin >> m; m; --m) {
    string s;
    int x, y;
    if (cin >> s >> x >> y, s == "CHANGE")
      update(x, y);
    else if (auto [sm, mx] = query(x, y); s == "QMAX")
      cout << mx << '\n';
    else
      cout << sm << '\n';
  }
  return cout << flush, 0;
}

Upd on 2024.3.9:

感谢 @ClHg2 的指出,直接把中儿子塞到左儿子里复杂度也是对的,因为一个点的爷爷权和一定至少是自己的两倍所以树高依然是 $\log$ 的。常数会更小一点。

改完的代码:

#include <bits/stdc++.h>
#define int long long
using namespace std;
constexpr int N = 3e4 + 9;
int n, m, fa[N], top[N], sn[N], sz[N], d[N], lb[N], tf[N], stk[N], sum[N], tp;
vector<int> es[N];
struct info {
  int sm, mx;
  info() : sm(0), mx(INT_MIN) {}
  info(int x) : sm(x), mx(x) {}
  info(int sm, int mx) : sm(sm), mx(mx) {}
  info& operator+=(const info& to) {
    return sm += to.sm, mx = max(mx, to.mx), *this;
  }
  info operator+(const info& to) const { return info(*this) += to; }
} a[N], tr[N];
int dfs1(int x) {
  d[x] = d[fa[x]] + 1;
  for (int y : es[x]) {
    erase(es[y], fa[y] = x);
    if (sz[x] += dfs1(y), sz[y] > sz[sn[x]]) sn[x] = y;
  }
  return ++sz[x];
}
inline void pushup(int x) {
  tr[x] = a[x];
  for (int y = fa[x]; tf[y] == x; y = lb[y]) tr[x] += tr[y];
}
int build(int l, int r) {
  int x = stk[r--];
  if (lb[x] = stk[l - 1], l <= r) {
    int i = upper_bound(sum + l, sum + r + 1, (sum[l - 1] + sum[r]) >> 1) - sum;
    tf[build(l, i)] = x, i < r && (tf[build(i + 1, r)] = x);
  }
  return pushup(x), x;
}
void dfs2(int x, int t) {
  top[stk[++tp] = x] = t, sum[tp] = sum[tp - 1] + sz[x];
  if (int z = sn[x]) {
    for (sum[tp] -= sz[z], dfs2(z, t); int y : es[x])
      if (y != z) dfs2(y, y);
  } else
    build(tp - (d[x] - d[t]), tp);
  --tp;
}
inline int lca(int u, int v) {
  while (top[u] != top[v]) {
    if (d[top[u]] < d[top[v]]) swap(u, v);
    u = fa[top[u]];
  }
  return d[u] < d[v] ? u : v;
}
inline void update(int x, int t) {
  for (a[x] = t; x; x = tf[x]) pushup(x);
}
inline info qlink(int u, int z) {
  info ans;
  while (d[u] >= z)
    if (int x = lb[u]; d[x] < z - 1)
      ans += a[exchange(u, fa[u])];
    else
      ans += tr[exchange(u, x)];
  return ans;
}
inline info query(int u, int v) {
  int z = d[lca(u, v)];
  return qlink(u, z) + qlink(v, z + 1);
}
signed main() {
  cin.tie(nullptr)->sync_with_stdio(false);
  cin >> n;
  for (int i = 1, u, v; i < n; ++i)
    cin >> u >> v, es[u].push_back(v), es[v].push_back(u);
  for (int i = 1, x; i <= n; ++i) cin >> x, a[i] = x;
  dfs1(1), dfs2(1, 1);
  for (cin >> m; m; --m) {
    string s;
    int x, y;
    if (cin >> s >> x >> y, s == "CHANGE")
      update(x, y);
    else if (auto [sm, mx] = query(x, y); s == "QMAX")
      cout << mx << '\n';
    else
      cout << sm << '\n';
  }
  return cout << flush, 0;
}

具体有啥更多的应用可能还有待进一步发掘。感觉是个非常适合引入 OI 的东西,尤其是平替树上倍增的部分(?

以上。

【理性愉悦】无向连通图正整数边权线性时空单源最短路浅析

2023-09-16 22:30:03 By return20071007

作为相关 paper 的学习笔记好了。基本但不是百分百翻译论文。

前置知识:

  1. Dijkstra(下称 Dj),和其他一些同难度的基础算法。

  2. 至少一种线性树上并查集。不会的话可以看这个(如果会 Top Cluster 树分块,注意那个启发式合并改成裸的双优并查集也是对的)或者这个(0 基础也能看!!1)。

开始前提示:

  1. WRM($\omega=\Omega(\log n)$)。

  2. 线性 Dj 双向规约线性排序,这个大家还不太会,所以这个算法对 Dj 进行了一定的延展。但由于其本质上还是基于 Dj 那一套贪心思路的,我们在文章前期依然可能会用 Dj 中的相关流程来进行指代。

  3. 不保证笔者写得很顺畅,但保证一定大体能看。可能会有细节脑抽(比如把 $[v]_i$ 写成 $v[i]$),如有发现还望指出。

  4. paper 主部分有 38 个引理 7 个伪代码,作为进度条本文会在每个引理和伪代码前面给出编号。这里由于没有 paper 在解决问题之后碎碎念的一大段所以只有 37 个引理。

  5. $\subset$ 不取等号。

  6. 涉及数据结构 Q 堆(实现出来会被降格为平凡暴力数组所以不用太在意)。晚点可能补二叉堆版代码以及补那个单 $\alpha$ 但不需要原子堆的做法。

  7. 全文共 27k 文字分析纯干货,请放心食用。

SSSP,启动!


阅读更多……

【理性愉悦】如何优雅地玩转线性-常数时间复杂度

2023-07-30 22:53:04 By return20071007

本文收录部分 $\Theta(n)$ 预处理 $\Theta(1)$($\Theta(n)-\Theta(1)$)操作的强制在线算法(这个说法准确吗……反正大概就这意思啦)。

对于同一个问题可能不会收录所有算法,点名 RMQ。

基本前置:Word-RAM Model($w=\Omega(\log n)$)。


1.RMQ

世界名画。

首先我们很容易导出一个 $\Theta(n\log n)-\Theta(1)$ 的算法,用 ST 表还是二区间合并看心情。我们把原序列按 $\Theta(\log n)$ 分块,块间运行这个算法。

询问如果跨块则可以用一个后缀+块间+一个前缀拼起来,其中前后缀显然也可以 $\Theta(n)$ 预处理出来。当然如果不在意常数的话,前后缀的部分也可以一并用块内的算法运行,无妨(事实上这样比较方便在板子题测正确性)。

那么我们现在仅考虑一个块内的询问。我们考虑 RMQ 的一种 $\Theta(n)-\Theta(\log n)$ 离线算法:把询问 $[l,r]$ 按右端点排序,扫描线维护序列 $[1,r]$ 下标上的单调栈。我们发现单调栈的一个位置 $i$ 实际上代表着 $[i,r]$ 的后缀最小值在 $i$,那么我们在单调栈上二分找到第一个 $\ge l$ 的位置就可以了。我们试图把这个算法套上块。由于序列长度仅为 $O(\log n)$,我们实际上可以把每个位置的前缀单调栈压在一个二进制数里,进而把每个位置的前缀单调栈都存下来。这一步让我们不再需要离线。至于找第一个 $\ge l$ 的位置更是可以用一步位运算直接化归到找一个二进制数最低位 1 的位置,而这个是可以 $\Theta(1)$ 做的。这一步让我们撇去了复杂度里的 $\log$。

综上所述,我们现在的 RMQ 算法成功被优化成了 $\Theta(n)-\Theta(1)$。


2.LCA

常数比较好的写法是 dfs 序求 LCA,序列长度上比欧拉序求 LCA 短一倍。在此介绍一下。

考虑我们现在求 $u$ 和 $v$ 的 LCA,不妨有 $dfn_u\le dfn_v$,这里 $dfn$ 指节点的 dfs 序。

如果 $u=v$ 那么答案就是 $u$,直接判掉;否则,我们考虑 $[dfn_u+1,dfn_v]$ 这一段 dfn 上对应深度最小的节点。如果 $u$ 是 $v$ 的祖先,那么这个点一定是 $u$ 的儿子;否则,考虑从 $u$ 到 $v$ 的 dfs 过程中一定不会再经过 LCA(其在 $u$ 前已被访问),也不会跨越到 LCA 的子树之外。考虑到 $u$ 与 $v$ 在 LCA 的不同子树中,这部分一定会访问到 $v$ 对应子树的根(否则无法跨越到该子树内),那么这个根就是我们求出深度最小的节点。我们发现,这个节点一定是 LCA 的儿子,直接取它的父亲作为答案即可。

求区间最小深度这部分使用 RMQ 的优化,我们把 LCA 也成功优化成了 $\Theta(n)-\Theta(1)$。


3.LA

其实就是给定一棵树求给定点的给定级祖先。根据长链剖分那一套理论我们有一个简单的 $O(n\log n)-\Theta(1)$ 算法。我们简单优化一下这个东西并把它改成 $O(p\log n)-\Theta(1)$ 的,其中 $p$ 是树的叶子数。考虑到我们唯一更改的其实是只需要维护每个叶子的二的幂级祖先,这个我们在 dfs 的过程中维护一个祖先栈并在访问到叶子时直接根据下标找祖先即可。

这里我们介绍一个有趣的东西:ART Decomposition。简单来说,这个算法对树进行分解。考虑每个极高(自身满足且父亲不满足或没有父亲)点满足其子树有 $O(B)$ 个节点,那么很显然所有这些被取出的子树两两无交。把所有这些子树从原树上删去,那么新树的叶子必然满足原来子树内有 $\Omega(B)$ 个点,那么新树有 $O(\frac nB)$ 个叶子。我们取 $B=\Theta(\log n)$,那么新树可以直接跑那个长剖算法,我们把问题规模实际上降低到了 $O(\log n)$。

(注意这里 ART 玩起来很舒适是因为我们的复杂度瓶颈给到了叶子数量上。如果不是这样的话,我们还需要考虑把只有一个儿子的节点和儿子压起来,并用一个链上数据结构处理压起来的链。一般而言为了复杂度考虑这部分还得再分块,而且有时需要不同的维护方法,主打的就是一个阴间。)

我们套娃一层,现在问题的规模变成了 $O(\log\log n)$。那么我们对于每个点存下它的所有祖先,这需要的二进制位数是 $O(\log\log n\log\log\log n)$ ,也即 $O(w)$ 的。接下来根据一些简单的位运算操作我们就可以在常数时间内求出 k 级祖先了。我们把 LA 也成功优化成了 $\Theta(n)-\Theta(1)$。


4.UFDS(上树版)

我们现在解决这样一个问题:给定一棵树支持两种操作:连接一个点与父亲的边,或者查询两个点是否联通。

老规矩,我们先试图导出一个 $O(n\log n)-\Theta(1)$ 的算法。考虑对每个点维护其联通块编号,合并时启发式合并即可。简便起见,我们的启发式合并可以直接用度数之和,这样在树上就可以直接暴搜求 size 了。

我们考虑把树按 $\Theta(\log n)$ 分块。块间显然可以运行这个算法。至于块内,我们考虑把一个点与父亲的边状态用一个布尔值标记作为这个点的点权。我们考虑用一个联通块内最高的点作为这个联通块的根,那么一个点所在联通块的根就是最低的权为 1 的点。我们考虑把每个点按照拓扑序(具体的爱啥序啥序)排列并顺次对应给一个二进制位,这一位的值表示这个点的权值;然后我们对于每个点维护一个二进制数压缩存储块内的每一个数(拓扑序位置意义下)是否为其祖先。查询的时候把两个部分按位与然后取最高位(拓扑序最大)即可。

我们还涉及到另外一个问题:查 $u$ 和 $v$ 所在块的 LCA,以及其块与两段路径对应的临界点。我们考虑 LCA 那个算法,求出最小值所在位置附带本身 dfs 序作为第二关键字,那么求出最大 dfs 序的最小值就是 $v$ 所在块一边路径与 LCA 的临界点。至于 $u$ 的临界点,可以直接暴力反过来搞一遍,也可以求出最小 dfs 序的最小值再维护一下其左边的兄弟,没有所谓。我们把 UFDS 也成功优化成了 $\Theta(n)-\Theta(1)$。


5.SDFU(?)

标题瞎起的,本质上就是想说把 UFDS 的问题反过来:给定一棵树支持两种操作:断开一个点与父亲的边,或者查询两个点是否联通。别忘了这篇文章的大前提是强制在线。

我们发现块内并没有什么区别,我们接下来只考虑怎么导出一个 $O(n\log n)-\Theta(1)$ 的算法。我们注意到上面那个启发式合并的算法,如果我们每次能精准找到断开两部分中 size 较小的一个并重新赋联通块编号,那么我们的问题就解决了。我们考虑修改一下 dfs 的步骤:维护一个栈,每个元素有三个部分:当前点,当前点的前驱和已经搜到了这个点的哪一条出边;每一次,弹出栈顶元素,把对应出边的点(若合法)入栈,然后修改原栈顶的已搜到出边。如此一来,我们考虑直接对断开边 $(u,v)$ 的 $u$ 和 $v$ 进行一个平行的 dfs,哪边先到底哪边就 size 较小。当我们找到较小的 size 时另一边也至多搜了这个规模次,于是我们的复杂度就正确了。继续树分块,我们把 SDFU(?)也成功优化成了 $\Theta(n)-\Theta(1)$。


6.区间绝对众数

标题终于不是令人迷惑的字母缩写力(喜)。

注意这个做法依赖哈希,所以不同于其他命题,这个东西的复杂度会看起来有点蠢。不过哈希一般是可以防卡的,所以先这样吧。

我们把问题分为两部分:求值和检验。

对于求值,老规矩 $\Theta(\log n)$分块,由于摩投具有结合律所以块间可以直接猫。块内递归一层把问题规模降到 $O(\log\log n)$,注意到这时候离散化后的本质不同序列和查询只有 $O((\log\log n)^{\log\log n}\times(\log\log n)^2)=O(2^{\log\log\log n\times\log\log n}(\log\log n)^2)$ 也即 $O(n)$ 种。直接摩投预处理即可。

对于检验,就是给定 $[l,r,x]$ 判断 $x$ 是否为 $[l,r]$ 的绝对众数。我们有结论,对于给定的 $x$,其作为绝对众数出现的所有区间并集大小和是 $O(c)$ 的,其中 $c$ 为 $x$ 的出现次数。具体证明可以考虑把折线图画出来然后对于每个峰把贡献累进去然后缩掉。我们考虑如何求出这个并集。我们考虑枚举“一段并集中的极长连续段左起第一个 $x$”出现的位置 $k$,可以证明其一定是 $2i-p_i$($p_i$ 表示 $x$ 第 $i$ 次出现的位置)某个前缀最小值所在处(的 $p$)。我们注意到这个连续段的左端点一定存在一个可以实现绝对众数的区间满足其右起第一个 $x$ 出现的位置是 $k$ 开始第一个后缀最大值,而右端点对应区间右起第一个 $x$ 出现的位置是能承受的极远后缀最大值,这两部分都可以双指针维护,最后把所有区间丢一起取并(左端点天然单调)然后用一个游标维护所有并集中的极长连续段(及其左端点前一个位置)的 $x$ 出现次数的前缀和,用哈希表维护。我们意识到,如果前缀和表中查不到 $l-1$ 或者 $r$ 那么一定寄了,否则我们两个前缀和一减就可以判了。

如此一来,我们把区间绝对众数也优化成了 $\Theta(n)-\Theta(1)$,虽然是期望意义下的。


7.RMSQ

人话:区间最大子段和。

我们不妨原序列是 $A$,下标从一开始。我们首先计算其前缀和数组 $C$,规定 $C_0=0$。我们考虑对于 $C$ 的每一个位置 $i$ 求出其之前最后一个 $C$ 值不小于自己的位置 $L_i$,若不存在则记为 0。我们于是有了第一条性质:$\forall i\in[1,n],j\in(L_i,i)$ 有 $C_j\lt C_i$。我们注意到,对于子段 $[j,i]$,如果 $j\le L_i$,那么换成 $[j,L_i]$ 一定不劣,于是我们不需要考虑这样的子段对答案的可能贡献。

我们再对于每个 $i$ 预处理 $[L_i,i)$ 上 $C$ 的最小值位置加一(如果有多个,取最靠右的那一个),记作 $P_i$。我们于是有了第二条性质:$\forall i\in[1,n],j\in[P_i,i)$ 有 $C_j\gt C_{P_i-1}$。综合这两条性质我们可以推出:$\forall i\in[1,n],j\in[P_i,i)$ 有 $C_j\in(C_{P_i-1},C_i)$。

(当然 $P$ 的定义本身也带给我们一个比较显然的性质:$\forall i\in[1,n],j\in[L_i,i)$ 有 $C_j\ge C_{P_i-1}$。这个不用太过在意。)

我们再记 $M_i=C_i-C_{P_i-1}$,换而言之就是 $[P_i,i]$ 的区间和。我们注意到,对于以 $i$ 为结尾的有效子段 $[j,i]$,由于 $j\in(L_i,i]$,故其和 $C_i-C_{j-1}\le C_i-C_{P_i-1}$。也就是说,如果只是要求全局的最大子段和,我们把所有 $M$ 取 $\max$ 已经做完了。

考虑对 $[l,r]$ 区间查询的解决方案。我们考虑暂且无视 $P$ 的限制直接硬做,求出 $M_l$ 到 $M_r$ 的最大值所在位置 $i$。如果 $P_i\ge l$,那我们显然已经做完了,返回即可。否则我们即有 $P_i\lt l$。由于 $P_i\gt L_i$,我们得到 $L_i\lt l$。根据我们前面的性质,我们推得 $\forall j\in[l,i)$ 有 $C_j\lt C_i$,也就是说以 $j$ 为结尾的最大子段和一定不如改成以 $i$ 结尾,直接不考虑。对于以 $i$ 为结尾的最大子段,其左端点减一的 $C$ 值要取到最小,这是一个 RMQ 的形式,直接做掉即可。对于以 $j\in(i,r]$ 的最大子段,我们考虑 $P_j$ 的位置:

若 $P_j\le P_i$:$P_i\in[L_j,j)$,推出 $C_{P_i-1}\ge C_{P_j-1}$;$i\in(L_j,j)$,推出 $C_i\lt C_j$。进而 $M_j=C_j-C_{P_j-1}\gt C_i-C_{P_i-1}=M_i$,与 $M_i$ 的最大性矛盾。

若 $P_i\lt P_j\le i$:$i\in[P_j,j)$,推出 $C_i\gt C_{P_j-1}$;但 $P_j-1\in[P_i,i)$,推出 $C_{P_j-1}\lt C_i$,矛盾。

若 $i\lt P_j\le j$:$P_j\ge l$,这是一个合法的区间。

综上所述,对于 $j\in(i,r]$,一定有 $P_j\ge l$ 合法,直接在 $M$ 上查 RMQ 即可。

于是我们就在三次 RMQ 内解决了区间最大子段和问题。由于 RMQ 已经是 $\Theta(n)-\Theta(1)$ 的了,我们把区间最大子段和也优化成了 $\Theta(n)-\Theta(1)$。


总结一下,序列上的问题化归到最后本质上基本都是 $\log$ 分块然后块间二进制瞎搞(个别的递归一层)。树上的问题除了 LCA 拍上链以外别的基本都是用的树分块或者 ART(实际上 UFDS 和反问题也可以这么搞,就是有点阴间)。

更多的问题能不能用类似的套路搞,比如区间 or?或者进一步区间 nand?限于笔者水平,在此不得而知,若读者有所了解,还请不吝赐教;如若文章存在纰漏,也请劳烦指出。

有个暴论,一个正常的 01 序列静态区间查询全都可以这么搞,因为 $\log$ 分块后块内总状态数有限,如果不行就再递归一层。

至此,且容我暂且搁笔。

K r u s k a l 重 构 链

2022-03-24 08:59:05 By return20071007

算是个笔记好了。

众所周知,Kruskal 重构树干的事情本质上是:把树上路径边权最大值查询转换为 LCA 问题。但同时我们还有另外一种搞法,可以把它转化成 RMQ。

具体来说,在 Kruskal 的过程中,对于每个连通块维护一条链。初始的时候链只有孤点,在合并连通块时,把两端连通块的链首尾相接,中间连边的权值就是当前边权。在最后得到的这个链的结构上,两点间边权的最大值就等于是两点在树上边权的最大值。

为了证明这一点,我们可以先观察到一个事情:链和重构树只是连通块内部的两种结构,对于大的连通性并没有影响。换句话说,对于任何 $w$,仅保留原树中 $\le w$ 的边,那么两种结构维护下的任意连通状态完全没有任何区别。然后链上 RMQ 和树上路径最大值都可以认为是找到最大的 $w$ 使得仅保留原树中 $\le w$ 的边,指定的两点连通。于是我们的这个算法就是正确的。

这个链的结构显然是用链表结构会比较方便。当然启发式合并复杂度也是对的。然后这里不管是 RMQ 还是找连通性刻画(就是一般在 Kruskal 重构树上跳倍增的玩意)实际上 ST 表线段树也好四毛子也好都会好搞的。就算是最朴素的 ST 表,相比于重构树也能省下一半空间。

以下是 [SCOI2013]摩托车交易 的代码,写挺丑的,还要开 C++20,感性理解一下就好。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
constexpr int N = 1e5 + 9;
int fa[N];
int findf(int x) { return x == fa[x] ? x : fa[x] = findf(fa[x]); }
int n, m, q, id[N], b[N], rt;
int ed[N], nxt[N], pos[N];
ll val[N], sgt[N << 1];
ll dst(int u, int v) {
  ll ret = LLONG_MAX;
  if ((u = pos[u]) > (v = pos[v])) swap(u, v);
  for (u += n, v += n; u < v; u >>= 1, v >>= 1) {
    if (u & 1) ret = min(ret, sgt[u++]);
    if (v & 1) ret = min(ret, sgt[--v]);
  }
  return ret;
}
signed main() {
  cin.tie(nullptr)->sync_with_stdio(false);
  cin >> n >> m >> q;
  for (int i = 1; i <= n; ++i) cin >> id[i];
  for (int i = 1; i <= n; ++i) cin >> b[i];
  vector<tuple<ll, int, int>> es(m);
  for (auto& [w, u, v] : es) cin >> u >> v >> w;
  for (int i = 1; i <= n; ++i) fa[i] = ed[i] = i;
  for (int x, rt = 0; q; --q, rt = x)
    if (cin >> x; rt) es.emplace_back(LLONG_MAX, rt, x);
  for (ranges::sort(es, greater()); auto [w, u, v] : es)
    if ((u = findf(u)) != (v = findf(v)))
      nxt[ed[u]] = v, val[ed[u]] = w, ed[fa[v] = u] = ed[v];
  for (int i = 1; i <= n; ++i)
    if (fa[i] == i) {
      for (int j = 0; j < n; ++j) sgt[(pos[i] = j) + n] = val[i], i = nxt[i];
      break;
    }
  for (int i = n - 1; i; --i) sgt[i] = min(sgt[i << 1], sgt[i << 1 | 1]);
  ll tmp = 0;
  if (b[id[1]] < 0)
    cout << 0 << '\n';
  else
    tmp = b[id[1]];
  for (int i = 2; i <= n; ++i)
    if (tmp = min(tmp, dst(id[i - 1], id[i])); b[id[i]] > 0)
      tmp += b[id[i]];
    else
      cout << min(-(ll)b[id[i]], tmp) << '\n', tmp = max(tmp + b[id[i]], 0ll);
  return cout << flush, 0;
}

以下是另一份很丑的 [NOIP2013 提高组] 货车运输 的代码,可以看到这个结构在图不保证连通的时候实际上代码变化极少。

#include <bits/stdc++.h>
using namespace std;
constexpr int N = 1e4 + 9;
int fa[N];
int findf(int x) { return x == fa[x] ? x : fa[x] = findf(fa[x]); }
int n, m, q;
int ed[N], nxt[N], pos[N];
int val[N], sgt[N << 1];
int dst(int u, int v) {
  int ret = INT_MAX;
  if ((u = pos[u]) > (v = pos[v])) swap(u, v);
  for (u += n, v += n; u < v; u >>= 1, v >>= 1) {
    if (u & 1) ret = min(ret, sgt[u++]);
    if (v & 1) ret = min(ret, sgt[--v]);
  }
  return ret;
}
signed main() {
  cin.tie(nullptr)->sync_with_stdio(false);
  cin >> n >> m;
  vector<tuple<int, int, int>> es(m);
  for (auto& [w, u, v] : es) cin >> u >> v >> w;
  for (int i = 1; i <= n; ++i) fa[i] = ed[i] = i;
  memset(val + 1, -1, n * sizeof(int));
  for (ranges::sort(es, greater()); auto [w, u, v] : es)
    if ((u = findf(u)) != (v = findf(v)))
      nxt[ed[u]] = v, val[ed[u]] = w, ed[fa[v] = u] = ed[v];
  for (int i = 1, j = -1; i <= n; ++i)
    if (fa[i] == i)
      for (int x = i; x; x = nxt[x]) sgt[(pos[x] = ++j) + n] = val[x];
  for (int i = n - 1; i; --i) sgt[i] = min(sgt[i << 1], sgt[i << 1 | 1]);
  for (cin >> q; q; --q) {
    int u, v;
    cin >> u >> v;
    cout << dst(u, v) << '\n';
  }
  return cout << flush, 0;
}

一种野蛮处理静态树上在线链查询的做法

2022-01-01 18:57:59 By return20071007

算是个笔记吧。

一棵 $n$ 个点的树,带边权,每次给定 $u,v$ 求从 $u$ 到 $v$ 的路径上的边权乘积,带模数,强制在线。

然后把询问搞得多一点,这个时候就要求我们的查询得要在常数时间内完成。

这里给出一种预处理 $O(n\log n)$(空间同)单次查询 $O(1)$ 的常数巨大的做法。

首先把询问拆成两个点分别到 LCA,这一步显然可以用欧拉序+ST表等方式在这个时空限制内完成。

把树剖一下。然后发现一个点到祖先的路径可以拆成一条“一个点到一个链顶”的路径,和最后一小段一个点到祖先的路径,分别做即可。

对于最后那段查询,它的两个端点在一条重链上,所以在 dfn 序上连续,问题转化为在序列上询问,猫树做掉。

然后之前那些“一个点到一个链顶”的询问,观察到一个点到根至多有 $O(\log n)$ 个链顶,那么可以把所有这样的路径预处理出来然后做掉。

至于如何预处理,可以每个点后面拖个列表表示这个点到根路径上的链顶们的答案。每个点直接从父亲继承这个列表然后每个更新一下,如果自己是链顶那么在列表后面加个数挂着就好了。时空复杂度 $O(n\log n)$。

那么还剩两个事:

  • 给定一个点和一个链顶,找到这个点的列表中这个链顶的出现位置。

对于每种这样的一个点和一个链顶,把答案存下来就好了。因为列表里有这个链顶的点一定都在这个链顶的子树内,dfn 序连续,所以很好算,每个链顶后面拖个位置列表就好了。

  • 之前提到的一个点到祖先的询问中,那个“分界”链顶(也就是前面用上述预处理,后面用猫树)是哪个。

找到祖先点所在链的链顶,按照上述找出现位置的方法找到在那个点的列表中出现在了哪里,进行一个 1 的偏差即可得到分界链顶的位置,进而将其求出(不过其实就不用求出来了,直接查前缀和就好了)。

于是我们的问题就解决了。

板子题还没找到,代码没写过,目测常数巨大。不过如果 $n$ 稍微小一点,似乎是可以接受的(因为常数主要在预处理)。

以上。

一种野蛮处理静态树上离线链查询的做法

2021-12-06 21:23:43 By return20071007

算是个笔记吧。

众所周知,用 Tarjan 求 LCA 可以在 $\Theta(n+m)$ 的时间内完成。

UPD1:有误,最慢可以到一个 $\log$。

UPD2:找到个论文,至少期望还是线性复杂度。

#include <bits/stdc++.h>
using namespace std;
constexpr int N = 5e5 + 9;
int n, m, s;
int fa[N];
int ans[N];
vector<pair<int, int>> q[N];
vector<int> es[N];
int findf(int x) { return fa[x] == x ? x : fa[x] = findf(fa[x]); }
void dfs(int x, int f) {
  for (int y : es[x])
    if (y != f) dfs(y, x);
  for (auto [y, id] : q[x])
    if (!ans[id])
      ans[id] = -1;
    else if (ans[id] == -1)
      ans[id] = findf(y);
  fa[x] = f;
}
int main() {
  cin >> n >> m >> s;
  for (int u, v, i = 1; i < n; ++i)
    cin >> u >> v, es[u].push_back(v), es[v].push_back(u);
  for (int u, v, i = 1; i <= m; ++i)
    cin >> u >> v, q[u].emplace_back(v, i), q[v].emplace_back(u, i);
  for (int i = 1; i <= n; ++i) fa[i] = i;
  dfs(s, 0);
  for (int i = 1; i <= m; ++i) cout << ans[i] << endl;
  return 0;
}

(代码比较丑,意会即可。)

这个算法的精髓之一就在于其中一个点在并查集上跳到根,就是 LCA。如果我们在 dfs 之后把邻接表倒过来再来一次,那么另一个点跳到根也能跳到 LCA。

而树上链查询是可以分成两段分别到 LCA 的查询(这个 LCA 甚至不用显式求出),而一段到 LCA 的查询显然可以把 Tarjan 算法里的并查集加装成带权版轻松搞定。

似乎挺漂亮的一个玩意。代码写过但没找到纯板题,写的那题(链查询最大值)无关代码过多,所以不贴了。

以上。

一种野蛮处理静态序列多次在线区间查询的数据结构

2021-01-18 16:50:43 By return20071007

$O(n\log^*n)$(<-记 $\log^*n$ 表示 $n$ 最多能连续取几次 $\log$) 预处理然后 $O(1)$ 查询,但在 OI 里没啥意义,本文不作讨论。

空间复杂度同预处理复杂度。

不知道现在 OI 界是否有同样的算法,如果没有的话,可以称之为三叶虫树(UTT),有的话麻烦评论区告知,会删掉这个命名。

UPD:由于评论区已被神仙告知有,命名删除。不过博主还是不知道其结构是否相同(感觉不太是?),所以还是挂在这里吧。求轻喷。

接着是算法。

观察 zkw 线段树区间查询的查询结构,以 RMQ 为例:

  for (l += M - 1, r += M + 1; l ^ r ^ 1; l >>= 1, r >>= 1) {
    if (~l & 1) ret = max(ret, zkw[l ^ 1]);
    if (r & 1) ret = max(ret, zkw[r ^ 1]);
  }

发现就是两边链查询,查询的信息类似于一条链上所有左/右儿子的兄弟们的最大值。

然后我们就只需要解决从叶子开始的链查询问题了,用一个 pair 维护这两个信息,然后甚至连原线段树都能省了。

我们观察到,如果预处理出每一种这样的链的答案,那查询就能做常数时间,但是预处理复杂度是 $O(n\log n)$ 的。

之所以造成这样的复杂度是因为出现了一堆所谓“冗余”信息,因为很容易发现不同节点的前缀和路径中会出现巨量重叠.

那我们如果把重叠的部分选择性记录下来不就可以省时间了?

于是我们给线段树分层以记录重叠信息。

然后,观察三叶虫的结构(?):

很容易发现无论是横向还是纵向都被分成了三部分,于是我们采用仿生技术(?),把线段树也分成三部分。

画个图:

在每一部分内做从叶子开始的“前缀和”(<-其实是前缀操作,这里就以和为例,姑且称作前缀和了)。

比如说这个线段树的这个分法,所有会被计算的前缀和(就以和为例到底了吧):

最底下一层:

16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31

中间那层:

8,9,10,11,12,13,14,15,8+4,9+4,10+5,11+5,12+6,13+6.14+7,15+7

最顶上一层:

2,3,2+1,3+1

对就是这样。

然后查询显然还是常数时间。

至于怎么分这棵树,有两种处理方法:直接数学推决策,或者干脆先花 $O(\log^2 n)$ 的时间枚举两条线然后看何时最优。

然后 OI 用的场景,数据范围绝大部分情况下不会超过 $2^{28}\approx2.68\times10^8$ 对吧,这个场景下预处理复杂度可以视作线性(枚举一下就会发现常数差不多 $3$ 左右)。空间复杂度同预处理复杂度。

如果真的有什么毒瘤到数据范围出得比这还大的出题人,那也没事反正常数还是很小(误),我们就要升级一下我们的三叶虫树了,把它变成四叶虫树(真)

真就把分三层改成分四层就好了。这玩意的查询显然还是常数时间,预处理的常数当序列长度不到 $2^{2059}$ 时常数严控于 $3$。空间复杂度依然同预处理复杂度。

不可能真的有出题人出再大的数据范围了,不可能了。

真的要再大的话,复杂度开头给了。此时无非就是多分几层,然后再加一个层间前缀和(不用管这是什么)把查询稳在常数时间。空间复杂度还是同预处理复杂度。

例题:第一行输入序列长度和查询数,接着一行输入序列,接着若干行每行一个查询区间,所有输入为 $10^5$ 以内的正整数。

代码(分三层+枚举决策版):

#include <bits/stdc++.h>
using namespace std;
typedef pair<int, int> pii;
const int N = 1e5 + 9, Mx = 1 << 17;
int n, m, M = 1, h, lg[Mx << 1], x, y;
pii val[Mx << 1];
pii* suf[Mx << 1];
pii pmax(const pii& a, const pii& b) {
  return make_pair(max(a.first, b.first), max(a.second, b.second));
}
pii qlink(int pos, int len) {
  if (h - y > len) return suf[pos][len];
  pii ret = suf[pos][h - y - 1];
  len -= h - y, pos >>= h - y;
  if (y - x > len) return pmax(ret, suf[pos][len]);
  ret = pmax(ret, suf[pos][y - x - 1]);
  len -= y - x, pos >>= y - x;
  return pmax(ret, suf[pos][len]);
}
int main() {
  scanf("%d%d", &n, &m);
  while (M <= n || h <= 1) M <<= 1, ++h;
  for (int i = M + 1, a; i <= M + n; ++i) {
    scanf("%d", &a);
    ((i & 1) ? val[i ^ 1].first : val[i ^ 1].second) = a;
  }
  for (int i = M - 1; i > 1; --i)
    ((i & 1) ? val[i ^ 1].first : val[i ^ 1].second) =
        max(val[i << 1].first, val[i << 1 | 1].second);
  for (int x = 0, mx = INT_MAX; x < h; ++x)
    for (int y = x + 1; y < h; ++y) {
      int tmp = (x << x) + ((y - x) << y) + ((h - y) << h);
      if (tmp < mx) mx = tmp, ::x = x, ::y = y;
    }
  for (int i = (1 << x); i < (1 << (x + 1)); ++i) {
    suf[i] = new pii[x];
    suf[i][0] = val[i];
    for (int j = 1; j < x; ++j) suf[i][j] = pmax(suf[i][j - 1], val[i >> j]);
  }
  for (int i = (1 << y); i < (1 << (y + 1)); ++i) {
    suf[i] = new pii[y - x];
    suf[i][0] = val[i];
    for (int j = 1; j < y - x; ++j)
      suf[i][j] = pmax(suf[i][j - 1], val[i >> j]);
  }
  for (int i = M; i < M + M; ++i) {
    suf[i] = new pii[h - y];
    suf[i][0] = val[i];
    for (int j = 1; j < h - y; ++j)
      suf[i][j] = pmax(suf[i][j - 1], val[i >> j]);
  }
  for (int i = 2; i < M + M; ++i) lg[i] = lg[i >> 1] + 1;
  for (int l, r; m; --m) {
    scanf("%d%d", &l, &r), l += M - 1, r += M + 1;
    int L = qlink(l, lg[l ^ r] - 1).first;
    int R = qlink(r, lg[l ^ r] - 1).second;
    printf("%d\n", max(L, R));
  }
  //接下来应该要有一些手动释放内存的部分(可以 vector 实现然后省掉)。
  //但是测试 OJ 上不允许手动释放,并且程序终止时本身就会自动释放,所以没了。
  return 0;
}

缺点是常数略大,优点是复杂度漂亮。

是博主(至少自认为)原创的,鉴于博主孤陋寡闻,如果已有同样数据结构还请不吝告知。

以上。

如何优雅地在 zkw 线段树上跑无修 rmq 【笔记】

2021-01-17 09:51:44 By return20071007

本文将简介如何优雅地在 zkw 线段树上跑无修 rmq。

核心:数据结构优化线段树。

普通的 zkw 线段树解法分为线性建树和单次 $O(\log n)$ 的查询,显然查询是瓶颈。

观察 zkw 线段树的查询结构:

  for (l += M - 1, r += M + 1; l ^ r ^ 1; l >>= 1, r >>= 1) {
    if (~l & 1) ret = max(ret, zkw[l ^ 1]);
    if (r & 1) ret = max(ret, zkw[r ^ 1]);
  }

发现就是两边链查询,查询的信息类似于一条链上所有左/右儿子的兄弟们的最大值。

它是可以快速合并的,直接采用倍增优化(树上 ST 表),然后 $O(1)$ 回答询问。

由于树高 $O(\log n)$,故预处理 ST 的时空复杂度均为 $O(n\log\log n)$。

甚至可以原线段树都不要了,直接用数组建出 ST 表(见代码)。

综上,预处理时间复杂度:$O(n\log\log n)$,单次查询复杂度 $O(1)$,空间复杂度 $O(n\log\log n)$,优点是代码较短。

例题:第一行输入序列长度和查询数,接着一行输入序列,接着若干行每行一个查询区间,所有输入为 $10^5$ 以内的正整数。

代码:

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 9, Mx = 1 << 17;
int n, m, M = 1, lg[Mx << 1];
pair<int, int> val[Mx << 1][5];
int main() {
  scanf("%d%d", &n, &m);
  while (M <= n) M <<= 1;
  for (int i = M + 1, a; i <= M + n; ++i) {
    scanf("%d", &a);
    ((i & 1) ? val[i ^ 1][0].first : val[i ^ 1][0].second) = a;
  }
  for (int i = M - 1; i > 1; --i)
    ((i & 1) ? val[i ^ 1][0].first : val[i ^ 1][0].second) =
        max(val[i << 1][0].first, val[i << 1 | 1][0].second);
  for (int i = 2; i < M + M; ++i) lg[i] = lg[i >> 1] + 1;
  for (int i = 2; i < M + M; ++i)
    for (int h = 1; h < 5; ++h) {
      val[i][h].first =
          max(val[i][h - 1].first, val[i >> (1 << (h - 1))][h - 1].first);
      val[i][h].second =
          max(val[i][h - 1].second, val[i >> (1 << (h - 1))][h - 1].second);
    }
  for (int l, r, len; m; --m) {
    scanf("%d%d", &l, &r);
    l += M - 1, r += M + 1, len = lg[l ^ r];
    printf("%d\n", max(max(val[l][lg[len]].first,
                           val[l >> (len - (1 << lg[len]))][lg[len]].first),
                       max(val[r][lg[len]].second,
                           val[r >> (len - (1 << lg[len]))][lg[len]].second)));
  }
  return 0;
}

(注:其实也可以类似复杂度用于维护其它类似信息比如区间或。

维护不可随意重叠的信息如区间乘积取模时查询复杂度 $O(\log\log n)$,预处理复杂度和空间复杂度不变。)

关于#205数据范围

2020-11-27 17:58:59 By return20071007

题目里的数据范围全部是 $M$,但应该都改成 $N+M$。

bzoj 是这样的,题目里检验 hack 合法性判的也是 $N+M$。

像这样?

子任务 1(7 分):$N=1$,$1\le N+M\le100$。

子任务 2(19 分):$1\le N+M\le300$,且开关到任一烟花的距离不超过 $300$。

子任务 3(29 分):$1\le N+M\le5000$。

子任务 4(45 分):$1\le N+M\le3\times10^5$。

时间限制:$1\texttt{s}$

空间限制:$256\texttt{MB}$

共 9 篇博客