LeetCode之路:122. Best Time to Buy and Sell Stock II

来源:互联网 发布:linux查看用户信息 编辑:程序博客网 时间:2024/06/05 18:11

一、引言

这是一道非常有趣的题目!
这是一道非常有趣的题目!!
这是一道非常有趣的题目!!!

重要的事情先说三遍 : )

好了,接下来让我们看看这道题:

Say you have an array for which the i^th element is the price of a given stock on day i.

Design an algorithm to find the maximum profit. You may complete as many transactions as you like (ie, buy one and sell one share of the stock multiple times). However, you may not engage in multiple transactions at the same time (ie, you must sell the stock before you buy again).

这道题居然没有例子?! T_T,然后还有一大段的英文描述~~~

说实话,我第一次看到这道题,拿着百度翻译看题目居然都没有看懂,然后逐字逐句拿着程序运行查看正确结果后才弄清楚了题意,现在翻译如下:

话说你现在有一个数组,这个数组呢,第 i 个元素就是给定货物第 i 天的价格信息。

请你设计一个算法能够达到最高的收益。你需要完成你想要的交易(比如,先买入货物然后再卖出货物多次)。然而,你只能在同一天交易一次(买入或者卖出),并且你在卖出货物之前,必须要先买入货物。

为了避免你们只读了题目无法理清楚头绪(我也是通过提交空代码查看 Expected Answer 才弄清楚题意的),我还是举个我自己的例子来说明下什么叫做最大的收益:

举例:

[2, 4, 1, 0, 1, 0, 3]

看看这个例子,或许有些极端(这个货物在某些天居然是 0 价格~~~),我们来看看我们如何在 7 天(给定数组的长度)内得到最大收益:

第几天 货物价格 我们的选择 收益 一 2 看到第二天价格升高,今天我们买入 -2 二 4 看到第三天价格降低,今天我们卖出 +4 三 1 看到第四天价格降低,今天我们什么也不做 +0 四 0 看到第五天价格升高,今天我们买入 -0 五 1 看到第六天价格降低,今天我们卖出 +1 六 0 看到第七天价格升高,今天我们买入 -0 七 3 今天是最后一天,卖出我们手上的货物 +3

这个表格非常清晰地说明了我们作为一个追求利益最大化的商人是如何处理手上的资本不断进行货物的买入卖出从而得到利益的。

仔细感受下这个过程,能不能总结出来一个所有情况适用的规律呢?总结不出来也没关系,我也不是一下子就总结出来的,让我们再认真想想:

  1. 首先,我们一直在看的,都是后面的价格,也就是说前面的价格信息对于我们的行为没有任何影响。很好,这里我们获得了第一个信息:我们在一次买入和卖出行为时,只关心后面的价格信息

  2. 然后,我们总是在最高价格的时候卖出的吗?如果是这样的话,我们为什么要在第五天进行卖出呢(第五天的价格并非最高价格)。那么我们是什么时候才进行卖出呢?仔细看看价格规律,看到了吗,我们总是在知道明天会降价的时候,我们才会当天卖出!很好,这里我们得到了一个至关重要的信息:我们总是在明天要降价的情况下卖出手上的货物

  3. 最后,那么我们在何时买入货物呢?同样的道理,我们也不是在最低价格的时候买入(与第 2 条同样的道理),我们是在刚卖出之后,得知后面会涨价的时候,我们才会进行货物的买入操作!很好,我们又获得了一个非常重要的信息:我们总是在知道明天会涨价的情况下买入货物

相信只要你仔细看到了这里,这道题的题意你一定已经弄清楚了,那么就让我们思考下,这道题到底应该怎么做呢?

二、简单的抽象:买入卖出行为背后的映射

在引言里,我通过举例阐述了题意,并且详细分析了作为一个追求利益最大化的商人在每一天的行为。

那么商人的买入卖出行为能不能抽象为我们的代码逻辑呢?

还是让我们再看向引言里我举的例子,总结下我们找到的这三条关键信息:

  1. 我们在一次买入和卖出行为时,只关心后面的价格信息

  2. 我们总是在明天要降价的情况下卖出手上的货物

  3. 我们总是在知道明天会涨价的情况下买入货物

让我们仔细看看这三条信息,“我们只有在知道明天要降价的情况下卖出” 和 “我们只有在知道明天要涨价的情况下买入”,那么如果我们将买入和卖出看作一个单元操作的话:

从买入到卖出货物的过程中,货物的价格总是呈上升趋势

也就是说,只有当我们做到了在价格变化的最低点买入货物,在价格变化的最高点卖出货物,我们才能得到最大的收益。

那么,这个规律相对于程序设计层面上的抽象是什么呢:

将价格数组分割为这样的小数组:每个小数组都是顺序增大的;在每个数组中,我们在左边的最小值进行买入,右边的最大值进行卖出

如果你还没懂的话,我这里简单画了一个图进行阐述:

split array

这里,我们只需要将每个小数组的最右边的元素减去最左边的元素即可得到一次买入卖出的收益值。

经过我们的分析,代码其实就已经出来了:

// my solution 1 , runtime = 6 msclass Solution1 {public:    int maxProfit(vector<int>& prices) {        if (prices.size() <= 1) return 0;        auto preIt = prices.begin();        vector<int> split;        int profit = 0;        for (auto curIt = prices.begin() + 1; curIt != prices.end(); ++preIt, ++curIt)            if (*preIt > *curIt) split.push_back(preIt - prices.begin());        if (split.size() == 0) profit = prices[prices.size() - 1] - prices[0];        else {            for (int i = 0; i < split.size(); ++i) {                if (i == 0) profit += prices[split[0]] - prices[0];                else profit += prices[split[i]] - prices[split[i - 1] +1];            }            profit += prices[prices.size() - 1] - prices[split[split.size() - 1] + 1];        }        return profit;    }};

这段代码比较复杂,这里好好解释下:

  1. 首先,我们要明确我们要干嘛,我们需要分割数组,怎么分割呢?按照“上一个元素值大于了下一个元素的值”为标准进行分割,所以我们需要两个指针对数组进行遍历,因此,我们需要做的第一步就是判断数组是否有至少 1 个元素

  2. 然后,我们明确了数组含有至少 1 个元素,我们声明两个指针(迭代器),对参数数组 prices 进行遍历,当 preIt 指向的元素值大于了 curIt 指向的元素值,我们就将这个 preIt(也就是一个数组的最右边的分割地)记入我们的分割记录 split 中;直到我们遍历结束,我们也就拥有了数组中间(无开头和结尾)的分割记录 split 数组

  3. 最后,我们需要拿着分组信息计算我们的最大收益值;第一步,我们需要判断分组数组的大小,如果没有分组信息,那么证明价格数组就是规律排序的数组,那么只需要拿价格数组的最右边减去最左边即可;如果有分组信息,我们就需要将各个分组中的最右边减去最左边的值(其中第一个分组的最左边是价格数组的第一个元素,最后一个分组的最右边是价格数组的最后一个元素)递加;最后我们返回我们计算的收益值即可

split 的作用

怎么样,相信经过了我的图文并茂的解释,你应该理解了这段代码的逻辑。

如果你是自己通过分析做出来这道题的,那么从读题到抽象,从抽象到代码实现,都是一个非常有趣的过程~~~

三、更优的追求:逻辑、代码的简化

还有没有其他的方法呢?

这里我想到了一点优化的方案:上一个方法我们分割了数组,将分割信息存储到了 split 数组(split 数组中存储的是位置信息而非价格信息,不能直接用作计算)中去,最后从这个 split 数组中读取数据时处理处理下标处理时有点复杂,能不能简化些呢?

答案当然是可以的:

这里写图片描述

这里我利用两个数组,前者 buy 数组记录买入日期的价格,后者 sell 数组记录卖出日期的价格,这样我们最后只需要对这个数组进行整合计算即可,代码如下:

// my solution 2 , runtime = 13 msclass Solution2 {public:    int maxProfit(vector<int>& prices) {        if (prices.size() <= 1) return 0;        vector<int> buy;        vector<int> sell;        int profit = 0;        buy.push_back(prices[0]);        for (auto pre = prices.begin(), cur = prices.begin() + 1; cur != prices.end(); ++pre, ++cur)            if (*pre > *cur) {                buy.push_back(*cur);                sell.push_back(*pre);            }        sell.push_back(prices[prices.size() - 1]);        if (buy.size() == 1 && sell.size() == 1) return prices[prices.size() - 1] - prices[0];        for (int i = 0; i < buy.size(); ++i)            profit += sell[i] - buy[i];        return profit;    }};

代码逻辑和上一个方法是一样的,只是下面这种方法呢,在计算差值的时候能更加简便些。

那么还没有其他的方法呢?我实在是不想再处理数组下标了!

答案当然是有的。

这里我使用了 std::queue 实现了另一个版本的代码,思路与上述方法都是一样的,直接看代码吧:

// my soluiton 3 use std::queue , runtime = 6 msclass Solution3 {public:    int maxProfit(vector<int>& prices) {        if (prices.size() <= 1) return 0;        int profit = 0;        queue<int> stock;        for (auto i : prices) {            if (stock.empty() || stock.back() < i) {                stock.push(i);            }            else {                profit += stock.back() - stock.front();                while (!stock.empty()) stock.pop();                stock.push(i);            }        }        profit += stock.back() - stock.front();        return profit;    }};

