题目描述

image.png

题目链接

https://leetcode.cn/problems/4sum/

思路

最朴素的方法是使用四重循环枚举所有的四元组,然后使用哈希表进行去重操作,得到不包含重复四元组的最终答案。假设数组的长度是【18】四数之和 - 图2,则该方法中,枚举的时间复杂度为【18】四数之和 - 图3,去重操作的时间复杂度和空间复杂度也很高,因此我们需要换一种思路。

为了避免枚举到重复四元组,则需要保证每一重循环枚举到的元素不小于其上一重循环枚举到的元素,且在同一重循环中不能多次枚举到相同的元素。为了实现上述要求,可以对数组进行排序,并在循环过程中遵循以下两点:

  • 每一种循环枚举到的下标必须大于上一重循环枚举到的下标;
  • 同一重循环中,如果当前元素与上一个元素相同,则跳过当前元素。

使用上述方法,可以避免枚举到重复四元组,但是由于仍使用四重循环,时间复杂度仍是【18】四数之和 - 图4。然而它是很容易继续优化的,注意到数组已经被排序,因此可以使用双指针的方法去掉一重循环。使用两重循环分别枚举前两个数,然后在两重循环枚举到的数之后使用双指针枚举剩下的两个数。假设两重循环枚举到的前两个数分别位于下标【18】四数之和 - 图5【18】四数之和 - 图6,其中【18】四数之和 - 图7。初始时,左右指针分别指向下标【18】四数之和 - 图8和下标【18】四数之和 - 图9。每次计算四个数的和,并进行如下操作:

  • 如果和等于【18】四数之和 - 图10,则将枚举到的四个数加到答案中,然后将左指针右移直到遇到不同的数,将右指针左移直到遇到不同的数;
  • 如果和小于【18】四数之和 - 图11,则将左指针右移一位;
  • 如果和大于【18】四数之和 - 图12,则将右指针左移一位。

使用双指针枚举剩下的两个数的时间复杂度是【18】四数之和 - 图13,因此总时间复杂度是【18】四数之和 - 图14,低于【18】四数之和 - 图15

具体实现时,还可以进行一些剪枝操作:

  • 在确定第一个数之后,如果【18】四数之和 - 图16,说明此时剩下的三个数无论取什么值,四数之和一定大于【18】四数之和 - 图17,因此退出第一重循环;
  • 在确定第一个数之后,如果【18】四数之和 - 图18,说明此时剩下的三个数无论取什么值,四数之和一定小于【18】四数之和 - 图19,因此第一重循环直接进入下一轮,枚举【18】四数之和 - 图20
  • 在确定前两个数之后,如果【18】四数之和 - 图21,说明此时剩下的两个数无论取什么值,四数之和一定大于【18】四数之和 - 图22,因此退出第二重循环;
  • 在确定前两个数之后,如果【18】四数之和 - 图23,说明此时剩下的两个数无论取什么值,四数之和一定小于【18】四数之和 - 图24,因此第二重循环直接进入下一轮,枚举【18】四数之和 - 图25

    代码实现

    1. public List<List<Integer>> fourSum(int[] nums, int target) {
    2. if (nums == null || nums.length < 4) {
    3. return new ArrayList<>();
    4. }
    5. int length = nums.length;
    6. List<List<Integer>> res = new ArrayList<>();
    7. // 排序
    8. Arrays.sort(nums);
    9. // 枚举第一个数
    10. for (int first = 0; first < length - 3; first++) {
    11. // 跳过重复数字
    12. if (first > 0 && nums[first] == nums[first - 1]) {
    13. continue;
    14. }
    15. // 提前剪枝
    16. if ((long) nums[first] + nums[first + 1] + nums[first + 2] + nums[first + 3] > target) {
    17. break;
    18. }
    19. if ((long) nums[first] + nums[length - 3] + nums[length - 2] + nums[length - 1] < target) {
    20. continue;
    21. }
    22. // 枚举第二个数
    23. for (int second = first + 1; second < length - 2; second++) {
    24. // 跳过重复数字
    25. if (second > first + 1 && nums[second] == nums[second - 1]) {
    26. continue;
    27. }
    28. // 提前剪枝
    29. if ((long) nums[first] + nums[second] + nums[second + 1] + nums[second + 2] > target) {
    30. break;
    31. }
    32. if ((long) nums[first] + nums[second] + nums[length - 2] + nums[length - 1] < target) {
    33. continue;
    34. }
    35. // 第三、四个数采用滑动窗口思想
    36. int third = second + 1, fourth = length - 1;
    37. while (third < fourth) {
    38. int sum = nums[first] + nums[second] + nums[third] + nums[fourth];
    39. // 窗口右侧左滑
    40. if (sum > target) {
    41. // 滑动时要跳过重复数字
    42. int next = fourth - 1;
    43. while (next > third && nums[next] == nums[fourth]) {
    44. next--;
    45. }
    46. fourth = next;
    47. } else {
    48. // 符合条件的四元组
    49. if (sum == target) {
    50. List<Integer> tmp = new ArrayList<>();
    51. tmp.add(nums[first]);
    52. tmp.add(nums[second]);
    53. tmp.add(nums[third]);
    54. tmp.add(nums[fourth]);
    55. res.add(tmp);
    56. }
    57. // 窗口左侧右滑,滑动时要跳过重复数字
    58. int next = third + 1;
    59. while (next < fourth && nums[next] == nums[third]) {
    60. next++;
    61. }
    62. third = next;
    63. }
    64. }
    65. }
    66. }
    67. return res;
    68. }

    复杂度分析

  • 时间复杂度:【18】四数之和 - 图26,其中【18】四数之和 - 图27是数组的长度。排序的时间复杂度是【18】四数之和 - 图28,枚举四元组的时间复杂度是【18】四数之和 - 图29,因此总时间复杂度为【18】四数之和 - 图30

  • 空间复杂度:【18】四数之和 - 图31,其中【18】四数之和 - 图32是数组的长度。空间复杂度主要取决于排序额外使用的空间。此外排序修改了输入数组【18】四数之和 - 图33,实际情况中不一定允许,因此也可以看成使用了一个额外的数组存储了数组【18】四数之和 - 图34的副本并排序,空间复杂度为【18】四数之和 - 图35