力扣算法解析按专题吃透,不做题海 速查 90 天路线 ⌂ 全部教程

专题 01数组与双指针

这是整个系列的第一站,也是性价比最高的一站。数组是力扣里出现频率最高的数据结构, 双指针则是「把一个 O(n²) 的暴力解压成 O(n)」最常用、也最容易被想出来的武器。 这一章不追求题量,而是把双指针为什么能降复杂度这件事讲透——你真正要学的不是「哪道题用哪个指针」, 而是「怎么看出一层循环是多余的」这种嗅觉。

学习目标
掌握双指针三种形态,独立写出 4 道经典题
前置要求
Java 数组基础、会写 for 循环
本章产出
4 道题的推导 + 可提交 Java 代码
建议学时
5–6 小时

1.1为什么数组是所有题的起点

在动手写任何算法之前,先把「数组」这件事想清楚,后面很多优化思路都会自己冒出来。 数组在内存里是一段连续的空间,这个「连续」带来了两个对你刷题至关重要的性质。

性质一:随机访问是 O(1)

因为内存连续,数组里第 i 个元素的地址 = 起始地址 + i × 每个元素大小, 是一次加法算出来的,跟你数组有多长无关。也就是说:

int x = nums[i];   // 无论 nums 有 10 个还是 10 万个元素,这一步都只花固定时间

反例:链表要拿第 i 个元素,得从表头一个个数过去,O(n)。所以「能随机访问」这件事, 让很多「我要看某个位置的数」的操作变得几乎免费。双指针之所以能在数组上玩出花, 根基就是「移动指针、读当前位置」都是 O(1)。

性质二:可以原地(in-place)操作

数组的元素直接躺在内存里,你改 nums[i] 就是真的改了那块内存,不需要新建一个数组。 这带来一个超级实用的结论:很多「整理数组」的题(把某些元素挪前面、删掉某些元素、去重), 都可以用「两个指针在同一个数组上互相配合、边读边写」完成,空间复杂度压到 O(1)。

一句话记忆:数组 = 能随机访问 + 能原地改。这两点合起来,就是双指针可以 0 额外空间解决问题的根本原因。

降复杂度的直觉:少遍历一遍

绝大多数「数组题为什么能用双指针」的答案,归根结底是一句话:原来的暴力解里,有些遍历是重复的、可以合并的。

举个例子(先不用写代码,建立直觉):要找数组里两个数加起来等于某个 target。 最朴素的想法是「对每个数,再扫一遍后面所有数看有没有配对的」——两层循环,O(n²)。 但这两层循环里,i 和 j 都在从左往右扫,很多组合被反复查看。如果我们让两个指针一个从左、一个从右, 利用「数组有序」或者「用哈希记住见过谁」,就能把第二层循环整个省掉,变成 O(n)。 所谓双指针,本质就是「让两个下标协同前进,替代一团乱麻的嵌套循环」。

暴力解的样子优化后的样子复杂度变化
for i ... { for j ... } 两层彼此独立地扫left 和 right 协同,各只走一遍O(n²) → O(n)
每查一个数都从头找一遍用哈希/有序性记住信息,查一次 O(1)O(n²) → O(n)

1.2双指针的三种形态

双指针不是一种固定写法,而是一类思想。面试和刷题里,它最常见的有三种长相。 先认脸,后面做题时你才能对上号。

形态一:对撞指针(左右往中间夹)

两个指针 left 和 right 分别站在数组两端,根据条件决定「谁往中间走一步」,直到相遇。

int left = 0, right = n - 1;
while (left < right) {
    // 根据 nums[left] 和 nums[right] 的关系,移动其中一边
    if (应该左走) left++; else right--;
}

典型题:盛最多水的容器、两数之和(有序版)、验证回文串、三数之和的内层。

形态二:快慢指针(同向,一快一慢)

两个指针从同一端出发,fast 走得快(负责「探路」、遍历所有元素),slow 走得慢(负责「记录结果该写哪」)。 slow 永远指向「已经处理好的区间的下一个位置」。

int slow = 0;
for (int fast = 0; fast < n; fast++) {
    if (符合条件) {
        nums[slow] = nums[fast]; // 把 fast 看到的好东西搬到 slow
        slow++;
    }
}

