算法学习12: 单调队列和单调栈

单调队列

单调队列解决的问题: 窗口内最大/最小值的更新结构

滑动窗口窗口左边界窗口右边界都只能向右移动,且保证窗口左边界永远小于窗口右边界,如何在常数时间内找到窗口中的最大值?

单调队列的结构和操作

单调队列中储存的是有可能成为窗口最大值元素对应的下标索引及其值,每次窗口范围发生变化,都要对单调队列进行更新. 单调队列保证对应元素的值是严格递减的.

单调队列的维护基于如下事实:

越靠右(晚被淘汰)且值越大的元素,越有资格成为窗口内的最大值.

  • 初始时,窗口长度为0,窗口内没有元素,单调队列中也没有元素.
  • 窗口右边界向右扩展一位时,有新的元素加入窗口,将新加入的元素与单调队列末尾元素进行比较:
    • 新加入窗口的元素小于队列末尾元素: 当队列前边元素都出窗口后,该新元素有可能成为数组最大值,因此将其加到单调队列末尾.
    • 新加入窗口的元素大于队列末尾元素: 比起前边所有比新元素值小或相等的元素,新元素的值更大,且更后出窗口,因此它比前边所有比它小或相等的元素更有资格成为最大值,因此我们将前边所有值小于等于这个元素的元素全部弹出,然后将新元素加入到单调队列末尾.
  • 窗口左边界向右扩展一位时,窗口元素个数减一,因此我们要判断单调队列中是否有元素已经失效了. 因为单调队列中元素下标时严格递增的,因此只需要考察单调队列队首元素是否出窗口了,若出窗口则将其从队列中弹出.
  • 若在任意时刻,要求窗口最大值,只需返回单调队列的队首即可.

单调队列中可以只储存数组下标,其值可以通过数组索引得到.
实际编程时要注意别一不小心存成了数组的值.

// 初始化单调队列
Deque<Integer> qmax = new LinkedList<>(); 	// 单调队列,存储的是数组下标
int leftIndex = 0, rightIndex = 0; 			// 滑动窗口的左右边界

// 窗口左边界右移操作: 判断队首元素是否过期了
leftIndex++;
if (!qmax.isEmpty() && qmax.peekFirst() < leftIndex) {
	qmax.pollFirst();
}

// 窗口右边界右移操作: 弹出队尾所有小于等于当前元素值的元素,再将当前元素加入
rightIndex++;
while (!qmax.isEmpty() && arr[qmax.peekLast()] <= arr[rightIndex]) {
	qmax.pollLast();
}
qmax.addLast(rightIndex);

// 获取当前窗口最大值操作: 返回单调队列首元素
int maxElememt = arr[qmax.peekFirst()];

单调队列的应用

题目一: 生成窗口最大值数组leetcode 239

问题: 一个固定长度的滑动窗口在一个数组上滑动,要求返回窗口滑动到每一个位置时窗口内的最大值
解答: 直接应用单调队列,将窗口在数组上滑动的同时更新对应的单调队列.

题目二: 统计极差小于定值的子数组个数

问题: 给定一个数组,统计其极差(最大值与最小值之差)小于num子数组(子数组要求连续)的个数.
解答:

  • 该问题的解有一个很好的性质:
    • 如果一个子数组符合要求,则其中任何一个子数组也符合要求.
    • 如果一个子数组不符合要求,则任何包含它的子数组亦不符合要求.
  • 因此我们的解法如下
    • 初始时L=0,R从0开始向右扩张,直到不符合要求.
    • 若R扩张到不符合要求了,则将L右移一位,R从上一个达标处开始扩张.
    • 用一个单调队列存储窗口的最大值和最小值.

单调栈

单调栈解决的问题

对一个数组中的每一个数返回其左边第一个比它大的值右边第一个比它大的值,在O(N)时间内找到.

单调栈的结构

构造一个单调栈,保证栈的结构:从栈底到栈顶其元素是逐渐缩小的.先将所有数字依次压栈,再将栈弹出.

  • 将数组每一位依次入栈,入栈时为了维护单调栈从底到顶递减的结构,进行必要的出栈
    • 当前元素小于栈顶元素,则将该元素压入栈,不违背从栈底到栈顶递减的结构.
    • 当前元素大于等于栈顶元素,则当前元素比栈顶元素更有资格成为右侧元素的最近左较大值,因此不断出栈直到栈顶元素小于当前元素.对出栈元素更新其左临近更大值右临近更大值
      • 出栈元素是因为遇上了当前元素才出栈,因此其右临近更大值当前元素
      • 出栈元素左临近更大值为其栈中向底的下一个元素.
  • 当所有元素都已入栈了,则再将栈中元素依次弹出,并对出栈元素更新其左临近更大值右临近更大值
    • 出栈元素并不是因为右边任何一个元素而被弹出,因此其右临近更大值为空
    • 出栈元素左临近更大值为其栈中向底的下一个元素.

