在刚刚结束的第310场周赛中,第四题大部分人都是用线段树实现的,但是由于没有掌握这种高级的数据结构,我并没有ac,但是赛后在题解中发现有人并没有使用到线段树,仍然在 O ( n ∗ l o g ( n ) ) O(n*log(n)) O(n∗log(n)) 甚至 O ( n ∗ l o g ( l o g ( n ) ) ) O(n*log(log(n))) O(n∗log(log(n))) 时间复杂度内解出该题,我认为十分有必要总结一下!
给你一个整数数组 nums
和一个整数 k
。
找到 nums
中满足以下要求的最长子序列:
k
。请你返回满足上述要求的最长子序列的长度。
子序列是从一个数组中删除部分元素后,剩余元素不改变顺序得到的数组。
这个解法来自Heltion,算法的思想是很经典的分治,首先描述一下算法:
nums
的下标按下标对应 nums
中的值从小到大排序,值相同的下标下标较大的在前,得到下标数组 p
。nums
左半边每个元素结尾的最长递增子序列长度。p
的顺序访问 nums
,如果在左半边,则入队列,如果在右半边,则根据队列中数据更新对应的最长递增子序列长度。nums
右半边每个元素结尾的最长递增子序列长度。在分治过程中,我们首先将数组分为左右两部分,假设已经得到了左边数组每个元素结尾的最长满足条件的递增子序列长度,那么我们可以使用双端单调队列在 O ( n ) O(n) O(n) 时间内求出左半部分对右半部分的贡献,这保证至少右半边第一个元素得到了精确解(对他的贡献只可能来自左边,且在递归过程中计算过了左边所有元素对他的贡献),然后再对右半边的数组调用同样的过程。我们可以发现在递归过程中数组中从左到右除了第一个元素每个元素轮流在当右半边数组的第一个数,又显然以 nums
的第一个元素结尾的最长单调递增子序列长度一定为1,所以我们在递归过程中左半边总是能求出正确答案。因此最后我们求得了数组中每个元素结尾的递增子序列长度,取其中最大值就是我们想要的答案。
为什么双端单调队列可以求出左半部分对右半部分的贡献呢?
首先我们要知道什么是单调队列,顾名思义,单调队列中的元素是单调的,同理还有单调栈。对于数组左半部分的元素,我们做的操作和单调栈十分类似,如果队尾元素小于等于要入队的元素,那么先弹出队尾元素,这样保证队列中元素永远是有序的。由于我们是按元素在 p
中的顺序访问元素,因此后访问到的元素一定更大或者更靠前 ,那么队列中小于等于当前要入队元素的元素一定不会对之后访问到的右半边数组起到更大的作用,因此可以直接弹出丢弃。当遍历到右半边的元素时,查看队列首元素,也就是左边所有小于他的元素结尾的最长递增子序列长度,如果队首元素太小不满足子序列中相邻元素的差值不超过k
,受益于单调栈可以直接得到下一个最大的元素,我们可以直接弹出队首元素。
为什么排序时值相同的下标下标较大的在前
这样我们在按 p
的顺序访问 nums
中的元素时,值相同的元素总是右半部分先被访问到,因此相同的值左半部分不会对右半部分有贡献,这保证了子序列的严格递增。
显然递归的深度是 l o g ( n ) log(n) log(n) ,在每层递归时只需要遍历一次 p
,每个元素最多入队列出队列一次,因此需要 O ( n ) O(n) O(n) 的时间,最终算法的时间复杂度是 O ( n l o g ( n ) ) O(nlog(n)) O(nlog(n)) 。
这里直接使用了题解中的代码
int lengthOfLIS(vector& nums, int k) {
int n = nums.size();
vector dp(n, 1), p(n);
for (int i = 0; i < n; i += 1) p[i] = i;
sort(p.begin(), p.end(), [&](int x, int y){
return nums[x] == nums[y] ? x > y : nums[x] < nums[y];
});
function)> DFS = [&](int L, int R, vector p) {
if (L >= R) return;
int M = (L + R) >> 1;
vector> q(2);
for (int i : p) q[i > M].push_back(i);
DFS(L, M, q[0]);
deque dq;
for (int i : p)
if (i <= M) {
while (not dq.empty() and dp[dq.back()] <= dp[i]) dq.pop_back();
dq.push_back(i);
}
else {
while (not dq.empty() and nums[dq.front()] < nums[i] - k) dq.pop_front();
if (not dq.empty()) dp[i] = max(dp[i], dp[dq.front()] + 1);
}
DFS(M + 1, R, q[1]);
};
DFS(0, n - 1, p);
return *max_element(dp.begin(), dp.end());
}
有人认为使用合适的数据结构以后这个解法理论时间复杂度可以到达 O ( n ∗ l o g ( l o g ( n ) ) ) O(n*log(log(n))) O(n∗log(log(n))) (
但我不确定他分析对没有),但是不影响这是一个十分精彩的动态规划!
上面的算法像是将传统的动态规划求最长单调子序列进行了改进,得到了时间复杂度更低的算法,而这个算法类比起来更像是修改了使用有序辅助数组求最长单调子序列的方法,使其支持题目中的对 k
的要求。 d p [ i ] dp[i] dp[i] 表示当前以 i i i 结尾的最长单调子序列长度,每次更新时只需要找到小于 i i i 的在 d p dp dp 中最大的下标即可,但是由于 k
的限制,我们需要在 d p dp dp 中添加辅助元素 d p [ i + k ] dp[i+k] dp[i+k] 这样可以保证 d p [ i ] dp[i] dp[i] 的影响只停留在它之后的 k
个元素,同时删除 d p [ i ∼ i + k ] dp[i\sim i+k] dp[i∼i+k] 之中长度小于 d p [ i ] dp[i] dp[i] 的元素(这相当于有序辅助数组中在同一位置上替换的操作),最终 d p dp dp 中最大的元素就是我们要求的答案。
只需要遍历 nums
一遍,每次在 d p dp dp 中查询小于 i i i 的下标需要 O ( l o g n ) O(logn) O(logn),对 d p dp dp 中元素的插入删除次数不超过 4 n 4n 4n ,因此总的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)。
这里采用原始作者的实现, 代码更简洁,思路更清晰。
int lengthOfLIS(vector& nums, int k) {
int ans = 1;
map dic;
for(auto n : nums) {
auto pre = dic.upper_bound(-n);
int dp = 1 + pre->second;
ans = max(ans, dp);
auto it = dic.upper_bound(-n - k - 1);
int old = it -> second;
if(it != pre) {
auto nx = pre;
while(--nx != it && nx->second < dp);
dic.erase(nx, pre);
}
dic[-n] = dp;
if(old < dp) dic[-n-k] = old;
}
return ans;
}
等我学会线段树后会专门对线段树进行总结!
点赞过3,一周内更新线段树!