数据结构与算法(八) 双指针

简介: 数据结构与算法(八) 双指针

前言


这篇文章来讲双指针,这是一种在实际情况中十分常用的算法


正文


1、左右指针


左右指针主要来解决数组的问题,其中一些典型的应用场景以下会举例说明

一般来说,左右指针分别初始化在数组的左右两端,两指针同时向中间移动直至相遇

int binarySearch(vector<int>& nums, int target) {
    int n = nums.size();
    int p1 = 0;     // 左指针初始化在数组左端
    int p2 = n - 1; // 右指针初始化在数组右端
    while (p1 <= p2) {
        int mid = p1 + (p2 - p1) / 2;
        if (nums[mid] == target) {
            return mid;
        } else if (nums[mid] < target) {
            p1 = mid + 1; // 左指针向右移动
        } else if (nums[mid] > target) {
            p2 = mid - 1; // 右指针向左移动
        }
    }
    return -1;
}


例题:反转数组

void reverseArray(vector<int>& nums) {
    int n = nums.size();
    int p1 = 0;     // 左指针初始化在数组左端
    int p2 = n - 1; // 右指针初始化在数组右端
    while (p1 < p2) {
        swap(nums[p1], nums[p2]);
        p1++; // 左指针向右移动
        p2--; // 右指针向左移动
    }
}


2、快慢指针

快慢指针主要来解决链表的问题,其中一些典型的应用场景以下会举例说明

一般来说,快慢指针都初始化在链表左端,两指针呈加性或乘性关系逐步向右端移动

  • 例题:判断链表是否有环
bool hasCycle(ListNode* head) {
    ListNode* fast = head; // 快指针
    ListNode* slow = head; // 慢指针
    while (
        fast != nullptr &&
        fast -> next != nullptr
    ) {
        fast = fast -> next -> next; // 快指针每次走 2 步
        slow = slow -> next;         // 慢指针每次走 1 步
        if (fast == slow) {
            return  true;
        }
    }
    return  false;
}
// 思路
// 假如链表有环,指针移动会陷入死循环,不能到达末尾
// 换句话说,如果指针能到达末尾,则表明链表不存在环
// 假如链表有环,快指针每次走两步,慢指针每次走一步
// 那么快慢指针必然会在环上的某个节点相遇


例题:如果链表无环,找出链表中点

ListNode* listMiddle(ListNode* head) {
    ListNode* fast = head; // 快指针
    ListNode* slow = head; // 慢指针
    while (
        fast != nullptr &&
        fast -> next != nullptr
    ) {
        fast = fast -> next -> next; // 快指针每次走 2 步
        slow = slow -> next;         // 慢指针每次走 1 步
    }
    return slow;
}
// 思路
// 假如链表无环,快指针每次走两步,慢指针每次走一步
// 那么,当快指针到链表末尾时,慢指针刚好到链表中点


例题:如果链表无环,找出链表倒数第 n 个节点

ListNode* listLastK(ListNode* head, int n) {
    ListNode* fast = head;   // 快指针
    ListNode* slow = head;   // 慢指针
    while (n-- > 0) {        // 快指针首先走 n 步
        fast = fast -> next;
    }
    while (fast != nullptr) {
        fast = fast -> next; // 快指针每次走 1 步
        slow = slow -> next; // 慢指针每次走 1 步
    }
    return slow;
}
// 思路
// 首先,快指针先走 n 步,然后,快慢指针同时走
// 当快指针到达末尾,慢指针就是倒数第 n 个节点


例题:如果链表有环,找出环的起点

ListNode* listCycleS(ListNode* head) {
    ListNode* fast = head; // 快指针
    ListNode* slow = head; // 慢指针
    while (
        fast != nullptr &&
        fast -> next != nullptr
    ) {
        fast = fast -> next -> next; // 快指针每次走 2 步
        slow = slow -> next;         // 慢指针每次走 1 步
        if (slow == fast) {
            break;
        }
    }
    slow = head;
    while (slow != fast) {
        fast = fast -> next; // 快指针每次走 1 步
        slow = slow -> next; // 慢指针每次走 1 步
    }
    return slow;
}
// 思路
// 假如链表有环,快指针每次走两步,慢指针每次走一步
// 那么快慢指针必然会在环上的某个节点相遇
// 如果这时让慢指针或快指针指向头节点,并让快慢指针同时逐步前进
// 那么当它们再次相遇时,就是环的起点
// 为什么呢
// 假设它们第一次相遇时,慢指针走了 n 步,快指针走了 2n 步
// 快指针多走的 n 步是从相遇点绕了环整数圈回到相遇点
// 设从环起点到相遇点是 m 步
// 对于慢指针而言,过去从头节点走了 n - m 步到达环起点,然后走了 m 步到达相遇点
// 对于快指针而言,未来从相遇点再走 n - m 步能到环起点,因为再走 n 步是到相遇点
// 如果现在把慢指针指向头节点,把快指针留在相遇点
// 那么,当它们同时再走 n - m 步,就能在环起点再次相遇


