双指针

本页面将简要介绍双指针。

引入

双指针是一种简单而又灵活的技巧和思想,单独使用可以轻松解决一些特定问题,和其他算法结合也能发挥多样的用处。

双指针顾名思义,就是同时使用两个指针,在序列、链表结构上指向的是位置,在树、图结构中指向的是节点,通过或同向移动,或相向移动来维护、统计信息。

接下来我们来看双指针的几个具体使用方法。

维护区间信息

如果不和其他数据结构结合使用,双指针维护区间信息的最简单模式就是维护具有一定单调性,新增和删去一个元素都很方便处理的信息,就比如正数的和、正整数的积等等。

例题 1

例题 \(1\) leetcode 713. 乘积小于 K 的子数组

给定一个长度为 \(n\) 的正整数数组 \(\mathit{nums}\) 和整数 \(k\), 找出该数组内乘积小于 \(k\) 的连续子数组的个数。\(1 \leq n \leq 3 \times 10^4, 1 \leq nums[i] \leq 1000, 0 \leq k \leq 10^6\)

过程

设两个指针分别为 \(l,r\), 另外设置一个变量 \(\mathit{tmp}\) 记录 \([l,r]\) 内所有数的乘积。最开始 \(l,r\) 都在最左面,先向右移动 \(r\),直到第一次发现 \(\mathit{tmp}\geq k\), 这时就固定 \(r\),右移 \(l\),直到 \(\mathit{tmp}\lt k\)。那么对于每个 \(r\),\(l\) 是它能延展到的左边界,由于正整数乘积的单调性,此时以 \(r\) 为右端点的满足题目条件的区间个数为 \(r-l+1\) 个。

实现

  1. int numSubarrayProductLessThanK(vector<int>& nums, int k) {
  2. long long ji = 1ll, ans = 0;
  3. int l = 0;
  4. for (int i = 0; i < nums.size(); ++i) {
  5. ji *= nums[i];
  6. while (l <= i && ji >= k) ji /= nums[l++];
  7. ans += i - l + 1;
  8. }
  9. return ans;
  10. }

使用双指针维护区间信息也可以与其他数据结构比如差分、单调队列、线段树、主席树等等结合使用。另外将双指针技巧融入算法的还有莫队,莫队中将询问离线排序后,一般也都是用两个指针记录当前要处理的区间,随着指针一步步移动逐渐更新区间信息。

例题 2

接下来看一道在树上使用双指针并结合树上差分的例题:

例题 \(2\) luogu P3066 Running Away From the Barn G

给定一颗 \(n\) 个点的有根树,边有边权,节点从 1 至 \(n\) 编号,1 号节点是这棵树的根。再给出一个参数 \(t\),对于树上的每个节点 \(u\),请求出 \(u\) 的子树中有多少节点满足该节点到 \(u\) 的距离不大于 \(t\)。数据范围:\(1\leq n \leq 2\times 10^5,1 \leq t \leq 10^{18},1 \leq p_i \lt i,1 \leq w_i \leq 10^{12}\)

过程

从根开始用 dfs 遍历整棵树,使用一个栈来记录根到当前节点的树链,设一个指针 \(u\) 指向当前节点,另一个指针 \(p\) 指向与 \(u\) 距离不大于 \(t\) 的节点中深度最小的节点。记录到根的距离,每次二分查找确定 \(p\)。此时 \(u\) 对 \(p\) 到 \(u\) 路径上的所有节点都有一个贡献,可以用树上差分来记录。

注意不能直接暴力移动 \(p\),否则时间复杂度可能会退化至 \(O(n^2)\)。

子序列匹配

例题 \(3\) leetcode 524. 通过删除字母匹配到字典里最长单词

给定一个字符串 \(s\) 和一个字符串数组 \(\mathit{dictionary}\) 作为字典,找出并返回字典中最长的字符串,该字符串可以通过删除 \(s\) 中的某些字符得到。

过程

此类问题需要将字符串 \(s\) 与 \(t\) 进行匹配,判断 \(t\) 是否为 \(s\) 的子序列。解决这种问题只需先将两个指针一个 \(i\) 放在 \(s\) 开始位置,一个 \(j\) 放在 \(t\) 开始位置,如果 \(s[i]=t[j]\) 说明 \(t\) 的第 \(j\) 位已经在 \(s\) 中找到了第一个对应,可以进而检测后面的部分了,那么 \(i\) 和 \(j\) 同时加一。如果上述等式不成立,则 \(t\) 的第 \(j\) 位仍然没有被匹配上,所以只给 \(i\) 加一,在 \(s\) 的后面部分再继续寻找。最后,如果 \(j\) 已经移到了超尾位置,说明整个字符串都可以被匹配上,也就是 \(t\) 是 \(s\) 的一个子序列,否则不是。

