20172314 2018-2019-1《程序设计与数据结构》第七周学习总结
教材学习内容总结
概述
- 二叉查找树:是含附加属性的二叉树,即其左孩子小于父节点,而父节点又小于或等于右孩子。
- 二叉查找树的定义是二叉树定义的扩展。
- 二叉查找树的各种操作
用链表实现二叉查找树
-
每个BinaryTreeNode对象要维护一个指向结点所存储元素的引用,另外还要维护指向结点的每个孩子的引用。
-
LinkedBinarySearchTree类提供两个构造函数:一个负责创建一个空的LinkedBinarySearchTree;另一个负责创建一棵根结点为特定元素的LinkedBinarySearchTree。这两个构造函数都只是引用了LinkedBinaryTree中相应的那两个构造函数。
-
addElement操作:就是根据给定元素的值,在树中的恰当位置添加该元素。
- 如果该元素不是Comparable,该方法会抛出NoComparableElementException异常。
- 如果树为空,该元素称为新结点。
- 如果树非空,则依据二叉查找树的性质,分别与某结点及其左右孩子比较,按照左孩子<父节点,父节点<=右孩子的规则将其添加到适当位置,或者称为左右孩子的孩子。
- 向二叉树中添加元素
-
removeElement操作:从二叉查找树中删除一个元素时,必须推选出另一个结点(replacement方法找到这个结点)来代替要被删除的那个结点。
- 在树中找不到给定目标元素时,抛出ElementNotFoundException异常。
- 选择替换结点的三种情况
- 被删除结点没有孩子,则replacement返回null
- 被删除结点只有一个孩子,replacement返回这个孩子
- 被删除结点有两个孩子,replacement返回中序后继者(因为相等元素会放到右边)
- 从二叉树中删除元素
-
removeAllOccurrences操作:从二叉查找树中删除指定元素的所有存在。
- 在树中找不到给定目标元素时,抛出ElementNotFoundException异常。
- 如果该元素不是Comparable,该方法会抛出ClassCastException异常。
- 该方法会调用一次removeElement方法,以确保当树中根本不存在指定元素时会抛出异常。
- 如果树中还含有目标元素,就会再次调用removeElement方法。
-
removeMin操作:根据二叉查找树的定义,最右侧存最大的结点,最左侧存最小元素。
- 如果树根没有左孩子,根结点为最小,右孩子变为新的根结点。
- 如果左孩子是叶子结点,将父结点的引用设为null即可。
- 如果左孩子是内部结点,则这个左孩子的右孩子将代替自己成为它父节点的左孩子。
有序列表实现二叉查找树
-
树的主要使用之一就是为其它集合提供高效的实现。
-
LinkedBinarySearchTree类的方法与有序列表的方法之间存在着一一对应的关系。
-
列表的一些常见操作
-
有序列表的特有操作
-
BinarySearchTreeList实现的分析
- BinarySearchTreeList的实现是一种带有附加属性(任何结点的最大深度为log2n,其中n为树中储存元素的个数)的平衡二叉查找树
- 树实现会使有些操作变得高效,有些操作变得低效
- add操作和remove操作需要重新平衡化树
- 有序列表的链表实现分析和二叉查找树实现分析
-
平衡二叉查找树
-
如果二叉查找树不平衡,其效率可能比线性结构的还要低。例如蜕化树看起来更像一个链表,事实上它的效率比链表的还低,因为每个结点附带有额外的开销。
-
如果没有平衡假设,当树根是树中最小元素而被插入元素是树中最大的元素时,这种情况下addElement操作的时间复杂性是O(n)而不是O(logn)。
-
我们的目标是保持树的最大路径长度为(或接近)log2n。
-
平衡化树的四种方法
-
自树根向下的路径最大长度不超过log2n,最小长度必须不小于log2(n-1)
-
平衡因子指左子树减右子树深度的值。
-
右旋
- 通常是指左孩子绕着其父结点向右旋转。是由于树根的左孩子的左子树中较长的路径导致的不平衡。
- 如图所示的初始树,首先可以计算他成为平衡树之后的样子,他现在的最大路径长度是3,最小路径长度是1,树中有6个元素,因此最大路径长度应该是log26,即2。要平衡化该树,需要三步
- 使根的左孩子称为新根
- 使原来的根元素称为新根的右孩子
- 使原根的左孩子的右孩子成为原树根的新的左孩子
- 如图是依据上面得三步的右旋过程
-
左旋
- 通常指右孩子绕着其父结点向左旋转。是由于较长的路径出现在树根右孩子的右子树中而导致的不平衡。
- 同样于右旋,为了平衡化,需要三步
- 使树根的右孩子元素成为新的根元素
- 原根元素称为新根元素的左孩子
- 原树根右孩子的左孩子成为原树根新的右孩子
- 如图是依据上面的三步的左旋过程
-
右左旋
- 对于由树根右孩子的左子树中较长路径而导致的不平衡,需要先让树根右孩子的左孩子绕其父结点进行一次右旋,再让树根的右孩子绕树根进行一次左旋。
- 如图
-
左右旋
- 对于由树根左孩子的右子树中较长路径而导致的不平衡,需要先让树根左孩子的右孩子绕其父结点进行一次左旋,再让树根的左孩子绕树根进行一次右旋。
- 对于由树根左孩子的右子树中较长路径而导致的不平衡,需要先让树根左孩子的右孩子绕其父结点进行一次左旋,再让树根的左孩子绕树根进行一次右旋。
实现二叉查找树:AVL树
-
对于树中任何结点,如果其|平衡因子|(右子树的高度减去左子树的高度)>1,那么以该结点为树根的子树需要重新平衡。
-
树(或树的任何子树)只有两种途径变得不平衡:插入结点或删除结点。因此在每次进行这两种操作时,都必须更新平衡因子,然后从插入或删除结点的那个地方开始检查树的平衡性。上溯到根结点,所以AVL树通常最好实现为每个结点都包含一个指向父结点的引用。
-
AVL树的右旋
- 某结点的平衡因子为-2,则左子树过长,如果左孩子的平衡因子是-1,则这个结点的左子树为较长的路径,将这个左孩子绕初始结点右旋一次即可平衡该树。
- 某结点的平衡因子为-2,则左子树过长,如果左孩子的平衡因子是-1,则这个结点的左子树为较长的路径,将这个左孩子绕初始结点右旋一次即可平衡该树。
-
AVL树的左旋
- 某结点的平衡因子是+2,则右子树过长,如果右孩子的平衡因子是+1,则意味着较长的路径处在这个右孩子的右子树中,将该右孩子绕初始结点进行一次左旋即可平衡。
-
AVL树的右左旋
- 同样根据平衡因子来判断,某结点的平衡因子是+2,右孩子的平衡因子是-1,则过长的是右孩子的左子树,需要进行一次右左双旋(初始结点的右孩子的左孩子绕初始结点的右孩子进行一次右旋,再让初始结点的右孩子绕初始结点进行一次左旋)如图
- 同样根据平衡因子来判断,某结点的平衡因子是+2,右孩子的平衡因子是-1,则过长的是右孩子的左子树,需要进行一次右左双旋(初始结点的右孩子的左孩子绕初始结点的右孩子进行一次右旋,再让初始结点的右孩子绕初始结点进行一次左旋)如图
-
AVL树的左右旋
- 同样根据平衡因子来判断,某结点的平衡因子是-2,右孩子的平衡因子是+1,则过长的是左孩子的右子树,需要进行一次左右双旋(初始结点的左孩子的右孩子绕初始结点的左孩子进行一次左旋,再让初始结点的左孩子绕初始结点进行一次右旋)
实现二叉查找树:红黑树
-
红黑树是一种平衡二叉查找树,其中的每个结点存储一种颜色(红色或黑色,用布尔值表示,false表示红色)。结点颜色的规则:
- 根结点为黑色
- 红色结点的所有孩子都为黑色
- 从树根到树叶的每条路径都包含同样数目的黑色结点
-
某种程度上,红黑树中的平衡限制没有AVL树那么严格,但他们的序仍然是logn。
-
红黑树路径中至多一半红结点,至少一半黑结点。
-
红黑树最大高度约为2*logn,于是遍历最长路径的序仍然是logn。
-
插入的结点认为是红色,空结点认为是黑色。
-
红黑树示意:
-
红黑树中的元素插入
- 开始把新元素的颜色设置成红色,然后重新平衡化该树,根据红黑树的属性改变元素颜色,最后总会把根结点设为黑色。
- 插入之后的重新平衡化是一种迭代过程,从插入点上溯到树根,迭代过程的终止条件有两种
- (current == root):原因是每条路径黑色元素相同,而根节点总为黑色。
- (current.parent.color == black):因为current所指向的每一个结点都是红色(开始时,总是把新元素设置成红色,那么其父结点不可能为红色),那么如果当前结点的父结点是黑色,由于黑色数目是固定不变的,并且平衡时上溯处理早已平衡了当前结点的下面子树,所以只要满足这个条件,就可以实现平衡。
在每次迭代的过程中,有以下情况: - 父结点是左孩子
- 右叔叔是红色
- 父结点为黑
- 右叔叔为黑
- 祖父为红
- current由我变成父结点
- 右叔叔是黑色
- 我是右孩子的情况下
- current由我变成父结点
- 绕current左旋,current变成左孩子
- (current为左孩子的步骤)
- 父结点为黑
- 祖父为红
- 如果祖父不为空,让父结点绕祖父右旋
- 我是右孩子的情况下
- 右叔叔是红色
- 父结点是右孩子
- 左叔叔是红色
- 父结点为黑
- 左叔叔为黑
- 祖父为红
- current由我变成祖父
- 左叔叔是黑色
- 我是左孩子的情况下
- current由我变成父结点
- 绕current右旋,current变成右孩子
- (current为右孩子的步骤)
- 父结点为黑
- 祖父为红
- 如果祖父不为空,让父结点绕祖父左旋
- 我是左孩子的情况下
- 左叔叔是红色
- 以上两种情况是对称的,并且最后都会把根结点变为黑色。插入中最关注的是叔叔的颜色。
-
红黑树中的元素删除
- 删除元素之后需要重新平衡化(即重新着色),是一个迭代过程,终止条件有两种:
- (current == root)
- (current.color == red)
- 如果兄弟颜色是红
- 设置兄弟为黑
- 父结点为红
- 兄弟绕父结点右旋
- 旧兄弟绝交,新兄弟等于父结点的左孩子
接下来不管兄弟是黑还是红都要进行的步骤:
- 兄弟的两个孩子都是black/null
- 设置兄弟颜色是红
- current由我变为父结点
- 兄弟的两个孩子不全为黑
- 左孩子为黑
- 兄弟的右孩子为黑
- 兄弟为红
- 让兄弟的右孩子绕兄弟右旋
- 兄弟等于父结点的左孩子
- 左孩子为黑
- 兄弟的两个孩子都不为黑
- 兄弟为父结点的颜色
- 父结点为黑
- 兄弟左孩子颜色为黑
- 兄弟绕父结点右旋
- current由我变为树根
- 循环结束后删除该结点,并设置父亲的孩子引用为null。删除中最关注的是兄弟的颜色。
- 删除元素之后需要重新平衡化(即重新着色),是一个迭代过程,终止条件有两种:
教材学习中的问题和解决过程
-
问题一:在红黑树的删除中,迭代的一个终止条件是(current.color == red),不能理解原因。
-
问题一解决:首先要明白的是最终目的是为了让树满足红黑树的三个性质:根结点为黑色,红结点的孩子为黑色,每条路径黑色结点数目相同。删除时有两种情况:
- 删除的是红色:这种情况下直接删除就好,因为对于红黑树的第一个性质而言,不影响根节点,第二个性质,红结点的父节点是黑色,子结点也是黑色,两者均不需要变色,对第三个性质,删除红色并不影响黑色结点的数目。所以说,删除红色结点可以直接删。
- 删除的是黑色:这种情况下依然考虑红黑树的三个性质,可以看出删除黑色对三个性质都有影响,这种影响会一直上溯到根结点,当根结点为红色时,下面的全部都平衡了,整棵树平衡,最后在根据性质把根结点变为黑色即可。这就是迭代的第二个终止条件。
-
问题二:removeElement操作的代码理解有问题
-
问题二解决:这个方法负责从二叉查找树中删除给定的comparable元素;或者当在树中找不到给定的目标元素时,则抛出ElementNotFoundException异常。与前面的线性结构研究不同,这里不能通过简单的通过删除指定结点的相关引用指针而删除该结点。相反这里必须推选出一个结点来取代要被删除的结点。受保护方法,replacement返回指向一个结点的应用,该结点将代替要删除的结点,选择替换结点有三种情况:
- 如果被删除结点没有孩子那么repalcement返回null
- 如果有一个,那么replacement返回这个孩子
- 如果有两个孩子,则replacement会返回终须后继者。因为相等元素会放在后边
public T removeElement(T targetElement) { T result = null; if (isEmpty()) {//树为空时抛出异常 throw new ElementNotFoundException("LinkedbinarySearchTree"); } else {//树不为空 BinaryTreeNode<T> parent = null; if (((Comparable<T>) targetElement).equals(root.getElement())) {//要删除的元素是根结点 result = root.element; BinaryTreeNode<T> temp = replacement(root); if (temp == null) {//找不到结点替换 root = null; } else { //用找到的结点替换根结点 root.element = temp.element; root.setLeft(temp.getLeft()); root.setRight(temp.getRight()); } modCount--; } else {//要删除根节点的孩子 parent = root; if (((Comparable<T>) targetElement) .compareTo(root.getElement()) < 0) {//目标在根的左边 result = removeElement(targetElement, root.getLeft(), parent); } else {//目标在根的右边 result = removeElement(targetElement, root.getRight(), parent); } } } return result; } private T removeElement(T targetElement, BinaryTreeNode<T> node, BinaryTreeNode<T> parent) {//用来删除除根以外的目标元素 T result = null; if (node == null) { throw new ElementNotFoundException("LinkedbinarySearchTree"); } else { if (((Comparable<T>) targetElement).equals(node.getElement())) {//找到目标元素 result = node.element; BinaryTreeNode<T> temp = replacement(node);//将node元素删除 //往下继续查找目标元素,看看左右孩子是否是 if (parent.right == node) { parent.right = temp; } else { parent.left = temp; } modCount--; } else {//如果目标元素比根结点小,则在根结点左侧,再次使用该方法从左子树中查找目标元素 parent = node; if (((Comparable<T>) targetElement) .compareTo(root.getElement()) < 0) { result = removeElement(targetElement, root.getLeft(), parent); } else {//目标元素比根结点大,再次使用该方法从右子树中查找目标元素 result = removeElement(targetElement, root.getRight(), parent); } } } return result; } // 删除元素 private BinaryTreeNode<T> replacement(BinaryTreeNode<T> node) { BinaryTreeNode<T> result = null; if ((node.left == null) && (node.right == null)) {//如果左右子树都为空,该元素没有孩子,直接返回空删掉它即可 result = null; } else if ((node.left != null) && (node.right == null)) {只有左孩子时,将父结点指向左孩子 result = node.left; } else if ((node.left == null) && (node.right != null)) {//只有右孩子时,将父结点指向右孩子 result = node.right; } else {/* 先找到其右子树的最左孩子(或者左子树的最右孩子),即左(右)子树中序遍历时的第一个节点,然后将其与待删除的节点互换,最后再删除该节点(如果有右子树,则右子树上位)。总之就是先找到它的替代者,找到之后替换这个要删除的节点,然后再把这个节点真正删除掉。*/ BinaryTreeNode<T> current = node.right;//初始化右侧第一个结点 BinaryTreeNode<T> parent = node; //获取右边子树的最左边的结点 while (current.left != null) { parent = current; current = current.left; } current.left = node.left; // 如果当前待查询的结点 if (node.right != current) { parent.left = current.right;// 整体的树结构移动就可以了 current.right = node.right; } result = current; } return result; }
-
问题三:对于removeElement方法中“若被删除结点有两个孩子,replacement返回中序后继者”一句不能理解返回的是哪个结点
-
问题三解决:在课堂上,老师提到了前驱结点,就是对一棵树进行中序排序,形成一个序列,书上所提到的返回中序后继者的意思就是排序后的序列的被删除结点的前驱结点或后驱结点都可以,由自己来定义。例如
对这棵树进行中序排序为:2 3 4 5 6。删除结点3后,可以返回前驱结点2,也可以返回后驱结点4。
代码调试中的问题和解决过程
-
问题一:对于AVL树的实现,一开始想的是在printTree里面添加,在输出树之前对树进行平衡,然后测试类里直接定义一个完整的树,但是这样做的话考虑到树的很多情况,
tree = new AVLTreeNode<T>(element, (AVLTree<T>) null, null);
如果传入结点为空需要创建一个结点,如果要添加的元素比根结点小tree.left = addElement(tree.left, element);
表示将其添加到结点左边,并当(height(tree.right) - height(tree.left) == -2)
时,说明左子树过长,这时如果(element.compareTo(tree.left.getElement()) < 0
则说明元素比左孩子小,这样应该使用tree.rightRightRotation(tree)
方法,进行右旋,否则的话进行右左旋。
但这时问题出现了,运行显示Exception in thread "main" java.lang.NullPointerException错误,也就是空指针,在左旋方法中
将代码更改为如果
(element.compareTo(tree.left.getElement()) < 0
,进行左旋tree = leftLeftRotation(tree);
否则进行左右旋tree = leftRightRotation(tree);
,这是因为如果添加到左孩子的右孩子位置,符合“对于由树根左孩子的右子树中较长路径而导致的不平衡,需要先让树根左孩子的右孩子绕其父结点进行一次左旋,再让树根的左孩子绕树根进行一次右旋。”的定义,参考左右旋的图示,符合要求,如果用右左旋的话,左子树的左孩子是空的,并不能将她右旋,所以出现空指针错误。
代码托管
上周考试错题总结
无
结对及互评
- 20172305谭鑫:关于无返回值的条件下语句return的作用,也是我不知道但没提过的,学习了。代码问题是对作业的解答过程,很详细。
- 20172323王禹涵自己总结的知识点很到位,对问题有深入的去思考,图片全面,很认真。
其他
这章内容关于AVL树和红黑树的描述很难理解,情况太多了,需要进行分类整理并结合图示,AVL需要主要弄清楚旋转情况,红黑树要时刻牢记向三个性质转变。
学习进度条
代码行数(新增/累积) | 博客量(新增/累积) | 学习时间(新增/累积) | |
---|---|---|---|
目标 | 5000行 | 30篇 | 400小时 |
第一周 | 0/0 | 1/1 | 8/8 |
第二周 | 1163/1163 | 1/2 | 15/23 |
第三周 | 774/1937 | 1/3 | 12/50 |
第四周 | 3596/5569 | 2/5 | 12/62 |
第五周 | 3329/8898 | 2/7 | 12/74 |
第六周 | 4541/13439 | 3/10 | 12/86 |