Best Time to Buy and Sell Stock 系列算法题

tags: 算法, LeetCode, swift, 动态规划

这是最近在学算法中完成的第一个系列题,系列题的特点是后一道是前一道的升级版,后一道比前一道难,基本是一道比一道有意思,所以写一下总结是必须的呀。那为啥要重新学算法,大概是因为之前学得水吧。其实最近的计划是保持进阶学习的同时,不断地巩固基础,不断地进行精深练习。然后刚好遇到一个大神主播最近在讲算法,那就跟着学吧。

好了,今天的主角是LeetCode上的这几道题:
121 Best Time to Buy and Sell Stock
122 Best Time to Buy and Sell Stock II
123 Best Time to Buy and Sell Stock III
188 Best Time to Buy and Sell Stock IV
309 Best Time to Buy and Sell Stock with Cooldown

其实主角是应该是动态规划,这类最佳xx的题目,一看就先想到动态规划。每一道题大概我都会写下我当时的思路,和怎么不通过,怎么改进,怎么优化。

121 Best Time to Buy and Sell Stock

原题地址。题目的意思大概是,给你一个股票的每天价格数组prices,你在只能进行一次操作(即一次买入和一次卖出)的限制下,求最大的收益。比如每天的价格为[7, 1, 5, 3, 6, 4],那么最大收益就是1的时候买入,6的时候卖出,这样就能得到最大收益5。

第一步,明确下边界,当输入为空数组时,那最大收益就是0。而每天的价格,肯定是正数。

第二步,考虑不同的解决方案,这道题,用动态规划肯定是可以解的,然后其他方案,我也想不出来。
动态规划,先定义个dp[i]来表示第i天的最大收益,
dp[i] = prices[i] - prices[j]第j天买入的价格,因为交易只能有一次,所以,当买入卖出完成后,就能缓存当前结果了。
用swift来写的话,大概的递推过程如下:

var maxIncome = 0
for i in 0 ..< prices.count {
    for j in 0 ..< i {
        if prices[i] - prices[j] > maxIncome {
            maxIncome = prices[i] - prices[j]
        }
    }
}
return maxIncome

这样就能算出最大的收益了,不过如果把这段代码方式LeetCode上跑,结果是超时的。为啥呢?
额,在解释前,我说个LeetCode傻逼的地方,因为swift比较新吧,然后版本更迭又挺频繁的,所以swift的代码,LeetCode上很难保证其运行时间,比如说在同样时间复杂度的解题方案下,用JavaScript写的能过,用swift的不能过,额,真是坑爹。我被这个坑了几个小时,害我不断地优化代码,所以,虽然我是用swift来解题,但是有时候,我是用js复写了一遍swift的代码然后来提交的。
好了,上面这个解法为啥超时呢。。。根据经验,应该是重复计算的问题,但其实这里并没有重复计算,而是计算了很多本不需要计算的结果。第二个循环其实很多计算是不必要的,因为,我们要得出当前差值最大,那肯定是减去在i之前最小的值,而不需要去减i之前的每个数
所以,优化后,代码如下:

var maxIncome = 0
var minValue = Int.max
for i in 0 ..< prices.count {
    if prices[i] < minValue {
        minValue = prices[i]
    }
    if i > 0 {
        if prices[i] - minValue > maxIncome {
            maxIncome = prices[i] - minValue
        }
    }
}
return maxIncome

上面的两个方案,从复杂度来看,一个是O(n^2),一个是O(n),然后提交,accepted!!!

第三步,优化代码
其实这一步在第二步已经进行过一次了,即复杂度的优化,接下来就是一些小的优化,和代码风格的优化。
1、minValue的初始值可以为第一天的价格
2、在递推过程中,可以从第2天的开始,即i=1的时候。

var maxIncome = 0
var minValue = prices[i]
for i in 1 ..< prices.count {
    if prices[i] < minValue {
        minValue = prices[i]
    }
    if prices[i] - minValue > maxIncome {
        maxIncome = prices[i] - minValue
    }
}
return maxIncome 

上面的代码可以用三目运算符继续缩减行数的,不过,我觉得这样已经差不多了。ps,上面3段代码都是重新手打的,不保证通过啊。

122 Best Time to Buy and Sell Stock II

原题地址。这道题是在上题的基础上,然后不限制交易次数,但是每次交易不能同时进行,即买入后要卖出才能进行下一次买入的操作。
第一道题已经把基本的、通用的结果步骤写出来了,我解题的时候大概也是遵循这样的步骤来解,所以第二题就不详细啰嗦的写了。
动态规划的第一步是给出状态的定义,在这道题中
我们这样来定义最大收益dp:

