数据结构与算法 模板 动态规划

Data Structure and Algorithm Template

Chapter4. 动态规划

动态规划不是特定的算法, 应该是一种方法, 所以动态规划会更加复杂

DP与其他递推算法有相似之处, 可以与其他递推算法进行对比

动态规划基础

动态规划原理

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

最优子结构

最优子结构也可以考虑用贪心

  1. 原问题最优解涉及到多少子问题
  2. 确定最优解使用哪些子问题时, 需要考察多少种选择
无后效性

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

子问题重叠

如果有大量的重叠子问题, 可以用空间将这些子问题的解存储下来, 避免重复求解

基本思路
  1. 划分状态
  2. 寻找状态转移方程
  3. 按顺序求解每个阶段的问题

记忆化搜索

记忆化搜索是一种通过记录以及遍历过的状态的信息, 从而避免对同一状态重复遍历的搜索实现方式

因为记忆化搜索确保了每个状态只访问一次, 因此是一种常见的DP方式

引入

Luogu P1048 为例

山洞中有 M M M 株不同的草药, 采每一株都需要一些时间 t i t_i ti , 每一株也有它自身的价值 v i v_i vi , 给定一段时间 T T T , 在这段时间内可以采到一些草药, 让采到的草药的总价值最大

( 1 ≤ T ≤ 1 0 3 , 1 ≤ t i , v i , M ≤ 100 1 \le T \le 10^3, 1 \le t_i, v_i, M \le 100 1T103,1ti,vi,M100)

如果使用DFS, 同一个状态会被访问多次, 效率很低, 时间复杂度是指数级别, 运行会超时

如果查询完一个状态之后把它存下来, 下次如果需要访问这个状态就可以直接利用之前存下来的信息, 避免重复计算, 这样就可以提高效率, 是一种典型的 “空间换时间” 做法

int n, t;
int tcost[103], mget[103];
int mem[103][1003];

int dfs(int pos, int tleft) {
    if (mem[pos][tleft] != -1) {
        return mem[pos][tleft];
    }
    if (pos == n + 1) return mem[pos][tleft] = 0;
    int dfs1, dfs2 = -INF;
    dfs1 = dfs(pos + 1, tleft);
    if (tleft >= tcost[pos]) {
        dfs2 = dfs(pos + 1, tleft - tcost[pos]) + mget[pos];
    }
    return mem[pos][tleft] = max(dfs1, dfs2);
}

int main() {
    memset(mem, -1, sizeof(mem));
    cin >> t >> n;
    for (int i = 1; i <= n; i++) {
        cin >> tcost[i] >> mget[i];
    }
    cout << dfs(1, t) << '\n';
    return 0;
}
与递推的联系与区别

记忆化搜索和递推的代码是高度相似的, 这是因为它们采用了同样的状态表示和类似的状态转移, 所以两者的时间复杂度也类似

下面是递推实现的代码

int n, t, w[105], v[105], f[105][1005];

int main() {
    cin >> n >> t;
    for (int i = 1; i <= n; i++) cin >> w[i] >> v[i];
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= t; j++) {
            f[i][j] = f[i - 1][j];
            if (j >= w[i]) {
                f[i][j] = max(f[i][j], f[i - 1][j - w[i]] + v[i]);
            }
        }
    }
    cout << f[n][t] << '\n';
    return 0;
}

记忆化搜索和递推, 都确保了同一状态至多被求解一次, 但是实现略有不同: 递推通过设置明确的访问顺序来避免重复访问, 记忆化搜素没有明确访问顺序, 但通过已经访问过的状态打标记的方式, 也达到了同样的目的

记忆化搜索因为不需要明确访问顺序, 所以实现难度略低于递推, 且可以方便处理边界情况, 但是, 记忆化搜索很难用滚动数组的方式进行优化, 且存在递归所以运行效率比递推低

如何写记忆化搜索代码
方法1
  1. 把dp状态和转移方程写出来
  2. 根据它们写出dfs函数
  3. 添加记忆化数组
