今天我们来讲一讲JS的一种数据结构——队列。
在聊队列这个概念之前,我们还是先来聊聊数组与栈的概念。
1. 数组与栈
我们先来回顾一下数组身上常用的一些方法。我们先定义一个数组arr = ['a', 'b', 'c', 'd', 'e']。如果我们想往数组尾部增加一个元素用push方法。
let arr = ['a', 'b', 'c', 'd', 'e']
arr.push('f')
console.log(arr);
往数组的头部增加一个元素用unshift方法。
let arr = ['a', 'b', 'c', 'd', 'e']
arr.push('f')
arr.unshift('m')
console.log(arr);
往数组的尾部删除一个元素用pop方法。这个方法直接在原数组身上更改,并且返回那个被删除的元素。
let arr = ['a', 'b', 'c', 'd', 'e']
arr.push('f')
arr.unshift('m')
console.log(arr.pop());
console.log(arr);
它会返回被删除的元素‘f’,并且更改了原数组。
往数组的头部删除一个元素用shift方法,并且和pop方法一样,它也是直接在原数组身上更改,并且返回那个被删除的元素。
let arr = ['a', 'b', 'c', 'd', 'e']
arr.push('f')
arr.unshift('m')
// console.log(arr.pop());
console.log(arr.shift());
console.log(arr);
数组身上还有两个常用的方法slice和splice,我们在拷贝那篇文章中提到过它们的区别。slice可以返回一个新数组并且不影响原数组;splice也会返回一个新数组但它会影响原数组。
我们也可以用splice往数组中插入一个元素。
let arr = ['a', 'b', 'c', 'd', 'e']
arr.splice(2, 0, 'o')
console.log(newArr);
splice的第一个参数表示从第几个下标开始切,第二个参数表示切几个。当第二个参数为0时,就表示切0个值,第三个参数就能插入到第一个参数表示的下标上去。
我们还一般用构造函数Array去创建一个数组,比如我们想创建一个长度为7的数组,但我们不知道想往里面加几个值时,我们可以这样写:
const arr = new Array(7)
console.log(arr);
console.log(arr.length);
它就能给我们返回一个长度为7的空数组。
如果我们想往这个空数组里放7个0,我们可以直接这样写。
const arr = new Array(7).fill(0)
console.log(arr);
数组的概念我们先回顾到这里。我们再来回顾一下栈。
我们说过,在JS中并没有栈这种数据结构。栈是我们人为的用数组去模拟的一种数据结构。
栈的特点是一端封闭一端开放,栈的值先进后出。
当我们用数组去模拟栈时,只能使用push方法、pop方法或者shift方法、unshift方法。它只允许一端进一端出,并且不允许往栈之间插入一个值。当我们想用下标去读值时,只能读到 arr[0] 或者 arr[length-1],也就是栈头的元素。
2.队列
栈就先聊到这。我们来聊聊我们今天的主角——队列。
其实,在JS中也没有队列这种数据结构,队列也是我们人为的用数组模拟的一种数据结构。
队列的特点就是先进先出。它的两端都是开发的,像一段管道一样。
当我们用数组去模拟队列时,只能使用push方法、shift方法或者pop方法、unshift方法。
我们定义一个queue为一个队列。我们用push往队列中增加一些值。
const queue = []
queue.push('辣椒炒肉')
queue.push('辣椒炒辣椒')
queue.push('黄豆鸡脚')
当我们想把队列中的值拿出来使用时,此时只能使用shift方法。我们能用下标去遍历吗?不行,因为对于队列,我们只能访问到队头或者队尾的值,我们访问不到中间的值,否则它就不是队列了。
const queue = []
queue.push('辣椒炒肉')
queue.push('辣椒炒辣椒')
queue.push('黄豆鸡脚')
while (queue.length) {
const top = queue.shift()
console.log(`我爱吃:${top}`);
}
我们将queue.length作为循环条件,就能将队列中所有的数据都取出来。此时只能使用shift方法去取队列中的值。
3.leetcode232
关于队列的概念其实就这些,接下来,我们来看几道关于队列的经典算法题。
我们来看一下力扣上的232题:
请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(`push`、`pop`、`peek`、`empty`):
实现 `MyQueue` 类:
- `void push(int x)` 将元素 x 推到队列的末尾
- `int pop()` 从队列的开头移除并返回元素
- `int peek()` 返回队列开头的元素
- `boolean empty()` 如果队列为空,返回 `true` ;否则,返回 `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
我们需要自己定义一个构造函数MyQueue,当我们new这个构造函数的时候就能得到一个队列。这个队列有这些方法能使用:push:往队列中增加元素;pop:移除并返回队头的元素;peek:返回队头的元素;empty:判断队列是否为空,如果为空返回true,否则返回false。
var MyQueue = function () {
};
MyQueue.prototype.push = function (x) {
};
MyQueue.prototype.pop = function () {
};
MyQueue.prototype.peek = function () {
};
MyQueue.prototype.empty = function () {
};
它已经帮我们写了了框架。首先,我们先来写MyQueue这个构造函数。因为我们是用两个栈模拟队列,所以在构造函数中我们定义两个栈即可。
var MyQueue = function () {
this.stack1 = []
this.stack2 = []
};
然后我们开始写push这个方法。
MyQueue.prototype.push = function (x) {
};
在写push这个方法之前,我们得先来理解一下怎么使用两个栈模拟一个队列。
我们知道,栈是先进后出的,一端封闭一端开放。而队列是先进先出的,两头都是开放的。如果我们先将所有的值都存放在栈1中,在取值的时候,再将栈1中的值全部取出来放到栈2中,此时取值是不是就能取到最先进去的那个值了。
比如:我们有一串值:1、2、3。我们先将这串值存放到栈1中,1就在最底下,然后是2,3在最开头。当要取值的时候,因为是队列,所以此时取到的值应该是1。所以我们就将栈1中的值全部取出来放到栈2中。此时栈1的头部元素是3,放到栈2中时就会放到最底部,然后是2,再然后是1,1就会跑到栈2的头部去。所以当我们要取队列中的值时,就去栈2中取,取到的就是1了,也就是我们想要的结果。
所以对于push方法,就很简单了。只要有值进入队列,我们不管三七二十一,先放到栈1再说,什么时候将它们放到栈2中去那是pop要干的事。
所以push方法就这样:
MyQueue.prototype.push = function (x) {
this.stack1.push(x)
};
只要有值进入就存放到栈1中。
然后我们写pop方法。pop方法是要取队列的值。此时我们就要将栈1中的值全部放到栈2中去,以便于我们能去到最先进入队列中那个值。
MyQueue.prototype.pop = function () {
while (this.stack1.length) {
this.stack2.push(this.stack1.pop())
}
};
我们将循环条件设为this.stack1.length,只要栈1中存在值pop取出来,再push到栈2中去。这样栈1中的值就能全部放到栈2中去。
但这样写就行了吗?此时会产生一个问题。如果我在取了一个值后又往队列中放一个值,此时我再要取值,能取到我们想要的值吗?
比如还是那个例子:我们有一串值:1、2、3。将它们入队列,首先我们会将它们放到栈1中去,然后我们要取值。我们就从栈1中取出1、2、3放到栈2中去,此时栈2头部元素为1,然后是2,最底部是3。因为我们取了一个值,1就会出栈。此时栈2头部就为2了,最底部是3。
此时取完值后我又想让一个4入队列,4就会被放到栈1中去,因为调用了push方法。
然后按照我们的要求,我们在放完值后又要取值,于是调用了pop方法。因为我们将循环条件设为了this.stack1.length,只要栈1中有值我们就会放到栈2中去。所以4就会被push到栈2中去。这时问题来了,现在栈2中只有2和3。2在头部,3在底部。按理来说我们要取值,下一个取到的应该就是2了,因为它是第二个进入队列的。但我们现在将4push到了栈2中取,此时栈2的头部就会使4了,当我们要取值的时候取到的就会4了而不是2。这显然不是我们想要的结果。
所以我们不能随便将栈1中的值放到栈2中去,应该加一个条件。当栈2为空的时候我们才能将栈1中的值放到栈2中去。所以那个4就不能放到栈2中去,此时要取值还是在栈2中取,先取2,再取3。等到栈2空了之后再将4放到栈2中去。这样才满足队列的顺序:1、2、3、4。
所以应该这样写:
MyQueue.prototype.pop = function () {
if (this.stack2.length <= 0) {
while (this.stack1.length) {
this.stack2.push(this.stack1.pop())
}
}
return this.stack2.pop()
};
当栈2的长度小于等于0也就是栈2空了之后,才将栈1中的值放到栈2中去。要取值的时候就去栈2中取,所以最后返回this.stack2.pop()。
然后我们再来写peek方法,peek方法不是取值,而是要返回队列开头的元素。要返回队列开头的元素也就是要返回栈2的栈顶元素。
MyQueue.prototype.peek = function () {
};
peek方法的开头应该和pop方法一样,因为要对队列开头的元素进行操作,所以就得将栈1的元素放到栈2中去,当然是在栈2为空的条件下。
MyQueue.prototype.peek = function () {
if (this.stack2.length <= 0) {
while (this.stack1.length) {
this.stack2.push(this.stack1.pop())
}
}
};
此时栈1的元素就到栈2中去了。我们要返回队列开头元素,就是要返回栈2开头元素。我们就可以用下标取。这时我们是用下标0还是下标length-1呢?因为我们在栈2身上用的是push方法,push方法是将元素放到数组的尾部,所以此时栈2开头元素的下标应该是length-1。
所以我们返回栈2下标为length-1的元素就行了。
MyQueue.prototype.peek = function () {
if (this.stack2.length <= 0) {
while (this.stack1.length) {
this.stack2.push(this.stack1.pop())
}
}
const len = this.stack2.length
return this.stack2[len - 1]
};
这样我们就完成了peek方法。还有最后一个方法empty。这个方法的作用是判断队列是否为空。那这就很简单了。我们计算两个栈的长度看看是否为0就行了。
如果两个数组的长度之和有值,就表示队列不为空,就取反返回false。
MyQueue.prototype.empty = function () {
return !(this.stack1.length + this.stack2.length)
};
这样整个代码我们就完成了。
var MyQueue = function () {
this.stack1 = []
this.stack2 = []
};
MyQueue.prototype.push = function (x) {
this.stack1.push(x)
};
MyQueue.prototype.pop = function () {
if (this.stack2.length <= 0) {
while (this.stack1.length) {
this.stack2.push(this.stack1.pop())
}
}
return this.stack2.pop()
};
MyQueue.prototype.peek = function () {
if (this.stack2.length <= 0) {
while (this.stack1.length) {
this.stack2.push(this.stack1.pop())
}
}
const len = this.stack2.length
return this.stack2[len - 1]
};
MyQueue.prototype.empty = function () {
return !(this.stack1.length + this.stack2.length)
};
4. leetcode239
我们再来看一道力扣上关于队列的题,力扣第239题。
给你一个整数数组 `nums`,有一个大小为 `k` 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 `k` 个数字。滑动窗口每次只向右移动一位。
返回 滑动窗口中的最大值 。
示例 1:
输入: nums = [1,3,-1,-3,5,3,6,7], k = 3
输出: [3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
--------------- -----
[1 3 -1] -3 5 3 6 7 3
1 [3 -1 -3] 5 3 6 7 3
1 3 [-1 -3 5] 3 6 7 5
1 3 -1 [-3 5 3] 6 7 5
1 3 -1 -3 [5 3 6] 7 6
1 3 -1 -3 5 [3 6 7] 7
示例 2:
输入: nums = [1], k = 1
输出: [1]
就是说我们有一个数组[1,3,-1,-3,5,3,6,7],还有一个长度为3的滑动窗口,它会框住数组的3个数,每次只会往右走一格。求框住的3个数中的最大值。
let nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3 //[3,3,5,5,6,7]
var maxSlidingWindow = function (nums, k) {
const res = []
return res
};
我们定义一个函数maxSlidingWindow,当我们将nums和k传进去时能给我们返回一个数组。所以首先有一个空数组res。最后要返回这个数组。
我们怎么运用队列的思想解决这道题呢?
在开始思考之前我们还得介绍一种特殊的队列——双端队列。普通的队列只能一端进一端出,而双端队列可以这边进这边出或这边进那边出。就是可以任意端口进任意端口出。
我们可以使用双端队列解决这道问题。
我们来这样想一想:窗口一开始框住的是[1, 3, -1],此时最大值为3。当窗口向右移动一位,框住的就是[3, -1, -3],此时最大值还是3。那如果我们能知道出去的值和后面进来的值都比3要小,那第二波是不是就不用求最大值了,最大值还是3吧。
所以如果我们只对当前变化的元素来对决定是否更新最大值,时间复杂度就会降低很多。
我们来维护一个队列deque,当读到1时,我们将1存进去;当读到3时,发现3比1大,就让1出去,3进来,此时队头元素为3,最大值也为3;当读到-1时,发现-1比3小,就将-1存进去,放在3后面,此时队头元素还是3,最大值还是3;当读到-3时,队列里是3、-1,我们发现-3比-1要小,我们就让-3入队,排在-1后面;当读到5时,3已经不在窗口里了,所以要让3出队,队列里只剩-1、-3,要进来的是5,那就-1、-3都出列,5进来排到队头,此时最大值还是这个队头5;当读到3时,3比5小,进队,最大值还是5;当读到6时,队列里是5、3,都比6小,就让5、3出队列,6进来排到队头,此时最大值还是队头元素6;当读到7时,6比7小,就让6出列,7进来拍到队头,队头元素还是最大值。
我们发现这样操作我们维护的是一个长度小于等于3的单调递减的队列,队头永远是最大值,我们只要在每次移动的时候将队头元素push到res中去,我们就能得到一个全是最大值的数组了。
所以我们这样写,我们需要遍历原数组,定义一个变量len获取数组nums的长度,定义一个队列deque。
let nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3 //[3,3,5,5,6,7]
var maxSlidingWindow = function (nums, k) {
const len = nums.length
const res = []
const deque = []
return res
};
然后用for循环遍历原数组:
let nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3 //[3,3,5,5,6,7]
var maxSlidingWindow = function (nums, k) {
const len = nums.length
const res = []
const deque = []
for (let i = 0; i < len; i++) {
}
return res
};
当读到1时,先放入到队列中。当读到3时,因为我们要维护一个单调递减的队列,所以我们得判断队尾元素是否比3小,当此时队尾元素比3小时,我们就将这个队尾元素移除,再去判断下一个队尾元素是否比3小,直到队尾元素比3大或者队列中没有元素时,我们就不执行移除队尾元素操作,将3入队。所以我们得写一个循环语句,当队列中有元素且队尾元素比此时要进来的元素小时,就执行去除队尾元素操作。
let nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3 //[3,3,5,5,6,7]
var maxSlidingWindow = function (nums, k) {
const len = nums.length
const res = []
const deque = []
for (let i = 0; i < len; i++) {
while (deque.length && nums[deque[deque.length - 1]] < nums[i]) {
deque.pop()
}
deque.push(i)
}
return res
};
我们在这里往队列中存的是此时的下标,到时候我们直接拿着下标去数组中找值。nums[deque[deque.length - 1]] 表示的就是队尾元素。 因为我们往队列中存的是下标,deque[deque.length - 1] 表示的就是那个下标,然后拿着它去数组中找值。
还有一点,队列的长度一定要小于等于3。因为窗口长度为3,所以当队列长度大于3时,即使队头元素是最大值也要将它移除。
let nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3 //[3,3,5,5,6,7]
var maxSlidingWindow = function (nums, k) {
const len = nums.length
const res = []
const deque = []
for (let i = 0; i < len; i++) {
while (deque.length && nums[deque[deque.length - 1]] < nums[i]) {
deque.pop()
}
deque.push(i)
// 当队列头部存放的值 和 i 形成的区间大于窗口宽度时
while (deque.length && deque[0] <= i - k) {
deque.shift()
}
}
return res
};
比如此时队列中为[3, -1, -3],当读到5时,3的下标为1,5的下标为4。当读到5的时候3已经不在窗口里了,要将3移除,所以循环条件为deque[0] <= i - k。
那什么时候往数组res中push最大值也就是push队头元素呢?当读到数组中下标为2时就应该执行push操作了。
let nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3 //[3,3,5,5,6,7]
var maxSlidingWindow = function (nums, k) {
const len = nums.length
const res = []
const deque = []
for (let i = 0; i < len; i++) {
while (deque.length && nums[deque[deque.length - 1]] < nums[i]) {
deque.pop()
}
deque.push(i)
// 当队列头部存放的值 和 i 形成的区间大于窗口宽度时
while (deque.length && deque[0] <= i - k) {
deque.shift()
}
// 该取最大值
if (i >= k - 1) {
res.push(nums[deque[0]])
}
}
return res
};
这样我们就写完了整段代码,我们来运行一下看看结果:
成功的得到了我们想要的结果。