动态规划、贪心、分治、回溯思想初探

88 阅读14分钟

[本文主要参考]

超详细!动态规划详解分析(典型例题分析和对比,附源码)_动态规划案例解析-CSDN博客

算法分析与设计-数字三角形问题(动态规划)(通俗易懂,附源码和图解,含时间复杂度分析)(c++)_数字三角形问题 算法-CSDN博客

贪心算法详细讲解(附例题,一看就会)-CSDN博客

快速幂+矩阵优化斐波那契数列(超详细教程)_矩阵快速幂求斐波那契数列-CSDN博客

本文作为笔记只对参考内容进行补充,请结合参考内容食用!

注意下面的斐波拉契数都是从F(0) = 1,F(1) = 1开始!

热身:递归、迭代、DP实现斐波拉契数

#include <stdio.h>

int F_Recur(int);
int F_DP(int);
int F_Iter(int);
int main(){
	printf("递归:%d\n", F_Recur(5));
	printf("DP:%d\n", F_DP(5));
	printf("迭代:%d\n", F_Iter(5));
}
int F_Recur(int i){
	if(i==0 || i==1){
		return 1; 
	}else{
		return F_Recur(i-1) + F_Recur(i-2);
	}
} 
int F_DP(int i){
//	int F[i+1] = {0}; // C语言中使用变量定义数组的长度的同时不能初始化数组 
	int F[i+1]; // DP的一大特点,存储所有子问题的解
	F[0] = 1;
	F[1] = 1;
	int j = 2;
	
	for(j; j<=i; j++){
		F[j] = F[j-1] + F[j-2]; // 状态转移方程 
	}
	return F[i];
}
int F_Iter(int i){
	int pre = 1; // pre最开始指向 F[0]
	if (i == 0) {
        return pre;
    }
	int cur = 1; // cur最开始指向 F[1] 
	
	for(int j=2; j<=i; j++){
		int temp = cur;
		cur = cur + pre;
		pre = temp;
	}
	return cur;
}

数字三角形问题 DP和贪心的思路对比 (贪心思路其实解不了此题!)

数字三角形问题:

如图所示,有一个群岛,共分为若干层,第1层有一个岛屿,第2层有2个岛屿,……,第n层有n个岛屿。
每个岛上都有一块宝,其价值是一个正整数(图中圆圈中的整数)。寻宝者只允许从第一层的岛屿进人,
从第n层的岛屿退出,不能后退,他能收集他所经过的所有岛屿上的宝贝。但是,从第i层的岛屿进入第i1层的岛屿时,有且仅有有2条路径。你的任务是:对于给定的群岛和岛上宝贝的价值,计算一个寻宝者
行走一趟所能收集宝贝的最大价值。

image.png

动态规划的思路

image.png

1.分析最优子结构性质(递推关系) 2.递归定义最优值(动态规划核心) 建立状态转移方程:

image.png

为什么不从上往下推导规律?

因为从上往下根本推导不出来!只有最后一行的最优解是不依赖其他数字的,其它行的最优解都要根据前一行的最优解才能推算出来。以这一题给出的数字为例,当想根据第二行数字1215推算出第一行9的最优解时,我可以毫不犹豫的选择15吗?其实并不行 。第二行15的值更大并不意味着以15出发到最后一行的最优解比12出发到最后一行的最优解大。举个极端的例子,如果12的左下角数字改成100,因为所有数字里基本没有大于20的数,12这把可以说是稳赢了,这时候要是greedily15,那就是大错特错了,后面根本不用看了!这也是为什么贪心算法解出来是错误的

image.png

3.自底向上的方式计算出最优值(动态规划的执行过程) 代码实现(贪心算法也实现了)

#include <stdio.h>

int DP(int[][5]);
int greedy(int[][5]); 
int main(){
	int a[][5] = {{9},{12,15},{10,6,8},{2,18,9,5},{19,7,10,4,16}};
	printf("greedy: %d\n", greedy(a));
	printf("DP: %d", DP(a)); 
	return 0; 
}
int DP(int a[][5]){
	for(int i=3; i>=0; i--){
		for(int j=0; j<i+1; j++){
			a[i][j] = a[i][j] + ( a[i+1][j] > a[i+1][j+1] ? a[i+1][j] : a[i+1][j+1]);
		}
	} 
	return a[0][0];
}
int greedy(int a[][5]){
	int res=a[0][0];
	int x = 0;
	int y = 0;
	while(x<4){
		if(a[x+1][y] > a[x+1][y+1]){
			res+=a[x+1][y];
			x = x+1;
			y = y;
		}else{
			res+=a[x+1][y+1];
			x = x+1;
			y = y+1;
		}
	}
	return res;
}

4.根据计算最优值时得到的信息,构造最优解

这一步在这个问题中体现为根据得到的最大值,反向推导出走过的路径,这里不再实现相关代码,只需要知道,动态规划的思想4个步骤是怎么解决实际问题的即可。

贪心算法

贪心算法(Greedy Algorithm)又叫登山算法,它的根本思想是逐步到达山顶,即逐步获得最优解,是解决最优化问题时的一种简单但是适用范围有限的策略。

贪心算法没有固定的框架,算法设计的关键是贪婪策略的选择。贪心策略要无后向性,也就是说某状态以后的过程不会影响以前的状态,只与当前状态有关。

贪心算法是对某些求解最优解问题的最简单、最迅速的技术。某些问题的最优解可以通过一系列的最优的选择即贪心选择来达到。但局部最优并不总能获得整体最优解,但通常能获得近似最优解

在每一步贪心选择中,只考虑当前对自己最有利的选择,而不去考虑在后面看来这种选择是否合理。

贪心算法的伪代码描述

Greedy(C){//C是问题的输入集合即候选集合
	S={};//初始解集合为空
		while(not solution(S)){//集合S没有构成问题的一个解
				x=select(C);//在候选集合C中做贪心选择
				if feasible(S,x);//判断集合加入x后的解是否可行
					S=S+{x};
					C=C-{s};
		}
		return S;
}

贪心不一定找到最优解,为什么还要用它?

image.png

可绝对贪婪问题(贪婪算法能求出最优解)

组成新数最小问题:
键盘输入一个高精度(数字比较大)的正整数n,去掉其中任意s个数字后剩下的数字按原左右次序将组成
一个新的正整数。对于给定的n和s,通过编程寻找一种方案使得剩下的数字组成的新数最小。
输出应包括所去掉的数字的位置和组成的新的正整数(n不超过240位).

思路: 每次去掉一个数字的时候n都会降一位,这意味着只要保证去掉之后的最高位的数字较小就能保证这个n较小,又因为只可能原来数字的前两位作为去掉之后的最高位,要么去掉现在数字中的第一个要么去掉第二个,那我就去掉前两个之中较大的那个(贪心选择),每一步都这么做,得到的新正整数一定最小。 代码实现略。

贪心和DP与银生(胡扯)

人生路上有很多选择,你是为了眼前虚荣(贪心),牺牲长远利益(局部最优未必全局最优)?还是设定一个长远的目标(DP中不依赖其他子问题的子问题),根据目标计划你当前要怎么做呢(寻找子问题中的递推关系)?纵使知道了目标是什么,做具体的计划也是很费心思的(状态转移方程的确定最关键也最困难)。

分治思想(矩阵快速幂)实现斐波拉契数列(分治和DP的区别)

分治思想和DP思想类似,都是将待求解的问题分解成若干个子问题,先求解子问题,然后在子问题的解中得到原有问题的解。不同在于,前者的子问题相互独立,后者的子问题往往不相互独立。

我们先来看前面DP思想实现斐波拉契数的代码:

#include <stdio.h>

int F_DP(int);
int main(){
	printf("DP:%d\n", F_DP(5));
	return 0;
}

int F_DP(int i){
	int F[i+1]; // 存储所有子问题的解
	F[0] = 1;
	F[1] = 1;
	int j = 2;
	
	for(j; j<=i; j++){
		F[j] = F[j-1] + F[j-2]; // 状态转移方程 
	}
	return F[i];
}

可以看到DP思想实现斐波拉契数,会将目标结果F(5)及前面的子问题解都存储在F[]数组中,每计算新的子问题F[j]时,都会依赖之前计算的F[j-1]、F[j-2]。也就表明,DP思想的子问题的解不是相互独立的当然部分子问题F(0)、F(1)不依赖其他子问题。

下面来看分治思想解决斐波拉契数问题:

分治解法主要依赖矩阵快速幂的计算,有递推关系公式:

[F(n+1)F(n)]=[1110][F(n)F(n1)]=[F(n)+F(n1)F(n)]\begin{bmatrix}F(n+1)\\ F(n)\end{bmatrix}=\begin{bmatrix}1 & 1\\ 1 & 0\end{bmatrix}\begin{bmatrix}F(n)\\ F(n-1)\end{bmatrix}=\begin{bmatrix}F(n)+F(n-1)\\ F(n)\end{bmatrix}

有首项与第n项关系公式: [F(n+1)F(n)]=[1110]n[F(1)F(0)]\begin{bmatrix}F(n+1)\\F(n)\end{bmatrix}=\begin{bmatrix}1&1\\1&0\end{bmatrix}^n\begin{bmatrix}F(1)\\F(0)\end{bmatrix}

为了在实现代码中只定义一个2X22X2矩阵的运算函数,这里给上面公式凑成2X2矩阵,结果只取矩阵的第一列: [F(n+1)0F(n)0]=[1110]n[F(1)0F(0)0]\begin{bmatrix}F(n+1)&0\\F(n)&0\end{bmatrix}=\begin{bmatrix}1&1\\1&0\end{bmatrix}^n\begin{bmatrix}F(1)&0\\F(0)&0\end{bmatrix}

既然我们得到了前两项和第n项的关系,那就没必要去求F(2)、F(3)、...、F(n-1)到底是多少了(当然你也可以将求F(i)看作是子问题,然后求出F(i),然后根据递推关系求出F(n),这也是一种思路,只是这样会大大增加复杂度,请看下图)。而是将子问题转化为求矩阵Fn次幂,而因为幂运算的性质,像F16次方完全可以转化成F的三次自乘F^2^2^2(这也是为什么叫矩阵快速幂),将时间复杂度降到了对数水平。

image.png

详细介绍一下分治解法为什么子问题相互的独立:

分治解法的子问题是求矩阵Fn次幂,而当我们计算n次幂的时候,由于幂运算的性质,并不需要知道n-1次幂是多少,因此,子问题并不是完全相互依赖的。

下面是矩阵快速幂实现斐波拉契数的代码:

#include <stdio.h>

void deepCopy(int[2][2], int[2][2]);
void matrix_mult(int[2][2],int[2][2],int[2][2]);
void matrix_pow(int[2][2],int);
int F(int);
int main(){
	printf("%d",F(9));
	return 0;
}

int F(int n){
	if(n==1 || n==0){
		return 1;
	}
	int Fir[2][2] = {{1,0},{1,0}}; // 记录前两项的矩阵 
	int res[2][2] = {0}; // 用于存结果 
	int temp[2][2] = {0}; // 暂存res的结果 
	
	matrix_pow(res,n);
	deepCopy(res,temp);
	matrix_mult(temp,Fir,res);
	
	return res[1][0]; // 严格按照公式的话第二行一列才是F(n) 
}

void matrix_mult(int a[2][2], int b[2][2], int res[2][2]){
	
	res[0][0] = a[0][0]*b[0][0] + a[0][1]*b[1][0];
	res[0][1] = a[0][0]*b[0][1] + a[0][1]*b[1][1];
	res[1][0] = a[1][0]*b[0][0] + a[1][1]*b[1][0];
	res[1][1] = a[1][0]*b[0][1] + a[1][1]*b[1][1];

}

/**************!!!!!!!!!!!!!***************/ 
/*********这里格外重要 想了好长时间********/ 
void matrix_pow(int res[2][2], int n){
	int F[2][2] = {{1,1},{1,0}}; // 常量 
	int temp[2][2] = {{1,1},{1,0}}; // 单位矩阵 
	
	// 由于前面的处理这里n肯定是>1的 
	int exp = 0; // 记录需要几次自乘 
	int remainder = 0; // 余数 记录需要多乘几次F 
	int num = 2;
	while(num<=n){
		remainder = n - num;
		num*=num;
		exp++;
	}
	
	for(int i=0; i<exp; i++){
		matrix_mult(temp,temp,res);
		deepCopy(res,temp);
	}
	for(int j=0; j<remainder; j++){
		matrix_mult(temp,F,res);
		deepCopy(res,temp);
	}
}

void deepCopy(int src[2][2], int dest[2][2]){
	dest[0][0] = src[0][0];
	dest[0][1] = src[0][1];
	dest[1][0] = src[1][0];
	dest[1][1] = src[1][1];
} 

上面的代码也是计算斐波拉契数最快的代码,时间复杂度为O(logn)

分治思想简单总结

这里不再扩展分治思想的相关例题,只做简单总结,其他科还没复习呢耽误好几天了。。。

1:分治思想常用到递归实现,如二分查找、归并排序等“二分法”算法

2:分治思想的基本步骤

image.png

回溯算法

回溯算法涉及递归,也涉及图的深度优先遍历。下面以经典的8皇后问题了解一下什么是回溯。并拆分出排列、组合的关键代码

image.png

代码实现:

#include <stdio.h>

typedef struct pos{
	int x;
	int y;
}pos;
int check(int, pos[]);
void queen(int, int, pos[]);
int t=0; // 统计解的个数 
int main(){
	pos p[8]; 
	
	queen(0,8,p);
	printf("共有解%d个", t);
	
	return 0; 
}
// 验证和之前的位置有没有冲突 
int check(int i, pos p[]){
	for(int j=0; j<i; j++){
		if((p[j].x == p[i].x)|| (p[j].y == p[i].y) || (p[j].y - p[i].y) == (p[j].x - p[i].x) || (p[j].y - p[i].y) == (p[i].x - p[j].x)){
			return 0;
		}
	}
	return 1;
}
void queen(int i, int n, pos p[]){
	if(i==n){
		for(int j=0; j<n; j++){
			printf("%d %d,", p[j].y, p[j].x);
		}
		printf("\n"); 
		t++;
	}else{
		p[i].y = i;
		for(int j=0; j<n; j++){
			p[i].x = j;
			if(check(i,p)){
				queen(i+1, n, p);
			}
		}
	}
}

