DP 基础

\(\text{DP}\) 基础

本文记录了一些基础的 \(\text{DP}\) 以及一些优化技巧。

状压 \(\text{DP}\)

状压 \(\text{DP}\) 与其说是一种 \(\text{DP}\),不如说是一种暴搜的技巧,通过把 \(01\) 状态压缩进一个支持快速枚举和比较的集合中(例如 \(32\) 位整数或 std::bitset)来实现优化复杂度的一个 \(\text{trick}\)

状压 \(\text{DP}\) 与位运算强关联,位运算相关的技巧可以见 GCC tricks

一些位运算常见的技巧:

  • 对于集合中的元素,最好在一开始读入的时候就以 \(\text{0-index}\) 读入,这样便于压缩进二进制集合中。

例题

[SCOI2005] 互不侵犯

题意:在 \(n \times n\) 的国际象棋的棋盘里面放 \(m\) 个国王,使他们互不攻击,共有多少种摆放方案。

定义 \(f_{i, S, k}\) 为设定完前 \(i\) 行,第 \(i\) 行的二进制集合为 \(S\),目前已经设定好了 \(k\) 个国王的方案数。

考虑什么时候可以转移,定义当前行的状态为 \(S\),上一行的转移为 \(T\)

  • 如果 \([S \And T \ll 1][S \And T][S \And T \gg 1] = 0\),则说明 \(T\)\(S\) 左中右三格之内没有交集,即这一行的每一个国王都不在上一行国王能吃的范围内,意味可以进行转移:

\[f_{i, S, k} = f_{i, S, k} + f_{i - 1, T, k - |S|} \quad (|S| \leq k \leq m) \]

  • 否则则说明这一行的国王会与上一行的国王冲突,转移不能进行。

讲一讲这题的特殊的 \(\text{trick}\)

注意到有许多状态在行这个层面本身就是不合法的,例如 \(\text{101010*11*0101}\),只要二进制位上有相邻的位被置位,则该状态不合法。

因此,我们可以先做一个预处理,筛选出所有合法的状态,在每一轮转移时,直接枚举合法的状态即可。

更进一步,我们其实还可以预处理出对于任意一个状态 \(T\),它可以转移到的合法状态 \(S\)

时间复杂度不太好分析,其上界为 \(O(nm \times 2 ^ {2n})\),但实际上远优于该时间复杂度,简称其为 \(O(\text{能过})\)

事实上,本题也可以使用滚动数组压缩一维空间,也可以使用矩阵快速幂优化,但本题数据范围较小,故不用。

代码:

#include <bits/extc++.h>
#define inline __always_inline
#define popcnt(x) __builtin_popcount(x)
template <typename T> inline void read(T &x)
{
	char ch;
	for (ch = getchar(); !isdigit(ch); ch = getchar());
	for (x = 0; isdigit(ch); ch = getchar())	x = x * 10 + ch - '0';
}
const int MaxN = 10, MaxA = 1 << 9, MaxM = 85;

int n, m, mask, cnt = 0, s[MaxA];
std::vector<int> trans[MaxA];
int64_t f[MaxN][MaxA][MaxM];
int main()
{
	read(n), read(m);
	mask = (1 << n) - 1;
	for (int x = 0; x <= mask; x++)
		if (!(x & (x << 1 | x >> 1)))
			f[1][cnt][popcnt(x)] = 1, s[cnt++] = x;
	for (int j = 0; j < cnt; j++)
		for (int k = 0; k < cnt; k++)
			if (!(s[k] & (s[j] << 1 | s[j] | s[j] >> 1)))
				trans[j].push_back(k);
	for (int i = 2; i <= n; i++)
		for (int j = 0; j < cnt; j++)
			for (auto &&k : trans[j])
				for (int t = popcnt(s[k]); t <= m; t++)
					f[i][k][t] += f[i - 1][j][t - popcnt(s[k])];
	int64_t ans = 0;
	for (int x = 0; x < cnt; x++) ans += f[n][x][m];
	printf("%ld", ans);
	return 0;
}

