哈希表是每个程序员必须掌握的核心数据结构之一。今天我想通过七个实际案例,分享如何用哈希表解决算法问题。这些题目覆盖了哈希表的主要应用场景,从简单的字母统计到复杂的多指针协同操作,都是面试和工程中的高频考点。
字母异位词是指字母组成相同但排列顺序不同的单词,比如"listen"和"silent"。判断两个字符串是否为字母异位词,最直观的思路是统计每个字母出现的次数。
使用数组作为简易哈希表是最优选择:
cpp复制bool isAnagram(string s, string t) {
if(s.length() != t.length()) return false;
int hash[26] = {0};
// 统计s的字母频次
for(char c : s) {
hash[c-'a']++;
}
// 用t的字母抵消统计
for(char c : t) {
if(--hash[c-'a'] < 0) {
return false;
}
}
return true;
}
关键技巧:在第二个循环中直接判断是否出现负值,可以提前终止计算。这种优化在长字符串场景下能显著提升性能。
当需要找出两个数组共有的唯一元素时,哈希表的去重特性就派上用场了。C++中的unordered_set基于哈希实现,插入和查询都是O(1)复杂度。
cpp复制vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> set1(nums1.begin(), nums1.end());
unordered_set<int> result;
for(int num : nums2) {
if(set1.count(num)) {
result.insert(num);
}
}
return vector<int>(result.begin(), result.end());
}
实际工程中,如果nums2非常大,可以考虑先对两个数组都排序,然后使用双指针法,这样空间复杂度可以降到O(1)。
快乐数的计算过程可能陷入无限循环,这是典型的"循环检测"问题。哈希表可以记录已经出现过的数字,当数字重复出现时即可判定不是快乐数。
cpp复制int digitSquareSum(int n) {
int sum = 0;
while(n > 0) {
int digit = n % 10;
sum += digit * digit;
n /= 10;
}
return sum;
}
bool isHappy(int n) {
unordered_set<int> seen;
while(n != 1 && !seen.count(n)) {
seen.insert(n);
n = digitSquareSum(n);
}
return n == 1;
}
有趣的是,数学上可以证明所有非快乐数最终都会进入4 → 16 → 37 → 58 → 89 → 145 → 42 → 20 → 4的循环。利用这个性质可以进一步优化空间复杂度。
暴力解法需要O(n²)时间,而使用哈希表可以将时间复杂度降到O(n)。这是典型的"空间换时间"策略。
cpp复制vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> num_map;
for(int i = 0; i < nums.size(); ++i) {
int complement = target - nums[i];
if(num_map.count(complement)) {
return {num_map[complement], i};
}
num_map[nums[i]] = i;
}
return {};
}
注意点:必须先查询再插入,否则当target是某元素两倍时会错误匹配自身。例如nums=[3], target=6的情况。
直接四重循环时间复杂度O(n⁴)不可接受。将四个数组分成两组,先计算A+B的所有可能和及其出现次数,再在C+D中查找互补值。
cpp复制int fourSumCount(vector<int>& A, vector<int>& B,
vector<int>& C, vector<int>& D) {
unordered_map<int, int> sumAB;
for(int a : A) {
for(int b : B) {
sumAB[a + b]++;
}
}
int count = 0;
for(int c : C) {
for(int d : D) {
int target = -(c + d);
if(sumAB.count(target)) {
count += sumAB[target];
}
}
}
return count;
}
这种分组思想可以推广到多数求和问题,将O(n^k)降到O(n^(k/2))。
判断ransomNote是否能由magazine的字符组成,本质是统计字符资源是否足够。数组哈希表再次派上用场。
cpp复制bool canConstruct(string ransomNote, string magazine) {
int charCount[26] = {0};
// 统计杂志字符资源
for(char c : magazine) {
charCount[c-'a']++;
}
// 消耗字符资源
for(char c : ransomNote) {
if(--charCount[c-'a'] < 0) {
return false;
}
}
return true;
}
如果字符范围扩大到Unicode,可以使用unordered_map替代数组。但英文字母场景下数组效率更高。
这是哈希表与双指针结合的经典问题。关键在于如何高效去重。
cpp复制vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> result;
sort(nums.begin(), nums.end());
for(int i = 0; i < nums.size(); ++i) {
// 跳过重复的a
if(i > 0 && nums[i] == nums[i-1]) continue;
int left = i + 1, right = nums.size() - 1;
while(left < right) {
int sum = nums[i] + nums[left] + nums[right];
if(sum < 0) {
++left;
} else if(sum > 0) {
--right;
} else {
result.push_back({nums[i], nums[left], nums[right]});
// 跳过重复的b和c
while(left < right && nums[left] == nums[left+1]) ++left;
while(left < right && nums[right] == nums[right-1]) --right;
++left;
--right;
}
}
}
return result;
}
去重的三个关键点:
四数之和是三数之和的自然扩展,需要增加一层循环,并注意整数溢出问题。
cpp复制vector<vector<int>> fourSum(vector<int>& nums, int target) {
vector<vector<int>> result;
sort(nums.begin(), nums.end());
for(int i = 0; i < nums.size(); ++i) {
// 跳过重复的a
if(i > 0 && nums[i] == nums[i-1]) continue;
for(int j = i + 1; j < nums.size(); ++j) {
// 跳过重复的b
if(j > i + 1 && nums[j] == nums[j-1]) continue;
int left = j + 1, right = nums.size() - 1;
while(left < right) {
// 防止整数溢出
long sum = (long)nums[i] + nums[j] + nums[left] + nums[right];
if(sum < target) {
++left;
} else if(sum > target) {
--right;
} else {
result.push_back({nums[i], nums[j], nums[left], nums[right]});
// 跳过重复的c和d
while(left < right && nums[left] == nums[left+1]) ++left;
while(left < right && nums[right] == nums[right-1]) --right;
++left;
--right;
}
}
}
}
return result;
}
对于N数之和问题,通用解法是递归地将问题分解为更小规模的子问题,最终归结为两数之和。