文章目录
暴力递归
- 把问题转换为缩小了的同类问题的子问题
- 有明确的不需要继续进行递归的条件(base case)
- 有当得到了子问题的结果之后的决策过程
- 不记录每一个子问题的解
动态规划
- 从暴力递归中来
- 将每一子问题的解记录下来,避免重复运算
- 把暴力递归的过程,抽象成了状态表达
- 并且存在化简状态表达,有使其更加简洁的可能
英国诗人的拜伦的女儿艾达被称为世界上第一个程序员,但是贡献更大更为人们熟知并尊敬的是图灵。这里有什么差别,可以说艾达那个时候的程序,是明确知道一个问题怎么算,让计算机代替计算,得到问题的解(P问题),而图灵解决一个非常关键的问题,就是我不知道这个问题怎么算,但是我知道怎么尝试,比如并不知道德军密码是怎么加密的,但是他知道怎么尝试破译(NP问题)。
如果不知道怎么尝试,就会造成一个问题,不能很好的理解动态规划,因为动态规划就是为了优化暴力尝试的。这里首先给出一些暴力尝试的例子。
1. 暴力递归
1.1 求N!
如果这个问题我们给出的解是
1
×
2
×
3
⋯
×
n
1\times 2 \times 3 \cdots \times n
1×2×3⋯×n这既是一个P问题,我们明确的知道应该怎么计算然后让计算机代替我们。
如果换一个思路,知道了
(
n
−
1
)
!
(n-1)!
(n−1)!,那么
n
!
=
n
×
(
n
−
1
)
!
n! = n\times (n-1)!
n!=n×(n−1)!,这就是一个递归版本的尝试,给出一个启发思路
int factorial(int n)
{
if(n == 1)
return n;
return n*factorial(n-1);
}
1.2 汉诺塔问题
汉诺塔问题是一个经典的问题。汉诺塔(Hanoi Tower),又称河内塔,源于印度一个古老传说。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着64片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,任何时候,在小圆盘上都不能放大圆盘,且在三根柱子之间一次只能移动一个圆盘。问应该如何操作?
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/af3b1129df18ce3ca086df641b863d7c.png)
一股脑地考虑每一步如何移动很困难,我们可以换个思路。先假设除最下面的盘子之外,我们已经成功地将上面的63个盘子移到了b柱,此时只要将最下面的盘子由a移动到c即可。如图:
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/ffd944ff9a887ba672f1750f185a017f.png)
当最大的盘子由a移到c后,b上是余下的63个盘子,a为空。因此现在的目标就变成了将这63个盘子由b移到c。这个问题和原来的问题完全一样,只是由a柱换为了b柱,规模由64变为了63。因此可以采用相同的方法,先将上面的62个盘子由b移到a,再将最下面的盘子移到c……对照下面的过程,试着是否能找到规律:
- 将b柱子作为辅助,把a上的63个圆盘移动到b上
- 将a上最后一个圆盘移动到c
- 将a作为辅助,把b上的62个圆盘移动到a上
- 将b上的最后一个圆盘移动到c
……
也许你已经发现规律了,即每次都是先将其他圆盘移动到辅助柱子上,并将最底下的圆盘移到c柱子上,然后再把原先的柱子作为辅助柱子,并重复此过程。这是一个递归过程,加粗部分就是每个子问题要处理的内容。
/**汉诺塔问题,首先有两个杆from 和to 就是将饼从哪里移动到哪里,
然后有一个help杆,作为辅助杆**/
void HanoiTower(int n, string from ,string to,string help)
{
if(n == 1)
{
cout<<"move 1 from "<< from <<" to "<< to <<endl;
return;
}
HanoiTower(n-1,from,help,to);
cout<<"move "<< n <<" from "<< from <<" to "<< to << endl;
HanoiTower(n-1,help,to,from);
}
1.3 打印一个字符串所有的子序列,包括空字符串
区分子序列和子串
例如:一个字符串 awbcdewgh
他的子串: awbc、awbcd、awbcde …很多个子串 ,但是都是连续在一起 。
他的子序列: abc 、abcd、 abcde … 很多个子序列 ,但是子序列中的字符在字符串中不一定是连在一起的,而是删除其中若干个, 但是子序列一定是单调的(即字符之间ASCII单调递增或单调递减,相对顺序不能改变)
所以 子串!=子序列
打印一个字符串的所有子序列,可以理解为如果前面n-1个字符确定了,决定最后输出子序列的就是最后一个字符的有无,这样逆序回去就是一棵根据每一个字符有无构建的二叉树。如图,从根开始,有a没a,有b没b延续下去,最后会形成 2 n 2^n 2n种结果,代码测试如下,因为空格没有显示,不好区分,我将空个换成*打印:
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/0cc011e7fd78d307221e9b89761fc2cc.png)
#include <iostream>
#include<vector>
#include<string>
using namespace std;
/**打印一个字符串的所有子序列**/
vector<char> stringToChar(string str)
{
vector<char> res;
if(str.size() == 0)
return res;
for(int i=0;i<str.size();i++)
{
res.push_back(str[i]);
}
return res;
}
string charToString(vector<char> ch)
{
string res = "";
if(ch.size() == 0)
return res;
for(int i=0;i<ch.size();i++)
{
res = res+ch[i];
}
return res;
}
void process(vector<char> &ch,int i)
{
if(i == ch.size())
{
string res = charToString(ch);
cout<<res<<endl;
return ;
}
process(ch,i+1);
char tmp = ch[i];
ch[i] = '*';
process(ch,i+1);
ch[i] = tmp;
}
void printSubString(string str)
{
if(str.size() == 0 )
return ;
vector<char> tmp = stringToChar(str);
process(tmp,0);
}
int main()
{
cout << "Hello world!" << endl;
string str = "abc";
printSubString(str);
return 0;
}
1.4 母牛的故事
有一头母牛,它每年年初生一头小母牛。每头小母牛从第四个年头开始,每年年初也生一头小母牛。请编程实现在第n年的时候,共有多少头母牛?
输入描述:
输入数据由多个测试实例组成,每个测试实例占一行,包括一个整数n(0<n<55),n的含义如题目中描述。
输出描述:
对于每个测试实例,输出在第n年的时候母牛的数量。
每个输出占一行。
生牛的过程,我们以一个图来表示,根据每年牛的数量1,2,3,4,6,9,我们可以得到一个产牛的公式,从第4年开始,也就是从第1头小牛可以生产开始,每年牛的数量为
f
(
n
)
=
f
(
n
−
1
)
+
f
(
n
−
3
)
f(n)=f(n-1)+f(n-3)
f(n)=f(n−1)+f(n−3)
,根据数量来看这个公式肯定是对的,那么为什么对呢?我们来看
f
(
n
)
f(n)
f(n)是今年牛的数量,
f
(
n
−
1
)
f(n-1)
f(n−1)是去年牛的数量,
f
(
n
−
3
)
f(n-3)
f(n−3)是三年前牛的数量,因为牛不会死,去年有多少牛今天一定会有这些,也就是
f
(
n
−
1
)
f(n-1)
f(n−1),但是每年都会有新生的牛,那么今年新生的牛有哪些呢?这要看哪些牛是有生产能力的,每一头小牛到第四年的时候才有生产能力,那么如果要找今年能生产的牛有多少,要去找三年前有多少牛,这些牛都是有生产能力的,也就是今年新生牛的数量,所以每年牛的数量为
f
(
n
)
=
f
(
n
−
1
)
+
f
(
n
−
3
)
f(n)=f(n-1)+f(n-3)
f(n)=f(n−1)+f(n−3)。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/ba070d36dab15d6cffa6cedc8b3ae064.png)
/**母牛的故事**/
int cowNum(int n)
{
if(n == 1)
return 1;
else if(n == 2)
return 2;
else if(n == 3)
return 3;
else{
return cowNum(n-1)+cowNum(n-3);
}
}
2.动态规划
2.1 最小路径和
Given a
m
×
n
m \times n
m×n grid filled with non-negative numbers, find a path from top left to bottom right which minimizes the sum of all numbers along its path.
Note: You can only move either down or right at any point in time.
Example:
Input:
[ [1,3,1],
[1,5,1],
[4,2,1]]
Output: 7
Explanation: Because the path 1→3→1→1→1 minimizes the sum.
方案1:递归实现,首先递归实现有个问题,如果到达矩阵右下角的元素,直接返回右下角元素即可,如果达到最后一行,但是没有到最后一列,那么最小的路径只能向右走,也就是我现在的位置加上右边位置到达最右下角的最短路径,即processMinPath(arr,i,j+1);同理,如果到达最后一列,但是没有到达最后一行,只能向下走,即processMinPath(arr,i+1,j);如果元素位于中间位置,就需要知道向右走到达右下角的最短路径更短,还是向下走到达右下角的最短路径更短,毫无疑问这样是可以得到最后答案7的,但是也存在很大的问题,我们来分析一下。
![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/a100974975fabb69d5252685d405047b.png)
对于这样一个矩阵,我们假设递归函数为
f
(
0
,
0
)
f(0,0)
f(0,0),即从左上角开始递归得到整个矩阵的从左上角到右下角的最短路径,那么
f
(
0
,
0
)
f(0,0)
f(0,0)状态需要状态
f
(
0
,
1
)
f(0,1)
f(0,1)和
f
(
1
,
0
)
f(1,0)
f(1,0),这两个状态也是不知道的,要走到右下角才能得到他们的值,
f
(
0
,
1
)
f(0,1)
f(0,1)又依赖
f
(
0
,
2
)
f(0,2)
f(0,2)和
f
(
1
,
1
)
f(1,1)
f(1,1),
f
(
1
,
0
)
f(1,0)
f(1,0)又依赖
f
(
1
,
1
)
f(1,1)
f(1,1)和
f
(
2
,
0
)
f(2,0)
f(2,0),这时我们发现状态
f
(
1
,
1
)
f(1,1)
f(1,1)是计算过的,如果给定一个非常大的矩阵,这样的计算是非常费时间的,因为计算
f
(
1
,
1
)
f(1,1)
f(1,1)是两路的,两个并不知道已经计算过了,所以暴力递归带来很多重复计算。
方法2:
如果参数固定,返回值就固定,是无后效性问题,这样的问题一定是可以改成动态规划的。在这个题目中如果
i
,
j
i,j
i,j确定了,最后的结果一定是固定的,所以一定改成动态规划。我们知道我们要改这个递归问题是因为重复计算,那么如果我们所有需要知道的结果建立一个缓存,就可以以空间换时间,提高效率了。
/**最短路径**/
int processMinPath(vector<vector<int>> arr,int i,int j)
{
int rows = arr.size();
int cols = arr[0].size();
int res = arr[i][j];
if(i == rows-1 && j == cols-1)
{
return res;
}
if(i == rows-1 && j != cols-1)
{
return res + processMinPath(arr,i,j+1);
}
if(i != rows-1 && j == cols-1)
{
return res + processMinPath(arr,i+1,j);
}
return res + min(processMinPath(arr,i,j+1),processMinPath(arr,i+1,j));
}
int minPath(vector<vector<int>> arr)
{
return processMinPath(arr,0,0);
}
//根据递归实现思路修改动态规划的实现比较简单,逆着推回去就可以了。
int minPathDym(vector<vector<int>> arr)
{
int rows = arr.size();
int cols = arr[0].size();
vector<vector<int>> result(rows);
//初始化二维数组
for(int i=0;i<rows;i++)
{
result[i].resize(cols);
}
result[rows-1][cols-1] = arr[rows-1][cols-1];
//填充只依赖下面位置最短路径的最后一列
for(int i = rows-2;i >= 0;i--)
{
result[i][cols-1] = result[i+1][cols-1] + arr[i][cols-1];
}
//填充只依赖右面位置最短路径的最后一行
for(int j = cols-2; j >= 0; j--)
{
result[rows-1][j] = result[rows-1][j+1] + arr[rows-1][j];
}
//填充中间位置的最短路径
for(int i = rows-2;i >= 0;i--)
{
for(int j=cols-2;j >= 0;j--)
{
result[i][j] = arr[i][j]+min(result[i][j+1],result[i+1][j]);
}
}
return result[0][0];
}
2.2 给你一个数组arr,和一个整数aim,如果可以任意选择arr中的数字,能不能累加得到aim,返回true或者false
这个题目如果用递归实现就跟前面打印字符串的子字符串一样,对于每一个数组中的值都可以有或者没有,这样就会形成 2 n 2^n 2n个结果,只有有一个满足结果的就会返回true,如果到最后没有加到要实现的和,就为false。
/***给你一个数组arr,和一个整数aim,如果可以任意选择arr中的数字,
能不能累加得到aim,返回true或者false**/
bool recurProcess(vector<int> arr,int i,int sum,int aim)
{
if(sum == aim)
return true;
if(i == arr.size())
return false;
return recurProcess(arr,i+1,sum,aim) || recurProcess(arr,i+1,sum + arr[i],aim);
}
bool isSum(vector<int> arr,int aim)
{
return recurProcess(arr,0,0,aim);
}
那么这个题如果采用动态规划的解法怎么实现呢,需要记录哪些中间值?在左神的视频中谈到,有递归实现动态规划的几个点如下:
- 首先写出尝试版本,也就是暴力递归实现的版本,上面已经实现
- 列出可变参数范围,在最小路径和中可变参数就是 i , j i,j i,j,所以空间表为二维,这里变换参数为 i , s u m i,sum i,sum,也是两个值,所以空间记录表也是两维的。范围,i就是arr的长度,sum最大范围就是所有数字加起来的和
- 标记终止位置,也就是最后要得到的哪个位置的值
- 根据base case整理出空间表的哪些位置可以提前填好
- 最后普遍位置的值依赖哪些位置,将普通位置的结果填到空间表中
bool isSumDym(vector<int> arr,int aim)
{
int length = arr.size();
vector<vector<bool>> res(length+1);
for(int i =0 ;i<= length;i++)
{
res[i].resize(aim+1);
}
for (int i = 0; i < res.size(); i++)
{
res[i][aim] = true;
}
for (int i = arr.size() - 1; i >= 0; i--)
{
for (int j = aim - 1; j >= 0; j--)
{
res[i][j] = res[i + 1][j];
if (j + arr[i] <= aim) {
res[i][j] = res[i][j] || res[i + 1][j + arr[i]];
}
}
}
return res[0][0];
}