点查找、区间树和线段树

计算几何中的正交范围查询(Orthogonal Range Search),旨在快速响应与坐标轴对齐的“长方形/立方体”范围的对象查找。

对象为点的查找

思路:范围树

一维查找

有序向量可以解决这个问题,但它不能扩展到高维。为此,我们使用建立平衡二叉树的预处理方法,叶子节点是对象本身,内部节点存储左子树包含的对象的最大值。若已经对象已经排好序,则预处理是 O(n) 的,否则是 O(n log n) 的。

查询 [a, b] 范围内的对象,只需在搜索树中查找 a, b,到达叶节点 u, v。假设从树根到 u 和树根到 v 的路径在 z 处之后分歧,那么列出范围内对象的方法为:从 u 到 z,报告所有“右边”的子树;从 z 到 v,报告所有“左边”的子树。

如果仅需要范围内对象的个数,那么查询是 O(log n) 的;如果要列出每个对象,则查询需要遍历每个“子树”,是 O(k + log n) 的。数据结构称范围树(range tree)

二维查找

我们需要查找 [x1, x2] x [y1, y2] 长方形范围内的点。推广一维的思路,首先忽略 y 坐标,建立一个 x 坐标的范围树(x-range tree)。然后,在每个内部节点,对其子树包含的点建立一个 y 坐标的范围树(y-range tree)。这样就得到了一个 2D 范围树。

查找的方法为,首先搜索 x-range tree,得到 O(log n) 个子树;然后对于每个子树搜索 y-range tree。因此总时间复杂度为 O(k + (log n)^2)。

考察 2D 范围树的空间复杂度,每个 y-range tree 的大小是 O(m) 的(m为子树大小),由于 x-range tree 的子树大小之和 Sigma_i m = 1*n + 2*(n/2) + … = n log n,所以整体的空间复杂度为 O(n log n) 的。

最终,考察构造 2D 范围树的时间复杂度。朴素的方法是 O(n) 时间建立 x-range tree,然后对每个子树独立建立 y-range tree,用时 Sigma_i O(m log m) = O( n (log n)^2 )。但是,如果自底向上构造每个子树的 y-range tree,就可以利用左右孩子的y-range tree,进行 O(m) 的归并代替 O(m log m) 的排序,所以时间复杂度降为 O(n log n)。

d 维查找

沿着以上思路,构造 d 维范围树,不难看出其查询时间为 O(k + (log n)^d)。不难由数学归纳法证明,构造它的时间复杂度为 O(n (log n)^{d-1}) 的。理由:每上升一个维度, Sigma_i m (log m)^{d-1} = n log n * (log n)^{d-1} = n (log n)^{d}。

另一种推导预处理复杂度的方法是主定理【附注1】。设对 n 个对象建立 k 维范围树的复杂度为 T_k(n)。由于范围树是自底向上构造的,考虑最后一步(构造出左右两个规模各为一半的范围树后,构造树根对应的低一维范围树)T_2(n) = 2*T_2(n/2) + O(n),T_2(n) = O(n log n)。T_3(n) = 2*T_3(n/2) + T_2(n), T_3(n) = O(n (log n)^2)。以此类推。

使用分散层叠

Fractional Cascading(一般译为分散层叠,后面简称为 FC)将相关的数据结构链接到一起,从而从一个结构快速定位到另一个结构。使用分散层叠。

FC 的最简单例子是,给定两个有序向量 A, B,B 的元素都包含在 A 中,要查找 [x1, x2] 范围内有哪些 A,B 的元素。通过建立从 A 中元素到 B 中元素的链接,可以先对 A 做二分查找,而后通过链接列出 B 中元素,免去对 B 做二分查找。

A: 11 13 14 15 17 19 23 37 42 45
   |     |  |        |  |
B: 11    14 15       23 37

应用在范围树中,我们在最后一维用有序向量代替 1d 范围树。倒数第二维(2d 范围树),让父节点和左右孩子节点的有序向量相链接(这是因为左右孩子节点的有序向量包含在父节点的有序向量中)。在查询最后两维 [x1, x2] x [y1, y2] 时,从树顶开始,一边依据 [x1, x2] 决定下降方向,一边在维护有序向量中 [y1, y2] 值区间对应的下标区间。整个查找过程是 O(k + log n) 的。

应用这个过程,d 维范围树的查找优化到了 O(k + (log n)^{d-1}) 的 (d>1)。

思路:k-d 树

范围树的空间占用高,预处理的时间空间开销大。k-d 树牺牲了查询复杂度,但结构更简单,实际性能也很好。

二维 k-d 树的构造

