以下内容主要参考了严蔚敏版的数据结构教材。
对于n个元素的序列
{
k
0
,
k
1
,
k
2
,
.
.
.
k
n
−
1
}
\{k_0,k_1,k_2,...k_{n-1}\}
{k0,k1,k2,...kn−1},如果
k
i
<
=
k
2
i
+
1
k_i<=k_{2i+1}
ki<=k2i+1且
k
i
<
=
k
2
i
+
2
k_i<=k_{2i+2}
ki<=k2i+2(小堆)或者
k
i
>
=
k
2
i
+
1
k_i>=k_{2i+1}
ki>=k2i+1且
k
i
>
=
k
2
i
+
2
k_i>=k_{2i+2}
ki>=k2i+2(大堆),其中
i
=
0
,
1
,
2
,
.
.
.
,
⌊
n
2
⌋
−
1
i=0,1,2,...,\lfloor \frac{n}{2}\rfloor-1
i=0,1,2,...,⌊2n⌋−1,则该序列称为堆。当以一维数组存储堆时索引i也可以看成是数组中元素的索引。如果将某堆的一维数组存储结构看成是某颗二叉树的顺序存储结构,则该二叉树上所有非叶子节点的值都不大于其左右孩子的值(小堆)或者该二叉树上所有非叶子节点的值都不小于其左右孩子的值(大堆)。二叉树的根节点是所有n个节点的值中最小的(小堆)或者二叉树的根节点是所有n个节点的值中最大的(大堆)。以二叉树作为图示的两个堆的实例如图1所示。
如果在得到堆的最小或最大元素之后,将剩下的
n
−
1
n-1
n−1个元素重新调整又重建一个新的堆就可以得到n个元素中的次小或次大值。重复以上过程便可以得到一个有序序列,这个过程称之为堆排序。
堆排序需要解决两个问题:
- 如何由一个无序序列建成一个堆。
- 如何在输出堆顶元素之后,调整剩余的元素成为一个新的堆。
对于第二个问题,在输出堆顶元素之后可以用堆中的最后一个元素替代堆顶元素,然后比较当前堆顶元素和其左右孩子中较大的那个(大堆)或较小的那个(小堆),如果堆顶元素大于等于较大的那个则调整结束否则堆顶元素和较大的那个互换并从互换的那个孩子开始递归的调整(大堆),如果堆顶元素小于等于较小的那个则调整结束否则堆顶元素和较小的那个互换并从互换的那个孩子开始递归的调整(小堆)。调整直到当前要调整的节点为叶子节点或当前调整过程没有出现互换时停止。对于图1所示的大堆的排序过程如图2所示。
对于第一个问题,可以从第 ⌊ n 2 ⌋ − 1 \lfloor \frac{n}{2}\rfloor-1 ⌊2n⌋−1个元素(最后一个非终端节点)开始向下递归调整,接着是倒数第二个非终端节点,最后是根节点。从一个无序序列建大堆的过程如图3所示。
// To heapify a subtree rooted with node i which is an index in arr[]. n is size of heap
void heapify(int arr[], int n, int i)
{
int largest = i; // Initialize largest as root
int l = 2 * i + 1; // left = 2*i + 1
int r = 2 * i + 2; // right = 2*i + 2
// If left child is larger than root
if (l < n && arr[l] > arr[largest])
largest = l;
// If right child is larger than largest so far
if (r < n && arr[r] > arr[largest])
largest = r;
// If largest is not root
if (largest != i)
{
swap(arr[i], arr[largest]);
// Recursively heapify the affected sub-tree
heapify(arr, n, largest);
}
return;
}
// main function to do heap sort
void heapSort(int arr[], int n)
{
// Build heap (rearrange array)
for (int i = n / 2 - 1; i >= 0; i--)
heapify(arr, n, i);
// One by one extract an element from heap
for (int i = n - 1; i > 0; i--)
{
// Move current root to end
swap(arr[0], arr[i]);
// call max heapify on the reduced heap
heapify(arr, i, 0);
}
}
/* A utility function to print array of size n */
void printArray(int arr[], int n)
{
for (int i = 0; i < n; ++i)
cout << arr[i] << " ";
cout << "\n";
}
// Driver program
int main()
{
int arr[] = { 12, 11, 13, 5, 6, 7 };
int n = sizeof(arr) / sizeof(arr[0]);
heapSort(arr, n);
cout << "Sorted array is \n";
printArray(arr, n);
}
源代码来源于这里。堆排序对记录数较少的文件并不值得提倡,但对记录数较大的文件还是比较值得的。其主要时间花费主要是在初始建堆和后续调整建新堆的过程中。对于深度为k的堆(其实就是其对应的完全二叉树的高度),向下调整的过程中最多进行 2 ( k − 1 ) 2(k-1) 2(k−1)次关键字的比较。对于有n个元素高度为h的初始序列,初始建堆的过程中关键字比较的次数不会超过 4 n 4n 4n。推导如下:因为第i层 ( i = 1 , 2 , . . . , h ) (i=1,2,...,h) (i=1,2,...,h)的节点数最多为 2 i − 1 2^{i-1} 2i−1,以它们为根的二叉树的深度为 h − i + 1 h-i+1 h−i+1,则第i层中的所有节点在初始建堆的过程中关键字的比较的次数之和为
- 2 i − 1 ∗ 2 ( h − i ) = 2 i ∗ ( h − i ) 2^{i-1}*2(h-i)=2^i*(h-i) 2i−1∗2(h−i)=2i∗(h−i)。
则整个初始建堆过程中关键字的比较次数为:
- ∑ i = h − 1 1 2 i ∗ ( h − i ) \sum\limits_{i=h-1}^{1}2^i*(h-i) i=h−1∑12i∗(h−i),令 i = h − j i=h-j i=h−j则有:
- ∑ i = h − 1 1 2 i ∗ ( h − i ) \sum\limits_{i=h-1}^{1}2^i*(h-i) i=h−1∑12i∗(h−i)= ∑ j = 1 h − 1 2 h − j ∗ j \sum\limits_{j=1}^{h-1}2^{h-j}*j j=1∑h−12h−j∗j= ∑ j = 1 h − 1 2 h j 2 j \sum\limits_{j=1}^{h-1}2^h\frac{j}{2^j} j=1∑h−12h2jj= 2 h ∑ j = 1 h − 1 j 2 j 2^h\sum\limits_{j=1}^{h-1}\frac{j}{2^j} 2hj=1∑h−12jj<= 2 n ∑ j = 1 h − 1 j 2 j 2n\sum\limits_{j=1}^{h-1}\frac{j}{2^j} 2nj=1∑h−12jj
- 又因为 ∑ j = 1 h − 1 j 2 j = 1 2 + 2 4 + 3 8 + 4 16 + . . . + h − 1 2 h − 1 \sum\limits_{j=1}^{h-1}\frac{j}{2^j}=\frac{1}{2}+\frac{2}{4}+\frac{3}{8}+\frac{4}{16}+...+\frac{h-1}{2^{h-1}} j=1∑h−12jj=21+42+83+164+...+2h−1h−1,2 ∑ j = 1 h − 1 j 2 j = 1 + 2 2 + 3 4 + 4 8 + . . . + h − 1 2 h − 2 \sum\limits_{j=1}^{h-1}\frac{j}{2^j}=1+\frac{2}{2}+\frac{3}{4}+\frac{4}{8}+...+\frac{h-1}{2^{h-2}} j=1∑h−12jj=1+22+43+84+...+2h−2h−1,所以:
- 2 ∑ j = 1 h − 1 j 2 j − ∑ j = 1 h − 1 j 2 j 2\sum\limits_{j=1}^{h-1}\frac{j}{2^j}-\sum\limits_{j=1}^{h-1}\frac{j}{2^j} 2j=1∑h−12jj−j=1∑h−12jj= ∑ j = 1 h − 1 j 2 j \sum\limits_{j=1}^{h-1}\frac{j}{2^j} j=1∑h−12jj= 1 + 1 2 + 1 4 + 1 8 + . . . + 1 2 h − 2 − h − 1 2 h − 1 1+\frac{1}{2}+\frac{1}{4}+\frac{1}{8}+...+\frac{1}{2^{h-2}}-\frac{h-1}{2^{h-1}} 1+21+41+81+...+2h−21−2h−1h−1
- 又因为 1 + 1 2 + 1 4 + 1 8 + . . . + 1 2 h − 2 1+\frac{1}{2}+\frac{1}{4}+\frac{1}{8}+...+\frac{1}{2^{h-2}} 1+21+41+81+...+2h−21= 2 − 1 2 h − 2 2-\frac{1}{2^{h-2}} 2−2h−21(等比数列求和),所以:
- ∑ j = 1 h − 1 j 2 j \sum\limits_{j=1}^{h-1}\frac{j}{2^j} j=1∑h−12jj= 2 − 1 2 h − 2 − h − 1 2 h − 1 2-\frac{1}{2^{h-2}}-\frac{h-1}{2^{h-1}} 2−2h−21−2h−1h−1= 2 − h + 1 2 h − 1 2-\frac{h+1}{2^{h-1}} 2−2h−1h+1<=2.最后因此:
- ∑ i = h − 1 1 2 i ∗ ( h − i ) \sum\limits_{i=h-1}^{1}2^i*(h-i) i=h−1∑12i∗(h−i)<=4n
初始堆建立完毕后还要调用 v o i d h e a p i f y ( i n t a r r [ ] , i n t n , i n t i ) void\quad heapify(int\quad arr[], int \quad n, int \quad i) voidheapify(intarr[],intn,inti)一共 n − 1 n-1 n−1次来得到最后的有序序列,这个过程的关键字比较次数为(n个节点的完全二叉树的高度为 ⌊ log 2 n ⌋ + 1 \lfloor \log_2^n \rfloor+1 ⌊log2n⌋+1):
- 2 ∗ ( ⌊ log 2 n − 1 ⌋ + ⌊ log 2 n − 2 ⌋ + . . . + ⌊ log 2 2 ⌋ ) 2*(\lfloor \log_2^{n-1} \rfloor+\lfloor \log_2^{n-2} \rfloor+...+\lfloor \log_2^2 \rfloor) 2∗(⌊log2n−1⌋+⌊log2n−2⌋+...+⌊log22⌋)<= 2 ∗ n ⌊ log 2 n ⌋ 2*n\lfloor \log_2^n\rfloor 2∗n⌊log2n⌋
因此堆排序在最坏情况下的时间复杂度为
O
(
4
n
+
2
n
log
2
n
)
O(4n+2n\log_2^n)
O(4n+2nlog2n)=
O
(
n
log
2
n
)
O(n\log_2^n)
O(nlog2n)。
在面试中经常遇到要求从很大量的数据(m个)中(比如100万)找到最大的几个(n个)(比如10)树。这时我们可以先从海量数据中选取少部分的数据(k个)(比如100个)并构建一个小堆,在迭代剩下的数据,如果当前迭代的数据小于等于堆顶的元素则继续迭代下一个数据,如果当前迭代的数据大于堆顶的元素,则用当前的迭代元素代替堆顶元素并重新调整成堆。迭代完之后再在这k的数据中寻找最大的那m个。在内存不太够的情况下,这种方法也是可行的,可以只是将堆放在内存中,然后每次从硬盘中读取剩下的下一个需要迭代的数据。
也可以先将这大量的数据划分成许多块,然后每一块分别进行排序(如果有多台机器的话可以真真的并行执行)。最后从这许多块排好序的数据块中挑出所需要的最大的几个数。