算法学习笔记(3): 倍增

倍增

倍增,字面意思即”成倍增长“

他与二分十分类似,都是基于”2“的划分思想

那么具体是怎么样,我们以一个例子来看


查找 洛谷P2249

依据题面,我们知道这是一个单调序列,当然可以通过二分的方式来寻找答案,但是既然我们这里讲倍增,那么就用倍增来写吧!

首先,我们先贴上核心代码

void find(int k) {
    int i = 0, p = 1;
    while (p) {
        if (i + p < n && a[i + p] < k) i += p, p <<= 1;
        else p >>= 1;
    }

    if (a[i + 1] == k) 
        printf("%d ", i + 1);
    else
        printf("-1 ");
}

其中 i 表示所寻找的下标, p 表示步长。

算法步骤如下:

  1. 保证 i + p 没有超过上界并比较 a[i + p]k 的大小关系,如果小于 k ,证明最终答案必定在 i 之后,所以将 i 设为 i + p ,并将步长 p 乘以 2 ;否则,将步长 p 除以 2

  2. 重复上一步,直到步长 p == 0 ,此时, a[i] 为严格小于 k 的最后一个数。

  3. 如果 a[i + 1] 不为 k ,则 k 不存在于数组中,输出 -1 ;否则,输出 i + 1

其实不难发现,其实这种代码比而二分的代码简洁了很多,所以我很喜欢用倍增

了解了上述步骤,我们可以发现,倍增的思想体现在步长之上,那为什么步长关于 2 的变换时正确的呢?

其实我们很容易知道,每一个数都可以以二进制数表示,而这里的步长从某种意义上来说相当于对于数的每一个二进制位的修改。即是用了“二进制划分”的思想。


但是这种写法真的很好吗?在实际的测试中,这种写法的常数可能在 \(1.2 \sim 1.5\) 倍的样子。

所以我们需要优化常数

考虑如下代码:

int find(int v) {
    int idx = 0;
    for (int w = 1 << (int)log2(n); w; w >>= 1) {
        if (idx + w <= n && a[idx + w] < v)
            idx += w;
    }

    return a[idx + 1] == v ? idx + 1 : -1;
}

先不说简单很多的问题……又短又快。

考虑在二进制中,\(2^k\) 的影响一定是大于 \(2^{k - 1}\) 的。

这启示我们可以逐步减少步长,尝试向前走。于是有了此写法。常数十分优秀。


但是第二种写法能够完全代替第一种写法吗?

答案是不可以的,例如 109. 天才ACM - AcWing题库

这道题只能使用第一种写法才能保证复杂度为 \(O(n \log n)\) 或者 \(O(n \log^2 n)\) 的。

为什么?我们可以从更严谨的复杂度写法上看出端倪。

不妨设每一次判断的代价为 \(f(x)\),那么对于第一种写法,如果答案为 \(a\),那么其实际复杂度是 \(O(f(a) \log a)\) 的,而对于第二种写法,实际的复杂度是 \(O(f(n) \log n)\) 的,与答案的关系没有关系。

其实后者更可以看作是二分的小常数版本,所以复杂度都是 \(O(f(n) \log n)\) 的。


重点

像上面代码写的倍增最终 i 的位置是最后一个满足 if 后的条件的位置


变式练习

如果我们把问题改为寻找最后一次出现的位置呢?这时算法该如何书写?

其实非常类似的!

int find(int k) {
    int i = 0, p = 1;
    while (p) {
        if (i + p <= n && a[i + p] <= k) i += p, p <<= 1;
        else p >>= 1;
    }

    return a[i] == k ? i : -1;
}

或者是:

int find(int v) {
    int idx = 0;
    for (int w = 1 << (int)log2(n); w; w >>= 1) {
        if (idx + w <= n && a[idx + w] <= v)
            idx += w;
    }

    return a[idx] == v ? idx : -1;
}

快速幂

