动态规划解题思路

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/seagal890/article/details/89482888

动态规划解题思路

百度一下“动态规划”,是这样解释的:

动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。20世纪50年代初美国数学家R.E.Bellman等人在研究多阶段决策过程(multistep decision process)的优化问题时,提出了著名的最优化原理(principle of optimality),把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划。1957年出版了他的名著《Dynamic Programming》,这是该领域的第一本著作。

从这段描述中,可以看出“多阶段决策过程的优化问题时,提出了最优化原理,把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划”。

既然是多阶段 —— 那么每个阶段的“状态”是什么?

把多阶段过程转化为一系列单阶段问题 —— 如何转化的?

利用各阶段之间的关系 —— 什么关系?

解决这类过程优化问题 —— 是否在每个阶段或者过程存在最优解?

要彻底理解动态规划的解题思路,就必须能够清楚准备的回答这些问题。

从定义解读,从一个简单的例子开始……

网上很多例子是从这样一个简单的问题开始的:跳台阶

有n级台阶,一个人每次上一级或者两级,问有多少种走完n级台阶的方法。

LeetCode 70. Climbing Stairs  https://leetcode.com/problems/climbing-stairs/

解决这个问题的思路:

在这个问题上,我们用f(n)表示走上n级台阶的方法数。那么:

当 n=1 时,f(n) = 1;【一个阶段过程】

当 n=2 时,f(n) =2;  【另一个阶段过程】

就是说当台阶只有一级的时候,方法数是 1 种,台阶有两级的时候,方法数为 2。

那么当我们要走上n级台阶,必然是从 n-1 级台阶迈一步或者是从n-2级台阶迈两步;【阶段过程之间的关系】

所以到达 n 级台阶的方法数必然是:到达 n-1 级台阶的方法数加上到达 n-2 级台阶的方法数之和

即: f(n) = f(n-1)+f(n-2)  【这个是状态转换方程,从来表述状态转换的关系】

算法设计:

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

我们用dp[n]来表示动态规划表,dp[i],i>0,i<=n,表示到达i级台阶的方法数。

在使用动态规划求解问题时,常常会使用dp数组(可以是一维数组,也可以是二维数组)来辅助解决问题。

那么到底什么是dp数组?这个需要慢慢理解。

上面的问题中,我们定义 dp[i] 用来描述当 台阶数为 i 时,上到该台阶的方法数。这个定义非常重要!

当 i=0时,dp[0] 代表什么?在JAVA中,数组的下标从0开始,所以 dp[0] 代表第1个台阶。

当 i=0 时, dp[0] = 1(只有1个台阶,1种方法);

当 i=1时,  dp[1] = 2 )(有2个台阶,可以一个台阶一个台阶上;也可以一次上两个台阶,2种方法)

当 i =2时, dp[2] = dp[1] + dp[0] = 3 (从第2个台阶上到第3个台阶的方法数 + 从第1个台阶上到第3个台阶的方法数) 

找到状态转换方程:dp[i] = dp[i-1] + dp[i-2]

并且上述代码中 for循环中是从 i=2开始的(代表第3个台阶)

既然用 dp[0] 代表第1个台阶,那么第n个台阶就是 dp[n-1],所以程序需要返回 dp[n-1] 的值。

动态规划中重叠子问题的性质

Overlapping Subproblems Property in Dynamic Programming

动态规划解题中有一个核心思想:通过将给定的复杂问题分解成子问题来解决问题,并存储子问题的结果以避免再次计算相同的结果。例如:在求解斐波那契数(Fibonacci Numbers)问题时

/* simple recursive program for Fibonacci numbers */
int fib(int n) 
{ 
if ( n <= 1 ) 
	return n; 
return fib(n-1) + fib(n-2); 
}

当我们计算 fib(5)时的递归树可以表示为下图:

我们可以看到 fib(3) 被调用了2次。

如果我们已经存储了 fib(3) 的值,那么我们可以重用旧的存储值,而不是再次计算它。存储值的方法有以下两种,以便可以重用这些值:

(1)备忘录法(Memoization 自上而下)

(2)制表法(Tabulation 自下而上)