单调栈的实现(leetcode 1019)

不考虑重复元素

若不存在重复元素,则下边程序是对的,但若数字有重复的,则下边程序会在左临近更大值数组右临近更大值数组其中之一产生错误.

  • 若判断弹出条件为nums[i] >= nums[stack.peek()],则重复元素的右临近更大值数组出错(会导致下图中B,C,D左临近更大值均为A,右临近更大值分别为C,D,E)
  • 若判断弹出条件为nums[i] > nums[stack.peek()],则重复元素的左临近更大值数组出错(会导致下图中B,C,D右临近更大值均为E,左临近更大值分别为A,B,C)

在这里插入图片描述

考虑到重复元素,并要求左临近更大值数组右临近更大值数组均正确,只要遍历两遍数组,第一次保证左临近更大值数组正确,第二次保证右临近更大值数组正确.

int[] leftMore = new int[nums.length];  // 左临近更大值数组
int[] rightMore = new int[nums.length]; // 右临近更大值数组
Arrays.fill(leftMore, -1);              // 若左边没有最近的小于它的数,则为-1
Arrays.fill(rightMore, nums.length);    // 若右边没有最近的小于它的数,则为heights.length

Stack<Integer> smin = new Stack<>();    // 单调栈中存放的是数组的下标值,要求栈中元素对应的值递减

// 将所有元素插入单调栈
for (int i = 0; i < nums.length; i++) {
    // 易懂写法
    // if (smin.empty() || heights[i] < heights[smin.peek()]) {
    //     // 若当前值小于栈顶值,满足元素递减原则,直接压入栈
    //     smin.push(i);
    // } else {
    //     // 若当前值大于等于栈顶值,则弹出
    //     while (!smin.empty() && heights[i] >= heights[smin.peek()]) {
    //         int cur = smin.pop();
    //         // cur是因为i而被弹出,因此其右边较小值为i,其左边最小值为栈中向底一项
    //         rightMin[cur] = i;
    //         leftMin[cur] = smin.empty() ? -1 : smin.peek();
    //     }
    //     // 直到满足递增结构,再将对应元素插入
    //     smin.push(i);
    // }

    // 简洁写法
    // 若当前值小于栈顶值,满足元素递减原则,则不进入循环,直接压入栈
    // 若当前值大于等于栈顶值,则不断弹出栈顶直到其满足元素递减原则
    while (!smin.empty() && nums[i] >= nums[smin.peek()]) {  // 此处进入循环条件不同导致错误的结果不同
        int cur = smin.pop();
        // cur是因为i而被弹出,因此其右边较小值为i,其左边最小值为栈中向底一项
        rightMore[cur] = i;
        leftMore[cur] = smin.empty() ? -1 : smin.peek();
    }
    // 直到满足递减结构,再将对应元素插入
    smin.push(i);
}

// 将单调栈中元素弹出
while (!smin.empty()) {
    int cur = smin.pop();
    rightMore[cur] = nums.length;
    leftMore[cur] = smin.empty() ? -1 : smin.peek();
}

考虑重复元素

考虑到重复元素,则将单调栈中存储的元素由下标值改为下标数组

单调栈的应用

题目一: 直方图中的最大矩形leetcode 84

问题: 给出一个直方图,再直方图中找到面积最大的矩形,要求时间复杂度O(N)

在这里插入图片描述在这里插入图片描述

解法: 把直方图看成一系列棍的集合.
对于每一根棍,找到其两边的最近的高于它的棍,矩形面积=棍高度*两边连续的更高棍个数. 其两边最近的高于它的棍可以用单调栈来求.
对于每一个棍,都求出其对应的矩形面积,最终计算得到其最大面积.

这道题若使用不考虑重复元素的写法,会有一些棍的最近的高于它的棍求解错误,但由上边代码处分析可知,对于多个重复高度的棍,总会有一根棍的左右最近高于它的棍求取均是正确的,这对本道题目就已经足够了.