多倍经验(类似题,不能套代码,需要改动代码逻辑):

[POI2004] PRZ

定义 \(f_S\) 为集合 \(S\) 所包含的所有队员过桥的最小时间,则容易推出转移:

\[f_S = \min_{T \subseteq S \ \land \ W(S - T) \leq m} f_T + T(S - T) \]

其中 \(S - T\) 代表集合减,在代码实现中可以用 \(S \oplus T\) 实现,对于 \(W(S)\)\(T(S)\) 这两个函数可以先预处理出集合函数的值。

如果预处理不影响代码的时间复杂度,可以预处理出尽可能多的常用信息以减小常数,简化编码。

预处理 \(W(S)\)\(T(S)\) 的代码如下:

#define MSB(x) std::__lg(x)
for (int i = 1; i <= mask; i++)
{
	T[i] = std::max(T[i ^ 1 << MSB(i)], t[MSB(i)]);
	W[i] = W[i ^ 1 << MSB(i)] + w[MSB(i)];
}

其中 \(\operatorname{MSB}(x)\) 返回 \(x\) 的最高有效位,从而实现集合递推。

本题还用到了一个 \(\text{trick}\)子集枚举

对于一个二进制集合 \(S\),枚举其所有的子集 \(T\) 的代码如下:

for (int T = S; ; T = T - 1 & S)
{
	// do something here.
	if (!T) break;
}

上述算法等效于忽略了 \(S\) 在二进制中的所有的 \(0\),直接向 \(1\) 的有效位借位,从而实现枚举子集,时间复杂度为 \(O(3 ^ n)\)

总代码:

#include <bits/extc++.h>

#define inline __always_inline
#define MSB(x) std::__lg(x)
template <typename T> inline void chkmin(T &x, const T &y) { if (x > y) x = y; }
template <typename T> inline void read(T &x)
{
	char ch;
	for (ch = getchar(); !isdigit(ch); ch = getchar());
	for (x = 0; isdigit(ch); ch = getchar())	x = x * 10 + ch - '0';
}
const int MaxN = 16, MaxS = 1 << MaxN;

int m, n, mask, t[MaxN], w[MaxN], T[MaxS], W[MaxS], f[MaxS];
int main()
{
	read(m), read(n), mask = (1 << n) - 1;
	for (int i = 0; i < n; i++) read(t[i]), read(w[i]);
	for (int i = 1; i <= mask; i++)
	{
		T[i] = std::max(T[i ^ 1 << MSB(i)], t[MSB(i)]);
		W[i] = W[i ^ 1 << MSB(i)] + w[MSB(i)];
	}
	memset(f, 0x3f, sizeof(f)), f[0] = 0;
	for (int i = 1; i <= mask; i++)
		for (int j = i - 1 & i; ; j = j - 1 & i)
		{
			if (W[i ^ j] <= m) chkmin(f[i], f[j] + T[i ^ j]);
			if (!j) break;
		}
	printf("%d", f[mask]);
	return 0;
}

*花园

小 L 有一座环形花园,沿花园的顺时针方向,他把各个花圃编号为 \(1 \sim n\)。花园 \(1\)\(n\) 是相邻的。
任意相邻 \(m\) 个花圃中都只有不超过 \(k\) 个 C 形的花圃,其余花圃均为 P 形的花圃。
请帮小 L 求出符合规则的花园种数对 \(10 ^ 9 + 7\) 取模的结果。
\(2 \leq n \le 10^{15}\)\(2 \leq m \leq \min(n, 5)\)\(1 \leq k \lt m\)

本题的 \(\text{DP}\) 推导私以为很有难度,先考虑朴素的 \(\text{DP}\) 怎么做。