实现

  1. string findLongestWord(string s, vector<string>& dictionary) {
  2. sort(dictionary.begin(), dictionary.end());
  3. int mx = 0, r = 0;
  4. string ans = "";
  5. for (int i = dictionary.size() - 1; i >= 0; i--) {
  6. r = 0;
  7. for (int j = 0; j < s.length(); ++j) {
  8. if (s[j] == dictionary[i][r]) r++;
  9. }
  10. if (r == dictionary[i].length()) {
  11. if (r >= mx) {
  12. mx = r;
  13. ans = dictionary[i];
  14. }
  15. }
  16. }
  17. return ans;
  18. }

这种两个指针指向不同对象然后逐步进行比对的方法还可以用在一些 dp 中。

利用序列有序性

很多时候在序列上使用双指针之所以能够正确地达到目的,是因为序列的某些性质,最常见的就是利用序列的有序性。

例题 \(4\) leetcode 167. 两数之和 II - 输入有序数组

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

过程

这种问题也是双指针的经典应用了,虽然二分也很方便,但时间复杂度上多一个 \(\log{n}\),而且代码不够简洁。

接下来介绍双指针做法:既然要找到两个数,且这两个数不能在同一位置,那其位置一定是一左一右。由于两数之和固定,那么两数之中的小数越大,大数越小。考虑到这些性质,那我们不妨从两边接近它们。

首先假定答案就是 1 和 n,如果发现 \(num[1]+num[n]\gt \mathit{target}\),说明我们需要将其中的一个元素变小,而 \(\mathit{num}[1]\) 已经不能再变小了,所以我们把指向 \(n\) 的指针减一,让大数变小。

同理如果发现 \(num[1]+num[n]\lt \mathit{target}\),说明我们要将其中的一个元素变大,但 \(\mathit{num}[n]\) 已经不能再变大了,所以将指向 1 的指针加一,让小数变大。

推广到一般情形,如果此时我们两个指针分别指在 \(l,r\) 上,且 \(l\lt r\), 如果 \(num[l]+num[r]\gt \mathit{target}\),就将 \(r\) 减一,如果 \(num[l]+num[r]\lt \mathit{target}\),就将 \(l\) 加一。这样 \(l\) 不断右移,\(r\) 不断左移,最后两者各逼近到一个答案。

实现

  1. vector<int> twoSum(vector<int>& numbers, int target) {
  2. int r = numbers.size() - 1, l = 0;
  3. vector<int> ans;
  4. ans.clear();
  5. while (l < r) {
  6. if (numbers[l] + numbers[r] > target)
  7. r--;
  8. else if (numbers[l] + numbers[r] == target) {
  9. ans.push_back(l + 1), ans.push_back(r + 1);
  10. return ans;
  11. } else
  12. l++;
  13. }
  14. return ans;
  15. }

在归并排序中,在 \(O(n+m)\) 时间内合并两个有序数组,也是保证数组的有序性条件下使用的双指针法。

在单向链表中找环

过程

在单向链表中找环也是有多种办法,不过快慢双指针方法是其中最为简洁的方法之一,接下来介绍这种方法。

首先两个指针都指向链表的头部,令一个指针一次走一步,另一个指针一次走两步,如果它们相遇了,证明有环,否则无环,时间复杂度 \(O(n)\)。

如果有环的话,怎么找到环的起点呢?

我们列出式子来观察一下,设相遇时,慢指针一共走了 \(k\) 步,在环上走了 \(l\) 步(快慢指针在环上相遇时,慢指针一定没走完一圈)。快指针走了 \(2k\) 步,设环长为 \(C\),则有

\[\begin{align}
& \ 2 k=n \times C+l+(k-l) \\
& \ k=n \times C \\
\end{align}
\]

第一次相遇时 \(n\) 取最小正整数 1。也就是说 \(k=C\)。那么利用这个等式,可以在两个指针相遇后,将其中一个指针移到表头,让两者都一步一步走,再度相遇的位置即为环的起点。

习题

leetcode 15. 三数之和

leetcode 1438. 绝对差不超过限制的最长连续子数组

折半搜索

下面将简要介绍两种双向搜索算法:「双向同时搜索」和「\(\text{Meet in the middle}\)」。

双向同时搜索

定义

双向同时搜索的基本思路是从状态图上的起点和终点同时开始进行广搜或深搜。

如果发现搜索的两端相遇了,那么可以认为是获得了可行解。

过程

双向广搜的步骤:

  1. 将开始结点和目标结点加入队列 q
  2. 标记开始结点为 1
  3. 标记目标结点为 2
  4. while (队列 q 不为空)
  5. {
  6. q.front() 扩展出新的 s 个结点
  7. 如果 新扩展出的结点已经被其他数字标记过
  8. 那么 表示搜索的两端碰撞
  9. 那么 循环结束
  10. 如果 新的 s 个结点是从开始结点扩展来的
  11. 那么 将这个 s 个结点标记为 1 并且入队 q
  12. 如果 新的 s 个结点是从目标结点扩展来的
  13. 那么 将这个 s 个结点标记为 2 并且入队 q
  14. }

