基本技巧——倍增

RainPPR發表於2024-05-23

基本技巧——倍增

概念

倍增法,顧名思義就是翻倍、成倍增長。

它能夠使線性的處理轉化為對數級的處理,大大地最佳化時間複雜度。

常常在遞推中狀態空間的第二維記錄二的整數次冪的值,透過這些值拼湊出答案。

透過任意整數都可以表示為若干個二的次冪項的和(二進位制),以此計算。

ST 表概述

ST 表可以做到 \(\mathcal O(n\log n)\) 預處理,\(\mathcal O(1)\) 求出序列區間最大值。

按照最基礎的思想,設 \(f(i,j)\) 表示區間 \([i,j]\) 的最大值,考慮上述倍增思想。

重新設計狀態,

\(f(i,j)\) 表示區間 \([i,i+2^j-1]\) 的最大值,也就是從 \(i\) 開始的 \(2^j\) 個數。

考慮這樣子遞推的邊界,

  • 顯然 \(f(i,0)=a_i\)
  • 顯然 \(f(i,j)=\max\{f(i,j-1),f(i+2^{j-1},j-1)\}\)

這麼折半的預處理,可以做到 \(\mathcal O(n\log n)\) 的複雜度。

考慮查詢,如果我們按照樸素的思想去處理的話,也是 \(\mathcal O(n\log n)\) 的,但是

有一個很簡單的性質,\(\max\{x,x\}=x\),這意味著我們可以重複計算一個區間的最大值。

於是,我們可以把區間中一部分重複的區間跳過,直接去計算:

能覆蓋整個區間的兩個左右端點上的整個區間,就可以做到 \(\mathcal O(1)\)

除 RMQ 以外,還有其它的「可重複貢獻問題」。例如「區間按位與」、「區間按位或」等。

ST 表能較好的維護「可重複貢獻」的區間資訊(同時也應滿足結合律),時間複雜度較低。

藍書的程式碼:

void init() {
  for (int i = 1; i <= n; ++i) f[i][0] = a[i];
  int t = log(n) / log(2) + 1;
  for (int j = 1; j < t; ++j)
    for (int i = 1; i <= n - (1 << j) + 1; ++i)
      f[i][j] = max(f[i][j - 1], f[i + (1 << (j - 1))][j - 1]);
}

int query(int l, int r) {
  int k = log(r - l + 1) / log(2);
  return max(f[l][k], f[r - (1 << k) + 1][k]);
}

倍增 LCA 概述

樸素思想,從兩個點一步一步往上跳,跳到同一高度後再一起跳。

考慮倍增,記 \(f(u,x)\) 表示 \(u\) 向上 \(2^x\) 步的節點是哪個,直到跳到一起。

然後就可以 \(\mathcal O(n\log n)\) 預處理,\(\mathcal O(\log n)\) 查詢了。

這個思想是先儘可能的多跳,如果跳多了就返回,再跳更小的步數。

在 LCA 的同時,還可以同步記錄一些其他的東西。

比如下面的程式碼記錄了子樹中的最大節點編號(P10113 [GESP202312 八級] 大量的工作溝通),

int n, f[N];
int dep[N], mxj[N];
int lt[N][35];

void init(int u, int fa) {
    dep[u] = dep[fa] + 1, mxj[u] = max(u, mxj[fa]);
    for (int k = 0; k <= 30; ++k) lt[u][k + 1] = lt[lt[u][k]][k];
    for (int v : g[u]) if (v != fa) lt[v][0] = u, init(v, u);
}

int lca(int x, int y) {
    if (dep[x] < dep[y]) swap(x, y);
    for (int k = 30; ~k; --k) if (dep[lt[x][k]] >= dep[y]) x = lt[x][k];
    if (x == y) return x;
    for (int k = 30; ~k; --k) if (lt[x][k] != lt[y][k]) x = lt[x][k], y = lt[y][k];
    return lt[x][0];
}