定义 \(f_{i, S}\) 为前 \(i - 1\) 个花圃已种完,以第 \(i\) 个花圃为起点,后 \(m\) 个花圃对应的状态集合是 \(S\) 的方案数。

稍加思考可以推出转移:

\[f_{i, S} = \sum \begin{cases} f_{i - 1, S \gg 1} \\ f_{i - 1, S \gg 1 | 2 ^ m} \quad (|(S \gg 1 | 2 ^ m)| = m) \end{cases} \]

考虑如何统计答案,这里需要注意一个 误区,直接统计 \(\sum_S f_{n, S}\) 是错误的,因为初始状态为 \(S\) 的中间量可以从初始状态为 \(T\) 的中间量转移,使得答案混在一起。

正确的做法是枚举每一种初始状态 \(S\),独立地跑一遍 \(\text{DP}\),并累加 \(f_{n, S}\)

同时还可以利用滚动数组压缩一维,就可以取得 \(\text{80 pts}\) 的好成绩,时间复杂度 \(O(2 ^ {2m} \times n)\)

#include <bits/extc++.h>

#define inline __always_inline
#define popcnt(x) __builtin_popcount(x)
template <typename T> inline void read(T &x)
{
	char ch;
	for (ch = getchar(); !isdigit(ch); ch = getchar());
	for (x = 0; isdigit(ch); ch = getchar())	x = x * 10 + ch - '0';
}
const int MaxN = 1e5 + 5, MaxM = 5, MaxA = 1 << MaxM, mod = 1e9 + 7;

int64_t n;
int m, k, mask, s[MaxA], cnt = 0, f[2][MaxA];
int main()
{
	read(n), read(m), read(k), mask = (1 << m) - 1;
	for (int i = 0; i <= mask; i++)
		if (popcnt(i) <= k) s[cnt++] = i;
	int ans = 0;
	for (int t = 0; t < cnt; t++)
	{
		memset(f[0], 0, sizeof(f[0])), f[0][s[t]] = 1;
		for (int i = 1; i <= n; i++)
		{
			int prev = i - 1 & 1, next = i & 1;
			memset(f[next], 0, sizeof(f[next]));
			for (int j = 0; j < cnt; j++)
			{
				int x = s[j] >> 1 | 1 << m - 1;
				(f[next][s[j]] += f[prev][s[j] >> 1]) %= mod;
				if (popcnt(x) <= k)
					(f[next][s[j]] += f[prev][x]) %= mod;
			}
		}
		(ans += f[n & 1][s[t]]) %= mod;
	}
	printf("%d", ans);
	return 0;
}

考虑进一步优化,由于 \(n\) 的值域巨大,因此考虑矩阵快速幂。

矩阵快速幂只需要使用普通的 \((\times, +)\) 矩阵即可。

注意任何矩阵快速幂,如果乘法过程中有可能爆 \(32\) 位整数,即使初始值设定是 \(01\) 矩阵也一定要开 long long,因为在数次乘法之后,矩阵中的值可能非常大。

#include <bits/extc++.h>

#define inline __always_inline
#define popcnt(x) __builtin_popcount(x)
template <typename T> inline void read(T &x)
{
	char ch;
	for (ch = getchar(); !isdigit(ch); ch = getchar());
	for (x = 0; isdigit(ch); ch = getchar())	x = x * 10 + ch - '0';
}
const int MaxN = 1e5 + 5, MaxM = 5, MaxA = 1 << MaxM, mod = 1e9 + 7;

