【算法笔记】窗口内最大值或最小值的更新结构

1、窗口内最大值或最小值的更新结构

1.1、滑动窗口是什么?

  • 滑动窗口是一个想象出来的数据结构:
  • 滑动窗口有左边界L和右边界R,
  • 在数组或者字符串或者一个序列上,记为S,窗口就是S[L...R]这一部分,
  • L往右滑动意味着一个样本出窗口,R往右滑动意味着一个样本进了窗口,
  • L和R都只能往右滑动,L<=R

1.2、窗口内最大值或最小值的更新结构

  • 窗口内最大值或最小值的更新结构
  • 窗口不管是L还是R滑动之后,都会让窗口呈现新的状态,
  • 如何能够更快的得到窗口当前状态下的最大值和最小值?使得平均下来的复杂度能做到O(1)?-->利用单调双端队列:
  • 当窗口的R向右滑动的时候,看当前值和双端队列右侧代表值的大小,如果当前值大于队列中代表的值,将队列中所有小于当前值的值删除,然后加入当前值,
  • 目的是维护队列中从左到右是单调递减的。这个时候,队列中从左到右代表的值就是当L减少时,窗口中的最大值。
  • 如果在R移动时,将大于当前值的从队列右侧删除,队列中从左到右是单调递增的,队列中从左到右代表的值就是当L减少时,窗口中的最小值。

2、相关题目

2.1、题目一:获取固定大小窗口内的最大值列表

  • 题目一:获取固定大小窗口内的最大值列表
  • 假设一个固定大小为W的窗口,依次划过arr,
  • 返回每一次滑出状况的最大值
  • 例如,arr = [4,3,5,4,3,3,6,7], w = 3
  • 返回:[5,5,5,4,6,7]
2.1.1、暴力的对数器方法
  • 暴力方法实现的对数器
  • 遍历每一个窗口,找到最大值
java 复制代码
    /**
     * 暴力方法实现的对数器
     * 遍历每一个窗口,找到最大值
     */
    public static int[] comparator(int[] arr, int w) {
        if (arr == null || arr.length < w || w < 1) {
            return new int[0];
        }
        int N = arr.length;
        // 结果数组
        int[] ans = new int[N - w + 1];
        // 用来填充ans数组
        int index = 0;
        // 初始化窗口下标
        int L = 0;
        int R = w - 1;
        // 遍历窗口
        while (R < N) {
            // 找到当前窗口的最大值
            int max = arr[L];
            for (int i = L + 1; i <= R; i++) {
                max = Math.max(max, arr[i]);
            }
            ans[index++] = max;
            // 窗口向右滑动
            L++;
            R++;
        }
        return ans;
    }
2.1.2、窗口内最大值或最小值的更新结构的方法
  • 窗口内最大值或最小值的更新结构的方法
  • 思路:
    • 我们首先定义一个双端队列qmax,里面用来存放当前窗口内的最大值的下标,其内部下标对应的数字是递减的。
    • 一开始,我们从0开始往右走,将满足条件的数字加入到qmax中,当窗口没有形成的时候,只是往里面加入数据,
    • 当窗口形成的时候,每次我们都要看一下qmax中的first值,此时这个值就是当前窗口内的最大值,将其加入到结果数组中。
    • 然后我们看一下当前要走过去的L的边界的值,此时的L就是R-w,看看是不是在qmax中的值,如果是,说明这个值要随着L的移动而弹出。
    • 因为下一个循环增加了R,同时相当于L也往右走了一步。
    • 这样一直循环下去,就拿到了所有窗口内的最大值。
java 复制代码
    /**
     * 窗口内最大值或最小值的更新结构的方法
     * 思路:
     * 我们首先定义一个双端队列qmax,里面用来存放当前窗口内的最大值的下标,其内部下标对应的数字是递减的。
     * 一开始,我们从0开始往右走,将满足条件的数字加入到qmax中,当窗口没有形成的时候,只是往里面加入数据,
     * 当窗口形成的时候,每次我们都要看一下qmax中的first值,此时这个值就是当前窗口内的最大值,将其加入到结果数组中。
     * 然后我们看一下当前要走过去的L的边界的值,此时的L就是R-w,看看是不是在qmax中的值,如果是,说明这个值要随着L的移动而弹出。
     * 因为下一个循环增加了R,同时相当于L也往右走了一步。
     * 这样一直循环下去,就拿到了所有窗口内的最大值。
     */
    public static int[] getMaxWindow(int[] arr, int w) {
        if (arr == null || arr.length < w || w < 1) {
            return new int[0];
        }
        int N = arr.length;
        // 结果数组
        int[] ans = new int[N - w + 1];
        // 用来填充ans数组
        int index = 0;
        // qmax 窗口最大值的更新结构,放数组的下标
        LinkedList<Integer> qmax = new LinkedList<Integer>();
        // 窗口的有边界依次加入数值
        for (int R = 0; R < N; R++) {
            // 保持双端队列中数字的递减性,即将右侧代表的值小于当前值的下标弹出
            // 这里要包含等于的情况,因为如果加入等于的情况,就相当于把队列中的下标更新了,在后面弹出的时候,前面的是弹出不了的。
            while (!qmax.isEmpty() && arr[qmax.peekLast()] <= arr[R]) {
                qmax.pollLast();
            }
            // 加入当前的值
            qmax.addLast(R);
            // 接下来窗口要向右移动,判断左侧下标是不是和qmax中的最大值相同,如果相同,需要弹出
            if (qmax.peekFirst() == R - w) {
                qmax.pollFirst();
            }
            // 窗口形成以后,每次都往结果数组中添加值
            if (R - w + 1 >= 0) {
                ans[index++] = arr[qmax.peekFirst()];
            }
        }
        return ans;
    }

整体代码和测试如下:

java 复制代码
import java.util.LinkedList;

/**
 * 题目一:获取固定大小窗口内的最大值列表
 * 假设一个固定大小为W的窗口,依次划过arr,
 * 返回每一次滑出状况的最大值
 * 例如,arr = [4,3,5,4,3,3,6,7], w = 3
 * 返回:[5,5,5,4,6,7]
 */
public class Q1_SlidingWindowMaxArray {
    /**
     * 暴力方法实现的对数器
     * 遍历每一个窗口,找到最大值
     */
    public static int[] comparator(int[] arr, int w) {
        if (arr == null || arr.length < w || w < 1) {
            return new int[0];
        }
        int N = arr.length;
        // 结果数组
        int[] ans = new int[N - w + 1];
        // 用来填充ans数组
        int index = 0;
        // 初始化窗口下标
        int L = 0;
        int R = w - 1;
        // 遍历窗口
        while (R < N) {
            // 找到当前窗口的最大值
            int max = arr[L];
            for (int i = L + 1; i <= R; i++) {
                max = Math.max(max, arr[i]);
            }
            ans[index++] = max;
            // 窗口向右滑动
            L++;
            R++;
        }
        return ans;
    }

    /**
     * 窗口内最大值或最小值的更新结构的方法
     * 思路:
     * 我们首先定义一个双端队列qmax,里面用来存放当前窗口内的最大值的下标,其内部下标对应的数字是递减的。
     * 一开始,我们从0开始往右走,将满足条件的数字加入到qmax中,当窗口没有形成的时候,只是往里面加入数据,
     * 当窗口形成的时候,每次我们都要看一下qmax中的first值,此时这个值就是当前窗口内的最大值,将其加入到结果数组中。
     * 然后我们看一下当前要走过去的L的边界的值,此时的L就是R-w,看看是不是在qmax中的值,如果是,说明这个值要随着L的移动而弹出。
     * 因为下一个循环增加了R,同时相当于L也往右走了一步。
     * 这样一直循环下去,就拿到了所有窗口内的最大值。
     */
    public static int[] getMaxWindow(int[] arr, int w) {
        if (arr == null || arr.length < w || w < 1) {
            return new int[0];
        }
        int N = arr.length;
        // 结果数组
        int[] ans = new int[N - w + 1];
        // 用来填充ans数组
        int index = 0;
        // qmax 窗口最大值的更新结构,放数组的下标
        LinkedList<Integer> qmax = new LinkedList<Integer>();
        // 窗口的有边界依次加入数值
        for (int R = 0; R < N; R++) {
            // 保持双端队列中数字的递减性,即将右侧代表的值小于当前值的下标弹出
            // 这里要包含等于的情况,因为如果加入等于的情况,就相当于把队列中的下标更新了,在后面弹出的时候,前面的是弹出不了的。
            while (!qmax.isEmpty() && arr[qmax.peekLast()] <= arr[R]) {
                qmax.pollLast();
            }
            // 加入当前的值
            qmax.addLast(R);
            // 接下来窗口要向右移动,判断左侧下标是不是和qmax中的最大值相同,如果相同,需要弹出
            if (qmax.peekFirst() == R - w) {
                qmax.pollFirst();
            }
            // 窗口形成以后,每次都往结果数组中添加值
            if (R - w + 1 >= 0) {
                ans[index++] = arr[qmax.peekFirst()];
            }
        }
        return ans;
    }