典型题:移动零、删除有序数组中的重复项。这种写法也常叫「覆盖式双指针」。

形态三:固定一个,枚举另一个(三数之和式)

外层固定一个下标 i(当作三元组的「第一个数」),内层用对撞指针在 i 右边找另外两个数。 它是「枚举 + 对撞」的组合,专门处理「找 k 个数之和」这类题(k=3 最常见)。

for (int i = 0; i < n; i++) {
    int left = i + 1, right = n - 1;   // 固定了 i,在它右边用对撞指针
    while (left < right) {
        // 看 nums[i] + nums[left] + nums[right] 与目标的关系,移动左右
    }
}

典型题:三数之和、四数之和、最接近的三数之和。

记忆口诀:「两端夹」用对撞;「往前搬」用快慢;「找 k 个」先固定一个再对撞。 这三张脸认熟,本章四道题加上后面哈希、滑动窗口,你都会反复见到它们。

1.3移动零(简单,力扣 283)

简单 核心:快慢指针覆盖式搬运

题意:给一个数组,把所有 0 移到末尾,非零元素的相对顺序保持不变。 要求原地修改(不能新建数组),函数返回 void。 例:输入 [0,1,0,3,12],原地变成 [1,3,12,0,0]。

思路推导

第一步,先允许用额外空间。如果可以新建数组,思路太简单了:扫一遍,把非零元素依次放进新数组前部, 剩下的位置补 0。时间 O(n),空间 O(n)。题目要求原地,所以我们要把这个「新数组」的概念, 映射到原数组自身上。

第二步,观察「非零在前、零在后」意味着什么。其实我们根本不需要关心零去哪—— 只要保证「所有非零元素按原顺序占据数组最前面的位置」,剩下的位置自然全是零。 于是问题变成:如何把非零元素紧凑地排到前面?

第三步,这就是快慢指针。让 fast 当探路者从左到右扫,slow 指向「下一个非零该放的位置」。 fast 每遇到一个非零,就把它搬到 slow 那里,然后 slow 前进一格。 遍历结束后,[0, slow) 就是全部非零元素(顺序和原来一致),[slow, n) 补零即可。

fastnums 当时slow说明
0[0,1,0,3,12]0遇到 0,skip
1[1,1,0,3,12]1非零,搬到 slow=0,slow++
2[1,1,0,3,12]1遇到 0,skip
3[1,3,0,3,12]2非零,搬到 slow=1,slow++
4[1,3,12,3,12]3非零,搬到 slow=2,slow++

遍历完 slow=3,前 3 个已是非零 [1,3,12],下标 3、4 补零得到 [1,3,12,0,0]。 为什么顺序不变?因为 fast 和 slow 都从左往右走,非零元素按出现顺序被依次抄到前面,相对次序天然保持。

参考代码(覆盖式 + 末尾补零)

class Solution {
    public void moveZeroes(int[] nums) {
        int slow = 0;                      // slow 指向下一个非零元素应放的位置
        for (int fast = 0; fast < nums.length; fast++) {
            if (nums[fast] != 0) {         // fast 遇到非零元素
                nums[slow] = nums[fast];   // 把它搬到 slow 指向的位置
                slow++;                    // 非零区间向右扩一格
            }
        }
        // 此时 [0, slow) 是原数组的全部非零元素(顺序不变)
        for (int i = slow; i < nums.length; i++) {
            nums[i] = 0;                   // 剩余位置全部填 0
        }
    }
}

复杂度

O(n) 时间(最多遍历两遍),O(1) 空间(只用两个指针变量)。

更优雅的一遍写法:把上面「搬到 slow + 末尾补零」改成「交换」,就能一遍完成且不用补零: if (nums[fast] != 0) { int t = nums[slow]; nums[slow] = nums[fast]; nums[fast] = t; slow++; }。 当 slow == fast 时交换自己和自己,无影响;当 slow < fast 时把左边的非零换到前面、把那个位置的旧值(必为 0 或已处理过的)换到后面。 推荐记住交换版,面试写出来更利落。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 移除元素(力扣 27,简单)——与本题共用「快慢指针覆盖式搬运」技巧;差异在把非零往前搬改成把「不等于 val 的元素」往前搬(提示:把判等条件从 nums[fast] != 0 换成 nums[fast] != val,其余逻辑完全复用)。
  • 颜色分类(力扣 75,中等)——与本题共用「原地双指针」技巧;差异在要一趟内把 0、1、2 三色同时归位而非只把 0 挪到最后(提示:用 low/mid/high 三指针一次遍历完成分区,详见专题 16)。

