Kiner算法刷题记(十三):单调队列(数据结构基础篇)

系列文章导引

开源项目

本系列所有文章都将会收录到GitHub中统一收藏与管理,欢迎ISSUEStar

GitHub传送门:Kiner算法算题记

从RMQ问题开始

RMQ问题(Range Minimum/Maximum Query),即区间最大/最小值求解问题:对于长度为n的数列A,回答若干询问RMQ(A,i,j)(i,j<=n),返回数列A中下标在i,j里的最小(大)值,也就是说,RMQ问题是指求区间最值的问题。

RMQ(x,y),就是求解一个数组在(x,y)区间内的最小值

如:arr = [2,3,1,4,9,0,5,6,8,7];

RMQ(2,5) = 1

因为(2,5)的区间值有:1,4,9,0,因此,此区间内的最小值是0

思考:

RMQ(x,9)=?

即:询问区间的尾部是固定在索引为9的位置,而起始位置不确定,请问我们至少要记录上述数组中的多少个元素才能满足RMQ(x,9)的任意需求,即x可以是相对于该数组来说合法的任意索引。

解析:

我们先来暴力分析一下:

x=0|1|2|3|4|5: 最小值为0

x=6: 最小值为5

x=7: 最小值为6

x=8|9: 最小值为7

从上面的暴力分析我们呢可以看出,我们至少需要记录0,5,6,7这四个值,我们就可以满足RMQ(x,9)的任何需求。

那么,我们可以将上面的4个特殊值放到一个额外的数据结构中存储,存储时需保证这些数据的相对位置不变,即:

arr2 = [0,5,6,7]

从arr2中可以看出,我们这个序列是单调递增并且相对位置与原序列保持一致的。由于需要保持相对位置,所以元素进入的顺序肯定是:0 -> 5 -> 6 -> 7,大家有没有发现,我们元素进入的顺序跟之前学过的队列是一样的,从尾部进入,并且,这个队列还有一个特性,就是他里面的元素是单调的(单调递增或者单调递减),这就是我们今天要了解的进阶数据结构单调队列

单调队列解决的问题

从上面的RMQ问题我们其实应该大概能猜到了,单调队列经常用于维护区间的最值。不知道大家还记不记得之前还学过一个用于维护集合最值的神兵利器:堆(大顶堆、小顶堆)。这两个数据结构都用于维护最值,但是使用的场景稍有不同,我们今天说的单调队列是用于维护一个大集合中某一段区间内的最值,而通常适用于维护整个集合的最值。

  • 维护区间最小值的队列一定是单调递增队列
  • 维护区间最大值的队列一定是单调递减队列

单调队列维护区间最值模拟

# 还是以上面的数组为例:arr = [2,3,1,4,9,0,5,6,8,7]
# 假设我们要维护的区间长度为3(区间长度具体多少,取决于RMQ问题区间的起始点和终点,当起始点和终点重合时,我们的窗口长度为1,即每次窗口中只有一个元素,那么这个元素肯定就是当前窗口的最小值),我们可以使用之前说过的滑动窗口概念进行模拟

      ┏━━━━━━━┓
元素: 2   3   1   4   9   0   5   6   8   7
      ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛
索引: 0   1   2   3   4   5   6   7   8   9
      ┗━━━━━━━┛
# 初始时,我们的滑动窗口处于索引0~2,首先,我们先将第一个元素放入我们的单调队列中
queue = [2]
# 再放入第二个元素时,我们需要确保我们这个队列的单调递增的性质,由于第二个元素是3,满足单调递增性质,因此继续加入队列
queue = [2,3]
# 在加入第三个元素时,因为元素是1,明显已经违反了队列单调递增的性质,此时,我们需要将队列前面所有违反单调递增性质的元素删掉,然后再将第三个元素放入1中,明显,2和3都是比1大,都违反了单调递增性质,全部删掉,此时队列中只剩下新加入的1
queue = [1]
# 当我们把滑动窗口内部的元素都处理完后,然规滑动窗口向后移动一位

          ┏━━━━━━━┓
元素: 2   3   1   4   9   0   5   6   8   7
      ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛
索引: 0   1   2   3   4   5   6   7   8   9
          ┗━━━━━━━┛
# 此时,新增了一个元素4,因为4与1满足单调递增的性质,因此,将4加入队列,并继续让滑动窗口后移
queue = [1,4]

              ┏━━━━━━━┓