    public static void main(String[] args) {
        int testTime = 1000000;
        int maxSize = 100;
        int maxValue = 100;
        System.out.println("测试开始");
        for (int i = 0; i < testTime; i++) {
            int[] arr = generateRandomArray(maxSize, maxValue);
            int w = (int) (Math.random() * (arr.length + 1));
            int[] ans1 = getMaxWindow(arr, w);
            int[] ans2 = comparator(arr, w);
            if (!isEqual(ans1, ans2)) {
                System.out.println("错误!");
                break;
            }
        }
        System.out.println("测试结束");
    }

    // for test
    public static int[] generateRandomArray(int maxSize, int maxValue) {
        int[] arr = new int[(int) ((maxSize + 1) * Math.random())];
        for (int i = 0; i < arr.length; i++) {
            arr[i] = (int) (Math.random() * (maxValue + 1));
        }
        return arr;
    }

    // for test
    public static boolean isEqual(int[] arr1, int[] arr2) {
        if ((arr1 == null && arr2 != null) || (arr1 != null && arr2 == null)) {
            return false;
        }
        if (arr1 == null && arr2 == null) {
            return true;
        }
        if (arr1.length != arr2.length) {
            return false;
        }
        for (int i = 0; i < arr1.length; i++) {
            if (arr1[i] != arr2[i]) {
                return false;
            }
        }
        return true;
    }
}

2.2、题目二:满足条件子数组的数量

  • 题目二:满足条件子数组的数量
  • 给定一个整型数组arr,和一个整数num
  • 某个arr中的子数组sub,如果想达标,必须满足:sub中最大值 -- sub中最小值 <= num,
  • 返回arr中达标子数组的数量。
  • 注意:一般情况下,子数组是连续的,子序列是不连续的,所以本题的子数组是连续的数组。
2.2.1、暴力的对数器方法
  • 暴力的对数器方法
  • 思路:
    • 遍历每个子数组,跳出达标的,并统计数量,
    • 所有的子数组指的是0n-1的所有子数组。1n-1的所有子数组。2n-1的所有子数组。。。n-1n-1的所有子数组。
    • 所以两个for循环就能遍历所有的子数组。外层for循环控制子数组的左边界,内层for循环控制子数组的右边界。
java 复制代码
    /**
     * 暴力的对数器方法
     * 思路:
     * 遍历每个子数组,跳出达标的,并统计数量,
     * 所有的子数组指的是0~n-1的所有子数组。1~n-1的所有子数组。2~n-1的所有子数组。。。n-1~n-1的所有子数组。
     * 所以两个for循环就能遍历所有的子数组。外层for循环控制子数组的左边界,内层for循环控制子数组的右边界。
     */
    public static int comparator(int[] arr, int num) {
        if (arr == null || arr.length <= 0 || num < 0) {
            return 0;
        }
        int N = arr.length;
        int count = 0;
        // 循环所有子数组
        for (int L = 0; L < N; L++) {
            for (int R = L; R < N; R++) {
                // 获取到L~R的子数组的最大值和最小值
                int max = arr[L];
                int min = arr[L];
                for (int i = L + 1; i <= R; i++) {
                    max = Math.max(max, arr[i]);
                    min = Math.min(min, arr[i]);
                }
                if (max - min <= num) {
                    count++;
                }
            }
        }
        return count;
    }
2.2.2、窗口内最大值或最小值的更新结构的方法
  • 窗口内最大值或最小值的更新结构的方法

  • 思路:

  • 两个结论:

    • 结论1、如果一个子数组L~R达标,那么其内部的子数组都达标。
    • 因为在L~R这个子数组中的任意子数组,最大值一定比整个范围的最大值小,最小值一定比整个范围的最小值大,所以最大值和最小值的差都不会超过num。
    • 结论2、如果一个子数组L~R不达标,那么L往左,R往右,行程的子数组也不达标。
    • 因为在拓展的子数组中,最大值不会小于目前的最大值,最小值不会大于目前的最小值,目前的已经不达标,所以拓展的差值只会更大,不会更小,所以也不达标。
  • 有了上面这两个结论,我们就可以用以下的方法:

    • 我们准备两个双端队列maxWindow和minWindow,分别记录当前窗口的最大值和最小值的下标。
    • maxWindow中下标的对应值是递减的,最前面一个数就是当前窗口的最大值,minWindow中下标的对应值是递增的,最前面一个数就是当前窗口的最小值。
    • 然后我们让L从0开始,R从0开始,R一直往右走,直到窗口不达标,这样我们就可以算出0~R-1的所有达标子数组的数量。
    • 然后我们让L往右走一步,R继续往右走,直到窗口不达标,这样我们就可以算出1~R-1的所有达标子数组的数量。
    • 以此类推,我们就可以算出所有的达标子数组的数量。
    • 这种算法,L和R不是嵌套循环,只是两个错开,只要走到最后就行,所以时间复杂度为O(n)
java 复制代码
    /**
     * 窗口内最大值或最小值的更新结构的方法
     * 思路:
     * 结论1、如果一个子数组L~R达标,那么其内部的子数组都达标。
     * 因为在L~R这个子数组中的任意子数组,最大值一定比整个范围的最大值小,最小值一定比整个范围的最小值大,所以最大值和最小值的差都不会超过num。
     * 结论2、如果一个子数组L~R不达标,那么L往左,R往右,行程的子数组也不达标。
     * 因为在拓展的子数组中,最大值不会小于目前的最大值,最小值不会大于目前的最小值,目前的已经不达标,所以拓展的差值只会更大,不会更小,所以也不达标。
     * <br>
     * 有了上面这两个结论,我们就可以用以下的方法:
     * 我们准备两个双端队列maxWindow和minWindow,分别记录当前窗口的最大值和最小值的下标。
     * maxWindow中下标的对应值是递减的,最前面一个数就是当前窗口的最大值,minWindow中下标的对应值是递增的,最前面一个数就是当前窗口的最小值。
     * 然后我们让L从0开始,R从0开始,R一直往右走,直到窗口不达标,这样我们就可以算出0~R-1的所有达标子数组的数量。
     * 然后我们让L往右走一步,R继续往右走,直到窗口不达标,这样我们就可以算出1~R-1的所有达标子数组的数量。
     * 以此类推,我们就可以算出所有的达标子数组的数量。
     * 这种算法,L和R不是嵌套循环,只是两个错开,只要走到最后就行,所以时间复杂度为O(n)
     */
    public static int countSubArray(int[] arr, int num) {
        if (arr == null || arr.length == 0 || num < 0) {
            return 0;
        }
        int N = arr.length;
        int count = 0;
        // 窗口内最大值的数组,内部放的是下标,对应的值是递减的
        LinkedList<Integer> maxWindow = new LinkedList<>();
        // 窗口内最小值的数组,内部放的是下标,对应的值是递增的
        LinkedList<Integer> minWindow = new LinkedList<>();
        int R = 0;
        // L从0开始,往右扩
        for (int L = 0; L < N; L++) {
            // 对于每一个L,R往右走,直到窗口不达标,因为不达标以后,后面的R都不达标了
            while (R < N) {
                // 更新maxWindow
                while (!maxWindow.isEmpty() && arr[maxWindow.peekLast()] <= arr[R]) {
                    maxWindow.pollLast();
                }
                maxWindow.addLast(R);
                // 更新minWindow
                while (!minWindow.isEmpty() && arr[minWindow.peekLast()] >= arr[R]) {
                    minWindow.pollLast();
                }
                minWindow.addLast(R);
                // 判断是否满足条件,不满足条件退出while,满足继续往下执行
                if (arr[maxWindow.peekFirst()] - arr[minWindow.peekFirst()] > num) {
                    // 不达标,跳出while循环
                    break;
                } else {
                    R++;
                }
            }
            // 执行到这里,代表找到了不满足的R,count累加上两者之间走的个数
            count += R - L;
            // 接下来L要往左走一步,所以两个窗口都要尝试移除L的下标
            if (maxWindow.peekFirst() == L) {
                maxWindow.pollFirst();
            }
            if (minWindow.peekFirst() == L) {
                minWindow.pollFirst();
            }

        }
        return count;
    }

