数据结构与算法笔记4

本笔记记录王争专栏数据结构与算法之美的学习记录,以便自己复习回顾,代码部分均已经过验证,可直接使用

1. 二叉树基础

之前的都是线性表结构,栈、队列等。现在说说非线性表结构,树。相比下,复杂的多。

1. 树

类似我们现实的树,这里的每个元素我们叫“节点”,用来连线相邻节点之间的关系,叫“父子关系”。

此外,父节点、子节点、兄弟节点、根节点、叶子节点。根节点就是没有父节点的节点;叶子节点就是没有子节点的节点。

此外,还有三个比较相似的概念:高度Height、深度Depth、层level

  • 节点的高度=节点到叶子节点的最长路径(边数)
  • 节点的深度=根节点到这个节点所经历的边的个数
  • 节点的层数=节点的深度+1
  • 树的高度=根节点的高度

2. 二叉树 Binary Tree

二叉树:每个节点最多有两个“叉”,也就是两个子节点,左子节点和右子节点。有的节点可以只有左子节点或右子节点。

如果除了叶子结点,每个节点都有左右两个子节点,这种叫满二叉树。

如果最后一层的叶子节点都靠左排列,除了最后一层,其它层的节点个数都达到最大,叫完全二叉树。

3. 如何存储一个二叉树

一种是基于指针或引用的二叉链式存储法,一种是基于数组的顺序存储法。

比较简单、直观的链式存储法。每个节点都有三个字段,其中一个存储数据,另外两个指向左右子节点的指针,只要拎住根节点,可以通过左右子节点的指针,把整棵树都串起来。比较常用。大部分二叉树都是通过这种结构实现。

基于数组的顺序存储法。把根节点存储在下标i=1的位置,左子节点存储在下标2*i=2的位置,右子节点存储在2*i+1=3的位置。以此类推,B节点的左子节点存储在2*i=2*2=4的位置,右子节点存储在2*i+1=2*2+1=5的位置

在这里插入图片描述
如果节点x存储在数组中下标为i的位置,下标为2*i的位置存储的就是左子节点,下标为2*i+1存储的就是右子节点。反过来,下标为i/2的位置存储的就是它的父节点。通过这种方式,只需要知道根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为1的位置),这样可以通过下标计算,把整棵树串起来。

如果完全二叉树,仅仅“浪费”一个下标为0的存储位置; 如果非完全二叉树,会浪费较多的数组存储空间。
在这里插入图片描述
因此,完全二叉树,用数组存储最节省内存,不需要存储额外的左右子节点的指针,这也是为什么完全二叉树要求最后一层的子节点都靠左。

堆其实就是一种完全二叉树,最常用的存储方式是数组。

4. 二叉树的遍历

如何将所有节点都遍历打印出来?经典的方法有三种,前序遍历、中序遍历和后序遍历。前中后序,指的是节点和它的左右子树节点遍历打印的先后顺序。

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

实际上,二叉树的前、中、后序遍历就是一个递归过程。如前序遍历,就是先打印根节点,再递归打印左子树,最后递归打印右子树。

代码实现

public class Node {
    private int data;
    private Node leftNode;
    private Node rightNode;
    public Node(int data,Node leftNode,Node rightNode){
        this.data = data;
        this.leftNode = leftNode;
        this.rightNode = rightNode;
    }
  // getter/setter...
public class BinaryTree {
    // 二叉树的前中后序排序
    // 注意:必须逆序建立,先建立子节点,再往上建立,因为非叶子节点会使用到下面的节点,而初始化是按顺序初始化的
    public Node init(){
        Node J = new Node(8,null,null);
        Node H = new Node(4,null,null);
        Node G = new Node(2,null,null);
        Node F = new Node(7,null,J);
        Node E = new Node(5,H,null);
        Node D = new Node(1,null,G);
        Node C = new Node(9,F,null);
        Node B = new Node(3,D,null);
        Node A = new Node(6,B,C);
        return A; // 返回根节点
    }

    public void printNode(Node node){
        System.out.println(node.getData());
    }

    // 前序遍历
    public void preOrder(Node root){
        printNode(root);

        // 左子节点
        if(root.getLeftNode()!=null){
            preOrder(root.getLeftNode());
        }
        // 右子节点
        if(root.getRightNode()!=null){
            preOrder(root.getRightNode());
        }
    }

    // 中序遍历
    public void inOrder(Node root){
        // 左子节点
        if(root.getLeftNode()!=null){
            preOrder(root.getLeftNode());
        }
        printNode(root);
        // 右子节点
        if(root.getRightNode()!=null){
            preOrder(root.getRightNode());
        }
    }

    // 后序遍历
    public void postOrder(Node root){
        // 左子节点
        if(root.getLeftNode()!=null){
            preOrder(root.getLeftNode());
        }
        // 右子节点
        if(root.getRightNode()!=null){
            preOrder(root.getRightNode());
        }
        printNode(root);
    }

