目录
DP思想
DP和分治策略类似,讲一个问题分成几个子问题,然后地递归的求解这个几个子问题,之后合并子问题的解得到原问题的解。
不同点在于,分治策略用来解决子问题相互独立的问题,称为标准分治;动态规划用来解决子问题出现重叠的问题。
子问题出现重叠:一个子问题在求解后,可能被再次求解,于是可以将这些子问题的解存储下来,再次求解时直接使用即可。
DP可以解决的问题其实是分支策略解决的问题的一个子集,只是这个部分更适合用动态规划求解从而得到更小的运行时间。
DP抽象理解
只试图解决每个子问题一次;
一旦某个子问题已经求解,则将其保存起来,下次再需求解该子问题时直接查表;
DP性质
最优子结构:大问题的最优解可以由小问题的最优解推出
无后效性:未来与过去无关。给定某一阶段的状态,该状态以后的过程发展不受该阶段以前的状态影响。
DP快的原因
无论暴力还是递归,都是在可能解空间找出最优解。暴力法是枚举所有可能解,这是最大的可能解空间。DP是枚举有希望成为答案的解(这个空间比暴力的小),DP舍弃了一堆不可能称为最优解的答案(DP自带剪枝)。由此分析,DP的核心思想是尽可能的缩小解空间。暴力解空间往往是指数级,DP有可能将解空间降至多项式级。
DP关键步骤
最优子结构(设计状态)
边界
状态转移方程
例子:爬台阶问题
f(10) = f(9) + f(8)
是【最优子结构】 f(1) 与 f(2)
是【边界】 f(n) = f(n-1) + f(n-2)
【状态转移公式】
例题1:国王与金矿
转自https://mp.weixin.qq.com/s/w-N5-En40SKmO8oMcucNKw
有一个国家发现了 5 座金矿,每座金矿的黄金储量不同,需要参与挖掘的工人数也不同。参与挖矿工人的总数是 10 人。每座金矿要么全挖,要么不挖,不能派出一半人挖取一半金矿。要求用程序求解出,要想得到尽可能多的黄金,应该选择挖取哪几座金矿?
最优子结构
- 10个工人挖前四个金矿最有一个不挖;
- 五个工人挖前四个金矿,5个工人挖最后一个;
两者之间的关系是
Max[(4金矿10工人的挖金数量),(4金矿5工人的挖金数量+第 5 座金矿的挖金数量)]
边界
- 当只有 1 座金矿时,只能挖这座唯一的金矿,得到的黄金数量为该金矿的数量
- 当给定的工人数量不够挖 1 座金矿时,获取的黄金数量为 0
状态转移公式
金矿数量设为 N,工人数设为 W,金矿的黄金量设为数组G[],金矿的用工量设为数组P[]
-
边界值:F(n,w) = 0 (n <= 1, w < p[0])
-
F(n,w) = g[0] (n==1, w >= p[0])
-
F(n,w) = F(n-1,w) (n > 1, w < p[n-1])
-
F(n,w) = max(F(n-1,w), F(n-1,w-p[n-1]) + g[n-1])
实现
1.只挖第一座金矿
在只挖第一座金矿前面两个工人挖矿收益为 零,当有三个工人时,才开始产生收益为 200,而后即使增加再多的工人收益不变,因为只有一座金矿可挖。
2.挖第一座与第二座金矿
在第一座与第二座金矿这种情况中,前面两个工人挖矿收益为 零,因为 W < 3,所以F(N,W) = F(N-1,W) = 0。
当有 三 个工人时,将其安排挖第 一 个金矿,开始产生收益为 200。
当有 四 个工人时,挖矿位置变化,将其安排挖第 二 个金矿,开始产生收益为 300。
当有 五、六 个工人时,由于多于 四 个工人的人数不足以去开挖第 一 座矿,因此收益还是为 300。
当有 七 个工人时,可以同时开采第 一 个和第 二 个金矿,开始产生收益为 500。
3.挖前三座金矿
4.挖前四座金矿
仔细观察上面的几组动画可以发现:
-
对比「挖第一座与第二座金矿」和「挖前三座金矿」,在「挖前三座金矿」中,3 金矿 7 工人的挖矿收益,来自于 2 金矿 7 工人和 2 金矿 4 工人的结果,Max(500,300 + 350) = 650;
-
对比「挖前三座金矿」和「挖前四座金矿」,在「挖前四座金矿」中,4 金矿 10 工人的挖矿收益,来自于 3 金矿 10 工人和 3 金矿 5 工人的结果,Max(850,400 + 300) = 850;
代码:
int maxGold[max_people][max_n];
//maxGold[i][j]保存了i个人挖前j个金矿能够得到的最大金子数,等于-1表示未知
int GetMaxGold(int people, int minNum){
int resMaxGold; //声明返回的最大金矿数量
if(maxGold[people][minNum] != -1){
resMaxGold = maxGold[people][minNum]; //获得保存起来的值
}else if(minNum == 0){ //如果仅有一个金矿时 [对应动态规划中的"边界"]
if(people >= peopleNeed[minNum]) //当给出的人数足够开采这座金矿
resMaxGold = gold[minNum]; //得到的最大值就是这座金矿的金子数
else //否则这唯一的一座金矿也不能开采
resMaxGold = 0; //得到的最大值为 0 个金子
}else if(people >= peopleNeed[minNum]){//人够开采这座金矿[对应动态规划中的"最优子结构"]
//考虑开采与不开采两种情况,取最大值
retMaxGold = max(
GetMaxGold(people - peopleNeed[minNum],minNum - 1) + gold[minNum],
GetMaxGold(people,minNum - 1));
}else{ //否则给出的人不够开采这座金矿 [ 对应动态规划中的"最优子结构"]
resMaxGold = GetMaxGold(people,minNum - 1); //仅考虑不开采的情况
maxGold[people][minNum] = resMaxGold;
}
return resMaxGold;
}
最长上升子序列
如果要得出Ai序列的最长递增子序列,就需要计算出Ai-1的所有元素作为最大元素的最长递增序列,依次递推Ai-2,Ai-3,……,将此过程倒过来,即可得到递推算法,依次推出A1,A2,……,直到推出Ai为止,
Given an unsorted array of integers, find the length of longest increasing subsequence.
Example:
Input:[10,9,2,5,3,7,101,18]
Output: 4 Explanation: The longest increasing subsequence is[2,3,7,101], therefore the length is 4.
思路1
dp[i]:保存第i个元素的最大上升子序列的长度
dp[i]=max(dp[j])+1,∀0≤j<i,nums[i] > nums[j]
LISlength=max(dp[i]),∀0≤i<n.
public int lengthOfLIS(int[] nums) {
if(nums.length == 0)
return 0;
int[] dp = new int[nums.length]; //建立一个和nums长度一样的数组
dp[0] = 1;
int maxans = 1; //初始化最大长度,最小一个数
for(int i = 1; i < nums.length; i++){
int maxval = 0; //用于保存最大的dp表 中的数
for(int j = 0; j < i; j++){
if(nums[i] > nums[j]) //后面的数比前面的数大
maxval = Math.max(maxval, dp[j]);
}
dp[i] = maxval + 1; //若当前数比前面的数都小,则为1;否则为前面的的最大长度+1;
maxans = Math.max(maxans, dp[i]); //找出dp表中的最大值
}
return maxans;
}
时间复杂度:O(n^2)--两层循环;空间复杂度:O(n)--dp数组
思路2(优化):
tails[ i ]:i为当前索引,tail[i]为保存当前最大子序列长度为i+1的子序列中末尾最小的一个数
size:为子序列长度
public int lengthOfLIS(int[] nums) {
int[] tails = new int[nums.length];
int size = 0;
for (int x : nums) {
int i = 0, j = size;
while (i != j) {
int m = (i + j) / 2;
if (tails[m] < x)
i = m + 1;
else
j = m;
}
tails[i] = x;
if (i == size) ++size;
}
return size;
}
时间复杂度:O(nlogn);空间复杂度:O(n)
输出递增序列
public static int lengthOfLIS(int[] nums) {
if(nums.length == 0)
return 0;
int[] pos = new int[nums.length]; //保存第i个数的上一个序列内数的位置
//例如序列5 1 6 50 9 12,第4个数9的上一个序列数为6, 下标为3,那么pos[4] = 3
int[] dp = new int[nums.length]; //建立一个和nums长度一样的数组
dp[0] = 1;
for(int i = 1; i < nums.length; i++){
for(int j = 0; j < i; j++){
if(nums[i] > nums[j] && dp[i] < dp[j] + 1 ) { //后面的数比前面的数大
dp[i] = dp[j] + 1;
pos[i] = j;
}
}
}
int max = -1; //保存最大值
int lastPos = -1; //pos索引
for(int i = 0; i < nums.length; i++) {
if(dp[i] > max) {
max = dp[i]; //保存最大值
lastPos = i; //保存最大值的下标
}
}
int[] res = new int[max]; //存储序列
int j = max - 1;
for (int i = 0; i < max; i++) {
res[j--] = nums[lastPos];
lastPos = pos[lastPos]; //找出当前数的序列中上一个数
}
for (Integer ele : res) {
System.out.println(ele);
}
return max;
}
最长公共子序列
public class LongestCommonSubsequence {
/*
* 思路:将问题分解为两子问题:
* 1、am-1 = bn-1时
* 2、am-1 != bn-1时:(再分解为两子问题)
* a0,a1,a2,...,am-2与B=b0,b1,b2,...,bn-1
* a0,a1,a2,...,am-1与B=b0,b1,b2,...,bn-2
* 取两者中较大者
* 两子串:A=a0,a1,a2,...,am-1;B=b0,b1,b2,...,bn-1;
* 最长公共子串:Z=z0,z1,z2,...,zk-1
* 1、若am-1 = bn-1,则zk-1 = am-1 = bn-1,则z0,z1,z2,...,zk-2
* 是a0,a1,a2,...,am-2与B=b0,b1,b2,...,bn-2的一个最长公共子序列;
* 2、若am-1 != bn-1,则如果zk-1 != am-1,则z0,z1,z2,...,zk-1
* 是a0,a1,a2,...,am-2与B=b0,b1,b2,...,bn-1的一个最长公共子序列;
* 3、若am-1 != bn-1,则如果zk-1 != bn-1,则z0,z1,z2,...,zk-1
* 是a0,a1,a2,...,am-1与B=b0,b1,b2,...,bn-2的一个最长公共子序列;
*
* 求解:
* c[i][j]:记录A与B的最长公共子序列的长度
* b[i][j]:记录通过那一个子问题求得的解,以决定搜索的方向
*
* 状态转移公式:
* | 0 , if i=0 or y=0,
* c[i][j]=|c[i - 1,j - 1] + 1 if i, j > 0 and xi = yi,
* |max(c[i,j - 1], c[i - 1, j]) if i, j > 0 and xi != yi,
*
* 复杂度分析:
* 每次调用都至少向上或向左,或者同时向上或向左,遇到0就返回,方向相反,步数相同,因此时间复杂度为O(m + n).
*/
public static int[][] LCSlength(String str1, String str2){
char[] a = str1.toCharArray();
char[] d = str2.toCharArray();
int m = str1.length();
int n = str2.length();
int maxlen = Math.max(m, n) + 1;
int[][] c = new int[maxlen][maxlen];
int[][] b = new int[maxlen][maxlen];
if(str1.length() < 1 || str2.length() < 1)
return b;
for (int i = 0; i <= m; i++) {
c[i][0] = 0;
}
for (int j = 1; j <= n; j++) {
c[0][j] = 0;
}
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++){
if(a[i - 1] == d[j - 1]){ //相等的情况
c[i][j] = c[i - 1][j - 1] + 1;
b[i][j] = 0;
}else if(c[i - 1][j] > c[i][j - 1]){ //左边的大就取左边
c[i][j] = c[i - 1][j];
b[i][j] = 1;
}else{ //上边的大就取上边
c[i][j] = c[i][j - 1];
b[i][j] = -1;
}
}
}
return b;
}
public static void printLCS(int[][] b, char[] x, int i, int j){
if(i == 0 || j == 0){
return;
}
if(b[i][j] == 0){
printLCS(b, x, i - 1, j - 1);
System.out.print(x[i - 1] + " ");
}else if(b[i - 1][j - 1] == 1){
printLCS(b, x, i - 1, j);
}else {
printLCS(b, x, i, j - 1);
}
}
public static void main(String[] args) {
String aString = "abcdf";
String bString = "bcdfg";
int[][] b = LCSlength(aString, bString);
char[] x = aString.toCharArray();
int i = aString.length();
int j = bString.length();
printLCS(b, x, i, j);
}
}
最长公共子串
public class longestCommonSubstring{
/*
* 思路:公共子串是公共子序列的一种特殊情况,只是要求子序列的下标是连续递增1.
*
* 状态转移方程:
* | c[i - 1][j - 1] + 1, if xi == yi
* c[i][j] =| 0, if xi != yi
*
* 最后的结果为max{c[i][j]}, 1 =< i <= n, 1 =< j <= m
*/
static int LCSLength(String str1, String str2){
if(str1.length() < 1 || str2.length() < 1)
return 0;
char[] a = str1.toCharArray();
char[] d = str2.toCharArray();
int m = str1.length();
int n = str2.length();
int x = -1, y = -1;
int[][] c = new int[m + 1][n + 1];
for (int i = 0; i <= m; i++) {
c[i][0] = 0;
}
for (int i = 1; i <= n; i++) {
c[0][i] = 0;
}
int max = -1;
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if(a[i - 1] == d[j - 1]){
c[i][j] = c[i - 1][j - 1] + 1; //只需要跟左上方的c[i-1][j-1]比较就可以了
}else { //不连续的时候还要跟左边的c[i][j-1]、上边的c[i-1][j]值比较,这里不需要
c[i][j] = 0;
}
if(c[i][j] > max){
max = c[i][j];
x = i;
y = j;
}
}
}
//输出公共子串
List<Character> list = new ArrayList<Character>();
int i = x - 1;
int j = y - 1;
while(i >= 0 && j >= 0){
if (a[i] == d[j]) {
list.add(a[i]);
i--;
j--;
}else {
break;
}
}
for (int k = list.size() - 1; k >= 0; k--) { //将链表倒着输出
System.out.println(list.get(k) + " ");
}
return max;
}
public static void main(String[] args) {
String aString = "abcdf";
String bString = "bcdfg";
LCSLength(aString, bString);
}
}
最长回文子串
方法一:动态规划
/*动态规划
dp(i,j)代表s(i,...,j)是否可以建立成一个回文子串
|true, if s(i) == s(j) and s(i+1,...,j-1)也是一个回文子串
dp(i,j) =|false, otherwise
时间复杂度:O(n^2) 空间复杂度:O(n^2)
*/
public String longestPalindrome(String s){
if(s.length() < 1 || s == null) return "";
String res = "";
int n = s.length();
boolean[][] dp = new boolean[n][n];
for(int j = 0; j < s.length(); j++ ){ //j为末尾坐标
for(int i = 0; i <= j; i++){ //i为起始坐标
//j - i < 3:在j与i之间距离在3以内时起作用
dp[i][j] = s.charAt(i) == s.charAt(j) && (j - i < 3 || dp[i + 1][j - 1]);
if(dp[i][j] && (res == null || j - i + 1 > res.length())){
res = s.substring(i, j + 1);
}
}
}
return res;
}
方法二:从中心往外扩
public String longestPalindrome(String str) {
/*采用从中心往外扩的思想
首先中心点的选择问题:可以是字符串中的字符,也可以是字符串中两字符之间的空隙
由此可以得到中心点是2n-1个。
问题分为两步:
1、以字符为中心,比较字符两侧的字符,检查是否相等;相等继续往外扩,不相等就停止;
2、以空隙为中心,比较字符两侧的字符,检查是否相等;相等继续往外扩,不相等就停止;
以最大的长度作为更新起始和截止坐标的条件
时间复杂度:O(n^2) 空间复杂度:O(1)
*/
if(s.length() < 1 || s == null) return "";
int start = 0, end = 0; //回文字符串的开始和结尾
for(int i = 0; i < s.length(); i++){
//第一步:以字符为中心,以此字符为起点
int len1 = expandAroundCenter(s, i , i);
//第二步:以空隙为中心,比较空隙左右两边字符
int len2 = expandAroundCenter(s, i , i + 1);
int len = Math.max(len1, len2);
//更新始末坐标:新的扩张长度大于原长度
if(len > (end - start)){
start = i - (len - 1) / 2;
end = i + len / 2;
}
}
return s.substring(start, end + 1);
private int expandAroundCenter(String s, int left, int right){
int l = left, r = right;
while(l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r)){
l--;
r++;
}
return r - l - 1;
}
方法三:Mannacher算法
时间复杂度:O(n) 空间复杂度:O(n)
public String longestPalindrome(String str) {
if (str == null || str.length() == 0) {
return "";
}
char[] charArr = manacherString(str);
int[] pArr = new int[charArr.length]; //以每个位置为中心向外扩的回文串的半径长度值--回文半径数组
int C = -1; //只要回文右边界没更新,最早的到达的中心点
int R = -1; //回文右边界
int max = Integer.MIN_VALUE;
int start = 0;
int end = 0;
for (int i = 0; i < charArr.length; i++) {
//依据当前位置在不在回文右边界里面,R>i,表明当前位置在回文右边界里面,表明不用扩的只有自己1
//当前回文半径其实是原字符串的长度(由于中间加了特殊字符),2 * C - i代表当前i的对称点,
//Math.min(pArr[2 * C - i], R - i)起码不用验的区域
pArr[i] = R > i ? Math.min(pArr[2 * C - i], R - i) : 1;
//情况2和3本不用扩,向外扩一下就会失败;情况1和4向外扩有可能成功
while (i + pArr[i] < charArr.length && i - pArr[i] > -1) {
if (charArr[i + pArr[i]] == charArr[i - pArr[i]]) {
pArr[i]++;
}else {
break;
}
}
//扩出来的区域有了新的回文右边界,更新R,C(两者同时行动)
if (i + pArr[i] > R) {
R = i + pArr[i];
C = i;
}
//max = Math.max(max, pArr[i]);
if (pArr[i] > max) {
max = pArr[i] - 1;
if(i % 2 == 0) { //当前位置在空隙处
start = i / 2 - pArr[i] / 2;
end = i / 2 + pArr[i] / 2;
}else { //当前位置在字符上
start = i / 2 - pArr[i] / 2 + 1;
end = i / 2 + pArr[i] / 2;
}
}
}
//System.out.println("最大长度为:" + max);
return str.substring(start, end);
}
public char[] manacherString(String str) {
char[] charArr = str.toCharArray();
char[] res = new char[str.length() * 2 + 1];
int index = 0;
for (int i = 0; i < res.length; i++) {
res[i] = (i & 1) == 0 ? '#' : charArr[index++];
}
return res;
}
回文子串数
方法一:Manacher算法
public int countSubstrings(String s) {
/*思路:考虑manachaer算法求得回文半径数组,然后再去求得回文子串的个数
manachaer算法求得回文半径数组思路:
考虑四种可能性:
1、回文右边界在当前位置左边,暴力扩; 时间复杂度--最坏O(n)
2、回文右边界在当前位置右边
当前位置关于回文中心对称点的回文半径在大回文边界内;时间复杂度--最坏O(1)
当前位置关于回文中心对称点的回文半径左边界超出大回文边界左边界;时间复杂度--O(1)
当前位置关于回文中心对称点的回文半径左边界与大回文边界左边界重合; 时间复杂度--最坏O(n)
*/
if(s == null) return 0;
char[] str = manString(s);
//定义回文半径数组--记录当前位置的回文半径
int[] pArr = new int[str.length];
//定义大回文右边界
int R = -1;
//定义大回文中心
int C = -1;
int res = Integer.MIN_VALUE;
for(int i = 0; i < pArr.length; i++){
pArr[i] = R > i ? Math.min(pArr[2 * C - i], R-i) : 1;
while(i + pArr[i] < pArr.length && i - pArr[i] > -1){
if(str[i + pArr[i]] == str[i - pArr[i]]){
pArr[i]++;
}else{
break;
}
}
if(i + pArr[i] > R){
R = i + pArr[i];
C = i;
}
res = Math.max(res, pArr[i]);
}
//得出回文半径数组后求各个位置的回文子串数
int sum = 0;
for(int i = 0; i < pArr.length; i++){
sum += (pArr[i]) / 2;
}
return sum;
}
public char[] manString(String str){
int len = str.length();
char[] s = new char[len * 2 + 1]; //存放特殊字符
int index = 0;
for(int i = 0; i < s.length; i++){
s[i] = (i & 1) == 0 ? '#' : str.charAt(index++);
}
return s;
}
方法二:动态规划
public int countSubstrings1(String s) {
int len = s.length();
int count = 0;
boolean[][] dp = new boolean[len][len];
for(int j = 0; j < len; j++){
for(int i = 0; i <= j; i++){
dp[i][j] = s.charAt(i) == s.charAt(j) && (j - i < 3 || dp[i + 1][j - 1]);
if(dp[i][j])
count++;
}
}
return count;
}
最长回文子序列
public int longestPalindromeSubseq(String s) {
/*
思路:动态规划
dp[i][j] = 子串(i,j)的最长子序列的长度,i,j代表索引
|dp[i+1][j-1] + 2, if s.charAt(i) == s.charAt(j)
dp[i][j] =|dp[i][j] = Math.max(dp[i+1][j], dp[i][j-1]), otherwise
初始化dp[i][i] = 1
*/
int[][] dp = new int[s.length()][s.length()];
for(int i = s.length() - 1; i >= 0; i--){
dp[i][i] = 1;
for(int j = i+1; j < s.length(); j++){
if(s.charAt(i) == s.charAt(j)){
dp[i][j] = dp[i+1][j-1] + 2;
}else{
dp[i][j] = Math.max(dp[i+1][j], dp[i][j-1]);
}
}
}
return dp[0][s.length() - 1];
}
/*
思路:回溯
*/
public int longestPalindromeSubseq2(String s) {
return helper(s, 0, s.length() - 1, new int[s.length()][s.length()]);
}
private int helper(String s, int i, int j, int[][] memo) {
if (memo[i][j] != null) {
return memo[i][j];
}
if (i > j) return 0;
if (i == j) return 1;
if (s.charAt(i) == s.charAt(j)) {
memo[i][j] = helper(s, i + 1, j - 1, memo) + 2;
} else {
memo[i][j] = Math.max(helper(s, i + 1, j, memo), helper(s, i, j - 1, memo));
}
return memo[i][j];
}