其实,从上面的例子中我们已经对于倍增的思想有了一些体会。

实际上,“倍增”与“二进制划分”两个思想相互结合,才碰撞出了不一样的烟火。如这里的快速幂。

快速幂可以参考这篇文章:算法学习笔记(4):快速幂 - 知乎 (zhihu.com)

但是,在这篇文章的讲述中,快速幂的递归形式实际上时使用了二分的思想。而只有递推的形式才属于倍增的思想。

其实这里我们可以看出倍增与二分的联系:倍增类似于二分的逆过程,当然,这并不准确。

上面链接所给文章中快速幂讲述的十分清楚,甚至有额外的拓展,所以就不再详细展开。

这里给出一个快速幂的参考代码

// (a**x) % p
int quickPow(int a, int x, int p) {
    int r = 1;
    for (; x; x >>= 1, a = (a * a) % p)
        if (x & 1) r = (r * a) % p;
    return r;
}

ST表

见文章:算法学习笔记(3.1) - ST 表


倍增更多的用法

优化矩形查询

在静态的序列上查找一个区间非常的 naive 了,所以考虑在静态的矩形上查找一个矩形区间的信息。

f[x][y][i][j] 表示左下角为 \((x, y)\),右上角为 \((x + 2^i - 1, y + 2^j - 1)\) 的矩形的信息。

如果满足 op(a, a) = a,那么一个矩形可以被 \(4\) 个矩形表示。

如果不满足,那么横向可以被划分为 \(O(\log n)\) 个区间,纵向也是,所以一共可以被划分为 \(O(log^2 n)\)
个子矩形,加起来即可。

优化建图

这里以 【XR-1】逛森林 为例。

一个区间向一个区间连边(有向边),可以将一个点拆成入点和出点两个点,然后类似于ST表,每 \(2^k\) 合并为一个点。

当然,暴力合并一定是不可以的,所以类似于倍增求 LCA 的时候,将 in/out[x][k]in/out[fa[x][k][k] 合并成 in/out[x][k + 1] 即可,这样边数和点数都为 \(O(n \log n)\) 的了。

如果满足可重性,也就是重复连边对答案没有影响,那么类似于 ST表 的正常查询,拆成 \([x, x + 2^k)\)\((y - 2^k, y]\) 两个区间,连边即可。

如果不可以,那么同样的,两侧都拆分为 \(O(\log n)\) 个区间,如果可以,新建一个虚拟点,然后分别连边,这样边数新增 \(O(\log n)\),否则只能一一连边,共 \(O(\log^2 n)\) 条边,可能空间复杂度会炸,所以要慎重考虑。

还有一道 SCOI 2016 萌萌哒属于是倍增优化建图的另类了,优化区间一一合并。

优化 DP

这里以 NOIP 2018 保卫王国 为例。

倍增优化 DP,或者说叫做优化不带修的 DDP(瞬间高大上),也就是考虑链上状态影响的叠加。

改变 DP 的初始值,然后 \(O(\log n)\) 次叠加状态,然后得到最终的 DP 值。

这不仅可以用在序列上,也可以用在树上,只是在树上可能需要考虑的更多。

不过很好的是,可以将转移写成(广义)矩阵的形式,这样状态的叠加就会十分的 naive,直接维护一个矩阵即可。


作者有话说

感谢 Larray76 指出文中的一个问题。线段树的建树时间确实是 \(O(n)\) 而非 \(O(n \log n)\)

在实际的应用种,\(ST\) 表一般很少会用到,因为静态的要求太强了。

但是倍增不一样,不仅仅是可以代替二分的存在,还是很多算法的基础思想(这是二分无法做到的),例如多项式全家桶,并且其优美的性质总能解决很多问题。

倍增是十分重要的基础算法,在各大赛场上都有其身影,可见其为优化代码的重要手段,不得不重视!

完结撒花~

posted @ 2023-01-12 17:05  jeefy  阅读(134)  评论(0编辑  收藏  举报