队列
队列是一种“先进先出”的线性数据结构。可以从右端插入元素,左端弹出元素。所以第一进去的元素必然第一个出来。
上图就是一个队列,队列里面有三个元素。外面有两个元素,其中1 号元素刚从队列里面出来,6 号元素是要进到队列里面。
了解了队列的基本原理,我们们也可以用数组来模拟队列:
- 定义数组和队列首尾位置,
l
为队首,r
为队尾,一开始队列是空的 :int queue[SIZE], l = 1, r = 0;
- 入队列
x
:queue[++r] = x
- 访问队首元素:
queue[l]
- 出队列 :
l++;
访问队列元素的时候,要提前判断一下队列里面有没有元素,如果有才能返回队列元素。另外也可以直接计算出来队列里面的元素个数:r-l+1
.
循环队列
如果直接用数组模拟队列的话,队尾指针不断增大,队首指针不断增大,所以会浪费很多的空间。
想要解决这个问题,可以把队列想象成一个环。即下标为 0 的位置是最后一个位置的后继。两个指针都在环里面移动,这样就会复用以前的空间了。
假设现在队列一共有 size
个空间,尾指针的位置是 pos
, 如果添加一个元素,尾指针的位置就会变成 pos + 1 % size
,
用循环队列需要注意的一点就是要保证队列元素的个数在任意时刻都不大于环的长度。
单调队列
在单调队列中,元素的值是有顺序的,可以是单调递增的,也可以是单调递减的。基于这个性质,我们可以解决一些问题。
例题:
给定一个数组 nums
和滑动窗口的大小 k
,请找出所有滑动窗口里的最大值。
示例:
输入: 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
首先使用暴力的方法,代码如下。
int n = nums.size(); // 得到数组的长度。
for (int i = 0; i < n; ++i){ // 枚举右端点的位置
int ans = nums[i]; // 初始化区间答案
if (i >= k - 1){ // 判断右区间是不是合法。 [0, k-1] 正好 k 个元素。
for (int j = i - k + 1; j <= i; j++) // 确定左区间的位置,遍历,然后找最大值。
ans = max(ans, nums[j]);
}
}
这种暴力的做法,时间复杂度是 O ( n ∗ k ) O(n*k) O(n∗k) 的,n 是数组的长度, k 是区间的长度,所以 k 越大时间复杂度就越大。
下面来讨论一下更加优雅的做法,那就是单调队列:
首先强调一下,单调队列的基础不是一个简单的队列,它类似双端队列,即左右都可以出队。
单调队列的步骤:
1、初始化一个双端队列,也就是用数组que
来模拟队列的操作,队列里面存放的是每个数的位置。初始化队列的队首队尾的位置 left = 1, right = 0
, 代表现在队列里面没有元素,只有 right >= left
的时候,队列才有元素。 que[left]
que[right]
分别代表滑动窗口的左右位置。
2、滑动窗口的右端点向右移动一位,就是新增一个元素。拿新增的元素不断的和队尾代表的元素比较,如果新元素大于等于队尾代表的元素,那么队尾要弹出一个元素。直到队列没有元素或者新元素大于队尾所代表的元素。然后 right++
,把新元素的位置加入到队列。 想一下这样为什么可以,当滑动窗口右移一位,新增加一个元素y
,y
大于左边的一个元素x
,在滑动窗口接下来的右移过程中 x
永远不能成为最大值,至少 y
比 x
大,所以 x
就可以直接删除了。
3、滑动窗口的左端点向右移动。每次要保持窗口里面的元素个数是 k 个,既然滑动窗口的右端点移动了,左端点也要移动。
4、得到答案。如果 right >= k-1
, 代表前面有一个窗口的大小,那么就需要记一下答案,答案就是队首所代表的元素。
5、一直循环 2 3 4,直到所有元素都添加进队列。
当滑动窗口右移一位,加入一个元素 x
的时候。会不断弹出来队尾小于等于 x
的元素,所以 x
放在大于 x
的元素的后面。加入的每个值都是这样,那么队列里面代表的元素是单调递减的,队首就代表最大的元素。
如果文字不能理解的话,可以看下面的图再加深理解。
红色方框里面就是队列里面所代表的值。为了方便理解,直接把元素放到队列里面了。实际上队列里面放的是每个元素在数组中的位置。
加入数字 1, 队列里面就一个数字 1.
加入数字 2, 队尾元素小于 3, 把队尾元素弹出,所以队列里面元素只有一个 3
加入数字 -1, 队尾元素大于 -1, 直接放到队尾就可以了, 队列里面的元素是 3 、-1,窗口最大值为 3.
加入数字 -3, 队尾元素大于 -3, 也是直接放到队尾。 队列里面元素是 3、-1、-3, 窗口最大值是 3
加入数字 5, 队列元素都小于 5, 队列所有值依次弹出。然后 5 放到队尾, 队列元素是 5, 窗口最大值是 5
加入元素 3, 队尾元素大于 3, 直接放入队尾就可以, 队列元素是 5、3, 窗口最大值是 5
加入数字 6, 队列元素都小于 6, 队列所有值依次弹出。然后 6 放到队尾, 队列元素是 6, 窗口最大值是 6
加入数字 7, 队列元素都小于 7, 队列所有值依次弹出。然后 7 放到队尾, 队列元素是 7, 窗口最大值是 7
上面的图只是粗略的描述了一下过程,具体还是看下面的代码来理解。结合下面的代码,如果自己可以在纸上画一个具体的流程出来。那就说明已经能够理解了。
int n = nums.size(); // 得到数组的长度。
int que[SIZE], left = 1, right = 0;
// 步骤一 初始化。 定义数组模拟队列, 定义队列的左右端点。
for (int i = 0; i < n; ++i){ // 每次加入一个元素。
while (left <= right && nums[que[right]] <= nums[i]) right--; // 步骤二。 是否弹出队尾元素,注意是 while 循环。
que[++right] = i; // 步骤二。 弹出队尾元素之后要加入当前元素的位置。
while (left <= right && (que[right] - que[left] + 1) > k) left++; // 步骤三, 判断队列的左端点是不是要移动。确保窗口的大小是 k。
if (i >= k-1) ans.push_back(nums[que[left]]); // 步骤四, 记录答案。
}
单调队列的模板很好写。 无外乎就是几个步骤, 框架是死的,但是内容是活的。细节需要自己推敲。
上面的代码就是最简单的单调队列代码,其他的题都是在其基础上修改。所以一定要把上面的代码吃透了,理解其中每一句的意义。