元素: 2   3   1   4   9   0   5   6   8   7
      ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛
索引: 0   1   2   3   4   5   6   7   8   9
              ┗━━━━━━━┛
# 新增元素9不违反队列单调性,加入队列,窗口后移
queue = [1,4,9]


                  ┏━━━━━━━┓
元素: 2   3   1   4   9   0   5   6   8   7
      ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛
索引: 0   1   2   3   4   5   6   7   8   9
                  ┗━━━━━━━┛
# 注意,此时由于窗口的滑动,我们之前的最小值已经出了滑动窗口了,此时我们应该让1出队列,我们可以先用一个临时数据结构保存着这个值,毕竟这也是我们曾今的王(最小值)嘛,总要有点特殊待遇的,即使退位让贤了,也要拥有崇高的地位
# tmp用于保存因移出了滑动窗口而出队的最小值,注意,tmp也是一个单调递增队列,也需要维护单调递增的特性
tmp = [1]
# 此时,因为新进来的元素是0,由于4,9和0违反了单调性,将4、9删除,加入0,窗口后移
queue = [0]

                      ┏━━━━━━━┓
元素: 2   3   1   4   9   0   5   6   8   7
      ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛
索引: 0   1   2   3   4   5   6   7   8   9
                      ┗━━━━━━━┛
# 新增元素5不违反队列单调性,加入队列,窗口后移
tmp = [1]
queue = [0,5]

                          ┏━━━━━━━┓
元素: 2   3   1   4   9   0   5   6   8   7
      ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛
索引: 0   1   2   3   4   5   6   7   8   9
                          ┗━━━━━━━┛
# 新增元素6不违反队列单调性,加入队列,窗口后移
tmp = [1]
queue = [0,5,6]

                              ┏━━━━━━━┓
元素: 2   3   1   4   9   0   5   6   8   7
      ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛
索引: 0   1   2   3   4   5   6   7   8   9
                              ┗━━━━━━━┛
# 新增元素6不为饭队列单调性,加入队列,窗口后移
tmp = [1]
queue = [0,5,6]
# 此时我们队列的最小值0已经移动到滑动窗口之外了,出队列加入临时数组,新加入元素为8,不违反单调性,加入队列,窗口后移
# 由于tmp中0和1违反了单调递增的特性,因此,删除1,仅保留新增加的0
tmp = [0]
queue = [5,6,8]


                                  ┏━━━━━━━┓
元素: 2   3   1   4   9   0   5   6   8   7
      ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛
索引: 0   1   2   3   4   5   6   7   8   9
                                  ┗━━━━━━━┛
# 新增元素7与8违反队列单调性,删除8,7加入队列,此时窗口已经到了最后,结束
tmp = [0]
queue = [5,6,7]

# 最终我们可以发现我们把tmp和queue中的左右元素放在一起,结果就是我们上面暴力解析出来的答案:0,5,6,7

从上面的模拟中,我们也可以看出,其实单调队列维护最值问题的时候,其实就是在计算RMQ(x,9)即固定末尾的问题的必要元素。

# 验证一下,现在要求RMQ(x,6),根据上面模拟的分析
                      ┏━━━━━━━┓
元素: 2   3   1   4   9   0   5   6   8   7
      ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛
索引: 0   1   2   3   4   5   6   7   8   9
                      ┗━━━━━━━┛
# 新增元素5不违反队列单调性,加入队列,窗口后移
tmp = [1]
queue = [0,5]

# 结果是1,0,5,但是1和0明显违反了单调递增的特性,按照约定,删除1,最终结果应该为0,5


# 验证一下,现在要求RMQ(x,3),根据上面模拟的分析,根据上面的模拟分析

          ┏━━━━━━━┓
元素: 2   3   1   4   9   0   5   6   8   7
      ┗━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┻━━━┛
索引: 0   1   2   3   4   5   6   7   8   9
          ┗━━━━━━━┛
# 此时,新增了一个元素4,因为4与1满足单调递增的性质,因此,将4加入队列,并继续让滑动窗口后移
queue = [1,4]
# 最终结果很明显是1,4

# 至此,我们可以验证我们的分析过程是没有问题的。

单调队列的基本操作与性质

入队操作(维护元素单调性)

