浅谈「First bigger」问题
概述
本博客主要介绍关于「求序列中每一个数前面第一个大于它的数」一类问题的各种解法。
相关表述还有「求序列中每一个数前面比它大的数中最靠近它的数」,「求序列中每一个数后面第一个比它大的数」,「求序列中每一个数前面第一个比它小的数」等,都属于同类问题,在这里只对于「求序列中每一个数前面第一个大于它的数」问题进行讨论。
问题简析
给定一个长度为 \(n\) 的正整数序列 \(a\) ,其中序列的第 \(i\) 个元素 \(a_i\) 满足 \(1\le a_i\le 10^5\)
对于每一位置 \(i\) ,求在该数前第一个大于它的数。
可以发现,问题实际上要我们求一个这样的问题。
- \(\forall 1\le j<i, a_j>a_i\) ,求 \(\max j\)
我们设 \(d_i\) 表示位置 \(i\) 的答案
解法1
\(O(n^2)\) 算法
暴力
该问题可以使用二重循环进行暴力求解。
对于每一个 \(i\) ,我们直接枚举所有 \(1\le j<i\) ,对于所有 \(a_j>a_i\) ,求 \(j\) 最大的值即可。时间复杂度为 \(O(n^2)\) 。
实现上一个简单的优化为我们考虑从大到小枚举 \(j\) ,一旦找到了 \(a_j>a_i\) 就直接 break
即可。这样做可以对暴力进行常数上的优化,不过时间复杂度仍为 \(O(n^2)\) 。
玄学优化
可以发现,简单的二重循环暴力已经无法进行优化,考虑使用一定的贪心。
观察序列,我们可以发现对于每一个数 \(i\) ,如果 \(a_{i-1}>a_i\) 的话,则 \(d_i=i-1\) 。否则考虑判断如果 \(a_{d_{i-1}}>a_i\) ,则 \(d_i=d_{i-1}\) 否则继续递归循环。可以发现,这样做在随机情况可以做到近似 \(O(n)\) 的均摊的时间复杂度。不过仍能被极端数据卡成 \(O(n^2)\)。
解法2
\(O(n\sqrt n)\) 算法
分块
考虑使用分块解决问题。
我们首先对于序列进行分块,对于每一块处理出块中的最大值。
对于 \(i\) 来说,我们首先查询 \(i\) 左侧不属于完整一块的数。如果存在 \(a_j>a_i\) 则 \(d_i=j\)。
否则我们考虑一块一块的暴力跳,如果当前块最大值 \(a_j>a_i\) ,说明 \(d_i\) 位于当前块内,暴力查找块内位置即可。
否则考虑继续查询下一块。容易发现,这样做的时间复杂度是 \(O(n\sqrt n)\) 的。
解法3
\(O(nlog^2n)\) 算法
二分+线段树
接下来,我们考虑如何优化上述 \(O(n^2)\) 的算法。
一个比较容易想到的思路是使用数据结构进行维护。
首次,我们从左往右遍历序列,同时对线段树进行维护,这样就可以满足 \(1\le j<i\) 的条件了。
然后我们考虑对于线段树上的每一个位置维护其权值,查询时我们只需要二分倍增一下答案,这样就满足 \(j\) 最大的条件了。
check
函数的实现即为查询当前区间是否存在值大于 \(a_i\) 即可。也即查询当前区间最大值是否大于 \(a_i\) 即可,这样就满足 \(a_j>a_i\) 的条件了。
解法4
\(O(nlogn)\) 算法
二分+st表
考虑如何优化上述 \(O(nlog^2n)\) 的算法。
我们发现,使用线段树维护是完全没有必要的。因为我们二分时 check
的都是 \(j<i\) 的区间,因此并不会出现 \(d_i=j\ge i\) 的情况。
因此,考虑直接使用st表 \(O(nlogn)\) 预处理, \(O(1)\) 维护区间最大值,这样做的时间复杂度是 \(O(nlogn)\) 的。
线段树二分
考虑如何优化上述 \(O(nlog^2n)\) 的算法。
我们发现,使用二分进行查询是完全没有必要的。我们完全可以做到直接在线段树上进行二分。
由于我们线段树维护的值都满足 \(j<i\) 。因此我们在查询完全可以进行如下二分。
如果当前区间的右边的最大值大于 \(a_i\) 则往右边跳,否则往左边跳。
可以发现,我们只需要将未加入线段树的位置权值设为 \(-inf\) ,则往当右边最大值大于 \(a_i\) 时一定存在 \(j<i\) 使得 \(a_j>a_i\)
权值线段树
考虑换一种思路,我们可以使用权值线段树进行维护。
首先,从左往右遍历序列仍然能够满足 \(1\le j<i\) 的条件。
同时,我们对于权值进行维护。对于每个权值,我们维护其出现过的最大位置。
则 \(d_i\) 就等于权值线段树上权值大于 \(a_i\) 的区间的位置的最大值。可以发现,这样做满足了 \(a_j>a_i\) 和 \(\max j\) 的条件。
解法5
\(O(n)\) 算法
单调栈
考虑如何优化上述 \(O(nlogn)\) 的算法。
首先我们可以发现,上述 \(log\) 级数据结构都不能使用了。因此我们考虑使用 \(O(n)\) 的数据结构,单调栈。
在单调栈中,我们严格单调降序的维护所有权值。
维护时,如果当前 \(a_i\) 权值大于等于栈顶,则弹出栈顶。最后的栈顶即为 \(d_i\) 。最后加入 \(a_i\) 即可。
可以发现,由于弹出栈顶的数 \(j\) 是权值 \(a_j\) 小于等于 \(a_i\) 且位置 \(j\) 小于 \(i\) 的数。因此如果存在 \(d_k=j(k>i)\) 的话,\(d_k=i\) 一定更优。因此弹出栈顶元素不会影响答案。
可以发现,由于所有元素都最多加入和弹出单调栈一次,因此时间复杂度为 \(O(n)\) 。
//暴力
//玄学dp
//分块
//二分+线段树
//二分+st表
//权值线段树
//线段树二分
//单调栈