🔐 数据结构和算法 [ONGOING]
!本篇文章过于久远,其中观点和内容可能已经不准确,请见谅!~
想分享的是数据结构和算法的非常基础的知识,看了很多的文章,自己消化和理解的东西,花了非常多时间思考其中的细节,共勉~~
编程的过程中,接触了很多的业务和上层实现,但是很多时候接触比较底层、比较通用的算法和数据处理的时候,偶尔会一头雾水,想学,但是一直没有了解到足够的意义。虽然每个问题都能当场搜索,但是这个能力的问题,不能用搜索引擎来顶替。所以计划好好的接触一下基本的数据结构和算法思想的基础,至少每一种数据结构的不同存储方法及有关算法进行分析比较,不求精通,至少能聊。
下定决心来学习的背景是,看了一些算法思想之后,发现解决问题的思路不同了,之前内心强调无论好坏问题总能解决,现在更强调怎么更好的解决问题,就像是海边捡了一路的石子,突然发现还有贝壳的感觉。所以对于我来说,我的能力体现在我已经有能用手边的东西实现目标的经验,如果想要追求更高的目标,我就必须学会怎么更好的解决问题的经验,这是后续的第一块垫脚石,绕不过去的。
算法,我的理解就是怎么解决问题,在程序上是根据问题给定的输入,都能给出解决问题的输出。当然有很严格的定义,但是本质上就是描述一个根据输入,给出输出的计算过程。
对于算法比较重要的属性有很多,比如正确性、健壮性、可读性等。但是我们更看重的就是花销,能够用多大的时间和空间解决一个问题是衡量一个算法的好坏。毕竟一个计算过程,一个问题的解决,生产力的追求就是效率的提升,对于算法就是高效。
时间复杂度和空间复杂度的概念,描述的是一个算法在时间成本和空间成本的好坏,这就是算法的度量方法,可以比较不同算法的效率。
对于计算机而言,计算对于 CPU 来说就需要花费时间得出答案,计算越多,所需要的时间越多,实际上算法所用时间可以转换为算法执行的基本操作次数,所以很直观的就是不同的算法只需要数一下计算过程中的计算量即可表示出时间复杂度,而实际上确实是这样的,算法中涉及到多少计算量就是时间复杂度。
描述一个算法的复杂度,需要先定义问题的规模
n
,所用的时间复杂度记为 O
。比如
O(n)
表示问题规模 n
每增长 1,所用时间增长 1。很清楚的表达出随着问题规模的增大,解决问题的时间的增长程度。function calcSum(nums) {let sum = 0; // 1 次赋值 => O(1)let i = 0; // 1 次赋值 => O(1)// 循环 => O(n)while(i < nums.length){ //判断算作循环体内部 => O(1)sum = sum + nums[i]; // 求和、赋值 => O(2)i = i + 1; // 求和、赋值 => O(2)}return sum; // 函数返回不考虑}
上面的计算次数按照下面的程序步数
- 注释、声明语句、函数调用语句不计算运算过程;
- 表达式、赋值语句、函数执行语句、转移语句、动态存储管理语句、简单判断(程序运算步数记为 1);
- 求和规则:连续的顺序运算,总的运算步数为分开步数相加
- 乘积规则:运算中的运算,总的运算步数为分开计算后相乘
可以得到:
1 + 1 + n × (1 + 2 + 2)
即 5n + 2
的运算量。(ps: 在很多其他地方可能会忽略循环条件判断,或者将求和和赋值作为一次运算,从而得出
3n + 2
、2n + 2
、n + 1
的多种说法,其实都是没问题的。)在描述这个复杂度的时候,成本上更关心足够大的规模问题,更偏重于体现出增长趋势。所以在只考虑增长趋势的时候,并不一定要准确,能够反映在 n 趋向于很大的时候,能够表示数量级即可。所以就有:
- 常数系数可以略去:
O(n)
=>O(c × n)
,c
是常数 - 低次项可以略去:
O(n^a + n^b)
=>O(n^b)
,0 < a < b
常数和系数并不是关键,很多时候有些会被忽略不计算,所以上面的程序的复杂度
O(5n + 2)
可以记为 O(n)
。上面对于运算量的估计的思想,类似于下面这个图,悲观的复杂度趋势图和实际的运算量,不准确,而且开始的阶段与后续相反,但是仍然虚线部分体现出算法时间随着规模的上升的最坏趋势,这个趋势和量级是我们需要的一个复杂度描述:
常见的复杂度量级有(时间复杂度按数量级递增排列):
name | 英文名 | 中文名 | 示例场景 |
---|---|---|---|
O(1) | Constant Complexity | 常数复杂度 | 与问题规模无关,常数次解决 |
O(log n) | Logarithmic Complexity | 对数复杂度 | 每次循环能将规模折半的二分法 |
O(n) | Linear Complexity | 线性时间复杂度 | 单次首尾遍历 |
O(nlog n) | Linear Logarithmic Complexity | 线性对数时间复杂度 | 单次遍历再嵌套规模折半方法 |
O(n^2) | N square Complexity | 平方 | 双重遍历嵌套 |
O(n^3) | N square Complexity | 立方 | 三重遍历嵌套 |
O(2^n) | Exponential Growth | 指数(几乎认为不可解决) | 集合的全部子集问题 |
O(n!) | Factorial | 阶乘 | 递归嵌套线性复杂度 |
这些复杂度对应的规模趋势:
能看到这个差距了,但是不是很形象。在举一个这种对比很明显的极端例子:
统计全国人口数据的排序,数据量级在
10^9
,如果使用冒泡排序的 O(n^2)
复杂度需要 n^18
次运算,在普通计算机上(10^9 flops
)需要至少 10^9 秒 = 30 年
才能解决,而使用归并排序 O(nlogn)
只需要 30 × 10^9
次运算,普通计算机只需要 30秒
就能解决。所以一个好的算法在业务中对性能的提升无疑是巨大的,了解使用算法也是很有必要。
算法的复杂度有的算法仅仅与输入的规模有关,比如经典冒泡的 O(n^2),无论输入的序列怎样,顺序和乱序序列的运算量是相同的。但是更多时候的算法是输入敏感的(InputSensitive),比如相对智能的算法会判断,如果序列已经排序就应该去除交换、赋值等耗时操作了,这样根据输入能得出最好复杂度和最坏复杂度,比如插入排序的 最好复杂度 O(n)和最坏复杂度 O(n^2),虽然总体复杂度跟规模关系依然是 O(n^2),但是概率上来说比经典冒泡要好很多。
所以复杂度分布概率也是一个算法的优劣考量,很多时候虽然最好最坏复杂度无法改变,但是复杂度在这期间的概率分布向最好偏移也是很好的,平均复杂度也会根据这个概率分布相关。(比如如果能保证输入序列只有一对相邻的数字未排序,概率上来说冒泡排序也是可接受的)
- 平均复杂度是在假设输入实例是按照概率分布的,比如平均或者加权时间复杂度,描述了任意给定的输入实例的事件的可能复杂度,是独立的,不强调输入的连续和相关性。
- 分摊时间复杂度强调的是连续的足够多次的输入实例进行总体分析,总成本分摊到单次的场景,更强调连续多次。
在平均复杂度不好分析的时候,或者一个算法内部不确定调用条件的时候。可以从假设算法整体被大规模调用来看待,这个时候调用条件能确定,从而可以得出在不同的调用时不同的算法复杂度,然后分摊给每一次调用,得到分摊时间复杂度,这样的方法还是很常见的。
比如:
数组的扩容操作
、数组的插入操作
,在单次分析的时候不太容易断言复杂度,或者复杂度与输入有关,这个时候让输入的变量在大量的连续调用的时候,能够得到不同的复杂度,分摊后就能获得复杂度的分析了。由于复杂度是粗略的估算,每个运算都视为固定时间,不然整个估算也无法成立。但是实际上程序的一条基本操作语句,按照 cpu 时间非常短,但也并不总是相同,例如比较、代数运算、二进制运算、赋值、创建删除变量等所用时间并不相同,其中真实的创建删除变量和比较操作要差 100 倍的时间,所以在某些算法里面,即使复杂度相同,可能在细粒度的运算上好一些,导致整体算法还是更优的。
这个概念跟字面不太相同,看到这个名字感觉应该是最好、最坏复杂度差不多表示稳定性好,实际上并不是。输入的相等元素,排序后依然保持前后。相等数据的交换是稳定性的体现,也就是每个元素的移动都是有趋势的。稳定性的好处是对于大规模的问题,能降低一定的开销。(比如基数排序)
空间复杂度一般在讨论算法的时候相比没那么重要,但仍然是一个重要的度量手段,表示一个算法所需要的内存在规模上升时候的趋势。
但其实空间复杂度比时间复杂度简单很多,一般程序中不会出现太多创建变量的情况,所以比较常见的就是
O(1)、O(n)、O(n²)
,分别例子比如初始化:仅有固定几个变量、长度为 n
的数组、长度为 n×n
的二维数组。PS: 一个可能有争议的地方就在于传入参数是否算作空间复杂度,一般是不算的,只计算算法内部创建的变量数。
排序和搜索是算法和数据结构中很常见的需求,常见的排序算法有:
排序方法 | 平均时间复杂度 | 空间复杂度 | 稳定性 | 复杂性 |
---|---|---|---|---|
冒泡排序 | O(n2) | O(1) | 稳定 | 简单 |
直接插入排序 | O(n2) | O(1) | 稳定 | 简单 |
直接选择排序 | O(n2) | O(1) | 不稳定 | 简单 |
希尔排序 | O(nlog2n) | O(1) | 不稳定 | 较复杂 |
堆排序 | O(nlog2n) | O(1) | 不稳定 | 较复杂 |
快速排序 | O(nlog2n) | O(nlog2n) | 不稳定 | 较复杂 |
归并排序 | O(nlog2n) | O(n) | 稳定 | 较复杂 |
基数排序 | O(d(n+r)) | O(n+r)O(n+r) | 稳定 | 较复杂 |
常见的搜索算法有:
查找 | 平均时间复杂度 | 查找条件 |
---|---|---|
顺序查找 | O(n) | 无序或有序队列 |
二分查找(折半查找) | O(logn) | 有序数组 |
二叉排序树查找 | O(logn) | 二叉排序树 |
哈希表法(散列表) | O(1) | 先创建哈希表(散列表) |
分块查找 | O(logn) | 无序或有序队列 |
程序设计就是算法加上数据结构。算法,也是一个数据结构高效实现的基础,。
这也是算法和数据结构密不可分的一个原因,比如查找 (Searching) 和排序 (Sorting)在数据结构中的实现是很关键的一部分。
- 数据结构 (Data Structure) 描述的是数据之间的关系,如线性关系,图状关系等;
- 抽象数据类型 (Abstract Data Type) 规定了一个数学模型即对这个模型的一系列操作,如栈以及对栈的push及pop操作。
- 数据类型(data type) = 接口(interface) + 数据的表示(data representation) 数据表示有多种, 数据结构(data structural representation)的表示形式是其中一种.
数据类型关心的是怎么用,比如 int、float、char,还比如常见的数据结构 List 也可以称作是一个抽象的数据类型
List myList = new List();
,这个时候我们讨论的都是他们的接口,接口的输入输出,怎么实现的不去讨论。数据结构关心的是怎么实现,比如数据的关系、实现的算法、考虑怎么储存等,比如
List
的数据结构,需要实现的接口怎么去高效实现,这个时候讨论的都是数据的关系、接口的实现、算法的效率等,强调的是基于语言,实现数据类型的一整套东西。所以聊到数据结构的时候,那些数据类型和接口使用就不能说了。就像是一个开车的,不能跟造车工程师一起聊汽车,虽然说得都是一个东西,但是关注点不同。
一个指的是数据之间的关系,而另一个指这种关系在计算机中的表现形式。
逻辑结构就是数据之间的关系。而按数据之间的关系来说,逻辑结构大概可以分为两种:线性结构和非线性结构(集合、树、网)。
存储结构是逻辑结构的存储映像。通俗的讲,可以将存储结构理解为逻辑结构用计算机语言的实现。常见的存储结构有顺序存储、链式存储、索引存储以及散列存储(哈希表)。
特别的:
- 顺序存储结构:逻辑上相邻的节点存储在物理位置上相邻的存储单元中。
- 优点:节省空间,可以实现随机存取;
- 缺点:插入、删除时需要移动元素,效率低。
- 链式存储结构:一组内存地址不必连续的储存单元,每个节点包含其他节点的逻辑引用。
- 优点:插入、删除灵活;
- 缺点:不能随机存取,查找速度慢。
比如:栈在逻辑结构中只能属于线性结构,而在物理结构中它可以使用顺序存储(数组),也可以使用链式存储(链表)。
下面我们虽然会涉及一点存储结构,但是大部分讨论的基本上是在逻辑层,即线性和非线性的数据结构上进行一些算法的实现:
一种线性的数据结构,元素关系可以用一个接一个的表述,从逻辑和物理上的差异可以分为:向量、列表,两种储存结构(有时候也会称为顺序表和链表):
- 向量 (Vector),是数组的抽象和泛化(也称为顺序表),使用顺序存储结构。
- 列表 (List),是链表的抽象和泛化,使用链式存储结构。
按照上面两种储存结构,有不同的逻辑实现,或者称为应用实例,可以分为下面几种不同的线性数据结构:
一种典型的向量(顺序表),特征是使用下标访问,数据顺序排列。一般语言中都会有数组的基本数据类型的一个数据结构,因为自带索引,与内存地址对应,所以寻址读取数据比较容易,查找效率很高。不过传统意义上的数组不像是
JS
中的 Array
那么灵活,比如固定长度、物理存储结构相邻,数据类型简单等。操作 | 功能 | 适用 |
---|---|---|
size() | 返回数组的长度 | - |
get(r) | 返回对应下标的元素 | - |
put(r, e) | 对对应下标的元素赋值 | - |
insert(r, e) | 在对应下标的位置插入数据,后面依次后移 | - |
remove(r) | 删除对应下标的元素,后面依次前移 | - |
disordered() | 判断是否非降序排列 | - |
sort() | 非降序排列全部元素 | - |
find(e) | 查找目标元素的位置 | - |
search(e) | 查找不大于目标元素的最大下标 | 有序 |
deduplicate() | 去除重复元素 | - |
uniquify() | 去除重复元素 | 有序 |
traverse() | 遍历并处理全部元素 | - |
ps: js 动态语言和传统的数组不同,而且很多内置方法,这里仅演示用基本操作实现相关接口并分析复杂度:
动态数组 数组的操作一般根据需求有增删改查等目的的操作,但是传统意义上的数组的长度不可变,所以涉及到动态数组的概念,也就是在操作的过程中,对数组的大小进行处理,如果长度不够用了,需要扩张,长度太长则要缩减。这也就是封装的动态数组的概念。
例如经常在调试、日志里面看到 capacity 这个词,经常表示申请的内存大小,这就是动态数组的场景,下面这个就是 Android 调试的时候代码缓存的处理:
I/zygote64(21065): Do partial code cache collection, code=122KB, data=83KBI/zygote64(21065): After code cache collection, code=122KB, data=83KBI/zygote64(21065): Increasing code cache capacity to 512KB
expand 方法的 分摊复杂度分析:
扩容的量级应该是【常数】,还是【倍数】?假设从零开始向数组中连续插入 n = m*I >> 2 个元素即每 m 次插入数据就要扩容一次,每次扩容延长 I 的长度连续的扩容操作为:0、I、2I、3I、...、(m-1)*I 次耗时操作,总和为 O(n^2) 的复杂度,分摊复杂度为 O(n)>> 分摊的概念,因为此处肯定是连续调用的,所以分摊的复杂度更有意义也就是当 n 很大的时候,连续插入的操作中,扩容的操作每次就要 O(n)从直观意义上来看,n 越大,常数扩容的次数是线性的关系而倍增的话,每次插入 n = 2^m >> 2 个元素会在第 1, 2, 4, 8, 16,..., 2^m 次扩容,总体耗时 O(n)分摊给每次扩容只需要 O(1)直观意义上来讲,n 越大,扩容越大,次数也就相比更小了所以【倍增】的方式在复杂度上更优,扩容效率上,没有【常数】好,但是至少也有 50% 的使用率
有序数组 顾名思义,数组内部的元素是按照顺序排列好的,这样的一个特性,对于查找的目的,相比无序数组复杂度更低。
排序 目前的很多经典排序都是在数组上的实现,不同的思想和复杂度都是最基础的算法。
这里有单独的示例来展示不同的排序: 各类排序演示
二分查找 在有序数组的前提下,查询部分可以使用二分查找的方法,对数组进行减而治之,快速降低问题的规模从而实现较好的复杂度。
二分搜索(binary search),也叫折半搜索(half-interval search)、对数搜索(logarithmic search),因为每次选取中间值比较后就能知道在前后哪个区间了,所以一是需要能够选取中间的值,二是序列已经排序,所以链表的结构就无法用了。
用二分查找法找寻边界值(以非降序序列查找第一个目标值为例):
按照常理,我们的第一想法都是找到与目标值相同的元素,返回下标即可,但是隐含的目的其实有两个:找到存在与否 + 找到第一个目标;按照这两个目的涉及的比对和判断,可以转化为一个目标:找到小于目标值的最右元素。
这样每次中间指针只需要判断不小于目标值,说明小于的值都在前面,后面的区间即可直接略去,右边界在迭代中即可逐渐逼近最后一个小于目标值的元素。
得到最后一个小于目标值的元素,后面的元素等于目标值,则一定是第一个目标值,如果不等于目标值,则说明大于目标值,在有序序列中说明目标值不存在。
上面这个方法,从寻找目标值,变成寻找刚好小于目标值的边界,程序上更好处理判断和比对部分,最后的结果也能更好的满足重复元素第一个值的问题。
换个表述方式就是:
在序列中找到刚好小于目标值的元素,使得序列被分为小于目标值,和大于等于目标值的两个区间。最后只需要判断后面的区间第一个元素即可。search 16 in[10, 11, 12, 13, 15, 15, 16, 16, 18, 19]
=>[10, 11, 12, 13, 15, 15]
+[16, 16, 18, 19]
代码流程示例为:
var v = [10, 11, 12, 13, 15, 15, 16, 16, 18, 19]var target = 16 // 实际要找比 16 刚小的后面的 15var start = 0, end = 9 // 左右指针// 向上取整保证左边界不动的情况,中间指针最后能上升到右边界var getMiddle = (start, end) => start + math.ceil((end - start) / 2)// loop 1// [10, 11, 12, 13, 15, <15>, 16, 16, 18, 19]middle = getMiddle(0, 9) = 5v[middle] = 15 < target // 目标在右侧start = middle = 5 // 小于目标值可能是边界,所以左边界移动到中间指针处// [15, 16, 16, 18, 19]// loop 2// [15, 16, <16>, 18, 19]middle = getMiddle(5, 9) = 7v[middle] = 16 == target // 等于或者大于,都表示目标(刚好小于那个)在左边end = middle - 1 = 7 - 1 = 6 // 中间指针当前不是边界,右指针至少在中间指针左侧// [15, 16]// loop 3// [15, <16>]middle = getMiddle(5, 6) = 6v[middle] = 16 == target // 目标在左侧end = middle - 1 = 6 - 1 = 5 // 和 loop 2 相同,右侧指针在中间指针左边// [15]// loop 4// 循环终止start == end == 5// 输出判断(找到的这个值的下一个值是目标值,或者找不到)v[start + 1] == target ? (start +) : -1
双指针、快速排序
一种典型的列表,特征是无法使用下标,每个元素除了数据,额外包含一个指针或者引用相邻的元素。
// Definition for singly-linked list.function ListNode(val) {this.val = val;this.next = null;}
前端也有很多业务涉及,比如循环播放的
slider
组件的数据储存可以使用链表,甚至可以用双向链表实现循环播放。还有 element.parentElement
和 element.firstElementChild
。还有 React
中的 Fiber Node
和 Fiber tree
都是链表的实现。链表的特性和数组是相反的,其中插入、移除数据非常方便,但是无法按索引读取,所以几乎只能遍历来查找元素。除此之外相关算法和同为线性序列的数组相似。
这两个数据结构相比数组和链表更上层,数据的真实储存本质上还是数组和链表,堆栈和队列更多的是业务上的数据模型,而且这两个结构一般是在数组和链表的基础上实现的。
后进先出(last in first off,LIFO)的数据结构 ↓
先进先出 (fisrt in first out,FIFO)的结构 ↓
具体实现这里不做了,看下 js 里面的相关函数:
- 散列表 / 哈希表(Hash table)
- 图(Graph)
- 树(Tree)
- 堆积(Heap)
通过哈希函数将目标值映射到一个表中,目的是加快查找速度。
对哈希表的最直接印象就是md5验证和数据库分表。MD5能够对任意输入产生固定长度的输出,因此相同的文件验证上很直观。当一个数据表数据量很大是,用哈希的方式,将数据映射到多个的数据表里面,规则就是哈希函数,比如根据用户 id 尾数,0-9可以将全部数据分到 10 个表中,查询的时候根据尾数计算能知道去哪个表查询。
哈希/散列函数是哈希算法的关键,不同的输入经由这个函数能够输出一个确定的摘要信息,根据这个摘要信息就能找到这个输入。这个函数的目的就是对于不同的输入应该产生(均匀分布且差异大的摘要)不同的结构。
哈希的函数如果没办法对不同的输入产生不同的输出,也就是存在不同输入相同输出的情况,就是产生了冲突,或者叫碰撞。比如密码的哈希值作为密码等同的验证的时候,万万不想这个碰撞概率很大,一旦不同的密码能产生相同的哈希值,验证就出现了问题。
图是一种复杂的非线性结构,抽象性更高些,表示的是多对多的数据结构,在图形结构中,任意两个数据元素之间都有可能相关。
比如地图相关业务中,每个地点用节点表示,那么每两个节点的可能路由就是图形结构,里面涉及的最短路径等问题。比如网络结构中,互联网中的每两个节点也是图的结构。
跟具体的业务存储关系很大,而且相关算法也很多,但是单独的前端里面涉及较少,目前先不深入。
终于到了数的部分,这是常见算法中经常涉及的部分。
树的结构顾名思义,具有树状结构性质的数据集合。比如前端经常见到的就是
element.children
的 html
树,比如渲染的文件列表组件、上下级列表组件、地理位置组件等,数据源基本也是 树
的结构,非常常见。是一种典型的树树状结构。每一个节点最多有两个分支的树结构,通常分支被称作 左子树 和 右子树 ,分支具有左右次序。
二叉树有以下几个性质:
- 二叉树第
i
层上的节点数目最多为2^(i-1)
。 - 深度为
k
的二叉树至多有2^k-1
个节点。 - 包含
n
个节点的二叉树的高度至少为log2
。 - 在任意一棵二叉树中,若终端节点的个数为
n0
,度为2
的节点数为n2
,则n0 = n2 + 1
。
二叉树第 i 层最多拥有 2^(i-1) 个节点,深度为 k 的二叉树最多共有 2^(k+1)-1 个节点,且定义根节点所在的层级 i=0,所在的深度 k=0。
代码实现一个二叉树:
二叉树这块有非常多的知识点:二叉树的前中后序遍历、二叉搜索树、平衡二叉树、完全二叉树、最大(小)堆、红黑树、B+树等等。下面分别说一下:
- 二叉树的遍历
- 完全二叉树
- 最大最小堆
- 二叉搜索树
- 平衡二叉树
前中后遍历并不是直观的左中右的意思,遍历都是从左到右,只是根节点的处理时机是
(根)左右
左(根)右
左右(根)
;说白了是先处理还是先深入,如果二叉树是平衡的,开始节点的处理在前中后的情况是先输出,输出一半,还是输出全部之后才去处理。给定一个二叉树,写出前中后序:
- 6- / \- 3 8- / \ / \- 2 4 7 9前序:6324879中序:2346789后序:2437986
前序遍历
preOrderTraverse(cb: Function, node: BinaryTreeNode<T> | null): void {if (node == null) returncb(node.value) // 访问节点preOrderTraverse(cb, node.left) // 遍历左子树preOrderTraverse(cb, node.right) // 遍历右子树}
中序遍历
inOrderTraverse(cb: Function, node: BinaryTreeNode<T> | null): void {if (node == null) returninOrderTraverse(cb, node.left) // 遍历左子树cb(node.value) // 访问节点inOrderTraverse(cb, node.right) // 遍历右子树}
后序遍历
postOrderTraverse(cb: Function, node: BinaryTreeNode<T> | null): void {if (node == null) returnpostOrderTraverse(cb, node.left) // 遍历左子树postOrderTraverse(cb, node.right) // 遍历右子树cb(node.value) // 访问节点}
以上是递归版本的,很经典的递归,很简单。
还有迭代版本的,比较复杂点,前序和中序比较好理解,基本思路就是使用堆栈来储存已遍历的节点,实现一个流程处理两个分支:压入堆栈是左边分支,不能再压的时候到分支最底部,弹出的时候就是根节点输出,切换到右分支的时候。
迭代的后序(左右根)比较麻烦,安装上面的堆栈,不好判断右侧分支处理结束的时机,比较巧的方法是用(根右左)的方式转为反的前序遍历,然后最后结果在翻转一下,代码见上面的 Demo。
下面这个是迭代版本的前序、后序遍历
preOrderTraverse2(cb: Function, node: BinaryTreeNode<T> | null): void {if (node == null) returnlet current = node// 遍历/迭代版本,有两个分支,遍历操作没办法同时进入两个分支处理,所以需要记录每个节点// 因此非递归的遍历需要用到栈let stack = []while (current || stack.length > 0) {// 左边一直走到最左边,顺路将全部节点先保存,方便回头处理,不然就丢了while (current) {cb(current.value) // 前序就是不管左右分支,我先输出当前值就是了stack.push(current)current = current.left}// 右侧分支可能下面仍然有左右分支,返回最上面即可(没有右分支的话,如果堆栈有,依然可以处理)// 一路向上返回就能遍历整个树了if (stack.length > 0) {let lastStore = stack.pop()// cb(current.value) // 如果中序遍历此时输出,下一步进入右侧分支处理// 这句的目的是再右侧子树有左树,需要再入栈(开始处理右侧分支了)current = lastStore.right}}}postOrderTraverse2(cb: Function, node: BinaryTreeNode<T> | null): void {if (node == null) returnlet current = nodelet stack = []let lastDeal = null// 后序的稍微复杂点,因为涉及到右侧树完成后才能输出// 相比上面的,开始处理右侧的时候不推出数据,而是右侧全都处理完才推出。// 增加了一个记忆变量,如果刚才处理的是右侧数据,表示右侧处理完了,如果刚才处理的不是,说明右侧还没有处理,其余的和前中序相同// 重点在怎么区分右侧树已经处理了,还是需要新进入while (current || stack.length > 0) {while (current) {stack.push(current)current = current.left}if (stack.length > 0) {let lastStore = stack[stack.length - 1]// 当前的状态是左边树遍历完了,这个时候从堆栈中最后一个判断// 一种情况是这个节点右侧是没有压入过堆栈,需要将右侧的重新走收集的流程// 另一种是右侧分支弹出,现在这个节点右侧的是上一个弹出的节点 lastDeal 说明右侧的分支已经弹完了,这个节点左右都处理了,可以输出这个根了if (lastStore.right && lastStore.right !== lastDeal) current = lastStore.rightelse {lastDeal = stack.pop()cb(lastDeal)}}}}
二叉树从左到右填充
// TODO
最大最小堆的概念非常的常见,尤其是在 Leet-Code 刷题的过程中,很多算法复杂度比较好的排序或者选择算法都是涉及到最大最小堆。
// TODO
二叉搜索树是特殊的二叉树,添加了下面的限制:
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
- 任意节点的左、右子树也分别为二叉查找树;
- 没有键值相等的节点;
简单来说就是左边的节点越来越小,右边的节点越来越大,目的是在树中检索数据的时候,更快的向正确的分支深入,每次深入都能节省一半的查询。
插入
插入的逻辑是将元素插入到某个最下面的节点,规则是与当前节点比较,小的向左边走,大的向右边走,直到走到最下层。
删除
叶子节点的删除,直接删除即可,如果包含一个叶子节点,直接上移到被删除的位置即可。
- 8 -> 8- / \ / \- 4 10 4 10- / \ / \ / \ /- (1) 5 9 (11) 3 5 9- \- 3
包含两个节点分支的删除逻辑略微麻烦,例如下面这个树删除 8 节点后,按照一般想法左侧节点 4 和右侧 10 都可以提到 8 的位置,但是无论4还是10 提上来之后子树的两个节点,仍然需要处理。
实际上应该选择被删除节点的左子树最右元素,或者右子树的最左元素代替被删除的节点:
- (8) -> 6 or 9- / \ / \ / \- 4 10 4 10 4 10- / \ / \ / \ / \ / \ \- 1 5 9 11 1 5 9 11 1 5 11- \ \- 6 6
// TODO
// TODO
贪婪法、分治法、穷举法、动态规划,回溯法。
// TODO
感谢您的阅读,本文由 Ubug 版权所有。如若转载,请注明出处:Ubug(https://ubug.io/blog/data-structure-algorithm)