顾名思义, std::queue 也就是队列,这个版本的实现方法与上述方法的思路没有什么不同,只是处理数据的数据结构更换成了队列。

为什么使用队列呢?因为对于这种只处理头跟尾数据的情况,我思考觉得使用队列是最适合的。

关于 std::queue 还是有必要说一下的,比如说这里:

while (!stock.empty()) stock.pop();

其实这里还可以简化为:

// C++11stock = {};

那么这里为什么要这么写呢?不能直接使用 clear() 方法吗?

对的,std::queue 还真的不支持 clear() 操作,别说它,甚至 std::stack 也不支持,那么这是为什么呢?

why std::queue not support clear funtion

这段解释来源于 StackOverflow 社区,感兴趣的同学可以点击这里 why std::queue doesn’t support clear() function?。

这段话比较生涩,我尽自己最大的努力翻译一下:

队列是一种适配器容器,是一种使用了特殊的密封容器作为它的底层实现的容器,提供特殊访问底层容器的一些方法。

这也意味着队列使用的是已经存在的容器,它也确实只是这个容器的实现 FIFO (先进先出)的接口类而已。

这也就解释了队列不能使用清空操作的原因。如果你需要清空一个队列,那么这意味着你实际上需要的是一个实体类而非队列,所以你应该使用底层容器来替代而非一个队列。

这里翻译的非常生涩,麻烦大家忍痛看看哈 ~~~ 大概含义也解释清楚了,这里使用 std::queue 明显使我们的问题直接简化了一个数量级,可谓是体会到了数据结构的威力。

尽管想了很久很久,能不能再优化再优化,还是黔驴技穷了 T_T ~~~

也罢,让我们看看最高票答案的简洁优雅吧!

四、简洁的美:谁叫我是最高票答案呢

既然你已经耐心的看到了这里,那么我也就大发慈悲地拿出了最高票答案来震撼震撼你的心灵 :

Tree lines in C++

// perfect solution tree lines codeclass Solution4 {public:    int maxProfit(vector<int> &prices) {        int ret = 0;        for (size_t p = 1; p < prices.size(); ++p)            ret += max(prices[p] - prices[p - 1], 0);        return ret;    }};

什么!只有这么几行代码?!
什么!这里的 std::max 是干什么用的?!

让我们看看作者的解释吧:

suppose the first sequence is “a <= b <= c <= d”, the profit is “d - a = (b - a) + (c - b) + (d - c)” without a doubt. And suppose another one is “a <= b >= b’ <= c <= d”, the profit is not difficult to be figured out as “(b - a) + (d - b’)”. So you just target at monotone sequences.

简单翻译下:

假定第一种顺序是这样的: a <= b <= c <= d,那么此时的收益值为:d - a = (b - a) + (c - b) + (d - c),这一点毋容置疑(这一块只要你理解了我之前的逻辑,这个式子很好理解);现在假想第二种顺序:a <= b >= b’ <= c <= d,那么现在的收益值应该不难得出为:(b - a) + (d - b)。那么你就可以得到结果了。

说实话,这个方法理解起来比较难但是我们把第二种顺序的收益值计算方式分开:

(b - a) + (d - c) == (b - a) + (c - b’) + (d - c)

看到这里如果你还不明白,就跟着代码走一遍 a <= b >= b’ <= c <= d 顺序就知道结果是怎么出来的了。

我的天!
太神奇了!
真心觉得巧妙,作者一定是一个数学天才!在这里给这段代码的作者点 999+ 个赞~~~

五、总结

这道题我做了好久好久,从一开始的分析,到之后做出了第一个方法,然后开始思考如何优化代码,之后思考出来了使用队列;再之后思考不出来更优的办法了,点开看了看最高票答案,又一次心灵受到了强烈的震撼;真是一次奇妙的体验啊。

写了三个版本的代码,又体验了一把 std::queue,自己的体验来说,收获还是很多的;最高票答案虽说非常巧妙,可是我觉得要是直接写出了最高票答案其实难免觉得有点不尽兴。因为其他没想到这个方法的人,绕了太阳系走了一大圈,最终走到了目的地,虽说不如最高票答案简洁优雅,却也是提升了不少技能点呢~~~

这篇博客也写了好久,认认真真地写清楚了自己的实现逻辑,因为自己的能力不足,在代码的编写上,逻辑的分析上,甚至部分英文的翻译上难免有些出入,请各位读者谅解。

最后的最后,献给每一个正在辛苦刷 LeetCode 的人:

To be Stonger!

原创粉丝点击