算法学习-堆、滑动窗口

127 阅读8分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 2 天,点击查看活动详情

堆是一种特殊的树。

  1. 堆必须是完全二叉树
  2. 堆中每一个节点的值都必须大于等于,或者小于等于子节点的值。

大顶堆:顶部数据为堆中最大值。

小顶堆:顶部数据为堆中最小值。

java 默认是小顶堆。大顶堆函数为:(x,y)-> y - x

PriorityQueue<Integer> queue = new PriorityQueue<>(4);
queue.add(5);
queue.add(7);
System.out.println(queue.peek());
System.out.println(queue.poll());

peek/poll 都是堆顶元素,大堆就是最大值,小堆就是最小值


堆的使用技巧

查找:找⼤⽤⼩,⼤的进;找⼩⽤⼤,⼩的进。

排序:升序⽤⼩,降序⽤⼤。

为什么查找是找大用小?

首先如果找最大,肯定是用大顶堆。

如果找第K大的值,此时用小堆。因为小堆堆顶是最小的元素,让大的进堆,最后输出堆顶的就是第K大元素。

比如:[8,6,3,5,9,7,2] 找第二大元素。

  1. 设置堆元素个数为2,其实不设置也OK,new PriorityQueue<>(2),2 表示的是初始元素,不够会扩容,并非限制元素。
  2. 先添加两个元素进堆。
  3. 从2开始遍历剩余数据
    1. 判断是否比堆顶数据大,大的进堆。
  4. 输出堆顶值。

滑动窗口

滑动窗口本质上也是双指针算法思想的运用。

窗口:左右指针中间的部分。窗口大小固定需要考虑越界,不固定需要考虑是否满足要求。左右指针初始化为0,右指针向前,左指针不变,直到不满要求位置。当不满足要求时,需要根据要求调整左指针位置。

滑动:右指针一直向前,直到最后。

滑动窗口可以与堆配合。比如求窗口内的最大、最小元素。

常见算法题

子数组最大平均数

给你一个由 n 个元素组成的整数数组 nums 和一个整数 k

请你找出平均数最大且 长度为 k 的连续子数组,并输出该最大平均数。

任何误差小于 10-5 的答案都将被视为正确答案。

示例 1:

输入:nums = [1,12,-5,-6,50,3], k = 4
输出:12.75
解释:最大平均数 (12-5-6+50)/4 = 51/4 = 12.75

示例 2:

输入:nums = [5], k = 1
输出:5.00000

提示:

  • n == nums.length
  • 1 <= k <= n <= 105
  • -104 <= nums[i] <= 104

思路

长度已固定,且提示中不存在越界问题,直接写代码即可。

  1. 先从头获取k个长度子数组。取平均值
  2. 再从k位置继续往后遍历,每次删除掉 k 长度前的数据。
  3. 判断是否大于之前的值,大于则替换
  4. 返回结果

实现

 public double findMaxAverage(int[] nums, int k) {
     int sum = 0;
     // 找到长度K平均数
     for (int i = 0; i < k; i++) {
         sum += nums[i];
     }
     double maxAvg = sum / k;
     // 滑动窗口遍历 + 结果比对
     for (int i = k; i < nums.length; i++) {
         sum = sum + nums[i] - nums[i - k];
         maxAvg = Math.max(maxAvg, sum / k);
     }
     return maxAvg
 }

该方式 虽然实现了,但是性能并非最优解。

需求是求最大平均值没错,但是没有必要每次都求平均,求最大和也是一样的。所以此处多了 N个平均值操作。

最长连续递增序列

给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。

连续递增的子序列 可以由两个下标 lrl < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], ..., nums[r - 1], nums[r]] 就是连续递增子序列。

示例 1:

输入:nums = [1,3,5,4,7]
输出:3
解释:最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。 

示例 2:

输入:nums = [2,2,2,2,2]
输出:1
解释:最长连续递增序列是 [2], 长度为1。

提示:

  • 1 <= nums.length <= 104
  • -109 <= nums[i] <= 109

思路

