LeetCode 求和类问题 深入分析(两数之和,三数之和,四数之和)

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]
解释:27 之和等于目标数 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 = 0r = n - 1,判断nums[l] + nums[r]target的关系,如果前者要小,右移l指针,如果后者要小,左移r指针,直到找到满足相等的lr为止。

实用双指针,可以不断的缩小我们查找的范围,并且可以保证找到答案。证明如下:

  • 设答案为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的情况。
    • 第三种情况,lr同时抵达ij,此时已经找到答案。
  • 综上所述,双指针的解法一定可以找到答案。

  • 时间复杂度: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

《LeetCode 求和类问题 深入分析(两数之和,三数之和,四数之和)》

输入: 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

    原文作者:ATFWUS
    原文地址: https://blog.csdn.net/ATFWUS/article/details/119242614
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