五大常用算法之二——动态规划

99封情书 提交于 2019-11-28 08:18:10

1.动态规划算法简介

动态规划(Dynamic Programming) ,将原问题分解为相对简单的子问题来求解,常使用于有覆盖子问题最优子结构性质的问题。

1.1 基本定义

在动态规划中,我们要考察一系列的抉择来确定最优抉择是否包含最优抉择子序列。即要解决一个给定问题,我们需要将其分解成子问题,然后通过对子问题的求解来逐步构造全局的解。

注意!!!
这里的子问题和分治算法中的子问题是不一样的:

  • 分治算法中分解的子问题看成是独立的,通常通过递归来做,例如归并排序;
  • 动态规划分解后的子问题相互间有一定的联系,还有可能有重叠的部分,可迭代、可递归求解。
1.2 问题的特征

通常能通过动态规划求解的问题,有两个明显的特征:

  • 最优子结构:原问题的最优解包含其子问题的最优解;全局的最优解可通过子问题的最优解构造,是能用 动态规划求解问题的必要条件。(例如最短路径问题)
  • 重叠子问题:一个问题被分解成多个子问题,其中有些子问题被重复计算多次。可以通过记忆来优化重复计算。
1.3 求解步骤
  • 定义子问题
    动态规划中我们需要将原问题分解为子问题,所以我们需要定义子问题的问题状态(problem state),来确定当我们做出局部抉择之后,问题的变化情况。即,判断哪些量与子问题有关,需要记录
  • 证明最优原则的实用性
    即一个最优选择序列由最优选择子序列构成,即最优子结构。
  • 建立动态规划的递归方程
    当最优抉择包含最优抉择子序列的时,我们可以建立动态规划递归方程,它表示了原问题与子问题间的联系,也展示了每次做出决策之后的问题变化情况。
  • 求解动态规划的递归方程获得最优值
    动态规划递归方程类似于数学里的数列递归式,我们可以通过迭代或者递归求出原问题的最优值。
  • 沿着最优解的生成过程进行回溯,求出最优解
    有时候,我们通过动态规划递归方程求出的只是原问题的最优值,因此需要回溯找出最优解。

2.动态规划的经典问题

2.1 0-1背包问题
2.1.1 问题描述

n个物品和一个容量c的背包,从n个物品中选取装包的物品。其中,物品的重量为wiw_{i},价值为pip_{i}。一个可行的装载为物品的总重量不超过背包的容量,一个最佳的背包装载是物品总价值最高的可行装载。
即我们的最优化问题是:
0-1背包的问题定义

2.1.2 问题状态的描述

定义f(i,y)f(i,y)表示剩余容量为y,剩余物品为i,i+1,...,ni,i+1,...,n的背包问题最优解的值。即当我们面临将第ii件物品放入背包的子问题的时候,我们应该记录当前物品的状态xix_{i}和背包的剩余容量。

2.1.3 动态规划递归方程

在这里插入图片描述
2.1.4 递归求解
最优值求解

int f(int i, int capacity,int* weight, int* profit,int numOfObejects) {
	if (i == numOfObejects) { // 递归终止条件
		return capacity >= weight[i] ? profit[i] : 0;
	}
	if (capacity < weight[i]) return f(i + 1, capacity, weight, profit, numOfObejects);
	else {
		return f(i + 1, capacity, weight, profit, numOfObejects) >
			f(i + 1, capacity - weight[i], weight, profit, numOfObejects) + profit[i] ?
			f(i + 1, capacity, weight, profit, numOfObejects) :
			f(i + 1, capacity - weight[i], weight, profit, numOfObejects) + profit[i];
	}
}

上述算法只能求出最优值,如需获得最优解要使用回溯。可能有的朋友会想,在每一次选择最大的收益的时候,记下当时所选的解xix_{i}。但是,我个人认为,0-1背包的递归求解类似于构造一棵二叉树的过程,最后搜寻的节点不一定是最优路径上的节点,但是却会影响最优解的赋值。
例如假定n=5,p=[6,3,5,4,6],w=[2,2,6,5,4],c=10n=5,p=[6,3,5,4,6],w=[2,2,6,5,4],c=10,则递归调用的关系如下图树形结构,其中每个节点用剩余容量yy来表示。
递归调用树
最优解回溯

void traceback(int i,int capacity,int* weight, int* profit, int* x, int num,int best) {
	if (i == num) {
		if (capacity >= weight[i]) x[i] = 1;
		else x[i] = 0;
		return;
	}
	if (f(i, capacity, weight, profit, num) == f(i + 1, capacity, weight, profit, num)) {
		x[i] = 0;
		traceback(i + 1, capacity, weight, profit, x, num, best);
	}
	else {
		x[i] = 1;
		traceback(i + 1, capacity-weight[i], weight, profit, x, num, best);
	}
}