1.4删除有序数组中的重复项(简单,力扣 26)

简单 核心:快慢指针 + 有序性

题意:给定已按非严格递增排序的数组,原地删除重复出现的元素,使每个元素只出现一次, 返回删除后数组的新长度 k。力扣会根据你返回的 k 取前 k 个元素来判等。 例:输入 [1,1,2],返回 2,前两个元素应为 [1,2]。

思路推导

第一步,暴力想法为什么行不通。数组不能「真删除」一个元素(那会让后面全体前移,O(n) 且破坏下标)。 题目允许的「删除」其实是覆盖:把不重复的元素往前挪,盖掉重复的。

第二步,利用「有序」。因为数组已排序,重复的元素一定相邻。 这意味着:只要「当前元素和上一个保留下来的元素不同」,它就是一个该保留的新值; 否则它就是重复的,可以忽略。这个「上一个保留下来的元素」由 slow 指着。

第三步,和移动零是同一个模板。移动零是「把非零往前搬」,这里是「把与前一个不同的往前搬」。 slow 指向「已去重区间的末尾」。从第 2 个元素(fast=1)开始,一旦 nums[fast] != nums[slow], 就把 slow 先 +1 再写入 nums[fast]。第 1 个元素 nums[0] 永远保留(它一定是某值的第一个出现)。

fastnumsslow动作
1[1,1,2,2,2,3]0nums[1]==nums[0],重复,skip
2[1,1,2,2,2,3]1不同,slow→1,写入 2
3[1,2,2,2,2,3]1重复,skip
4[1,2,2,2,2,3]1重复,skip
5[1,2,2,2,2,3]1重复,skip
6[1,2,2,2,2,3]2不同,slow→2,写入 3

结束后 slow=2,长度返回 slow + 1 = 3,前 3 个元素 [1,2,3],正确。

参考代码

class Solution {
    public int removeDuplicates(int[] nums) {
        if (nums.length == 0) return 0;     // 空数组特判,否则下面 nums[0] 会越界
        int slow = 0;                        // [0, slow] 是已去重好的区间,首元素必保留
        for (int fast = 1; fast < nums.length; fast++) {
            if (nums[fast] != nums[slow]) {  // 遇到和 slow 不同的新值
                slow++;                      // 去重区间扩一格
                nums[slow] = nums[fast];     // 接到区间末尾
            }
        }
        return slow + 1;                     // 长度 = 末尾下标 + 1
    }
}

复杂度

O(n) 时间(一遍遍历),O(1) 空间。注意这题必须依赖输入有序;若无序,重复项不相邻,此模板失效(那种情况要用哈希,见 专题 02)。

易错点:返回值是 slow + 1 不是 slow。因为 slow 是「最后一个保留元素的下标」, 长度要 +1。另外 nums.length == 0 一定先特判——否则访问 nums[0] 抛异常,单元素数组 [1] 时循环不进、直接返回 1,也正确。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 删除有序数组中的重复项 II(力扣 80,中等)——与本题共用「快慢指针 + 有序性」技巧;差异在允许每个元素最多出现两次而非一次(提示:slow 推进条件由「与前一个不同」改成「与 slow 之前保留的倒数第 2 个比较」,或用计数变量记录连续出现次数)。
  • 移除元素(力扣 27,简单)——与本题共用「原地双指针覆盖」技巧;差异在删除的是「等于 val 的元素」而非相邻的重复元素(提示:把比较对象换成 val,nums[fast] != val 时搬到 slow)。

1.5盛最多水的容器(中等,力扣 11)

中等 核心:对撞指针 + 贪心移动

题意:数组 height 竖着画成 n 根柱子,height[i] 是第 i 根的高度。 选两根柱子 i < j 当容器两边,能装的水面积 = min(height[i], height[j]) × (j - i)。 求最大面积。 例:[1,8,6,2,5,4,8,3,7],最大是 49(选索引 1 和 8:min(8,7)×7 = 49)。