int dfs(int i) {
    if (mem[i] != -1) return mem[i];
    int ret = 1;
    for (int j = 1; j < i; j++) {
        if (a[j] < a[i]) {
            ret = max(ret, dfs(j) + 1);
        }
    }
    return mem[i] = ret;
}

int main() {
    memset(mem, -1, sizeof(mem));
    // 读入a数组部分略去
    for (int j = 1; j <= n; j++) {
        ret = max(ret, dfs(j));
    }
    cout << ret << '\n';
}
方法2
  1. 写出dfs程序
  2. 将dfs改成不需要外部变量的dfs
  3. 添加记忆化数组

例子就是引入part中的例题 Luogu P1048

线性DP

引入

Luogu P1216 为例

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

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

在上面这个例子中, 最优路径是 7 → 3 → 8 → 7 → 5 7\rightarrow3\rightarrow8\rightarrow7\rightarrow5 73875

对于三角形的每一个点, 下一步决策只有两种, 往左下和右下, 因此只需要记录该点的最大权值, 利用这个最大权值进行下一步决策

#include <iostream>
using namepsace std;
int a[1000][1000];

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < i; j++) {
            cin >> a[i][j];
        }
    }
    for (int i = n - 2; i >= 0; i--) {
        for (int j = 0; j <= i; j++) {
            a[i][j] += max(a[i + 1][j], a[i + 1][j + 1]);
        }
    }
    cout << a[0][0] << '\n';
    return 0;
}
最长不递减or上升子序列

此处以Acwing 895 为例

给定一个长度为 N N N 的数列, 求数值严格单调递增的子序列的长度最长是多少

输入: 第一行包含整数 N N N , 第二行包含 N N N 个整数, 表示完整序列

输出: 输出一个整数, 表示最大长度

( 1 ≤ N ≤ 100 1\le N \le 100 1N100, − 1 0 9 ≤ 数列中的树 ≤ 1 0 9 -10^9 \le 数列中的树 \le 10^9 109数列中的树109)

计算 f ( i ) f(i) f(i) 时, 尝试将 A i A_i Ai 接到其他的最长不下降子序列后面, 以更新答案

时间复杂度为 O ( n 2 ) O(n^2) O(n2)

#include <iostream>
using namespace std;
int n;
int a[1010], dp[1010];

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }
    for (int i = 1; i <= n; i++) {
        dp[i] = 1;
        for (int j = 1; j < i; j++) {
            if (a[j] < a[i]) {
                dp[i] = max(dp[i], dp[j] + 1);
            }
        }
    }
    int res = 0;
    for (int i = 1; i <= n; i++) {
        res = max(res, dp[i]);
    }
    cout << res << '\n';
    return 0;
}
优化后最长不递减or上升子序列

与上一part类似, 只是数据范围增大, 使用上一part的算法会运行超时

这一part将算法优化, 时间复杂度降低到 O ( n log ⁡ n ) O(n\log n) O(nlogn)

首先, 定义 a 1 … a n a_1 \dots a_n a1an 为原始序列, d d d 为当前的不下降子序列, l e n len len 为子序列的长度, 那么 d l e n d_{len} dlen 就是长度为 l e n len len 的不下降子序列末尾元素

初始化: d 1 d_1 d1 = a 1 a_1 a1 , l e n len len = 1

i i i 从2 到 n n n 循环, 依次求出前 i i i 个元素的最长不下降子序列的长度, 循环的时候只需要维护好数组 d d d l e n len len 即可

考虑进来一个元素 a i a_i ai

  1. 元素大于等于 d l e n d_{len} dlen , 直接将该元素插入到 d d d 序列的末尾
  2. 元素小于 d l e n d_{len} dlen , 找到第一个大于它的元素, 用 a i a_i ai 替换它
