Logo
Published on

数组-双指针问题详解

Authors

双指针问题详解

概述

双指针(Two Pointers)是一种重要的算法技巧,指的是在遍历元素的过程中,使用两个指针进行访问,从而达到相应的目的。

双指针的三种类型

  1. 对撞指针:两个指针方向相反,从两端向中间移动
  2. 快慢指针:两个指针方向相同,但移动速度不同
  3. 分离双指针:两个指针分别属于不同的数组/链表

时间复杂度优势

在数组的区间问题上,暴力算法的时间复杂度往往是 O(n²)。而双指针利用了区间「单调性」的性质,可以将时间复杂度降到 O(n)。

1. 对撞指针(Two Pointers)

原理

对撞指针指的是两个指针 leftright 分别指向序列的第一个元素和最后一个元素,然后 left 指针不断递增,right 不断递减,直到两个指针的值相撞(即 left == right),或者满足其他要求的特殊条件为止。

求解步骤

  1. 使用两个指针 leftright

    • left 指向序列第一个元素:left = 0
    • right 指向序列最后一个元素:right = len(nums) - 1
  2. 在循环体中将左右指针相向移动

    • 当满足一定条件时,将左指针右移:left += 1
    • 当满足另外一定条件时,将右指针左移:right -= 1
  3. 直到两指针相撞(即 left == right),或者满足其他要求的特殊条件时,跳出循环体

通用模板

int left = 0, right = nums.size() - 1;
while (left < right) {  // 注意边界条件
    if (满足要求的特殊条件) {
        return 符合条件的值;
    } else if (一定条件1) {
        left++;
    } else if (一定条件2) {
        right--;
    }
}
return 没找到 或 找到对应值;

适用场景

  1. 有序数组问题:查找满足某些约束条件的一组元素,如两数之和、三数之和等
  2. 字符串问题:反转字符串、验证回文串、颠倒二进制等
  3. 容器问题:盛水最多的容器等

例题 1:两数之和 II - 输入有序数组

题目:给定一个已按非递减顺序排列的整数数组 numbers,请你从数组中找出两个数满足相加之和等于目标数 target

暴力解法

class Solution {
public:
    vector<int> twoSum(vector<int>& numbers, int target) {
        for (int i = 0; i < numbers.size(); i++) {
            for (int j = i + 1; j < numbers.size(); j++) {
                if (numbers[i] + numbers[j] == target) {
                    return {i + 1, j + 1};
                }
            }
        }
        return {-1, -1};
    }
};

时间复杂度:O(n²)
空间复杂度:O(1)

对撞指针解法

class Solution {
public:
    vector<int> twoSum(vector<int>& numbers, int target) {
        int left = 0, right = numbers.size() - 1;

        while (left < right) {
            int sum = numbers[left] + numbers[right];
            if (sum == target) {
                return {left + 1, right + 1};
            } else if (sum > target) {
                right--;  // 和太大,右指针左移
            } else {
                left++;   // 和太小,左指针右移
            }
        }
        return {-1, -1};
    }
};

时间复杂度:O(n)
空间复杂度:O(1)

关键思路:利用数组有序的特性,当两数之和大于目标值时,右指针左移;当两数之和小于目标值时,左指针右移。

例题 2:验证回文串

题目:给定一个字符串,验证它是否是回文串,只考虑字母和数字字符,可以忽略字母的大小写。

class Solution {
public:
    bool isPalindrome(string s) {
        int left = 0, right = s.size() - 1;

        while (left < right) {
            // 跳过非字母数字字符
            while (left < right && !isalnum(s[left])) {
                left++;
            }
            while (left < right && !isalnum(s[right])) {
                right--;
            }

            // 比较字符(忽略大小写)
            if (tolower(s[left]) != tolower(s[right])) {
                return false;
            }

            left++;
            right--;
        }
        return true;
    }
};

时间复杂度:O(n)
空间复杂度:O(1)

关键思路

  1. 使用对撞指针从两端向中间移动
  2. 跳过非字母数字字符
  3. 比较字符时忽略大小写

例题 3:盛水最多的容器

