在处理数组相关的算法问题时,我们经常需要处理“子数组”这一概念。今天,我们将深入探讨一个稍显复杂但非常有意思的问题:如何找出每一个长度为 M 的子数组中,出现次数最多的那个元素的频率(即众数的频次)?
在这篇文章中,我们不仅要解决这个问题,还要一起探讨如何优化我们的思维过程,从朴素的暴力解法过渡到更加优雅的“滑动窗口”解法。无论你是在准备算法面试,还是想在日常开发中提升代码性能,这篇文章都会为你提供实用的见解。
问题陈述:我们需要做什么?
首先,让我们清晰地定义一下问题。假设我们有一个包含 N 个整数的数组 arr[],以及一个正整数 M。我们的任务是针对数组中每一个连续的、长度为 M 的子数组,计算出该子数组内任意元素出现的最高频率。
换句话说,我们需要从数组的第 0 个元素开始,依次取 M 个元素作为一个窗口,计算这个窗口里重复最多的数字出现了几次;然后将窗口向右移动一位,重复这个过程,直到数组末尾。
为了让你更直观地理解,让我们来看一个具体的例子。
#### 案例分析
输入: INLINECODEa181e8b5, INLINECODE775abd0e
这个数组的长度 N 是 9,M 是 4。这意味着我们要遍历所有可能的连续 4 个元素的组合。让我们手动模拟一下这个过程,这对我们理解算法逻辑至关重要。
- 第一个子数组 (索引 0-3):
{1, 2, 3, 1}
* 在这个窗口中,数字 1 出现了 2 次,其他数字各出现 1 次。
* 最大频率:2
- 第二个子数组 (索引 1-4):
{2, 3, 1, 2}
* 这里数字 2 出现了 2 次。
* 最大频率:2
- 第三个子数组 (索引 2-5):
{3, 1, 2, 4}
* 所有元素都是唯一的,没有重复。
* 最大频率:1
- 第四个子数组 (索引 3-6):
{1, 2, 4, 1}
* 数字 1 再次出现了 2 次。
* 最大频率:2
- 第五个子数组 (索引 4-7):
{2, 4, 1, 4}
* 数字 4 出现了 2 次。
* 最大频率:2
- 第六个子数组 (索引 5-8):
{4, 1, 4, 4}
* 数字 4 出现了 3 次!
* 最大频率:3
最终输出: 2 2 1 2 2 3
解题思路:从暴力到滑动窗口
#### 初步思考
面对这个问题,最直接的想法(暴力法)可能是:
对于每一个子数组,我都重新统计一遍所有数字的频率,然后找出最大值。这意味着我们需要双重循环:外层循环控制子数组的起始位置,内层循环遍历子数组内的元素并进行频率统计。
虽然这种方法逻辑简单,但它的效率并不高。如果数组长度 N 很大,这种重复计算会浪费大量的时间。
#### 优化思路:滑动窗口与哈希表
让我们换个角度思考。当我们从第一个子数组移动到第二个子数组时,窗口的变化其实很小:只是去掉了最左边的元素,并加入了右边的一个新元素。
我们可以利用这一特性,采用滑动窗口 算法结合 哈希表 的数据结构来实现优化。
具体步骤如下:
- 初始化: 我们使用一个 INLINECODEd9c51cff(在 Java 中是 INLINECODE80a30299)来存储当前窗口内数字的频率。初始化变量
val为 0,用来记录当前窗口的最大频率。
- 预处理第一个窗口: 我们先遍历数组的前 M 个元素,将它们加入哈希表,并统计出初始的最大频率
val。
- 滑动处理: 从索引 INLINECODEdf4b0bfb 开始遍历到数组末尾(INLINECODE12446ac8 从 INLINECODE1f4d2074 到 INLINECODE02d56c4b):
* 移除旧元素: 当前窗口最左边的元素是 arr[i - M]。我们需要在哈希表中将它对应的频率减 1。如果频率变为 0,可以选择将其从 map 中移除(虽然不减去也可以,但保持 map 效洁是个好习惯)。
* 加入新元素: 当前窗口最右边的新元素是 arr[i]。我们将它在哈希表中的频率加 1。
* 更新最大值: 这是最关键的一步。我们需要检查哈希表中的所有条目,找出最大的频率值,并更新 val。
代码实现与详细解析
为了让你能够完全掌握这个算法,我们提供了 C++ 和 Java 的完整实现,并添加了详细的中文注释。请注意我们在代码中如何处理窗口的滑动和数据的更新。
#### C++ 实现
在 C++ 中,unordered_map 提供了平均 O(1) 时间复杂度的插入和查询操作,非常适合这种场景。
// C++ program to find the maximum frequency
// in each subarray of size M
#include
using namespace std;
// 核心功能函数:计算每个 M 长度子数组的最大频率
void maxFrequencySubarrayUtil(
vector A, int N, int M)
{
int i = 0;
// 使用哈希表存储数组元素的频率
unordered_map m;
// 存储当前子数组中的最大频率
int val = 0;
// 第一步:处理第一个子数组 (索引 0 到 M-1)
for (; i < M; i++) {
m[A[i]]++; // 增加当前元素的频率
// 实时更新最大频率
val = max(val, m[A[i]]);
}
// 打印第一个子数组的结果
cout << val << " ";
// 第二步:开始滑动窗口,遍历剩余的数组 (从 M 到 N-1)
for (i = M; i < N; i++) {
// 1. 移除窗口中最左边的元素 (i - M 位置的元素)
m[A[i - M]]--;
// 2. 加入窗口右边的新元素 (当前位置 i 的元素)
m[A[i]]++;
// 重置 val 用于计算当前窗口的最大值
// 注意:这里为了逻辑清晰,重新遍历了 map
val = 0;
// 3. 遍历哈希表,找出当前窗口的最大频率
for (auto x : m) {
val = max(val, x.second);
}
// 打印当前子数组的最大频率
cout << val << " ";
}
}
// 主函数
int main()
{
// 测试用例 1
vector A = { 1, 2, 3, 1, 2, 4, 1, 4, 4 };
int N = A.size();
int M = 4;
// 调用函数
maxFrequencySubarrayUtil(A, N, M);
cout << endl;
// 测试用例 2
vector B = { 1, 1, 2, 2, 3, 5 };
int N2 = B.size();
int M2 = 4;
maxFrequencySubarrayUtil(B, N2, M2);
return 0;
}
#### Java 实现
在 Java 中,我们使用 HashMap 来实现同样的逻辑。请注意处理 Map 中不存在的键时的细节。
// Java program for the above approach
import java.util.*;
class Main {
// Function to find the frequency of
// the most common element in each M
// length subarrays
static void maxFrequencySubarrayUtil(int[] A, int N, int M) {
int i = 0;
// 使用 HashMap 存储元素频率
HashMap m = new HashMap();
// 存储最大频率
int val = 0;
// 初始化第一个窗口
for (; i < M; i++) {
// 如果元素存在,频率+1,否则设为1
if (m.containsKey(A[i])) {
m.put(A[i], m.get(A[i]) + 1);
} else {
m.put(A[i], 1);
}
// 更新最大频率
val = Math.max(val, m.get(A[i]));
}
// 打印第一个窗口的结果
System.out.print(val + " ");
// 滑动窗口处理剩余元素
for (i = M; i < N; i++) {
// 移除最左边的元素
int leftElement = A[i - M];
if (m.containsKey(leftElement)) {
int count = m.get(leftElement);
if (count == 1) {
m.remove(leftElement); // 如果频率变为0,移除它
} else {
m.put(leftElement, count - 1);
}
}
// 添加新元素
int rightElement = A[i];
if (m.containsKey(rightElement)) {
m.put(rightElement, m.get(rightElement) + 1);
} else {
m.put(rightElement, 1);
}
val = 0;
// 计算当前窗口的最大频率
for (Map.Entry x : m.entrySet()) {
val = Math.max(val, x.getValue());
}
// 打印结果
System.out.print(val + " ");
}
}
// Driver Code
public static void main(String[] args) {
int[] A = { 1, 2, 3, 1, 2, 4, 1, 4, 4 };
int N = A.length;
int M = 4;
maxFrequencySubarrayUtil(A, N, M);
System.out.println(); // 换行
int[] B = { 1, 1, 2, 2, 3, 5 };
int N2 = B.length;
int M2 = 4;
maxFrequencySubarrayUtil(B, N2, M2);
}
}
深入理解与实际应用
#### 常见陷阱与调试技巧
在编写此类代码时,你可能会遇到几个常见的陷阱:
- Map 的遍历开销: 你可能注意到了,在上面的代码中,每次窗口滑动时,我们都会重新遍历整个 Map 来寻找最大值。如果 Map 中的元素种类非常多(即数组中大部分元素都是唯一的),这部分的性能开销会比较大。虽然通常情况下,问题规模可控,但在极端情况下值得注意。
- 索引越界: 在处理 INLINECODE712fd739 这个索引时,一定要确保循环是从 INLINECODEfd223921 开始的。如果从 0 开始,
i - M将变成负数,导致数组越界异常。 - 移除元素的逻辑: 在 Java 中,当减少频率时,必须检查频率是否变为 0。虽然不删除也不会报错,但这会让 Map 中堆积大量无效的 0 频率键值对,影响后续遍历的效率。
#### 性能分析
时间复杂度: O(N K)。其中 N 是数组长度,K 是窗口内唯一元素的平均数量(最坏情况下 K = M)。虽然我们避免了重新计算整个窗口,但查找最大值的操作依然需要遍历哈希表。
- 空间复杂度: O(M)。因为我们最多只需要存储 M 个元素的频率。
总结
通过这篇文章,我们学习了如何利用滑动窗口技术来解决“寻找每个子数组最大频率”的问题。这种方法比暴力法更加高效,也是处理数组区间问题的标准套路之一。
我们鼓励你亲手运行上述代码,尝试修改输入值,观察输出的变化。只有通过实际操作,你才能真正掌握这些算法技巧,并在面试或实际工作中灵活运用。希望这篇文章对你有所帮助,祝你在算法学习的道路上越走越远!
#### 补充测试案例
为了帮助你验证代码的正确性,这里再提供两个测试案例:
案例 1:全部相同元素
输入:INLINECODEaef7ca2a, INLINECODE1e3b1927
预期输出:3 3 3
案例 2:全不相同的元素
输入:INLINECODE52375c06, INLINECODE918b1cd0
预期输出:1 1 1 1