数据结构与算法之美【15】-二叉查找树

二叉查找树也叫二叉搜索树。二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 O(n),非常高效。

我画了几个二叉查找树的例子,你一看应该就清楚了:

一、二叉查找树的查找操作

二叉查找树中查找逻辑很简单:先取根节点的值,如果它等于我们要查找的值,那就返回。如果要查找的值比根节点的值小,那就在左子树中递归查找;如果要查找的值比根节点的值大,那就在右子树中递归查找。

// tree为树的根节点,data为欲删除的数据
Node* find(Node* tree,int data) {
    Node* p = tree;
    while (p != nullptr) {
        if (data < p.data) { //如果节点数据大于data,则向左子树继续遍历
             p = p.left;
        }
        else if (data > p.data) {
            p = p.right; //如果节点数据小于于data,则向右子树继续遍历
        }
        else{
            return p; //找到节点即返回
        }
   }
   return null;
}

二、二叉查找树的插入操作

二叉查找树的插入逻辑也很简单,新插入的数据一般都是在叶子节点上,所以我们只需要从根节点开始,依次比较要插入的数据和节点的大小关系。如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。

void insert(Node* tree,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 { 
      if (p.left == null) {// 如果要插入的值小于节点值,且左子树为空,则插入到左子树位置
        p.left = new Node(data);
        return;
      }
      p = p.left;// 如果要插入的值大于节点值,但左子树不为空,则继续遍历左子树
    }
  }
}

三、二叉查找树的删除操作

二叉查找树的删除操作比较复杂,针对要删除节点的子节点个数的不同,我们需要分三种情况来处理。

第一种情况是,如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为 null。比如下图中的删除节点 55。

第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比如图中的删除节点 13。

第三种情况是,如果要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,我们可以应用上面两条规则来删除这个最小节点。比如图中的删除节点 18。

 void delete(Node* tree,int data) {
  Node p = tree; // p指向要删除的节点,初始化指向根节点
  Node pp = null; // pp记录的是p的父节点
  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;
    }
    p.data = minP.data; // 将欲删除的节点值修改为最小值
    delete minP;
    minPP.left = nullptr;
    return;
  }

  // 删除节点是叶子节点或者仅有一个子节点
  Node child; // p的子节点
  if (p.left != null) {
    child = p.left;
  } else if (p.right != null) {
    child = p.right;
  } else {
    child = null;
  }

  if (pp == null){  // 删除的是根节点
    delete tree; 
    tree = child;
  }
  else if (pp.left == p) { // 删除的是左节点
    pp.left = child;
    delete child;
  }
  else {         // 删除的是右节点
    pp.right = child;
    delete child;
  }
  
  return;
}

四、存在重复数据的二叉查找树

前面我们讲的二叉查找树的操作,针对的都是不存在键值相同的情况。那如果存储的两个对象键值相同,这种情况该怎么处理呢?我这里有两种解决方法。

第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。

第二种方法比较不好理解,不过更加优雅。每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。

五、有了如此高效的散列表,为什么还需要二叉树?

我们在散列表那节中讲过,散列表的插入、删除、查找操作的时间复杂度可以做到常量级的 O(1),非常高效。而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是 O(logn),相对散列表,好像并没有什么优势,那我们为什么还要用二叉查找树呢?我认为有下面几个原因:

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

第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)。

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

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

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

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

六、平衡二叉查找树/红黑树

平衡二叉树的严格定义是这样的:二叉树中任意一个节点的左右子树的高度相差不能大于 1。从这个定义来看,上一节我们讲的完全二叉树、满二叉树其实都是一种平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。

红黑树是一种平衡二叉查找树。它是为了解决普通二叉查找树在数据更新的过程中,复杂度退化的问题而产生的。红黑树的高度近似 log2n,所以它是近似平衡,插入、删除、查找操作的时间复杂度都是 O(logn)。 

顾名思义,红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求:

  1. 根节点是黑色的;
  2. 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
  3. 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
  4. 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Chiang木

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值