【数据结构】树
(ElemType*)malloc(sizeof(ElemType)*InitSize);
此函数是一个指针型函数,返回的指针指向该分配域的开头位置。
树
树的性质
- 树中的结点数 = 所有结点的度数 + 1
- 度为m的树中第i层上至多有\(m^{i-1}\)个结点(i>=1)
- 高度为h的m叉树至多有\((m^h-1)/(m-1)\)个结点(推导公式\(S=m^{h-1}+m^{h-2}+m^{h-3}+...+m+1=(m^h-1)/(m-1)\))
- 具有n个结点的m叉树的最小高度为\(⌈log_m(n(m-1)+1)⌉\)
- 按结点数的和算:总结点数\(N=n_0+n_1+n_2+...+n_m\)(结点数的和)(树的度为m,即 m叉树)
- 按度数算:总结点数\(N=n_1+2n_2+3n_3+...+mn_m+1\)(度数+1)
- 由上面两个式子可得,\(n_0=1+n_2+2n_3+3n_4+...+(m-1)n_m\)
树的存储结构
双亲表示法
双亲表示法采用一组连续空间来存储每个结点,同时在每个结点中增设一个伪指针,指示其双亲结点在数组中的位置。根结点下标为0,其伪指针域为-1.
- 存储结构;
//双亲表示法
#define MAX_TREE_SIZE 100 //树中最多结点数
typedef struct { //树的结点定义
ElemType data; //数据元素
int parent; //双亲位置域
}PTNode;
typedef struct { //树的类型定义
PTNode nodes[MAX_TREE_SIZE]; //双亲表示
int n; //结点数
}PTree; //Parent Tree
- 优点:
可以很快得到每个结点的双亲结点 - 缺点:
但求结点的孩子时需要遍历整个结构
孩子表示法
孩子表示法是从树的根节点开始,使用顺序表依次存储树中各个节点,将每个结点的孩子结点都用单链表链接起来形成一个线性结构,用于存储各节点的孩子节点位于顺序表中的位置,此时n个结点就有n个孩子链表(叶子结点的孩子链表为空表)。
- 存储结构;
//孩子表示法
typedef struct CTNode{
int child;//链表中每个结点存储的不是数据本身,而是数据在数组中存储的位置下标
struct CTNode * next;
}ChildPtr;
typedef struct {
TElemType data;//结点的数据类型
ChildPtr* firstchild;//孩子链表的头指针
}CTBox;
typedef struct{
CTBox nodes[MAX_SIZE];//存储结点的数组
int n,r;//结点数量和树根的位置
}CTree; //Child Tree
- 优点:
寻找子女的操作非常直接 - 缺点:
而寻找双亲的操作需要遍历n个结点中孩子链表指针域所指向的n个孩子链表
孩子兄弟表示法(二叉树表示法)
孩子兄弟表示法(左孩子右兄弟)以二叉链表作为树的存储结构。使每个结点包括三部分内容:结点值、指向结点第一个孩子结点的指针,及指向结点下一个兄弟结点的指针(沿此域可以找到结点的所有兄弟结点)。
- 存储结构:
//孩子兄弟表示法
typedef struct CSNode {
ElemType data; //数据域
struct CSNode *firstchild, *nextsibling; //第一个孩子和右兄弟指针
}CSNode, *CSTree; //Child Sibling Tree
- 优点:
可以方便地实现树转换为二叉树的操作,易于查找结点的孩子结点等 - 缺点:
从当前结点寻找其双亲结点比较麻烦。若为每个结点增设一个parent域指向其父节点,则查找结点的父节点也很方便。
树和森林的遍历
与二叉树遍历的对应关系
树 | 森林 | 二叉树 |
---|---|---|
先根遍历 | 先序遍历 | 先序遍历 |
后根遍历 | 中序遍历 | 中序遍历 |
树的遍历
-
先根遍历:
若树非空,则先访问根节点,再按从左到右的顺序遍历根结点的每棵子树,其访问顺序与这棵树相应的二叉树的先序遍历循序相同。
先序遍历(NLR)->先根遍历(根 孩子 兄弟) -
后根遍历:
若树非空,则按从左到右的顺序遍历根结点的每棵子树,之后再访问根节点。其访问顺序与这棵树相应的二叉树的中序遍历循序相同。
中序遍历(LNR)->后根遍历(孩子 根 兄弟)
森林的遍历
-
先序遍历森林。若森林为非空,则按如下规则进行遍历:
- 访问森林中第一棵树的根节点。
- 先序遍历第一棵树中根节点的子树森林。
- 先序遍历除去第一棵树之后剩余的树构成的森林。
-
中序遍历森林。若森林为非空,则按如下规则进行遍历:
- 中序遍历第一棵树中根节点的子树森林。
- 访问森林中第一棵树的根节点。
- 中序遍历除去第一棵树之后剩余的树构成的森林。
二叉树
二叉树的性质
- 非空二叉树上的叶子结点数 = 度为2的结点数 + 1,即 \(n_0 = n_2 + 1\)(叶子当然比分支多啊,类比一下现实中的树)
- 非空二叉树上第k层上至多有\(2^{k-1}\)个结点(k$>=$1)
- 高度为h的二叉树至多有\(2^h-1\)个结点(h>=1)
- 在有n个结点的二叉树中,有n+1个空指针
- 在有n个结点的二叉树中,有n-1个度(即 边,一个度为一个边)(除了根结点,其他结点都是边+结点)
- 哈夫曼树由于其构造方法,所以只有度为0和度为2的结点(\(n_1\)=0)
- 节点总数\(N=n_0+n_1+n_2=n_1+2n_2+1\)
完全二叉树的性质
-
对完全二叉树按从上到下、从左到右的顺序依次编号1,2,...,n,则对结点i有以下关系:
- 所在层次为\(⌊log_2i⌋ + 1\)
- 双亲结点编号为\(⌊i/2⌋\)
- 左孩子结点编号为\(2i\)
- 右孩子结点编号为\(2i+1\)
PS:若越界则不存在。
这个性质也是二叉树(堆)的顺序存储下标从1开始的理由。 -
具有n个(n>0)结点的完全二叉树的高度为\(⌈log_2(n+1)⌉\)或\(⌊log_2n + 1⌋\)
-
完全二叉树\(n_1\)只能等于0或1
-
当节点总数N为偶数时:叶结点数=结点总数/2。\(n_0=N/2\)
-
当节点总数N为奇数时:叶结点数=(结点总数+1)/2。\(n_0=(N+1)/2\)
-
当节点总数N为偶数时,说明有一个度为1的节点。\(n_1=1\)
-
当节点总数N为奇数时,说明该树结构中没有度为1的节点。\(n_1=0\)
题目
设一棵完全二叉树共有699个节点,则在该二叉树中的叶节点数是什么?
答:
n=n0+n1+n2
n0=n2+1
n=699,奇数,说明n1为0;
n=n0+n0-1
n0=350,所以叶节点数为350。
一颗完全二叉树第六层有8个叶结点(根为第一层),则结点个数最多有()个。
二叉树第k层最多有 2^(k-1) 个节点
第六层最多有32个节点
第五层最多有16个节点
第四层最多有8个节点
第三层最多有4个节点
第二层最多有2个节点
第一层最多有1个节点
完全二叉树的叶节点只可能出现在后两层
如果完全二叉树有6层,则前5层是满二叉树,总节点数目为16+8+4+2+1+8=39
如果完全二叉树有7层,则前6层是满二叉树,
前六层总节点数目为32+16+8+4+2+1=63
第六层有8个叶子节点,则有32-8=24个非叶子节点
第七层最多有24*2个叶子节点
总节点数目为63+24*2=111
一颗有124个叶子节点的完全二叉树,最多有多少个节点?
最多有248个节点
根据完全二叉树的性质,\(n_0=n/2\)或\(n_0=(n+1)/2\)
所以,\(n=2\*n_0\)或\(n=2\*n_0-1\)
题中要求树的最多节点数,即 树的节点数等于叶子节点数的两倍,248
二叉树的存储结构
-
顺序存储结构:
- 注意:要从数组下标为1开始存储树中的结点(若从0开始存,则不满足上述完全二叉树的结点关系性质,因为0乘除任何数都是0)
- 适用于完全二叉树(堆)和满二叉树
注意区别树的顺序存储结构与二叉树的顺序存储结构,在树的顺序存储结构(双亲表示法)中,数组下标代表结点的编号,下标上所存的内容指示了结点之间的关系;而在二叉树的顺序存储结构中,数组下标既代表了结点的编号,又指示了树中各结点之间的关系。
-
链式存储结构:
- 数据域 + 左右指针域
二叉树的遍历
注意:中序和后序遍历,都是出容器时 遍历访问,进入容器只是存放遍历结点的顺序。
先序遍历是访问后,入容器
遍历二叉树其实是以一定的规则,将二维的二叉树中的结点排列成一个线性序列,其实质是对一个非线性结构进行线性化操作,使这个访问序列中的每个结点(第一个和最后一个除外)都有一个直接前驱和直接后继。
注意:这三种遍历算法的访问路径是相同的(都是围着树的外圈访问一整圈),只是访问结点的时机不同。(先序遍历第一次经过就访问,中序遍历第二次经过才访问,后序遍历第三次经过才访问。加上左右分支,沿着树的外圈访问),可以以此来求某一结点的前驱后继。但要求整体的前驱后继,还是将整个二叉树生成遍历序列。
理解:也很好理解不是吗?
先序遍历****第一次经过就访问根节点;
中序遍历第一次经过访问左子树,第二次经过才访问根节点;
后序遍历第一次经过访问左子树,第二次经过访问右子树,第三次经过才访问根节点。
- 由二叉树的先序序列和中序序列(或 后序序列和中序序列)可以唯一地确定一棵二叉树。
由先(后)序序列可以得知根结点,再由中序序列可以根据根结点划分左右子树;
再由先(后)序序列得知左右子树根结点,再中序划分。。
我们以下的使用的栈或队列都是作为一个工具来解决其他问题的,我们可以把栈或队列的声明和操作写的很简单,而不必分函数写出。
- 顺序栈
- 声明一个栈并初始化:
ElemType stack[maxSize]; //存放栈中的元素
int top = -1; //栈顶指针(指向栈顶元素)
2. 元素进栈:
stack[++top] = x;
3. 元素出栈:
x = stack[top--];
4. 判断栈空
top == -1; //栈空
top > -1; //栈非空
- 顺序队列
- 声明一个队列并初始化:
ElemType queue[maxSize]; //存放队列中元素
int front = -1, rear = -1; //队头(指向队头元素的前一个位置),队尾(指向队尾元素)
2. 元素入队:
queue[++rear] = x;
3. 元素出队:
x = queue[++front];
4. 判断队空
front == rear; //队空
front < rear; //队非空
先序遍历(PreOrder)
左右都是经过,根才访问。
-
操作过程:
简称,根左右
若二叉树为空,则什么也不做;否则,- 访问根节点;
- 先序遍历左子树;
- 先序遍历右子树。
-
具体实现:
- 递归算法:
void PreOrder(BiTree T) {
if(T == NULL) { //合法性检验
return;
}
visit(T); //访问根节点
PreOrder(T->lchild); //递归遍历左子树
PreOrder(T->rchild); //递归遍历右子树
}
- 非递归算法:
//二叉树先序遍历的非递归算法,算法需要借助一个栈
void PreOrder2(BiTree T) {
InitStack(S); //初始化栈
BiTNode *p = T; //p是遍历指针
while(p || !IsEmpty(S)) { //p不空 或 栈中还有元素时循环
if(p) { //根节点进栈,遍历左子树
visit(p);
Push(S, p); //每遇到非空二叉树先向左走
p = p->lchild;
}else { //根指针出栈,访问根节点,遍历右子树
Pop(S, p); //出栈,访问根节点
p = p->rchild; //再向右子树走
}
}
}
方法二:参照层次遍历,只不过一个是队列,一个是栈
非递归的DFS,代码如下:
//java
private List<TreeNode> traversal(TreeNode root) {
List<TreeNode> res = new ArrayList<>();
Stack<TreeNode> stack = new Stack<>();
stack.push(root);
while (!stack.empty()) {
TreeNode node = stack.peek();
res.add(node);
stack.pop();
if (node.right != null) {
stack.push(node.right);
}
if (node.left != null) {
stack.push(node.left);
}
}
return res;
}
上面的代码,唯一需要强调的是,为什么需要先右后左压入数据?是因为我们需要将先访问的数据,后压入栈(请思考栈的特点)。
如果不理解代码,请看下图:
1:首先将a压入栈
2:a弹栈,将c、b压入栈(注意顺序)
3:b弹栈,将e、d压入栈
4,5:d、e、c弹栈,将g、f压入栈
6:f、g弹栈
中序遍历(InOrder)
左右都是经过,根才访问。
-
操作过程:
简称,左根右 -
具体实现:
- 递归算法:
void InOrder(BiTree T) {
if(T == NULL) {
return;
}
InOrder(T->lchild);
visit(T);
InOrder(T->rchild);
}
- 非递归算法:
借助**栈**,可以将二叉树的递归遍历算法转换为非递归算法。
先扫描(并非访问)根节点的所有左节点,并将它们一一进栈,然后出栈一个结点\*p(显然结点\*p没有左孩子结点或左孩子结点均已被访问过),访问它。然后扫描该结点的右孩子结点,将其进栈,再扫描该右孩子结点的所有左结点并一一进栈,如此继续,直到栈空为止。
//二叉树中序遍历的非递归算法,算法需要借助一个栈
void InOrder2(BiTree T) {
InitStack(S); //初始化栈
BiTNode *p = T; //p是遍历指针
while(p || !IsEmpty(S)) { //p不空 或 栈中还有元素时循环
if(p) { //根节点进栈,遍历左子树
Push(S, p); //每遇到非空二叉树先向左走
p = p->lchild;
}else { //根指针出栈,访问根节点,遍历右子树
Pop(S, p); //出栈,访问根节点
visit(p);
p = p->rchild; //再向右子树走
}
}
}
后序遍历(Postorder)
左右都是经过,根才访问。
-
操作过程:
简称,左右根 -
具体实现:
- 递归算法:
void PostOrder(BiTree T) {
if(T == NULL) {
return;
}
PostOrder(T->lchild);
PostOrder(T->rchild);
visit(T);
}
- 非递归算法:
后序非递归遍历二叉树的顺序是是先访问左子树,再访问右子树,最后访问根结点。当用**堆栈**来存储结点时,必须分清返回根结点时是从左子树返回的还是从右子树返回的。所以,使用**辅助指针r**,其指向最近访问过的结点。也可在结点中增加一个标志域,记录是否已被访问。
> **注意:**后序遍历必须要存储上一个访问过的节点,因为**节点出栈的时候是无状态的,我们是无法从中判断出右结点是否被访问过。**所以需要一个额外的辅助指针来记录下当前访问的状态。
void PostOrder(BiTree T) {
InitStack(S);
p = T;
r = NULL; //r指向最近访问过的结点
while(p || !IsEmpty(S)) {
if(p) { //走到最左边
push(S, p);
p = p->lchild;
}else { //向右
GetTop(S, p); //取栈顶结点
if(p->rchild && p->rchild!=r) { //若右子树存在,且未被访问过
p = p->rchild; //转向右
}else { //否则,弹出结点并访问
pop(S, p); //将结点弹出
visit(p->data); //访问该结点
r = p; //记录最近访问过的结点
p = NULL; //结点访问完后,重置p指针,用于再次循环(从栈内结点开始)
}
}
}
}
因为直到最后才访问根结点,所以访问到值为x的结点时,上面的所有祖先根结点都还没被访问,栈中所有元素均为该结点的祖先,依次出栈打印即可。
迭代后序遍历访问一个结点*p时,栈中结点恰好是*p结点的所有祖先。从栈底到栈顶结点再加上*p结点,刚好构成从根结点到*p结点的一条路径。再很多算法设计中都利用了这一特性求解,如求根结点到某结点的路径、求两个结点的最近公共祖先等,都可以利用这个思路来实现。
这三种遍历算法中,递归遍历左右子树的顺序都是固定的,只是访问根结点的顺序不同。不管采用哪种遍历算法,每个结点都访问一次且仅访问一次,故时间复杂度都是O(n)。
在递归遍历中,递归工作栈的栈深恰好为树的深度,所以在最坏情况下,二叉树是有n个结点且深度为n的单支树,遍历算法的空间复杂度为O(n)。
层次遍历(LevelOrder)
-
操作过程:
要进行层次遍历,需要借助一个队列。先将二叉树根结点入队,然后出队,访问该结点,若它有左子树,则将左子树根结点入队;若它有右子树,则将右子树根结点入队。然后出队,对出队结点访问,如此反复,直到队列为空。 -
具体实现:
void LevelOrder(BiTree T) {
InitQueue(Q); //初始化辅助队列
BiTNode *p;
EnQueue(Q, T); //将根结点入队
while(!IsEmpty(Q)) { //队列不空循环
DeQueue(Q, p); //队头元素出队,出队指针才是用来遍历的遍历指针
visit(p); //访问当前p所指向结点
if(p->lchild != NULL) { //左子树不空,则左子树入队列
EnQueue(Q, p->lchild);
}
if(p->rchild != NULL) { //右子树不空,则右子树入队列
EnQueue(Q, p->rchild);
}
}
}
用层次遍历易于找到某结点的父结点。而且层次遍历采用迭代效率高,迭代方法也方便实现。
层次遍历延伸版:
有时候我们会遇到那种一层一层分隔开遍历的情况,就使用这种层次遍历方法,每次将一整层的节点输出出去。
-
按层打印:题目要求的二叉树的 从上至下 打印(即按层打印),又称为二叉树的 广度优先搜索(BFS)。BFS 通常借助 队列 的先入先出特性来实现。
-
每层打印到一行:将本层全部节点打印到一行,并将下一层全部节点加入队列,以此类推,即可分为多行打印。
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> res = new ArrayList<List<Integer>>();
if (root == null) {
return res;
}
Queue<TreeNode> queue = new LinkedList<TreeNode>();
queue.offer(root);
while (!queue.isEmpty()) {
List<Integer> level = new ArrayList<Integer>();
int count = queue.size(); // 这一层有多少节点
for (int i = 1; i <= count; ++i) { // 把这一层的节点都输出出去
TreeNode node = queue.poll();
level.add(node.val);
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
}
res.add(level);
}
return res;
}
}
线索二叉树
传统的链式存储仅能体现一种父子关系,不能直接得到结点在遍历中的前驱或后继。在二叉链表表示的二叉树中存在大量的空指针(n-1个),利用这些空链域存放指向其直接前驱或后继的指针,即可成为线索二叉树。
在有n个结点的二叉树中,有n+1个空指针。这是因为每个叶结点有2个空指针,而每个度为1的结点有1个空指针,所以总的空指针数为\(2n_0+n_1\),又有\(n_0=n_2+1\),所以总的空指针为\(n_0+n_1+n_2+1=n+1\)
注意:线索二叉树指明了在存储过程中的数据存放方式,所以它是一种物理结构。
-
目的:
为了加快查找结点前驱和后继的速度(加快对二叉树的遍历)。
线索二元树的左线索指向其前驱,右线索指向其后继。 -
存储结构:
//线索二叉树
typedef struct ThreadNode { //线索二叉树结点
ElemType data; //数据元素
struct ThreadNode *lchild, *rchild; //左、右孩子指针
int ltag, rtag; //左、右线索标志
//tag=0代表child指针指向孩子,tag=1代表child指针指向前驱后继
}ThreadNode, *ThreadTree;
以这种结点结构构成的二叉链表作为二叉树的存储结构,称为线索链表;
其中指向结点前驱和后继的指针称为线索;
加上线索的二叉树称为线索二叉树;
对二叉树以某种遍历使其变为线索二叉树的过程称为线索化。
前序线索化
- 具体实现:
通过前序遍历对二叉树线索化的递归算法如下:
//前序遍历对二叉树线索化的递归算法
void PreThread(ThreadTree &p, ThreadTree &pre) {
//指针pre指向前序遍历时上一个刚刚访问过的结点,用它来表示各结点访问的前后关系
if(!p) { //递归出口
return;
}
//下面开始建立线索化(其实就相当于遍历中的 “访问” )
if(p->lchild == NULL) { //左子树遍历到头,左子树为空,“建立前驱线索”
p->lchild = pre;
p->ltag = 1;
}
if(pre!=NULL && pre->rchild==NULL) { //“建立前驱结点的后继线索”
pre->rchild = p; //“建立前驱结点的后继线索”(仅建立了前驱的后继线索,所以最后一个结点的后继线索没有建立)
pre->rtag = 1;
}
pre = p; //标记当前结点称为刚刚访问过的结点(注意:访问!=扫描)
//访问是指对该结点进行操作(如print输出),而扫描则代表只是经过了这个结点,并没有执行任何操作
PreThread(p->lchild, pre); //递归线索化左子树
PreThread(p->rchild, pre); //递归,线索化右子树
}
//通过前序遍历建立前序线索二叉树
void CreatePreThread(ThreadTree T) {
ThreadTree pre = NULL;
if(!T) {
return;
}
PreThread(T, pre); //线索化二叉树,没有建立最后一个结点的后继线索
pre->rchild = NULL; //处理遍历的最后一个结点
pre->rtag = 1;
}
中序线索化
-
操作过程:
对二叉树的线索化,实质上就是遍历一次二叉树,只是在遍历的过程中,检查当前结点左右指针域是否为空,若为空,将它们改为指向前驱结点或后继结点的线索。 -
具体实现:
通过中序遍历对二叉树线索化的递归算法如下:
//中序遍历对二叉树线索化的递归算法
void InThread(ThreadTree &p, ThreadTree &pre) {
//指针pre指向中序遍历时上一个刚刚访问过的结点,用它来表示各结点访问的前后关系
if(!p) { //递归出口
return;
}
InThread(p->lchild, pre); //递归线索化左子树
//下面开始建立线索化(其实就相当于遍历中的 “访问” )
if(p->lchild == NULL) { //左子树遍历到头,左子树为空,建立前驱线索
p->lchild = pre;
p->ltag = 1;
}
if(pre!=NULL && pre->rchild==NULL) {
pre->rchild = p; //建立前驱结点的后继线索(仅建立了前驱的后继线索,所以最后一个结点的后继线索没有建立)
pre->rtag = 1;
}
pre = p; //标记当前结点称为刚刚访问过的结点(注意:访问!=扫描)
//访问是指对该结点进行操作(如print输出),而扫描则代表只是经过了这个结点,并没有执行任何操作
InThread(p->rchild, pre); //递归,线索化右子树
}
//通过中序遍历建立中序线索二叉树
void CreateInThread(ThreadTree T) {
ThreadTree pre = NULL;
if(!T) {
return;
}
InThread(T, pre); //线索化二叉树,没有建立最后一个结点的后继线索
pre->rchild = NULL; //处理遍历的最后一个结点
pre->rtag = 1;
}
线索化后,先序线索二叉树可以很简单的找到一个结点的先序后继,而查找先序前驱必须知道该结点的双亲结点;
中序线索二叉树可以找到一个结点的中序前驱与中序后继;
后序线索二叉树可以找到一个结点的后序前驱,而查找后序后继也必须知道该结点的双亲结点。
线索二叉树的遍历
中序线索化二叉树主要是为访问运算服务的,这种遍历不再需要借助栈,因为他的结点中隐含了线索二叉树的前驱和后继信息。利用线索二叉树,可以实现二叉树遍历的非递归算法。
- 求中序线索二叉树中中序下的第一个结点:
ThreadNode *Firstnode(ThreadNode *p) {
while(p->ltag == 0) { //最左下的结点(不一定是叶结点)
p = p->lchild;
return p;
}
}
- 求中序线索二叉树中结点p在中序序列下的后继结点:
ThreadNode *Nextnode(ThreadNode *p) {
if(p->rtag == 0) { //有右子树
return Firstnode(p->rchild); //找右子树的最左下结点(即为后继节点)(手动找)
}else { //rtag==1 直接返回后继线索
return p->rchild; //(根据线索自动找)
}
}
- 中序线索二叉树的中序遍历算法:
void Inorder(ThreadNode *T) {
//从中序下第一个结点(最左下结点)开始,依次找其后继节点
for(ThreadNode *p=Firstnode(T); p!=NULL; p=Nextnode(p)) {
visit(p);
}
}
堆
优先队列(Priority Queue):特殊的“队列”,取出元素的顺序是按照元素的优先权(关键字)大小,而不是元素进入队列的先后顺序。
使用数组或链表的话效率不高,所以我们试着采用二叉树来实现优先队列,所以我们引入了“堆”。
- 基本概念:
堆可以看成是一棵完全二叉树,特点是父亲大孩子小,或者父亲小孩子大,前者称为大顶堆,后者称为小顶堆。
注意:从根结点到任意结点路径上的结点序列具有有序性。所以堆的插入和删除都是沿着有序的轨迹进行操作。
堆经常被用来实现优先级队列,优先级队列在操作系统的作业调度和其他领域有着广泛的应用。
树和二叉树的应用
二叉搜索树(BST)
二叉搜索树 或 二叉排序树作为一种动态集合,其特点是树的结构通常不是一次生成的,而是在查找过程中,当树中不存在关键值等于给定值得结点时再进行插入的。
- 二叉排序树(Binary Sort Tree)的定义:
- 若左子树非空,则左子树上所有关键字值均小于根节点关键字值。
- 若右子树非空,则右子树上所有关键字值均大于根节点关键字值。
- 左、右子树本身也分别是一棵二叉排序树。
注意:二叉排序树的插入必为一个新的叶子结点。
左子树结点值 < 根结点值 < 右子树结点值 (没有相等值)
所以对二叉排列树进行中序遍历,可以得到一个递增的有序序列。
当有序表是静态查找表时,宜用顺序表作为其存储结构,而采用二分查找实现其查找操作;若有序表是动态查找表,则应选择二叉排序树作为其逻辑结构。
查找
-
操作过程:
二叉排序树的查找是从根节点开始,沿着某个分支逐层向下进行比较的过程。
若二叉排序树非空,则将给定值与根节点的关键字比较,若相等,则查找成功;若给定值小于根节点的关键字,则在根节点的左子树查找;若给定值大于根节点的关键字,则在根节点的右子树查找。 -
具体实现:
- 递归实现:
BSTNode *BST_Search(BiTree T, ElemType key) {
if(!T) {
return NULL;
}
if(key < T->data) {
return BST_Search(key, T->lchild);
}else if(key > T->data) {
return BST_Search(key, T->rchild);
}else { //相等
return T;
}
}
- 非递归实现:
//查找函数返回指向关键字值为key的结点指针,若不存在,返回NULL
BSTNode *BST_Search(BiTree T, ElemType key, BSTNode *&p){
p = NULL; //p指向被查找结点的双亲,用于插入和删除操作中
while(T!=NULL && key!=T->data) {
p = T;
if(key < T->data) {
T = T->lchild;
}else {
T = T->rchild;
}
}
return T;
}
插入
-
操作过程:
若原二叉排序树为空,则直接插入结点;否则,若关键字k小于根结点关键字,则插入左子树,若关键字k大于根结点关键字,则插入右子树。 -
具体实现:
//在二叉排序树T中插入一个关键字为k的结点
int BST_Insert(BiTree &T, ElemType k) {
if(T == NULL) { //原树为空,新插入的记录为根结点
T = (BiTree)malloc(sizeof(BSTNode));
T->data = k;
T->lchild = NULL;
T->rchild = NULL;
return 1; //插入成功
}else if(k == T->data) { //树中存在相同关键字的结点
return 0;
}else if(k < T->data) { //插入T的左子树
return BST_Insert(T->lchild, k);
}else { //插入T的右子树
return BST_Insert(T->rchild, k);
}
}
构造
-
操作过程:
每读入一个元素,就建立一个新结点,若二叉排序树为空,则将新结点作为二叉排序树的根结点;若二叉排序树为非空,则将新结点的值域根结点的值比较,若小于根结点的值,则插入左子树,否则插入右子树。
其实就是,依次输入数据元素,并将它们插入二叉排序树中适当位置上的过程。 -
具体实现:
//用关键字数组str[]建立一个二叉排序树
void Creat_BST(BiTree &T, ElemType str[], int n) {
T = NULL; //初始时bt为空树
int i = 0;
while(i < n) { //依次将每个元素插入
BST_Insert(T, str[i]);
i++;
}
}
删除
在二叉排序树中删除一个结点时,不能把以该结点为根的子树上的结点都删除,必须先把被删除结点从存储二叉排序树的链表摘下,将因删除结点而断开的二叉链表重新链接起来,同时确保二叉排序树的性质不会丢失。
-
操作过程:
- 若被删除结点z是叶结点,则直接删除,不会破坏二叉排序树的性质。
- 若结点z只有一棵左子树或右子树,则让z的子树称为z父节点的子树,替代z的位置。
- 若结点z有左、右两棵子树,则令z的直接后继(或直接前驱)(即右子树中的最小元素(或左子树中最大元素))替代z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。
-
具体实现:
//查找最小元素的递归函数
BiTree FindMin(BiTree T) {
if(!T) { //空的二叉搜索树
return NULL;
}
if(!T->lchild) { //找到最左孩子结点并返回
return T;
}else {
return FindMin(T->lchild); //沿着左子树继续找
}
}
//查找最大元素的迭代函数
BiTree FindMax(BiTree T) {
if(!T) { //空的二叉搜索树
return NULL;
}
while(T->rchild) { //找到最右孩子结点
T = T->rchild;
}
return T;
}
//删除二叉排序树中值为x的结点
BiTree Delete(ElemType x, BiTree T) {
BiTree tmp;
if(!T) {
printf("要删除的元素未找到");
}
if(x < T->data) {
T->lchild = Delete(x, T->lchild); //左子树递归删除
}else if(x > T->data) {
T->rchild = Delete(x, T->rchild); //右子树递归删除
}else {
if(T->lchild && T->rchild) { //被删除结点有左右两个子结点
tmp = FindMin(T->rchild)); //在右子树中找最小元素填充删除结点
T->data = tmp->data;
T->rchild = Delete(T->data, T->rchild);//在删除结点的右子树中删除最小元素
}else { //被删除结点有一个或没有子结点
tmp = T;
if(!T->lchild) { //没有左孩子
T = T->rchild;
}else if(!T->rchild) { //没有右孩子
T = T->lchild;
}
free(tmp);
}
}
return T;
}
平衡二叉树(AVL)
为避免树的高度增长过快,降低二叉排序树的性能,规定在插入和删除二叉树结点是,要保证任意结点的左右子树高度差的绝对值不超过1,将这一的二叉树称为平衡二叉树(Balanced Binary Tree),简称平衡树(AVL)。
平衡因子:结点左子树与右子树的高度差为该结点的平衡因子,则平衡二叉树结点的平衡因子的值只可能是-1、0、1。
插入
- 操作过程:
每当在二叉排序树中插入(或删除)一个结点时,首先检查其插入路径上的结点是否因为此次操作而导致了不平衡。若导致了不平衡,则先找到插入路径上离插入结点最近的平衡因子的绝对值大于1的结点A,再对A为根的子树,再保持二叉排序树的前提下,调整各结点的位置关系,使之重新达到平衡。
每次调整的对象都是最小不平衡子树,即以插入路径上离结点最近的平衡因子的绝对值大于1的结点作为根的子树。
将最小不平衡子树的三个节点按大小列出排序,调整三个结点(爷子孙三代)(LL,RR,LR,RL),调整成父、左孩子、右孩子,调整完毕后,将剩下的结点(小于等于四个,因为调整完毕后只有两层,现在加入第三层)顺着接到下面(若不清楚怎么顺着接,可以将其分支画出来,补充到四个,从左到右接上去就好了(空节点也要接上去))。
- LL:(三个结点:X<k1<k2)(中间大小的结点提出,另外两个放左右)
- RR:(三个结点:k1<k2<Z)
- LR:(三个结点:k1<k2<k3)(中间大小的结点提出,另外两个放左右)(孙子(k2)提出,比较另外两个,放左右)
- RL:(三个结点:k1<k2<k3)
查找
查找过程中,与给定值进行比较的关键字个数不超过树的深度。
- 性质:
假设以\(n_h\)表示深度为h的平衡树中含有的最少结点数。显然,有\(n_0\)=0,\(n_1\)=1,\(n_2\)=2,并且有\(n_h=n_{h-1}+n_{h-2}+1\)。
红黑树
红黑树需要满足的条件
-
每个节点要么是黑色,要么就是红色
-
根节点也就是root节点需要是黑色
-
红节点的子节点一定是黑节点(红节点肯定有父节点,且是黑节点)
-
叶子节点和null节点是黑节点(说明了红黑树中一半以上都是黑节点)
-
从红黑树的任意一个节点出发到它的叶子节点,它所经过的黑节点数都是相同的,这就是红黑树所需要实现的平衡(也就是说,当前节点的每一条分支路径,它们所包括的黑节点数是一样的,最差的情况就是红黑相间)
-
新插入的节点是红节点,可能在参与平衡操作时会变成黑节点(加入直接插入的节点就是黑节点的话,那么每插入一个节点肯定都要做旋转或者变色来达到平衡。但是如果新插入的是红节点且它的父节点是黑节点的话,那就直接插入,整棵树还是平衡的,就不需要再做平衡处理了)
红黑树的时间复杂度
从上面平衡二叉树中我们知道,平衡二叉树的任意节点的左右子树的深度相同或者差1,这个条件稍微有点苛刻了,这样会出现很多时候插入时出现不满足的情况,需要花时间去做一些变换。而从红黑树所需要的条件中可以推出,红黑树的任意节点的左右子树的深度相同,或者相差一倍,也就是某条分支路径上出现了红黑相间,从中可以看到,红黑树所需要的平衡条件相比于平衡二叉树要宽松的多,这种条件就使得我们在插入节点的变换会更少。
我们再来看看红黑树的时间复杂度,首先要知道树的搜索过程或者插入过程的复杂度是完全依赖于树的深度的,假如红黑树有N个节点,黑节点有N(b)个,红节点有N(r)个,即N = N(b) + N(r),我们可以先把红黑树的红节点盖住只看黑节点的话,那么整棵树其实就是一个平衡二叉树,此时的时间复杂度是就是O(logN(b)),而从上面的分析我们知道最差的情况就是红黑相间,也就是路径中红节点的个数是黑节点的两倍,此时,时间复杂度将是2O(logN(b)),又因为红黑树中黑节点占了一半以上,那么N(b)最大也就是逼近于N,即N(b) = N,此时时间复杂度就是2O(logN),也即是O(logn),到这里可以看到红黑树的时间复杂和平衡二叉树的时间复杂度都是O(logn),但是红黑树却拥有更宽松的条件,这也是为什么红黑树用的比平衡二叉树多的重要原因。
哈夫曼树(最优二叉树)
在许多实际应用中,树中结点常常被赋予一个表示某种意义的数值,称为该结点的权。
从树的根结点到任意节点的路径长度(经过的边数)与该结点上权值的乘积,称为该结点的带权路径长度。
树中所有结点的带权路径长度之和称为该树的带权路径长度(WPL)。
- \(WPL=∑路径\*结点权值=非叶子结点的权值之和\)
性质:
- 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大。
- 构造过程中共新建了n-1个结点(双分支结点),因此哈夫曼树中的结点总数为2n-1。
- 每次构造都选择2棵树作为新结点的孩子,因此哈夫曼树中不存在度为1的结点。(\(n_1\)=0)
- 字符 <-> 编码 <-> 叶子结点,一一对应。
构造:
- 在n个权值中选出两个最小的权值,对应的两个结点组成一个新的二叉树,且新二叉树的根结点的权值为左右孩子权值的和;
- 那两个最小权值的合并为如今这个新的权值;
- 即,在原有的n个权值中删除那两个最小的权值,同时将新的权值加入到n–2个权值的行列中,以此类推;
- 重复(1)和(2),直到所有的结点构建成了一棵二叉树为止,这棵树就是哈夫曼树。
注意:构建时,不要抓着那棵合成的树一直构建,当该树合成到一定程度,它就不是最小的权值了。
哈夫曼编码时,0和1究竟是表示左子树还是表示右子树没有明确规定。因此,左、右结点的顺序是任意的,所以构造出的哈夫曼树并不唯一,但各哈夫曼树的带权路径长度(WPL, Weighted Path Length)相同且为最优。
常用算法
层次遍历的应用
求树高
求树高
- 设计思想:
采用层次遍历的算法,设置变量level记录当前结点所在的层数,设置变量last指向当前层的最右结点,每次层次遍历出队时与last比较,若两者相等,则层数+1,并让last指向下一层的最右结点,直到遍历完成。出队指针用来访问遍历(遍历指针),出队遇到最右结点层数+1,其实也可以遇到下层第一个结点+1,但是不好记录第一个结点。
- 算法:
int Btdepth(BiTree T) {
if(!T) {
return 0;
}
int front = -1, rear = -1; //队头队尾,队头指向队头元素的前一个位置,队尾指向队尾元素
int last = 0, level = 0; //last指向下一层的最右结点
BiTree Q[MaxSize]; //设置队列Q,元素是二叉树结点指针且容量足够
Q[++rear] = T; //将根结点入队
BiTree p;
//其实层次遍历,是队头指针遍历(出队时访问),队尾指针只是负责增加元素
while(front < rear) { //队不空
p = Q[++front]; //队列元素出队
if(p->lchild) {
Q[++rear] = p->lchild //左孩子入队
}
if(p->rchild) {
Q[++rear] = p->rchild; //右孩子入队
}
if(front==last) { //处理该层的最右结点,front指向该层刚出队的最右节点,遇到最右结点层数+1
level++; //层数+1
last = rear; //last指向下层
}
}
return level;
}
//递归
int Btdepth2(BiTree T) {
if(!T) {
return 0;
}
ldep = Btdepth2(T->lchild); //左子树高度
rdep = Btdepth2(T->rchild); //右子树高度
if(ldep > rdep) {
return ldep+1; //树的高度为子树最大高度加根结点
}else {
return rdep+1;
}
}
求树宽
求树宽
-
设计思想:
采用层次遍历的方法求出所有结点的层次,并将所有结点和对应的层次放在一个队列中,然后通过扫描队列求出各层的结点总数,最大的层结点总数即为二叉树的宽度。MY:采用层次遍历的方法,设置宽度变量width记录宽度,max记录最大宽度,设置变量last指向当前层的最右结点,每次层次遍历出队时宽度+1 ,再与last指针比较,若两者相等(即遍历到最右结点),则width与max比较,用max记录下当前最大宽度,并将宽度清零,last指向下一层的最右结点。
-
算法:
int BTWidth(BiTree b) {
BiTree p;
int last = 0, width = 0, max = 0;
BiTree queue[MaxSize];
int front = -1, rear = -1;
queue[++rear] = b; //根结点入队
while(front < rear) {
p = queue[++front]; //出队
width++; //宽度+1
if(p->lchild) {
queue[++rear] = p->lchild;
}
if(p->rchild) {
queue[++rear] = p->rchild;
}
if(front == last) { //遍历到最右结点
if(width > max) {
max = width; //记录最大宽度
width = 0; //宽度清零
}
last = rear; //指向下一层的最右结点
}
}
return max;
}
后序遍历的应用
出结点x的所有祖先(即路径)
输出结点x的所有祖先(即路径)
- 设计思想:
采用后序遍历算法,在出栈的同时判断是否为x,如果为x输出栈内所有元素。 - 算法:
void Search(BiTree bt, ElemType x) {
BiTNode *p = bt, *r = NULL;
InitStack(S);
while(p || !IsEmpty(S)) {
if(p) { //走到最左边
push(S, p);
p = p->lchild;
}else {
getTop(S, p);
if(p->rchild && p->rchild!=r) { //如果右结点存在且最近未被访问过
p = p->rchild;
}else {
pop(S, p);
if(p->data == x) {
print(p);
while(!IsEmpty(S)) {
pop(S, p);
print(p);
}
}
r = p;
p = NULL;
}
}
}
}
最近公共祖先结点
最近公共祖先结点
- 设计思想:
后序非递归,当访问到p时,将栈中所有元素复制到临时栈T,再继续访问,访问到q时,从栈顶开始比较栈S和栈T中元素,第一个相等的元素即为最近公共祖先。 - 算法:
bool Ancestor(BiTree ROOT, BiTNode *p, BiTNode *q, BiTNode *&r) {
BiTree S[];
int Stop = -1;
BiTree T[];
int Ttop = -1;
BiTNode *x = ROOT, *visit = NULL;
while(x || Stop > -1) { //IsEmpty(S)
if(x) { //走到最左边
S[++Stop] = x; //push(S, x);
x = x->LLINK;
}else {
x = S[Stop]; //GetTop(S, x);
if(x->RLINK && x->RLINK != visit) {
x = x->RLINK;
}else {
x = S[Stop--]; //pop(S, x);
if(x == p) {
//将栈S中的所有元素复制到临时栈T
for(int i=0; i<=Stop; i++) {
T[i] = S[i];
Ttop = Stop;
}
}
if(x == q) {
//将栈S中的所有元素从栈顶开始,分别于栈T中比较,第一个相等的元素就是最近公共祖先
for(int i=Stop; i>-1; i--) {
for(int j=Ttop; j>-1; j--) {
if(S[i] == T[j]) { //相等
r = S[i]; //返回最近公共祖先
return true; //找到了
}
}
}
}
r = x;
x = NULL;
}
}
}
return false;
}
根据遍历序列建立树
根据遍历序列建立树
- 设计思想:
- 根据先序序列确定树的根结点;
- 根据根结点在中序序列中划分出二叉树的左右子树包含哪些结点,然后根据左右子树结点在先序序列中的次序确定子树的根结点(即回到步骤1);
- 上述操作,直到每棵子树仅有一个结点(该子树的根结点)为止。
- 算法:
BiTree PreInCreat(ElemType A[], ElemType B[], int l1, int h1, int l2, int h2) {
//l1,h1为先序的第一和最后一个结点下标,l2,h2为中序的第一和最后一个结点的下标
//初始调用时,l1=l2=1, h1=h2=n
root = (BiTree)malloc(sizeof(BiTNode)); //建立根结点
root->data = A[l1]; //根结点
for(i=l2; B[i]!=root->data; i++); //根结点在中序序列中的划分
llen = i-l2; //左子树长度
rlen = h2-i; //右子树长度
if(llen) { //建立左子树
root->lchild = PreInCreat(A, B, l1+1, l1+llen, l2, l2+llen-1);
}else {
root->lchild = NULL;
}
if(rlen) { //建立右子树
root->rchild = PreInCreat(A, B, h1-rlen+1, h1, h2-rlen+1, h2);
}else {
root->rchild = NULL;
}
return root;
}
通过先序和中序数组生成后序数组
给出一棵二叉树的先序和中序数组,通过这两个数组直接生成正确的后序数组。
示例1
输入
[1,2,3],[2,1,3]
输出
[2,3,1]
答案:
常规做法:先序遍历划分,中序遍历划分,public void f(int[] preOrder, int left1, int right1, int[] inOrder, int left2, int right2) {
- 先序遍历的第一个元素作为根节点
- 然后拿着根节点去中序遍历寻找可以得知其左右子树分别的数量
- 拿着这个数量,我们就可以再去先序遍历里面进行划分左右子树
- 这样就能递归得到后序遍历(左右根)了
简化做法:先序遍历找根,中序遍历划分,public void f(int[] preOrder, int root, int[] inOrder, int left, int right) {
- 在先序遍历中找出根节点
- 在中序遍历中进行左右划分
- 在中序遍历中划分后,利用划分的左右子树数量,重新在先序遍历中找到根节点。
import java.util.*;
public class Solution {
/**
*
* @param preOrder int整型一维数组 the array1
* @param inOrder int整型一维数组 the array2
* @return int整型一维数组
*/
public int[] postOrder;
public int num;
public int[] findOrder (int[] preOrder, int[] inOrder) {
// write code here
// 先序:根左右
// 中序:左根右
// 后序:左右根
postOrder = new int[preOrder.length];
f(preOrder, 0, inOrder, 0, preOrder.length - 1);
return postOrder;
}
// 从先序里面找到第一个元素,就是根节点,
// 然后带着根节点去中序里面划分出左右子树的个数
// 再带着左右子树的个数去划分先序数组
// 函数功能:从先序找出第一个root,划分中序的左右
// root仅与先序遍历中的根节点索引有关
// left和right仅与中序遍历中的左右指针划分有关
public void f(int[] preOrder, int root, int[] inOrder, int left, int right) {
if (left > right) {
return;
}
// 在中序中找到根节点位置i
int i = 0;
for (i = left; i < right && inOrder[i] != preOrder[root]; i++);
// 左
f(preOrder, root + 1, inOrder, left, i - 1);
// 右
f(preOrder, root + i - left + 1, inOrder, i + 1, right);
// 根
postOrder[num++] = preOrder[root];
}
}
【算法】分治四步走
实际应用
目录树
问题描述:
可能平常会遇到一些需求,比如构建菜单,构建树形结构,数据库一般就使用父id来表示,为了降低数据库的查询压力,我们可以使用Java8中的Stream流一次性把数据查出来,然后通过流式处理。
我们一起来看看,代码实现为了实现简单,就模拟查看数据库所有数据到List里面。
实体类:Menu.java
public class Menu {
/**
* id
*/
public Integer id;
/**
* 名称
*/
public String name;
/**
* 父id ,根节点为0
*/
public Integer parentId;
/**
* 子节点信息
*/
public List<Menu> childList;
public Menu(Integer id, String name, Integer parentId) {
this.id = id;
this.name = name;
this.parentId = parentId;
}
public Menu(Integer id, String name, Integer parentId, List<Menu> childList) {
this.id = id;
this.name = name;
this.parentId = parentId;
this.childList = childList;
}
}
使用stream
方法一:递归组装树形结构
@Test
public void testtree(){
//模拟从数据库查询出来
List<Menu> menus = Arrays.asList(
new Menu(1,"根节点",0),
new Menu(2,"子节点1",1),
new Menu(3,"子节点1.1",2),
new Menu(4,"子节点1.2",2),
new Menu(5,"根节点1.3",2),
new Menu(6,"根节点2",1),
new Menu(7,"根节点2.1",6),
new Menu(8,"根节点2.2",6),
new Menu(9,"根节点2.2.1",7),
new Menu(10,"根节点2.2.2",7),
new Menu(11,"根节点3",1),
new Menu(12,"根节点3.1",11));
//获取父节点
List<Menu> collect = menus.stream().filter(m -> m.getParentId() == 0).map(
(m) -> {
m.setChildList(getChildrens(m, menus));
return m;
}
).collect(Collectors.toList());
System.out.println("-------转json输出结果-------");
System.out.println(JSON.toJSON(collect));
}
/**
* 递归查询子节点
* @param root 根节点
* @param all 所有节点
* @return 根节点信息
*/
private List<Menu> getChildrens(Menu root, List<Menu> all) {
List<Menu> children = all.stream().filter(m -> {
return Objects.equals(m.getParentId(), root.getId());
}).map(
(m) -> {
m.setChildList(getChildrens(m, all));
return m;
}
).collect(Collectors.toList());
return children;
}
方法二:非递归组装树形结构
Map<Integer, List<Menu>> perListMap =menuList.stream().collect(Collectors.groupingBy(Menu::getParentid));
menuList.stream().forEach(item -> item.setChildren(perListMap.get(item.getFid())) );
return ActionResult.Succeed(perListMap.get(0));
方法二用到 Java8 新特性 运用stream流的技巧 ;🎉🎉代码简洁🎉 🎉
结果:
不使用stream
生成树(集合方式)
public class GetTree {
public static void main(String[] args) {
//模拟 一次select查询出来的集合
List<Category> categories = new ArrayList<>();
categories.add(new Category(1L,"一级目录1"));
categories.add(new Category(2L,"一级目录2"));
categories.add(new Category(3L,"一级目录3"));
categories.add(new Category(4L,"一级目录4"));
categories.add(new Category(5L,"一级目录5"));
categories.add(new Category(7L,"二级目录2-1",2L));
categories.add(new Category(8L,"二级目录3-1",3L));
categories.add(new Category(6L,"二级级目录1-1",1L));
categories.add(new Category(9L,"二级目录3-2",3L));
categories.add(new Category(10L,"三级目录7-10",7L));
categories.add(new Category(11L,"三级目录9-11",9L));
categories.add(new Category(12L,"四级目录11-12",11L));
CategoryDto root = new CategoryDto();
//设置根节点 默认写入的一个根节点 看实际业务场景可以改变
root.setId(0L);
root.setName("root");
HashMap<Long,CategoryDto> nodehMap = new HashMap<>();
nodehMap.put(0L,root); // 根节点
//遍历 如果没有父id 将根节点id 设置为父id
for (Category category : categories) {
if (category.getParentId() == null){
category.setParentId(0L);
}
// 放入到map集合中
nodehMap.put(category.getId(),new CategoryDto(category));
}
// 遍历map集合
for (CategoryDto value : nodehMap.values()) {
// 取出当前的 value 的上级目录
CategoryDto parent = nodehMap.get(value.getParentId());
if (parent != null){
// 存在 将他加入进去
parent.addSubNode(value);
}
}
// 返回的root 就是一个树形结构
return root
}
}
实例
特别注意:树的前中后序遍历这种深度优先遍历及其应用,都可以用递归来解决,不要执着于考研时候的迭代方法。
对于递归,我们在访问节点的时候需要判断该节点是否满足我们题目所需要的特定条件,比如 该节点是否为公共祖先。
递归:明确函数功能其实就是明确题目的目的、递归的目的。比如说一棵树,你就可以对它左子树操作、右子树操作,然后再写对根节点的操作,这样就能完成对整个树的递归。
使用递归时,我们就把树看作三个部分即可,根节点、左子树、右子树,我们只能对当前的根节点进行操作,左子树和右子树进行递归即可。
104. 二叉树的最大深度
给定一个二叉树,找出其最大深度。
二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。
说明: 叶子节点是指没有子节点的节点。
示例:
给定二叉树 [3,9,20,null,null,15,7],
3
/ \
9 20
/ \
15 7
返回它的最大深度 3 。
深度优先递归求解
我们知道,每个节点的深度与它左右子树的深度有关,且等于其左右子树最大深度值加上 1。即:
maxDepth(root) = max(maxDepth(root.left), maxDepth(root.right)) + 1
以 [3,4,20,null,null,15,7] 为例:
1.我们要对根节点的最大深度求解,就要对其左右子树的深度进行求解
2.我们看出。以4为根节点的子树没有左右节点,其深度为1。而以20为根节点的子树的深度,同样取决于它的左右子树深度。
图片
3.对于15和7的子树,我们可以一眼看出其深度为1。
4.由此我们可以得到根节点的最大深度为
maxDepth(root-3)
=max(maxDepth(sub-4),maxDepth(sub-20))+1
=max(1,max(maxDepth(sub-15),maxDepth(sub-7))+1)+1
=max(1,max(1,1)+1)+1
=max(1,2)+1
=3
根据分析,我们通过递归进行求解:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public int maxDepth(TreeNode root) {
// 递归解决
// 深度 = max(左深度, 右深度) + 1
if (root == null) {
return 0;
}
int left = maxDepth(root.left);
int right = maxDepth(root.right);
return left > right? left + 1 : right + 1;
}
}
DFS迭代
其实我们上面用的递归方式,本质上是使用了DFS的思想。先介绍一下DFS:深度优先搜索算法(Depth First Search),对于二叉树而言,它沿着树的深度遍历树的节点,尽可能深的搜索树的分支,这一过程一直进行到已发现从源节点可达的所有节点为止。
如上图二叉树,它的访问顺序为:
A-B-D-E-C-F-G
到这里,我们思考一个问题?虽然我们用递归的方式根据DFS的思想顺利完成了题目。但是这种方式的缺点却显而易见。因为在递归中,如果层级过深,我们很可能保存过多的临时变量,导致栈溢出。这也是为什么我们一般不在后台代码中使用递归的原因。如果不理解,下面我们详细说明:
事实上,函数调用的参数是通过栈空间来传递的,在调用过程中会占用线程的栈资源。而递归调用,只有走到最后的结束点后函数才能依次退出,而未到达最后的结束点之前,占用的栈空间一直没有释放,如果递归调用次数过多,就可能导致占用的栈资源超过线程的最大值,从而导致栈溢出,导致程序的异常退出。
所以,我们引出下面的话题:如何将递归的代码转化成非递归的形式。这里请记住,99%的递归转非递归,都可以通过栈来进行实现。
非递归的DFS,代码如下:
//java
private List<TreeNode> traversal(TreeNode root) {
List<TreeNode> res = new ArrayList<>();
Stack<TreeNode> stack = new Stack<>();
stack.add(root);
while (!stack.empty()) {
TreeNode node = stack.peek();
res.add(node);
stack.pop();
if (node.right != null) {
stack.push(node.right);
}
if (node.left != null) {
stack.push(node.left);
}
}
return res;
}
上面的代码,唯一需要强调的是,为什么需要先右后左压入数据?是因为我们需要将先访问的数据,后压入栈(请思考栈的特点)。
如果不理解代码,请看下图:
1:首先将a压入栈
2:a弹栈,将c、b压入栈(注意顺序)
3:b弹栈,将e、d压入栈
4,5:d、e、c弹栈,将g、f压入栈
6:f、g弹栈
层次遍历解决
思路与算法
我们也可以用「广度优先搜索」的方法来解决这道题目,但我们需要对其进行一些修改,此时我们广度优先搜索的队列里存放的是「当前层的所有节点」。每次拓展下一层的时候,不同于广度优先搜索的每次只从队列里拿出一个节点,我们需要将队列里的所有节点都拿出来进行拓展,这样能保证每次拓展完的时候队列里存放的是当前层的所有节点,即我们是一层一层地进行拓展,最后我们用一个变量 ans 来维护拓展的次数,该二叉树的最大深度即为 ans。
可以看到,与上面的深度优先遍历,只是一个使用队列、一个使用栈的区别而已。。
// 层次遍历迭代解决
class Solution {
public int maxDepth(TreeNode root) {
if (root == null) {
return 0;
}
Queue<TreeNode> queue = new LinkedList<TreeNode>();
queue.offer(root); // 这里我测试了一下,不能加入空元素
int ans = 0;
while (!queue.isEmpty()) {
int size = queue.size(); // 获取一层拥有的节点数
// 这里循环的用意是:清空该层的所有结点,下一次获取队列容量就会是下一层的节点数了
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
}
ans++;
}
return ans;
}
}
102. 二叉树的层序遍历
给你一个二叉树,请你返回其按 层序遍历 得到的节点值。 (即逐层地,从左到右访问所有节点)。
示例:
二叉树:[3,9,20,null,null,15,7],
3
/ \
9 20
/ \
15 7
返回其层序遍历结果:
[
[3],
[9,20],
[15,7]
]
答案
层次遍历同上
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) {
return result;
}
Queue<TreeNode> queue = new LinkedList<>();
int last = 0;
queue.add(root);
while (!queue.isEmpty()) {
List<Integer> list = new ArrayList<>();
int size = queue.size();
for (int i = 0; i < size; i++) {
TreeNode node = queue.remove();
list.add(node.val);
if (node.left != null) {
queue.add(node.left);
}
if (node.right != null) {
queue.add(node.right);
}
}
result.add(list);
}
return result;
}
}
103. 二叉树的锯齿形层序遍历
给你二叉树的根节点 root ,返回其节点值的 锯齿形层序遍历 。(即先从左往右,再从右往左进行下一层遍历,以此类推,层与层之间交替进行)。
示例 1:
输入:root = [3,9,20,null,null,15,7]
输出:[[3],[20,9],[15,7]]
示例 2:
输入:root = [1]
输出:[[1]]
示例 3:
输入:root = []
输出:[]
广度优先遍历
此题是「102. 二叉树的层序遍历」的变种,最后输出的要求有所变化,要求我们按层数的奇偶来决定每一层的输出顺序。规定二叉树的根节点为第 0 层,如果当前层数是偶数,从左至右输出当前层的节点值,否则,从右至左输出当前层的节点值。
我们依然可以沿用第 102 题的思想,修改广度优先搜索,对树进行逐层遍历,用队列维护当前层的所有元素,当队列不为空的时候,求得当前队列的长度 size,每次从队列中取出 size 个元素进行拓展,然后进行下一次迭代。
为了满足题目要求的返回值为「先从左往右,再从右往左」交替输出的锯齿形,我们可以利用「双端队列」的数据结构来维护当前层节点值输出的顺序。
双端队列是一个可以在队列任意一端插入元素的队列。在广度优先搜索遍历当前层节点拓展下一层节点的时候我们仍然从左往右按顺序拓展,但是对当前层节点的存储我们维护一个变量 isOrderLeft 记录是从左至右还是从右至左的:
如果从左至右,我们每次将被遍历到的元素插入至双端队列的末尾。
如果从右至左,我们每次将被遍历到的元素插入至双端队列的头部。
当遍历结束的时候我们就得到了答案数组。
class Solution {
public List<List<Integer>> zigzagLevelOrder(TreeNode root) {
List<List<Integer>> ans = new LinkedList<List<Integer>>();
if (root == null) {
return ans;
}
// 层次遍历队列
Queue<TreeNode> nodeQueue = new LinkedList<TreeNode>();
nodeQueue.offer(root);
// 标记顺序与逆序
boolean isOrderLeft = true;
while (!nodeQueue.isEmpty()) {
// 按层遍历,记录每一层的节点
Deque<Integer> levelList = new LinkedList<Integer>();
int size = nodeQueue.size();
for (int i = 0; i < size; ++i) {
TreeNode curNode = nodeQueue.poll();
if (isOrderLeft) {
levelList.offerLast(curNode.val);
} else {
levelList.offerFirst(curNode.val);
}
if (curNode.left != null) {
nodeQueue.offer(curNode.left);
}
if (curNode.right != null) {
nodeQueue.offer(curNode.right);
}
}
ans.add(new LinkedList<Integer>(levelList));
isOrderLeft = !isOrderLeft;
}
return ans;
}
}
98. 验证二叉搜索树
给定一个二叉树,判断其是否是一个有效的二叉搜索树。
假设一个二叉搜索树具有如下特征:
- 节点的左子树只包含小于当前节点的数。
- 节点的右子树只包含大于当前节点的数。
- 所有左子树和右子树自身必须也是二叉搜索树。
示例 1:
输入:
2
/ \
1 3
输出: true
示例 2:
输入:
5
/ \
1 4
/ \
3 6
输出: false
解释: 输入为: [5,1,4,null,null,3,6]。
根节点的值为 5 ,但是其右子节点值为 4 。
递归答案
首先看完题目,我们很容易想到 遍历整棵树,比较所有节点,通过 左节点值<节点值,右节点值>节点值 的方式来进行求解。但是这种解法是错误的,因为对于任意一个节点,我们不光需要左节点值小于该节点,并且左子树上的所有节点值都需要小于该节点。(右节点一致)所以我们在此引入上界与下界,用以保存之前的节点中出现的最大值与最小值。
明确了题目,我们直接使用递归进行求解。这里需要强调的是,在每次递归中,我们除了进行左右节点的校验,还需要与上下界进行判断。由于该递归分析有一定难度,所以我们先展示代码:
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
public boolean isValidBST(TreeNode root) {
// 这里需要使用long类型来存储上下界,不然会被root.val的int类型超越
return isBST(root, Long.MIN_VALUE, Long.MAX_VALUE);
}
// 节点的左子树只包含小于当前节点的数。
// 节点的右子树只包含大于当前节点的数。
// 所以我们需要设置上下界,确保我们的子树在某一区间范围内
// 函数功能:给出结点与上下界,判断结点是否满足上下界条件,递归。
// 这里需要使用long类型来存储上下界,不然会被root.val的int类型超越
public boolean isBST(TreeNode root, long min, long max) {
if (root == null) {
return true;
}
if (min >= root.val || max <= root.val) {
return false;
}
return isBST(root.left, min, root.val) && isBST(root.right, root.val, max);
}
}
方法二:中序遍历
思路和算法
基于方法一中提及的性质,我们可以进一步知道二叉搜索树「中序遍历」得到的值构成的序列一定是升序的,这启示我们在中序遍历的时候实时检查当前节点的值是否大于前一个中序遍历到的节点的值即可。如果均大于说明这个序列是升序的,整棵树是二叉搜索树,否则不是,下面的代码我们使用栈来模拟中序遍历的过程。
可能由读者不知道中序遍历是什么,我们这里简单提及一下,中序遍历是二叉树的一种遍历方式,它先遍历左子树,再遍历根节点,最后遍历右子树。而我们二叉搜索树保证了左子树的节点的值均小于根节点的值,根节点的值均小于右子树的值,因此中序遍历以后得到的序列一定是升序序列。
class Solution {
public boolean isValidBST(TreeNode root) {
Deque<TreeNode> stack = new LinkedList<TreeNode>();
double inorder = -Double.MAX_VALUE;
while (!stack.isEmpty() || root != null) {
while (root != null) {
stack.push(root);
root = root.left;
}
root = stack.pop();
// 如果中序遍历得到的节点的值小于等于前一个 inorder,说明不是二叉搜索树
if (root.val <= inorder) {
return false;
}
inorder = root.val;
root = root.right;
}
return true;
}
}
我的:
class Solution {
public boolean isValidBST(TreeNode root) {
// 这里相当于无穷小
long preVisit = Long.MIN_VALUE;
TreeNode p = root; // 操作指针
Stack<TreeNode> stack = new Stack<>();
// 中序遍历
while (p != null || !stack.empty()) {
if (p != null) {
stack.push(p);
p = p.left;
} else {
p = stack.pop();
// 如果中序遍历前一个元素大于等于后一个元素,代表这个不是二叉排序树
// 因为二叉排序树的中序遍历是递增的,且无重复值
if (preVisit >= p.val) {
return false;
} else {
preVisit = p.val;
p = p.right;
}
}
}
return true;
}
}
700. 二叉搜索树中的搜索
给定二叉搜索树(BST)的根节点和一个值。 你需要在BST中找到节点值等于给定值的节点。 返回以该节点为根的子树。 如果节点不存在,则返回 NULL。
例如,
给定二叉搜索树:
4
/ \
2 7
/ \
1 3
和值: 2
你应该返回如下子树:
2
/ \
1 3
答案
假设目标值为 val。
根据BST的特性,我们可以很容易想到查找过程
-
如果val小于当前结点的值,转向其左子树继续搜索;
-
如果val大于当前结点的值,转向其右子树继续搜索;
-
如果已找到,则返回当前结点。
代码如下
给出递归和迭代两种解法:
//java
//递归
public TreeNode searchBST(TreeNode root, int val) {
if (root == null)
return null;
if (root.val > val) {
return searchBST(root.left, val);
} else if (root.val < val) {
return searchBST(root.right, val);
} else {
return root;
}
}
//迭代
public TreeNode searchBST(TreeNode root, int val) {
while (root != null) {
if (root.val == val) {
return root;
} else if (root.val > val) {
root = root.left;
} else {
root = root.right;
}
}
return null;
}
在上述示例中,如果要找的值是 5,但因为没有节点值为 5,我们应该返回 NULL。
递归与迭代的区别
- 递归:重复调用函数自身实现循环称为递归;
- 迭代:利用变量的原值推出新值称为迭代,或者说迭代是函数内某段代码实现循环;
450. 删除二叉搜索树中的节点
给定一个二叉搜索树的根节点 root 和一个值 key,删除二叉搜索树中的 key 对应的节点,并保证二叉搜索树的性质不变。返回二叉搜索树(有可能被更新)的根节点的引用。
一般来说,删除节点可分为两个步骤:
- 首先找到需要删除的节点;
- 如果找到了,删除它。
说明: 要求算法时间复杂度为 O(h),h 为树的高度。
示例:
root = [5,3,6,2,4,null,7]
key = 3
5
/ \
3 6
/ \ \
2 4 7
给定需要删除的节点值是 3,所以我们首先找到 3 这个节点,然后删除它。
一个正确的答案是 [5,4,6,2,null,null,7], 如下图所示。
5
/ \
4 6
/ \
2 7
另一个正确答案是 [5,2,6,null,4,null,7]。
5
/ \
2 6
\ \
4 7
递归答案
明确了概念,我们进行分析。我们要删除BST的一个节点,首先需要找到该节点。而找到之后,会出现三种情况。
-
待删除的节点左子树为空,让待删除节点的右子树替代自己。
-
待删除的节点右子树为空,让待删除节点的左子树替代自己。
-
如果待删除的节点的左右子树都不为空。我们需要找到比当前节点小的最大节点(前驱),来替换自己
-
或者比当前节点大的最小节点(后继),来替换自己。
分析完毕,直接上代码。
这里我们给出通过后继节点来替代自己的方案(请后面自行动手实现另一种方案):
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
// 删除节点,返回删除后的根节点避免断链
public TreeNode deleteNode(TreeNode root, int key) {
// 递归,毕竟无法确定循环层数
if (root == null) {
return null;
}
if (key < root.val) { // 关键字小于根节点值,向左走,删除左子树中的值,返回的结点为左子树
root.left = deleteNode(root.left, key);
} else if (key > root.val) { // 关键字大于根节点值,向右走,删除右子树中的值,返回的结点为右子树
root.right = deleteNode(root.right, key);
} else { // 关键字等于根节点值
if (root.left != null && root.right != null) { // 左右子树不为空,那就将前驱结点替换根节点,删除前驱结点
TreeNode temp = findMin(root.right);
root.val = temp.val;
root.right = deleteNode(root.right, root.val);
} else { // 左右子树至少有一个为空
if (root.left != null) { // 左子树不为空,返回左子树(删除根)
return root.left;
} else if (root.right != null) { // 右子树不为空,返回右子树(删除根)
return root.right;
} else { // 左右子树都为空,返回null(删除根)
return null;
}
}
}
// 最后需要返回根节点的引用
return root;
}
// 一路向左,找到最小元素
public TreeNode findMin(TreeNode root) {
if (root == null) {
return null;
}
while (root.left != null) {
root = root.left;
}
return root;
}
}
迭代答案
方法一的递归深度最多为 n,而大部分是由寻找值为 key 的节点贡献的,而寻找节点这一部分可以用迭代来优化。寻找并删除 successor 时,也可以用一个变量保存它的父节点,从而可以节省一步递归操作。
class Solution {
public TreeNode deleteNode(TreeNode root, int key) {
TreeNode cur = root, curParent = null;
while (cur != null && cur.val != key) {
curParent = cur;
if (cur.val > key) {
cur = cur.left;
} else {
cur = cur.right;
}
}
if (cur == null) {
return root;
}
if (cur.left == null && cur.right == null) {
cur = null;
} else if (cur.right == null) {
cur = cur.left;
} else if (cur.left == null) {
cur = cur.right;
} else {
TreeNode successor = cur.right, successorParent = cur;
while (successor.left != null) {
successorParent = successor;
successor = successor.left;
}
if (successorParent.val == cur.val) {
successorParent.right = successor.right;
} else {
successorParent.left = successor.right;
}
successor.right = cur.right;
successor.left = cur.left;
cur = successor;
}
if (curParent == null) {
return cur;
} else {
if (curParent.left != null && curParent.left.val == key) {
curParent.left = cur;
} else {
curParent.right = cur;
}
return root;
}
}
}
剑指 Offer 40. 最小的k个数
最小的k个数:输入整数数组 arr ,找出其中最小的 k 个数。例如,输入4、5、1、6、2、7、3、8这8个数字,则最小的4个数字是1、2、3、4。
示例 1:
输入:arr = [3,2,1], k = 2
输出:[1,2] 或者 [2,1]
示例 2:
输入:arr = [0,1,2,1], k = 1
输出:[0]
限制:
0 <= k <= arr.length <= 10000
0 <= arr[i] <= 10000
堆和大小顶堆
这道题出自《剑指offer》,是一道非常高频的题目。可以通过排序等多种方法求解。但是这里,我们使用较为经典的大顶堆(大根堆)解法进行求解。因为我知道有很多人可能一脸懵逼,所以,我们先复习一下大顶堆。
首先复习一下堆,堆(Heap)是计算机科学中一类特殊的数据结构的统称,我们通常是指一个可以被看做一棵完全二叉树的数组对象。
堆的特性是父节点的值总是比其两个子节点的值大或小。如果父节点比它的两个子节点的值都要大,我们叫做大顶堆。如果父节点比它的两个子节点的值都要小,我们叫做小顶堆。
我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子。
大顶堆,满足以下公式
小顶堆也一样:
小顶堆,满足以下公式
答案
上面我们学习了大顶堆,现在考虑如何用大根堆进行求解。
首先,我们创建一个大小为k的大顶堆。假如数组为[4,5,1,6,2,7,3,8],k=4。大概是下面这样:
我想肯定这里有不知道如何建堆的同学。记住:对于一个没有维护过的堆(完全二叉树),我们可以从其最后一个节点的父节点开始进行调整。这个不需要死记硬背,其实就是一个层层调节的过程。
(从最后一个节点的父节点调整)
(继续向上调整)
(继续向上调整)
建堆+调整的代码大概就是这样:(翻Java牌子)
//建堆。对于一个还没维护过的堆,从他的最后一个节点的父节点开始进行调整。
private void buildHeap(int[] nums) {
//最后一个节点
int lastNode = nums.length - 1;
//记住:父节点 = (i - 1) / 2 左节点 = 2 * i + 1 右节点 = 2 * i + 2;
//最后一个节点的父节点
int startHeapify = (lastNode - 1) / 2;
while (startHeapify >= 0) {
//不断调整建堆的过程
heapify(nums, startHeapify--);
}
}
//调整大顶堆的过程
private void heapify(int[] nums, int i) {
//和当前节点的左右节点比较,如果节点中有更大的数,那么交换,并继续对交换后的节点进行维护
int len = nums.length;
if (i >= len)
return;
//左右子节点
int c1 = ((i << 1) + 1), c2 = ((i << 1) + 2);
//假定当前节点最大
int max = i;
//如果左子节点比较大,更新max = c1;
if (c1 < len && nums[c1] > nums[max]) max = c1;
//如果右子节点比较大,更新max = c2;
if (c2 < len && nums[c2] > nums[max]) max = c2;
//如果最大的数不是节点i的话,那么heapify(nums, max),即调整节点i的子树。
if (max != i) {
swap(nums, max, i);
//递归处理
heapify(nums, max);
}
}
private void swap(int[] nums, int i, int j) {
nums[i] = nums[i] + nums[j] - (nums[j] = nums[i]);
}
然后我们从下标 k 继续开始依次遍历数组的剩余元素。如果元素小于堆顶元素,那么取出堆顶元素,将当前元素入堆。在上面的示例中 ,因为2小于堆顶元素6,所以将2入堆。我们发现现在的完全二叉树不满足大顶堆,所以对其进行调整。
(调整前)
(调整后)
继续重复上述步骤,依次将7,3,8入堆。这里因为7和8都大于堆顶元素5,所以只有3会入堆。
(调整前)
(调整后)
最后得到的堆,就是我们想要的结果。由于堆的大小是 K,所以这里空间复杂度是O(K),时间复杂度是O(NlogK)。
根据分析,完成代码:
//java
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if (k == 0)
return new int[0];
int len = arr.length;
if (k == len)
return arr;
//对arr数组的前k个数建堆
int[] heap = new int[k];
System.arraycopy(arr, 0, heap, 0, k);
buildHeap(heap);
//对后面较小的树建堆
for (int i = k; i < len; i++) {
if (arr[i] < heap[0]) {
heap[0] = arr[i];
heapify(heap, 0);
}
}
//返回这个堆
return heap;
}
private void buildHeap(int[] nums) {
int lastNode = nums.length - 1;
int startHeapify = (lastNode - 1) / 2;
while (startHeapify >= 0) {
heapify(nums, startHeapify--);
}
}
private void heapify(int[] nums, int i) {
int len = nums.length;
if (i >= len)
return;
int c1 = ((i << 1) + 1), c2 = ((i << 1) + 2);
int max = i;
if (c1 < len && nums[c1] > nums[max]) max = c1;
if (c2 < len && nums[c2] > nums[max]) max = c2;
if (max != i) {
swap(nums, max, i);
heapify(nums, max);
}
}
private void swap(int[] nums, int i, int j) {
nums[i] = nums[i] + nums[j] - (nums[j] = nums[i]);
}
}
大根堆(前 K 小) / 小根堆(前 K 大),Java中有现成的 PriorityQueue,实现起来最简单:\(O(NlogK)\)
本题是求前 K 小,因此用一个容量为 K 的大根堆,每次 poll 出最大的数,那堆中保留的就是前 K 小啦(注意不是小根堆!小根堆的话需要把全部的元素都入堆,那是 \(O(NlogN)\),就不是 \(O(NlogK)\)啦~~)
这个方法比快排慢,但是因为 Java 中提供了现成的 PriorityQueue(默认小根堆),所以实现起来最简单,没几行代码~
上面自己实现堆可能有点麻烦,所以我们使用Java自带的PriorityQueue优先队列,它是使用堆来实现的,默认为小顶堆,我们改一下比较器即可。
使用API:
// 保持堆的大小为K,然后遍历数组中的数字,遍历的时候做如下判断:
// 1. 若目前堆的大小小于K,将当前数字放入堆中。
// 2. 否则判断当前数字与大根堆堆顶元素的大小关系,如果当前数字比大根堆堆顶还大,这个数就直接跳过;
// 反之如果当前数字比大根堆堆顶小,先poll掉堆顶,再将该数字放入堆中。
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if (k == 0 || arr.length == 0) {
return new int[0]; // 返回长度为0的空数组
}
// 默认是小根堆,实现大根堆需要重写一下比较器。
// 我们需要一个容量为k的大顶堆,后面的数字来和顶进行比较,比它小就替换,调整堆
Queue<Integer> heap = new PriorityQueue<>((v1, v2) -> v2 - v1);
// 建立一个容量为k的大顶堆
for (int i = 0; i < k; i++) {
heap.add(arr[i]);
}
// 后面的数字和顶进行比较,比他小就替换,调整堆
for (int i = k; i < arr.length; i++) {
if (arr[i] < heap.peek()) {
heap.remove();
heap.add(arr[i]);
}
}
// 将队列转化为int[]数组
return heap.stream().mapToInt(Integer::valueOf).toArray();
}
}
大佬解法:更多解法
剑指 Offer 07. 重建二叉树
输入某二叉树的前序遍历和中序遍历的结果,请重建该二叉树。假设输入的前序遍历和中序遍历的结果中都不含重复的数字。
例如,给出
前序遍历 preorder = [3,9,20,15,7]
中序遍历 inorder = [9,3,15,20,7]
返回如下的二叉树:
3
/ \
9 20
/ \
15 7
答案
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public TreeNode buildTree(int[] preorder, int[] inorder) {
// 1. 从前序遍历中找到第一个节点作为根节点
// 2. 再拿着这个节点去中序遍历中找到它,并将数组二分,得到左右子树的节点个数
// 3. 拿着节点个数去前序遍历,开始分第二波,先分左子树,再分右子树
return build(preorder, 0, preorder.length - 1, inorder, 0, inorder.length - 1);
}
// 前序遍历数组,起始位置,终止位置,中序遍历数组,起始位置,终止位置
public TreeNode build(int[] preorder, int p1, int p2, int[] inorder, int i1, int i2) {
if (p1 > p2) { // p1大于p2的时候,递归出口,毕竟二分法。。
return null;
}
TreeNode root = new TreeNode(preorder[p1]);
int index = 0;
for (index = i1; inorder[index] != root.val && index <= i2; index++); // 在中序遍历中找到该节点
// 二分
int leftLength = index - i1;
int rightLength = i2 - index;
// 左子树
if (leftLength != 0) {
root.left = build(preorder, p1 + 1, p1 + leftLength, inorder, i1, i1 + leftLength - 1);
} else {
root.left = null;
}
// 右子树
if (rightLength != 0) {
// 倒着找到右子树
// root.right = build(preorder, p2 - rightLength + 1, p2, inorder, i2 - rightLength + 1, i2);
// 顺着找到右子树,两个都可以,选取你喜欢的
root.right = build(preorder, p1 + leftLength + 1, p2, inorder, index + 1, index + rightLength);
} else {
root.right = null;
}
return root;
}
}
答案二(哈希)
// 我们不一个一个找index了,直接哈希映射试试看
class Solution {
Map<Integer, Integer> map = new HashMap<>();
public TreeNode buildTree(int[] preorder, int[] inorder) {
// 1. 从前序遍历中找到第一个节点作为根节点
// 2. 再拿着这个节点去中序遍历中找到它,并将数组二分,得到左右子树的节点个数
// 3. 拿着节点个数去前序遍历,开始分第二波,先分左子树,再分右子树
for (int i = 0; i < inorder.length; i++) {
map.put(inorder[i], i);
}
return build(preorder, 0, preorder.length - 1, inorder, 0, inorder.length - 1);
}
// 前序遍历数组,起始位置,终止位置,中序遍历数组,起始位置,终止位置
public TreeNode build(int[] preorder, int p1, int p2, int[] inorder, int i1, int i2) {
if (p1 > p2) { // p1大于p2的时候,递归出口,毕竟二分法。。
return null;
}
TreeNode root = new TreeNode(preorder[p1]);
int index = 0;
// for (index = i1; inorder[index] != root.val && index <= i2; index++); // 在中序遍历中找到该节点
index = map.get(root.val);
// 二分
int leftLength = index - i1;
int rightLength = i2 - index;
// 左子树
if (leftLength != 0) {
root.left = build(preorder, p1 + 1, p1 + leftLength, inorder, i1, i1 + leftLength - 1);
} else {
root.left = null;
}
// 右子树
if (rightLength != 0) {
// 倒着找到右子树
// root.right = build(preorder, p2 - rightLength + 1, p2, inorder, i2 - rightLength + 1, i2);
// 顺着找到右子树,两个都可以,选取你喜欢的
root.right = build(preorder, p1 + leftLength + 1, p2, inorder, index + 1, index + rightLength);
} else {
root.right = null;
}
return root;
}
}
剑指 Offer 36. 二叉搜索树与双向链表
输入一棵二叉搜索树,将该二叉搜索树转换成一个排序的循环双向链表。要求不能创建任何新的节点,只能调整树中节点指针的指向。
为了让您更好地理解问题,以下面的二叉搜索树为例:
4
/ \
2 5
/ \
1 3
我们希望将这个二叉搜索树转化为双向循环链表。链表中的每个节点都有一个前驱和后继指针。对于双向循环链表,第一个节点的前驱是最后一个节点,最后一个节点的后继是第一个节点。
下图展示了上面的二叉搜索树转化成的链表。“head” 表示指向链表中有最小元素的节点。
1 <--> 2 <--> 3 <--> 4 <--> 5 <--> 1
特别地,我们希望可以就地完成转换操作。当转化完成以后,树中节点的左指针需要指向前驱,树中节点的右指针需要指向后继。还需要返回链表中的第一个节点的指针。
答案
/*
// Definition for a Node.
class Node {
public int val;
public Node left;
public Node right;
public Node() {}
public Node(int _val) {
val = _val;
}
public Node(int _val,Node _left,Node _right) {
val = _val;
left = _left;
right = _right;
}
};
*/
class Solution {
Node pre, head; // 前驱指针、头指针放在外面作为成员变量避免修改
public Node treeToDoublyList(Node root) {
if(root == null) {
return null;
}
dfs(root);
// 最后的时候cur为null,pre为最后一个节点
// 我们如果需要制作循环链表的话,我们需要将头尾相连
head.left = pre;
pre.right = head;
return head;
}
void dfs(Node cur) {
if(cur == null) {
return;
}
// 左根右
// 左
dfs(cur.left);
// 根
if(pre != null) {
// 连接双向链表
pre.right = cur;
cur.left = pre;
} else {
head = cur;
}
pre = cur;
// 右
dfs(cur.right);
}
}
剑指 Offer 54. 二叉搜索树的第k大节点
给定一棵二叉搜索树,请找出其中第k大的节点。
示例 1:
输入: root = [3,1,4,null,2], k = 1
3
/ \
1 4
\
2
输出: 4
示例 2:
输入: root = [5,3,6,2,4,null,null,1], k = 3
5
/ \
3 6
/ \
2 4
/
1
输出: 4
答案
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
int res, k;
public int kthLargest(TreeNode root, int k) {
this.k = k;
dfs(root);
return res;
}
// 其实就是中序遍历的变种
void dfs(TreeNode root) {
if(root == null || res != 0) return;
// 从右边开始,因为是找第k大的节点
dfs(root.right);
// 找第k个
k--;
if(k == 0) {
res = root.val;
return;
}
dfs(root.left);
}
}
剑指 Offer 26. 树的子结构
输入两棵二叉树A和B,判断B是不是A的子结构。(约定空树不是任意一个树的子结构)
B是A的子结构, 即 A中有出现和B相同的结构和节点值。
例如:
给定的树 A:
3
/ \
4 5
/ \
1 2
给定的树 B:
4
/
1
返回 true,因为 B 与 A 的一个子树拥有相同的结构和节点值。
示例 1:
输入:A = [1,2,3], B = [3,1]
输出:false
示例 2:
输入:A = [3,4,5,1,2], B = [4,1]
输出:true
我的
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
// 是否是子结构
public boolean isSubStructure(TreeNode A, TreeNode B) {
if (A == null || B == null) return false;
return f(A, B) || isSubStructure(A.left, B) || isSubStructure(A.right, B);
}
// 是否从根节点开始就是子结构
public boolean f(TreeNode A, TreeNode B) {
if (B == null) return true;
if (A == null) return false;
// 原问题 = 当前问题 + 子类问题
return A.val == B.val && f(A.left, B.left) && f(A.right, B.right);
}
}
答案
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public boolean isSubStructure(TreeNode A, TreeNode B) {
if (A == null || B == null) {
return false;
}
//先从根节点判断B是不是A的子结构,如果不是在分别从左右两个子树判断,
//只要有一个为true,就说明B是A的子结构
return recur(A, B) || isSubStructure(A.left, B) || isSubStructure(A.right, B);
}
// 同步遍历,不相同就返回false
public boolean recur(TreeNode A, TreeNode B) {
//这里如果B为空,说明B已经访问完了,确定是A的子结构
if (B == null)
return true;
//如果B不为空A为空,或者这两个节点值不同,说明B树不是
//A的子结构,直接返回false
if (A == null || A.val != B.val)
return false;
//当前节点比较完之后还要继续判断左右子节点
return recur(A.left, B.left) && recur(A.right, B.right);
}
}
剑指 Offer 33. 二叉搜索树的后序遍历序列
输入一个整数数组,判断该数组是不是某二叉搜索树的后序遍历结果。如果是则返回 true,否则返回 false。假设输入的数组的任意两个数字都互不相同。
参考以下这颗二叉搜索树:
5
/ \
2 6
/ \
1 3
示例 1:
输入: [1,6,3,2,5]
输出: false
示例 2:
输入: [1,3,2,6,5]
输出: true
答案
class Solution {
public boolean verifyPostorder(int[] postorder) {
// 二叉搜索树,中序遍历是升序排序的
// 左右根,小大等于
// 1. 获取最后一个节点,作为根节点
// 2. 向左寻找,记录右子树的节点个数(顺着都比根节点大),记录左子树的节点个数(顺着都比根节点小)
// 3. 从右子树继续找最后一个节点,作为根节点
// 4. 从左子树继续找最后一个节点,作为根节点
return f(postorder, 0, postorder.length - 1);
}
// 分治算法,划分 左右根
public boolean f(int[] postorder, int left, int right) {
// 相遇了,那就没必要再分了
if (left >= right) {
return true;
}
int p = right - 1; // 操作指针
// 遇到大于就后退,记录下第一个小于的下标
while (p >= left && postorder[p] > postorder[right]) {
p--;
}
int leftRoot = p; // 第一个小于的下标,是左子树的根节点
while (p >= left && postorder[p] < postorder[right]) {
p--;
}
// 上面是从后往前找,我们也可以从前往后找
// int p = left;
// while(postorder[p] < postorder[right]) p++;
// int m = p;
// while(postorder[p] > postorder[right]) p++;
// return p == right && f(postorder, left, m - 1) && f(postorder, m, right - 1);
// p最后减到了left-1,代表遍历完了,当前节点满足条件
// 开始划分左右子树,查看是否满足条件
return p == left - 1 && f(postorder, left, leftRoot) && f(postorder, leftRoot + 1, right - 1);
}
}
剑指 Offer 34. 二叉树中和为某一值的路径
输入一棵二叉树和一个整数,打印出二叉树中节点值的和为输入整数的所有路径。从树的根节点开始往下一直到叶节点所经过的节点形成一条路径。
示例:
给定如下二叉树,以及目标和 target = 22,
5
/ \
4 8
/ / \
11 13 4
/ \ / \
7 2 5 1
返回:
[
[5,4,11,2],
[5,8,4,5]
]
答案
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> list = new ArrayList<>();
public List<List<Integer>> pathSum(TreeNode root, int target) {
dfs(root, target);
return res;
}
// 一看dfs,递归
public void dfs(TreeNode root, int target) {
if (root == null) {
return;
}
list.add(root.val);
target -= root.val;
// 这里题目说明了,要从根节点开始一直到叶节点
if (target == 0 && root.left == null && root.right == null) {
res.add(new ArrayList<>(list));
}
dfs(root.left, target);
dfs(root.right, target);
// 还原现场
list.remove(list.size() - 1);
}
}
class Solution {
LinkedList<List<Integer>> res = new LinkedList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> pathSum(TreeNode root, int sum) {
recur(root, sum);
return res;
}
void recur(TreeNode root, int tar) {
if(root == null) return;
path.add(root.val);
tar -= root.val;
if(tar == 0 && root.left == null && root.right == null)
res.add(new LinkedList(path));
recur(root.left, tar);
recur(root.right, tar);
path.removeLast(); // 这里用链表,更容易还原现场。。。
}
}
剑指 Offer 32 - III. 从上到下打印二叉树 III
请实现一个函数按照之字形顺序打印二叉树,即第一行按照从左到右的顺序打印,第二层按照从右到左的顺序打印,第三行再按照从左到右的顺序打印,其他行以此类推。
例如:
给定二叉树: [3,9,20,null,null,15,7],
3
/ \
9 20
/ \
15 7
返回其层次遍历结果:
[
[3],
[20,9],
[15,7]
]
答案
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
if(root == null) {
return new ArrayList<List<Integer>>();
}
Queue<TreeNode> queue = new LinkedList<>(); // 单向队列,用来层次遍历
List<List<Integer>> res = new ArrayList<>(); // 列表用来存放结果
queue.add(root);
while(!queue.isEmpty()) {
LinkedList<Integer> tmp = new LinkedList<>(); //双端队列
for(int i = queue.size(); i > 0; i--) { // 这是一个很聪明的办法啊,不然我们就得像下面那样提前获取size,否则queue.size()会被修改
// int size = queue.size();
// for(int i = 0; i < size; i++) { // 每遍历一层都把一整层都输出
TreeNode node = queue.poll(); // 出队访问,这里会修改queue.size()的值,一定要注意
if(res.size() % 2 == 0) { // 判断奇偶
tmp.addLast(node.val); // 偶数层 -> 队列头部,这样能保证倒序,从右到左
} else {
tmp.addFirst(node.val); // 奇数层 -> 队列尾部,这样能保证顺序,从左到右
}
// 层次遍历加左右节点
if(node.left != null) {
queue.add(node.left);
}
if(node.right != null) {
queue.add(node.right);
}
}
// 加入结果列表
res.add(tmp);
}
return res;
}
}
剑指 Offer 55 - I. 二叉树的深度
输入一棵二叉树的根节点,求该树的深度。从根节点到叶节点依次经过的节点(含根、叶节点)形成树的一条路径,最长路径的长度为树的深度。
例如:
给定二叉树 [3,9,20,null,null,15,7],
3
/ \
9 20
/ \
15 7
返回它的最大深度 3 。
答案
方法一:深度优先搜索
思路与算法
如果我们知道了左子树和右子树的最大深度 l 和 r,那么该二叉树的最大深度即为
而左子树和右子树的最大深度又可以以同样的方式进行计算。因此我们可以用「深度优先搜索」的方法来计算二叉树的最大深度。具体而言,在计算当前二叉树的最大深度时,可以先递归计算出其左子树和右子树的最大深度,然后在 \(O(1)\) 时间内计算出当前二叉树的最大深度。递归在访问到空节点时退出。
class Solution {
public int maxDepth(TreeNode root) {
if (root == null) {
return 0;
} else {
int leftHeight = maxDepth(root.left);
int rightHeight = maxDepth(root.right);
return Math.max(leftHeight, rightHeight) + 1;
}
}
}
复杂度分析
-
时间复杂度:\(O(n)\),其中 n 为二叉树节点的个数。每个节点在递归中只被遍历一次。
-
空间复杂度:\(O(\textit{height})\),其中 \(\textit{height}\) 表示二叉树的高度。递归函数需要栈空间,而栈空间取决于递归的深度,因此空间复杂度等价于二叉树的高度。
方法二:广度优先搜索
思路与算法
我们也可以用「广度优先搜索」的方法来解决这道题目,但我们需要对其进行一些修改,此时我们广度优先搜索的队列里存放的是「当前层的所有节点」。每次拓展下一层的时候,不同于广度优先搜索的每次只从队列里拿出一个节点,我们需要将队列里的所有节点都拿出来进行拓展,这样能保证每次拓展完的时候队列里存放的是当前层的所有节点,即我们是一层一层地进行拓展,最后我们用一个变量 \(\textit{ans}\) 来维护拓展的次数,该二叉树的最大深度即为 \(\textit{ans}\)。
class Solution {
public int maxDepth(TreeNode root) {
if (root == null) {
return 0;
}
Queue<TreeNode> queue = new LinkedList<TreeNode>();
queue.offer(root);
int ans = 0;
while (!queue.isEmpty()) {
int size = queue.size();
while (size > 0) {
TreeNode node = queue.poll();
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
size--;
}
ans++;
}
return ans;
}
}
复杂度分析
-
时间复杂度:\(O(n)\),其中 n 为二叉树的节点个数。与方法一同样的分析,每个节点只会被访问一次。
-
空间复杂度:此方法空间的消耗取决于队列存储的元素数量,其在最坏情况下会达到 \(O(n)\)。
剑指 Offer 27. 二叉树的镜像
请完成一个函数,输入一个二叉树,该函数输出它的镜像。
例如输入:
4
/ \
2 7
/ \ / \
1 3 6 9
镜像输出:
4
/ \
7 2
/ \ / \
9 6 3 1
示例 1:
输入:root = [4,2,7,1,3,6,9]
输出:[4,7,2,9,6,3,1]
答案
方法一:递归
思路与算法
这是一道很经典的二叉树问题。显然,我们从根节点开始,递归地对树进行遍历,并从叶子结点先开始翻转。如果当前遍历到的节点 \(\textit{root}\) 的左右两棵子树都已经翻转,那么我们只需要交换两棵子树的位置,即可完成以 \(\textit{root}\) 为根节点的整棵子树的翻转。
代码
class Solution {
public TreeNode invertTree(TreeNode root) {
if (root == null) {
return null;
}
TreeNode left = invertTree(root.left);
TreeNode right = invertTree(root.right);
root.left = right;
root.right = left;
return root;
}
}
复杂度分析
时间复杂度:\(O(N)\),其中 N 为二叉树节点的数目。我们会遍历二叉树中的每一个节点,对每个节点而言,我们在常数时间内交换其两棵子树。
空间复杂度:\(O(N)\)。使用的空间由递归栈的深度决定,它等于当前节点在二叉树中的高度。在平均情况下,二叉树的高度与节点个数为对数关系,即 \(O(\log N)\)。而在最坏情况下,树形成链状,空间复杂度为 \(O(N)\)。
剑指 Offer 68 - II. 二叉树的最近公共祖先
给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。
百度百科中最近公共祖先的定义为:“对于有根树 T 的两个结点 p、q,最近公共祖先表示为一个结点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”
例如,给定如下二叉树: root = [3,5,1,6,2,0,8,null,null,7,4]
示例 1:
输入: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1
输出: 3
解释: 节点 5 和节点 1 的最近公共祖先是节点 3。
示例 2:
输入: root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 4
输出: 5
解释: 节点 5 和节点 4 的最近公共祖先是节点 5。因为根据定义最近公共祖先节点可以为节点本身。
说明:
- 所有节点的值都是唯一的。
- p、q 为不同节点且均存在于给定的二叉树中。
答案
方法一:递归
思路和算法
我们递归遍历整棵二叉树,定义 \(f_x\) 表示 x 节点的子树中是否包含 p 节点或 q 节点,如果包含为 true,否则为 false。那么符合条件的最近公共祖先 x 一定满足如下条件:
其中 \(\text{lson}\) 和 \(\text{rson}\) 分别代表 x 节点的左孩子和右孩子。初看可能会感觉条件判断有点复杂,我们来一条条看,\(f_{\text{lson}}\ \&\&\ f_{\text{rson}}\) 说明左子树和右子树均包含 p 节点或 q 节点,如果左子树包含的是 p 节点,那么右子树只能包含 q 节点,反之亦然,因为 p 节点和 q 节点都是不同且唯一的节点,因此如果满足这个判断条件即可说明 x 就是我们要找的最近公共祖先。再来看第二条判断条件,这个判断条件即是考虑了 x 恰好是 p 节点或 q 节点且它的左子树或右子树有一个包含了另一个节点的情况,因此如果满足这个判断条件亦可说明 x 就是我们要找的最近公共祖先。
你可能会疑惑这样找出来的公共祖先深度是否是最大的。其实是最大的,因为我们是自底向上从叶子节点开始更新的,所以在所有满足条件的公共祖先中一定是深度最大的祖先先被访问到,且由于 \(f_x\) 本身的定义很巧妙,在找到最近公共祖先 x 以后,\(f_x\) 按定义被设置为 true ,即假定了这个子树中只有一个 p 节点或 q 节点,因此其他公共祖先不会再被判断为符合条件。
注意:大家发现了吗?这其实就是一个递归的后序遍历应用。跟我们在上面写的后序遍历应用迭代是一样的。
class Solution {
private TreeNode ans;
public Solution() {
this.ans = null;
}
// 判断树root里面是否包含p或q节点
private boolean dfs(TreeNode root, TreeNode p, TreeNode q) {
if (root == null) return false;
boolean lson = dfs(root.left, p, q); // 左
boolean rson = dfs(root.right, p, q); // 右
if ((lson && rson) || ((root.val == p.val || root.val == q.val) && (lson || rson))) { // 根
ans = root;
}
// 左子树、右子树、根有一个包含即可返回true
return lson || rson || (root.val == p.val || root.val == q.val);
}
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
this.dfs(root, p, q);
return this.ans;
}
}
复杂度分析
-
时间复杂度:\(O(N)\),其中 N 是二叉树的节点数。二叉树的所有节点有且只会被访问一次,因此时间复杂度为 \(O(N)\)。
-
空间复杂度:\(O(N)\),其中 N 是二叉树的节点数。递归调用的栈深度取决于二叉树的高度,二叉树最坏情况下为一条链,此时高度为 N,因此空间复杂度为 \(O(N)\)。
方法二:存储父节点
思路
我们可以用哈希表存储所有节点的父节点,然后我们就可以利用节点的父节点信息从 p 结点开始不断往上跳,并记录已经访问过的节点,再从 q 节点开始不断往上跳,如果碰到已经访问过的节点,那么这个节点就是我们要找的最近公共祖先。
算法
- 从根节点开始遍历整棵二叉树,用哈希表记录每个节点的父节点指针。
- 从 p 节点开始不断往它的祖先移动,并用数据结构记录已经访问过的祖先节点。
- 同样,我们再从 q 节点开始不断往它的祖先移动,如果有祖先已经被访问过,即意味着这是 p 和 q 的深度最深的公共祖先,即 LCA 节点。
class Solution {
Map<Integer, TreeNode> parent = new HashMap<Integer, TreeNode>();
Set<Integer> visited = new HashSet<Integer>();
public void dfs(TreeNode root) {
if (root.left != null) {
parent.put(root.left.val, root);
dfs(root.left);
}
if (root.right != null) {
parent.put(root.right.val, root);
dfs(root.right);
}
}
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
dfs(root);
while (p != null) {
visited.add(p.val);
p = parent.get(p.val);
}
while (q != null) {
if (visited.contains(q.val)) {
return q;
}
q = parent.get(q.val);
}
return null;
}
}
复杂度分析
-
时间复杂度:\(O(N)\),其中 N 是二叉树的节点数。二叉树的所有节点有且只会被访问一次,从 p 和 q 节点往上跳经过的祖先节点个数不会超过 N,因此总的时间复杂度为 \(O(N)\)。
-
空间复杂度:\(O(N)\) ,其中 N 是二叉树的节点数。递归调用的栈深度取决于二叉树的高度,二叉树最坏情况下为一条链,此时高度为 N,因此空间复杂度为 \(O(N)\),哈希表存储每个节点的父节点也需要 \(O(N)\) 的空间复杂度,因此最后总的空间复杂度为 \(O(N)\)。
我的答案
写一个递归函数看看树root中是否有index节点,然后使用此函数进行公共祖先判断。
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
if (root == null) {
return null;
}
if (root.val == p.val) {
return p;
}
if (root.val == q.val) {
return q;
}
boolean leftp = f(root.left, p); // 左子树是否包含p
boolean rightp = f(root.right, p); // 右子树是否包含p
boolean leftq = f(root.left, q);
boolean rightq = f(root.right, q);
if ((leftp && rightq) || (rightp && leftq)) {
return root;
}
if (leftp && leftq) {
return lowestCommonAncestor(root.left, p, q);
}
if (rightp && rightq) {
return lowestCommonAncestor(root.right, p, q);
}
return null;
}
// 树root中有index节点吗
public boolean f(TreeNode root, TreeNode index) {
if (root == null) {
return false;
}
return root.val == index.val || f(root.left, index) || f(root.right, index);
}
}
剑指 Offer 68 - I. 二叉搜索树的最近公共祖先
给定一个二叉搜索树, 找到该树中两个指定节点的最近公共祖先。
百度百科中最近公共祖先的定义为:“对于有根树 T 的两个结点 p、q,最近公共祖先表示为一个结点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”
例如,给定如下二叉搜索树: root = [6,2,8,0,4,7,9,null,null,3,5]
示例 1:
输入: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 8
输出: 6
解释: 节点 2 和节点 8 的最近公共祖先是 6。
示例 2:
输入: root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 4
输出: 2
解释: 节点 2 和节点 4 的最近公共祖先是 2, 因为根据定义最近公共祖先节点可以为节点本身。
说明:
- 所有节点的值都是唯一的。
- p、q 为不同节点且均存在于给定的二叉搜索树中。
答案
方法一:两次遍历
思路与算法
注意到题目中给出的是一棵「二叉搜索树」,因此我们可以快速地找出树中的某个节点以及从根节点到该节点的路径,例如我们需要找到节点 p:
-
我们从根节点开始遍历;
-
如果当前节点就是 p,那么成功地找到了节点;
-
如果当前节点的值大于 p 的值,说明 p 应该在当前节点的左子树,因此将当前节点移动到它的左子节点;
-
如果当前节点的值小于 p 的值,说明 p 应该在当前节点的右子树,因此将当前节点移动到它的右子节点。
对于节点 q 同理。在寻找节点的过程中,我们可以顺便记录经过的节点,这样就得到了从根节点到被寻找节点的路径。
当我们分别得到了从根节点到 p 和 q 的路径之后,我们就可以很方便地找到它们的最近公共祖先了。显然,p 和 q 的最近公共祖先就是从根节点到它们路径上的「分岔点」,也就是最后一个相同的节点。因此,如果我们设从根节点到 p 的路径为数组 \(\textit{path_p}\),从根节点到 q 的路径为数组 \(\textit{path_q}\),那么只要找出最大的编号 i,其满足
那么对应的节点就是「分岔点」,即 p 和 q 的最近公共祖先就是 \(\textit{path_p}[i]\)(或 \(\textit{path_q}[i]\))。
代码
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
List<TreeNode> path_p = getPath(root, p);
List<TreeNode> path_q = getPath(root, q);
TreeNode ancestor = null;
for (int i = 0; i < path_p.size() && i < path_q.size(); ++i) {
if (path_p.get(i) == path_q.get(i)) {
ancestor = path_p.get(i);
} else {
break;
}
}
return ancestor;
}
public List<TreeNode> getPath(TreeNode root, TreeNode target) {
List<TreeNode> path = new ArrayList<TreeNode>();
TreeNode node = root;
while (node != target) {
path.add(node);
if (target.val < node.val) {
node = node.left;
} else {
node = node.right;
}
}
path.add(node);
return path;
}
}
复杂度分析
-
时间复杂度:\(O(n)\),其中 n 是给定的二叉搜索树中的节点个数。上述代码需要的时间与节点 p 和 q 在树中的深度线性相关,而在最坏的情况下,树呈现链式结构,p 和 q 一个是树的唯一叶子结点,一个是该叶子结点的父节点,此时时间复杂度为 \(\Theta(n)\)。
-
空间复杂度:\(O(n)\),我们需要存储根节点到 p 和 q 的路径。和上面的分析方法相同,在最坏的情况下,路径的长度为 \(\Theta(n)\),因此需要 \(\Theta(n)\) 的空间。
方法二:一次遍历
思路与算法
在方法一中,我们对从根节点开始,通过遍历找出到达节点 p 和 q 的路径,一共需要两次遍历。我们也可以考虑将这两个节点放在一起遍历。
整体的遍历过程与方法一中的类似:
我们从根节点开始遍历;
-
如果当前节点的值大于 p 和 q 的值,说明 p 和 q 应该在当前节点的左子树,因此将当前节点移动到它的左子节点;
-
如果当前节点的值小于 p 和 q 的值,说明 p 和 q 应该在当前节点的右子树,因此将当前节点移动到它的右子节点;
-
如果当前节点的值不满足上述两条要求,那么说明当前节点就是「分岔点」。此时,p 和 q 要么在当前节点的不同的子树中,要么其中一个就是当前节点。
可以发现,如果我们将这两个节点放在一起遍历,我们就省去了存储路径需要的空间。
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
TreeNode ancestor = root;
while (true) {
if (p.val < ancestor.val && q.val < ancestor.val) {
ancestor = ancestor.left;
} else if (p.val > ancestor.val && q.val > ancestor.val) {
ancestor = ancestor.right;
} else {
break;
}
}
return ancestor;
}
}
复杂度分析
-
时间复杂度:\(O(n)\),其中 n 是给定的二叉搜索树中的节点个数。分析思路与方法一相同。
-
空间复杂度:\(O(1)\)。
剑指 Offer 32 - II. 从上到下打印二叉树 II
从上到下按层打印二叉树,同一层的节点按从左到右的顺序打印,每一层打印到一行。
例如:
给定二叉树: [3,9,20,null,null,15,7],
3
/ \
9 20
/ \
15 7
返回其层次遍历结果:
[
[3],
[9,20],
[15,7]
]
提示:
节点总数 <= 1000
答案
方法一:广度优先搜索
思路和算法
我们可以用广度优先搜索解决这个问题。
我们可以想到最朴素的方法是用一个二元组 (node, level) 来表示状态,它表示某个节点和它所在的层数,每个新进队列的节点的 level 值都是父亲节点的 level 值加一。最后根据每个点的 level 对点进行分类,分类的时候我们可以利用哈希表,维护一个以 level 为键,对应节点值组成的数组为值,广度优先搜索结束以后按键 level 从小到大取出所有值,组成答案返回即可。
考虑如何优化空间开销:如何不用哈希映射,并且只用一个变量 node 表示状态,实现这个功能呢?
我们可以用一种巧妙的方法修改广度优先搜索:
- 首先根元素入队
- 当队列不为空的时候
- 求当前队列的长度 \(s_i\)
- 依次从队列中取 \(s_i\) 个元素进行拓展,然后进入下一次迭代
它和普通广度优先搜索的区别在于,普通广度优先搜索每次只取一个元素拓展,而这里每次取 \(s_i\) 个元素。在上述过程中的第 i 次迭代就得到了二叉树的第 i 层的 \(s_i\) 个元素。
为什么这么做是对的呢?我们观察这个算法,可以归纳出这样的循环不变式:第 ii 次迭代前,队列中的所有元素就是第 ii 层的所有元素,并且按照从左向右的顺序排列。证明它的三条性质(你也可以把它理解成数学归纳法):
- 初始化:\(i = 1\) 的时候,队列里面只有 root,是唯一的层数为 11 的元素,因为只有一个元素,所以也显然满足「从左向右排列」;
- 保持:如果 \(i = k\) 时性质成立,即第 kk 轮中出队 \(s_k\) 的元素是第 k 层的所有元素,并且顺序从左到右。因为对树进行广度优先搜索的时候由低 k 层的点拓展出的点一定也只能是 \(k + 1\) 层的点,并且 \(k + 1\) 层的点只能由第 k 层的点拓展到,所以由这 \(s_k\) 个点能拓展到下一层所有的 \(s_{k+1}\) 个点。又因为队列的先进先出(FIFO)特性,既然第 kk 层的点的出队顺序是从左向右,那么第 \(k + 1\) 层也一定是从左向右。至此,我们已经可以通过数学归纳法证明循环不变式的正确性。
- 终止:因为该循环不变式是正确的,所以按照这个方法迭代之后每次迭代得到的也就是当前层的层次遍历结果。至此,我们证明了算法是正确的。
class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> ret = new ArrayList<List<Integer>>();
if (root == null) {
return ret;
}
Queue<TreeNode> queue = new LinkedList<TreeNode>();
queue.offer(root);
while (!queue.isEmpty()) {
List<Integer> level = new ArrayList<Integer>();
int currentLevelSize = queue.size();
for (int i = 1; i <= currentLevelSize; ++i) {
TreeNode node = queue.poll();
level.add(node.val);
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
}
ret.add(level);
}
return ret;
}
}
复杂度分析
记树上所有节点的个数为 n。
- 时间复杂度:每个点进队出队各一次,故渐进时间复杂度为 \(O(n)\)。
- 空间复杂度:队列中元素的个数不超过 n 个,故渐进空间复杂度为 \(O(n)\)。
剑指 Offer 55 - II. 平衡二叉树
输入一棵二叉树的根节点,判断该树是不是平衡二叉树。如果某二叉树中任意节点的左右子树的深度相差不超过1,那么它就是一棵平衡二叉树。
示例 1:
给定二叉树 [3,9,20,null,null,15,7]
3
/ \
9 20
/ \
15 7
返回 true 。
示例 2:
给定二叉树 [1,2,2,3,3,null,null,4,4]
1
/ \
2 2
/ \
3 3
/ \
4 4
返回 false 。
答案
前言
这道题中的平衡二叉树的定义是:二叉树的每个节点的左右子树的高度差的绝对值不超过 1,则二叉树是平衡二叉树。根据定义,一棵二叉树是平衡二叉树,当且仅当其所有子树也都是平衡二叉树,因此可以使用递归的方式判断二叉树是不是平衡二叉树,递归的顺序可以是自顶向下或者自底向上。
方法一:自顶向下的递归
定义函数 \(\texttt{height}\),用于计算二叉树中的任意一个节点 p 的高度:
有了计算节点高度的函数,即可判断二叉树是否平衡。具体做法类似于二叉树的前序遍历,即对于当前遍历到的节点,首先计算左右子树的高度,如果左右子树的高度差是否不超过 1,再分别递归地遍历左右子节点,并判断左子树和右子树是否平衡。这是一个自顶向下的递归的过程。
class Solution {
public boolean isBalanced(TreeNode root) {
if (root == null) {
return true;
}
return Math.abs(height(root.left) - height(root.right)) <= 1 && isBalanced(root.left) && isBalanced(root.right);
}
public int height(TreeNode root) {
if (root == null) {
return 0;
}
return Math.max(height(root.left), height(root.right)) + 1;
}
}
复杂度分析
-
时间复杂度:\(O(n^2)\),其中 n 是二叉树中的节点个数。
最坏情况下,二叉树是满二叉树,需要遍历二叉树中的所有节点,时间复杂度是 \(O(n)\)。
对于节点 p,如果它的高度是 d,则 \(\texttt{height}(p)\) 最多会被调用 d 次(即遍历到它的每一个祖先节点时)。对于平均的情况,一棵树的高度 h 满足 \(O(h)=O(\log n)\),因为 \(d \leq h\),所以总时间复杂度为 \(O(n \log n)\)。对于最坏的情况,二叉树形成链式结构,高度为 \(O(n)\),此时总时间复杂度为 \(O(n^2)\)。 -
空间复杂度:\(O(n)\),其中 n 是二叉树中的节点个数。空间复杂度主要取决于递归调用的层数,递归调用的层数不会超过 n。
方法二:自底向上的递归
方法一由于是自顶向下递归,因此对于同一个节点,函数 \(\texttt{height}\) 会被重复调用,导致时间复杂度较高。如果使用自底向上的做法,则对于每个节点,函数 \(\texttt{height}\) 只会被调用一次。
自底向上递归的做法类似于后序遍历,对于当前遍历到的节点,先递归地判断其左右子树是否平衡,再判断以当前节点为根的子树是否平衡。如果一棵子树是平衡的,则返回其高度(高度一定是非负整数),否则返回 -1。如果存在一棵子树不平衡,则整个二叉树一定不平衡。
class Solution {
public boolean isBalanced(TreeNode root) {
return height(root) >= 0;
}
public int height(TreeNode root) {
if (root == null) {
return 0;
}
int leftHeight = height(root.left);
int rightHeight = height(root.right);
if (leftHeight == -1 || rightHeight == -1 || Math.abs(leftHeight - rightHeight) > 1) {
return -1;
} else {
return Math.max(leftHeight, rightHeight) + 1;
}
}
}
复杂度分析
-
时间复杂度:\(O(n)\),其中 n 是二叉树中的节点个数。使用自底向上的递归,每个节点的计算高度和判断是否平衡都只需要处理一次,最坏情况下需要遍历二叉树中的所有节点,因此时间复杂度是 \(O(n)\)。
-
空间复杂度:\(O(n)\),其中 n 是二叉树中的节点个数。空间复杂度主要取决于递归调用的层数,递归调用的层数不会超过 n。
剑指 Offer 28. 对称的二叉树
请实现一个函数,用来判断一棵二叉树是不是对称的。如果一棵二叉树和它的镜像一样,那么它是对称的。
例如,二叉树 [1,2,2,3,4,4,3] 是对称的。
1
/ \
2 2
/ \ / \
3 4 4 3
但是下面这个 [1,2,2,null,3,null,3] 则不是镜像对称的:
1
/ \
2 2
\ \
3 3
示例 1:
输入:root = [1,2,2,3,4,4,3]
输出:true
示例 2:
输入:root = [1,2,2,null,3,null,3]
输出:false
答案
方法一:递归
思路和算法
如果一个树的左子树与右子树镜像对称,那么这个树是对称的。
因此,该问题可以转化为:两个树在什么情况下互为镜像?
如果同时满足下面的条件,两个树互为镜像:
- 它们的两个根结点具有相同的值
- 每个树的右子树都与另一个树的左子树镜像对称
我们可以实现这样一个递归函数,通过「同步移动」两个指针的方法来遍历这棵树,p 指针和 q 指针一开始都指向这棵树的根,随后 p 右移时,q 左移,p 左移时,q 右移。每次检查当前 p 和 q 节点的值是否相等,如果相等再判断左右子树是否对称。
代码如下。
class Solution {
public boolean isSymmetric(TreeNode root) {
return check(root, root);
}
public boolean check(TreeNode p, TreeNode q) {
if (p == null && q == null) {
return true;
}
if (p == null || q == null) {
return false;
}
return p.val == q.val && check(p.left, q.right) && check(p.right, q.left);
}
}
复杂度分析
假设树上一共 n 个节点。
- 时间复杂度:这里遍历了这棵树,渐进时间复杂度为 \(O(n)\)。
- 空间复杂度:这里的空间复杂度和递归使用的栈空间有关,这里递归层数不超过 n,故渐进空间复杂度为 \(O(n)\)。
方法二:迭代
思路和算法
「方法一」中我们用递归的方法实现了对称性的判断,那么如何用迭代的方法实现呢?首先我们引入一个队列,这是把递归程序改写成迭代程序的常用方法。初始化时我们把根节点入队两次。每次提取两个结点并比较它们的值(队列中每两个连续的结点应该是相等的,而且它们的子树互为镜像),然后将两个结点的左右子结点按相反的顺序插入队列中。当队列为空时,或者我们检测到树不对称(即从队列中取出两个不相等的连续结点)时,该算法结束。
class Solution {
public boolean isSymmetric(TreeNode root) {
return check(root, root);
}
public boolean check(TreeNode u, TreeNode v) {
Queue<TreeNode> q = new LinkedList<TreeNode>();
q.offer(u);
q.offer(v);
while (!q.isEmpty()) {
u = q.poll();
v = q.poll();
if (u == null && v == null) {
continue;
}
if ((u == null || v == null) || (u.val != v.val)) {
return false;
}
q.offer(u.left);
q.offer(v.right);
q.offer(u.right);
q.offer(v.left);
}
return true;
}
}
复杂度分析
- 时间复杂度:\(O(n)\),同「方法一」。
- 空间复杂度:这里需要用一个队列来维护节点,每个节点最多进队一次,出队一次,队列中最多不会超过 n 个点,故渐进空间复杂度为 \(O(n)\)。
剑指 Offer 32 - I. 从上到下打印二叉树
从上到下打印出二叉树的每个节点,同一层的节点按照从左到右的顺序打印。
例如:
给定二叉树: [3,9,20,null,null,15,7],
3
/ \
9 20
/ \
15 7
返回:
[3,9,20,15,7]
答案
解题思路:
- 题目要求的二叉树的 从上至下 打印(即按层打印),又称为二叉树的 广度优先搜索(BFS)。
- BFS 通常借助 队列 的先入先出特性来实现。
算法流程:
- 特例处理: 当树的根节点为空,则直接返回空列表 [] ;
- 初始化: 打印结果列表 res = [] ,包含根节点的队列 queue = [root] ;
- BFS 循环: 当队列 queue 为空时跳出;
- 出队: 队首元素出队,记为 node;
- 打印: 将 node.val 添加至列表 tmp 尾部;
- 添加子节点: 若 node 的左(右)子节点不为空,则将左(右)子节点加入队列 queue ;
- 返回值: 返回打印结果列表 res 即可。
复杂度分析:
- 时间复杂度 \(O(N)\) : N 为二叉树的节点数量,即 BFS 需循环 N 次。
- 空间复杂度 \(O(N)O\) : 最差情况下,即当树为平衡二叉树时,最多有 N/2 个树节点同时在 queue 中,使用 \(O(N)\) 大小的额外空间。
class Solution {
public int[] levelOrder(TreeNode root) {
if(root == null) return new int[0];
Queue<TreeNode> queue = new LinkedList<>(){{ add(root); }};
ArrayList<Integer> ans = new ArrayList<>();
while(!queue.isEmpty()) {
TreeNode node = queue.poll();
ans.add(node.val);
if(node.left != null) queue.add(node.left);
if(node.right != null) queue.add(node.right);
}
int[] res = new int[ans.size()];
for(int i = 0; i < ans.size(); i++)
res[i] = ans.get(i);
return res;
}
}
剑指 Offer 37. 序列化二叉树
序列化是将一个数据结构或者对象转换为连续的比特位的操作,进而可以将转换后的数据存储在一个文件或者内存中,同时也可以通过网络传输到另一个计算机环境,采取相反方式重构得到原数据。
请设计一个算法来实现二叉树的序列化与反序列化。这里不限定你的序列 / 反序列化算法执行逻辑,你只需要保证一个二叉树可以被序列化为一个字符串并且将这个字符串反序列化为原始的树结构。
提示: 输入输出格式与 LeetCode 目前使用的方式一致,详情请参阅 LeetCode 序列化二叉树的格式。你并非必须采取这种方式,你也可以采用其他的方法解决这个问题。
请实现两个函数,分别用来序列化和反序列化二叉树。
示例:
你可以将以下二叉树:
1
/ \
2 3
/ \
4 5
序列化为 "[1,2,3,null,null,4,5]"
答案
方法一:深度优先搜索
思路和算法
二叉树的序列化本质上是对其值进行编码,更重要的是对其结构进行编码。可以遍历树来完成上述任务。众所周知,我们一般有两个策略:广度优先搜索和深度优先搜索。
- 广度优先搜索可以按照层次的顺序从上到下遍历所有的节点
- 深度优先搜索可以从一个根开始,一直延伸到某个叶,然后回到根,到达另一个分支。根据根节点、左节点和右节点之间的相对顺序,可以进一步将深度优先搜索策略区分为:
- 先序遍历
- 中序遍历
- 后序遍历
这里,我们选择先序遍历的编码方式,我们可以通过这样一个例子简单理解:
我们从根节点 1 开始,序列化字符串是 1,。然后我们跳到根节点 2 的左子树,序列化字符串变成 1,2,。现在从节点 2 开始,我们访问它的左节点 3(1,2,3,None,None,)和右节点 4
(1,2,3,None,None,4,None,None)。None,None, 是用来标记缺少左、右子节点,这就是我们在序列化期间保存树结构的方式。最后,我们回到根节点 1 并访问它的右子树,它恰好是叶节点 5。最后,序列化字符串是 1,2,3,None,None,4,None,None,5,None,None,。
即我们可以先序遍历这颗二叉树,遇到空子树的时候序列化成 None,否则继续递归序列化。那么我们如何反序列化呢?首先我们需要根据 , 把原先的序列分割开来得到先序遍历的元素列表,然后从左向右遍历这个序列:
- 如果当前的元素为 None,则当前为空树
- 否则先解析这棵树的左子树,再解析它的右子树
具体请参考下面的代码。
代码
public class Codec {
public String rserialize(TreeNode root, String str) {
if (root == null) {
str += "None,";
} else {
str += str.valueOf(root.val) + ",";
str = rserialize(root.left, str);
str = rserialize(root.right, str);
}
return str;
}
public String serialize(TreeNode root) {
return rserialize(root, "");
}
public TreeNode rdeserialize(List<String> l) {
if (l.get(0).equals("None")) {
l.remove(0);
return null;
}
TreeNode root = new TreeNode(Integer.valueOf(l.get(0)));
l.remove(0);
root.left = rdeserialize(l);
root.right = rdeserialize(l);
return root;
}
public TreeNode deserialize(String data) {
String[] data_array = data.split(",");
List<String> data_list = new LinkedList<String>(Arrays.asList(data_array));
return rdeserialize(data_list);
}
}
复杂度分析
- 时间复杂度:在序列化和反序列化函数中,我们只访问每个节点一次,因此时间复杂度为 \(O(n)\),其中 n 是节点数,即树的大小。
- 空间复杂度:在序列化和反序列化函数中,我们递归会使用栈空间,故渐进空间复杂度为 \(O(n)\)。
方法二:括号表示编码 + 递归下降解码
思路和算法
我们也可以这样表示一颗二叉树:
- 如果当前的树为空,则表示为 X
- 如果当前的树不为空,则表示为
(<LEFT_SUB_TREE>)CUR_NUM(RIGHT_SUB_TREE)
,其中:<LEFT_SUB_TREE>
是左子树序列化之后的结果<RIGHT_SUB_TREE>
是右子树序列化之后的结果CUR_NUM
是当前节点的值
根据这样的定义,我们很好写出序列化的过程,后序遍历这颗二叉树即可,那如何反序列化呢?根据定义,我们可以推导出这样的巴科斯范式(BNF):
T -> (T) num (T) | X
它的意义是:用 T 代表一棵树序列化之后的结果,| 表示 T 的构成为 (T) num (T) 或者 X,| 左边是对 T 的递归定义,右边规定了递归终止的边界条件。
因为:
- T 的定义中,序列中的第一个字符要么是 X,要么是 (,所以这个定义是不含左递归的
- 当我们开始解析一个字符串的时候,如果开头是 X,我们就知道这一定是解析一个「空树」的结构,如果开头是 (,我们就知道需要解析 (T) num (T) 的结构,因此这里两种开头和两种解析方法一一对应,可以确定这是一个无二义性的文法
- 我们只需要通过开头的第一个字母是 X 还是 ( 来判断使用哪一种解析方法
所以这个文法是 LL(1) 型文法,如果你不知道什么是 LL(1) 型文法也没有关系,你只需要知道它定义了一种递归的方法来反序列化,也保证了这个方法的正确性——我们可以设计一个递归函数:
- 这个递归函数传入两个参数,带解析的字符串和当前当解析的位置 ptr,ptr 之前的位置是已经解析的,ptr 和 ptr 后面的字符串是待解析的
- 如果当前位置为 X 说明解析到了一棵空树,直接返回
- 否则当前位置一定是 (,对括号内部按照 (T) num (T) 的模式解析
具体请参考下面的代码。
代码
public class Codec {
public String serialize(TreeNode root) {
if (root == null) {
return "X";
}
String l = "(" + serialize(root.left) + ")";
String r = "(" + serialize(root.right) + ")";
return l + root.val + r;
}
public TreeNode deserialize(String data) {
int[] ptr = {0};
return parse(data, ptr);
}
public TreeNode parse(String data, int[] ptr) {
if (data.charAt(ptr[0]) == 'X') {
++ptr[0];
return null;
}
TreeNode cur = new TreeNode(0);
cur.left = parseSubtree(data, ptr);
cur.val = parseInt(data, ptr);
cur.right = parseSubtree(data, ptr);
return cur;
}
public TreeNode parseSubtree(String data, int[] ptr) {
++ptr[0]; // 跳过左括号
TreeNode subtree = parse(data, ptr);
++ptr[0]; // 跳过右括号
return subtree;
}
public int parseInt(String data, int[] ptr) {
int x = 0, sgn = 1;
if (!Character.isDigit(data.charAt(ptr[0]))) {
sgn = -1;
++ptr[0];
}
while (Character.isDigit(data.charAt(ptr[0]))) {
x = x * 10 + data.charAt(ptr[0]++) - '0';
}
return x * sgn;
}
}
复杂度分析
- 时间复杂度:序列化时做了一次遍历,渐进时间复杂度为 \(O(n)\)。反序列化时,在解析字符串的时候 ptr 指针对字符串做了一次顺序遍历,字符串长度为 \(O(n)\),故这里的渐进时间复杂度为 \(O(n)\)。
- 空间复杂度:考虑递归使用的栈空间的大小,这里栈空间的使用和递归深度有关,递归深度又和二叉树的深度有关,在最差情况下,二叉树退化成一条链,故这里的渐进空间复杂度为 \(O(n)\)。
面试题 04.10. 检查子树
检查子树。你有两棵非常大的二叉树:T1,有几万个节点;T2,有几万个节点。设计一个算法,判断 T2 是否为 T1 的子树。
如果 T1 有这么一个节点 n,其子树与 T2 一模一样,则 T2 为 T1 的子树,也就是说,从节点 n 处把树砍断,得到的树与 T2 完全相同。
注意:此题相对书上原题略有改动。
示例1:
输入:t1 = [1, 2, 3], t2 = [2]
输出:true
示例2:
输入:t1 = [1, null, 2, 4], t2 = [3, 2]
输出:false
答案
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode(int x) { val = x; }
* }
*/
class Solution {
public boolean checkSubTree(TreeNode t1, TreeNode t2) {
if(t2 == null){ // 子树为空
return true;
}
if(t1 == null && t2 != null){ // 子树不为空
return false;
}
return isSame(t1, t2) || checkSubTree(t1.left, t2) || checkSubTree(t1.right, t2);
}
public boolean isSame(TreeNode t1, TreeNode t2) {
// if (t1 == t2) {
// return true;
// }
// 同时为空
if (t1 == null && t2 == null) {
return true;
}
// 不同时为空
if (t1 == null || t2 == null){
return false;
}
return t1.val == t2.val && isSame(t1.left, t2.left) && isSame(t1.right, t2.right);
}
}
笔者将不定期更新【考研或就业】的专业相关知识以及自身理解,希望大家能【关注】我。
如果觉得对您有用,请点击左下角的【点赞】按钮,给我一些鼓励,谢谢!
如果有更好的理解或建议,请在【评论】中写出,我会及时修改,谢谢啦!
本文来自博客园,作者:Nemo&
转载请注明原文链接:https://www.cnblogs.com/blknemo/p/11241464.html