    public static void main(String[] args) {
        BinaryTree tree = new BinaryTree();
        Node root = tree.init();
        System.out.println("前序遍历");
        tree.preOrder(root);
        System.out.println("=============");
        System.out.println("中序遍历");
        tree.inOrder(root);
        System.out.println("=============");
        System.out.println("后序遍历");
        tree.postOrder(root);
        System.out.println("=============");
    }
}

二叉树遍历的时间复杂度是多少?

从前中后序的顺序图,可以看出,每个节点最多被访问两次,遍历操作的时间复杂度,跟节点的个数N成正比,也就是O(n)

2. 二叉查找树 Binary Search Tree

1. 概念

是二叉树中最常用的一种类型,也叫二叉搜索树,为了实现快速查找而生的。不仅仅支持快速查找一个数据,还支持快速插入、删除一个数据。如何做到的?依赖二叉查找树的特殊结构。

二叉查找树要求,在树的任意一个节点,其左子树的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。

2. 操作

1. 查找操作

先取根节点,如果等于我们要查找的数据,就返回。如果要查找的数据比根节点的值小,就在左子树中递归查找;如果要查找的数据比根节点的值大,就在右子树递归查找

代码实现

public class BinarySearchTree {
    public static class Node{
        private int data;
        private Node left;
        private Node right;
        public Node(int data){
            this.data = data;
        }
    }

    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;
    }
}

2. 插入操作

插入过程类似查找操作。新插入的数据一般都在叶子节点,只需要从根节点开始,依次比较要插入的数据和节点的大小关系。

如果要插入的数据比节点的数据大,并且节点的右子树为空,将新数据直接插到右子节点的位置;如果不为空,递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,将新数据插入到左子节点的位置;如果不为空,再递归遍历左子树,查找插入位置。

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{
      if(p.left==null){
        p.left = new Node(data);
        return;
      }
      p = p.left;
    }
  }
}

3. 删除操作

删除操作较为复杂,分为三种情况

  1. 如果要删除的节点没有子节点,只需要将父节点中,指向要删除节点的指针置为null,如下图的节点55
  2. 如果要删除的节点只有一个子节点,只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点,如下图的节点13
  3. 如果要删除的节点有两个子节点,要找到这个节点的右子树的最小节点,把他替换到要删除的节点上。然后删除这个最小节点。因为最小节点肯定没有左子节点,然后应用上面两点来删除这个最小节点。如下图的节点18
    在这里插入图片描述
public void delete(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;//将minP的数据替换到p中
    p = minP; // 下边就变成删除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 = p.left;
  }else if(pp.left==p){
    pp.left = child;
  }else{
    pp.right=child;
  }
}

其实,删除的话,还可以先标记,但不真正删除,浪费了内存空间,但删除操作简化。

4. 其他操作

除了插入、删除和查找外,还支持快速的查找最大节点和最小节点、前驱节点和后继节点。

// 查找最小节点
public Node findMin(){
  if(tree==null) return null;
  Node p = tree;
  while(p.left!=null){
    p = p.left;
  }
  return p;
}

// 查找最大节点
public Node findMax(){
  if(tree==null) return null;
  Node p = tree;
  while(p.right!=null){
    p=p.right;
  }
  return p;
}

二叉树除了支持上面的操作外,还有一个重要特性,中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度为O(n),非常高效。因此,也叫二叉排序树。

3. 支持重复数据的二叉查找树

实际软件开发中,二叉查找树中存储的是对象。我们利用对象的某个字段作为键值key来构建二叉查找树。我们把对象的其他字段叫卫星数据

如果存储的两个对象key相同,如何处理?两种解决办法

  1. 二叉查找树的每个节点不仅会存储一个数据,我们可以通过链表和支持动态扩容的数组等结构,将值相同的数据存储在同一个节点
  2. 每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们将这个要插入的数据放到这个节点的右子树。也就是说,把这个新插入的数据当做大于这个节点的值来处理
    在这里插入图片描述

当要查找数据的时候,遇到值相同的节点,并不停止查找操作,而是继续在右子树查找,直到遇到叶子节点停止。这样把key等于要查找值的所有节点都找出来。

对于删除操作,也需要先找到每个要删除的节点,然后按照之前的删除操作的方法,依次删除。

4. 二叉查找树的时间复杂度分析

二叉查找树的形态各式各样,对于同一种数据,构造三种二叉查找树,其查找、插入、删除操作的效率都不一样。第一种二叉树,已经退化为链表,查询的时间复杂度为O(n)
在这里插入图片描述

其实,不管插入、删除还是查找,时间复杂度跟树的高度成正比,也就是O(height)。如何求一棵树包含n个节点的完全二叉树的高度呢?

树的高度等于最大层数减一,转换为层来表示。包含n个节点的完全二叉树,第一层包含1个节点,第二层包含2个节点,第三层包含4个节点,以此类推,下面一层个数是上一层的2倍,第K层包含的节点个数是2^(k-1)