题目:给定一个长度为 n 的整数数组 height。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i])。找出其中的两条线,使得它们与 x 轴构成的容器可以容纳最多的水。

class Solution {
public:
    int maxArea(vector<int>& height) {
        int left = 0, right = height.size() - 1;
        int maxArea = 0;

        while (left < right) {
            // 计算当前面积
            int h = min(height[left], height[right]);
            int area = h * (right - left);
            maxArea = max(maxArea, area);

            // 移动较短的边(短板效应)
            if (height[left] < height[right]) {
                left++;
            } else {
                right--;
            }
        }

        return maxArea;
    }
};

时间复杂度:O(n)
空间复杂度:O(1)

关键思路

  1. 使用对撞指针从两端开始
  2. 计算当前面积:面积 = min(height[left], height[right]) * (right - left)
  3. 移动较短的边(短板效应),因为移动较长的边不会增加面积

2. 快慢指针(Fast and Slow Pointers)

原理

快慢指针指的是两个指针从同一侧开始遍历序列,且移动的步长一个快一个慢。移动快的指针被称为「快指针(fast)」,移动慢的指针被称为「慢指针(slow)」。两个指针以不同速度、不同策略移动,直到快指针移动到数组尾端,或者两指针相交,或者满足其他特殊条件时为止。

求解步骤

  1. 使用两个指针 slowfast

    • slow 一般指向序列第一个元素:slow = 0
    • fast 一般指向序列第二个元素:fast = 1
  2. 在循环体中将指针向右移动

    • 当满足一定条件时,将慢指针右移:slow += 1
    • 当满足另外一定条件时(也可能不需要满足条件),将快指针右移:fast += 1
  3. 直到快指针移动到数组尾端(即 fast == len(nums) - 1),或者两指针相交,或者满足其他特殊条件时跳出循环体

通用模板

int slow = 0;
for (int fast = 1; fast < nums.size(); fast++) {
    if (满足要求的特殊条件) {
        slow++;
        nums[slow] = nums[fast];  // 可能需要赋值操作
    }
}
return slow + 1;  // 返回合适的值

适用场景

  1. 数组去重:删除有序数组中的重复元素
  2. 链表问题:判断链表是否有环、找链表中点、删除重复元素
  3. 移动零:将数组中的零移动到末尾
  4. 滑动窗口:某些滑动窗口问题的变种

例题 1:删除有序数组中的重复项

题目:给你一个有序数组 nums,请你原地删除重复出现的元素,使每个元素只出现一次,返回删除后数组的新长度。