int64_t n;
int m, k, mask, s[MaxA], cnt = 0, f[2][MaxA];
struct vector_t
{
	int v[MaxA];
	inline void clear() { memset(v, 0, sizeof(v)); }
	inline auto &operator[](int x) { return v[x]; }
	inline const auto &operator[](int x) const { return v[x]; }
} v;
struct matrix_t
{
	int M[MaxA][MaxA];
	inline void unit() { for (int i = 0; i < MaxA; i++) M[i][i] = 1; }
	inline void clear() { memset(M, 0, sizeof(M)); }
	inline auto &operator[](int x) { return M[x]; }
	inline const auto &operator[](int x) const { return M[x]; }
} trans;
inline matrix_t operator*(const matrix_t &x, const matrix_t &y)
{
	matrix_t m; m.clear();
	for (int i = 0; i < MaxA; i++)
		for (int k = 0; k < MaxA; k++)
			for (int j = 0; j < MaxA; j++)
				m[i][j] = (m[i][j] + 1l * x[i][k] * y[k][j]) % mod;		// 不开 long long 见祖宗!!!
	return m;
}
inline vector_t operator*(const matrix_t &x, const vector_t &y)
{
	vector_t v; v.clear();
	for (int i = 0; i < MaxA; i++)
		for (int j = 0; j < MaxA; j++)
			v[i] = (v[i] + 1l * x[i][j] * y[j]) % mod;
	return v;
}
inline matrix_t operator^(matrix_t x, int64_t n)
{
	matrix_t m; m.clear(), m.unit();
	for (; n; n >>= 1, x = x * x)
		if (n & 1) m = m * x;
	return m;
}

int main()
{
	read(n), read(m), read(k), mask = (1 << m) - 1;
	for (int i = 0; i <= mask; i++)
		if (popcnt(i) <= k)
		{
			s[cnt++] = i;
			trans[i][i >> 1] = 1;
			int j = i >> 1 | 1 << m - 1;
			if (popcnt(j) <= k)
				trans[i][j] = 1;
		}
	trans = trans ^ n;
	int ans = 0;
	for (int t = 0; t < cnt; t++)
	{
		v[s[t]] = 1;
		(ans += (trans * v)[s[t]]) %= mod;
		v[s[t]] = 0;
	}
	printf("%d", ans);
	return 0;
}

*[USACO13NOV] No Change G

你有 \(k\) 个硬币,面值 \(w_{1 \dots k}\),希望购买 \(n\) 件商品,价格 \(c_{1 \dots n}\)
你必须按顺序购买,记上一次购买完了前 \(l\) 件商品,这一次你可以花恰好一枚硬币来购买 \([l + 1, r]\) 件商品(没有找零)
请计算购买完 \(n\) 件商品你最多能剩下多少钱。
\(1 \leq k \leq 16, 1 \leq n \leq 10 ^ 5\)

定义 \(f_S\) 为使用完集合 \(S\) 的所有硬币所能购买完的最大商品数,则可以推出转移:

\[\begin{aligned} k(l, w) &= \max_{r \in [l, n], \sum_{i = l} ^ r c_i \leq w} r \\ f_S &= \max_{i \in S} f_{S - i} + k(f_{S - i} + 1, w_i) \end{aligned} \]

其中 \(k(l, w)\) 可以使用二分 \(O(\log n)\) 计算,也可以使用双指针预处理。

本题 \(\text{trick}\):枚举二进制集合 \(S\) 的元素。

#define LSB(x) (__builtin_ctz(x))
for (int T = S; T; T ^= T & -T)
{
	int x = LSB(T);
	// do something here.
}

其中 \(\operatorname{LSB}(x)\) 指二进制下的最低有效位。

代码:

#include <bits/extc++.h>

#define inline __always_inline
#define MSB(x) (31 - __builtin_clz(x))
#define LSB(x) (__builtin_ctz(x))
template <typename T> inline void chkmax(T &x, const T &y) { if (x < y) x = y; }
template <typename T> inline void read(T &x)
{
	char ch;
	for (ch = getchar(); !isdigit(ch); ch = getchar());
	for (x = 0; isdigit(ch); ch = getchar())	x = x * 10 + ch - '0';
}
const int MaxK = 16, MaxA = 1 << MaxK, MaxN = 1e5 + 5;

