Day 5 - 双指针与折半搜索

双指针

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

引入

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

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

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

维护区间信息

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

例题 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\) 个。

实现

int numSubarrayProductLessThanK(vector<int>& nums, int k) {
  long long ji = 1ll, ans = 0;
  int l = 0;
  for (int i = 0; i < nums.size(); ++i) {
    ji *= nums[i];
    while (l <= i && ji >= k) ji /= nums[l++];
    ans += i - l + 1;
  }
  return ans;
}

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

例题 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\) 的一个子序列,否则不是。

实现

string findLongestWord(string s, vector<string>& dictionary) {
  sort(dictionary.begin(), dictionary.end());
  int mx = 0, r = 0;
  string ans = "";
  for (int i = dictionary.size() - 1; i >= 0; i--) {
    r = 0;
    for (int j = 0; j < s.length(); ++j) {
      if (s[j] == dictionary[i][r]) r++;
    }
    if (r == dictionary[i].length()) {
      if (r >= mx) {
        mx = r;
        ans = dictionary[i];
      }
    }
  }
  return ans;
}

这种两个指针指向不同对象然后逐步进行比对的方法还可以用在一些 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\) 不断左移,最后两者各逼近到一个答案。

实现

vector<int> twoSum(vector<int>& numbers, int target) {
  int r = numbers.size() - 1, l = 0;
  vector<int> ans;
  ans.clear();
  while (l < r) {
    if (numbers[l] + numbers[r] > target)
      r--;
    else if (numbers[l] + numbers[r] == target) {
      ans.push_back(l + 1), ans.push_back(r + 1);
      return ans;
    } else
      l++;
  }
  return ans;
}

在归并排序中,在 \(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}\)」。

双向同时搜索

定义

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

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

过程

双向广搜的步骤:

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

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}\) 里面,搜索后半段时,每搜出一种方案,就把它与互补的第一段方案合并来更新答案。

参考代码:

#include <algorithm>
#include <cstdio>
#include <iostream>
#include <map>

using namespace std;

int n, m, ans = 0x7fffffff;
map<long long, int> f;
long long a[40];

int main() {
  cin >> n >> m;
  a[0] = 1;
  for (int i = 1; i < n; ++i) a[i] = a[i - 1] * 2;  // 进行预处理

  for (int i = 1; i <= m; ++i) {  // 对输入的边的情况进行处理
    int u, v;
    cin >> u >> v;
    --u;
    --v;
    a[u] |= ((long long)1 << v);
    a[v] |= ((long long)1 << u);
  }

  for (int i = 0; i < (1 << (n / 2)); ++i) {  // 对前一半进行搜索
    long long t = 0;
    int cnt = 0;
    for (int j = 0; j < n / 2; ++j) {
      if ((i >> j) & 1) {
        t ^= a[j];
        ++cnt;
      }
    }
    if (!f.count(t))
      f[t] = cnt;
    else
      f[t] = min(f[t], cnt);
  }

  for (int i = 0; i < (1 << (n - n / 2)); ++i) {  // 对后一半进行搜索
    long long t = 0;
    int cnt = 0;
    for (int j = 0; j < (n - n / 2); ++j) {
      if ((i >> j) & 1) {
        t ^= a[n / 2 + j];
        ++cnt;
      }
    }
    if (f.count((((long long)1 << n) - 1) ^ t))
      ans = min(ans, cnt + f[(((long long)1 << n) - 1) ^ t]);
  }

  cout << ans;

  return 0;
}

外部链接

posted @ 2024-07-12 08:10  So_noSlack  阅读(3)  评论(0编辑  收藏  举报