从队尾入队,入队的同时,会把队列中破坏队列单调性的元素删掉(从队尾移除),以此来维护队列的单调性

出队操作(维护元素生命周期)

如果元素超出区间范围,就将元素从队首出队

性质

队首元素永远是当前维护区间的最值(最大或最小),维护区间最小值用单调递增序列,维护区间最大值用单调递减队列

使用单调队列实现滑动窗口求最小值问题

/**
 * 获取一个大小为k滑动窗口内部元素最小值
 * @param {Array<number>} arr 待选数组
 * @param {number} k 滑动窗口大小
 * @returns {Array<number>}
 */
function moveWindow(arr, k) {
    const minQueue = [];
    let res = [];
    for(let i=0;i<arr.length;i++) {
        // 将队尾所有违反单调性的搅屎棍踢出去,即弹出队尾所有比当前要压入队列的元素大的数
        while(minQueue.length && minQueue[minQueue.length-1].data > arr[i]) minQueue.pop();
        // 搅屎棍踢出去了,就有位置让新人进来了,为了方便知道你个元素的位置,队列中存储的是索引与值的对象
        minQueue.push({idx: i, data: arr[i]});
        // 当元素已经移出了滑动窗口时,需要将元素从队首弹出
        if(i-minQueue[0].idx === k) minQueue.shift();
        // 如果滑动窗口无法再向后滑动,则继续
        if(i+1<k) continue;
        // 否则将队首元素压入结果数组
        res.push(minQueue[0].data);
    }
    return res;
}

console.log(moveWindow([1,3,-1,-3,5,3,6,7], 3));// [ -1, -3, -3, -3, 3, 3 ]

使用单调队列判断两个序列趋势相同的子序列

/**
 * 寻找两个数组中长度最长的趋势相同的子序列的长度
 * @description 
 * 𝑢,𝑣 两个序列趋势相同,当且仅当对于任意 𝑙 和 𝑟,均有 𝑅𝑀𝑄(𝑢,𝑙,𝑟)=𝑅𝑀𝑄(𝑣,𝑙,𝑟) (1≤𝑙≤𝑟≤𝑛),
 * 
 * ​其中 𝑛 是序列长度,𝑅𝑀𝑄(𝑢,𝑙,𝑟) 是 𝑢 序列从 𝑙 到 𝑟 中的最小值(有可能有多个最小值)的最大下标。
 * 
 * ​现有两个序列 𝐴={𝑎1,𝑎2,𝑎3,…,𝑎𝑛},𝐵={𝑏1,𝑏2,𝑏3,…,𝑏𝑛} 两个序列
 * 
 * ​请求出最大的 𝑝,使得𝐴‘={𝑎1,𝑎2,𝑎3,…,𝑎𝑝} 与𝐵‘={𝑏1,𝑏2,𝑏3,…,𝑏𝑝} 趋势相同。
 * 
 * @param {number[]} arr1 
 * @param {number[]} arr2 
 * @returns 
 */
function twinSequence(arr1, arr2) {
    // 既然题目已经给出𝑅𝑀𝑄(𝑢,𝑙,𝑟)=𝑅𝑀𝑄(𝑣,𝑙,𝑟) (1≤𝑙≤𝑟≤𝑛)的条件,那么我们的固定末尾的RMQ(x,n)应该也是满足这个条件的
    // 所以,我们只要把数组中的每个元素都压入到单调队列当中,然后再看一下压入后两个单调队列长度是否相同即可,如果不相同,则说明趋势不同了,直接返回p
    let p;
    let q1 = [], q2 = [];
    for(p=0;p<arr1.length;p++) {
        while(q1.length && q1[q1.length-1] > arr1[p]) q1.pop();
        while(q2.length && q2[q2.length-1] > arr2[p]) q2.pop();
        q1.push(arr1[p]);
        q2.push(arr2[p]);
        // 分别将两个数组的元素加入到单调队列,直到两个单调队列的长度不相等时截止
        if(q1.length!==q2.length) break;
    }

    return p;
}
console.log(twinSequence([3,1,5,2,4], [5,2,4,3,1]));// 4
console.log(twinSequence([3,1,5,2,4], [5,2,4,3,6]));// 5
// 从上面可以看出,如果子序列长度与原序列长度相等,则说明两个原序列的趋势完全相同
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

星河阅卷

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值