for (int i = 0; i < n; i++) cin >> a + i;
memset(dp, 0, 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;
最长公共子序列

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

f ( i , j ) f(i,j) f(i,j) 表示只考虑 A A A 的前 i i i 个元素, B B B 的前 j j j 个元素时的最长公共子序列的长度, 求这时的最长公共子序列长度就是子问题. f ( i , j ) f(i, j) f(i,j) 就是所说的状态, 那么 f ( n , m ) f(n,m) f(n,m) 就是最终的结果了

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

f ( i , j ) = { f ( i − 1 , j − 1 ) + 1 A i = B i max ⁡ ( f ( i − 1 , j ) , f ( i , j − 1 ) ) A i ≠ B i f(i,j)=\begin{cases} f(i - 1, j - 1) + 1 & A_i =B_i\\\max(f(i - 1, j), f(i, j - 1)) & A_i\ne B_i\end{cases} f(i,j)={f(i1,j1)+1max(f(i1,j),f(i,j1))Ai=BiAi=Bi

时间复杂度是 O ( n m ) O(nm) O(nm)

int a[maxn], b[maxn], f[maxn][maxn];

int solve() {
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (a[i] == a[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];
}

背包DP

01背包

假设有第 i i i 个物品重量为 w i w_{i} wi , 价值为 v i v_{i} vi, 以及背包的总容量 W W W, 对于每个物体, 只能取和不取两种状态

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

那么状态转移方程为** f j = max ⁡ ( f j , f j − w i + v i ) f_j=\max \left(f_j,f_{j-w_i}+v_i\right) fj=max(fj,fjwi+vi)**

for (int i = 1; i <= n; i++) {
    for (int j = W, j >= w[i]; j--) {
        f[j] = max(f[j], f[j - w[i]] + v[i]);
    }
}
完全背包

与01背包类似, 区别在于一个物品可以无数次选取,而不是只能被选取一次

设状态 f ( i , j ) f_(i,j) f(i,j) 为只能选前 i i i 个物品时, 容量为 j j j 的背包可以达到的最大价值

状态转移方程为 f i , j = max ⁡ ( f i − 1 , j , f i , j − w i + v i ) f_{i,j}=\max(f_{i-1,j},f_{i,j-w_i}+v_i) fi,j=max(fi1,j,fi,jwi+vi)

for (int i = 1; i <= n; i++) {
    for (int j = w[i]; j <= W; j++) {
        f[j] = max(f[j - w[i]] + v[i], f[j]);
    }
}
多重背包

多重背包也是01背包的变式, 区别在于每种物品有 k i k_i ki个, 而非一个

状态转移方程为 f i , j = max ⁡ k = 0 k i ( f i − 1 , j − k × w i + v i × k ) f_{i,j}=\max_{k=0}^{k_i}(f_{i-1,j-k\times w_i}+v_i\times k) fi,j=k=0maxki(fi1,jk×wi+vi×k)

二进制分组优化

考虑优化, 仍考虑将多重背包转换为01背包求解

具体来说, 就是令 A i , j ( j ∈ [ 0 , ⌊ log ⁡ 2 ( k i + 1 ) ⌋ − 1 ] ) A_{i,j}\left(j\in\left[0,\lfloor \log_2(k_i+1)\rfloor-1\right]\right) Ai,j(j[0,log2(ki+1)⌋1]) 分别表示由 2 j 2^{j} 2j 个单个物品"捆绑"而成的大物品. 如果 k i + 1 k_i+1 ki+1 不是 2 2 2 的整数次幂, 则需要在最后添加一个由 k i − 2 ⌊ log ⁡ 2 ( k i + 1 ) ⌋ − 1 k_i-2^{\lfloor \log_2(k_i+1)\rfloor-1} ki2log2(ki+1)⌋1 个单个物品"捆绑"而成的大物品用于补足

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;
}
混合背包

混合背包就是前面三种背包的混合体

for (循环物品种类) {
    if (是01背包) {
        套用01背包代码
    } else if (是完全背包) {
        套用完全背包代码
    } else if (是多重背包) {
        套用多重背包代码
    }
}
二维费用背包

与01背包类似, 与01背包不同的是, 选择一个物品会消耗两种价值, 只需要在状态中增加一维存放第二种价值即可

for (int k = 1; k <= n; k++) {
	for (int i = m; i >= m[k]; i--) { // 对价值1进行枚举
		for (int j = t; j >= t[k]; j--) { // 对价值2进行枚举
            dp[i][j] = max(dp[i][j], dp[i - m[k]][j - t[k]] + 1);
        }
	}
}
分组背包

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

本质上也是01背包, 其实是从"在所有物品中选择一件" 变成了"从当前组中选择一件", 所有对每一组进行一次01背包即可

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

区间DP

定义

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

f ( i , j ) f(i,j) f(i,j)表示将下标位置 i i i j j j 的所有元素合并能获得的价值的最大值, 那么状态转移方程 f ( i , j ) = max ⁡ { f ( i , k ) + f ( k + 1 , j ) + c o s t } f(i,j)=\max\{f(i,k)+f(k+1,j)+cost\} f(i,j)=max{f(i,k)+f(k+1,j)+cost} , c o s t cost cost为将这两组元素合并起来的代价

性质
  1. 合并: 即将将两个或多个部分进行整合, 也可以反过来
  2. 特征: 能将问题分解为能两两合并的形式
  3. 求解: 对整个问题设最优值, 枚举合并点, 将问题分解为左右两个部分, 最后合并两个部分的最优值得到原问题的最优值
解释

此处以NOI1995的 石子合并 为例

在一个环上有 n n n 个数 a 1 , a 2 , … , a n a_1,a_2,\dots,a_n a1,a2,,an , 进行 n − 1 n-1 n1次合并操作, 每次操作将相邻的两堆合并成一堆, 能获得新的一堆中的石子数量的和的得分, 你需要最大化你的得分

考虑状态转移方程: f ( i , j ) = max ⁡ { f ( i , k ) + f ( k + 1 , j ) + ∑ t = i j a t }   ( i ≤ k < j ) f(i,j)=\max\{f(i,k)+f(k+1,j)+\sum_{t=i}^{j} a_t \}~(i\le k<j) f(i,j)=max{f(i,k)+f(k+1,j)+t=ijat} (ik<j) , f ( i , j ) f(i,j) f(i,j) 表示将区间 [ i , j ] [i, j] [i,j] 合并在一起的最大得分

s u m i sum_i sumi 表示 a a a 数组的前缀和, 状态转移方程变形为 f ( i , j ) = max ⁡ { f ( i , k ) + f ( k + 1 , j ) + s u m j − s u m i − 1 } f(i,j)=\max\{f(i,k)+f(k+1,j)+sum_j-sum_{i-1} \} f(i,j)=max{f(i,k)+f(k+1,j)+sumjsumi1}

怎样进行状态转移

以 $len = j - i + 1 $ 作为DP的阶段, 首先从小到大枚举 l e n len len 然后枚举 i i i , 根据 l e n len len i i i 的值计算出 j j j 的值, 然后枚举 k k k

时间复杂度为 O ( n 3 ) O(n^3) O(n3)

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

树形DP

树形DP, 即在树上进行的DP, 由于树固有的递归性质, 树形DP一般都是递归进行的

基础

此处以Luogu P1352为例

某大学有 n n n 个职员, 编号为 1 ∼ N 1\sim N 1N, 他们之间有从属关系, 也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数 a i a_i ai,但是呢,如果某个职员的直接上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会了。所以,请你编程计算,邀请哪些职员可以使快乐指数最大,求最大的快乐指数

f ( i , 0 / 1 ) f(i,0/1) f(i,0/1) 代表以 i i i 为根的子树的最优解(第二维的值为0表示 i i i 不参加舞会的情况, 1代表 i i i 参加舞会的情况)

对于每个状态, 都存在两种决策

  1. 上司不参加舞会时, 下属可以参加, 也可以不参加, 此时有 f ( i , 0 ) = ∑ max ⁡ { f ( x , 1 ) , f ( x , 0 ) } f(i,0) = \sum\max \{f(x,1),f(x,0)\} f(i,0)=max{f(x,1),f(x,0)}
  2. 上司参加舞会时,下属都不会参加,此时有 f ( i , 1 ) = ∑ f ( x , 0 ) + a i f(i,1) = \sum{f(x,0)} + a_i f(i,1)=f(x,0)+ai

可以通过DFS, 在返回上一层时更新当前节点的最优解

#include <iostream>
#include <algorithm>
using namespace std;

struct Edge {
    int v, next;
}e[6005];

int n, cnt, ans;
int head[6005], f[6005][2], is_h[6005], vis[6005];

void addedge(int u, int v) {
    e[++cnt].v = v;
    e[cnt].next = head[u];
    head[u] = cnt;
}

void calc(int k) {
    vis[k] = 1;
    for (int i = head[k]; i; i = e[i].next) {
        if (vis[e[i].v]) continue;
        calc(e[i].v);
        f[k][1] += f[e[i].v][0];
        f[k][0] += max(f[e[i].v][0], f[e[i].v][1]);
    }
    return;
}

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> f[i][1];
    for (int i = 1; i <= n; i++) {
        int l, k;
        cin >> l >> k;
        is_h[l] = 1;
        addedge(k, l);
    }
    for (int i = 1; i <= n; i++) {
        if (!is_h[i]) {
            calc(i);
            cout << max(f[i][1], f[i][0]) << '\n';
            return 0;
        }
    }
}
树形背包

树形背包, 简单来讲就是背包DP和树形DP的结合

此处以 Luogu P2014 为例

现在有 n n n 门课程,第 i i i 门课程的学分为 a i a_i ai,每门课程有零门或一门先修课,有先修课的课程需要先学完其先修课,才能学习该课程。

一位学生要学习 m m m 门课程,求其能获得的最多学分数。

n , m ≤ 300 n,m \leq 300 n,m300

考虑到每门课最多只有一门先修课的特点, 可以利用这个性质建树

f ( u , i , j ) f(u, i, j) f(u,i,j) 表示以 u u u 号点为根的子树中, 以及遍历了 u u u 号点的前 i i i 棵子树, 选了 j j j 门课程的最大学分

枚举 u u u 点的每个子节点 v v v , 同时枚举以 v v v 为根的子树选了几门课程, 将子树的结果合并到 u u u

设点 x x x 的儿子个数为 s x s_x sx , 以 x x x 为根的子树大小为 s i z _ x siz\_x siz_x , 可以写出下面的状态转移方程

f ( u , i , j ) = max ⁡ v , k ≤ j , k ≤ siz_v f ( u , i − 1 , j − k ) + f ( v , s v , k ) f(u,i,j)=\max_{v,k \leq j,k \leq \textit{siz\_v}} f(u,i-1,j-k)+f(v,s_v,k) f(u,i,j)=v,kj,ksiz_vmaxf(u,i1,jk)+f(v,sv,k)

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

#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
int dp[305][305], s[305], n, m;
vector<int> e[305];

int dfs(int u) {
    int p = 1;
    f[u][1] = s[u];
    for (auto v : e[u]) {
        int siz = dfs(v);
        for (int i = min(p, m + 1); i; i--) {
            for (int j = 1; j <= siz && i + j <= m + 1; j++) {
                dp[u][i + j] = max(dp[u][i + j], dp[u][i] + dp[v][j]);;
            }
        }
        p += siz;
    }
    return p;
}

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        int k;
        cin >> k >> s[i];
        e[k].push_back(i);
    }
    dfs(0);
    cout << dp[0][m + 1] << '\n';
    return 0;
}

