第三次DS博客作业
Q0.展示PTA总分
Q1.本章学习总结
1.1 学习内容总结
- BF算法
- 最暴力的寻找子串的方法,一位一位找,没有特别的地方故不详细说明,最差时间复杂度为O(m*n),m、n分别代表主串和子串的长度,实现方法大概如下:从左到右一个个匹配,如果这个过程中有某个字符不匹配,子串回退到开头,并将模式串向右移动一位
- 最暴力的寻找子串的方法,一位一位找,没有特别的地方故不详细说明,最差时间复杂度为O(m*n),m、n分别代表主串和子串的长度,实现方法大概如下:从左到右一个个匹配,如果这个过程中有某个字符不匹配,子串回退到开头,并将模式串向右移动一位
/*BF算法匹配部分简略伪代码*/
读入主串str,子串sub
定义i,j,k初值均为0
while i,j分别小于主串和子串的长度 do
如果str[i]与sub[j]相匹配,i、j都自增1
如果不匹配,回退j=0,i=k+1,k=i(i代表下次匹配从主串当前的下一位置开始,k记录上一次的起始位置)
end while
最终若j的值小于子串长度,代表匹配失败,否则匹配成功
- KMP算法
- 同样是寻找子串的方法,相对于BF算法,它通过消除主串指针的回溯来提高匹配的效率:保持主串i指针不变(不回溯主串),通过修改子串j指针(下标),让其尽量移动到有效的位置,以减少比较次数,时间复杂度为O(m+n)。它引入了一个next数组,以最简单的理解来说,就是对应子串中第i个位置前真子串的长度的最大值。在匹配失败时我们尽量多的移动模式串,滑动模式串使其模式串位置为next[j]的字符与主串位置i的字符继续匹配(因为有真子串,模式串有一部分是一样的,不需要重新匹配)
- 举个栗子,如上图中的子串AAAB,首先初始化next[0]=-1,然后i=1时,sub[1]前没有真子串,因此next[1]=0
i=2时,sub[2]前有真子串 "A"="A" ,真子串长度为1,因此next[2]=1
i=3时,sub[3]前有真子串 "AA"="AA",长度为2,因此next[3]=2,下面开始匹配
- 举个栗子,如上图中的子串AAAB,首先初始化next[0]=-1,然后i=1时,sub[1]前没有真子串,因此next[1]=0
- 光看这个图,我们会发现,他虽然匹配的次数变少了,可是子串明明前面都是A了,它却一直在C这个位置卡了很久啊!因此我们需要改进获取next数组的算法。只需要在判断真子串时,如果当前字符与正在判断真子串位置的字符相同,我们就让他们的next值相等(算是跳过这个位置)
- 改进后,同样的对于AAAB,它的next数组会变为-1 -1 -1 2,当第一次B遇到C时,j值修正后是2,不匹配,再次修正j后j=-1了,于是直接将主串后移,模式串回溯
- 改进后,同样的对于AAAB,它的next数组会变为-1 -1 -1 2,当第一次B遇到C时,j值修正后是2,不匹配,再次修正j后j=-1了,于是直接将主串后移,模式串回溯
- 下面给出代码
- 为什么下面的代码循环条件中不直接使用s.size()/s.length()呢?因为这两个函数返回的结果类型是unsigned int,但我们的next数组中还会有-1这个数字(int类型的数组),此时进行比较会出现-1大于s.size()的情况导致循环提前结束!
- 想更深入阅读的话推荐推荐阅读这篇博客
- 建议配合一定的例子来阅读KMP算法的代码,会比较好懂一点
- 同样是寻找子串的方法,相对于BF算法,它通过消除主串指针的回溯来提高匹配的效率:保持主串i指针不变(不回溯主串),通过修改子串j指针(下标),让其尽量移动到有效的位置,以减少比较次数,时间复杂度为O(m+n)。它引入了一个next数组,以最简单的理解来说,就是对应子串中第i个位置前真子串的长度的最大值。在匹配失败时我们尽量多的移动模式串,滑动模式串使其模式串位置为next[j]的字符与主串位置i的字符继续匹配(因为有真子串,模式串有一部分是一样的,不需要重新匹配)
void GetNext(int next[], string s)
{
int j, k;
int len = s.length();
next[0] = -1; //初始化next数组
j = 0;
k = -1; //k在这里其实可以理解为储存真子串的长度
while (j < len - 1) //注意循环条件是到长度减1
{
if (k == -1 || s[j] == s[k])
{
j++; k++;
if (s[j] == s[k]) //已改进,如果是相同字符跳过
next[j] = next[k];
else
next[j] = k;
}
else
k = next[k]; //上一次的最长前缀里有没有和现在正判断的字符前面字符一样的字符串,有k个
}
}
int KMP(string s, string t)
{
int j = 0, k = 0;
int len1 = s.length(), len2 = t.length();
int next[1005];
GetNext(next, t);
/*for (j = 0; j < len2; j++)
printf("%d ", next[j]);
j = 0;*/
while (j < len1 && k < len2)
{
if (k == -1 || s[j] == t[k])
{
j++; k++;
}
else
k = next[k]; //回溯
}
if (k >= len2)
return j - k;
else return -1;
}
- 二叉树
- 树结构是一种非线性存储结构,存储的是具有“一对多”关系的数据元素的集合,而二叉树中包含的各个节点的度不能超过 2,它有以下性质
- 二叉树中,第 i 层最多有 2^i-1 个结点。
- 如果二叉树的深度为 h,那么此二叉树最多有 2^h-1 个结点。
- 二叉树中,终端结点数(叶子结点数)为 n0,度为 2 的结点数为 n2,则 n0=n2+1,完全二叉树度为1的结点只能为0或1
- 如果二叉树中除去最后一层节点为满二叉树,且最后一层的结点依次从左到右分布,则此二叉树被称为完全二叉树
- 存储结构
- 二叉树常用两种存储结构,顺序(数组)和链式存储,对于一个链式存储的二叉树,它的结构体除了数据本身外,还需要2个指针分别指向左右孩子。对下图右部的二叉树,顺序和链式表示结果如下图(顺序存储后需补全#号)
- 在顺序存储中,如果根结点的下标是从1开始,以i代表当前数组的下标,则它的左孩子下标为2*i,右孩子为2*i+1;若从0开始,则左孩子下标为2*i+1,右孩子为2*i+2
- 二叉树常用两种存储结构,顺序(数组)和链式存储,对于一个链式存储的二叉树,它的结构体除了数据本身外,还需要2个指针分别指向左右孩子。对下图右部的二叉树,顺序和链式表示结果如下图(顺序存储后需补全#号)
- 二叉树建树(链式结构)
- 层次建树:即每层每层的建树,就是通过上文顺序存储的特点(左右孩子的下标)来进行建树
- 树结构是一种非线性存储结构,存储的是具有“一对多”关系的数据元素的集合,而二叉树中包含的各个节点的度不能超过 2,它有以下性质
typedef struct TNode //后文均使用这个结构体
{
char data;
TNode* lchild; //左孩子指针
TNode* rchild; //右孩子指针
}TNode,*Tree;
Tree CreateTree(string s, int i) //这里一开始传入的i值为1
{
Tree bt;
if (i >= s.size() || s[i] == '#') //注意对i的大小判断一定要在前面,否则先判断s[i]很有可能会越界出错
return NULL;
bt = new TNode;
bt->data = s[i];
bt->lchild = CreateTree(s, 2 * i); //递归建立左孩子
bt->rchild = CreateTree(s, 2 * i + 1); //递归建立右孩子
return bt;
}
- 前序遍历建树/前序+中序遍历建树/后序+中序遍历建树:根据三种遍历的特点(NLR,LNR,LRN,在后面还会进行介绍)进行建树,其中后两种都是先根据前序/后序遍历确定根是谁(第一个/最后一个),再在中序遍历中确定根的位置,左右分治进行建树(仅前序遍历建树感觉有点奇怪,但是因为有相关题目所以还是写上来了)注意前序+后序遍历不能建树!!
Tree CreateTree(string s, int &i) //前序遍历建树,注意i的与号,一开始调用i传入的值为0
{
Tree bt;
if (i >= s.size() || s[i] == '#')
{
i++; //这个i自增针对的是读到#号的情况,使之继续往后读字符串,否则就会因为s[i]一直是#而卡在这里了
return NULL;
}
bt = new TNode;
bt->data = s[i];
i++;
bt->lchild = PreCreateTree(s, i);
bt->rchild = PreCreateTree(s, i); //根据前序遍历根-->左孩子-->右孩子的性质建树
return bt;
}
Tree RebuildTree(char* pre, char* in, int n) //前序+中序遍历结果建树,传入的n为树的节点数
{
int i;
Tree T;
if (n <= 0)
return NULL;
T = new TNode;
T->data = *pre; //根结点就是前序遍历的第一个元素!!
for (i = 0; i < n; i++) //在中序遍历结果中找到这个根节点
if (in[i] == *pre)
break;
T->lchild = RebuildTree(pre + 1, in, i); //在中序遍历中以找到的根节点为界,在左半部分继续递归找根结点建树
T->rchild = RebuildTree(pre + i + 1, in + i + 1, n - i - 1); //在右半部分找根节点建树
return T;
}
Tree RebuildTree(int* in, int* post, int n) //后序+中序遍历建树,n为节点数
{
int i;
Tree T;
if (n <= 0)
return NULL;
T = new TNode;
T->data = post[n - 1]; //根结点是后序遍历的最后一个元素
for (i = 0; i < n; i++) //在中序遍历中寻找该元素
if (in[i] == post[n - 1])
break;
T->lchild = RebuildTree(in, post, i); //在中序遍历中以找到的根节点为界,在左半部分继续递归找根结点建树
T->rchild = RebuildTree(in + i + 1, post + i, n - i - 1); //在右半部分找根节点建树
return T;
}
- 二叉树遍历
- 前序、中序、后序遍历:个人认为这三种遍历是一样的,输出顺序分别为前序:根节点-->左孩子-->右孩子(NLR);中序遍历:左孩子-->根节点-->右孩子(LNR);后序遍历:左孩子-->右孩子-->根节点(LRN)
- 层次遍历:按照二叉树中的层次从左到右依次遍历每层中的结点。通过使用队列的数据结构,从树的根结点开始,依次将其左孩子和右孩子入队。而后每次队列中一个结点出队,都将其左孩子和右孩子入队,直到树中所有结点都出队,出队结点的先后顺序就是层次遍历的最终结果。
void Order(Tree T) //三种序列遍历
{
if (T)
{
/*前序遍历就把输出语句写这里*/
Order(T->lchild);
/*中序遍历就把输出语句写这里*/
Order(T->rchild);
/*后序遍历就把输出语句写这里*/
}
}
void LevelOrder(Tree T) //层次遍历
{
queue<Tree>qu; //新建类型为上面定义的树类型的队列
Tree temp;
qu.push(T); //将根结点入队
while (!qu.empty())
{
temp = qu.front();
if (temp->lchild) //将队头结点的左右孩子依次入队(如果存在的话)
qu.push(temp->lchild);
if (temp->rchild)
qu.push(temp->rchild);
cout << temp->data << " ";
qu.pop(); //输出完出队
}
}
- 二叉树的应用:表达式树
- 表达式树即将表达式转化为树的形式进行保存,通过前序、中序、后序遍历三种不同的遍历方式可以得到前缀、中缀、后缀表达式
- 表达式树的叶子节点均为数字,根节点均为运算符号
- 利用栈辅助建树,实现从叶子节点往根节点反向建树的效果。注意运算符栈有剩余元素的情况
- “1+(2+3)*2-4/5”经过表达式树转换后变成如下结构
- 伪代码简单思路
初始化2个栈,一个用于储存运算符,一个用于储存树的结点
While 字符不为空 do
如果读到数字,新建结点,左右孩子为空,放入树根栈
如果读到符号,根据运算符优先级(与栈顶比较)进行入运算符栈或出栈
If 运算符优先级低于等于栈顶 then
从树根栈取2个元素分别作为左右孩子,根结点为当前符号建成新结点并入栈
else If 运算符优先级高于栈顶 then
运算符入运算符栈
else if 运算符为右括号且栈顶为左括号 then
出栈左括号
End if
End while
while 运算符栈还有剩余元素 do
从树根栈取2个元素为左右孩子,运算符栈顶元素为新根创建结点,入树根栈
End while
- 其他树的结构
- 树的双亲表示法:顺序存储各个节点的同时,给各节点附加一个记录其父节点位置的变量。但这个表示方法缺点也很明显:很难找孩子结点
typedef struct ParentTree //结点的结构
{
int data;
int parent; //记录双亲结点,最开始的根节点为-1
}
2. 树的孩子表示法:与双亲表示法相反,适用于查找某结点的孩子结点,不适用于查找其父结点、且空指针多造成浪费。孩子表示法会给各个节点配备一个链表,用于存储各节点的孩子节点位于顺序表中的位置(存的是下标而不是数据本身!)
typedef struct SonTree //结点的结构
{
int data;
struct SonTree *son[maxSize]; //maxsize代表最大孩子数
}
3. 树的孩子兄弟表示法:是最常用的表示树的方法,位于同一层的节点之间互为兄弟节点。该表示法缺点是找父亲结点困难
typedef struct Tree
{
ElemType data;
struct Tree * son,* bro; //指向孩子与兄弟结点
}
- 树的孩子兄弟表示法的应用:目录树
- 先忽略题目中按字典序、先文件夹再文件等的要求,我们可以画出目录树结构如图
- 同级则在兄弟链上下手,上下层则在孩子链上移动。要注意孩子兄弟表示法有些类似于不带头结点的链表,操作需要小心一些
- 伪代码简单思路
- 先忽略题目中按字典序、先文件夹再文件等的要求,我们可以画出目录树结构如图
建树函数:
读入一行字符串
While 字符串没有读完 do
初始化用来截取名字的字符串
寻找号并截取名字,每次寻找都将高度+1
没有找到号即为文件,遍历该结点的孩子结点的兄弟,新结点建在文件夹之后,按文件名的字典序建立
找到号即为文件夹,遍历寻找是否有同名文件夹,若有则将指针定位到该位置,下次遍历直接从这里开始。返回循环开头继续读字符串,若没有则在文件前、按文件夹的字典序建新结点
End while
输出函数:
输出结点时按高度x2输出空格,并以根结点-->孩子结点-->兄弟结点的顺序输出
- 线索二叉树
- 在二叉树的结点上加上线索的二叉树称为线索二叉树,对二叉树以某种遍历方式(如先序、中序、后序或层次等)进行遍历,使其变为线索二叉树的过程称为对二叉树进行线索化
- 线索二叉树中的线索能记录每个结点前驱和后继信息。为了区别线索指针和孩子指针,在每个结点中设置两个标志ltag和rtag。ltag=0 时lchild指向左孩子;ltag=1 时lchild指向前驱;rtag=0 时rchild指向右孩子;rtag=1 时rchild指向后继
- n个结点的二叉链表共有2n个指针域,非空指针域为n-1个,空指针域有n+1个
- 根据上面的信息,我们将某棵二叉树转化为中序线索二叉树即可得到下面的结果
- 哈夫曼树
- 当用n个结点(都做叶子结点且都有各自的权值)试图构建一棵树时,如果构建的这棵树的带权路径长度最小,称这棵树为“最优二叉树”
- 记住一个规则:权重越大的结点离树根越近,就可以很简单的理解哈夫曼树
- 简单表述建哈夫曼树的步骤如下:1.读入一组数据-->2.从该组数据中取最小的2个数据为左右孩子建树,新结点值为他们的和-->3.将这个和也加入到数据中,同时移除这两个数据-->4.重复步骤2直到只剩下1个数据
- 下图中所有绿色数据加起来即为WPL
- 关于哈夫曼树的建法,网络上和课件上都有给出使用数组的做法,这里贴出我在本次学习之前的建哈夫曼树的思路。使用的是queue+vector进行建树,个人感觉没有复杂很多,但是速度是比数组快的。(但是并不确定是否完全正确,目前遇到的数据都是OK的)
初始化vector ve存储树结构,每个输入的数字作为根结点,左右孩子为空,存入ve
if 只有一个结点时 then 直接返回该结点
将vector根据data值从小到大进行排列
初始化队列储存树结构,存入初始树结构,此时队首的树结构为:左右孩子为第1、2个结点,根为他们的和
初始化i=2
While i小于vector的大小 do
如果连续两个ve[i]不大于队首的值,则取这两个结点为左右孩子建立树结构并入队
其他情况,或是vector中只剩下一个结点时,以ve[i]为左孩子,队首为右孩子新建树结构,出队队首并将新结点入队
End while
如果队列剩余元素不为1,取前2个为左右孩子形成新的结点,新结点入队,出队前2个元素,循环直至队列只剩一个元素
此时队列剩下的1个元素即为所求哈夫曼树
- 并查集
- 假设有这样一张关系图
- 看左图我们都知道,9是所有人的上级,但是在一般结构中,5会觉得:2是我的上级,1、9是谁啊不认识。这时候我们就需要并查集,让关系图形成右图那样子
- 首先我们需要将所有人进行独立,即每个人都是独立的,我的上级就是我自己
- 每次都对比两个人,如果他们的上级不相同,则将他们都进行合并到一起,在所有合并结束后最后让所有人再去寻找一次上级
- 例如首先有2 5,它们的上一级不相同,则将它们合并,5的上级为2,2的上级还是自己
- 再来有2 3,它们的上级又不同,合并后2、3、5的上级均为2
- 这时候来了一个1 2,这时候在合并时,可以将2、3、5全部归到1去
- 假设有这样一张关系图
typedef struct node //并查集结构声明
{
int data; //编号
int rank; //结点秩,记录高度
int parent; //记录上一级
} UFSTree;
int FindRoot(UFSTree t[],int x) //查找x的上级
{
if (x != t[x].parent) //上级不是自己的情况
return FindRoot(t,t[x].parent)); //递归找上级
else
return x; //上级即为自己
}
void GetUnion(UFSTree t[],int x,int y) //合并数据x和y
{
x = FindRoot(t,x); //找x的上级
y = FindRoot(t,y); //找y的上级
if (t[x].rank > t[y].rank) //y结点的秩小于x结点的秩
t[y].parent = x; //将y连到x结点上,x作为y的上级
else
{
t[x].parent = y; //将x连到y结点上,y作为x的上级
if (t[x].rank == t[y].rank)
t[y].rank++; //因为y是x的上级了,但它们的秩却一样,所以y的秩要增加
}
}
1.2 学习体会
- 进入树的章节的学习,能感觉到难度的骤增,感觉内容也逐渐抽象了起来,与上学期的递归紧密联系了起来,还是挺令人困扰的
- 与前面章节的内容也有了串联,很多时候不仅仅只需要树的内容,还需要结合栈、队列等来帮助
- 对算法方面的要求也在逐渐提高,KMP算法看着代码十分简单但是要理解起来还需要花费好大功夫、并查集的递归也需要有一定的思考等
- 线索二叉树虽然并不要求会写代码,但是真的感觉还是太抽象了,看了很多不同的资料还是只能有一个一知半解,是在后续中还需要补足的方面
Q2.阅读代码
2.1 出现次数最多的子树元素和
2.1.1 设计思路
- 本题要求出现次数最多的子树元素和,举个例子,有树:
- - 叶子结点自身也算是一个子树(一个根结点带着左右空结点),所以出现了4个子树元素和0 1 2 4
- 接下来计算2-4-2和3-0-1这两棵子树,它们的和为8和4,还有整棵树的和13
- 至此得到4的出现次数最多,答案即为4
- 由于本题是要求所有子树的和,所以可以通过一个后序遍历来实现从底部向上计算每段子树的值
- 再定义一个map来记录对应元素和的出现次数即可。若最大出现次数有多个数字,按任意顺序输出
- 时间复杂度为O(n),空间复杂度为O(n)
2.1.2 伪代码
定义map,键值为和的大小,映射值为出现次数
int TreeSum(TreeNode* root)
{
if 根的左右子树为均空 then
直接返回根结点数值
end if
if 左子树不空 then
左子树和 = TreeSum(root -> left);
对应和出现次数+1
end if
if 右子树不空 then
右子树和 = TreeSum(root -> right);
对应和出现次数+1
end if
return 当前结点的值加上左右子树和的值 //相等于后序遍历
}
vector<int> findFrequentTreeSum(TreeNode* root) {
vector<int> ans;
if(root == NULL) return ans;
int sum = TreeSum(root);
ints[sum] ++; //整棵树的和(算上根节点)也不能忘记
遍历map,得到出现次数最大对应的值,存入vector
}
2.1.3 运行结果
2.1.4 分析
- 求子树的和,该题解充分利用了后序遍历的特性:左孩子-->右孩子-->根节点,让这题的计算实现的是“从叶子向根部”逐层向上叠加,这样子写不会出现有落下某一个子树没算的情况
- 使用map来对应和出现的次数,选择的题解代码中选择了遍历2次map(因为会有同一个数字出现次数相同的情况),考虑的比较周全,但我认为还会有更好一点的方案来优化一下
- 例如在求和的时候就记录最大的出现次数,这样也只需增加1个变量,就可以在最后遍历1次map就可以得到所有符合条件的情况
2.2 监控二叉树
2.2.1 设计思路
- 本题的题意简单来看,节点上的每个摄影头都可以监视其父对象、自身及其直接子对象,至少需要几个摄像头能做到覆盖到整个二叉树
- 因此我们不妨先对每个结点的状态做一个定义:0-该节点放置了摄像头,1-该节点可以被其他摄像头监控,2-该节点没有被监控到
- 假设有这样的二叉树,我们很简单的知道红色部分是应该放摄像头的位置
- 使用DFS,在这里即为后序遍历的顺序开始对每个结点的状态进行赋值,可以得到下面的结果
- 因为要最少的摄像头,那么每个摄像头要尽量观察到多的结点,因此我们不能在叶子结点上放摄像头,所以所有叶子结点的值都应为2
- 更具体的赋值说明在后面说明
- 该题解代码的时间复杂度为O(n),空间复杂度为O(n)
2.2.2 伪代码
/*这里的伪代码主要展示dfs的部分*/
int dfs(TreeNode* root) {
如果当前结点是叶子结点则返回值2
如果左子树不空则求左子树的值,右子树同理
if 左右子树中有一个检测不到(即为左右子树有一个值为2) then
摄像头数量+1
返回值0(即为当前结点放一个摄像头)
end if
if 左右子树出现了至少1个能被监测到的 then
返回值2 (因为超出摄像头的范围了,所以记录当前结点没有被检测到)
end if
if 左右子节点有一个是摄像头
返回值1 (在摄像头范围中,记录当前结点可以被监测到)
end if
摄像头数量+1
return 0; (这里是只有一边子树且没有被监测到的情况)
2.2.3 运行结果
2.2.4 分析
- 本题又是一题典型的看起来简单写起来复杂的题目。与上一题一样采用后序遍历,同样是从叶子向根部开始赋值,确保了最后取到的一定是最小值
- 难点我认为在于在定义了各种状态后,我们如何找到其中的规律对结点进行赋值?
- 题解中这部分就写的很好,如左节点/右节点如果只有1个或可以被看到,则当前节点检测不到(左右孩子出现1个1,那么自己即为2)、左右结点有一个观察不到则当前结点放置摄像头(左右孩子出现一个2,那么自己为0)
- 题解还有一个优秀的地方是它除了递归占用空间外,完美的利用了题目条件中所有结点都自带0值的条件(虽然截图中没有截图到),没有过多的数据结构,这是相比其他题解代码更优秀的地方
2.3 二叉树中的最大路径和
2.3.1 设计思路
- 本题与第一题稍有类似,但是也存在着自己的问题,他所求的是一条路径上的最大和:比如,你的右孩子是负数我不需要了,但是父结点的父结点、父节点的父结点的另一子树的某些值加上去我得到最大值呢?……
- 题解代码依然是采用后序遍历,利用后序遍历由叶节点开始遍历每一节点,对于每一结点他都可能成为最大值所经过的结点
- 我们假设左子树能提供的大小为pal,右子树为par,倘若pal<0,则令其等于0,视为无意义;par同理(舍去负数)
- 每个带左右孩子的结点的则最大路径可能值为pal+par+root—>val(即一个倒V字的路径)
- 父节点能做的贡献为max(pal,par)+root—>val(只取V的一边,就是值更大的那一边)
- 例如上图中,我们就选取了4->2这条路径
- 该题解代码的时间复杂度为O(n),空间复杂度为O(n)
2.3.2 伪代码
/*这里的伪代码是主要处理部分*/
int post(TreeNode* &root,int &res) //res是用于记录最大路径和
{
如果root为空即返回0
pal=post(root->left,res); //左子树递归
par=post(root->right,res); //右子树递归
pal和par若有小于0的,则将其值改为0
res与pal+par+当前结点值做比较,留下大的值给res
返回pal与par中更大的与当前结点值的和
}
2.3.3 运行结果
2.3.4 分析
- 一开始看到这个题解代码————这么短,挺震惊的。但又不得不感叹题解代码的巧妙,直接将res也带到每一次递归之中,进行动态处理
- 起初我还在想,如果我的树中都是负数,那这代码不会出现问题吗?
我以为我在第五层,实际上我在第一层。人家题解作者早就考虑到了,虽然pal与par归零了,但是父结点值还是有进行考虑的,所以最终还是会有一个最小的负数值返回 - 比较难理解的地方可能在为什么在取了更大值作为res后还要返回一个算式呢?实际上可以进行这样的简单理解:前一个算的是当前结点已经是最大路径和所形成的树的根节点的情况;而后一个结点算的就是这一部分也只是最大路径和所形成的树的一部分的情况
- 另外,经过这三题,我们可以比较清楚的知道,后序遍历在大部分解决树的问题时都会是最优选项
2.4 恢复二叉搜索树
2.4.1 设计思路
- 什么是二叉搜索树? 它或者是一棵空树,或者是具有下列性质的二叉树: 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值; 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值
- 根据上述情报,我们可以利用中序遍历来检查一棵二叉搜索树(利用中序遍历左->根->右的特性)
- 我们可以使用双指针的方法,检查中序遍历结果里的逆序对,在遇到逆序对的时候,如果是第一次遇见,则储存索引小的,如果不是,则只存储索引大的那个
- 如有1 4 3 2 5,第一个逆序对4 3,储存4,第二个逆序对3 2,储存2;那么2 4即为被交换的元素
- 但第一次遇见也要储存大索引的,因为有可能一个序列中只有这相邻的数进行了交换(1 3 2 4 5)
- 其实根据中序遍历,我们就已经可以通过递归等方式做这题了,但是本题解中它又使用了一个Morris遍历算法来进行遍历。我们都知道普通的中序遍历就是采用递归的方式,其实这占用的空间还是挺大的,尤其是当结点很多的时候。但是Morris算法进行遍历,所需的空间复杂度只有O(1)!
- 简单的Morris算法步骤如下,更具体的还请点击上面的超链接进行查看:
1,根据当前节点,找到其前序节点,如果前序节点的右孩子是空,那么把前序节点的右孩子指向当前节点,然后进入当前节点的左孩子。
2,如果当前节点的左孩子为空,打印当前节点,然后进入右孩子。
3,如果当前节点的前序节点其右孩子指向了它本身,那么把前序节点的右孩子设置为空,打印当前节点,然后进入右孩子。 - 题解代码的时间复杂度为O(n),空间复杂度为O(1)
2.4.2 伪代码
TreeNode *s1 = NULL, *s2 = NULL, *pre = NULL; //s1存索引小的结点,s2存索引大的结点,pre存前驱结点
void recoverTree(TreeNode* root) {
TreeNode* cur = root; // 游标
while(cur != NULL){
若左子树不空则进入左子树,找cur的前驱结点
若cur的左子结点没有右子结点,那cur的左子结点就是前驱
若cur的左子结点有右子结点,就一路向右下,走到底就是cur的前驱
若前驱还没有指向自己,说明左边还没有遍历,将前驱的右指针指向自己,后进入前驱
若前驱已经指向自己了,直接比较是否有逆序对,然后进入右子树
if(pre != NULL && cur->val < pre->val){ //前驱指向自己时在这里找逆序对
if(s1 == NULL) s1 = pre;
s2 = cur;
}
进入右子树
若左子树为空,检查是否有逆序对,然后进入右子树
if(pre != NULL && cur->val < pre->val){
if(s1 == NULL) s1 = pre;
s2 = cur;
}
进入右子树
}
s1与s2交换
return;
}
2.4.3 运行结果
2.4.4 分析
- 本题解最大的特点即为它O(1)的空间复杂度,没用采用常规的中序遍历而是Morris遍历算法,并且使用了双指针和前驱指针来辅助逆序对的交换
- 所以最大的优点和难点也就都是这个算法了,能够理解这个算法就相当于读透这个题解了吧
- 目前我对这个算法还是处于一个初步认识的阶段,就觉得它有些像我们最近所学的线索二叉树??有一种即插即用版的线索二叉树的感觉
- 当然以目前的数据标准,使用递归(空间复杂度O(n))等方法来实现也不是不可以,到了以后面对大数据,这O(1)的空间复杂度还是太香了,感觉可能是在未来必学的算法之一吧