前言
今天的学习内容是【动态规划】。原来文章可以改主题的呀。
第三天
今天重点学习的是动态规划,它是算法与数据结构的重难点之一。在此之前,先做一道算法题吧!累计的题目数。
第九题
剑指 Offer 67. 把字符串转换成整数
写一个函数 StrToInt,实现把字符串转换成整数这个功能。不能使用 atoi 或者其他类似的库函数。
首先,该函数会根据需要丢弃无用的开头空格字符,直到寻找到第一个非空格的字符为止。
当我们寻找到的第一个非空字符为正或者负号时,则将该符号与之后面尽可能多的连续数字组合起来,作为该整数的正负号;假如第一个非空字符是数字,则直接将其与之后连续的数字字符组合起来,形成整数。
该字符串除了有效的整数部分之后也可能会存在多余的字符,这些字符可以被忽略,它们对于函数不应该造成影响。
注意:假如该字符串中的第一个非空格字符不是一个有效整数字符、字符串为空或字符串仅包含空白字符时,则你的函数不需要进行转换。 在任何情况下,若函数不能进行有效的转换时,请返回 0。
说明: 假设我们的环境只能存储 32 位大小的有符号整数,那么其数值范围为 [−231, 231 − 1]。如果数值超过这个范围,请返回 INT_MAX (231 − 1) 或 INT_MIN (−231) 。
思路: ok,看到这么长的题目别害怕,仔细读其实没那么复杂(才怪)。习惯性地从特殊情况开始写起。先判断空字符串和全是空格的场景。接着再写一个正则表达式用来判断第一个非空格字符是否是整数或小数(说实话,读题的时候真没感觉到有小数的情况,知道后面测试被一个'3.14159'爆了)。
function strToInt(str: string): number {
if(str.split(' ').join('').length===0) return 0 //排除空字符串和全是空格的情况
const intReg = /^([\+\-]?\d+)|(\d+\.\d+)$/ //判断是否是整数或小数
let index = 0 //用来做哈希表的key
const map:Map<number,number> = new Map() //用来存所有的非空字符串,但其实只有第一个有用
str.split(' ').forEach((item)=>{ //例如输入' -1 '就会得到['','-1','']
if(item.length!==0){ //空格都会变成空字符串
index++
if(intReg.test(item)){
const num = parseInt(item)
const result = num>(2**31-1)?(2**31-1):num<-(2**31)?-(2**31):num
map.set(index,result)
}else{
map.set(index,0)
}
}
})
return map.get(1)
};
ok接下来进入动态规划的学习。有点激动。
动态规划的问题特性与解题框架
动态规划是算法与数据结构的重难点之一,其包含了「分治思想」、「空间换时间」、「最优解」等多种基石算法思想。
动态规划特点
「分治」是算法中的一种基本思想,其通过将原问题分解为子问题,不断递归地将子问题分解为更小的子问题,并通过组合子问题的解来得到原问题的解。
类似于分治算法,「动态规划」也通过组合子问题的解得到原问题的解。不同的是,适合用动态规划解决的问题具有「重叠子问题」和「最优子结构」两大特性。 好的又引出两个概念。
重叠子问题
动态规划的子问题是有重叠的,即各个子问题中包含重复的更小子问题。若使用暴力法穷举,求解这些相同子问题会产生大量的重复计算,效率低下。
动态规划在第一次求解某子问题时,会将子问题的解保存;后续遇到重叠子问题时,则直接通过查表获取解,保证每个独立子问题只被计算一次,从而降低算法的时间复杂度。
最优子结构
如果一个问题的最优解可以由其子问题的最优解组合构成,并且这些子问题可以独立求解,那么称此问题具有最优子结构。
动态规划从基础问题的解开始,不断迭代组合、选择子问题的最优解,最终得到原问题最优解。
重叠子问题示例:斐波那契数列
斐波那契数列:[0,1,1,2,3,5,8,...],数学定义:F(0)=0,F(1)=1,F(n)=F(n-1)+F(n-2)
三种方法,介绍重叠子问题的概念和解决方案。
方法一:暴力递归(听着就很卡)
在这个数列中,只有F(0)和F(1)是我们知道的,那在计算机中让他计算,最容易想到的方法就是,把公式丢给它,让他自己从0和1逐渐推到我们想要的第n位。像这样:(暴力递归)
const fun = (n:number)=>{
if(n===0) return 0
if(n===1) return 1
return fun(n-1)+fun(n-2)
}
那我们假设我们仅仅是求一个fun(4)。那会发生什么呢:这个方法会return一个fun(3)+fun(2)。这里已经执行了一次fun(2),不过在fun(3)中,会再return一个fun(2)+fun(1)又会执行一次fun(2)。那么就有:
- 每执行一次fun(),时间复杂度为O(1)
- 二叉树节点数为指数级O(2^n) 因此,暴力递归的总时间复杂度为O(2^n)。不用讲多可怕了。
方法二:记忆化递归
那么像方法一种,那些重复执行的fun(x)就是重叠子问题了。那如果我们在第一次算到这个重复的值的时候就把它储存起来,那么之后再重复查到它的是时候就可以直接拿出来了。
const fun1 = (n:number,dp:number[ ])=>{
if(n===0) return 0
if(n===1) return 1
if(dp[n]!==0) return dp[n] //若第n位以前计算过,则可直接返回
dp[n] = fun1(n-1,dp)+fun1(n-2,dp)
return dp[n]
}
const fun2 = (n:number)=>{ //求第 n 个斐波那契数
const dp = new Array(n).fill(0)
return fun1(n,dp)
}
这样的话,在fun1(x)中,基本上dp[n] = fun1(n-1,dp)+fun1(n-2,dp)里的fun1(n-2,dp)在fun1(n-1,dp)当中就已经算过了,所以时间复杂度就只跟n成线性关系,则时间复杂度为O(n)。
方法三:动态规划
它相当于是一个反过来的记忆化递归,是从f(0)、f(1)也就是从底至顶的方式求f(n),而不是从f(n-1)、f(n-2)算下来,
const fun = (n:number)=>{
if(n===0) return 0
const dp:number[] = new Array(n+1)
dp[1] = 1
for(let i = 2;i<=n;i++){
dp[i] = dp[i-1] + dp[i-2] //求出dp[i]并存入数组
}
return dp[n]
}
而在上面这段代码中,我们使用了dp数组保存子问题的解,其空间复杂度为O(n)。又由于f(n)只与f(n-1)和f(n-2)有关,因此我们可以仅使用两个变量a,b交替进行计算即可。这样空间复杂度就可以降至O(1)
const fun = (n:number)=>{
if(n===0) return 0
let a = 0
let b = 1
for(let i = 2;i<=n;i++){
const tmp = a
a = b
b = tmp + a
}
return b
}
小结
我们可以看到,记忆化递归和动态规划的思路其实是一致的,利用已经算过的来计算未算过的这样可以节省时间。区别只在于记忆化递归:从顶至底而动态规划:从底至顶。
但在斐波那契数列问题中,不包含【最优子结构】,只需计算每个子问题的解,避免重复计算即可,并不需要从子问题组合中选择最优组合。那么下面来看看最优子结构的例子。
最优子结构示例:蛋糕最高售价
有一家蛋糕店,根据蛋糕重量的不同分别设定了不同的售价:
| 蛋糕重量 | 售价 |
|---|---|
| 0 | 0 |
| 1 | 2 |
| 2 | 3 |
| 3 | 6 |
| 4 | 7 |
| 5 | 11 |
| 6 | 15 |
问题:现给定一个重量为n的蛋糕,应该如何切分蛋糕,才能达到蛋糕的最高售价呢? 设重量为n蛋糕的售价为p(n),切分的最高总售价为f(n)。
-
子问题: f(n) 的子问题包括f(0),f(1),f(2),⋯,f(n−1) ,分别代表重量为0,1,2,⋯,n−1 蛋糕的最高售价。 已知无蛋糕时f(0)=0,蛋糕重量为 1 时不可切分f(1)=p(1) ;
-
最优子结构:
定义: 如果一个问题最优解可以由其子问题最优解组合构成,那么称此问题具有最优子结构。
同样用三种方法介绍:
方法一:暴力递归
时间复杂度为O(2^n)
const maxCakePrice = (n:number,priceList:number[])=>{
if (n<=1) return priceList[n]
let f_n = 0
for(let i = 0;i<n;i++){
f_n = Math.max(f_n,maxCakePrice(i,priceList)+priceList[n-i])
}
return f_n
}
我斗胆跑了一下,直接炸了。
方法二:记忆化递归
同样的,这道题也存在着很多的重叠子问题,那么用上面用到的记忆化递归来解决:
const maxCakePrice = (n:number,priceList:number[],dp:number[])=>{
if(n<=1) return priceList[n]
let f_n = 0
for(let i = 0;i<n;i++){
const f_i = dp[i]!==0?dp[i]:maxCakePrice(i,priceList,dp)
f_n = Math.max(f_n,f_i+priceList[n-i])
}
dp[n] = f_n
return f_n
}
const maxCakePriceMemorized = (n:number,priceList:number[])=>{
const dp:number[] = new Array(n+1)
return maxCakePrice(n, priceList, dp)
}
方法三:动态规划
那我们记得,动态规划是从底至顶的方法。
const maxCakePrice = (n:number,priceList:number[])=>{
if(n<=1) return priceList[n]
const dp:number[] = new Array(n+1)
for(let i = 1;i<n;i++){
for(let j = 0;j<i;j++){
dp[i] = Math.max(dp[j],dp[i]+priceList[j-i])
}
}
return dp[n]
}
小结
普遍来看,求最值的问题一般都具有【重叠子问题】和【最优子结构】的特点,因此此类问题往往适合用动态规划解决。
接下来来开心做题!
做题咯
第十题
剑指 Offer 10- I. 斐波那契数列
写一个函数,输入 n ,求斐波那契(Fibonacci)数列的第 n 项(即 F(N))定义如下:
F(0) = 0, F(1) = 1
F(N) = F(N - 1) + F(N - 2), 其中 N > 1.
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。(还真是要求多捏)
思路: 取模的意思其实跟取余是一样的,就是%。但这里有个小细节,是每个f(n)都要取模,而不是只有最终答案要取模。这个点在代码里会标出来。思路就没什么好讲的咧就跟上面的示例一个道理。
function fib(n: number): number {
if(n===0) return 0
let a = 0
let b = 1
for(let i = 2;i<=n;i++){
const tmp = a
a = b
b = (a + tmp)%1000000007
}
return b //是在上面遍历计算中的每次 b 都得取模,而不是单单在return这里取模
};
第十一题
剑指 Offer 10- II. 青蛙跳台阶问题
一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
示例:
输入: n = 2
输出: 2
思路: 说实话,想了一小会儿,一开始以为很简单地直接写出来,发现问题居然是有多少种跳法,是连先后顺序都要算上的,大意了,所以这是一个小小难点吧算是容易忽略。所以思路就是从下往上,记录每n级有多少种跳法。例如我们要算f(4)的时候,我们已经分别记录了f(1,2,3),那么4级实际上可以看作,先跳到3级则只需再跳1级,也可看做先跳到2级则只需再跳2级(跳两次1级的情况会和3+1的重合,所以不用考虑)。
function numWays(n: number): number {
if(n<=1) return 1
if(n===2) return 2
let a = 1
let b = 2
let num = 0
for(let i = 3;i<=n;i++){
num = (a+b)%1000000007
a = b
b = num
}
return num
};
想通了之后实际上也是一个斐波那契数列。
ok今天想早点睡咧,先到这里。 代码敲多了一定要起来走走喝水,要温故而知新。
下面是我的文章会引用到的作者和他的文章,都是跟着它学的,感谢。
作者:Krahets
来源:力扣(LeetCode)