整体代码和测试如下:

java 复制代码
import java.util.LinkedList;

/**
 * 题目二:满足条件子数组的数量
 * 给定一个整型数组arr,和一个整数num
 * 某个arr中的子数组sub,如果想达标,必须满足:sub中最大值 -- sub中最小值 <= num,
 * 返回arr中达标子数组的数量。
 * 注意:一般情况下,子数组是连续的,子序列是不连续的,所以本题的子数组是连续的数组。
 */
public class Q2_AllLessNumSubArray {

    /**
     * 暴力的对数器方法
     * 思路:
     * 遍历每个子数组,跳出达标的,并统计数量,
     * 所有的子数组指的是0~n-1的所有子数组。1~n-1的所有子数组。2~n-1的所有子数组。。。n-1~n-1的所有子数组。
     * 所以两个for循环就能遍历所有的子数组。外层for循环控制子数组的左边界,内层for循环控制子数组的右边界。
     */
    public static int comparator(int[] arr, int num) {
        if (arr == null || arr.length <= 0 || num < 0) {
            return 0;
        }
        int N = arr.length;
        int count = 0;
        // 循环所有子数组
        for (int L = 0; L < N; L++) {
            for (int R = L; R < N; R++) {
                // 获取到L~R的子数组的最大值和最小值
                int max = arr[L];
                int min = arr[L];
                for (int i = L + 1; i <= R; i++) {
                    max = Math.max(max, arr[i]);
                    min = Math.min(min, arr[i]);
                }
                if (max - min <= num) {
                    count++;
                }
            }
        }
        return count;
    }

    /**
     * 窗口内最大值或最小值的更新结构的方法
     * 思路:
     * 结论1、如果一个子数组L~R达标,那么其内部的子数组都达标。
     * 因为在L~R这个子数组中的任意子数组,最大值一定比整个范围的最大值小,最小值一定比整个范围的最小值大,所以最大值和最小值的差都不会超过num。
     * 结论2、如果一个子数组L~R不达标,那么L往左,R往右,行程的子数组也不达标。
     * 因为在拓展的子数组中,最大值不会小于目前的最大值,最小值不会大于目前的最小值,目前的已经不达标,所以拓展的差值只会更大,不会更小,所以也不达标。
     * <br>
     * 有了上面这两个结论,我们就可以用以下的方法:
     * 我们准备两个双端队列maxWindow和minWindow,分别记录当前窗口的最大值和最小值的下标。
     * maxWindow中下标的对应值是递减的,最前面一个数就是当前窗口的最大值,minWindow中下标的对应值是递增的,最前面一个数就是当前窗口的最小值。
     * 然后我们让L从0开始,R从0开始,R一直往右走,直到窗口不达标,这样我们就可以算出0~R-1的所有达标子数组的数量。
     * 然后我们让L往右走一步,R继续往右走,直到窗口不达标,这样我们就可以算出1~R-1的所有达标子数组的数量。
     * 以此类推,我们就可以算出所有的达标子数组的数量。
     * 这种算法,L和R不是嵌套循环,只是两个错开,只要走到最后就行,所以时间复杂度为O(n)
     */
    public static int countSubArray(int[] arr, int num) {
        if (arr == null || arr.length == 0 || num < 0) {
            return 0;
        }
        int N = arr.length;
        int count = 0;
        // 窗口内最大值的数组,内部放的是下标,对应的值是递减的
        LinkedList<Integer> maxWindow = new LinkedList<>();
        // 窗口内最小值的数组,内部放的是下标,对应的值是递增的
        LinkedList<Integer> minWindow = new LinkedList<>();
        int R = 0;
        // L从0开始,往右扩
        for (int L = 0; L < N; L++) {
            // 对于每一个L,R往右走,直到窗口不达标,因为不达标以后,后面的R都不达标了
            while (R < N) {
                // 更新maxWindow
                while (!maxWindow.isEmpty() && arr[maxWindow.peekLast()] <= arr[R]) {
                    maxWindow.pollLast();
                }
                maxWindow.addLast(R);
                // 更新minWindow
                while (!minWindow.isEmpty() && arr[minWindow.peekLast()] >= arr[R]) {
                    minWindow.pollLast();
                }
                minWindow.addLast(R);
                // 判断是否满足条件,不满足条件退出while,满足继续往下执行
                if (arr[maxWindow.peekFirst()] - arr[minWindow.peekFirst()] > num) {
                    // 不达标,跳出while循环
                    break;
                } else {
                    R++;
                }
            }
            // 执行到这里,代表找到了不满足的R,count累加上两者之间走的个数
            count += R - L;
            // 接下来L要往左走一步,所以两个窗口都要尝试移除L的下标
            if (maxWindow.peekFirst() == L) {
                maxWindow.pollFirst();
            }
            if (minWindow.peekFirst() == L) {
                minWindow.pollFirst();
            }

        }
        return count;
    }

    public static void main(String[] args) {
        int maxLen = 100;
        int maxValue = 200;
        int testTime = 100000;
        System.out.println("测试开始");
        for (int i = 0; i < testTime; i++) {
            int[] arr = generateRandomArray(maxLen, maxValue);
            int sum = (int) (Math.random() * (maxValue + 1));
            int ans1 = comparator(arr, sum);
            int ans2 = countSubArray(arr, sum);
            if (ans1 != ans2) {
                System.out.println("错误!");
                printArray(arr);
                System.out.println(sum);
                System.out.println(ans1);
                System.out.println(ans2);
                break;
            }
        }
        System.out.println("测试结束");

    }

    // for test
    public static int[] generateRandomArray(int maxLen, int maxValue) {
        int len = (int) (Math.random() * (maxLen + 1));
        int[] arr = new int[len];
        for (int i = 0; i < len; i++) {
            arr[i] = (int) (Math.random() * (maxValue + 1)) - (int) (Math.random() * (maxValue + 1));
        }
        return arr;
    }

    // for test
    public static void printArray(int[] arr) {
        if (arr != null) {
            for (int i = 0; i < arr.length; i++) {
                System.out.print(arr[i] + " ");
            }
            System.out.println();
        }
    }

}

2.3、题目三:加油站的良好出发点问题

  • 题目三:加油站的良好出发点问题
  • 在一条环路上有 n 个加油站,其中第 i 个加油站有汽油 gas[i] 升。
  • 你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。
  • 给定两个整数数组 gas 和 cost ,如果你可以按顺序绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1 。如果存在解,则保证它是唯一的。
  • 测试链接:https://leetcode.cn/problems/gas-station
2.3.1、计算出全量的方法
  • 计算出全量的方法
    • 我们可以先计算出从每一个加油站出发,能否绕环路一周的数组,
    • 然后遍历这个数组,找到第一个为true的位置,就是我们要找的答案。
