题目及测试
package pid312;
/*戳气球
有 n 个气球,编号为0 到 n-1,每个气球上都标有一个数字,这些数字存在数组 nums 中。
现在要求你戳破所有的气球。每当你戳破一个气球 i 时,你可以获得 nums[left] * nums[i] * nums[right] 个硬币。
这里的 left 和 right 代表和 i 相邻的两个气球的序号。注意当你戳破了气球 i 后,气球 left 和气球 right 就变成了相邻的气球。
求所能获得硬币的最大数量。
说明:
你可以假设 nums[-1] = nums[n] = 1,但注意它们不是真实存在的所以并不能被戳破。
0 ≤ n ≤ 500, 0 ≤ nums[i] ≤ 100
示例:
输入: [3,1,5,8]
输出: 167
解释: nums = [3,1,5,8] --> [3,5,8] --> [3,8] --> [8] --> []
coins = 3*1*5 + 3*5*8 + 1*3*8 + 1*8*1 = 167
}*/
public class main {
public static void main(String[] args) {
int[][] testTable = {{3,1,5,8},{1,1,2,2,5,6,7,7},{1,2,3,5},{1,1,1,1}};
for (int[] ito : testTable) {
test(ito);
}
}
private static void test(int[] ito) {
Solution solution = new Solution();
int rtn;
long begin = System.currentTimeMillis();
for (int i = 0; i < ito.length; i++) {
System.out.print(ito[i]+" ");
}//开始时打印数组
rtn = solution.maxCoins(ito);//执行程序
long end = System.currentTimeMillis();
System.out.println("rtn=" + rtn);
/*for (int i = 0; i < rtn; i++) {
System.out.print(ito[i]+" ");
}//打印结果几数组
*/ System.out.println();
System.out.println("耗时:" + (end - begin) + "ms");
System.out.println("-------------------");
}
}
没想出来
解法1(别人的)
回溯法
刚看到这个题目,脑中可以很轻易的想象出解空间的结构:一个n层的数组,每层的元素相同,我们从第一层走到第n层,每层走动时不能使用之前走过的元素。然后按照规则计算获取的金币,我们尝试所有可以走的路径并记录下每条路径所能获得的金币和,最大值即题目的解。在层数不确定的情况下,使用递归比for循环的嵌套更加方便:
因为被戳破的气球等于不存在,我们在计算获得的金币时需要做一点小小的处理。因为气球上的数字是大于等于0的,我们将走过的气球标志为-1。在计算可以获得的金币数时,如果相邻的气球是-1,则略过取相邻的下一个气球即可。另外,出于两边的气球只有一个相邻气球,需要做一下特殊处理。我们将上述代码的“尝试所有可走路径”中的“to do something”完善起来:
按上面的思路,这就是一个很简单的搜索问题,但每走一层都会对下面的路径造成影响,所以我们需要通过回溯的手法,每尝试完一种可能性后,在尝试下一种路径前我们都要把之前路径戳破的气球恢复。回溯很简单,只需要加一行代码,即递归调用结束后将当前for循环中戳破的气球恢复。
上述解法超时导致提交不通过。细想下当前解法的时间复杂度就可以知道,不通过是有原因的。 解法2(别人的) 分治法 效率提升到这里好像挺完美了,但我们还忽略了一点:即使使用了分治使时间复杂度大幅下降,但我们的实现中还存在着递归调用。递归调用的效率是很低的,因为牵扯到大量的函数调用,即栈帧的创建与释放。而且由于临时变量的存在以及需要保存之前栈帧的esp、程序计数器等寄存器值,在递归层数加深时会占用大量的栈空间,非常容易引起爆栈。这样的代码是绝对不可以放到生产环境上的,我们应该去思考递归调用的回归过程,通过模拟回归过程来用递推实现上述代码。 解法3(别人的) 动态规划
每层有n中选择,第i层有n-i中选择,时间复杂度为n*(n-1)*(n-2)...*1即 !n。n的阶乘,指数级的时间复杂度,太可怕,我们应该想办法优化它。
我们都都知道,算法的时间复杂度分为多项式级时间复杂度与非多项式级时间复杂度,我们来重温一下时间复杂度的排名:
O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n)< O(!n)
其中 O(!n)与O(2^n)被称为非多项式级时间复杂度,增长速度大于且远远大于前面的多项式级时间复杂度。
当遇到时间复杂度为 !n 的算法时,首先考虑的是使用分治的方式将问题规模缩小。因为 !n 的增长率是恐怖的,缩小问题规模,时间复杂度的优化效果也将是立竿见影的。下面看一个很简单的例子,8的阶乘是远大于两个4的阶乘的和的:
8的阶乘是40320。我们如果将问题分解,比如对半分则我们将得到两个问题规模为4的子问题,时间复杂度为4的阶乘加4的阶乘等于48。
在将规模为8的原问题分解为两个子问题时,我们将会有6种分法,为了覆盖解空间我们需要将所有子问题的分解方式都尝试一次,则尝试所有分法的计算次数为∑( !k +!(n-k)),其中0 public static void maxCoins2(int[] nums, int y, int length, int beforeCoins) {
//回归条件
if (y == length) {
if (beforeCoins > maxCoin) {
maxCoin = beforeCoins;
}
return;
}
for (int i = 0; i < length; i++) {
//略过已经戳破的气球
if (nums[i] == -1) {
continue;
}
//标记已经戳破的气球
int temp = nums[i];
nums[i] = -1;
//获取上一个气球的数字
int before = i - 1;
int beforeNum = 0;
while (before > -1 && nums[before] == -1) {
before--;
}
if (before < 0) {
beforeNum = 1;
} else {
beforeNum = nums[before];
}
//获取下一个气球的数字
int next = i + 1;
int nextNum = 0;
while (next < length && nums[next] == -1) {
next++;
}
if (next > length - 1) {
nextNum = 1;
} else {
nextNum = nums[next];
}
//计算戳破当前气球的coin
int tempCoin = temp * nextNum * beforeNum;
//递归进行下一戳
maxCoins2(nums, y + 1, length, beforeCoins +
tempCoin);
//回溯尝试其它戳法
nums[i] = temp;
}
}
在使用分治法时,我们应该考虑的核心问题是如何用子问题的解来表示原问题的解,也就是子问题该如何划分才能通过子问题来求解原问题。我们把描述子问题的解与原问题的解之间的关系的表达式称为状态转移方程。
首先我们尝试每戳破一个气球,以该气球为边界将气球数组分为两部分,使用这两部分的解来求解原问题。
我们设戳破区间 i 到 j 间的气球我们得到的最大金币数为coin。及coin = def( i , j )。
则当我们戳破气球 k 时,两边区间的最大值分别是 def( i , k-1 ) 与 def( k+1 , j )。
此时我们发现了问题,因为戳破了气球 k ,气球数组的相邻关系发生了改变,k-1 与 k+1 原本都与 k 相邻,而 k 戳破后他们两个直接相邻了。而且先戳破 k+1 与先戳破 k-1 得到的结果将完全不同,也就是说两个子问题间发生了依赖。如果先戳破 k-1 ,则 k+1 左边的相邻气球变成了 k-2;反之 k-1 右边相邻的气球变成了 k+2 。
子问题的处理顺序将影响到每个子问题的解,这将使我们的状态转移方程极为复杂和低效,我们应当换一种划分子问题的方式,使每个子问题都是独立的。
那么我们换一种划分方式,既然两个子问题都依赖 k 和两个边界,那么我们划分子问题时,k 与两个边界的气球我们都不戳破,求出 i+1 到 k-1 与 k+1 到 j-1 之间的解。这样两个子问题间的依赖便被消除了,两个边界及气球 k 不被戳破,两个子问题的依赖都不会越过 k 到另一个子问题上,子问题间是相互独立的。
并且在两个子问题解决后,气球序列还剩下 k 与两个边界的气球没有戳破,那么我们用两个子问题的解与戳破 k 与两个边界的最大值即可求出原问题的解。
那么 def( i , j ) 函数的定义则为,不戳破 i 与 j ,仅戳破 i 与 j 之间的气球我们能得到的最大金币数。
如此划分,状态转移方程为: def( i, j ) = def( i , k ) + def( k , j )+nums[ i ][ j ][ k ]
其中 nums[ i ][ j ][ k ] 为戳破气球 k 时我们能得到的金币数,因为def( i , j )表示戳破 i 到 j 之间的气球,自然包括 k 。
上述方程其实还有问题,前面说过,为了保证我们可以完整的搜索解空间,我们需要尝试所有的子问题划分方式,对于上述状态转移方程,也就是 k 的取值。k 的取值应当介于 i+1 与 j-1 之间,我们尝试所有 k 的取值并从中挑选最大值,这才是原问题真正的解。
真正的状态转移方程应该为:def( i, j ) = max { def( i , k ) + def( k , j )+nums[ i ][ j ][ k ] } | i
因为 k 是介于 i 与 j 之间的,那么当 i 与 j 相邻时我们的问题将不能再继续划分。此时按照我们对问题的定义,“不戳破 i 与 j ,仅戳破 i 与 j 之间的气球”,因为 i 与 j 之间没有气球,我们得到的金币数是 0 。
为了保证问题定义的正确性,我们向上推演一次。def( i , i+2 ) = def( i , i+1 ) + def( i+1 , i+2 ) + nums[i]*nums[ i+1]*nums[i+2]
def( i , i+1 ) , def( i+1 , i+2 ) 都是最小子问题,返回0。即 def( i , i+2 ) = nums[i]*nums[ i+1]*nums[i+2] 。因为问题的定义我们不戳破 i 与 i+2,所以我们只能戳破 i+1,戳破 i+1得到的金币确实是 nums[i]*nums[ i+1]*nums[i+2] 即 def( i , i+2 ) 。
所以说对于我们的状态转移方程 def( i, j ) = max { def( i , k ) + def( k , j )+nums[ i ][ j ][ k ] } | i
用递推模拟回归过程的方法,就是在上述实现的缓存 cache[i][j] 中逐渐推演,通过一步步的解决小问题来得到最终问题的解,这便是动态规划解法。/**
* @Author Nyr
* @Date 2019/11/30 0:23
* @Param nums:气球数组;length:数组长度,避免每层都计算一次;begin:开始下标;end:结束下标;cache:缓存,避免重复计算
* @Return
* @Exception
* @Description 状态转移方程 def( i, j ) = max { def( i , k ) + def( k , j )+nums[ i ][ j ][ k ] } | i
动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。对于分治法求解的问题,子问题的相互独立仅仅是同层级的子问题间没有互相依赖。但对于动态规划而言,同层级的子问题可能会依赖相同的低层级问题,这就导致低层级问题可能会被计算多次。
若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。具体的动态规划算法多种多样,但它们具有相同的填表格式。
其实在上面的分治解法,我加入了一个二维数组用于缓存已经计算过的子问题的结果,将缓存去掉才是概念上的分治解法。而加入了缓存避免了子问题的重复计算,已经是一个动态规划解法的雏形,我们只需要将递归改为递推便是动态规划解法。正如上面所说,通常情况下,递归的解法是不可以放在生产环境的,因为我们很难控制问题规模的大小,无法预料何时会有爆栈的风险。
具有最优子结构性质以及重叠子问题性质的问题可以通过动态规划求解。
最优子结构
• 如果一个问题的最优解包含其子问题的最优解,我们就称此问题具有最优子结构
• 一个问题具有最优子结构,可能使用动态规划方法,也可能使用贪心方法。所以最优子结构只是一个线索,不是看到有最优子结构就一定是用动态规划求解
重叠子问题
• 子问题空间必须足够“小”,即在不断的递归过程中,是在反复求解大量相同的子问题,而不是每次递归时都产生新的子问题。
• 一般的,不同子问题的总数是输入规模的多项式函数为好
• 如果递归算法反复求解相同的子问题,我们就称最优化问题具有重叠子问题性质
对于前面的分治解法,我们的计算过程分为两个阶段:
1、递归的不断的分解问题,直到问题不可继续分解。
2、当问题不可继续分解,也就是分解到最小子问题后,由最小子问题的解逐步向上回归,逐层求出上层问题的解。
阶段1我们称为递归过程,而阶段2我们称为递归调用的回归过程。我们要做的,就是省略递归分解子问题的过程,将阶段2用递推实现出来。
举个例子,对于区间 0 到 4 之间的结果,递归过程是:
dp[0][4] =max { dp[0][1]+dp[1][4]+nums[0]*nums[1]*nums[4] , dp[0][2]+dp[2][4]+nums[0]*nums[2]*nums[4] , dp[0][3]+dp[3][4]+nums[0]*nums[3]*nums[4] }
标红部分没有达到回归条件,会继续向下分解,以 dp[1][4] 为例:
dp[1][4]= max { dp[1][2]+dp[2][4]+nums[1]*nums[2]*nums[4] , dp[1][3]+dp[3][4]+nums[1]*nums[3]*nums[4] }
标红部分继续分解:
dp[2][4]= dp[2][3] + dp[3][4] + nums[2]*nums[3]*nums[4]
dp[1][3] = dp[1][2] + dp[1][3] + nums[1]*nums[2]*nums[3]
到这里因为已经分解到了最小子问题,最小子问题会带着它们的解向上回归,也就是说我们的回归过程是:dp[3][4] , dp[2][3] , dp[2][4] , dp[1][2] , dp[1][3] , dp[1][4] , dp[0][1] , dp[0][2] , dp[0][3] , dp[0][4] 。因为 dp[i][j] 依赖的是 dp[i][k] 与 dp[k][j] 其中 i < k < j ,也就是说如果要求解 dp[ i ][ j ] 依赖了 [ i ][ 0 ] 到 [ i ][ j-1 ] 以及 [ i+1 ][ j ] 到 [ j-1 ][ j ] 的值。那么我们在dp表中 i 从 length 递减到 0, j 从 i+1 递增到 j 推演即可。
如果觉着顺序抽象,可以在上述分治解法的基础上,打印出缓存数组的演变过程,来理解回归的计算顺序。 public static int maxCoins4DP(int[] nums) {
//避免空指针异常
if (nums == null) {
return 0;
}
//创建虚拟边界
int length = nums.length;
int[] nums2 = new int[length + 2];
System.arraycopy(nums, 0, nums2, 1, length);
nums2[0] = 1;
nums2[length + 1] = 1;
length = nums2.length;
//创建dp表
length = nums2.length;
int[][] dp = new int[length][length];
//开始dp:i为begin,j为end,k为在i、j区间划分子问题时的边界
for (int i = length - 2; i > -1; i--) {
for (int j = i + 2; j < length; j++) {
//维护一个最大值;如果i、j相邻,值为0
int max = 0;
for (int k = i + 1; k < j; k++) {
int temp = dp[i][k] + dp[k][j] + nums2[i] * nums2[k] * nums2[j];
if (temp > max) {
max = temp;
}
}
dp[i][j] = max;
}
}
return dp[0][length-1];
}