学会双指针,这几道题目就够了

659 阅读14分钟

什么是双指针算法?

1 双指针算法一般是指有两个指针,可以用来遍历,也可以用来查找,还可以当做滑动窗口,双指针算法的关键是寻找到什么时候更新low指针,什么时候更新high指针,也就是根据什么条件来更新指针,和贪心算法主要找到什么是最贪心差不多。

167. 两数之和 II - 输入有序数组

题目描述

给定一个已按照升序排列 的有序数组,找到两个数使得它们相加之和等于目标数。 函数应该返回这两个下标值 index1 和 index2,其中 index1 必须小于 index2。

说明:

返回的下标值(index1 和 index2)不是从零开始的。<br/>
你可以假设每个输入只对应唯一的答案,而且你不可以重复使用相同的元素。<br/>

例子

输入: numbers = [2, 7, 11, 15], target = 9
输出: [1,2]
解释: 2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。

思考 1

不管做任何的算法,首先要理解好前提,也就是已知条件,比如这里已经明确表示了这是已经升序的了,所以很容易想到使用双指针,一个指针指向最小,一个指向最大,然后根据是否和targe相等不断的移动指针。

实现1

/**
 * @param {number[]} numbers
 * @param {number} target
 * @return {number[]}
 */
export default (numbers, target) => {
  let minPoints = 0;
  const len = numbers.length;
  let maxPoints = len - 1;
  const res = [];
  while (minPoints < maxPoints) {
    if (numbers[minPoints] + numbers[maxPoints] === target) {
      res.push(++minPoints);
      res.push(++maxPoints);
    } else if (numbers[minPoints] + numbers[maxPoints] > target) {
      maxPoints--;
    } else {
      minPoints++;
    }
  }
  return res;
};

算法时间复杂度 O(n), 空间复杂度 O(1)

88. 合并两个有序数组

题目描述

给你两个有序整数数组 nums1 和 nums2,请你将 nums2 合并到 nums1 中,使 nums1 成为一个有序数组。

说明:

1 初始化 nums1 和 nums2 的元素数量分别为 m 和 n 。
2 你可以假设 nums1 有足够的空间(空间大小大于或等于 m + n)来保存 nums2 中的元素。

例子

输入:
nums1 = [1,2,3,0,0,0], m = 3
nums2 = [2,5,6], n = 3

输出:[1,2,2,3,5,6]

提示:

1 -10^9 <= nums1[i], nums2[i] <= 10^9
2 nums1.length == m + n
3 nums2.length == n

思考 1

1 采用两个指针,一个指向nums1的末尾,一个指向nums2的末尾,然后对比就可以了,不断的插入到num1的末尾,算法比较简单。
2 还有数组很容易就想到了排序,可以先把nums2加入到nums1,然后可以进行排序就可以了。

实现1

/**
 * @param {number[]} nums1
 * @param {number} m
 * @param {number[]} nums2
 * @param {number} n
 * @return {void} Do not return anything, modify nums1 in-place instead.
 */
// [0];
// (0)[1];
// 1;
export default (nums1, m, nums2, n) => {
  let i = m - 1;
  let j = n - 1;
  let k = m + n - 1;
  for (let m1 = m; m1 < m + n; m1++) {
    nums1[m1] = 0;
  }

  while (k >= 0) {
    if (nums2[j] >= nums1[i]) {
      nums1[k] = nums2[j];
      k--;
      j--;
    } else {
      if (i < 0 && j >= 0) {
        while (j >= 0) {
          nums1[k] = nums2[j];
          k--;
          j--;
        }
      } else if (j < 0 && i >= 0) {
        while (i >= 0) {
          nums1[k] = nums1[i];
          k--;
          i--;
        }
      } else if (i >= 0 && j >= 0) {
        nums1[k] = nums1[i];
        k--;
        i--;
      } else {
        k--;
      }
    }
  }
  return nums1;
};