数位DP

引入

数位是指把一个数字按照个, 十, 百, 千等位数一位一位拆开, 关注它每一位上的数字

数位DP的特征

  1. 要求统计满足一定条件的数的数量(即, 最终目的为计数)
  2. 这些条件经过转化之后可以使用 “数位” 的思想去理解和判断
  3. 输入会提供一个数字区间(有时只提供上界)作为统计限制
  4. 上界很大, 暴力枚举会超时

基本原理:

将数字增加的过程归并起来, 将这些过程产生的计数答案存在一个通用的数组里, 根据题目具体要求设置状态, 用递推或者DP进行转移

数位DP中通常会利用常规计数技巧, 比如把一个区间内的答案拆成两部分相减 (即 a n s [ l , r ] = a n s [ 0 , r ] − a n s [ 0 , l − 1 ] ans_{[l,r]} = ans_{[0, r]} - ans_{[0, l -1]} ans[l,r]=ans[0,r]ans[0,l1] )

有了通用数组, 就可以统计答案, 可以用记忆化搜索, 也可以用循环迭代递推, 为了不重不漏, 要从高到低枚举, 再考虑每一位都可以填哪些数字, 最后利用通用数组统计答案

以下有若干例题, 用于解释数位DP

例题1

Luogu P2602

给定两个正整数 a a a b b b , 求在 [ a , b ] [a, b] [a,b] 中的所有整数中, 每个数码各出现了多少次