dp[i][0],dp[i][1]代表 the i day, have or not stock,即第i天是否拥有股票哟,0代表没有,1代表当天持有股票。

第二步是给出状态递推方程

dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i])  
//即第i天没股票的最大收益,dp[i-1][0]代表前一天没有股票,dp[i-1][1]+prices[i]前一天有股票并卖出,而第i天的最大收益会是这两者之间的最大值。
dp[i][1] = max(dp[i - 1][0] - prices[i], dp[i - 1][1])  
//即第i天持有股票时的最大收益,dp[i-1][0]-prices[i]代表前一天没有股票并买入,dp[i-1][1]表示前一天持有股票的最大收益

有了上面的递推方程,即dp[prices.count-1][0],最后一天没有股票,就是当前的最大收益了。

其实这里可能大家会有疑惑,为啥dp要是二维数组,为什么一个为天数,一个为是否持股。额,其实,动态规划就是把每个因素转化为一个维度,这是标准的动态规划递推方程,每当有不同的决定因素时,往往可以通过增加维度来解题。但是增加维度的方式有多种,一位数组变成二维数组是一个方法,而增加不同的变量也是可以达到增加维度的效果的,如这里,你可以定义,s0[i],s1[i]分别为第i天没持股,和持股的最大收益。这样就不需要二维数组啦,但是方法的本质是一样。

代码如下(swift):

func maxProfit(_ prices: [Int]) -> Int {
    if prices.count <= 1 { return 0 }
    var dp: [[Int]] = Array(repeating: [0, 0], count: prices.count)
    dp[0][0] = 0
    dp[0][1] = -prices[0]
    for i in 1 ..< prices.count  {
        dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i])
        dp[i][1] = max(dp[i - 1][0] - prices[i], dp[i - 1][1])
    }
    return dp[prices.count - 1][0]
}

这道题还有个神奇的解法,不过跟动态规划没啥关系,我是看着别人代码写出来的😂,自行理解吧:

func maxProfit(_ prices: [Int]) -> Int{
    var max = 0
    for i in 1 ..< prices.count {
        if prices[i] > prices[i - 1] {
            max += prices[i] - prices[i - 1]
        }
    }
    return max
}

Best Time to Buy and Sell Stock III 和 IV

原题III
原题IV

第三题是限制次数两次,第四题是限制交易次数k次,在解第三题的时候,可以用一些方式,达到不增加维度的方式来解题,具体参照LeetCode讨论区里面的解法吧。而第四题是比第三题更加具有普遍性,上面说过,如果有新的决定因素,我们可以将它作为一个新的维度。即:
//dp[i][j][0] dp[i][j][1] the i day, have j transactions, have or not stock
//dp[i][j][0] = max(dp[i - 1][j][0], dp[i - 1][j - 1][1] + prices[i])
//dp[i][j][1] = max(dp[i - 1][j][0] - prices[i], dp[i - 1][j][1])
第一行是状态定义,跟之前的定义类似,但是增加了一个维度,即第i天已经有j次交易是否持股的最大收益。
第二、第三行是状态转移方程,首先,买入股票并没有完成一次操作,而是在卖出之后,才会增加交易次数。所以能得出上面的两个方程。
代码如下:

func maxProfit(_ k: Int, _ prices: [Int]) -> Int {
    if prices.count <= 1 { return 0 }

    var dp: [[[Int]]] = Array(repeating: Array(repeating: [Int.min, Int.min], count: k + 1), count: prices.count)
    dp[0][0][0] = 0
    dp[0][0][1] = -prices[0]
    
    var maxIncome = 0
    for i in 0 ..< prices.count  {
        for j in 0 ... k {
            if i > 0 {
                if j > 0 {
                    dp[i][j][0] = max(dp[i - 1][j][0], dp[i - 1][j - 1][1] + prices[i])
                } else {
                    dp[i][j][0] = 0
                }
                dp[i][j][1] = max(dp[i - 1][j][0] - prices[i], dp[i - 1][j][1])

                maxIncome = max(maxIncome, dp[i][j][0])
            }
        }
    }
    return maxIncome
}

maxIncome用来缓存j次交易后的最大收益,最后就是最大收益。上面的代码其实是不能通过, 但是这并不是重点。上面的代码大概就代表了整个解题的思路和递推过程。

假如理解了上面的代码,再来理解下面完整的代码,其实整体没啥变化,加了一些优化而已:

