【初阶数据结构】理解堆的特性与应用:深入探索完全二叉树的独特魅力

简介: 【初阶数据结构】理解堆的特性与应用:深入探索完全二叉树的独特魅力

一、二叉树的顺序结构

普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段

二、堆的概念及结构

如果有一个关键码的集合K ={k0,k1,k2,…,kn-1},把它的所有元素按照完全二叉树的顺序存储方式存储在一个一维数组中,并满足:Ki <= K2*i+1且 Ki <= K2*i+2 (Ki >= K2*i+1且 Ki >= K2*i+2) i =0, 1, 2…则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆

堆分为大堆和小堆

  • 小堆要求:任意一个父亲结点<=孩子结点
  • 大堆要求:任意一个父亲结点>=孩子结点

堆的性质

  • 大堆中某个节点的值总是不大于其父节点的值
  • 小堆中某个节点的值总是不小于其父节点的值
  • 堆总是一棵完全二叉树

三、堆的实现

堆分为大堆或小堆,无论是向上或向下调整算法,会根据大小堆的需求去修改部分的代码,其实就是修改大于小于号的问题。以下代码部分是根据建小堆来走,如果需要建大堆可以修改直接的大于小于号。

堆总是一颗完全二叉树,对于搭建完全二叉树的结构,一般采用数组作为存储结构,而完全二叉树作为逻辑结构。

父子节点间下标规律关系

  • leftchild = parent * 2 + 1;
  • rightchild = paretn * 2 +2;
  • parent = (child - 1) / 2;(不区分左右孩子)

3.1 堆向下调整算法

堆向下调整(Heapify Down)是一个修复堆性质的过程,而不是用于初始化或完全建立堆数据结构的过程。使用向下调整算法的前提是需要左右子树必须是一个堆才能进行调正,如果左右子树不是一个堆,我们将不采取使用向下调整算法,而是采用向上调整算法。

堆向下调整算法只用于根节点不满某种条件时,使用向下调正算法进行调整,至于使用向下调整算法不能达到我们的预期,比如现在建小堆,从根节点和根左右节点调整,由于左右子树不是一个小堆,无法保证此时的根就是最小的值,可能在某个子树中,左右子树话没有进行调整。除此之外删除节点也适合向下调整算法。

void AdjustDown(HPDataType *a,int size,int parent)
{
    int child=parent * 2 + 1;
    while(child<size)//空树或者只有一个结点
    {
        //假设左孩子小,如果右孩子小,就更新下(左右孩子相差1)选择较小的孩子
        if(child+1<size && a[child+1]<a[child])
        {
            ++child;
        }
        if(a[child]<a[parent])
        {
            Swap(&a[child],&a[parent]);
            //通过孩子结点的数值赋值父亲结点,实现向下的逻辑
            parent=child;
            child=parent*2+1;              
        }
        else
        {
            break;
        }
    }
} 

3.2 向上调整算法

在堆数据结构中,堆向上调整(Heapify Up)是一种用于保持堆的性质的操作,通常适用于最后一个元素出现问题或者插入新元素的时候使用.

void AdJustUp(HPDataType *a,int child)
{
    int parent=(child-1)/2;//父亲和孩子的数值是下标
    while(child>0)//向上到根就停下
    {
       if(a[child] < a[parent])
       {
           swap(&a[child],&a[parent]);
        //通过父亲结点的数值赋值孩子结点,实现向上的逻辑
        child=parent;
        parent=(child-1)/2;
       }
        else
        {
      break;//不用交换,直接退出
        }
     }   
}

这里的孩子节点不用需要判断左右孩子,肯定是先插入左孩子,在插入右孩子,如果如果是插入右孩子,就不必要考虑左孩子,此时左孩子的值是符合大于(小于)等于父亲的情况.

无论是向下调整算法还是向下调整算法,目的都是使得保持堆的性质,在判断语句中得以体现。想要更好地理解这个两个算法,搞清楚谁是需要被处理的节点,循环条件是什么?

3.3 处理完全二叉树不是堆情况

把它构建成一个堆。根节点左右子树不是堆,这里我们从倒数的第一个非叶子节点的子树使用向上调整算法开始调整,一直调整到根节点的树,就可以调整成堆。

3.4 堆的插入

随机插入一个数值到数值的尾上,再进行向上调整算法直到满足堆

void HeapPush(HP *php,HPDataType x)
{
  assert(php);
    if(php->size==php->capacity)
    {
        int newcapacity=php->capacity==0?4:php->capacity*2;
        HPDataType*tmp=(HPDataType*)realloc(php->a,newcapacity*sizeof(HPDataTyped));
        if(tmp==NULL)
        {
            perror("realloc fail!!!");
            return 1;
        }
        php->a=tmp;
        php->capacity=newcapacity;
    }
    php->a[php->size]=x;
      php->size++;
    
    //重头戏--向上调整形成一个堆,这里的size代表的是下一个元素,所以-1
    AdjustUp(php->a,php->size-1);
}

