Day 10 - 动态规划与树状数组

1|0动态规划基础

主要介绍动态规划的基本思想,以及动态规划中状态及状态转移方程的设计思路,帮助各位初学者对动态规划有一个初步的了解。

1|1引入

[IOI1994] 数字三角形

给定一个 r 行的数字三角形(r1000),需要找到一条从最高点到底部任意处结束的路径,使路径经过数字的和最大。每一步可以走到当前点左下方的点或右下方的点。

7 3 8 8 1 0 2 7 4 4 4 5 2 6 5

在上面这个例子中,最优路径是 73875

最简单粗暴的思路是尝试所有的路径。因为路径条数是 O(2r) 级别的,这样的做法无法接受。

注意到这样一个事实,一条最优的路径,它的每一步决策都是最优的。

以例题里提到的最优路径为例,只考虑前四步 7387,不存在一条从最顶端到 4 行第 2 个数的权值更大的路径。

而对于每一个点,它的下一步决策只有两种:往左下角或者往右下角(如果存在)。因此只需要记录当前点的最大权值,用这个最大权值执行下一步决策,来更新后续点的最大权值。

这样做还有一个好处:我们成功缩小了问题的规模,将一个问题分成了多个规模更小的问题。要想得到从顶端到第 r 行的最优方案,只需要知道从顶端到第 r1 行的最优方案的信息就可以了。

这时候还存在一个问题:子问题间重叠的部分会有很多,同一个子问题可能会被重复访问多次,效率还是不高。解决这个问题的方法是把每个子问题的解存储下来,通过记忆化的方式限制访问顺序,确保每个子问题只被访问一次。

上面就是动态规划的一些基本思路。下面将会更系统地介绍动态规划的思想。

1|2动态规划原理

能用动态规划解决的问题,需要满足三个条件:最优子结构,无后效性和子问题重叠。

1|0最优子结构

具有最优子结构也可能是适合用贪心的方法求解。

注意要确保我们考察了最优解中用到的所有子问题。

  1. 证明问题最优解的第一个组成部分是做出一个选择;
  2. 对于一个给定问题,在其可能的第一步选择中,假定你已经知道哪种选择才会得到最优解。你现在并不关心这种选择具体是如何得到的,只是假定已经知道了这种选择;
  3. 给定可获得的最优解的选择后,确定这次选择会产生哪些子问题,以及如何最好地刻画子问题空间;
  4. 证明作为构成原问题最优解的组成部分,每个子问题的解就是它本身的最优解。方法是反证法,考虑加入某个子问题的解不是其自身的最优解,那么就可以从原问题的解中用该子问题的最优解替换掉当前的非最优解,从而得到原问题的一个更优的解,从而与原问题最优解的假设矛盾。

要保持子问题空间尽量简单,只在必要时扩展。

最优子结构的不同体现在两个方面:

  1. 原问题的最优解中涉及多少个子问题;
  2. 确定最优解使用哪些子问题时,需要考察多少种选择。

子问题图中每个定点对应一个子问题,而需要考察的选择对应关联至子问题顶点的边。

1|0无后效性

已经求解的子问题,不会再受到后续决策的影响。

1|0子问题重叠

如果有大量的重叠子问题,我们可以用空间将这些子问题的解存储下来,避免重复求解相同的子问题,从而提升效率。

1|0基本思路

对于一个能用动态规划解决的问题,一般采用如下思路解决:

  1. 将原问题划分为若干 阶段,每个阶段对应若干个子问题,提取这些子问题的特征(称之为 状态);
  2. 寻找每一个状态的可能 决策,或者说是各状态间的相互转移方式(用数学的语言描述就是 状态转移方程)。
  3. 按顺序求解每一个阶段的问题。

如果用图论的思想理解,我们建立一个有向无环图,每个状态对应图上一个节点,决策对应节点间的连边。这样问题就转变为了一个在 DAG 上寻找最长(短)路的问题(参见:\tetDAG 上的 DP)。

1|3最长公共子序列

最长公共子序列问题:

给定一个长度为 n 的序列 A 和一个 长度为 m 的序列 Bn,m5000),求出一个最长的序列,使得该序列既是 A 的子序列,也是 B 的子序列。

子序列的定义可以参考子序列。一个简要的例子:字符串 abcde 与字符串 acde 的公共子序列有 acdeacadaecdcedeadeacecdeacde,最长公共子序列的长度是 4。

f(i,j) 表示只考虑 A 的前 i 个元素,B 的前 j 个元素时的最长公共子序列的长度,求这时的最长公共子序列的长度就是 子问题f(i,j) 就是我们所说的 状态,则 f(n,m) 是最终要达到的状态,即为所求结果。

对于每个 f(i,j),存在三种决策:如果 Ai=Bj,则可以将它接到公共子序列的末尾;另外两种决策分别是跳过 Ai 或者 Bj。状态转移方程如下:

f(i,j)={f(i1,j1)+1Ai=Bjmax(f(i1,j),f(i,j1))AiBj

可参考 SourceForge 的 LCS 交互网页 来更好地理解 LCS 的实现过程。

该做法的时间复杂度为 O(nm)

另外,本题存在 O(nmw) 的算法1。有兴趣的同学可以自行探索。

int a[MAXN], b[MAXM], f[MAXN][MAXM]; int dp() { for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) if (a[i] == b[j]) f[i][j] = f[i - 1][j - 1] + 1; else f[i][j] = std::max(f[i - 1][j], f[i][j - 1]); return f[n][m]; }

1|4最长不下降子序列

最长不下降子序列问题:

给定一个长度为 n 的序列 An5000),求出一个最长的 A 的子序列,满足该子序列的后一个元素不小于前一个元素。

1|0算法一

f(i) 表示以 Ai 为结尾的最长不下降子序列的长度,则所求为 max1inf(i)

计算 f(i) 时,尝试将 Ai 接到其他的最长不下降子序列后面,以更新答案。于是可以写出这样的状态转移方程:f(i)=max1j<i,AjAi(f(j)+1)

容易发现该算法的时间复杂度为 O(n2)

int a[MAXN], d[MAXN]; int dp() { d[1] = 1; int ans = 1; for (int i = 2; i <= n; i++) { d[i] = 1; for (int j = 1; j < i; j++) if (a[j] <= a[i]) { d[i] = max(d[i], d[j] + 1); ans = max(ans, d[i]); } } return ans; }

1|0算法二2

n 的范围扩大到 n105 时,第一种做法就不够快了,下面给出了一个 O(nlogn) 的做法。

回顾一下之前的状态:(i,l)

但这次,我们不是要按照相同的 i 处理状态,而是直接判断合法的 (i,l)

再看一下之前的转移:(j,l1)(i,l),就可以判断某个 (i,l) 是否合法。

初始时 (1,1) 肯定合法。

那么,只需要找到一个 l 最大的合法的 (i,l),就可以得到最终最长不下降子序列的长度了。

那么,根据上面的方法,我们就需要维护一个可能的转移列表,并逐个处理转移。

所以可以定义 a1an 为原始序列,di 为所有的长度为 i 的不下降子序列的末尾元素的最小值,len 为子序列的长度。

初始化:d1=a1,len=1

现在我们已知最长的不下降子序列长度为 1,那么我们让 i 从 2 到 n 循环,依次求出前 i 个元素的最长不下降子序列的长度,循环的时候我们只需要维护好 d 这个数组还有 len 就可以了。关键在于如何维护。

考虑进来一个元素 ai

  1. 元素大于等于 dlen,直接将该元素插入到 d 序列的末尾。
  2. 元素小于 dlen,找到 第一个 大于它的元素,用 ai 替换它。

为什么:

  • 对于步骤 1:

    由于我们是从前往后扫,所以说当元素大于等于 dlen 时一定会有一个不下降子序列使得这个不下降子序列的末项后面可以再接这个元素。如果 d 不接这个元素,可以发现既不符合定义,又不是最优解。

  • 对于步骤 2:

    同步骤 1,如果插在 d 的末尾,那么由于前面的元素大于要插入的元素,所以不符合 d 的定义,因此必须先找到 第一个 大于它的元素,再用 ai 替换。

步骤 2 如果采用暴力查找,则时间复杂度仍然是 O(n2) 的。但是根据 d 数组的定义,又由于本题要求不下降子序列,所以 d 一定是 单调不减 的,因此可以用二分查找将时间复杂度降至 O(nlogn).

参考代码如下:

for (int i = 0; i < n; ++i) scanf("%d", a + i); memset(dp, 0x1f, sizeof dp); mx = dp[0]; for (int i = 0; i < n; ++i) { *std::upper_bound(dp, dp + n, a[i]) = a[i]; } ans = 0; while (dp[ans] != mx) ++ans;