例题:如果链表有环,找出环的长度

int listCycleL(ListNode* head) {
    ListNode* fast = head; // 快指针
    ListNode* slow = head; // 慢指针
    while (
        fast != nullptr &&
        fast -> next != nullptr
    ) {
        fast = fast -> next -> next; // 快指针每次走 2 步
        slow = slow -> next;         // 慢指针每次走 1 步
        if (slow == fast) {
            break;
        }
    }
    int clen = 0;
    fast = fast -> next -> next;     // 快指针每次走 2 步
    slow = slow -> next;             // 慢指针每次走 1 步
    clen += 1;
    while (slow != fast) {
        fast = fast -> next -> next; // 快指针每次走 2 步
        slow = slow -> next;         // 慢指针每次走 1 步
        clen += 1;
    }
    return clen;
}
// 思路
// 假如链表有环,快指针每次走两步,慢指针每次走一步
// 那么快慢指针必然会在环上的某个节点相遇
// 这时快指针每次走两步,慢指针每次走一步
// 记录慢指针再次相遇快指针时的步数,就是环的长度
// 因为对于慢指针而言,刚好走过环的一圈回到相遇点
// 另外对于快指针而言,刚好走过环的两圈回到相遇点


3、滑动窗口


滑动窗口主要来解决子串的问题,其常规步骤及核心问题如下:


  • 初始化左右指针,并确定窗口是左闭右闭区间还是左闭右开区间等等
  • 右指针向右滑动,扩大窗口范围,直至窗口能够满足条件或不再满足条件,并更新数据
  • 左指针向右滑动,缩小窗口范围,直至窗口不再满足条件或能够满足条件,并更新数据
  • 重复上述两步骤,直至右指针到达字符串的末尾

滑动窗口典型的应用场景如下:


找出不含重复字符的最长子串 | leetcode3

给定一个字符串 s,找出其中不含重复字符的最长子串

解题思路:

在字符串 s 上使用滑动窗口,要求窗口不含重复字符

解题步骤:

初始化左右指针在字符串的开头,定义窗口为左闭右闭区间

右指针向右滑动,扩大窗口范围,直至窗口不再满足要求,更新辅助数据结构和答案

左指针向右滑动,缩小窗口范围,直至窗口能够满足要求,更新辅助数据结构

重复上述两步骤,直至右指针到达字符串的结尾

关键问题:

如何判断窗口是否包含重复字符?

维护一个辅助数据结构哈希集合,用于保存窗口包含的字符

当右指针移动、扩大窗口范围时,将其扫过的字符加入集合

当左指针移动、缩小窗口范围时,将其扫过的字符从集合中删除


class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        int n = s.size();
        if (n == 0) {
            return 0;
        }
        // 初始化辅助结构
        // 具体为哈希集合,用于保存窗口包含的字符
        unordered_set<char> set;
        // 初始化左右指针
        // 定义为左闭右闭区间
        int p1 = 0;
        int p2 = -1;
        // 初始化答案的值
        int ans = INT_MIN;
        // 滑动窗口主流程
        while (true) {
            // 右指针向右滑动,扩大窗口范围,直至窗口包含重复字符,更新辅助数据结构
            // 因为是求最大值,所以要在扩大窗口范围时更新答案
            while (true) {
                p2++;
                if (p2 <= n - 1 && set.find(s[p2]) == set.end()) {
                    ans = max(ans, p2 - p1 + 1);
                    set.insert(s[p2]);
                } else {
                    break;
                }
            }
            // 右指针到达字符串的结尾时停止
            if (p2 >= n) {
                break;
            }
            // 左指针向右滑动,缩小窗口范围,直至窗口不含重复字符,更新辅助数据结构
            while (true) {
                p1++;
                if (s[p1 - 1] != s[p2]) {
                    set.erase(s[p1 - 1]);
                } else {
                    break;
                }
            }
        }
        // 返回答案
        return ans;
    }
};


优化思路:


上述思路在移动左指针时,其实有很多不必要的操作

而通过优化辅助数据结构,可以使左指针一步移动到合适的位置



