167. 两数之和 II - 输入有序数组 https://leetcode.cn/problems/two-sum-ii-input-array-is-sorted

26. 删除有序数组中的重复项 https://leetcode.cn/problems/remove-duplicates-from-sorted-array

27. 移除元素 https://leetcode.cn/problems/remove-element

283. 移动零 https://leetcode.cn/problems/move-zeroes

344. 反转字符串 https://leetcode.cn/problems/reverse-string

5. 最长回文子串 https://leetcode.cn/problems/longest-palindromic-substring

83. 删除排序链表中的重复元素 https://leetcode.cn/problems/remove-duplicates-from-sorted-list

前置知识

阅读本文前,你需要先学习:

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

所谓左右指针,就是两个指针相向而行或者相背而行;而所谓快慢指针,就是两个指针同向而行,一快一慢。

对于单链表来说,大部分技巧都属于快慢指针,单链表的六大解题套路 都涵盖了,比如链表环判断,倒数第 K 个链表节点等问题,它们都是通过一个 fast 快指针和一个 slow 慢指针配合完成任务。

在数组中并没有真正意义上的指针,但我们可以把索引当做数组中的指针,这样也可以在数组中施展双指针技巧,本文主要讲数组相关的双指针算法

一、快慢指针技巧

原地修改

数组问题中比较常见的快慢指针技巧,是让你原地修改数组

比如说看下力扣第 26 题「删除有序数组中的重复项」,让你在有序数组去重:

26. 删除有序数组中的重复项 | 力扣 | LeetCode |  🟢

给你一个 非严格递增排列 的数组 nums ,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。元素的 相对顺序 应该保持 一致 。然后返回 nums 中唯一元素的个数。

考虑 nums 的唯一元素的数量为 k ,你需要做以下事情确保你的题解可以被通过:

  • 更改数组 nums ,使 nums 的前 k 个元素包含唯一元素,并按照它们最初在 nums 中出现的顺序排列。nums 的其余元素与 nums 的大小不重要。
  • 返回 k 。

判题标准:

系统会用下面的代码来测试你的题解:

int[] nums = [...]; // 输入数组
int[] expectedNums = [...]; // 长度正确的期望答案

int k = removeDuplicates(nums); // 调用

assert k == expectedNums.length;
for (int i = 0; i < k; i++) {
    assert nums[i] == expectedNums[i];
}

如果所有断言都通过,那么您的题解将被 通过

示例 1:

输入:nums = [1,1,2]
输出:2, nums = [1,2,_]
解释:函数应该返回新的长度 2 ,并且原数组 nums 的前两个元素被修改为 1, 2 不需要考虑数组中超出新长度后面的元素。

示例 2:

输入:nums = [0,0,1,1,1,2,2,3,3,4]
输出:5, nums = [0,1,2,3,4]
解释:函数应该返回新的长度 5 , 并且原数组 nums 的前五个元素被修改为 0, 1, 2, 3, 4 。不需要考虑数组中超出新长度后面的元素。

提示:

  • 1 <= nums.length <= 3 * 104
  • -104 <= nums[i] <= 104
  • nums 已按 非严格递增 排列
题目来源:力扣 26. 删除有序数组中的重复项

函数签名如下:

java
int removeDuplicates(int[] nums);

简单解释一下什么是原地修改:

如果不是原地修改的话,我们直接 new 一个 int[] 数组,把去重之后的元素放进这个新数组中,然后返回这个新数组即可。

但是现在题目让你原地删除,不允许 new 新数组,只能在原数组上操作,然后返回一个长度,这样就可以通过返回的长度和原始数组得到我们去重后的元素有哪些了。

由于数组已经排序,所以重复的元素一定连在一起,找出它们并不难。但如果毎找到一个重复元素就立即原地删除它,由于数组中删除元素涉及数据搬移,整个时间复杂度是会达到 O(N2)O(N^2)

