1157-子数组中占绝大多数的元素
设计一个数据结构,有效地找到给定子数组的 多数元素 。
子数组的 多数元素 是在子数组中出现 threshold
次数或次数以上的元素。
实现 MajorityChecker
类:
MajorityChecker(int[] arr)
会用给定的数组arr
对MajorityChecker
初始化。int query(int left, int right, int threshold)
返回子数组中的元素arr[left...right]
至少出现threshold
次数,如果不存在这样的元素则返回-1
。
示例 1:
**输入:**
["MajorityChecker", "query", "query", "query"]
[[[1, 1, 2, 2, 1, 1]], [0, 5, 4], [0, 3, 3], [2, 3, 2]]
**输出:**
[null, 1, -1, 2]
**解释:**
MajorityChecker majorityChecker = new MajorityChecker([1,1,2,2,1,1]);
majorityChecker.query(0,5,4); // 返回 1
majorityChecker.query(0,3,3); // 返回 -1
majorityChecker.query(2,3,2); // 返回 2
提示:
1 <= arr.length <= 2 * 104
1 <= arr[i] <= 2 * 104
0 <= left <= right < arr.length
threshold <= right - left + 1
2 * threshold > right - left + 1
- 调用
query
的次数最多为104
方法一:随机化 + 二分查找
思路与算法
根据题目的要求,每次询问给定的 threshold 的 2 倍一定严格大于给定区间的长度。因此,我们实际上要找出的是区间的「绝对众数」:即出现次数严格超过区间长度一半的数,并判断该「绝对众数」的出现次数是否至少为 threshold。
若区间的「绝对众数」存在,记为 x,那么当我们在区间中「随机」选择一个数时,会有至少 \dfrac{1/2 的概率选到 x。如果我们进行 k 次选择,那么一次都没有选到 x 的概率不会超过 \left( \dfrac{1/2} \right)^k。当 k 的值较大时,例如 k=20,\left( \dfrac{1/2} \right)^k 的数量级在 10^{-6 左右,我们可以认为 k 次选择中几乎不可能没有选择到 x。
当我们选择到一个数 x’ 时,我们如何统计它在区间中出现的次数呢?我们可以使用预处理 + 二分查找的方法。我们使用一个哈希表来存储每个数出现的位置,对于哈希表中的每个键值对 (\textit{key}, \textit{value}),key 表示一个数,value 是一个数组,按照递增的顺序存储了 key 在数组 arr 中每一次出现的位置。这样一来,我们在哈希表中以 x’ 为键得到的数组上,分别二分查找 left 和 right} + 1,得到的两个下标之间包含的元素个数,就是子区间 [\textit{left}, \textit{right}] 中包含 x’ 的个数。
我们将「个数」记为 occ:
如果 occ} \geq \textit{threshold,那么我们就找到了答案 x’;
如果 occ} < \textit{threshold 但 occ 至少为区间长度的一半,那么说明 occ:
- 是「绝对众数」,但它出现的次数不够多。由于「绝对众数」只能有一个,因此一定不存在满足要求的元素;
- 恰好出现了区间长度一半的次数,那么其它的数最多也只会出现区间长度一半的次数,也不够多,因此也不存在满足要求的元素。
如果 occ 小于区间长度的一半,那么我们再随机选择一个数并进行二分查找。
当 k 次随机选择都完成,但我们仍然没有找到答案时,就可以认为不存在这样的元素。
随机化正确性分析
记询问的次数为 q,假设询问之间是独立的,那么我们最后给出的所有答案均正确的概率为:
\left( 1 - \left( 1/2} \right)^k \right)^q \sim 1 - q \cdot \left( 1/2} \right)^k
当 k=20,q = 10^4 时,正确的概率约为 1 - 10^4 \times 10^{-6} = 99%,已经是一个非常优秀的随机化算法。如果读者觉得这个概率仍然不够高,可以取 k=30,这样正确的概率约为 1 - 10^4 \times 10^{-9} = 99.999%。
代码
1 | class MajorityChecker { |
1 | class MajorityChecker { |
1 | public class MajorityChecker { |
1 | class MajorityChecker: |
1 | type MajorityChecker struct { |
1 | typedef struct Element { |
1 | const K = 20; |
复杂度分析
时间复杂度:O(n + kq\log n),其中 n 是数组 arr 的长度,q 是询问的次数,k 是每次询问随机选择的次数。预处理哈希表需要 O(n) 的时间,单次询问需要 O(k \log n) 的时间。
空间复杂度:O(n),即为哈希表需要使用的空间。
方法二:摩尔投票 + 线段树
前言
本方法严重超纲。读者至少需要掌握:
投票算法的结合性
对于每一次查询,我们可以对子数组先进行一次遍历,使用投票算法找出可能的「绝对众数」x’,再使用一次遍历,记录 x’ 真正出现的次数,与 threshold 进行比较并得出答案。
这样做的时间复杂度为 O(l),其中 l 是子数组的长度,并不是一种高效率的做法。但我们可以发现,摩尔投票中存储的两个值是具有结合性质的:
在摩尔投票中,我们会存储两个值 (x’, \textit{cnt}),其中 x’ 表示答案,cnt 表示 x’ 当前的价值。如果下一个数 y’ = x’,那么 cnt 的值加 1,否则减 1。当 cnt 变为 -1 时,会将 x’ 替换成 y’ 并将 cnt 初始化为 1;
对于一个给定的数组,我们可以将它分成任意的两部分(即使不连续都可以),分别使用投票算法得到 (x_0’, \textit{cnt}_0) 和 (x_1’, \textit{cnt}_1)。那么整个数组使用投票算法得到的结果为:
如果 x_0’ = x_1’,结果为 (x_0’, \textit{cnt}_0 + \textit{cnt}_1);
如果 x_0’ \neq x_1’,结果为 cnt}_0 和 cnt}_1 中较大的那个对应的 x’,以及 |\textit{cnt}_0 - \textit{cnt}_1|。
我们可以使用通俗的解释证明正确性:投票算法本质上是在数组中不断找出两个不同的整数,把它们消除。当数组中只剩下一种整数时,这个整数就是 x’,它出现的次数就是 cnt。如果数组中存在「绝对众数」,那么它就是 x’,否则最后剩下的 x’ 可能是任何值。因此,我们先将数组分成任意的两部分,分别进行消除,得到了 cnt}_0 个 x_0’ 以及 cnt}_1 个 x_1’。再将它们进行消除,就得到了整个数组进行投票算法的结果:
如果数组中存在「绝对众数」x’,那么根据鸽巢原理,一定有一个部分的绝对众数也是 x’,它可以在 (x_0’, \textit{cnt}_0) 或 (x_1’, \textit{cnt}_1) 中得到保留;
如果不存在,那么 (x_0’, \textit{cnt}_0) 和 (x_1’, \textit{cnt}_1) 的值都无关紧要。
上述结合性可以推广到将数组拆分成任意多个部分的情形,因此我们就可以使用线段树,每个节点存储对应区间的 (x’, \textit{cnt})。
算法
我们使用线段树解决本题。线段树的每个节点存储两个值,即对应区间的 (x’, \textit{cnt})。
对于每个查询操作 (\textit{left}, \textit{right}, \textit{threshold}),我们在线段树上查询区间 [\textit{left}, \textit{right}],合并所有区间的值,得到答案 (x’, \textit{cnt})。随后我们使用与方法一中相同的哈希表,进行二分查找,判断 x’ 是否出现了至少 threshold 次即可。
代码
1 | struct Node { |
1 | class MajorityChecker { |
1 | public class MajorityChecker { |
1 | class Node: |
1 | typedef struct Element { |
复杂度分析
时间复杂度:O(n + q\log n),其中 n 是数组 arr 的长度,q 是询问的次数。预处理哈希表和线段树需要 O(n) 的时间,单次询问需要 O(\log n) 的时间。
空间复杂度:O(n),即为哈希表和线段树需要使用的空间。