备忘录法(Memoization 自上而下)一个问题的备忘录法程序类似于递归版本,只需稍加修改,在计算解决方案之前,它会先查找一个查找表。我们初始化一个查找数组,所有初始值都为零。每当我们需要子问题的解决方案时,我们首先查看查找表。如果存在预先计算的值,则返回该值,否则,计算该值并将结果放入查找表中,以便日后重用。

/* Java program for Memoized version */
public class Fibonacci 
{ 
final int MAX = 100; 
final int NIL = -1; 

int lookup[] = new int[MAX]; 

/* Function to initialize NIL values in lookup table */
void _initialize() 
{ 
	for (int i = 0; i < MAX; i++) 
		lookup[i] = NIL; 
} 

/* function for nth Fibonacci number */
int fib(int n) 
{ 
	if (lookup[n] == NIL) 
	{ 
	if (n <= 1) 
		lookup[n] = n; 
	else
		lookup[n] = fib(n-1) + fib(n-2); 
	} 
	return lookup[n]; 
} 

public static void main(String[] args) 
{ 
	Fibonacci f = new Fibonacci(); 
	int n = 40; 
	f._initialize(); 
	System.out.println("Fibonacci number is" + " " + f.fib(n)); 
} 

} 

制表法(Tabulation 自下而上):给定问题的制表程序以自下而上的方式构建一个表,并返回表中的最后一个条目。例如,对于相同的斐波那契数,我们首先计算fib(0),然后计算fib(1),然后计算fib(2),然后计算fib(3),依此类推。所以,实际上,我们是自下而上构建子问题的解决方案。

/* Java program for Tabulated version */
public class Fibonacci 
{ 
int fib(int n) 
{ 
	int f[] = new int[n+1]; 
	f[0] = 0; 
	f[1] = 1; 
	for (int i = 2; i <= n; i++) 
		f[i] = f[i-1] + f[i-2]; 
	return f[n]; 
} 

public static void main(String[] args) 
{ 
	Fibonacci f = new Fibonacci(); 
	int n = 9; 
	System.out.println("Fibonacci number is" + " " + f.fib(n)); 
} 

} 

输出结果为:Fibonacci number is 34.

制表法和备忘录法都存储子问题的解决方案。

下面来对比一下执行效率

采用递归方法计算

package com.bean.algorithm.basic;

public class FibonacciNumber {
	
	/* simple recursive program for Fibonacci numbers */
	// Recursion method
	public static int fib(int n) 
	{ 
	if ( n <= 1 ) 
		return n; 
	return fib(n-1) + fib(n-2); 
	}

	public static void main(String[] args) {
		// TODO Auto-generated method stub

		int N=40;
		long startTime=System.currentTimeMillis();
		System.out.println("result ="+fib(N));
		long endTime=System.currentTimeMillis();
		System.out.println("Elapsed time is: "+(endTime-startTime)+" ms");	
	}
}

执行结果为:

result =102334155
Elapsed time is: 605 ms

采用动态规划计算方法

package com.bean.algorithm.basic;

public class FibonacciNumber2 {

	/* Java program for Memoized version */
	final int MAX = 100;
	final int NIL = -1;

	int lookup[] = new int[MAX];

	/* Function to initialize NIL values in lookup table */
	void _initialize() {
		for (int i = 0; i < MAX; i++)
			lookup[i] = NIL;
	}

	/* function for nth Fibonacci number */
	int fib(int n) {
		if (lookup[n] == NIL) {
			if (n <= 1)
				lookup[n] = n;
			else
				lookup[n] = fib(n - 1) + fib(n - 2);
		}
		return lookup[n];
	}

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		FibonacciNumber2 f = new FibonacciNumber2();
		int N = 40;
		long startTime = System.currentTimeMillis();
		f._initialize();
		System.out.println("result =" + f.fib(N));
		long endTime = System.currentTimeMillis();
		System.out.println("Elapsed time is: " + (endTime - startTime) + " ms");

	}

}

执行结果为;

result =102334155
Elapsed time is: 0 ms

当 N=40时,求解斐波那契数(Fibonacci Numbers)问题,计算结果都是:102334155;但是执行时间却截然不同。

递归方法花费的时间远远超过了上述动态规划求解方法。

