双指针技巧是一种常用的算法优化方法,主要用于处理数组或链表等线性结构的问题。它通过定义两个指针(或索引),并以不同的策略移动这两个指针,来达到减少时间复杂度、简化问题解决过程的目的。根据具体问题和解题策略的不同,双指针技巧大致可以分为以下几类:

# 快慢指针

快慢指针主要用于解决链表中的问题,如检测环、寻找环的入口、寻找链表的中点等。在这种策略中,两个指针从同一位置出发,一个以快速移动(例如,每次移动两步),另一个以慢速移动(例如,每次移动一步)。通过比较快慢指针的相对速度,我们可以得到一些有用的信息。

检测链表中的环

#include <iostream>
using namespace std;
// 定义链表节点类
class ListNode {
public:
    int val; // 节点存储的值
    ListNode *next; // 指向下一个节点的指针
    // 构造函数,初始化节点值和指针
    ListNode(int x) : val(x), next(NULL) {}
};
// 检测链表中是否存在环
bool hasCycle(ListNode *head) {
    // 如果链表为空或者只有一个节点,则不可能形成环
    if (head == NULL || head->next == NULL) return false;
    
    // 使用两个指针,slow 每次移动一步,fast 每次移动两步
    ListNode *slow = head, *fast = head->next;
    
    // 循环直到两个指针相遇
    while (fast != slow) {
        // 如果 fast 指针到达链表末尾(NULL),说明链表中没有环
        if (fast == NULL || fast->next == NULL) return false;
        
        // 移动指针,slow 移动一步,fast 移动两步
        slow = slow->next;
        fast = fast->next->next;
    }
    // 如果 while 循环结束,说明 fast 和 slow 相遇了,链表中存在环
    return true;
}

左右指针

左右指针主要用于处理数组或字符串问题,特别是那些涉及到对元素进行配对、查找特定条件下的元素组合等问题。在这种策略中,一个指针从数组(或字符串)的左端开始,另一个从右端开始,根据问题的需要,逐步向中间移动,直到满足特定条件或两指针相遇。

反转字符串

#include <iostream>
#include <string>
using namespace std;
void reverseString(string& s) {
    int left = 0, right = s.size() - 1;
    while (left < right) {
        swap(s[left], s[right]);
        left++;
        right--;
    }
}
int main() {
    string str = "hello";
    reverseString(str);
    cout << str << endl; // 输出 olleh
    return 0;
}

# 滑动窗口

滑动窗口是一种特殊的双指针应用,主要用于解决数组或字符串上的子区间问题,如最长无重复字符的子串、包含所有字符的最短子串等。在这种策略中,两个指针定义了一个窗口的边界,通过移动这两个指针的位置来扩大或缩小窗口的大小,以此来找到满足特定条件的最优解。

最长无重复字符的子串长度

#include <iostream>
#include <string>
#include <unordered_set>
using namespace std;
int lengthOfLongestSubstring(string s) {
    // 使用一个 unordered_set 来存储当前考虑的子串中的字符。
    //unordered_set 是一个容器,能够快速判断一个值是否存在于集合中。
    unordered_set<char> charSet;
    
    // 定义两个指针 l 和 r,分别表示当前考虑的子串的左边界和右边界。
    // 初始化结果 res 为 0,用于记录遍历过程中发现的最长子串的长度。
    int l = 0, res = 0;
    
    // 开始遍历字符串 s,r 作为右边界指针,从头到尾移动。
    for (int r = 0; r < s.size(); r++) {
        // 如果当前字符 s [r] 已经存在于 charSet 中,说明遇到了重复字符。
        // 需要移动左边界 l,直到 s [r] 不再 charSet 中为止。
        while (charSet.find(s[r]) != charSet.end()) {
            // 从 charSet 中移除左边界字符,然后将左边界向右移动。
            charSet.erase(s[l]);
            l++;
        }
        
        // 将当前字符加入到 charSet 中,因为它不在当前考虑的子串中。
        charSet.insert(s[r]);
        
        // 更新结果 res,当前子串的长度为 r - l + 1。
        //max 函数用于比较并保留更大的值。
        res = max(res, r - l + 1);
    }
    
    // 返回最终计算出的最长无重复字符子串的长度。
    return res;
}
int main() {
    // 测试字符串
    string str = "abcabcbb";
    // 调用函数并输出结果
    cout << lengthOfLongestSubstring(str) << endl; // 输出 3
    return 0;
}

# 注意事项:

  • 初始化:正确初始化两个指针的位置。
  • 边界条件:在移动指针时,要注意检查边界条件,避免访问超出数组或链表的范围。
  • 移动策略:根据问题的特性,决定是同时移动两个指针还是交替移动;以及如何更新指针的位置。
  • 停止条件:明确循环或迭代的停止条件,确保算法能够在正确的时候终止。

双指针技巧通过减少不必要的计算和优化遍历过程,往往能将算法的时间复杂度从 O (n^2) 降低到 O (n),在处理一些特定问题时非常有效。

# 题目

【算法 2-2】常见优化技巧 - 题单 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

更新于