LeetCode中求和类问题是一类比较简单的算法问题,但是在这些问题里所涉及到的降低复杂度,剪枝等思想很经典,仔细思考发现很有意思,很适合初入算法的同学,能够给优化自己的代码提供一些思路。本文整理了LeetCode中常见的求和类问题(两数之和,三数之和,四数之和),分析其求解及改进的思路。希望能给初入算法的同学提供一些帮助。
0.涉及到的算法题目
本文所涉及到的所有算法题均来源于LeetCode
- 两数之和
- LeetCode 第 1 题
- 两数之和II - 输入有序数组
- LeetCode 第 167 题
- 两数之和IV - 输入BST
- LeetCode 第 653 题
- 三数之和
- LeetCode 第 15 题
- 四数之和
- LeetCode 第 18 题
1.两数之和
1.1 问题概述
给定一个整数数组 nums
和一个整数目标值 target
,请你在该数组中找出 和为目标值 target
的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
输入:nums = [2,7,11,15], target = 9
输出:[0,1]
解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
1.2 思路分析一 - 暴力枚举
考虑最简单的做法,就是枚举数组中两个数的组合,判断组合相加是否等于target
,相等即可返回。
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
代码如下:
class Solution {
public int[] twoSum(int[] nums, int target) {
int n = nums.length;
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
if (nums[i] + nums[j] == target) {
return new int[]{i, j};
}
}
}
return new int[0];
}
}
1.3 思路分析二 - 哈希表缓存
上面枚举的思路应该都可以想到,但是两层循环时间复杂度还是太高。
我们思考内层循环主要做的事情,就是:遍历每一个元素,寻找一个数nums[j]
满足nums[i] + nums[j] == target
。也可以理解为,在内层寻找一个数nums[j]
满足nums[j] == target - nums[i]
。因为对于每一个内层循环,target - nums[i]
为定值,这样内层循环就简化为:在一个数组中寻找一个数。我们可以使用哈希表先将所有的数缓存一遍,接下来就可以在极短的时间内找到这个数。不过会增加哈希表的空间开销。
- 时间复杂度:O(N)
- 空间复杂度:O(N)
代码如下:
class Solution {
public int[] twoSum(int[] nums, int target) {
Map<Integer,Integer> map = new HashMap<>();
for(int i = 0; i < nums.length; i++){
if(map.containsKey(target - nums[i])){
return new int[]{i,map.get(target - nums[i])};
}
map.put(nums[i],i);
}
return new int[0];
}
}
2.两数之和II - 输入有序数组
2.1 问题概述
给定一个已按照 升序排列 的整数数组 numbers
,请你从数组中找出两个数满足相加之和等于目标数 target
。
函数应该以长度为 2
的整数数组的形式返回这两个数的下标值。numbers
的下标 从 1 开始计数 ,所以答案数组应当满足 1 <= answer[0] < answer[1] <= numbers.length
。
你可以假设每个输入只对应唯一的答案,而且你不可以重复使用相同的元素。
输入:numbers = [2,7,11,15], target = 9
输出:[1,2]
解释:2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。
2.2 思路分析一 - 二分法
这个题实质上和两数之和是一样的,但是数组是有序的。
这个题完全可以使用上述的哈希表的思路,但是这样就没有用上有序这个条件,时间和空间上综合来看不算太优。
参照上述哈希表方法的分析思路,第二层循环其实就是在找一个数,而我们的数组是有序的,如果使用二分法,就可以在 logN 的时间内完成查找,且不需要占用额外的空间。这里的二分法可以自己实现,也可调用语言的二分查找库,这里我使用自己实现的二分查找。
代码如下:
class Solution {
public int[] twoSum(int[] numbers, int target) {
int n = numbers.length;
for(int i = 0; i < n; i++){
int l = i + 1;
int r = n - 1;
int t = target - numbers[i];
while(l <= r){
int m = l + (r - l) / 2;
if(numbers[m] > t){
r = m - 1;
}else if(numbers[m] < t){
l = m + 1;
}else if(numbers[m] == t){
return new int[]{i + 1, m + 1};
}
}
}
return new int[0];
}
}
2.3 思路分析二 - 双指针
针对这个有序数组的特性,我们可以使用这样的双指针思路:初始化两个指针l = 0
,r = n - 1
,判断nums[l] + nums[r]
和target
的关系,如果前者要小,右移l
指针,如果后者要小,左移r
指针,直到找到满足相等的l
和r
为止。
实用双指针,可以不断的缩小我们查找的范围,并且可以保证找到答案。证明如下:
-
设答案为
i,j
,即满足条件nums[i] + nums[j] == target
。(i < j
) -
l,r
指针一个向右扫描,一个向左扫描,总共会出现三种情况:- 第一种情况,
l
先抵达i
,此时r
还在j
的右边,即l = i r > j
,此时恒有nums[i] + nums[j] > target
,r
指针会往左移,这样,一定会移动到r = j
的情况。 - 第二种情况,
r
先抵达j
,此时l
还在i
的左边,即r = j l < i
,此时恒有nums[i] + nums[j] < target
,l
指针会往右移,这样,一定会移动到l = i
的情况。 - 第三种情况,
l
和r
同时抵达i
和j
,此时已经找到答案。
- 第一种情况,
-
综上所述,双指针的解法一定可以找到答案。
-
时间复杂度:O(N)
-
空间复杂度:O(1)
代码如下:
class Solution {
public int[] twoSum(int[] numbers, int target) {
int n = numbers.length;
for(int i = 0; i < n; i++){
int l = i + 1;
int r = n - 1;
int t = target - numbers[i];
while(l <= r){
int m = l + (r - l) / 2;
if(numbers[m] > t){
r = m - 1;
}else if(numbers[m] < t){
l = m + 1;
}else if(numbers[m] == t){
return new int[]{i + 1, m + 1};
}
}
}
return new int[0];
}
}
3.两数之和IV - 输入BST
3.1 题目概述
给定一个二叉搜索树 root
和一个目标结果 k
,如果 BST 中存在两个元素且它们的和等于给定的目标结果,则返回 true
。
输入: root = [5,3,6,2,4,null,7], k = 9
输出: true
3.2 思路分析一 - 哈希表缓存
虽然输入的是一棵树,但是我们还是可以使用上述哈希表的思路,然后再按照树的遍历方法来遍历就可以了。树的遍历方式不限。
- 时间复杂度: O(N)
- 空间复杂度: O(N)
代码如下:
class Solution {
Set<Integer> set;
int k;
boolean success;
public boolean findTarget(TreeNode root, int k) {
set = new HashSet<>();
this.k = k;
success = false;
find(root);
return success;
}
void find(TreeNode node){
if(node == null) return;
if(set.contains(k - node.val)){
success = true;
return;
}
set.add(node.val);
find(node.left);
find(node.right);
}
}
3.3 思路分析二 - 中序遍历转有序数组
上述思路可以解决问题,但是没有使用到BST的特性,BST是二叉搜索树,按照中序遍历可以得到一个升序数组。我们可以先遍历树将得到的升序序列存到一个数组中,再按照两数之和II的双指针解法去做。这个思路的时间和空间复杂度和哈希表的思路是一样的。
- 时间复杂度:O(N)
- 空间复杂度:O(N)
代码如下:
class Solution {
List<Integer> list;
public boolean findTarget(TreeNode root, int k) {
list = new ArrayList<>();
getSortArray(root);
int n = list.size();
int l = 0;
int r = n - 1;
while(l < r){
if(list.get(l) + list.get(r) > k){
r--;
}else if(list.get(l) + list.get(r) < k){
l++;
}else{
return true;
}
}
return false;
}
void getSortArray(TreeNode node){
if(node == null) return;
getSortArray(node.left);
list.add(node.val);
getSortArray(node.right);
}
}
4.三数之和
4.1 题目概述
给你一个包含 n
个整数的数组 nums
,判断 nums
中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0
且不重复的三元组。
注意: 答案中不可以包含重复的三元组。
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
4.1 思路分析一 - 双循环+哈希缓存+Set去重
三数之和的本质也和上面的两数之和差不多,主要的差别就是:如何去重。再者,因为涉及到三个数,所以一定要对循环进行合适的剪枝,不然时间复杂度很容易超出限制。
在此题中,我们依然可以采用哈希表来缓存数组中的值,这样就可以把原本的三层循环降低到两层循环,然后我们可以将满足条件的三个数排序后转换成字符串作为Set的唯一键,进行去重。
此外,我们还可以进行一些剪枝操作,降低时间的消耗:
- 对于选择的序列
i,j,k
保证i < j < k
,这样能够避免重复选取已经选到的数。(这种做法是保证选取数的下标唯一性,不保证数的唯一性),具体的实施就是:保证第二层的循环下标永远大于第一层。 - 对于每层循环,如果循环当前循环的数与上一次循环的数相等,那么直接跳过这一次循环。(如果两次循环的数相同,那么获得的组合也是一样的,产生重复的循环,直接返回)
代码如下:
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
int n = nums.length;
List<List<Integer>> res = new ArrayList<>();
if(n == 0){
return res;
}
Map<Integer,Integer> map = new HashMap<>();
Set<String> set = new HashSet<>();
for(int i = 0; i < n; i++){
map.put(nums[i],i);
}
for(int i = 0; i < n - 1; i++){
if(i > 0 && nums[i] == nums[i - 1]) continue;
for(int j = i + 1; j < n; j++){
if(j > i + 1 && nums[j] == nums[j - 1]) continue;
if(map.containsKey(- nums[i] - nums[j])){
int k = map.get(- nums[i] - nums[j]);
if(k > i && k > j){
String key = getStrNum(nums[i], nums[j], nums[k]);
if(!set.contains(key)){
List<Integer> list = new ArrayList<>();
list.add(nums[i]);
list.add(nums[j]);
list.add(nums[k]);
res.add(list);
set.add(key);
}
}
}
}
}
return res;
}
// 将三个数排序后转换成字符串形式的唯一键
public String getStrNum(int a, int b, int c){
int sum = a + b + c;
int min = Math.min(Math.min(a,b),c);
int max = Math.max(Math.max(a,b),c);
a = min;
b = sum - min - max;
c = max;
StringBuilder sb = new StringBuilder("");
sb.append(String.valueOf(a));
sb.append("-");
sb.append(String.valueOf(b));
sb.append("-");
sb.append(String.valueOf(c));
return sb.toString();
}
}
- 时间复杂度:O(N^2)
- 空间复杂度:O(N)
4.2 思路分析二 - 排序+双指针
上述去重的思路其实还是把可能的结果都生成了一遍的,只不过进行了去重,这样浪费了大量的时间。在上面的思路中,我们保证下标是有序的,这样避免了选取相同下标元素的可能,如果我们把整个数组进行排序,并且保证第二层循环大于第一层循环,并且相邻两次的循环不能相同,这样就能保证所有选取的元素不重复。
因为数组此时是有序的,我们可以使用双指针的思路来解决第三个数的寻找问题。(具体双指针的实现见代码)
同样我们可以进行一些剪枝的操作:
- 如果第一层循环的值已经大于0,之后的数只会更大,再也无法找到满足条件的值,直接返回。
- 如果
nums[i] + nums[j] > 0
,第二层之后的数只会更大,可以退出当前循环。 - 如果寻找第三个数的过程中
j = k
,之后的数只会更大,可以退出当前循环。
代码如下:
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
int n = nums.length;
List<List<Integer>> res = new ArrayList<>();
if(n == 0){
return res;
}
Arrays.sort(nums);
for(int i = 0; i < n; i++){
// 剪枝
if(i > 0 && nums[i] == nums[i - 1]) continue;
if(nums[i] > 0) break;
int t = 0 - nums[i];
int k = n - 1;
for(int j = i + 1; j < n; j++){
// 剪枝
if(j > i + 1 && nums[j] == nums[j - 1]) continue;
if(nums[i] + nums[j] > 0) break;
while( j < k && nums[j] + nums[k] > t) k--;
// 剪枝
if(j == k) break;
if(nums[j] + nums[k] == t){
List<Integer> list = new ArrayList<>();
list.add(nums[i]);
list.add(nums[j]);
list.add(nums[k]);
res.add(list);
}
}
}
return res;
}
}
- 这段代码的效率是上一个思路代码的60倍。
- 时间复杂度:O(N^2)
- 空间复杂度:O(logN)(排序消耗的空间)
5.四数之和
5.1 题目概述
给定一个包含 n 个整数的数组 nums
和一个目标值 target
,判断 nums
中是否存在四个元素 a,b,c 和 d ,使得 a + b + c + d 的值与 target
相等?找出所有满足条件且不重复的四元组。
注意: 答案中不可以包含重复的四元组。
输入:nums = [1,0,-1,0,-2,2], target = 0
输出:[[-2,-1,1,2],[-2,0,0,2],[-1,0,0,1]]
0 <= nums.length <= 200
-109 <= nums[i] <= 109
-109 <= target <= 109
5.2 思路分析一 - 四层循环+Set去重
问题其实还是差不多,这里主要就是层数变多了,但同时数据量也小了,四层循环通过一些剪枝后,还是能通过本题。
去重的思路还是保证下标升序,并用Set去重。
剪枝操作:
- 对于每一层循环,如果当前循环
nums
值等于上一次循环的值,那么这次循环直接跳过。
代码如下:
class Solution {
public List<List<Integer>> fourSum(int[] nums, int target) {
int n = nums.length;
List<List<Integer>> res = new ArrayList<>();
Set<String> set = new HashSet<>();
if(n == 0){
return res;
}
for(int i = 0; i < n - 3; i++){
if(i > 0 && nums[i] == nums[i-1]) continue;
for(int j = i + 1; j < n - 2; j++){
if(j > i + 1 && nums[j] == nums[j - 1]) continue;
for(int k = j + 1; k < n - 1; k++){
if(k > j + 1 && nums[k] == nums[k - 1]) continue;
for(int l = k + 1; l < n; l++){
if(l > k + 1 && nums[l] == nums[l - 1]) continue;
if(nums[i] + nums[j] + nums[k] + nums[l] == target){
String key = getKey(nums[i],nums[j],nums[k],nums[l]);
if(!set.contains(key)){
List<Integer> list = new ArrayList<>();
list.add(nums[i]);
list.add(nums[j]);
list.add(nums[k]);
list.add(nums[l]);
res.add(list);
set.add(key);
}
}
}
}
}
}
return res;
}
public String getKey(int a, int b, int c, int d){
int[] arr = new int[]{a,b,c,d};
Arrays.sort(arr);
StringBuilder sb = new StringBuilder("");
for(int i = 0; i < 4; i++){
sb.append(String.valueOf(arr[i]));
sb.append("-");
}
return sb.toString();
}
}
- 时间复杂度:O(N^4)
- 空间复杂度:O(N)
5.3 思路分析二 - 排序+双指针
上面这种解法还是太耗时了,还可以参照三数之和的排序➕双指针的思路,基本上一模一样,只不过对于四层循环,还有可以剪枝的地方。
剪枝操作:
- 第一层循环:
if(nums[i] + nums[i + 1] + nums[i + 2] + nums[i + 3] > target) break;
后面一组已经比target大了,之后的只会更大,直接退出循环。if(nums[i] + nums[n -3] + nums[n - 2] + nums[n - 1] < target) continue;
加上最大的三个树都还小于target,直接开始下一次循环。
- 二三层思路一样,先判断后一组是否比target要大,再判断加上最大的几个数是否比target要小,这是两种极端情况。
代码如下:
class Solution {
public List<List<Integer>> fourSum(int[] nums, int target) {
int n = nums.length;
List<List<Integer>> res = new ArrayList<>();
if(n == 0){
return res;
}
Arrays.sort(nums);
for(int i = 0; i < n - 3; i++){
// 剪枝
if(i > 0 && nums[i] == nums[i - 1]) continue;
if(nums[i] + nums[i + 1] + nums[i + 2] + nums[i + 3] > target) break;
if(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;
if(nums[i] + nums[j] + nums[j + 1] + nums[j + 2] > target) break;
if(nums[i] + nums[j] + nums[n - 2] + nums[n - 1] < target) continue;
int t = target - nums[i] - nums[j];
int l = n - 1;
for(int k = j + 1; k < n - 1; k++){
// 剪枝
if(k > j + 1 && nums[k] == nums[k - 1]) continue;
if(nums[i] + nums[j] + nums[k] + nums[k + 1] > target) break;
if(nums[i] + nums[j] + nums[k] + nums[n - 1] < target) continue;
while(k < l && nums[k] + nums[l] > t) l--;
if(k == l) break;
if(nums[k] + nums[l] == t){
addToRes(res, nums[i], nums[j], nums[k], nums[l]);
}
}
}
}
return res;
}
void addToRes(List<List<Integer>> res, int a, int b, int c, int d){
res.add(Arrays.asList(a,b,c,d));
}
}
- 这段代码的效率是思路一的200倍。
- 时间复杂度:O(N^3)
- 空间复杂度:O(logN)(排序消耗的空间)
6.求和类题型的总结(两数之和,三数之和,四数之和)
6.1 题目共性
- 这种求和类题目,其实本质上都可以理解成两数之和,通常是固定一些数字,然后再去寻找另一些数字。
6.2 常见思路分析
- 使用哈希表缓存一遍值是一种常见的思路,这意味着可以优化掉一层循环(空间换时间)。但是需要注意的地方是,哈希表缓存的时机和循环的方式息息相关。如果是先缓存一遍值,那么再之后的循环中,每次从哈希表中取出值都需要判断是否和自身重复;如果一边遍一边缓存,那么找的值都是以前的值,没必要判断是否重复。
- 如果数据有序,可以考虑使用二分法,这将不需要额外的空间,但能把一层循环的时间优化到
O(logN)
(时间换空间)。 - 如果数据有序,可以考虑使用双指针的方法,这样从不论从时间上,还是空间上,都是最优的。
- 如果数据无序,如果需要寻找的数字多于两个,可以考虑进行排序,因为排序的复杂度其实是低于
O(N*logN)
的。 - 如果要找的数字很多,常常需要进行剪枝,这里面常见的剪枝思路:
- 优化相邻的循环,如果相邻的数值相同,那么其实这一次的结果和上一次是相同的,直接跳过当前循环。
- 优化不必要的循环,针对有序的情况,可以判断之后的一个组合是否大于
target
,如果大于,那么之后所有的数都将不满足条件;可以判断当前数字与最大的数字组合是否小于target
,如果小于,那么这个数里面的循环没有意义,直接进行下一次循环。
ATFWUS 2021-07-30