数据结构

3,968 阅读28分钟

线性结构:

      数组、队列、链表、栈

非线性结构:

    二维数组、多维数组、广义表、树结构、图结构

数据结构:

    数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie书

数组

     数组(Array)是一种线性数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。

     那么看一下线性结构类型的数据↓:他们都遵循 先进先出 的规则。

     添加说明:栈是个 先进后出的

话题回归数组

查询

我们去随机访问数组的某一个值时:(时间复杂度为O(1))

a[i]_address = base_address + i * data_type_size

a[i][j]_address = base_address + ( i * n + j) * type_size

由于内存的连续性,可以快速定位到在那个区,查询会很快,插入和删除比较低效

插入

       我们要将一个数据插入到数组中的第 k 个位置。为了把第 k个位置腾出来,给新来的数据,我们需要将第 k~n 这部分的元素都顺序地往后挪一位。

       (时间复杂度 平均为O(n)  最好为O(1)  最坏为 O(n) )

       问题:在一个数组指定位置插入一个值,时间最优?

       思路:把C放到最后,X放入该位置

删除

  实际上删除数组中的元素时,为了内存的连续性,需要搬移数据。

延申出来删除的思路:

   数组 a[10] 中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素。

为了避免 d,e,f,g,h 这几个数据会被搬移三次,我们可以先记录下已经删除的数据。

每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。

链表

结构:

   

1)链表是以节点的方式来存储,是链式存储

2)每个节点包含 data 域next 域:指向下一个节点.

3)如图:发现链表的各个节点不一定是连续存储
4)链表分带头节点的链表和没有头节点的链表,根据实际的需求来确定

单链表

头结点不存储实际的数据元素,用于辅助数据元素的定位,方便插入和删除操作。

尾节点的后继指针指向空地址null。

单链表的插入和删除:

插入:先找到要插入位置的前一个节点(B),x的尾节点指向c,b的尾节点指向x

删除:找到要删除节点的前一个节点(a),直接由a的尾节点指向c,B就没有被指向,会被jvm回收。

(单链表相比数组的优势在于插入删除元素快,不需要移动大量元素,只需要改变指针的指向 

那么插入删除头尾部 的时间复杂度是O(1),查询、插入删除指定位置的时间复杂度是O(n))

循环链表

尾结点指针是指向链表的头结点。处理的数据具有环型结构特点。

问题:如何判断一个链表是不是环形链表呢?

思路:如果是环形链表 设置快慢两个变量,同时遍历 遍历快的一个 会追上遍历慢的那一个。

双向链表

•指向前一个节点地址(前驱指针prev)和下一个节点地址(后继指针next)

支持双向遍历,更灵活。对于有序列表支持双向查找。

插入:

 将x放入a,b中间,将x的 prev指向a的next, a的next指向x的prev

就像牵手,x的左手伸向a的右手,a的右手也要伸向x的左手,这才算是牵手成功

然后 x的next指向b的prev, b的prev指向x的next。

删除:

要删除B元素

B元素的下一个节点的prev****指向B元素的上一个节点**(A元素)的next**

B元素的上一个节点的next 指向B元素下一个节点的**(C元素)的prev**

然后释放B元素

从链表中删除一个数据的两种情况:

  1. 删除某值等于XX的节点,需要遍历比对,再进行指针操作删除,时间复杂度为O(n)
  2. 删除给定指针指向的结点:

单链表删除的时候,需要知道n的前节点是什么,还要从头遍历,直到p>next=n

         然后把找到的该节点指向n的下一个节点,时间复杂度O(n)。双链表删除的时候,它有前           节点和后节点,时间复杂度O(1)

栈是一种“操作受限”的线性表,就是压子弹的方式,只允许在一端插入和删除数据。(先进后出)   相当于数组或链表暴露了太多的操作接口,操作上的确灵活,但使用时就比较不可控,自然也就更容易出错。

应用场景

1.子程序调用:在跳往子程序前,会先将下个指令的地址存到堆栈中,直到子程序执行完后再将地址取出,回到原来的程序中