思路推导

第一步,暴力。枚举所有 (i, j) 对,算面积取最大,O(n²)。n 到 10⁵ 必超时。

第二步,关键观察。面积由两件事决定:宽度 j - i 和短板高度 min(h[i], h[j])。 既然宽度最大也就是两端(left=0, right=n-1),我们不妨从最宽的区间出发,然后往里收缩, 看能不能在宽度变小的同时,靠更高的柱子把面积补回来。

第三步,移动哪边?这是本题灵魂。当前区间 [left, right],面积受限于较矮的那根(短板)。 假如移动较高的那根:宽度必然减小 1,而新高度顶多等于原来的短板(因为短板没动,新柱子再高也被它卡住), 所以面积一定不会变大,纯属浪费。因此:只有移动较矮的那根,才可能为面积带来提升。 移动矮的,相当于在「当前这根矮柱子能参与的所有区间里,宽度最大的那个已经算过了,更窄的都不可能更优」,于是放心丢弃当前矮指针。

第四步,证明意识(避免「是不是漏解」的担心)。固定 left 不动、right 从最右往左移的每一步, 都是在枚举「以 left 为左端、不同右端」的区间。其中宽度最大的就是 right 最右时。 如果此时 left 是短板,那么以 left 为左端、任何更窄的区间,高度上限都不变(还是被 left 卡住)、宽度更小, 面积必更小,全部可以丢弃。所以「矮了就移动它」不会漏掉更优解。这正是 1.7 节要强调的「移动条件的证明意识」。

left,right高度面积移动
0, 81, 7min(1,7)×8=8左矮(1),left++
1, 88, 7min(8,7)×7=49右矮(7),right--
1, 78, 3min(8,3)×6=18右矮(3),right--
… 继续,过程中用 max 记录下历史最大 49

参考代码

class Solution {
    public int maxArea(int[] height) {
        int left = 0, right = height.length - 1;
        int ans = 0;
        while (left < right) {
            int h = Math.min(height[left], height[right]); // 取较矮的柱子作为水面高度
            ans = Math.max(ans, h * (right - left));        // 更新历史最大面积
            if (height[left] < height[right]) {
                left++;   // 左边是短板,移动左指针,期待遇到更高的柱子
            } else {
                right--;  // 否则右边是短板(含相等时随便动哪边都行),移动右指针
            }
        }
        return ans;
    }
}

复杂度

O(n) 时间(左右指针各走一遍,共 O(n) 步),O(1) 空间。这是「对撞指针 + 贪心」的典范。

相等时动哪边?两者等价。当 height[left] == height[right],移动任意一边都不会漏解: 因为此时两边都是短板,宽度即将减小,新区间高度上限不超过当前值,面积不会变大,丢弃哪一种都安全。代码里用 else 统一 处理即可。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 接雨水(力扣 42,困难)——与本题共用「对撞指针 + 贪心移动」技巧;差异在面积由「某根柱子到两侧的较大值」决定,要维护 leftMax/rightMax 而非只看当前两柱(提示:每次选左右较小的一侧先处理,用该侧的历史最大高度减当前高度累加进答案)。
  • 验证回文串(力扣 125,简单)——与本题共用「对撞指针」技巧;差异在比较的是字符而非算面积,且需跳过非字母数字、忽略大小写(提示:left/right 向中间夹,遇到非字母数字就跳过,相等才继续推进)。

1.6三数之和(中等,力扣 15)

中等 核心:排序 + 固定一数 + 对撞

题意:给定数组 nums,找出所有不重复的三元组 [a, b, c] 使 a + b + c = 0, 返回 List<List<Integer>>。 例:nums = [-1,0,1,2,-1,-4],输出 [[-1,-1,2], [-1,0,1]](顺序不限,但每组内不重复、组间不重复)。

思路推导

第一步,暴力 + 哈希。最直白:三层循环枚举三元组 O(n³),还得去重。优化一层:固定 a, 在剩余部分用「两数之和」的哈希思路找 b + c = -a,复杂度降到 O(n²)。但哈希去重非常麻烦—— 哈希只能保证数值配对,没法优雅地保证「三元组不重复」。于是转向排序 + 双指针对撞,去重顺手解决。

