本文正在参加「金石计划」
双指针
双指针是基础算法之一,也是笔试面试中的热门考虑知识点,今天通过 道面试原题来由浅到深学习「双指针」算法。
1743. 从相邻元素对还原数组
存在一个由 n 个不同元素组成的整数数组 nums ,但你已经记不清具体内容。好在你还记得 nums 中的每一对相邻元素。
给你一个二维整数数组 adjacentPairs ,大小为 n - 1 ,其中每个 adjacentPairs[i] = [ui, vi] 表示元素 ui 和 vi 在 nums 中相邻。
题目数据保证所有由元素 nums[i] 和 nums[i+1] 组成的相邻元素对都存在于 adjacentPairs 中,存在形式可能是 [nums[i], nums[i+1]] ,也可能是 [nums[i+1], nums[i]] 。这些相邻元素对可以 按任意顺序 出现。
返回 原始数组 nums 。如果存在多种解答,返回 其中任意一个 即可。
示例 1:
输入:adjacentPairs = [[2,1],[3,4],[3,2]]
输出:[1,2,3,4]
解释:数组的所有相邻元素对都在 adjacentPairs 中。
特别要注意的是,adjacentPairs[i] 只表示两个元素相邻,并不保证其 左-右 顺序。
提示:
- nums.length == n
- adjacentPairs.length == n - 1
- adjacentPairs[i].length == 2
- 2 <= n <=
- - <= nums[i], ui, vi <=
- 题目数据保证存在一些以 adjacentPairs 作为元素对的数组
单向构造(哈希表计数)
根据题意,由于所有的相邻关系都会出现在 中,假设其中一个合法数组为 ,长度为 。
那么显然 和 在 中只存在一对相邻关系,而其他 则存在两对相邻关系。
因此我们可以使用「哈希表」对 中出现的数值进行计数,找到“出现一次”的数值作为 数值的首位,然后根据给定的相邻关系进行「单向构造」,为了方便找到某个数其相邻的数是哪些,我们还需要再开一个「哈希表」记录相邻关系。
代码:
class Solution {
public int[] restoreArray(int[][] aps) {
int m = aps.length, n = m + 1;
Map<Integer, Integer> cnts = new HashMap<>();
Map<Integer, List<Integer>> map = new HashMap<>();
for (int[] ap : aps) {
int a = ap[0], b = ap[1];
cnts.put(a, cnts.getOrDefault(a, 0) + 1);
cnts.put(b, cnts.getOrDefault(b, 0) + 1);
List<Integer> alist = map.getOrDefault(a, new ArrayList<>());
alist.add(b);
map.put(a, alist);
List<Integer> blist = map.getOrDefault(b, new ArrayList<>());
blist.add(a);
map.put(b, blist);
}
int start = -1;
for (int i : cnts.keySet()) {
if (cnts.get(i) == 1) {
start = i;
break;
}
}
int[] ans = new int[n];
ans[0] = start;
ans[1] = map.get(start).get(0);
for (int i = 2; i < n; i++) {
int x = ans[i - 1];
List<Integer> list = map.get(x);
for (int j : list) {
if (j != ans[i - 2]) ans[i] = j;
}
}
return ans;
}
}
- 时间复杂度:
- 空间复杂度:
双向构造(双指针)
在解法一中,我们通过「哈希表」计数得到 首位元素作为起点,进行「单向构造」。
那么是否存在使用任意数值作为起点进行的双向构造呢?
答案是显然的,我们可以利用 的长度为 ,构造一个长度 的数组 (这里可以使用 static 进行加速,让多个测试用例共享一个大数组)。
这里 数组不一定要开成 大小,只要我们 大小大于 的两倍,就不会存在越界问题。
从 数组的 中间位置 开始,先随便将其中一个元素添加到中间位置,使用「双指针」分别往「两边拓展」(l 和 r 分别指向左右待插入的位置)。
当 l 指针和 r 指针之间已经有 个数值,说明整个 构造完成,我们将 范围内的数值输出作为答案即可。
代码:
class Solution {
static int N = (int)1e6+10;
static int[] q = new int[N];
public int[] restoreArray(int[][] aps) {
int m = aps.length, n = m + 1;
Map<Integer, List<Integer>> map = new HashMap<>();
for (int[] ap : aps) {
int a = ap[0], b = ap[1];
List<Integer> alist = map.getOrDefault(a, new ArrayList<>());
alist.add(b);
map.put(a, alist);
List<Integer> blist = map.getOrDefault(b, new ArrayList<>());
blist.add(a);
map.put(b, blist);
}
int l = N / 2, r = l + 1;
int std = aps[0][0];
List<Integer> list = map.get(std);
q[l--] = std;
q[r++] = list.get(0);
if (list.size() > 1) q[l--] = list.get(1);
while ((r - 1) - (l + 1) + 1 < n) {
List<Integer> alist = map.get(q[l + 1]);
int j = l;
for (int i : alist) {
if (i != q[l + 2]) q[j--] = i;
}
l = j;
List<Integer> blist = map.get(q[r - 1]);
j = r;
for (int i : blist) {
if (i != q[r - 2]) q[j++] = i;
}
r = j;
}
int[] ans = new int[n];
for (int i = l + 1, idx = 0; idx < n; i++, idx++) {
ans[idx] = q[i];
}
return ans;
}
}
- 时间复杂度:
- 空间复杂度:
2024. 考试的最大困扰度
一位老师正在出一场由 道判断题构成的考试,每道题的答案为 true (用 'T' 表示)或者 false (用 'F' 表示)。老师想增加学生对自己做出答案的不确定性,方法是最大化有连续相同结果的题数。(也就是连续出现 true 或者连续出现 false)。
给你一个字符串 ,其中 是第 个问题的正确结果。除此以外,还给你一个整数 ,表示你能进行以下操作的最多次数:
每次操作中,将问题的正确答案改为 'T' 或者 'F' (也就是将 改为 'T' 或者 'F' )。
请你返回在不超过 次操作的情况下,最大 连续 'T' 或者 'F' 的数目。
示例 1:
输入:answerKey = "TTFF", k = 2
输出:4
解释:我们可以将两个 'F' 都变为 'T' ,得到 answerKey = "TTTT" 。
总共有四个连续的 'T' 。
提示:
- 要么是
'T',要么是'F'
滑动窗口
题目求修改次数不超过 的前提下,连续段 'T' 或 'F' 的最大长度。
等价于求一个包含 'F' 或者 'T' 的个数不超过 的最大长度窗口。
假定存在一个 int getCnt(char c) 函数,返回包含字符 c 数量不超过 的最大窗口长度,那么最终 max(getCnt('T'), getCnt('F')) 即是答案。
其中 getCnt 函数的实现可以使用「滑动窗口」:使用 和 分别代表窗口的左右端点, 为区间 中的字符 c 的数量,每次右端点 移动时,若满足 ,让 自增,当 时,使左端点 往右移动,同时更新 ,直到 区间恢复合法性(包含字符 c 的数量 不超过 个)。
代码:
class Solution {
String s;
int n, k;
public int maxConsecutiveAnswers(String answerKey, int _k) {
s = answerKey;
n = s.length(); k = _k;
return Math.max(getCnt('T'), getCnt('F'));
}
int getCnt(char c) {
int ans = 0;
for (int i = 0, j = 0, cnt = 0; i < n; i++) {
if (s.charAt(i) == c) cnt++;
while (cnt > k) {
if (s.charAt(j) == c) cnt--;
j++;
}
ans = Math.max(ans, i - j + 1);
}
return ans;
}
}
- 时间复杂度:
- 空间复杂度:
1764. 通过连接另一个数组的子数组得到一个数组
给你一个长度为 n 的二维整数数组 groups ,同时给你一个整数数组 nums 。
你是否可以从 nums 中选出 n 个 不相交 的子数组,使得第 i 个子数组与 groups[i] (下标从 0 开始)完全相同,且如果 i > 0 ,那么第 (i-1) 个子数组在 nums 中出现的位置在第 i 个子数组前面。(也就是说,这些子数组在 nums 中出现的顺序需要与 groups 顺序相同)
如果你可以找出这样的 n 个子数组,请你返回 true ,否则返回 false 。
如果不存在下标为 k 的元素 nums[k] 属于不止一个子数组,就称这些子数组是 不相交 的。子数组指的是原数组中连续元素组成的一个序列。
示例 1:
输入:groups = [[1,-1,-1],[3,-2,0]], nums = [1,-1,0,1,-1,-1,3,-2,0]
输出:true
解释:你可以分别在 nums 中选出第 0 个子数组 [1,-1,0,1,-1,-1,3,-2,0] 和第 1 个子数组 [1,-1,0,1,-1,-1,3,-2,0] 。
这两个子数组是不相交的,因为它们没有任何共同的元素。
提示:
- groups.length == n
- 1 <= n <=
- 1 <= groups[i].length, sum(groups[i].length) <=
- 1 <= nums.length <=
- - <= groups[i][j], nums[k] <=
朴素解法
本质上是道模拟题。
使用 i 指针代表 nums 当前枚举到的位置;j 代表 groups 中枚举到哪个数组。
cnt 代表匹配的数组个数。
- 当
i开头的连续一段和groups[j]匹配:j指针右移一位(代表匹配下一个数组),i指针右移groups[j].length长度。 - 当
i开头的连续一段和groups[j]不匹配:i右移一位。
代码:
class Solution {
public boolean canChoose(int[][] gs, int[] nums) {
int n = nums.length, m = gs.length;
int cnt = 0;
for (int i = 0, j = 0; i < n && j < m;) {
if (check(gs[j], nums, i)) {
i += gs[j++].length;
cnt++;
} else {
i++;
}
}
return cnt == m;
}
boolean check(int[] g, int[] nums, int i) {
int j = 0;
for (;j < g.length && i < nums.length; j++, i++) {
if (g[j] != nums[i]) return false;
}
return j == g.length;
}
}
- 时间复杂度:
- 空间复杂度:
1537. 最大得分
你有两个 有序 且数组内元素互不相同的数组 nums1 和 nums2 。
一条 合法路径 定义如下:
- 选择数组
nums1或者nums2开始遍历(从下标 处开始)。 - 从左到右遍历当前数组。
- 如果你遇到了
nums1和nums2中都存在的值,那么你可以切换路径到另一个数组对应数字处继续遍历(但在合法路径中重复数字只会被统计一次)。
得分定义为合法路径中不同数字的和。
请你返回所有可能合法路径中的最大得分。
由于答案可能很大,请你将它对 取余后返回。
示例 1:
输入:nums1 = [2,4,5,8,10], nums2 = [4,6,8,9]
输出:30
解释:合法路径包括:
[2,4,5,8,10], [2,4,5,8,9], [2,4,6,8,9], [2,4,6,8,10],(从 nums1 开始遍历)
[4,6,8,9], [4,5,8,10], [4,5,8,9], [4,6,8,10] (从 nums2 开始遍历)
最大得分为上图中的绿色路径 [2,4,6,8,10] 。
提示:
nums1和nums2都是严格递增的数组。
前缀和 + 构造(分段计算)
一个简单且正确的做法,是我们构造一种决策方案,使得能够直接计算出最大得分。
首先,在最佳路径中所有的公共点都必然会经过,因此我们可以将值相等的点进行合并,即看作同一个点。
利用两个数组均满足「单调递增」,我们可以通过 的复杂度统计出那些公共点,以二元组 的形式存储到 list 数组(二元组含义为 )。
对于 list 中的每对相邻元素(相邻公共点),假设为 和 ,我们可以通过「前缀和」计算出 以及 的和,从而决策出在 (或者说是 ,这两个是同一个点)时,我们应当走哪一段。
当计算完所有公共点之间的得分后,对于最佳路线的首位两端,也是结合「前缀和」做同样的逻辑处理即可。
代码:
class Solution {
int MOD = (int)1e9 + 7;
public int maxSum(int[] nums1, int[] nums2) {
int n = nums1.length, m = nums2.length;
long[] s1 = new long[n + 10], s2 = new long[m + 10];
for (int i = 1; i <= n; i++) s1[i] = s1[i - 1] + nums1[i - 1];
for (int i = 1; i <= m; i++) s2[i] = s2[i - 1] + nums2[i - 1];
List<int[]> list = new ArrayList<>();
for (int i = 0, j = 0; i < n && j < m; ) {
if (nums1[i] == nums2[j]) list.add(new int[]{i, j});
if (nums1[i] < nums2[j]) i++;
else j++;
}
long ans = 0;
for (int i = 0, p1 = -1, p2 = -1; i <= list.size(); i++) {
int idx1 = 0, idx2 = 0;
if (i < list.size()) {
int[] info = list.get(i);
idx1 = info[0]; idx2 = info[1];
} else {
idx1 = n - 1; idx2 = m - 1;
}
long t1 = s1[idx1 + 1] - s1[p1 + 1], t2 = s2[idx2 + 1] - s2[p2 + 1];
ans += Math.max(t1, t2);
p1 = idx1; p2 = idx2;
}
return (int)(ans % MOD);
}
}
- 时间复杂度:
- 空间复杂度:
序列 DP
另外一个较为常见的做法是「序列 DP」做法。
定义 代表在 nums1 上进行移动,到达 的最大得分;定义 代表在 nums2 上进行移动,到达 的最大得分。
由于两者的分析是类似的,我们以 为例进行分析即可。
不失一般性考虑 如何转移,假设当前处理到的是 ,根据 是否为公共点,进行分情况讨论:
- 不为公共点,此时只能由 转移而来,即有 ;
- 为公共点(假设与 公共),此时能够从 或 转移而来,我们需要取 和 的最大值,即有 。
更重要的是,我们需要确保计算 时, 已被计算完成。
由于最佳路线必然满足「单调递增」,因此我们可以使用「双指针」来对 和 同时进行转移,每次取值小的进行更新,从而确保更新过程也是单调的,即当需要计算 时,比 小的 和 均被转移完成。
代码:
class Solution {
int MOD = (int)1e9 + 7;
public int maxSum(int[] nums1, int[] nums2) {
int n = nums1.length, m = nums2.length;
long[] f = new long[n + 1], g = new long[m + 1];
int i = 1, j = 1;
while (i <= n || j <= m) {
if (i <= n && j <= m) {
if (nums1[i - 1] < nums2[j - 1]) {
f[i] = f[i - 1] + nums1[i - 1];
i++;
} else if (nums2[j - 1] < nums1[i - 1]) {
g[j] = g[j - 1] + nums2[j - 1];
j++;
} else {
f[i] = g[j] = Math.max(f[i - 1], g[j - 1]) + nums1[i - 1];
i++; j++;
}
} else if (i <= n) {
f[i] = f[i - 1] + nums1[i - 1];
i++;
} else {
g[j] = g[j - 1] + nums2[j - 1];
j++;
}
}
return (int) (Math.max(f[n], g[m]) % MOD);
}
}
- 时间复杂度:
- 空间复杂度:
581. 最短无序连续子数组
给你一个整数数组 nums ,你需要找出一个 连续子数组 ,如果对这个子数组进行升序排序,那么整个数组都会变为升序排序。
请你找出符合题意的 最短 子数组,并输出它的长度。
示例 1:
输入:nums = [2,6,4,8,10,9,15]
输出:5
解释:你只需要对 [6, 4, 8, 10, 9] 进行升序排序,那么整个表都会变为升序排序。
提示:
-
-
-
进阶:你可以设计一个时间复杂度为 的解决方案吗?
双指针 + 排序
最终目的是让整个数组有序,那么我们可以先将数组拷贝一份进行排序,然后使用两个指针 和 分别找到左右两端第一个不同的地方,那么 这一区间即是答案。
代码:
class Solution {
public int findUnsortedSubarray(int[] nums) {
int n = nums.length;
int[] arr = nums.clone();
Arrays.sort(arr);
int i = 0, j = n - 1;
while (i <= j && nums[i] == arr[i]) i++;
while (i <= j && nums[j] == arr[j]) j--;
return j - i + 1;
}
}
- 时间复杂度:
- 空间复杂度:
双指针 + 线性扫描
另外一个做法是,我们把整个数组分成三段处理。
起始时,先通过双指针 和 找到左右两次侧满足 单调递增 的分割点。
即此时 和 满足升序要求,而中间部分 不确保有序。
然后我们对中间部分 进行遍历:
- 发现 :由于对 部分进行排序后 会出现在 后,将不满足整体升序,此时我们需要调整分割点 的位置;
- 发现 :由于对 部分进行排序后 会出现在 前,将不满足整体升序,此时我们需要调整分割点 的位置。
一些细节:在调整 和 的时候,我们可能会到达数组边缘,这时候可以建立两个哨兵:数组左边存在一个足够小的数,数组右边存在一个足够大的数。
代码:
class Solution {
int MIN = -100005, MAX = 100005;
public int findUnsortedSubarray(int[] nums) {
int n = nums.length;
int i = 0, j = n - 1;
while (i < j && nums[i] <= nums[i + 1]) i++;
while (i < j && nums[j] >= nums[j - 1]) j--;
int l = i, r = j;
int min = nums[i], max = nums[j];
for (int u = l; u <= r; u++) {
if (nums[u] < min) {
while (i >= 0 && nums[i] > nums[u]) i--;
min = i >= 0 ? nums[i] : MIN;
}
if (nums[u] > max) {
while (j < n && nums[j] < nums[u]) j++;
max = j < n ? nums[j] : MAX;
}
}
return j == i ? 0 : (j - 1) - (i + 1) + 1;
}
}
- 时间复杂度:
- 空间复杂度:
2000. 反转单词前缀
给你一个下标从 开始的字符串 word 和一个字符 ch 。找出 ch 第一次出现的下标 i ,反转 word 中从下标 开始、直到下标 i 结束(含下标 i )的那段字符。如果 word 中不存在字符 ch ,则无需进行任何操作。
- 例如,如果
word = "abcdefd"且ch = "d",那么你应该 反转 从下标 开始、直到下标3结束(含下标3)。结果字符串将会是"dcbaefd"。
返回 结果字符串 。
示例 1:
输入:word = "abcdefd", ch = "d"
输出:"dcbaefd"
解释:"d" 第一次出现在下标 3 。
反转从下标 0 到下标 3(含下标 3)的这段字符,结果字符串是 "dcbaefd" 。
提示:
word由小写英文字母组成ch是一个小写英文字母
模拟
先从前往后遍历,找到第一个 ch 的下标 (初始值为 ),然后对 应用双指针进行翻转(若没有 ch 字符,则 ,则 为不合法区间,翻转过程被跳过)。
代码:
class Solution {
public String reversePrefix(String word, char ch) {
char[] cs = word.toCharArray();
int n = cs.length, idx = -1;
for (int i = 0; i < n && idx == -1; i++) {
if (cs[i] == ch) idx = i;
}
int l = 0, r = idx;
while (l < r) {
char c = cs[l];
cs[l++] = cs[r];
cs[r--] = c;
}
return String.valueOf(cs);
}
}
- 时间复杂度:
- 空间复杂度:
toCharArray会产生新数组,复杂度为
总结
双指针算法常见于「链表」类题目,或是运用到「滑动窗口」。
通常使用「双指针」求解的题目都均由“线性复杂度”,这引导我们当题目数据范围在 时候,并且没有思路是,可以往「双指针」方向去思考。