第二章 双指针

2.1 介绍

  • 算法思想
  • 双指针主要用于遍历数组,两个指针指向不同的元素,从而协同完成任务。也可以延伸到多个数组的多个指针。
  • 若两个指针指向同一数组,遍历方向相同且不会相交,则也称为滑动窗口(两个指针包围的区域即为当前的窗口),经常用于区间搜索。
  • 若两个指针指向同一数组,但是遍历方向相反,则可以用来进行搜索,待搜索的数组往往是排好序的。

2.2 Two Sum问题

167. 两数之和 II - 输入有序数组(简单)

  • 分析
  • 由于给出的数组是有序的,我们可以采取类似二分查找的思想进行解题
  • 一头一尾两个指针。判断和和target的关系。如果比target大,则尾指针往前走,不然头指针往后走
  • 代码
public int[] twoSum(int[] numbers, int target) {
    int start = 0;
    int end = numbers.length-1;
    while (start < end) {
        if (numbers[start] + numbers[end] < target) {
            start++;
        } else if (numbers[start] + numbers[end] > target) {
            end--;
        } else {
            return new int[]{start + 1, end + 1};
        }
    }
    return null;
}
  • 注意:题目要求返回的下标是从1开始的

2.3 归并两个有序数组

88. 合并两个有序数组(简单)

  • 思路
  • 我的想法
  • 由于这两个数组是排好序的,所以只需要两个指针同时从头开始,谁小把动谁,同时放置元素。
  • 由于这个题目中使用nums1来保存合并之后的数据,所以需要将其中的元素先保存起来
  • 时间 O(m+n) ,空间 O(m)
  • 代码
public void merge(int[] nums1, int m, int[] nums2, int n) {
    int[] backup = new int[m];
    System.arraycopy(nums1, 0, backup, 0, m);
    int p1 = 0;
    int p2 = 0;
    int i = 0;
    while (p1 < m && p2 < n) {
        if (backup[p1] <= nums2[p2]) {
            nums1[i] = backup[p1];
            p1++;
        } else {
            nums1[i] = nums2[p2];
            p2++;
        }
        i++;
    }
    // 假设其中一个已经完成复制,剩下的数组中还有一些数据。
    // nums1中已经有了p1+p2个数据,一共要复制m+n个数据,则剩下的数据量为m+n-p1-p2
    if (p1 < m) {
        System.arraycopy(backup, p1, nums1, p1+p2, m+n-p1-p2);
    }
    if (p2 < n) {
        System.arraycopy(nums2, p2, nums1, p1+p2, m+n-p1-p2);
    }
}
  • 优化空间为 O(1) 的新思路,来自LeetCode官方
  • 双指针,因为nums1从m-1开始到最后都是没有数据的,所以可以采取如下思路
  • p1指向nums1的“尾巴”,这个尾巴只的是下标为m-1的位置
  • p2指向nums2的尾巴
  • p指向的是nums1的最后一个位置
  • 谁大谁复制,然后往前走
  • 代码
public void merge(int[] nums1, int m, int[] nums2, int n) {
    int p1 = m - 1;
    int p2 = n - 1;
    int p = m + n - 1;
    while (p1 >= 0 && p2 >= 0) {
        if (nums1[p1] > nums2[p2]) {
            nums1[p] = nums1[p1];
            p1--;
        } else {
            nums1[p] = nums2[p2];
            p2--;
        }
        p--;
    }
    // 注意点:循环结束之后是什么情况?
    if (p2 >= 0) {
        System.arraycopy(nums2, 0, nums1, 0,p2 + 1);
    }
}
  • 注意点:循环结束之后是什么情况?
  • nums1用完了,剩下nums2,剩余元素p2+1个,那么复制过去即可
  • nums2用完了,剩下nums1,剩下元素p1+1个。因为我们用nums1作为新容器,所以不用进行操作

2.4 快慢指针

  • KEY
  • 利用快慢指针解题的关键是使用fast和slow的路程差

