【算法竞赛】队列

队列相关概念

队列中的数据存取方式是“先进先出”,只能向队尾插入数据,从队头移出数据.

队列的原型在生活中很常见,如食堂打饭的队伍,先到先服务.队列有两种实现方式:链队列和循环队列,如图1.2所示.
在这里插入图片描述
链队列可以看作单链表的一种特殊情况,用指针把各个节点连接起来.

循环队列是一种顺序表,使用一组连续的存储单元依次存放队列元素,用两个指针head和tail分别指示队头元素和队尾元素,当head和tail走到底时,下一步回到开始的位置,从而在这组连续空间内循环.

循环队列能解决溢出问题.如果不循环,head和tail都一直往前走,可能会走到存储空间之外,导致溢出.

队列和栈的主要问题是查找较慢,需要从头到尾一个个查找,在某些情况下可以用优先队列,让优先级最高(最大的数或最小的数)先出队列.

竞赛中一般用STLqueue或手写静态数组实现队列.

STL queue

STLqueue的主要操作如下:

(1)queue< Type >q: 定义队列,Type为数据类型,如int、float、char等.
(2)q.push(item): 把item放进队列.
(3)q.front(): 返回队首元素,但不会删除.
(4)q.pop(): 删除队首元素.
(5)q.back(): 返回队尾元素.
(6)q.size(): 返回元素个数.
(7)q.empty():检查队列是否为空.

例1.2 机器翻译
在这里插入图片描述
在这里插入图片描述
下面是STLqueue的代码,由于不用自己管理队列,代码很简洁.
注意检查内存中是否有单词的方法.如果一个一个地暴力搜索,太慢;如果用哈希算法,不仅很快,而且代码简单.

#include<bits/stdc++.h>
using namespace std;

int main() {
    int m, n;
    scanf("%d %d", &m, &n);
    queue<int> mem;
    int Hash[1003] = {0};
    int cnt = 0;
    while (n--) 
    {
        int en;
        scanf("%d", &en);
        if (!Hash[en]) 
        {
            ++cnt;
            mem.push(en);
            Hash[en] = 1;
            while (mem.size() > m) 
            {
                Hash[mem.front()] = 0;
                mem.pop();
            }
        }
    }
    printf("%d\n", cnt);
    return 0;
}

手写循环队列

下面是循环队列的手写模板.

代码中给出了静态分配空间和动态分配空间两种方式(动态分配实现放在注释中).

竞赛中一般用静态分配.

#include<bits/stdc++.h>
// 定义队列大小
#define N 1003 
// 用哈希检查内存中有没有单词
int Hash[N]={0}; 

// 定义循环队列结构体
struct myqueue
{
    int data[N];
    // 如果动态分配,可以这样写: int * data;
    int head;
    int rear;

    // 初始化队列
    bool init()
   {
        // 如果动态分配,这样写: Q.data=(int*) malloc(N * sizeof(int));
        data = new int[N];
        if (!data) 
        	return false;
        head = rear = 0;
        return true;
    }

    // 返回队列长度
    int size() 
    {
        return (rear - head + N) % N;//重点
    }

    // 判断队列是否为空
    bool empty() 
    {
        return size() == 0;
    }

    // 队尾插入新元素
    bool push(int e) 
    {
        if ((rear + 1) % N == head) 
        	return false;
        	
        data[rear] = e;
        rear = (rear + 1) % N;
        return true;
    }

    // 删除队头元素,并返回它
    bool pop(int &e) 
    {
        if (head == rear) 
        	return false;
        	
        e = data[head];
        head = (head + 1) % N;//重点-循环链表的表征
        return true;
    }

    // 返回队首,但不删除
    int front() {
        return data[head];
    }
};