1|5参考资料与注释

1: 位运算求最长公共子序列 - -Wallace- - 博客园

2: 最长不下降子序列 nlogn 算法详解 - lvmememe - 博客园

2|0背包 DP

前置知识:动态规划部分简介。

2|1引入

在具体讲何为「背包 dp」前,先来看如下的例题:

「USACO07 DEC」Charm Bracelet

题意概要:有 n 个物品和一个容量为 W 的背包,每个物品有重量 wi 和价值 vi 两种属性,要求选若干物品放入背包使背包中物品的总价值最大且背包中物品的总重量不超过背包的容量。

在上述例题中,由于每个物体只有两种可能的状态(取与不取),对应二进制中的 01,这类问题便被称为「0-1 背包问题」。

2|20-1 背包

1|0解释

例题中已知条件有第 i 个物品的重量 wi,价值 vi,以及背包的总容量 W

设 DP 状态 fi,j 为在只能放前 i 个物品的情况下,容量为 j 的背包所能达到的最大总价值。

考虑转移。假设当前已经处理好了前 i1 个物品的所有状态,那么对于第 i 个物品,当其不放入背包时,背包的剩余容量不变,背包中物品的总价值也不变,故这种情况的最大价值为 fi1,j;当其放入背包时,背包的剩余容量会减小 wi,背包中物品的总价值会增大 vi,故这种情况的最大价值为 fi1,jwi+vi

由此可以得出状态转移方程:

fi,j=max(fi1,j,fi1,jwi+vi)

这里如果直接采用二维数组对状态进行记录,会出现 MLE。可以考虑改用滚动数组的形式来优化。

由于对 fi 有影响的只有 fi1,可以去掉第一维,直接用 fi 来表示处理到当前物品时背包容量为 i 的最大价值,得出以下方程:

fj=max(fj,fjwi+vi)

务必牢记并理解这个转移方程,因为大部分背包问题的转移方程都是在此基础上推导出来的。

1|0实现

还有一点需要注意的是,很容易写出这样的 错误核心代码

for (int i = 1; i <= n; i++) for (int l = 0; l <= W - w[i]; l++) f[l + w[i]] = max(f[l] + v[i], f[l + w[i]]); // 由 f[i][l + w[i]] = max(max(f[i - 1][l + w[i]], f[i - 1][l] + w[i]), // f[i][l + w[i]]); 简化而来

这段代码哪里错了呢?枚举顺序错了。

仔细观察代码可以发现:对于当前处理的物品 i 和当前状态 fi,j,在 jwi 时,fi,j 是会被 fi,jwi 所影响的。这就相当于物品 i 可以多次被放入背包,与题意不符。(事实上,这正是完全背包问题的解法)

为了避免这种情况发生,我们可以改变枚举的顺序,从 W 枚举到 wi,这样就不会出现上述的错误,因为 fi,j 总是在 fi,jwi 前被更新。

因此实际核心代码为

for (int i = 1; i <= n; i++) for (int l = W; l >= w[i]; l--) f[l] = max(f[l], f[l - w[i]] + v[i]);

例题代码:

#include <iostream> using namespace std; const int maxn = 13010; int n, W, w[maxn], v[maxn], f[maxn]; int main() { cin >> n >> W; for (int i = 1; i <= n; i++) cin >> w[i] >> v[i]; // 读入数据 for (int i = 1; i <= n; i++) for (int l = W; l >= w[i]; l--) if (f[l - w[i]] + v[i] > f[l]) f[l] = f[l - w[i]] + v[i]; // 状态方程 cout << f[W]; return 0; }

2|3完全背包

1|0解释

完全背包模型与 0-1 背包类似,与 0-1 背包的区别仅在于一个物品可以选取无限次,而非仅能选取一次。

我们可以借鉴 0-1 背包的思路,进行状态定义:设 fi,j 为只能选前 i 个物品时,容量为 j 的背包可以达到的最大价值。

需要注意的是,虽然定义与 0-1 背包类似,但是其状态转移方程与 0-1 背包并不相同。

1|0过程

可以考虑一个朴素的做法:对于第 i 件物品,枚举其选了多少个来转移。这样做的时间复杂度是 O(n3) 的。

状态转移方程如下:

fi,j=maxk=0+(fi1,jk×wi+vi×k)

考虑做一个简单的优化。可以发现,对于 fi,j,只要通过 fi,jwi 转移就可以了。因此状态转移方程为:

fi,j=max(fi1,j,fi,jwi+vi)

理由是当我们这样转移时,fi,jwi 已经由 fi,j2×wi 更新过,那么 fi,jwi 就是充分考虑了第 i 件物品所选次数后得到的最优结果。换言之,我们通过局部最优子结构的性质重复使用了之前的枚举过程,优化了枚举的复杂度。

与 0-1 背包相同,我们可以将第一维去掉来优化空间复杂度。如果理解了 0-1 背包的优化方式,就不难明白压缩后的循环是正向的(也就是上文中提到的错误优化)。

「Luogu P1616」疯狂的采药

题意概要:有 n 种物品和一个容量为 W 的背包,每种物品有重量 wi 和价值 vi 两种属性,要求选若干个物品放入背包使背包中物品的总价值最大且背包中物品的总重量不超过背包的容量。

例题代码:

#include <iostream> using namespace std; const int maxn = 1e4 + 5; const int maxW = 1e7 + 5; int n, W, w[maxn], v[maxn]; long long f[maxW]; int main() { cin >> W >> n; for (int i = 1; i <= n; i++) cin >> w[i] >> v[i]; for (int i = 1; i <= n; i++) for (int l = w[i]; l <= W; l++) if (f[l - w[i]] + v[i] > f[l]) f[l] = f[l - w[i]] + v[i]; // 核心状态方程 cout << f[W]; return 0; }

2|4多重背包

多重背包也是 0-1 背包的一个变式。与 0-1 背包的区别在于每种物品有 ki 个,而非一个。

一个很朴素的想法就是:把「每种物品选 ki 次」等价转换为「有 ki 个相同的物品,每个物品选一次」。这样就转换成了一个 0-1 背包模型,套用上文所述的方法就可已解决。状态转移方程如下:

fi,j=maxk=0ki(fi1,jk×wi+vi×k)

时间复杂度 O(Wi=1nki)

核心代码:

