1.核心概念
动态规划(Dynamic Programming,简称DP)是一种用于解决具有重叠子问题和最优子结构性质的问题的算法设计技术。动态规划通常用于优化递归算法,避免重复计算相同子问题,提高算法的效率。
原理核心概念包括:
-
最优子结构(Optimal Substructure): 问题的最优解包含了其子问题的最优解。通过将问题分解成子问题,可以更容易求解整体问题。
-
重叠子问题(Overlapping Subproblems): 在递归解决问题的过程中,同一子问题可能被多次求解。为了避免重复计算,可以将子问题的解存储起来,避免重复求解。
-
状态转移方程(State Transition Equation): 将问题的解表示为一个或多个子问题的解的函数,描述问题的状态之间的关系。状态转移方程是动态规划的核心。
2.问题解决的一般步骤
-
定义状态: 确定问题的状态,即问题的子结构。状态应该包含足够的信息来描述问题的一个局部最优解。
-
找到最优子结构: 确认问题是否具有最优子结构性质,即问题的最优解包含了其子问题的最优解。
-
建立状态转移方程: 根据定义的状态,尝试找到状态之间的关系。这通常涉及到用当前状态表示为之前某些状态的函数。
-
考虑边界条件: 确保定义良好的初始状态,以及在递推过程中如何处理边界条件。
-
递推计算: 利用状态转移方程进行递推计算,填充状态数组或表格。
-
优化空间复杂度: 在填充状态数组或表格时,可以考虑是否存在冗余的信息,是否可以通过滚动数组等方式优化空间复杂度。
-
分析时间复杂度: 分析算法的时间复杂度,确保算法的效率在可接受范围内。
-
理解问题本质: 深入理解问题的本质,考虑问题的特性,有时可以根据问题的特性得到简化的转移方程。
3. 经典问题
动态规划被广泛应用于解决各种问题,以下是一些经典的动态规划问题:
-
斐波那契数列(Fibonacci Sequence): 计算第n个斐波那契数的问题,是最经典的动态规划问题之一。
-
最长递增子序列(Longest Increasing Subsequence): 寻找给定数组中的最长递增子序列的长度。
-
背包问题(Knapsack Problem): 0/1背包问题和背包问题的变体,如分数背包问题,是动态规划中的典型问题。
-
最长公共子序列(Longest Common Subsequence): 在两个序列中找到的最长公共子序列的长度,用于比较两个序列的相似性。
-
编辑距离(Edit Distance): 计算将一个字符串转换成另一个字符串所需的最小编辑操作次数,包括插入、删除和替换。
-
矩阵链乘法(Matrix Chain Multiplication): 寻找一种括号化方式,使得矩阵链相乘的代价最小。
-
最大子数组和(Maximum Subarray Sum): 寻找数组中的一个子数组,使其元素之和最大。
-
最短路径问题(Shortest Path Problem): 通过给定图中的边权重,找到两个节点之间的最短路径。
-
股票买卖问题(Stock Buy and Sell): 寻找股票价格序列中可以获得的最大利润。
-
硬币找零问题(Coin Change Problem): 找零钱的最小硬币数目,是一个典型的动态规划问题。
-
编辑距离问题(Edit Distance): 通过插入、删除、替换等操作,将一个字符串转换为另一个字符串所需的最小操作次数。
-
最长回文子序列(Longest Palindromic Subsequence): 寻找给定字符串中的最长回文子序列的长度。
-
打家劫舍问题(House Robber): 一排房屋,每个房屋中有一定数量的钱,不能同时打劫相邻的房屋,求能够打劫到的最大金额。
-
区间调度问题(Interval Scheduling): 选择一组不相交的区间,使得选中的区间数量最大。
-
最小路径和问题(Minimum Path Sum): 给定一个包含非负整数的二维网格,找到从左上角到右下角的最小路径和。
-
零钱兑换问题(Coin Change Problem): 给定不同面额的硬币和一个总金额,计算可以凑成总金额的不同组合数。
-
最大正方形问题(Maximal Square): 在给定的二维二进制矩阵中,找到只包含 1 的最大正方形,并返回其面积。
-
单词拆分问题(Word Break): 给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,确定 s 是否可以被空格拆分成一个或多个在字典中出现的单词。
-
不同的二叉搜索树(Unique Binary Search Trees): 给定一个整数 n,求以 1 ... n 为节点组成的二叉搜索树有多少种。
-
字符串匹配问题(Regular Expression Matching): 实现支持 '.' 和 '*' 的正则表达式匹配。
4.举几个例
- 斐波那契数列
-
定义状态: 设
dp[i]
表示斐波那契数列的第i
个数。 -
最优子结构: 显然,斐波那契数列的最优解包含了其子问题的最优解。
-
转移方程: 由于斐波那契数列的定义是
dp[i] = dp[i-1] + dp[i-2]
,因此状态转移方程为dp[i] = dp[i-1] + dp[i-2]
。 -
边界条件: 初始条件是
dp[0] = 0
,dp[1] = 1
。 -
递推计算: 利用转移方程递推计算即可。
-
当涉及到动态规划问题的Java实现时,下面是一个简单的示例,展示了如何使用动态规划解决斐波那契数列问题:
public class Fibonacci {
public static void main(String[] args) {
int n = 10;
int result = fibonacci(n);
System.out.println("Fibonacci of " + n + " is: " + result);
}
public static int fibonacci(int n) {
if (n <= 1) {
return n;
}
int[] dp = new int[n + 1];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
}
这是一个简单的动态规划实现,计算了斐波那契数列的第n个数。该程序使用了一个数组 dp
来存储中间状态,避免了递归中的重复计算,提高了效率。
- 最长递增子序列
-
定义状态: 设
dp[i]
表示以第i
个元素结尾的最长递增子序列的长度。 -
最优子结构: 最长递增子序列的最优解包含了其子问题的最优解。如果我们知道以第
i-1
个元素结尾的最长递增子序列,可以通过比较第i
个元素与前面的元素来得到以第i
个元素结尾的最长递增子序列。 -
转移方程:
dp[i] = max(dp[j]) + 1
,其中0 <= j < i
,且nums[j] < nums[i]
。意思是,以第i
个元素结尾的最长递增子序列长度,等于前面某个元素结尾的最长递增子序列长度(通过max(dp[j])
选择最长的那个),再加上当前元素本身。 -
边界条件: 对于每个元素,初始时它自身构成一个长度为1的递增子序列,因此
dp[i]
初始化为1。 -
递推计算: 从左往右遍历数组,利用转移方程递推计算
dp[i]
。
-
public class LongestIncreasingSubsequence {
public static void main(String[] args) {
int[] nums = {10, 9, 2, 5, 3, 7, 101, 18};
int result = lengthOfLIS(nums);
System.out.println("Length of Longest Increasing Subsequence: " + result);
}
public static int lengthOfLIS(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1); // 初始化dp数组为1
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
int maxLength = 0;
for (int len : dp) {
maxLength = Math.max(maxLength, len);
}
return maxLength;
}
}
这个例子解决了找到给定数组的最长递增子序列的长度的问题。通过动态规划的思想,定义了状态数组 dp
,并使用双重循环遍历数组来计算每个位置的最长递增子序列长度。最终,返回 dp
数组中的最大值。
- 最短路径
最短路径问题是动态规划在图算法中的一个重要应用,其中一个典型的例子是 Dijkstra 算法。这个算法用于找到图中两个节点之间的最短路径。
import java.util.*;
class Graph {
private int vertices;
private List<Map<Integer, Integer>> adjacencyList;
public Graph(int vertices) {
this.vertices = vertices;
this.adjacencyList = new ArrayList<>(vertices);
for (int i = 0; i < vertices; i++) {
this.adjacencyList.add(new HashMap<>());
}
}
public void addEdge(int source, int destination, int weight) {
this.adjacencyList.get(source).put(destination, weight);
this.adjacencyList.get(destination).put(source, weight);
}
public void dijkstra(int start) {
PriorityQueue<Node> priorityQueue = new PriorityQueue<>(Comparator.comparingInt(node -> node.weight));
boolean[] visited = new boolean[vertices];
int[] distance = new int[vertices];
Arrays.fill(distance, Integer.MAX_VALUE);
distance[start] = 0;
priorityQueue.offer(new Node(start, 0));
while (!priorityQueue.isEmpty()) {
int current = priorityQueue.poll().vertex;
if (visited[current]) {
continue;
}
visited[current] = true;
for (Map.Entry<Integer, Integer> neighbor : adjacencyList.get(current).entrySet()) {
int nextVertex = neighbor.getKey();
int newDistance = distance[current] + neighbor.getValue();
if (newDistance < distance[nextVertex]) {
distance[nextVertex] = newDistance;
priorityQueue.offer(new Node(nextVertex, newDistance));
}
}
}
// Print the shortest distances
for (int i = 0; i < vertices; i++) {
System.out.println("Shortest distance from node " + start + " to node " + i + ": " + distance[i]);
}
}
private static class Node {
private int vertex;
private int weight;
public Node(int vertex, int weight) {
this.vertex = vertex;
this.weight = weight;
}
}
}
public class ShortestPathExample {
public static void main(String[] args) {
int vertices = 6;
Graph graph = new Graph(vertices);
graph.addEdge(0, 1, 4);
graph.addEdge(0, 2, 2);
graph.addEdge(1, 2, 5);
graph.addEdge(1, 3, 10);
graph.addEdge(2, 4, 3);
graph.addEdge(3, 5, 7);
graph.addEdge(4, 5, 1);
int startNode = 0;
graph.dijkstra(startNode);
}
}
这个例子中,我们创建了一个带有权重的无向图,并使用 Dijkstra 算法来计算从起始节点到其他节点的最短路径。Dijkstra 算法通过贪心策略每次选择当前距离最短的节点进行松弛操作。
- 打家劫舍问题
打家劫舍问题是一个经典的动态规划问题,其中房屋被排列成一排,每个房屋中有一定数量的钱。相邻的房屋在同一晚上被小偷闯入,由于安保系统,如果两个相邻的房屋在同一晚上被闯入,系统就会自动报警。
-
定义状态: 定义动态规划数组,例如dp[i]表示前i个房屋能够得到的最大金额。
-
初始化状态: 根据问题的特点初始化动态规划数组,通常是dp[0]和dp[1]的初始化。
-
找到状态转移方程: 寻找房屋被偷和不被偷的状态转移方程。这涉及到观察问题的规律,通常可以通过前一个状态推导出当前状态。
-
迭代计算: 使用状态转移方程迭代计算动态规划数组,直到计算出最终的状态,即dp[n],其中n是房屋的数量。
-
返回结果: 返回dp[n]作为问题的最终解,表示能够偷取的最大金额。
小偷决定在这些房屋中选择一些非相邻的房屋进行盗窃,目标是在不触发警报的情况下,获取最大的盗窃金额。以下是一个 Java 实现:
public class HouseRobber {
public static void main(String[] args) {
int[] nums = {2, 7, 9, 3, 1};
int result = rob(nums);
System.out.println("Maximum amount that can be robbed: " + result);
}
public static int rob(int[] nums) {
int n = nums.length;
if (n == 0) {
return 0;
} else if (n == 1) {
return nums[0];
}
int[] dp = new int[n];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < n; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[n - 1];
}
}
在这个例子中,我们使用动态规划来解决打家劫舍问题。我们定义一个数组 dp
,其中 dp[i]
表示在第 i
个房屋处的最大盗窃金额。状态转移方程为 dp[i] = max(dp[i-1], dp[i-2] + nums[i])
,即在第 i
个房屋处,小偷可以选择盗窃或者不盗窃,选择盗窃时金额为 dp[i-2] + nums[i]
,选择不盗窃时金额为 dp[i-1]
。