java 复制代码
    /**
     * 计算出全量的方法
     * 我们可以先计算出从每一个加油站出发,能否绕环路一周的数组,
     * 然后遍历这个数组,找到第一个为true的位置,就是我们要找的答案。
     */
    public static int canCompleteCircuit(int[] gas, int[] cost) {
        boolean[] good = goodArray(gas, cost);
        for (int i = 0; i < gas.length; i++) {
            if (good[i]) {
                return i;
            }
        }
        return -1;
    }

    /**
     * 返回从每个加油站出发,能否绕环路行驶一周的数组,
     * 每个元素的值为true表示可以,为false表示不可以。
     * 思路:
     * 1)首先,我们新建一个数组arr,称其为纯能数组,其大小是原来数组的两倍,这样,就可以将从最后一个到倒数第二个加油站的判断包含到一个数组里面,省去了下标越界的问题。
     * 此时如果原来的数组长度是N,那么arr的长度M就是2N,此时0~N-1就是从0号加油站出发,到N-1号加油站的纯能数组,... N~2N-1就是从N号加油站出发,转一圈到N-1号加油站的纯能数组。
     * 2)然后,我们用gas对应位置的值减去cost对应位置的值,得到的数组就是纯能数组。其意义就是从i位置到i+1位置用本加油站的油,能多出来的油量。
     * 此时如果该位置是负数,说明只用本加油站的油,从i位置是无法到达i+1位置的,所以从i位置出发,是无法绕环路行驶一周的。
     * 3)然后,我们在arr中从1位置出发,每一次累加一次前一个位置i-1的和,此时arr的意义就是从0位置出发,到达每一个位置的油量,如果油量有负数,代表从0位置出发,是无法到达i位置的。
     * 4)此时arr[0,N-1]就直接代表了从0位置出发,能否绕一圈的值,如果都是整数,代表可以,如果有负数,代表不行。
     * 但是从1到其他位置的,因为arr累加了所有前面的值,不能直接代表,此时要算从1到N能否走一圈,只需要将1到N的值都减去arr[0],得到的值如果是都是正数,就是可以,有负数就是不行。
     * 依次类推,从N到2N-1的值都减去arr[N-1],就代表从N转一圈到N-1加油站的可能性,如果都是正数,就是可以,有负数就是不行。
     * <br>
     * 这个时候,在纯能数组arr上用一个长度为N的窗口划过,就可以判断出从每个位置出发,能否绕一圈。具体思路如下:
     * 1)首先,我们新建一个窗口内最小值的双端队列minWindows,里面存放的是加入数据从小到大排列的arr数组的下标,每次从头取到的值,代表了此时窗口内的最小值下标。
     * 2)然后,我们将arr数组从0到N-1的数据加入到minWindows中,此时minWindows中第一个位置对应的值,就是整个窗口内的最小值,最小值如果是正数,就代表从0位置出发,可以绕一圈,是负数代表不行。
     * 3)接着,我们从1开始,每次按照规则加入一个arr中的数,然后然后判断minWindows中开头的数减去arr前一个位置的数的大小,如果是正数,就代表从1位置出发,可以绕一圈,负数代表不行。
     * 依次类推,就能判断出所有的位置,能否绕一圈。
     */
    public static boolean[] goodArray(int[] gas, int[] cost) {
        int N = gas.length;
        int M = N << 1;
        // 创建纯能数组
        int[] arr = new int[M];
        // 先填充arr为gas[i]-cost[i]的值,代表了从每个加油站i出发,能否走到下一个i+1
        for (int i = 0; i < N; i++) {
            arr[i] = gas[i] - cost[i];
            // N到2N-1的位置是和0到N-1的一样的
            arr[i + N] = arr[i];
        }
        // 从1出发,每个位置加上前一个的值,代表了加上前一个加油站的油量,能否走到i+1的位置
        for (int i = 1; i < M; i++) {
            arr[i] += arr[i - 1];
        }
        // 新建一个最小值的双端队列,将最小值的下标加入队列中,此时队列中是从小到大的数的下标
        LinkedList<Integer> minWindows = new LinkedList<>();
        for (int i = 0; i < N; i++) {
            while (!minWindows.isEmpty() && arr[minWindows.peekLast()] >= arr[i]) {
                minWindows.pollLast();
            }
            minWindows.addLast(i);
        }
        // 结果数组
        boolean[] ans = new boolean[N];
        // 判断从0位置开始的位置,加入数据是从N出发
        int preIndex = 0;
        int preValue = 0;
        for (int i = N; i < M; i++) {
            // 先判断上一个位置是不是可行
            ans[preIndex] = arr[minWindows.peekFirst()] - preValue >= 0 ? true : false;
            // 如果最小值是前一个下标,移除
            if (minWindows.peekFirst() == preIndex) {
                minWindows.pollFirst();
            }
            // 加入当前的值
            while (!minWindows.isEmpty() && arr[minWindows.peekLast()] >= arr[i]) {
                minWindows.pollLast();
            }
            minWindows.addLast(i);
            // 更新前一个值和偏移量
            preValue = arr[preIndex++];
        }
        return ans;
    }
2.3.2、遇到一个满足就退出的方法
  • 遇到一个满足就退出的方法:
    • 直接判断,找到一个index后直接返回
    • 和前面的判断逻辑一样,只是直接返回一个index
  • 提交时方法名改为:canCompleteCircuit
java 复制代码
    /**
     * 遇到一个满足就退出的方法:
     * 直接判断,找到一个index后直接返回
     * 和前面的判断逻辑一样,只是直接返回一个index
     * 提交时方法名改为:canCompleteCircuit
     */
    public static int canCompleteCircuit2(int[] gas, int[] cost) {
        int N = gas.length;
        int M = N << 1;
        // 创建纯能数组
        int[] arr = new int[M];
        // 先填充arr为gas[i]-cost[i]的值,代表了从每个加油站i出发,能否走到下一个i+1
        for (int i = 0; i < N; i++) {
            arr[i] = gas[i] - cost[i];
            // N到2N-1的位置是和0到N-1的一样的
            arr[i + N] = arr[i];
        }
        // 从1出发,每个位置加上前一个的值,代表了加上前一个加油站的油量,能否走到i+1的位置
        for (int i = 1; i < M; i++) {
            arr[i] += arr[i - 1];
        }
        // 新建一个最小值的双端队列,将最小值的下标加入队列中,此时队列中是从小到大的数的下标
        LinkedList<Integer> minWindows = new LinkedList<>();
        for (int i = 0; i < N; i++) {
            while (!minWindows.isEmpty() && arr[minWindows.peekLast()] >= arr[i]) {
                minWindows.pollLast();
            }
            minWindows.addLast(i);
        }
        // 结果数组
        boolean[] ans = new boolean[N];
        // 判断从0位置开始的位置,加入数据是从N出发
        int preIndex = 0;
        int preValue = 0;
        for (int i = N; i < M; i++) {
            // 先判断上一个位置是不是可行
            if (arr[minWindows.peekFirst()] - preValue >= 0) {
                return preIndex;
            }
            // 如果最小值是前一个下标,移除
            if (minWindows.peekFirst() == preIndex) {
                minWindows.pollFirst();
            }
            // 加入当前的值
            while (!minWindows.isEmpty() && arr[minWindows.peekLast()] >= arr[i]) {
                minWindows.pollLast();
            }
            minWindows.addLast(i);
            // 更新前一个值和偏移量
            preValue = arr[preIndex++];
        }
        return -1;
    }

整体代码如下:

java 复制代码
import java.util.LinkedList;

/**
 * 题目三:加油站的良好出发点问题
 * 在一条环路上有 n 个加油站,其中第 i 个加油站有汽油 gas[i] 升。
 * 你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。
 * 给定两个整数数组 gas 和 cost ,如果你可以按顺序绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1 。如果存在解,则保证它是唯一的。
 * 测试链接:https://leetcode.cn/problems/gas-station
 */