int k, n, mask, w[MaxK], c[MaxN], f[MaxN];
int64_t sum = 0, g[MaxN];
inline int diff(int l, int r) { return c[r] - c[l - 1]; }
inline int find(int s, int x)
{
	int l = s, r = n + 1;
	while (r - l > 1)
	{
		int mid = l + r >> 1, c = diff(s, mid);
		if (x < c) r = mid;
		else l = mid;
	}
	return l;
}
int main()
{
	read(k), read(n), mask = (1 << k) - 1;
	for (int i = 0; i < k; i++) read(w[i]), sum += w[i];
	for (int i = 1; i <= n; i++) read(c[i]), c[i] += c[i - 1];
	int64_t ans = -1;
	for (int i = 1; i <= mask; i++)
	{
		g[i] = g[i ^ 1 << MSB(i)] + w[MSB(i)];
		for (int j = i; j; j ^= j & -j)
		{
			int x = LSB(j);
			if (f[i ^ 1 << x] == n) { f[i] = n; break; }
			chkmax(f[i], find(f[i ^ 1 << x] + 1, w[x]));
		}
		if (f[i] == n) chkmax(ans, sum - g[i]);
	}
	printf("%ld", ans);
	return 0;
}

树形 \(\text{DP}\)

树形 DP,顾名思义,就是把 \(\text{DP}\) 这一形式套到了树上。

树上背包

换根 \(\text{DP}\)

换根 \(\text{DP}\) 是一类较为抽象的树形 DP,其核心思想在于,对于一个需要求 \(f_{1 \dots n}\)\(\text{DP}\) 问题:

  • 先计算 \(f_1\)
  • \(\text{DFS}\) 遍历树,对于一个节点 \(u\) 与其孩子 \(v\),根据题目的要求,由 \(f_u\) 转移出 \(f_v\)

Tree Painting

*[APIO2014] 连珠线

本题很有难度,是一道练思维的好题。

首先先观察题目。对于一次 Insert 操作,可以发现该操作会产生由两条蓝线连接的三个点,其形态为:\(x \rightarrow u \rightarrow y\),且不会再改变。同时,一个节点 \(u\) 也只能作为一条蓝线的中点。

因此可以考虑把中间的点 \(u\) 纳入 \(\text{DP}\) 的状态,并分类讨论 \(u\) 是不是蓝线的中点,记状态为 \(f_{u, 0/1}\)

但稍加思考就会发现这样很难推导转移,因为不好枚举与 \(u\) 相邻接的两个蓝线端点。

因此可以考虑更改状态设计,强制规定 \(f_{u, 1}\) 时,\(u \rightarrow \operatorname{parent}(u)\) 必须是蓝线,从而只需要枚举 \(v \in \operatorname{son}(u)\) 即可。

但这样答案枚举不完全,因此需要考虑以每个节点为根节点独立地做一遍 \(\text{DP}\),从而枚举到每一种蓝线的形态,时间复杂度 \(O(n ^ 2)\)

稍加思考可以推出转移:

\[\begin{aligned} w_v &\leftarrow w_{v, \operatorname{parent}(v)} \\ F(v) &\leftarrow \max \begin{cases} f_{v, 0} \\ f_{v, 1} + w_v \end{cases} \\ \Delta(v) &\leftarrow f_{v, 0} + w_v - F(v) \\ f_{u, 0} &= \sum_{v \in \operatorname{son}(u)} F(v) \\ f_{u, 1} &= f_{u, 0} + \max_{v \in \operatorname{son}(u)} \Delta(v) \end{aligned} \]

考虑使用换根 \(\text{DP}\) 优化,对于根节点由 \(u \rightarrow v\) 的转移,有:

