玩转数据结构5-二分搜索树

上节课进一步研究了链表及其具有的一种固有属性--递归,并递归实现了链表元素的删除操作。本节课学习另外一种高效的数据结构--树。

1. 二分搜索树

树是一种天然的组织结构,在实际生活中非常常见,比如:

  • 文件夹的存储结构
  • 公司的人员组织架构

很多数据使用树结构存储以后,出奇的高效。树结构主要包括以下几种:

  • 二分搜索树
  • 平衡二叉树
  • 红黑树
  • 线段树等等

本节课学习二分搜索树,提到二分搜索树就不得不提二叉树。二叉树与链表一样,是一种动态数据结构,它也是由节点组成,但二叉树节点包含了两个地址变量,分别指向节点的左子树和右子树:

// 二叉树的节点
class Node{
    E e;
    Node left;
    Node right;
}
二叉树

二叉树具有很强的辨识性,特点明显:

  • 有且只有一个根节点
  • 每个节点最多有两个孩子
  • 每个节点最多有一个父亲
Characteration

另外,同链表一样,二叉树具有天然的递归属性:

  • 每个节点的左子树也是二叉树
  • 每个节点的右子树也是二叉树


    Recursion

需要注意的是,二叉树不一定是满的


非满二叉树

二分搜索树是二叉树的一种,在二分搜索树中:

  • 每个节点的值大于其左子树的所有节点值
  • 每个节点的值小于其右子树的所有节点值
  • 每个子树也是一个二分搜索树
二分搜索树

注意,二分搜索树存储的元素必须具有可比较性。基于这些内容,我们可以给出二分搜索树的一个基本结构:

public class BST<E extends Comparable<E>> {
    private class Node{
        public E e;
        public Node left;
        public Node right;
        public Node(E e) {
            this.e = e;
            left = null;
            right = null;
        }
    } 
    private Node root; // 树的根节点
    private int size; // 树中所有节点的数量
    
    public BST() {
        root = null;
        size = 0;
    }
    
    public boolean isEmpty() {
        return size == 0;
    }
    public int getSize() {
        return size;
    }
}

注意,泛型声明时必须具有可比较性。

2. 增加、查找和遍历

2.1 添加元素

向二分搜索树中添加元素,需要保持二分搜索树的顺序性,可以采用递归写法和非递归写法。递归写法相对较简单,只要处理好边界点和添加逻辑即可:

  • 当根节点为空时,直接创建新节点,添加到根节点中
  • 当添加元素大于根结点元素时,向以根结点的右子树为根节点的二分搜索树中添加
  • 当添加元素小于根节点元素时,向以根结点的左子树为根节点的二分搜索树中添加
// 公有方法添加元素
public void addElement(E e) {
    // 调用私有添加方法向根结点所在树添加元素
    root = addElement(root, e);
}
// 私有方法,向以node结点为根的二分搜索树中添加元素
// 返回添加元素后的根节点
private Node addElement(Node node,E e) {
    if(node==null) {
        node = new Node(e);
        size++;
        return node;
    }
    if(e.compareTo(node.e)<0) {
        // 递归实现,添加元素小于node,向node的左子树中添加元素
        node.left = addElement(node.left,e);
    }
    if(e.compareTo(node.e)>0) {
        // 递归实现,添加元素大于node,向node的右子树中添加元素
        node.right = addElement(node.right,e);
    }
    return node;
}

添加元素的非递归写法,与之前链表的写法相似,从实用性角度来看,非递归写法并不常用,这里不做研究。

2.2 查找元素

二分搜索树中不存在索引的概念,因此查找操作是指查询二分搜索树中是否包含某个元素。递归实现起来,也是非常简单的:

  • 如果节点元素等于要查找的元素,则直接返回true
  • 如果节点元素为空,返回false
  • 如果节点元素大于要查找的元素,到节点的左子树中继续查找
  • 如果节点元素小于要查找的元素,到节点的右子树中继续查找
// 查看二分搜索树中是否包含元素e
public boolean contains(E e){
    // 调用私有方法
    return contains(root,e);
}

// 查看以node 为根的二分搜索树中是否包含元素e
private boolean contains(Node node,E e) {
    if(node==null) {
        return false;
    }
    if(e.equals(node.e))
        return true;
    if(e.compareTo(node.e)<0) {
        return contains(node.left,e);
    }
    else
        return contains(node.right,e);
}
2.3 深度优先遍历