public class Q3_GasStation {
    /**
     * 计算出全量的方法
     * 我们可以先计算出从每一个加油站出发,能否绕环路一周的数组,
     * 然后遍历这个数组,找到第一个为true的位置,就是我们要找的答案。
     */
    public static int canCompleteCircuit(int[] gas, int[] cost) {
        boolean[] good = goodArray(gas, cost);
        for (int i = 0; i < gas.length; i++) {
            if (good[i]) {
                return i;
            }
        }
        return -1;
    }

    /**
     * 返回从每个加油站出发,能否绕环路行驶一周的数组,
     * 每个元素的值为true表示可以,为false表示不可以。
     * 思路:
     * 1)首先,我们新建一个数组arr,称其为纯能数组,其大小是原来数组的两倍,这样,就可以将从最后一个到倒数第二个加油站的判断包含到一个数组里面,省去了下标越界的问题。
     * 此时如果原来的数组长度是N,那么arr的长度M就是2N,此时0~N-1就是从0号加油站出发,到N-1号加油站的纯能数组,... N~2N-1就是从N号加油站出发,转一圈到N-1号加油站的纯能数组。
     * 2)然后,我们用gas对应位置的值减去cost对应位置的值,得到的数组就是纯能数组。其意义就是从i位置到i+1位置用本加油站的油,能多出来的油量。
     * 此时如果该位置是负数,说明只用本加油站的油,从i位置是无法到达i+1位置的,所以从i位置出发,是无法绕环路行驶一周的。
     * 3)然后,我们在arr中从1位置出发,每一次累加一次前一个位置i-1的和,此时arr的意义就是从0位置出发,到达每一个位置的油量,如果油量有负数,代表从0位置出发,是无法到达i位置的。
     * 4)此时arr[0,N-1]就直接代表了从0位置出发,能否绕一圈的值,如果都是整数,代表可以,如果有负数,代表不行。
     * 但是从1到其他位置的,因为arr累加了所有前面的值,不能直接代表,此时要算从1到N能否走一圈,只需要将1到N的值都减去arr[0],得到的值如果是都是正数,就是可以,有负数就是不行。
     * 依次类推,从N到2N-1的值都减去arr[N-1],就代表从N转一圈到N-1加油站的可能性,如果都是正数,就是可以,有负数就是不行。
     * <br>
     * 这个时候,在纯能数组arr上用一个长度为N的窗口划过,就可以判断出从每个位置出发,能否绕一圈。具体思路如下:
     * 1)首先,我们新建一个窗口内最小值的双端队列minWindows,里面存放的是加入数据从小到大排列的arr数组的下标,每次从头取到的值,代表了此时窗口内的最小值下标。
     * 2)然后,我们将arr数组从0到N-1的数据加入到minWindows中,此时minWindows中第一个位置对应的值,就是整个窗口内的最小值,最小值如果是正数,就代表从0位置出发,可以绕一圈,是负数代表不行。
     * 3)接着,我们从1开始,每次按照规则加入一个arr中的数,然后然后判断minWindows中开头的数减去arr前一个位置的数的大小,如果是正数,就代表从1位置出发,可以绕一圈,负数代表不行。
     * 依次类推,就能判断出所有的位置,能否绕一圈。
     */
    public static boolean[] goodArray(int[] gas, int[] cost) {
        int N = gas.length;
        int M = N << 1;
        // 创建纯能数组
        int[] arr = new int[M];
        // 先填充arr为gas[i]-cost[i]的值,代表了从每个加油站i出发,能否走到下一个i+1
        for (int i = 0; i < N; i++) {
            arr[i] = gas[i] - cost[i];
            // N到2N-1的位置是和0到N-1的一样的
            arr[i + N] = arr[i];
        }
        // 从1出发,每个位置加上前一个的值,代表了加上前一个加油站的油量,能否走到i+1的位置
        for (int i = 1; i < M; i++) {
            arr[i] += arr[i - 1];
        }
        // 新建一个最小值的双端队列,将最小值的下标加入队列中,此时队列中是从小到大的数的下标
        LinkedList<Integer> minWindows = new LinkedList<>();
        for (int i = 0; i < N; i++) {
            while (!minWindows.isEmpty() && arr[minWindows.peekLast()] >= arr[i]) {
                minWindows.pollLast();
            }
            minWindows.addLast(i);
        }
        // 结果数组
        boolean[] ans = new boolean[N];
        // 判断从0位置开始的位置,加入数据是从N出发
        int preIndex = 0;
        int preValue = 0;
        for (int i = N; i < M; i++) {
            // 先判断上一个位置是不是可行
            ans[preIndex] = arr[minWindows.peekFirst()] - preValue >= 0 ? true : false;
            // 如果最小值是前一个下标,移除
            if (minWindows.peekFirst() == preIndex) {
                minWindows.pollFirst();
            }
            // 加入当前的值
            while (!minWindows.isEmpty() && arr[minWindows.peekLast()] >= arr[i]) {
                minWindows.pollLast();
            }
            minWindows.addLast(i);
            // 更新前一个值和偏移量
            preValue = arr[preIndex++];
        }
        return ans;
    }

    /**
     * 遇到一个满足就退出的方法:
     * 直接判断,找到一个index后直接返回
     * 和前面的判断逻辑一样,只是直接返回一个index
     * 提交时方法名改为:canCompleteCircuit
     */
    public static int canCompleteCircuit2(int[] gas, int[] cost) {
        int N = gas.length;
        int M = N << 1;
        // 创建纯能数组
        int[] arr = new int[M];
        // 先填充arr为gas[i]-cost[i]的值,代表了从每个加油站i出发,能否走到下一个i+1
        for (int i = 0; i < N; i++) {
            arr[i] = gas[i] - cost[i];
            // N到2N-1的位置是和0到N-1的一样的
            arr[i + N] = arr[i];
        }
        // 从1出发,每个位置加上前一个的值,代表了加上前一个加油站的油量,能否走到i+1的位置
        for (int i = 1; i < M; i++) {
            arr[i] += arr[i - 1];
        }
        // 新建一个最小值的双端队列,将最小值的下标加入队列中,此时队列中是从小到大的数的下标
        LinkedList<Integer> minWindows = new LinkedList<>();
        for (int i = 0; i < N; i++) {
            while (!minWindows.isEmpty() && arr[minWindows.peekLast()] >= arr[i]) {
                minWindows.pollLast();
            }
            minWindows.addLast(i);
        }
        // 结果数组
        boolean[] ans = new boolean[N];
        // 判断从0位置开始的位置,加入数据是从N出发
        int preIndex = 0;
        int preValue = 0;
        for (int i = N; i < M; i++) {
            // 先判断上一个位置是不是可行
            if (arr[minWindows.peekFirst()] - preValue >= 0) {
                return preIndex;
            }
            // 如果最小值是前一个下标,移除
            if (minWindows.peekFirst() == preIndex) {
                minWindows.pollFirst();
            }
            // 加入当前的值
            while (!minWindows.isEmpty() && arr[minWindows.peekLast()] >= arr[i]) {
                minWindows.pollLast();
            }
            minWindows.addLast(i);
            // 更新前一个值和偏移量
            preValue = arr[preIndex++];
        }
        return -1;
    }
}

2.4、题目四:动态规划中利用窗口内最大值或最小值更新结构做优化(难)

  • 题目四:动态规划中利用窗口内最大值或最小值更新结构做优化(难)
  • arr是货币数组,其中的值都是正数。再给定一个正数aim。
  • 每个值都认为是一张货币,
  • 返回组成aim的最少货币数
  • 注意:因为是求最少货币数,所以每一张货币认为是相同或者不同就不重要了
2.4.1、暴力递归尝试方法
  • 暴力递归尝试方法
  • 思路:
    • 从左往右的尝试模型,每个位置都可以判断要或者不要,然后求出最小的张数
