学习了之前的基础DP内容,发现比赛题目往往不会考常规DP呀!更喜欢考背包、树型、区间DP,赶紧来学习学习。
学习自:https://www.cnblogs.com/ljy-endl/p/11612275.html
树型DP、区间DP和状压DP
一、经典树形DP问题
树型动态规划是建立在树上的,相应的有二个方向:
- 叶->根:在回溯的时候从叶子节点往上更新信息,是比较常见的树型DP方向,也就是用DFS从根节点一直搜到叶子节点,然后从叶子节点层层往上更新到根节点。
- 根 - >叶:往往是在从叶往根dfs一遍之后(相当于预处理),再重新往下获取最后的答案。
不管是 从叶->根 还是 从 根 - >叶,两者都是根据需要采用,没有好坏高低之分。和线性动态规划相比,树形DP往往是要利用递归+记忆化搜索。
树的重心
对于一棵n个结点的无根树,找到一个点,使得把树变成以该点为根的有根树时,最大子树的结点数最小,即删除这个点后最大连通块的结点数最小,那么这个点就是树的重心。
例题:题目大意:对于一棵无根树,找到一个点使得树以该点为根的有根树,最大子树(选择该节点后其子树的最大节点)的节点数最小。
可以画出下面的树:
很显然要使得最大子树的节点数最小,那就只能以1号节点作为根节点,如果以其它节点作为根节点,产生的最大子树的节点数都比1号节点作为根节点的结果大,例如:以2号节点作为根节点,最大子树的节点数就为4,而1号节点最大子树的节点数为3。
站在某个节点来看,把当前节点当作根节点,那它的子树有哪些可能?首先是其子节点,各自又可以构成子树,这很容易想到。关键在于,其父节点也应该可以构成子树,因为我们是把这个节点当作根节点了,所以与它相连的那些节点都会作为子树(题目并没有说一定是二叉树),所以一定记得要去讨论父节点作为子树的情况。
import java.util.*;
import java.io.*;
public class Main {
static BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
static BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
static LinkedList<Integer>[] tree;
static int[] dp = new int[101];
static int n;
// 记录最大子树节点数的最小值并记录节点
static int minAns = Integer.MAX_VALUE;
static int node = -1;
public static void main(String[] args) throws IOException {
Scanner scan = new Scanner(System.in);
n = scan.nextInt();
tree = new LinkedList[n + 1];
for (int i = 0; i < n + 1; i++) {
tree[i] = new LinkedList<>();
}
// 建树
for (int i = 1; i <= n - 1; i++) {
// n个节点,n - 1条边
int u = scan.nextInt();
int v = scan.nextInt();
// 无向边->双向边
tree[u].add(v);
tree[v].add(u);
}
// 从1号节点开始搜
dfs(1, -1); // 1号节点的父节点初始化为-1
// 记录from父节点的目的是为了避免重复搜索
System.out.println(minAns + " " + node);
}
static void dfs(int to, int from) {
// dp[i]表示以i为根的子树的节点个数
dp[to] = 1; // 初始化,只有根节点,节点数=1
int maxNode = 0;
for (int next : tree[to]) {
// 遍历子节点
if (next == from) // 避免重复搜索
continue;
// 往下搜索直到叶子节点
dfs(next, to);
// 更新dp[i]的值
dp[to] += dp[next];
// 找以to为根节点的子树的最大节点
maxNode = Math.max(maxNode, dp[next]);
}
// 再与父节点比较,因为其父节点也可以构成子树
// 父节点构成的子树的节点数 = n - dp[to]
maxNode = Math.max(maxNode, n - dp[to]);
if (maxNode < minAns) { // 找最大子树中的最小节点数
minAns = maxNode;
node = to;
}
}
}
其实就是把当前节点当作根节点去考虑其可能子树的问题,上面代码改成BFS也可以,最终目的都是为了遍历树,这道题并不会利用上BFS的特性(最短路)。用DFS也不用专门开个vis数组,只需要去记录下father节点,就可以避免重复搜索的问题。
树的直径(树的最长路径)
树的直径: 树的直径是指树的最长简单路。
例题:题目大意:对于一棵无根树,找到树的一条直径。假设边权为1。
可以画出下面的树:
也就是说两个节点间的最长路径。
LeetCode这道题,由于是二叉树,所以很简单,用递归就可以做
class Solution {
int max = 0;
public int diameterOfBinaryTree(TreeNode root) {
maxDiameter(root);
return max;
}
int maxDiameter(TreeNode root) {
if (root == null) return 0;
int leftMax = maxDiameter(root.left);
int rightMax = maxDiameter(root.right);
// 后序位置更新最大值
max = Math.max(max, leftMax + rightMax);
return 1 + Math.max(leftMax, rightMax);
}
}
图的直径
但如果是树,不是特别的二叉树,该怎么做,一般的树可以当作是图。
两次dfs或bfs。第一次任意选一个点进行dfs(bfs)找到离它最远的点,此点就是最长路的一个端点,再以此点进行dfs(bfs),找到离它最远的点,此点就是最长路的另一个端点,于是就找到了树的直径。
大臣的旅费
欸,这个过程,让我想到之前的蓝桥杯真题:大臣的旅费:
方法一:两次DFS搜索
这道题其实是在找图的直径问题,先从任意节点出发,找到距离最远的点,这个点作为一个端点,然后再在这个点的基础上遍历找到距离当前点的最远的点,这个点作为另一个端点,这两个端点的距离就是图的直径。(实现的过程和找上面找树的直径的方法一样)
import java.util.*;
import java.io.*;
public class Main {
static BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
static BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
public static void main(String[] args) throws IOException {
Scanner scan = new Scanner(System.in);
int n = scan.nextInt();
LinkedList<int[]>[] graph = new LinkedList[n + 1];
// 建图
for (int i = 0; i < n + 1; i++) {
graph[i] = new LinkedList<>();
}
for (int i = 1; i <= n - 1; i++) {
int u = scan.nextInt();
int v = scan.nextInt();
int w = scan.nextInt();
// 无向图,即双向图
graph[u].add(new int[] {v, w});
graph[v].add(new int[] {u, w});
}
Queue<Integer> queue = new LinkedList<>();
// 从任意节点开始找到最远距离端点
int[] dist = new int[n + 1];
boolean[] vis = new boolean[n + 1];
vis[1] = true; // 从第一个节点开始找
queue.offer(1);
while (!queue.isEmpty()) {
int cur = queue.poll();
for (int[] next : graph[cur]) {
int v = next[0];
int w = next[1];
if (vis[v]) continue;
vis[v] = true;
// 找每个节点距离1的最长距离
if (dist[cur] + w > dist[v])
dist[v] = dist[cur] + w;
queue.offer(v);
}
}
// 记录下一次开始搜索的开始端点
int node = 0;
int max = 0;
// 找下一次开始搜索的端点
for (int i = 1; i <= n; i++) {
if (dist[i] > max) {
max = dist[i];
node = i;
}
}
// 再以新端点去找最远的距离
queue = new LinkedList<>();
queue.offer(node);
vis = new boolean[n + 1];
dist = new int[n + 1];
vis[node] = true;
while (!queue.isEmpty()) {
int cur = queue.poll();
for (int[] next : graph[cur]) {
int v = next[0];
int w = next[1];
if (vis[v]) continue;;
if (dist[cur] + w > dist[v])
dist[v] = dist[cur] + w;
vis[v] = true;
queue.offer(v);
}
}
// 找最后最大距离
max = 0;
for (int i = 1; i <= n; i++) {
max = Math.max(max, dist[i]);
}
// 长度计算等差数列求和:(11 + 10 + max) * max / 2
System.out.println(1L * (11 + 10 + max) * max / 2);
}
}
这道题也是非常经典,一般都以为要让找最短距离,结果让找图中最远的两个节点距离(也就是图的直径),注意图的直径是根据图的边权来计算的(树的直径同样),如果没有边权那就相当于是求边权=1的朴素的图的直径问题。
方法二:DP
用两次搜索是不错的做法,但是可以通过树的直径的特殊点,用DP来解决,大大缩短了求解时间。
树的直径的长度一定会是某个点的最长距离f[i]与次长距离g[i]之和。最后求出max{f[i]+g[i]}就可以了。 令j是i的儿子,则:
1、若f[j]+dis[i][j]>f[i],则g[i]=f[i],f[i]=f[j]+dis[i][j];//最大值次大值被更新
2、否则,若f[j]+dis[i][j]>g[i],则g[i]=f[j]+dis[i][j];//次大值被更新
也不需要开两个数组,用一个二维数组即可,dp[0][i] 表示距离节点 i 的最长距离,dp[1][i] 表示距离节点 i 的次长距离。
import java.util.*;
import java.io.*;
public class Main {
static BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
static BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
static int[][] dp;
static LinkedList<int[]>[] tree;
static int ans = 0; // 记录答案
public static void main(String[] args) throws IOException {
Scanner scan = new Scanner(System.in);
int n = scan.nextInt();
tree = new LinkedList[n + 1];
for (int i = 0; i <= n; i++) {
tree[i] = new LinkedList<>();
}
// 建树
for (int i = 1; i <= n - 1; i++) {
int u = scan.nextInt();
int v = scan.nextInt();
// 无权无向边,边权当1
tree[u].add(new int[] {v, 1});
tree[v].add(new int[] {u, 1});
}
dp = new int[2][n + 1];
// dp[0][i] 代表开始节点距离节点i的最大距离
// dp[1][i] 代表开始节点距离节点i的次大距离
// 由于使用dp来解,所以可以从任意节点开始搜索
dfs(1, -1); // 根节点的父节点设置为-1
System.out.println(ans);
}
static void dfs(int u, int father) {
for (int[] next : tree[u]) {
int v = next[0];
int w = next[1];
if (v == father) continue; // 避免重复搜索
dfs(v, u); // 注意还是从叶子节点开始求解,所以dfs一定要搜到最底端
if (dp[0][v] + w > dp[0][u]) {
// 找到了新的最大值,那么旧的最大值作为次大值
dp[1][u] = dp[0][u];
dp[0][u] = dp[0][v] + w;
// 当前节点的距离,必须要从根节点推得,所以需要由根节点的距离 + 权值w
} else if (dp[0][v] + w > dp[1][u]) {
// 如果不能更新最大值,看能否更新次大值
dp[1][u] = dp[0][v] + w;
}
}
// 求最长直径长度
ans = Math.max(dp[0][u] + dp[1][u], ans);
}
}
一定要把握清楚,是从叶子节点往根节点推,还是从根节点往叶子节点推。
有了上一题做法,那么,大臣的旅费,其实还是树形DP问题!!!
import java.util.*;
import java.io.*;
public class Main {
static BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
static BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
static LinkedList<int[]>[] graph;
static int[][] dp;
static int max = 0;
public static void main(String[] args) throws IOException {
Scanner scan = new Scanner(System.in);
int n = scan.nextInt();
dp = new int[2][n + 1];
// dp[0][i],表示起点到节点i的最大距离
// dp[1][i],表示起点到节点i的次大距离
graph = new LinkedList[n + 1];
// 建图
for (int i = 0; i < n + 1; i++) {
graph[i] = new LinkedList<>();
}
for (int i = 1; i <= n - 1; i++) {
int u = scan.nextInt();
int v = scan.nextInt();
int w = scan.nextInt();
// 无向图,即双向图
graph[u].add(new int[] {v, w});
graph[v].add(new int[] {u, w});
}
// 从任意节点搜索
dfs(1, -1);
System.out.println(1L * (10 + 11 + max) * max / 2);
}
static void dfs(int u, int father) {
for (int[] next : graph[u]) {
int v = next[0];
int w = next[1];
if (v == father) continue; // 避免重复搜索
// 搜到叶子节点为止
dfs(v, u);
if (dp[0][v] + w > dp[0][u]) {
// 原来的最大值作为次大值
dp[1][u] = dp[0][u];
dp[0][u] = dp[0][v] + w;
} else if (dp[0][v] + w > dp[1][u]){
// 不能更新最大值,看能否更新次大值
dp[1][u] = dp[0][v] + w;
}
}
// 更新图中直径
max = Math.max(max, dp[0][u] + dp[1][u]);
}
}
※树的中心
这是一道非常经典的题目,用到了两次DFS搜索,并且也融入了树的直径的内容,整个分析过程非常易懂,但要让自己写出来还是有很大难度。
要找到树中一个点,使得该点到其它节点的最远距离最近,输出这个最远距离的最小值。
import java.util.*;
import java.io.*;
public class Main {
static BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
static BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
static LinkedList<int[]>[] graph;
static int[] down1;
static int[] down2;
static int[] up;
static int[] p1;
public static void main(String[] args) throws IOException {
Scanner scan = new Scanner(System.in);
int n = scan.nextInt();
// 向下走的最大值、次大值
down1 = new int[n + 1];
down2 = new int[n + 1];
// 向上走的最大值
up = new int[n + 1];
// 记录当前节点的夫节点
p1 = new int[n + 1];
graph = new LinkedList[n + 1];
// 构建树
for (int i = 0; i < n + 1; i++) {
graph[i] = new LinkedList<>();
}
for (int i = 1; i <= n - 1; i++) {
int u = scan.nextInt();
int v = scan.nextInt();
int w = scan.nextInt();
graph[u].add(new int[] {v, w});
graph[v].add(new int[] {u, w});
}
// 先从任意节点出发,确定down1、down2最大值、次大值数组
dfs(1, -1);
// 再从任意节点搜索,确定up数组
dfss(1, - 1);
// 找结果
int min = Integer.MAX_VALUE;
for (int i = 1; i <= n; i++) {
min = Math.min(min, Math.max(up[i], down1[i]));
}
System.out.println(min);
}
// 更新down1 down2数组,并且记录p1信息
static void dfs(int u, int father) {
for (int[]next : graph[u]) {
int v = next[0];
int w = next[1];
if (father == v) continue; // 避免重复搜索
dfs(v, u);
// 搜到叶子节点才开始更新
if (down1[v] + w > down1[u]) {
// 更新最大值,之前的最大值作为次大值
down2[u] = down1[u];
down1[u] = down1[v] + w;
// 找到当前节点最大值路径的子节点,用于第二次dfs搜索更新up数组
p1[u] = v;
} else if (down1[v] + w > down2[u]){
// 无法更新最大值,尝试更新次大值
down2[u] = down1[v] + w;
}
}
}
// 更新up数组,注意根节点不能up,所以根节点的up值无意义
static void dfss(int u, int father) {
for (int[] next : graph[u]) {
int v = next[0];
int w = next[1];
if (v == father) continue;
if (p1[u] == v) { // u是父节点,v是子节点
// 如果当前节点u的最大值路径的下一个子节点为v,那就肯定不能拿,得拿次大值
// 也就是说子节点的up数组只能由根节点的up数组或者根节点的down次大值更新
up[v] = Math.max(up[u], down2[u]) + w;
} else {
// 不是最大路径上的下一个子节点,那就可以用down1[u]
up[v] = Math.max(up[u], down1[u]) + w;
}
// 注意这里我们必须从根节点往叶子节点更新,因为up数组的转移方程需要使用父亲节点的信息
dfss(v, u);
}
}
}
题目中是正权值的情况,如果是负权值,该怎么处理?负权值数组就不能初始化为0,还得去初始化为最小值,这么想太麻烦了,我们只需要在最后更新结果时作出修改:根节点不能往上,叶子节点不能往下
// 从根节点开始,只能往下走,所以是down1
int res = down1[1];
for(int i=2; i<=n; ++i)
{
// 当是叶子节点时,只能向上走
if(is_leaf[i]) res = min(res, up[i]);
// 不是叶子节点也不是根节点,那么可以向上向下走
else res = min(res, max(down1[i], up[i]));
}
310、最小高度树(中等)
对于任意一个节点的高度,有三种情况:往下,往上又往下,往上又往上,这三种情况其实就是树的中心的三种考虑情况,解题思路也类似,先求down1 down2 并且保存 p1,然后求up。
class Solution {
LinkedList<Integer>[] tree;
// 记录向下走的最大值、次大值、最大值的子节点、向上走的最大值
int[] down1, down2, p1, up;
public List<Integer> findMinHeightTrees(int n, int[][] edges) {
tree = new LinkedList[n];
down1 = new int[n];
down2 = new int[n];
p1 = new int[n];
up = new int[n];
for (int i = 0; i < n; i++) tree[i] = new LinkedList<>();
for (int[] cur : edges) {
int u = cur[0];
int v = cur[1];
tree[u].add(v);
tree[v].add(u);
}
dfs(0, -1);
dfss(0, -1);
int min = 0x3f3f3f3f;
List<Integer> ans = new LinkedList<>();
for (int i = 0; i < n; i++) {
int tmp = Math.max(down1[i], up[i]);
if (tmp < min) {
min = tmp;
ans = new LinkedList<>();
ans.add(i);
} else if (tmp == min) {
ans.add(i);
}
}
return ans;
}
void dfs(int u, int father) {
for (int v : tree[u]) {
if (v == father) continue;
dfs(v, u);
// 如果是最大值
if (down1[v] + 1 > down1[u]) {
down2[u] = down1[u];
down1[u] = down1[v] + 1;
// 记录当前根节点的最大距离的子节点
p1[u] = v;
} else if (down1[v] + 1 > down2[u]) {
down2[u] = down1[v] + 1;
}
}
}
void dfss(int u, int father) {
for (int v : tree[u]) {
if (v == father) continue;
if (v == p1[u]) {
// 只能用次大值更新
up[v] = Math.max(up[u], down2[u]) + 1;
} else {
up[v] = Math.max(up[u], down1[u]) + 1;
}
dfss(v, u);
}
}
}
二、普通树形DP问题
没有上司的舞会
题目很简单,如果父节点参加舞会,所有该父节点的子节点都不能参加舞会,每个人参加舞会都有一个快乐指数,问最大的快乐指数可以是多少?
import java.util.*;
import java.io.*;
public class Main {
static LinkedList<Integer>[] tree;
static int[][] dp = new int[6100][2];
// dp[i][1] 表示第i个职员去的最大快乐指数
static int n;
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
n = scan.nextInt();
for (int i = 1; i <= n; i++) {
dp[i][1] = scan.nextInt();
// 每个职员只考虑自己去,那么基础情况就是只有自己
}
tree = new LinkedList[n + 1];
for (int i = 0; i < n + 1; i++) tree[i] = new LinkedList<>();
boolean[] vis = new boolean[n + 1]; // 找根节点
for (int i = 0; i < n - 1; i++) {
int l = scan.nextInt();
int k = scan.nextInt();
tree[k].add(l);
// k是l的上司
vis[l] = true; // 没有出现的l就是根
}
int root = -1;
for (int i = 1; i <= n; i++) {
if (vis[i] == false) root = i;
}
dfs(root, - 1);
System.out.println(Math.max(dp[root][0], dp[root][1]));
}
static void dfs(int u, int father) {
for (int v : tree[u]) {
if (v == father) continue;
dfs(v, u);
// 上司不去,下属可以去不去
dp[u][0] += Math.max(dp[v][0], dp[v][1]);
// 上司去,下属不能去
dp[u][1] += dp[v][0];
}
}
}
※二叉苹果树
因为是二叉树,对于当前根节点,只可能分给两个子树,假如当前跟节点拿到了q个枝条,最多给左孩子q-1个枝条(-1用于连接),把分给做孩子的枝条数记为k,那么右孩子还能分得q - k - 1个枝条。
import java.util.*;
import java.io.*;
public class Main {
static LinkedList<int[]>[] tree;
static int[][] dp;
// dp[i][1] 表示第i个职员去的最大快乐指数
static int n;
static int q;
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
n = scan.nextInt();
q = scan.nextInt();
dp = new int[n + 1][q + 1];
tree = new LinkedList[n + 1];
for (int i = 0; i < n + 1; i++) tree[i] = new LinkedList<>();
for (int i = 0; i < n - 1; i++) {
int u = scan.nextInt();
int v = scan.nextInt();
int w = scan.nextInt();
tree[u].add(new int[] {v, w});
tree[v].add(new int[] {u, w});
}
// 根节点是1
dfs(1, -1);
System.out.println(dp[1][q]);
}
static void dfs(int u, int father) {
for (int[] next : tree[u]) {
int v = next[0];
int w = next[1];
if (v == father) continue;
dfs(v, u);
// 根节点最多分配q个枝条,最少0个枝条
for (int i = q; i >= 0; i--) {
// 左孩子最多只能分配i - 1个枝条(-1用于连接孩子与根)
for (int j = 0; j <= i - 1; j++) {
// dp[v][j],子孩子分配j个枝条,另一个子孩子就只能分配i - j - 1
dp[u][i] = Math.max(dp[u][i], dp[v][j] + dp[u][i - j - 1] + w);
}
}
}
}
}
三、区间DP问题
区间dp就是在区间上进行动态规划,求解一段区间上的最优解。主要是通过合并小区间的最优解进而得出整个大区间上最优解的dp算法。
概述
取出回文
回文系列问题都应该按照之前动态规划中最大回文串的处理方式,使用二维的dp数组,表示字符串[i…j]的最少消去次数,然后对于i…j我们可以枚举其中的拆分点,拆分成左右两部分,从而保证每次消除都是以最优最少的方式进行消除。
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
String str = scan.nextLine();
int n = str.length();
int[][] dp = new int[n + 1][n + 1];
// dp[i][j] 字符串[i...j]需要的最少消去次数
for (int i = 0; i < n + 1; i++) {
Arrays.fill(dp[i], Integer.MAX_VALUE);
}
for (int i = 1; i <= n; i++) dp[i][i] = 1; // 一个字符至少需要删除1次
// 先遍历区间长度,再遍历左端点,从而确定右端点
for (int l = 1; l <= n; l++) { // 区间长度为0就不用遍历
for (int i = 1; i <= n; i++) {
int j = i + l;
if (j > n) break; // 右端点不能超出界限
if (str.charAt(i - 1) == str.charAt(j - 1)) {
if (j - i == 1) {
// 两个字符,必是回文
dp[i][j] = 1;
} else {
dp[i][j] = dp[i + 1][j - 1];
}
}
// 遍历所有区间,以确保最少的消去次数
for (int k = i; k < j; k++) {
dp[i][j] = Math.min(dp[i][j], dp[i][k] + dp[k + 1][j]);
}
}
}
System.out.println(dp[1][n]);
}
}
从上面这道题引出区间DP的模板,先遍历区间长度,再遍历左端点,从而确定右端点:
// 上面考虑好只有一个数组元素的情况
// 先遍历区间长度,再遍历左端点,从而确定右端点
// 1是指右端点与左端点的差值
for (int l = 1; l <= n; l++) {
for (int i = 1; i <= n; i++) {
int j = i + l;
// 右端点至少=左端点+1,单独只有一个数的情况已提前考虑
if (j > n) break; // 右端点不能超出界限
// 遍历所有区间,以确保最少的消去次数
for (int k = i; k < j; k++) {
dp[i][j] = Math.min(dp[i][j], dp[i][k] + dp[k + 1][j]);
}
}
}
石子合并
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
int n = scan.nextInt();
int[] preSum = new int[n + 1];
for (int i = 0; i < n; i++) {
int cur = scan.nextInt();
preSum[i + 1] = preSum[i] + cur; // 前缀和
}
int[][] dp = new int[n + 1][n + 1];
for (int i = 0; i < n + 1; i++) {
Arrays.fill(dp[i], Integer.MAX_VALUE);
}
for (int i = 0; i < n + 1; i++) {
dp[i][i] = 0; // 合并一堆石头不需要代价
}
// 先遍历区间长度
for (int l = 1; l <= n; l++) { // 右端点至少=左端点+1,单独只有一个数的情况已提前考虑
// 遍历左端点
for (int i = 1; i <= n; i++) {
// 确定右端点
int j = i + l;
if (j > n) break; // 不能超出界限
// 枚举拆分点
for (int k = i; k < j; k++) {
// 即i到j个石块,现在假设其中间切分点为k,现在需要把两个部分合在一起
// 但是不知道两堆石块的大小为多少合并时,代价才是最小的,所以才需要拆分切分点
dp[i][j] = Math.min(dp[i][j], dp[i][k] + dp[k + 1][j] + preSum[j] - preSum[i - 1]);
}
}
}
// [](])结果为4
System.out.println(dp[1][n]);
}
}
环形石子合并
环形问题的很好解决思路在于,将数组大小扩展为2n,前缀和数组也扩展为2n再把环形问题转换为链式问题进行求解,区间长度还是1 - n,但是左端点可以从1 - 2 * n。由于是环形石子合并,所以以任意位置为开始起点都可以把所有石子合并,所以最后还要枚举所有可能开始起点找到最大、最小值。
import java.util.*;
import java.io.*;
public class Main {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
int n = scan.nextInt();
int[] stones = new int[450]; // 开两倍数组大小
for (int i = 0; i < n; i++) {
int cur = scan.nextInt();
stones[i] = cur;
// 双倍数组构造成环形
stones[i + n] = cur;
}
// 环形前缀和
int[] preSum = new int[450];
for (int i = 0; i < n + n; i++) {
preSum[i + 1] = preSum[i] + stones[i];
}
// 一个最小代价,一个最大代价
int[][] maxP = new int[450][450];
int[][] minP = new int[450][450];
for (int i = 0; i < 450; i++) {
Arrays.fill(maxP[i], Integer.MIN_VALUE);
Arrays.fill(minP[i], Integer.MAX_VALUE);
maxP[i][i] = 0;
minP[i][i] = 0;
}
// 枚举区间长度
for (int l = 1; l <= n; l++) {
// 枚举左端点(注意环形)
for (int i = 1; i <= 2 * n; i++) {
// 右端点,一个元素的情况已经提前考虑
int j = i + l;
if (j > 2 * n) break;
// 枚举区间拆分点
for (int k = i; k < j; k++) {
maxP[i][j] = Math.max(maxP[i][j], maxP[i][k] + maxP[k + 1][j] + preSum[j] - preSum[i - 1]);
minP[i][j] = Math.min(minP[i][j], minP[i][k] + minP[k + 1][j] + preSum[j] - preSum[i - 1]);
}
}
}
// 由于是环形,可以以任意一点作为起点进行合并,所以还需要枚举起点
int max = Integer.MIN_VALUE;
int min = Integer.MAX_VALUE;
for (int i = 1; i <= n; i++) {
// 注意长度n是包含左端点在内的,所以要i + n - 1才是环形的右端点
max = Math.max(max, maxP[i][i + n - 1]);
min = Math.min(min, minP[i][i + n - 1]);
}
System.out.println(min);
System.out.println(max);
}
}
括号配对
给出一个的只有’(‘,’)’,’[‘,’]’四种括号组成的字符串,求最多有多少个括号满足匹配。
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
String str = scan.nextLine();
int n = str.length();
int[][] dp = new int[n + 1][n + 1];
// 先遍历区间长度
for (int l = 1; l <= n; l++) {
// 遍历左端点
for (int i = 1; i <= n; i++) {
// 确定右端点
int j = i + l;
if (j > n) break; // 不能超出界限
if ((str.charAt(i - 1) == '(' && str.charAt(j - 1) == ')') || (str.charAt(i - 1) == '[' && str.charAt(j - 1) == ']')) {
dp[i][j] = dp[i + 1][j - 1] + 2; // 匹配成功最长匹配长度+2
} else {
dp[i][j] = dp[i + 1][j - 1]; // 没有匹配成功,只能取决于之前的
}
// 遍历每个可能区间,选择最大匹配可能
for (int k = i; k < j; k++) {
dp[i][j] = Math.max(dp[i][j], dp[i][k] + dp[k + 1][j]);
}
}
}
// [](])结果为4,最大匹配数
System.out.println(dp[1][n]);
}
}
括号配对(升级)
上一题是求最多有多少个括号满足匹配,本题是求加多少个括号能够使原字符串满足匹配。
求括号串的最少添加数,那就是总字符串长度减去最大匹配数就可以了。下面还是采用了dp的做法。
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
String str = scan.nextLine();
int n = str.length();
int[][] dp = new int[n + 1][n + 1];
for (int i = 0; i < n + 1; i++) {
Arrays.fill(dp[i], Integer.MAX_VALUE);
}
for (int i = 1; i < n + 1; i++) {
dp[i][i] = 1; // 只有一个括号至少要添加一次
}
// 先遍历区间长度
for (int l = 1; l <= n; l++) {
// 遍历左端点
for (int i = 1; i <= n; i++) {
// 确定右端点
int j = i + l;
if (j > n) break; // 不能超出界限
if ((str.charAt(i - 1) == '(' && str.charAt(j - 1) == ')') || (str.charAt(i - 1) == '[' && str.charAt(j - 1) == ']')) {
if (j - i == 1) {
// 刚好两个括号匹配
dp[i][j] = 0;
} else {
dp[i][j] = dp[i + 1][j - 1];
}
}
// 遍历每个可能区间
for (int k = i; k < j; k++) {
// 求最少括号添加数
dp[i][j] = Math.min(dp[i][j], dp[i][k] + dp[k + 1][j]);
}
}
}
System.out.println(dp[1][n]);
}
}
能量项链
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
int n = scan.nextInt();
int[] a = new int[300];
for (int i = 1; i <= n; i++) {
a[i] = scan.nextInt();
a[i + n] = a[i];
// 两倍数组构建环形
}
// 2个珠子i j k的合并值 = i * j * k,所以需要记录三个值,k的值就是i
a[2 * n + 1] = a[1]; // 最后一个节点值应该是第一个珠子的节点值
int[][] dp = new int[300][300];
for (int i = 0; i <= 2 * n; i++) {
Arrays.fill(dp[i], Integer.MIN_VALUE);
dp[i][i] = 0; // 一个珠子不能释放
}
// 枚举区间长度
for (int l = 1; l <= n; l++) {
// 枚举左端点
for (int i = 1; i <= 2 * n; i++) {
// 注意环形
int j = i + l;
if (j > 2 * n) break;
for (int k = i; k < j; k++) { // 枚举中间拆分点
// 2个珠子,3个值, 1 2 3
dp[i][j] = Math.max(dp[i][j], dp[i][k] + dp[k + 1][j] + a[i] * a[k + 1] * a[j + 1]);
// k = i时,dp[1][1] + dp[2][3] {1}{2,3}合并 = a[i] * a[k + 1] * a[j + 1]
}
}
}
int max = 0;
for (int i = 1; i <= n; i++) { // 由于是环形,所以还要枚举开始节点
max = Math.max(max, dp[i][i + n - 1]);
}
System.out.println(max);
}
}
区间DP的大多数题目都涉及合并的问题,区间合并产生价值、能量之类的,关键就是先遍历区间长度再遍历左端点,从而确定右端点,然后在这个区间内又要去找中间拆分点,拆分成俩部分,在两部分的区间中找最值。
四、状压DP问题
状态压缩动态规划,就是我们俗称的状压DP,是利用计算机二进制的性质来描述状态的一种DP方式。
糖果
因为糖果种类为1—M,可以用二进制每位的1来表示当前这包糖果中的糖果种类有哪些,例如1010:代表当前这包糖果中有第2、4种类的糖果,使用二进制表示后,求两包糖果中的不同糖果种类数就很简单了,直接把两包糖果的种类数求“或|”运算即可,10 | 11 = 11。
假设我们已经知道了当前某包糖果能够提供的糖果种类情况a[i],令dp[i]表示为实现糖果种类为i的情况(当然是二进制表示下的十进制数)所需的最少糖果包数。所以dp[a[i]] = 1,只需要1包就能满足。我们最终要求的结果是dp[(1 << m) - 1],也就是m个种类都得有的情况。外层遍历糖果袋数,内层遍历糖果种类数,如果当前糖果种类数之前还没有出现过,说明是无法实现的。如果出现过,那么我们可以通过a[i] | a[j]来看新构成的糖果种类数。
import java.util.*;
import java.io.*;
public class Main {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
int n = scan.nextInt();
int m = scan.nextInt();
int k = scan.nextInt();
int[] a = new int[n + 1]; // 记录n包糖果的种类
// dp[i],买i种糖果需要的最少袋数,答案:dp[1 << m - 1]
int[] dp = new int[1 << 20];
// 求最小值,初始化为最大值
Arrays.fill(dp, Integer.MAX_VALUE);
// n包糖果,m种口味,k颗糖一包
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= k; j++) {
// 读入每包糖
// 用二进制1来表示糖果种类
// 1010,代表有第2、4种类的糖果
int cur = scan.nextInt();
a[i] |= (1 << (cur - 1)); // 求总的种类就是或
// 那么买当前糖类种数,至少需要一袋
dp[a[i]] = 1;
}
}
//*这里一定要理解清楚*
// 枚举所有糖果种类可能
for (int i = 0; i < 1 << m; i++) {
// 当前糖果种类数未曾出现,那么当前状态是无意义的
if (dp[i] == Integer.MAX_VALUE) continue;
for (int j = 1; j <= n; j++) {
dp[i | a[j]] = Math.min(dp[i | a[j]], dp[i] + 1);
}
}
if (dp[(1 << m) - 1] == Integer.MAX_VALUE) System.out.println(-1);
else System.out.println(dp[(1 << m) - 1]);
}
}
回路计数
也是状压,今天磕不动了,去做做别的。继续!
import java.util.*;
import java.io.*;
public class Main {
static int gcd(int a, int b) {
return b == 0 ? a : gcd(b, a % b);
}
public static void main(String[] args) {
int[][] dist = new int[30][30];
for (int i = 1; i <= 21; i++) {
for (int j = 1; j <= 21; j++) {
// 从1-21
if (gcd(i, j) == 1) {
dist[i][j] = 1;
// 1代表这条路是通的
}
}
}
int cnt = 1 << 22;
long[][] num = new long[cnt][22];
// num[i][j] : 在i状态下到达j点的路径数
num[2][1] = 1; // 2的二进制=10,没有第0个点只从第1个点开始,从第1个点到第1个点,路径数=1
for (int i = 2; i <= cnt - 2; i++) { // cnt - 1是全部点都到达,但是没有第0个点,所以还要-1
for (int j = 1; j <= 21; j++) { // 枚举每一个点,去除状态i下已经走过的点,避免重复走
if (((i >> j) & 1) == 1) { // 说明当前状态经过了当前点
int st = i - (1 << j); // 不能重复走过某一点,所以把该状态中经过的该点去除
for (int k = 1; k <= 21; k++) { // 枚举从j可能到达的下一个点k
if (((st >> k) & 1) == 1) { // 点k必须是当前状态i要走的点
if (dist[k][j] == 1) num[i][j] += num[st][k]; // 并且j点和k点之间是通的
}
}
}
}
}
long ans = 0;
// 注意节点1与所有节点都互质,节点1与其余节点都有通路,也就是说其它节点与节点1都相连
// 可以以任意节点i作为结束节点,最后再走一步又能到达节点1,所以是求和
for (int i = 1; i <= 21; i++) ans += num[cnt - 2][i];
System.out.println(ans);
}
}
答案:881012367360
状压DP的特点在于,最外层遍历可能状态,内层遍历决定状态的其它因素,进而考虑下一步可能转移到的状态。
很多棋盘问题都运用到了状压,同时,状压也很经常和BFS及DP连用。(状态压缩搜索)
最短Hamilton路径(中等)
这道题和上一道题本质是一样的,上面是要求方案数所以累加,这道题求最小值,所以是求Min。同样也是先遍历状态(也就是说在当前状态下包括中间点、终点在内走过的所有点),再遍历终点,再遍历中间点,注意!当前状态必须包含终点j和中间点k,否则当前状态不能用,并且要注意一定要把状态中终点去除掉。
import java.util.*;
import java.io.*;
public class Main {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
int n = scan.nextInt();
int[][] wei = new int[20][20];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
// 记录两点间距离
wei[i][j] = scan.nextInt();
}
}
int cnt = 1 << 20; // 最多有0-19 20个点
// 最后要的结果是 (1<<20) - 1也就是全为1,全部访问的结果
int[][] dp = new int[cnt][20];
// dp[i][j],在i的状态下,到达j的最少距离
for (int i = 0; i < cnt; i++) {
Arrays.fill(dp[i], 1000000000); // 不要用Integer.MAX_VALUE,会导致溢出变为负数
}
// 起点为第0个节点,经过它后状态变为1(2进制),所以dp[1][0] = 0
dp[1][0] = 0;
// 先遍历状态
for (int i = 1; i < (1 << n); i++) { // 全0无意义
// 遍历状态i下到达j的点(终点)
for (int j = 0; j < n; j++) {
if (((i >> j) & 1) == 1) {
// 当前点已经走过,那就去掉走过的点
int st = i - (1 << j);
// 再遍历到达j(终点)的可能中间节点(终点j和中间节点k都必须出现在状态i中)
for (int k = 0; k < n; k++) {
if (((st >> k) & 1) == 1) {
// 中间节点k出现在状态i中
dp[i][j] = Math.min(dp[i][j], dp[st][k] + wei[k][j]); // wei[k][j] k到j的距离,k始终是中间节点
}
}
}
}
}
// 所有点都走完,且最后走到第n-1号节点的最小长度
System.out.println(dp[(1 << n) - 1][n - 1]);
}
}
骑士
考虑到每行每列之间都有互相的约束关系。因此,我们可以用行和列作为另一个状态的部分。用一个新的方法表示行和列的状态:数字。考虑任何一个十进制数都可以转化成一个二进制数,而一行的状态就可以表示成这样——例如:1010(2)
就表示:这一行的第一个格子没有国王,第二个格子放了国王,第三个格子没有放国王,第四个格子放了国王。而这个二进制下的数就可以转化成十进制: 10(10)
今天做dp又做麻了,明天再来,