9-data-structure2
数据结构专题2
《算法笔记》第九章,数据结构专题2,主要涉及树、并查集、堆等。
树和二叉树
树的基本定义:在数据结构中的树只有一个根结点,若干个节点和边,并且所有的节点是连通的且无闭环。
几个比较实用的性质:
- 树可以是空的,此时连根结点都没有,叫做空树。
- 树的层次layer,是从根结点作为第一层开始计算的,依次递增。
- 结点的子树数量叫做度degree,注意不计算空树。结点的最大度数看做是树的度数,叫做宽度。
- 树中的边只能连接两个结点,因此,对于有\(n\)个结点的树,一定有\(n-1\)个边。如果有\(n\)个相互连通的结点,并且有\(n-1\)个边,那么一定是一棵树。
- 叶子结点是度为0的结点。
- 节点的深度是从根结点深度为1开始算起到该节点的层数;节点的高度是从最底层的叶子结点到该节点的层数。类似于树的宽度定义,树的深度是结点的最大深度,树的高度是根结点的高度。
- 多棵树组合在一起叫做森林forest。
二叉树的递归定义:
- 二叉树可以是空树(没有根结点);
- 二叉树由根结点、左子树、右子树组成。左右子树都是二叉树;
二叉树一定是度为2的树,但是度为2的树不一定是二叉树,因为二叉树的左右子树是严格区分的,不能互换。
两种特殊的二叉树:
- 满二叉树:除去叶子结点外,所有的节点的左右子树都是满的(即树的每一层都是满的,包括最底层)
- 完全二叉树:除去最底层外,所有层都是满结点的;并且最底层的叶子结点从左到右是连续的。
二叉树的存储,一般的二叉树使用链表来定义(对于完全二叉树可以使用数组来定义):
1 | struct Node { |
新建一个结点,但是还未加入到树中,具体要增加到树的哪个地方依赖于之后的具体二叉树的性质。
1 | Node * newNode (int data) { |
二叉树的查找与修改:查找到所有满足查询条件的结点,并且可以修改数据
1 | // 下面的代码是从左开始的DFS |
二叉树的插入依赖于具体二叉树的性质,但是一般来说,二叉树的插入就是在不满足某种条件的位置,并且这个位置一般是确定唯一的,否则就会有多个位置适合插入了。
下面是一个二叉树插入新结点的伪代码:
1 | // 下面的函数需要特殊注意,current_root是引用形式,这样方便直接修改传入的root值 |
二叉树的创建,就是重复上面的插入函数:
1 | Node* create(int data[], int n) { |
上面是一般的二叉树存储,对于完全二叉树,在元素数量不多的情况下,可以直接使用数组存储。
将根结点放在数据的位置\(1\),不能从\(0\)开始存放,否则会丧失下面的规律:
对于结点\(i\),它的左子树位置一定是\(2i\),右子树位置是\(2i+1\)。
判断是否是叶子结点,该结点的左子树位置超过了树的结点数量\(n\)。
二叉树的遍历
这里讨论二叉树的四种遍历方法:
- 先序遍历:根结点、左子树、右子树
- 中序遍历:左子树、根结点、右子树
- 后序遍历:左子树、右子树、根结点
- 层序遍历:BFS,访问同一层的结点结束后,再访问下一层
在上面的遍历过程中,可以发现,总是先访问左子树,再访问右子树。
下面是先序遍历的函数
1 | void preSearch(Node* root) { |
层序遍历,利用到之前学的使用队列实现BFS
1 | void layerSearch(Node* root) { |
先序、中序以及后序遍历的作用在于,可以根据先序+中序或者后序+中序的输出,来唯一的重建树。先序或者后序都可以确定当前序列的根结点(第一位或者最后一位),然后结合中序就可以找到左右子树。
利用先序+中序重建树,如果两者对应的序列数组是\([preL, preR]\)和\([inL, inR]\),那么根结点是\(preL\)位置的元素,根据根结点寻找根结点在中序中的位置假设为\(k\),那么对应的左子树的先序和中序序列是\([preL+1, preL+k-inL]\),\([inL, k-1]\);右子树的先序序列是\([preL+k-inL+1, preR]\),中序序列是\([k+1, inR]\)。
使用递归的方式就可以实现重建树。
二叉树也可以使用数组的方式来实现,指针变为数组的下标,左右子树都指向数组的下标,同时,如果是空树就设为-1,这里不赘述具体实现。
树的遍历
一般的树有多个子结点,并且不限制左右树顺序。
对于多个子树,当然可以使用链表来保存,但是有些麻烦,这里按照书上的说明统一使用静态写法,
1 | struct Node { |
创建新结点的办法,类似与前面的静态二叉树:
1 | int index = 0; // 记录当前的新元素在tree数组中的位置,index下没有数据 |
二叉查找树(BST)
二叉查找树就是满足左子树中的元素都\(<=\)根结点,右子树都\(>\)根结点的二叉树。当然,\(==\)根结点的元素到底在哪颗子树存放可以看题目的具体要求。
bst的查找:
1 | Node* search(Node* root, int x) { |
bst的插入:
1 | void insert(Node* &root, int x) { |
bst的删除比较特殊,主要是涉及如何让结点被删除后,以该结点为根结点的整棵树还能够保证原来二叉查找树的性质。
有两种办法:用该结点的前驱或者后继来替换该结点。
- 前驱:某个节点左子树中的最大结点,在树上表现为左子树的最右结点(可能有自己的左子树)
- 后继:某个结点右子树中的最小结点,在树上表现为右子树的最左结点(可能有自己的右子树)
寻找前驱和后继:
1 | // 寻找某棵树的最大结点 |
bst的删除操作:
1 | void deleteNode(Node* &root, int x) { |
平衡二叉树(AVL)
平衡二叉树是对于前面二叉查找树的改进,“平衡“的意思是保证左右子树的高度差不超过1,这样总能够保证找寻所需元素时的搜索效率在\(O(log(n))\)内。
平衡二叉树是两位前苏联的数学家提出来的,取他们名的大写字母命名为AVL树。在AVL树中的每个结点会额外记录一下当前结点的高度,同时利用左右子树的结点高度差\(左子树高度-右子树高度\),可以可以计算当前结点的平衡因子。
AVL树的难点在于插入与删除,一个结点的定义
1 | struct Node { |
新建结点
1 | Node* newNode(int x) { |
查询当前结点的高度与计算平衡因子
1 | int getHight(Node* node) { |
更新节点高度
1 | void updateHeight(Node* node) { |
AVL树的查询操作,与一般的BST树一样,这里不重复叙述。
AVL树的插入操作,比较复杂,需要考虑在插入新结点后,如何保持”平衡“?这里有两种办法调整当前树的左右子树高度,左旋和右旋:
- 左旋:通过旋转,让当前根结点变为原来右子树根结点的左结点,原来右子树根结点成为新根结点
- 右旋:通过旋转,让当前根结点变为原来左子树根结点的右结点,原来左子树根结点成为新根结点
详细的旋转过程可以参考《算法笔记》上的图示,这里提供代码,核心思想在于:
- 左旋:当前根结点变为新的左结点,当前根结点的右子树链接到右结点(新根结点)的左子树
- 右旋:当前根结点变为新的右结点,当前根结点的左子树链接到左结点(新根结点)的右子树
代码:
1 | // 左旋 |
接下来可以讨论AVL树插入新元素的操作了。当AVL树插入新结点后,可能造成左子树比右子树高\(2\),或者是右子树比左子树高\(2\)。一共有四种可能(更详细的讨论在书上):
- LL型:左子树高->左子树的左子树高;右旋根结点解决
- LR型:左子树高->左子树的右子树高;先左旋左子树,后右旋根结点
- RR型:右子树高->右子树的右子树高;左旋解决
- RL型:右子树高->右子树的左子树高;右旋右子树,后左旋根结点
插入操作的代码:
1 | void insert(Node* &root, int x) { |
AVL树的删除,书上没有提及。
并查集
并查集是一种维护集合的数据结构,主要针对合并、查找与集合三个单词。
并查集的实现就是一个数组,
1 | int father[N]; |
father[i]
指的是元素i
的父亲结点。在并查集中,一个集合只有一个结点满足father[i]=i
,叫做根结点,把这个根结点看做是集合的标志。
并查集支持的操作,
初始化:
1 | for(int i =0;i<n;++i) { |
查找当前集合的根结点:
1 | int findFather(int i) { |
合并操作,如果两个元素实际是属于同一集合,就合并这两个元素分属的两集合的根结点,只保留一个根结点:
1 | void Union(int x, int y) { |
路径压缩操作,是一种简化father
数组,从而提高寻找根结点效率的方法,直接让father[i]
指向根结点,而不是父结点。
1 | int findFather(int i) { |
堆
堆是一课完全二叉树。它区别于BST和AVL的是,它只规定根结点要>=
或者<=
两个结点,根结点是当前树最大值的完全二叉树叫做大顶堆,反之叫做小顶堆。
如果现在我们已经有一棵完全二叉树,如何把它调整为一个大顶堆?
原则是从右到左,从下到上依次检查当前结点是否满足条件:
- 叶子结点无需调整
- 非叶子结点,将当前根结点与左、右子结点的最大值进行比较,如果当前根结点已经是最大值就无需变动;否则就互换,互换之后,由于左右子树在之前的调整过程中已经保证了大顶堆,那么新的根结点一定是最大值,只需要继续让被换以后的根结点与下一级子树进行比较即可。重复这个过程,直至无需再调整位置。
类似的,如果我们想新建一个小顶堆,只需要总是寻找根结点、左、右结点中的最小值即可,整体过程与上述过程类似。
从代码的角度分析,如何实现大顶堆?按照之前的讨论,完全二叉树可以使用根结点在位置\(1\)的数组进行存储。
1 | const int maxn = 110; |
实现下上面的调整过程,让当前位置的\(i\)一直向下调整。
1 | // high一般为n |
建堆过程,从最右边,最低层的非叶子结点开始遍历,逐个执行上述函数:
1 | void createHeap() { |
删除堆顶元素,让最末端的叶子结点替换堆顶元素,然后执行向下调整
1 | void deleteTop() { |
新增元素,让新元素放在堆的最末端,然后执行从下到上的调整
1 | // low一般为1, high是当前要进行向上调整的元素 |
堆的基本元素讲完后,我们可以使用堆来实现堆排序,下面是一个实现从小到大递增排序的例子:
1 | void heapSort() { |
哈夫曼树
哈夫曼树的构建方法:
- 初始状态的n个结点看做是单独的n个二叉树
- 选择根结点值最小的两个结点进行合并,生成新的父结点,父结点的值是两个结点的和
- 重复上述过程,直至形成一棵完整的二叉树
叶子结点的权值\(\times\)根结点该叶子结点的路径长度,叫做改叶子结点的带权路径长度。一棵二叉树的带权路径长度Weight Path Length of tree(WPL)是所有叶子结点带权路径长度的和。哈夫曼树是满足一组叶子结点,带权路径长度最小的树。在建立哈夫曼树的过程中,能够保证哈夫曼树的WPL是最小的,但是可能存在多个哈夫曼树,比如可能同时存在多个相同的最小值结点。
哈夫曼树用来计算某个值的时候,可以考虑使用优先队列/小顶堆,每次取出最小的两个值合并后再入队。
现在考虑给字符用01编码的问题,如果随便给字符编码,就可能导致某个叶子结点的编码是另一个叶子结点编码的前缀,这样在使用这种编码过程的时候就可能出现问题。如果任一字符的编码都不是另一字符的编码前缀,这种编码方式叫做前缀编码。
对于这种情况,可以使用任意一棵二叉树,根结点到叶子结点的路径(左子树0,右子树1)作为该叶子结点的编码。它能够满足前缀编码的要求。
但是,另一个问题是如果我们希望一段字符串的01编码长度最小呢?这就可以使用哈夫曼编码来解决,统计字符串中各个字符的出现次数作为权值,然后建立哈夫曼树,就能够保证最后字符串的编码一定是最短的。