前缀和and哈希表的巧妙配合
文章目录
例题
leetcode [560]和为K的子数组
给你一个整数数组
nums
和一个整数k
,请你统计并返回 该数组中和为k
的子数组的个数 。子数组是数组中元素的连续非空序列。
示例 1:
输入:nums = [1,1,1], k = 2 输出:2
示例 2:
输入:nums = [1,2,3], k = 3 输出:2
提示:
1 <= nums.length <= 2 * 104
-1000 <= nums[i] <= 1000
-107 <= k <= 107
分析
在处理数组相关问题时,我们常常需要高效地找出满足特定条件的子数组。对于 “和为 K 的子数组” 这一问题,其核心在于统计数组中元素连续非空序列的和恰好等于给定整数 k
的子数组个数。
传统的暴力解法会遍历数组中所有可能的子数组,计算它们的和并与 k
比较,这种方法的时间复杂度为 O(n²),当数组长度较大时,效率会变得很低。
为了优化时间复杂度,我们可以引入前缀和与哈希表的概念。前缀和是指从数组起始位置到当前位置的元素之和,通过前缀和,我们可以快速计算出任意子数组的和。而哈希表则用于记录每个前缀和出现的次数,利用前缀和的差值特性,我们可以在 O(1) 的时间内判断是否存在和为 k
的子数组。
下面我们将详细分析如何运用前缀和与哈希表的巧妙配合来解决这个问题,从而将时间复杂度优化到 O(n),提高算法的执行效率。
前置知识介绍
前缀和
前缀和算法是一种在数组处理中非常实用的算法技巧,它能够高效地解决很多与区间和相关的问题。下面从基本概念、使用场景、实现方式以及具体示例等方面来详细介绍前缀和算法。
基本概念
前缀和是一个数组的中间结果,对于一个给定的数组 arr
,其前缀和数组 prefixSum
的定义如下:
prefixSum[0] = arr[0]
prefixSum[i] = prefixSum[i - 1] + arr[i]
(其中i > 0
)
简单来说,前缀和数组 prefixSum
中的第 i
个元素表示原数组 arr
中从索引 0
到索引 i
的所有元素的总和。
使用场景
前缀和算法主要用于快速计算数组中任意区间 [i, j]
(包含 i
和 j
)的元素之和。在不使用前缀和的情况下,计算区间和需要遍历该区间内的所有元素,时间复杂度为 (O(j - i + 1));而使用前缀和数组,只需要进行一次减法运算,时间复杂度可以降低到 (O(1))。常见的应用场景包括:
- 多次查询区间和:当需要多次查询数组中不同区间的元素和时,使用前缀和可以避免重复计算,提高查询效率。
- 统计子数组和:例如统计数组中和为某个特定值的子数组的个数。
哈希表
在 C++ 里,哈希表容器能够高效地存储和查找键值对,它借助哈希函数把键映射到存储桶,从而实现快速的数据访问。C++ 标准库提供了两种主要的哈希表容器:std::unordered_map
和 std::unordered_set
,下面为你详细介绍。
1. std::unordered_map
std::unordered_map
是一个关联容器,存储的是键值对,且每个键都是唯一的。它的底层实现采用哈希表,这使得插入、查找和删除操作的平均时间复杂度为 (O(1))。
基本使用
#include <iostream>
#include <unordered_map>
int main() {
// 创建一个 unordered_map 实例,键为 string 类型,值为 int 类型
std::unordered_map<std::string, int> myMap;
// 插入元素
myMap["apple"] = 1;
myMap["banana"] = 2;
myMap["cherry"] = 3;
// 查找元素
auto it = myMap.find("banana");
if (it != myMap.end()) {
std::cout << "The value of banana is: " << it->second << std::endl;
}
// 遍历元素
for (const auto& pair : myMap) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
return 0;
}
代码解释
- 创建容器:
std::unordered_map<std::string, int> myMap;
定义了一个键为std::string
类型,值为int
类型的哈希表。 - 插入元素:使用
myMap["apple"] = 1;
这样的语法可以插入或更新键值对。 - 查找元素:
myMap.find("banana")
用于查找键为"banana"
的元素,若找到则返回指向该元素的迭代器,若未找到则返回myMap.end()
。 - 遍历元素:借助范围
for
循环能够遍历哈希表中的所有键值对。
2. std::unordered_set
std::unordered_set
是一个只存储唯一键的容器,不存储对应的值。它同样基于哈希表实现,插入、查找和删除操作的平均时间复杂度也是 (O(1))。
基本使用
#include <iostream>
#include <unordered_set>
int main() {
// 创建一个 unordered_set 实例,存储 int 类型的元素
std::unordered_set<int> mySet;
// 插入元素
mySet.insert(1);
mySet.insert(2);
mySet.insert(3);
// 查找元素
if (mySet.find(2) != mySet.end()) {
std::cout << "2 is in the set." << std::endl;
}
// 遍历元素
for (const auto& element : mySet) {
std::cout << element << std::endl;
}
return 0;
}
代码解释
- 创建容器:
std::unordered_set<int> mySet;
定义了一个存储int
类型元素的哈希集合。 - 插入元素:
mySet.insert(1);
用于向集合中插入元素。 - 查找元素:
mySet.find(2)
用于查找元素2
,若找到则返回指向该元素的迭代器,若未找到则返回mySet.end()
。 - 遍历元素:利用范围
for
循环可以遍历集合中的所有元素。
3. 注意事项
- 哈希函数:默认情况下,C++ 标准库为常见类型(如
int
、std::string
等)提供了哈希函数。但对于自定义类型,你需要自己定义哈希函数和相等比较函数。 - 性能:虽然平均时间复杂度为 (O(1)),但在最坏情况下(哈希冲突严重),时间复杂度可能会退化为 (O(n))。
- 元素顺序:哈希表容器不保证元素的顺序,插入和遍历元素的顺序可能不同。
题目分析
这道题要求我们在给定的整数数组 nums
中,找出和为指定整数 k
的子数组的个数。子数组是数组中连续的非空序列,下面我们从几个方面来深入分析这道题。
- 暴力解法的不足: 传统的暴力解法是通过嵌套循环遍历数组中所有可能的子数组,对于每个子数组,再遍历子数组内的元素计算其和,然后与
k
进行比较。这种方法虽然直观,但时间复杂度高达 (O(n^2)),其中n
是数组nums
的长度。当数组长度较大时,计算量会急剧增加,导致算法效率低下,无法满足实际应用中对性能的要求。 - 前缀和的引入: 为了优化算法,我们引入前缀和的概念。前缀和数组记录了从数组起始位置到每个位置的元素之和。通过前缀和,我们可以快速计算出任意子数组的和。具体来说,对于一个子数组
nums[i..j]
,它的和等于前缀和prefixSum[j] - prefixSum[i - 1]
(当i = 0
时,prefixSum[i - 1]
看作0
)。这样,原本需要遍历子数组内所有元素来计算和的操作,现在只需要进行一次减法运算,大大提高了计算效率。 - 哈希表的作用: 仅仅使用前缀和还不足以解决问题,因为我们需要找出所有和为
k
的子数组。这时,哈希表就派上了用场。我们可以使用哈希表来记录每个前缀和出现的次数。当我们遍历数组计算当前前缀和currentSum
时,如果发现currentSum - k
在哈希表中存在,说明存在一个前缀和使得当前前缀和减去它等于k
,也就意味着从那个前缀和的下一个位置到当前位置的子数组的和为k
。此时,我们将currentSum - k
对应的出现次数累加到结果中。同时,将当前前缀和currentSum
及其出现次数记录到哈希表中,以便后续遍历使用。 - 特殊情况处理: 在初始化哈希表时,我们需要将前缀和为
0
的情况初始化为出现1
次。这是因为当子数组从数组的第一个元素开始,并且其和恰好为k
时,此时当前前缀和currentSum
就等于k
,而currentSum - k = 0
。如果没有初始化前缀和为0
的情况,就会遗漏这种情况的子数组。 - 时间复杂度分析: 通过使用前缀和与哈希表的方法,我们只需要遍历数组一次,时间复杂度为 (O(n)),其中
n
是数组的长度。在遍历过程中,对于每个元素,计算前缀和、在哈希表中查找和插入操作的平均时间复杂度都是 (O(1)),因此总的时间复杂度为 (O(n)),相比暴力解法有了显著的性能提升。
综上所述,这道题通过前缀和与哈希表的巧妙配合,将原本复杂的子数组和的计算问题转化为简单的前缀和差值查找问题,从而高效地解决了问题。
解决代码
#include <iostream>
#include <vector>
#include <unordered_map>
using namespace std;
//leetcode submit region begin(Prohibit modification and deletion)
class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
unordered_map<int, int> prefixSumCount{{0, 1}}; // 初始化前缀和为0出现1次
int count = 0, currentSum = 0;
for (int num : nums) {
currentSum += num; // 计算当前前缀和
// 查找是否存在符合条件的前缀和
if (prefixSumCount.find(currentSum - k) != prefixSumCount.end()) {
count += prefixSumCount[currentSum - k];
}
// 更新哈希表
prefixSumCount[currentSum]++;
}
return count;
}
};
//leetcode submit region end(Prohibit modification and deletion)
代码解释
- Solution类是力扣的解题代码模板,如果不熟悉核心代码编程模式的,可以去了解一下
- 初始化哈希表和变量:
unordered_map<int, int> prefixSumCount{{0, 1}}; // 初始化前缀和为0出现1次
int count = 0, currentSum = 0;
prefixSumCount
是一个unordered_map
,键的类型为int
,表示前缀和;值的类型也为int
,表示该前缀和出现的次数。初始时,将前缀和为0
的情况初始化为出现1
次,这是为了处理子数组从数组开头就满足和为k
的情况(即currentSum = k
时,currentSum - k = 0
)。count
用于记录和为k
的子数组的个数,初始化为0
。currentSum
用于记录当前的前缀和,初始化为0
。
- 遍历数组并计算前缀和与结果:
for (int num : nums) {
currentSum += num; // 计算当前前缀和
// 查找是否存在符合条件的前缀和
if (prefixSumCount.find(currentSum - k) != prefixSumCount.end()) {
count += prefixSumCount[currentSum - k];
}
// 更新哈希表
prefixSumCount[currentSum]++;
}
- 使用范围
for
循环遍历数组nums
中的每个元素num
。 currentSum += num;
将当前元素num
累加到currentSum
中,从而计算出当前的前缀和。prefixSumCount.find(currentSum - k) != prefixSumCount.end()
用于在哈希表prefixSumCount
中查找键为currentSum - k
的元素。如果找到(即返回的迭代器不等于prefixSumCount.end()
),说明存在一个前缀和使得当前前缀和减去它等于k
,也就意味着从那个前缀和的下一个位置到当前位置的子数组的和为k
。此时,count += prefixSumCount[currentSum - k];
将该前缀和出现的次数累加到count
中,因为每出现一次这样的前缀和,就对应着一个和为k
的子数组。prefixSumCount[currentSum]++;
将当前的前缀和currentSum
及其出现次数更新到哈希表中,以便后续遍历使用。如果currentSum
是第一次出现,那么它的出现次数会从0
变为1
;如果已经出现过,那么出现次数会加1
。
- 返回结果:
return count;
- 遍历完数组后,
count
中记录的就是和为k
的子数组的个数,将其返回作为函数的结果。
难点
- 那个currentsum似乎一直是从第0个数开始的前缀和,好像没有包括从中间哪个数开始的前缀和,为什么不会漏掉结果?
currentSum
确实是从数组第 0 个数开始累加得到的前缀和,但代码不会漏掉结果,这得益于哈希表 prefixSumCount
的使用。下面详细解释为何不会漏掉结果。
核心原理
假设当前遍历到数组的第 j
个元素,此时的前缀和 currentSum
是从第 0 个元素到第 j
个元素的和,即 currentSum = nums[0] + nums[1] + ... + nums[j]
。如果存在一个之前的位置 i
(i < j
),使得从第 i + 1
个元素到第 j
个元素的子数组和为 k
,那么有:
nums[i + 1] + nums[i + 2] + ... + nums[j] = k
而 currentSum = nums[0] + nums[1] + ... + nums[i] + nums[i + 1] + ... + nums[j]
,设从第 0 个元素到第 i
个元素的前缀和为 prefixSum[i]
,则 currentSum = prefixSum[i] + k
,移项可得 prefixSum[i] = currentSum - k
。
结合代码分析
for (int num : nums) {
currentSum += num; // 计算当前前缀和
// 查找是否存在符合条件的前缀和
if (prefixSumCount.find(currentSum - k) != prefixSumCount.end()) {
count += prefixSumCount[currentSum - k];
}
// 更新哈希表
prefixSumCount[currentSum]++;
}
- 计算当前前缀和:在每次循环中,
currentSum
都会累加当前元素num
,得到从第 0 个元素到当前元素的前缀和。 - 查找符合条件的前缀和:通过
currentSum - k
来查找之前是否存在某个前缀和prefixSum[i]
满足prefixSum[i] = currentSum - k
。如果存在,说明从第i + 1
个元素到当前元素的子数组和为k
。由于prefixSumCount
记录了每个前缀和出现的次数,所以将其出现次数累加到count
中。 - 更新哈希表:将当前的
currentSum
及其出现次数更新到哈希表中,以便后续查找使用。
示例说明
假设数组 nums = [1, 1, 1]
,k = 2
。
- 当遍历到第一个元素
1
时,currentSum = 1
,currentSum - k = -1
,哈希表中不存在-1
,更新prefixSumCount[1] = 1
。 - 当遍历到第二个元素
1
时,currentSum = 2
,currentSum - k = 0
,哈希表中存在0
(初始时prefixSumCount[0] = 1
),说明从第 0 个元素到当前元素的子数组和为k
,count
加 1,更新prefixSumCount[2] = 1
。 - 当遍历到第三个元素
1
时,currentSum = 3
,currentSum - k = 1
,哈希表中存在1
,说明从第 1 个元素到当前元素的子数组和为k
,count
加上prefixSumCount[1]
的值(即 1),更新prefixSumCount[3] = 1
。
最终 count = 2
,符合预期结果。
综上所述,虽然 currentSum
是从第 0 个数开始的前缀和,但通过哈希表查找 currentSum - k
,可以找到所有可能的从中间某个数开始的子数组,不会漏掉结果。