2.处理递归调用:和子程序的调用类似,只是除了存储下一个指令的地址外,也将参数、区域变量等数据存入堆栈中。

3.表达式的转换与求值

4.二叉树的遍历

5.图形的深度优先(depth-first)搜索法

跳表

 例如redis的Zset有序集合,这种链表加多级索引的结构,就是跳表,跳表可以提高查询效率。

 不停地往跳表中插入数据时,如果我们不更新索引,就有可能出现某 2 个索引结点之间数据非常多的情况。极端情况下,跳表还会退化成单链表。所以↓↓↓↓↓↓↓

索引的动态更新:

当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中。跳表通过一个随机函数,来决定将这个结点插入到哪几级索引中,比如随机函数生成了值 K,那就将这个结点添加到第一级到第 K 级这 K 级索引中。

从概率上来讲,能够保证跳表的索引大小和数据大小平衡性,不至于性能过度退化。使用概率均衡技术而不是使用强制性均衡技术,因此,对于插入和删除结点比传统上的平衡树算法更为简洁高效。

当然,Redis 之所以用跳表来实现有序集合,还有其他原因,比如

•跳表更容易代码实现。比起红黑树来说更好懂、更好写,而简单就意味着可读性好,不容易出错。

•跳表更加灵活,它可以通过改变索引构建策略,有效平衡执行效率和内存消耗。

队列

先进先出策略FIFO(First In,First Out)  

1.队列本身是有序列表,数组或链表实现,若使用数组的结构来存储队列的数据,则队列数组的声明如下图, 其中 maxSize 是该队列的最大容量。

2.因为队列的输出、输入是分别从前后端来处理,因此需要两个变量 front及 rear分别记录队列前后端的下标,front 会随着数据输出而改变,而 rear则是随着数据输入而改变,如图所示:

队列增加数据时候 rear增加;队列消费数据时候 front改变。

散列表(Hash table)

先回顾一下数组和链表,数组查询容易,插入删除困难;链表查询困难,插入和删除容易。

那么散列表:查询插入删除都容易;

含义:

散列表应用数组支持按照下标随机访问的特性,根据关键码值(Key value)对数据直接进行访问。通过把关键码值key映射到表中一个位置来访问记录,以加快查速度。

这个映射函数叫做散列函数

存放记录的数组叫做散列表

散列函数 :

  • **hash(key):**其中 key 表示元素的键值,hash(key) 的值表示经过散列函数计算得到的散列值。

  • 存储过程:通过散列函数把元素的键值key映射为下标,然后将数据存储在数组中对应下标的位置。

  • **查询过程:**当我们按照键值查询元素时,我们用同样的散列函数,将键值转化数组下标,从对应的数组下标的位置取数据。

散列函数设计的基本要求:

1. 散列函数计算得到的散列值是一个非负整数,因为数组下标是从 0 开始;

2. 如果 key1 = key2,那 hash(key1) == hash(key2);

3. 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

在真实的情况下,要想找到一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的。即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。

常用的散列冲突的解决办法:

开放寻址法 和 链表法

  1. 开放寻址法

         当出现散列冲突,重新探测一个空闲位置,将其插入

⁣⁣⁣⁣ ⁣⁣⁣⁣ ⁣⁣⁣⁣ ①线性探测

            散列表插入数据:

散列表的大小为 10,在元素 x 插入散列表之前,已经 6 个元素插入到散列表中。x 经过 Hash 算法之后,被散列到位置下标为 7 的位置,但是这个位置已经有数据了,所以就产生了冲突。

于是我们就顺序地往后一个一个找,看有没有空闲的位置,遍历到尾部都没有找到空闲的位置,于是我们再从表头开始找,直到找到空闲位置 2,于是将其插入到这个位置。

 散列表查询数据:

查找:类似插入过程。我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。

 散列表删除数据:

