算法和数据结构解析-8 : 栈和队列相关问题
Posted 鮀城小帅
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了算法和数据结构解析-8 : 栈和队列相关问题相关的知识,希望对你有一定的参考价值。
1. 栈和队列数据结构
1.1 栈(Stack)
栈(Stack)又名堆栈,它是一种重要的数据结构。从数据结构角度看,栈也是线性表,其特殊性在于栈的基本操作是线性表操作的子集,它是操作受限的线性表,因此,可称为限定性的数据结构。
栈被限定仅在表尾进行插入或删除操作。表尾称为栈顶,相应地,表头称为栈底。所以栈具有“后进先出”(LIFO)的特点。
栈的基本操作除了在栈顶进行插入(入栈,push)和删除(出栈,pop)外,还有栈的初始化,判断是否为空以及取栈顶元素等。
1.2 队列
队列(Queue)是一种先进先出(FIFO,First-In-First-Out)的线性表。
在具体应用中通常用链表或者数组来实现。队列只允许在后端(称为 rear)进行插入操作,在前端(称为 front)进行删除操作。
队列的操作方式和堆栈类似,唯一的区别在于队列只允许新数据在后端进行添加。
- 双端队列 (Deque:double ended queue)
双端队列,是限定插入和删除操作在表的两端进行的线性表。
队列的每一端都能够插入数据项和移除数据项。
相对于普通队列,双端队列的入队和出队操作在两端都可进行。所以,双端队列同时具有队列和栈的性质。
- 优先队列
优先队列不再遵循先入先出的原则,而是分为两种情况:
最大优先队列,无论入队顺序,当前最大的元素优先出队。
最小优先队列,无论入队顺序,当前最小的元素优先出队。
比如有一个最大优先队列,它的最大元素是8,那么虽然元素8并不是队首元素,但出队的时候仍然让元素8首先出队:
要满足以上需求,利用线性数据结构并非不能实现,但是时间复杂度较高,需要遍历所有元素,最坏时间复杂度O(n),并不是最理想的方式。
因此,一般是用大顶堆(Max Heap,有时也叫最大堆)来实现最大优先队列,每一次入队操作就是堆的插入操作,每一次出队操作就是删除堆顶节点。
入队操作:
1. 插入新节点5
2. 新节点5上浮到合适位置。
出队操作:
1.把原堆顶节点10“出队”
2.最后一个节点1替换到堆顶位置
3.节点1下沉,节点9成为新堆顶
二叉堆节点上浮和下沉,操作次数不会超过数的深度,所以时间复杂度都是O(logn)。那么优先队列,入队和出队的时间复杂度,也是O(logn)。
2.使用队列实现栈
2.1 题目说明
使用队列实现栈的下列操作:
- push(x) -- 元素 x 入栈
- pop() -- 移除栈顶元素
- top() -- 获取栈顶元素
- empty() -- 返回栈是否为空
注意:
- 你只能使用队列的基本操作-- 也就是 push to back, peek/pop from front, size, 和 is empty 这些操作是合法的。
- 你所使用的语言也许不支持队列。 你可以使用 list 或者 deque(双端队列)来模拟一个队列 , 只要是标准的队列操作即可。
- 你可以假设所有操作都是有效的(例如, 对一个空的栈不会调用 pop 或者 top 操作)。
2.2 分析
这道题目涉及到栈和队列两种数据结构。它们的共同特点是,数据元素以线性序列的方式存储;区别在于,元素进出的方式不同。
队列本身对数据元素的保存,是完全符合数据到来次序的,同时也保持这个顺序依次出队。而弹栈操作的实现,是要删除最后进入的数据,相当于反序弹出。
实现的基本思路是,我们可以用一个队列保存当前所有的数据,以它作为栈的物理基础;而为了保证后进先出,我们在数据入队之后,就把它直接移动到队首。
2.3 方法一:两个队列实现
可以增加一个队列来做辅助。我们记原始负责存储数据的队列为queue1,新增的辅助队列为queue2。
- 当一个数据x压栈时,我们不是直接让它进入queue1,而是先在queue2做一个缓存。默认queue2中本没有数据,所以当前元素一定在队首。
queue1:a b
queue2:x
- 接下来,就让queue1执行出队操作,把之前的数据依次输出,同时全部添加到queue2中来。这样,queue2就实现了把新元素添加到队首的目的。
queue1:
queue2:x a b
- 最后,我们将queue2的内容复制给queue1做存储,然后清空queue2。在代码上,这个实现非常简单,只要交换queue1和queue2指向的内容即可。
queue1:x a b
queue2:
而对于弹栈操作,只要直接让queue1执行出队操作,删除队首元素就可以了。
package com.webcode.stack_and_queue;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Stack;
// 使用两个队列实现自定义栈
public class MyStack
// 定义两个队列
Queue<Integer> queue1;
Queue<Integer> queue2;
public MyStack()
queue1 = new LinkedList<>();
queue2 = new LinkedList<>();
// 入栈方法
public void push(int x)
// 1. 把x保存到queue2中
queue2.offer(x);
// 2. 将queue1中所有元素依次出队,然后放入queue2
while (!queue1.isEmpty())
queue2.offer( queue1.poll() );
// 3. 交换两个队列
Queue<Integer> temp = queue1;
queue1 = queue2;
queue2 = temp;
// 出栈操作
public int pop()
// queue1出队就是出栈
return queue1.poll();
// 获取栈顶元素
public int top()
return queue1.peek();
// 判断为空
public boolean empty()
return queue1.isEmpty();
复杂度分析
时间复杂度:入栈操作 O(n),其余操作都是 O(1)。
push:入栈操作,需要将queue1中的 n 个元素出队,并入队 n+1 个元素到 queue2,总计 2n+1 次操作。每次出队和入队操作的时间复杂度都是 O(1),因此入栈操作的时间复杂度是 O(n)。
pop:出栈操作,只是将queue1的队首元素出队,时间复杂度是 O(1)。
top:获得栈顶元素,对应获得queue1的队首元素,时间复杂度是 O(1)。
isEmpty:判断栈是否为空,只需要判断queue1是否为空,时间复杂度是 O(1)。
空间复杂度:O(n),其中 n 是栈内的元素。需要使用两个队列存储栈内的元素。
2.4 方法二:一个队列实现
当一个新的元素x压栈时,其实我们可以不借助辅助队列,而是让它直接入队queue1,它会添加在队尾。然后接下来,只要将之前的所有数据依次出队、再重新入队添加进queue1,就自然让x移动到队首了。
// 用一个队列实现自定义栈
public class MyStack2
// 定义一个队列
Queue<Integer> queue;
public MyStack2()
queue = new LinkedList<>();
public void push(int x)
// 1. 首先记录当前队列长度
int l = queue.size();
// 2. 把x入队
queue.offer(x);
// 3. 把queue中原先的所有元素依次出队,然后再入队
for (int i = 0; i < l; i++)
queue.offer( queue.poll() );
public int pop()
return queue.poll();
public int top()
return queue.peek();
public boolean empty()
return queue.isEmpty();
复杂度分析
时间复杂度:入栈操作 O(n),其余操作都是 O(1)。
push:入栈操作,需要将queue1中的 n 个元素出队,并入队 n+1 个元素到 queue2,总计 2n+1 次操作。每次出队和入队操作的时间复杂度都是 O(1),因此入栈操作的时间复杂度是 O(n)。
pop:出栈操作。只是将queue1的队首元素出队,时间复杂度是 O(1)。
top:获得栈顶元素,对应获得queue1的队首元素,时间复杂度是 O(1)。
isEmpty:判断栈是否为空,只需要判断queue1是否为空,时间复杂度是 O(1)。
空间复杂度:O(n),其中 n 是栈内的元素。需要使用两个队列存储栈内的元素。
3. 使用栈实现队列
3.1 题目说明
请你仅使用两个栈实现先入先出队列。队列应当支持一般队列的支持的所有操作(push、pop、peek、empty):
实现 MyQueue 类:
- void push(int x) 将元素 x 推到队列的末尾
- int pop() 从队列的开头移除并返回元素
- int peek() 返回队列开头的元素
- boolean empty() 如果队列为空,返回 true ;否则,返回 false
说明:
- 你只能使用标准的栈操作 —— 也就是只有 push to top, peek/pop from top, size, 和 is empty 操作是合法的。
- 你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。
进阶:
你能否实现每个操作均摊时间复杂度为 O(1) 的队列?换句话说,执行 n 个操作的总时间复杂度为 O(n) ,即使其中一个操作可能花费较长时间。
示例:
输入:
["MyQueue", "push", "push", "peek", "pop", "empty"]
[[], [1], [2], [], [], []]
输出:
[null, null, null, 1, 1, false]
解释:
MyQueue myQueue = new MyQueue();
myQueue.push(1); // queue is: [1]
myQueue.push(2); // queue is: [1, 2] (leftmost is front of the queue)
myQueue.peek(); // return 1
myQueue.pop(); // return 1, queue is [2]
myQueue.empty(); // return false
提示:
- 1 <= x <= 9
- 最多调用 100 次 push、pop、peek 和 empty
- 假设所有操作都是有效的 (例如,一个空的队列不会调用 pop 或者 peek 操作)
3.2 分析
我们要用栈来实现队列。一个队列是 FIFO 的,但一个栈是 LIFO 的。为了满足队列的 FIFO 的特性,我们需要将入栈的元素次序进行反转,这样在出队时就可以按照入队顺序依次弹出了。
想要反转,简单的想法是只要把所有元素依次弹出,并压入另一个栈,自然就变成本来栈底元素到了栈顶了。所以我们的实现,需要用到两个栈。
3.3 方法一:入队时反转
一种直观的思路是,最终的栈里,按照“自顶向下”的顺序保持队列。也就是说,栈顶元素是最先入队的元素,而最新入队的元素要压入栈底。
我们可以用一个栈来存储元素的最终顺序(队列顺序),记作stack1;用另一个进行辅助反转,记作stack2。
最简单的实现,就是直接用stack2,来缓存原始压栈的元素。每次调用push,就把stack1中的元素先全部弹出并压入stack2,然后把新的元素也压入stack2;这样stack2就是完全按照原始顺序入栈的。最后再把stack2中的元素全部弹出并压入stack1,进行反转。
package com.webcode.stack_and_queue;
import java.util.Stack;
// 用两个栈实现队列:入队时翻转
public class MyQueue
// 定义两个栈
Stack<Integer> stack1;
Stack<Integer> stack2;
public MyQueue()
stack1 = new Stack<>();
stack2 = new Stack<>();
// 入队方法
public void push(int x)
// 1. 首先将stack1中所有元素弹出,压入stack2
while (!stack1.isEmpty())
stack2.push( stack1.pop() );
// 2. 将新元素压入stack1
stack1.push(x);
// 3. 再将stack2中所有元素弹出,压入stack
while (!stack2.isEmpty())
stack1.push( stack2.pop() );
// 出队方法
public int pop()
return stack1.pop();
// 获取队首元素
public int peek()
return stack1.peek();
// 判空
public boolean empty()
return stack1.isEmpty();
public static void main(String[] args)
MyQueue myQueue = new MyQueue();
myQueue.push(1); // queue is: [1]
myQueue.push(2); // queue is: [1, 2] (leftmost is front of the queue)
myQueue.peek(); // return 1
myQueue.pop(); // return 1, queue is [2]
myQueue.empty(); // return false
复杂度分析
- 入队
时间复杂度:O(n)
除新元素之外,所有元素都会被压入两次,弹出两次。新元素被压入两次,弹出一次。(当然,我们可以稍作改进,在stack1清空之后把新元素直接压入,就只压入一次了)
这个过程产生了4n+3 次操作,其中 n 是队列的大小。由于入栈操作和弹出操作的时间复杂度为 O(1), 所以时间复杂度为 O(n)。
空间复杂度:O(n)
需要额外的内存来存储队列中的元素。
- 其它操作(pop、peek、isEmpty)
时间复杂度:O(1)
空间复杂度:O(1)
3.4 方法二:出队时反转
可以不要在入队时反转,而是在出队时再做处理。
执行出队操作时,我们想要弹出的是stack1的栈底元素。所以需要将stack1中所有元素弹出,并压入stack2,然后弹出stack2的栈顶元素。
我们观察可以发现,stack2中的元素,其实就是保持着队列顺序的,所以完全没必要将它们再压回stack1,下次出队时,我们只要直接弹出stack2中的栈顶元素就可以了。
package com.webcode.stack_and_queue;
import java.util.Stack;
// 用两个栈实现队列:入队时翻转
public class MyQueue2
// 定义两个栈
Stack<Integer> stack1;
Stack<Integer> stack2;
public MyQueue2()
stack1 = new Stack<>();
stack2 = new Stack<>();
// 入队方法
public void push(int x)
stack1.push(x);
// 出队方法
public int pop()
stack2 = new Stack<>();
while (!stack1.isEmpty())
stack2.push(stack1.pop());
return stack2.pop();
// 获取队首元素
public int peek()
stack2 = new Stack<>();
while (!stack1.isEmpty())
stack2.push(stack1.pop());
return stack1.peek();
// 判空
public boolean empty()
return stack1.isEmpty();
public static void main(String[] args)
MyQueue2 myQueue = new MyQueue2();
myQueue.push(1); // queue is: [1]
myQueue.push(2); // queue is: [1, 2] (leftmost is front of the queue)
myQueue.peek(); // return 1
myQueue.pop(); // return 1, queue is [2]
myQueue.empty(); // return false
复杂度分析
- 入队(push)
时间复杂度:O(1)。向栈压入元素的时间复杂度为O(1)
空间复杂度:O(n)。需要额外的内存(stack1和stack2共同存储)来存储队列元素。
- 出队(pop)
时间复杂度: 摊还复杂度 O(1),最坏情况下的时间复杂度 O(n)
在最坏情况下,stack2 为空,算法需要执行while循环进行反转。具体过程是从 stack1 中弹出 n 个元素,然后再把这 n 个元素压入 stack2,在这里n代表队列的大小。这个过程产生了 2n 步操作,时间复杂度为 O(n)。
但当 stack2 非空时,只需要直接弹栈,算法就只有 O(1) 的时间复杂度。均摊下来,摊还复杂度为O(1)。
空间复杂度 :O(1)
- 取队首元素(peek)和判断是否为空(empty)
时间复杂度:O(1)
空间复杂度:O(1)
3.5 摊还复杂度分析
摊还分析(Amortized Analysis,均摊法),用来评价某个数据结构的一系列操作的平均代价。
对于一连串操作而言,可能某种情况下某个操作的代价特别高,但总体上来看,也并非那么糟糕,可以形象的理解为把高代价的操作“分摊”到其他操作上去了,要求的就是均摊后的平均代价。
摊还分析的核心在于,最坏情况下的操作一旦发生了一次,那么在未来很长一段时间都不会再次发生,这样就会均摊每次操作的代价。
摊还分析与平均复杂度分析的区别在于,平均情况分析是平均所有的输入。而摊还分析是平均操作。在摊还分析中,不涉及概率,并且保证在最坏情况下每一个操作的平均性能。
所以摊还分析,往往会用在某一数据结构的操作分析上。
4. 有效的括号
4.1 题目说明
给定一个只包括 '(',')','','','[',']' 的字符串,判断字符串是否有效。
有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
注意空字符串可被认为是有效字符串。
示例 1:
输入: "()"
输出: true
示例 2:
输入: "()[]"
输出: true
示例 3:
输入: "(]"
输出: false
示例 4:
输入: "([)]"
输出: false
示例 5:
输入: "[]"
输出: true
4.2 分析
判断括号的有效性,这是一个非常经典的问题。
由于给定字符串中只包含 '(',')','','','[',']' ,所以我们不需要额外考虑非法字符的问题。
对于合法的输入字符,关键在于遇到一个“左括号”时,我们会希望在后续的遍历中,遇到一个相同类型的“右括号”将其闭合。
由于规则是:后遇到的左括号,要先闭合,因此我们想到,利用一个栈可以实现这个功能,将左括号放入栈顶,遇到右括号时弹出就可以了。
4.3 具体实现
代码实现非常简单:我们可以创建一个栈,然后遍历字符串。遇到左括号,就压栈;遇到右括号,就判断和当前栈顶的左括号是否匹配,匹配就弹栈,不匹配直接返回false。
public class ValidParentheses
// 使用栈
public boolean isValid(String s)
Deque<Character> stack = new LinkedList<>();
// 遍历字符串中所有字符,依次判断
for (int i = 0; i < s.length(); i++)
// 获取当前字符
char ch = s.charAt(i);
// 判断当前字符是左括号还是右括号
// 如果是左括号,直接将对应的右括号入栈
if ( ch == '(' )
stack.push(')');
else if ( ch == '[' )
stack.push(']');
else if ( ch == '' )
stack.push('');
else
// 如果是右括号,弹栈判断是否匹配
if (stack.isEmpty() || stack.pop() != ch) return false;
return stack.isEmpty();
public static void main(String[] args)
ValidParentheses validParentheses = new ValidParentheses();
System.out.println(validParentheses.isValid("()[]"));
System.out.println(validParentheses.isValid("(]"));
System.out.println(validParentheses.isValid("([)]"));
System.out.println(validParentheses.isValid("[]"));
复杂度分析
时间复杂度:O(n),其中n是字符串s的长度。只需要遍历一次字符串。
空间复杂度:O(n)。栈中最多会保存字符串中所有的左括号,数量为O(n)。
5.柱状图中最大的矩形
5.1 题目说明
给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。
求在该柱状图中,能够勾勒出来的矩形的最大面积。
以上是柱状图的示例,其中每个柱子的宽度为 1,给定的高度为 [2,1,5,6,2,3]。
图中阴影部分为所能勾勒出的最大矩形面积,其面积为 10 个单位。
示例:
输入: [2,1,5,6,2,3]
输出: 10
5.2 分析
题目要求计算最大矩形面积,我们可以发现,关键其实就在于确定矩形的“宽”和“高”(即矩形面积计算中的长和宽)。
而宽和高两者间又有制约条件:一定宽度范围内的高,就是最矮那个柱子的高度。
5.3 方法一:暴力法
一个简单的思路,就是遍历所有可能的宽度。也就是说,以每个柱子都作为矩形的左右边界进行计算,取出所有面接中最大的那个。
// 方法一:暴力法(遍历所有可能的宽度)
public int largestRectangleArea1(int[] heights)
// 定义变量保存最大面积
int largestArea = 0;
// 遍历数组,作为矩形左边界
for (int left = 0; left < heights.length; left ++)
// 定义变量保存当前矩形高度
int currHeight = heights[left];
// 遍历数组,选取矩形右边界
for (int right = left; right < heights.length; right ++)
// 确定当前矩形的高度
currHeight = (heights[right] < currHeight) ? heights[right] : currHeight;
// 计算当前矩形面积
int currArea = (right - left + 1) * currHeight;
// 更新最大面积
largestArea = (currArea > largestArea) ? currArea : largestArea;
return largestArea;
复杂度分析
时间复杂度:O(N^2)。很明显,代码中用到了双重循环,需要耗费平方时间复杂度来做遍历计算。这个复杂度显然是比较高的。
空间复杂度:O(1)。只用到了一些辅助变量。
5.4 方法二:双指针
我们可以首先遍历数组,以当前柱子的高度,作为考察的矩阵“可行高度”。然后定义左右两个指针,以当前柱子为中心向两侧探寻,找到当前高度的左右边界。
左右边界的判断标准,就是出现了比当前高度矮的柱子,或者到达了数组边界。
// 方法二:双指针法(遍历所有可能的高度)
public int largestRectangleArea2(int[] heights)
// 定义变量保存最大面积
int largestArea = 0;
// 遍历数组,以每个柱子高度作为最终矩形的高度
for (int i = 0; i < heights.length; i++)
// 保存当前高度
int height = heights[i];
// 定义左右指针
int left = i, right = i;
// 寻找左边界,左指针左移
while (left >= 0)
if (heights[left] < height) break;
left --;
// 寻找右边界,右指针右移
while (right < heights.length)
if (heights[right] < height) break;
right ++;
// 计算当前宽度
int width = right - left - 1;
// 计算面积
int currArea = height * width;
largestArea = (currArea > largestArea) ? currArea : largestArea;
return largestArea;
复杂度分析
时间复杂度:O(N^2)。尽管少了一重循环,但在内部依然要去暴力寻找左右边界,这个操作最好情况下时间复杂度为O(1),最坏情况下为O(N),平均为O(N)。所以整体的平均时间复杂度仍然是O(N^2)。
空间复杂度:O(1)。只用到了一些辅助变量。
5.5 方法三:双指针优化
在双指针法寻找左右边界的过程中我们发现,如果当前柱子比前一个柱子高,那么它的左边界就是前一个柱子;如果比前一个柱子矮,那么可以跳过之前确定更高的那些柱子,直接从前一个柱子的左边界开始遍历。
这就需要我们记录下每一个柱子对应的左边界,这可以单独用一个数组来保存。
// 方法三:双指针法改进
public int largestRectangleArea3(int[] heights)
// 定义变量保存最大面积
int largestArea = 0;
// 定义两个数组,保存每个柱子对应的左右边界
int n = heights.length;
int[] lefts = new int[n];
int[] rights = new int[n];
// 遍历数组,计算左边界
for (int i = 0; i < n; i++)
// 保存当前高度
int height = heights[i];
// 定义左指针
int left = i - 1;
// 左指针左移,寻找左边界
while (left >= 0)
if (heights[left] < height) break;
left = lefts[left]; // 如果左边柱子更高,就直接跳到它的左边界柱子再判断
lefts[i] = left;
// 遍历数组,计算右边界
for (int i = n - 1; i >= 0; i--)
// 保存当前高度
int height = heights[i];
// 定义右指针
int right = i + 1;
// 右指针右移,寻找右边界
while (right < n)
if (heights[right] < height) break;
right = rights[right]; // 如果右边柱子更高,就直接跳到它的右边界柱子再判断
rights[i] = right;
// 遍历所有柱子,计算面积
for (int i = 0; i < n; i++)
int currArea = (rights[i] - lefts[i] - 1) * heights[i];
largestArea = (currArea > largestArea) ? currArea : largestArea;
return largestArea;
复杂度分析
时间复杂度:O(N)。我们发现,while循环内的判断比对总体数量其实是有限的。每次比对,或者是遍历到一个新元素的时候,或者是之前判断发现当前柱子较矮,需要继续和前一个柱子的左边界进行比较。所以总的时间复杂度是O(N)。
空间复杂度:O(N)。用到了长度为n的数组来保存左右边界。
5.6 方法四:使用单调栈
从上面的算法中我们可以发现,“找左边界”最重要的,其实就是排除左侧不可能的那些元素,跳过它们不再遍历。
所以我们可以考虑用这样一个数据结构,来保存当前的所有“候选左边界”。
当遍历到一个高度时,就让它和“候选列表”中的高度比较:如果发现它比之前的候选大,可以直接追加在后面;而如果比之前的候选小,就应该删除之前更大的候选。最终,保持一个按照顺序、单调递增的候选序列。
过程中应该按照顺序,先比对最新的候选、再比对较老的候选。显然,我们可以用一个栈来实现这样的功能。
栈中存放的元素具有单调性,这就是经典的数据结构单调栈了。
我们用一个具体的例子 [6,7,5,2,4,5,9,3] 来理解单调栈。
我们需要求出每一根柱子的左侧且最近的小于其高度的柱子。初始时的栈为空。
(1)我们枚举 6,因为栈为空,所以 6 左侧的柱子是“哨兵”,位置为 -1。随后我们将 6 入栈。
栈:[6(0)]。(这里括号内的数字表示柱子在原数组中的位置索引)
(2)我们枚举 7,由于 6<7,因此不会移除栈顶元素,所以 7 左侧的柱子是 6,位置为 0。随后我们将 7 入栈。
栈:[6(0), 7(1)]
(3)我们枚举 5,由于 7≥5,因此移除栈顶元素 7。同样地, 6≥5,再移除栈顶元素 6。此时栈为空,所以 5 左侧的柱子是「哨兵」,位置为−1。随后我们将 5 入栈。
栈:[5(2)]
(4)接下来的枚举过程也大同小异。我们枚举 2,移除栈顶元素 5,得到 2 左侧的柱子是「哨兵」,位置为 −1。将 2 入栈。
栈:[2(3)]
(5)我们枚举 4,5 和 9,都不会移除任何栈顶元素,得到它们左侧的柱子分别是2,4 和 5,位置分别为 3,4 和 5。将它们入栈。
栈:[2(3), 4(4), 5(5), 9(6)]
(6)我们枚举 3,依次移除栈顶元素 9,5 和 4,得到 3 左侧的柱子是 2,位置为 3。将 3 入栈。
栈:[2(3), 3(7)]
这样一来,我们得到它们左侧的柱子编号分别为 [−1,0,−1,−1,3,4,5,3]。
用相同的方法,我们从右向左进行遍历,也可以得到它们右侧的柱子编号分别为 [2,2,3,8,7,7,7,8],这里我们将位置 8 看作右侧的“哨兵”。
在得到了左右两侧的柱子之后,我们就可以计算出每根柱子对应的左右边界,并求出答案了。
// 方法四:单调栈
public int largestRectangleArea4(int[] heights)
// 定义变量保存最大面积
int largestArea = 0;
// 定义两个数组,保存每个柱子对应的左右边界
int n = heights.length;
int[] lefts = new int[n];
int[] rights = new int[n];
// 定义一个栈
Stack<Integer> stack = new Stack<>();
// 遍历所有柱子,作为当前高度,先找左边界
for (int i = 0; i < n ; i ++)
while ( !stack.isEmpty() && heights[stack.peek()] >= heights[i] )
stack.pop();
// 所有大于等于当前高度的元素全部弹出,找到了左边界
lefts[i] = stack.isEmpty() ? -1 : stack.peek();
stack.push(i);
stack.clear();
// 遍历所有柱子,作为当前高度,寻找右边界
for (int i = n - 1; i >= 0; i --)
while ( !stack.isEmpty() && heights[stack.peek()] >= heights[i] )
stack.pop();
// 所有大于等于当前高度的元素全部弹出,找到了左边界
rights[i] = stack.isEmpty() ? n : stack.peek();
stack.push(i);
// 遍历所有柱子,计算面积
for (int i = 0; i < n; i++)
int currArea = (rights[i] - lefts[i] - 1) * heights[i];
largestArea = (currArea > largestArea) ? currArea : largestArea;
return largestArea;
复杂度分析
时间复杂度:O(N)。每一个位置元素只会入栈一次(在枚举到它时),并且最多出栈一次。因此当我们从左向右/从右向左遍历数组时,对栈的操作的次数就为 O(N)。所以单调栈的总时间复杂度为 O(N)。
空间复杂度:O(N)。用到了单调栈,大小为O(N)。
5.7 方法五:单调栈优化
当一个柱子高度比栈顶元素小时,我们会弹出栈顶元素,这就说明当前柱子就是栈顶元素对应柱子的右边界。所以我们可以只遍历一次,就求出答案。
// 方法五:单调栈优化
public int largestRectangleArea(int[] heights)
// 定义变量保存最大面积
int largestArea = 0;
// 定义两个数组,保存每个柱子对应的左右边界
int n = heights.length;
int[] lefts = new int[n];
int[] rights = new int[n];
// 初始化rights为右哨兵n
for (int i = 0; i < n; i ++) rights[i] = n;
// 定义一个栈
Stack<Integer> stack = new Stack<>();
// 遍历所有柱子,作为当前高度,先找左边界
for (int i = 0; i < n ; i ++)
while ( !stack.isEmpty() && heights[stack.peek()] >= heights[i] )
// 栈顶元素如果小于当前元素,那么它的右边界就是当前元素
rights[stack.peek()] = i;
stack.pop();
// 所有大于等于当前高度的元素全部弹出,找到了左边界
lefts[i] = stack.isEmpty() ? -1 : stack.peek();
stack.push(i);
// 遍历所有柱子,计算面积
for (int i = 0; i < n; i++)
int currArea = (rights[i] - lefts[i] - 1) * heights[i];
largestArea = (currArea > largestArea) ? currArea : largestArea;
return largestArea;
复杂度分析
时间复杂度:O(N)。只有一次遍历,同样每个位置入栈一次、最多出栈一次。
空间复杂度:O(N)。用到了单调栈,大小为O(N)。
以上是关于算法和数据结构解析-8 : 栈和队列相关问题的主要内容,如果未能解决你的问题,请参考以下文章