云计算百科
云计算领域专业知识百科平台

双指针算法 题解总结

引入:双指针到底解决什么问题

我目前对“双指针”的理解是:在遍历数组、数字状态或候选区间时,同时维护两个会移动的位置,并让它们承担不同的职责。这样做的价值不只是代码里出现了两个变量,而是每次移动都能根据题目的规律排除一批不可能的情况,从而减少重复枚举。

很多直接枚举的写法会产生两层、三层甚至四层循环。双指针并不能把所有问题都变成线性复杂度,但在数据有序、结果具有单调性,或者数组需要原地读写时,它经常可以把一部分重复搜索压缩掉。

本文整理八道题:283 移动零、1089 复写零、202 快乐数、11 盛最多水的容器、611 有效三角形的个数、LCR 179 两数之和、15 三数之和、18 四数之和。范围主要集中在四种模式:

  • 同向快慢指针:一个指针读,一个指针写;
  • 从后向前的读写指针:避免扩展写入时覆盖还没有读取的数据;
  • 快慢指针判环:把反复变化的数字状态看成一条路线;
  • 排序后相向双指针:根据和的大小、面积上限或不等式关系移动左右边界。

这里的“原地”是指直接修改输入数组,不额外创建一个同等规模的新数组;“去重”是指避免同一个答案因为数组中出现重复数字而被加入多次。下面按题号逐题整理我对指针含义、移动理由和代码细节的理解。


一、283. 移动零

题目描述

给定一个数组 nums,将数组中的所有 0 移动到数组末尾,同时保持非零元素原来的相对顺序。要求直接修改输入数组,也就是原地完成,不能返回一个新的完整数组。

例如,[0, 1, 0, 3, 12] 处理后变为 [1, 3, 12, 0, 0]。

题目链接

LeetCode 283. 移动零

算法思路

这道题适合同向快慢指针。我的判断依据是:题目要求把“满足条件的元素”集中到数组前面,同时保留它们原来的顺序,并且要求原地修改。

可以把任务拆成两步:先把所有非零数按原顺序写到数组前面,再把剩余位置补成零。

  • fast 是读指针,从左到右检查每一个元素;
  • slow 是写指针,表示下一个非零元素应该放在哪里;
  • 每读到一个非零元素,就写到 nums[slow],然后让 slow 向右移动;
  • 全部扫描结束后,slow 左侧已经放好了所有非零元素,从 slow 开始到数组末尾全部填零。

这里不需要在每遇到一个零时搬动后面的整段元素。那种做法虽然容易想到,但多个零会反复搬动同一批数据,最坏情况下会达到 O(n²)。快慢指针把“寻找非零元素”和“放置非零元素”分开,每个元素只被扫描和处理有限次。

slow 不会超过 fast,因此写入动作不会覆盖右侧还没有读到的元素。当 slow 和 fast 相等时,当前非零元素相当于写回原处,也不会有问题。

Java代码

class Solution {
public void moveZeroes(int[] nums) {
int slow = 0;

for (int fast = 0; fast < nums.length; fast++) {
if (nums[fast] != 0) {
nums[slow] = nums[fast];
slow++;
}
}

while (slow < nums.length) {
nums[slow] = 0;
slow++;
}
}
}

代码说明

slow 初始为 0,表示第一个非零数应当写入的位置。fast 扫描到非零值时,nums[slow] = nums[fast] 将它写入当前应该保留的区域,然后 slow++ 为下一个非零值预留位置。

扫描结束时,区间 [0, slow) 中已经按原有顺序放入所有非零数。这里的右边界 slow 不包含在已完成区域中,所以从 slow 开始补零即可。

例如输入 [0, 1, 0, 3, 12],非零数依次是 1、3、12,它们会被写到下标 0、1、2,最后下标 3、4 写成零。

复杂度与易错点

  • 时间复杂度:O(n)。fast 扫描数组一次,补零最多再扫描一次,整体仍是线性时间。
  • 空间复杂度:O(1),只使用了几个额外变量。