142. 环形链表 II(中等)

  • 分析
  • 判断有没有环
  • fast一次走2步,slow一次走1步,如果有环,那么在若干次移动之后fast肯定能追上slow。这一点不难理解,如果有环的,那么fast一定能无限走下去,在加之fast走的比slow快,那么fast肯定能追上slow
  • 有没有环的问题解决了,那么问题来了,怎么找到环的入口
  • 找到环的入口
  • 借用官方的一张图
  • 先给出fast走过的长度:a+n(b+c)+b,slow走过的长度:a+b
  • 由于fast的步长是slow的两倍,所以 a+n(b+c)+b=2(a+b),化简整理之后得:a=(n-1)(b+c)+c
  • 这就简单了,slow再走c个长度之后就能到环的入口。也就是说从头开始走c个长度之后也能到环的入口,这样也就可以找到环的入口了
  • 代码
public static ListNode detectCycle(ListNode head) {
    if (head == null || head.next == null) {
    return null;
    }
    ListNode fast = head.next.next;
    ListNode slow = head.next;
    while (fast != slow && fast != null && fast.next != null) {
        fast = fast.next.next;
        slow = slow.next;
    }
    if (fast == slow) {
        fast = head;
        while (fast != slow) {
            fast = fast.next;
            slow = slow.next;
        }
        return fast;
    }
    return null;
}

2.5 滑动窗口

  • KEY
  • 滑动窗口类型的问题中都会有两个指针。一个用于「延伸」现有窗口的 right 指针,和一个用于「收缩」窗口的 left 指针。在任意时刻,只有一个指针运动,而另一个保持静止。

3. 无重复字符的最长子串(中等)

  • 分析
  • 显而易见的是,如果我们遍历字符串。从i开始,找无重复字符的子串。假设结束位置为 t
  • 那么,当我们滑动窗口的时候,从 i+1t的这个子串一定是无重复字符的子串。
  • 所以,当我们不停的移动 i,遍历整个字符串,即可找到最长的,无重复子串
  • 代码
public int lengthOfLongestSubstring(String s) {
    Set<Character> set = new HashSet<>();
    int len = 0;
    int right = 0;
    for (int i = 0; i < s.length(); i++) {
        if (i != 0) {
            set.remove(s.charAt(i));
        }
        while (right < s.length() && !set.contains(s.charAt(right))) {
            set.add(s.charAt(right));
            right++;
        }
        // 子串长度为 right - 1 - i + 1 = right - i
        len = Math.max(len, right - i);
    }
    return len;
}
  • 注意
  • right变量存在的位置实际上是与当前子串出现重复的后面的第一个字符的位置,所以,子串的实际范围是从iright-1,所以子串的长度即为 right-i,即setsize()

☆ 76. 最小覆盖子串(困难)

  • 分析
  • 两个指针 leftright组成的窗口中,一定要包含T中的所有字符。
  • 如果不包含,则扩大窗口,即 right往右移;如果包含,则缩小窗口,即 left往右移动。
  • 同时维护两个变量。一个用于记录长度进行比较,一个用来记录窗口中的字符串
  • 怎么判断窗口中是否都包含了 T中的所有字符呢?
  • 使用一个map来记录 T中的字符和数量(为什么要记录数量?因为 T中可能存在重复的字符)
  • 代码
public String minWindow(String s, String t) {
    int length = 1000000;
    Map<Character, Integer> tmap = new HashMap<>();
    Map<Character, Integer> smap = new HashMap<>();

    for (int i = 0; i < t.length(); i++) {
        char c = t.charAt(i);
        if (!tmap.containsKey(t.charAt(i))) {
            tmap.put(c, 1);
            smap.put(c, 0);
        } else {
            tmap.replace(c, tmap.get(c) + 1);
        }
    }
    String res = "";
    int left = 0, right = -1;
    while (right < s.length()) {
        right++;
        if (right == s.length()) {
            break;
        }
        char cr = s.charAt(right);
        if (smap.containsKey(cr)) {
            smap.replace(cr, smap.get(cr) + 1);
        }
        boolean flag = check(tmap, smap);
        while (flag && left <= right) {
            char cl = s.charAt(left);
            if (right - left + 1 < length) {
                length = right - left + 1;
                res = s.substring(left, right+1);
            }
            if (smap.containsKey(cl)) {
                smap.replace(cl, smap.get(cl) - 1);
            }
            // 如果我们缩小窗口时,删除的不是t中的字符,那么再次check肯定没问题,不然就要重新check了
            flag = tmap.containsKey(cl) ? check(tmap, smap) : true;
            left++;
        }
    }
    return res;
}