( 1 ≤ a ≤ b ≤ 1 0 12 ) (1\le a \le b \le 10^{12}) (1ab1012)

解释: 对于满 i i i 位的数, 所有数字出现的次数都是相同的, 故设数组 d p i dp_i dpi 为满 i i i 位的数中每个数字出现的次数, 此时不处理前导零, 则有 d p i = 10 × d p i − 1 + 1 0 i − 1 dp_i = 10 \times dp_{i - 1} + 10^{i - 1} dpi=10×dpi1+10i1 , 这两部分前一个是来自前 i − 1 i - 1 i1 位数字的贡献, 后一个是来自于第 i i i 位数字的贡献

统计答案: 将上界按位分开, 从高到低枚举, 不贴着上界时, 后面可以随便取值. 贴着上界时, 后面就只能取 0 0 0 到上界, 分两部分分别计算贡献 , 最后考虑前导 0 0 0, 第 i i i 位为前导 0 0 0 时, 此时 1 ∼ i − 1 1\sim i-1 1i1 位都是 0 0 0 , 也就是多算了将 i − 1 i-1 i1 位填满的答案, 需要额外减去

#include <iostream>
using namespace std;
const int N = 15;
typedef long long ll;
ll l, r, dp[N], mi[N];
ll ans1[N], ans2[N];
int a[N];