java 复制代码
    /**
     * 暴力递归尝试方法
     * 思路:
     * 从左往右的尝试模型,每个位置都可以判断要或者不要,然后求出最小的张数
     */
    public static int minCoins(int[] arr, int aim) {
        return process(arr, 0, aim);
    }

    /**
     * 递归函数,返回从index位置开始,组成rest的最少货币数
     * 因为有可能组不出来,要判断最小,所以用Integer.MAX_VALUE表示组不出来
     */
    public static int process(int[] arr, int index, int rest) {
        // rest 不合法,代表组不出来,返回 Integer.MAX_VALUE
        if (rest < 0) {
            return Integer.MAX_VALUE;
        }
        // base case : 超过最后一个位置,刚好组成rest,
        if (index == arr.length) {
            return rest == 0 ? 0 : Integer.MAX_VALUE;
        }
        // 不要index位置
        int p1 = process(arr, index + 1, rest);
        // 要index位置
        int p2 = process(arr, index + 1, rest - arr[index]);
        if (p2 != Integer.MAX_VALUE) {
            p2++;
        }
        return Math.min(p1, p2);
    }
2.4.2、暴力递归尝试改为动态规划
  • 暴力递归尝试改为动态规划
  • 思路:
    • 1、根据递归函数,有两个可变参数index[0,N]和rest[0,aim],所以dp数组为dp[N+1][aim+1],
    • 2、根据base case,dp[N][0] = 0,N行其他位置为Integer.MAX_VALUE
    • 3、根据递归函数,dp[index][rest] = min(dp[index+1][rest], dp[index+1][rest-arr[index]]),从下往上,从左往右填表
    • 4、根据递归函数的调用,dp[0][aim]就是最终结果
java 复制代码
    /**
     * 暴力递归尝试改为动态规划
     * 思路:
     * 1、根据递归函数,有两个可变参数index[0,N]和rest[0,aim],所以dp数组为dp[N+1][aim+1],
     * 2、根据base case,dp[N][0] = 0,N行其他位置为Integer.MAX_VALUE
     * 3、根据递归函数,dp[index][rest] = min(dp[index+1][rest], dp[index+1][rest-arr[index]]),从下往上,从左往右填表
     * 4、根据递归函数的调用,dp[0][aim]就是最终结果
     */
    public static int dp1(int[] arr, int aim) {
        if (aim == 0) {
            return 0;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 0;
        for (int j = 1; j <= aim; j++) {
            dp[N][j] = Integer.MAX_VALUE;
        }
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                int p1 = dp[index + 1][rest];
                int p2 = rest - arr[index] >= 0 ? dp[index + 1][rest - arr[index]] : Integer.MAX_VALUE;
                if (p2 != Integer.MAX_VALUE) {
                    p2++;
                }
                dp[index][rest] = Math.min(p1, p2);
            }
        }
        return dp[0][aim];
    }
2.4.3、用张数限制的方法来求解
  • 用张数限制的方法来求解
  • 思路:
    • 将货币数组进行分组,统计每一张的货币张数,就可以求出组成aim的最少货币数,
    • 这里和【算法笔记】从暴力递归到动态规划(二)
    • 中的题目十三有所不同,虽然都是在限制张数的情况下,但是题目十三求的是组成aim的方法数量,这里求的是最少货币数,
    • 所以不能用那种基于严格表结构依赖的方法来优化。
java 复制代码
    public static class Info {
        public int[] coins;
        public int[] counts;

        public Info(int[] coins, int[] counts) {
            this.coins = coins;
            this.counts = counts;
        }
    }

    public static Info getInfo(int[] arr) {
        HashMap<Integer, Integer> countsMap = new HashMap<>();
        for (int value : arr) {
            if (!countsMap.containsKey(value)) {
                countsMap.put(value, 1);
            } else {
                countsMap.put(value, countsMap.get(value) + 1);
            }
        }
        int N = countsMap.size();
        int[] coins = new int[N];
        int[] counts = new int[N];
        int index = 0;
        for (Map.Entry<Integer, Integer> entry : countsMap.entrySet()) {
            coins[index] = entry.getKey();
            counts[index++] = entry.getValue();
        }
        return new Info(coins, counts);
    }


    /**
     * 用张数限制的方法来求解
     * 思路:
     * 将货币数组进行分组,统计每一张的货币张数,就可以求出组成aim的最少货币数,
     * 这里和[【算法笔记】从暴力递归到动态规划(二)](https://blog.csdn.net/u012559967/article/details/154991975)
     * 中的题目十三有所不同,虽然都是在限制张数的情况下,但是题目十三求的是组成aim的方法数量,这里求的是最少货币数,
     * 所以不能用那种基于严格表结构依赖的方法来优化。
     */
    public static int dp2(int[] arr, int aim) {
        if (aim == 0) {
            return 0;
        }
        // 得到info时间复杂度O(arr长度)
        Info info = getInfo(arr);
        int[] coins = info.coins;
        int[] counts = info.counts;
        int N = coins.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 0;
        for (int j = 1; j <= aim; j++) {
            dp[N][j] = Integer.MAX_VALUE;
        }
        // 这三层for循环,时间复杂度为O(货币种数 * aim * 每种货币的平均张数)
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                // 先设置成没有使用的情况
                dp[index][rest] = dp[index + 1][rest];
                // 循环可以使用的张数,更新dp[index][rest]
                for (int num = 1; num * coins[index] <= aim && num <= counts[index]; num++) {
                    if (rest - num * coins[index] >= 0 && dp[index + 1][rest - num * coins[index]] != Integer.MAX_VALUE) {
                        dp[index][rest] = Math.min(dp[index][rest], num + dp[index + 1][rest - num * coins[index]]);
                    }
                }
            }
        }
        return dp[0][aim];
    }
2.4.4、用窗口内最小值的更新结构来优化张数限制的动态规划
  • 用窗口内最小值的更新结构来优化张数限制的动态规划
  • 思路:
    • 在上面的张数限制的动态规划方法中,填表的过程中要循环每一个货币的张数,如何优化这个过程?
    • 【算法笔记】从暴力递归到动态规划(二)中的题目十三求货币数量的时候,我们可以用完全表结构依赖的方法来优化,因为那是累加和,我们只需要减去不需要的累加即可。
    • 但是在这里,我们求的是最小值,在减去上一个不需要的货币限制后,最小值是如何变化的,我们是不知道的,这个时候就需要用用窗口内最小值的更新结构来优化。
    • 要用到窗口,首先要形成窗口,我们可以根据当前的面值coins[i]进行分组,比如分成coins[i]组,如果当前位置为x,组号为mod,
    • 则mod,mod + x,mod + 2x,mod + 3x...,mod + (counts[i] - 1) * x为一组,
    • 这样根据分组去更新窗口内的最小值,就可以优化掉循环张数的过程。
    • 其次还有一个问题,因为求的是货币数,比如coins[i]这个货币有a张,则用到的货币就是从0到a,落到dp数组中,越是前面的数,在比较的时候就要加上更多的值。
    • 所以在更新窗口内的最小值的时候,要根据当前的行进行补偿,这样才能计算出合适的最小值。
    • 补偿值是当前位置cur与窗口内最小值pre的差值,除以当前面值coin,得到的商就是补偿值
  • 这种优化方式在数组中有大量的重复值的时候,比较明显