删除操作稍微有些特别。我们不能单纯地把要删除的元素设置为空。会导致原来的查找算法失效。本来存在的数据,会被认定为不存在。为了解决这个问题可以将删除的元素,特殊标记为 deleted。当线性探测查找的时候,遇到标记为 deleted的空间,并不是停下来,而是继续往下探测。

②二次探测

              不仅要使用一个散列函数。而是使用一组散列函数 hash1(key),hash2(key),                         hash3(key)……

              先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,                  依次类推,直到找到空闲的存储位置。

③双重散列

跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是

               hash(key)+0,hash(key)+1,hash(key)+2……

               二次探测探测的步长就变成了原来的“二次方”,它探测的下标序列就是                                     hash(key)+0,hash(key)+1 ,hash(key)+2 ……

以上就是开放寻址法,那么开放寻址法有个问题就是:

         当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越 来越少,探测的时间就会越来越久。

         极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为 O(n)。

         同理,在删除和查找时,也有可能会探测整张散列表,才能找到要查找或者删除的数据。

         为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用**装载因子(load factor)**来表示空位的多少。

        ** 散列表的装载因子 = 填入表中的元素个数 / 散列表的长度**

         装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。当装载因子过大时,需要动态扩容,搬移数据需要通过散列函数重新计算每个数据的存储位置。

  1. 链表法

 •在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,

•所有散列值相同的元素我们都放到相同槽位对应的链表中。

•当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。

•当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。这两个操作的时间复杂度跟链表的长度 k 成正比,也就是 O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数。

•我们将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是 O(logn)。这样可以有效避免散列碰撞攻击。

解决散列冲突的办法比较  

开放寻址 vs 链表法

开放寻址的散列表中的数据都存储在数组中,可以有效地利用CPU 缓存加快查询速度,序列化起来比较简单。删除数据的时候需要特殊标记已经删除掉的数据; 所有的数据都存储在一个数组中,解决冲突代价高;装载因子的上限不能太大。导致这种方法比链表法更浪费内存空间。

所以,当数据量比较小、装载因子小的时候,适合采用开放寻址法

基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。

二叉树

•高度(Height):节点到叶子节点的最长路径(边数)

•深度(Depth):跟节点到这个节点所经历的边的个数

•层(Level):节点的深度+1

•树的高度:根节点的高度

满二叉树:

             除了叶子节点之外,每个节点都有左右两个子节点

完全二叉树:

             叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其                 他层的节点个数都要达到最大。

二叉树的遍历:

   •前序遍历、中序遍历和后序遍历

•前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。

•中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。

•后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。

二叉查找树(Binary Search Tree)

在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。(左小右大)

查询

public class BinarySearchTree {
        private Node tree;
        public Node find(int data) {
            Node p = tree;
            while (p != null) {
                if (data < p.data) p = p.left;
                else if (data > p.data) p = p.right;
                else return p;
            }
            return null;
        }
        public static class Node {
            private int data;
            private Node left;
            private Node right;
        }
  }

插入

如果在插入过程中,碰到一个节点的值和新数据的值相同,要把新节点当作大于它的值来处理,也就是放到右节点处

public void insert(int data) {
 if (tree == null) {    tree = new Node(data);     return;     }
 Node p = tree;
 while (p != null) {
     if (data > p.data) {
         if (p.right == null) {
             p.right = new Node(data);
             return;
         }
         p = p.right;
     } else { // data < p.data
         if (p.left == null) {
            p.left = new Node(data);
            return;
         }
     p = p.left;
 }
}
}

删除

•删除的三种情况:没有子节点、只有一个子节点、有两个子节点

如果删除节点下面有子节点,那么使它的子节点最小的节点放到该位置