class Solution {
public:
    int removeDuplicates(vector<int>& nums) {
        if (nums.empty()) return 0;

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

时间复杂度:O(n)
空间复杂度:O(1)

关键思路

  1. slow 指针指向已处理数组的最后一个位置
  2. fast 指针遍历整个数组
  3. nums[fast] != nums[slow] 时,说明找到了新元素,将其放到 slow + 1 位置

例题 2:删除排序链表中的重复元素

题目:给定一个已排序的链表的头 head,删除所有重复的元素,使每个元素只出现一次。返回已排序的链表。

class Solution {
public:
    ListNode* deleteDuplicates(ListNode* head) {
        if (head == nullptr) return nullptr;

        ListNode* slow = head;
        ListNode* fast = head;

        while (fast != nullptr) {
            if (fast->val != slow->val) {
                slow->next = fast;
                slow = slow->next;
            }
            fast = fast->next;
        }

        // 断开与后面重复元素的连接
        slow->next = nullptr;
        return head;
    }
};

时间复杂度:O(n)
空间复杂度:O(1)

关键思路

  1. slow 指针指向已处理链表的最后一个节点
  2. fast 指针遍历整个链表
  3. 当遇到不同值的节点时,将其连接到 slow 后面

例题 3:删除排序链表中的重复元素 II

题目:给定一个已排序的链表的头 head,删除原始链表中所有重复数字的节点,只留下不同的数字。返回已排序的链表。

class Solution {
public:
    ListNode* deleteDuplicates(ListNode* head) {
        if (head == nullptr) return nullptr;

        ListNode* pre = nullptr;
        ListNode* curr = head;

        while (curr != nullptr) {
            // 检查当前节点是否有重复
            if (curr->next != nullptr && curr->val == curr->next->val) {
                // 跳过所有重复的节点
                while (curr->next != nullptr && curr->val == curr->next->val) {
                    curr = curr->next;
                }

                // 删除重复的节点
                if (pre == nullptr) {
                    head = curr->next;  // 更新头节点
                } else {
                    pre->next = curr->next;
                }
            } else {
                pre = curr;  // 没有重复,更新pre指针
            }
            curr = curr->next;
        }

        return head;
    }
};

时间复杂度:O(n)
空间复杂度:O(1)

关键思路

  1. 使用 pre 指针记录前一个非重复节点
  2. 当发现重复节点时,跳过所有重复节点
  3. 根据 pre 是否为空来决定如何删除重复节点

3. 分离双指针(Separate Two Pointers)

原理

分离双指针指的是两个指针分别属于不同的数组,两个指针分别在两个数组中移动。这种技巧常用于处理两个有序数组的合并、求交集、求并集等问题。

求解步骤

  1. 使用两个指针 left_1left_2

    • left_1 指向第一个数组的第一个元素:left_1 = 0
    • left_2 指向第二个数组的第一个元素:left_2 = 0
  2. 根据条件移动指针

    • 当满足一定条件时,两个指针同时右移:left_1 += 1left_2 += 1
    • 当满足另外一定条件时,将 left_1 指针右移:left_1 += 1
    • 当满足其他一定条件时,将 left_2 指针右移:left_2 += 1
  3. 当其中一个数组遍历完时或者满足其他特殊条件时跳出循环体

通用模板

int left_1 = 0, left_2 = 0;
while (left_1 < nums1.size() && left_2 < nums2.size()) {
    if (一定条件1) {
        left_1++;
        left_2++;
    } else if (一定条件2) {
        left_1++;
    } else if (一定条件3) {
        left_2++;
    }
}

适用场景

  1. 有序数组合并:合并两个有序数组
  2. 求交集:找出两个数组的交集
  3. 求并集:找出两个数组的并集
  4. 归并排序:归并两个有序子数组

例题:两个数组的交集

题目:给定两个数组 nums1nums2,返回它们的交集。输出结果中的每个元素一定是唯一的。

class Solution {
public:
    vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
        // 先排序,便于使用双指针
        sort(nums1.begin(), nums1.end());
        sort(nums2.begin(), nums2.end());

        int left = 0, right = 0;
        vector<int> ans;

        while (left < nums1.size() && right < nums2.size()) {
            if (nums1[left] == nums2[right]) {
                // 避免重复添加相同元素
                if (ans.empty() || ans.back() != nums1[left]) {
                    ans.push_back(nums1[left]);
                }
                left++;
                right++;
            } else if (nums1[left] < nums2[right]) {
                left++;
            } else {
                right++;
            }
        }
        return ans;
    }
};

时间复杂度:O(m log m + n log n),其中 m 和 n 分别是两个数组的长度
空间复杂度:O(1),不考虑输出数组的空间

关键思路

  1. 先对两个数组进行排序
  2. 使用分离双指针分别遍历两个数组
  3. 当元素相等时,添加到结果中(注意去重)
  4. 当元素不等时,移动较小元素对应的指针

总结

双指针算法是一种高效的算法技巧,通过合理使用两个指针可以显著降低时间复杂度:

三种双指针类型对比

类型特点适用场景时间复杂度
对撞指针从两端向中间移动有序数组、回文串、容器问题O(n)
快慢指针同向移动,速度不同数组去重、链表问题O(n)
分离双指针分别遍历不同数组数组合并、求交集并集O(m+n)

选择原则

  1. 对撞指针:适用于有序数组的两数之和、三数之和等问题
  2. 快慢指针:适用于数组去重、链表环检测等问题
  3. 分离双指针:适用于两个有序数组的合并、求交集等问题

注意事项

  1. 注意边界条件的处理
  2. 确保指针移动不会越界
  3. 根据题目要求正确处理重复元素
  4. 合理利用数组的有序性来优化算法