轮流依据 x,y 坐标二分点集,每次选取坐标值的中位数。不断二分,直到类别中只剩下 O(1) 个数据点。这样得到的树必定是平衡的,即高度为 O(log n)。在内部节点,存储包围盒坐标,称这个包围盒为 cell。

构建的方法如下:

  1. 首先把点集按照 x 坐标排序、y 坐标排序,得到两个有序向量,在两个有序向量之间添加链接,用时 O(n log n)。
  2. 按 x 坐标中位数将 x 有序向量分为两半,然后依据 y 有序向量到 x 有序向量的链接,将 y 有序向量扫描一遍,拆成两半,花费 O(n) 时间。此时,问题别拆分为两个规模为 n/2 的子问题。递归地拆分,设拆分规模为 n 的子问题用时 T(n),则 T(n) = 2T(n/2) + O(n),由主定理,T(n) = O(n log n)。(通用的选取中位数方法见【附注2】)

因此,建立二维 k-d 树的时间复杂度为 O(n log n);空间复杂度为 O(n)。

二维 k-d 树的搜索

搜索 k-d 树的方法比较显然:若包围盒包含于范围,则汇报包围盒中全部对象;若包围盒部分包含于范围,则递归地搜索左右孩子。

为了推导如上过程的复杂度,考虑包含 n 个元素的 cell 中,与任意线段相交的子 cell 的个数,设为 Q(n)。下降两层后,得到 4 个子 cell,注意到线段只能与这四个子 cell 中的两个相交。因此,Q(n) = 2Q(n/4) + O(1), Q(n) = O(n^{1/2})。因此,包含 n 个元素的 cell 中至多有 4Q(n) = O(n^{1/2}) 个子 cell 与长方形范围相交,因此搜索过程的复杂度为 O(k + n^{1/2})

高维 k-d 树

同理推导,搜索过程的复杂度为 O(k + n^{1-1/d})

对象为线段的查找

构造区间树的过程就像拿竹签子串串;构造线段树的过程就像让条带下落到树上,断开成若干段挂在树上。

区间树

问题:给定一维的 n 个区间,如何在 O(k + log n) 时间内列出包含某个点的所有线段(设有 k 条)?(称这个问题为 stabbing query)

思路:建立一颗”区间树 (Interval tree)“。列出所有区间端点,找出中位数(xm),作为根节点,存储所有包含该点的区间,这些区间按左端点和右端点顺序存储两次。剩下的区间要么在 xm 的左边,要么在 xm 的右边,分别为它们递归建立一颗区间树,作为根节点的左右孩子。

区间树的空间复杂度是 O(n) 的,不加证明地断言建立区间树用时 O(n log n) ,树高是 O(log n) 的。

查找方法:查询的点为 xq,若 xq==xm,直接输出根节点存储的所有区间即可;不妨设 xq < xm,那么1)遍历根节点存储的按左端点排序的区间,列出所有区间,直到左端点 > xq;2)递归地查询左孩子的区间树。

不难看出,查询的时间是 O(k + log n) 的。

线段树

问题:给定平面中不相交的N条线段,准备应对如下询问:给定一个新的竖直线段,返回所有与之相交的线段。

思路:考虑输入线段的x坐标投影,用所有端点的 x 坐标,将 x 轴划分为一系列区间。以这些区间为叶子节点建立一个平衡二叉树(称线段树 segment tree)。若线段 s 包含了节点 v(是线段),却不包含节点 v 的父节点,则把 s 存到 v 的节点列表中。由于线段是不相交的,所以该列表可以”从上到下“(按任意区间中的 x 坐标对应的 y 坐标)排序。

查询方法:使用竖直线段的 x 坐标查询平衡二叉树。每深入到一个节点,在该节点存储的列表中二分搜索找到符合 y 范围的线段汇报。

复杂度:平衡二叉树高度 O(log n);每个线段被存储在 O(log n) 个节点;预处理时间空间 O(n log n);查询时间O( k + log(n)^2 )

维护区间信息的线段树

计算几何中的”线段树“允许在 O(log n) 时间查询给定点包含在哪些区间内。它的变种,编程竞赛/测试中的线段树允许使用 O(log n) 时间询问和变更一个区间。

问题:已知一个长度为 n 的数列,进行如下两种操作:1) 将某个区间中的每个数加上 k,2) 求出某个区间内所有数的和。有办法在 O(log n) 内完成每个操作吗?题目来自 Luogu P3372 【模板】线段树 1。