第二步,排序是前提。先 Arrays.sort(nums)。排序后我们能做两件事: ① 内层用对撞指针在 i 右侧找 b + c = -nums[i](O(n)); ② 相同数值相邻,去重只要「跳过相邻的相同值」。

第三步,外层固定 i,内层对撞。外层 i 从 0 到 n-3,把 nums[i] 当 a; left = i+1, right = n-1,看 nums[left] + nums[right] 与 -nums[i] 的关系: 和太小则 left++,太大则 right--,相等就收集。

第四步,去重(本题真正的坑)。要求「不重复的三元组」,不是「不重复的下标」。两层去重:

为什么是「跳过与前一个相同」,不是「与后一个」?因为 i 是从前往后遍历, nums[i] == nums[i-1] 表示「当前这个 a 和上一轮用的 a 一样」。 既然上一轮已经以那个 a 为起点、扫过它右边所有搭配,这一轮再做必然产生相同的三元组。 同理内层也是「右边已经等于下一个,就跳过下一个」——方向统一朝「已处理过的那个」靠。

参考代码

class Solution {
    public List<List<Integer>> threeSum(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        Arrays.sort(nums);                       // 第一步:排序,对撞和去重都依赖有序
        for (int i = 0; i < nums.length - 2; i++) {
            // 外层去重:当前 a 和上一个 a 相同,跳过(上一轮已穷尽它的所有搭配)
            if (i > 0 && nums[i] == nums[i - 1]) continue;
            int left = i + 1, right = nums.length - 1;
            int target = -nums[i];               // 要在右边找 b + c = -a
            while (left < right) {
                int sum = nums[left] + nums[right];
                if (sum == target) {
                    res.add(Arrays.asList(nums[i], nums[left], nums[right]));
                    // 内层去重:左右各自跳过相同值,避免重复三元组
                    while (left < right && nums[left] == nums[left + 1]) left++;
                    while (left < right && nums[right] == nums[right - 1]) right--;
                    left++; right--;             // 收拢到下一对不同值
                } else if (sum < target) {
                    left++;                      // 和太小,左指针右移增大
                } else {
                    right--;                     // 和太大,右指针左移减小
                }
            }
        }
        return res;
    }
}

复杂度

排序 O(n log n) + 外层 n 次、每次内层 O(n),总 O(n²) 时间,O(1) 额外空间(排序一般算原地,结果列表不计)。

边界自查:① 数组长度 < 3 时循环条件 i < nums.length - 2 直接不进,返回空列表,正确。 ② 全相同如 [0,0,0,0]:i=0 收集一次 [0,0,0],内层 left/right 跳过相同值后收拢相遇,i=1 时因 nums[1]==nums[0] 被外层去重跳过,最终只返回一组,正确。 ③ 若要求 a+b+c=target(任意和),只需把 target 改成传入值, 其余不变。

巩固练习(先自己想 15 分钟,卡住再看括号里的提示)

  • 最接近的三数之和(力扣 16,中等)——与本题共用「排序 + 固定一数 + 对撞」技巧;差异在目标是找「和最接近 target」的三元组而非等于 0(提示:用 Math.abs(sum - target) 更新全局最优,找到等于 target 也不要提前返回,继续收缩)。
  • 四数之和(力扣 18,中等)——与本题共用同框架;差异在多套一层循环固定第二个数,变成「固定两数 + 对撞」(提示:外层再加一层 for j,注意用 long 累加和防止溢出,并同样做两层去重)。