public boolean check(Map<Character, Integer> tmap, Map<Character, Integer> wmap) {
    for (Character character : tmap.keySet()) {
        if (!wmap.containsKey(character) || wmap.get(character) < tmap.get(character)) {
            return false;
        }
    }
    return true;
}
  • 注意:
  • leftp1 处,rightp2 处时。窗口代表的子串为从 [p1,p2]的子串(包含p2)。所以,对于的取子串的操作时,需要right+1,因为Java中的取子串是前闭后开的选取
  • 优化点(挖坑)
  • 我给出的代码没有进行优化,根据官方题解给出的思路,还有优化空间
  • s 中包含了许多 t 中未出现的字符,那么我们能否先处理一下 s,只关心那些 t 中的字符,忽略多余的字符,然后再进行滑动窗口
    ….

2.6 基础练习

367. 有效的完全平方数(简单)

  • 思路
  • 采用二分查找法搜索平方根(来自LeetCode官方)
  • 左边界为2,右边界为num/2
  • 如果 left < rightmid = (left + right) /2 判断mid*mid和target的关系
  • 如果大于,则 right = mid - 1;如果小于 left = mid + 1
  • 注意:int类型做平方运算,极有可能溢出,所以要使用long防止溢出
  • 代码
public boolean isPerfectSquare(int num) {
    if (num == 1) {
    return true;
    }
    long left = 2;
    long right = num / 2;
    while (left <= right) {
        long mid = left + (right - left) / 2;
        long t = mid * mid;
        if (t > num) {
            right = mid - 1;
        } else if (t < num) {
            left = mid + 1;
        } else {
            return true;
        }
    }
    return false;
}

633. 平方数之和(中等)

  • 思路
  • 找到最接近这个数的平方数,然后从这个记为n。从n开始,二分查找两个符合条件的平方数
  • 代码
public boolean judgeSquareSum(int c) {
    if (c <= 5) {
    return c != 3;
    }
    long mid = 0;
    long left = 2;
    long right = c / 2;
    while (left <= right) {
        mid = left + (right - left) / 2;
        long t = mid * mid;
        if (t > c) {
            right = mid - 1;
        } else if (t < c) {
            left = mid + 1;
        } else {
            break;
        }
    }

    left = 0;
    right = mid;
    while (left <= right) {
        long sum = left*left + right*right;
        mid = left + left + (right - left) / 2;
        if (sum < c) {
            left++;
        } else if (sum > c) {
            right--;
        } else {
            return true;
        }
    }
    return false;
}
  • 优化
  • 如果使用内置求平方根函数的话可以大大提高时间效率

680. 验证回文字符串 Ⅱ(简单)

  • 思路
  • 回文串的判断是经典可以使用二分查找思想进行解题的问题
  • 一头一尾开始进行判断。如果相等,则继续下一个位置。
  • 如果不相等,则有两种情况:删除left所在的字符或者删除right所在的字符
  • 不论删除哪里所在的字符,重新判断的时候,因为 [0,left)(right, s.length()-1]在内的字符都已经判断过了,剩余 [left, right]区间内的还未进行判断。
  • 所以只需要判断 (left, right][left, right) 中的字符即可
  • 时间复杂度 O(n)
  • 代码