算法时间复杂度 O(m+n), 空间复杂度 O(1)

实现2

/**
 * @param {number[]} nums1
 * @param {number} m
 * @param {number[]} nums2
 * @param {number} n
 * @return {void} Do not return anything, modify nums1 in-place instead.
 */
export default (nums1, m, nums2, n) => {
  for (let i = m; i < m + n; i++) {
    nums1[i] = nums2[i - m];
  }
  nums1.sort((a, b) => a - b);
  return nums1;
};

算法时间复杂度 O(m+nlg(m+n)), 空间复杂度 O(1)

142. 环形链表 II

题目描述

给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。

为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意,pos 仅仅是用于标识环的情况,并不会作为参数传递到函数中。

说明:

1 不允许修改给定的链表。

进阶:

你是否可以使用 O(1) 空间解决此题?。

例子1

输入:head = [3,2,0,-4], pos = 1
输出:返回索引为 1 的链表节点,因为链表中有一个环,其尾部连接到第二个节点。

例子2

输入:head = [1,2], pos = 0
输出:链表中有一个环,其尾部连接到第一个节点。。

例子3

输入:输入:head = [1], pos = -1

输出:返回 null,链表中没有环。

提示:

1 链表中节点的数目范围在范围 [0, 104] 内
2 -10^5 <= Node.val <= 10^5
3 pos 的值为 -1 或者链表中的一个有效索引

思考 1

1 因为以前做过一道印象特别深刻的题目,就是判断链表中是否存在环,采用快慢指针,慢指针走一步,快指针走两步,所以这里也准备使用快慢指针,可是这里并不是求链表中是否存在环,而是要找到环的开始节点,后来想到以前还做过一道龟兔赛跑,查找节点的题目,可是还是没有头绪
2 后来看了题解才想起了,这个题目是做过的,但是还没理解题目的真谛

4789e95ec78f70489a1997cf80c6eeeb.png

可以发现真正的要点就是a=c,所以要找起始点就很容易了,可以分别从开始出发一个指针,从z节点出发一个指针,当两者相遇的时候就是环的起始点。

这种解法其实就是知道就知道,不知道除非看过,不然很难想出来

这种固定套路的记住就可以了

实现1

/**
 * Definition for singly-linked list.
 * function ListNode(val) {
 *     this.val = val;
 *     this.next = null;
 * }
 */

/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var detectCycle = function(head) {
  let temphead = head;
  let slow = head;
  let fast = head;

  do {
    slow = slow ? slow.next : null;
    fast = fast? fast.next : null;
    fast = fast ? fast.next : null;
  } while (slow !== fast && fast !== null);

  if (slow === fast && fast!==null) {
    while (temphead !== fast) {
      temphead = temphead.next;
      fast = fast.next;
    }
  }
  return fast || null;
};

算法时间复杂度 O(n), 空间复杂度 O(1)

76. 最小覆盖子串

题目描述

给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。

说明:

1 如果 s 中存在这样的子串,我们保证它是唯一的答案。

进阶:
你能设计一个在 o(n) 时间内解决此问题的算法吗?

例子1

输入:s = "ADOBECODEBANC", t = "ABC"
输出:"BANC"

例子2

输入:s = "a", t = "a"
输出:"a"

提示:

1 1 <= s.length, t.length <= 10^5
2 s 和 t 由英文字母组成

思考 1

1 这里首先想到的肯定是双指针,因为这个专题就是双指针, 所以首先想到的肯定是设置两个指针,一个slow,一个fast, slow的位置肯定是小于或者等于fast指针的位置。
然后遍历字符串,找到包含所有t的子串,然后再不断更新slow指针,那么下一个问题就变成了按照什么规则更新slow指针?
然后很自然就会想到因为我们是找的最短的字符串,所以如果slow指针不断前进的时候,如果发现slow现在所指的字符不在t中,当然可以继续前进,因为slow在这种情况下前进,肯定不会影响最短字符串的是否包含t中的所有字符的。
当发现slow指针指的字符在t中的时候,这个时候因为字符串还没有遍历完,所以我们还是需要继续遍历下去的,否则不能肯定现在找到的是最短的字符串是s中能找到的包含t的最短字符串,这个时候就要更新slow指针,也就是让从我们找到的最短字符串中删除一个在t中的字符且在我们的找到的最短字符串中重复的次数不大于在t中出现的次数的字符。