java 复制代码
    /**
     * 用窗口内最小值的更新结构来优化张数限制的动态规划
     * 思路:
     * 在上面的张数限制的动态规划方法中,填表的过程中要循环每一个货币的张数,如何优化这个过程?
     * 在[【算法笔记】从暴力递归到动态规划(二)](https://blog.csdn.net/u012559967/article/details/154991975)中的题目十三求货币数量的时候,我们可以用完全表结构依赖的方法来优化,因为那是累加和,我们只需要减去不需要的累加即可。
     * 但是在这里,我们求的是最小值,在减去上一个不需要的货币限制后,最小值是如何变化的,我们是不知道的,这个时候就需要用用窗口内最小值的更新结构来优化。
     * 要用到窗口,首先要形成窗口,我们可以根据当前的面值coins[i]进行分组,比如分成coins[i]组,如果当前位置为x,组号为mod,
     * 则mod,mod + x,mod + 2*x,mod + 3*x...,mod + (counts[i] - 1) * x为一组,
     * 这样根据分组去更新窗口内的最小值,就可以优化掉循环张数的过程。
     * 其次还有一个问题,因为求的是货币数,比如coins[i]这个货币有a张,则用到的货币就是从0到a,落到dp数组中,越是前面的数,在比较的时候就要加上更多的值。
     * 所以在更新窗口内的最小值的时候,要根据当前的行进行补偿,这样才能计算出合适的最小值。
     * 补偿值是当前位置cur与窗口内最小值pre的差值,除以当前面值coin,得到的商就是补偿值
     * 这种优化方式在数组中有大量的重复值的时候,比较明显
     */
    public static int dp3(int[] arr, int aim) {
        if (aim == 0) {
            return 0;
        }
        // 得到info时间复杂度O(arr长度)
        Info info = getInfo(arr);
        int[] coins = info.coins;
        int[] counts = info.counts;
        int N = coins.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 0;
        for (int j = 1; j <= aim; j++) {
            dp[N][j] = Integer.MAX_VALUE;
        }

        // 因为用了窗口内最小值的更新结构
        // 虽然是嵌套了很多循环,但是时间复杂度为O(货币种数 * aim)
        for (int i = N - 1; i >= 0; i--) {
            // 将面值进行分组,每个分组的面值都是coins[i]的倍数,按照组数mod进行循环
            for (int mod = 0; mod < Math.min(aim + 1, coins[i]); mod++) {
                // 到达mod组,当前面值 X
                // 内部的钱数为:mod,mod + x,mod + 2*x,mod + 3*x...,mod + (counts[i] - 1) * x
                // 这些位置可以形成窗口
                LinkedList<Integer> minWindows = new LinkedList<>();
                minWindows.add(mod);
                dp[i][mod] = dp[i + 1][mod];
                // 根据组循环能组成aim的值,更新窗口,并最终求出最小值
                for (int r = mod + coins[i]; r <= aim; r += coins[i]) {
                    while (!minWindows.isEmpty() && (dp[i + 1][minWindows.peekLast()] == Integer.MAX_VALUE || dp[i + 1][minWindows.peekLast()] + compensate(minWindows.peekLast(), r, coins[i]) >= dp[i + 1][r])) {
                        minWindows.pollLast();
                    }
                    minWindows.addLast(r);
                    // 窗口内的最小值过期了,需要弹出
                    int overdue = r - coins[i] * (counts[i] + 1);
                    if (minWindows.peekFirst() == overdue) {
                        minWindows.pollFirst();
                    }
                    if (dp[i + 1][minWindows.peekFirst()] == Integer.MAX_VALUE) {
                        dp[i][r] = Integer.MAX_VALUE;
                    } else {
                        dp[i][r] = dp[i + 1][minWindows.peekFirst()] + compensate(minWindows.peekFirst(), r, coins[i]);
                    }
                }
            }
        }
        return dp[0][aim];
    }

    /**
     * 计算补偿值,补偿值是当前位置cur与窗口内最小值pre的差值,除以当前面值coin,得到的商就是补偿值
     */
    public static int compensate(int pre, int cur, int coin) {
        return (cur - pre) / coin;
    }

整体代码和测试如下:

java 复制代码
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;

/**
 * 题目四:动态规划中利用窗口内最大值或最小值更新结构做优化(难)
 * arr是货币数组,其中的值都是正数。再给定一个正数aim。
 * 每个值都认为是一张货币,
 * 返回组成aim的最少货币数
 * 注意:因为是求最少货币数,所以每一张货币认为是相同或者不同就不重要了
 */
public class Q4_MinCoinsOnePaper {

    /**
     * 暴力递归尝试方法
     * 思路:
     * 从左往右的尝试模型,每个位置都可以判断要或者不要,然后求出最小的张数
     */
    public static int minCoins(int[] arr, int aim) {
        return process(arr, 0, aim);
    }

    /**
     * 递归函数,返回从index位置开始,组成rest的最少货币数
     * 因为有可能组不出来,要判断最小,所以用Integer.MAX_VALUE表示组不出来
     */
    public static int process(int[] arr, int index, int rest) {
        // rest 不合法,代表组不出来,返回 Integer.MAX_VALUE
        if (rest < 0) {
            return Integer.MAX_VALUE;
        }
        // base case : 超过最后一个位置,刚好组成rest,
        if (index == arr.length) {
            return rest == 0 ? 0 : Integer.MAX_VALUE;
        }
        // 不要index位置
        int p1 = process(arr, index + 1, rest);
        // 要index位置
        int p2 = process(arr, index + 1, rest - arr[index]);
        if (p2 != Integer.MAX_VALUE) {
            p2++;
        }
        return Math.min(p1, p2);
    }

