“双指针” 是考察重点。难度一般。以下任一题的考察频率均较高,要熟练掌握。
一
88.合并两个有序数组:三指针 + 从后向前
要求一般都是空间复杂度O(1)。所以采用从后向前的双指针遍历!
「算法思路」
-
设置两个指针
p1和p2分别指向nums1和nums2的 「有效数字的尾部」,另外设置指针p指向nums1的 「最末尾」。 -
让
p1、p2从后向前遍历,通过比较nums1[p1]和nums2[p2]的大小,对p指向的最末尾进行填充。 -
当
p1 < 0时,遍历结束。将nums2的剩余全部元素,直接拷贝到nums1的前面。
const merge = (nums1, m, nums2, n) => {
let p1 = m - 1, p2 = n - 1; // 两个数组的指针,指向有效数字的末尾
let last = m + n - 1; // 待填充的位置
while (p1 >= 0 || p2 >= 0) {
if (p1 < 0) {
nums1[last--] = nums2[p2--];
} else if (p2 < 0) { // 因为题目中要求合并后的数组存储在原数组 nums1
return;
} else {
nums1[last--] = (nums1[p1] > nums2[p2]) ? nums1[p1--] : nums2[p2--]; // 稳定性
}
}
};
时间复杂度:O(m + n),指针 last 移动 (m+n) 次。
空间复杂度:O(1),对 num1 数组原地修改,不需要额外空间。
二
415. 字符串相加(大数相加):进位carry + 补0
要求:不能使用任何处理大整数的库,也不能将字符串直接转换成整数。
用双指针模拟竖式加法,定义两个指针p1和p2分别指向两个数字的末尾,定义变量carry维护当前进位。
注意: 对于位数较短的数字需要做 「补0」 操作。
const addStrings = (num1, num2) => {
let p1 = num1.length - 1, p2 = num2.length - 1;
let carry = 0; // 进位
const res = [];
while (p1 >= 0 || p2 >= 0 || carry) { // 注意循环条件 carry
let n1 = (p1 < 0) ? 0 : Number(num1[p1]); // 注意 p1--
let n2 = (p2 < 0) ? 0 : Number(num2[p2]);
let sum = n1 + n2 + carry;
res.push(sum % 10);
carry = Math.floor(sum / 10);
p1--;
p2--;
}
return res.reverse().join(''); // 注意
};
时间复杂度:O(max(m, n))。
空间复杂度:O(max(m,n))。
// 大整数
const addStrings = (num1, num2) {
return (BigInt(num1) + BigInt(num2)).toString();
}
165.比较版本号:split + 补0
const compareVersion = (v1, v2) => {
const arr1 = v1.split('.');
const arr2 = v2.split('.');
let p1 = 0, p2 = 0;
while (p1 < arr1.length || p2 < arr2.length) {
let n1 = arr1[p1] ? Number(arr1[p1]) : 0; // 补0
let n2 = arr2[p2] ? Number(arr2[p2]) : 0;
if (n1 < n2) {
return -1;
} else if (n1 > n2) {
return 1;
}
p1++;
p2++;
}
return 0;
}
时间复杂度:O(max(m, n))。
空间复杂度:O(m + n)。
如果不让用 split && 不能分隔成字符串数组,那么通过 「双指针(3 个 while) + 计算两点号间的数值」。
const compareVersion = function(s1, s2) {
let p1 = 0, p2 = 0; // 双指针
while (p1 < s1.length || p2 < s2.length) {
let sum1 = 0; // 两个点号之间的数值
let sum2 = 0;
while (p1 < s1.length && s1[p1] !== '.') {
sum1 = sum1 * 10 + s1[p1];
p1++; // 更新双指针
}
while (p2 < s2.length && s2[p2] !== '.') {
sum2 = sum2 * 10 + s2[p2];
p2++;
}
if (sum1 < sum2) return -1;
if (sum1 > sum2) return 1;
p1++; // 跳过点号
p2++;
}
return 0;
}
2. 两数相加:虚拟头节点 + 大数相加
思路和字符串相加类似,用双指针模拟大数相加的竖式加法。
- 使用虚拟头节点要首尾呼应
return dummy.next - 记得判断并更新双指针!
const addTwoNumbers = (l1, l2) => {
let dummy = new ListNode(); // 虚拟头节点
let curr = dummy; // 当前节点赋值
let carry = 0; // 进位
while (l1 || l2 || carry) { // l1、l2都是头节点
let n1 = l1 ? l1.val : 0; // 补0
let n2 = l2 ? l2.val : 0;
let sum = n1 + n2 + carry;
curr.next = new ListNode(sum % 10); // 更新链表和当前节点curr
curr = curr.next;
carry = Math.floor(sum / 10);
if (l1) l1 = l1.next; // 注意!更新双指针!!!
if (l2) l2 = l2.next;
}
return dummy.next; // 注意返回值
}
时间复杂度:
空间复杂度:
43. 字符串相乘(大数相乘):下标之和 + 去除前导0
不能使用任何内置的 BigInteger 库或直接将输入转换为整数。 思路:双重循环 + 下标之和。
- 首先,如果任一非负整数是
0,那么乘积一定返回0。 - 对两个均不为 0 的正整数,例如
12 * 34 = 2 * 4 + (1 * 4 + 2 * 3) * 10 + 1 * 3 * 100,有:- 从尾元素
2 * 4开始计算,根据2和4的 「下标之和」 为0,可以令res[0] = 8。 - 然后计算
1 * 4,因为1和4的下标之和为1,有res[1] = 4。 - 同理
2 * 3时,因为2和3的下标之和也等于1,所以应该先和res[1]相加4 + 6 = 10,有进位carry那么就令res[2]等于进位carry。而res[1]存放除以10后的值。 - 同理
1 * 3时,有res[2] = 3 + 1。其中1是上一步已经存进res[2]的 'carry'。
- 从尾元素
const multiply = (num1, num2) => {
if (num1 == '0' || num2 == '0') return '0';
let s1 = num1.split('').reverse().join(''); // 反转字符串,方便计算下标
let s2 = num2.split('').reverse().join('');
const res = [];
for (let i = 0; i < s1.length; i++) {
for (let j = 0; j < s2.length; j++) {
let index = i + j; // 下标之和
if (!res[index]) res[index] = 0; // 给 res 中相应的下标处赋值
let multi = Number(s1[i]) * Number(s2[j]) + res[index]; // 乘积之和
res[index] = multi % 10;
res[index + 1] = res[index + 1] ? res[index + 1] + Math.floor(multi / 10) : Math.floor(multi / 10);
}
}
if (!res[res.length - 1]) res.pop(); // 避免前导零的情况
return res.reverse().join('');
};
时间复杂度:O(mn)。
「BigInt」:看清题意是否要求禁用BigInt类型。
const multiply = (s1, s2) => {
return (BigInt(s1) * BigInt(s2)).toString(); // String(num) 也可以
}
三
15. 三数之和:两次去重,背思路
答案中不可以包含重复的三元组,难点在于如何去重。 「排序 + 双指针 + 两次去重」
将数组升序排序,然后遍历该数组,选定一个值为定值,然后对其右侧进行求解:
-
若
nums[i] > 0,因为已经升序排列,所以后面三个数不可能相加等于0,break退出循环; -
对于重复元素,
continue跳过循环,直接去重; -
然后对左指针
L = i + 1,右指针R = len - 1,当L < R执行循环:-
当
nums[i] + nums[L] + nums[R] === 0,push到结果数组,并进行去重、同时移动L和R -
当和
大于0,说明nums[R]太大,R左移 -
当和
小于0,说明nums[L]太小,L左移
-
注意 去重的思路是,将当前元素和上一个元素进行对比,相等则跳过,不是和后一个元素比。
const threeSum = (nums) => {
nums.sort((a, b) => a - b); // 升序
const res = [];
for (let i = 0; i <= nums.length - 3; i++) { // 注意 n - 3
if (nums[i] > 0) break; // 终止循环,不能等于 0 是因为存在 [0, 0, 0]
if (i > 0 && nums[i] == nums[i - 1]) continue; // 第一次去重
let left = i + 1, right = nums.length - 1;
while (left < right) {
let sum = nums[i] + nums[left] + nums[right];
if (sum > 0) {
right--;
} else if (sum < 0) {
left++;
} else {
res.push([nums[i], nums[left++], nums[right--]]);
while (left < right && nums[left] == nums[left - 1]) left++; // 第二次去重
while (left < right && nums[right] == nums[right + 1]) right--;
}
}
}
return res;
};
时间复杂度:O(n^2)。 sort是O(nlogn),for循环O(n),双指针遍历O(n),总体O(nlogn) + O(n)*O(n),因此取最大值O(n^2)。
「空间复杂度:O(1)。」 sort不算!
209. 长度最小的子数组:滑动窗口,记初始化
注意题意是连续子数组。 滑动窗口:初始化左右边界均为 0,用 sum 表示窗口中元素总和,通过调整右边界实现窗口扩张,直到窗口中元素总和大于等于 target为止,记录此时窗口中元素的个数。然后通过调整左边界实现窗口收缩,直到窗口中元素总和小于 target 为止。
牢记滑动窗口的初始化、窗口扩张、窗口收缩!
const minSubArrayLen = (target, nums) => {
let left = 0, right = 0; // 滑动窗口的左右边界
let sum = 0; // 滑动窗口内所有元素的累加和
let res = Infinity; // 注意
while (right < nums.length) {
sum += nums[right]; // 窗口扩张
right++;
// 注意上面是先计算sum值再更新right,所以sum实际是取了索引(left, right - 1)的数值
while (sum >= target) { // 窗口收缩
res = Math.min(res, right - left); // 经常做错
sum -= nums[left];
left++;
}
}
// 初始化 Infinity的,最后返回结果时一般需要做判断!
return res == Infinity ? 0 : res; // 注意!经常做错!
};
时间复杂度:O(n)。 两个指针最多各移动 N 次。
空间复杂度:O(1)。
42.接雨水: 四个初始化,背思路
双指针的时间复杂度已经是最优的了。 「总体思想」 对于每个下标
i,分别计算它能够接到的水量,相加即可。其中,位置i想要接到雨水,那么两边必然要有比它更高的柱子,此时雨水能达到的最大高度等于下标i两边的最大高度中的最小值, 位置i能接的雨水量等于下标i处的雨水能达到的最大高度减去nums[i]。
1. 四个初始化
L:左指针、初始为 0,从左往右移动。R:右指针、初始为length -1,从右往左移动。lmax:从左往右遍历找到的最大值,随L移动而更新,初始为 0。rmax:从右往左遍历找到的最大值,随R移动而更新,初始为 0。
2. 先更新左右最大值,后进行比较
当两个指针L和R没有相遇时,进行如下操作:
- 使用
nums[L]和nums[R]的值更新lmax和rmax的值; - 如果
nums[L] < nums[R],则L处能接的雨水量等于lmax - nums[L],将其加到雨水总量 res,然后右移L。「右侧有更高的条形块,则应该从左向右遍历,积水的高度依赖于当前方向的高度」 - 如果
nums[L] >= nums[R], 则R处能接的雨水量等于rmax - nums[R],将其加到雨水总量 res,然后左移R。「左侧有更高的条形块,同理」
注意: 当两指针相遇时,一定是在最高点,因此不用单独加,最高点水量一定为 0。
const trap = (nums) => {
let lmax = 0, rmax = 0;
let L = 0, R = nums.length - 1;
let res = 0;
// 相等时位于最高点,不会有积水。
while (L < R) {
lmax = Math.max(lmax, nums[L]); // 1.更新左右的最大值
rmax = Math.max(rmax, nums[R]);
// 遍历方向是从矮的一侧到高的一侧,只有这样才能计算出积水高度。
if (nums[L] < nums[R]) { // 2. 比较
res = res + (lmax - nums[L]); // 当前积水高度取决于矮的一侧
L++; // 从左向右遍历
} else {
res = res + (rmax - nums[R]);
R--;
}
}
return res;
};
时间复杂度:O(n)。
空间复杂度: O(1)。
四
141. 环形链表:快慢指针
使用快慢指针,如果 fast 最终遇到空指针,说明链表没有环;如果 fast 最终和 slow 相遇,那么肯定是 fast 套了若干圈,说明链表中有环。
const hasCycle = (head) => {
let slow = head;
let fast = head; // 初始都等于 head
while (fast && fast.next) { // 循环条件
slow = slow.next;
fast = fast.next.next;
if (slow === fast) return true;
}
return false;
};
时间复杂度:O(n)。
空间复杂度:O(1)。
剑指22. 链表中倒数第K个节点:前后指针
- 构建双指针距离: 初始两个节点都指向head,然后将
right移动 k 次。 - 双指针同步移动: 当
right === null时,left就是我们要找的倒数第 k 个节点。
注意:该题下标从 1 开始。
const getKthFromEnd = function(head, k) {
let left = head;
let right = head;
for (let i = 0; i < k; i++) { // 构建双指针距离 k
right = right.next;
}
while (right) { // 前后指针同步移动
left = left.next;
right = right.next;
}
return left;
};
19. 删除链表的倒数第N个节点:前后指针 + 储存上一个节点
- 总体思路和 剑指22. 链表中倒数第K个节点 一致。
- 题目给出的 n 可能等于链表长度(也就是要删除头节点),那么在构建双指针距离完成后,right 为空!
- 单链表删除节点需要保存前一个节点,因此 while 循环的判断条件是
right.next。
const removeNthFromEnd = function(head, n) {
let left = head;
let right = head;
for (let i = 0; i < n; i++) {
right = right.next;
}
if (!right) return head.next; // 临界情况:删除头节点
while (right.next) { // 循环结束后,prev 是要删除节点的前一个节点
left = left.next;
right = right.next;
}
left.next = left.next.next; // 删除 prev 的下一个节点
return head; // 已经排除特殊情况
};
160. 相交链表:双指针迭代(证明正确性)
不论相交还是不相交,只要两个指针都遍历完两个链表 或者 在这个遍历过程中,都一定会返回相遇的结果! 假设链表 headA 和 headB 长度是 m 和 n,有:(题解)
- 当链表相交时,假设不相交部分的长度分别是 a 和 b,相交长度为 c,那么有
a + c = m和b + c = n。- 如果
a == b,则两个指针会同时到达相交的起始节点,返回即可。 - 如果
a !== b,则 pa 遍历 headA 后会继续遍历 headB,pb 遍历 headB 后会继续遍历 headA,当各自走过的长度为a + c + b和b + c + a后,很明显会相遇,返回节点即可。
- 如果
- 当链表不相交时,
- 如果 m == n,那么两个指针会同时到达尾结点,同时变成null,此时
return pa就是return null。 - 如果 m !== n,那么两个指针都会各自遍历完两个链表,移动
m + n次,同时变成 null,返回null。
- 如果 m == n,那么两个指针会同时到达尾结点,同时变成null,此时
const getIntersectionNode = function(headA, headB) {
if (!headA || !headB) return null;
let pa = headA, pb = headB;
while (pa !== pb) { // 注意循环终止条件,返回 null 或 相交点
pa = pa ? pa.next : headB;
pb = pb ? pb.next : headA;
}
return pa; // pa等于pb
}
时间复杂度:O(m+n)
空间复杂度:O(1)
283. 移动零:关注非零元素
- 指针
p指向待交换元素,指针i遍历数组。 - 当
i遇到非零元素时,就和p指向的元素进行交换。
const moveZeroes = (nums) => {
let p = 0;
for (let i = 0; i < nums.length; i++) {
if (nums[i]) {
[nums[p], nums[i]] = [nums[i], nums[p]];
p++;
}
}
return nums;
};
总结
- 双指针很多都伴随着 空间复杂度O(1) 的要求。
- 要及时更新双指针!
- 总结相关题目:
合并两个有序数组和字符串相加思路都是 从后向前,通过 while 遍历。字符串相加和字符串相乘都是竖式模拟大数加法、大数乘法,其中大数乘法是用的双重循环,严格来说不算双指针。长度最小的子数组和接雨水看重初始化过程,多背思路!相交链表和两数相加(虚拟头节点)的链表问题,通过 较巧的思路 + 双指针解决。比较版本号是比较常规的 字符串应用双指针 问题。