[Algo-2]双指针技巧:你真的学懂双指针了吗?

大家好,我是程序员牛奶,本文讲解的内容是双指针技巧的相关习题,读完本文可以去解决以下题目:

LeetCode 力扣 难度
26. Remove Duplicates from Sorted Array 26. 删除有序数组中的重复项 🟢
83. Remove Duplicates from Sorted List 83. 删除排序链表中的重复元素 🟢
27. Remove Element 27. 移除元素 🟢
283. Move Zeroes 283. 移动零 🟢
167. Two Sum II - Input Array Is Sorted 167. 两数之和 II - 输入有序数组 🟡
344. Reverse String 344. 反转字符串 🟢
5. Longest Palindromic Substring 5. 最长回文子串 🟡

前置知识

阅读本文前,你需要先学习我的单链表双指针相关的算法内容。

全局地图

在处理数组和链表相关问题时,双指针技巧是经常用到的,主要分为两类:

  • 左右指针:两个指针相向而行或者相背而行
  • 快慢指针:两个指针同向而行,一快一慢

对于单链表来说,大部分技巧都属于快慢指针,比如链表环判断、倒数第 K 个节点等。在数组中并没有真正意义上的指针,但我们可以把索引当做指针,同样施展双指针技巧。本文主要讲数组相关的双指针算法。


一、快慢指针技巧

快慢指针的通用模板:

ini 复制代码
int slow = 0, fast = 0;
while (fast < nums.length) {
    if (满足条件) {
        nums[slow] = nums[fast];
        slow++;
    }
    fast++;
}
// slow 就是结果数组的长度

核心思想:fast 负责探路遍历整个数组,slow 负责维护结果数组的边界。fast 每次前进一步,只有满足条件时才把元素「收录」到 slow 位置。后续每道题都是往这个骨架里填不同的判断条件。

原地修改数组

26. 删除有序数组中的重复项

给你一个非严格递增排列的数组 nums,请你原地删除重复出现的元素,使每个元素只出现一次,返回删除后数组的新长度。

如果不是原地修改,我们直接 new 一个新数组把去重元素放进去就行了。但题目要求原地删除,不允许 new 新数组,只能在原数组上操作。

朴素思路的问题: 由于数组已经排序,重复元素一定连在一起,找出它们并不难。但如果每找到一个重复元素就立即原地删除,由于数组删除元素涉及数据搬移,整个时间复杂度会达到 O(N²)。

快慢指针的思路: 我们让慢指针 slow 走在后面,快指针 fast 走在前面探路,找到一个不重复的元素就赋值给 slow 并让 slow 前进一步。

具体来说,slowfast 都从 0 出发。fast 每次前进一步,然后比较 nums[fast]nums[slow]

  • 如果相等,说明是重复元素,fast 继续前进,slow 不动
  • 如果不等,说明遇到了新元素,先让 slow 前进一步,再把 nums[fast] 赋值给 nums[slow]

这样就保证了一个循环不变量:nums[0..slow] 始终是无重复的元素 。当 fast 遍历完整个数组后,nums[0..slow] 就是去重后的结果。

为什么返回 slow + 1 而不是 slow?因为 slow 是最后一个有效元素的索引,而数组长度 = 索引 + 1。

ini 复制代码
class Solution {
    public int removeDuplicates(int[] nums) {
        if (nums.length == 0) {
            return 0;
        }
        int slow = 0, fast = 0;
        while (fast < nums.length) {
            if (nums[fast] != nums[slow]) {
                slow++;
                // 维护 nums[0..slow] 无重复
                nums[slow] = nums[fast];
            }
            fast++;
        }
        // 数组长度为索引 + 1
        return slow + 1;
    }
}

注意这里的操作顺序是slow++ 再赋值 。为什么?因为 nums[0](即初始的 slow 位置)一定是结果的一部分------有序数组的第一个元素不可能是「重复」的,所以 slow 需要先前进到下一个空位,再写入新值。

83. 删除排序链表中的重复元素

如果给你一个有序的单链表,如何去重呢?其实和数组去重一模一样,唯一的区别是把数组赋值操作变成操作指针而已。