例題

給定一個長度為 \(N\) 的序列 \(A\),對於詢問的整數 \(T\),求出
最大的整數 \(k\),使得

\[\sum_{i=1}^kA_i\le T \]

找到線上演算法。

首先,可以求出 \(A\) 的字首和 \(S\),然後就可以簡單的二分了。

但是,如果答案 \(k\) 非常小,二分的演算法就會很不優,甚至不如順序列舉。

考慮倍增演算法(下面是藍書上的),

  • \(p=1,k=0,r=0\)
  • 如果 \(r+S_{k+p}-S_k\le T\),就另 \(r\gets r+S_{k+p}-S_k\)\(k\gets k+p\)\(p\gets2p\)
  • 否則,另 \(p\gets p/2\)
  • 重複上一步,直至 \(p=0\),此時 \(k\) 即為答案。

這個思想與倍增 LCA 的思想不同,是先跳,如果能跳就增大下一次跳的,跳不了就減小。

天才 ACM

題目:https://www.acwing.com/problem/content/111/

考慮到,一個集合的校驗值,一定是最大對最小,次大對次小。

隨便舉個例子,若 \(a<b<c<d\),則

\[(d-a)^2+(c-b)^2=a^2+b^2+c^2+d^2-2(ad+bc)\\ (b-a)^2+(d-c)^2=a^2+b^2+c^2+d^2-2(ab+cd) \]

上式減下式,

\[ab+cd-ad-bc=a(b-d)+c(d-b)=(c-a)(d-b) \]

乘積為正數,即上式大於下式,即貪心可行且正確。

迴歸問題,容易總結出來:

對於左端點,找到最右的點,使得校驗值小於限制的值。

考慮到計算校驗值是 \(\mathcal O(n\log n)\) 的,因此這裡需要最佳化。

注意到和上面的題形式類似,可以倍增處理,

因為倍增的複雜度是 \(\mathcal O(\log n)\) 的,因此整體複雜度為,

\[\mathcal O(n\log^2n) \]

不太可過,但是注意到每次右端點增加的時候,可以類似歸併排序的合併。

於是複雜度降為,

\[\mathcal O(n\log n) \]

但是細節很多,本人採用了閉區間的寫法,

#include <bits/stdc++.h>

using namespace std;

#define endl '\n'

using ll = long long;
constexpr int N = 1e6 + 10;

int n, m;
ll t;
int a[N], b[N];
int q[N];

bool getchk(int l, int r, int ad) {
	int lt = r - ad + 1;
    for (int i = lt; i <= r; ++i) b[i] = a[i];
    sort(b + lt, b + r + 1);
    int tot = 0, u = l, v = lt;
	while (u < lt && v <= r) {
		if (b[u] < b[v]) q[tot++] = b[u++];
		else q[tot++] = b[v++];
	}
	while (u < lt) q[tot++] = b[u++];
	while (v <= r) q[tot++] = b[v++];
	ll chk = 0;
	for (int i = 0, j = tot - 1, k = 1; k <= m && i < j; ++i, --j, ++k)
	chk += 1ll * (q[j] - q[i]) * (q[j] - q[i]);
	return chk <= t;
}

int getpos(int l) {
	int p = 1, k = l - 1;
	while (p) {
		if (k + p <= n && getchk(l, k + p, p)) {
			k = k + p, p <<= 1;
			for (int i = l; i <= k + p; ++i) b[i] = q[i - l];
		} else p >>= 1;
	} return k;
}

void solev() {
	cin >> n >> m >> t;
	for (int i = 1; i <= n; ++i) cin >> a[i];
	int l = 1, ans = 0;
	while (l <= n) l = getpos(l) + 1, ++ans;
	cout << ans << endl;
}

signed main() {
	ios::sync_with_stdio(false);
	cin.tie(nullptr), cout.tie(nullptr);
	int T; cin >> T;
	while (T--) solev();
	return 0;
}

相關文章