int main() {
    Queue Q;
    Q.init();
    int m, n;
    std::cin >> m >> n;
    int cnt = 0;
    while (n--) {
        int en;
        std::cin >> en;
        if (!Hash[en]) 
        {
            // 如果内存中没有这个单词
            ++cnt;
            Q.push(en);
            Hash[en] = 1;
            while (Q.size() > m) {
                int tmp;
                Q.pop(tmp);
                Hash[tmp] = 0;
            }
        }
    }
    std::cout << cnt << std::endl;
    return 0;
}

双端队列和单调队列

概念

前面的队列很“规矩”,队列的元素都是“先进先出”,队头只能弹出,队尾只能进入.

有没有不那么规矩的队列呢?

这就是双端队列:双端队列是一种具有队列和栈性质的数据结构,它能在两端进行插入和删除,而且也只能在两端插入和删除.

更可靠的编码可以用STL的双端队列deque,它的用法如下。

(1)dq[i]:返回队列中下标为i的元素。
(2)dq.front():返回队头。
(3)dq.back():返回队尾。
(4)dq.pop_back():删除队尾,不返回值。
(5)dq.pop_front():删除队头,不返回值。
(6)dq.push_back(e):在队尾添加一个元素e。
(7)dq.push_front(e):在队头添加一个元素e。

双端队列的经典应用是单调队列。
单调队列中的元素是单单调有序的,且元素在队列中的顺序和原来在序列中的顺序一致;单调队列的队头和队尾都能入队和出队。

提示:
灵活运用单调队列能够使很多问题的求解获得优化。

其原理可以简单地概括为:
序列中的几个元素,用单调队列处理时,每个元素只需要进出队列一次,复杂度为O(n)。

单调队列与滑动窗口

下面介绍单调队列的基本应用,了解如何通过单调队列获得优化。
注意队列中"删头、去尾、窗口"的操作。

例1.3 滑动窗口

在这里插入图片描述
本题用暴力法很容易编程:从头到尾扫描,每次检查处个数,一共检查O(nk)次。
暴力法显然会超时。

下面用单调队列求解,它的复杂度为O(n)。

在本题中,单调队列有以下特征:
(1)队头的元素始终是队列中最小的。根据需要输出队头,但是不一定弹出。
(2)元素只能从队尾进入队列,从队头、队尾都可以弹出。
(3)序列中的每个元素都必须进入队列。
例如,x进入队尾时,和原队尾y比较,如果x<=y,就从队尾弹出y;一直到弹出队尾所有比x大的元素,最后x进入队尾。这个入队操作保证了队头元素是队列中最小的。

单调队列的时间复杂度:每个元素最多入队一次、出队一次,且出入队时间都为O(1),
因此总时间为O(n)。
因为题中需要逐一处理所有几个数,所以O(n)已经是能达到的最优复杂度。

从以上过程可以看出,单调队列有两个重要操作:删头、去尾。
(1)删头:如果队头的元素脱离了窗口,这个元素就没用了,弹出它。
(2)去尾:如果新元素进队尾时,原队尾的存在破坏了队列的单调性,就弹出它。
在这里插入图片描述

#include <bits/stdc++.h>
using namespace std;
const int N = 1000005;

int main() {
    int n, m;
    scanf("%d%d", &n, &m);
    int a[N];
    deque<int> q;

    // 读取序列元素
    for (int i = 1; i <= n; i++) 
    {
        scanf("%d", &a[i]);
    }

    // 输出每个窗口中的最小值
    for (int i = 1; i <= n; i++)
    {
        // 去尾操作,保持队列中的元素对应的值单调递减
        while (!q.empty() && a[q.back()] > a[i]) 
        	q.pop_back();
        q.push_back(i);
        if (i >= m) 
        {
            // 输出当前窗口中的最小值
            while (!q.empty() && q.front() <= i - m) 
            	q.pop_front();
            printf("%d ", a[q.front()]);
        }
    }
    printf("\n");

    // 清空队列,准备用于求最大值
    while (!q.empty()) 
    	q.pop_front();

    // 输出每个窗口中的最大值
    for (int i = 1; i <= n; i++) 
    {
        // 去尾操作,保持队列中的元素对应的值单调递增
        while (!q.empty() && a[q.back()] < a[i]) 
        	q.pop_back();
        	
        q.push_back(i);
        if (i >= m) {
            // 输出当前窗口中的最大值
            while (!q.empty() && q.front() <= i - m) 
            	q.pop_front();
            printf("%d ", a[q.front()]);
        }
    }
    printf("\n");

    return 0;
}