动态规划中最优子结构的性质

Optimal Substructure

如果利用子问题的最优解可以得到给定问题的最优解,则给定问题具有最优子结构性质。

一个最优化策略具有这样的性质,不论过去状态和决策如何,对前面的决策所形成的状态而言,余下的诸决策必须构成最优策略。简而言之,一个最优化策略的子策略总是最优的。一个问题满足最优化原理又称其具有最优子结构性质。

例如,最短路径问题具有以下最优子结构性质:

如果节点x位于从源节点u到目标节点v的最短路径上,则从u到v的最短路径是从u到x的最短路径和从x到v的最短路径的组合。

标准的全对最短路径算法(如Floyd–Warshall和Bellman–Ford)是动态编程的典型示例。

另一方面,最长路径问题不具有最优子结构性质。这里的“最长路径”是指两个节点之间的最长简单路径(无循环路径)。考虑一下CLRS书中给出的以下未加权图。从Q到T有两条最长路径:Q→R→T和Q→S→T。与最短路径不同,这些最长路径不具有最佳子结构特性。例如,最长路径q→r→t不是q到r的最长路径和r到t的最长路径的组合,因为q到r的最长路径是q→s→t→r,r到t的最长路径是r→q→s→t。

如果问题的一个最优解包含了子问题的最优解,则该问题具有最优子结构。当一个问题具有最优子结构的时候,我们就可能要用到动态规划(贪心策略也是有可能适用的)。

寻找最优子结构时,可以遵循一种共同的模式:

  • 问题的一个解可以是一个选择。
  • 假设对一个给定的问题,已知的是一个可以导致最优解的选择。不必关心如何确定这个选择,假定他是已知的。
  • 在已知这个选择之后,要确定那些子问题会随之发生,以及如何最好的描述所的得到的子问题空间。
  • 利用一种“剪贴”技术,来证明在问题的一个最优解中,使用的子问题的解本身也必须是最优的。

最优子结构在问题域中以两种方式变化:

  • 有多少个子问题被使用在原问题的一个最优解中
  • 再决定一个最优解中使用那些子问题时有多少个选择

使用动态规划解决一个问题的步骤

  1. 识别该问题是否可以使用动态规划来解决
  2. 确定状态;使用最少的参数来决定状态表达式(状态方程)
  3. 找到状态方程(状态转换关系)
  4. 采用备忘录法或者制表法求解问题

第一步:如何将问题分类为动态规划问题?

通常,所有需要最大化或最小化某些数量或计数问题的问题,例如在特定条件下计数安排或某些概率问题,都可以通过使用动态规划来解决。

所有的动态规划问题都满足重叠子问题的性质,大多数经典的动态问题也满足最优子结构的性质。一旦我们在一个给定的问题中观察到这些性质,确保它可以用DP来解决。

第二步:决定状态

DP问题都是关于状态及其转换的。这是最基本的步骤,必须非常小心地完成,因为状态转换取决于您所做的状态定义的选择。那么,让我们看看“状态”这个词是什么意思。

一个状态可以定义为一组参数,这些参数可以唯一地标识某个位置或存在于给定的问题中。这组参数应该尽可能小,以减少状态空间。

例如:背包问题中,通过两个参数指数和重量来定义状态,即dp[指数][重量]。这里dp[index][weight]告诉我们,它可以通过从0到index的范围内选择具有袋重能力的项目来获得最大利润。因此,这里的参数索引和权重可以唯一地识别背包问题的子问题。

因此,第一步是在确定问题是一个dp问题之后,确定问题的状态。

正如我们所知,dp就是用计算结果来表示最终结果。

因此,下一步将是找到以前状态之间的关系,以达到当前状态。

第三步:找到状态方程,建立状态之间的关系

这部分是解决DP问题最困难的部分,需要大量的直觉、观察和实践。

例如:给定三个数{1,3,5},我们需要找到可以得到累加和N的方法总数(数字允许重复使用)

当目标和 N=6 时的情况(有 8 种方法):

1+1+1+1+1+1
1+1+1+3
1+1+3+1
1+3+1+1
3+1+1+1
3+3
1+5
5+1

让我们动态的思考这个问题。首先,我们需要决定问题的状态。我们将使用一个参数n来决定状态,因为它可以唯一地标识任何子问题。所以,我们的状态dp看起来像dp(n)。这里,dp(n) 是指使用1、3、5作为元素形成 n 的安排总数。

现在,我们需要计算dp(n)。怎么计算呢?

因为我们只能用1、3或5来构成一个给定的数字。假设我们知道n=1,2,3,4,5,6的结果;

dp(n = 1), dp (n = 2), dp (n = 3) ……… dp (n = 6)

现在,我们想知道状态dp(n=7)。看看,我们只能使用1、3和5相加。现在,我们可以通过以下三种方式得到7的总和:

1)在所有可能的状态组合中添加1(n=6)

例如:【(1+1+1+1+1+1+1)+1】

[(1+1+1+3)+1]

[(1+1+3+1)+1]

[(1+3+1+1)+1]

[(3+1+1+1)+1]

[(3+3)+1]

[(1+5)+1]

[(5+1)+1]

2)在所有可能的状态组合中添加3(n=4);

例如:【1+1+1+1+3】

[(1+3)+3]

[(3+1)+3]

3)在所有可能的状态组合中添加5(n=2)

例如:【1+1+5】

现在,仔细考虑,上述种个方法涵盖了所有可能的方法,形成总计7种方法;

因此,我们可以说这个结果

dp(7)= dp(6)+dp(4)+dp(2)

dp(7)= dp(7-1)+dp(7-3)+dp(7-5)

一般来说,

dp(n)=dp(n-1)+dp(n-3)+dp(n-5)

因此,我们的代码将如下所示:

// Returns the number of arrangements to 
// form 'n' 
int solve(int n) 
{ 
// base case 
if (n < 0) 
	return 0; 
if (n == 0) 
	return 1; 

return solve(n-1) + solve(n-3) + solve(n-5); 
}	 

上面的代码似乎是指数级的,因为它一次又一次地计算相同的状态。所以,我们只需要添加一个备忘录(记忆状态)

第4步:添加状态的备忘录(记忆状态)或制表法过程推导

这是DP解决问题中最简单的部分。只需要存储状态的解,以便下次需要该状态时,可以直接从内存中使用它。

向上述代码添加备忘录(记忆状态),自定顶下求解(Top Down)

// initialize to -1 
int dp[MAXN]; 

// this function returns the number of 
// arrangements to form 'n' 
int solve(int n) 
{ 
// base case 
if (n < 0) 
	return 0; 
if (n == 0) 
	return 1; 

// checking if already calculated 
if (dp[n]!=-1) 
	return dp[n]; 

// storing the result and returning 
return dp[n] = solve(n-1) + solve(n-3) + solve(n-5); 
} 

例题:最长递增子序列的问题(Longest Increasing Subsequence,LIS)

我们来讨论一下最长递增子序列的问题(Longest Increasing Subsequence,LIS)

问题描述为:最长增加子序列(lis)问题是求给定序列最长子序列的长度,使子序列的所有元素按递增顺序排序。

例如: 在给定的序列 {10, 22, 9, 33, 21, 50, 41, 60, 80} 中,最长的递增子序列长度为 6,LIS 是 {10, 22, 33, 50, 60, 80}.

nums[] 10 22 9 33 21 50 41 60 80
LIS 1 2   3   4   5 6

最优子结构:

在序列 nums[0..n-1] 中,假设 L(i) 是以i结束的最长子序列,这样nums[i] 就是LIS的最后一个元素。
那么: L(i) 可以写作:
L(i) = 1 + max( L(j) ), 当 0 < j < i and arr[j] < arr[i]; 或者
L(i) = 1,                         如果j不存在.
为了找到给定的序列中的 LIS,我们需要返回 max(L(i))  当 0 < i < n时.

因此,我们认为LIS问题满足最优子结构性质,这是利用子问题的解来解决的主要问题。

下面是最长递增子序列问题的简单递归实现。它遵循上面讨论的递归结构。

package com.bean.algorithm.basic;

public class LIS {
	/* A Naive Java Program for LIS Implementation */

	static int max_ref; // stores the LIS