3.5 堆的删除

关于堆的删除,我们一般默认规定删除堆顶也是就是根节点,至于删除尾部数据意义不大,尾部数据没有特别的地方,既不是最大(小)的数据意义不大。

3.5.1 挪移数据覆盖删除

挪移数据覆盖会导致堆发生严重BUG,整棵树的父子关系全乱,也就是需要维持大小关系乱了(我拿你当兄弟,你却像当我爹)

3.5.2 首尾交换再删除

对于堆的删除,我们采用另外一种方法,首尾交换再删除,左右子树依旧是堆,同时关系也没有乱,并且删除堆顶数据通过尾删再向下调整代价很低

void HeapPop(HP *php)
{
  assert(php);
    assert(php->size>0)//没有数值,不需要删除
     Swap(&php->a[0],&php->a[php->size-1]);//size是指向下一个
    php->size--;
    
    AdjustDown(php->a,php->size,0)
        
}

四、堆的应用

4.1 堆排序

堆排序(HeapSort)移除位在第一个数据的根节点,并做最大堆调整的递归运算建堆(本质:模拟堆插入的过程建堆)。上面对于堆的调整不是叫做堆排序,堆排序是对数组元素进行操作的

堆排序即是运用堆的思想进行排序,总共分为两个步骤:建堆和利用堆删除思想进行排序

1.建堆 (后面有解释)

  • 升序:建大堆
  • 降序:建小堆

2.利用堆删除思想来进行排序

建堆和堆删除中都用到了向下调整,因此掌握了向下调整,就可以完成堆排序。

该过程解析:这里是需要升序,根据结论需要建大堆。可以这样子理解升序建大堆目的,我们配合物理结构数组和逻辑结构二叉树去看待这个问题,如果我们需要升序,意味着数组最后一个元素是最大值,那么大堆可以保证堆顶元素是最大值,再利用堆删除的思想,将堆顶元素和尾元素交换,那么可以保证最大值在尾,而且由于是大堆,尾元素互会通过向下调整算法使得堆顶元素为次大的值,这个时候最后一个元素不用去动他,倒数第二个位置跟次大堆顶元素交换,这样子就完成了堆排序。

4.1.1 如果升序建小堆

如果升序建小堆1 2 2 6 5 8 4 9,当我们把最小值1选出来后,接下来需要找次小值。最小值1的位置是不动,剩下的数不能看成堆,关系乱了,只能重新建堆,找出次小值,但是代价很大

4.1.2 向上或向下调整建堆

这里为了快速地使用堆排序,这里可以直接通过向上或向下调整算法直接建堆。不止可以使用向上调整建堆,也可以使用向下调整建堆(使用向下调整建堆,需要保证左右为堆),对此不能从整体入手,可以一步步向上。从倒数的第一个非叶子,也就是最后一个结点的父亲,不断的向上而向下调整。向下调整建堆相较于向上调整建堆有很多优势,在建堆的时间复杂度分析中,可以看出,这里关于这方面会单独拿出来分析。对此这需要掌握堆向下调整算法即可

这里不要跟上面堆的插入混淆,这里数组元素已经确定,而堆的插入元素在不断地更新,如果使用向下调整意味着从新插入界节点重新向上调整,向上调整只需要对新插入节点进行移动即可

//升序
void HeapSort(int *a,int n)  
    //O(N*logN)
    //for(int i=0;i<n;i++)
    //{
    //  AdjustUp(a,i);
   // }
    //O(N)
  for(int i=(n-1-1)/2;i>=0;--i)
    {
    AdjustDown(a,n,i);//从倒数的第一个非叶子,也就是最后一个结点的父亲
    }
  int end=n-1;//下标--同时调整后,最后一个元素不再改动
  
  //O(N*logN)
  while(end>0)//利用堆删除思想进行排序
    {
    Swap(&a[0],&a[end]);
        AdjustDown(a,end,0);//要清楚为什么要向下调整
        --end;
    }

4.1.3向下向上调整建堆时间复杂度

过程解析:无论是向上还是向下调整建堆,建堆的累积调整次数等于每一层节点个数*向上(下)调正次数之和。主要是利用高中数学中错位相减法计算出求和通式。由于一般不会得知树的高度去求时间复杂度,而是通过节点个数去求时间复杂度,这里需要利用树的高度与节点个数的关系式,进行替换即可满二叉树:2^h-1 =N 可得 h =log(N+1)

这里建堆主要就是受到每一个节点个数*向上(下)调正次数,对于向下调整建堆多节点*少调整、少节点*多调整,而向上调整建堆多节点*多调整、少节点*少调整导致时间复杂度差异。

4.2 TOP-K问题

即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。

对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中,内存不足的问题)。最佳的方式就是用堆来解决,基本思路如下