\[\begin{aligned} \operatorname{son}'(u) &\leftarrow \operatorname{son}(u) - \{v\} \\ \operatorname{son}'(v) &\leftarrow \operatorname{son}(v) + \{u\} \\ f_{u, 0}' &= f_{u, 0} - F(v) \\ f_{u, 1}' &= f_{u, 0}' + \max_{x \in \operatorname{son}'(u)} \Delta(x) \\ F'(u) &\leftarrow \max \begin{cases} f_{u, 0}' \\ f_{u, 1}' + w_u \end{cases} \\ \Delta(u) &\leftarrow f_{u, 0}' + w_u - F'(u) \\ f_{v, 0}' &= f_{v, 0} + F'(u) \\ f_{v, 1}' &= f_{v, 0}' + \max_{x \in \operatorname{son}'(v)} \Delta(x) \end{aligned} \]

则原 \(\text{DP}\) 的答案能够以 \(O(1)\) 的时间复杂度逐步转移至 \(1 \dots n\)

如何维护 \(\max_{x \in \operatorname{son}'(u)} \Delta(x)\) 这个式子?一个常见的套路是维护最大值 \(d_{u, 0}\) 和次大值 \(d_{u, 1}\),则有:

\[\max_{x \in \operatorname{son}'(u)} \Delta(x) = \begin{cases} d_{u, 0} \quad \Delta(v) \neq d_{u, 0} \\ d_{u, 1} \quad \Delta(v) = d_{u, 0} \end{cases} \]

一个推转移的 \(\text{trick}\):

  • 尽可能多地预处理出值,使得你的每一条 \(\text{DP}\) 公式看起来尽可能简洁,尽管变量的个数可能变多,但单个式子更加简单,更容易观察出性质。

代码:

#include <bits/extc++.h>

#define inline __always_inline
template <typename T> inline void chkmax(T &x, const T &y) { if (x < y) x = y; }
template <typename T> inline void chkmax(T &x, T &y, const T &z) 
{
	if (x <= z) y = x, x = z;
	else if (y < z) y = z;
}
template <typename T> inline void read(T &x)
{
	char ch;
	for (ch = getchar(); !isdigit(ch); ch = getchar());
	for (x = 0; isdigit(ch); ch = getchar())	x = x * 10 + ch - '0';
}
const int MaxN = 2e5 + 5, inf = 2e9;

int	n, f[MaxN][2], d[MaxN][3], F[MaxN], g[MaxN][2];
struct edge_t { int v, w; };
std::vector<edge_t> graph[MaxN];
inline void add_edge(int u, int v, int w) { graph[u].push_back({v, w}), graph[v].push_back({u, w}); }
void dfs(int u, int p)
{
	d[u][1] = d[u][2] = -inf; f[u][0] = 0;
	for (auto &&[v, w] : graph[u])
		if (v != p)
		{
			dfs(v, u);
			F[v] = std::max(f[v][0], f[v][1] + w);
			d[v][0] = f[v][0] + w - F[v];
			chkmax(d[u][1], d[u][2], d[v][0]);
			f[u][0] += F[v];
		}
	f[u][1] = f[u][0] + d[u][1];
}
void chroot(int u, int p)
{
	for (auto &&[v, w] : graph[u])
		if (v != p)
		{
			int f_u[2] = {f[u][0] - F[v], f_u[0] + (d[v][0] != d[u][1] ? d[u][1] : d[u][2])};	// special trick!
			int F_u = std::max(f_u[0], f_u[1] + w);
			f[v][0] += F_u;
			chkmax(d[v][1], d[v][2], f_u[0] + w - F_u);
			f[v][1] = f[v][0] + d[v][1];
			chroot(v, u);
		}
}

int main()
{
	read(n);
	for (int i = 1, u, v, w; i < n; i++) read(u), read(v), read(w), add_edge(u, v, w);
	dfs(1, 0);
	chroot(1, 0);
	int ans = 0;
	for (int i = 1; i <= n; i++) chkmax(ans, f[i][0]);
	printf("%d", ans);
	return 0;
}

长链剖分优化

树上启发式合并

动态 \(\text{DP}\)

posted @ 2024-10-20 00:29  yiming564  阅读(30)  评论(0)    收藏  举报