Meet in the middle

本节要介绍的不是二分搜索(二分搜索的另外一个译名为「折半搜索」)。

引入

\(\text{Meet in the middle}\) 算法没有正式译名,常见的翻译为「折半搜索」、「双向搜索」或「中途相遇」。

它适用于输入数据较小,但还没小到能直接使用暴力搜索的情况。

过程

\(\text{Meet in the middle}\) 算法的主要思想是将整个搜索过程分成两半,分别搜索,最后将两半的结果合并。

性质

暴力搜索的复杂度往往是指数级的,而改用 \(\text{Meet in the middle}\) 算法后复杂度的指数可以减半,即让复杂度从 \(O(a^b)\) 降到 \(O(a^{b/2})\)。

例题

例题 「USACO09NOV」灯 Lights

有 \(n\) 盏灯,每盏灯与若干盏灯相连,每盏灯上都有一个开关,如果按下一盏灯上的开关,这盏灯以及与之相连的所有灯的开关状态都会改变。一开始所有灯都是关着的,你需要将所有灯打开,求最小的按开关次数。

\(1\le n\le 35\)。

解题思路:

如果这道题暴力 \(\text{DFS}\) 找开关灯的状态,时间复杂度就是 \(O(2^{n})\), 显然超时。不过,如果我们用 \(\text{meet in middle}\) 的话,时间复杂度可以优化至 \(O(n2^{n/2})\)。\(\text{meet in middle}\) 就是让我们先找一半的状态,也就是找出只使用编号为 \(1\) 到 \(\mathrm{mid}\) 的开关能够到达的状态,再找出只使用另一半开关能到达的状态。如果前半段和后半段开启的灯互补,将这两段合并起来就得到了一种将所有灯打开的方案。具体实现时,可以把前半段的状态以及达到每种状态的最少按开关次数存储在 \(\text{map}\) 里面,搜索后半段时,每搜出一种方案,就把它与互补的第一段方案合并来更新答案。

参考代码:

  1. #include <algorithm>
  2. #include <cstdio>
  3. #include <iostream>
  4. #include <map>
  5. using namespace std;
  6. int n, m, ans = 0x7fffffff;
  7. map<long long, int> f;
  8. long long a[40];
  9. int main() {
  10. cin >> n >> m;
  11. a[0] = 1;
  12. for (int i = 1; i < n; ++i) a[i] = a[i - 1] * 2; // 进行预处理
  13. for (int i = 1; i <= m; ++i) { // 对输入的边的情况进行处理
  14. int u, v;
  15. cin >> u >> v;
  16. --u;
  17. --v;
  18. a[u] |= ((long long)1 << v);
  19. a[v] |= ((long long)1 << u);
  20. }
  21. for (int i = 0; i < (1 << (n / 2)); ++i) { // 对前一半进行搜索
  22. long long t = 0;
  23. int cnt = 0;
  24. for (int j = 0; j < n / 2; ++j) {
  25. if ((i >> j) & 1) {
  26. t ^= a[j];
  27. ++cnt;
  28. }
  29. }
  30. if (!f.count(t))
  31. f[t] = cnt;
  32. else
  33. f[t] = min(f[t], cnt);
  34. }
  35. for (int i = 0; i < (1 << (n - n / 2)); ++i) { // 对后一半进行搜索
  36. long long t = 0;
  37. int cnt = 0;
  38. for (int j = 0; j < (n - n / 2); ++j) {
  39. if ((i >> j) & 1) {
  40. t ^= a[n / 2 + j];
  41. ++cnt;
  42. }
  43. }
  44. if (f.count((((long long)1 << n) - 1) ^ t))
  45. ans = min(ans, cnt + f[(((long long)1 << n) - 1) ^ t]);
  46. }
  47. cout << ans;
  48. return 0;
  49. }

外部链接

