题目分析
题目链接:https://leetcode.com/problems...
经典的动态规划问题——最长递增子序列(一)
在上一篇博客中,我们介绍了最长递增子序列(LIS)问题的一个动态规划算法,时间复杂度为O(n^2)(如果使用二叉树能降低到O(nlogn))。在这篇文章我们再分析一个O(nlogn)的巧妙算法。思路来自:https://www.geeksforgeeks.org...
应该存储哪些递增子序列
从左到右扫描输入的过程中,我们能够构造出很多种递增子序列,我们要存储这些中间子序列,以便与将来扫描到的元素拼接,产生更长的递增子序列。如此往复,最终产生最长的递增子序列。
比如对于输入2, 11, 4, 12, 6, 1
,当扫描到6
的时候,我们用已经扫描过的元素可以构造出:
- 4个只包含一个元素的递增子序列:
2
,11
,4
,12
- 2个包含包含两个元素的递增子序列:
2, 4
,11, 12
显然,如果将所有可以构造出的子序列都存储起来,不但浪费空间,而且在查找符合条件的子序列的时候也会消耗很多时间。我们只需要存储那些“有潜质”的递增子序列。“有潜质”表示这个子序列更有可能构造出LIS,它包含两个方面的标准:
- 长度越长越好
- 结尾元素的值越小越好
对于2, 11, 4, 12, 6, 1
的例子,在6
之前构造的那些子序列,很多是不需要存储的:
- 存储
2, 4
而不需要存储4
(结尾元素相同而长度更短) - 存储
2, 4
而不需要存储11, 12
(长度相同而结尾元素更大)
但是,我们存储了2, 4
,还要不要存储2
呢?它虽然结尾元素更小,但是长度也更小。
答案是要的,考虑2, 5, 3, 1, 2, 3, 4, 5, 6
在扫描到1
的时候,已经存储了2, 3
,但是如果不存储单元素递增子序列1
,最终就没办法构造出LIS1, 2, 3, 4, 5, 6
。
那些结尾元素更小的递增子序列,即使现在长度还很短,但是将来可能接收更多的元素,成为最长递增子序列。因此,那些结尾元素更小的节点也要存储。
综上所述,对于每一种长度的递增子序列,我们都要存储一个结尾元素最小的。
如何存储构造出递增子序列
按照上面的结论,我们可以在实现算法的时候,使用一个数组buildingLists
,数组的第n项存储长度为n的“最有潜质的”(也就是结尾元素最小的)递增子序列。
我们只在意序列的长度和结尾元素,序列前面的那些元素不会对将来添加元素造成影响。因此
存储递增序列的时候只需要存结尾元素,子序列在
buildingLists
中的下标就是它的长度。
按照这种方式存储的buildingLists
,其中每个子序列的结尾元素必定是严格递增的(长度更长的序列一定结尾元素更大)。可以用反证法证明:
假设i
buildingLists[j]
(最有潜质的长度为j的递增子序列)的结尾元素比buildingLists[i]
的结尾元素更小或相等。可以推理出:buildingLists[j]
代表的子序列,只要取出它的前i项就是一个比buildingLists[i]
更好的子序列。可是按照定义,buildingLists[i]
才应该是最有潜质的、长度为i的递增子序列啊!这就推出了一个矛盾。
扫描时如何构造递增序列
好,现在我们已经知道要存储哪些、如何存储目前已经构造出的递增子序列,那么我们要如何在扫描的过程中构造递增子序列呢?原理是很简单的:如果当前扫描到的元素比某个已有子序列的结尾元素更大,那么就可以将当前元素拼接到这个子序列的后面。
但是,如果有多个子序列(每个子序列长度不同)都满足这个要求呢?此时应该选择尽可能长的(等价于结尾元素尽可能大)那个已有子序列来拼接。其中的道理可以用通俗的话来解释:假设新扫描到的元素是X,反正不管拿哪个已有序列来拼接X,拼接以后结尾元素必定是X(结尾元素可以确定),那还不如拿一个原本就最长的那个子序列来和X拼接,得到的新序列能更长一点!
比如已经有这3个子序列:
- 0
- 0, 4
- 0, 4, 12
现在扫描到8。前两个序列都可以在后面拼接8。反正不管拿0
还是0, 4
来拼接,拼接完以后的结尾元素都是8,那当然是用0, 4
来拼接啊!得到的新序列长度更大!
拼接以后得到了0, 4, 8
,显然比0, 4, 12
更有潜质,因此将buildingLists[3]
从12更新为更好的8。
完整例子
现在看一个完整的例子来测试我们已经有的思路。输入为0, 8, 4, 12, 2, 10, 6, 14, 1, 9, 5, 13, 3, 11, 7, 15
:
A[0] = 0. Case 1. There are no active lists, create one.
0.
-----------------------------------------------------------------------------
A[1] = 8. Case 2. Clone and extend.
0.
0, 8.
-----------------------------------------------------------------------------
A[2] = 4. Case 3. Clone, extend and discard.
0.
0, 4.
0, 8. Discarded
-----------------------------------------------------------------------------
A[3] = 12. Case 2. Clone and extend.
0.
0, 4.
0, 4, 12.
-----------------------------------------------------------------------------
A[4] = 2. Case 3. Clone, extend and discard.
0.
0, 2.
0, 4. Discarded.
0, 4, 12.
-----------------------------------------------------------------------------
A[5] = 10. Case 3. Clone, extend and discard.
0.
0, 2.
0, 2, 10.
0, 4, 12. Discarded.
-----------------------------------------------------------------------------
A[6] = 6. Case 3. Clone, extend and discard.
0.
0, 2.
0, 2, 6.
0, 2, 10. Discarded.
-----------------------------------------------------------------------------
A[7] = 14. Case 2. Clone and extend.
0.
0, 2.
0, 2, 6.
0, 2, 6, 14.
-----------------------------------------------------------------------------
A[8] = 1. Case 3. Clone, extend and discard.
0.
0, 1.
0, 2. Discarded.
0, 2, 6.
0, 2, 6, 14.
-----------------------------------------------------------------------------
A[9] = 9. Case 3. Clone, extend and discard.
0.
0, 1.
0, 2, 6.
0, 2, 6, 9.
0, 2, 6, 14. Discarded.
-----------------------------------------------------------------------------
A[10] = 5. Case 3. Clone, extend and discard.
0.
0, 1.
0, 1, 5.
0, 2, 6. Discarded.
0, 2, 6, 9.
-----------------------------------------------------------------------------
A[11] = 13. Case 2. Clone and extend.
0.
0, 1.
0, 1, 5.
0, 2, 6, 9.
0, 2, 6, 9, 13.
-----------------------------------------------------------------------------
A[12] = 3. Case 3. Clone, extend and discard.
0.
0, 1.
0, 1, 3.
0, 1, 5. Discarded.
0, 2, 6, 9.
0, 2, 6, 9, 13.
-----------------------------------------------------------------------------
A[13] = 11. Case 3. Clone, extend and discard.
0.
0, 1.
0, 1, 3.
0, 2, 6, 9.
0, 2, 6, 9, 11.
0, 2, 6, 9, 13. Discarded.
-----------------------------------------------------------------------------
A[14] = 7. Case 3. Clone, extend and discard.
0.
0, 1.
0, 1, 3.
0, 1, 3, 7.
0, 2, 6, 9. Discarded.
0, 2, 6, 9, 11.
----------------------------------------------------------------------------
A[15] = 15. Case 2. Clone and extend.
0.
0, 1.
0, 1, 3.
0, 1, 3, 7.
0, 2, 6, 9, 11.
0, 2, 6, 9, 11, 15. <-- LIS List
----------------------------------------------------------------------------
扫描完所有的输入以后,buildingLists
中长度最长的那个递增子序列就是LIS。
算法实现
class Solution
{
public:
int lengthOfLIS(vector &nums)
{
const int size = nums.size();
// 注意,buildingLists[n]表示长度为n+1的子序列
vector buildingLists;
// 依次扫描输入
for (int i = 0; i < size; i++)
{
// lower_bound找出指定返回内第一个大于等于nums[i]的元素
// 假设找出来的序列长度为L
// 通过向长度为L-1的已有序列的结尾拼接nums[i]
// 形成长度为L的、结尾元素更小的递增子序列
auto newListSpot = lower_bound(buildingLists.begin(), buildingLists.end(), nums[i]);
if (newListSpot == buildingLists.end())
{
// lower_bound返回end迭代器,表示找不到大于等于nums[i]的元素
// 也就是说nums[i]可以直接拼接到最长的已有序列后面
buildingLists.push_back(nums[i]);
}
else
{
// 如果找到了结尾大于等于nums[i]的序列(假设长度为L)
// 说明我们可以构造出结尾元素更小(为nums[i])的、长度为L的序列来替换它
*newListSpot = nums[i];
}
}
return buildingLists.size();
}
};
lower_bound实际上是二分查找(我们已经在前面证明了buildingLists
是严格单调递增的),返回第一个大于等于nums[i]的元素的指针,因此lower_bound操作耗时O(logn)。整个算法就是一层for(n)循环中嵌套了logn的操作,因此总的耗时为O(nlogn)。
为什么这是一个动态规划算法
可能不太容易看出为什么这个算法是一个动态规划算法。事实上,在这个动态规划算法中我们研究的问题是已扫描到的数字能构成哪些(有潜质的)递增子序列,buildingLists
中存储的就是上一个问题的解,其中最长的那个子序列就是已扫描到的数字能构成的最长递增子序列。
LIS问题的结构:
其中,【箭头左边的问题】是【箭头右边的问题】的子问题。从左到右解决问题,最终得到原问题的解。
扫描下一个元素,用这个元素拼接到buildingLists
中的某个序列尾部,然后更新buildingLists
,这一串动作实际上就是在用子问题的解来计算父问题的解。buildingLists
就是这个动态规划算法的记忆存储,由于每个问题的解决都只依赖于上一个子问题,因此我们只需要存储上一个问题的解(即buildingLists
)。