堆
堆
本文我们重点讨论堆,堆分为最小堆和最大堆两种,由于两者操作操作类似,所以我们只讨论最小堆(在实现的过程中我们定义了compare函数,可以同时适用于最小堆和最大堆)。
最小堆的定义如下:A[i] <= A[i+1], A[i] <= A[i+2],对应于完全二叉树,即非叶子节点的值大于等于其两个叶子节点的值。
这里,我们将讨论如下内容:
a) 如何对给定的一个序列进行建堆;
b) 如何进行堆的插入;
c) 如何进行堆的删除;
d) 堆的应用:堆排序;
e) 最后,会讨论如何修改堆中任意一个元素。
一、建堆
给定一个序列如下:
8, 5, 9, 1, 2, 6, 10, 7, 3, 4, 11, 15, 14, 13, 12
初始时序列的状态如下:
// Graphviz作图
下面我们对其建堆,建堆的过程是逐个加入元素,调整为最小堆,直到将所有的元素全部处理完。具体的程序如下所示:
// 建堆 #include <iostream> #include <vector> using namespace std; typedef bool (*cmp_fun)(int a, int b); void display(const vector<int>& arr) { for (vector<int>::size_type i = 0; i != arr.size(); ++i) { cout << arr[i] << ' '; } cout << endl; } bool cmp_less(int a, int b) { return a < b; } bool cmp_greater(int a, int b) { return a > b; } bool exchange(int& a, int& b) { int t = a; a = b; b = t; } void move_up(vector<int>& arr, int idx, cmp_fun cmp = cmp_less) { int par = 0; while (idx > 0) { par = (idx-1) / 2; if (cmp(arr[idx], arr[par])) { exchange(arr[idx], arr[par]); idx = par; } else { break; } } } void build_heap(vector<int>& arr) { if (arr.size() < 2) { return; } for (int i = 1; i != arr.size(); ++i) { move_up(arr, i); } } int main() { int a[] = {8, 5, 9, 1, 2, 6, 10, 7, 3, 4, 11, 15, 14, 13, 12}; vector<int> arr(a, a + sizeof (a) / sizeof (*a)); display(arr); build_heap(arr); display(arr); system("PAUSE"); return 0; }
建堆后的序列如下:
1 2 6 3 4 9 10 8 7 5 11 15 14 13 12
对应的最小堆图如下:
建堆的过程即是遍历整个序列,对每个元素进行move_up操作,调整次序,以符合最小堆的规则。move_up的时间复杂度为O(logN),由于需要顺序处理整个序列种的每个元素,所以建堆的时间复杂度为O(NlogN)。
根据之前的初始序列,我们的程序只能得到这一种结果,但是这不是堆的唯一的结果,例如有如下形式的堆同样符合最小堆的规则:
由程序得到的堆中3与4交换后同样符合最小堆的规则,交换后同样是另外一种最小堆。所以我们的程序可以从一个序列得到一种最小堆,但这不是唯一的最小堆,还有多种其他的形式存在。
二、堆的插入
在建堆的过程中,我们实际上已经运用了堆的插入操作,针对插入的元素进行move_up调整操作。
在插入之前,现有的堆是满足堆规则的,我们插入的位置是在现有堆的末尾。从末尾进行move_up操作。直到带插入元素不再小于(最大堆是大于)其parent节点。
这里有个问题就是:插入元素的其实位置必须是堆的末尾吗?我们的回答是YES,因为如果不再末尾,插入元素后,其后面的元素会依次后移一位,这样包括带插入元素在内的后面所有元素都不在满足堆的规则。如果再继续调整将非常麻烦。
从另外一个角度来讲,由于堆自身的规则所限,带插入元素调整后的最终位置不是由其初始时的插入位置决定的,而是取决于其余堆中其他元素的大小关系。
三、堆的删除
根据最小堆的规则,我们直接删除堆最上面的根节点,即使堆中最小的节点,具体程序如下:
// 堆的删除 #include <iostream> #include <vector> using namespace std; typedef bool (*cmp_fun)(int a, int b); void display(const vector<int>& arr) { for (vector<int>::size_type i = 0; i != arr.size(); ++i) { cout << arr[i] << ' '; } cout << endl; } bool cmp_less(int a, int b) { return a < b; } bool cmp_greater(int a, int b) { return a > b; } bool exchange(int& a, int& b) { int t = a; a = b; b = t; } void move_up(vector<int>& arr, int idx, cmp_fun cmp = cmp_less) { int par = 0; while (idx > 0) { par = (idx-1) / 2; if (cmp(arr[idx], arr[par])) { exchange(arr[idx], arr[par]); idx = par; } else { break; } } } void build_heap(vector<int>& arr) { if (arr.size() < 2) { return; } for (int i = 1; i != arr.size(); ++i) { move_up(arr, i); } } void move_down(vector<int>& arr, int n, cmp_fun cmp = cmp_less) { int i = 0, left = 0, right = 0; while (i < n) { left = 2 * i + 1; right = 2 * i + 2; if (left > n-1) { break; } else if (left == n-1) { if (cmp(arr[left], arr[i])) { exchange(arr[left], arr[i]); i = left; } else { break; } } else { if (cmp(arr[left], arr[right])) { if (cmp(arr[left], arr[i])) { exchange(arr[left], arr[i]); i = left; } else { break; } } else { if (cmp(arr[right], arr[i])) { exchange(arr[right], arr[i]); i = right; } else { break; } } } } } void delete_heap(vector<int>& arr, vector<int>& del) { if (arr.empty()) { return; } del.clear(); for (int i = arr.size()-1; i >= 0; --i) { del.push_back(arr[0]); exchange(arr[0], arr[i]); move_down(arr, i); } } int main() { int a[] = {8, 5, 9, 1, 2, 6, 10, 7, 3, 4, 11, 15, 14, 13, 12}; vector<int> arr(a, a + sizeof (a) / sizeof (*a)); display(arr); build_heap(arr); display(arr); vector<int> del; delete_heap(arr, del); display(del); display(arr); system("PAUSE"); return 0; }
删堆的算法逻辑为:先移除最上面的哪个元素,也就是最小的那个元素,然后将堆最后面的那个元素前移的最上面的位置,之后做move_down操作,其中move_down的时间复杂度是O(logN)。
关于堆的删除操作我们有如下几个讨论:
1. 我们之前设定的事删除最小的元素,所以很自然就是删除最上面的哪个元素,但是,如果删除的是其他元素怎么办?比如删除的是中间某个节点应该如何操作?同样,我们还是先将其移除,然后用同样的方法,将堆中最后面的元素移到该位置上,然后进行调整操作。具体该如何操作呢?调整的目的无非就是满足最小堆的规则,即父节点小于等于两个子节点。我们首先检测该节点是否小于其父节点,如果小于则move_up,如果大于父节点,且小于其子节点,则保持不变,如果大于其子节点则move_down。可能你会问,既然该节点是最后面的节点,移到删除位置后不可能小于其父节点了吧,其实是可能的,因为有可能移动的该节点与删除的节点不是在同一个树分支上,也就是说两个节点之间的最短路径经过了根节点,这种情况下,有可能存在移动后其小于父节点的情况。
以上是对删除除了最小节点外的其他节点的讨论。
2. 为什么删除最小节点后,需要将最后面的节点移动到根节点的位置,为什么不能不移动,而直接调整?
首先,由于删除了一个节点,所以堆的大小必定减小了1。删除根节点后,如果不前移最后一个节点,可以对1 2 … n-1位置上的节点进行本地调整,但是这样调整的时间复杂度是O(NlogN),其实是对剩余的n-1个堆进行重建堆,这样效率不够好。另外还有另一种方式,就是删除根节点后,不进行前移最后一个节点,而是直接对其进行调整,检测根节点下面的两个子节点的大小关系,上移较小的那个,这样上移后,原来的那个子节点就变空了,依次类推,直至不能再上移位置。但是这种做法的结果极有可能会导致调整后中间存在空节点,经过多次删除后,最终的堆树有可能变成一个链表(这是一种极端的情况),也就是说,会浪费大量的空间。另外,最小堆的二叉树结构应该符合完全二叉树的形式,而如果采用直接上移的方式,势必破坏完全二叉树的形式。
所以,针对删除操作,我们是采用先删除根节点,然后将堆中最后一个节点前移的方式,然后再move_down操作。
3. 最后,关于move_down的操作:move_down的时间复杂度为O(logN),在内部检测中需要考虑3种情况,即待调整元素是否是叶子节点,是否只有一个left节点,还是left和right节点都有。对3种情况进行讨论,然后判断待调整节点与子节点的大小关系,决定是否move_down下去。
删除一个节点的时间复杂度是O(logN),所以删除堆中的所有元素的时间复杂度是O(NlogN)。
四、最小堆的应用——堆排序
根据最小堆的插入、删除操作,可以对序列进行排序,叫做堆排序。具体程序如下:
// 堆排序 #include <iostream> #include <vector> using namespace std; typedef bool (*cmp_fun)(int a, int b); void display(const vector<int>& arr) { for (vector<int>::size_type i = 0; i != arr.size(); ++i) { cout << arr[i] << ' '; } cout << endl; } bool cmp_less(int a, int b) { return a < b; } bool cmp_greater(int a, int b) { return a > b; } bool exchange(int& a, int& b) { int t = a; a = b; b = t; } void move_up(vector<int>& arr, int idx, cmp_fun cmp = cmp_less) { int par = 0; while (idx > 0) { par = (idx-1) / 2; if (cmp(arr[idx], arr[par])) { exchange(arr[idx], arr[par]); idx = par; } else { break; } } } void build_heap(vector<int>& arr) { if (arr.size() < 2) { return; } for (int i = 1; i != arr.size(); ++i) { move_up(arr, i); } } void move_down(vector<int>& arr, int n, cmp_fun cmp = cmp_less) { int i = 0, left = 0, right = 0; while (i < n) { left = 2 * i + 1; right = 2 * i + 2; if (left > n-1) { break; } else if (left == n-1) { if (cmp(arr[left], arr[i])) { exchange(arr[left], arr[i]); i = left; } else { break; } } else { if (cmp(arr[left], arr[right])) { if (cmp(arr[left], arr[i])) { exchange(arr[left], arr[i]); i = left; } else { break; } } else { if (cmp(arr[right], arr[i])) { exchange(arr[right], arr[i]); i = right; } else { break; } } } } } void delete_heap(vector<int>& arr, vector<int>& del) { if (arr.empty()) { return; } del.clear(); for (int i = arr.size()-1; i >= 0; --i) { del.push_back(arr[0]); exchange(arr[0], arr[i]); move_down(arr, i); } } void heap_sort(vector<int>& arr) { if (arr.size() < 2) { return; } // 建堆 for (int i = 1; i != arr.size(); ++i) { move_up(arr, i); } for (int i = arr.size()-1; i >= 0; --i) { exchange(arr[0], arr[i]); move_down(arr, i); } for (int i = 0; i < arr.size()/2; ++i) { exchange(arr[i], arr[arr.size()-i-1]); } } int main() { int a[] = {8, 5, 9, 1, 2, 6, 10, 7, 3, 4, 11, 15, 14, 13, 12}; vector<int> arr(a, a + sizeof (a) / sizeof (*a)); display(arr); heap_sort(arr); display(arr); system("PAUSE"); return 0; }
其中move_up是建堆的过程,时间复杂度是O(NlogN);move_down是提取根节点的过程,时间复杂度是O(NlogN);exchange是逆置的过程,时间复杂度是O(N)。所以堆排序整体的时间复杂度是O(NlogN)。
另外堆还适用于提取N个元素中最小的M个元素,具体算法逻辑为,建立一个M大小的最大堆,然后扫描N个元素,检测当前元素与最大堆中的根节点的大小关系,如果小于根节点,则删除根节点,并将新节点插入。直至将N个元素扫描完。其时间复杂度为O(NlogN)。
五、关于堆的update操作
刚刚上面我们谈到了提取N个元素中最小的M个元素的算法逻辑,其中,如果待检测元素小于最大堆的根节点,则我们是先删除根节点,再插入新节点。也就是说这个操作涉及了两个过程:删除根节点,插入一个新节点。
首先,删除根节点的具体操作是移除根节点,然后将最后一个元素前移至根节点处,然后move_down操作,进行调整,最终调整为符合堆的规则。其时间复杂度为O(NlogN)。
其次,插入一个新节点,即使将新节点放置于堆的最后面,然后进行move_up操作,直至调整为符合堆的规则。其时间复杂度为O(NlogN)。
那么,我们会问,可不可以直接将新节点替换根节点,然后进行调整。假如我们用新节点替换了根节点,替换后,如果新节点都是大于两个子节点的,那么照样符合最大堆的规则,所以不用进行后续的调整。如果小于其中之一,或者都小于子节点,那么进行move_down操作。操作后照样符合最大堆,所以这种策略是可行的,这样可以省去一般的时间复杂度,仅仅相当于删除操作中的move_down。
具体实施中,我们可以将根节点重置为新节点,然后move_down。这其实相当于堆根节点进行了update操作,即修改操作。
但是,我们又产生了又一个问题:如果可以对根节点进行修改操作,那么我们可以不可以对其他节点进行update操作呢?
假如我们对其中一个中间节点就行直接修改操作,这时我们需要判断修改后的值与父节点以及子节点的大小关系。如果是介于二者之间的,那么没有问题,不用进行调整。如果小于子节点,那么可以进行move_down操作,直至符合堆规则,貌似也没有问题。如果大于父节点,那么就会上移,如果在move_up的过程中,还没有到根节点的时候就终止了,那么还是没有问题的。如果到了根节点,并且大于根节点的两个子节点,那么也是没有问题。但是如果到了根节点,根节点小于另一个子树的根节点,那么还需要进行move_down操作,直至调整好。所以针对最大堆非根节点的update操作会出现以下几种情况:
- a[(i-1)/2] >= a[i] >= max(a[2*i+1], a[2*i+2])——无需调整
- a[i] < max(a[2*i+1], a[2*i+2])——move_down至终止
- a[i] > [a[(i-1)/2]]——move_up
a) 没有到达根节点即终止
b) 达到根节点,且a[0] >= a[1], a[0] >= a[2],终止
c) 达到根节点,但a[0]小于另一个子树的根节点,所以朝另一个树move_down直至终止
以上可以作为任意节点的修改算法。
讨论了对堆中任意节点的修改算法后,我们是否应该反思一下最小堆和最大堆的初衷是什么?最小堆和最大堆的最大特点就是父节点小于等于或大于等于其两个子节点。所以,其根节点是堆中最小或最大的节点,我们在利用最小堆和最大堆进行解决问题的时候也就是利用其根节点的特性。所以,对于修改堆中任意节点来说意义不大。
关于从N中提取M个最小元素的操作中,我们到底是否需要delet+insert操作还是update操作,取决于已有的接口。
比如在实现从N中提取M个最小元素的的时候,我们可能借助于STL中的set容器,由于set没有update操作,所以,我们只能借助于erase和insert操作。
以上是关于修改堆中任意元素的讨论。
六、总结
以上是介绍了关于堆的一些操作,其中包括如何建立一个堆、堆的插入操作、堆的删除操作、堆的应用——堆排序、以及如何修改堆中的任意元素。
(完)
文档信息
·版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0
·博客地址:http://www.cnblogs.com/unixfy
·博客作者:unixfy
·作者邮箱:goonyangxiaofang(AT)163.com
·如果你觉得本博文的内容对你有价值,欢迎对博主 小额赞助支持