public void delete(int data) {
     // p 指向要删除的节点,初始化指向根节点
      Node p = tree; 
     // pp 记录的是 p 的父节点
      Node pp = null;  
      while (p != null && p.data != data) {
          pp = p;
          if (data > p.data) p = p.right;
          else p = p.left;
      }
     if (p == null) return; // 没有找到
      // 要删除的节点有两个子节点
     if (p.left != null && p.right != null) { 
         // 查找右子树中最小节点
         Node minP = p.right;
          Node minPP = p; // minPP 表示 minP 的父节点
          while (minP.left != null) {
              minPP = minP;
              minP = minP.left;
          }
          // 将 minP 的数据替换到 p 中
           p.data = minP.data;
           // 下面就变成了删除 minP 了
           p = minP; 
           pp = minPP;
      }
 // 删除节点是叶子节点或者仅有一个子节点
      Node child; // p 的子节点
      if (p.left != null) child = p.left;
      else if (p.right != null) child = p.right;
      else child = null;
      if (pp == null) tree = child; // 删除的是根节点
      else if (pp.left == p) pp.left = child;
      else pp.right = child;
}

标记删除:

将要删除的节点标记为“已删除”,但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。而且,这种处理方法也并没有增加插入、查找操作代码实现的难度。

散列表VS二叉树

散列表的插入、删除、查找操作的时间复杂度可以做到常量级的O(1)

二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度是O(logn)

第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。

第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,平衡二叉查找树的性能非常稳定,时间复杂度稳定在O(logn)。

第三,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。

第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。

最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。

综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。我们在实际的开发过程中,需要结合具体的需求来选择使用哪一个。

二叉树平衡

AVL树:每一个结点的左子树和右子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。

为什么要平衡二叉树呢?

           为了解决频繁的插入、删除等动态更新后,时间复杂度退化的问题。

           完全二叉树、满二叉树都是平衡二叉树

左旋(rotate left)、右旋(rotate right)

**那么我们根据什么来决定左旋还是右旋呢?**一个重要的因素就来了

平衡因子

•某结点的左子树与右子树的**高度(深度)**差即为该结点的平衡因子(BF,Balance Factor)。

平衡二叉树上所有结点的平衡因子只可能是 -1,0 或 1。

当最小不平衡树的根结点的平衡因子BF是大于1时,就右旋

当最小不平衡树的根结点的平衡因子BF是小于-1时,就左旋

插入结点后,最小不平衡子树的BF与它的子树的BF符号相反时,就需要对结点先进行一次旋转以使得符号相同后,再反向旋转一次才能够完成平衡操作

红黑树

红黑树是一种不严格的近似平衡的平衡二叉查找树。

概要:

•根节点是黑色的 

•每个叶子节点都是黑色的空节点(NIL),叶子节点不存储数据(为了代码实现而设置) 

•任何相邻的节点都不能同时为红色,红色节点被黑色节点隔开

 •每个节点,从该节点到达其可达的叶子节点的所有路径,都包含相同数目的黑色节点 

红黑树相对比AVL树呢

如图,将红色节点从红黑树中去掉,得到一刻黑色四叉树,由于从任意红色节点到达叶子节点的路径,都包含相同数目的黑色节点,所以将某些节点放到叶子节点后,会得到一个完全二叉树。

高度近似log2n。所以黑树的高度不超过log2n,将红色节点放回去的高度不超过2log2n

结论:红黑树的高度比AVL树的高度仅大了一倍性能下降的不多。但是在维护平衡的成本上,比AVL树低。所以红黑树的查找、插入、删除操纵性能 比较稳定。

红黑树的实现

•任何相邻的节点都不能同时为红色,红色节点被黑色节点隔开

•每个节点,从该节点到达其可达的叶子节点的所有路径,都包含相同数目的黑色节点

红黑树的应用

•1、广泛用于C++的STL中,Map和Set都是用红黑树实现的;

•2、著名的Linux进程调度Completely Fair Scheduler,用红黑树管理进程控制块,进程的虚拟内存区域都存储在一颗红黑树上,每个虚拟地址区域都对应红黑树的一个节点,左指针指向相邻的地址虚拟存储区域,右指针指向相邻的高地址虚拟地址空间;

•3、IO多路复用epoll的实现采用红黑树组织管理sockfd,以支持快速的增删改查;