在发现不满足条件时仅移动左指针

  1. 初始化左指针为0
  2. 从1开始执行遍历。
    1. 发现不满足条件,i <= i -1,调整左指针
    2. 满足条件计算长度
  3. 返回结果

在发现不满足条件时进行计算,并移动左指针

  1. 初始化左指针为0
  2. 从1开始执行遍历。
    1. 发现不满足条件,i <= i -1,
      1. 计算长度并判断是否最大
      2. 调整左指针
    2. 如果right到最后(1,3,5,6)
      1. 计算长度并判断是否最大
  3. 返回结果

两种思路都可以,但是很显然,第一种更清晰。第二种还需要额外的判断是否到最后一位,虽然少一些长度计算,但是多了一次判断,总体来说性能并没有很大差别。(第一种,每次计算最大长度,第二种,每次做一下判断)

不用滑动窗口直接计算

  1. 初始化当前长度为1;
  2. 从1开始执行遍历
    1. 发现不满足条件
      1. 判断并赋值最大长度
      2. 长度 重新赋值为1
    2. 满足条件长度++
  3. 返回最大长度

实现

思路1:

public int findLengthOfLCIS(int[] nums) {
    int result = 1;
    int left = 0;
    for (int i = 1; i < nums.length; i++) {
        if (nums[i] <= nums[i - 1]) {
            left = i;
        }
        result = Math.max(result, i - left + 1);
    }
    return result;
}

思路2:

public static int findLengthOfLCIS(int[] nums) {
    int result = 1;
    int left = 0;
    for (int i = 1; i < nums.length; i++) {
        if (nums[i] <= nums[i - 1]) {
            result = Math.max(result, i - left);
            left = i;
        }
        if (i == (nums.length - 1)) {
            result = Math.max(result, i - left + 1);
        }
    }
    return result;
}

public static int findLengthOfLCIS1(int[] nums) {
    int result = 1;
    int left = 0;
    for (int i = 0; i < nums.length; i++) {
        if (i == (nums.length - 1) || nums[i] > nums[i + 1]) {
            result = Math.max(result, i - left + 1);
            left = i + 1;
        }
    }
    return result;

思路3:

/**
 * 不使用 滑动窗口
 */
public static int findLengthOfLCIS2(int[] nums) {
    int result = 1;
    int count = 1;
    for (int i = 1; i < nums.length; i++) {
        if (nums[i] <= nums[i - 1]) {
            count = 1;
        } else {
            count++;
        }
        result = Math.max(result, count);
    }
    return result;
}

无重复字符最长字符串

给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

示例 1:

输入: s = "abcabcbb"
输出: 3 
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。

示例 2:

输入: s = "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

示例 3:

输入: s = "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

提示:

  • 0 <= s.length <= 5 * 104
  • s 由英文字母、数字、符号和空格组成

思路

  1. 初始化左指针为0
  2. 初始化返回结果值为0 (提示字符串长度允许为 0 )
  3. 初始化Map用于判断 是否存在重复值。
  4. 遍历字符串
    1. 判断如果存在重复值
      1. 调整左指针位置
    2. 添加Map集合
    3. 计算最大长度。
  5. 返回最大长度

实现

public int lengthOfLongestSubstring(String s) {
    int result = 0;
    int left = 0;
    // 使用Map判断是否存在 Map记录下标位置
    Map<Character, Integer> map = new HashMap();
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        if (map.containsKey(c)) {
            // 2 这里要取最大值
            left = Math.max(left, map.get(c) + 1);
        }
        map.put(c, right);
        result = Math.max(result, right - left + 1);
    }
    return result;
}

代码中 2 位置为什么要取最大值

比如:"abccbde" 第一次c重复时,left = 4,接下来b也要重复,此时如果不取最大,left = 2,显然不行。当出现重复之后,重复值之前的均不再考虑了,left指针绝对不能再回去。

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

“abc” 该字符串包含三个字符,在条件限制为最多两个不同字符时,最长子串为 ab、bc均为2

“abbc” 同理, 该字符子串为 “abb” 、"bbc" 均为 3