在二分搜索树中,一个非常重要的操作就是遍历。遍历就是把所有节点都访问一遍。在二分搜索树中,有三种遍历方法,以访问根结点是在访问左子树和右子树的之前、之后和之间来进行划分:

  • 前序遍历:根节点在访问节点的左子树和右子树之前访问:

      function traverse(node):
          if(node==null)
              return;
    
          访问该节点
          traverse(node.left)
          traverse(node.right)
    

    前序遍历是最自然,也是最常用的一种遍历方式:

      // 前序遍历二分搜索树
      public void preOrder() {
          preOrder(root);
      }
    
      // 前序遍历以node为根的二分搜索树
      private void preOrder(Node node) {
          if(node==null) {
              return;
          }
          System.out.println(node.e);
          preOrder(node.left);
          preOrder(node.right);
      }
    
  • 中序遍历:根节点在访问节点的左子树和右子树之间进行访问:

      function traverse(node):
          if(node==null)
              return;
    
          traverse(node.left)
          访问该节点
          traverse(node.right)
    

    结合二分搜索树的结构特性,可以发现,中序遍历可以将树中的元素从小到大排列一遍,因此二分搜索树也是一种有序的数据结构

      // 中序遍历
      public void inOrder() {
          inOrder(root);
      }
    
      private void inOrder(Node node) {
          if(node==null) {
              return;
          }
          inOrder(node.left);
          System.out.println(node.e);
          inOrder(node.right);
      }
    
  • 后序遍历:根节点在访问节点的左子树和右子树之后进行访问

      function traverse(node):
          if(node==null)
              return;
    
          traverse(node.left)
          traverse(node.right)
          访问该节点
    

    代码实现:

      // 后序遍历
      public void postOrder() {
          postOrder(root);
      }
      private void postOrder(Node node) {
          if(node==null) {
              return;
          }
          postOrder(node.left);
          postOrder(node.right);
          System.out.println(node.e);
      }
    

下面,利用之前介绍的关于栈的知识,研究二分搜索树前序遍历的非递归实现。前序遍历的过程,可以用一个压栈和弹栈的流程表示出来:

  • 对已在栈内的根结点,访问到该节点时,将该节点弹栈,然后立刻将其右节点和左节点依次压入栈内
  • 当某个子树为空时,不进行压栈操作
  • 当栈内元素为空时,遍历结束
    // 前序遍历,非递归写法
    public void preOderNR() {
        if(root==null) {
            return;
        }
        Stack<Node> stack = new Stack();
        stack.push(root);
        while(!stack.isEmpty()) {
            Node node = stack.pop();
            System.out.println(node.e);
            if(node.right!=null) {
                stack.push(node.right);             
            }
            if(node.left!=null) {
                stack.push(node.left);              
            }
        }
    }
2.4 层序遍历

前面介绍的几种遍历方法均属于深度优先遍历,本节介绍一种层序遍历方法,即逐层访问树的元素,这种遍历方式也叫做广度优先遍历。这里借助队列先进先出的结构特性来实现二分搜索树的层序遍历。

  • 每次访问到一个节点时(出队),将该节点的左孩子和右孩子依次入队
  • 当某个孩子为空时,不进行入队操作
  • 当队列内元素为空时,遍历结束
// 层序遍历,广度优先遍历
public void levelOrder() {
    if(root==null) {
        return;
    }
    Queue<Node> queue = new LinkedList<>();
    // 根节点入队
    queue.add(root);
    
    // 队列不为空时
    while(!queue.isEmpty()) {
        // 队首元素出队,记为cur节点
        Node cur = queue.remove();
        
        System.out.println(cur.e);
        // cur节点的左孩子不为空时,入队
        if(cur.left!=null) {
            queue.add(cur.left);
        }
        // cur节点的右孩子不为空时,入队
        if(cur.right!=null) {
            queue.add(cur.right);
        }
    }
}

树的层序优先遍历通常能够更快地找到问题的解,常用于在路径规划中寻找最短路径。

3. 二分搜索树删除节点

3.1 删除最大值和最小值

