💬 欢迎讨论:如果你在学习过程中有任何问题或想法,欢迎在评论区留言,我们一起交流学习。你的支持是我继续创作的动力! 👍 点赞、收藏与分享:觉得这篇文章对你有帮助吗?别忘了点赞、收藏并分享给更多的小伙伴哦!你们的支持是我不断进步的动力! 🚀 分享给更多人:如果你觉得这篇文章对你有帮助,欢迎分享给更多对C++算法感兴趣的朋友,让我们一起进步!
接上篇:【优选算法篇】解密前缀和:让数组求和变得如此高效(上篇)-CSDN博客
引言:通过上篇文章带大家简单了解“前缀和算法”,小试牛刀。接下来将让大家感受一下前缀和在解题的妙处。
k
的倍数:通过前缀和和余数来判断某些特定条件的子数组。面试中的价值
前言
前缀和(Prefix Sum)算法在笔试中的重要性非常高,因为它能够极大地优化数组相关问题的时间复杂度,尤其是在涉及区间求和、子数组和等问题时。通过前缀和算法,许多看似复杂的算法问题都能得到高效解决,避免了暴力解法的时间瓶颈。
前缀和(Prefix Sum) 是指一个数组的前缀部分元素的累加和。对于一个数组 arr
,前缀和数组 prefix_sum
的定义为:
prefix_sum[i] = arr[0] + arr[1] + ... + arr[i-1]
,即数组从第一个元素到第 i
个元素的和。[l, r]
的和,时间复杂度为 O(1)。k
的子数组数量。k
整除的问题,利用前缀和的余数减少不必要的重复计算。题目链接:560. 和为 K 的子数组 - 力扣(LeetCode)
题目描述:
sum[i]
表示数组从 nums[0]
到 nums[i]
的前缀和。nums[i:j]
的和可以表示为: sum[j]−sum[i−1]=k 转化为: sum[j]−k=sum[i−1] 这说明,如果我们知道当前前缀和 sum[j]
和目标值 k
,我们只需要找到之前某个前缀和 sum[i-1]
满足这个条件,就可以确定一个子数组的和为 k
。hash
来记录每个前缀和出现的次数。sum
。hash[sum]
。sum[j]
,我们计算是否存在 sum[j] - k
在哈希表中。如果存在,说明有对应的子数组和等于 k
,并将 hash[sum[j] - k]
的值累加到结果中。int subarraySum(vector<int>& nums, int k)
{
unordered_map<int, int> hash; // 哈希表记录前缀和及其出现次数
hash[0] = 1; // 初始化:前缀和为 0 的情况出现 1 次(用于处理从头开始的子数组)
int ret = 0, sum = 0; // ret:结果变量;sum:当前前缀和
for (auto x : nums) // 遍历数组的每个元素
{
sum += x; // 更新当前前缀和
// 检查是否存在满足条件的前缀和
// 如果 `sum - k` 存在于哈希表中,说明从之前某位置到当前的子数组和为 k
if (hash.count(sum - k))
ret += hash[sum - k]; // 累加满足条件的子数组个数
// 更新哈希表:记录当前前缀和 `sum` 出现的次数
hash[sum]++;
}
return ret; // 返回结果
}
假设 nums = [1, 1, 1]
,k = 2
:
最终返回结果:ret = 2
。
暴力解法的核心思路
int subarraySum(vector<int>& nums, int k)
{
int n = nums.size(); // 数组长度
int ret = 0; // 用于记录满足条件的子数组数量
// 枚举子数组的起点
for (int i = 0; i < n; i++)
{
int sum = 0; // 当前子数组的和
// 枚举子数组的终点
for (int j = i; j < n; j++)
{
sum += nums[j]; // 累加当前子数组的和
if (sum == k) // 检查子数组和是否等于 k
ret++; // 满足条件,结果加 1
}
}
return ret; // 返回满足条件的子数组数量
}
输入:nums = [1, 2, 3]
, k = 3
n = 3
ret = 0
i = 0
sum = 0
j = 0
: sum = 0 + 1 = 1
,不满足条件。j = 1
: sum = 1 + 2 = 3
,满足条件,ret = 1
。j = 2
: sum = 3 + 3 = 6
,不满足条件。i = 1
sum = 0
j = 1
: sum = 0 + 2 = 2
,不满足条件。j = 2
: sum = 2 + 3 = 5
,不满足条件。i = 2
sum = 0
j = 2
: sum = 0 + 3 = 3
,满足条件,ret = 2
。最终返回结果:ret = 2
优点:
缺点:
暴力解法适用于入门理解和小规模测试,但当数组规模较大时,应该优先选择优化解法(如前缀和 + 哈希表),将时间复杂度优化至 O(n)。
这段代码通过前缀和和哈希表的结合,快速统计和为 k 的子数组个数,既高效又易于扩展,是面试中常见的算法技巧。
题目链接:974. 和可被 K 整除的子数组 - 力扣(LeetCode)
题目描述:
算法核心思路
使用 前缀和 + 哈希表 来高效解决问题。
前缀和的数学推导
sum[j]
,表示数组从起点到位置 j 的元素和。哈希表的作用
处理负余数的问题
int subarraysDivByK(vector<int>& nums, int k)
{
unordered_map<int, int> hash; // 哈希表存储余数及其出现次数
hash[0] = 1; // 初始条件:前缀和为 0 的余数情况,初始时存在 1 次
int sum = 0, ret = 0; // sum 表示当前前缀和,ret 统计满足条件的子数组个数
for (auto x : nums) // 遍历数组中的每个元素
{
sum += x; // 更新当前前缀和
int r = (sum % k + k) % k; // 计算当前前缀和的余数,并保证非负
if (hash.count(r)) // 如果当前余数已经存在于哈希表中
ret += hash[r]; // 累加满足条件的子数组个数
hash[r]++; // 更新当前余数的出现次数
}
return ret; // 返回结果
}
示例:nums = [4, 5, 0, -2, -3, 1]
, k = 5
最终返回结果:ret = 7
暴力解法的核心思路
int subarraysDivByK(vector<int>& nums, int k)
{
int n = nums.size(); // 数组长度
int ret = 0; // 记录满足条件的子数组数量
// 枚举所有子数组的起点
for (int i = 0; i < n; i++)
{
int sum = 0; // 子数组的和
// 枚举子数组的终点
for (int j = i; j < n; j++)
{
sum += nums[j]; // 累加当前子数组的和
if (sum % k == 0) // 检查是否能被 k 整除
ret++; // 如果满足条件,计数加 1
}
}
return ret; // 返回结果
}
示例输入:nums = [4, 5, 0, -2, -3, 1]
, k = 5
i = 0
到 i = n - 1
。运行细节如下:
最终,统计到的满足条件的子数组数量为 7
。
两层嵌套循环:
空间复杂度:
优点:
缺点:
优化方向
暴力解法虽简单直观,但对于大规模数据难以满足性能要求,因此在实际应用中更推荐使用优化解法。
回顾:代码中的重要点
hash[0]
初始化为 1,表示前缀和刚好等于 k 倍数的情况(例如从数组开头开始的子数组)。题目描述:
4.1 算法思路
这道题的目标是求数组中连续子数组的最大长度,使得这个子数组中包含相等数量的 0
和 1
。问题可以转化为找出具有相同前缀和的两个下标之间的最大距离。
核心思路:
0
看作 -1
,那么子数组中 0
和 1
的个数相等可以转换为前缀和为 0的情况。sum
来记录从数组开始到当前位置的前缀和。hash
存储前缀和及其第一次出现的位置。0
(因为前缀和相同,说明中间部分抵消了)。i
减去哈希表中存储的对应前缀和的第一次出现的位置 hash[sum]
,即可得到一个符合条件的子数组长度。ret
,更新最大长度。class Solution
{
public:
int findMaxLength(vector<int>& nums)
{
// 创建一个哈希表,用于存储前缀和及其第一次出现的位置
unordered_map<int, int> hash;
hash[0] = -1; // 初始值:假设在下标 -1 之前前缀和为 0
int sum = 0; // 用于记录当前的前缀和
int ret = 0; // 用于记录结果,即最大长度
// 遍历数组
for(int i = 0; i < nums.size(); i++)
{
// 更新前缀和:遇到 0 视为 -1,遇到 1 视为 +1
sum += nums[i] == 0 ? -1 : 1;
// 如果当前前缀和已经在哈希表中
if(hash.count(sum))
{
// 计算从之前存储的位置到当前的位置的子数组长度
ret = max(ret, i - hash[sum]);
}
else
{
// 如果前缀和第一次出现,记录其位置
hash[sum] = i;
}
}
return ret; // 返回最大长度
}
};
关键变量:
sum
:当前的前缀和。hash
:记录前缀和第一次出现的位置。 hash[0] = -1
是为了处理整个数组的前缀和正好为 0 的情况。ret
:存储结果,即最大长度。hash
,令 hash[0] = -1
,代表从起始位置开始前缀和为 0。nums
,每次遇到 1
,将 sum
加 1
;每次遇到 0
,将 sum
减 1
。sum
是否已经存在于哈希表中: i - hash[sum]
,更新 ret
。sum
记录到哈希表,值为当前下标 i
。ret
。示例分析
示例 1:
输入:nums = [0, 1]
hash = {0: -1}
, sum = 0
, ret = 0
i = 0, nums[i] = 0 → sum = -1
hash = {0: -1, -1: 0}
i = 1, nums[i] = 1 → sum = 0
sum = 0
在 hash
中,计算长度 1 - (-1) = 2
ret = 2
ret = 2
示例 2:
输入:nums = [0, 1, 0]
hash = {0: -1}
, sum = 0
, ret = 0
i = 0, nums[i] = 0 → sum = -1
hash = {0: -1, -1: 0}
i = 1, nums[i] = 1 → sum = 0
sum = 0
在 hash
中,计算长度 1 - (-1) = 2
ret = 2
i = 2, nums[i] = 0 → sum = -1
sum = -1
在 hash
中,计算长度 2 - 0 = 2
ret
不变。ret = 2
暴力解法算法思路
i
和 j
确定,子数组为 nums[i:j+1]
。0
和 1
的数量是否相等。i
。i
,再遍历终点 j
,即子数组范围 [i, j]
。0
和 1
的数量:
[i, j]
,统计其中的 0
和 1
的数量。0
和 1
的数量相等,则更新最大长度。class Solution {
public:
int findMaxLength(vector<int>& nums) {
int maxLength = 0; // 记录最大长度
// 枚举子数组的起点
for (int i = 0; i < nums.size(); i++) {
int count0 = 0, count1 = 0; // 统计 0 和 1 的数量
// 枚举子数组的终点
for (int j = i; j < nums.size(); j++) {
if (nums[j] == 0) count0++; // 统计 0
else count1++; // 统计 1
// 如果 0 和 1 的数量相等,更新最大长度
if (count0 == count1) {
maxLength = max(maxLength, j - i + 1);
}
}
}
return maxLength;
}
};
示例解析
示例 1:
输入:nums = [0, 1]
[0]
:count0 = 1, count1 = 0
→ 不满足条件。[1]
:count0 = 0, count1 = 1
→ 不满足条件。[0, 1]
:count0 = 1, count1 = 1
→ 满足条件,更新最大长度 maxLength = 2
。输出:2
示例 2:
输入:nums = [0, 1, 0]
[0]
:count0 = 1, count1 = 0
→ 不满足条件。[1]
:count0 = 0, count1 = 1
→ 不满足条件。[0, 1]
:count0 = 1, count1 = 1
→ 满足条件,maxLength = 2
。[0, 1, 0]
:count0 = 2, count1 = 1
→ 不满足条件。[1, 0]
:count0 = 1, count1 = 1
→ 满足条件,maxLength = 2
。输出:2
i
和终点 j
的两层循环,时间复杂度为 O(n²)。0
和 1
的数量,时间复杂度为 O(1)。优点:
缺点:
对比优化方法
暴力解法虽然可以解决问题,但在性能上远不及优化方法(如利用前缀和与哈希表的解法)。优化后的方法可以将时间复杂度降到 O(n),在 LeetCode 算法平台上更适合处理大规模输入。
通过将问题转化为前缀和的寻找,我们能够在 O(n) 时间复杂度内高效解决这类问题。
题目链接:1314. 矩阵区域和 - 力扣(LeetCode)
题目描述:
5.1 算法思路:
核心思路
dp
,其中 dp[i][j]
表示从矩阵左上角 (0, 0)
到位置 (i-1, j-1)
的子矩阵的所有元素之和。(x1, y1)
到 (x2, y2)
的和可以快速通过前缀和计算: Sum=dp[x2][y2]−dp[x1−1][y2]−dp[x2][y1−1]+dp[x1−1][y1−1](i, j)
,需要求以该位置为中心,大小为 k
的块内所有元素的和。(max(0, i-k), max(0, j-k))
(min(n-1, i+k), min(m-1, j+k))
dp
数组的索引,注意处理越界。class Solution {
public:
vector<vector<int>> matrixBlockSum(vector<vector<int>>& mat, int k) {
int n = mat.size(); // 矩阵的行数
int m = mat[0].size(); // 矩阵的列数
// 计算二维前缀和数组 dp
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1] - dp[i - 1][j - 1] + mat[i - 1][j - 1];
}
}
// 计算结果矩阵 ret
vector<vector<int>> ret(n, vector<int>(m, 0));
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
// 块的左上角和右下角坐标
int x1 = max(0, i - k) + 1, y1 = max(0, j - k) + 1;
int x2 = min(n - 1, i + k) + 1, y2 = min(m - 1, j + k) + 1;
// 利用前缀和快速计算块和
ret[i][j] = dp[x2][y2] - dp[x1 - 1][y2] - dp[x2][y1 - 1] + dp[x1 - 1][y1 - 1];
}
}
return ret;
}
};
1. 前缀和的计算
for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { dp[i][j] = dp[i - 1][j] + dp[i][j - 1] - dp[i - 1][j - 1] + mat[i - 1][j - 1]; } }
dp[i][j]
代表从矩阵左上角到位置 (i-1, j-1)
的矩形的元素和。dp[i - 1][j]
dp[i][j - 1]
dp[i - 1][j - 1]
mat[i - 1][j - 1]
2. 块和的计算
int x1 = max(0, i - k) + 1, y1 = max(0, j - k) + 1; int x2 = min(n - 1, i + k) + 1, y2 = min(m - 1, j + k) + 1; ret[i][j] = dp[x2][y2] - dp[x1 - 1][y2] - dp[x2][y1 - 1] + dp[x1 - 1][y1 - 1];
max
和 min
确保块范围不越界。+1
是因为 dp
数组比矩阵大一维)。(i, j)
。(i, j)
,枚举大小为 k
的块中的所有元素。[max(0, i-k), min(n-1, i+k)]
[max(0, j-k), min(m-1, j+k)]
ret
中。ret
。以下是暴力解法的实现代码:
class Solution {
public:
vector<vector<int>> matrixBlockSum(vector<vector<int>>& mat, int k) {
int n = mat.size(); // 矩阵的行数
int m = mat[0].size(); // 矩阵的列数
vector<vector<int>> ret(n, vector<int>(m, 0)); // 结果矩阵
// 遍历每个元素
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
// 枚举块的范围
int sum = 0;
for (int x = max(0, i - k); x <= min(n - 1, i + k); x++) {
for (int y = max(0, j - k); y <= min(m - 1, j + k); y++) {
sum += mat[x][y]; // 累加块中的元素
}
}
ret[i][j] = sum; // 记录结果
}
}
return ret;
}
};
时间复杂度
总复杂度分析:
(i, j)
,需要 O(n × m)。(i, j)
,枚举大小为 k
的块范围。(2k+1) × (2k+1)
个元素。空间复杂度:
ret
和输入矩阵 mat
的大小均为 O(n × m),不使用额外存储。优点:
缺点:
k
较大时,计算复杂度会大幅上升(O(n × m × k²)),导致超时。k
值较大的情况,无法在合理时间内完成计算。为了提高效率,可以通过以下优化方案:
暴力解法虽然直观易于理解,但性能不佳,仅适用于小规模数据。在实际应用中,前缀和的优化方法是解决该题目的最佳方案。
(i, j)
,时间复杂度为 O(n × m)。总时间复杂度:O(n × m)
dp
,大小为 (n+1) × (m+1)
。ret
,大小为 n × m
。总空间复杂度:O(n × m)
通过二维前缀和优化,我们在 O(n × m) 的时间复杂度内完成了矩阵块和的计算,极大提高了效率。
前缀和是处理区间问题的基础工具,它能有效地优化数组相关问题的查询时间。通过预处理前缀和数组,我们能够将查询区间和的时间复杂度从 O(n) 降到 O(1)。进阶应用中,前缀和常与哈希表、余数、差分数组等技术结合使用,解决更多复杂的问题。掌握前缀和的基础与进阶技巧,是提高算法效率、应对复杂问题的重要步骤。
7. 最后
通过上面几个例题:「矩阵块和」的暴力解法与优化解法、「连续数组」的前缀和应用,以及**「查找数组中平衡块」的二维前缀和方法。 前缀和算法通过将复杂的重复计算问题转化为简单的加减法,极大地提升了问题求解的效率。在处理数组或矩阵中的区间求和、动态范围查询等问题时,表现出显著的性能优势,特别适用于大规模数据的应用场景。
路虽远,行则将至;事虽难,做则必成
亲爱的读者们,下一篇文章再会!!!