首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

双端队列

通过对数据结构队列的分析, 我们知道在同一端进行插入和删除操作就可以得到栈,在一端插入另一端删除就可以得到队列。有些时候,我们可能需要组合这些操作, 于是就有了所谓的双端队列,它与栈和队列没什么太大的差别,就是可以在线性表的两端插入和删除数据。

同样的双端队列也可以通过链表和数组两种形式来实现。链表的实现方式比较简单,不需要过多的讨论边界问题。 比如说Linux内核所用的list,通过list_add和list_add_tail两个函数就可以分别在队首和队尾插入新的数据; 而函数list_del有一个输入参数可以移除指定的链表节点,所以需要删除队首就把队首节点的地址作为参数调用list_del就可以,要删除队尾就传递队尾的地址。

数组形式双端链表实现就要相对麻烦一些,需要谨慎的处理边界问题。在本文中我们详细介绍动态数组形式的双端队列deque,该队列可以动态的申请内存, 理论上只要计算机的内存够用它可以存放任意多的数据,使用起来比较方便。

1. 定义和初始化

数组形式的队列的实现一样,双端队列也需要两个指针分别指示队首和队尾的位置。 这里我们通过一些宏定义实现一种类似C++模板的双端队列,下面左边定义了前缀为deque的结构体,右边是按照宏定义展开后的结构体,其中double可以替换为其它类型。

        #define T double
        #define TEMPLATE_TYPE(a) a ##_double
        typedef struct TEMPLATE_TYPE(deque) {
            T *begin;
            T *end;
            T *stor_begin;
            T *stor_end;
        } TEMPLATE_TYPE(deque);
        
        typedef struct deque_double {
            double *begin;
            double *end;
            double *stor_begin;
            double *stor_end;
        } deque_double;
        

在结构体中,我们定义了四个指针,begin,end, stor_begin, stor_end分别用于指示队首、队尾、存储空间的起始位置和结束位置。我们通过下面的函数deque_T_init完成双端队列的初始化工作, 其中T表示具体的队列元素类型。在该函数的一开始,我们就通过malloc申请4个单元的存储空间,并用stor_begin标记。接下来判定内存是否成功申请,若是则分别设定其它几个指针的初值。

        int TEMPLATE_FUNCTION(deque, init)(TEMPLATE_TYPE(deque) *q) {
            q->stor_begin = (T*)malloc(4 * sizeof(T));
            if (0 == q->stor_begin)
                return 1;
        
            q->stor_end = q->stor_begin + 4;
            q->begin = q->stor_begin;
            q->end = 0;
        
            return 0;
        }

正如它的名字那样,双端队列是一个可以在两端自由的插入和移除数据的线性表。我们为之提供了push_front、push_back、pop_front、pop_back四个接口函数,分别用于在两端插入和移除数据。

2. push_front

下面是push_front的实现,它有两个参数。其中q是一个指示着目标队列的指针,data则是将要插入的数据。

        int TEMPLATE_FUNCTION(deque, push_front)(TEMPLATE_TYPE(deque) *q, T data) {

接下来,先检查队列是否已满,判定的依据就是指针end和begin指向了相同的地址。如果队列已满,我们就通过接口函数adjust_capacity将队列的存储空间扩大两倍。

            if (q->end == q->begin) {
                int c = TEMPLATE_FUNCTION(deque, capacity)(q);
                TEMPLATE_FUNCTION(deque, adjust_capacity)(q, c*2);
            }

然后根据end是否为0,判定队列是否为空。如果队列空,则让end指向队列的起始位置,表示该位置是第一个空闲的地址。在整个队列的维护过程中,我们都在维护begin和end两个指针, 让begin始终指向队列中的第一个元素,end始终指向第一个空闲空间。

            if (0 == q->end)
                q->end = q->begin;

由于push_front需要再队首中插入数据,所以需要指针begin后退一个单元。

            q->begin--;

这后退一个单元,可能导致begin超出已经申请的存储范围。此时,让begin指向存储空间的最后一个单元,循环的使用内存。因为在函数之处我们就已经处理了队列满的情况,所以此时该单元一定是空闲的。

            if (q->begin < q->stor_begin)
                q->begin = q->stor_end - 1;

在处理了所有的边界条件之后,我们就可以将新的数据写入队首了,并返回了。

            q->begin[0] = data;
            return DEQUE_NOERR;
        }

3. push_back

下面是push_back函数的实现,其参数列表与push_front一样,分别指示了目标队列和将要插入的数据。

        int TEMPLATE_FUNCTION(deque, push_back)(TEMPLATE_TYPE(deque) *q, T data) {

同样的,在函数的一开始我们就需要检查队列是否已满或者为空。如果已满则申请更大的内存,如果为空则调整队尾指针end。

           if (q->end == q->begin) {
                int c = TEMPLATE_FUNCTION(deque, capacity)(q);
                TEMPLATE_FUNCTION(deque, adjust_capacity)(q, c*2);
            }
            if (0 == q->end)
                q->end = q->begin;

由于我们始终保持end指向第一个空闲空间,所以这里可以直接将目标数据写入该空间中。

            q->end[0] = data;

写入新数据后,end所指的空间将不再空闲,需要将之后移一个单元。但是后移操作可能导致end超出存储边界,此时循环的将之指向存储空间的起始位置。 因为在函数的起始,我们就已经处理了队列为满的情况,所以此时存储空间的起始位置一定是空闲的。

            q->end++;
            if (q->end == q->stor_end)
                q->end = q->stor_begin;

处理完边界问题之后,我们就可以返回推出了。

            return DEQUE_NOERR;
        }

3. pop_front

下面是pop_front函数的实现,它从队首移除一个数据,有两个参数。其中q为目标队列,buf则是用于保存从队首中移除的数据。

        int TEMPLATE_FUNCTION(deque, pop_front)(TEMPLATE_TYPE(deque) *q, T *buf) {

首先我们需要确定队列中有数据,否则报错退出。

            if (0 == q->end)
                return DEQUE_EMPTY;

然后用队首中的数据填充输出缓存buf。

            *buf = *(q->begin);

接下来的工作就是移除队首数据,并处理各种边界条件。队首指针自加一就可以移除队首数据。

            q->begin++;

队首指针自加一后,可能会超出存储边界。此时让指针begin指向存储空间的起始位置,以循环地使用存储空间。

            if (q->begin == q->stor_end)
                q->begin = q->stor_begin;

移除队首数据之后,队列可能为空,此时表现就是队首指针begin指向了end所指引的空闲空间。我们令队尾指针end为0,标记队列已空。

            if (q->begin == q->end) {
                q->begin = q->stor_begin;
                q->end = 0;
            }

最后返回退出。

            return DEQUE_NOERR;
        }

4. pop_back

下面是pop_back函数的实现,它从队尾移除一个数据,有两个参数。其中q为目标队列,buf则是用于保存从队尾中移除的数据。

        int TEMPLATE_FUNCTION(deque, pop_back)(TEMPLATE_TYPE(deque) *q, T *buf) {

接下来仍然是先判定队列是否为空。

            if (0 == q->end)
                return DEQUE_EMPTY;

由于end始终指向队列中的第一个空闲单元,所以需要令其后退一个单元。

            q->end--;

end后退一个单元可能超出存储边界,需要调整令其指向存储空间中的最后一个单元。

            if (q->end < q->stor_begin)
                q->end = q->stor_end - 1;

此刻,end就指向了队尾的数据,我们用它来填充输出缓存buf。

            *buf = *(q->end);

刚刚我们调整队尾指针end的过程就相当于是将队尾的数据从队列中移除。移除操作可能导致队列为空,此时为end赋值为0,标记空对列。

            if (q->begin == q->end) {
                q->begin = q->stor_begin;
                q->end = 0;
            }

最后返回退出。

            return DEQUE_NOERR;
        }

5. 完

双端队列可以在队首或者队尾插入和删除数据,如果我们只在一端完成插入和删除操作就得到了一个栈,如果再一端插入另一端删除就得到了一个队列。我们还可以任意组合这些操作,具有更宽泛的应用。




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1