func maxProfit(_ k: Int, _ prices: [Int]) -> Int {
    if prices.count <= 1 { return 0 }
    
    if k > prices.count / 2 {
        return maxIncomeK(prices)
    }
    
    var dp: [[[Int]]] = Array(repeating: Array(repeating: [Int.min, Int.min], count: k + 1), count: 2)
    dp[0][0][0] = 0
    dp[0][0][1] = -prices[0]
    
    var maxIncome = 0
    for i in 1 ..< prices.count  {
        let x = i % 2
        let y = (i - 1) % 2
        for j in 0 ... k {
            if j > 0 {
                dp[x][j][0] = max(dp[y][j][0], dp[y][j - 1][1] + prices[i])
            } else {
                dp[x][j][0] = 0
            }
            dp[x][j][1] = max(dp[y][j][0], dp[y][j][1] + prices[i]) - prices[i] //避免Int.min减去某个数,导致的溢出崩溃。
            maxIncome = max(maxIncome, dp[x][j][0])
        }
    }
    return maxIncome
}

func maxIncomeK(_ prices: [Int]) -> Int{
    var max = 0
    for i in 1 ..< prices.count {
        if prices[i] > prices[i - 1] {
            max += prices[i] - prices[i - 1]
        }
    }
    return max
}

上面的代码变量名、方法名有些不是起得很合理,词穷啊,想名字好难。上面的代码对比之前的方案,差别在于:
1、当k > prices.count / 2,这个时候就变回第二题了,这里的maxIncomK是第二题中那个神奇的解法。
其实完成上面这步处理,这道题就算完了,但是还有优化的地方,这里不能理解就略过吧,不过连我都能理解,应该没啥难度
2、状态压缩:var dp在定义的时候,第一维度只用来2个空间大小,而let x = i % 2和let y = (i - 1) % 2则是配套使用,额,其实本质是因为maxIncome用来缓存了当前的最大收益,所以这里可以不用那么多空间来存储,只需要用到这次跟上一次,这两个存储空间就够用了,这里属于空间复杂度的优化。
3、在递推过程里面,dp[x][j][1]的方程是不是有点奇怪,其实这是为了解决swift中Int.min减去某个数,导致的溢出崩溃。因为我们初始化的时候把每个值都初始化为Int.min。
好像没其他了,这道题就这样吧。

309 Best Time to Buy and Sell Stock with Cooldown

这里每次交易完成后,必须冷静下,即至少暂停一天的操作,不能买不能卖,冷静一天都才有所决策。
这道题其实难度比3、4小,如果3、4都理解了,那么这道题就不难理解了。
下面给出我的解题方案(js):

/**
 * @param {number[]} prices
 * @return {number}
 */
var maxProfit = function(prices) {
    if (prices.length <= 1) return 0 
    var dp = []
    for (let i = 0; i < prices.length; i++) {
        dp[i] = []
    }
    
    dp[0][0] = 0
    dp[0][1] = -prices[0]
    dp[0][2] = Number.MIN_VALUE
    for (let i = 1; i < prices.length; i++)  {
        dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][2])
        dp[i][1] = Math.max(dp[i - 1][0] - prices[i], dp[i - 1][1])
        dp[i][2] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i])
    }
    return Math.max(dp[prices.length - 1][0], dp[prices.length - 1][2])
}

为啥我用了js来写,因为就是最前面说的LeetCode上swift抽风的问题。
dp[i][0] 表示第i天没有持股的最大收益
dp[i][1] 表示第i天有持股的最大收益
dp[i][2] 表示第i天没有持股,并且刚刚卖出了股票,如果是这个状态,那接下来就要冷静啦。即不能马上持股。

写在最后

这几道题目在写完之后确实收获不小,建议大家都亲手写一遍吖。

有人说算法没什么鸟蛋用,额,其实,写算法跟写业务差不多,好像这句我说过啊。
按我目前的理解,算法是进行精深练习的一个比较方便快捷的途径,不过也是比较原始的。但这能不断强化一些基本的代码能力,思考和抽象的能力吧。而且算法作为计算机的基础课程,对以后进阶有很大帮助吖。
上面这段我乱说的,大家自行理解吧。

还有一个,说到动态规划,我之前二话不说就是一个递归。但其实动态规划更应该用递推而不是递归。
所以以后看到动态规划的题,还是用递推吧。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,026评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,655评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,726评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,204评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,558评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,731评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,944评论 2 314
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,698评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,438评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,633评论 2 247
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,125评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,444评论 3 255
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,137评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,103评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,888评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,772评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,669评论 2 271

推荐阅读更多精彩内容