•4、Nginx中用红黑树管理timer,因为红黑树是有序的,可以很快的得到距离当前最小的定时器;

•5、Java中TreeMap的实现;

B树

B树是一种平衡的多叉查找树,也就是说最多可以开m个叉(m>=2),我们称之为m阶b树。

m阶B树满足以下条件:

1.根结点至少有两个子女;

2.树中的每个结点最多含有m个孩子;除了根结点和叶子结点,其他结点至少有[ceil(m / 2)(代表是取上限的函数)]个孩子;若根结点不是叶子结点时,则至少有两个孩子(除了没有孩子的根结点)

3.如果一个结点有n-1个关键字,那么该结点有n个分支。这n-1个关键字互不相等并按照递增顺序排列。每个非根节点所包含的关键字个数 j 满足:ceil(m / 2) - 1 <= j <= m - 1;

4.所有的叶子结点都出现在同一层中,叶子结点不包含任何关键字信息;

多阶B树

•每个内部节点:n为该结点中关键字的个数;ki为该结点的关键字且满足ki<ki+1;pi为该结点的孩子结点指针且满足pi所指结点上的关键字大于ki且小于ki+1,p0所指结点上的关键字小于k1,pn所指结点上的关键字大于kn。

M=5阶B树:

B+树

把数据都存储在叶结点,而内部结点只存关键字和孩子指针,因此简化了内部结点的分支因子,B+树遍历也更高效,其中B+树只需所有叶子节点串成链表这样就可以从头到尾遍历,其中内部结点是并不存储信息,而是存储叶子结点的最小值作为索引

1.有n棵子树的节点含有n个关键字(也有认为是n-1个关键字) 

2.所有的叶子节点包含了全部的关键字,及指向含这些关键字记录的指针,且叶子节点本身根据关键字自小而大顺序连接 

3.非叶子节点可以看成索引部分,节点中仅含有其子树(根节点)中的最大(或最小)关键字

B树与B+树区别

•B树每个节点都存储数据,所有节点组成这棵树。B+树只有叶子节点存储数据(B+数中有两个头指针:一个指向根节点,另一个指向关键字最小的叶节点),叶子节点包含了这棵树的所有数据,所有的叶子结点使用链表相连,便于区间查找和遍历,所有非叶节点起到索引作用。

•B树中叶节点包含的关键字和其他节点包含的关键字是不重复的,B+树的索引项只包含对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址。

•B+树中查找,无论查找是否成功,每次都是一条从根节点到叶节点的路径。

各自优点

B树的优点:

B树的每一个节点都包含key和value,因此经常访问的元素可能离根节点更近,因此访问也更迅速

B+ 树的优点:

•由于B+树在内部节点上不含数据信息,因此在内存页中能够存放更多的key。 数据存放的更加紧密,具有更好的空间局部性。

•B+树的叶子结点都是相链的,因此对整棵树的便利只需要一次线性遍历叶子结点即可。而B树则需要进行每一层的递归遍历。而且由于数据顺序排列并且相连,所以便于区间查找和搜索,访问叶子节点上关联的数据也具有更好的缓存命中率。

为什么用B+树做数据库索引?

•我们在MySQL中的数据一般是放在磁盘中的,读取数据的时候肯定会有访问磁盘的操作,磁盘中有两个机械运动的部分,分别是盘片旋转和磁臂移动。盘片旋转就是我们市面上所提到的多少转每分钟,而磁盘移动则是在盘片旋转到指定位置以后,移动磁臂后开始进行数据的读写。那么这就存在一个定位到磁盘中的块的过程,而定位是磁盘的存取中花费时间比较大的一块,毕竟机械运动花费的时候要远远大于电子运动的时间。当大规模数据存储到磁盘中的时候,显然定位是一个非常花费时间的过程,但是我们可以通过B树进行优化,提高磁盘读取时定位的效率。