高效解决这道题就要用到快慢指针技巧:

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

这样,就保证了 nums[0..slow] 都是无重复的元素,当 fast 指针遍历完整个数组 nums 后,nums[0..slow] 就是整个数组去重之后的结果。

看代码:

java
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;
    }
}

你可以打开下面的可视化面板,多次点击 while (fast < nums.length) 这一行代码,即可看到两个指针维护 nums[0..slow] 无重复元素:

Algorithm visualize
  算法可视化面板

再简单扩展一下,看看力扣第 83 题「删除排序链表中的重复元素」,如果给你一个有序的单链表,如何去重呢?

其实和数组去重是一模一样的,唯一的区别是把数组赋值操作变成操作指针而已,你对照着之前的代码来看:

java
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;
    }
}

算法执行的过程请看下面这个可视化面板:

Algorithm visualize
  算法可视化面板

Note

这里可能有读者会问,链表中那些重复的元素并没有被删掉,就让这些节点在链表上挂着,合适吗?

这就要探讨不同语言的特性了,像 Java/Python 这类带有垃圾回收的语言,可以帮我们自动找到并回收这些「悬空」的链表节点的内存,而像 C++ 这类语言没有自动垃圾回收的机制,确实需要我们编写代码时手动释放掉这些节点的内存。

不过话说回来,就算法思维的培养来说,我们只需要知道这种快慢指针技巧即可。

除了让你在有序数组/链表中去重,题目还可能让你对数组中的某些元素进行「原地删除」

比如力扣第 27 题「移除元素」,看下题目:

27. 移除元素 | 力扣 | LeetCode |  🟢

给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素。元素的顺序可能发生改变。然后返回 nums 中与 val 不同的元素的数量。

假设 nums 中不等于 val 的元素数量为 k,要通过此题,您需要执行以下操作:

  • 更改 nums 数组,使 nums 的前 k 个元素包含不等于 val 的元素。nums 的其余元素和 nums 的大小并不重要。
  • 返回 k

用户评测:

评测机将使用以下代码测试您的解决方案:

int[] nums = [...]; // 输入数组
int val = ...; // 要移除的值
int[] expectedNums = [...]; // 长度正确的预期答案。
                            // 它以不等于 val 的值排序。

int k = removeElement(nums, val); // 调用你的实现

assert k == expectedNums.length;
sort(nums, 0, k); // 排序 nums 的前 k 个元素
for (int i = 0; i < actualLength; i++) {
    assert nums[i] == expectedNums[i];
}

如果所有的断言都通过,你的解决方案将会 通过

示例 1:

输入:nums = [3,2,2,3], val = 3
输出:2, nums = [2,2,_,_]
解释:你的函数函数应该返回 k = 2, 并且 nums 中的前两个元素均为 2。
你在返回的 k 个元素之外留下了什么并不重要(因此它们并不计入评测)。

示例 2:

输入:nums = [0,1,2,2,3,0,4,2], val = 2
输出:5, nums = [0,1,4,0,3,_,_,_]
解释:你的函数应该返回 k = 5,并且 nums 中的前五个元素为 0,0,1,3,4。
注意这五个元素可以任意顺序返回。
你在返回的 k 个元素之外留下了什么并不重要(因此它们并不计入评测)。

提示:

  • 0 <= nums.length <= 100
  • 0 <= nums[i] <= 50
  • 0 <= val <= 100
题目来源:力扣 27. 移除元素
java
// 函数签名如下
int removeElement(int[] nums, int val);

题目要求我们把 nums 中所有值为 val 的元素原地删除,依然需要使用快慢指针技巧:

如果 fast 遇到值为 val 的元素,则直接跳过,否则就赋值给 slow 指针,并让 slow 前进一步。

这和前面说到的数组去重问题解法思路是完全一样的,直接看代码:

java
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;
    }
}

