文章目录
写在前面
本篇博客将介绍面试过程中常见的数据结构特点及其应用场景,比如满二叉树、完全二叉树、BST-tree、AVL-tree、B/B±tree、RB-tree、Tier-tree(字典树)等树形数据结构,bitmap(位图)等线性数据结构,堆等数据结构(不知如何归类),快排、归并排序、桶排序等排序算法、回溯剪枝、贪心、动态规划等,然后再讨论一些经典题,比如链表判环,字符串拷贝,Top-K问题,海量数据问题。
博客中部分文字是对参考博客的总结,部分图片摘录于原博客,特此申明。
二叉树
二叉树具有的性质是:
- 二叉树第i层上至多有 2 i − 1 2^{i-1} 2i−1个节点;
- 高度为k的二叉树至多有 2 k − 1 2^k-1 2k−1个节点;
- 包含n个节点的二叉树高度范围为 [ l o g 2 ( n + 1 ) , n ] [log_2(n+1),n] [log2(n+1),n];
- 二叉树的叶子节点个数为 n 0 n_0 n0,度为2的节点数为 n 2 n_2 n2,则 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1。
满二叉树
满二叉树:高度为h,节点数为
2
h
−
1
2^h-1
2h−1的二叉树,树的形式如下:
完全二叉树
完全二叉树:完全二叉树除最低层外,上层组成的是一个满二叉树,最低层的叶子节点从左向右排布。树的形式如下图:
BST树
BST树又称二叉搜索树,二叉查找树,BST树是这样的一棵树,要么为空树,要么
- 若左子树不为空,则左子树上所有节点的值均小于根节点的值,并且左子树也为二叉搜索树;
- 若右子树不为空,则右子树上所有节点的值均大于根节点的值,并且右子树也为二叉搜索树;
- 无键值相等节点。
二叉搜索树平均查找时间复杂度为O(log(n)),当树退化为链表时,此时时间复杂度为线性;二叉搜索树插入时间复杂度与查找相似,因为插入时首先查找到值对应的父节点,然后在它叶子节点处插入;二叉搜索树的删除可能比较麻烦,如图,删除6节点,后继节点是7,先将两节点互换,在处理删除节点。从二叉搜索树的性质看,我们不难知道,二叉搜索树的后续节点为右子树的最左下节点(如果存在),那么分几种情况:- 右子树无左右子树节点,即叶子节点,直接删除;
- 右子树的左子树存在,则其后续节点一定为叶子节点,待交换后,待删除的节点会被交换至叶子节点处,直接删除;
- 右子树左子树不存在,则直接右子树根节点连接在待删除位置,删除待删除的节点。
当然,右子树不存在时,处理更简单,直接将左子树取代待删除节点位置。
AVL树
极端情况下当使用有序序列逐步创建BST树时,BST树将退化成链表,查找性能将恶化为线性,本质原因是树的高度太大。AVL树正是为了克服此缺点而诞生的。
AVL树,又称平衡二叉搜索树,在BST树的基础上对子树的深度做了限制,其定义是,AVL树要么是一个空树,要不左子树和右子树均为AVL树并且左子树和右子树的高度差绝对值不超过1。
AVL树的搜索比较简单,由于AVL树具有很高的平衡性,因此在AVL树查找性能总能维持为O(log(n)),而插入和删除会破坏树的平衡性因此稍微复杂些,因为涉及到树的旋转,如下:
- 左旋:将右支往左拉,右支变成父节点后,其左支变为原来父节点的右支。
- 右旋:与左旋正好反过来,将左支往右边拉,左支变成父节点后,其右支变成原来父节点的左支。
旋转的目的是将节点多的一支出让给节点少的一支。
当AVL树被插入新节点时,平衡性会被破坏,需要旋转操作,分4种情况,因为左左与右右呈镜像、左右与右左呈镜像,这里只分析左左与左右。
- 左左,破坏平衡的节点位于左子树的左子树上,那么需要将失衡点(10)右旋,经过一次旋转即可再次平衡。
- 左右,与左左不同,需要经过两次旋转才能取得平衡,首先将节点7进行左旋,变成左左的情形,然后右旋,调整因节点4破坏的节点9的平衡性。
AVL树的插入即上述四种情况,删除可能复杂些,首先判断删除节点属于哪种类型,然后判断节点被删除后树的平衡性是否破坏。
1.若删除节点是叶子节点,则删除叶子节点后,判断父节点是否平衡,一直追溯到根节点,若发现某个节点失衡,则判断属于哪种情况(左左,左右,右左,右右),再调整;
2.若删除节点只有左子树或者右子树,先将节点删除,然后用左(右)子树取代删除节点位置,然后步骤同1,即顺着路径向上判断父节点、父父节点是否失衡;
3.若删除节点左子树和右子树都存在,则利用中序遍历找到删除节点的前(或者后)驱节点,用这个节点取代它,然后步骤同2。
对删除的总结是,先通过替换节点将树变成搜索树,保证有序性,然后通过旋转恢复树的平衡性。
性能总结:AVL树查找时间复杂度为O(log(n)),插入时,左左(右右)只需旋转一次,左右(右左)需要旋转两次,因此插入时间复杂度为O(log(n)),删除由于需要不断回溯父节点判断树是否失衡,因此最坏时间复杂度会大于O(log(n)),即logN+logN,logN次查找+logN回溯。
RB树
从AVL树性能分析看,AVL树要求严格的平衡这样能保证查找性能在最坏的情况下也能保持O(logN),但是代价是插入和删除引入的旋转操作开销非常大。而RB树(红黑树)对树的平衡性做了一些妥协,使得查找性能不那么坏的情况下提高插入和删除的性能。
RB树的性质是:
1.树的节点要么是红色要么是黑色;
2.根节点是黑色;
3.叶子节点都为黑色,且为null;
4.每个红色节点的两个子节点都是黑色,i.e.,路径上不存在连续的红色节点;
5.从树上任意节点出发到达叶子节点,每条路径上黑色节点的个数相同。
6.新加入到红黑树的节点为红色节点。
从数学上可以证明满足上述5个条件能保证RB-tree的查询、插入、删除操作时间复杂度为O(logN)。
当插入新节点时可能会破坏红黑树6个性质之一,可以通过变色和旋转解决之。
变色即从插入的新节点开始依次调整父节点、叔节点、兄弟节点等的颜色。
旋转操作与AVL相似,分左旋和右旋。
插入操作引起的变色和旋转操作如下:
1.左左节点旋转
插入前:
插入后:
祖父节点右旋,然后变色。
2.左右节点旋转
父节点左旋,然后祖父节点右旋,最后变色。
3.右左节点旋转
先父节点右旋,再祖父节点左旋,最后变色。
4.右右节点旋转
祖父节点左旋,再变色。
总结:
- 无需调整:当父节点为黑色节点时,不破坏RB-tree性质,无需调整;
- 变色:
- 空树加入根节点,将根节点红色变为黑色;
- 父节点和叔父节点均为红色时候,将插入节点由红色变为黑色;
- 旋转+变色:
- 父节点为红色左节点,叔父节点为黑色,
- 插入节点为左子节点,左左节点旋转;
- 插入节点为右子节点,左右节点旋转;
- 父节点为红色右节点,叔父节点为黑色,
- 插入节点为左子节点,右左节点旋转;
- 插入节点为右子节点,右右节点旋转;
- 父节点为红色左节点,叔父节点为黑色,
删除操作
删除操作将稍微复杂点,按子节点是否为null以及是否为红色分类讨论:
1.子节点至少有一个为null
2.子节点均不为null
则找删除节点的前驱节点或者后驱节点,然后用它的值代替删除前驱或者后继节点,再旋转+变色操作。删除前驱节点分下面情况讨论:
2.1前驱为黑色节点,且有一个非null子节点
2.2前驱为黑色节点,子节点均为null
2.3前驱为红色节点,子节点均为null
关于红黑树的知识大部分内容来自这篇博客,想详细了解可以点击看看。
红黑树的基础是二叉平衡搜索树(AVL-tree),但是克服了AVL-tree删除和插入存在极大旋转开销问题,红黑树对平衡性做了一定妥协,因此查找、插入、删除性能均能维持为对数复杂度,因此广泛应用于C++中STL map和set的实现,以及Java中TreeMap和TreeSet的实现,同时Linux内核中做内存管理选取的树结构也是红黑树。
B树和B+树
B树,本质上是平衡多路查找树,常见于做磁盘文件索引,因为磁盘I/O代价非常高昂,当存储大量数据时,平衡二叉树深度会过大,查找索引会下发多次磁盘I/O,因此将多个索引存在一个节点中,因此设计了B树(平衡多路查找树)。
B树:
- 排序方式 :一个节点内,所有关键字按递增顺序排序,左小右大;
- 子节点个数:非叶节点其子节点个数为[1,M],M为树的阶数,表示一个节点最多有多少个查找路径(一个子节点对应一条查找路径),M=2,为二叉树;
- 关键字个数:每个节点关键字个数为[ceil(M/2)-1,M-1],比如M=3,则关键字个数为1或者2;
- 关键字存储:zhihu文章对上述要点总结到位,但是对关键字位置存储描述并不准确,可见这篇博客,总结是,B树在每个节点中都会存储关键字信息(Key值+磁盘数据对应的指针),我们可以这么理解,B树的整个树都是关键字,关键字按照搜索树架构组成B树,而B+树实际上不再是是树结构,B+树的结构我们可以理解成,选取一些值,使得恰好能讲所有的Key按照二分方式(i.e.,使树的高度尽可能低)索引Key,而这些值有的可能落在Key集合中,也有可能都不在Key集合中,它的目的仅仅是加速Key索引,用这些选取的值构建多路搜索树,然后在叶子节点那层存储真正的Key值+Key值磁盘数据的指针,不难理解,B+树更加轻量,因此每个节点能存更多的索引。
B+树:
B+树除B树总结的第4点外,1-3点将关键字改成索引,基本大致相同。B+树广泛应用于磁盘文件存储,MySQL底层索引存储。
面试过程中一般会考察对B树和B+树的理解,对B树和B+树插入删除等引起树结构改变的操作考察较少,这里简单记录一下。
B树节点插入和删除过程可参见这篇博客,对B树插入删除过程的可视化可见这个链接。
删除过程如下:
1.若加入的关键字没达到m-1个,则直接加入不会破坏B树性质;
2.若加入关键字达到m-1个,则将插入关键字的节点分裂,将其中间值插入到父节点中,若父节点也达到m-1,则按照相同的步骤,直至回溯到根节点,若依然达到m-1个,则给B树增加一层。
删除过程如下:
1.若删除关键字后,节点中关键字树满足B树特性(i.e.,关键字个数大于ceil(m/2)-1),则不作后续处理;
2.否则,向兄弟节点借关键字,
- 若兄弟节点关键字借出后个数仍大于等于ceil(m/2)-1 (足够),则父节点关键字下移(填补至删除节点),兄弟节点关键字上移(填补父节点关键字)
- 若兄弟节点关键字借出后个数小于ceil(m/2)-1(不够),则父节点关键字下移,删除父节点,将子节点取代父节点。若依然不满足条件,则继续回溯。
删除操作总结: 子节点关键字个数不满足条件时(ceil(m/2)-1),首先考虑从兄弟节点借关键字(流程线是,父节点取关键字下移至删除关键字的节点,然后兄弟节点上移至父节点,填补父节点关键字),若仍不能满足B树性质,则考虑将节点合并后,删除一个节点,即父节点关键字与子节点关键字合并,父节点删除,子节点取代父节点的位置。
下图删除12是情形1,删除37是情形2中不够,删除操作也可能会找前驱后继节点,步骤可参考BST树的删除操作。
Tier
Tier,前缀树又称字典树,是一个N叉树,常用于单词检索、统计和排序字符串、字符串前缀,Tier一个经典的结构图如下:
构造Tier分3个步骤:
1.定义Tier的节点(节点既可以存单个字符也可以存从根节点至当前节点匹配的前缀,根节点为空字符串)
2.构造Tier N叉树结构
3.定义Tier操作,insert,search,startsWith
对Tier的理解可参考我的这篇博客。
Bitmap
Bitmap,又称位图,是一种以空间换时间的做法,位图的一个著名应用是布隆过滤器(不过Bloom Filter与Bitmap存在一点本质余缺),因为Bloom Filter通过计算字符串在它的bitmap上对应的值是否全为1判断字符串是否存在,但是存在一定的False Positive,而Bitmap是为每个数分配一个bit来表示,缺点是如果数据跨度大,且稀疏,Bitmap的内存浪费将十分明显。
Bitmap的思想非常简单,但是用处非常大,其中大数据处理是面试中常考的类型(大数据是指在内存有限的情况下,外存的数据不能全部加载至内存中处理),应用有以下几类,可参见博客:
- 使用位图判断整型数组是否存在重复元素。当然此题最朴素的做法是将数据全部加载至内存,排序,再扫描一次判断相邻的值是重复即可,或者扫描一次将值插入到hashmap中,然后检查当前访问值是否已经出现在hashmap中来判断重复。前者需要将数据全部加载到内存然后做大规模的移动,对于数据量较大的场景是不适用的,后者不能提前确定hash容量,因此存在潜在的resize风险。此题可用bitmap解题,先扫描一次找出数组最大值max,然后创建一个大小为max+1的数组,再遍历一次时检查对应位上是否已经被置位,若是则找打重复,否则置位。
- 在2.5个亿整数中找出不重复的数,2.5个亿数内存无法存下。解题方法,博客中给出的解法1本质上是记录访问某个数时,它之前是否已经出现过,其实我感觉应该用1-bitmap即可,不太理解为什么要用2-bitmap,解法2实际上是面试中常考的方法,因为涉及到分治思想,先将整个数据集切分成几个小文件,将小文件加载值内存排序,去除小文件中重复的数,将不重复的数留下,(分开->合并->分开->合并…),然后在两两小文件加载至内存合并再排序,去除重复的数,留下不重复的数,等到之后,只剩下两个文件,再次归并去重后,剩下的即是整个序列的不重复数。分支思想的本质是一次处理成本太高,而化整为零处理成本比较低,部分处理完合并后即是整体处理的结果。,解法3采取的是快排,思路与解法2类似,不过这类解法背后的思想都是分治思想。
- 已知某个文件内包含大量电话号码,每个号码为8位数,统计不同号码的个数。抽象此题背后的数学模型其实本质上与前两问相同,求不重复元素个数,而这类问题的解法无非两种,i.e.,排序或者hashmap,排序会把内存挤爆不合适处理大容量外存数据,而位图的思想与hashmap类似,不过位图能用1bit表示一个数,而hashmap需要1byte表示一个数,因此位图空间利用率高。OK,解此问套路相同,维护一个 2 8 2^8 28个bit的位图,每个bit代表一个号码,进行置为和查询操作即可找出重复数。
- 后面几个问题类似,不记录了。
Heap
堆,在C++中用优先级队列(priority_queue)表示,默认最大元素在队列的头部,i.e.,最大堆,堆本质上是一个二叉树,结构如下图,但是存储结构可以用数组表示,即表示成按照满二叉树和完全二叉树的形式以数组的方式存储节点,父节点与左右子节点的关系是,父节点位置为i,则左子节点位置为2*i+1
,右子节点位置为2*i+2
,堆支持的操作是获取最大值、插入、删除,小堆获取最小值。
1.插入,将堆看成完全二叉树,每次先填上一层,当填下一层时从左往右填。插入的步骤如下(大堆):
-
将新元素插到堆末尾;
-
按照提前定义的大小规则,将新元素与父节点比较大小,若新元素大于父节点,则交换新元素与父节点位置;
-
不断进行第二步,直至不需要交换或者到达堆顶
2.删除,与插入操作相反插入操作是从低到顶,而删除操作是从顶到低,删除操作只能针对堆顶元素,删除操作如下: -
删除堆顶元素;
-
将删除节点的左右子节点进行比较,将较大者上移,填补父节点,不断进行此步,直至不出现父节点空缺。
对堆知识的总结主要源自于这篇博客,详细可阅读它。
排序
快排
快速排序的平均时间复杂度为 O ( n l g n ) O(nlgn) O(nlgn),采用了分治的思想,基本思路是,在数组中选一个基准,将大于基准的值放置在它右边,将小于基准的值放置在它左边。具体可参考这里。
归并排序
归并排序也采用了分治的思想,基本思路是,将数组分成两个部分,待两个部分分别排好序后,进行合并,因此整个数组即可有序。
桶排序
桶排序本质上是计数排序,在LT中有考查过。基本思想是,将数组分别分散到每个桶中,然后在桶里对桶中元素排序。
算法
回溯剪枝
回溯法是一种近乎暴力解法,但是若时间复杂度不做要求,不失为一种通用解法。往往在回溯法解题时,可以将问题分解成多叉树的问题,然后回溯的过程是对树遍历的过程,对一些明显可以提前终止遍历的路径,可以提前剪枝。
注:回溯法适合求每个解的具体情况。
动态规划
动态规划里经典的问题和解法是背包问题,其中01背包考查最为广泛。较之回溯法,动态规划一般适合解决解个数的问题。
贪婪算法
贪婪算法没有定式,一般在解贪婪算法题时,可以考虑使得每一步贪婪,即可全局贪婪。
经典题
- 链表判环,双指针。
- 字符串拷贝,考虑内存覆盖的问题。
- Top-K问题,考查堆排序和快速排序。
- 海量数据问题,将大文件化成小文件,处理完小文件即可相当于处理完大文件,类似桶排序。
参考资料
博客的总结可以参考一个厉害学长的博客,博客链接在此。
AVL-tree,RB-tree
https://blog.csdn.net/qq_25940921/article/details/82183093
https://blog.csdn.net/sun_tttt/article/details/65445754
https://www.cnblogs.com/leng2052/p/5323954.html
https://www.cnblogs.com/LiaHon/p/11203229.html
B-tree,B+tree
https://blog.csdn.net/guoziqing506/article/details/64122287
Tier
https://leetcode-cn.com/circle/article/mv8GnX/
https://leetcode-cn.com/explore/learn/card/trie/165/introduction-to-trie/641/
Bitmap
https://blog.csdn.net/gongpulin/article/details/81137548
Heap
https://blog.csdn.net/juanqinyang/article/details/51418629