本节讨论如何从二分搜索树中删除元素,先从最简单的情况开始:删除二分搜索树的最大值和最小值。基于二分搜索树的结构特性,最大值位于二分搜索树的最右边,最小值位于二分搜索树的最左边。因此,删除最大值和最小值可以很容易使用递归方法来实现:

  • 删除最小值

      //  删除二分搜索树中的最小值,返回删除后的根结点
      public Node removeMin(){
          // 如果根节点为空,抛异常(可选操作)
          if(root==null) {
              throw new IllegalArgumentException("Remove failed. The tree is empty.");
          }
          // 删除根节点所在树的最小值,并返回删除后的根结点
          root = removeMin(root);
          return root;
      }
      
      // 从以node 为根的二分搜索树中删除最小值,返回删除后的根结点
      private Node removeMin(Node node) {
          // 如果节点左孩子为空,说明该节点即为最小值,直接删除
          if(node.left==null) {
              node = node.right;
              size--;
          }
          // 如果节点左孩子不为空,递归从以左孩子为根结点的二分搜索树中删除最小值,并返回删除后的根节点
          else {
              node.left = removeMin(node.left);
          }
          return node;
      }
    
  • 删除最大值(与删除最小值类似)

      //  删除二分搜索树中的最大值,返回删除后的根结点
      public Node removeMax(){
          if(root==null)
              throw new IllegalArgumentException("Remove failed. The tree is empty.");
          else {
              root = removeMax(root);
              return root;
          }
      }
      
      // 从以node 为根的二分搜索树中删除最大值,返回删除后的根结点
      private Node removeMax(Node node) {
          if(node.right==null) {
              node = node.left;
              size--;
          }
          else {
              node.right =removeMax(node.right);
          }
          return node;
      }
    
3.2 删除最大值和最小值的第二种方法

上述删除操作,没能返回删除的元素值,不实用。这里介绍第二种删除方法,即先找到二分搜索树中的最大值和最小值所在节点,然后删除该节点。

  • 查找最小值

      // 找到二分搜索树中的最小值
      public E minimum(){
          if(size==0)
              throw new IllegalArgumentException("The tree is empty.");
          else {
              // 调用私有最小值函数
              // 找到以root为根节点的二分搜索树中的最小值所在节点
              Node minNode = minimum(root);
              return minNode.e;           
          }
      }
      
      // 找到以node 为结点的二分搜索树的最小值所在结点
      private Node minimum(Node node) {
          // 如果node节点的左孩子为空,表明node即为最小值所在节点
          if(node.left==null) {
              return node;
          }
          else // node节点的左孩子不为空,则递归查找node节点左子树中的最小值所在节点
              return minimum(node.left);
      }
    
  • 移除最小值的第二种方法(实际上不能称之为第二种方法,只加入了查找最小值操作)

      // 移除最小值所在节点,返回移除的元素
      public E removeMin2(){
          if(size==0) {
              throw new IllegalArgumentException("The tree is empty.");
          }
          // 找到以root为根节点的二分搜索树的最小值所在节点
          Node res = minimum(root);
          // 调用私有移除方法
          // 移除以root结点为根的二分搜索树的最小值所在节点,返回移除后的根结点
          root = removeMin2(root);
          return res.e;
      }
          
      // 移除以node结点为根的二分搜索树的最小值,返回移除后的根结点
      public Node removeMin2(Node node) {
          if(node.left==null) {
              node = node.right;
              size--;
          }
          else
              node.left = removeMin2(node.left);
          return node;
      }
    
  • 查找最大值

      // 找到二分搜索树中的最大值
      public E maxmum(){
          if(size==0)
              throw new IllegalArgumentException("The tree is empty.");
          else {
              // 调用私有最大值函数
              // 找到以root为根节点的二分搜索树中的最大值所在节点
              Node maxNode = maxmum(root);
              return maxNode.e;           
          }
      }
          
      // 找到以node 为结点的二分搜索树的最大值所在结点
      private Node maxmum(Node node) {
          // 如果node节点的右孩子为空,表明node即为最大值所在节点
          if(node.right==null) {
              return node;
          }
          else // node节点的右孩子不为空,则递归查找node节点右子树中的最大值所在节点
              return maxmum(node.right);
      }
    
  • 删除最大值的“第二种方法”

      // 移除最大值的第二种实现,返回移除的元素
      public E removeMax2() {
          if(size==0)
              throw new IllegalArgumentException("The tree is empty.");
          Node delNode = maxmum(root);
          root = removeMax2(root);
          return delNode.e;
      }
          
      // 移除以node为根的二分搜索树的最大值,返回移除后的根结点
      private Node removeMax2(Node node) {
          if(node.right==null) {
              node = node.left;
              size--;
          }
          else {
              node.right = removeMax2(node.right);
          }
          return node;
      }
    