你可以打开下面的可视化面板,多次点击 while (fast < nums.length) 这一行代码,即可看到两个指针维护 nums[0..slow] 无重复元素:

Algorithm visualize
  算法可视化面板

注意这里和有序数组去重的解法有一个细节差异,我们这里是先给 nums[slow] 赋值然后再给 slow++,这样可以保证 nums[0..slow-1] 是不包含值为 val 的元素的,最后的结果数组长度就是 slow

实现了这个 removeElement 函数,接下来看看力扣第 283 题「移动零」:

给你输入一个数组 nums,请你原地修改,将数组中的所有值为 0 的元素移到数组末尾,函数签名如下:

java
void moveZeroes(int[] nums);

比如说给你输入 nums = [0,1,4,0,2],你的算法没有返回值,但是会把 nums 数组原地修改成 [1,4,2,0,0]

结合之前说到的几个题目,你是否有已经有了答案呢?

稍微修改上一题中的 removeElement 函数就可以完成这道题,或者直接复用 removeElement 函数也可以。

题目让我们将所有 0 移到最后,其实就相当于移除 nums 中的所有 0,然后再把后面的元素都赋值为 0:

java
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;
        }
    }

    // 双指针技巧,复用 [27. 移除元素] 的解法。
    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;
    }
}

你可以点开下面的可视化面板,多次点击 while (fast < nums.length) 这行代码查看快慢指针的运动,然后多次点击 nums[p] = 0; 这行代码将后面的元素都改为 0:

Algorithm visualize
  算法可视化面板

到这里,原地修改数组的这些题目就已经差不多了。

滑动窗口

数组中另一大类快慢指针的题目就是「滑动窗口算法」。我在另一篇文章 滑动窗口算法核心框架详解 给出了滑动窗口的代码框架:

// 滑动窗口算法框架伪码
int left = 0, right = 0;

while (right < nums.size()) {
    // 增大窗口
    window.addLast(nums[right]);
    right++;
    
    while (window needs shrink) {
        // 缩小窗口
        window.removeFirst(nums[left]);
        left++;
    }
}

具体的题目本文就不重复了,这里只强调滑动窗口算法的快慢指针特性:

left 指针在后,right 指针在前,两个指针中间的部分就是「窗口」,算法通过扩大和缩小「窗口」来解决某些问题。

二、左右指针的常用算法

二分查找

我在另一篇文章 二分查找框架详解 中有详细探讨二分搜索代码的细节问题,这里只写最简单的二分算法,旨在突出它的双指针特性:

java
int binarySearch(int[] nums, int target) {
    // 一左一右两个指针相向而行
    int left = 0, right = nums.length - 1;
    while(left <= right) {
        int mid = (right + left) / 2;
        if(nums[mid] == target)
            return mid; 
        else if (nums[mid] < target)
            left = mid + 1; 
        else if (nums[mid] > target)
            right = mid - 1;
    }
    return -1;
}

n 数之和

看下力扣第 167 题「两数之和 II」:

167. 两数之和 II - 输入有序数组 | 力扣 | LeetCode |  🟠

给你一个下标从 1 开始的整数数组 numbers ,该数组已按 非递减顺序排列  ,请你从数组中找出满足相加之和等于目标数 target 的两个数。如果设这两个数分别是 numbers[index1]numbers[index2] ,则 1 <= index1 < index2 <= numbers.length

以长度为 2 的整数数组 [index1, index2] 的形式返回这两个整数的下标 index1 index2

你可以假设每个输入 只对应唯一的答案 ,而且你 不可以 重复使用相同的元素。

你所设计的解决方案必须只使用常量级的额外空间。

 

示例 1:

输入:numbers = [2,7,11,15], target = 9
输出:[1,2]
解释:2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。

示例 2:

输入:numbers = [2,3,4], target = 6
输出:[1,3]
解释:2 与 4 之和等于目标数 6 。因此 index1 = 1, index2 = 3 。返回 [1, 3] 。