	/*
	 * To make use of recursive calls, this function must return two things: 1)
	 * Length of LIS ending with element arr[n-1]. We use max_ending_here for this
	 * purpose 2) Overall maximum as the LIS may end with an element before arr[n-1]
	 * max_ref is used this purpose. The value of LIS of full array of size n is
	 * stored in max_ref which is our final result
	 */
	static int _lis(int arr[], int n) {
		// base case
		if (n == 1)
			return 1;

		// 'max_ending_here' is length of LIS ending with arr[n-1]
		int res, max_ending_here = 1;

		/*
		 * Recursively get all LIS ending with arr[0], arr[1] ... arr[n-2]. If arr[i-1]
		 * is smaller than arr[n-1], and max ending with arr[n-1] needs to be updated,
		 * then update it
		 */
		for (int i = 1; i < n; i++) {
			res = _lis(arr, i);
			if (arr[i - 1] < arr[n - 1] && res + 1 > max_ending_here)
				max_ending_here = res + 1;
		}

		// Compare max_ending_here with the overall max. And
		// update the overall max if needed
		if (max_ref < max_ending_here)
			max_ref = max_ending_here;

		// Return length of LIS ending with arr[n-1]
		return max_ending_here;
	}

	// The wrapper function for _lis()
	static int lis(int arr[], int n) {
		// The max variable holds the result
		max_ref = 1;

		// The function _lis() stores its result in max
		_lis(arr, n);

		// returns max
		return max_ref;
	}

	// driver program to test above functions
	public static void main(String args[]) {
		int arr[] = { 10, 22, 9, 33, 21, 50, 41, 60 };
		int n = arr.length;
		System.out.println("Length of lis is " + lis(arr, n) + "\n");
	}
}

输出结果为:

Length of lis is 5

重叠的子问题:考虑到上述实现,下面是大小为4的数组的递归树。lis(n)给出nums[]的lis长度。

我们可以看到,有许多子问题是反复解决的。因此,该问题具有重叠的子结构性质,可以通过备忘录方或制表法来避免同一子问题的重复计算。下面是lis问题的DP算法实现。

package com.bean.algorithm.basic;

public class LIS2 {
	/* Dynamic Programming Java implementation of LIS problem */

	/*
	 * lis() returns the length of the longest increasing subsequence in arr[] of
	 * size n
	 */
	static int lis(int arr[], int n) {
		int lis[] = new int[n];
		int i, j, max = 0;

		/* Initialize LIS values for all indexes */
		for (i = 0; i < n; i++)
			lis[i] = 1;

		/* Compute optimized LIS values in bottom up manner */
		for (i = 1; i < n; i++)
			for (j = 0; j < i; j++)
				if (arr[i] > arr[j] && lis[i] < lis[j] + 1)
					lis[i] = lis[j] + 1;

		/* Pick maximum of all LIS values */
		for (i = 0; i < n; i++)
			if (max < lis[i])
				max = lis[i];

		return max;
	}

	public static void main(String args[]) {
		int arr[] = { 10, 22, 9, 33, 21, 50, 41, 60 };
		int n = arr.length;
		System.out.println("Length of lis is " + lis(arr, n) + "\n");
	}
}

输出结果为:

Length of lis is 5

例题:统计所有递增子序列的问题(Count all increasing subsequences)

我们得到一个数字数组(数值在0到9之间)。任务是对数组中可能的所有子序列进行计数,以便在每个子序列中,每个数字都大于其子序列中的前一个数字。

举例说明

对于数组  arr[] = {1, 2, 3, 4}
输出结果  15
所有的递增子序列有:
{1}, {2}, {3}, {4}, {1,2}, {1,3}, {1,4}, 
{2,3}, {2,4}, {3,4}, {1,2,3}, {1,2,4}, 
{1,3,4}, {2,3,4}, {1,2,3,4}

对于数组  arr[] = {4, 3, 6, 5}
输出结果  8

所有的递增子序列有:

{4}, {3}, {6}, {5}, 
{4,6}, {4,5}, {3,6}, {3,5}

对于数组  arr[] = {3, 2, 4, 5, 4}
输出结果  14
所有的递增子序列有:

{3}, {2}, {4}, {3,4},
{2,4}, {5}, {3,5}, {2,5}, {4,5}, {3,2,5}
{3,4,5}, {4}, {3,4}, {2,4}

方法一:

一个简单的解决方案是使用最长增长子序列(lis)问题的动态规划解。像lis问题一样,我们首先计算以每个索引结尾的增加子序列的计数。最后,我们返回所有值的和(在LCS问题中,我们返回所有值的最大值)。

// We count all increasing subsequences ending at every 
// index i
subCount(i) = Count of increasing subsequences ending 
              at arr[i]. 

// Like LCS, this value can be recursively computed
subCount(i) = 1 + ∑ subCount(j) 
              where j is index of all elements
              such that arr[j] < arr[i] and j < i.
1 is added as every element itself is a subsequence
of size 1.

// Finally we add all counts to get the result.
Result = ∑ subCount(i)
         where i varies from 0 to n-1.

算法设计正确性的证明

For example, arr[] = {3, 2, 4, 5, 4}

// There are no smaller elements on left of arr[0] 
// and arr[1]
subCount(0) = 1
subCount(1) = 1  

// Note that arr[0] and arr[1] are smaller than arr[2]
subCount(2) = 1 + subCount(0) + subCount(1)  = 3

subCount(3) = 1 + subCount(0) + subCount(1) + subCount(2) 
            = 1 + 1 + 1 + 3
            = 6
  
subCount(3) = 1 + subCount(0) + subCount(1)
            = 1 + 1 + 1
            = 3
                             
Result = subCount(0) + subCount(1) + subCount(2) + subCount(3)
       = 1 + 1 + 3 + 6 + 3
       = 14.

方法二:

上面的解决方案并没有使用这样一个事实:在给定的数组中,我们只有10个可能的值。我们可以使用数组count[]来使用这个事实,这样count[d]就可以存储小于d的当前count位数。

For example, arr[] = {3, 2, 4, 5, 4}

// We create a count array and initialize it as 0.
count[10] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0}

// Note that here value is used as index to store counts
count[3] += 1 = 1  // i = 0, arr[0] = 3
count[2] += 1 = 1  // i = 1, arr[0] = 2

// Let us compute count for arr[2] which is 4
count[4] += 1 + count[3] + count[2] += 1 + 1 + 1  = 3

// Let us compute count for arr[3] which is 5
count[5] += 1 + count[3] + count[2] + count[4] 
         += 1 + 1 + 1 + 3
         = 6

// Let us compute count for arr[4] which is 4
count[4] += 1 + count[0] + count[1]
         += 1 + 1 + 1
         += 3
         = 3 + 3
         = 6
            
Note that count[] = {0, 0, 1, 1, 6, 6, 0, 0, 0, 0}                  
Result = count[0] + count[1] + ... + count[9]
       = 1 + 1 + 6 + 6 {count[2] = 1, count[3] = 1
                        count[4] = 6, count[5] = 6} 
       = 14.

下面给出算法代码

package com.bean.algorithm.basic;

public class CountAllLIS {

	// Java program to count increasing
	// subsequences in an array of digits.
	// Function To Count all the sub-sequences
	// possible in which digit is greater than
	// all previous digits arr[] is array of n
	// digits
	static int countSub(int arr[], int n) {
		// count[] array is used to store all
		// sub-sequences possible using that
		// digit count[] array covers all
		// the digit from 0 to 9
		int count[] = new int[10];

		// scan each digit in arr[]
		for (int i = 0; i < n; i++) {
			// count all possible sub-
			// sequences by the digits
			// less than arr[i] digit
			for (int j = arr[i] - 1; j >= 0; j--)
				count[arr[i]] += count[j];

			// store sum of all sub-sequences
			// plus 1 in count[] array
			count[arr[i]]++;
		}

		// now sum up the all sequences
		// possible in count[] array
		int result = 0;
		for (int i = 0; i < 10; i++)
			result += count[i];

		return result;
	}

	// Driver program to run the test case
	public static void main(String[] args) {
		int arr[] = { 3, 2, 4, 5, 4 };
		int n = arr.length;

		System.out.println(countSub(arr, n));
	}
}

输出结果为:

14

