深入理解动态规划
先看一道北大POJ上的题:
http://poj.org/problem?id=1163
在上面的数字三角形中寻找一条从顶部到底边的路径,使得路径上所经过的数字之和最大。路径上的每一步都只能往左下或右下走。只需要求出这个最大和即可,不必给出具体路径。 三角形的行数大于1小于等于100,数字为 0 - 99。
输入格式:
5 //这一行的数字表示三角形行数,下面几行输入三角形
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
要求输出最大和
一、递归实现
用num( row, col) 来表示第r行第 j 个数字(r,j从1开始算)
用MaxSum(row, col)表示从num(row, col)到底边的各条路径中,最佳路径的数字之和。
因此,此题的问题就变成了求 MaxSum(1,1)
从num(row, col)出发,下一步只能走num(row + 1, col)或者num(row + 1, col + 1)。故对于N行的三角形,我们可以写出如下的递归式子:
if ( N == row)
MaxSum(row, col) = num(row, col)
else
MaxSum(row, col) = Max{ MaxSum(row+1, col), MaxSum(row + 1, col + 1) } + num(row, col)
完整代码:
#include <iostream>
#include <algorithm>
using namespace std;
#define MAX 101
int num[MAX][MAX];
int n;
int MaxSum(int row, int col)
{
if(n == row)
{
return num[row][col];
}
return max(MaxSum(row + 1, col), MaxSum(row + 1, col + 1)) + num[row][col];
}
int main()
{
int row, col;
cin >> n;
for(row = 1; row <= n; row++)
{
for(col = 1; col <= row; col++)
{
cin >> num[row][col];
}
}
cout << MaxSum(1,1) << endl;
}
运行结果:
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
30
这个程序提交上去会超时,因为递归会包含大量的重复计算。
具体的计算步骤如下:
MaxSum(1, 1) = max(MaxSum(2, 1) + MaxSum(2, 2)) + num(1, 1)
MaxSum(2, 1) = max(MaxSum(3, 1) + MaxSum(3, 2)) + num(2, 1)
MaxSum(3, 1) = max(MaxSum(4, 1) + MaxSum(4, 2)) + num(3, 1)
MaxSum(4, 1) = max(MaxSum(5, 1) + MaxSum(5, 2)) + num(4, 1)
MaxSum(5, 1)直接返回4
MaxSum(5, 2)直接返回5
MaxSum(4, 2) = max(MaxSum(5, 2) + MaxSum(5, 3)) + num(4, 2)
MaxSum(5, 2)直接返回5
MaxSum(5, 3)直接返回2
MaxSum(3, 2) = max(MaxSum(4, 2) + MaxSum(4, 3)) + num(3, 2)
MaxSum(4, 2) = max(MaxSum(5, 2) + MaxSum(5, 3)) + num(4, 2)
MaxSum(5, 2)直接返回5
MaxSum(5, 3)直接返回2
MaxSum(4, 3) = max(MaxSum(5, 3) + MaxSum(5, 4)) + num(4, 3)
MaxSum(5, 3)直接返回2
MaxSum(5, 4)直接返回6
MaxSum(2, 2) = max(MaxSum(3, 2) + MaxSum(3, 3)) + num(2, 2)
MaxSum(3, 2) = max(MaxSum(4, 2) + MaxSum(4, 3)) + num(3, 2)
MaxSum(4, 2) = max(MaxSum(5, 2) + MaxSum(5, 3)) + num(4, 2)
MaxSum(5, 2)直接返回5
MaxSum(5, 3)直接返回2
MaxSum(4, 3) = max(MaxSum(5, 3) + MaxSum(5, 4)) + num(4, 3)
MaxSum(5, 3)直接返回2
MaxSum(5, 4)直接返回6
MaxSum(3, 3) = max(MaxSum(4, 3) + MaxSum(4, 4)) + num(3, 3)
MaxSum(4, 3) = max(MaxSum(5, 3) + MaxSum(5, 4)) + num(4, 3)
MaxSum(5, 3)直接返回2
MaxSum(5, 4)直接返回6
MaxSum(4, 4) = max(MaxSum(5, 4) + MaxSum(5, 5)) + num(4, 4)
MaxSum(5, 4)直接返回6
MaxSum(5, 5)直接返回5
可以统计出,每个数被计算的次数如下图所示:
二、动态规划
(一)记忆递归
每算出一个MaxSum(row, col)就保存起来,下次用到其值的时候直接取用,则可免去重复计算。那么可以用n方的时间复杂度完成计算,因为三角形的数字总数是 n(n+1)/2
#include <iostream>
#include <algorithm>
using namespace std;
#define MAX 101
int num[MAX][MAX];
int n;
int maxSum[MAX][MAX];
int MaxSum(int row, int col)
{
if(-1 != maxSum[row][col])
{
return maxSum[row][col];
}
if(n == row)
{
maxSum[row][col] = num[row][col];
}
return max(MaxSum(row + 1, col), MaxSum(row + 1, col + 1)) + num[row][col];
}
int main()
{
int row, col;
cin >> n;
for(row = 1; row <= n; row++)
{
for(col = 1; col <= row; col++)
{
cin >> num[row][col];
maxSum[row][col] = -1;
}
}
cout << MaxSum(1,1) << endl;
}
(二)优化方案一(递推)
递归会造成大量的重复计算,即使是把结果存储起来,仍旧要重复去取值。
可以考虑用递推的方法,从最后一行开始计算。
(1)把最后一行直接写出
* | * | * | * | * |
---|---|---|---|---|
* | * | * | * | * |
* | * | * | * | * |
* | * | * | * | * |
4 | 5 | 2 | 6 | 5 |
(2)倒数第2行
maxSum[4][1] = max(2 + 4, 2 + 5) = 7
maxSum[4][2] = max(7 + 5, 7 + 2) = 12
maxSum[4][3] = max(4 + 2, 4 + 6) = 10
maxSum[4][4] = max(4 + 6, 4 + 5) = 10
* | * | * | * | * |
---|---|---|---|---|
* | * | * | * | * |
* | * | * | * | * |
7 | 12 | 10 | 10 | * |
4 | 5 | 2 | 6 | 5 |
(3)倒数第3行
maxSum[3][1] = max(8 + 7, 8 + 12) = 20
maxSum[3][2] = max(1 + 12, 1 + 10) = 13
maxSum[3][3] = max(0 + 10, 0 + 10) = 10
* | * | * | * | * |
---|---|---|---|---|
* | * | * | * | * |
20 | 13 | 10 | * | * |
7 | 12 | 10 | 10 | * |
4 | 5 | 2 | 6 | 5 |
(4)倒数第4行
maxSum[2][1] = max(3 + 20, 3 + 13) = 23
maxSum[2][2] = max(8 + 13, 8 + 10) = 21
* | * | * | * | * |
---|---|---|---|---|
23 | 21 | * | * | * |
20 | 13 | 10 | * | * |
7 | 12 | 10 | 10 | * |
4 | 5 | 2 | 6 | 5 |
(5)倒数第5行
max[1][1] = max(7 + 23, 7 + 21) = 30
30 | * | * | * | * |
---|---|---|---|---|
23 | 21 | * | * | * |
20 | 13 | 10 | * | * |
7 | 12 | 10 | 10 | * |
4 | 5 | 2 | 6 | 5 |
代码实现:
#include <iostream>
#include <algorithm>
using namespace std;
#define MAX 101
int num[MAX][MAX];
int n;
int maxSum[MAX][MAX];
int main()
{
int row, col;
cin >> n;
for(row = 1; row <= n; row++)
{
for(col = 1; col <= row; col++)
{
cin >> num[row][col];
}
}
for(int col = 1; col <= n; col++)
{
maxSum[n][col] = num[n][col];
}
for(int row = n - 1; row >= 1; row--)
{
for( int col = 1; col <= row; col++)
{
maxSum[row][col] = max(maxSum[row + 1][col], maxSum[row + 1][col + 1]) + num[row][col];
}
}
cout << maxSum[1][1] << endl;
}
(三)优化方案二(一维数组)
int maxSum[101][101]总共占用了101 * 101 * 4 = 40804B = 40kB的内存空间。如果只用一维数组来存储结果的话,int maxSum[101] 总共只需占用101 * 4 = 404B的内存空间。
方法是人底层一行行地向上递推。
(1)最后一行原始数据
4 | 5 | 2 | 6 | 5 |
---|
(2)第四行第一列的最大值放在maxSum[1]中
7 | 5 | 2 | 6 | 5 |
---|
(3)第四行第二列的最大值放在maxSum[2]中
7 | 12 | 2 | 6 | 5 |
---|
(4)第四行第三列的最大值放在maxSum[3]中
7 | 12 | 10 | 6 | 5 |
---|
(5)第四行第四列的最大值放在maxSum[4]中
7 | 12 | 10 | 10 | 5 |
---|
(6)第三行第一列的最大值放在maxSum[1]中
20 | 12 | 10 | 10 | 5 |
---|
(7)第三行第二列的最大值放在maxSum[2]中
20 | 13 | 10 | 10 | 5 |
---|
(8)第三行第三列的最大值放在maxSum[3]中
20 | 13 | 10 | 10 | 5 |
---|
(9)第二行第一列的最大值放在maxSum[1]中
23 | 13 | 10 | 10 | 5 |
---|
(10)第二行第一列的最大值放在maxSum[2]中
23 | 21 | 10 | 10 | 5 |
---|
(11)第一行第一列的最大值放在maxSum[2]中
30 | 21 | 10 | 10 | 5 |
---|
代码实现:
#include <iostream>
#include <algorithm>
using namespace std;
#define MAX 101
int num[MAX][MAX];
int n;
int maxSum[MAX];
int main()
{
int row, col;
cin >> n;
for(row = 1; row <= n; row++)
{
for(col = 1; col <= row; col++)
{
cin >> num[row][col];
}
}
for(int col = 1; col <= n; col++)
{
maxSum[col] = num[n][col];
}
for(int row = n - 1; row >= 1; row--)
{
for(int col = 1; col <= row; col++)
{
maxSum[col] = max(maxSum[col], maxSum[col + 1]) + num[row][col];
}
}
cout << maxSum[1] << endl;
}
(四)优化方案三(不用额外数组)
上一步中的maxSum[]一维数组也可以不要,直接把每一行的最大值存到num[][]中的最后一行即可。
实现代码:
#include <iostream>
#include <algorithm>
using namespace std;
#define MAX 101
int num[MAX][MAX];
int n;
int *maxSum;
int main()
{
int row, col;
cin >> n;
for(row = 1; row <= n; row++)
{
for(col = 1; col <= row; col++)
{
cin >> num[row][col];
}
}
maxSum = num[n];
for(int row = n - 1; row >= 1; row--)
{
for(int col = 1; col <= row; col++)
{
maxSum[col] = max(maxSum[col], maxSum[col + 1]) + num[row][col];
}
}
cout << maxSum[1] << endl;
}
注意,优化方案二和优化方案三,都只是节省空间,不能节省时间。
向Java数组添加一个元素
程序:
public class ListInsert {
public static long[] insert(long[] arr, int atIndex, long num){
//新建数组,对原数组扩容
long[] newArr = new long[arr.length + 1];
int i = 0;
for(; i < atIndex; i++){
newArr[i] = arr[i];
}
//插入数据
newArr[i] = num;
//将大于index的数据向后移动一位
for(int j = i; j < arr.length; j++){
newArr[++i] = arr[j];
}
return newArr;
}
//测试
public static void main(String[] args){
long[] arr = {1,2,3,4,5};
long[] arr1 = insert(arr, 2, 100);
for (long l : arr1) {
System.out.print(l + " ");
}
}
}
运行结果:
1 2 100 3 4 5
求组合数
关于组合的介绍,可以参考小朋友学奥数(12):组合
一、利用基本公式,递归
#include <iostream>
using namespace std;
typedef long long ll;
ll combination(ll n, ll k)
{
if(0 == k)
{
return 1;
}
return combination(n, k - 1) * (n - k + 1)/k;
}
int main()
{
cout << combination(10, 3) << endl;
return 0;
}
运行结果:
120
分析:
C(10, 3)
= C(10, 2) * 8 / 3
= C(10, 1) * 9 * 8 / (3 * 2)
= C(10, 0) * 10 * 9 * 8 / (3 * 2 * 1)
= 1 * 10 * 9 * 8 / (3 * 2 * 1)
= 120
二、利用基本性质,递归
利用公式C(n, k) = C(n - 1, k) + C(n - 1, k - 1)
#include <iostream>
using namespace std;
typedef long long ll;
ll combination(ll n, ll k)
{
if(0 == k || n == k)
{
return 1;
}
if(1 == k)
{
return n;
}
return combination(n - 1, k) + combination(n - 1, k - 1);
}
int main()
{
cout << combination(10, 3)<< endl;
return 0;
}
三、逆元+快速幂解法
(一)基本概念
上面两种方法都使用了递归方法,递归方法有个缺陷,就是在数据较大时效率较低。所以这里要介绍一个种新的求组合算法。在了解此算法之前,要先了解一些概念。
1 同余
同余是数论中的重要概念。
给定一个正整数m,如果两个整数a和b满足a-b能够被m整除,即(a-b)/m得到一个整数,那么就称整数a与b对模m同余,记作a≡b(mod m)
例1:4 ≡ 9 (mod 5),即4和9对模5同余
例2:13 ≡ 23(mod 10),即13和23对模10同余
2 模的加减乘除运算
取模运算的等价变形适合加法、减法、乘法
(a + b) % p = (a % p + b % p) % p
(a - b) % p = (a % p - b % p) % p
( a * b) % p = (a % p * b % p) % p
例3:(30 + 40) % 11 = 70 % 11 = 4
(30% 11 + 40%11) % 11 = (8 + 7) % 11 = 15 % 11 = 4
例4:(80 - 20) % 7 = 60 % 7 = 4
(80 % 7 - 20 % 7) % 7 = (3 - 6) % 7 = -3 % 7 = 4 (取模是让商尽可能小,所以这里有 -3 / 7 = -1 …… 4)
例5:(18 * 20) % 7 = 360 % 7 = 3
(18%7 * 20%7)% 7 = (4 * 6)% 7 = 3
但是,取模运算的等价变形不符合除法
a/b % p ≠ (a%p / b%p) % p
例6:(100 % 20)% 11 = 5 % 11 = 5
(100%11 / 20%11) % 11 = (1 / 9) % 11 = 0 % 11 = 0
3 逆元
逆元:对于a和p,若gcd(a, p) = 1(a和p互素)且 a*b%p≡1,则称b为a%p的逆元。
那这个逆元有什么用呢?试想一下求(a / b)%p,如果你知道b%p的逆元是c,那么就可以转变成(a/b)%p = (a/b) * 1 % p = (a / b) * (b* c % p) % p = a*c % p = (a%p) (c%p) % p,这样的话,除法运算就可以转化为乘法运算。
那怎么求逆元呢?这时候就要引入强大的费马小定理!
费马小定理:对于a和素数p,满足a^(p-1) % p ≡ 1
接着因为a^(p−1) = a^(p−2) * a,所以有a^(p−2) * a % p ≡ 1。
对比逆元的定义可得,a^(p−2)就是a的逆元。
所以问题就转换成求解a^(p−2),即变成求快速幂的问题了。
4 快速幂
这部分的内容可以参考 小朋友学算法(6):求幂pow函数的四种实现方式 中的第四种方法
(二)逆元 + 快速幂求组合思路
现在目标是求C(n, m) %p,p为素数(经典p=1e9+7)。
虽然有C(n, m) = n! / [m! (n - m)!],但由于取模的性质对于除法不适用,则有
所以需要利用逆元把“除法”转换成“乘法”,才能借助取模的性质计算组合数。
求解C(n, m)%p的步骤:
(1)通过循环,预先算好所有小于max_number的阶乘(%p)的结果,存到fac[max_number]里 (fac[i] = i! % p)
(2)求m! % p的逆元(即求fac[m]的逆元):根据费马小定理,x%p的逆元为x^(p−2), 因此通过快速幂,求解fac[m]^(p−2) % p,记为M
(3)求(n-m)! % p的逆元:同理就是求解fac[n−m]^(p−2) % p,记为NM
(4)C(n, m) % p = ((fac[n] * M) % p * NM) % p
(三) 算法代码
#include <cstdio>
using namespace std;
#define MAX_NUMBER 100000
//快速幂求x^n%mod
long long quick_pow(long long x, long long n, long long mod)
{
long long res = 1;
while (n)
{
if (n & 1)
{
res = res * x % mod;
}
x = x * x % mod;
n >>= 1;
}
return res;
}
long long fac[MAX_NUMBER+5];
long long n, m, p;
int main()
{
while (~scanf("%lld %lld %lld", &n, &m, &p))
{
//预处理求fac,fac[i] = i!%p
fac[0] = 1;
for (int i = 1; i <= n; i++)
{
fac[i] = fac[i - 1] * i % p;
}
//组合数 = n!*(m!%p的逆元)*((n-m)!%p的逆元)%p
printf("%lld
", fac[n] * quick_pow(fac[m], p - 2, p) % p * quick_pow(fac[n - m], p - 2, p) % p);
}
}
运行结果:
10000 5000 100000007
93446621
分割字符串
一、准备知识
在分割字符串之前,先来了解一些跟字符串相关的变量或函数:
(1)size_type:size_type由string类类型和vector类类型定义的类型,用以保存任意string对象或vector对象的长度,标准库类型将size_type定义为unsigned类型。
(2)string的find("x")函数,若找到则返回x的位置,没找到则返回npos。npos代表no postion,表示没找到,其值为-1
#include <iostream>
using namespace std;
int main()
{
string s = "abcdefg";
int x = s.find("abc");
int y = s.find("ddd");
cout << x << endl;
cout << y << endl;
return 0;
}
运行结果:
0
-1
(3)substr(x, y)
basic_string substr(size_type _Off = 0,size_type _Count = npos) const;
参数
_Off:所需的子字符串的起始位置。默认值为0.
_Count:复制的字符数目。如果没有指定长度_Count或_Count+_Off超出了源字符串的长度,则子字符串将延续到源字符串的结尾。
返回值
一个子字符串,从其指定的位置开始
(4)C++字符串与C语言字符串之间的互相转化
C++中有string类型,C语言中没有string类型。若要把C++中的string类型转化为C语言中的string类型,必须用c_str()函数。
#include <iostream>
using namespace std;
int main()
{
string s = "hello";
const char *s1 = s.c_str();
cout << s1 << endl;
return 0;
}
反过来,若C语言中的字符串要转化为C++中的string类型,直接赋值即可
#include <iostream>
using namespace std;
int main()
{
const char *a = "Hi";
string s = a;
cout << s << endl;
return 0;
}
(5)atoi
atoi为C语言中的字符串转化为整型的函数。若要转化C++中的字符串,要先用c_str()转化为C语言的字符串,才能使用atoi。
atoi声明于<stdlib.h>或<cstdlib>中。
#include <iostream>
#include <cstdlib>
using namespace std;
int main()
{
string s = "23";
int a = atoi(s.c_str());
cout << a << endl;
return 0;
}
反过来,有个整型转换为字符串的函数叫itoa,这个不是标准函数,有些编译器不支持。
二、split()实现
令人遗憾的是,C++标准库STL中没有提供分割字符串的函数,所以只能自己实现一个。
#include<iostream>
#include<vector>
#include<cstdlib>
using namespace std;
// 这里&是引用,不是取地址符
vector<string> split(const string &s, const string &separator)
{
vector<string> v;
string::size_type beginPos, sepPos;
beginPos = 0;
sepPos = s.find(separator);
while(string::npos != sepPos)
{
v.push_back(s.substr(beginPos, sepPos - beginPos));
beginPos = sepPos + separator.size();
sepPos = s.find(separator, beginPos);
}
if(beginPos != s.length())
{
v.push_back(s.substr(beginPos));
}
return v;
}
int main()
{
string s = "1 22 333";
// 因为参数separator是string类型,所以这里只能用" ",不能用' '
vector<string> vec = split(s, " ");
vector<string>::iterator it;
for(it = vec.begin(); it != vec.end(); it++)
{
// 输出字符串
cout << (*it) << endl;
// 输出整数
cout << atoi((*it).c_str()) << endl;
}
}
运行结果:
1
1
22
22
333
333
两数交换
两个数相等的交换情况。
#include <iostream>
#include <algorithm>
using namespace std;
// 两个参数是指针(指向地址的引用)
void myswap(int *x, int *y)
{
*x ^= *y;
*y ^= *x;
*x ^= *y;
}
// 两个参数是引用,在这里&代表引用,不代表地址
void myswap2(int &x, int &y)
{
x ^= y;
y ^= x;
x ^= y;
}
int main()
{
cout << "**********交换不同的值**********" << endl;
int a = 1, b = 2;
myswap(&a, &b); // 因为myswap的参数是指针,所以必须传地址
cout << a << ' ' << b << endl;
int a2 = 3, b2 = 4;
myswap2(a2, b2);
cout << a2 << ' ' << b2 << endl;
cout << "**********交换相同的值**********" << endl;
int a3 = 5, b3 = 5;
myswap(&a3, &b3); // 因为myswap的参数是指针,所以必须传地址
cout << a3 << ' ' << b3 << endl;
int a4 = 6, b4 = 6;
myswap2(a4, b4);
cout << a4 << ' ' << b4 << endl;
int c[1];
c[0] = 7;
myswap(&c[0], &c[0]);
cout << c[0] << ' ' << c[0] << endl;
int d[1];
d[0] = 8;
myswap2(d[0], d[0]);
cout <<d[0] << ' ' << d[0] << endl;
int e[1];
e[0] = 9;
swap(e[0], e[0]);
cout <<e[0] << ' ' << e[0] << endl;
return 0;
}
运行结果:
**********交换不同的值**********
2 1
4 3
**********交换相同的值**********
5 5
6 6
0 0
0 0
9 9
结果显示,前两组数值不同的两个数,能交换成功。后五组相同的值,交换成功的有3组。不成功的有两组,并且值都变成了0。
这是什么原因呢?
观察最后三对的数,都是数组里的同一元素交换。myswap和myswap2函数是咱们自己定义的,swap函数是由<algorithm>提供的。可见自己写的两个函数,一定存在bug。
仔细观察,发现a3 = 5, b3 = 5,这是两个不同的变量(在内存里的地址不同),而myswap2(d[0], d[0]),这是对同一个地址里的数交换。
d0 = 8时,
a = a ^ b = 8 ^ 8 = 0,因为a和b都指向d0,所以此时b = a = 0。
b = b ^ a = 0 ^ 0 = 0,
a = a ^ b = 0 ^ 0 = 0,
所以最终得到的是0 0,而不是8 8。
对于myswap(&c[0], &c[0]),与myswap2(d[0], d[0])的道理是一样的。
改进:如果值一样,那就不用交换了:
#include <iostream>
#include <algorithm>
using namespace std;
// 两个参数是指针(指向地址的引用)
void myswap(int *x, int *y)
{
if(x != y)
{
*x ^= *y;
*y ^= *x;
*x ^= *y;
}
}
// 两个参数是引用,在这里&代表引用,不代表地址
void myswap2(int &x, int &y)
{
if(x != y)
{
x ^= y;
y ^= x;
x ^= y;
}
}
int main()
{
cout << "**********交换不同的值**********" << endl;
int a = 1, b = 2;
myswap(&a, &b); // 因为myswap的参数是指针,所以必须传地址
cout << a << ' ' << b << endl;
int a2 = 3, b2 = 4;
myswap2(a2, b2);
cout << a2 << ' ' << b2 << endl;
cout << "**********交换相同的值**********" << endl;
int a3 = 5, b3 = 5;
myswap(&a3, &b3); // 因为myswap的参数是指针,所以必须传地址
cout << a3 << ' ' << b3 << endl;
int a4 = 6, b4 = 6;
myswap2(a4, b4);
cout << a4 << ' ' << b4 << endl;
int c[1];
c[0] = 7;
myswap(&c[0], &c[0]);
cout << c[0] << ' ' << c[0] << endl;
int d[1];
d[0] = 8;
myswap2(d[0], d[0]);
cout <<d[0] << ' ' << d[0] << endl;
int e[1];
e[0] = 9;
swap(e[0], e[0]);
cout <<e[0] << ' ' << e[0] << endl;
return 0;
}
运行结果:
**********交换不同的值**********
2 1
4 3
**********交换相同的值**********
5 5
6 6
7 7
8 8
9 9
现在结果对了。在此,咱们可以进一步推断<algorithm>中的swap函数的实现方式:
(1)参数是用引用而不是用指针,因为调用方式 是swap(e[0], e[0])而不是swap(&e[0], &e[0])
(2)swap内部用if作了判断。