一、滑动窗口是什么
滑动窗口是一种想象出来的数据结构: 滑动窗口有左边界L和有边界R 在数组或者字符串或者一个序列上,记为S,窗口就是S[L..R]这一部分 L往右滑意味着一个样本出了窗口,R往右滑意味着一个样本进了窗口 L和R都只能往右滑。
这里有一个小小的问题,假如说一个数组[5,7,6,5,4,8,3],我想要求每个窗口的最大值,我怎么可以实现?
one hour letter...
我们可以使用一个双端队列来实现,假如说窗口都从0开始,R++,这是5从右边入队列,R++,7比5大,那么5从右边弹出,7入队列,R++ ,6比7 小,直接从右边入队列,R++,5比6小,直接入队列,假如说这个时候我想缩小了,L++,双端队列左边的就是当前最大值,双端队列中存储的是数组的下标,如果L移动到7右边,那么L 7就从双端队列的左边出队列,队列要严格维持从左到右依次变小,如果相同,队列中的从右边出队列,新的入队列。下面我们来实战一下。
题目一
假设一个固定大小为W的窗口,依次划过arr, 返回每一次滑出状况的最大值 例如,arr = [4,3,5,4,3,3,6,7], W = 3 返回:[5,5,5,4,6,7]。
这个题目就是用上面的技巧来解的。直接上代码:
public static int[] getMaxWindow(int[] arr,int w){
if (arr == null || w < 1 || arr.length < w){
return null;
}
int N = arr.length;
int[] result = new int[N - w + 1];
int index = 0;
//双端队列
LinkedList<Integer> qMax = new LinkedList<>();
//窗口右端依次往右
for (int R = 0; R < N; R++) {
//如果队列不为空,并且窗口最右端的值比队列尾的值大,就一直弹出,直到小于
while (!qMax.isEmpty() && arr[qMax.peekLast()] <= arr[R]){
qMax.pollLast();
}
//窗口右端的值入队列
qMax.add(R);
//查看队列头部的值是否过期,如果过期就移除,不过期就保留
if (qMax.peekFirst() == R - w){
qMax.pollFirst();
}
//判断是否能正常形成一个窗口如果可以形成一个窗口,才开始计答案
if (R > w - 1){
//双端队列的头部,就是最大值
result[index++] = arr[qMax.peekFirst()];
}
}
return result;
}
怎么样是不是既简单,又清晰,接下来我们再看一个稍难一些的
题目二
给定一个整型数组arr,和一个整数num 某个arr中的子数组sub,如果想达标,必须满足: sub中最大值 – sub中最小值 <= num, 返回arr中达标子数组的数量
这一题先给大家说一个结论。就是在R往右走的过程中,如果达标,那么所有的子序列都达标。如果往右走开始不达标了,那么在往右也不会达标。这一点我们很好想,假如说一个0 - 100 的数组达标,那么 0 - 50 必定达标, 为啥,因为在50 - 100 这些数中,最小值只可能比0 - 50 中的小 ,最大值也只可可能比 0 - 50 大,他们的差值,就会比0 - 50 大, 如果 0 - 50 都不达标,那么,差值更大的就更不会达标,当然,如果差值大的都达标了,那么差值小的不可能不达标。
这里我们准备一个装最大值的双端列表和一个装最小值的双端列表,R每移动一次,让他们相减,如果达标,就继续,不达标就停止,这是L++。如此重复
public static int AllLessNumSubArray(int[] arr,int sum){
if (arr == null || sum < 0 || arr.length < 0){
return 0;
}
int N = arr.length;
int count = 0;
int R = 0;
//存储最小值的双端队列
LinkedList<Integer> qMin = new LinkedList<>();
//存储最大值的双端对垒
LinkedList<Integer> qMax = new LinkedList<>();
for (int L = 0; L < N; L++) {
// 移动窗口右端不能大于数组的长度
while (R < N){
while (!qMax.isEmpty() && qMax.peekLast() <= arr[R] ){
qMax.pollLast();
}
while (!qMin.isEmpty() && qMin.peekLast() >= arr[R]){
qMin.pollLast();
}
//两个队列添加值
qMax.add(R);
qMin.add(R);
//最大值减最小值 大于 sum 初次不达标 break 否则 R++ 继续向后括
if (arr[qMax.peekFirst()] - arr[qMin.peekFirst()] > sum){
break;
}else {
R ++;
}
count += R - L;
if (qMax.peekFirst() == L){
qMax.pollFirst();
}
if (qMin.peekFirst() == L){
qMin.pollFirst();
}
}
}
return count;
}
看是不是很好用,也不是很难。
题目三
加油站的良好出发点问题
这道题我们先处理一下,啥gas数组,cost数组,太烦,我们自己加工出一个数组,arr,我们用gas- cost 作为arr每个位置的值,arr[-1,-1,2,0],从各个加油站出发,中途只要掉到0以下,就熄火了,a一上来就熄火了,b也是一上来就熄火了,c可以一直不熄火,d跑了一个加油站后熄火。
我们可以用暴力方法来解这题,时间复杂度是O(n^2),当然,我们也可以用滑动窗口来解,这里,我们可以转换一下,思路,通过求累加和的方式求解。例如arr = [-2,-3,4,-2,6,-1]首先,我们先对其求累加和[-2,-5,-1,-3,3,2,0,-3,1,-1,5,4] 这里,我们对arr这个数组求了两遍累加和,我们为什么要做两遍累加和呢,我们把 6位置,也就是值为0 的这个地方想象为0 位置,-3想象为1位置,1想象为2位置,-1想象为3位置。5想象4位置,4想象为5位置 ,这样我们在从头看,从-2到2相当于我们从0到5跑了一圈,-5到0 表示 从1位置出发回到0位置。-1到 -3 表示从2位置出发回到1位置,依次类推。所有原始数组中出发的位置,在这个长数组中 都能把原始的累加和数组给加工出来,想达成这样的目的,怎么加工出来,我们从哪个位置出发就用这个长数组减去前一个位置的数,就能出来,例如,从二位置出发,我们需要的累加和为[4,2,8,7,5,2],我们再看这个长数组,2位置为1,它前面位置为-5,那么我们把每个值都减-5,这样是不是就加工出我们需要的数组了。
// 这个方法的时间复杂度O(N),额外空间复杂度O(N)
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;
}
public static boolean[] goodArray(int[] g, int[] c) {
int N = g.length;
int M = N << 1;
int[] arr = new int[M];
for (int i = 0; i < N; i++) {
arr[i] = g[i] - c[i];
arr[i + N] = g[i] - c[i];
}
for (int i = 1; i < M; i++) {
arr[i] += arr[i - 1];
}
// 举个例子说明一下
// 比如纯能数组(也就是燃料 - 距离之后)的数组 :
// 纯能数组 = 3, 2,-6, 2, 3,-4, 6
// 数组下标 = 0 1 2 3 4 5 6
// 客观上说:
// 0位置不是良好出发点
// 1位置不是良好出发点
// 2位置不是良好出发点
// 3位置是良好出发点
// 4位置不是良好出发点
// 5位置不是良好出发点
// 6位置是良好出发点
// 把数组增倍之后 :
// arr = 3, 2,-6, 2, 3,-4, 6, 3, 2,-6, 2, 3,-4, 6
// 然后计算前缀和 :
// arr = 3, 5,-1, 1, 4, 0, 6, 9,11, 5, 7,10, 6,12
// index = 0 1 2 3 4 5 6 7 8 9 10 11 12 13
// 这些就是上面发生的过程
// 接下来生成长度为N的窗口
LinkedList<Integer> w = new LinkedList<>();
for (int i = 0; i < N; i++) {
while (!w.isEmpty() && arr[w.peekLast()] >= arr[i]) {
w.pollLast();
}
w.addLast(i);
}
// 上面的过程,就是先遍历N个数,然后建立窗口
// arr =[3, 5,-1, 1, 4, 0, 6],9,11, 5, 7,10, 6,12
// index = 0 1 2 3 4 5 6 7 8 9 10 11 12 13
// w中的内容如下:
// index: 2 5 6
// value: -1 0 6
// 左边是头,右边是尾,从左到右严格变大
// 此时代表最原始的arr的这部分的数字:
// 原始的值 = [3, 2,-6, 2, 3,-4, 6],3, 2,-6, 2, 3,-4, 6
// 原始下标 = 0 1 2 3 4 5 6 0 1 2 3 4 5 6
// 上面这个窗口中,累加和最薄弱的点,就是w中最左信息
// 也就是会累加出,-1这个值,所以会走不下去。
// 宣告了此时0位置不是良好出发点。
// 接下来的代码,就是依次考察每个点是不是良好出发点。
// 目前的信息是:
// 计算的前缀和 :
// arr =[3, 5,-1, 1, 4, 0, 6],9,11, 5, 7,10, 6,12
// index = 0 1 2 3 4 5 6 7 8 9 10 11 12 13
// w中的内容如下:
// index: 2 5 6
// value: -1 0 6
// 此时代表最原始的arr的这部分的数字:
// 原始的值 = [3, 2,-6, 2, 3,-4, 6],3, 2,-6, 2, 3,-4, 6
// 原始下标 = 0 1 2 3 4 5 6 0 1 2 3 4 5 6
// 现在让窗口往下移动
// 计算的前缀和 :
// arr = 3,[5,-1, 1, 4, 0, 6, 9],11, 5, 7,10, 6,12
// index = 0 1 2 3 4 5 6 7 8 9 10 11 12 13
// w中的内容如下:
// index: 2 5 6 7
// value: -1 0 6 9
// 此时代表最原始的arr的这部分的数字:
// 原始的值 = 3,[2,-6, 2, 3,-4, 6, 3],2,-6, 2, 3,-4, 6
// 原始下标 = 0 1 2 3 4 5 6 0 1 2 3 4 5 6
// 上面这个窗口中,累加和最薄弱的点,就是w中最左信息
// 但是w最左的值是-1啊!而这个窗口中最薄弱的累加和是-4啊。
// 对!所以最薄弱信息 = 窗口中的最左信息 - 窗口左侧刚出去的数(代码中的offset!)
// 所以,最薄弱信息 = -1 - 0位置的3(窗口左侧刚出去的数) = -4
// 看到了吗?最薄弱信息,依靠这种方式,加工出来了!
// 宣告了此时1位置不是良好出发点。
// 我们继续,让窗口往下移动
// 计算的前缀和 :
// arr = 3, 5,[-1, 1, 4, 0, 6, 9,11], 5, 7,10, 6,12
// index = 0 1 2 3 4 5 6 7 8 9 10 11 12 13
// w中的内容如下:
// index: 2 5 6 7 8
// value: -1 0 6 9 11
// 此时代表最原始的arr的这部分的数字:
// 原始的值 = 3, 2,[-6, 2, 3,-4, 6, 3, 2],-6, 2, 3,-4, 6
// 原始下标 = 0 1 2 3 4 5 6 0 1 2 3 4 5 6
// 上面这个窗口中,累加和最薄弱的点,就是w中最左信息
// 但是w最左的值是-1啊!而这个窗口中最薄弱的累加和是-6啊。
// 对!所以最薄弱信息 = 窗口中的最左信息 - 窗口左侧刚出去的数(代码中的offset!)
// 所以,最薄弱信息 = -1 - 1位置的5(窗口左侧刚出去的数) = -6
// 看到了吗?最薄弱信息,依靠这种方式,加工出来了!
// 宣告了此时2位置不是良好出发点。
// 我们继续,让窗口往下移动
// 计算的前缀和 :
// arr = 3, 5, -1,[1, 4, 0, 6, 9,11, 5], 7,10, 6,12
// index = 0 1 2 3 4 5 6 7 8 9 10 11 12 13
// w中的内容如下:
// index: 5 9
// value: 0 5
// 没错,9位置的5进来,让6、7、8位置从w的尾部弹出了,
// 同时原来在w中的2位置已经过期了,所以也弹出了,因为窗口左边界已经划过2位置了
// 此时代表最原始的arr的这部分的数字:
// 原始的值 = 3, 2, -6,[2, 3,-4, 6, 3, 2, -6],2, 3,-4, 6
// 原始下标 = 0 1 2 3 4 5 6 0 1 2 3 4 5 6
// 上面这个窗口中,累加和最薄弱的点,就是w中最左信息
// 但是w最左的值是0啊!而这个窗口中最薄弱的累加和是1啊
// 对!所以最薄弱信息 = 窗口中的最左信息 - 窗口左侧刚出去的数(代码中的offset!)
// 所以,最薄弱信息 = 0 - 2位置的-1(窗口左侧刚出去的数) = 1
// 看到了吗?最薄弱信息,依靠这种方式,加工出来了!
// 宣告了此时3位置是良好出发点。
// 往下同理
boolean[] ans = new boolean[N];
for (int offset = 0, i = 0, j = N; j < M; offset = arr[i++], j++) {
if (arr[w.peekFirst()] - offset >= 0) {
ans[i] = true;
}
if (w.peekFirst() == i) {
w.pollFirst();
}
while (!w.isEmpty() && arr[w.peekLast()] >= arr[j]) {
w.pollLast();
}
w.addLast(j);
}
return ans;
}
看是不是很清晰,下面,一道是很熟悉的题:
题目四:
arr是货币数组,其中的值都是正数。再给定一个正数aim。 每个值都认为是一张货币, 返回组成aim的最少货币数 注意: 因为是求最少货币数,所以每一张货币认为是相同或者不同就不重要了
这一题我们在动态规划中写过,用从左到右的尝试模型,从左到右,依次要还是不要,然后往下做决策。知道滑动窗口后,我们换一种其他的尝试方式。我们就又有了其他的解法啦,我们可以把面值去重后取出来,作为一个数组,然后再把对应的张数作为一个数组。
在这里我们如果想用临近格子替代,因为这里不是求累加和,而是求最小值,d出去之后,我的最小值不知道该怎么变 了。这也是这个尝试区别于之前尝试的地方,之前的尝试是求累加和的,我们用临近的格子,减去d的位置,加上a就行了,但这是直接求的最小张数,d出去之后就不知道怎么变了。
我们可以这么求。
如果i 是 3元,我们求完0之后求3,这时,b窗口形成,求完3之后求6这是c窗口形成,接下来求9元。一直到不超过面值,然后在求1元,4元,7元10元,不超过面值,一直往后求。
这里我们准备一个双端队列放最小值,那进双端队列要怎么pk呢,因为是最少张数,假如说x元a张,y元b张,那么就是a+(y-x)/面值 和 b 进行pk 。
在这里,把各种方式写的代码一块给大家。在这些方法里面,使用滑动窗口进行优化的性能最好
public static int minCoins(int[] arr, int aim) {
return process(arr, 0, aim);
}
public static int process(int[] arr, int index, int rest) {
if (rest < 0) {
return Integer.MAX_VALUE;
}
if (index == arr.length) {
return rest == 0 ? 0 : Integer.MAX_VALUE;
} else {
int p1 = process(arr, index + 1, rest);
int p2 = process(arr, index + 1, rest - arr[index]);
if (p2 != Integer.MAX_VALUE) {
p2++;
}
return Math.min(p1, p2);
}
}
// dp1时间复杂度为:O(arr长度 * 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[] zhangs;
public Info(int[] c, int[] z) {
coins = c;
zhangs = z;
}
}
public static Info getInfo(int[] arr) {
HashMap<Integer, Integer> counts = new HashMap<>();
for (int value : arr) {
if (!counts.containsKey(value)) {
counts.put(value, 1);
} else {
counts.put(value, counts.get(value) + 1);
}
}
int N = counts.size();
int[] coins = new int[N];
int[] zhangs = new int[N];
int index = 0;
for (Entry<Integer, Integer> entry : counts.entrySet()) {
coins[index] = entry.getKey();
zhangs[index++] = entry.getValue();
}
return new Info(coins, zhangs);
}
// dp2时间复杂度为:O(arr长度) + O(货币种数 * 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[] zhangs = info.zhangs;
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];
for (int zhang = 1; zhang * coins[index] <= aim && zhang <= zhangs[index]; zhang++) {
if (rest - zhang * coins[index] >= 0
&& dp[index + 1][rest - zhang * coins[index]] != Integer.MAX_VALUE) {
dp[index][rest] = Math.min(dp[index][rest], zhang + dp[index + 1][rest - zhang * coins[index]]);
}
}
}
}
return dp[0][aim];
}
// dp3时间复杂度为:O(arr长度) + O(货币种数 * aim)
// 优化需要用到窗口内最小值的更新结构
public static int dp3(int[] arr, int aim) {
if (aim == 0) {
return 0;
}
// 得到info时间复杂度O(arr长度)
Info info = getInfo(arr);
int[] c = info.coins;
int[] z = info.zhangs;
int N = c.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--) {
for (int mod = 0; mod < Math.min(aim + 1, c[i]); mod++) {
// 当前面值 X
// mod mod + x mod + 2*x mod + 3 * x
LinkedList<Integer> w = new LinkedList<>();
w.add(mod);
dp[i][mod] = dp[i + 1][mod];
for (int r = mod + c[i]; r <= aim; r += c[i]) {
while (!w.isEmpty() && (dp[i + 1][w.peekLast()] == Integer.MAX_VALUE
|| dp[i + 1][w.peekLast()] + compensate(w.peekLast(), r, c[i]) >= dp[i + 1][r])) {
w.pollLast();
}
w.addLast(r);
int overdue = r - c[i] * (z[i] + 1);
if (w.peekFirst() == overdue) {
w.pollFirst();
}
if (dp[i + 1][w.peekFirst()] == Integer.MAX_VALUE) {
dp[i][r] = Integer.MAX_VALUE;
} else {
dp[i][r] = dp[i + 1][w.peekFirst()] + compensate(w.peekFirst(), r, c[i]);
}
}
}
}
return dp[0][aim];
}
public static int compensate(int pre, int cur, int coin) {
return (cur - pre) / coin;
}