void solve(ll n, ll *ans) {
    ll tmp = n;
    int len = 0;
    while (n) {
        a[++len] = n % 10;
        n /= 10;
    }
    for (int i = len; i >= 1; i--) {
        for (int j = 0; j <  	q10; j++) ans[j] += dp[i - 1] * a[i];
        for (int j = 0; j < 10; j++) ans[j] += mi[i - 1];
        tmp -= mi[i - 1] * a[i], ans[a[i]] += tmp + 1;
        ans[0] -= mi[i - 1];
    }
}

int main() {
    cin >> l >> r;
    mi[0] = 1ll;
    for (int i = 1; i <= 13; i++) {
        dp[i] = dp[i - 1] * 10 + mi[i - 1];
        mi[i] = 10ll * mi[i - 1];
    }
    solve(r, ans1), solve(l - 1, ans2);
    for (int i = 0; i < 10; i++) {
        cout << ans1[i] - ans2[i] << ' ';
    }
    return 0;
}
例题2

统计一个区间内数位上不能有4也不能有连续的62的数有多少

解释: 没有4的话在枚举的时候判断一下, 不枚举4就可以保证状态合法了, 所以这个约束没有记忆化搜索的必要, 而对62的话, 涉及到两位, 当前一位是6或者不是6这两种不同情况计数是不相同的, 所以要用状态来记录不同的方案数, d p p o s , s t a dp_{pos, sta} dppos,sta 表示当前第 p o s pos pos 位, 前一位是否是6的状态, 这里 s t a sta sta 只需要取0 和1 两种状态就可以了, 不是6的情况可以视为同种, 不影响计数