易错点主要有以下几个:

  • 非零元素包括负数,判断条件必须是 nums[fast] != 0。
  • 前移非零元素后不能忘记补零,否则数组后半部分可能残留旧值。
  • 题目要求原地修改,方法返回类型是 void,不是返回新数组。
  • 不能因为担心自己覆盖自己,就写出复杂的交换逻辑;slow == fast 时赋值是安全的。
  • 非零元素的相对顺序必须保持,不能使用会改变顺序的随意交换方案。
  • 这道题让我先认识到一种很常见的双指针职责分工:fast 负责找,slow 负责放。后面的同向数组题也可以先从这个角度判断。


    二、1089. 复写零

    题目描述

    给定一个固定长度的数组 arr,每遇到一个零,就在它后面复制一个零,并将右侧元素依次向右移动。超出数组长度的元素会被丢弃。要求原地修改数组,数组长度保持不变。

    例如,[1, 0, 2, 3, 0, 4, 5, 0] 处理后为 [1, 0, 0, 2, 3, 0, 0, 4]。

    题目链接

    LeetCode 1089. 复写零

    算法思路

    这道题和移动零的区别在于:元素不是被筛选后占一个位置,而是零会额外占用一个位置。数组仍然是固定长度,因此从前向后直接写会覆盖后面还没有读取的元素。

    我把它理解成一个“虚拟扩容数组”:普通数字在虚拟数组中占一个位置,零占两个位置。实际数组并没有真的扩容,只是先按照这个规则计算每个原元素在虚拟数组中的位置。

    具体做法如下:

  • 统计原数组中零的数量 zeros。
  • i 从数组最后一个位置开始,表示当前正在读取的原元素。
  • j 从虚拟扩容数组的末尾开始,表示当前应该写入的虚拟位置。
  • 从后向前处理每个原元素。如果 j 落在真实数组范围内,就写入 nums[j] = nums[i]。
  • 如果当前元素是零,还需要让 j 再向左移动一格,并在范围内写入第二个零。
  • 最后让 i 和 j 继续向左移动,处理前一个原元素。
  • 从后向前写的好处是,写入位置位于当前读取位置的右侧,或者与当前读取位置重合,不会破坏左侧尚未读取的内容。若虚拟位置已经超出真实数组边界,只计算位置而不实际赋值。

    Java代码

    class Solution {
    public void duplicateZeros(int[] nums) {
    int zeros = 0;
    for (int num : nums) {
    if (num == 0) {
    zeros++;
    }
    }

    int i = nums.length – 1;
    int j = nums.length + zeros – 1;

    while (i >= 0) {
    if (j < nums.length) {
    nums[j] = nums[i];
    }

    if (nums[i] == 0) {
    j–;
    if (j < nums.length) {
    nums[j] = 0;
    }
    }

    i–;
    j–;
    }
    }
    }

    代码说明

    zeros 表示所有零在虚拟数组中多占出来的格数,因此虚拟数组最后一个下标是 nums.length + zeros – 1。

    i 始终指向真实数组中还没有处理的原元素,j 指向这个原元素在虚拟布局中应该写入的位置。普通元素只占一格,所以写完后 j–;零占两格,所以先写一份,再额外 j– 写第二份零,最后再统一 j– 进入前一个原元素对应的区域。

    边界判断只在写入前进行。比如虚拟数组的末尾可能超过真实数组末尾,这些位置虽然参与了计算,但不能访问 nums[j]。因此 if (j < nums.length) 是避免数组越界的关键。

    复杂度与易错点

    • 时间复杂度:O(n)。统计零一次,倒序处理一次。
    • 空间复杂度:O(1)。没有创建虚拟数组,虚拟数组只是用来推导下标。

    易错点包括:

  • 不能从前向后直接复制,否则新写入的内容可能覆盖后面还没有读到的元素。
  • j 可能大于等于 nums.length,写入前必须检查范围。
  • 当前元素为零时,需要额外向左移动一次并写入第二个零。
  • “虚拟扩容”不等于真的创建一个更长的数组,否则空间复杂度就不是常数了。
  • 有些边界位置的零复制后完全落在数组外,只需要计算,不需要写入。
  • 和移动零放在一起看,二者都属于原地读写,但方向相反:如果写入不会增加元素数量,可以从前向后用快慢指针;如果一个元素可能扩展成多个位置,并且前写会覆盖未读数据,就更适合从后向前处理。


    三、202. 快乐数

    题目描述

    给定一个正整数 n,不断将它替换为各位数字的平方和。如果最终得到 1,那么这个数就是快乐数;如果计算过程进入一个不包含 1 的循环,则它不是快乐数。

    例如,19 → 82 → 68 → 100 → 1,所以 19 是快乐数。

    题目链接

    LeetCode 202. 快乐数

    算法思路

    这道题表面上没有数组下标,但仍然可以使用快慢指针。每个数字都可以看成一个状态,计算“各位数字平方和”就是从当前状态走向下一个状态。

    如果一个状态序列最终到达 1,由于 1 的下一个状态仍然是 1,所以它会进入 1 这个长度为一的环。如果不是快乐数,序列也会进入另一个环。问题的关键就变成了:状态序列是否成环,以及相遇时的状态是不是 1。

    做法是:

    • slow 每次执行一次 getNext;
    • fast 每次执行两次 getNext;
    • 如果序列进入环,快指针最终会追上慢指针;
    • 相遇后判断相遇值是否为 1。

    这里将数字状态当成链表节点,getNext 就类似于链表节点的 next。快慢指针不一定只能操作数组或链表,只要对象之间存在“下一步”的关系,就可能用来判断环。

    Java代码

    class Solution {
    public boolean isHappy(int n) {
    int slow = n;
    int fast = getNext(n);

    while (slow != fast) {
    slow = getNext(slow);
    fast = getNext(getNext(fast));
    }

    return slow == 1;
    }

    private int getNext(int n) {
    int sum = 0;

    while (n > 0) {
    int digit = n % 10;
    sum += digit * digit;
    n /= 10;
    }

    return sum;
    }
    }

    代码说明

    getNext(int n) 负责计算下一个状态。n % 10 取出当前个位数字,n /= 10 去掉个位,循环结束时就得到了所有位数字平方和。

    这里将 slow 初始化为 n,将 fast 初始化为 getNext(n),相当于让快指针先走一步。这样可以直接使用普通的 while (slow != fast)。如果两个指针都初始化为 n,则需要采用 do…while 或其他方式保证至少先执行一次移动。

    非快乐数 2 的变化过程会出现:2 → 4 → 16 → 37 → 58 → 89 → 145 → 42 → 20 → 4。再次遇到 4 就说明进入了循环,快慢指针会在这个环中相遇,而相遇值不是 1。

    复杂度与易错点

    • 时间复杂度:每次计算各位平方和需要处理数字的位数,单次是 O(log n);对于 int 范围的输入,状态很快会进入一个有限的小范围,整体可以按 O(log n) 级别理解。
    • 空间复杂度:O(1)。

    易错点包括:

  • fast 每轮必须走两步,即 getNext(getNext(fast))。
  • 不能只循环到 n == 1,因为非快乐数会无限循环,需要显式处理环。
  • 取个位使用 % 10,去掉个位使用 /= 10。
  • 1 本身是一个自环,所以最后判断相遇值是否为 1。
  • 另一种常见写法是用 HashSet 记录出现过的状态,逻辑直观但需要 O(n) 级别的额外空间;本题的快慢指针版本把辅助空间降到了 O(1)。

  • 四、11. 盛最多水的容器

    题目描述

    给定一个整数数组 height,其中 height[i] 表示位于下标 i 的竖线高度。选择两条不同的竖线,与横轴共同构成一个容器,求这个容器能够盛放的最大水量。

    容器面积由两部分决定:两条线之间的宽度,以及两条线中较短的高度。面积公式是 min(height[left], height[right]) * (right – left)。

    题目链接

    LeetCode 11. 盛最多水的容器

    算法思路

    这是相向双指针的典型题。开始时让 left 位于最左端、right 位于最右端,此时宽度最大。每一轮先计算当前容器面积,再决定移动哪一侧。

    水位受到短板限制,所以如果左边高度较小,继续保留左边而只让右边向内移动时:

    • 宽度一定会变小;
    • 水位仍然不会超过当前左边的短板;
    • 面积不可能超过当前这组边界的面积。

    因此,左边较短时,当前左边界对应的其他更窄组合都可以排除,只有换掉左边、寻找更高的左边界才有可能产生更大的面积。右边较短时同理。

    这就是“移动短边”的依据,不是一个脱离题目条件的固定口诀。它依赖于面积中的短板限制和宽度单调变小这两个事实。当两边一样高时,移动任意一边都可以,代码中选择移动左边。

    Java代码

    class Solution {
    public int maxArea(int[] height) {
    int left = 0;
    int right = height.length – 1;
    int ret = 0;

    while (left < right) {
    int width = right – left;
    int minHeight = Math.min(height[left], height[right]);
    int area = width * minHeight;
    ret = Math.max(ret, area);

    if (height[left] <= height[right]) {
    left++;
    } else {
    right–;
    }
    }

    return ret;
    }
    }

    代码说明

    left 和 right 表示当前还没有排除的两条边。宽度是下标差 right – left,不是元素个数,所以不需要加一。

    每轮先通过 Math.min 求出短板高度,再计算面积并更新 ret。更新完当前组合后,根据两边高度决定移动短边。循环条件是 left < right,因为同一个下标不能形成有宽度的容器。

    例如数组 [1,8,6,2,5,4,8,3,7],最初两端形成面积 8。左边高度 1 是短板,移动左指针后,边界来到高度 8,此时与右端高度 7 形成面积 7 * 7 = 49,后续搜索不会得到更大结果。

    复杂度与易错点

    • 时间复杂度:O(n)。每轮至少移动一个指针,两个指针总共只向内移动有限次。
    • 空间复杂度:O(1)。

    易错点包括:

  • 水位是较短边的高度,必须使用 Math.min,不能使用 Math.max。
  • 宽度是 right – left。
  • 先计算并更新当前面积,再移动指针,避免漏算当前边界组合。
  • 移动短边而不是长边;长边被保留时,宽度变小但短板上限没有改善。
  • 循环条件应为 left < right。
  • int 可以覆盖题目通常给出的面积范围,但如果题目约束更大,面积计算也需要检查是否应该使用 long。

  • 五、611. 有效三角形的个数

    题目描述

    给定一个包含非负整数的数组 nums,统计从数组中选择三个下标后,能够组成三角形的组合数量。相同数值如果来自不同下标,仍然属于不同的下标组合,需要分别计数。

    三角形成立的条件是:两条较短边之和严格大于最长边。

    题目链接

    LeetCode 611. 有效三角形的个数

    算法思路

    直接使用三层循环会得到 O(n³)。排序以后,可以固定最长边,再用左右指针批量统计满足条件的组合。

    先对数组升序排序。令 i 从数组末尾向前移动,把 nums[i] 作为当前固定的最长边。剩下的两条边在 [0, i – 1] 中选择,设置:

    • left = 0,指向当前最小候选边;
    • right = i – 1,指向当前最大的候选边。

    判断 nums[left] + nums[right] > nums[i]:

    • 如果成立,由于数组已经有序,left 到 right – 1 的所有元素都不小于 nums[left],它们和 nums[right] 组成的组合也都满足条件。因此可以一次增加 right – left 个答案,然后让 right–。
    • 如果不成立,说明当前最小边太小。固定 left 时,即使使用最大的另一条边 nums[right] 也不够,只有让 left++ 变大才有机会满足条件。

    这里的批量计数是本题最重要的地方。排序带来的单调性让一次判断可以覆盖一整段候选,而不是逐个检查。

    Java代码

    import java.util.Arrays;

    class Solution {
    public int triangleNumber(int[] nums) {
    Arrays.sort(nums);
    int ret = 0;

    for (int i = nums.length – 1; i >= 2; i–) {
    int left = 0;
    int right = i – 1;

    while (left < right) {
    if (nums[left] + nums[right] > nums[i]) {
    ret += right – left;
    right–;
    } else {
    left++;
    }
    }
    }

    return ret;
    }
    }

    代码说明

    排序后固定的 nums[i] 是最长边,所以只需要判断 nums[left] + nums[right] > nums[i]。另外两组边之和一定不会更小,因为 nums[i] 已经是三者中的最大值。

    条件成立时,为什么增加 right – left 而不是 right – left + 1?当前 right 已经被作为第二条边使用,左边只能从 left 到 right – 1 选择,共有 right – left 个下标。之后 right–,继续处理下一条可能的第二长边。

    条件不成立时,当前 left 和最大的 right 都无法组成三角形,因此保留这个 left 没有必要,直接 left++。

    例如排序后的 [2, 2, 3, 4]:固定最长边 4 时,3 与两个 2 都能组成三角形,一次计入两个组合;固定最长边 3 时,两个 2 又组成一个组合,总数为 3。这里相同的边长来自不同下标,因此不能像三数之和那样按数值去重。

    复杂度与易错点

    • 时间复杂度:排序为 O(n log n),外层固定最长边并进行双指针扫描的部分为 O(n²),总复杂度是 O(n²)。
    • 空间复杂度:除排序实现可能使用的栈空间外,算法只使用常数个变量;Java 中 Arrays.sort(int[]) 的具体辅助空间取决于实现。

    易错点包括:

  • 三角形条件是严格大于 >,等于时只是退化成一条直线。
  • 条件成立时增加 right – left,不要把 right 自己重复算作左边。
  • 条件成立移动 right,条件不成立移动 left,方向不能写反。
  • 必须先排序,否则无法使用批量统计的单调性。
  • 零会被严格不等式自然排除,不必单独删除。
  • 本题统计的是下标组合,不是不同数值组合;重复数值来自不同下标时要分别计数。
  • 如果题目数据范围使答案可能超过 int,还需要根据题目约束选择更大的结果类型;常规题目约束下 int 返回值符合题面要求。

  • 六、LCR 179. 两数之和

    题目描述

    给定一个已经按非递减顺序排列的数组 price 和目标值 target,找出两个数,使它们的和等于 target,并返回这两个数。题目通常保证存在满足条件的答案,返回的是数值而不是下标。

    题目链接

    LCR 179. 查找总价格为目标值的两个商品

    算法思路

    这是有序数组中的标准相向双指针。left 从最小值开始,right 从最大值开始。每轮计算两端之和:

    • 和等于 target,直接返回两个数;
    • 和小于 target,需要让和变大,所以移动 left,尝试更大的数;
    • 和大于 target,需要让和变小,所以移动 right,尝试更小的数。

    移动理由依赖于数组有序。如果当前和太小,移动 right 只会让右侧数变小,结果不可能变大,因此当前 left 与这个 right 的组合以及更小的右侧组合都可以排除;同理,和太大时应排除当前 right。

    如果数组无序,就不能直接套用这套方向判断。无序数组通常需要哈希表,或者先排序并同时保留原下标;本题已经有序,所以双指针可以用常数级额外空间完成。

    Java代码

    class Solution {
    public int[] twoSum(int[] price, int target) {
    int left = 0;
    int right = price.length – 1;

    while (left < right) {
    int sum = price[left] + price[right];

    if (sum == target) {
    return new int[] {price[left], price[right]};
    } else if (sum < target) {
    left++;
    } else {
    right–;
    }
    }

    return new int[0];
    }
    }

    代码说明

    left 和 right 始终指向两个不同位置,所以循环条件为 left < right。当 sum == target 时,返回包含两个价格的数组,而不是返回两个下标。

    以 price = [2, 7, 11, 15]、target = 9 为例,开始时 2 + 15 = 17,和太大,右指针向左移动;接着 2 + 11 = 13 仍然太大,右指针继续移动;最后 2 + 7 = 9,返回 [2, 7]。

    方法末尾的 return new int[0] 是为了覆盖“没有找到答案”的代码路径。题目如果保证答案存在,正常执行时会在循环中返回;这行不会影响核心算法。

    复杂度与易错点

    • 时间复杂度:O(n)。两个指针都只向内移动,不会反复回退。
    • 空间复杂度:O(1),不计返回数组占用的固定空间。

    易错点包括:

  • 这套移动方向建立在数组有序的前提上。
  • sum < target 时移动 left,sum > target 时移动 right。
  • left < right 保证不会重复使用同一个位置。
  • LCR 179 返回的是两个数值,不是下标;和其他“两数之和”题目混淆时需要重新核对题面。
  • 如果题目约束中的数值可能使两数相加溢出 int,可以将 sum 声明为 long;常见约束下 int 通常足够。
  • 这道题让我更清楚地看到:相向双指针的关键不是“左右各放一个指针”,而是数组有序后,当前和的大小能够决定哪一侧不再有可能。


    七、15. 三数之和

    题目描述

    给定一个整数数组 nums,找出所有和为 0 且不重复的三元组 [nums[i], nums[left], nums[right]]。三元组中的三个元素必须来自三个不同下标,答案中的组合顺序不重要。

    例如,输入 [-1, 0, 1, 2, -1, -4],结果为 [[-1, -1, 2], [-1, 0, 1]]。

    题目链接

    LeetCode 15. 三数之和

    算法思路

    三数之和如果使用三层循环,复杂度是 O(n³)。排序后固定第一个数,再在右侧区间用左右指针寻找另外两个数,可以降到 O(n²)。

    步骤如下:

  • 先将数组升序排序。
  • 用 i 固定三元组中的第一个数。
  • 令 left = i + 1、right = nums.length – 1,在剩余区间中寻找两数之和 -nums[i]。
  • 计算三数和:
    • 和小于 0,left++,尝试更大的数;
    • 和大于 0,right–,尝试更小的数;
    • 和等于 0,加入结果,然后左右指针都移动。
  • 为了不重复加入相同答案,需要在固定层和命中答案后分别去重。
  • “去重”在这里指的是答案值不能重复,并不是说数组中相同元素只能使用一次。比如 [-1, -1, 2] 是合法答案,因为两个 -1 来自两个不同下标。真正需要跳过的是:当某一层固定的值与上一轮相同,继续使用它只会生成已经处理过的答案。

    固定层去重写成 i > 0 && nums[i] == nums[i – 1]。找到一个答案后,先让 left++ 和 right– 离开当前组合,再跳过新的左右指针与刚使用值相同的元素。

    排序还有一个额外作用:如果 nums[i] > 0,那么后面的数也都大于零,三数和不可能再回到零,可以提前结束外层循环。

    Java代码

    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.List;

    class Solution {
    public List<List<Integer>> threeSum(int[] nums) {
    Arrays.sort(nums);
    List<List<Integer>> ret = new ArrayList<>();

    for (int i = 0; i < nums.length – 2; i++) {
    if (i > 0 && nums[i] == nums[i – 1]) {
    continue;
    }
    if (nums[i] > 0) {
    break;
    }

    int left = i + 1;
    int right = nums.length – 1;

    while (left < right) {
    int sum = nums[i] + nums[left] + nums[right];

    if (sum < 0) {
    left++;
    } else if (sum > 0) {
    right–;
    } else {
    ret.add(Arrays.asList(nums[i], nums[left], nums[right]));
    left++;
    right–;

    while (left < right && nums[left] == nums[left – 1]) {
    left++;
    }
    while (left < right && nums[right] == nums[right + 1]) {
    right–;
    }
    }
    }
    }

    return ret;
    }
    }

    代码说明

    排序后,三元组会以非递减顺序出现,因此相同答案中的数字排列也统一了。外层循环只需要保证 i 右边还有两个位置,所以条件是 i < nums.length – 2。

    i > 0 && nums[i] == nums[i – 1] 只跳过同一层中连续重复的固定值,不会影响使用重复数字组成合法答案。比如排序后的 [-1, -1, 0, 1, 2],第一个 -1 已经作为固定值搜索过,第二个 -1 作为固定值时会得到同样的答案,因此跳过。

    在左右扫描中,和偏小时左指针右移,和偏大时右指针左移。命中答案后必须同时移动两个指针,否则下一轮还会停在同一组数上。移动后再跳过相同值,才能避免 [0, 0, 0, 0] 之类的数据产生重复三元组。

    复杂度与易错点

    • 时间复杂度:排序为 O(n log n),外层固定一个数、内层双指针扫描为 O(n²),总复杂度为 O(n²)。
    • 空间复杂度:不计返回结果时,主要是排序可能使用的栈空间;结果列表占用的空间取决于答案数量。

    易错点包括:

  • 固定层去重应比较 nums[i] 和 nums[i – 1],不能比较后一个位置,否则可能跳过合法答案。
  • 只有命中答案后,才需要专门跳过左右两侧的重复值;和偏小时或偏大时正常移动即可。
  • 命中后必须让 left、right 同时移动,否则可能死循环或重复加入当前答案。
  • left < right 保证三个下标互不相同。
  • 返回的是数值组合,不是下标组合。
  • Arrays.sort(nums) 会改变输入数组;本题的算法利用了这个变化。
  • 本题目标固定为 0,所以 nums[i] > 0 时可以提前结束;这个剪枝不能不加判断地套到目标值任意的四数之和中。

  • 八、18. 四数之和

    题目描述

    给定一个整数数组 nums 和目标值 target,找出所有总和等于 target 且不重复的四元组 [nums[i], nums[j], nums[left], nums[right]]。四个元素必须来自四个不同下标。

    例如,输入 nums = [1, 0, -1, 0, -2, 2]、target = 0,结果为 [[-2, -1, 1, 2], [-2, 0, 0, 2], [-1, 0, 0, 1]]。

    题目链接

    LeetCode 18. 四数之和

    算法思路

    四数之和可以看成三数之和再增加一层固定下标:先排序,用 i 固定第一个数,用 j 固定第二个数,再用 left 和 right 在剩余区间中进行相向扫描。

    流程是:

  • 升序排序,使移动方向和去重都有依据。
  • 外层循环固定 i,并跳过同一层重复的 nums[i]。
  • 内层循环固定 j,并只在当前 i 的范围内跳过重复的 nums[j]。
  • 令 left = j + 1、right = nums.length – 1。
  • 计算四数和:
    • 和小于 target,左指针右移;
    • 和大于 target,右指针左移;
    • 和等于 target,记录答案,左右指针各移动一次,再跳过重复值。
  • 这里必须使用 long 计算 sum。即使方法参数和数组元素是 int,四个接近 1_000_000_000 的数相加也可能超过 int 的最大值。需要在加法开始前转换类型:(long) nums[i] + nums[j] + nums[left] + nums[right]。如果只把最终结果赋值给 long,而加法过程仍全部是 int,溢出已经发生,后续再转换也无法恢复。

    四层去重分别对应:

    • 固定 i 时跳过同层重复值;
    • 固定 j 时跳过同一 i 下的重复值;
    • 找到答案后跳过重复的 left 值;
    • 找到答案后跳过重复的 right 值。

    Java代码

    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.List;

    class Solution {
    public List<List<Integer>> fourSum(int[] nums, int target) {
    Arrays.sort(nums);
    List<List<Integer>> ret = new ArrayList<>();

    for (int i = 0; i < nums.length – 3; i++) {
    if (i > 0 && nums[i] == nums[i – 1]) {
    continue;
    }

    for (int j = i + 1; j < nums.length – 2; j++) {
    if (j > i + 1 && nums[j] == nums[j – 1]) {
    continue;
    }

    int left = j + 1;
    int right = nums.length – 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 {
    ret.add(Arrays.asList(
    nums[i], nums[j], nums[left], nums[right]));
    left++;
    right–;

    while (left < right && nums[left] == nums[left – 1]) {
    left++;
    }
    while (left < right && nums[right] == nums[right + 1]) {
    right–;
    }
    }
    }
    }
    }

    return ret;
    }
    }

    代码说明

    外层 i 右侧至少要剩下三个元素,所以循环条件是 i < nums.length – 3。内层 j 右侧至少要剩下两个元素,所以条件是 j < nums.length – 2。

    j 的去重条件不能简单写成 j > 0。当 j == i + 1 时,它是当前固定的 i 后面第一个候选位置,即使数值与前面其他位置相同,也不能跨越当前 i 的边界去跳过。正确的判断是 j > i + 1 && nums[j] == nums[j – 1],表示只跳过同一个 i 下已经处理过的第二个数。

    相向扫描部分和三数之和相同:和小就需要增大左侧数字,和大就需要减小右侧数字。命中后同时移动左右指针,再跳过重复数值。

    四个下标始终满足 i < j < left < right,所以不会重复使用同一个数组位置。排序后加入结果的四个数天然是非递减顺序,答案的表示也统一了。

    复杂度与易错点

    • 时间复杂度:排序为 O(n log n),两层固定下标约为 O(n²),每组固定下标用双指针扫描为 O(n),总复杂度为 O(n³)。
    • 空间复杂度:不计返回结果时,主要是排序可能使用的栈空间;结果列表占用的空间取决于答案数量。

    易错点包括:

  • 四数和计算必须在加法开始前使用 long,推荐写成 long sum = (long) nums[i] + nums[j] + nums[left] + nums[right]。
  • i、j 两个固定层都要去重,命中后 left、right 也要去重。
  • j 的去重条件是 j > i + 1,不能写成只判断 j > 0。
  • 命中答案后要同时移动左右指针,否则会停在原组合上。
  • 四个位置必须严格递增,不能使用同一个下标两次。
  • 这里的 target 不一定是 0,不能直接照搬三数之和中 nums[i] > 0 就结束的条件。
  • 先保证基础移动和去重逻辑正确,再考虑上下界剪枝;剪枝条件如果没有同步处理类型范围,反而可能引入错误。

  • 九、八道题放在一起看:双指针规律的归纳

    1. 快慢指针不只是一种写法

    283 移动零中的两个指针都从左向右,但一个负责读取、一个负责写入;202 快乐数中的两个指针也都沿着同一条状态路线前进,但一个走一步、一个走两步;1089 复写零则是从右向左完成读写。

    所以我现在不再只按变量名记忆“快慢指针”,而是先问两个指针各自代表什么:

    • 是不是一个负责读、一个负责写?
    • 是否存在会重复出现的状态?
    • 写入是否会让数据变长,从而覆盖尚未读取的内容?

    指针的移动方向和速度,都是由这个职责决定的。

    2. 左右指针的前提是能够排除不可能情况

    LCR 179 中,数组有序使得“和小增大左端、和大减小右端”成立;11 盛最多水的容器中,短板和宽度的关系使得移动长边没有机会超过当前面积;611 有效三角形的个数中,排序后的不等式让一次判断能够批量计数。

    因此,看到两个边界并不意味着一定能用双指针。需要进一步确认:移动一侧后,是否真的能排除一批候选,并且不会错过答案。

    3. 排序既是为了移动,也是为了去重

    611、15、18 都先排序,但使用排序的方式不完全相同:

    • 三角形个数利用有序关系批量增加答案;
    • 三数之和利用和的大小移动左右指针,并对固定数和命中后的左右值去重;
    • 四数之和在三数之和基础上多固定一层,因此多了一层 j 的去重。

    排序还会改变输入数组原来的顺序,这些题的题意允许这种变化。若题目要求保留原数组顺序,就需要重新考虑是否可以排序。

    4. 去重是“同一层不重复”,不是“相同数字不能使用”

    在三数之和和四数之和中,相同数值来自不同下标时仍然可能组成合法答案。例如两个 0 可以同时参与一个答案。去重针对的是“同一个值组合已经被记录过”,而不是把重复元素全部删除。

    • 外层固定值:与同层前一个值相同就跳过;
    • 内层固定值:只在同一个外层固定值范围内跳过;
    • 左右扫描命中后:跳过与刚刚使用的数值相同的指针位置。

    611 则不同,因为它统计下标组合的数量,相同边长对应不同下标时需要分别计数,不能使用三数之和的去重方式。

    5. 数值范围会影响指针题的正确性

    18 四数之和中,指针方向本身没有问题,但如果四个 int 先相加并溢出,sum 的大小就会变错,后面的移动也会跟着错。使用 long 时,类型转换必须发生在第一次加法之前。

    这提醒我,算法逻辑和 Java 类型范围需要一起检查。代码看起来符合双指针模板,并不代表在所有数值范围下都安全。


    十、这一组题形成的判断顺序

    现在遇到一个可能使用双指针的问题时,我会按下面的顺序判断:

  • 先确认目标是什么。 是把某类元素集中到一侧、完成原地读写、判断状态是否成环,还是寻找满足关系的两个或多个数。
  • 再说明每个指针的含义。 一个指针是读、一个是写,还是左右边界,或者是状态序列中速度不同的两个位置。
  • 检查数据是否有序,或者是否可以先排序。 只有存在顺序和单调性,才可能根据当前结果决定移动方向。
  • 写出移动依据。 和小了为什么左移,和大了为什么右移;短板为什么需要更换;条件成立时为什么可以批量计数。
  • 确定循环边界。 两个指针能否指向同一位置,通常决定使用 < 还是 <=;固定了几个数,也决定剩余区间至少要留几个位置。
  • 检查是否需要去重。 返回所有数值组合时通常要处理重复答案;统计下标组合时,重复数值不一定需要跳过。
  • 检查写入方向和覆盖风险。 原地扩展写入时,前向写是否会覆盖未读数据;如果会,就考虑从后向前。
  • 检查类型和边界。 多个整数相加是否可能溢出,空数组、短数组、全零、全重复等情况是否会让指针初始化或循环条件失效。
  • 最后核对复杂度。 统计每个指针总共移动多少次,区分排序成本、外层固定成本和结果输出成本。
  • 这八道题放在一起后,双指针对我们来说不再是单独的一组固定代码,而是一种利用职责、顺序、单调性和排除关系来减少重复搜索的方法。代码中的 left++、right– 或 slow++ 只有在移动理由清楚时才真正可靠。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 双指针算法 题解总结
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!