思路对比: 数组中我们用 nums[slow] = nums[fast] 来「收录」新元素,链表中对应的操作是 slow.next = fast,即让 slow 的 next 指向 fast,跳过中间那些重复节点。同样地,slow = slow.next 对应数组中的 slow++

判断条件也完全一致:fast.val != slow.val 时说明遇到了新元素。循环结束后,需要额外做一步 slow.next = null,断开 slow 与后面重复元素的连接。

ini 复制代码
class Solution {
    public ListNode deleteDuplicates(ListNode head) {
        if (head == null) return null;
        ListNode slow = head, fast = head;
        while (fast != null) {
            if (fast.val != slow.val) {
                // nums[slow] = nums[fast];
                slow.next = fast;
                // slow++;
                slow = slow.next;
            }
            // fast++
            fast = fast.next;
        }
        // 断开与后面重复元素的连接
        slow.next = null;
        return head;
    }
}

Note: 链表中那些重复的元素并没有被删掉,就让这些节点挂着合适吗?像 Java/Python 这类带有垃圾回收的语言,可以自动回收这些「悬空」节点的内存;而像 C++ 这类语言则需要手动释放。就算法思维的培养来说,我们只需要知道这种快慢指针技巧即可。

27. 移除元素

除了在有序数组/链表中去重,题目还可能让你对数组中的某些元素进行「原地删除」。给你一个数组 nums 和一个值 val,原地移除所有数值等于 val 的元素,返回移除后数组的新长度。

和去重的区别: 去重时判断条件是 nums[fast] != nums[slow](和 slow 比),这里的判断条件是 nums[fast] != val(和固定值比)。思路完全一样------如果 fast 遇到值为 val 的元素则直接跳过,否则赋值给 slow 并让 slow 前进一步。

但注意一个关键的细节差异:这里是先给 nums[slow] 赋值然后再 slow++ ,而去重那题是先 slow++ 再赋值。为什么?因为去重时 nums[0] 一定是结果的一部分(有序数组的第一个元素不可能重复),所以 slow 从 0 开始、先自增再赋值到新位置;而移除元素时 nums[0] 本身可能就等于 val,所以必须先赋值到当前 slow 位置再自增,确保不遗漏。

这导致循环不变量也略有不同:去重维护的是 nums[0..slow] 无重复,而移除元素维护的是 nums[0..slow-1] 不包含 val。最终返回值也不同:去重返回 slow + 1,移除元素直接返回 slow

ini 复制代码
class Solution {
    public int removeElement(int[] nums, int val) {
        int fast = 0, slow = 0;
        while (fast < nums.length) {
            if (nums[fast] != val) {
                nums[slow] = nums[fast];
                slow++;
            }
            fast++;
        }
        return slow;
    }
}

283. 移动零

给你输入一个数组 nums,请你原地修改,将数组中的所有值为 0 的元素移到数组末尾。比如输入 nums = [0,1,4,0,2],算法会把 nums 原地修改成 [1,4,2,0,0]

你是否已经有了答案?题目让我们将所有 0 移到最后,其实就相当于移除 nums 中的所有 0,然后再把后面的元素都赋值为 0。

为什么可以直接复用 removeElement 「移动零」本质上就是「移除值为 0 的元素」+ 「把剩余位置填 0」。第一步和上一题完全一样,只是 val = 0;第二步只需要一个简单的 for 循环把 slow 之后的位置全部置零。

这就是快慢指针框架的威力------同一个模板,换个判断条件就能解决不同的题目,甚至可以组合使用。

ini 复制代码
class Solution {
    public void moveZeroes(int[] nums) {
        // 去除 nums 中的所有 0,返回去除 0 之后的数组长度
        int p = removeElement(nums, 0);
        // 将 p 之后的所有元素赋值为 0
        for (; p < nums.length; p++) {
            nums[p] = 0;
        }
    }

    int removeElement(int[] nums, int val) {
        int fast = 0, slow = 0;
        while (fast < nums.length) {
            if (nums[fast] != val) {
                nums[slow] = nums[fast];
                slow++;
            }
            fast++;
        }
        return slow;
    }
}

