背景
对于数据结构,其实学过C语言的都不陌生,无外乎就队列、栈、二叉树等等这些。但其实对于初学者最困惑的不是数据结构是怎么样的,而是数据结构有什么用?我自己也是工作好几年后才体验到数据结构的快乐。所以本系列文章重点从应用场景切入,让大家理解数据结构的好处。
简介
队列是一种特殊的线性表,特殊之处在于它只允许在表的前端进行删除操作,而在表的后端进行插入操作,和栈一样,队列是一种操作受限制的线性表。队列有两种形态,顺序排列的线性队列,也有头尾闭环的环形队列,也称作循环队列。
- 线性队列(循环队列)
简单的理解,就像是排队做核酸,大家自觉排成一队或几队,这每一队就是一个队列,先来的人先做,后到的人后做。如下图,当前有数据Data1过来,则先进入队里,排在队头,再来个Data2时,则排在Data1后面,再来就Data3就排在Data2后面。

如果此时要让数据出队,则先让Data1出队,然后Data2和3往前移一个位置。
如果此时再来个Data4,则把Data4填到队尾即可。
- 环形队列(循环队列)
跟顺序队列唯一的区别,就是它首尾相接,就是相当于一队人排成一个环状,环形队列也因此得名。
当需要把Data1移出队时,可以获取出数据,然后修改队头的位置即可,不用去搬运其他成员的数据,甚至出队的数据都不需要删除,等着后面新数据将其覆盖即可。这就是循环队列的好处。
但实际在内存中,不可能真的存在一个环形的缓存(起码现在没有)。在内存里,环形队列跟顺序队列完全一样,只是其队头跟队尾根据出队入队在不断地变化。
应用场景
就如前面所说,队列有个特点是可以缓冲,那这里可以想到什么呢?对了,就是小学做的那个数学题,一边进水xx升/小时,另一边排水xx升/小时,中间的那个水箱就是个队列。。。。不举这种浪费水的例子,咱们换一个,其实最常用的地方,就是在有事件请求跟事件消耗中,并且请求跟消耗是异步进行的场景。
举个实际例子,现在有一个触摸屏,用户点击下屏幕,显示屏会有对应的动作,点一次动一次,并且动作跟点击顺序有关系,先点的先动作。那按这样的设计思路,最简单的写法如下:
void Check(void)
{
/* 检测的相关处理 */
}
void Operation(void)
{
/* 触发的相关执行动作 */
}
int main(void)
{
/* 检测触摸位置 */
Check();
/* 执行对应的动作 */
Operation();
return 0;
}
但上面的实现有什么问题?如果这时候执行的动作很多,占用时间很多,比如整个屏刷新需要1s(夸张点的说法哈),那上面这种写法就会导致我按键只能1s按一次,按快了没反应。那这里我们先引入实时操作系统的概念——时间片,把需要执行的动作切分成很多个时间片,比如整屏刷新原本需要1s,那就把它分成100份10ms的时间片,触摸检测也一样,然后在整屏每刷新10ms的时候,就检测一次触摸情况。这样在用户的角度来看,基本就看不出来延时的时间,按键和屏的刷新变成几乎同步进行。
void Check(void)
{
while(1)
{
/* 检测触摸事件--10ms检测一次 */
os_delay(10);
}
}
void Operation(void)
{
while(1)
{
/* 刷屏--刷新一部分就延时让出一下,或者利用操作系统本身时间片特性让出也行。 */
os_delay(1);
}
}
int main(void)
{
/* 创建触摸检测和刷屏两个任务--伪代码,重在理解 */
task_create(Check);
task_create(Operation);
return 0;
}
但这上面的实现还是有问题,虽然同一个屏,刷新的速度是一样的,但是不同的人,操作的速度是不一样的,老年人可能几秒钟才能操作一下,但单身20年的小伙可能1秒可以操作20下。因为屏刷新只能1s刷一下,那精神小伙操作的那20下,可能就只有最后一下可以显示出来。这时候小伙就不高兴了,为什么我一顿操作猛如虎,这屏就给这么点反应,要求点多少下刷新多少次。那么这时候我们的主角终于可以登场了,分析下这个场景,触摸检测到1s操作了20次,而屏只能1s刷新一次,典型的事件请求>事件消耗,并且这个事件请求是有间断性的,也就是小伙并不是一直按1秒操作20下的速度来操作的,只是这么操作了一次。那这里我们就引入队列,把小伙的操作都给存起来。
- 顺序队列
/*******************队列的相关操作********************/
/* 预留的缓存大小 */
#define QUEUE_MAX 30
struct tagQueueCB
{
uint8_t Buff[QUEUE_MAX];
uint32_t Tail;
};
struct tagQueueCB QueueCB;
/* 入队操作 */
uint8_t Queue_Push(uint8_t data)
{
uint8_t rtn = 0;
if (QueueCB.Tail < QUEUE_MAX)
{
QueueCB.Buff[QueueCB.Tail++] = data;
rtn = 1;
}
else
{
/* 队列满,返回失败 */
rtn = 0;
}
return rtn;
}
/* 出队操作 */
uint8_t Queue_Poll(uint8_t *data)
{
uint8_t rtn = 0;
if (QueueCB.Tail)
{
*data = QueueCB.Buff[0];
QueueCB.Tail--;
for (uint32_t i = 0; i < QueueCB.Tail; i++)
{
QueueCB.Buff[i] = QueueCB.Buff[i + 1];
}
rtn = 1;
}
else
{
/* 队列空时返回失败 */
rtn = 0;
}
return rtn;
}
/***************************************************/
void Check(void)
{
uint8_t data = 0;
while(1)
{
/* 检测事件 */
/* 有触摸事件,入一次队 */
if (0 == Queue_Push(data))
{
/* 异常处理 */
}
/* 检测触摸事件--10ms检测一次 */
os_delay(10);
}
}
void Operation(void)
{
uint8_t data = 0;
while(1)
{
/* 检测队列,非空则出队,并刷整屏 */
if (0 == Queue_Poll(&data))
{
/* 异常处理 */
}
/* 执行刷屏 */
/* 刷屏--刷新一部分就延时让出一下,或者利用操作系统本身时间片特性让出也行。 */
os_delay(1);
}
}
int main(void)
{
/* 创建触摸检测和刷屏两个任务--伪代码,重在理解 */
task_create(Check);
task_create(Operation);
return 0;
}
这样就解决了小伙的手速问题。当然如果过两年小伙手速又提升了,那这30的缓存可能也扛不住。这上面实现的就是最普通的顺序队列,使用数组存储数据,并提供出队、入队的操作。然而细心的小伙伴可能已经发现了,这出队操作效率也太低了吧,把队头数据移出后,后续所有数据都得往前移,而且现在是多线程,如果在入队的过程中,执行了出队,数据就会错乱(重入)。那么这里我们就提供第二种队列的实现方式——循环队列。
- 循环队列
/*******************队列的相关操作********************/
/* 预留的缓存大小 */
#define QUEUE_MAX 30
struct tagQueueCB
{
uint8_t Buff[QUEUE_MAX];
uint32_t Head; /* 队头位置 */
uint32_t Tail; /* 队尾位置 */
uint32_t Num; /* 队列元素个数 */
};
struct tagQueueCB QueueCB;
/* 入队操作 */
uint8_t Queue_Push(uint8_t data)
{
uint8_t rtn = 0;
if (QueueCB.Num < QUEUE_MAX)
{
QueueCB.Buff[QueueCB.Tail] = data;
QueueCB.Tail = (QueueCB.Tail + 1) % QUEUE_MAX;
/* 数量记得放最后修改,不然还是会有重入问题 */
QueueCB.Num++;
rtn = 1;
}
else
{
/* 队列满,返回失败 */
rtn = 0;
}
return rtn;
}
/* 出队操作 */
uint8_t Queue_Poll(uint8_t *data)
{
uint8_t rtn = 0;
if (QueueCB.Num)
{
*data = QueueCB.Buff[QueueCB.Head];
QueueCB.Head = (QueueCB.Head + 1) % QUEUE_MAX;
/* 数量记得放最后修改,不然还是会有重入问题 */
QueueCB.Num--;
rtn = 1;
}
else
{
/* 队列空时返回失败 */
rtn = 0;
}
return rtn;
}
/***************************************************/
void Check(void)
{
uint8_t data = 0;
while(1)
{
/* 检测事件 */
/* 有触摸事件,入一次队 */
if (0 == Queue_Push(data))
{
/* 异常处理 */
}
/* 检测触摸事件--10ms检测一次 */
os_delay(10);
}
}
void Operation(void)
{
uint8_t data = 0;
while(1)
{
/* 检测队列,非空则出队,并刷整屏 */
if (0 == Queue_Poll(&data))
{
/* 异常处理 */
}
/* 执行刷屏 */
/* 刷屏--刷新一部分就延时让出一下,或者利用操作系统本身时间片特性让出也行。 */
os_delay(1);
}
}
int main(void)
{
/* 创建触摸检测和刷屏两个任务--伪代码,重在理解 */
task_create(Check);
task_create(Operation);
return 0;
}
除了上面这种应用,还有像通信协议,也会用到队列,一般叫通信缓存。另外还有像实时操作系统里的邮箱,其实就是一个事件队列。
还有一种应用,是用在日志存储,存储的结构其实就是一个循环队列,但跟通信缓存有一点不同的是,通信缓存一般是不允许入队入满的,如果队满了,一般的做法也是把要入队的信息舍弃掉。但日志存储则是需要把最开始的数据覆盖掉,以此来保持记录最近的记录。
总结
- 常见队列有两种,顺序队列和循环队列。
- 一般用在需要数据缓存的地方,比如通信驱动与应用处理的衔接,日志存储服务与应用的衔接等等。
- 对于队列的操作,要多注意重入的问题,毕竟这种数据结构就是在多方共用内存的情况下使用的。
相关知识
栈、链表、树、图、散列表