Day 5 - 双指针与折半搜索的更多相关文章

  1. 【BZOJ 2679】[Usaco2012 Open]Balanced Cow Subsets(折半搜索+双指针)

    [Usaco2012 Open]Balanced Cow Subsets 题目描述 给出\(N(1≤N≤20)\)个数\(M(i) (1 <= M(i) <= 100,000,000)\) ...

  2. codeforces912E(折半搜索+双指针+二分答案)

    E. Prime Gift E. Prime Gift time limit per test 3.5 seconds memory limit per test 256 megabytes inpu ...

  3. codeforces 880E. Maximum Subsequence(折半搜索+双指针)

    E. Maximum Subsequence time limit per test 1 second memory limit per test 256 megabytes input standa ...

  4. CF 888E Maximum Subsequence——折半搜索

    题目:http://codeforces.com/contest/888/problem/E 一看就是折半搜索?……然后排序双指针. 两个<m的数加起来如果>=m,一定不会更新答案.因为- ...

  5. bzoj2679: [Usaco2012 Open]Balanced Cow Subsets(折半搜索)

    2679: [Usaco2012 Open]Balanced Cow Subsets Time Limit: 10 Sec  Memory Limit: 128 MBSubmit: 462  Solv ...

  6. 【LOJ#6072】苹果树(矩阵树定理,折半搜索,容斥)

    [LOJ#6072]苹果树(矩阵树定理,折半搜索,容斥) 题面 LOJ 题解 emmmm,这题似乎猫讲过一次... 显然先\(meet-in-the-middle\)搜索一下对于每个有用的苹果数量,满 ...

  7. 2018.11.01 NOIP训练 某种密码(折半搜索)

    传送门 直接折半搜索,把所有和装到unorderedmapunordered_mapunorderedm​ap里面最后统计答案就行了. 然后考试的时候读优并没有处理有负数的情况于是爆零了 代码

  8. [折半搜索][哈希]POJ1186方程的解数

    题目传送门 这道题明显N数据范围非常小,但是M很大,所以用折半搜索实现搜索算法的指数级优化,将复杂度优化到O(M^(N/2)). 将搜出的两半结果用哈希的方式合并(乘法原理). Code: #incl ...

  9. Codeforces Round #297 (Div. 2)E. Anya and Cubes 折半搜索

    Codeforces Round #297 (Div. 2)E. Anya and Cubes Time Limit: 2 Sec  Memory Limit: 512 MBSubmit: xxx  ...

  10. 折半搜索【p4799】[CEOI2015 Day2]世界冰球锦标赛

    Description 今年的世界冰球锦标赛在捷克举行.Bobek 已经抵达布拉格,他不是任何团队的粉丝,也没有时间观念.他只是单纯的想去看几场比赛.如果他有足够的钱,他会去看所有的比赛.不幸的是,他 ...

随机推荐

  1. 基于ADB Shell 实现的 Android TV、电视盒子万能遥控器 — ADB Remote ATV

    ADB Remote ATV Android TV 的遥控器,基于 ADB Shell 命令 ADB Remote ATV 是一个 Android TV 的遥控器,基于 ADB Shell 命令,泛用 ...

  2. 【u8 login debug】u8 16.0 没有调试 login的解决办法

    16.0 没有调试 login,改一下注册表 就行[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Ufsoft\WF\V8.700]"Enable.Debu ...

  3. DRF之通过GenericAPIView的视图子类实现数据的增删改查接口

    1.安装DRF pip install djangorestframework 2.将DRF注册到APP中 INSTALLED_APPS = [ 'django.contrib.admin', 'dj ...

  4. nginx使用lua waf防火墙来做防CC配置

    nginx添加lua模块 启动和安装nginx yum install -y nginx systemctl daemon-reload systemctl enable nginx #为了实验方便这 ...

  5. makedown快速入门

    Makedown学习 Makedown 作为一个强大文本编辑语言,学习并熟悉应用是写好一篇优秀博客的基础 那么接下来我将介绍makedown语言最常用的几个语法 标题 +"space&quo ...

  6. 15种pod的状态

    15种pod的状态 调度失败 常见错误状态(Unschedulable) pod被创建后进入调度阶段,k8s调度器依据pod声明的资源请求量和调度规则,为pod挑选一个适合运行的节点.当集群节点不满足 ...

  7. 国产大模型参加高考,同写2024年高考作文,及格分(通义千问、Kimi、智谱清言、Gemini Advanced、Claude-3-Sonnet、GPT-4o)

    大家好,我是章北海 今天高考,上午的语文结束,市面上又要来一场大模型参考的文章了. 我也凑凑热闹,让通义千问.Kimi.智谱清言一起来写一下高考作文. 公平起见,不加任何其他prompt,直接把题目甩 ...

  8. .NET5 .NET CORE 使用Apollo

    Apollo默认有一个"SampleApp"应用,"DEV"环境 和 "timeout" KEY. nuget 中下载 "Com. ...

  9. Scrapy框架(八)--CrawlSpider

    CrawlSpider类,Spider的一个子类 - 全站数据爬取的方式 - 基于Spider:手动请求 - 基于CrawlSpider - CrawlSpider的使用: - 创建一个工程 - cd ...

  10. Chapter1 p2 vec

    在上一小节中,我们完成了对BMPImage类的构建,成功实现了我们这个小小引擎的图像输出功能. 你已经完成了图像输出了,接着就开始路径追踪吧... 开个玩笑XD 对于曾经学习过一些图形学经典教材的人来 ...