Burst Balloons(leetcode戳气球,困难)从指数级时间复杂度到多项式级时间复杂度的超详细优化思路(回溯到分治到动态规划)
这道题目做了两个晚上,发现解题思路的优化过程非常有代表性。文章详细说明了如何从回溯解法改造为分治解法,以及如何由分治解法过渡到动态规划解法。解法的用时从 超时 到 超过 95.6% 提交者,到超过 99.8% 提交者。现整理下来分享给大家,如有错误评论区欢迎指正!
题目如下:
回溯法
刚看到这个题目,脑中可以很轻易的想象出解空间的结构:一个n层的数组,每层的元素相同,我们从第一层走到第n层,每层走动时不能使用之前走过的元素。然后按照规则计算获取的金币,我们尝试所有可以走的路径并记录下每条路径所能获得的金币和,最大值即题目的解。在层数不确定的情况下,使用递归比for循环的嵌套更加方便,算法的框架如下:
//维护一个最大值 int maxCoin=0; public void getCoins(int[] nums,int currentLevel,int currentCoin){ //每层的气球数,也是层级数 int length=nums.length; //回归条件,我们走完最后一层了 if(currentLevel==length){ //如果该路径所得金币数大于当前最大值,更新最大值 if(currentCoin>maxCoin){ maxCoin=currentCoin; } } //尝试该层所有可走的路径 for(int i=0;i<length;i++){ //to do something } }
因为被戳破的气球等于不存在,我们在计算获得的金币时需要做一点小小的处理。因为气球上的数字是大于等于0的,我们将走过的气球标志为-1。在计算可以获得的金币数时,如果相邻的气球是-1,则略过取相邻的下一个气球即可。另外,出于两边的气球只有一个相邻气球,需要做一下特殊处理。我们将上述代码的“尝试所有可走路径”中的“to do something”完善起来:
//尝试该层所有可走的路径 for(int i=0;i<length;i++){ //to do something //如果气球已经被戳破了,略过 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; //递归搜索下一层,进行变量传递 maxCoins(nums,currentCoin+tempCoin,currentLevel+1); }
按上面的思路,这就是一个很简单的搜索问题,但每走一层都会对下面的路径造成影响,所以我们需要通过回溯的手法,每尝试完一种可能性后,在尝试下一种路径前我们都要把之前路径戳破的气球恢复。回溯很简单,只需要加一行代码,即递归调用结束后将当前for循环中戳破的气球恢复,即下方代码标红部分:
//尝试该层所有可走的路径 for(int i=0;i<length;i++){ //to do something //如果气球已经被戳破了,略过 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; //递归搜索下一层,进行变量传递 maxCoins(nums,currentCoin+tempCoin,currentLevel+1);
//回溯
nums[i]=temp; }
回溯的完整代码如下:
/** * @Author Nyr * @Date 2019/11/30 22:24 * @Param nums:气球数组, y:递归层级,即currentLevel, length:数组长度,防止每层都计算一次, beforeCoins:之前所有层获得的金币和,即currentCoin * @Return * @Exception * @Description 回溯解法 */ 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; } }
上述解法通过与样例代码跑的结果比较进行测试,结果是正确的。
为什么要与样例代码的结果比较而不直接提交呢,当然是超时导致提交不通过。细想下当前解法的时间复杂度就可以知道,不通过是有原因的。
每层有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<k<n。以问题规模为8时为例,将问题分为两个子问题的计算次数将是1804,与原问题计算40320次时相比,性能得到了极大的提升。
下面我们就尝试使用分治法来求解该问题。
分治法
在使用分治法时,我们应该考虑的核心问题是如何用子问题的解来表示原问题的解,也就是子问题该如何划分才能通过子问题来求解原问题。我们把描述子问题的解与原问题的解之间的关系的表达式称为状态转移方程。
首先我们尝试每戳破一个气球,以该气球为边界将气球数组分为两部分,使用这两部分的解来求解原问题。
我们设戳破区间 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<j
这样我们便找到了用子问题的解来表示原问题的解的方法,或者说子问题的划分方式。因为我们要划分子问题,必然不是只划分一次这么简单。而是要把问题一直划分到不能继续划分,也就是划分到问题规模最小的最小子问题,使效率最大化。
因为 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<k<j ,回归条件 def( i , i+1 ) = 0 是正确的。
状态转移方程与回归条件都找到了,实现起来就很简单了:
/** * @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<k<j 的实现 */ public static int maxCoins4(int[] nums, int length, int begin, int end,int[][] cache) { //回归条件,问题分解到最小子问题 if (begin == end - 1) { return 0; } //缓存,避免重复计算 if(cache[begin][end]!=0){ return cache[begin][end]; } //维护一个最大值 int max = 0; //状态转移方程 def( i, j ) = max { def( i , k ) + def( k , j )+nums[ i ][ j ][ k ] } | i<k<j for (int i = begin + 1; i < end; i++) { int temp = maxCoins4(nums, length, begin, i,cache) + maxCoins4(nums, length, i, end,cache) + nums[begin] * nums[i] * nums[end]; if (temp > max) { max = temp; } } //缓存,避免重复计算 cache[begin][end]=max; return max; }
我们再封装一层方法,对空数组进行处理。因为 def( i , j ) 并不戳破两个边界的气球,我们为气球数组加上虚拟的边界:
public static final int maxCoins4MS(int[] nums) { //空数组处理 if (nums == null) { return maxCoin; } //加虚拟边界 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; //创建缓存数组 int[][] cache=new int[length][length]; //调用分治函数 return maxCoins4M(nums2, length,cache); } public static int maxCoins4M(int[] nums, int length,int[][] cache) { int max = maxCoins4(nums, length, 0, length - 1,cache); return max; }
实现很简单,一个带缓存的递归调用,我们来看一下效果,测试代码如下:
public static void main(String[] args) { int[] nums = {3,4,5,6,7,5,7,8,5,3,2,5}; long start = System.currentTimeMillis(); start = System.currentTimeMillis(); System.out.println(maxCoins(nums)); System.out.println("原始回溯用时 : " + String.valueOf(System.currentTimeMillis() - start)+" 运算次 数:"+sum3); start = System.currentTimeMillis(); System.out.println(maxCoins4MS(nums)); System.out.println("分治用时 : " + String.valueOf(System.currentTimeMillis() - start)+" 运算次 数:"+sum1+" 实际运算次数:"+sum2); }
我在运算时加入和调用次数的计数以及计时,因为使用的是System.currentTimeMillis()进行计时,时间的精度是毫秒级,可以看到结果完全相同,分治法甚至在1毫秒之内解决了回溯使用26秒才能解决的问题。
运算次数上回溯运算了 8亿次 而分治法只运算了 573次,并且其中真正的运算只有 78次,另外的 495次是通过缓存避免的重复计算。
指数级(非多项式级时间复杂度的一种)时间复杂度的可怕可见一斑!
用分治法提交:
效率提升到这里好像挺完美了,但我们还忽略了一点:即使使用了分治使时间复杂度大幅下降,但我们的实现中还存在着递归调用。递归调用的效率是很低的,因为牵扯到大量的函数调用,即栈帧的创建与释放。而且由于临时变量的存在以及需要保存之前栈帧的esp、程序计数器等寄存器值,在递归层数加深时会占用大量的栈空间,非常容易引起爆栈。这样的代码是绝对不可以放到生产环境上的,我们应该去思考递归调用的回归过程,通过模拟回归过程来用递推实现上述代码。
用递推模拟回归过程的方法,就是在上述实现的缓存 cache[i][j] 中逐渐推演,通过一步步的解决小问题来得到最终问题的解,这便是动态规划解法。
动态规划
动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。对于分治法求解的问题,子问题的相互独立仅仅是同层级的子问题间没有互相依赖。但对于动态规划而言,同层级的子问题可能会依赖相同的低层级问题,这就导致低层级问题可能会被计算多次。
若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。具体的动态规划算法多种多样,但它们具有相同的填表格式。
其实在上面的分治解法,我加入了一个二维数组用于缓存已经计算过的子问题的结果,将缓存去掉才是概念上的分治解法。而加入了缓存避免了子问题的重复计算,已经是一个动态规划解法的雏形,我们只需要将递归改为递推便是动态规划解法。正如上面所说,通常情况下,递归的解法是不可以放在生产环境的,因为我们很难控制问题规模的大小,无法预料何时会有爆栈的风险。
具有最优子结构性质以及重叠子问题性质的问题可以通过动态规划求解。
最优子结构
- 如果一个问题的最优解包含其子问题的最优解,我们就称此问题具有最优子结构
- 一个问题具有最优子结构,可能使用动态规划方法,也可能使用贪心方法。所以最优子结构只是一个线索,不是看到有最优子结构就一定是用动态规划求解
重叠子问题
- 子问题空间必须足够“小”,即在不断的递归过程中,是在反复求解大量相同的子问题,而不是每次递归时都产生新的子问题。
- 一般的,不同子问题的总数是输入规模的多项式函数为好
- 如果递归算法反复求解相同的子问题,我们就称最优化问题具有重叠子问题性质
对于前面的分治解法,我们的计算过程分为两个阶段:
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 推演即可。
如果觉着顺序抽象,可以在上述分治解法的基础上,打印出缓存数组的演变过程,来理解回归的计算顺序。
/** * @Author Nyr * @Date 2019/11/30 01:43 * @Param * @Return * @Exception * @Description 动态规划解法 */ 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]; }
用动态规划法提交: