1. 哈希表与双指针算法实战解析
今天我们来深入探讨LeetCode中几道经典的哈希表和双指针算法题目。这些题目看似简单,但其中蕴含着许多值得深思的算法技巧和优化思路。作为算法训练营的第七天内容,我们将通过454.四数相加、383.赎金信、15.三数之和和18.四数之和这四道题目,系统性地掌握哈希表和双指针在实际问题中的应用。
1.1 哈希表基础应用
哈希表(Hash Table)是一种通过键值对存储数据的数据结构,它能够在平均O(1)时间复杂度内完成数据的插入、删除和查找操作。在算法问题中,哈希表常被用来快速判断某个元素是否存在,或者统计元素的出现次数。
454. 四数相加 II
这道题目要求我们统计四个数组中各取一个数相加等于0的组合数量。直接暴力解法需要O(n^4)的时间复杂度,显然不可取。我们可以采用分组哈希的策略,将问题转化为两数之和的形式:
java复制class Solution {
public int fourSumCount(int[] nums1, int[] nums2, int[] nums3, int[] nums4) {
int n = nums1.length;
int ans = 0;
// 记录nums1和nums2中两数之和的出现次数
Map<Integer, Integer> num2cnt = new HashMap<>();
for(int i=0; i<n; i++) {
for(int j=0; j<n; j++) {
num2cnt.merge(nums1[i]+nums2[j], 1, Integer::sum);
}
}
// 检查nums3和nums4中两数之和的相反数是否在哈希表中
for(int i=0; i<n; i++) {
for(int j=0; j<n; j++) {
int cnt = num2cnt.getOrDefault(-nums3[i]-nums4[j], 0);
ans += cnt;
}
}
return ans;
}
}
关键点:将四数之和问题分解为两个两数之和问题,时间复杂度从O(n^4)降低到O(n^2),空间复杂度为O(n^2)。这种分组哈希的思想在处理多数组组合问题时非常有效。
383. 赎金信
这道题目要求判断ransomNote字符串是否能由magazine字符串中的字符组成。我们可以使用数组作为简易哈希表来统计字符出现次数:
java复制class Solution {
public boolean canConstruct(String ransomNote, String magazine) {
if(magazine.length() < ransomNote.length()) return false;
int[] char2cnt = new int[26];
// 统计magazine中各字符出现次数
for(char c : magazine.toCharArray()) {
char2cnt[c-'a']++;
}
// 检查ransomNote中的字符是否都能被满足
for(char c : ransomNote.toCharArray()) {
if(--char2cnt[c-'a'] < 0) return false;
}
return true;
}
}
注意事项:当magazine长度小于ransomNote时直接返回false可以提前终止判断。使用固定大小的数组作为哈希表比使用HashMap更高效,因为字符范围已知且有限。
1.2 双指针算法进阶
双指针算法通常用于处理有序数组或链表的问题,通过维护两个指针来减少不必要的计算。在三数之和和四数之和问题中,双指针与排序的结合能有效降低时间复杂度。
15. 三数之和
这道经典题目要求找出所有不重复的三元组,使其和为0。解题关键在于排序、双指针和去重:
java复制class Solution {
public List<List<Integer>> threeSum(int[] nums) {
Arrays.sort(nums);
List<List<Integer>> ans = new ArrayList<>();
for(int i=0; i<nums.length-2; i++) {
// 提前终止条件
if(nums[i]>0) break; // 当前元素>0,后续组合不可能等于0
if(i>0 && nums[i]==nums[i-1]) continue; // 去重第一个元素
// 优化条件
if(nums[i]+nums[i+1]+nums[i+2]>0) break; // 前三个元素和>0
if(nums[i]+nums[nums.length-2]+nums[nums.length-1]<0) continue; // 最大和<0
int j=i+1, k=nums.length-1;
while(j<k) {
int sum = nums[i] + nums[j] + nums[k];
if(sum < 0) {
j++;
} else if (sum > 0) {
k--;
} else {
ans.add(List.of(nums[i], nums[j], nums[k]));
j++; k--;
// 去重
while(j<k && nums[j]==nums[j-1]) j++;
while(j<k && nums[k]==nums[k+1]) k--;
}
}
}
return ans;
}
}
优化技巧:
- 排序后如果第一个数已经大于0,可以直接终止循环
- 检查当前数与前两个数的和是否大于0,可以提前终止
- 检查当前数与最后两个数的和是否小于0,可以跳过当前数
- 每次找到有效组合后,需要跳过重复元素
18. 四数之和
四数之和是三数之和的扩展,需要多一层循环,但核心思路相同。需要注意的是不能像三数之和那样直接根据单个元素与target的关系进行剪枝:
java复制class Solution {
public List<List<Integer>> fourSum(int[] nums, int target) {
Arrays.sort(nums);
int n = nums.length;
List<List<Integer>> ans = new ArrayList<>();
for(int i=0; i<n-3; i++) {
// 不能直接判断nums[i] > target就break,因为target可能是负数
if(i>0 && nums[i] == nums[i-1]) continue;
// 前四个数之和已经大于target
if((long)nums[i] + nums[i+1] + nums[i+2] + nums[i+3] > target) break;
// 当前数与最后三个数之和仍小于target
if((long)nums[i] + nums[n-3] + nums[n-2] + nums[n-1] < target) continue;
for(int j=i+1; j<n-2; j++) {
if(j>i+1 && nums[j] == nums[j-1]) continue;
// 当前两个数与最后两个数之和仍小于target
if((long)nums[i] + nums[j] + nums[n-2] + nums[n-1] < target) continue;
int k = j+1, l = n-1;
while(k<l) {
long sum = (long)nums[i] + nums[j] + nums[k] + nums[l];
if(sum < target) {
k++;
} else if (sum > target) {
l--;
} else {
ans.add(List.of(nums[i], nums[j], nums[k], nums[l]));
k++; l--;
while(k<l && nums[k]==nums[k-1]) k++;
while(k<l && nums[l]==nums[l+1]) l--;
}
}
}
}
return ans;
}
}
关键区别:在四数之和中,不能简单地因为nums[i] > target就终止循环,因为target可能是负数,而后续的负数相加可能满足条件。必须考虑多个数的组合情况。
2. 算法优化与常见错误分析
2.1 时间复杂度对比
| 题目 | 暴力解法 | 优化解法 | 空间复杂度 |
|---|---|---|---|
| 四数相加II | O(n^4) | O(n^2) | O(n^2) |
| 赎金信 | O(n*m) | O(n+m) | O(1) |
| 三数之和 | O(n^3) | O(n^2) | O(1) |
| 四数之和 | O(n^4) | O(n^3) | O(1) |
2.2 常见错误与调试技巧
-
四数相加II中的哈希表使用:
- 错误:直接在第一次双重循环中就计算所有四个数的和
- 正确:分组计算,先计算前两个数组的和并记录次数,再计算后两个数组的和的相反数
-
三数之和的去重问题:
- 错误:只在找到三元组后才去重
- 正确:在外层循环和内层循环都需要跳过重复元素
-
整数溢出问题:
- 错误:在四数之和中直接相加四个int可能导致溢出
- 正确:使用long类型存储中间结果
-
剪枝条件错误:
- 错误:在四数之和中直接判断nums[i] > target就break
- 正确:必须考虑多个数的组合情况,特别是target为负数时
2.3 实际编码中的性能优化
-
选择合适的数据结构:
- 当键的范围已知且有限时(如小写字母),使用数组比HashMap更高效
- 对于大范围或复杂键,HashMap是更好的选择
-
提前终止条件:
- 在循环中加入合理的提前终止条件可以显著减少不必要的计算
- 例如在三数之和中,当第一个数大于0时就可以终止循环
-
避免重复计算:
- 在双指针法中,移动指针时要跳过重复元素
- 在哈希表法中,合理设计键的生成方式避免重复存储
-
类型转换与溢出处理:
- 对于可能的大数相加,提前转换为更大的数据类型
- 在Java中,int相加可能溢出,使用long存储中间结果
3. 算法思想扩展与应用
3.1 哈希表的其他应用场景
- 缓存实现:哈希表常被用来实现缓存系统,如LRU Cache
- 快速查找:需要频繁查找元素的场景,如单词拼写检查
- 频率统计:统计元素出现频率,如找出数组中出现次数超过一半的元素
- 唯一性检查:检查集合中是否存在重复元素
3.2 双指针的变种问题
- 滑动窗口:解决子串/子数组问题,如最小覆盖子串
- 快慢指针:链表问题,如检测环、找中点
- 合并区间:处理重叠区间问题
- 雨水收集:二维数组中的双指针应用
3.3 多指针与多维问题
对于更高维的问题,如五数之和、六数之和等,可以采用类似的思路:
- 排序数组
- 固定前k-2个数
- 对最后两个数使用双指针
- 注意去重和剪枝条件
这种分治思想可以将高维问题转化为低维问题,逐步降低复杂度。
4. 算法实战经验分享
在实际面试和竞赛中,处理这类问题有几个实用技巧:
-
先考虑暴力解法:即使知道暴力解法不可行,也要先明确暴力解法的时间复杂度,这有助于思考优化方向。
-
画图辅助思考:对于双指针问题,画出指针移动的示意图能帮助理解算法过程。
-
小规模测试:先用手动计算小规模例子验证算法正确性,再处理大规模数据。
-
边界条件检查:特别注意空输入、单个元素、全部相同元素等边界情况。
-
逐步优化:从暴力解法开始,逐步思考如何利用数据结构或算法技巧降低时间复杂度。
-
代码复用:像四数之和这类问题,可以复用三数之和的代码结构,保持代码风格一致。
-
变量命名有意义:使用能表达意图的变量名,如left、right比i、j更能表达双指针的含义。
-
注释关键步骤:在复杂算法中添加简要注释,说明关键步骤的意图,便于后期维护和理解。
通过这四道题目的系统训练,我们不仅掌握了哈希表和双指针的具体应用,更重要的是学会了如何分析问题、设计算法和优化性能的通用方法。这些技巧对于解决其他算法问题同样具有指导意义。