示例 3:

输入:numbers = [-1,0], target = -1
输出:[1,2]
解释:-1 与 0 之和等于目标数 -1 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。

提示:

  • 2 <= numbers.length <= 3 * 104
  • -1000 <= numbers[i] <= 1000
  • numbers非递减顺序 排列
  • -1000 <= target <= 1000
  • 仅存在一个有效答案
题目来源:力扣 167. 两数之和 II - 输入有序数组

只要数组有序,就应该想到双指针技巧。这道题的解法有点类似二分查找,通过调节 leftright 就可以调整 sum 的大小:

java
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) {
                // 让 sum 大一点
                left++;
            } else if (sum > target) {
                // 让 sum 小一点
                right--;
            }
        }
        return new int[]{-1, -1};
    }
}

我在另一篇文章 一个函数秒杀所有 nSum 问题 中也运用类似的左右指针技巧给出了 nSum 问题的一种通用思路,本质上利用的也是双指针技巧。

反转数组

一般编程语言都会提供 reverse 函数,其实这个函数的原理非常简单,力扣第 344 题「反转字符串」就是类似的需求,让你反转一个 char[] 类型的字符数组,我们直接看代码吧:

java
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--;
    }
}

关于数组翻转的更多进阶问题,可以参见 二维数组的花式遍历

回文串判断

回文串就是正着读和反着读都一样的字符串。比如说字符串 abaabba 都是回文串,因为它们对称,反过来还是和本身一样;反之,字符串 abac 就不是回文串。

现在你应该能感觉到回文串问题和左右指针肯定有密切的联系,比如让你判断一个字符串是不是回文串,你可以写出下面这段代码:

java
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;
}

那接下来我提升一点难度,给你一个字符串,让你用双指针技巧从中找出最长的回文串,你会做吗?

这就是力扣第 5 题「最长回文子串」:

5. 最长回文子串 | 力扣 | LeetCode |  🟠

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

示例 1:

输入:s = "babad"
输出:"bab"
解释:"aba" 同样是符合题意的答案。

示例 2:

输入:s = "cbbd"
输出:"bb"

提示:

  • 1 <= s.length <= 1000
  • s 仅由数字和英文字母组成
题目来源:力扣 5. 最长回文子串

函数签名如下:

java
String longestPalindrome(String s);

找回文串的难点在于,回文串的的长度可能是奇数也可能是偶数,解决该问题的核心是从中心向两端扩散的双指针技巧

如果回文串的长度为奇数,则它有一个中心字符;如果回文串的长度为偶数,则可以认为它有两个中心字符。所以我们可以先实现这样一个函数:

java
// 在 s 中寻找以 s[l] 和 s[r] 为中心的最长回文串
String palindrome(String s, int l, int r) {
    // 防止索引越界
    while (l >= 0 && r < s.length()
            && s.charAt(l) == s.charAt(r)) {
        // 双指针,向两边展开
        l--; r++;
    }

    // 此时 s[l+1..r-1] 就是最长回文串
    return s.substring(l + 1, r);
}

这样,如果输入相同的 lr,就相当于寻找长度为奇数的回文串,如果输入相邻的 lr,则相当于寻找长度为偶数的回文串。

那么回到最长回文串的问题,解法的大致思路就是:

翻译成代码,就可以解决最长回文子串这个问题:

java
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 = longest(res, s1, s2)
            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++;
        }
        // 此时 [l+1, r-1] 就是最长回文串
        return s.substring(l + 1, r);
    }
}

你可以点开下面的可视化面板,多次点击 while (l >= 0 && r < s.length && s[l] === s[r]) 这一行代码,即可看到 l, r 两个指针向两边展开的过程:

Algorithm visualize
  算法可视化面板

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

到这里,数组相关的双指针技巧就全部讲完了,这些技巧的更多扩展延伸见 更多数组双指针经典高频题