到这里,原地修改数组的这些题目就已经差不多了。回顾一下,四道题用的都是同一个快慢指针框架,区别仅在于判断条件和赋值顺序。

滑动窗口

数组中另一大类快慢指针的题目就是「滑动窗口算法」。left 指针在后,right 指针在前,两个指针中间的部分就是「窗口」,算法通过扩大和缩小「窗口」来解决某些问题。具体题目可参见滑动窗口算法核心框架详解


二、左右指针的常用算法

左右指针的通用模板:

sql 复制代码
int left = 0, right = nums.length - 1;
while (left < right) {
    if (找到目标) {
        return 结果;
    } else if (需要收缩左边界) {
        left++;
    } else {
        right--;
    }
}

核心思想:left 从最左出发,right 从最右出发,两个指针相向而行,通过某种条件判断来决定移动哪个指针,逐步逼近目标。

167. 两数之和 II - 输入有序数组

给你一个下标从 1 开始的整数数组 numbers,该数组已按非递减顺序排列,请你从数组中找出满足相加之和等于目标数 target 的两个数,返回它们的下标。

关键洞察: 只要数组有序,就应该想到双指针技巧。

左右指针的思路: left 指向最小的元素,right 指向最大的元素,计算两者之和 sum

  • 如果 sum == target,找到答案,返回下标
  • 如果 sum < target,说明需要让 sum 大一点,而数组是升序的,所以让 left 右移,这样 numbers[left] 会变大
  • 如果 sum > target,说明需要让 sum 小一点,让 right 左移,这样 numbers[right] 会变小

为什么这样做是正确的?因为数组有序,left 右移一定让 sum 增大,right 左移一定让 sum 减小,所以我们可以通过调整指针来精确控制 sum 的大小,逐步逼近 target。而且由于每次操作都排除了一个不可能的元素,两个指针最终一定会相遇,时间复杂度为 O(N)。

sql 复制代码
class Solution {
    public int[] twoSum(int[] numbers, int target) {
        // 一左一右两个指针相向而行
        int left = 0, right = numbers.length - 1;
        while (left < right) {
            int sum = numbers[left] + numbers[right];
            if (sum == target) {
                // 题目要求的索引是从 1 开始的
                return new int[]{left + 1, right + 1};
            } else if (sum < target) {
                left++; // 让 sum 大一点
            } else if (sum > target) {
                right--; // 让 sum 小一点
            }
        }
        return new int[]{-1, -1};
    }
}

类似的左右指针技巧还可以推广到 nSum 问题的通用解法,详见一个函数秒杀所有 nSum 问题

344. 反转字符串

给你一个 char[] 类型的字符数组,请你原地反转这个数组。

思路: 这是左右指针最直观的应用。left 从最左出发,right 从最右出发,每次交换 s[left]s[right],然后两个指针同时向中间靠拢,直到相遇。

为什么这样就能反转?因为每次交换都把「应该在右边的元素」和「应该在左边的元素」互换了位置。当 leftright 相遇时,所有元素都已经到了正确的位置。

ini 复制代码
void reverseString(char[] s) {
    // 一左一右两个指针相向而行
    int left = 0, right = s.length - 1;
    while (left < right) {
        // 交换 s[left] 和 s[right]
        char temp = s[left];
        s[left] = s[right];
        s[right] = temp;
        left++;
        right--;
    }
}

5. 最长回文子串

给你一个字符串 s,找到 s 中最长的回文子串。

回文串就是正着读和反着读都一样的字符串,比如 abaabba。判断一个串是不是回文,自然可以用左右指针从两端向中间逼近:

sql 复制代码
boolean isPalindrome(String s) {
    int left = 0, right = s.length() - 1;
    while (left < right) {
        if (s.charAt(left) != s.charAt(right)) {
            return false;
        }
        left++;
        right--;
    }
    return true;
}

但找最长回文子串的难点在于,回文串的长度可能是奇数也可能是偶数,我们不知道回文的中心在哪里。

从中心向两端扩展的思路: 换个角度------与其从两端向中间收缩来「验证」回文,不如从中间向两端扩展来「寻找」回文。我们枚举每一个可能的中心位置,然后用左右指针从中心向两边展开,直到不满足回文条件为止。

