leetcode 之双指针

796 阅读9分钟

“双指针” 是考察重点。难度一般。以下任一题的考察频率均较高,要熟练掌握。

88.合并两个有序数组:三指针 + 从后向前

题目 88.合并两个有序数组

要求一般都是空间复杂度O(1)。所以采用从后向前的双指针遍历!

「算法思路」

  1. 设置两个指针p1p2分别指向nums1nums2「有效数字的尾部」,另外设置指针p指向nums1「最末尾」

  2. p1、p2从后向前遍历,通过比较nums1[p1]nums2[p2]的大小,对p指向的最末尾进行填充。

  3. 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

题目 415. 字符串相加

要求:不能使用任何处理大整数的库,也不能将字符串直接转换成整数。

用双指针模拟竖式加法,定义两个指针p1p2分别指向两个数字的末尾,定义变量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

题目 165. 比较版本号

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. 两数相加:虚拟头节点 + 大数相加

题目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

题目 43. 字符串相乘

不能使用任何内置的 BigInteger 库或直接将输入转换为整数。 思路:双重循环 + 下标之和。

  • 首先,如果任一非负整数是0,那么乘积一定返回0
  • 对两个均不为 0 的正整数,例如 12 * 34 = 2 * 4 + (1 * 4 + 2 * 3) * 10 + 1 * 3 * 100,有:
    • 从尾元素2 * 4开始计算,根据24「下标之和」0,可以令res[0] = 8
    • 然后计算1 * 4,因为14下标之和1,有res[1] = 4
    • 同理2 * 3时,因为23下标之和也等于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. 三数之和:两次去重,背思路

题目 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)sortO(nlogn),for循环O(n),双指针遍历O(n),总体O(nlogn) + O(n)*O(n),因此取最大值O(n^2)

「空间复杂度:O(1)。」 sort不算!

209. 长度最小的子数组:滑动窗口,记初始化

题目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.接雨水: 四个初始化,背思路

题目 42.接雨水

题解

双指针的时间复杂度已经是最优的了。 「总体思想」 对于每个下标i,分别计算它能够接到的水量,相加即可。其中,位置i想要接到雨水,那么两边必然要有比它更高的柱子,此时雨水能达到的最大高度等于下标i两边的最大高度中的最小值, 位置i能接的雨水量等于下标i处的雨水能达到的最大高度减去nums[i]

1. 四个初始化

  • L:左指针、初始为 0,从左往右移动。
  • R:右指针、初始为 length -1,从右往左移动。
  • lmax:从左往右遍历找到的最大值,随L移动而更新,初始为 0。
  • rmax:从右往左遍历找到的最大值,随R移动而更新,初始为 0。

2. 先更新左右最大值,后进行比较

当两个指针LR没有相遇时,进行如下操作:

  • 使用nums[L]nums[R]的值更新lmaxrmax的值;
  • 如果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. 环形链表:快慢指针

题目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个节点:前后指针

题目剑指offer 22.链表中倒数第K个节点

  1. 构建双指针距离: 初始两个节点都指向head,然后将right移动 k 次。
  2. 双指针同步移动: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个节点:前后指针 + 储存上一个节点

题目19. 删除链表的倒数第N个节点

  1. 总体思路和 剑指22. 链表中倒数第K个节点 一致。
  2. 题目给出的 n 可能等于链表长度(也就是要删除头节点),那么在构建双指针距离完成后,right 为空
  3. 单链表删除节点需要保存前一个节点,因此 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. 相交链表:双指针迭代(证明正确性)

题目160. 相交链表

不论相交还是不相交,只要两个指针都遍历完两个链表 或者 在这个遍历过程中,都一定会返回相遇的结果! 假设链表 headA 和 headB 长度是 m 和 n,有:(题解

  • 当链表相交时,假设不相交部分的长度分别是 a 和 b,相交长度为 c,那么有 a + c = mb + 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。
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. 移动零:关注非零元素

题目 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;
};

总结

  1. 双指针很多都伴随着 空间复杂度O(1) 的要求。
  2. 及时更新双指针
  3. 总结相关题目:
  • 合并两个有序数组字符串相加 思路都是 从后向前,通过 while 遍历。
  • 字符串相加字符串相乘 都是竖式模拟 大数加法、大数乘法,其中大数乘法 是用的双重循环,严格来说不算双指针。
  • 长度最小的子数组接雨水 看重初始化过程,多背思路!
  • 相交链表两数相加(虚拟头节点) 的链表问题,通过 较巧的思路 + 双指针解决。
  • 比较版本号 是比较常规的 字符串应用双指针 问题。