二叉堆实现及时间复杂度分析
- 前言
- 时间复杂度
- 堆的基本操作
- 堆插入
- 删除堆顶元素
- 堆重建
- 时间复杂度分析
- 结束语
前言
二叉堆是一种特殊的堆,其特点是堆顶的元素为整个堆的最大值或最小值。因此,堆有两种实现形式,一种是最大堆,另一种是最小堆。其定义如下:
最大堆:父结点的键值总是大于或等于任何一个子节点的键值;
最小堆:父结点的键值总是小于或等于任何一个子节点的键值。
堆存放的底层数据结构,一般采用数组。堆有什么用呢,一般可以用来排序(堆排序),取前K个大值(或小值)的元素,抑或只关心数据的最大值或最小值的情况(启发式搜索算法中,频繁查询代价最小值的元素,如Astar),均可构造堆结构,进行快速存取。
时间复杂度
这里直接给出几个操作的时间复杂度,后面详细分析下
堆排序:nlog(n)
堆插入元素:log(n)
堆调整:log(n)
堆重建:O(n)
堆的基本操作
堆基本操作包括,删除堆顶元素,插入元素,堆重建。这里,均以最小堆为例,说明堆基本操作及代码。最大堆只需要将判断条件修改一下即可。
堆插入
堆插入,就是现在已经有建好的堆了,新来一个元素后,需要将新元素加到堆中。因为,前面已经有的元素,其顺序关系已经满足最小堆的定义。即父结点的键值总是小于或等于任何一个子节点的键值。要满足这个定义,可以先把新元素,放到堆的最后,然后与其父结点进行比较,当父节点大于新元素时,交换两者的位置,让新元素成为父节点。然后再次比较当前新位置的父节点,是否比新元素大。这也就是堆结构中的上浮操作,把较小的元素从最末的位置,慢慢交换上来,直到满足定义或者到达堆顶。
// 已经建好堆的数组,插入新元素
void insert_heaq(vector<int>& data, int val)
{
data.push_back(val);
int child = data.size() - 1;
int i = (child - 1) / 2;
// 上浮,i 为父节点
while(child > 0)
{
if(data[i] <= val)
{
// 父节点不大于自己,停止上浮
break;
}
data[child] = data[i];
child = i;
i = (child - 1) >> 1;
}
data[child] = val;
}
解释一下:采用数组结构,从下标0开始存放元素,假设当前结点的下标为i,其元素值为data[i],对应的左子结点下标为left = (i * 2) + 1,右子结点下标为right = (i * 2) + 2,其父结点下标为p = (i-1) / 2。也就是data[3]的父节点为data[(3-1)/2],也就是data[1]。一般,还有种存放方式,就是从下标1开始存放,其左结点(i * 2)和右结点(i * 2) + 1。
代码解释:首先将新元素放到vector的末尾,然后循环比较自己的父结点是否比自己大,比自己大则交换。
删除堆顶元素
一般地,外部获取到堆顶元素后,会采取删除堆顶元素的操作。
int front_heaq(vector<int>& data)
{
if(data.empty())
return -1; // 定义-1为错误码
return data[0];
}
删除堆顶元素肯定会破坏原堆的性质,因此需要对堆进行调整。一种做法是,先把堆顶元素与末尾元素交换,然后判断新的堆顶元素,是否满足定义,若不满足则与左右子结点比较,将左右子结点中,较小的元素交换到当前父结点位置,然后依次比较下去。这就是,堆的下沉操作。把较大的元素与子结点比较,下沉到堆尾。
// 获取堆顶元素,并删除堆顶
int pop_heaq(vector<int>& data)
{
if(data.empty())
{
return -1; // 定义-1为错误码
}
int i = 0,left,right,min_index,min_val;
int res = data[0]; // 作为返回元素
int val = data.back(); // 堆末元素
data.pop_back();
int n = data.size() - 1; //最末位置下标
int len = ((n-1) / 2)+ 1; // 最末非叶子结点位置
// 数据从0开始存放
left = ((i << 1) + 1);
right = ((i << 1) + 2);
// 元素下沉,i为父节点
while(i < len && left <= n)
{
min_index = left;
min_val = data[left];
if(right <= n && data[right] < min_val)
{
// 保留最小子节点
min_val = data[right];
min_index = right;
}
// 与最小节点比较
if(min_val < val)
{
// 把小的值挪到父节点
data[i] = min_val;
// 更新父节点及子节点
i = min_index;
left = ((i << 1) + 1);
right = ((i << 1) + 2);
continue;
}
break;
}
data[i] = val;
return res;
}
堆重建
有两种方法,一种是,直接遍历数组每一个元素,对每一个新元素调用insert_heaq函数进行插入,这种方法的时间复杂度是nlog(n)。另外一种是原地调整数组元素,使其满足定义。具体的方法是:
从最末一个非叶子结点开始,由下到上,依次比较当前结点与其左右结点的大小,如子结点值小于当前值,则交换。交换之后,要检查交换后的新位置是否满足,其值小于或等于它的子节点,迭代检查。
更加详细的展示和分析可以参考这个链接:zabery-堆排序及分析
调堆的过程应该从最后一个非叶子节点开始,假设有数组A = {1, 3, 4, 5, 7, 2, 6, 8, 0}。那么调堆的过程如下图,数组下标从0开始,A[3] = 5开始。分别与左孩子和右孩子比较大小,如果A[3]最大,则不用调整,否则和孩子中的值最大的一个交换位置,在图1中是A[7] > A[3] > A[8],所以A[3]与A[7]对换,从图1.1转到图1.2。***
给出代码如下:
// 调整当前index所在的堆,比较index的子节点,并递归调整
static void adjust_heaq(vector<int>& data,int index)
{
int min_index,min_val,left,right;
int n = data.size() - 1; //最末位置下标
int len = ((n-1) / 2)+ 1; // 最末非叶子结点位置
left = (index << 1) + 1; // 左结点
right = (index << 1) + 2; // 右结点
// 和下沉一样的逻辑
while(index < len && left <= n)
{
min_val = data[left];
min_index = left;
if(right <= n && data[right] < min_val)
{
// 保留最小子节点
min_val = data[right];
min_index = right;
}
if(min_val < data[index])
{
// 交换元素
data[min_index] = data[index];
data[index] = min_val;
// 更新父节点,一直往下判断
index = min_index;
left = (index << 1) + 1;
right = (index << 1) + 2;
continue;
}
break;
}
}
// 原地建堆,时间复杂度o(n)
void build_heaq(vector<int>& data)
{
if(data.empty())
{
return;
}
// 从下往上交换:最小的非叶子节点开始
int n = data.size() - 1;
int i = (n - 1) / 2;
for(; i >= 0; i--)
{
adjust_heaq(data,i);
}
}
简单测试下,代码c++,不过c也是一样的写法。
// 主函数入口,测试函数
int main()
{
vector<int> data = {1,3,9,4,5,8,2,0,6};
build_heaq(data);
return 0;
}
经过排列后的data顺序为:
0 1 2 3 5 8 9 4 6
时间复杂度分析
对堆重建的时间复杂度分析如下:
假设堆的高度为h,根节点高位为0,则每一个高度的元素个数计算方式为。因此,位于高度的某个元素需要遍历的次数为。整个堆的全部元素遍历次数为:
也就是堆顶元素需要遍历,第一个元素需要遍历。可以看到,为等差数列与等比数列的乘积求和,因此可以采用错位相减的方法求得。
一般地,可以近似为,且最末的叶子节点不会遍历,用代替,则
因此,时间复杂度为。
结束语
以上就是实现及时间复杂度分析的全部内容,欢迎留言讨论。