二叉堆实现及时间复杂度分析

  • 前言
  • 时间复杂度
  • 堆的基本操作
  • 堆插入
  • 删除堆顶元素
  • 堆重建
  • 时间复杂度分析
  • 结束语


前言

二叉堆是一种特殊的堆,其特点是堆顶的元素为整个堆的最大值或最小值。因此,堆有两种实现形式,一种是最大堆,另一种是最小堆。其定义如下:
最大堆:父结点的键值总是大于或等于任何一个子节点的键值
最小堆:父结点的键值总是小于或等于任何一个子节点的键值
堆存放的底层数据结构,一般采用数组。堆有什么用呢,一般可以用来排序(堆排序),取前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。
***

codeforces 二叉堆_结点

给出代码如下:

// 调整当前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

codeforces 二叉堆_排序算法_02

时间复杂度分析

对堆重建的时间复杂度分析如下:
假设堆的高度为h,根节点高位为0,则每一个高度的元素个数计算方式为codeforces 二叉堆_排序算法_03。因此,位于高度codeforces 二叉堆_排序算法_04的某个元素需要遍历的次数为codeforces 二叉堆_数据结构_05。整个堆的全部元素遍历次数为:

codeforces 二叉堆_结点_06

也就是堆顶元素需要遍历codeforces 二叉堆_数据结构_07,第一个元素需要遍历codeforces 二叉堆_算法_08。可以看到,codeforces 二叉堆_排序算法_09为等差数列与等比数列的乘积求和,因此可以采用错位相减的方法求得codeforces 二叉堆_排序算法_09

codeforces 二叉堆_排序算法_11
codeforces 二叉堆_数据结构_12

一般地,codeforces 二叉堆_结点_13可以近似为codeforces 二叉堆_结点_14,且最末的叶子节点不会遍历,用codeforces 二叉堆_数据结构_15代替codeforces 二叉堆_结点_13,则

codeforces 二叉堆_codeforces 二叉堆_17

因此,时间复杂度为codeforces 二叉堆_数据结构_18

结束语

以上就是实现及时间复杂度分析的全部内容,欢迎留言讨论。