具体来说,我们实现一个辅助函数 palindrome(s, l, r),从 s[l]s[r] 开始向两边展开:

  • 只要 l >= 0 && r < s.length() && s.charAt(l) == s.charAt(r),就继续展开(l--r++
  • 当条件不满足时停止,此时 s[l+1..r-1] 就是以该中心找到的最长回文串

如果输入相同的 lr(如 palindrome(s, i, i)),就相当于寻找长度为奇数的回文串(中心是一个字符);如果输入相邻的 lr(如 palindrome(s, i, i+1)),则相当于寻找长度为偶数的回文串(中心是两个字符之间的间隙)。

所以完整算法就是:遍历每个位置 i,分别以 i 为中心和以 i, i+1 为中心找最长回文,取所有结果中最长的那个。

ini 复制代码
class Solution {
    public String longestPalindrome(String s) {
        String res = "";
        for (int i = 0; i < s.length(); i++) {
            // 以 s[i] 为中心的最长回文子串(奇数长度)
            String s1 = palindrome(s, i, i);
            // 以 s[i] 和 s[i+1] 为中心的最长回文子串(偶数长度)
            String s2 = palindrome(s, i, i + 1);
            res = res.length() > s1.length() ? res : s1;
            res = res.length() > s2.length() ? res : s2;
        }
        return res;
    }

    String palindrome(String s, int l, int r) {
        // 向两边展开,直到不满足回文条件
        while (l >= 0 && r < s.length()
                && s.charAt(l) == s.charAt(r)) {
            l--;
            r++;
        }
        // 循环结束时 s[l] != s[r],所以回文串是 s[l+1..r-1]
        return s.substring(l + 1, r);
    }
}

你应该能发现最长回文子串使用的左右指针和之前题目有所不同:之前的左右指针都是从两端向中间相向而行,而回文子串问题则是让左右指针从中心向两端扩展(相背而行)。不过这种情况也就回文串这类问题会遇到,所以也归为左右指针。

时间复杂度为 O(N²):外层循环 O(N),每次展开最坏 O(N)。存在更优的 Manacher 算法可以做到 O(N),但面试中掌握中心扩展法已经足够,感兴趣可自行了解。


总结

题目 指针类型 核心判断条件 返回值
26. 删除有序数组中的重复项 快慢 nums[fast] != nums[slow] slow + 1
83. 删除排序链表中的重复元素 快慢 fast.val != slow.val head
27. 移除元素 快慢 nums[fast] != val slow
283. 移动零 快慢 复用 removeElement(nums, 0)
167. 两数之和 II 左右 sumtarget 的大小关系 下标数组
344. 反转字符串 左右 left < right 时交换
5. 最长回文子串 左右(中心扩展) s[l] == s[r] 时继续展开 子串

决策路径:

  • 遇到「原地修改/删除」→ 快慢指针,slow 维护结果边界
  • 遇到「有序数组 + 查找/配对」→ 左右指针相向而行
  • 遇到「回文」→ 左右指针从中心向两端扩展
  • 遇到「子数组/子串 + 条件约束」→ 滑动窗口(快慢指针变体),详见我的滑动窗口相关文章。
相关推荐
Kir1to6 小时前
RabbitMQ 核心概念与快速安装
后端
Kir1to6 小时前
Exchange 交换机类型,六种工作模式与 Spring Boot 整合
后端
日月云棠6 小时前
11 Spring容器整合与核心接口体系
java·后端
日月云棠6 小时前
10 AOP与动态编译源码剖析
java·后端
蓝银草同学7 小时前
新手指南:快速理清独立仓库 Java 8 多模块项目依赖并运行
前端·后端
蓝银草同学7 小时前
前端转 Java,第一篇看懂 pom.xml:Maven 依赖管理从入门到不懵
前端·后端
IT策士7 小时前
Django 从 0 到 1 打造完整电商平台:收货地址管理
后端·python·django
HjhIron7 小时前
从三件套到模块化:前端开发的底层思维
前端·后端
前端市界7 小时前
在阿里云 Docker 中管理 MySQL 8.0:常用命令与 Docker Compose 最佳实践
后端