对于完全二叉树来说,最后一层的节点个数其实是在1~2^(L-1)个之间(假设最大层数是L)。如果把每一层的节点个数加起来就是总的节点个数n,也就是说如果节点的个数是n,那么n满足关系

n >= 1+2+4+8+...+2^(L-2)+1
n <= 1+2+4+8+...+2^(L-2)+2^(l-1)

L的范围是[log₂(n+1),log₂n+1],完全二叉树的层数小于等于log₂n+1,也就是说,高度小于等于log₂n

显然,我们需要的是,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树,平衡二叉查找树。平衡二叉查找树的高度接近logn,所以,插入、删除、查找时间复杂度比较稳定,为O(logn)

如何求二叉树的高度?递归法,根节点高度=max(左子树高度,右子树高度)+1

5. 相比散列表,二叉查找树的优势

  1. 散列表的数据无序存储,如果要输出有序的数据,需要先排序。而二叉查找树,只需要中序遍历,就可以在O(n)的时间复杂度内,输出有序的数据序列
  2. 散列表扩容耗时,且遇到hash冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但工程中常用的平衡二叉查找树性能稳定,时间复杂度O(logn)
  3. 尽管散列表的查找等操作的时间复杂度是常量级,但因为hash冲突,常量不一定比logn小,实际查找速度不一定比O(logn)快,加上hash函数的耗时,效率不一定高
  4. 散列表的构造复杂,需要考虑很多东西,如hash函数的设计、hash冲突解决方法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性,解决方案也成熟、固定。

二者并不冲突,根据具体的需求选择使用哪一个。

3. 红黑树

为什么工程中都喜欢用红黑树,而不是其他平衡二叉树?

1. 什么是“平衡二叉查找树”

严格定义:二叉树中任意一个节点的左右子树的高度相差不能大于1。

发明平衡二叉树的初衷是,解决普通二叉查找树在频繁插入、删除等动态更新的情况下,出现时间复杂度退化的问题。

平衡的意思,其实就是让整棵树看起来比较“对称”,比较“平衡”,不出现左子树很高、右子树很矮的情况。相应的插入、删除、查找等操作效率高一些。

2. 如何定义一棵“红黑树”

一般提到平衡二叉查找树,听到的基本都是红黑树,默认平衡二叉查找树就是红黑树。

英文Red-Black Tree,简称RB树,红黑树如何定义的?

红黑树的节点,一类被标记为黑色,一类被标记为红色,此外,一棵红黑树还需满足:

  • 根节点是黑色的
  • 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据
  • 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
  • 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点

第二点“叶子结点都是黑色的空节点”,主要是为了简化红黑树的代码实现而设置的,暂时将黑色的、空的叶子节点都省略掉

图例:

在这里插入图片描述

3. 为什么说红黑树“近似平衡”

近似平衡也就是性能不会退化的太严重

一棵极其平衡的二叉树(满二叉树或完全二叉树)的高度大约是log₂n,如果要证明红黑树近似平衡,只需要分析,红黑树的高度是否比较稳定的趋近于log₂n

推导过程:

首先,如果将红色节点从红黑树去掉,单纯包含黑色节点的红黑树的高度是多少?

红色节点删除之后,有些节点就没有父节点,直接拿节点的祖父节点作为父节点,之前的二叉树变成四叉树。

在这里插入图片描述
红黑树的定义中有一条:从任意节点到可达的叶子节点的每个路径包含相同数目的黑色节点。我们从四叉树中取出某些节点,放到叶子节点位置,四叉树就变成完全二叉树。所以,仅包含黑色节点的四叉树的高度,比包含相同节点个数的完全二叉树的高度要小。

完全二叉树的高度近似log₂n,这里的四叉“黑树”的高度低于完全二叉树,因此,去掉红色节点的“黑树”的高度不会超过log₂n

把红色节点再加回去,高度变多少呢?

从上图中可看出,红色节点不相邻,也就是,有一个红色节点就至少要有一个黑色节点,将它跟其他红色节点隔开。红黑树中包含最多黑色节点的路径不会超过log₂n,所以加入红色节点后,最长路径不超过2log₂n,也就是说,红黑树的高度近似2log₂n

红黑树的高度只比高度平衡的AVL树的高度log₂n大了一倍,在性能上下降的不多。

4. 为什么用红黑树

AVL树是一种高度平衡的二叉树,查找的效率非常高,但为了维持高度的平衡,付出更多代价,每次插入、删除都要进行调整,比较复杂耗时。对于有频繁的插入、删除操作的数据集合,代价较高。

红黑树近似平衡,维护成本较低,插入、删除、查找等操作性能比较稳定。工业级的应用,更倾向于使用它。

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

红黑树的实现较为复杂,自己写代码难度较高,倾向于使用跳表替代它。

4. 红黑树的实现

TODO

猜你喜欢

转载自blog.csdn.net/wjl31802/article/details/93771567