单调队列与最大子序和问题

首先说明什么是子序和:
给定长度为n的整数序列A,它的"子序列"定义为A中非空的一段连续的元素。
例如:
序列(6,-1,5,4,一7),前4个元素的子序和为6+(-1)+5+4=14。

最大子序和问题按子序列有无长度限制分为两种:

问题(1):不限制子序列的长度。
在所有可能的子序列中找到一个子序列,该子序列和最大。

问题(2):限制子序列的长度。
给定一个限制长度m,找出一段长度不超过m的连续子序列,使它的子序和最大。

问题(1)比较简单,用贪心法或动态规划(Dynamie Programming,DP)算法,复杂度都为O(n)。
问题(2)用单调队列,复杂度也为O(n)。通过这个例子,读者可以理解为什么单调队列能用于DP优化。

问题(1)不是本节的内容,不过为了参照,下面也给出是题解:

问题(1)的求解

用贪心法或DP,在O(n)时间内求解。下面是例题。
在这里插入图片描述
题解1: 贪心法
逐个扫描序列中的元素并累加。加一个正数时,子序和会增加;加一个负数时,子序和会减小。
如果当前得到的和变成了负数,这个负数在接下来的累加中会减少后面的求和,所以抛弃它,从下一位置开始重新求和。

#include <bits/stdc++.h>
using namespace std;
const int INF = 0x7fffffff;

int main() 
{
    int t;
    cin >> t;
    // 处理每个测试用例
    for (int i = 1; i <= t; i++) 
    {
        int n;
        cin >> n;
        // 最大子序和,初始化为极小负数
        int maxsum = -INF;
        
        // 起点、终点、扫描位置
        int start = 1, end = 1, p = 1;
        int sum = 0;
        
        // 遍历输入的序列
        for (int j = 1; j <= n; j++) 
        {
            int a;
            cin >> a;
            sum += a;
            // 如果当前子序和大于最大子序和,更新最大子序和及起点和终点位置
            if (sum > maxsum) 
            {
                maxsum = sum;
                start = p;
                end = j;
            }
            // 如果当前子序和小于 0,从下一个位置重新开始求和
            if (sum < 0) 
            {
                sum = 0;
                p = j + 1;
            }
        }
        // 输出当前测试用例的结果
        printf("Case %d:\n", i);
        printf("%d %d %d\n", maxsum, start, end);
        // 如果不是最后一个测试用例,输出一个空行
        if (i!= t) cout << endl;
    }
    return 0;
}

题解2:动态规划
定义状态dp[i],表示以a[i]为结尾的最大子序和.

dp[i]的计算有两种情况:
(1)dp[i]只包括一个元素,就是a[i];
(2)dp[i]包括多个元素,从前面某个a[v]开始,v<i,到a[i]结束,即dp[i-1]+a[i]。
取两者的最大值,得到状态转移方程dp[i]=max(dp[i-1]+a[i],a[i])。
在所有dp[i]中,最大值就是题目的解。

#include <bits/stdc++.h>
using namespace std;

int main() 
{
    int t;
    cin >> t;
    for (int k = 1; k <= t; k++) 
    {
        int n;
        cin >> n;
        int dp[100005];
        for (int i = 1; i <= n; i++) 
        {
            cin >> dp[i];
        }
        
        //重点--------
        int start = 1, end = 1, p = 1;
        int maxsum = dp[1];
        for (int i = 2; i <= n; i++)
        {
            // 状态转移方程:dp[i]=max(dp[i-1]+a[i], a[i])
            if (dp[i - 1] + dp[i] >= dp[i]) 
            {
                dp[i] = dp[i - 1] + dp[i];
            } 
            else 
            {
                p = i;
            }
            
            if (dp[i] > maxsum) 
            {
                maxsum = dp[i];
                start = p;
                end = i;
            }
        }
        //重点--------
        
        printf("Case %d:\n", k);
        printf("%d %d %d\n", maxsum, start, end);
        
        if (k!= t) cout << endl;
    }
    return 0;
}
问题(2)的求解