1.7本专题常见坑

  • 坑一:对撞指针「移动谁」没有证明意识。像盛水容器这种题,移动条件是「矮的先动」。 很多人背下代码但说不清为什么,换个变体(比如求最小容器、或高度相等时)就乱。 口诀:移动「瓶颈」那一侧。面积/和受限于某一边,就动那一边才可能变好;动另一侧只减宽度不增上限。
  • 坑二:三数之和去重方向写反。写成 nums[i] == nums[i+1] 去重会漏掉首组或越界。 记住统一朝「已经处理过的那个相同值」去重(外层比 i-1,内层比 left+1 / right-1),并且内层要先收集、再跳重、最后收拢。
  • 坑三:原地操作污染了未处理区间。快慢指针「覆盖式」写法里,slow 永远 ≤ fast, 你写到 slow 的位置不会破坏 fast 还没读到的数据(因为那些在更右边)。但若有人图省事「先删后搬」或乱用交换, 可能把还没看过的有用元素覆盖掉。写之前问自己:我写的位置,fast 已经读过了吗?
  • 坑四:忘记特判空数组 / 单元素。删除重复项、移动零等都要先想 nums.length == 0 或 == 1 时循环进不进、下标越不越界。
  • 坑五:把「返回长度」和「返回数组」搞混。力扣 26 这类题只返回 k,力扣靠 k 截取前 k 个判等, 你不必真的把后面清掉,只要保证前 k 个是正确的去重结果即可(当然清掉也不算错)。

1.8自测题

Q1. 为什么数组上的双指针能做到 O(1) 额外空间,而链表上同样思路常常不行?

因为数组内存连续、可以原地随机读写:快慢指针能直接 nums[slow] = nums[fast] 覆盖,不占新空间。

链表节点散落在内存各处,要「改」一个节点的前驱关系得真的改指针,而且你没法像数组那样「把后面的元素整体往前抄一格」—— 它得一个个新建/重连节点,往往反而要额外空间。所以原地的覆盖式双指针是数组专属的红利。

Q2. 盛最多水的容器里,如果 height[left] == height[right],移动哪边?会漏掉最优解吗?

任意移动一边都不会漏解。此时两边都是短板,宽度即将 -1,新区间高度上限 ≤ 当前短板值,面积不会更大, 因此无论丢弃左还是右的这一种搭配,都不可能丢掉更优解。代码里用 else 统一右移即可。

反过来说,如果你同时移动两边(left++; right--; 都执行),那才可能漏——因为相当于把两种候选一起丢掉,跳过了中间某些组合。

Q3. 三数之和里,外层去重 if (i > 0 && nums[i] == nums[i-1]) continue; 能否换成「和下一个比」?

不能。数组已排序、i 从左到右,nums[i] == nums[i-1] 表示「这个 a 上一轮用过、它的全部搭配已穷尽」,跳过安全。

若写 nums[i] == nums[i+1]:一是当 i 已是倒数第二/三个时可能越界或漏判;二是语义变成「和未来的比」, 逻辑上应当在处理完当前 a 的所有搭配之后才考虑跳过,而外层循环每轮就要决定要不要以当前 a 为起点, 所以比较对象必须是「已经处理完的上一个」,即 i-1。

Q4.【动手题】不参考代码,在纸上写出「删除有序数组中的重复项」的完整 Java 方法(含空数组特判), 并用输入 [0,0,1,1,1,2,2,3,3,4] 手推一遍,给出返回的 k 和前 k 个元素。

方法见 1.4 节。手推:slow 从 0 起,fast 遍历,遇到与 slow 不同的就 slow++ 并写入。

处理完前 k 个元素应为 [0,1,2,3,4],返回 k = 5。

校验要点:① nums.length == 0 先返回 0;② 单元素直接返回 1;③ 全相同如 [2,2,2] 返回 1(前 1 个是 [2])。 卡住的话,对照慢指针「区间末尾」的定义再推一次。

Q5. 移动零如果用「交换版」一遍写法,当 slow == fast 时执行交换会出问题吗?

不会。slow == fast 表示「当前位置本来就该放这个非零元素」,交换自己和自己,值不变、无副作用。

真正起作用的是 slow < fast 时:把 slow 处的旧值(它要么是 0,要么是之前被换过来的待处理值)换到 fast 位置, 而非零换到前面。遍历结束无需补零,因为所有 0 都已被换到了后方。

学完这章,你应该能回答:
  • 数组的「连续内存」带来了哪两个对双指针至关重要的性质?
  • 对撞、快慢、固定一数三种双指针形态,分别长什么样、各对应哪类题?
  • 盛水容器为什么「移动短板」不会漏解(能口头证明)?
  • 三数之和的两层去重分别去的是什么、为什么比的是 i-1 而不是 i+1?
  • 能脱稿写出本章 4 道题中任意一道的 Java 代码并通过边界用例。