这里讨论堆。通常当我们谈论堆时,我们经常谈论的是二叉堆。但这并不是堆的全部。堆(heap),可以说是树(tree)的一种。参见下图:
图中并不算十分完整,但也基本显示出的树的家族的轮廓。二叉树就不多说了。这次主要讨论二叉堆(binary heap)以及左偏树(leftist tree)。二叉堆是讨论过的(见基本数据结构–堆)。是看算法导论的时候写的(现在依然没有看完~)。那时没有实现优先队列,这里会实现一下。我们知道二叉堆的应用主要就是优先队列和堆排序。之所以称这种树为堆,是因为其满足堆的基本性质——堆序性质,即每个父节点都比它的子节点大/小。
二叉堆一般采用数组实现。而这次另外要讨论的左偏树(也称左式堆)也满足堆序性质,但其实现方式与二叉树是一样的,即采用二叉链存储结构。左式堆除了堆序性质外,注重于堆的合并操作,左偏树/左式堆可以以O(logN)的时间复杂度将两个堆合并。这是普通二叉树与二叉堆所做不到的。因为所有的合并操作均需要用到指针,而二叉树的合并需要O(N)的时间,所以这也是左式堆这种数据结构存在的意义(I guess)。后续的数据结构如斐波那契堆、二项堆等实现之后,可以就相关操作做一个对比。另外左式堆之后有一种堆——斜堆,与左式堆有着密切的关系。斜堆并不携带任何平衡信息,与左式堆的关系就像伸展树之于AVL树。但其实好像并不是特别好用。所以这里就不介绍了。
优先队列/二叉堆
优先队列基本上都是用二叉堆来实现的。优先队列的基本模型如下:
可以看到其基本操作为插入和删除其中优先级最低或者最高的元素(key最小或者最大的)。二叉堆比较简单,这里直接实现。这里构建的是最小堆,即父节点比子节点小的堆。其核心操作为 DeleteMin
和 Insert
。
typedef int ElemType;
class Min_Heap
{
private:
int capacity;
int size;
ElemType * Elements;
public:
Min_Heap(int _capacity);
~Min_Heap();
void Clear();
void Insert(ElemType x);
ElemType FindMin();
ElemType DeleteMin();
bool IsEmpty();
bool IsFull();
int Get_Size();
void print();
};
方法实现:
Min_Heap::Min_Heap(int _capacity)
{
capacity = _capacity;
size = 0;
Elements = new ElemType [capacity+1];
Elements[0] = -1;
}
Min_Heap::~Min_Heap()
{
delete Elements;
}
void Min_Heap::Clear()
{
size = 0;
}
void Min_Heap::Insert(ElemType x)
{
if(IsFull())
{
cout << "The heap is full, can not insert more elements !" << endl;
return;
}
int i;
//如果不加i>1这个条件的话,就靠Elements[0]来结束循环,所以Min_data必须小于堆中所有元素
for(i = size+1; Elements[i/2]>x && i>1; i /= 2)
Elements[i] = Elements[i/2];
Elements[i] = x;
size ++;
}
ElemType Min_Heap::FindMin()
{
if(IsEmpty())
return Elements[0];
return Elements[1];
}
ElemType Min_Heap::DeleteMin()
{
if(IsEmpty())
{
cout << "The heap is empty ! return to Min_data !" << endl;
return -1;
}
ElemType Min_element = Elements[1];
ElemType Last_element = Elements[size--];//将最后一个元素从堆中取出来
int i,child;
for(i = 1; i*2 <= size; i = child)
{
child = 2*i;
//节点i存在右孩子且右孩子小于左孩子的话,就将右孩子上浮
if(child+1 <= size && Elements[child+1]<Elements[child])
child ++;
//需要判断child与最后一个元素谁填充到节点i
if(Last_element > Elements[child])
Elements[i] = Elements[child];
else//找到合适的位置给Last_element插入,则不再上浮
break;
}
Elements[i] = Last_element;
return Min_element;
}
bool Min_Heap::IsEmpty()
{
return size == 0;
}
bool Min_Heap::IsFull()
{
return size == capacity;
}
int Min_Heap::Get_Size()
{
return size;
}
void Min_Heap::print()
{
for(int i=1; i<=size; i++)
cout << Elements[i] << " ";
cout << endl;
}
其中插入和删除操作中,使用了一个临时变量来存储该节点值,而不是每一次都进行交换,最坏复杂度均为O(logN)。插入中用到了使节点上浮的操作。删除最小值中用到了使节点下沉/下滤的操作。但是可以证明新插入的元素一般不会直接上浮到最顶层,一般会终止的比较早,平均一次插入需要2.607次比较。所以我们可以说插入操作复杂度为O(1)。则从数组建堆时对每一个元素执行插入操作,平均复杂度为O(N)。
堆排序
我们知道堆的另一个应用就是堆排序。如果要就上述操作来实现堆排序,则只有依次执行DeleteMin
,再将得到的最小值依次输出,则就得到了堆中元素的升序排列。显然这并不是最高效的实现。
还有一种思路,单就为了实现堆排序来说,我们可以将上浮和下沉的操作提取出来,按照如下方式实现堆排序。过程参考代码。网上太多讲解了。堆排序的实现,堆我们可以直接采用数组,而不必将其封装为ADT。实现如下。注意这里所有堆实现为了可读性以及便于操作,下标均从1开始,到n结束,则分配内存需要多分配一个。这里实现的是最大堆。
void Build_max_heap(ElemType A[],int n)
{
for(int i = n/2; i > 0; i--)
sift_down(A,i,n);//建堆,复杂度O(N)
}
//上浮,优先队列插入元素才会用到,堆排序不会用到
void sift_up(ElemType A[],int i,int n)
{
ElemType tmp;
for(tmp = A[i]; i >= 2 && tmp > A[i/2] ; i /= 2)
A[i] = A[i/2];
A[i] = tmp;
}
//下沉
void sift_down(ElemType A[],int i,int n)
{
ElemType tmp;
int child;
for(tmp = A[i]; 2*i <= n; i = child)
{
child = 2*i;
if(child != n && A[child+1] > A[child])
child ++;
if(tmp < A[child])
A[i] = A[child];
else
break;
}
A[i] = tmp;
}
void Heap_Sort(ElemType A[], int n)
{
for(int i=n/2; i>0; i--)
sift_down(A,i,n);//对n=n/2->1执行sift_down操作即可建堆,顺序不能反过来
for(int i=n; i>1; i--)
{
swap<ElemType>(A[i],A[1]);
sift_down(A,1,i-1);//这里一定这注意最后一个参数为目前堆的size
}
}
template<typename T>
void swap(T & x , T & y)
{
T tmp = x;
x = y;
y = tmp;
}
我们甚至可以调用上述过程来实现优先队列,很容易实现。要说的是,上一篇(基本数据结构–堆)中,堆的实现是不那么高效的,其中sift_down
操作采用了递归。能够轻松转化为循环(而不是模拟一个栈去转化)的递归我们应当将其写作循环的形式。尤其是递归层数较多时,写成递归形式很可能会爆栈。
左式堆
左式堆(leftist heap),也称左偏树(leftist tree)。注重合并(merge)操作。实现合并操作后,其余操作(Insert
、DeleteMin
)均可通过合并操作实现。合并操作复杂度为O(logN),采用递归实现,是递归的强大力量的一个例证。
前面提到过,二叉堆和二叉树的合并操作都无法在短时间实现。其中插入复杂度O(1)的二叉堆将其中一个堆所有元素插入到另一个都需要至少O(N)复杂度。
介绍之前,引入一个概念:树中一个节点X的零路径长(null path length)Npl(X)定义为X到一个没有两个儿子的节点的最短路径长。具有0个或者1个儿子的节点Npl值为0。Npl(NULL)=-1。我们需要将每个节点的Npl值标记在节点中。注意:任意节点的Npl等于其左右孩子的Npl值中最小值加1。
左式堆性质:对于堆中每一个节点X,左儿子NPL大于等于右节点。
节点以及操作定义:
typedef int ElemType;
typedef struct LHNode
{
ElemType element;
LHNode * left;
LHNode * right;
int npl;
} * LHeap ;
bool IsEmpty(LHeap H);
ElemType FindMin(LHeap H);
LHeap Merge(LHeap H1, LHeap H2);//合并两个左式堆
LHeap Merge1(LHeap H1,LHeap H2);
void Insert(LHeap & H, ElemType x);
void DeleteMin(LHeap & H);
template <typename T>
void Swap(T & x , T & y); //最后两个操作太过简单,就不给了
void pre_traverse(LHeap H);
左式堆的最基本最重要的操作是合并。合并操作(以小顶堆为例):
1. 将两颗左偏树/两个左式堆中根节点值最小的一棵树的根节点作为合并之后的树的根节点(假设为H1,另一颗H2)
2. 将H2与H1的右子树合并(挂在右子树的合适位置),如何找到合适位置呢?递归实现,注意递归终止条件
3. 在上述过程中每次都对节点执行左式堆性质检测,如果不满足,则将其左子树与右子树交换,则一定满足左式堆性质
合并时间复杂度O(logN),简直可以说是完美的递归操作。后续Insert
操作看做与一个节点的树的合并即可。DeleteMin
操作删除根节点后,将左右子树合并即可。
操作实现:
bool IsEmpty(LHeap H)
{
return H == NULL;
}
ElemType FindMin(LHeap H)
{
if(H == NULL)
{
cout << "The leftist heap is empty , has no min element ! " << endl;
return 0;
}
return H->element;
}
//合并两个左式堆
LHeap Merge(LHeap H1, LHeap H2)
{
if(H1 == NULL)//H2与空树合并,则返回H2
return H2;
if(H2 == NULL)//H1与空树合并,则结果为H1
return H1;
if(H1->element < H2->element)
return Merge1(H1,H2);
else
return Merge1(H2,H1);
}
//递归将H2挂在H1的右子树上
LHeap Merge1(LHeap H1,LHeap H2)
{
if(H1->left == NULL)
H1->left = H2;
else
{
H1->right = Merge(H1->right , H2);
if(H1->left->npl < H1->right->npl)
Swap(H1->left, H1->right);
H1->npl = H1->right->npl + 1; //根的npl = min(left->npl,right->npl) + 1
}
return H1;
}
void Insert(LHeap & H, ElemType x)
{
LHeap NewNode = new LHNode;
NewNode->element = x;
NewNode->left = NewNode->right = NULL;
NewNode->npl = 0;
H = Merge(NewNode , H);
}
void DeleteMin(LHeap & H)
{
if(IsEmpty(H))
{
cout << "The leftist heap is empty , can not delete any elements !" << endl;
return;
}
LHeap leftHeap = H->left;
LHeap rightHeap = H->right;
delete H;
H = Merge(leftHeap , rightHeap);
}
参考资料:数据结构与算法分析——C语言实现