思路:仿照计算几何的线段树,如果要更新的区间包含节点 v,却不包含 v 的父节点,则把该更新的信息记录在 v 上。由于这个标记不会立刻向 v 的左右孩子传递,称它为懒惰标记。在节点 v 上,维护该区间所有数的和。

代码:假设我们堆状建树,下标为 p 的左右孩子为 2*p, 2*p+1,则设懒惰标记数组为 lazy,区间和数组为 sum。由于懒惰标记的存在,sum 与真实的区间和之间有差距,相差祖先的累积懒惰标记乘以区间长度。

不变量是:

  • 全局:真实区间和 = sum[p] + 路径累计懒惰标记 * 区间长度
  • 局部:sum[p] = sum[2*p] + sum[2*p+1] + lazy[p] * p的区间长度

区间变更和区间查询操作

区间变更

从根开始递归访问,如果区间和节点区间没有交集则直接返回,区间包含节点区间则添加懒惰标记并返回,区间和节点区间有交集则更新 sum 并递归访问左右孩子。

void awardlazy(segment* tree, int i, int amount) {
    tree[i].lazy += amount;
    tree[i].sum += amount * (tree[i].r - tree[i].l + 1);
}

void rangeadd(segment* tree, int i, int lo, int hi, int amount) {
    if (lo > hi || hi < tree[i].l || lo > tree[i].r) return;
    if (lo > tree[i].l && hi < tree[i].r) tree[i].sum += (hi - lo + 1) * amount;
    else if (lo > tree[i].l) tree[i].sum += (tree[i].r - lo + 1) * amount;
    else if (hi < tree[i].r) tree[i].sum += (hi - tree[i].l + 1) * amount;
    else { // target interval contains node i
        awardlazy(tree, i, amount);
        return;
    }
    rangeadd(tree, 2*i, lo, hi, amount);
    rangeadd(tree, 2*i+1, lo, hi, amount);
}

区间查询

从根开始递归访问,如果区间和节点区间没有交集则直接返回 0,区间包含节点区间则直接返回 sum[p](我们保证发生这种情形时,根到 p 的路径上所有懒惰标记都已清除),区间和节点区间有交集则把 lazy[p] “下放” 到它的左右孩子(左右孩子的 lazy 和 sum 都要更新)并返回

ll lookup(segment* tree, int i, int lo, int hi) {
    if (lo > hi || hi < tree[i].l || lo > tree[i].r) return 0;
    if (lo <= tree[i].l && hi >= tree[i].r) {
        return tree[i].sum;
    }
    // need to recurse, propagate lazy mark
    awardlazy(tree, 2*i, tree[i].lazy);
    awardlazy(tree, 2*i+1, tree[i].lazy);
    tree[i].lazy = 0;
    return lookup(tree, 2*i, lo, hi) + lookup(tree, 2*i+1, lo, hi);
}

加难:支持乘法区间变更

现在需要支持第三种操作:将某个区间中的每个数乘以 k。题目来自 Luogu P3373 【模板】线段树 2。

为此,我们为添加一个新懒惰标记标记应该乘以多少。至此,有两个懒惰标记:add 表示“该加多少”,mult 表示“该乘多少”。

假设沿 p 回溯到根的路径依次经过 p~1, p~2, …, (这里 ~ 借用了 git 中的用法)不变量变为:

  • 全局:真实区间和 = (sum[p] * mul[p~1] + len(p) * add[p~1]) * mul[p~2] + len(p) * add[p~2] …
  • 局部:sum[p] = (sum[2*p] + sum[2*p+1]) * mul[p]+ len(p) * add[p]

注意,计算的次序是“先乘后加”的。

下放懒惰标记的办法

// if has child, must propagate any existing lazy marks before adding new ones
void try_push_lazy(segment* tree, int i) {
    if (tree[i].l != tree[i].r) {
        tree[2*i].sum = (tree[2*i].sum * tree[i].lazy_mult + tree[i].lazy_add * (tree[2*i].r - tree[2*i].l + 1)) % MOD;
        tree[2*i+1].sum = (tree[2*i+1].sum * tree[i].lazy_mult + tree[i].lazy_add * (tree[2*i+1].r - tree[2*i+1].l + 1)) % MOD;
        tree[2*i].lazy_mult = (tree[2*i].lazy_mult * tree[i].lazy_mult) % MOD;
        tree[2*i+1].lazy_mult = (tree[2*i+1].lazy_mult * tree[i].lazy_mult) % MOD;
        tree[2*i].lazy_add = (tree[2*i].lazy_add * tree[i].lazy_mult + tree[i].lazy_add) % MOD;
        tree[2*i+1].lazy_add = (tree[2*i+1].lazy_add * tree[i].lazy_mult + tree[i].lazy_add) % MOD;
        // reset
        tree[i].lazy_add = 0;
        tree[i].lazy_mult = 1;
    }
}