思路

  1. 初始化左指针为0
  2. 初始化最大长度为0
  3. 初始化Map集合
  4. 遍历字符
    1. 添加元素到集合
    2. 判断如果大小超过2
      1. 集合删除左指针元素
      2. 左指针前移一位
      3. 直到集合元素为2
    3. 计算长度
  5. 返回结果

实现

private static int twoCharOfLongestSubstring(String s, int k) {
    int max = 0;
    int left = 0;
    Map<Character, Integer> map = new HashMap<>();
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        map.put(c, right);
        while (map.size() > k) {
            // 1 注意这里 left 必须增加删除元素的下标 不能想着简单的 left++
            Integer index = map.remove(s.charAt(left));
            // 2 这里还要记得 + 1
            left = index + 1;
        }
         // 3 这里同样要记得 + 1
        max = Math.max(max, right - left + 1);
    }
    return max;
}

1 位置 为什么不能 ++

比如:"abbabbcccc" 当c出现时,不满足两个元素条件,此时需要删除元素a(left指针),在删除a时候,要使用a的最新下标,也是3的位置,所以要赋值 a 的最新下标位置。

2 位置 为什么要 + 1

比如:"abc" 当 c 出现时,要删除 a,此时a元素下标位置为0。而我们后续计算长度要排除a,从b位置开始,所以要 + 1;

3 位置 为什么要 + 1

因为长度最小也是1。假如只有1个元素时,right 下标为 0 , left 下标为 0,right - left = 0, 但是结果必须为1。

长度最小的子数组

给定一个含有 n 个正整数的数组和一个正整数 target

找出该数组中满足其和 ≥ target 的长度最小的 连续子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度**。**如果不存在符合条件的子数组,返回 0

示例 1:

输入:target = 7, nums = [2,3,1,2,4,3]
输出:2
解释:子数组 [4,3] 是该条件下的长度最小的子数组。

示例 2:

输入:target = 4, nums = [1,4,4]
输出:1

示例 3:

输入:target = 11, nums = [1,1,1,1,1,1,1,1]
输出:0

提示:

  • 1 <= target <= 109
  • 1 <= nums.length <= 105
  • 1 <= nums[i] <= 105

思路

  1. 初始化left指针为 0,sum为0
  2. 初始化最小长度为0。
  3. 遍历数组
    1. sum += 数组值
    2. 如果sum >= 目标值
      1. 计算长度,并更新
        1. 如果长度为0 直接赋值,否则取最小值
      2. sum - left 指针位置值
      3. 更新left指针 + 1
      4. 遍历,直到 < 目标值
  4. 返回结果

实现

public int minSubArrayLen(int target, int[] nums) {
    int min = Integer.MAX_VALUE;
    int left = 0;
    int sum = 0;
    for (int right = 0; right < nums.length; right++) {
        sum += nums[right];
        while (sum >= target) {
            min = Math.min(min, right - left + 1);
            sum -= nums[left];
            left++;
        }
    }
    return min == Integer.MAX_VALUE ? 0 : min;
}

寻找字符串的异位排列

异位排列:对于某个字符串 s 来说,对其内部字符打乱形成新的字符就是s的异位排列。比如 "abc" 异位排列有“acb”、“bac”、“bca”...... 包含"abc"

字符串 s1 = "ab",字符串s2="acebac",对于s1字符串来说,他的异位字符串为 “ba”, 本题在于判断 字符串s2中是否包含s1的异位字符串。

思路

首先,判断 s2 中 是否包含 s1 的异位排列,那么s1的长度即可作为一个滑动窗口的大小。由于异位字符顺序实在是太多了,如何合理的判断才是首要问题。此时,题目中明确表示 字符串的取值 均为 小写英文字母。

小写字母共26个,我们可以使用26位数组对应26个字母,通过判断数组是否一样来实现判断是否异位排列。

java中对于字符的操作有以下规则:

'a' - 'a' = 0;
'b' - 'a' = 1;
......
'z' - 'a' = 25
  1. 获取字符串长度并判断
  2. 初始化 两个 26位数组,分别用于存储两个字符的数据。
  3. 将s1长度的字符串加入两个数组中。判断是否一致,一致返回true
  4. 不一致,继续遍历s2利用滑动窗口特性。(此时左指针可以初始化为0,right初始化为s1长度)
    1. 增加一个right指针元素,删除一个left指针元素。
    2. 判断是否一致,一致返回true
  5. 返回false

实现

/**
     * 判断s2 是否包含 s1 的排列
     *  滑动窗口思想:
     *  1. 初始化左指针 left = 0
     *  2. 将s1长度作为窗口的大小
     *  3. 初始化26位数组,保存26位字母,通过判断数组是否一致来确认是否包含s1的排列
     *
     * @param s1
     * @param s2
     */
private static boolean checkInclusion(String s1, String s2) {
    int s1Len = s1.length(), s2Len = s2.length();
    if (s2Len < s1Len) {
        return false;
    }
    int[] arr1 = new int[26];
    int[] arr2 = new int[26];
    // 先初始化 s1 数组 用于后续的比对。同时顺带将s2也初始化部分
    for (int i = 0; i < s1Len; i++) {
        arr1[s1.charAt(i) - 'a']++;
        arr2[s2.charAt(i) - 'a']++;
    }
    // 直接比对
    if (Arrays.equals(arr1, arr2)) {
        return true;
    }
    // 窗口长度固定,没有其他的控制条件,直接初始化为0即可
    int left = 0;
    for (int right = s1Len; right < s2Len; right++) {
        arr2[s2.charAt(right) - 'a']++;
        // 直接 ++ 即可
        arr2[s2.charAt(left++) - 'a']--;
        if (Arrays.equals(arr1, arr2)) {
            return true;
        }
    }
    return false;
}

颜色分类

给定一个包含红色、白色和蓝色、共 n 个元素的数组 nums ,**原地**对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。

我们使用整数 012 分别表示红色、白色和蓝色。

必须在不使用库内置的 sort 函数的情况下解决这个问题。

示例 1:

输入:nums = [2,0,2,1,1,0]
输出:[0,0,1,1,2,2]

示例 2:

输入:nums = [2,0,1]
输出:[0,1,2]

提示:

  • n == nums.length
  • 1 <= n <= 300
  • nums[i]012

进阶:

  • 你能想出一个仅使用常数空间的一趟扫描算法吗?

思想

其实就是一个排序。我们这里重点关注,使用常数空间的 一次遍历算法。

首先我们可利用的点就是 值是固定的,只有 0/1/2 三种。我们期望的顺序就是,0 最前,1中间,2最后。

这里结合双指针思想,left 位置放 0 ,right 位置放 2。可以出现以下判断。

  1. 当前位置如果为2,那需要与right指针交换,交换完成后 right -- (交换完以后,需要继续判断当前位置值,所以当前位置不能变)
  2. 如果当前位置为0,那需要与left指针交换,交换完后,left++ (交换完以后,当前位置 ++ )
  3. 如果当前位置为1,当前位置 ++

为什么当前位置为0时,交换完需要 ++ ?

首先当前位置为0时,是于左侧指针交换,左侧指针肯定是 0 或者 1,如果是0那么表示0肯定要在这个位置,如果是1那么 index++ left不动(等后续有0时替换)。

实现

private static void changeColor(int[] color) {
    int left = 0, right = color.length - 1, index = 0;
    while (index <= right) {
        if (color[index] == 2) {
            swap(color, index, right--);
        }
        if (color[index] == 0) {
            swap(color, index++, left++);
        } else {
            index++;
        }
    }
}

private static void swap(int[] color, int i, int j) {
    int temp = color[i];
    color[i] = color[j];
    color[j] = temp;
}

// 还有一种交换方法 看起来比较高大上
private static void swap1(int[] color, int i, int j) {
    // 这里有个前提 i != j
    if (i == j) {
        return;
    }
    color[i] = color[i] + color[j];
    color[j] = color[i] - color[j];
    color[i] = color[i] - color[j];
}