【算法修炼】树形DP、区间DP和状压DP

学习了之前的基础DP内容,发现比赛题目往往不会考常规DP呀!更喜欢考背包、树型、区间DP,赶紧来学习学习。
学习自:https://www.cnblogs.com/ljy-endl/p/11612275.html

一、经典树形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又做麻了,明天再来,

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

@u@

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值