数据集合中前K个元素来建堆

  • 前k个最大的元素,则建小堆
  • 前k个最小的元素,则建大堆

用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素 ,将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素 ,时间复杂度O(N*logK)

解析过程:这里思路跟堆排序大差不差,主要就是利用堆顶的特性。如果需要找出最大值,那么在小堆(都是很大的值)中堆顶就相当于门槛,至少需要被最大中的最小要大才有资格进来,然后重新筛选出来新的最小值当保安。

测试代码(自取)

void TestTopk()
{
    int n = 10000;
    int* a = (int*)malloc(sizeof(int)*n);
    srand(time(0));
    for (size_t i = 0; i < n; ++i)
    {
    a[i] = rand() % 1000000;
    }
    a[5] = 1000000 + 1;
    a[1231] = 1000000 + 2;
    a[531] = 1000000 + 3;
    a[5121] = 1000000 + 4;
    a[115] = 1000000 + 5;
    a[2335] = 1000000 + 6;
    a[9999] = 1000000 + 7;
    a[76] = 1000000 + 8;
    a[423] = 1000000 + 9;
    a[3144] = 1000000 + 10;
}





相关文章
|
13天前
|
存储 算法 Java
散列表的数据结构以及对象在JVM堆中的存储过程
本文介绍了散列表的基本概念及其在JVM中的应用,详细讲解了散列表的结构、对象存储过程、Hashtable的扩容机制及与HashMap的区别。通过实例和图解,帮助读者理解散列表的工作原理和优化策略。
28 1
散列表的数据结构以及对象在JVM堆中的存储过程
|
25天前
|
存储 Java
Java中的HashMap和TreeMap,通过具体示例展示了它们在处理复杂数据结构问题时的应用。
【10月更文挑战第19天】本文详细介绍了Java中的HashMap和TreeMap,通过具体示例展示了它们在处理复杂数据结构问题时的应用。HashMap以其高效的插入、查找和删除操作著称,而TreeMap则擅长于保持元素的自然排序或自定义排序,两者各具优势,适用于不同的开发场景。
41 1
|
30天前
|
存储 算法 C语言
通义灵码在考研C语言和数据结构中的应用实践 1-5
通义灵码在考研C语言和数据结构中的应用实践,体验通义灵码的强大思路。《趣学C语言和数据结构100例》精选了五个经典问题及其解决方案,包括求最大公约数和最小公倍数、统计字符类型、求特殊数列和、计算阶乘和双阶乘、以及求斐波那契数列的前20项和。通过这些实例,帮助读者掌握C语言的基本语法和常用算法,提升编程能力。
60 4
|
15天前
|
存储 搜索推荐 算法
【数据结构】树型结构详解 + 堆的实现(c语言)(附源码)
本文介绍了树和二叉树的基本概念及结构,重点讲解了堆这一重要的数据结构。堆是一种特殊的完全二叉树,常用于实现优先队列和高效的排序算法(如堆排序)。文章详细描述了堆的性质、存储方式及其实现方法,包括插入、删除和取堆顶数据等操作的具体实现。通过这些内容,读者可以全面了解堆的原理和应用。
57 16
|
23天前
|
机器学习/深度学习 存储 人工智能
数据结构在实际开发中的广泛应用
【10月更文挑战第20天】数据结构是软件开发的基础,它们贯穿于各种应用场景中,为解决实际问题提供了有力的支持。不同的数据结构具有不同的特点和优势,开发者需要根据具体需求选择合适的数据结构,以实现高效、可靠的程序设计。
53 7
|
1月前
|
存储 JavaScript 前端开发
为什么基础数据类型存放在栈中,而引用数据类型存放在堆中?
为什么基础数据类型存放在栈中,而引用数据类型存放在堆中?
68 1
|
1月前
|
存储 算法
探索数据结构:分支的世界之二叉树与堆
探索数据结构:分支的世界之二叉树与堆
|
16天前
|
C语言
【数据结构】栈和队列(c语言实现)(附源码)
本文介绍了栈和队列两种数据结构。栈是一种只能在一端进行插入和删除操作的线性表,遵循“先进后出”原则;队列则在一端插入、另一端删除,遵循“先进先出”原则。文章详细讲解了栈和队列的结构定义、方法声明及实现,并提供了完整的代码示例。栈和队列在实际应用中非常广泛,如二叉树的层序遍历和快速排序的非递归实现等。
90 9
|
7天前
|
存储 算法
非递归实现后序遍历时,如何避免栈溢出?
后序遍历的递归实现和非递归实现各有优缺点,在实际应用中需要根据具体的问题需求、二叉树的特点以及性能和空间的限制等因素来选择合适的实现方式。
15 1
|
9天前
|
存储 算法 Java
数据结构的栈
栈作为一种简单而高效的数据结构,在计算机科学和软件开发中有着广泛的应用。通过合理地使用栈,可以有效地解决许多与数据存储和操作相关的问题。