#include <iostream>
#include <cstring>
#include <iostream>
using namespace std;
int x, y, dp[15][3], p[50];

void pre() {
    memset(dp, 0, sizeof(dp));
    dp[0][0] = 1;
    for (int i = 1; i <= 10; i++) {
        dp[i][0] = dp[i - 1][0] * 9 + dp[i - 1][1];
        dp[i][1] = dp[i - 1][0];
        dp[i][2] = dp[i - 1][2] * 10 + dp[i - 1][1] + dp[i - 1][0];
    }
}

int cal(int x) {
    int cnt = 0, ans = 0, tmp = x;
    while (x) {
        p[++cnt] = x % 10;
        x /= 10;
    }
    bool flag = false;
    p[cnt + 1] = 0;
    for (int i = cnt; i; i--) {
        ans += p[i] * dp[i - 1][2];
        if (flag) {
            ans += p[i] * dp[i - 1][0];
        } else {
            if (p[i] > 4) ans += dp[i - 1][0];
            if (p[i] > 6) ans += dp[i - 1][1];
            if (p[i] > 2 && p[i + 1] == 6) ans += dp[i][1];
            if (p[i] == 4 || (p[i] == 2 && p[i + 1] == 6)) flag = true;
        }
    }
    return tmp - ans;
}

int main() {
    pre();
    while (~(cin >> x >> y)) {
        if (!x && !y) break;
        x = min(x, y), y = max(x, y);
        cout << cal(y + 1) - cal(x);
    }
    return 0;
}
例题3

给定区间 [ l , r ] [l, r] [l,r] , 求其中满足条件 不含前导0且相邻两个数字相差至少为2 的数字个数

可以考虑简化问题. 设 a n s i ans_i ansi 表示在区间 [ 1 , i ] [1, i] [1,i] 中满足条件的数的数量, 那么所求的答案就是 a n s r − a n s l − 1 ans_r - ans_{l - 1} ansransl1

对于一个小于 n n n 的数, 它从高到低肯定出现某一位, 使得这一位上的数值小于 n n n 这一位上对应的数值, 而之前的所有位都和 n n n 上的位相等

有了这个性质, 可以定义 f ( i , s t , o p ) f(i, st, op) f(i,st,op) 表示当前将要考虑的是从高到低的第 i i i 位, 当前该前缀的状态是 s t st st 且前缀和当前求解的数字的大小关系是 o p op op ( o p = 1 op = 1 op=1 表示等于, $op = 0 $ 表示小于) 时的数字个数

状态转移方程: f ( i , s t , o p ) = ∑ k = 1 m a x x f ( i + 1 , k , o p = 1   and ⁡   k = m a x x ) ( ∣ s t − k ∣ ≥ 2 ) f(i,st,op)=\sum_{k=1}^{\mathit{maxx}} f(i+1,k,op=1~ \operatorname{and}~ k=\mathit{maxx} )\quad (|\mathit{st}-k|\ge 2) f(i,st,op)=k=1maxxf(i+1,k,op=1 and k=maxx)(stk2)

这里的 k k k 就是当前枚举的下一位的值, 而 m a x x maxx maxx 就是当前能取到的最高位, 因为如果 o p = 1 op = 1 op=1 , 那么在这一位上取的值一定不能大于求解的数字上该位的值, 否则没有限制

可以考虑使用记忆化搜索实现