注意,这里用的回溯并没有优化,重复计算率极高,因为你算了f(0,c)还要算f(1,c)。

2.1.5 无重复计算的递归求解
从上面的递归调用树来看,虚线框部分是我们重复计算的,因此,为了避免对f(i,y)的重复计算,可以建立一个列表,把计算过的值保留在这个列表中。初始化为-1表示并没有求过值,以后每次调用f()前都看列表中是否存在求过的值。
该列表可以用散列表表示,也可以用二叉搜索树表示。
这里由于权重都是整数,容量也是整数,所以我们用一个二维数组fArray[numOfObjects][c]来存储。

#include <iostream>
using namespace std;

# define NUMOFOBJECTS 5
# define C 10
int fArray[NUMOFOBJECTS][C+1];
int f(int i, int capacity, int* weight, int* profit, int numOfObejects) {
	if (fArray[i][capacity] >= 0) return fArray[i][capacity]; // 已经计算过
	// 未计算过
	if (i == numOfObejects) { // 递归终止条件
		fArray[i][capacity] = (capacity >= weight[i] ? profit[i] : 0);
		return fArray[i][capacity];
	}
	if (capacity < weight[i]) {
		fArray[i][capacity] = f(i + 1, capacity, weight, profit, numOfObejects);
	}
	else {
		fArray[i][capacity] =  (f(i + 1, capacity, weight, profit, numOfObejects) >
			f(i + 1, capacity - weight[i], weight, profit, numOfObejects) + profit[i] ?
			f(i + 1, capacity, weight, profit, numOfObejects) :
			f(i + 1, capacity - weight[i], weight, profit, numOfObejects) + profit[i]);
	}
	return fArray[i][capacity];
}
int main() {
	
	int p[] = { 6,3,5,4,6 };
	int w[] = { 2,2,6,5,4 };
	int i, j;
	for (i = 0; i < NUMOFOBJECTS; i++) {
		for (j = 0; j <= C; j++) fArray[i][j] = -1;
	}
	int result = f(0, C, w, p, NUMOFOBJECTS - 1);
	cout << result << endl;
	system("pause");
	return 0;
}

2.1.6 权为整数的迭代求解
当权为整数的时,我么可以按照动态规划递归方程实现一个非常简单的迭代程序来计算f(0,c)f(0,c),其中每个f(i,y)f(i,y)只需计算一次。
同样的,我们用二维数组f[][]f[][]来保存计算过的值。
递归求最优值

void knapsack(int* profit, int* weight, int numberOfObjects, int capacity, int f[][C + 1]) {
	// 先初始化递归式的基础
	// f[i][y] = profit[i] if y >= weight[i]
	int i, j;
	int yMax = min(weight[numberOfObjects - 1] - 1, capacity);
	for (j = 0; j <= yMax; j++) f[numberOfObjects - 1][j] = 0;
	for (j = weight[numberOfObjects - 1]; j <= capacity; j++) f[numberOfObjects - 1][j] = profit[numberOfObjects-1];
	// 迭代计算f[i][y],其中0≤i<NUMOFOBJECTS - 1
	for (i = numberOfObjects - 2; i >= 1; i--) {
		yMax = min(weight[i] - 1, capacity);
		for (j = 0; j <= yMax; j++) f[i][j] = f[i + 1][j];
		for (j = weight[i]; j <= capacity; j++) {
			f[i][j] = max(f[i+1][j],f[i+1][j-weight[i]]+profit[i]);
		}
	}
	// 计算f[0][C]
	f[0][capacity] = f[1][capacity]; // x[0] = 0;
	if (capacity > weight[0]) {
		f[0][capacity] = max(f[0][capacity], f[1][capacity - weight[0]] + profit[0]);
	}
}

递归求最优解

void traceback(int f[][C + 1], int* x,int* weight,int numofObjects,int capacity) {
	for (int i = 0; i < numofObjects-1; i++) {
		if (f[i][capacity] == f[i + 1][capacity]) x[i] = 0; // 不包括物品i
		else {
			x[i] = 1;
			capacity -= weight[i];
		}
	}
	// 对于numofObjects
	x[numofObjects - 1] = (capacity > weight[numofObjects - 1] ? 1 : 0);
	// x[numofObjects - 1] = (f[numofObjects][capacity] > 0 ? 1 : 0);
}

其中,函数knapasack的复杂度为O(nc)O(nc),函数traceback的复杂度为O(n)O(n)

2.2 最短路径问题
2.2.1 所有顶点对之间的最短路径问题

弗洛伊德算法(Floyd)

问题状态的记录和递归方程:
c(i,j,k)c(i,j,k)表示从顶点ii到顶点jj的一条最短路径的长度,其中间顶点的编号都不大于kk
动态规划方程

2.2.2 带负值的单源最短路径问题

Bellman-Ford算法

参考资料

1.《数据结构、算法与应用 C++描述》 第19章
2.《算法导论》

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!