题目二: 最大子矩阵大小leetcode 85

问题: 给出一个矩阵,其值为0或1,找到面积最大的全1矩阵面积.
如下矩阵:
[ 1 0 1 0 0 1 0 1 1 1 1 1 1 1 1 1 0 0 1 0 ] \left[ \begin{matrix} 1 & 0 & 1 & 0 & 0 \\ 1 & 0 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \\ 1 & 0 & 0 & 1 & 0 \end{matrix} \right] 11110010111001110110
其面积最大的全1矩阵面积为6
解法: 这个问题应用到了上边问题(直方图中最大矩形)作为子问题.

  • 首先一行一行扫描,将每一行都作为生成矩形的底边(看成一个直方图),来找到这一行每一位上面有多少个连续的1,存储在heights矩阵中.heights矩阵的转换规则如下
    • 若矩阵中某一位的值为'0',则以它为底的连续的1的长度为0,即heights[j]=0
    • 若矩阵中某一位的值为'1',则以它为底的连续的1的长度以正上方元素为底连续1的长度+1
    • heights[j] = matrix[i][j] == '0' ? 0 : heights[j] + 1;
  • 对每一行求出以它为底边生成矩形的最大值(应用上一题代码),然后找到所有行结果的最大值.

题目三: 一道很难想的题目

问题: 给出一个环形数组,表示一个环形的烽火台,烽火台之间互有遮挡
有两种条件下两个烽火台可以互相看见

  1. 相邻的两个节点可以互相看见
  2. 两个节点之间沿圆周组成的优弧劣弧之间所有的节点都小于这两个节点,则这两个节点可以互相看见

如下边圆周之上有9组能互相看见的节点.

在这里插入图片描述

简单问法: 请问n个两两不相同的组成的数组之间一共有几组能互相看见的节点? 在O(1)时间内给出

解答: 分情况讨论

  1. n<=1, 共有0对两两互相看见的节点
  2. n=2, 共有1对两两互相看见的节点
  3. n>=3, 共有2*i-3对两两互相看见的节点

其中第1,2种情况易知,第三种情况证明如下
对于每一组互相看见的节点对,都有一个较小值和一个较大值,我们希望确定较小值取找较大值.
找出最大值次大值,将除此两个值之外的n-2个值各自作为较小值,都能沿着顺逆两个方向找到较大值且两个较大值互不相同,有2*(n-2)组互相看见的节点,除此之外最大值最小值互相能看见.因此共有2*n-3个可以互相看见的节点对.

在这里插入图片描述

困难问法: 若n个节点之间有可能存在相同值,其组成的环形数组共有几个能互相看见的节点?

解答: 与上问类似,我们站在较低节点上去找较高节点,本质上是在找节点是否存在左临近较大值右临近较大值,因此用到单调栈结构.
不同的是这个单调栈要考虑到存在重复值,因此栈中存储的是{下标, 相等元素出现次数}结构,要涉及到节点的合并.

在这里插入图片描述

  • 先找到数组的一个最大值(若有多个相等最大值则任选一个),从这个节点开始进行构造单调栈.(后面会解释为什么选这个节点作为开始).在上图中从5(起点)开始.

  • 先将节点依次入栈,在将所有节点入栈过程中会发现出栈的情况. 这说明出栈节点存在逆时针临近最大值,这时可以以出栈节点较小值,以临近节点较大值,找到互能看见的节点.

    • 出栈节点在单调栈中记载的出现次数等于1,则说明从这个节点沿着顺时针和逆时针方向看去,都能找到一个临近更大值,且这两个临近更大值会挡住其它的更大值.因此此节点带来了2个新的互能看见的节点.

      例如:遍历到红色4时,节点{值为3,出现1次}被弹出,其顺时针,逆时针方向上都有一个更大值,带来[5,3],[4,3]两对互能看见节点

    • 出栈节点在单调栈中记载的出现次数k大于1,这些节点沿着顺逆时针方向各自都能找到临近更大值,带来2*k互能看见的节点,且这几个出栈节点之间互能看见,带来C(k,2)互能看到的节点,共带来C(k,2)+2*k互能看到的节点.

      例如: 若遍历到黄色5时,节点{值为4,出现4次}被弹出,共带来[4,5].[5,4],和6个[4,4],共带来8个新的互能看到的节点

    • 这里可以看到,为什么选取数组最大值开始作为遍历起点,的原因,因为只有这样才能保证数组中所有节点出栈时沿着顺时针方向都能找到一个顺时针临近最大值.

  • 遍历完成之后,单调栈中还存在节点,我们要将其全部出栈,出栈时考虑带来了几个胡能看到的节点.

