leetcode-70-爬楼梯(climbing trees)-java
爬楼梯的三种解法
1 简单的递归算法
return climbStairs(n-1)+climbStairs(n-2);
速度是o(2^n),速度很慢
2 动态规划,也是备忘录算法
有一个hashmap存放之前得出的值
if(map.containsKey(n)){
return map.get(n);
}
else{
int val=climbStair(n-1,map)+climbStair(n-2,map);
map.put(n, val);
return val;
}
速度o(n),空间o(n)
3 动态规划,简单的自底向上,只保留运算下一个所需要的数字,其余的不要,所以空间固定
int first=1;
int second=2;
int third=0;
for(int i=3;i<=n;i++){
third=first+second;
first=second;
second=third;
}
return third;
速度o(n),空间o(c)
leetcode-121-买卖股票的最佳时机(best time to buy and sell stock)-java
最大利润为max(当前的值-之前的min,之前的max利润)
动态规划的要诀就是得到当前的要的值,如何根据之前的值推断的公式,不断递推
leetcode-53- 最大子序和(maximum subarray)-java
包含i处的最大利润 sum=max(sum+nums[i],nums[i]);
i处的最大利润 max_sum=max(sum,max_sum);
当前的和 为如果之前的sum小于0,则为当前元素
否则为sum+now
然后每次更新max
leetcode-198-打家劫舍(house robber)-java
思路:文中给出不能连续抢两家,因此假设从最后一个房屋开始抢,最后一个房屋为index。将原问题分割成子问题,子问题的最优决策可以导出原问题的最优决策。现有两种可能情况,当前房屋抢和当前房屋不抢。若当前房屋抢,则下一房屋不能抢;若当前房屋不抢,则下一房屋可抢;选出这两种情况的最大值,递归执行,直到index<0。
现在i的max=max( (i-2)的max+num[i] ,(i-1)的max)
max[i]=max[i-2]+nums[i] / max[i-1]
用动态规划,不断max上去
leetcode-55-跳跃游戏(jump game)-java
只要用上界就行了。
那么这道题的思路就是使用一个贪心法,使用一个步进指针,用一个上界指针。
每次遍历的时候,不停的更新上界指针的位置(也就是当前位置+当前可以跳到的位置),知道看你能遇到结尾吗?如果成功了,就范围true,没有就返回false
leetcode-62-不同路径 (unique paths)-java
方法1:
建立一个n行m列的数组,每个对应的数字是从左上角到到这一点能走的路线数,很明显,因为只能向右或向下,所以最上的一条边和最左的一条边只能从左上角一路走,都为1。
然后之后的所有的点的值为左边+上边。然后从第2行的第2个到最后一个开始计算,然后每行依次类推,右下角的最后一个计算,它的值就是结果
方法2:
滚动数组的优化就是其实你在算dp [i] [j] 的时候,你左边的dp[i][j-1]还是dp[j-1],而你上面的dp[i-1][j]还是dpj,所以可以只需要一个数组,所以滚动优化决定的是你更新的顺序;
leetcode-322-零钱兑换 (coin change)-java
建立一个数组,needCoins,value为对应index需要的硬币数,结果就是index为amount的value。
首先初始化数组,0,为0,其余都是-1(其实应该只需要初始化0就可以,-1应该不需要)
自底向上的动态规划,然后从1开始循环到amount,每次计算出i所需要的硬币数,硬币数为它之前的i-coins[j]所在位的value的min+1,如果那些位都没有或者都为-1,则值为-1
leetcode-300-最长上升子序列(longest increasing subsequence)-java
解法1:使用动态规划。
状态的定义:以 num[i] 结尾的最长上升子序列的长度。
状态转移方程:之前的数中比 num[i] 小的最长上升子序列的长度 + 1。
对于原数组每个元素,二重循环从头遍历原数组,每当找到一个比当前元素小的值,证明至少可以形成一个dp[j]+1的上升子序列,所以dp[i] = max(dp[i], dp[j] + 1),而dp[j]之前已经求得。
速度o(n^2)
解法二:动态规划+二分查找
10,9,2,5,3,7,101,18
首先看到10,加入备选集,备选集合为{10};
之后看到了9,没有形成上升序列,那么9不应该加入备选集合。但是因为9小于10,所以如果把10替换成9会增加接下来产生上升序列的机会,且并不影响备选集合元素的个数(因为是替换),所以替换掉,备选集现在有{9};
遇到2道理同上,替换掉9,备选集变成{2};
遇到5,这时候形成了上升序列,此时应该是添加到备选集合,变为{2,5};
遇到3,没有形成上升序列,但还是道理同加入9的情况,如果此时把5替换成3,会增加接下来形成上升序列的机会,且备选集保持上升,并且个数也没变,所以替换掉5,备选集变成{2,3};
遇到7,同遇到5,添加元素,备选集{2,3,7};
遇到101,同上,备选集{2,3,7,101};
遇到18,还是一样,虽然没有形成上升序列,但是如果把101替换掉,那么接下来形成上升序列的机会会增加,并且备选集的上升属性和元素个数都不变,所以替换,备选集变为{2,3,7,18}。
至此所有元素添加完毕,备选集的元素个数就是最长上升子序列长度。但这里注意,备选集里面的元素并不是最后最长子序列的元素。因为在寻找最长子序列的过程中,目标是尽可能的让以后形成上升序列的机会增加,所以进行了替换。
“人工”做出来之后,只要用程序实现思考过程就好。总结起来就是:
如果遇到的元素比备选集合里面的元素都大,那么就添加进去,使得上升序列长度增加;
如果遇到的元素比备选集合里最后一个元素小,那么代表它无法被添加到备选集。但是为了使后面得到上升序列的机会增加,需要在不破坏集合上升属性和元素总数的情况下,替换掉备选集中的元素,那么就是替换掉大于他的元素中最小的那个,这样才能满足条件。
相当于原来是10,20,30, 现在将15插入,换成10,15,30,更容易插入。
如果下一个是25,变成10,15,25,显然更容易让后一个加入。
如果下一个是40,会变成10,15,30,40,总共4个,但实际排序是10,20,30,40,但是长度一样,所以之前替换为15无妨,因为长度没变。
新加入的元素要比最后一个大,而最后一个元素显然是在新加入元素之前,所以无妨。
例如顺序为10,20,30,15,25,27 会从10,20,30,,替换为10,15,25,最后加入27
这时候,发现备选集一直是保持有序,寻找替换元素的时候就可以用到二分查找,得到O(n log n)的时间复杂度。其中还要注意的是如果元素已经在备选集合中,是不需要任何操作的,因为它并不能增加上升序列的长度,也不会增加之后遇到上升序列的机会,所以直接跳过。
这个做法的精髓是即使用小的元素替换掉中间的元素,备选集的大小不变,还是原来的大小,
leetcode-152-乘积最大子序列-java
设定max数组和min数组,分别代表以i为结尾的乘积最大/小子序列之积
每次计算新的max[i],min[i],根据nums[i],max[i-1],min[i-1],三者小于0,大于0,等于0,来设置。
我们注意到上边max[i] 的取值无非就是三种,max[i-1] * nums[i] 、min[i-1] * nums[i] 以及 nums[i]。
所以我们更新的时候,无需去区分当前是哪种情况,只需要从三个取值中选一个最大的即可。
max[i] = max(max[i-1] * nums[i], min[i-1] * nums[i], nums[i]);
求 dpMin[i] 同理。
min[i] = min(max[i-1] * nums[i], min[i-1] * nums[i], nums[i]);
更新过程中,我们可以用一个变量 max 去保存当前得到的最大值。
动态规划的老问题,我们注意到更新 dp[i] 的时候,我们只用到 dp[i-1] 的信息,再之前的信息就用不到了。所以我们完全不需要一个数组,只需要一个变量去重复覆盖更新即可。
leetcode-309-最佳买卖股票时机含冷冻期-java
第 1 步:状态定义
dp[i][j] 表示 [0, i] 区间内,到第 i 天(从 0 开始)状态为 j 时的最大收益。
这里 j 取三个值:
0 表示不持股;
1 表示持股;
2 表示处在冷冻期。
第 2 步:状态转移方程
不持股可以由这两个状态转换而来:(1)昨天不持股,今天什么都不操作,仍然不持股。(2)昨天持股,今天卖了一股。
持股可以由这两个状态转换而来:(1)昨天持股,今天什么都不操作,仍然持股;(2)昨天处在冷冻期,今天买了一股;
处在冷冻期只可以由不持股转换而来,因为题目中说,刚刚把股票卖了,需要冷冻 1 天。
第 3 步:思考初始化
在第 0 天,不持股的初始化值为 0,持股的初始化值为 -prices[0](表示购买了一股),虽然不处于冷冻期,但是初始化的值可以为 0。
第 4 步:思考输出
每一天都由前面几天的状态转换而来,最优值在最后一天。取不持股和冷冻期的最大者。
由于当前天只参考了昨天的状态值,因此可以考虑使用滚动数组。
leetcode-279-完全平方数-java
首先初始化长度为n+1的数组dp,每个位置都为0
如果n为0,则结果为0
对数组进行遍历,下标为i,每次都将当前数字先更新为最大的结果,即dp[i]=i,比如i=4,最坏结果为4=1+1+1+1即为4个数字
动态转移方程为:dp[i] = MIN(dp[i], dp[i - j * j] + 1),i表示当前数字,jj表示平方数
时间复杂度:O(nsqrt(n)),sqrt为平方根
leetcode-139-单词拆分-java
这个方法的想法是对于给定的字符串(s)可以被拆分成子问题 s1和 s2。如果这些子问题都可以独立地被拆分成符合要求的子问题,那么整个问题 s也可以满足。也就是,如果 “catsanddog” 可以拆分成两个子字符串 “catsand” 和 “dog” 。子问题 “catsand” 可以进一步拆分成 “cats” 和 “and” ,这两个独立的部分都是字典的一部分,所以 “catsand” 满足题意条件,再往前, “catsand” 和 “dog” 也分别满足条件,所以整个字符串 “catsanddog” 也满足条件。
现在,我们考虑 dp数组求解的过程。我们使用 n+1大小数组的 dp,其中 n是给定字符串的长度。我们也使用 2 个下标指针 i和 j ,其中 i 是当前字符串从头开始的子字符串(s′)的长度, j 是当前子字符串(s′)的拆分位置,拆分成 s′(0,j) 和 s′(j+1,i)。
为了求出 dp 数组,我们初始化 dp[0]为 true ,这是因为空字符串总是字典的一部分。 dp数组剩余的元素都初始化为 false 。
我们用下标 i来考虑所有从当前字符串开始的可能的子字符串。对于每一个子字符串,我们通过下标 j 将它拆分成 s1和 s2′ (注意 i 现在指向 s2′的结尾)。为了将 dp[i]数组求出来,我们依次检查每个 dp[j] 是否为 true ,也就是子字符串 s1′ 是否满足题目要求。如果满足,我们接下来检查 s2′是否在字典中。如果包含,我们接下来检查 s2′ 是否在字典中,如果两个字符串都满足要求,我们让 dp[i] 为 true,否则令其为 false。
leetcode-140-单词拆分 II-java
这个方法背后的想法是对于给定的问题(s),它可以被拆分成子问题 s1 和 s2 。如果这些子问题分别都能满足条件,那么整个文字 s 也可以满足。比方说, “catsanddog” 可以被拆分成子字符串 “catsand” 和 “dog” 。子问题 “catsand” 进一步可以被拆分成 “cats” 和 “and” ,它们分别都是字典的一部分,所以 “catsand” 也是满足条件的。递归回来,因为 “catsand” 和 “dog” 分别都满足要求,所以原字符串 “catsanddog” 也符合要求。
现在,我们来考虑 dp 数组如何求出。我们使用长度为 n+1n+1n+1 的数组 dp ,其中 n 是给定字符串的长度。 dp[k] 被用来存储用 s[0:k−1]可被拆分成合法单词的句子。我们同事用两个指针 i 和 j ,其中 i 表示子字符串 s′ 的长度(s′ 是 s 的一个前缀), j 表示 s′ 的拆分位置,即拆分成 s′(0,j) 和 s′(j+1,i) 。
为了求出 dp 数组,我们将 dp[0] 初始化为空串。我们以 i 为结尾表示的子字符串的所有前缀,通过指针 j 将 s 拆分成 s1′ 和 s2′ 。为了求出dp[i] ,我们检查所有 dp[j] 包含的所有非空字符串,也就是所有能形成 s1′ 的句子。如果存在,我们进一步检查 s2′ 是否在字典里,如果两个条件都满足,我们将子字符串 s2′ 添加到所有 s1′ 的句子的后面(这些句子已经保存在了 dp[j] 里面),并将这些新形成的句子保存进(dp[i])。最终, dp[n] (n 是给定字符串 s 的长度)里面保存了所有可以得到完整字符串 s 的所有句子。
leetcode-312-戳气球-java 解法2 解法3(别人的)
解法1
回溯法
刚看到这个题目,脑中可以很轻易的想象出解空间的结构:一个n层的数组,每层的元素相同,我们从第一层走到第n层,每层走动时不能使用之前走过的元素。然后按照规则计算获取的金币,我们尝试所有可以走的路径并记录下每条路径所能获得的金币和,最大值即题目的解。在层数不确定的情况下,使用递归比for循环的嵌套更加方便:
因为被戳破的气球等于不存在,我们在计算获得的金币时需要做一点小小的处理。因为气球上的数字是大于等于0的,我们将走过的气球标志为-1。在计算可以获得的金币数时,如果相邻的气球是-1,则略过取相邻的下一个气球即可。另外,出于两边的气球只有一个相邻气球,需要做一下特殊处理。我们将上述代码的“尝试所有可走路径”中的“to do something”完善起来:
按上面的思路,这就是一个很简单的搜索问题,但每走一层都会对下面的路径造成影响,所以我们需要通过回溯的手法,每尝试完一种可能性后,在尝试下一种路径前我们都要把之前路径戳破的气球恢复。回溯很简单,只需要加一行代码,即递归调用结束后将当前for循环中戳破的气球恢复。
上述解法超时导致提交不通过。细想下当前解法的时间复杂度就可以知道,不通过是有原因的。
每层有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
分治法
在使用分治法时,我们应该考虑的核心问题是如何用子问题的解来表示原问题的解,也就是子问题该如何划分才能通过子问题来求解原问题。我们把描述子问题的解与原问题的解之间的关系的表达式称为状态转移方程。
首先我们尝试每戳破一个气球,以该气球为边界将气球数组分为两部分,使用这两部分的解来求解原问题。
我们设戳破区间 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] 中逐渐推演,通过一步步的解决小问题来得到最终问题的解,这便是动态规划解法。
动态规划
动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。对于分治法求解的问题,子问题的相互独立仅仅是同层级的子问题间没有互相依赖。但对于动态规划而言,同层级的子问题可能会依赖相同的低层级问题,这就导致低层级问题可能会被计算多次。
若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。具体的动态规划算法多种多样,但它们具有相同的填表格式。
其实在上面的分治解法,我加入了一个二维数组用于缓存已经计算过的子问题的结果,将缓存去掉才是概念上的分治解法。而加入了缓存避免了子问题的重复计算,已经是一个动态规划解法的雏形,我们只需要将递归改为递推便是动态规划解法。正如上面所说,通常情况下,递归的解法是不可以放在生产环境的,因为我们很难控制问题规模的大小,无法预料何时会有爆栈的风险。
具有最优子结构性质以及重叠子问题性质的问题可以通过动态规划求解。
最优子结构
• 如果一个问题的最优解包含其子问题的最优解,我们就称此问题具有最优子结构
• 一个问题具有最优子结构,可能使用动态规划方法,也可能使用贪心方法。所以最优子结构只是一个线索,不是看到有最优子结构就一定是用动态规划求解
重叠子问题
• 子问题空间必须足够“小”,即在不断的递归过程中,是在反复求解大量相同的子问题,而不是每次递归时都产生新的子问题。
• 一般的,不同子问题的总数是输入规模的多项式函数为好
• 如果递归算法反复求解相同的子问题,我们就称最优化问题具有重叠子问题性质
对于前面的分治解法,我们的计算过程分为两个阶段:
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 推演即可。
如果觉着顺序抽象,可以在上述分治解法的基础上,打印出缓存数组的演变过程,来理解回归的计算顺序。