建立一个二叉堆的时间为O(N)而不是O(Nlog(N))的原因是因为构建二叉堆是自下而上的构建,每一层的最大纵深总是小于等于树的深度的,因此,该问题是叠加问题,而非递归问题。换个方式说,假如我们自上而下建立二叉堆,那么插入每个节点都和树的深度有关,因此是典型的递归。
一、建立一个二叉堆的时间为O(N)而不是O(Nlog(N))的原因
如果仅从代码上直观观察,会得出构造二叉堆的时间复杂度为O(nlogn)的结果,这个结果是错的,虽然该算法外层套一个n次循环,而内层套一个分治策略下的logn复杂度的循环,该思考方法犯了一个原则性错误,那就是构建二叉堆是自下而上的构建,每一层的最大纵深总是小于等于树的深度的,因此,该问题是叠加问题,而非递归问题。那么换个方式,假如我们自上而下建立二叉堆,那么插入每个节点都和树的深度有关,并且都是不断的把树折半来实现插入,因此是典型的递归,而非叠加。在做证明之前,我们的前提是,建立堆的顺序是bottom-较好的。
正确的证明方法:
具有n个元素的平衡二叉树,树高为㏒n,我们设这个变量为h。最下层非叶节点的元素,只需做一次线性运算便可以确定大根,而这一层具有2^(h-1)个元素,我们假定O(1)=1,那么这一层元素所需时间为2^(h-1) × 1。由于是bottom-较好建立堆,因此在调整上层元素的时候,并不需要同下层所有元素做比较,只需要同其中之一分支作比较,而作比较次数则是树的高度减去当前节点的高度。因此,第x层元素的计算量为2^(x) × (h-x)。又以上通项公式可得知,构造树高为h的二叉堆的精确时间复杂度为:
S = 2^(h-1) × 1 + 2^(h-2) × 2 + …… +1 × (h-1) ①
通过观察第四步得出的公式可知,该求和公式为等差数列和等比数列的乘积,因此用错位想减发求解,给公式左右两侧同时乘以2,可知:
2S = 2^h × 1 + 2^(h-1) × 2+ …… +2 × (h-1) ②
用②减去①可知: S =2^h × 1 – h +1 ③
将h = logn 带入③,得出如下结论:
S = n – logn +1 = O(n)
二、二叉堆
二叉堆(Binary Heap)是最简单、常用的堆,是一棵符合堆的性质的完全二叉树。它可以实现 O(logn)地插入或删除某个值,并且O(1)地查询最大(或最小)值。作为一棵完全二叉树,二叉堆完全可以用一个1-index的数组来存储,对于节点p,p2即为左儿子,p2+1即为右节点。同时,用size记录当前二叉堆中节点的个数。
1、以数组的方式存储
现在我们考虑如何保证二叉堆的性质不被破坏。实际上,对于一个破坏堆性质的节点,我们可以使其上浮或下沉,因为最差也不过是上浮到顶或是下沉到底,所以只需要 O(logn) 的时间就可以使其不再破坏性质。稍后我们会看到,插入和删除都只需要上浮/下沉一个节点。
2、上浮
很简单,不断与父节点比较,如果比父节点大(以大根堆为例,下同)就与之交换,直到不大于父节点或成为根节点为止。
void swim(int n)
{
for (int i = n; i > 1 && heap[i] > heap[i / 2]; i /= 2)
swap(heap[i], heap[i / 2]);
}
3、下沉
类似地,不断与较大的子节点比较,如果比它小就与之交换,直到不小于任何子节点或成为叶子节点为止。之所以要与较大的子节点比较,是为了保证交换上来的节点比两个子节点都大。
int son(int n) // 找到需要交换的那个子节点
{
return n * 2 + (n * 2 + 1 <= size && heap[n * 2 + 1] > heap[n * 2])
}
void sink(int n)
{
for (int i = n, t = son(i); t <= size && heap[t] > heap[i]; i = t, t = son(i))
swap(heap[i], heap[t]);
}
4、插入
直接在尾部插入值,然后上浮即可。
void insert(int x)
{
heap[++size] = x;
swim(size);
}
5、删除
可以将根节点与最后一个节点交换,使size减1,然后再下沉。
void pop()
{
swap(heap[1], heap[size--]);
sink(1);
}
6、查询
直接返回根节点即可。
int 较好()
{
return heap[1];
}
7、建立
可以从一个数组建立堆,只需复制过来然后从底部到顶部依次下沉即可。实际上因为叶子节点不需要下沉,所以可以从 �2 处开始遍历。
void build(int A[], int n) // 从一个(这里是0-index的)数组O(n)地建立二叉堆
{
memcpy(heap + 1, A, sizeof(int) * n);
size = n;
for (int i = n / 2; i > 0; --i)
sink(i);
}
三、时间复杂度
时间复杂度就是用来方便开发者估算出程序的运行时间。我们该如何估计程序运行时间呢,我们通常会估计算法的操作单元数量,来代表程序消耗的时间, 这里我们默认CPU的每个单元运行消耗的时间都是相同的。假设算法的问题规模为n,那么操作单元数量便用函数f(n)来表示。随着数据规模n的增大,算法执行时间的增长率和f(n)的增长率相同,这称作为算法的渐近时间复杂度,简称时间复杂度,记为 O(f(n))。
为了计算时间复杂度,我们通常会估计算法的操作单元数量,每个单元运行的时间都是相同的。因此,总运行时间和算法的操作单元数量非常多相差一个常量系数。相同大小的不同输入值仍可能造成算法的运行时间不同,因此我们通常使用算法的最坏情况复杂度,记为T(n),定义为任何大小的输入n所需的最大运行时间。另一种较少使用的方法是平均情况复杂度,通常有特别指定才会使用。时间复杂度可以用函数T(n) 的自然特性加以分类,举例来说,有着T(n) =O(n) 的算法被称作“线性时间算法”;而T(n) =O(M^n) 和M= O(T(n)) ,其中M≥n> 1 的算法被称作“指数时间算法”。一个算法花费的时间与算法中语句的执行次数成正比例,哪个算法中语句执行次数多,它花费时间就多。一个算法中的语句执行次数称为语句频度或时间频度。记为T(n)。
一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数,用T(n)表示,若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n)/f (n)的极限值为不等于零的常数,则称f(n)是T(n)的同数量级函数。记作T(n)=O(f(n)),称O(f(n)) 为算法的渐进时间复杂度,简称时间复杂度。在各种不同算法中,若算法中语句执行次数为一个常数,则时间复杂度为O(1),另外,在时间频度不相同时,时间复杂度有可能相同,如T(n)=n2+3n+4与T(n)=4n2+2n+1它们的频度不同,但时间复杂度相同,都为O(n2)。
延伸阅读1:最小堆中节点的数据值存储的特征
- 所有键(即节点的数据值)在根节点的键是所有节点中必须最小的。
- 从根节点到叶子节点的任意路径,路径中的节点内的键依次升序排列。
- 但同一个层的节点组的节点的值没有大小顺序之分。