在这里插入图片描述

例如: 对于上边的数组来说,遍历结束时栈中存在节点[{值为5,出现3次}, {值为4,出现2次}, {值为3,出现3次}]

  • 出栈节点不是栈中倒数第一个倒数第二个节点.设出栈节点出现次数为k,则这k个节点在顺逆时针方向都能找到临近较大值,则新带来的互能看到的节点C(k,2)+2*k个.
  • 若弹出节点是栈中倒数第一个节点,这时候要考虑到栈底节点到底出现几次,设出栈节点出现次数为k.
    • 出栈节点的出现次数**>=2**,则说明顺逆方向都各自能能找到临近较大值,则新带来的互能看到的节点C(k,2)+2*k个.
    • 若栈底节点的出现次数**=1**,则说明顺逆方向会找到同一个临近较大值,则新带来的互能看到的节点C(k,2)+1*k个.
  • 出栈节点栈底节点,设出栈节点出现次数为k.则其在顺逆方向上都找不到临近较大值,这k个节点之间互能看见,则新带来的互能看到的节点k==1 ? 0 : C(k,2)个.

代码如下:

import java.util.Stack;

class Solution {
	// 定义Pair类,用于记录节点值及其出现次数
	public static class Pair {
		public int value;
		public int times;

		public Pair(int value) {
			this.value = value;
			this.times = 1;
		}
	}

	// 辅助函数,找到循环数组下一个位置的索引
	public static int nextIndex(int size, int i) {
		return i < (size - 1) ? i + 1 : 0;
	}

	// 辅助函数,计算n个相等节点之间产生的 互能看见节点个数
	public static long getInternalSum(int n) {
		return n == 1L ? 0L : (long) n * (long) (n - 1) / 2L;
	}

	// 辅助函数,找到数组最大节点对应的下标
	public static int getMaxIndex(int[] arr) {
		int maxIndex = 0;
		for (int i = 0; i < arr.length; i++) {
			maxIndex = arr[maxIndex] < arr[i] ? i : maxIndex;
		}
		return maxIndex;
	}

	// 核心函数,计算 互能看见节点个数
	public static long communications(int[] arr) {
		// 判空
		if (arr == null || arr.length < 2) {
			return 0;
		}

		int size = arr.length; // 数组长度
		int maxIndex = getMaxIndex(arr); // 数组最大值下标,作为压栈起点
		int value = arr[maxIndex]; // 临时变量,当前遍历到的节点的值
		int index = nextIndex(size, maxIndex);// 临时变量,当前遍历到的节点的下标
		long res = 0L; // 累加 记录 互能看见节点个数
		Stack<Pair> stack = new Stack<>(); // 单调递减栈
		stack.push(new Pair(value));

		// 将所有节点压入单调栈
		while (index != maxIndex) {
			value = arr[index];
			// 为了维护栈的递减结构,弹出所有前边小于当前值的节点
			while (!stack.isEmpty() && stack.peek().value < value) {
				int times = stack.pop().times;
				// 计算 出栈节点之间 以及 出栈节点和临近较大节点之间 产生的 胡能看到节点个数
				res += getInternalSum(times) + times;
				res += stack.isEmpty() ? 0 : times;
			}

			// 将当前节点入栈(有可能合并节点)
			if (!stack.isEmpty() && stack.peek().value == value) {
				stack.peek().times++;
			} else {
				stack.push(new Pair(value));
			}

			// 遍历指针后移
			index = nextIndex(size, index);
		}

		// 入栈完毕,将剩余节点出栈
		while (!stack.isEmpty()) {
			int times = stack.pop().times;
			
			// 判断弹出的是从栈底开始第几个节点
			int stackSize = stack.size();
			if (stackSize > 1) {
				// 弹出的是 从栈底开始第三个 以及 其上 的节点
				res += getInternalSum(times) + 2 * times;
			} else if (stackSize == 1) {
				// 弹出的是 从栈底开始第二个节点
				res += getInternalSum(times) + (stack.peek().times > 1 ? 2 * times : times);
			} else if (stackSize == 0) {
				// 弹出的是最后一个节点
				res += getInternalSum(times);
			}
		}

		return res;
	}
}
  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值