具体做法:


将辅助数据结构改变成哈希映射,用于记录窗口扫过的字符及其最后一次出现的位置

这样右指针遇到出现过的字符时,可以快速找到该字符上一次出现的位置,即左指针的更新位置

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        int n = s.size();
        if (n == 0) {
            return 0;
        }
        // 初始化辅助结构
        // 具体为哈希映射,键为出现过的字符,值为其最后一次出现的位置
        unordered_map<char, int> map;
        // 初始化左右指针
        // 定义为左闭右闭区间
        int p1 = 0;
        int p2 = -1;
        // 初始化答案的值
        int ans = INT_MIN;
        // 滑动窗口主流程
        while (true) {
            // 右指针向右滑动,扩大窗口范围,直至窗口包含重复字符,更新辅助数据结构
            // 因为是求最大值,所以要在扩大窗口范围时更新答案
            while (true) {
                p2++;
                if (p2 <= n - 1 && (map.find(s[p2]) == map.end() || map.find(s[p2]) -> second < p1)) {
                    ans = max(ans, p2 - p1 + 1);
                    map[s[p2]] = p2;
                } else {
                    break;
                }
            }
            // 右指针到达字符串的结尾时停止
            if (p2 >= n) {
                break;
            }
            // 左指针一步到位,缩小窗口范围,使得窗口不含重复字符,更新辅助数据结构
            p1 = map[s[p2]] + 1;
            map[s[p2]] = p2;
        }
        // 返回答案
        return ans;
    }
};

找出覆盖给定字符的最短子串 | leetcode76

给定两个字符串 st,找出 s 中涵盖 t 所有字符的最短子串

解题思路:

在字符串 s 上使用滑动窗口,要求窗口涵盖字符串 t 的所有字符

解题步骤:

初始化左右指针在字符串的开头,定义窗口为左闭右闭区间

右指针向右滑动,扩大窗口范围,直至窗口能够满足要求,更新辅助数据结构

左指针向右滑动,缩小窗口范围,直至窗口不再满足要求,更新辅助数据结构和答案

重复上述两步骤,直至右指针到达字符串的结尾

关键问题:

根据上述思路,需要解决一个关键问题,即如何判断 s 中的窗口是否涵盖 t 中的所有字符?

维护两个哈希映射,分别用于保存 t 中的所有字符及其数量以及 s 窗口中的字符及其数量

然后根据哈希映射,判断 s 所对

class Solution {
public:
    string minWindow(string s, string t) {
        int sLen = s.size();
        int tLen = t.size();
        if (sLen < tLen) {
            return "";
        }
        // 初始化辅助结构
        // 具体为哈希映射
        unordered_map<char, int> sCnt; // 用于存放 s 窗口中的字符及其数量
        unordered_map<char, int> tCnt; // 用于存放 t 中所有的字符及其数量
        for (char & c: t) {
            tCnt[c]++;
        }
        // 初始化左右指针
        // 定义为左闭右闭区间
        int p1 = 0;
        int p2 = -1;
        // 初始化答案的值
        int ansL = INT_MAX;
        int ans1;
        int ans2;
        // 滑动窗口主流程
        while (true) {
            // 右指针向右滑动,扩大窗口范围,直至窗口包含字符串 t 所有字符
            while (true) {
                p2++;
                sCnt[s[p2]]++;
                if (check(sCnt, tCnt) || p2 >= sLen) {
                    break;
                }
            }
            // 右指针到达字符串的结尾时停止
            if (p2 >= sLen) {
                break;
            }
            // 左指针向右滑动,缩小窗口范围,直至窗口不含字符串 t 所有字符
            // 因为是求最小值,所以要在缩小窗口范围时更新答案
            while (true) {
                if (ansL > p2 - p1 + 1) {
                    ansL = p2 - p1 + 1;
                    ans1 = p1;
                    ans2 = p2;
                }
                sCnt[s[p1]]--;
                p1++;
                if (!check(sCnt, tCnt) || p1 > p2) {
                    break;
                }
            }
            // 左指针到达字符串的结尾时停止
            if (p1 >= sLen) {
                break;
            }
        }
        // 返回答案
        return ansL == INT_MAX ? "" : s.substr(ans1, ansL);
    }
    // 判断 s 中的窗口是否涵盖 t 所有字符
    bool check(
        unordered_map<char, int>& sCnt,
        unordered_map<char, int>& tCnt
    ) {
        for (auto & item: tCnt) {
            if (sCnt[item.first] < item.second) {
                return false;
            }
        }
        return true;
    }
};