int dfs(int x, int st, int op) {
    if (!x) return 1;
    if (!op && ~f[x][st]) return f[x][st];
    int maxx = op ? dim[x] : 9, ret = 0;
    for (int i = 0; i <= maxx; i++) {
        if (abs(st - i) < 2) continue;
        if (st == 11 && i == 0) {
            ret += dfs(x - 1, 11, op & (i == maxx));
        } else {
            ret += dfs(x - 1, i, op & (i == maxx));
        }
    }
    if (!op) f[x][st] = ret;
    return ret;
}

int solve(int x) {
    memset(f, -1, sizeof(f));
    dim.clear();
    dim.push_back(-1);
    int t = x;
    while (x) {
        dim.push_back(x % 10);
        x /= 10;
    }
    return dfs(dim.size() - 1, 11, 1);
}

状压DP

状压DP是DP的一种, 通过将状态压缩为整数来达到优化转移的目的

N × N N \times N N×N 的棋盘中放 K K K 个国王 ( 1 ≤ N ≤ 9 , 1 ≤ K ≤ N × N 1 \le N \le 9, 1 \le K \le N \times N 1N9,1KN×N ), 使他们互不攻击, 共有多少摆放方案

国王能攻击到上下左右以及左上左下右上右下八个方向附近的一个格子, 共8个格子

f ( i , j , l ) f(i, j, l) f(i,j,l) 表示前 i i i 行, 第 i i i 行的状态为 j j j , 且棋盘上已经放置 l l l 个国王时的合法方案数

对于编号为 j j j 的状态, 用二进制整数 s i t ( j ) sit(j) sit(j) 表示国王的放置情况, s i t ( j ) sit(j) sit(j) 的某个二进制位为0表示对应位置不放国王, 1表示对应位置放置国王, 用 s t a ( j ) sta(j) sta(j) 表示该状态的国王个数, 即二进制数 s i t ( j ) sit(j) sit(j) 中1的个数

设当前行的状态为 i i i , 前一行的状态为 x x x , 可以得到下面的状态转移方程 f ( i , j , l ) = ∑ f ( i − 1. x , l − s t a ( j ) ) f(i, j, l) = \sum_{}f(i - 1. x, l-sta(j)) f(i,j,l)=f(i1.x,lsta(j))

设上一行状态编号为 x x x , 在保证当前行和上一行不冲突的前提下, 枚举所有可能的 x x x 进行转移, 转移方程 f ( i , j , l ) = ∑ f ( i − 1 , x , l − s t a ( j ) ) f(i, j, l) = \sum_{}f(i - 1, x, l - sta(j)) f(i,j,l)=f(i1,x,lsta(j))

#include <iostream>
#include <algorithm>
using namespace std;
long long sta[2005], sit[2005], f[15][2005][105];
int n, k, cnt;

void dfs(int x, int sum, int cur) {
    if (cur >= n) { // 有新的合法状态
        sit[++cnt] = x;
        sta[cnt] = num;
    	return;
    }
    dfs(x, num, cur + 1); // cur位置不放国王
    dfs(x + (1 << cur), num + 1, cur + 2); // cur位置放国王, 与它相邻的位置不能再放国王
}

bool compatible(int j, int x) {
    if (sit[j] & sit[x]) return false;
    if ((sit[j] << 1) & sit[x]) return false;
    if (sit[j] & (sit[x] << 1)) return false;
    return true;
}

int main() {
    cin >> n >> k;
    dfs(0, 0, 0); // 先预处理一行的所有合法状态
    for (int j = 1; j <= cnt; j++) f[1][j][sta[j]] = 1;
    for (int i = 2; i <= n; i++) {
        for (int j = 1; j <= cnt; j++) {
            for (int x = 1; x <= cnt; x++) {
                if (!compatible(j, x)) continue; // 排除不合法转移
                for (int l = sta[j]; l <= k; l++) {
                    f[i][j][l] += f[i - 1][x][l - sta[j]];
                }
            }
        }
    }
    long long ans = 0;
    for (int i = 1; i <= cnt; i++) {
        ans += f[n][i][k]; // 累加答案
    }
    cout << ans << '\n';
    return 0;
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值