C++:一个可变优先队列(Mutable Priority Queue)的实现

如果只是为了找代码,请访问https://github.com/im-red/MutablePriorityQueue,只需要其中的mutable_priority_queue.h文件就可以了,其他文件是单元测试和性能测试代码。


优先队列大家都比较熟悉,其核心数据结构是堆(一个特殊的二叉树),按照《算法导论》上的介绍,支持4种操作:

INSERT(S, x):把元素x插入集合S中。这一操作等价于S=S∪{x}。
MAXIMUM(S):返回S中具有最大关键字的元素。
EXTRACT-MAX(S):去掉并返回S中的具有最大关键字的元素。
INCREASE-KEY(S, x, k):将元素x的关键字值增加到k,这里假设k的值不小于x的原有关键字值。

这4种操作的时间复杂度分别为O(lgn)、O(1)、O(lgn)和O(lgn),其中n为堆中已有元素数量。
前几天在实现一个算法时需要用到优先队列,发现stl里面有一个std::priority_queue的优先队列可以用,一开始挺高兴,但是用着就发现问题了:std::priority_queue不支持INCREASE-KEY操作,也就是说元素一旦插入进去,就再也无法修改了,更甚至说,除非元素到达堆顶,连访问都做不到了。这导致std::priority_queue用起来的效果大打折扣,甚至还不如维护一个有序数组。
在网上找了一下有没有其他优先队列的C++实现,发现boost里面有一个mutable_heap_interface,实现了修改已插入数据的功能。它在进行插入操作时,会返回一个handle_type类型的数据,也就是一个key(本文中key与handle含义相同),通过这个key,你可以修改已插入数据。但是boost真的太重量级了,我本来只是想写一个小东西,不想依赖这么庞大的库。所以仿照它的接口,我写了一个自己的实现:MutablePriorityQueue,公共接口如下:

template<typename T>
class MutablePriorityQueue
{
public:
    typedef size_t handle_type;
    typedef T value_type;

    MutablePriorityQueue(std::function<bool(value_type &, value_type &)> comp);
    size_t size();
    bool empty();
    handle_type push(const value_type &v);
    value_type pop();
    value_type top();
    handle_type topHandle();
    value_type value(handle_type const &handle);
    void update(handle_type const &handle, value_type const &v);
}

其中push对应INSERT,top对应MAXIMUM,pop对应EXTRACT-MAX,update对应INCREASE-KEY,不过update没有INCREASE-KEY的新数据比旧数据更优先的限制,新数据既可以比旧数据优先,也可以不比旧数据优先。
一般来说,如果不实现修改已插入元素的功能,优先队列使用一个元素数组就能实现:

std::vector<value_type> m_vElements;

而可变优先队列则不然。为了能够访问已插入元素,是一定要有一个唯一的key的,指针也好,数组下标也好,一定要有手段访问到这个元素:

std::vector<handle_type> m_vHandleHeap;

那么有问题来了:key产生后,如何保证这个key不会随着元素的插入和弹出而对应到其他元素?这个可以想到解决方法:建堆时不移动元素,不论数据的插入还是删除,都不移动已有数据,而是移动key,建立一个key的堆,比较的是元素,但是移动的是key。
这就没问题了吗,不是的:当我要update一个元素,然后传入了一个key和一个新值。通过key,我可以在O(1)时间内更新元素值。更新完元素值以后,我们需要重新对堆进行有序化,也就是对key组成的堆进行更新。那么这时我该如何定位这个key在哪里呢?遍历key的堆(数组)自然是可以的,因为key都是唯一的。但是这么做会导致update调用的复杂度变为O(n),而不是O(lgn)。那怎么办?我的做法是又增加了一个数组:

std::vector<size_t> m_vHandleIndex;

这个数组与元素数组是平行的,也就是说m_vElements与m_vHandleIndex是一一对应的。它存的是什么呢?是指向该元素的key在key的堆中的索引。当得到一个key以后,m_vHandleHeap[key]是元素值,m_vHandleIndex[key]则是key在key的堆中的下标。这样,我们就可以在O(1)时间内得到key在堆中的位置了。
以上就是主要工作了,此外的工作还有如何存元素的问题,在上面可以看到我用的数组存元素,那么key就是数组索引了。由于元素弹出时不能移动元素(不然key可能会对应到错误的元素),所以弹出后数组大小也没办法缩小,因此为了有效利用空间,我增加了一个数组:

std::vector<handle_type> m_vInvalid;

这个数组是来存储无效元素的,当插入新元素时,优先利用已有空间,而不是直接push_back进去。
这里我没有使用指针做key,也就是插入新元素时不是new出来的。如果是new出来的元素,表面看起来可以通过释放失效元素来释放空间,但是内存碎片估计会比较多,不见得释放了以后就真的可以重新使用。而且new和delete也会有一定的开销(具体多少我没试过),会对性能产生影响。
这个实现对比stl的实现性能怎么样呢,我测试的方法是连续push或者连续pop N个元素(由于stl实现没有update操作,没办法比),然后对比时间,理想状况下时间消耗是O(NLogN)。实际性能测试的结果在下边。

OS: Windows Subsystem for Linux(Ubuntu 16.04.9)
CPU: Intel(R) Core(TM) i5-8250U @1.60GHz 1.80GHz
Compiler: gcc version 5.4.0 20160609 (Ubuntu 5.4.0-6ubuntu1~16.04.9)

蓝线是原数据。
绿线是使用a*NLogN拟合出来的曲线。
红线是使用a*N*N拟合出来的曲线。

元素类型为int时:
push操作:
push int
pop操作:
pop int

元素类型为Node时:

struct Node
{
    int value;
    char ext[80];
};

push操作:
push node
pop操作:
pop node

可以看出来性能差距还是不小的,主要体现在pop操作上,pop int数据时耗时差8倍的样子,原因也比较好确定:由于多了几个数组,访存就比stl实现多了几倍,导致性能下降。同时堆数据结构原本就对cache不友好,我们又加了好几个数组,常驻集大了以后cache miss率也上去了,进一步导致性能变差。不过好在从拟合曲线可以看出来,复杂度和stl实现是一样的都是a*NLogN,也还说的过去。

猜你喜欢

转载自blog.csdn.net/imred/article/details/80317016