和前面例题的"滑动窗口"类似,可以用单调队列的"窗口、删头、去尾"解决问题(2)。

首先求前缀和s[i]。
s[i]是a[1]~~a[i]的和,计算所有的s[i]~s[n],时间复杂度为O(n)。

问题(2)转换为:找出两个位置i、k,使s[i]-s[k]最大(i一k<=m)

首先思考用DP求解,把问题进一步转换为:首先固定一个i,找到它左边的一个端点k,i-k<m,使s[i]-s[k]最大,定义这个最大值是dp[i];逐步扩大i,求得所有的dp[i],其中的最大值就是问题的解。

如果简单地暴力检查,对每个i检查比它小的m个s[k],那么总时间复杂度为O(nm),将超时。

暴力检查的方法不可行,改用一个大小为m的窗口寻找最大子序和ans。从头到尾依次把s[的元素放人这个窗口。

(1)首先把s[1]放入窗口,并且记录ans的初始值s[1]。
(2)接着把s[2]放入窗口(假设窗口长度大于2),有两种情况:如果s[1]<s[2],那么更新ans=max{s[1],s[2],s[2]-s[1]};如果s[1]>s[2],那么保持ans=s[1]不变,然后从窗口中抛弃s[1],只留下s[2],因为后面再把新的s[i’]放入窗口时,s[i’]-s[2]比s[i’]-s[1]更大。
继续这个过程,直到所有的s[]处理结束。

总结上面的思路,把新的s[i]放入窗口时;
(1)把窗口内比s[i]大的所有s[j]都抛弃,i-j<=m,因为这些些s[j]在处理s[i]后面的s[i’]时用不到了,s[i’]-s[i]要优于s[i’]-s[j]就可以了;
(2)若窗口内最小的是s[k],此时肯定有s[k]<s[i],检查s[i]-s[k]是否为当前的最大子序和,如果是,就更新最大子序和ans;
(3)每个s[i]都会进入队列。

此时,最优策略是一个"位置递增、前缀和也递增"的序列,用单调队列最合适了。
s[i]进入队尾时,如果原队尾比s[i]大,则去尾;如果队头超过窗口范围m,则去头,而最小的那个s[k]就是队头。算法的原理和"滑动窗口"差不多。
在这个单调队列中,每个s[i]只进出队列一次,计算复杂度为0(n)。

当然,以下是提取的代码:

```cpp
#include <bits/stdc++. h>

using namespace std;

deque<int>dq;

int s[100005];

int main(){

    int n,m; scanf("%d%d",&n,&m);

    for(int i=1;i<=n;i++) scanf("%lld",&s[i]);

    for(int i=1;i<=n;i++) s[i]=s[i]+s[i-1];

    //计算前缀和

    int ans=-1e8;

    dq.push_back(0);

    for(int i=1;i<=n;i++)

        while(!dq.empty() && dq.front()<i-m) dq.pop_front(); //队头超过m范围:删头

        if(dq.empty()) ans= max(ans,s[i]);

        else ans= max(ans,s[i]-s[dq.front()]); //队头就是最小的s[k]

        while(!dq.empty()&& s[dq.back()]>= s[i]) dq.pop_back(); //队尾大于s[i],去尾

        dq.push_back(i);

    }

    printf("%d\n",ans);

    return 0;
}

优先队列

优先队列的特征是每次让优先级最高(最大的数或最小的数)的先出队列。优先队列并不是简单的线性结构,而是用堆这种复杂结构来实现。由于代码写起来比较麻烦,竞赛时的优先队列一般直接用STL编码。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值