3.3 移除二分搜索树中的任意元素

前面介绍的移除最大值和最小值都比较简单,困难的是移除二分搜索树中的任意元素。实际上,可以分为三种情况来处理:

  • 删除只有左孩子的节点
    对只有左孩子的节点,直接返回该节点的左子树即可


    node=node.left
  • 删除只有右孩子的节点
    对只有右孩子的节点,直接返回该节点的右子树即可


    node=node.right
  • 删除左右都有孩子的节点
    对左右都有孩子的节点,最主要的问题是,删除该节点后,使用哪一个节点来替代该节点的位置,且能保证二分搜索树的结构不变。目前,广泛采用的是Hibbard在1962年提出的Hibbard删除法:1)首先从待删除节点delNode的右子树中,找到最小值所在节点sNode(sNode = minimum(delNode.right));


    minimum(delNode.right)=24

2)将sNode的左子树赋值为delNode的左子树(sNode.left = delNode.left);


sNode.left = delNode.left

3)将sNode的右子树赋值为delNode右子树中删除sNode以后的二分搜索树(sNode.right = delMin(delNode.right));
sNode.right = delMin(delNode.right)

4) 删除delNode节点(delNode.left = delNode.right = null; delNode = sNode)


delNode=sNode

Java代码实现:

    // 移除二分搜索树中的任意元素e
    public void removeElement(E e) {
        // 如果树为空,则抛异常(可选择操作)
        if(size==0)
            throw new IllegalArgumentException("The tree is empty.");
        // 调用私有方法,从以root为根的二分搜索树中删除元素e
        root = removeElement(root,e);
    }
    // 从以node为根结点的二分搜索树种删除元素e,返回删除后的根结点
    private Node removeElement(Node node,E e) {
        // 如果node为空,表明没有找到该元素
        if(node==null) {
            return null;
        }
        // 1. 如果node节点就是要删除的元素
        // 分三种情况分别处理
        if(e.equals(node.e)) {          
            // 1)待删除结点左子树为空的情况
            if(node.left==null) {
                Node rightNode = node.right;
                node.right = null;
                size--;
                return rightNode;
            }
            // 2)待删除结点右子树为空的情况
            if(node.right==null) {
                Node leftNode = node.left;
                node.left = null;
                size--;
                return leftNode;
            }
            // 3)待删除结点左右子树均不为空的情况
            // 找到待删除节点右子树中的最小值作为新的节点
            Node minNode = minimum(node.right);
            // 新节点的右子树为待删除节点右子树中删掉最小值的树
            minNode.right = removeMin2(node.right);
            // 新节点的左子树为待删除节点的左子树
            minNode.left = node.left;
            // 待删除节点的左右子树清空
            node.left = node.right = null;
            // 返回新节点作为根节点
            return minNode;
            
        }
        // 2. 如果要删除的元素小于node节点的元素,向node的左子树递归
        else if(e.compareTo(node.e)<0) {
            node.left = removeElement(node.left,e);
            return node;
        }
        else { // 3. e.compareTo(node.e)>0
            node.right =  removeElement(node.right,e);
            return node;
        }
    }

4. 总结

本节课主要学习了二分搜索树这样一种高效的数据结构。从二分搜索树的基本结构出发,介绍了如何使用递归法向二分搜索树中添加元素、查找元素以及二分搜索树的前序、中序和后序遍历的递归实现。借助之前栈的相关知识,实现了二分搜索树前序遍历的非递归实现;借助队列的相关知识又实现了二分搜索树的层序遍历。最后,介绍了如何从二分搜索树中删除元素,包括最小值、最大值以及任意指定的元素。主要难点在于如何删除任意元素,这里又细分了三种情况进行处理:待删除节点只有右孩子、只有左孩子以及左右都有孩子。对于左右都有孩子的情况,采用Hibbard删除法,从右孩子中找到最小值节点作为新的根节点。下节课学习集合与映射这两种数据结构,并深入分析二分搜索树的时间复杂度。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,386评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,939评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,851评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,953评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,971评论 5 369
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,784评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,126评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,765评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,148评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,744评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,858评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,479评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,080评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,053评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,278评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,245评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,590评论 2 343

推荐阅读更多精彩内容