注意,一个节点可能同时有 add 和 mult 两种懒惰标记,需要依据“先乘后加”的运算规则下放,以保证不变量成立。

区间变更的方法

ll rangemult(segment* tree, int i, int lo, int hi, int mult) {
    // assert that the path from root to i does not contain lazy add or mult
    if (lo > hi || hi < tree[i].l || lo > tree[i].r) return tree[i].sum;
    try_push_lazy(tree, i);
    if (lo <= tree[i].l && hi >= tree[i].r) {
        awardlazymult(tree, i, mult);
        return tree[i].sum;
    }
    tree[i].sum = (rangemult(tree, 2*i, lo, hi, mult) + rangemult(tree, 2*i+1, lo, hi, mult)) % MOD;
    return tree[i].sum;
}

该递归调用保证当前节点到树根的路径上没有懒惰标记。为了维护这个承诺,如果递归调用必须事先下放懒惰标记。

区间变更后的 sum 其实不好计算,需要让左右孩子返回自己的 sum,然后依据线段树的局部不变量计算。

附注

【附注1】主定理

假设输入大小为 n 的算法的运行时间满足 T(n) = a*T(n/b) + f(n)

情况1:f(n) = O(n^c), c<log_b(a)
则 T(n) = BigTheta(n^{log_b(a)})

情况2:f(n) = BigTheta( n^{log_b(a)} * (log(n))^k ), k \geq 0
则 T(n) = BigTheta( n^{log_b(a)} * (log(n))^{k+1} )

情况3:f(n) = BigOmega(n^c), c>log_b(a)
如果存在 k < 1, 满足任意足够大的 n,a*f(n/b) <= kf(n),则 T(n) = BigTheta(f(n))

【附注2】quickselect 算法

从 n 个元素的无序列表中选取出第 k 大的元素,时间复杂度是多少?朴素的办法是排序 O(n log n),而时间复杂度的下限是 O(n),因为在访问所有元素之前不可能断言某个元素第 k 大。

quickselect 是一个平均用时 O(n) 的算法。它的思想和 quicksort 很像,选取一个支点 pivot,交换数组中的元素使得支点左边的元素不超过支点,支点右边的元素不小于支点:

# input pivot position, output new position of pivot after swap
def partition(A, lo, hi, pivot): # A[lo, hi]
    pivotvalue = A[pivot]
    A[hi], A[pivot] = A[pivot], A[hi] # swap pivot to right end
    while lo < hi:
        # fill gap at A[hi]
        while lo < hi and A[lo] <= pivotvalue:
            lo += 1
        A[hi] = A[lo]
        # fill gap at A[lo]
        while lo < hi and A[hi] >= pivotvalue:
            hi -= 1
        A[lo] = A[hi]
    A[lo] = pivotvalue
    return lo

求第 k 小的元素,可以通过让 A[k-1] 成为支点解决。不断试探性地选取支点(策略较重要),交换数组元素,考察支点的新位置:如果恰好等于 k-1,则问题解决;如果小于 k-1,则我们可以只考虑右半边的数组;如果大于 k-1,则我们可以只考虑左半边的数组。

def kth_small(A, k):
    k = k-1 # make A[k-1] a pivot
    lo, hi = 0, len(A)-1
    while lo < hi:
        pos = partition(A, lo, hi, lo)
        if pos <= k:
            lo = pos+1
        if pos >= k:
            hi = pos-1
    return A[k]

由上面的算法可见,在求出第 k 小元素的同时,quickselect 算法也保证了第 k 小元素出现在 A[k-1] 的位置,且 A[0..k-1) 不超过 A[k-1],A(k-1..n) 不小于 A[k-1],无序向量被分为了两部分。

期望用时

设对 n 个元素求第 k 小,用时期望为 T(n)。试探性选择支点并交换数组元素,得知支点的下标变为 j, 从而将其转换为子问题的过程表明 T(n) \leq (n-1) + (1/n) * Sigma_{j=0}^{n-1} T(max(j, n-j-1)) \leq (n-1) + (2/n) * Sigma_{j=n/2}^{n-1} T(j)。

不妨假设 T(n) \leq c*n,则 c 应满足 c \leq (n-1)/n + (c/4) * (2n-2-n)/n * (2n-2+n)/n \leq 1 + c*3/4,取 c=4 即可。因此,quickselect 的平均用时为 O(n)。


Posted

in

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *