分治、动态规划、贪心、回溯、分支界定

723 阅读6分钟

事出有因,写这篇文章主要是因为上周的软考一程序大题填空。另外判断该程序属于哪一类问题,我填的贪心,但是答案是动态规划。 题目百度搜索下:动态规划-凸多边形的最优三角形划分,极难。 blog.csdn.net/qq_37706228… 虽然之前多少有了解这几种算法,但是再讲得清楚明白还有之间的区别时就难了,所以要给写下来。

分治

分而治之,把一个复杂的问题分成多个相同或相似的子问题,再把子问题分成更小的子问题递归或者循环求解。这个思想是很多高效算法的基础,例如排序算法(快速排序,归并排序),傅里叶变换(快速傅里叶变换)等。

基本步骤

  • 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题
  • 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
  • 合并:将各个子问题的解合并为原问题的解

复杂度分析

假使,最初的问题规模是n,这些小的子问题的个数为a,子问题的规模是n/b,分解或者合并的复杂度表示为f(n),那么总的时间复杂度就可以表示为:T(n)=aT(n/b)+f(n)

经典问题

二分搜索、合并排序、快速排序、大整数乘法、Strassen矩阵乘法、棋盘覆盖、线性时间选择、最接近点对问题、循环赛日程表、汉诺塔

二分查找

这个例子就是在有序数组中查找某个元素是否存在,存在就输出位置,因此,我们可以把这个大问题对半切开看作是两个问题,求每个小问题中再次对半搜索,直到一半的位置处的值与给定值相等。

public class BinarySearch {
	private int[] data;
    
	public BinarySearch(int[] data) {
		this.data = data;
	}
	public int search(int target,int min,int max) {
		if(min > max)
			return -1;
		int n = (min + max)/2;
		if(target > data[n])
			min = n + 1;
		if(target < data[n])
			max = n -1;
		if(target == data[n])
			return n;
		else
			return search(target,min,max);
	}
	public static void main(String[] args) {
		int[] ints = {1,2,7,9,25,44,66,99};
		BinarySearch bs = new BinarySearch(ints);
		System.out.println(bs.search(50,0,ints.length-1));
		System.out.println(bs.search(44,0,ints.length-1));
	}
}

动态规划

首先看个简单的算法,求解斐波拉契数列,使用递归十分的简单。

//输入6 输出8(1+1+2+3+5+8)
public int fib(int n) {
    if(n<=0)
        return 0;
    if(n==1)
        return 1;
    return fib(n-1)+fib(n-2);
}

上面的递归树中的每一个子节点都会执行一次,很多重复的节点被执行,fib(2)被重复执行了5次。

由于调用每一个函数的时候都要保留上下文,所以空间上开销也不小。这么多的子节点被重复执行,如果在执行的时候把执行过的子节点保存起来,后面要用到的时候直接查表调用的话可以节约大量的时间。下面就看看动态规划的两种方法怎样来解决斐波拉契数列Fibonacci 数列问题。

两种形式

自顶向下的备忘录法

public static int Fibonacci(int n) {
    if(n<=0)
        return n;
    int []Memo=new int[n+1];        
    for(int i=0;i<=n;i++)
        Memo[i]=-1;
    return fib(n, Memo);
}
public static int fib(int n,int []Memo) {
    if(Memo[n]!=-1)
        return Memo[n];
    //如果已经求出了fib(n)的值直接返回,否则将求出的值保存在Memo备忘录中。               
    if(n<=2)
        Memo[n]=1;
    else
        Memo[n]=fib( n-1,Memo)+fib(n-2,Memo);  
    return Memo[n];
}

备忘录法也是比较好理解的,创建了一个n+1大小的数组来保存求出的斐波拉契数列中的每一个值,在递归的时候如果发现前面fib(n)的值计算出来了就不再计算,如果未计算出来,则计算出来后保存在Memo数组中,下次在调用fib(n)的时候就不会重新递归了。也就是记住已经解决过的子问题的解。

自底向上的动态规划

备忘录法还是利用了递归,上面算法不管怎样,计算fib(6)的时候最后还是要计算出fib(1),fib(2),fib(3)……,那么何不先计算出fib(1),fib(2),fib(3)……,呢?这也就是动态规划的核心,先计算子问题,再由子问题计算父问题。

public static int fib(int n) {
    if(n<=0)
        return n;
    int []Memo=new int[n+1];
    Memo[0]=0;
    Memo[1]=1;
    for(int i=2;i<=n;i++) {
        Memo[i]=Memo[i-1]+Memo[i-2];
    }       
    return Memo[n];
}

算法原理

最优子结构

用动态规划求解最优化问题的第一步就是刻画最优解的结构,如果一个问题的解结构包含其子问题的最优解,就称此问题具有最优子结构性质。因此,某个问题是否适合应用动态规划算法,它是否具有最优子结构性质是一个很好的线索。使用动态规划算法时,用子问题的最优解来构造原问题的最优解。因此必须考查最优解中用到的所有子问题。

重叠子问题

在斐波拉契数列结构图中,可以看到大量的重叠子问题,比如说在求fib(6)的时候,fib(2)被调用了5次,在求cut(4)的时候cut(0)被调用了4次。如果使用递归算法的时候会反复的求解相同的子问题,不停的调用函数,而不是生成新的子问题。如果递归算法反复求解相同的子问题,就称为具有重叠子问题(overlapping subproblems)性质。在动态规划算法中使用数组来保存子问题的解,这样子问题多次求解的时候可以直接查表不用调用函数递归。

经典问题

凸多边形的最优三角形划分、爬楼梯、最小路径和、买卖股票的最佳时机、最长回文子串、最长上升子序列、最长公共子序列...

再举栗(看完贪心再回来对比)

public static void main(String[] args) {
	int[] coins = {1,5,11};
	int money = 15;
	changeMoney(coins,money);
}
/**
 * 硬币找零算法,备忘录方法
 * @param coins 硬币面额数组
 * @param money 目标金额
 */
public static void changeMoney( int[] coins , int money ) {
	//备忘录数组,一一对应
	int[] memo = new int[money + 1];
	//0元对应的最小硬币数是0
	memo[0] = 0;
	System.out.println( "钱\t" + "对应的最小硬币数" );
	//遍历备忘录数组,为每个金额记录他的最小硬币数,我们从1元开始
	for( int currentMoney = 1 ; currentMoney <= money ; currentMoney++ ) {
		//默认最小硬币数就是当前金额的本身
		//举例:15元最多就是15个1元的硬币呗
		int minCoins = currentMoney;
		//遍历硬币面额数组,找到前边所能找到的最小硬币数加1
		for( int coinKind = 0 ; coinKind < coins.length ; coinKind++ ) {
			//只有当前金额大于等于某个硬币面额的时候才可以用这种方法
			//举例:5-1=4,5-5=0,5-11=-6,我们没有-6元好吧……
			if( currentMoney >= coins[coinKind] ) {
				//我们在备忘录中查找之前的金额对应的最小硬币数
				//如果能查到就在它的基础上加1
				int tempCoins = memo[currentMoney - coins[coinKind]] + 1;
				//找到几种情况中最小的硬币数
				//举例:15元 tempConis
				//可以是memo[15-1]+1、memo[15-5]+1、memo[15-10]+1
				//我们要找到最小的
				if( tempCoins < minCoins ) {
					minCoins = tempCoins;
				}
			}
		}
		//找到当前金额对应的最小硬币数,我们将它记录在备忘录中
		memo[currentMoney] = minCoins;
		if (currentMoney == money) {
			System.out.println( currentMoney + "\t" + memo[currentMoney] );
		}
	}
}
钱	对应的最小硬币数
15	3

贪心

接着我们再来个例子,如果一个国家的币种有11、5、1块钱面值,求组合出15块最少所需张数。 这时候我们可以从大的开始,11块1张,然后就只能再来4张1块,共5张,这就是贪心,只着眼现实当下。如果是动态规划,则会例举11块1张,1块4张或者5块3张,或者1块15张,显然最优解为3张5块就行了(回上面看代码)。

贪心算法是指在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,它所做出的仅仅是在某种意义上的局部最优解。不能保证求得的最后解是最佳的。

贪心算法没有固定的算法框架,算法设计的关键是贪心策略的选择。必须注意的是,贪心算法不是对所有问题都能得到整体最优解,选择的贪心策略必须具备无后效性(即某个状态以后的过程不会影响以前的状态,只与当前状态有关。) 所以,对所采用的贪心策略一定要仔细分析其是否满足无后效性。

基本思路

  • 建立数学模型来描述问题
  • 把求解的问题分成若干个子问题
  • 对每个子问题求解,得到子问题的局部最优解
  • 把子问题的解局部最优解合成原来问题的一个解

经典问题

纸币找零、背包问题、最小生成树的Prim算法、Kruskal算法、霍夫曼编码...

贪心算法还是很常见的算法之一,这是由于它简单易行,构造贪心策略不是很困难。 可惜的是,它需要证明后才能真正运用到题目的算法中。 一般来说,贪心算法的证明围绕着:整个问题的最优解一定由在贪心策略中存在的子问题的最优解得来的

开始提出的纸币找零案例:

public static void greedyGiveMoney(int money) {
	int[] moneyLevel = {1,5,11}; // 把所有钱放到数组中
	for (int i = moneyLevel.length - 1; i >= 0; i--) { // 先从面值最大的钱开始取,依次减小面值
		int num = money/ moneyLevel[i];	  // 纸张数目
		int mod = money % moneyLevel[i];  // 剩余的钱
		money = mod;
		if (num > 0) {  // 如果这个面值的纸张数目不为0
			System.out.println("需要" + num + "张" + moneyLevel[i] + "块的");
		}
	}
}

输出结果:

需要1张11块的
需要4张1块的

然而不是全局最优解。

回溯

基本思路

从一条路往前走,能进则进,不能进则退回来,换一条路再试。 基本做法是深度优先搜索,是一种组织得井井有条的、能避免不必要重复搜索的穷举式搜索算法。

  1. 描述解的形式,定义一个解空间,它包含问题的所有解。
  2. 构造状态空间树。
  3. 构造约束函数(用于杀死节点)。

经典问题

八皇后问题、简单迷宫、括号生成、通配符匹配...

简单迷宫

人人都懂,一般人类解决迷宫问题的思维。

举栗:

ABCE
SFCS
ADEE

给你一个二维网格和一个单词,找出该单词是否存在于网格中,起点随意,可以从经过水平或垂直方向的单元格,同一单元格内字母不能重复使用。

给你一个ABCCED,则:

给你一个SEE,则:

给你一个ABCB,则找不到

从某一个点出发,像走迷宫那样,往上下左右尝试走走看,如果符合条件可以走就走下去,直到找到符合题意的路径;如果遇到了走不通的情况,那么就调头回去继续尝试。如果到最后所有的地方都尝试过了,那么就说明找不到符合题意的路径了。

那么,**算法怎么写?**我觉得理解回溯法最核心的问题在于:

程序究竟是如何回溯的?假设我走迷宫的时候走到了某一点A发现走不下去了,我现在准备使用我的回溯法调头。我该调头到哪?我需要记录我怎么调头吗?

**答:程序的回溯,是靠程序运行时候的递归栈完成的。我该调头到哪,我要不要记录怎么调头,不是我们写代码时候关心的内容。**回溯法里面最重要的回溯,居然是自动完成的。我之前不能完全理解回溯法,就卡壳在这里。

举个例子,经过“ABCD”四个点,在D点的时候发现走不下去,要回溯。我们就要调头回去,因为回溯函数是递归调用的,所以所有的回溯函数全部存放在一个递归栈中,程序自然而然地将运行不下去的D点函数出栈,回溯到了C点。然后递归地进行所有的判断。这样就可以完成整个走迷宫的过程。

|D|       | |
|C|  回溯  |C|
|B|  -->  |B|
|A|       |A|
- -       - -

全局变量

private boolean[][] marked; //记录这个点是不是被访问过了
private int[][] direction = {{-1, 0}, {0, -1}, {0, 1}, {1, 0}};//上下左右四个方向
private int m;// 盘面上有多少行
private int n;// 盘面上有多少列
private String word; //题目给出的word
private char[][] board; //题目给出的矩阵board

为什么回溯法解题需要那么多全局变量呢,自然是因为回溯法的完成是在递归栈里面完成的,不可以把变量当做递归函数的参数进行调用,这样参数可能会在递归运行时发生改变。

// 参数含义:走到x,y点,现在要找的是word里第start个字符
public static boolean backtrace(int x, int y, int start) {
    // 回溯出口,最后字符在x,y点
	if (start == word.length() - 1 && board[x][y] == word.charAt(start)) return true;
	if (board[x][y] == word.charAt(start)) {
        // x,y这个点走了
		marked[x][y] = true;
        // 有上下左右四种走法
		for (int k = 0; k < 4; k++) {
			int newX = x + direction[k][0];
			int newY = y + direction[k][1];
            // x,y没被访问且在矩阵区域,就以新的x,y找start+1个字符
			if ((marked[newX][newY] == false) && inArea(newX, newY)) {
                // 如果上层递归方法给底层方法发来true则返回true
				if (backtrace(newX, newY, start + 1) == true) return true;
			}
		}
        //  以这个点为起点不满足题意,在结束尝试后将mark置为false
		marked[x][y] = false;
	}
    // 以x y为起点无法找到满足题意的路线
	return false;
}
// //判断是否越界
public static boolean inArea(int x, int y) {
	return x >= 0 && x < m && y >= 0 && y < n;
}
public boolean exist(char[][] board, String word) {
	m = board.length;
	n = board[0].length;
	this.word = word;
	this.board = board;
	marked = new boolean[m][n];
    // 把矩阵每个点当做起始点,做回溯
	for (int x = 0; x < m; x++) {
		for (int y = 0; y < n; y++) {
			if (backtrace(x, y, 0)) {
				return true;
			}
		}
	}
	return false;
}

具体题目参见:leetcode-cn.com/problems/wo…

回溯算法(Java):leetcode-cn.com/problems/wo…

分支界定

总结

分而治之就是相似的子问题求解。

贪心算法和动态规划区别:贪心着眼现实当下,动规谨记历史进程。

回溯基本做法是深度优先搜索。

贪心算法总是做出在当前看来是最好的选择。

分治和动态规划都要求原问题具有最优子结构性质。

  • 分治法通常利用递归求解。

  • 动态规划通常利用迭代法自底向上求解,但也能用具有记忆功能的递归法自顶向下求解。

  • 分治法将分解后的子问题看成相互独立的。

  • 动态规划将分解后的子问题理解为相互间有联系,有重叠部分。