数据结构与算法

线性结构和非线性结构

数据结构包括:线性结构和非线性结构

线性结构:

  1. 线性结构作为最常用的数据结构,其特点是数据元素之间存一对一的线性关系

  2. 线性结构有两种不同的存储结构,及顺序存储结构和链式存储结构。顺序存储的线性表称为顺序表,顺序表中的存储元素是连续的

  3. 链式存储的线性表称为链表,链表中的存储元素不一定是连续的,元素节点中存放数据元素以及相邻元素的地址信息

  4. 线性结构常见的有:数组、队列、链表和栈。

非线性结构

非线性结构包括:二维数组、多维数组、广义表、树、图。

稀疏数组(sparse array)

基本介绍:

当一个数组中大部分元素为0,或者为同一个值的数组时,可以使用稀疏数组来保存该数组。

稀疏数组的处理方法是:

  1. 记录数组一共有几行几列,有多少个不同的值

  2. 把具有不同值的元素的行列及值记录在一个小规模的数组中,从而缩小程序的规模

1567227428401

二维数组转稀疏数组的思路

  1. 遍历原始的二维数组,得到有效数据的个数sum

  2. 根据sum就可以创建稀疏数组sparseArr= int[sum+1][3]

  3. 将二维数组的有效数据数据存入到稀疏数组

稀疏数组转原始的二维数组的思路

  1. 先读取稀疏数组的第一行,根据第一行的数据,创建原始的二维数组,比如上面的 chessArr2=int[11][11]

  2. 在读取稀疏数组后几行的数据,并赋给原始的二维数组即可。

队列

  1. 队列是一个有序列表,可以用数组或者链表来实现

  2. 遵循先进限出的原则,即:先存入对列的数据,要先取出,后存入的元素后取出

u=3865082282,2895414691&fm=26&gp=0

数组模拟队列

当我们将数据存入队列时称之为“addQueue”,addQueue的处理需要两个步骤:

  1. 将尾指针往后移:rear+1,当front == rear则表示队列为空

  2. 若尾指针rear小于队列的最大下标maxSize-1,则将数据存入rear所指的数组元素中,否则无法存入数据,rear == maxSize-1表示队列满了

问题分析及优化:

  1. 目前数组使用一次就不能在使用了,无法复用

  2. 将这个数组使用算法,改进成一个环形队列使用取模方式:%

1567239121893

链表

链表是一个有序的列表,但是它在内存中是如下结构的:

1567245049449
  1. 链表是以节点的方式来存储的,链式存储

  2. 每个节点包含data域和next域,next域指向下一个节点

  3. 如图:链表的各个节点不一定是连续存储的

  4. 链表分为带头结点的链表和没有头节点的链表,根据实际需求来确定

单向链表

代码实现

其中用到的User类

常见单链表题目

  1. 查找单链表的倒数第k个节点使用 T get(int index)方法即可,比如获取倒数第1个节点:

  1. 链表反转,这里写两种方式,还有一种递归反转不会

  1. 逆序打印单链表

方式1:先将单链表进行反转在遍历(破坏了单链表的结构,不建议)

方式2:利用栈,将各个节点压入栈中,在弹栈。利用栈先进后出的特点

(还没有涉及到栈,先不实现,也可以用jdk的Stack)

单向链表的缺点

  1. 单向链表,查找方向只能是一个方向

  2. 单向链表不能自我删除,需要靠辅助节点

双向链表

双向链表的结构包含两个指针,一个指向前一个节点的前驱指针和一个指向下一个节点的后继指针。

u=4194072899,3280572795&fm=26&gp=0

双向链表的遍历、添加、删除的操作思路:

  1. 遍历方式和单链表一样,但是既可以向前查找也可以向后查找

  2. 添加(默认添加到双向链表的最后)

  3. 删除:因为是双向链表,因此可以实现自我删除某个节点,直接找到要删除的某个节点,比如指向删除数据的指针为pointer,则

代码实现

单向循环链表

Josephu(约瑟夫、约瑟夫环)问题:

Josephu问题为:设编号为1,2,…n的n个人围坐一圈,约定编号为k(1<=k<=n)的人从1开始报数,数到m的那个人出列,它的下一位又从1开始报数,数到m的那个人又出列,依次类推,直到所有人出列为止,由此产生一个出队编号的序列。 提示:用一个不带头结点的循环链表来处理 Josephu问题:先构成一个有n个结点的单循环链表,然后由k结点起从1开始计数,计到m时,对应结点从链表中删除,然后再从被删除结点的下一个结点又从1开始计数,直到最后一个结点从链表中删除算法结束。

6f18498993b43b41373f9b25cf8582f6

构建一个单向循环链表思路:

  1. 先创建第一个节点,让head指向该节点,并形成环形。

  2. 然后当我们每创建一个新的节点,就把该节点加入到已有的环形链表中即可

遍历环形链表

  1. 先让一个辅助指针pointer(变量),指向head节点

  2. 然后通过一个while循环遍历该环形链表即可遍历结束的条件pointer.next == head

约瑟夫问题代码

使用不带头节点的单向循环链表完成

  1. 栈的英文为stack

  2. 栈时一个先进后出的有序列表(FILO:First In Last Out)

  3. 栈是限制线性表中元素的插入和删除只能在线性表的同一端进行的一种特殊线性表。允许插入和删除的一端为变化的一端,称为栈顶(Top),另一端为固定的一端,称栈低(Bottom)

  4. 根据栈的定义可知,最先放入栈中元素在栈低,最后放入的元素在栈顶,而删除元素刚好相反,最后放入的元素最先删除,最后放入的元素最后删除

  5. 相关术语:压栈(将元素存如栈中)、弹栈(从栈中取出元素)

压栈演示:

f0e2d9944ce2

出栈演示:

e84246bd9137013

栈的应用场景

  1. 子程序的调用:在跳往子程序之前,回先将下一个指令的地址存入栈中,直到子程序编写执行完后再将地址取出。

  2. 处理递归调用:和子程序的调用类似,只是除了存储下一个指令的地址外,也将参数、区域变量等数据存入栈中。

  3. 表达式的转换与求值

  4. 二叉树遍历

  5. 图的深度优先搜索(depth first)

代码实现

使用数组实现栈

思路分析:

  1. 定义一个top变量表示栈顶,初始化为-1

  2. 入栈的操作,当有数据加入到栈时,top++,stack[top] = data;

  3. 出栈操作,保存栈顶元素然后移动栈顶并返回数据

代码实现:

运行结果:

栈的链表方式实现

当然遍历还可以实现一个Iterator迭代器,其他相似方法省略主要方法如下:

测试:

前缀、中缀、后缀表达式

  1. 前缀表达式也称为波兰表达式,前缀表达式的运算符

  2. 举例说明:`(3+4)X5-6,对应的前缀表达式就是- X + 3 4 5 6

前缀表达式的计算机求值

从左至右扫描表达式,遇到数字时,将数字压入堆栈,遇到运算符时,弹出栈顶的两个数,用运算符对他们做相应的计算(栈顶元素和次项元素),并将结果入栈;重复上述过程知道表达式的最左端,最后运算得出的值即为表达式的结果

例如:(3+4)X5-6对应的前缀表达式就是- X + 3 4 5 6,针对前缀表达式求值步骤如下:

  1. 从右至左扫描,6、5、4、3压入堆栈

  2. 遇到+运算符,因此弹出的34(3为栈顶元素,4为次项元素),计算出3+4的值,得到7再将7入栈

  3. 接下来是X运算符,因此弹出75,计算出7X5=35,将35压入栈

  4. 最后是运算符,计算出35-6的值,即29,由此得出最终结果

中缀表达式

  1. 中缀表达式的求职就是最常见的运算表达式,如(3+4)X5-6

  2. 中缀表达式的求值是我们人最熟悉的,但是对计算机来说却不好操作,因此,再计算结果时,往往会将中缀表达式转换成其他表达式来操作(一般转后缀表达式)。

后缀表达式

  1. 后缀表达式又称为逆波兰表达式,与前缀表达式相似,知识运算符位于操作数之后。

  2. (3+4)X5-6对应的后缀表达式就是 3 4 + 5 X 6 -

后缀表达式的计算机求值

从左至右扫描表达式,遇到数字时,将数字压入堆栈,遇到运算符时,弹出栈顶的两个数,用运算符对他们做相应的计算(次顶元素和栈顶元素),并将结果入栈,重复上述过程知道表达式最右端,最后运算得出的值即为表达式的结果。

例如:(3+4)X5-6对应的前缀表达式是3 4 + 5 X 6 -,针对后缀表达式求值步骤如下:

  1. 从左至右扫描,将3和4压入堆栈

  2. 遇到+运算符,因此弹出43(4为栈顶元素,3为次顶元素),计算出3+4的值,得7入栈

  3. 将5入栈

  4. 接下来是X运算符,因此弹出5和7,计算7X5=35入栈

  5. 6入栈

  6. 最后是运算符,计算出35-6得值,即29,由此得出最终结果

逆波兰计算器

完成后缀表达式的计算器

  1. 输入一个逆波兰表达式,使用栈(stack),计算其结果

  2. 支持小括号和多位数整数,因为这里我们主要讲的是数据结构,因此计算器进行简化,只支持对整数的计算。

中缀表达式转后缀表达式

通过上面的逆波兰计算器,我们会发现需要认为的将中缀表达式转为后缀表达式才能计算,很不方便,那么现在就来看一下如何将中缀表达式转为后缀表达式?

  1. 初始化两个栈:运算符栈stack1和存储中间结果的栈stack2

  2. 从左至右的扫描中缀表达式

  3. 遇到操作数时,将其压入stack2

  4. 遇到运算符时,比较其与stack1栈顶运算符的优先级

    • 如果stack1为空,或栈顶运算符为左括号“(”,则直接将此运算符入栈

    • 否则,若优先级比栈顶运算符高,也将运算符压入stack1

    • 否则,将stack1栈顶的运算符弹出并压入到stac2中,在粗转到(4-1)stack1中新的栈顶运算符相比较

  5. 遇到括号时

    1. 如果是左括号“(”,则直接压入stack1

    2. 如果是右括号“)”,则依次弹出stack1栈顶的运算符,并压入stack2,直到遇到左括号为止,此时将这一对括号丢弃

  6. 重复步骤2至5,直到表达式的最右边

  7. 将stack1中剩余的运算符依次弹出并压入stack2

  8. 依次弹出stack2中的元素并输出,结果的逆序即为中缀表达式对应的后缀表达式。

递归

递归,就是在程序运行的过程中自己调用自己。

能用递归来解决的问题必须满足两个条件:

  1. 可以通过递归调用来缩小问题规模,且新问题与原问题有着相同的形式。

  2. 必须有一个明确的结束条件。

递归能解决什么样的问题:

  1. 数学问题:8皇后问题,汉诺塔,阶乘问题,迷宫问题,斐波拉契数列等

  2. 算法问题:快排、归并排序、二分查找、分治算法等

  3. 遍历文件

迷宫问题

运行结果:

由于地图是随机生成的,所以不一定每一次有从map[1][1]map[6][5]的连通路径,有可能是死路。

八皇后问题(回溯法)

在线试玩八皇后游戏

八皇后问题,是一个古老而著名的问题,是回溯法的典型案例,该问题是国际西洋象棋手马克思.贝瑟尔于1848年提出的:在8X8格的国际象棋上拜访八个皇后,使其不能相互攻击,即:任意两个皇后都不能处于同一行、同一列或同一斜线上,问有多少中摆法。

思路分析:

  1. 第一个皇后先放第一行第一列

  2. 第二个皇后放在第二行第一列,然后判断是否可行,如果不可行,继续放在第二列,第三列,依次把所有列都放完,直到找到一个合适的位置

  3. 继续放第三个皇后,还是第一列、第二列….直到第8个皇后也能放在一个不冲突的位置,算是找到了一个正确的解

  4. 当得到一个正确的解时,在栈回退到上一个栈时,就会开始回溯,即将第一个皇后,放到第一列的所有正确解,全部得到

  5. 然后回头继续第一个皇后放第二列,后面继续循环执行1,2,3,4 的步骤

说明:理论上应该创建一个二维数组来表示棋盘,但是实际上可以通过算法,用一个以为数组即可解决问题,arr[8] = {0,4,7,5,2,6,1,1},arr的下标对应第几行,即第几个皇后,arr[i]=val,val表示第i+1个皇后,放在第i+1行的第val+1列

共有92种解法。

算法的时间复杂度

时间频度

时间平度:一个算法花费的时间于算法中语句的执行次数成正比,那个算法中语句执行次数多,它花费时间就多,一个算法中的语句执行次数称为语句频度或时间频度,记为T(n)

时间复杂度

  1. 一般情况下,算法中的基本操作语句的重复执行次数,是问题规模n的某个函数,用T(n)表示,若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n)/f(n)的极限值为不等于零的常数,则称f(n)是T(n)的同数量级函数。记作T(n)=O(f(n)),称O(f(n))为算法的渐进时间复杂度,简称时间复杂度。

  2. T(n)不同,但时间复杂度可能相同。如:

    T(n)=n2+7n+6T(n)=n^2+7n+6

    T(n)=3n2+2n+2T(n)=3n^2+2n+2

    它们的T(n)不同,但时间复杂度相同,都为:

    O(n2)O(n^2)
  3. 计算时间复杂度的方法:

  4. 用常数1代替运行时间中的所有加法常数(6替换为1)

T(n)=n2+7n+1T(n)=n^2+7n+1
  • 修改后的运行次数函数中,只保留最高阶项得:

T(n)=n2T(n)=n^2
  • 去除最高阶项的系数(系数为1去除还是1)

T(n)=n2记为:O(n2)T(n)=n^2 记为:O(n^2)

如上方法就得到了时间复杂度,其他推算,方法同理。

常见的时间复杂度

  1. 常数阶O(1)

  2. 对数阶O(log2n)

  3. 线性阶O(n)

  4. 线性对数阶O(nlog2n)

  5. 平方阶O(n2)

  6. 立方阶O(n3)

  7. k次方阶O(nk)

  8. 指数阶O(2n)

说明:

常见的算法时间复杂度由小到大依次为:O(1) < O(log2n)2</sub>n)2 </sup>) < O(n3) < O(nk) < O(2n),随着问题规模n的不断增大,上述时间复杂度不断增大,算法的执行效率越低,应尽量避免使用指数阶算法。

举例说明时间复杂度

常数阶O(1)

无论代码执行了多少行,只要是没有循环等复杂结构,那么这个代码的时间复杂度就都是O(1)

上述代码在执行的时候,它对资源的消耗并不随着某个变量的增长而增长,那么无论这类代码有长,即使有几万几十万行,都可以用O(1)来表示它的时间复杂度

对数阶O(log2n)

在while循环里面,每次都将i乘以2,乘完之后,i距离n就越来越近了。假设循环了x次之后,i就大于2了,此时这个循环就退出了,也就是说2的x次方等于n,那么x=log2n也就是说当循环了log2n次以后,这个代码就结束了,因此这个代码的时间复杂度为:O(log2n)。O(log2n)的这个2时间上是根据代码变化的,如果循环里是i=i*3,则是时间复杂度为O(log3n)

线性阶O(n)

这段代码,for循环里面的代码会执行n遍,因此它消耗的时间是随着n的变化而变化的,因此这类代码都可以用O(n)来表示它的时间复杂度。

线性对数阶O(nlogn)

线性对数阶O(nlogN)其实非常容易理解,将时间复杂度为O(logn)的代码循环N遍,那么它的时间复杂度就是n*O(logN),也就是O(nlogN)

平方阶O(n2)

平方阶O(n2)就更容易理解了,如果把O(n)的代码在嵌套循环一遍,他的时间复杂度就是O(n2),这段代码其实就是嵌套了两层n循环,他的时间复杂度就是O(n*n),即O(n2)如果将其中一层循环的n改成m,那么它的时间复杂度编程了O(m*n)

平均时间复杂度和最坏时间复杂度

  1. 平均时间复杂度是指所有可能的输入示例均以等概率出现的情况下该算法的运行时间

  2. 最坏情况下的时间复杂度称最坏时间复杂度,一般讨论的时间复杂度均是最坏情况的时间复杂度。这样做的原因是:最坏情况下的时间复杂度是算法在任何输入实力上运行时间的界限;这就保证了算法的运行时间不会比最坏情况更长。

  3. 平均时间复杂度和最坏时间复杂度是否一直和算法有关

849589-2016132192

图片来源

算法的空间复杂度

  1. 类似与时间复杂度的讨论,一个算法的空间复杂度定义该算法所消耗的存储空间,他也是一个问题规模n的函数

  2. 空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度。有的算法需要占用临时工作单元数以解决问题的规模n有关,它随着n的增大而增大,当n较大时,将占用较多的存储单元

  3. 在做算法分析时,主要讨论的是时间复杂度,从用户使用体验上看,更看重程序的执行速度,一些缓存产品(redis、memcahe)和算法(基数排序)本质就是用空间换时间

排序算法

排序是将一组数据,按照指定的顺序进行排列的过程。

排序分类:

  1. 内部排序:指将需要处理的所有数据都加载到内部存储器中进行排序。

  2. 外部排序法:数据量过大,无法全部加载到内存中,需要借助外部存储进行排序。

849589-20190860540

冒泡排序

冒泡排序的基本思想是:通过对待排序序列从前向后(从下标较小的元素开始),依次比较相临元素的值,若发现逆序则交换,使值较大的元素主键从前移向后部,就像水底的气泡一样逐渐向上冒。

因为排序的过程中,各元素不断接近自己的位置,如果一个循环比较下来没有进行交换过,就说明序列有序,因此要在排序的过程中设置一个标志判断元素是否进行过交换,从而减少不必要的比较。

849589-20171015223269197

图片来源

使用8万数据排序,所需时间为:9148毫秒

选择排序

选择排序(Selection-sort)是一种简单直观的排序算法。它的工作原理:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。

算法描述:

n个记录的直接选择排序可经过n-1趟直接选择排序得到有序结果。具体算法描述如下:

  • 初始状态:无序区为R[1..n],有序区为空;

  • 第i趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为R[1..i-1]和R(i..n)。该趟排序从当前无序区中-选出关键字最小的记录 R[k],将它与无序区的第1个记录R交换,使R[1..i]和R[i+1..n)分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区;

  • n-1趟结束,数组有序化了。

849589590-1433219824

图片来源

使用8万数据排序测试,排序所需时间:740毫秒

插入排序

插入式排序属于内部排序法,是对于与排序的元素以插入的方式找寻该元素的适当位置,以达到排序的目的。

插入排序的基本思想是:把n个待排序的元素看成一个有序表和一个无序表,开始时有序表中只包含一个元素,无序表中包含有n-1个元素,排序过程中每次从有序表中取出第一个元素,把它的排序码依次与有序表元素的排序码进行比较,将它插入到有序表中的适当位置,使之成为新的有序表。

849589-151100000

代码实现:

随机8万数据排序用时1868毫秒

希尔排序

希尔排序是希尔于1959年提出的一种排序算法。写入排序也是一种插入排序,它是简单插入排序经过改进之后的一个更高效的版本,也称为缩小增量排序。

基本思想:希尔排序是把记录按下表的一定增量分组,对于每组使用直接插入排序算法,随着增量逐渐减少,每组包含的关键词越来越多,当增量减至1时,整个文件恰好被分成一组,算法终止。

shell_sort

动画解释:

  1. 首先选择增量gap = 10/2 = 5,序列按照增量5,被划分为5组,按颜色划分分别为[8 , 3 ],[ 9 , 5 ],[1 , 4 ],[7 , 6 ],[2 , 0 ]

  2. 对上面5组分别进行插入排序,排序后序列变为 3、5、1、6、0、8、9、4、7、2,可以看到,这五组中的相对小元素都被调到前面了。

  3. 继续缩小增量gap = 5/2 =2, 整个序列被分为2[3 , 1 , 0 , 9 , 7 ][5 , 6 , 8 , 4 , 2 ]

  4. 分别对上面两组进行插入排序,排序后的序列变为0、1、3、7、 9、 2、 4、 5、 6、 8

  5. 再缩小增量gap = 2/2 = 1,然后对序列进行插入排序,即完成了整个序列的排序。

交换法希尔排序代码实现:

测试:8万数据排序所需时间:6987毫秒,很慢

使用移动法实现希尔排序

测试:8万数据排序用时:16毫秒

快速排序

快速排序是对冒泡排序的一种改进,基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后在按此方法对着两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。

quick_sort

代码实现:

测试:8万数据排序用时:25毫秒

归并排序

归并排序(merge sort)是利用归并的思想实现的排序方法,该算法采用经典的分治策略,即分而治之,所谓分:将问题分成一些小问题然后递归求解,治:治就是将分的各部分得到的答案“修补在一起”

基本思想:

1568473057712

动画演示:

84958043-37375010

实现步骤:

13u09187390183

代码实现:

测试,8万数据排序用时21毫秒

基数排序

  1. 基数排序属于分配式排序,又称桶子法(bucket sort)顾名思义,它是通过键值的各个位的值,将要排序的元素分配至桶中,达到排序的作用

  2. 基数排序法是属于稳定性的排序,技术排序法的效率高的稳定性排序法

  3. 技术排序是桶排序的扩展

  4. 技术排序是1887年赫尔曼.何乐礼发明的,实现方式:将整数按位数切割成不同的数字,然后按每个位数分别比较。

基本思想:

将所有待比较数值统一为同样的数组数位长度,数位较短的数前面补零。然后从低位开始,依次进行一次排序。这样从最低位排序移植到最高位排序完成以后,数列就变成一个有序数列。

8495397662527

使用短小的数组进行分步演示:

1568553577389

分步进行代码:

所以综上所属,根据上述推导过程将代码加上循环改进得到最终基数排序代码:

测试:随机8万数据排序所需时间24毫秒

值得注意的是由于上述基数排序在分配内存时总共有11int数组长度是一样的,所以堆内存的消耗很大,尽管速度很快,但是如果数组长度太大就会导致OOM异常。

查找算法

在Java中常用的查找方式有四种:

  1. 顺序查找

  2. 二分查找/折半查找

  3. 插值查找

  4. 斐波那契查找

线性查找算法

二分查找

二分查找算法是对有序数组而言,如果数组无序需要先对数组排序后进行查找。

思路分析:

  1. 首先确定该数组的中间下标mid = (left + right)/2

  2. 然后让需要查找的数key和array[mid]比较

  3. 如果key>array[mid]说明需要查找的数在mid的右边,这也是为什么数组需要是有序的原因

  4. 如果key<array[mid]说明需要查找的数在mid的左边

  5. 重复执行3,4步骤直到查找到或者查找完整个数组没有找到key

插值查找

  1. 插值查找算法类似于二分查找,不同的是插值算法每次从自适应mid处开始查找

  2. 将折半查找中的求mid索引的公式左变化

mid=low+high2=low+12(highlow)mid=low+keyarray[low]array[high]array[low](highlow)mid= \frac {low + high}2=low + \frac 12(high-low) \\ \Downarrow \\ mid=low + \frac {key-array[low]}{array[high]-array[low]}(high-low)

也就是把之前二分查找的代码中求mid的地方改为如下即可:

该改进是为了快速找到离目标key最近的mid。

加入需要在如下数组中查找key=3

传统求mid的方式:

而插值的计算mid的方式:

由此可见,使用mid能够快速让mid逼近key值,减少搜索次数。

斐波那契查找算法(黄金分割法)

  1. 黄金分割点是指把一条线段分割为两部分,是其中一部分于全长之比等于另一部分之比,取其前三位数字的近似值是0.618,由于按此比例设计的造型十分美丽,因此称为黄金分割,也称为中外比,这是一个神奇的数字,会带来意想不到的效果。

  2. 斐波那契数列{1,1,2,3,5,8,13,21,34,55}发现斐波那契数列的两个相邻数的比例,无限接近黄金分割值0.618

斐波那契查找原理:

斐波那契查找原理于前两种相似,仅仅改变了中间节点mid的位置,mid不再是中间值或插值得到,而是位于黄金分割点附近,即mid = low + F(k-1)-1(F表示斐波那契数列)

F(k-1)-1的理解:

1568637694479
  1. 由斐波那契数列F(k)=F(k-1)+F(k-2)的性质可以得到F[k]-1 = (F[k-1]-1) + (F(k-2)-1) + 1,该式说明:只要顺序表的长度为F[k]-1,则可以将该表分成长度为F[k-1]-1F[k-2]-1的两段,如上图所示。从而中间位置mid=low+F(k-1)-1

  2. 类似的,每一子段也可以用相同的方式分割

  3. 但是顺序表的长度m不一定刚好等于F[k]-1,所以需要将原来的顺序表长度n增加至F[k]-1。这里的k值只要能使得F[k]-1恰好大于或等于n即可。由一下代码得到,顺序表的长度增加后,新增加的位置(从n+1到F[k]-1位置),都赋为n为位置的值即可。

代码实现:

哈希表

哈希表也称为散列表,是根据关键码值(key-value)而进行直接访问的数据结构。也就是说,它通过把关键码值映射到表中的一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

hash-table

基本思路:

  1. 禁止null value插入。

  2. 根据key的hashCode值 与 0x7FFFFFFF求与后得到新的hash值,然后计算其在table中的索引下标。

  3. 在索引下标下遍历链表,查找key是否已存在,存在则更新value值。

  4. 如果不存在,判断table.count是否超过阈值,超过则重新rehash,将原元素全部拷贝到新的table中,并重新计算索引下标。rehash后,容量是以前的2倍+1的大小,这点也和HashMap不同,HashMap是2倍。

  5. 插入元素时直接插入在链表头部。

  6. 更新元素计数器。

代码实现:

待实现的方法:

二叉树

为什么需要树

数组存储方式:

优点:通过下标方式访问元素,速度快,对于有序数组,还可以使用二分查找提高效率。

缺点:如果要检索具体某个值,或者插入值(按一定顺序)会整体移动,效率低

链式存储方式:

优点:在一定程度上对数组方式有优化(比如:插入一个数值节点,只需要将擦汗如节点,链接到链表中即可,删除效率高)

缺点:在进行检索时效率较低,需要从头节点开始遍历才能找到某个值

树存储方式:

能提高数据存储,读取的效率,比如利用二叉排序树,既可以保证数据的检索速度,同时也可以保证数据的插入,删除,修改的速度。

常用术语

  1. 节点:结点的子树个数

  2. 根节点:根结点(root)是树的一个组成部分,也叫树根。所有非空的二叉树中,都有且仅有一个根结点。它是同一棵树中除本身外所有结点的祖先,没有父结点。

  3. 父节点:有子树的结点是其子树的根节点的父结点

  4. 兄弟结点(Sibling):具有同一个父结点的各结点彼此是兄弟结点

  5. 子节点:若A结点是B结点的父结点,则称B结点是A结点的子结点

  6. 叶子节点:通俗讲就是没有子节点的节点,书上说度为0的结点

  7. 节点的权

  8. 路径和路径长度:从结点n1到nk的路径为一个结点序列n1,n2,...,nk。ni是ni+1的父结点。路径所包含边的个数为路径的长度

  9. 层:规定根结点在1层,其他任一结点的层数是其父结点的层数加1

  10. 子树:有且仅有一个特定的称为根(root)的节点;当n>1时,其余节点可分为m(m>0)个互不相交的有限集T1,T2,...,Tm,其中每个集合本身又是一棵树,并且称其为根的子树

  11. 树的高度:树的所有结点中最大的度数

  12. 森林:是m(m≥0)颗互不相交的树的集合

二叉树概念

  1. 树有很多中,么个节点最多可能有两个子节点的一种形式称为二叉树

  2. 二叉树的字节带你分为左子节点和右子节点

  3. 如果该二叉树的所有叶子节点都在最后一层,并且节点总数=2n-1,n为层数,则我们称为满二叉树

  4. 如果该二叉树的所有叶子节点都在最后一层或者倒数第二层,而且最后一层的叶子节点在左边连续,倒数第二层的节点在右边连续,我们称为完全二叉树。

说到底二叉树是一棵树,其中每个节点都不能有多余两个的儿子。

二叉树遍历

二叉树的遍历方式分为三种:先序遍历、中序遍历、后续遍历

遍历方式的区分是相对父节点而言,先序遍历:先遍历父节点在遍历左子树然会遍历右子树,第二遍历父节点就是中序遍历,后续遍历同理父节点最后遍历。

为了说明遍历当然需要先创建一颗二叉树:

下面的代码插入时会使用一种规则,即待插入的节点会从根节点开始遍历,如果待插入元素比父节点小就继续往左子树寻找,如果待插入元素比父节点大就往右子树寻找,直到找到叶子节点便插入,这个规则就会使得这棵二叉树有序。

首先创建二叉树的结构:

如上泛型T需要实现Comparable接口,用于对象比较,在添加树节点时小于root节点的会在向左边插入,大于root节点的向root的右边插入,因此需要对象可以比较大小,就要实现Comparable

二叉树添加节点的代码:

递归遍历时使用到的公共方法:

先序遍历递归方式和非递归方式两种实现方式:

中序遍历递归和非递归的两种实现方式:

后续遍历的两种实现方式:

树中的其他方法:

测试:

首先创建一棵树如下图所示:

1568965248432

代码如下:

测试树方法:

二叉查找树

二叉树的一个重要应用是它在查找中的使用,使二叉树成为二叉查找树的性质也就是之前创建二叉树所使用到的那个创建规则,归纳来说:对于树中的每个节点X,它的左子树中所有关键字值小于X的关键字值,而它的右子树值大于X的关键字值。

由于上面二叉树遍历时已经使用了两种方式遍历,从遍历的方式中也可以看出,一棵排序的二叉树想要查找某个值也是轻而易举的,与上面节点只存储值的结构不同,这次定义K,V结构,由于是键值,查找便可以根据K查找V,也能凸显二叉查找树的作用。

152203lavvxvvviezkalda

查找和插入

首先创建二叉查找树的基本类结构

二叉查找树的get和put方法实现

1569067254361

二叉查找树的插入方式如上图,将递归调用前的代码想象成沿着树向下走:它会将给定的键和每个节点的键相比较并根据结果向左后者向右移动到下一个节点。然后可以将递归调用后的代码想象成沿着树向上爬。对于get()方法,这对应着一系列的返回指令(return)但是对于put()方法,这意味着重置搜索路径上每个父节点指向子节点的链接,并增加路径中么个节点的计数器的值。在一棵简单的二叉查找树中,唯一的新连接就是在最底层指向新节点的链接,重置更上层的链接可以通过比较语句来避免,同样,我们需要将路径上每个节点中的计数器的值加 1,但我们使用了更加通用的代码,使之等于节点的所有子节点的计数器之和加 1。但是基本的二叉查找树的实现通常是非递归的,使用递归遍历理解代码的工作方式。

有序性相关方法

二叉查找树的最大键、最小键以及向上取整和向下取整:

计算floor()函数如下图所示

1569067612566

排名

rank()是select()的逆方法,它会返回给定键的排名,它的实现和select()相似,如果给定键等于根节点上的键,则返回左子树中的节点总数t;如果给定的键小于根节点上的键,则返回左子树中键的排名(递归计算);如果给定的键大于根上的键,则返回t + 1(计算根节点上的键)加上右子树中键的排名(递归计算)。

1569068307219

删除

删除有三种情况:

  1. 删除节点为叶子节点,直接删除(删除72)

19655822084261
  1. 删除节点只有一个子节点:只有一个左子节点或者只有一个右子节点(删除79)

1965332581496
  1. 删除节点有两个子节点:删除的节点包含两个子节点(在该节点的右子树中寻找最小节点,替换为该节点元素,然后删除右子树元素最小的节点),看一个例子:

节点p的左子树和右子树均不为空:首先找到p的后继y,因为y一定没有左子树,所以可以删除y,并让y的父亲节点成为y的右子树的父亲节点,并用y的值代替p的值;或者可以先找到p的前驱x,x一定没有右子树,所以可以删除x,并让x的父亲节点成为y的左子树的父亲节点

196558871752535
196558581225916

具体代码:

删除最小键图:

1569068378256

删除节点图解:

1569068348571

范围查找

下图为在F和T之间进行范围查询的过程:

1569069742917

为了实现接受两个参数并能够将给定范围内的键返回给用例的keys()方法,我们修改了这段代码,将所有落在给定范围内的每个键添加到有序集合或者队列中,并跳过那些不可能含有所查找键的子树。我们不需要知道具体使用什么数据结构来实现Iterable<Key>,只要能够使用Java的foreach构造器遍历即可,所以返回Iterable<Key>

遍历

为了测试方便,我么需要将树的结构有序的打印出来,所以使用中序遍历,就不在实现其他遍历方式

测试

平衡查找树

二叉查找树算法的的运行时间取决于树的形状,而树的形状又取决于键被插入的先后顺序,最好情况、一般情况、最坏情况的二叉查找树形状如下图所示:

1569146603973

我们可以看到,当树的形状是最坏情况时,就等同于链表,尽管插入操作效率影响和链表相同,但是查找时效率却比链表要慢的多,因为每次遍历一个节点时都需要判断节点的左子树是否为空,为了保证要运行时间是稳定的对数级别,引入了平衡查找树

概念

  1. 平衡查找树也可以称之为平衡二叉树顾名思义就是在二叉查找树的基础上增加能够保证树平衡性的规则。

  2. 具有一下特点:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。平衡二叉树的常用实现方式有红黑树、AVL、替罪羊树、Treap、伸展树等。

59385048214420

为了保证二叉树的平衡在插入节点后如果左子树和右子树的高度差大于 1,则需要对树进行调整,这种操作称之为旋转,旋转操作分为单旋(又分:左旋和右旋)和双旋。

四种不平衡范型

在插入的过程中,会出现一下四种情况破坏AVL树的特性,我们可以采取如下相应的旋转。

1、左-左型:做右旋。

2、右-右型:做左旋转。

3、左-右型:先做左旋,后做右旋即称之为双旋。

4、右-左型:先做右旋,再做左旋同样也是双旋。

图解

假如初始时有两个节点构成的一棵树:

daaaef819261fdaee6

然后我们根据二叉查找树的性质依次插入数值:1,4,5,6,7,10,9,8

首先插入 1:

4afda7f4037142093661eb4d54

此时这种情况树已经不符合平衡性质,树向左倾斜,最后插入导致树失衡的1号节点位于左子树的左节点,就属于左-左型,需要右旋来调整。

60dd81383c5d4cdb70e0a675

然后插入4:

b4454a0f82e3dbd8e2c94

树还没有出现失衡,继续插入5:

6a6be56774be4089dbb

此时树不满足平衡性质,树向右倾斜,最后插入导致树失衡的5号节位于右子树的右节点,属于右-右型,需要左旋转调整。

3a141d73bae7432ddf54cb

继续插入6:

ef7e8009f398fd33042

此时树失衡,新插入的影响平衡的节点6号位于右子树的右节点,属于右-右型,需要进行左旋。

8045cd3eba70e0ca6af

继续插入7:

d64d4dc6e86c4b69654d8706

此时树失衡,属于右-右型,需要进行左旋。

10a259d9451446edea6ed69

继续插入10:

d328a3839d794d8ecf

树依然处于平衡状态,继续插入9:

d879c29dca284c08fcc4b6

可以看到树此时向右倾斜,且新插入的影响了树平衡的9号节点位于右子树的左子树,属于右-左型,右-左型仅使用一次左旋或右旋无法完成平衡,处理如下:

  1. 先对节点10进行右旋把它变成右-右型

17a6245a3c57423f2fe966
  1. 然后在进行左旋

602308d0ac7f6f3543

所以对于这种右-左型的,我们需要进行一次右旋再左旋

那么根据右-左型的处理方式,插入9号节点后需要对树先左旋在右旋 即:

885547d3d7eb8d70

同理,也存在左-右型的,例如:

85e970e05576e002d93133

影响树平衡的8号节点位于左子树的右子树,属于左-右型,对于左-右型的情况和右-左型相反,我们需要对它进行一次左旋,再右旋。

26ba7015c187341f3b

参考连接

创建树的基本结构

左旋

2183091813131

左旋过程:

avl_8138901
  1. 一次创建4,3,6,5,7,8节点插入,4首先插入所以会作为树的根节点

  2. 当插入节点8时树就不再符合平衡查找树的规则,且右子树比左子树高所以需要左旋,从而降低右子树的高度。

  3. 由于在 4 的右孩子 6 的右子树7上插入结点 8 ,使 4 的平衡因子由 1 增至 2 而失去平衡。故需进行一次逆时针旋转操作。即将 4 的右孩子 6 向左上旋转代替 4 作为根结点, 4 向左下旋转成为 6 的左子树的根结点。而原来 6 的左子树则变成 4 的右子树。

右旋

right_rotate131313

右旋过程如上图所示:

根据四种不平衡范型的过程图解,代码如下:

双旋

左右旋:

double_rotate_372740913

右左旋:

right_left_12379

增删查

put方法:

其中putBalance()方法如下:

get方法:

delete方法:

删除需要的平衡方法:

delete中需要的其他一些方法:

keys()方法:

测试用例见项目

红黑树

Red-Black Tree,又称为“红黑树”,它一种特殊的二叉查找树。红黑树的每个节点上都有存储位表示节点的颜色,可以是红(Red)或黑(Black)。

红黑树的特性: (1)每个节点或者是黑色,或者是红色。 (2)根节点是黑色。 (3)每个叶子节点(NIL)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!] (4)如果一个节点是红色的,则它的子节点必须是黑色的。 (5)从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。

注意: (1) 如上特性(3)中的叶子节点,是只为空(NIL或null)的节点。 (2) 特性(5),确保没有一条路径会比其他路径长出俩倍。因而,红黑树是相对是接近平衡的二叉树。

251730074203156

红黑树并不是一棵完美平衡二叉查找树,它有自己的行为准则,红黑树左子树和右子树的黑结点的层数是相等的,也即任意一个结点到到每个叶子结点的路径都包含数量相同的黑结点(性质5)。所以我们叫红黑树这种平衡为黑色完美平衡

红黑树一些结点的叫法如下图所示;

5klzr4xgv8

我们把正在处理(遍历)的结点叫做当前结点,如图2中的D,它的父亲叫做父结点,它的父亲的另外一个子结点叫做兄弟结点,父亲的父亲叫做祖父结点。

前面讲到红黑树能自平衡,有三种操作:左旋、右旋和变色。

  • 左旋:以某个结点作为支点(旋转结点),其右子结点变为旋转结点的父结点,右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。与平衡查找树左旋相似因为红黑树需要控制颜色。

  • 右旋:以某个结点作为支点(旋转结点),其左子结点变为旋转结点的父结点,左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。

  • 变色:结点的颜色由红变黑或由黑变红。

红黑树操作图解

红黑树查找

因为红黑树是一颗二叉平衡树,并且查找不会破坏树的平衡,所以查找跟二叉平衡树的查找无异:

  1. 从根结点开始查找,把根结点设置为当前结点;

  2. 若当前结点为空,返回null;

  3. 若当前结点不为空,用当前结点的key跟查找key作比较;

  4. 若当前结点key等于查找key,那么该key就是查找目标,返回当前结点;

  5. 若当前结点key大于查找key,把当前结点的左子结点设置为当前结点,重复步骤2;

  6. 若当前结点key小于查找key,把当前结点的右子结点设置为当前结点,重复步骤2;

如下图所示:

hsl2t3a6rl

由于红黑树总保持黑色完美平衡,所以它的查找最坏时间复杂度为O(2lgN),也即整颗树刚好红黑相隔的时候。

红黑树插入

插入操作包括两部分工作:一查找插入的位置;二插入后自平衡。查找插入的父结点很简单,跟查找操作区别不大:

  1. 从根结点开始查找;

  2. 若根结点为空,那么插入结点作为根结点,结束。

  3. 若根结点不为空,那么把根结点作为当前结点;

  4. 若当前结点为null,返回当前结点的父结点,结束。

  5. 若当前结点key等于查找key,那么该key所在结点就是插入结点,更新结点的值,结束。

  6. 若当前结点key大于查找key,把当前结点的左子结点设置为当前结点,重复步骤4;

  7. 若当前结点key小于查找key,把当前结点的右子结点设置为当前结点,重复步骤4;

如下图所示:

i2qmdjrhy5

找到插入位置后,把插入结点放到正确的位置并且新插入的节点颜色时红色。理由很简单,红色在父结点(如果存在)为黑色结点时,红黑树的黑色平衡没被破坏,不需要做自平衡操作。但如果插入结点是黑色,那么插入位置所在的子树黑色结点总是多1,必须做自平衡。

所有插入情景如下图所示:

b98wuyfpii

在开始每个情景的讲解前,我们还是先来约定下,如下图所示:

wsqois4eke

图8的字母并不代表结点Key的大小。I表示插入结点,P表示插入结点的父结点,S表示插入结点的叔叔结点,PP表示插入结点的祖父结点。

好了,下面让我们一个一个来分析每个插入的情景以其处理。

插入情景1:红黑树为空树

最简单的一种情景,直接把插入结点作为根结点就行,但注意,根据红黑树性质2:根节点是黑色。还需要把插入结点设为黑色。

处理:把插入结点作为根结点,并把结点设置为黑色。

插入情景2:插入结点的Key已存在

插入结点的Key已存在,既然红黑树总保持平衡,在插入前红黑树已经是平衡的,那么把插入结点设置为将要替代结点的颜色,再把结点的值更新就完成插入。

处理

  • 把I设为当前结点的颜色

  • 更新当前结点的值为插入结点的值

插入情景3:插入结点的父结点为黑结点

由于插入的结点是红色的,当插入结点的黑色时,并不会影响红黑树的平衡,直接插入即可,无需做自平衡。

处理:直接插入

插入情景4:插入结点的父结点为红结点

再次回想下红黑树的性质2:根结点是黑色。如果插入的父结点为红结点,那么该父结点不可能为根结点,所以插入结点总是存在祖父结点。这点很重要,因为后续的旋转操作肯定需要祖父结点的参与。

情景4又分为很多子情景,下面将进入重点部分:

插入情景4.1:叔叔结点存在并且为红结点

从红黑树性质4可以,祖父结点肯定为黑结点,因为不可以同时存在两个相连的红结点。那么此时该插入子树的红黑层数的情况是:黑红红。显然最简单的处理方式是把其改为:红黑红。如下所示。

处理:

  • 将P和S设置为黑色

  • 将PP设置为红色

  • 把PP设置为当前插入结点

b465tfffg3
fowxwzean0

可以看到,我们把PP结点设为红色了,如果PP的父结点是黑色,那么无需再做任何处理;但如果PP的父结点是红色,根据性质4,此时红黑树已不平衡了,所以还需要把PP当作新的插入结点,继续做插入操作自平衡处理,直到平衡为止。

试想下PP刚好为根结点时,那么根据性质2,我们必须把PP重新设为黑色,那么树的红黑结构变为:黑黑红。换句话说,从根结点到叶子结点的路径中,黑色结点增加了。这也是唯一一种会增加红黑树黑色结点层数的插入情景

我们还可以总结出另外一个经验:红黑树的生长是自底向上的。这点不同于普通的二叉查找树,普通的二叉查找树的生长是自顶向下的。

插入情景4.2:叔叔结点不存在或为黑结点,并且插入结点的父亲结点是祖父结点的左子结点

单纯从插入前来看,也即不算情景4.1自底向上处理时的情况,叔叔结点非红即为叶子结点(Nil)。因为如果叔叔结点为黑结点,而父结点为红结点,那么叔叔结点所在的子树的黑色结点就比父结点所在子树的多了,这不满足红黑树的性质5。后续情景同样如此,不再多做说明了。

前文说了,需要旋转操作时,肯定一边子树的结点多了或少了,需要租或借给另一边。插入显然是多的情况,那么把多的结点租给另一边子树就可以了。

插入情景4.2.1:插入结点是其父结点的左子结点

处理:

  • 将P设为黑色

  • 将PP设为红色

  • 对PP进行右旋

3sc9yv4fjg

由上图可得,左边两个红结点,右边不存在,那么一边一个刚刚好,并且因为为红色,肯定不会破坏树的平衡。

咦,可以把PP设为红色,I和P设为黑色吗?答案是可以!看过《算法:第4版》的同学可能知道,书中讲解的就是把PP设为红色,I和P设为黑色。但把PP设为红色,显然又会出现情景4.1的情况,需要自底向上处理。

插入情景4.2.2:插入结点是其父结点的右子结点

这种情景显然可以转换为情景4.2.1,如下图所示,不做过多说明了。

处理:

  • 对P进行左旋

  • 把P设置为插入结点,得到情景4.2.1

  • 进行情景4.2.1的处理

9kznwh0zl3

插入情景4.3:叔叔结点不存在或为黑结点,并且插入结点的父亲结点是祖父结点的右子结点

该情景对应情景4.2,只是方向反转,不做过多说明了,直接看图。

插入情景4.3.1:插入结点是其父结点的右子结点

处理:

  • 将P设为黑色

  • 将PP设为红色

  • 对PP进行左旋

插入情景4.3.2:插入结点是其父结点的右子结点

处理:

  • 对P进行右旋

  • 把P设置为插入结点,得到情景4.3.1

  • 进行情景4.3.1的处理

klam057cu1

好了,讲完插入的所有情景了。可能又同学会想:上面的情景举例的都是第一次插入而不包含自底向上处理的情况,那么上面所说的情景都适合自底向上的情况吗?答案是肯定的。理由很简单,但每棵子树都能自平衡,那么整棵树最终总是平衡的。好吧,在出个习题,请大家拿出笔和纸画下试试(请务必动手画下,加深印象):

红黑树删除

红黑树插入已经够复杂了,但删除更复杂,也是红黑树最复杂的操作了。但稳住,胜利的曙光就在前面了!

红黑树的删除操作也包括两部分工作:一查找目标结点;而删除后自平衡。查找目标结点显然可以复用查找操作,当不存在目标结点时,忽略本次操作;当存在目标结点时,删除后就得做自平衡处理了。删除了结点后我们还需要找结点来替代删除结点的位置,不然子树跟父辈结点断开了,除非删除结点刚好没子结点,那么就不需要替代。

二叉树删除结点找替代结点有3种情情景:

  • 情景1:若删除结点无子结点,直接删除

  • 情景2:若删除结点只有一个子结点,用子结点替换删除结点

  • 情景3:若删除结点有两个子结点,用后继结点(大于删除结点的最小结点)替换删除结点

补充说明下,情景3的后继结点是大于删除结点的最小结点,也是删除结点的右子树种最右结点。那么可以拿前继结点(删除结点的左子树最左结点)替代吗?可以的。但习惯上大多都是拿后继结点来替代,后文的讲解也是用后继结点来替代。另外告诉大家一种找前继和后继结点的直观的方法(不知为何没人提过,大家都知道?):把二叉树所有结点投射在X轴上,所有结点都是从左到右排好序的,所有目标结点的前后结点就是对应前继和后继结点。如下图所示。

zwy37abbrw

接下来,讲一个重要的思路:删除结点被替代后,在不考虑结点的键值的情况下,对于树来说,可以认为删除的是替代结点!话很苍白,我们看下图。在不看键值对的情况下,图中红黑树最终结果是删除了Q所在位置的结点!这种思路非常重要,大大简化了后文讲解红黑树删除的情景!

bsbpol9hzx

基于此,上面所说的3种二叉树的删除情景可以相互转换并且最终都是转换为情景1!

  • 情景2:删除结点用其唯一的子结点替换,子结点替换为删除结点后,可以认为删除的是子结点,若子结点又有两个子结点,那么相当于转换为情景3,一直自顶向下转换,总是能转换为情景1。(对于红黑树来说,根据性质5.1,只存在一个子结点的结点肯定在树末了)

  • 情景3:删除结点用后继结点(肯定不存在左结点),如果后继结点有右子结点,那么相当于转换为情景2,否则转为为情景1。

二叉树删除结点情景关系图如下图所示:

plg763eg42

综上所述,删除操作删除的结点可以看作删除替代结点,而替代结点最后总是在树末。有了这结论,我们讨论的删除红黑树的情景就少了很多,因为我们只考虑删除树末结点的情景了。

同样的,我们也是先来总体看下删除操作的所有情景,如下图所示。

372p26y12o

即使简化了还是有9种情景!但跟插入操作一样,存在左右对称的情景,只是方向变了,没有本质区别。同样的,我们还是来约定下,如图所示:

wcrztmlzai

图中的字母并不代表结点Key的大小。R表示替代结点,P表示替代结点的父结点,S表示替代结点的兄弟结点,SL表示兄弟结点的左子结点,SR表示兄弟结点的右子结点。灰色结点表示它可以是红色也可以是黑色。

值得特别提醒的是,R是即将被替换到删除结点的位置的替代结点,在删除前,它还在原来所在位置参与树的子平衡,平衡后再替换到删除结点的位置,才算删除完成。

万事具备,我们进入最后的也是最难的讲解。

删除情景1:替换结点是红色结点

我们把替换结点换到了删除结点的位置时,由于替换结点时红色,删除也了不会影响红黑树的平衡,只要把替换结点的颜色设为删除的结点的颜色即可重新平衡。

处理:颜色变为删除结点的颜色

删除情景2:替换结点是黑结点

当替换结点是黑色时,我们就不得不进行自平衡处理了。我们必须还得考虑替换结点是其父结点的左子结点还是右子结点,来做不同的旋转操作,使树重新平衡。

删除情景2.1:替换结点是其父结点的左子结点

删除情景2.1.1:替换结点的兄弟结点是红结点

若兄弟结点是红结点,那么根据性质4,兄弟结点的父结点和子结点肯定为黑色,不会有其他子情景,我们按图21处理,得到删除情景2.1.2.3(后续讲解,这里先记住,此时R仍然是替代结点,它的新的兄弟结点SL和兄弟结点的子结点都是黑色)。

处理:

  • 将S设为黑色

  • 将P设为红色

  • 对P进行左旋,得到情景2.1.2.3

  • 进行情景2.1.2.3的处理

1oslwf8jey

删除情景2.1.2:替换结点的兄弟结点是黑结点

当兄弟结点为黑时,其父结点和子结点的具体颜色也无法确定(如果也不考虑自底向上的情况,子结点非红即为叶子结点Nil,Nil结点为黑结点),此时又得考虑多种子情景。

删除情景2.1.2.1:替换结点的兄弟结点的右子结点是红结点,左子结点任意颜色

即将删除的左子树的一个黑色结点,显然左子树的黑色结点少1了,然而右子树又又红色结点,那么我们直接向右子树“借”个红结点来补充黑结点就好啦,此时肯定需要用旋转处理了。如图22所示。

处理:

  • 将S的颜色设为P的颜色

  • 将P设为黑色

  • 将SR设为黑色

  • 对P进行左旋

av4y38axjh

平衡后的图怎么不满足红黑树的性质?前文提醒过,R是即将替换的,它还参与树的自平衡,平衡后再替换到删除结点的位置,所以R最终可以看作是删除的。另外图2.1.2.1是考虑到第一次替换和自底向上处理的情况,如果只考虑第一次替换的情况,根据红黑树性质,SL肯定是红色或为Nil,所以最终结果树是平衡的。如果是自底向上处理的情况,同样,每棵子树都保持平衡状态,最终整棵树肯定是平衡的。后续的情景同理,不做过多说明了。

删除情景2.1.2.2:替换结点的兄弟结点的右子结点为黑结点,左子结点为红结点

兄弟结点所在的子树有红结点,我们总是可以向兄弟子树借个红结点过来,显然该情景可以转换为情景2.1.2.1。图如下所示:

处理:

  • 将S设为红色

  • 将SL设为黑色

  • 对S进行右旋,得到情景2.1.2.1

  • 进行情景2.1.2.1的处理

删除情景2.1.2.3:替换结点的兄弟结点的子结点都为黑结点

好了,此次兄弟子树都没红结点“借”了,兄弟帮忙不了,找父母呗,这种情景我们把兄弟结点设为红色,再把父结点当作替代结点,自底向上处理,去找父结点的兄弟结点去“借”。但为什么需要把兄弟结点设为红色呢?显然是为了在P所在的子树中保证平衡(R即将删除,少了一个黑色结点,子树也需要少一个),后续的平衡工作交给父辈们考虑了,还是那句,当每棵子树都保持平衡时,最终整棵总是平衡的。

处理:

  • 将S设为红色

  • 把P作为新的替换结点

  • 重新进行删除结点情景处理

9lho28rnpx

删除情景2.2:替换结点是其父结点的右子结点

好啦,右边的操作也是方向相反,不做过多说明了,相信理解了删除情景2.1后,肯定可以理解2.2。

删除情景2.2.1:替换结点的兄弟结点是红结点

处理:

  • 将S设为黑色

  • 将P设为红色

  • 对P进行右旋,得到情景2.2.2.3

  • 进行情景2.2.2.3的处理

wf32vmh0uy

删除情景2.2.2:替换结点的兄弟结点是黑结点

删除情景2.2.2.1:替换结点的兄弟结点的左子结点是红结点,右子结点任意颜色

处理:

  • 将S的颜色设为P的颜色

  • 将P设为黑色

  • 将SL设为黑色

  • 对P进行右旋

6okc2dq0mq

删除情景2.2.2.2:替换结点的兄弟结点的左子结点为黑结点,右子结点为红结点

处理:

  • 将S设为红色

  • 将SR设为黑色

  • 对S进行左旋,得到情景2.2.2.1

  • 进行情景2.2.2.1的处理

su8760oy4j

删除情景2.2.2.3:替换结点的兄弟结点的子结点都为黑结点

处理:

  • 将S设为红色

  • 把P作为新的替换结点

  • 重新进行删除结点情景处理

paok423nu3

综上,红黑树删除后自平衡的处理可以总结为:

  1. 自己能搞定的自消化(情景1)

  2. 自己不能搞定的叫兄弟帮忙(除了情景1、情景2.1.2.3和情景2.2.2.3)

  3. 兄弟都帮忙不了的,通过父母,找远方亲戚(情景2.1.2.3和情景2.2.2.3)

哈哈,是不是跟现实中很像,当我们有困难时,首先先自己解决,自己无力了总兄弟姐妹帮忙,如果连兄弟姐妹都帮不上,再去找远方的亲戚了。

引用连接

红黑树代码实现

基本结构:

左旋:

右旋

颜色变换:

插入方法

查找方法

删除

删除方法中用到的其他方法

范围查询

哈夫曼树

  1. 给定n个权值作为n个叶子节点,构造一棵二叉树,若该树的带权路径长度(wpl)达到最小,称这样的二叉树为最优二叉树,也成为哈夫曼树(Huffman Tree)

  2. 哈夫曼树是带权路径长度最短的树,权值较大的节点离根较近。

概念解释:

  1. 路径和长度:在一棵树中,从一个节点往下可以达到的孩子或孙子节点之间的通路,称之为路径。通路中分支的数目称为路径长度。若规定根节点的层数为1,则从根节点到第L层节点的路径长度为L-1。

  2. 节点的权及带权路径长度:若将树中的节点赋给一个有着某种含义的数值,则这个数值称为该节点的权,节点的带权路径长度为:从根节点到该节点之间的路径长度与该节点的权的乘积。

  3. 树的带权路径长度:树的带权路径长度规定为所有鞋子节点的带权路径长度之和,记为WPL,权值越大的节点离根节点越近,这样的二叉树才是最优二叉树。

  4. WPL最小的就是哈夫曼树。

1569584840437

哈夫曼树创建思路图解

例如使用数据{13, 7, 8, 3, 29, 6, 1}构建一棵哈夫曼树

步骤:

  1. 从小到大进行排序,每一个数据都是一个节点,每个节点都可以看作一棵最简单的二叉树

  2. 取出根节点权值最小的两棵二叉树

  3. 组成一棵新的二叉树,该新的二叉树的根节点的权值是前面两棵二叉树根节点权值的和

  4. 在将两棵新的二叉树,以根节点的权值大小再次排序,不断重复1,2,3,4的步骤直到数列中,所有的数据都被处理,就得到一棵哈夫曼树。

代码实现

哈夫曼编码

  1. 哈夫曼编码是一种编码方式,属于一种程序算法

  2. 哈夫曼编码是哈夫曼树在电子通信中的经典应用之一。

  3. 哈夫曼编码广泛的应用于数据文件压缩。其压缩率通常在20%~90%之前

  4. 哈夫曼是可变字长编码(VLC)的一种。Huffman于1952年提出一种编码方法,称为最佳编码。

编码示例:

  1. 对字符串I'm not afraid when the rain won't stop进行编码

  2. 各个字符对应的个数

    1569596376070

  3. 按照上面字符出现的次数构建一棵哈夫曼树,将次数作为权值

  4. 根绝哈夫曼树,给各个字符规定编码,向左的路径为0,向右的路径为1

  5. 将字符串中各个字符对应的哈夫曼编码拼接在一起,构成句子的哈夫曼编码

代码实现

基本结构

编码

解码

测试

多路查找树

二叉树的操作效率较高,但是也存在问题

351812564,34444298

树的高度为:4

节点个数:24-1 = 15

二叉树需要加载到内存,如果二叉树的节点少则不会有什么问题,但是如果二叉树的节点很多(比如有1亿节点)就会存在如下问题:

  1. 在构建二叉树时,需要多次进行I/O操作(海里数据存储在数据库或文件中),节点海量,构建二叉树时,速度有影响

  2. 节点海量,也会造成树的高度很大,降低操作速度

为了解决上述问题提出多叉树

  1. 二叉树中,每个节点有数据项,最多有两个子节点。如果允许每个节点可以有更多的数据项或更多的子节点,就是多叉树(multiway tree)

  2. 多叉树通过重新组织节点,减少了数得高度,能对二叉树进行优化

  3. 对多叉树中的2-3树举例如下:

1570954543713

2-3树

  1. 2-3树的所有叶子节点都在同一层

  2. 有两个子节点的节点叫二节点,二节点要么没有子节点,要么有两个子节点

  3. 有三个子节点的节点叫三节点,三节点要么没有子节点,要么有三个子节点

  4. 2-3树是由二节点和三节点构成的树

插入规则:

  1. 2-3树的所有叶子节点都在同一层

  2. 有两个子节点的节点叫二节点

  3. 有三个子节点的节点叫三节点

  4. 当按照规则插入一个数到某个节点时,不能满足上面三个要求就需要拆分,先向上拆,如果上层满,则拆本层,拆后任然需要满足上面三个条件

  5. 对于三节点的子树的值大小仍然遵守BST二叉排序树的规则

插入节点情况

本节内容来源于:算法 英文版第4版 Robert Sedgewick译本

向2- 结点中插入新键:

要在2-3树中插入一个新结点,我们可以和二叉查找树 一样先进行一次未命中的查找,然后把新结点挂在树的底部。插入K但这样的话树无法保持完美平衡性。 2-3树的主要 原因就在于它能够在插入后继续保持平衡。如果未命中的查 找结束于一个2-结点,事情就好办了 :我们只要把这个2-结点替换为一个3-结点,将要插入的键保存在其中即可(如下图所示)。如果未命中的查找结束于一个3-结点,事情就要麻烦一些。

1570957495292

向一棵只含有一个3节点的树中插入新键

在考虑一般情况之前,先假设我们需要向一棵只含有一 个 3-结点的树中插人一个新键。这棵树中有两个键,所以在它唯一的结点中已经没有可插人新键的空间了。为了将新键插人,我们先临时将新键存入该结 点中,使之成为一个4-结点。它很自然地扩展了以前的结点并含有3个键和4 条链接。创建一个4- 结点很方便,因为很容易将它转换为一棵由3个2-结点组成的2-3树•,其中一个结点(根 ) 含有中键,一个结点含有3个键中的最小者(和根结点的左链接相连) ,一个结点含有3个键中的最大者(和根结点的右链接相连)。这棵树既是一棵含有3个结点的二叉查找树,同时也是一棵完美平衡的2-3 树,因为其中所有的空链接到根结点的距离都相等。插人前树的高度为0,插人后树的高度为1。 这个例子很简单但却值得学习,它说明了 2-3树是如何生长的,如图3.3.4所示:

1570957601439

向一个父节点为2节点的三节点中插入新键

作为第二轮热身,假设未命中的查找结束于一个3-结点,而它的父结点是一个2-结点。在这 种情况下我们需要在维持树的完美平衡的前提下为新键腾出空间。我们先像刚才一样构造一个临时的4-结点并将其分解,但此时我们不会为中键创建一个新结点,而是将其移动至原来的父结点中。 你可以将这次转换看成将指向原3- 结点的一条链接替换为新父结点中的原中键左右两边的两条链 接,并分别指向两个新的2-结点。根据我们的假设,父结点中是有空间的:父结点是一个2-结点(一 个键两条链接),插人之后变为了一个3-结点(两个键3 条链接)。另外,这次转换也并不影响(完 美平衡的)2-3树的主要性质。树仍然是有序的,因为中键被移动到父结点中去了;树仍然是完美 平衡的,插入后所有的空链接到根结点的距离仍然相同。请确认你完全理解了这次转换—— 它是2-3 树的动态变化的核心,其过程如图3.3.5所示。

1570957684489

向一 个父结点为3-结点的3-结点中插入新键

现在假设未命中的查找结束于一个父结点为3- 结点的结点。我们再次和刚才一样构造一个 临时的4-结点并分解它,然后将它的中键插入它的父结点中。但父结点也是一个3-结点,因此 我们再用这个中键构造一个新的临时4-结点,然后在这个结点上进行相同的变换,即分解这个 父结点并将它的中键插入到它的父结点中去。推广到一般情况,我们就这样一直向上不断分解临 时的4-结点并将中键插入更高层的父结点,直至遇到一个2- 结点并将它替换为一个不需要继续 分解的3-结点,或者是到达3-结点的根。该过程如图3.3.6所示。

1570957742601

分解根结点

如果从插入结点到根结点的路径上全都是3-结点,我们的根结点最终变成一个临时的4-结点。 此时我们可以按照向一棵只有一个3-结点的树中插入新键的方法处理这个问题。我们将临时的4- 结点分解为3个 2-结点,使得树高加1 , 如图3.3.7所示。请注意,这次最后的变换仍然保持了树 的完美平衡性,因为它变换的是根结点。

1570957806213

局部变换

将一个4-结点分解为一棵2-3树可能有6种情况,都总结在了图3.3.8中。这个4-结点可能是 根结点,可能是一个2-结点的左子结点或者右子结点,也可能是一个3-结点的左子结点、中子结点或者右子结点。2-3树插人算法的根本在于这些变换都是局部的:除了相关的结点和链接之外不必修改或者检查树的其他部分。每次变换中,变更的链接数量不会超过一个很小的常数。需要特别 指出的是,不光是在树的底部,树中的任何地方只要符合相应的模式,变换都可以进行。每个变换 都会将4-结点中的一个键送入它的父结点中,并重构相应的链接而不必涉及树的其他部分。

1570957868959

全局性质

这些局部变换不会影响树的全局有序性和平衡性:任意空链接到根结点的路径长度都是相等 的。作为参考,图 3.3.9所示的是当一个4-结点是一个3-结点的中子结点时的完整变换情况。如果在变换之前根结点到所有空链接的路径长度为h,那么变换之后该长度仍然为h。所有的变换都 具有这个性质,即使是将一个4-结点分解为两个2-结点并将其父结点由2-结点变为3-结点,或 是由3-结点变为一个临时的4-结点时也是如此。当根结点被分解为3个 2-结点时,所有空链接到根结点的路径长度才会加1。

1570957939641

和标准的二叉查找树由上向下生长不同,2-3树的生长是由下向上的。如果你花点时间仔细研 究一下图3.3.10,就能很好地理解2-3树的构造方式。它给出了我们的标准索引测试用例中产生的 一系列2-3树,以及一系列由同一组键按照升序依次插入到树中时所产生的所有2-3树。还记得在 二叉查找树中,按照升序插入10个键会得到高度为9 的一棵最差查找树吗?如果使用2-3树,树 的局度是2。

以上的文字已经足够为我们定义一个使用2-3树作为数据结构的符号表的实现了。2-3树的分 析和二叉查找树的分析大不相同,因为我们主要感兴趣的是最坏情况下的性能,而非一般情况(这种情况下我们会用随机键模型分析预期的性能)。在符号表的实现中,一般我们无法控制用例会按 照什么顺序向表中插人键,因此对最坏情况的分析是唯一能够提供性能保证的办法。

1570957995565

B树

B-tree树即B树],B即Balanced,平衡的意思。有人把B-tree翻译成B-树,容易让人产生误解。会以为B-树是一种树,而B树又是另一种树。实际上,B-tree就是指的B树。

B树通过重新组织节点,降低树的高度,并减少I/O操作次数来提升效率

1570955917370
  1. 如图B树通过重新组织节点,降低了树的高度

  2. 文件系统及数据库系统的设计者利用了磁盘预读原理,讲一个节点的大小设计等于一个页(一页大小通常为4K),这样每个节点只需要依次I/O就可以完全载入

  3. 将树的度M设置为1024,在600亿个元素中最多需要4次I/O操作就可以读取到想要的元素,B树广泛应用于文件存储系统以及数据库系统中

前面已经介绍了2-3树,它就是B树(英语:B-tree 也写成B-树),这里我们再做一个说明,我们在学习Mysql时,经常听到说某种类型的索引是基于B树或者B+树的,如图:

1570969833537

B树的说明:

  1. B树的阶:节点的最多子节点个数。比如2-3树的阶是3,2-3-4树的阶是4

  2. B-树的搜索,从根结点开始,对结点内的关键字(有序)序列进行二分查找,如果命中则结束,否则进入查询关键字所属范围的儿子结点;重复,直到所对应的儿子指针为空,或已经是叶子结点

  3. 关键字集合分布在整颗树中, 即叶子节点和非叶子节点都存放数据.

  4. 搜索有可能在非叶子结点结束

  5. 其搜索性能等价于在关键字全集内做一次二分查找

代码实现

概念

图(graph)是数据结构和算法学中最强大的框架之一(或许没有之一)。图几乎可以用来表现所有类型的结构或系统,从交通网络到通信网络,从下棋游戏到最优流程,从任务分配到人际交互网络,图都有广阔的用武之地。

而要进入图论的世界,清晰、准确的基本概念是必须的前提和基础。下面对其最核心和最重要的概念作出说明。关于图论的概念异乎寻常的多,先掌握下面最核心最重要的,足够开展一些工作了,其它的再到实践中不断去理解和熟悉吧。

图(graph)并不是指图形图像(image)或地图(map)。通常来说,我们会把图视为一种由“顶点”组成的抽象网络,网络中的各顶点可以通过“边”实现彼此的连接,表示两顶点有关联。注意上面图定义中的两个关键字,由此得到我们最基础最基本的2个概念,顶点(vertex)和边(edge)。

img

顶点:

上图中红色节点就是顶点,表示某个事物或对象。由于图的术语没有标准化,因此,称顶点为点、节点、结点、端点等都是可以的。叫什么无所谓,理解是什么才是关键。

边:

上图中顶点之间黑色的线条就是边,表示事物与事物之间的关系。需要注意的是边表示的是顶点之间的逻辑关系,粗细长短都无所谓的。包括上面的顶点也一样,表示逻辑事物或对象,画的时候大小形状都无所谓。

图的表示方式

图的表示方式有两种:二维数组表示(邻接矩阵),链表表示(邻接表)

邻接矩阵

​ 邻接矩阵是表示图形中顶点之间相邻关系的矩阵,对于n个顶点的图而言,矩阵是的row和col表示的是1....n个点。

1571028041708

邻接表

  1. 邻接矩阵需要为每个顶点都分配n个边的空间,其实有很多边都是不存在,会造成空间的一定损失。

  2. 邻接表的实只心存在的边,不关心不存在的边接表由数组链表组

1571028357400

说明:

  1. 标号为0的结点的相关联的结点为 1 2 3 4

  2. 标号为1的结点的相关联结点为0 4,

  3. 标号为2的结点相关联的结点为 0 4 5

  4. ....

图的深度优先遍历

使用先前的MatrixGraph类稍作修改:

所谓图的遍历,即是对结点的访问。一个图有那么多个结点,如何遍历这些结点,需要特定策略,一般有两种访问策略:

  1. 深度优先遍历

  2. 广度优先遍历

图的深度优先搜索(Depth First Search简称:DFS)

  1. 深度优先遍历,从初始访问结点出发,初始访问结点可能有多个邻接结点,深度优先遍历的策略就是首先访问第一个邻接结点,然后再以这个被访问的邻接结点作为初始结点,访问它的第一个邻接结点, 可以这样理解:每次都在访问完当前结点后首先访问当前结点的第一个邻接结点

  2. 我们可以看到,这样的访问策略是优先往纵向挖掘深入,而不是对一个结点的所有邻接结点进行横向访问。

  3. 显然,深度优先搜索是一个递归的过程

步骤:

  1. 访问初始结点v,并标记结点v为已访问。

  2. 查找结点v的第一个邻接结点w。

  3. 若w存在,则继续执行4,如果w不存在,则回到第1步,将从v的下一个结点继续。

  4. 若w未被访问,对w进行深度优先遍历递归(即把w当做另一个v,然后进行步骤123)。

  5. 查找结点v的w邻接结点的下一个邻接结点,转到步骤3。

图的广度优先遍历

图的广度优先搜索(Broad First Search) 。

类似于一个分层搜索的过程,广度优先遍历需要使用一个队列以保持访问过的结点的顺序,以便按这个顺序来访问这些结点的邻接结

步骤

  1. 访问初始结点v并标记结点v为已访问。

  2. 结点v入队列

  3. 当队列非空时,继续执行,否则算法结束。

  4. 出队列,取得队头结点u。

  5. 查找结点u的第一个邻接结点w。

  6. 若结点u的邻接结点w不存在,则转到步骤3;否则循环执行以下三个步骤:

    • 6.1若结点w尚未被访问,则访问结点w并标记为已访问。

    • 6.2结点w入队列

    • 6.3 查找结点u的继w邻接结点后的下一个邻接结点w,转到步骤6。

Last updated

Was this helpful?