什么是回溯?

针对八皇后问题,当 i==n找到该问题的一个解后,被递归调用queen(i+1,n,p)阻塞的for(int j=0; j<n; j++)继续下一次循环,就可以理解为一次回溯。(回溯性建构)这一概念在后面复习树的遍历、图的遍历的时候相信会有更深入的理解。

从结果上看,红框求解出一个解之后,回溯到前三个皇后相同的0 1,1 4,2 6,前提下,寻找第四行为另一位置的解。

image.png

从八皇后问题中提取 排列 算法的关键代码

分析八皇后问题的解决思路,因为每一行都只能有一个皇后,也就是新放置的皇后一定是在之前放置皇后的下一行,每个皇后的y坐标是固定逐一递增的。而不同的只有x坐标。

因此本质就是对0~7进行全排列(当然八皇后问题会排除一些完全不可能的排列,所谓“剪枝”作用),对0~n个数进行全排列的代码可以是:

#include <stdio.h>

void allArrange(int,int,char[],int[]);
int check(int, int[]);
int t=0;
int main(){
	char str[] = "hello";
	int n = sizeof(str)/sizeof(char) - 1; // \0不算
	int index[n]; // 用于存储每种排列的坐标 
	
	allArrange(0,n,str,index);
	printf("共有%d种排列", t);
	
	return 0; 
}
void allArrange(int i, int n, char str[], int index[]){
	if(i==n){
		for(int j=0; j<n; j++){
			printf("%c ", str[index[j]]); 
		}
		printf("\n");
		t++;
	}else{
		for(int j=0; j<n; j++){
			index[i] = j;
			if(check(i,index)){
				allArrange(i+1, n, str, index);
			}
		}
	}
}
int check(int i, int index[]){
	for(int j=0; j<i; j++){
		if(index[j] == index[i]){
			return 0;	
		}
	}
	return 1;
}

Deduce出组合算法的关键代码

其实和8皇后源码看不出什么关系,纯照抄常考专题

/**************!!!!!!!!!!!!!***************/ 
/*********这里格外重要 直接抄的代码********/ 
#include <stdio.h>

void combine(int, int, char[], int[], int);
int check(int, int, int[]);
int t=0;
int main(){
	char str[] = "hello";
	int n = sizeof(str)/sizeof(char) - 1; // \0不算
	int index[n]; // 用于存储每种排列的坐标 
	
	for(int i=1; i<=n; i++){
		combine(n, i, str, index, i);
	}
	printf("共有%d种组合", t);
	
	return 0;
}
// 首先从n个数中选取编号最大的数,然后在剩下的n-1个数里面选取m-1个数,直到从n-(m-1)个数中选取1个数为止。
// n的本质是待选集合最初的右边界(不包含) 
void combine(int n, int r, char str[], int index[], int R){
	if(r == 0){
		for(int i=0; i<R; i++){
			printf("%c ", str[index[i]]);
		}
		printf("\n");
		t++;
	}else{
		// 不断缩小待选集合,从数组的所有(也可以说是调用递归时给定待选集合的所有),到刚好够挑 
		// j是待选集合的右边界(不包含j)
		for(int j=n; j>=r; j--){ 
			// 挑最大的那个(这大的我得着了,乐),剩下的留给combine挑
			index[r-1] = j-1;
			// 待选集合的最右侧元素j-1被挑走了,所以递归的待选集合最初右边界(不包含)也要-1 即j-1
			combine(j-1, r-1, str, index, R); 
		}
		// 为什么这么挑不会有重的呢?
		/*
			因为每次我挑的都是待选集合中最大的那个,
			而每一个待选集合及其衍生出来的待选集合组成的集合组都是不同的,
			因此不会挑重。(我不信我回头看能看懂。。。。) 
		*/ 
	}
}

然而标准全排列的代码并不像8皇后问题里的那样

这里更是纯抄常考专题,还是没背熟

#include <stdio.h>

void allArrange(char[], int, int);
void swap(char*, char*);
int t=0;
int main(){
	
	char str[] = "hello";
	int n = sizeof(str)/sizeof(char) - 1;
	
	allArrange(str,n-1,0);
	printf("共%d种全排列", t);
	
	return 0;
}
void allArrange(char str[], int n, int k){
	if(k==n){
		printf("%s\n", str);
		t++;
	}else{
		for(int i=k; i<=n; i++){
			swap(&str[i], &str[k]);
			allArrange(str, n, k+1);
			swap(str+i, str+k);
		}
	}
}
void swap(char* a, char* b){
	char temp = *a;
	*a = *b;
	*b = temp; 
}