•为什么B类树可以进行优化呢?我们可以根据B类树的特点,构造一个多阶的B类树,然后在尽量多的在结点上存储相关的信息,保证层数尽量的少,以便后面我们可以更快的找到信息,磁盘的I/O操作也少一些,而且B类树是平衡树,每个结点到叶子结点的高度都是相同,这也保证了每个查询是稳定的。

•总的来说,B+树是为了磁盘或其它存储设备而设计的一种平衡多路查找树(相对于二叉,B树每个内节点有多个分支),与红黑树相比,在相同的的节点的情况下,一颗B/B+树的高度远远小于红黑树的高度(在下面B/B+树的性能分析中会提到)。B/B+树上操作的时间通常由存取磁盘的时间和CPU计算时间这两部分构成,而CPU的速度非常快,所以B树的操作效率取决于访问磁盘的次数,关键字总数相同的情况下B树的高度越小,磁盘I/O所花的时间越少。

•堆是一个完全二叉树(除了最后一层,其它层的几点书都是满的,最后一层的节点都靠左排列)

•堆中每一个节点的值都必须大于等于(或小于等于)其子树每个节点的值

大顶堆:每个节点的值都大于等于子树中每个节点的值(1、2)

小顶堆:每个节点的值都小于等于子树中每个节点的值(3、4)

如何实现一个堆

数组实现,数组下标为 i 的 :

•左子节点下标 i * 2

•右子节点下标 i * 2 + 1

•父节点下标 i / 2

数组实现的优点:节省空间,不需要存储左右子节点的指针,通过下标查找。

(这就是前面讲的,为什么会有完全二叉树这种特别的二叉树结构)

堆的插入

1.在数组尾部插入

2.堆化(heapify):调整数据位置,让其重新满足堆的特性

堆化:顺着节点的路径,向上或者向下对比,然后交换。

自下向上调整:每次与父节点比较,交换

自上向下调整:每次与子节点比较,交换

例如代码实现:

堆的删除

代码实现

堆化的时间复杂度

包含n个节点的完全二叉树,高度不超过log2n。插入和删除中的堆化都是顺着节点所在的路径比较和交换,所以时间复杂度和树的高度成正比,为O(logn)

堆排序

•时间复杂度:O(n logn)

•原地排序算法(不需要或者很少量的额外辅助的空间)

堆排序的两个步骤:

1、建堆

2、排序

堆排序 - 建堆

建堆的两种方式:

•1、新建一个堆数组,将原有数组按照顺序做堆的插入和自下向上堆化操作;

•2、在原始数组上,从最后一个非叶子元素开始做自上向下堆化操作。

堆的应用

•队列: 先进先出

•优先级队列:优先级高的先出

•堆实现的优先级队列:堆顶元素先出

•用处:赫夫曼编码、图的最短路径、最小生成树算法

•作业系统中的任务调度程序

•优先处理级别高用户(消息、作业。。。)的请求

•RabbitMQ优先级队列

•Java的PriorityQueue

•C++的priority_queue

•Top k排行榜

静态数据排行:包含n个数据的数组中,查找前k大的数据•维护大小为k的小顶堆,遍历数组元素,与堆顶元素比较,如果大于堆顶元素,则将堆顶元素删除,并插入当前遍历的元素。•遍历复杂度O(n),堆化复杂度O(logk),总时间复杂度:O(nlogk)动态数据排行:•维护大小为k的小顶堆,添加元素时,与堆顶元素比较,如果大于堆顶元素,则将堆顶元素删除,并插入添加的元素。•时间复杂度:O(logk)

堆的应用——求中位数

•数组从小到大排序,当数组个数n为奇数时,中位为n/2

•n为偶数时,中位为n/2 和n/2+1,取n/2

•静态数据:先排序,直接取第n/2个数据

•动态数据:每次数据插入做排序低效。

动态数据用堆实现:

当前数组排序后,维护两个堆:

大顶堆存储数组前半部分数据(前n/2个元素,n为奇数时存储前n/2+1个)

小顶堆存储数组后半部分数据(后n/2)

此时大顶堆堆顶元素即为中位数。

公众号:京北有鱼