public static boolean validPalindrome(String s) {
    if (s.length() <= 2) {
    return true;
    }
    int left = 0;
    int right = s.length() - 1;
    while (left <= right) {
        if (s.charAt(left) == s.charAt(right)) {
            left++;
            right--;
        } else {
            // 删除左边
            boolean flag1 = true;
            boolean flag2 = true;
            int newLeft = left + 1;
            int newRight = right;
            while (newLeft <= newRight) {
                if (s.charAt(newLeft) == s.charAt(newRight)) {
                    newLeft++;
                    newRight--;
                } else {
                    flag1 = false;
                    break;
                }
            }
            // 删除右边
            newLeft = left;
            newRight = right - 1;
            while (newLeft <= newRight) {
                if (s.charAt(newLeft) == s.charAt(newRight)) {
                    newLeft++;
                    newRight--;
                } else {
                    flag2 = false;
                    break;
                }
            }
            // 两边只要有一个能满足条件,就可以返回true
            return flag1 || flag2;
        }
    }
    return true;
}
  • 注意:
  • 如果删除左边的不能是回文,那么还需要判断删除右边的是不是回文。如果有一个是的话那么还是满足条件的

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

  • 思路
  • 如何判断一个字符串是否可由给出字符串中删除某些字符得出?
  • 即判断d中的字符串是否为s的子序列。
  • 如何快速的判断一个字符串是否为某个字符串的子序列?
  • 模仿两个有序数组归并排序,时间复杂度为 O(n) ,n为待判断的字符串长度
  • 时间复杂度 O(MN) ,其中,M为字典d中的字符个数。N为字典中最长的字符长度
  • 代码
public String findLongestWord(String s, List<String> d) {
    String res = "";
// 依次从字典d中取出字符串进行匹配比较
    for (String t : d) {
        int i = 0;
        int j = 0;
        while (i < t.length() && j < s.length()) {
            if (t.charAt(i) == s.charAt(j)) {
                i++;
                j++;
            } else {
                j++;
            }
        }
        // 如果t符合条件退出时,i = t.length(), j <= s.length()
        if (i == t.length() && j <= s.length() ) {
            if (t.length() > res.length()) {
                // 当t的长度大于res的长度时,可以直接赋值
                res = t;
            } else if (t.length() == res.length()) {
                // 当t的长度==res的长度时,需要进行字典序排序 
                String[] array = {res, t};
                Arrays.sort(array);
                res = array[0];
            }
        }
     }
    return res;
}
  • 注意
  • 返回结果是字典序中序号较小的,所以在给出答案时,要进行字典序判定

2.7 进阶练习

☆ 340. 至多包含 K 个不同字符的最长子串(困难)

  • 思路
  • 使用HashMap记录子串中的字符及其个数,因为子串中一个字符可以重复出现多次
  • 滑动窗口,start = end = 0
  • 当Map中的字符种类个数小于等于K时,扩大窗口;大于K时缩小窗口
  • 符合条件的子串为 [start, end]
  • 代码
public static int lengthOfLongestSubstringKDistinct(String s, int k) {
    if (k == 0) {
    return 0;
    }
    if (k >= s.length()) {
        return s.length();
    }

    Map<Character, Integer> map = new HashMap<>();
    int maxLen = 0;
    int start = 0;
    int end = 0;
    while (end < s.length()) {
        char c = s.charAt(end);
        // 尝试将c放入map中
        map.put(c, end);

        if (map.size() <= k) {
            // c放入之后满足条件,继续扩大窗口
            end++;
        } else {
            maxLen = Math.max(end - start, maxLen);
            // c放入之后不满足条件了,需要缩小窗口
            while (start <= end) {
                char t = s.charAt(start);
                if (map.get(t) == start) {
                    map.remove(t);
                    break;
                }
                start++;
            }
            maxLen = Math.max(end - start, maxLen);
            start++;
        }
    }
    if (map.size() <= k) {
        maxLen = Math.max(end - start, maxLen);
    }
    return maxLen;
}
  • 注意一些特殊情况的处理
  • s = "a",k = 0,answer = 0
  • s = "a",k >= 1,answer = 1
  • s = ”aa“, k = 1,answer = 2