例子:跳跃游戏

给定一个非负的整数数组,你处在在数组的第一个位置(下标为0处)。数组中的每个元素表示该位置的最大跳跃长度。

确定是否能够到达最后一个索引。

例1:给定数组 [2,3,1,1,4],你处于数组的第一个位置,此位置数组元素值为2,即你可以从数组元素的第一个位置跳到第二个位置(此时只跳了了1步,没有超过最大值2步);然后数组第二个位置的元素值为3,这意味着你最大跳3步,而此时你直接跳3步,则跳到了数组的最后一个位置。

例2:给定数组 [3,2,1,0,4],你处于数组的第一个位置,此位置数组元素值为3,无论你怎么跳,你都会跳到数组元素为0的位置处。而此位置的数组元素为0,所以你无论如何也跳不到数组的最后一个位置。
 

这个问题可以使用多种方法求解,例如回溯算法,贪心算法等等。那么如何使用动态规划算法求解?

既然是多阶段 —— 那么每个阶段的“状态”是什么?

把多阶段过程转化为一系列单阶段问题 —— 如何转化的?

利用各阶段之间的关系 —— 什么关系?

解决这类过程优化问题 —— 是否在每个阶段或者过程存在最优解?

这个算法设计用dp[i]表示从当前位置 i 能够跳跃的最大距离。

那么数组的第一个元素,即为开始点就是dp[0]的值,所以 dp[0] = nums[0]。

然后从后面一个位置进行判断,循环条件从 i=1到n。同时用 i 表示当前可以跳跃的距离。

那么当 i =1时,开始进行判断,如果dp[0]的值大于等于nums[1]的值时,则表示 nums[1]这一点可以跳到,还可以跳的更远,那么这一跳的位置是 Math.max(dp[0], 1+nums[1])中的最大值。为什么呢?这个问题不好理解。

下面来看分析过程:

例如数组: int[] array = { 2, 3, 1, 1, 4 };
初始时maxdistance=0;表示起跳位置。
当i=0时,array[0]=2, 表示可以跳跃到位置下标为1,2;即位置array[1]和位置array[2]都是可达的。
那么位置 array[3]是不可达的。
那么到底是选择跳到1位置好呢?还是2位置好呢?
这时候,需要继续判断。我们的目标是需要跳到数组末尾(甚至能够跳过数组末尾),所以我们需要跳的尽量远。
 
选择1:假设到达位置 array[1], 此时i=1,那么 i+array[i] 在i=1时的值为:1+array[1]=1+3=4,这时array[4]可达。
而array[4]已经达到数组末尾了。
但是假设到达位置array[2],此时i=1,那么 i+array[i] 在i=1时的值为:1+array[2]=1+2=3,这时array[4]不可达。
所以此时我们选择跳到位置array[1],而 maxDistance = 1+array[1] = 4 最大。
于是,我们可以得到状态方程:
选择能够达到的最远位置,而能够跳跃的最远可以达到的数组元素的下标为  max(maxDistance,i+array[i])
将它作为新的 maxDistance,即 maxDistance =  max(maxDistance,i+array[i])

如果dp[0]的值小于 i(当前可以跳跃的距离),则说明当前跳跃的最大距离就是dp[0]表示的值。

算法代码如下:

public static boolean canJump(int[] nums) {
		 
		 //检查数组的有效性
		 if(nums.length==0 || nums == null) {
			 return false;
		 }
		    //表示从当前位置可以到达的最远位置
	        int maxDistance = 0;
	        //令i为当前位置代表的跳跃距离
	        //从i=0开始遍历
	        for(int i = 0; i < nums.length; i++){
	            //如果i大于maxDistance,则说明这个位置不可达,返回false
	        	if( i > maxDistance){
	        		//这个位置从当前
	                return false;
	            }

	            if(maxDistance >= nums.length - 1){
	            	//表示可达到数组末尾,返回true
	                return true;
	            }
	            //要理解为什么需要取 maxDistance和i + nums[i]的最大值?
	            maxDistance = Math.max(maxDistance, i + nums[i]);
	        }
	        return true;
}

猜你喜欢

转载自blog.csdn.net/seagal890/article/details/89482888
今日推荐