找出给定字符串的异位词子串 | leetcode438

给定两个字符串 sp,找出 s 中所有 p 的异位词的开始索引

解题思路:

在字符串 s 上使用滑动窗口,要求窗口就是字符串 p 的异位词

这里隐含一个要求,那就是 s 中的窗口的长度和 p 的长度要是一样的

解题步骤:

初始化左右指针,定义窗口为左闭右闭区间,窗口长度等于异位词长度

将窗口向右移动,也即右指针向右移动一步、左指针向右移动一步,并更新辅助数据结构和答案

重复上述的步骤,直至右指针到达字符串的结尾

关键问题:

根据上述思路,需要解决一个关键问题,即如何判断 s 中的窗口是否等于 p 中的所有字符?

维护两个哈希映射,分别用于保存 p 中的所有字符及其数量以及 s 窗口中的字符及其数量

然后根据哈希映射,判断 s 所对应的哈希

class Solution {
public:
    vector<int> findAnagrams(string s, string p) {
        int sLen = s.size();
        int pLen = p.size();
        if (sLen < pLen) {
            return vector<int>();
        }
        // 初始化辅助结构
        // 具体为哈希映射
        unordered_map<char, int> sCnt; // 用于存放 s 窗口中的字符及其数量
        unordered_map<char, int> pCnt; // 用于存放 p 中所有的字符及其数量
        for (int i = 0; i < pLen; i++) {
            pCnt[p[i]]++;
        }
        // 初始化左右指针
        // 定义为左闭右闭区间
        int p1 = 0;
        int p2 = pLen - 1;
        for (int i = 0; i < pLen; i++) {
            sCnt[s[i]]++;
        }
        // 初始化答案的值
        vector<int> ans;
        if (check(sCnt, pCnt)) {
            ans.emplace_back(0);
        }
        // 滑动窗口主流程
        while (true) {
            // 右指针向右一步
            p2++;
            sCnt[s[p2]]++;
           // 左指针向右一步
            sCnt[s[p1]]--;
            p1++;
            // 右指针到达字符串的结尾时停止
            if (p2 >= sLen) {
                break;
            }
            // 更新答案
            if (check(sCnt, pCnt)) {
                ans.emplace_back(p1);
            }
        }
        // 返回答案
        return ans;
    }
    // 判断 s 中的窗口是否就是 p 的异位词
    bool check(
        unordered_map<char, int>& sCnt,
        unordered_map<char, int>& pCnt
    ) {
        for (auto & item: pCnt) {
            if (sCnt[item.first] != item.second) {
                return false;
            }
        }
        return true;
    }
};

目录
相关文章
|
5月前
|
算法
双指针算法
双指针算法
31 2
|
2月前
|
算法 索引 容器
双指针算法详解
本文介绍了双指针算法及其应用。双指针算法是在数组或字符串中常用的高效技术,通过维护两个指针遍历数据结构以解决特定问题。根据指针移动方向,可分为同向双指针、相向双指针和快慢指针。同向双指针如移动零和复写零问题;快慢指针如快乐数问题;相向双指针如盛水最多的容器、有效三角形数量及多数之和等问题。通过合理运用双指针技巧,可简化代码并提高效率。
60 4
双指针算法详解
|
5月前
|
机器学习/深度学习 搜索推荐 算法
【再识C进阶2(下)】详细介绍指针的进阶——利用冒泡排序算法模拟实现qsort函数,以及一下习题和指针笔试题
【再识C进阶2(下)】详细介绍指针的进阶——利用冒泡排序算法模拟实现qsort函数,以及一下习题和指针笔试题
|
1月前
|
算法 C++
【算法】双指针+二分(C/C++
【算法】双指针+二分(C/C++
|
4月前
【数据结构OJ题】复制带随机指针的链表
力扣题目——复制带随机指针的链表
51 1
【数据结构OJ题】复制带随机指针的链表
|
2月前
crash —— 如何知道哪些数据结构内嵌了指定的数据结构或者内嵌了指向指定数据结构的指针
crash —— 如何知道哪些数据结构内嵌了指定的数据结构或者内嵌了指向指定数据结构的指针
|
3月前
|
算法 容器
【算法】双指针
【算法】双指针
|
3月前
|
算法 C++ 容器
【C++算法】双指针
【C++算法】双指针
|
6月前
|
算法 C语言
C数据结构-翻转指针法、头插法实现单链表反转
本文介绍以C语言实现无头单链表反转的算法:翻转指针法与头插法。
52 4