for (int i = 1; i <= n; i++) { for (int weight = W; weight >= w[i]; weight--) { // 多遍历一层物品数量 for (int k = 1; k * w[i] <= weight && k <= cnt[i]; k++) { dp[weight] = max(dp[weight], dp[weight - k * w[i]] + k * v[i]); } } }

1|0二进制分组优化

考虑优化。我们仍考虑把多重背包转化成 0-1 背包模型来求解。

1|0解释

显然,复杂度中的 O(nW) 部分无法再优化了,我们只能从 O(ki) 处入手。为了表述方便,我们用 Ai,j 代表第 i 种物品拆分出的第 j 个物品。

在朴素的做法中,jkiAi,j 均表示相同物品。那么我们效率低的原因主要在于我们进行了大量重复性的工作。举例来说,我们考虑了「同时选 Ai,1,Ai,2」与「同时选 Ai,2,Ai,3」这两个完全等效的情况。这样的重复性工作我们进行了许多次。那么优化拆分方式就成为了解决问题的突破口。

1|0过程

我们可以通过「二进制分组」的方式使拆分方式更加优美。

具体地说就是令 Ai,j(j[0,log2(ki+1)1]) 分别表示由 2j 个单个物品「捆绑」而成的大物品。特殊地,若 ki+1 不是 2 的整数次幂,则需要在最后添加一个由 ki2log2(ki+1)1 个单个物品「捆绑」而成的大物品用于补足。

举几个例子:

  • 6=1+2+3
  • 8=1+2+4+1
  • 18=1+2+4+8+3
  • 31=1+2+4+8+16

显然,通过上述拆分方式,可以表示任意 ki 个物品的等效选择方式。将每种物品按照上述方式拆分后,使用 0-1 背包的方法解决即可。

时间复杂度 O(Wi=1nlog2ki)

1|0实现

二进制分组代码:

index = 0; for (int i = 1; i <= m; i++) { int c = 1, p, h, k; cin >> p >> h >> k; while (k > c) { k -= c; list[++index].w = c * p; list[index].v = c * h; c *= 2; } list[++index].w = p * k; list[index].v = h * k; }

1|0单调队列优化

单调队列/单调栈优化

习题:「Luogu P1776」宝物筛选_NOI 导刊 2010 提高(02)

2|5混合背包

混合背包就是将前面三种的背包问题混合起来,有的只能取一次,有的能取无限次,有的只能取 k 次。

这种题目看起来很吓人,可是只要领悟了前面几种背包的中心思想,并将其合并在一起就可以了。下面给出伪代码:

for (循环物品种类) { if (是 0 - 1 背包) 套用 0 - 1 背包代码; else if (是完全背包) 套用完全背包代码; else if (是多重背包) 套用多重背包代码; }

1|0例题

「Luogu P1833」樱花

n 种樱花树和长度为 T 的时间,有的樱花树只能看一遍,有的樱花树最多看 Ai 遍,有的樱花树可以看无数遍。每棵樱花树都有一个美学值 Ci,求在 T 的时间内看哪些樱花树能使美学值最高。

核心代码:

for (int i = 1; i <= n; i++) { if (cnt[i] == 0) { // 如果数量没有限制使用完全背包的核心代码 for (int weight = w[i]; weight <= W; weight++) { dp[weight] = max(dp[weight], dp[weight - w[i]] + v[i]); } } else { // 物品有限使用多重背包的核心代码,它也可以处理0-1背包问题 for (int weight = W; weight >= w[i]; weight--) { for (int k = 1; k * w[i] <= weight && k <= cnt[i]; k++) { dp[weight] = max(dp[weight], dp[weight - k * w[i]] + k * v[i]); } } } }

2|6二维费用背包

「Luogu P1855」榨取 kkksc03

n 个任务需要完成,完成第 i 个任务需要花费 ti 分钟,产生 ci 元的开支。

现在有 T 分钟时间,W 元钱来处理这些任务,求最多能完成多少任务。

这道题是很明显的 0-1 背包问题,可是不同的是选一个物品会消耗两种价值(经费、时间),只需在状态中增加一维存放第二种价值即可。

这时候就要注意,再开一维存放物品编号就不合适了,因为容易 MLE。

1|0实现

for (int k = 1; k <= n; k++) for (int i = m; i >= mi; i--) // 对经费进行一层枚举 for (int j = t; j >= ti; j--) // 对时间进行一层枚举 dp[i][j] = max(dp[i][j], dp[i - mi][j - ti] + 1);

2|7分组背包

「Luogu P1757」通天之分组背包

n 件物品和一个大小为 m 的背包,第 i 个物品的价值为 wi,体积为 vi。同时,每个物品属于一个组,同组内最多只能选择一个物品。求背包能装载物品的最大总价值。

这种题怎么想呢?其实是从「在所有物品中选择一件」变成了「从当前组中选择一件」,于是就对每一组进行一次 0-1 背包就可以了。

再说一说如何进行存储。我们可以将 tk,i 表示第 k 组的第 i 件物品的编号是多少,再用 cntk 表示第 k 组物品有多少个。

1|0实现

for (int k = 1; k <= ts; k++) // 循环每一组 for (int i = m; i >= 0; i--) // 循环背包容量 for (int j = 1; j <= cnt[k]; j++) // 循环该组的每一个物品 if (i >= w[t[k][j]]) // 背包容量充足 dp[i] = max(dp[i], dp[i - w[t[k][j]]] + c[t[k][j]]); // 像0-1背包一样状态转移

这里要注意:一定不能搞错循环顺序,这样才能保证正确性。

2|8有依赖的背包

「Luogu P1064」金明的预算方案

金明有 n 元钱,想要买 m 个物品,第 i 件物品的价格为 vi,重要度为 pi。有些物品是从属于某个主件物品的附件,要买这个物品,必须购买它的主件。

目标是让所有购买的物品的 vi×pi 之和最大。

考虑分类讨论。对于一个主件和它的若干附件,有以下几种可能:只买主件,买主件 + 某些附件。因为这几种可能性只能选一种,所以可以将这看成分组背包。

如果是多叉树的集合,则要先算子节点的集合,最后算父节点的集合。

2|9泛化物品的背包

这种背包,没有固定的费用和价值,它的价值是随着分配给它的费用而定。在背包容量为 V 的背包问题中,当分配给它的费用为 vi 时,能得到的价值就是 h(vi)。这时,将固定的价值换成函数的引用即可。

2|10杂项

1|0小优化

根据贪心原理,当费用相同时,只需保留价值最高的;当价值一定时,只需保留费用最低的;当有两件物品 i,ji 的价值大于 j 的价值并且 i 的费用小于 j 的费用时,只需保留 i

1|0背包问题变种

1|0输出方案

输出方案其实就是记录下来背包中的某一个状态是怎么推出来的。我们可以用 gi,v 表示第 i 件物品占用空间为 v 的时候是否选择了此物品。然后在转移时记录是选用了哪一种策略(选或不选)。输出时的伪代码:

int v = V; // 记录当前的存储空间 // 因为最后一件物品存储的是最终状态,所以从最后一件物品进行循环 for (从最后一件循环至第一件) { if (g[i][v]) { 选了第 i 项物品; v -= 第 i 项物品的重量; } else { 未选第 i 项物品; } }

1|0求方案数

对于给定的一个背包容量、物品费用、其他关系等的问题,求装到一定容量的方案总数。

这种问题就是把求最大值换成求和即可。

例如 0-1 背包问题的转移方程就变成了:

dpi=(dpi,dpici)

初始条件:dp0=1

因为当容量为 0 时也有一个方案,即什么都不装。

1|0求最优方案总数

要求最优方案总数,我们要对 0-1 背包里的 dp 数组的定义稍作修改,DP 状态 fi,j 为在只能放前 i 个物品的情况下,容量为 j 的背包「正好装满」所能达到的最大总价值。

这样修改之后,每一种 DP 状态都可以用一个 gi,j 来表示方案数。

fi,j 表示只考虑前 i 个物品时背包体积「正好」是 j 时的最大价值。

gi,j 表示只考虑前 i 个物品时背包体积「正好」是 j 时的方案数。

转移方程:

如果 fi,j=fi1,jfi,jfi1,jv+w 说明我们此时不选择把物品放入背包更优,方案数由 gi1,j 转移过来,

如果 fi,jfi1,jfi,j=fi1,jv+w 说明我们此时选择把物品放入背包更优,方案数由 gi1,jv 转移过来,

如果 fi,j=fi1,jfi,j=fi1,jv+w 说明放入或不放入都能取得最优解,方案数由 gi1,jgi1,jv 转移过来。

初始条件:

memset(f, 0x3f3f, sizeof(f)); // 避免没有装满而进行了转移 f[0] = 0; g[0] = 1; // 什么都不装是一种方案

因为背包体积最大值有可能装不满,所以最优解不一定是 fm

最后我们通过找到最优解的价值,把 gj 数组里取到最优解的所有方案数相加即可。

实现:

for (int i = 0; i < N; i++) { for (int j = V; j >= v[i]; j--) { int tmp = std::max(dp[j], dp[j - v[i]] + w[i]); int c = 0; if (tmp == dp[j]) c += cnt[j]; // 如果从dp[j]转移 if (tmp == dp[j - v[i]] + w[i]) c += cnt[j - v[i]]; // 如果从dp[j-v[i]]转移 dp[j] = tmp; cnt[j] = c; } } int max = 0; // 寻找最优解 for (int i = 0; i <= V; i++) { max = std::max(max, dp[i]); } int res = 0; for (int i = 0; i <= V; i++) { if (dp[i] == max) { res += cnt[i]; // 求和最优解方案数 } }

1|0背包的第 k 优解

普通的 0-1 背包是要求最优解,在普通的背包 DP 方法上稍作改动,增加一维用于记录当前状态下的前 k 优解,即可得到求 0-1 背包第 k 优解的算法。
具体来讲:dpi,j,k 记录了前 i 个物品中,选择的物品总体积为 j 时,能够得到的第 k 大的价值和。这个状态可以理解为将普通 0-1 背包只用记录一个数据的 dpi,j 扩展为记录一个有序的优解序列。转移时,普通背包最优解的求法是 dpi,j=max(dpi1,j,dpi1,jvi+wi),现在我们则是要合并 dpi1,jdpi1,jvi+wi 这两个大小为 k 的递减序列,并保留合并后前 k 大的价值记在 dpi,j 里,这一步利用双指针法,复杂度是 O(k) 的,整体时间复杂度为 O(nmk)。空间上,此方法与普通背包一样可以压缩掉第一维,复杂度是 O(mk) 的。

例题 HDU 2639 Bone Collector II

求 0-1 背包的严格第 k 优解。n100,v1000,k30

实现:

memset(dp, 0, sizeof(dp)); int i, j, p, x, y, z; scanf("%d%d%d", &n, &m, &K); for (i = 0; i < n; i++) scanf("%d", &w[i]); for (i = 0; i < n; i++) scanf("%d", &c[i]); for (i = 0; i < n; i++) { for (j = m; j >= c[i]; j--) { for (p = 1; p <= K; p++) { a[p] = dp[j - c[i]][p] + w[i]; b[p] = dp[j][p]; } a[p] = b[p] = -1; x = y = z = 1; while (z <= K && (a[x] != -1 || b[y] != -1)) { if (a[x] > b[y]) dp[j][z] = a[x++]; else dp[j][z] = b[y++]; if (dp[j][z] != dp[j][z - 1]) z++; } } } printf("%d\n", dp[m][K]);

2|11参考资料与注释

3|0区间 DP

3|1定义

区间类动态规划是线性动态规划的扩展,它在分阶段地划分问题时,与阶段中元素出现的顺序和由前一阶段的哪些元素合并而来有很大的关系。

令状态 f(i,j) 表示将下标位置 ij 的所有元素合并能获得的价值的最大值,那么 f(i,j)=max{f(i,k)+f(k+1,j)+cost}cost 为将这两组元素合并起来的价值。

3|2性质

区间 DP 有以下特点:

合并:即将两个或多个部分进行整合,当然也可以反过来;

特征:能将问题分解为能两两合并的形式;

求解:对整个问题设最优值,枚举合并点,将问题分解为左右两个部分,最后合并两个部分的最优值得到原问题的最优值。

3|3解释

1|0例题

「NOI1995」石子合并

题目大意:在一个环上有 n 个数 a1,a2,,an,进行 n1 次合并操作,每次操作将相邻的两堆合并成一堆,能获得新的一堆中的石子数量的和的得分。你需要最大化你的得分。

需要考虑不在环上,而在一条链上的情况。

f(i,j) 表示将区间 [i,j] 内的所有石子合并到一起的最大得分。

写出 状态转移方程f(i,j)=max{f(i,k)+f(k+1,j)+t=ijat} (ik<j)

sumi 表示 a 数组的前缀和,状态转移方程变形为 f(i,j)=max{f(i,k)+f(k+1,j)+sumjsumi1}

1|0怎样进行状态转移

由于计算 f(i,j) 的值时需要知道所有 f(i,k)f(k+1,j) 的值,而这两个中包含的元素的数量都小于 f(i,j),所以我们以 len=ji+1 作为 DP 的阶段。首先从小到大枚举 len,然后枚举 i 的值,根据 leni 用公式计算出 j 的值,然后枚举 k,时间复杂度为 O(n3)

1|0怎样处理环

题目中石子围成一个环,而不是一条链,怎么办呢?

方法一:由于石子围成一个环,我们可以枚举分开的位置,将这个环转化成一个链,由于要枚举 n 次,最终的时间复杂度为 O(n4)

方法二:我们将这条链延长两倍,变成 2×n 堆,其中第 i 堆与第 n+i 堆相同,用动态规划求解后,取 f(1,n),f(2,n+1),,f(n1,2n2) 中的最优值,即为最后的答案。时间复杂度 O(n3)

3|4实现

for (len = 2; len <= n; len++) for (i = 1; i <= 2 * n - 1 - len; i++) { int j = len + i - 1; for (k = i; k < j; k++) f[i][j] = max(f[i][j], f[i][k] + f[k + 1][j] + sum[j] - sum[i - 1]); }

3|5几道练习题

NOIP 2006 能量项链

NOIP 2007 矩阵取数游戏

「IOI2000」邮局

4|0树状数组

4|1引入

树状数组是一种支持 单点修改区间查询 的,代码量小的数据结构。

什么是「单点修改」和「区间查询」?

假设有这样一道题:

已知一个数列 a,你需要进行下面两种操作:

  • 给定 x,y,将 a[x] 自增 y
  • 给定 l,r,求解 a[lr] 的和。

其中第一种操作就是「单点修改」,第二种操作就是「区间查询」。

类似地,还有:「区间修改」、「单点查询」。它们分别的一个例子如下:

  • 区间修改:给定 l,r,x,将 a[lr] 中的每个数都分别自增 x
  • 单点查询:给定 x,求解 a[x] 的值。

注意到,区间问题一般严格强于单点问题,因为对单点的操作相当于对一个长度为 1 的区间操作。

普通树状数组维护的信息及运算要满足 结合律可差分,如加法(和)、乘法(积)、异或等。

  • 结合律:(xy)z=x(yz),其中 是一个二元运算符。
  • 可差分:具有逆运算的运算,即已知 xyx 可以求出 y

需要注意的是:

  • 模意义下的乘法若要可差分,需保证每个数都存在逆元(模数为质数时一定存在);
  • 例如 gcdmax 这些信息不可差分,所以不能用普通树状数组处理,但是:

事实上,树状数组能解决的问题是线段树能解决的问题的子集:树状数组能做的,线段树一定能做;线段树能做的,树状数组不一定可以。然而,树状数组的代码要远比线段树短,时间效率常数也更小,因此仍有学习价值。

有时,在差分数组和辅助数组的帮助下,树状数组还可解决更强的 区间加单点值区间加区间和 问题。

4|2树状数组

1|0初步感受

先来举个例子:我们想知道 a[17] 的前缀和,怎么做?

一种做法是:a1+a2+a3+a4+a5+a6+a7,需要求 7 个数的和。

但是如果已知三个数 ABCA=a[14] 的和,B=a[56] 的总和,C=a[77] 的总和(其实就是 a[7] 自己)。你会怎么算?你一定会回答:A+B+C,只需要求 3 个数的和。

这就是树状数组能快速求解信息的原因:我们总能将一段前缀 [1,n] 拆成 不多于 logn 段区间,使得这 logn 段区间的信息是 已知的

于是,我们只需合并这 logn 段区间的信息,就可以得到答案。相比于原来直接合并 n 个信息,效率有了很大的提高。

不难发现信息必须满足结合律,否则就不能像上面这样合并了。

下面这张图展示了树状数组的工作原理:

最下面的八个方块代表原始数据数组 a。上面参差不齐的方块(与最上面的八个方块是同一个数组)代表数组 a 的上级——c 数组。

c 数组就是用来储存原始数组 a 某段区间的和的,也就是说,这些区间的信息是已知的,我们的目标就是把查询前缀拆成这些小区间。

例如,从图中可以看出:

  • c2 管辖的是 a[12]
  • c4 管辖的是 a[14]
  • c6 管辖的是 a[56]
  • c8 管辖的是 a[18]
  • 剩下的 c[x] 管辖的都是 a[x] 自己(可以看做 a[xx] 的长度为 1 的小区间)。

不难发现,c[x] 管辖的一定是一段右边界是 x 的区间总信息。我们先不关心左边界,先来感受一下树状数组是如何查询的。

举例:计算 a[17] 的和。

过程:从 c7 开始往前跳,发现 c7 只管辖 a7 这个元素;然后找 c6,发现 c6 管辖的是 a[56],然后跳到 c4,发现 c4 管辖的是 a[14] 这些元素,然后再试图跳到 c0,但事实上 c0 不存在,不跳了。

我们刚刚找到的 cc7,c6,c4,事实上这就是 a[17] 拆分出的三个小区间,合并得到答案是 c7+c6+c4

举例:计算 a[47] 的和。

我们还是从 c7 开始跳,跳到 c6 再跳到 c4。此时我们发现它管理了 a[14] 的和,但是我们不想要 a[13] 这一部分,怎么办呢?很简单,减去 a[13] 的和就行了。

那不妨考虑最开始,就将查询 a[47] 的和转化为查询 a[17] 的和,以及查询 a[13] 的和,最终将两个结果作差。

1|0管辖区间

那么问题来了,c[x](x1) 管辖的区间到底往左延伸多少?也就是说,区间长度是多少?

树状数组中,规定 c[x] 管辖的区间长度为 2k,其中:

  • 设二进制最低位为第 0 位,则 k 恰好为 x 二进制表示中,最低位的 1 所在的二进制位数;
  • 2kc[x] 的管辖区间长度)恰好为 x 二进制表示中,最低位的 1 以及后面所有 0 组成的数。

举个例子,c88 管辖的是哪个区间?

因为 88(10)=01011000(2),其二进制最低位的 1 以及后面的 0 组成的二进制是 1000,即 8,所以 c88 管辖 8a 数组中的元素。

因此,c88 代表 a[8188] 的区间信息。

我们记 x 二进制最低位 1 以及后面的 0 组成的数为 lowbit(x),那么 c[x] 管辖的区间就是 [xlowbit(x)+1,x]

这里注意:lowbit 指的不是最低位 1 所在的位数 k,而是这个 1 和后面所有 0 组成的 2k

怎么计算 lowbit?根据位运算知识,可以得到 lowbit(x) = x & -x

lowbit 的原理:

x 的二进制所有位全部取反,再加 1,就可以得到 -x 的二进制编码。例如,6 的二进制编码是 110,全部取反后得到 001,加 1 得到 010

设原先 x 的二进制编码是 (...)10...00,全部取反后得到 [...]01...11,加 1 后得到 [...]10...00,也就是 -x 的二进制编码了。这里 x 二进制表示中第一个 1x 最低位的 1

(...)[...] 中省略号的每一位分别相反,所以 x & -x = (...)10...00 & [...]10...00 = 10...00,得到的结果就是 lowbit

实现:

int lowbit(int x) { // x 的二进制中,最低位的 1 以及后面所有 0 组成的数。 // lowbit(0b01011000) == 0b00001000 // ~~~~^~~~ // lowbit(0b01110010) == 0b00000010 // ~~~~~~^~ return x & -x; }

1|0区间查询

接下来我们来看树状数组具体的操作实现,先来看区间查询。

回顾查询 a[47] 的过程,我们是将它转化为两个子过程:查询 a[17] 和查询 a[13] 的和,最终作差。

其实任何一个区间查询都可以这么做:查询 a[lr] 的和,就是 a[1r] 的和减去 a[1l1] 的和,从而把区间问题转化为前缀问题,更方便处理。

事实上,将有关 lr 的区间询问转化为 1r1l1 的前缀询问再差分,在竞赛中是一个非常常用的技巧。

那前缀查询怎么做呢?回顾下查询 a[17] 的过程:

c7 往前跳,发现 c7 只管辖 a7 这个元素;然后找 c6,发现 c6 管辖的是 a[56],然后跳到 c4,发现 c4 管辖的是 a[14] 这些元素,然后再试图跳到 c0,但事实上 c0 不存在,不跳了。

我们刚刚找到的 cc7,c6,c4,事实上这就是 a[17] 拆分出的三个小区间,合并一下,答案是 c7+c6+c4

观察上面的过程,每次往前跳,一定是跳到现区间的左端点的左一位,作为新区间的右端点,这样才能将前缀不重不漏地拆分。比如现在 c6 管的是 a[56],下一次就跳到 51=4,即访问 c4

我们可以写出查询 a[1x] 的过程:

  • c[x] 开始往前跳,有 c[x] 管辖 a[xlowbit(x)+1x]
  • xxlowbit(x),如果 x=0 说明已经跳到尽头了,终止循环;否则回到第一步。
  • 将跳到的 c 合并。

实现时,我们不一定要先把 c 都跳出来然后一起合并,可以边跳边合并。

比如我们要维护的信息是和,直接令初始 ans=0,然后每跳到一个 c[x]ansans+c[x],最终 ans 就是所有合并的结果。

实现:

int getsum(int x) { // a[1]..a[x]的和 int ans = 0; while (x > 0) { ans = ans + c[x]; x = x - lowbit(x); } return ans; }

1|0树状数组与其树形态的性质

在讲解单点修改之前,先讲解树状数组的一些基本性质,以及其树形态来源,这有助于更好理解树状数组的单点修改。

我们约定:

  • l(x)=xlowbit(x)+1。即,l(x)c[x] 管辖范围的左端点。
  • 对于任意正整数 x,总能将 x 表示成 s×2k+1+2k 的形式,其中 lowbit(x)=2k
  • 下面「c[x]c[y] 不交」指 c[x] 的管辖范围和 c[y] 的管辖范围不相交,即 [l(x),x][l(y),y] 不相交。「c[x] 包含于 c[y]」等表述同理。

性质 1:对于 xy,要么有 c[x]c[y] 不交,要么有 c[x] 包含于 c[y]

证明:

证明:假设 c[x]c[y] 相交,即 [l(x),x][l(y),y] 相交,则一定有 l(y)xy

y 表示为 s×2k+1+2k,则 l(y)=s×2k+1+1。所以,x 可以表示为 s×2k+1+b,其中 1b2k

不难发现 lowbit(x)=lowbit(b)。又因为 blowbit(b)0

所以 l(x)=xlowbit(x)+1=s×2k+1+blowbit(b)+1s×2k+1+1=l(y),即 l(y)l(x)xy

所以,如果 c[x]c[y] 相交,那么 c[x] 的管辖范围一定完全包含于 c[y]

性质 2:在 c[x] 真包含于 c[x+lowbit(x)]

证明:

证明:设 y=x+lowbit(x)x=s×2k+1+2k,则 y=(s+1)×2k+1l(x)=s×2k+1+1

不难发现 lowbit(y)2k+1,所以 l(y)=(s+1)×2k+1lowbit(y)+1s×2k+1+1=l(x),即 l(y)l(x)x<y

所以,c[x] 真包含于 c[x+lowbit(x)]

性质 3:对于任意 x<y<x+lowbit(x),有 c[x]c[y] 不交。

证明:

证明:设 x=s×2k+1+2k,则 y=x+b=s×2k+1+2k+b,其中 1b<2k

不难发现 lowbit(y)=lowbit(b)。又因为 blowbit(b)0

因此 l(y)=ylowbit(y)+1=x+blowbit(b)+1>x,即 l(x)x<l(y)y

所以,c[x]c[y] 不交。

有了这三条性质的铺垫,我们接下来看树状数组的树形态(请忽略 ac 的连边)。

事实上,树状数组的树形态是 xx+lowbit(x) 连边得到的图,其中 x+lowbit(x)x 的父亲。

注意,在考虑树状数组的树形态时,我们不考虑树状数组大小的影响,即我们认为这是一棵无限大的树,方便分析。实际实现时,我们只需用到 xnc[x],其中 n 是原数组长度。

这棵树天然满足了很多美好性质,下面列举若干(设 fa[u] 表示 u 的直系父亲):

  • u<fa[u]
  • u 大于任何一个 u 的后代,小于任何一个 u 的祖先。
  • ulowbit 严格小于 fa[u]lowbit

证明:

y=x+lowbit(x)x=s×2k+1+2k,则 y=(s+1)×2k+1,不难发现 lowbit(y)2k+1>lowbit(x),证毕。

  • x 的高度是 log2lowbit(x),即 x 二进制最低位 1 的位数。

高度的定义:

x 的高度 h(x) 满足:如果 xmod2=1,则 h(x)=0,否则 h(x)=max(h(y))+1,其中 y 代表 x 的所有儿子(此时 x 至少存在一个儿子 x1)。

也就是说,一个点的高度恰好比它最高的那个儿子再高 1。如果一个点没有儿子,它的高度是 0

这里引出高度这一概念,是为后面解释复杂度更方便。

  • c[u] 真包含于 c[fa[u]](性质 2)。
  • c[u] 真包含于 c[v],其中 vu 的任一祖先(在上一条性质上归纳)。
  • c[u] 真包含 c[v],其中 vu 的任一后代(上面那条性质 uv 颠倒)。
  • 对于任意 v>u,若 v 不是 u 的祖先,则 c[u]c[v] 不交。

证明:

uu 的祖先中,一定存在一个点 v 使得 v<v<fa[v],根据性质 3c[v] 不相交于 c[v],而 c[v] 包含 c[u],因此 c[v] 不交于 c[u]

  • 对于任意 v<u,如果 v 不在 u 的子树上,则 c[u]c[v] 不交(上面那条性质 uv 颠倒)。
  • 对于任意 v>u,当且仅当 vu 的祖先,c[u] 真包含于 c[v](上面几条性质的总结)。这就是树状数组单点修改的核心原理。
  • u=s×2k+1+2k,则其儿子数量为 k=log2lowbit(u),编号分别为 u2t(0t<k)
    • 举例:假设 k=3u 的二进制编号为 ...1000,则 u 有三个儿子,二进制编号分别为 ...0111...0110...0100

证明:

在一个数 x 的基础上减去 2tx 二进制第 t 位会反转,而更低的位保持不变。

考虑 u 的儿子 v,有 v+lowbit(v)=u,即 v=u2tlowbit(v)=2t。设 u=s×2k+1+2k

考虑 0t<ku 的第 t 位及后方均为 0,所以 v=u2t 的第 t 位变为 1,后面仍为 0满足 lowbit(v)=2t

考虑 t=k,则 v=u2kv 的第 k 位变为 0不满足 lowbit(v)=2t

考虑 t>k,则 v=u2tv 的第 k 位是 1,所以 lowbit(v)=2k不满足 lowbit(v)=2t

  • u 的所有儿子对应 c 的管辖区间恰好拼接成 [l(u),u1]
    • 举例:假设 k=3u 的二进制编号为 ...1000,则 u 有三个儿子,二进制编号分别为 ...0111...0110...0100
    • c[...0100] 表示 a[...0001 ~ ...0100]
    • c[...0110] 表示 a[...0101 ~ ...0110]
    • c[...0111] 表示 a[...0111 ~ ...0111]
    • 不难发现上面是三个管辖区间的并集恰好是 a[...0001 ~ ...0111],即 [l(u),u1]

证明:

u 的儿子总能表示成 u2t(0t<k),不难发现,t 越小,u2t 越大,代表的区间越靠右。我们设 f(t)=u2t,则 f(k1),f(k2),,f(0) 分别构成 u 从左到右的儿子。

不难发现 lowbit(f(t))=2t,所以 l(f(t))=u2t2t+1=u2t+1+1

考虑相邻的两个儿子 f(t+1)f(t)。前者管辖区间的右端点是 f(t+1)=u2t+1,后者管辖区间的左端点是 l(f(t))=u2t+1+1,恰好相接。

考虑最左面的儿子 f(k1),其管辖左边界 l(f(k1))=u2k+1 恰为 l(u)

考虑最右面的儿子 f(0),其管辖右边界就是 u1

因此,这些儿子的管辖区间可以恰好拼成 [l(u),u1]

1|0单点修改

现在来考虑如何单点修改 a[x]

我们的目标是快速正确地维护 c 数组。为保证效率,我们只需遍历并修改管辖了 a[x] 的所有 c[y],因为其他的 c 显然没有发生变化。

管辖 a[x]c[y] 一定包含 c[x](根据性质 1),所以 y 在树状数组树形态上是 x 的祖先。因此我们从 x 开始不断跳父亲,直到跳得超过了原数组长度为止。

n 表示 a 的大小,不难写出单点修改 a[x] 的过程:

  • 初始令 x=x
  • 修改 c[x]
  • xx+lowbit(x),如果 x>n 说明已经跳到尽头了,终止循环;否则回到第二步。

区间信息和单点修改的种类,共同决定 c[x] 的修改方式。下面给几个例子:

  • c[x] 维护区间和,修改种类是将 a[x] 加上 p,则修改方式则是将所有 c[x] 也加上 p
  • c[x] 维护区间积,修改种类是将 a[x] 乘上 p,则修改方式则是将所有 c[x] 也乘上 p

然而,单点修改的自由性使得修改的种类和维护的信息不一定是同种运算,比如,若 c[x] 维护区间和,修改种类是将 a[x] 赋值为 p,可以考虑转化为将 a[x] 加上 pa[x]。如果是将 a[x] 乘上 p,就考虑转化为 a[x] 加上 a[x]×pa[x]

下面以维护区间和,单点加为例给出实现。

实现:

void add(int x, int k) { while (x <= n) { // 不能越界 c[x] = c[x] + k; x = x + lowbit(x); } }

1|0建树

也就是根据最开始给出的序列,将树状数组建出来(c 全部预处理好)。

一般可以直接转化为 n 次单点修改,时间复杂度 Θ(nlogn)(复杂度分析在后面)。

比如给定序列 a=(5,1,4) 要求建树,直接看作对 a[1] 单点加 5,对 a[2] 单点加 1,对 a[3] 单点加 4 即可。

也有 Θ(n) 的建树方法,见 Θ(n) 建树一节。

1|0复杂度分析

空间复杂度显然 Θ(n)

时间复杂度:

  • 对于区间查询操作:整个 xxlowbit(x) 的迭代过程,可看做将 x 二进制中的所有 1,从低位到高位逐渐改成 0 的过程,拆分出的区间数等于 x 二进制中 1 的数量(即 popcount(x))。因此,单次查询时间复杂度是 Θ(logn)
  • 对于单点修改操作:跳父亲时,访问到的高度一直严格增加,且始终有 xn。由于点 x 的高度是 log2lowbit(x),所以跳到的高度不会超过 log2n,所以访问到的 c 的数量是 logn 级别。因此,单次单点修改复杂度是 Θ(logn)

4|3区间加区间和

前置知识:前缀和 & 差分。

该问题可以使用两个树状数组维护差分数组解决。

考虑序列 a 的差分数组 d,其中 d[i]=a[i]a[i1]。由于差分数组的前缀和就是原数组,所以 ai=j=1idj

一样地,我们考虑将查询区间和通过差分转化为查询前缀和。那么考虑查询 a[1r] 的和,即 i=1rai,进行推导:

i=1rai=i=1rj=1idj

观察这个式子,不难发现每个 dj 总共被加了 rj+1 次。接着推导:

i=1rj=1idj=i=1rdi×(ri+1)=i=1rdi×(r+1)i=1rdi×i

i=1rdi 并不能推出 i=1rdi×i 的值,所以要用两个树状数组分别维护 didi×i 的和信息。

那么怎么做区间加呢?考虑给原数组 a[lr] 区间加 xd 带来的影响。

因为差分是 d[i]=a[i]a[i1]

  • a[l] 多了 va[l1] 不变,所以 d[l] 的值多了 v
  • a[r+1] 不变而 a[r] 多了 v,所以 d[r+1] 的值少了 v
  • 对于不等于 l 且不等于 r+1 的任意 ia[i]a[i1] 要么都没发生变化,要么都加了 va[i]+v(a[i1]+v) 还是 a[i]a[i1],所以其它的 d[i] 均不变。

那就不难想到维护方式了:对于维护 di 的树状数组,对 l 单点加 vr+1 单点加 v;对于维护 di×i 的树状数组,对 l 单点加 v×lr+1 单点加 v×(r+1)

而更弱的问题,「区间加求单点值」,只需用树状数组维护一个差分数组 di。询问 a[x] 的单点值,直接求 d[1x] 的和即可。

这里直接给出「区间加区间和」的代码:

实现:

int t1[MAXN], t2[MAXN], n; int lowbit(int x) { return x & (-x); } void add(int k, int v) { int v1 = k * v; while (k <= n) { t1[k] += v, t2[k] += v1; // 注意不能写成 t2[k] += k * v,因为 k 的值已经不是原数组的下标了 k += lowbit(k); } } int getsum(int *t, int k) { int ret = 0; while (k) { ret += t[k]; k -= lowbit(k); } return ret; } void add1(int l, int r, int v) { add(l, v), add(r + 1, -v); // 将区间加差分为两个前缀加 } long long getsum1(int l, int r) { return (r + 1ll) * getsum(t1, r) - 1ll * l * getsum(t1, l - 1) - (getsum(t2, r) - getsum(t2, l - 1)); }

根据这个原理,应该可以实现「区间乘区间积」,「区间异或一个数,求区间异或值」等,只要满足维护的信息和区间操作是同种运算即可,感兴趣的读者可以自己尝试。

4|4二维树状数组

1|0单点修改,子矩阵查询

二维树状数组,也被称作树状数组套树状数组,用来维护二维数组上的单点修改和前缀信息问题。

与一维树状数组类似,我们用 c(x,y) 表示 a(xlowbit(x)+1,ylowbit(y)+1)a(x,y) 的矩阵总信息,即一个以 a(x,y) 为右下角,高 lowbit(x),宽 lowbit(y) 的矩阵的总信息。

对于单点修改,设:

f(x,i)={xi=0f(x,i1)+lowbit(f(x,i1))i>0

f(x,i)x 在树状数组树形态上的第 i 级祖先(第 0 级祖先是自己)。

则只有 c(f(x,i),f(y,j)) 中的元素管辖 a(x,y),修改 a(x,y) 时只需修改所有 c(f(x,i),f(y,j)),其中 f(x,i)nf(y,j)m

正确性证明:

c(p,q) 管辖 a(x,y),求 pq 的取值范围。

考虑一个大小为 n 的一维树状数组 c1(对应原数组 a1)和一个大小为 m 的一维树状数组 c2(对应原数组 a2)。

则命题等价为:c1(p) 管辖 a1[x]c2(q) 管辖 a2[y] 的条件。

也就是说,在树状数组树形态上,px 及其祖先中的一个点,qy 及其祖先中的一个点。

所以 p=f(x,i)q=f(y,j)

对于查询,我们设:

g(x,i)={xi=0g(x,i1)lowbit(g(x,i1))i,g(x,i1)>00otherwise.

则合并所有 c(g(x,i),g(y,j)),其中 g(x,i),g(y,j)>0

正确性证明:

表示合并两个信息的运算符(比如,如果信息是区间和,则 =+)。

考虑一个一维树状数组 c1c1[g(x,0)]c1[g(x,1)]c1[g(x,2)] 恰好表示原数组上 [1x] 这段区间信息。

类似地,设 t(x)=c(x,g(y,0))c(x,g(y,1))c(x,g(y,2)),则 t(x) 恰好表示 a(xlowbit(x)+1,1)a(x,y) 这个矩阵信息。

又类似地,就有 t(g(x,0))t(g(x,1))t(g(x,2)) 表示 a(1,1)a(x,y) 这个矩阵信息。

其实这里 t(x) 这个函数如果看成一个树状数组,相当于一个树状数组套了一个树状数组,这也就是「树状数组套树状数组」这个名字的来源。

下面给出单点加、查询子矩阵和的代码。

单点加:

void add(int x, int y, int v) { for (int i = x; i <= n; i += lowbit(i)) { for (int j = y; j <= m; j += lowbit(j)) { // 注意这里必须得建循环变量,不能像一维数组一样直接 while (x <= n) 了 c[i][j] += v; } } }

查询子矩阵和:

int sum(int x, int y) { int res = 0; for (int i = x; i > 0; i -= lowbit(i)) { for (int j = y; j > 0; j -= lowbit(j)) { res += c[i][j]; } } return res; } int ask(int x1, int y1, int x2, int y2) { // 查询子矩阵和 return sum(x2, y2) - sum(x2, y1 - 1) - sum(x1 - 1, y2) + sum(x1 - 1, y1 - 1); }

1|0子矩阵加,求子矩阵和

前置知识:前缀和 & 差分和区间加区间和一节。

和一维树状数组的「区间加区间和」问题类似,考虑维护差分数组。

二维数组上的差分数组是这样的:

d(i,j)=a(i,j)a(i1,j)a(i,j1)+a(i1,j1)

为什么这么定义?

这是因为,理想规定状态下,在差分矩阵上做二维前缀和应该得到原矩阵,因为这是一对逆运算。

二维前缀和的公式是这样的:

s(i,j)=s(i1,j)+s(i,j1)s(i1,j1)+a(i,j)

所以,设 a 是原数组,d 是差分数组,有:

a(i,j)=a(i1,j)+a(i,j1)a(i1,j1)+d(i,j)

移项就得到二维差分的公式了。

d(i,j)=a(i,j)a(i1,j)a(i,j1)+a(i1,j1)

这样以来,对左上角 (x1,y1),右下角 (x2,y2) 的子矩阵区间加 v,相当于在差分数组上,对 d(x1,y1)d(x2+1,y2+1) 分别单点加 v,对 d(x2+1,y1)d(x1,y2+1) 分别单点加 v

至于原因,把这四个 d 分别用定义式表示出来,分析一下每项的变化即可。

举个例子吧,初始差分数组为 0,给 a(2,2)a(3,4) 子矩阵加 v 后差分数组会变为:

(000000v00v000000v00v)

(其中 a(2,2)a(3,4) 这个子矩阵恰好是上面位于中心的 2×3 大小的矩阵。)

因此,子矩阵加的做法是:转化为差分数组上的四个单点加操作。

现在考虑查询子矩阵和:

对于点 (x,y),它的二维前缀和可以表示为:

i=1xj=1yh=1ik=1jd(h,k)

原因就是差分的前缀和的前缀和就是原本的前缀和。

和一维树状数组的「区间加区间和」问题类似,统计 d(h,k) 的出现次数,为 (xh+1)×(yk+1)

然后接着推导:

i=1xj=1yh=1ik=1jd(h,k)=i=1xj=1yd(i,j)×(xi+1)×(yj+1)=i=1xj=1yd(i,j)×(xy+x+y+1)d(i,j)×i×(y+1)d(i,j)×j×(x+1)+d(i,j)×i×j

所以我们需维护四个树状数组,分别维护 d(i,j)d(i,j)×id(i,j)×jd(i,j)×i×j 的和信息。

当然了,和一维同理,如果只需要子矩阵加求单点值,维护一个差分数组然后询问前缀和就足够了。

下面给出代码:

实现:

typedef long long ll; ll t1[N][N], t2[N][N], t3[N][N], t4[N][N]; void add(ll x, ll y, ll z) { for (int X = x; X <= n; X += lowbit(X)) for (int Y = y; Y <= m; Y += lowbit(Y)) { t1[X][Y] += z; t2[X][Y] += z * x; // 注意是 z * x 而不是 z * X,后面同理 t3[X][Y] += z * y; t4[X][Y] += z * x * y; } } void range_add(ll xa, ll ya, ll xb, ll yb, ll z) { //(xa, ya) 到 (xb, yb) 子矩阵 add(xa, ya, z); add(xa, yb + 1, -z); add(xb + 1, ya, -z); add(xb + 1, yb + 1, z); } ll ask(ll x, ll y) { ll res = 0; for (int i = x; i; i -= lowbit(i)) for (int j = y; j; j -= lowbit(j)) res += (x + 1) * (y + 1) * t1[i][j] - (y + 1) * t2[i][j] - (x + 1) * t3[i][j] + t4[i][j]; return res; } ll range_ask(ll xa, ll ya, ll xb, ll yb) { return ask(xb, yb) - ask(xb, ya - 1) - ask(xa - 1, yb) + ask(xa - 1, ya - 1); }

4|5权值树状数组及应用

我们知道,普通树状数组直接在原序列的基础上构建,c6 表示的就是 a[56] 的区间信息。

然而事实上,我们还可以在原序列的权值数组上构建树状数组,这就是权值树状数组。

什么是权值数组?

一个序列 a 的权值数组 b,满足 b[x] 的值为 xa 中的出现次数。

例如:a=(1,3,4,3,4) 的权值数组为 b=(1,0,2,2)

很明显,b 的大小和 a 的值域有关。

若原数列值域过大,且重要的不是具体值而是值与值之间的相对大小关系,常离散化原数组后再建立权值数组。

另外,权值数组是原数组无序性的一种表示:它重点描述数组的元素内容,忽略了数组的顺序,若两数组只是顺序不同,所含内容一致,则它们的权值数组相同。

因此,对于给定数组的顺序不影响答案的问题,在权值数组的基础上思考一般更直观,比如 [NOIP2021] 数列

运用权值树状数组,我们可以解决一些经典问题。

1|0单点修改,查询全局第 k 小

在此处只讨论第 k 小,第 k 大问题可以通过简单计算转化为第 k 小问题。

该问题可离散化,如果原序列 a 值域过大,离散化后再建立权值数组 b。注意,还要把单点修改中的涉及到的值也一起离散化,不能只离散化原数组 a 中的元素。

对于单点修改,只需将对原数列的单点修改转化为对权值数组的单点修改即可。具体来说,原数组 a[x]y 修改为 z,转化为对权值数组 b 的单点修改就是 b[y] 单点减 1b[z] 单点加 1

对于查询第 k 小,考虑二分 x,查询权值数组中 [1,x] 的前缀和,找到 x0 使得 [1,x0] 的前缀和 <k[1,x0+1] 的前缀和 k,则第 k 大的数是 x0+1(注:这里认为 [1,0] 的前缀和是 0)。

这样做时间复杂度是 Θ(log2n) 的。

考虑用倍增替代二分。

x=0sum=0,枚举 ilog2n 降为 0

  • 查询权值数组中 [x+1x+2i] 的区间和 t
  • 如果 sum+t<k,扩展成功,xx+2isumsum+t;否则扩展失败,不操作。

这样得到的 x 是满足 [1x] 前缀和 <k 的最大值,所以最终 x+1 就是答案。

看起来这种方法时间效率没有任何改善,但事实上,查询 [x+1x+2i] 的区间和只需访问 c[x+2i] 的值即可。

原因很简单,考虑 lowbit(x+2i),它一定是 2i,因为 x 之前只累加过 2j 满足 j>i。因此 c[x+2i] 表示的区间就是 [x+1x+2i]

如此一来,时间复杂度降低为 Θ(logn)

实现:

// 权值树状数组查询第 k 小 int kth(int k) { int sum = 0, x = 0; for (int i = log2(n); ~i; --i) { x += 1 << i; // 尝试扩展 if (x >= n || sum + t[x] >= k) // 如果扩展失败 x -= 1 << i; else sum += t[x]; } return x + 1; }

1|0全局逆序对(全局二维偏序)

全局逆序对也可以用权值树状数组巧妙解决。问题是这样的:给定长度为 n 的序列 a,求 a 中满足 i<ja[i]>a[j] 的数对 (i,j) 的数量。

该问题可离散化,如果原序列 a 值域过大,离散化后再建立权值数组 b

我们考虑从 n1 倒序枚举 i,作为逆序对中第一个元素的索引,然后计算有多少个 j>i 满足 a[j]<a[i],最后累计答案即可。

事实上,我们只需要这样做(设当前 a[i]=x):

  • 查询 b[1x1] 的前缀和,即为左端点为 a[i] 的逆序对数量。
  • b[x] 自增 1

原因十分自然:出现在 b[1x1] 中的元素一定比当前的 x=a[i] 小,而 i 的倒序枚举,自然使得这些已在权值数组中的元素,在原数组上的索引 j 大于当前遍历到的索引 i

用例子说明,a=(4,3,1,2,1)

i 按照 51 扫:

  • a[5]=1,查询 b[10] 前缀和,为 0b[1] 自增 1b=(1,0,0,0)
  • a[4]=2,查询 b[11] 前缀和,为 1b[2] 自增 1b=(1,1,0,0)
  • a[3]=1,查询 b[10] 前缀和,为 0b[1] 自增 1b=(2,1,0,0)
  • a[2]=3,查询 b[12] 前缀和,为 3b[3] 自增 1b=(2,1,1,0)
  • a[1]=4,查询 b[13] 前缀和,为 4b[4] 自增 1b=(2,1,1,1)

所以最终答案为 0+1+0+3+4=8

注意到,遍历 i 后的查询 b[1x1] 和自增 b[x] 的两个步骤可以颠倒,变成先自增 b[x] 再查询 b[1x1],不影响答案。两个角度来解释:

  • b[x] 的修改不影响对 b[1x1] 的查询。
  • 颠倒后,实质是在查询 ija[i]>a[j] 的数对数量,而 i=j 时不存在 a[i]>a[j],所以 ij 相当于 i<j,所以这与原来的逆序对问题是等价的。

如果查询非严格逆序对(i<ja[i]a[j])的数量,那就要改为查询 b[1x] 的和,这时就不能颠倒两步了,还是两个角度来解释:

  • b[x] 的修改 影响b[1x] 的查询。
  • 颠倒后,实质是在查询 ija[i]a[j] 的数对数量,而 i=j 时恒有 a[i]a[j],所以 ij 不相当于 i<j,与原问题 不等价

如果查询 ija[i]a[j] 的数对数量,那这两步就需要颠倒了。

另外,对于原逆序对问题,还有一种做法是正着枚举 j,查询有多少 i<j 满足 a[i]>a[j]。做法如下(设 x=a[j]):

  • 查询 b[x+1V]Vb 的大小,即 a 的值域(或离散化后的值域))的区间和。
  • b[x] 自增 1

原因:出现在 b[x+1V] 中的元素一定比当前的 x=a[j] 大,而 j 的正序枚举,自然使得这些已在权值数组中的元素,在原数组上的索引 i 小于当前遍历到的索引 j

4|6树状数组维护不可差分信息

比如维护区间最值等。

注意,这种方法虽然码量小,但单点修改和区间查询的时间复杂度均为 Θ(log2n),比使用线段树的时间复杂度 Θ(logn) 劣。

1|0区间查询

我们还是基于之前的思路,从 r 沿着 lowbit 一直向前跳,但是我们不能跳到 l 的左边。

因此,如果我们跳到了 c[x],先判断下一次要跳到的 xlowbit(x) 是否小于 l

  • 如果小于 l,我们直接把 a[x] 单点 合并到总信息里,然后跳到 c[x1]
  • 如果大于等于 l,说明没越界,正常合并 c[x],然后跳到 c[xlowbit(x)] 即可。

下面以查询区间最大值为例,给出代码:

实现:

int getmax(int l, int r) { int ans = 0; while (r >= l) { ans = max(ans, a[r]); --r; for (; r - lowbit(r) >= l; r -= lowbit(r)) { // 注意,循环条件不要写成 r - lowbit(r) + 1 >= l // 否则 l = 1 时,r 跳到 0 会死循环 ans = max(ans, C[r]); } } return ans; }

可以证明,上述算法的时间复杂度是 Θ(log2n)

时间复杂度证明:

考虑 rl 不同的最高位,一定有 r 在这一位上为 1l 在这一位上为 0(因为 rl)。

如果 r 在这一位的后面仍然有 1,一定有 rlowbit(r)l,所以下一步一定是把 r 的最低位 1 填为 0

如果 r 的这一位 1 就是 r 的最低位 1,无论是 rrlowbit(r) 还是 rr1r 的这一位 1 一定会变为 0

因此,r 经过至多 logn 次变换后,rl 不同的最高位一定可以下降一位。所以,总时间复杂度是 Θ(log2n)

1|0单点更新

请先理解树状数组树形态的以下两条性质,再学习本节。

  • u=s×2k+1+2k,则其儿子数量为 k=log2lowbit(u),编号分别为 u2t(0t<k)
  • u 的所有儿子对应 c 的管辖区间恰好拼接成 [l(u),u1]

关于这两条性质的含义及证明,都可以在本页面的 树状数组与其树形态的性质 一节找到。

更新 a[x] 后,我们只需要更新满足在树状数组树形态上,满足 yx 的祖先的 c[y]

对于最值(以最大值为例),一种常见的错误想法是,如果 a[x] 修改成 p,则将所有 c[y] 更新为 max(c[y],p)。下面是一个反例:(1,2,3,4,5) 中将 5 修改成 4,最大值是 4,但按照上面的修改这样会得到 5。将 c[y] 直接修改为 p 也是错误的,一个反例是,将上面例子中的 3 修改为 4

事实上,对于不可差分信息,不存在通过 p 直接修改 c[y] 的方式。这是因为修改本身就相当于是把旧数从原区间「移除」,然后加入一个新数。「移除」时对区间信息的影响,相当于做「逆运算」,而不可差分信息不存在「逆运算」,所以无法直接修改 c[y]

换句话说,对每个受影响的 c[y],这个区间的信息我们必定要重构了。

考虑 c[y] 的儿子们,它们的信息一定是正确的(因为我们先更新儿子再更新父亲),而这些儿子又恰好组成了 [l(y),y1] 这一段管辖区间,那再合并一个单点 a[y] 就可以合并出 [l(y),y],也就是 c[y] 了。这样,我们能用至多 logn 个区间重构合并出每个需要修改的 c

实现:

void update(int x, int v) { a[x] = v; for (int i = x; i <= n; i += lowbit(i)) { // 枚举受影响的区间 C[i] = a[i]; for (int j = 1; j < lowbit(i); j *= 2) { C[i] = max(C[i], C[i - j]); } } }

容易看出上述算法时间复杂度为 Θ(log2n)

1|0建树

可以考虑拆成 n 个单点修改,Θ(nlog2n) 建树。

也有 Θ(n) 的建树方法,见本页面 Θ(n) 建树一节的方法一。

4|7Tricks

1|0Θ(n) 建树

以维护区间和为例。

方法一:

每一个节点的值是由所有与自己直接相连的儿子的值求和得到的。因此可以倒着考虑贡献,即每次确定完儿子的值后,用自己的值更新自己的直接父亲。

实现:

// Θ(n) 建树 void init() { for (int i = 1; i <= n; ++i) { t[i] += a[i]; int j = i + lowbit(i); if (j <= n) t[j] += t[i]; } }

方法二:

前面讲到 c[i] 表示的区间是 [ilowbit(i)+1,i],那么我们可以先预处理一个 sum 前缀和数组,再计算 c 数组。

实现:

// Θ(n) 建树 void init() { for (int i = 1; i <= n; ++i) { t[i] = sum[i] - sum[i - lowbit(i)]; } }

1|0时间戳优化

对付多组数据很常见的技巧。若每次输入新数据都暴力清空树状数组,就可能会造成超时。因此使用 tag 标记,存储当前节点上次使用时间(即最近一次是被第几组数据使用)。每次操作时判断这个位置 tag 中的时间和当前时间是否相同,就可以判断这个位置应该是 0 还是数组内的值。

实现:

// 时间戳优化 int tag[MAXN], t[MAXN], Tag; void reset() { ++Tag; } void add(int k, int v) { while (k <= n) { if (tag[k] != Tag) t[k] = 0; t[k] += v, tag[k] = Tag; k += lowbit(k); } } int getsum(int k) { int ret = 0; while (k) { if (tag[k] == Tag) ret += t[k]; k -= lowbit(k); } return ret; }

4|8例题


__EOF__

本文作者So_noSlack
本文链接https://www.cnblogs.com/So-noSlack/p/18306501.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   So_noSlack  阅读(56)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
历史上的今天:
2023-07-17 第七节 搜索专题 - 3
点击右上角即可分享
微信分享提示