这个题目有些不是很好理解的地方,可能就是在理解slow指针应该怎么前进?

比如 s="ADOBECODEBANC",t="ABC"的时候,

我们可以很容易的发现slow = 0,fast = 5的时候,也就是"ADOBEC"的时候是包含所有t的字符的,这时候最短字符串就是"ADOBEC"

然后想办法从"ADOBEC" 删除掉一个在t中且在我们找到的最短字符串"ADOBEC"重复次数小于在t中重复的次数,当然这个例子中,t 不包含重复的字符,
可以发现A在t中,所以slow一直前进到1,然后fast前进到10,此时又发现新的包含t的字符串“DOBECODEBA”
因为D和O不在t中,所以slow可以一直前进到4,也就是变成slow=4,fast=10
这个时候重点就来了,slow下一步如何前进,如果此时slow前进到4,可以发现“B”在t中,那么是不是就删除"B",让slow=5,让fast继续寻找“B”呢?很明显不行,因为“ECODEBA”里边有"B",没有必要去寻找"B"
所以slow还得继续前进,一直前进到slow=7,也就是删除“C”,这个时候待查找字符串就变成了“ODEBA”,这样就可以让fast去寻找 “C”了。

如果还是不懂,可以看一下代码,主要看下是如何不断的更新slow的,
关键的一点是记住更新slow的目的是为了在已经查找的最短字符串中删除一个字符,然后让fast再去寻找到一个,形成新的最短字符串

通过这个题可以学习一下如何维持一个滑动窗口的状态,不断的从窗口中删除和不断的添加到滑动窗口

实现1

/**
 * @param {string} s
 * @param {string} t
 * @return {string}
 */
export default (s, t) => {
  const tMap = new Map();
  for (let i = 0; i < t.length; i++) {
    if (!tMap.has(t[i])) {
      tMap.set(t[i], 1);
    } else {
      let count = tMap.get(t[i]) + 1;
      tMap.set(t[i], count);
    }
  }

  let cnt = 0,
    slow = 0,
    resStart = 0,
    resLen = s.length + 1;

  for (let fast = 0; fast < s.length; fast++) {
    if (tMap.has(s[fast])) {
      let count = tMap.get(s[fast]) - 1;
      tMap.set(s[fast], count);
      if (count >= 0) {
        cnt++;
      }
      // 若目前滑动窗口已包含T中全部字符,
      // 则尝试将l右移,在不影响结果的情况下获得最短子字符串
      while (cnt === t.length) {
        if (fast - slow + 1 < resLen) {
          resStart = slow;
          resLen = fast - slow + 1;
        }
        let count = tMap.get(s[slow]) + 1;
        tMap.set(s[slow], count);
        if (tMap.has(s[slow]) && count > 0) {
          cnt--;
        }
        slow++;
      }
    }
  }
  return resLen > s.length ? "" : s.substr(resStart, resLen);
};

这里的时间复杂度可以很容易的看出是O(s.length),空间复杂度O(t.length)

633. 平方数之和

题目描述

给定一个非负整数 c ,你要判断是否存在两个整数 a 和 b,使得 a^2 + b^2 = c 。

例子1

输入: 5
输出: True
解释: 1 * 1 + 2 * 2 = 5

例子2

输入:3
输出:false

例子3

输入:4
输出:true

例子4

输入:2
输出:true

例子5

输入:1
输出:true

提示:

1 0 <= c <= 231 - 1

思考 1

1 直接使用两个指针,遍历从1到Math.sqrt(c)就可以了,题目比较简单。

实现1

/**
 * @param {number} c
 * @return {boolean}
 */

export default (c) => {
  let high = Math.ceil(Math.sqrt(c));
  let low = 0;
  while (low <= high) {
    if (Math.pow(low, 2) + Math.pow(high, 2) === c) {
      return true;
    } else if (Math.pow(low, 2) + Math.pow(high, 2) > c) {
      high--;
    } else {
      low++;
    }
  }
  return false;
};

这里的时间复杂度可以很容易的看出是O(Math.sqrt(c)),空间复杂度O(1)

680. 验证回文字符串 Ⅱ

题目描述

最多删除一个字符后,判断剩余的字符串是否为回文串。

例子1

输入: "aba"
输出: True

例子2

输入:"abca"
输出:True
解释:可以删除c字符就可以了

提示:

1 字符串长度小于50000

思考 1

1 直接使用两个指针,一个指针从开头,一个从结尾,如果发现不一致就看下删除low指向的字符,看下剩下的是否是回文字符串,看下删除high指向的字符,剩下的是否是回文字符串, 最后如果发现删除了超过一个字符,则返回false,否则为true,思路比较简单。

实现1

/**
 * @param {string} s
 * @return {boolean}
 */
const isPalindrome = (s, low, high, count) => {
  if (count > 1) return false;
  while (low < high) {
    if (s[low] !== s[high]) {
      count++;
      return isPalindrome(s, low, high - 1, count) || isPalindrome(s, low + 1, high, count);
    } else {
      low++;
      high--;
    }
  }
  return true;
};
const validPalindrome = (s) => {
  if (!s) {
    return false;
  }
  if (s.length === 1) {
    return true;
  }
  let low = 0;
  let high = s.length - 1;
  let count = 0;
  return isPalindrome(s, 0, high, count);
};
export default isPalindrome;

这里的时间复杂度可以很容易的看出是O(n)因为我们不管递归多少次,最多也是遍历整个字符串进行对比,空间复杂度O(1)

524. 通过删除字母匹配到字典里最长单词

题目描述

给定一个字符串和一个字符串字典,找到字典里面最长的字符串,该字符串可以通过删除给定字符串的某些字符来得到。如果答案不止一个,返回长度最长且字典顺序最小的字符串。如果答案不存在,则返回空字符串。


例子1

输入: s = "abpcplea", d = ["ale","apple","monkey","plea"]
输出: "apple"

例子2

输入:s = "abpcplea", d = ["a","b","c"]
输出:"a"

提示:

1 所有输入的字符串只包含小写字母。
2 字典的大小不会超过 1000。
3 所有输入的字符串长度不会超过 1000。

思考 1

1 这个应该也很简单,主要是先排序数组,然后遍历数组中每个元素,假设数组中元素为s1,判断下s中是否存在s1中所有字符,且s1中出现的每个字符必须和出现在s中的相对顺序都一样,因为只能在s中删除字符变成s1,如果相对顺序不一致,就无法通过s中删除字符变成s1.

这里需要注意的是js中对于字符串的字典排序使用localeCompare

indexOf函数可以指定第二个参数

实现1

/**
 * @param {string} s
 * @param {string[]} d
 * @return {string}
 */
export default (s, d) => {
  // 首先按照长度降序排序,如果字符串长度相同,按照字典序升序排序
  d.sort((a, b) => {
    if (a.length !== b.length) {
      return b.length - a.length;
    }
    return a.localeCompare(b);
  });

  for (let i = 0; i < d.length; i++) {
    let index = -1;
    for (let j = 0; j < d[i].length; j++) {
      // 判断在d[i]中的字符是否也在s中,同时相对顺序是一定的
      index = s.indexOf(d[i][j], index + 1);
      // 如果不存在,就没必要进行下去了
      if (index < 0) {
        break;
      }
    }
    // 如果找到了,直接跳出
    if (index >= 0) {
      return d[i];
    }
  }
  return "";
};