    /**
     * 暴力递归尝试改为动态规划
     * 思路:
     * 1、根据递归函数,有两个可变参数index[0,N]和rest[0,aim],所以dp数组为dp[N+1][aim+1],
     * 2、根据base case,dp[N][0] = 0,N行其他位置为Integer.MAX_VALUE
     * 3、根据递归函数,dp[index][rest] = min(dp[index+1][rest], dp[index+1][rest-arr[index]]),从下往上,从左往右填表
     * 4、根据递归函数的调用,dp[0][aim]就是最终结果
     */
    public static int dp1(int[] arr, int aim) {
        if (aim == 0) {
            return 0;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 0;
        for (int j = 1; j <= aim; j++) {
            dp[N][j] = Integer.MAX_VALUE;
        }
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                int p1 = dp[index + 1][rest];
                int p2 = rest - arr[index] >= 0 ? dp[index + 1][rest - arr[index]] : Integer.MAX_VALUE;
                if (p2 != Integer.MAX_VALUE) {
                    p2++;
                }
                dp[index][rest] = Math.min(p1, p2);
            }
        }
        return dp[0][aim];
    }

    public static class Info {
        public int[] coins;
        public int[] counts;

        public Info(int[] coins, int[] counts) {
            this.coins = coins;
            this.counts = counts;
        }
    }

    public static Info getInfo(int[] arr) {
        HashMap<Integer, Integer> countsMap = new HashMap<>();
        for (int value : arr) {
            if (!countsMap.containsKey(value)) {
                countsMap.put(value, 1);
            } else {
                countsMap.put(value, countsMap.get(value) + 1);
            }
        }
        int N = countsMap.size();
        int[] coins = new int[N];
        int[] counts = new int[N];
        int index = 0;
        for (Map.Entry<Integer, Integer> entry : countsMap.entrySet()) {
            coins[index] = entry.getKey();
            counts[index++] = entry.getValue();
        }
        return new Info(coins, counts);
    }


    /**
     * 用张数限制的方法来求解
     * 思路:
     * 将货币数组进行分组,统计每一张的货币张数,就可以求出组成aim的最少货币数,
     * 这里和[【算法笔记】从暴力递归到动态规划(二)](https://blog.csdn.net/u012559967/article/details/154991975)
     * 中的题目十三有所不同,虽然都是在限制张数的情况下,但是题目十三求的是组成aim的方法数量,这里求的是最少货币数,
     * 所以不能用那种基于严格表结构依赖的方法来优化。
     */
    public static int dp2(int[] arr, int aim) {
        if (aim == 0) {
            return 0;
        }
        // 得到info时间复杂度O(arr长度)
        Info info = getInfo(arr);
        int[] coins = info.coins;
        int[] counts = info.counts;
        int N = coins.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 0;
        for (int j = 1; j <= aim; j++) {
            dp[N][j] = Integer.MAX_VALUE;
        }
        // 这三层for循环,时间复杂度为O(货币种数 * aim * 每种货币的平均张数)
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                // 先设置成没有使用的情况
                dp[index][rest] = dp[index + 1][rest];
                // 循环可以使用的张数,更新dp[index][rest]
                for (int num = 1; num * coins[index] <= aim && num <= counts[index]; num++) {
                    if (rest - num * coins[index] >= 0 && dp[index + 1][rest - num * coins[index]] != Integer.MAX_VALUE) {
                        dp[index][rest] = Math.min(dp[index][rest], num + dp[index + 1][rest - num * coins[index]]);
                    }
                }
            }
        }
        return dp[0][aim];
    }

    /**
     * 用窗口内最小值的更新结构来优化张数限制的动态规划
     * 思路:
     * 在上面的张数限制的动态规划方法中,填表的过程中要循环每一个货币的张数,如何优化这个过程?
     * 在[【算法笔记】从暴力递归到动态规划(二)](https://blog.csdn.net/u012559967/article/details/154991975)中的题目十三求货币数量的时候,我们可以用完全表结构依赖的方法来优化,因为那是累加和,我们只需要减去不需要的累加即可。
     * 但是在这里,我们求的是最小值,在减去上一个不需要的货币限制后,最小值是如何变化的,我们是不知道的,这个时候就需要用用窗口内最小值的更新结构来优化。
     * 要用到窗口,首先要形成窗口,我们可以根据当前的面值coins[i]进行分组,比如分成coins[i]组,如果当前位置为x,组号为mod,
     * 则mod,mod + x,mod + 2*x,mod + 3*x...,mod + (counts[i] - 1) * x为一组,
     * 这样根据分组去更新窗口内的最小值,就可以优化掉循环张数的过程。
     * 其次还有一个问题,因为求的是货币数,比如coins[i]这个货币有a张,则用到的货币就是从0到a,落到dp数组中,越是前面的数,在比较的时候就要加上更多的值。
     * 所以在更新窗口内的最小值的时候,要根据当前的行进行补偿,这样才能计算出合适的最小值。
     * 补偿值是当前位置cur与窗口内最小值pre的差值,除以当前面值coin,得到的商就是补偿值
     * 这种优化方式在数组中有大量的重复值的时候,比较明显
     */
    public static int dp3(int[] arr, int aim) {
        if (aim == 0) {
            return 0;
        }
        // 得到info时间复杂度O(arr长度)
        Info info = getInfo(arr);
        int[] coins = info.coins;
        int[] counts = info.counts;
        int N = coins.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 0;
        for (int j = 1; j <= aim; j++) {
            dp[N][j] = Integer.MAX_VALUE;
        }

        // 因为用了窗口内最小值的更新结构
        // 虽然是嵌套了很多循环,但是时间复杂度为O(货币种数 * aim)
        for (int i = N - 1; i >= 0; i--) {
            // 将面值进行分组,每个分组的面值都是coins[i]的倍数,按照组数mod进行循环
            for (int mod = 0; mod < Math.min(aim + 1, coins[i]); mod++) {
                // 到达mod组,当前面值 X
                // 内部的钱数为:mod,mod + x,mod + 2*x,mod + 3*x...,mod + (counts[i] - 1) * x
                // 这些位置可以形成窗口
                LinkedList<Integer> minWindows = new LinkedList<>();
                minWindows.add(mod);
                dp[i][mod] = dp[i + 1][mod];
                // 根据组循环能组成aim的值,更新窗口,并最终求出最小值
                for (int r = mod + coins[i]; r <= aim; r += coins[i]) {
                    while (!minWindows.isEmpty() && (dp[i + 1][minWindows.peekLast()] == Integer.MAX_VALUE || dp[i + 1][minWindows.peekLast()] + compensate(minWindows.peekLast(), r, coins[i]) >= dp[i + 1][r])) {
                        minWindows.pollLast();
                    }
                    minWindows.addLast(r);
                    // 窗口内的最小值过期了,需要弹出
                    int overdue = r - coins[i] * (counts[i] + 1);
                    if (minWindows.peekFirst() == overdue) {
                        minWindows.pollFirst();
                    }
                    if (dp[i + 1][minWindows.peekFirst()] == Integer.MAX_VALUE) {
                        dp[i][r] = Integer.MAX_VALUE;
                    } else {
                        dp[i][r] = dp[i + 1][minWindows.peekFirst()] + compensate(minWindows.peekFirst(), r, coins[i]);
                    }
                }
            }
        }
        return dp[0][aim];
    }

    /**
     * 计算补偿值,补偿值是当前位置cur与窗口内最小值pre的差值,除以当前面值coin,得到的商就是补偿值
     */
    public static int compensate(int pre, int cur, int coin) {
        return (cur - pre) / coin;
    }


    // 为了测试
    public static void main(String[] args) {
        int maxLen = 30;
        int maxValue = 30;
        int testTime = 300000;
        System.out.println("功能测试开始");
        for (int i = 0; i < testTime; i++) {
            int N = (int) (Math.random() * maxLen);
            int[] arr = randomArray(N, maxValue);
            int aim = (int) (Math.random() * maxValue);
            int ans1 = minCoins(arr, aim);
            int ans2 = dp1(arr, aim);
            int ans3 = dp2(arr, aim);
            int ans4 = dp3(arr, aim);
            if (ans1 != ans2 || ans3 != ans4 || ans1 != ans3) {
                System.out.println("错误!");
                printArray(arr);
                System.out.println(aim);
                System.out.println(ans1);
                System.out.println(ans2);
                System.out.println(ans3);
                System.out.println(ans4);
                break;
            }
        }
        System.out.println("功能测试结束");

        System.out.println("==========");

        int aim = 0;
        int[] arr = null;
        long start;
        long end;
        int ans2;
        int ans3;

        System.out.println("性能测试开始");
        maxLen = 30000;
        maxValue = 20;
        aim = 60000;
        arr = randomArray(maxLen, maxValue);

        start = System.currentTimeMillis();
        ans2 = dp2(arr, aim);
        end = System.currentTimeMillis();
        System.out.println("dp2答案 : " + ans2 + ", dp2运行时间 : " + (end - start) + " ms");

        start = System.currentTimeMillis();
        ans3 = dp3(arr, aim);
        end = System.currentTimeMillis();
        System.out.println("dp3答案 : " + ans3 + ", dp3运行时间 : " + (end - start) + " ms");
        System.out.println("性能测试结束");

        System.out.println("===========");

        System.out.println("货币大量重复出现情况下,");
        System.out.println("大数据量测试dp3开始");
        maxLen = 20000000;
        aim = 10000;
        maxValue = 10000;
        arr = randomArray(maxLen, maxValue);
        start = System.currentTimeMillis();
        ans3 = dp3(arr, aim);
        end = System.currentTimeMillis();
        System.out.println("dp3运行时间 : " + (end - start) + " ms");
        System.out.println("大数据量测试dp3结束");

        System.out.println("===========");

        System.out.println("当货币很少出现重复,dp2比dp3有常数时间优势");
        System.out.println("当货币大量出现重复,dp3时间复杂度明显优于dp2");
        System.out.println("dp3的优化用到了窗口内最小值的更新结构");
    }

    // 为了测试
    public static int[] randomArray(int N, int maxValue) {
        int[] arr = new int[N];
        for (int i = 0; i < N; i++) {
            arr[i] = (int) (Math.random() * maxValue) + 1;
        }
        return arr;
    }

    // 为了测试
    public static void printArray(int[] arr) {
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i] + " ");
        }
        System.out.println();
    }
}

后记

个人学习总结笔记,不能保证非常详细,轻喷

相关推荐
smj2302_796826521 小时前
解决leetcode第3753题范围内总波动值II
python·算法·leetcode
m***66732 小时前
SQL 实战—递归 SQL:层级结构查询与处理树形数据
java·数据库·sql
Naiva2 小时前
【小技巧】Microchip 把 MPLAB X IDE工程编码改成 UTF-8
笔记
骑着猪去兜风.3 小时前
线段树(二)
数据结构·算法
鲸沉梦落3 小时前
Java中的Stream
java
yihuiComeOn4 小时前
[源码系列:手写Spring] AOP第二节:JDK动态代理 - 当AOP遇见动态代理的浪漫邂逅
java·后端·spring
fengfuyao9854 小时前
竞争性自适应重加权算法(CARS)的MATLAB实现
算法
散峰而望4 小时前
C++数组(二)(算法竞赛)
开发语言·c++·算法·github
leoufung4 小时前
LeetCode 92 反转链表 II 全流程详解
算法·leetcode·链表