这里的时间复杂度可以很容易的看出是Math.max((d.length* d[i].length * s.length),s.length* lg(s.length)) 因为可以看到是三层循环和对s进行排序
空间复杂度O(1)

340. 找出至多包含k个不同字符的最长子串

题目描述

至多包含 K 个不同字符的最长子串

给定一个字符串 s ,找出至多包含 k 个不同字符的最长子串 T。


例子1

输入: s = "eceba", k = 2
输出: 3
解释:则 T 为 “ece”,所以长度为 3。

例子2

输入:s = “aa”, k = 1
输出: 2
解释:则 T 为 “aa”,所以长度为 2。

思考 1

1 因为这里是双指针的专题,很自然就想到了双指针,那么双指针的核心是什么呢?

可以思考一下

核心就是找到如何更新两个指针,这里很容易就想到一个low指针指向包含k个不同字符的字符串s1的低位,一个high指向s1高位

剩下的就是考虑如何不断的更新两个指针,什么时候更新low指针,什么时候更新high指针

比较容易想到在这种时候,如果high指针向前移动,直到找到和不能再移动,不能再移动就是指发现了和high移动第一步后不相同的字符的时候,假设此时有a个相同字符,a >= 1, 此时如何更新low呢?

此时最好的情况是low指向的字符在s1中只出现一次,此时可以直接low++(此时low指向的字符肯定不等于high指向的字符,如果此时相等,那么说明我们找到的s1是错误的)

那么剩下的就是当low指向的字符在s1中出现了不止一次,出现了多次,那么该如何更新low呢?或者low更新不了,是不是能更新high呢?

这应该是题目最难的地方,所以这里可以停下里思考一下?

这时候low可以一直删除字符,直到字符在s1中只出现一次,可以停止了,此时有发现了一个新的字符串s2,比较s2和s1的长度就可以了

2 其实这里还可以使用dp,因为很容易就可以看出dp转移方程
假设dp[i] 表示含有k个字符的最长字符串
那么dp[i+1] 很容易想到dp[i+1] = Math.max(dp[i], 以i+1为结尾的最长k个字符串)

实现1

/**
 * @param {string} s
 * @param {number} k
 * @return {number}
 */
export default (s, k) => {
  // 处理边界情况
  if (s.length === 0 || k === 0) return 0;
  // 不同字符的个数
  let distinct = 0;
  // 记录每个字符出现的次数
  const map = new Map();
  let low = 0;
  let res = 0;

  for (let high = 0; high < s.length; high++) {
    const highChar = s.charAt(high);
    if (!map.has(highChar)) {
      map.set(highChar, 1);
      distinct++;
    } else {
      let count = map.get(highChar) + 1;
      map.set(highChar, count);
    }
    // 如果不同的字符数小于k,继续增加high,直到找到包含大于k个字符
    if (distinct <= k) {
      res = Math.max(res, high - low + 1);
    } else {
      // 大于k个字符的时候,这时候就要更新low,不断删除字符,直到小于k个不同字符
      while (distinct > k) {
        const lowChar = s.charAt(low);
        let lowCount = map.get(lowChar);
        if (lowCount > 1) {
          map.set(lowChar, lowCount - 1);
        } else {
          map.delete(lowChar);
          distinct--;
        }
        low++;
      }
    }
  }
  return res;
};

这里的时间复杂度可以很容易的看出是O(n),虽然里边有while循环,但是仍然是O(n)

空间复杂度O(n)

双指针算法总结

一般是涉及到范围的都可以尝试下双指针算法,双指针算法的关键是要找到如何更新两个指针,让我们更加的接近算法的解。换句话说就是要想想如何缩小我们要查找的范围,让我们更加的接近答案。