数据结构——树
一边看数据结构,一边刷Leetcode,顺便写篇博客串讲一下。
树的常用类型
一般而言,常用的树都是二叉树。也就是一个结点最多有两个子节点。
而对于不同的应用场景,有多种定义的树:
- 搜索二叉树:左子节点数值小于节点数值小于右子节点数值;
- 满二叉树:一个二叉树上面,所有的分支节点都存在左子树和右子树,且所有的叶子都在同一层上。
二叉树的性质
- 在二叉树的第
i
i
i层,最多有
2
i
−
1
2^{i-1}
2i−1个结点。
注意根结点是第1层。
- 深度为k的二叉树最多有
2
k
−
1
2^{k}-1
2k−1个结点。
假设是满二叉树,最多不过是:
1 + 2 + 4 + 8 + ⋯ = 1 ( 1 − 2 k ) 1 − 2 1+2+4+8 +\dots =\frac{1(1-2^k)}{1-2} 1+2+4+8+⋯=1−21(1−2k) 。 - 对任何一个二叉树,如果其终端结点数(叶子结点数)为
n
0
n_0
n0,度为2的结点数为
n
2
n_2
n2,则
n
0
=
n
2
+
1
n_0=n_2+1
n0=n2+1。
假设度为1的结点数为
n 1 n_1 n1,则总结点数为
n = n 0 + n 1 + n 2 n=n_0+n_1+n_2 n=n0+n1+n2,而每一个结点对应着一根输入连接线(除了根结点没有),则连接线总数
s u m = n − 1 = n 0 + n 0 + n 2 − 1 sum=n-1=n_0+n_0+n_2-1 sum=n−1=n0+n0+n2−1,同时连接线也可以通过输出来计算
s u m = 2 ∗ n 2 + 1 ∗ n 1 sum=2*n_2+1*n_1 sum=2∗n2+1∗n1,两者联立可得
n 0 + n 0 + n 2 − 1 = 2 ∗ n 2 + 1 ∗ n 1 n_0+n_0+n_2-1=2*n_2+1*n_1 n0+n0+n2−1=2∗n2+1∗n1,即
n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1。
二叉树遍历方法
- 前序遍历
主要特点是,根结点 | 左子树 | 右子树
:{A} {B(DGH)} {C(EI)F},第一位是根结点。 - 中序遍历
主要特点是,左子树 | 根结点 | 右子树
: (GDH)B A (EI)C(F) - 后序遍历
主要特点是,左子树 | 右子树 | 根结点
,GHDB IEFC A,最后一位是根结点。 - 层序遍历
最为直观的遍历方式,从上到下,从左到右,依次遍历一遍。
遍历算法的实现:
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
int Preorder(TreeNode * root, vector<int> & key)
{
if(root == NULL)
return 0;
key.push_back(root->val);
Preorder(root->left,key);
Preorder(root->right,key);
return 1;
}
/*************实现功能
key将记录树的前序排列
*********************/
int Inorder(TreeNode * root, vector<int> & key)
{
if(root == NULL)
return 0;
Preorder(root->left,key);
key.push_back(root->val);
Preorder(root->right,key);
return 1;
}
/*************实现功能
key将记录树的中序排列
*********************/
int Postorder(TreeNode * root, vector<int> & key)
{
if(root == NULL)
return 0;
Preorder(root->left,key);
Preorder(root->right,key);
key.push_back(root->val);
return 1;
}
/*************实现功能
key将记录树的后序排列
*********************/
层序遍历稍微有点复杂,不过结合队列来处理,也是比较简单的。
int Sequence(TreeNode * root, vector<int> & key)
{
if(root == NULL)
return 0;
queue<TreeNode* > q;
q.push(root);
while(q.empty() != 1)
{
TreeNode temp = q.front();
q.pop();
key.push_back(temp->val);
if(temp->left != NULL)
q.push(temp->left);
if(temp->right != NULL)
q.push(temp->right);
}
}
如果还有不清楚的地方,可以浏览Leetcode上的题目:从上到下打印二叉树。
二叉树的构建方法
给出中序遍历和前序/后续遍历,重建出二叉树,同样是结合题目来看:重建二叉树。需要注意的是,仅知道前序遍历和后序遍历无法唯一的确定二叉树
。
class Solution {
public:
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
this->preorder = preorder;
for(int i = 0; i < inorder.size(); i++)
dic[inorder[i]] = i; //构建一个hash表,用于快速查找中序遍历中root的索引
return recur(0, 0, inorder.size() - 1);
}
private:
vector<int> preorder;
unordered_map<int, int> dic;
TreeNode* recur(int root, int left, int right) {
//root是在前序遍历的索引,left,right是在中序遍历的索引(左边界和右边界)
if(left > right) return nullptr; // 递归终止
TreeNode* node = new TreeNode(preorder[root]); // 建立根节点
int i = dic[preorder[root]]; // 划分根节点、左子树、右子树
node->left = recur(root + 1, left, i - 1); // 开启左子树递归
node->right = recur(root + i - left + 1, i + 1, right); // 开启右子树递归
return node; // 回溯返回根节点
}
};
这段代码容易造成混淆,不过重点是讲清楚这么做的思路
前序遍历Preorder
特点:
root | left | right |
---|---|---|
0 | [1, index_root] | [index_root+1, Preorder.size()-1] |
中序遍历Inorder
特点:
left | root | right |
---|---|---|
[0, index_root-1] | index_root | [index_root+1, Preorder.size()-1] |
所以说,如果已知中序遍历和前序遍历,那么具体的思路是:
前序遍历第一个元素就是根结点
;- 找到根结点在中序遍历的索引,分割左子树和右子树,
中序遍历中,根结点左侧是左子树,右侧是右子树
; - 找到左子树和右子树后,再回到前序遍历,分割开左子树与右子树;
前序遍历中,左子树第一个元素就是左子结点,右子树的第一个元素就是右子结点
。- 整理出子问题的
Preorder
和Inorder
,进入到下一层。
思路清楚后,主要的问题就是该如何编程和提高效率:
- 采用hash表,提高查找效率
- 使用递归方法,从上到下解决问题,递进的构建子树
- 将子问题的前序遍历和中序遍历用两端的索引进行存储,提高了空间利用率,避免每一个递归都需要重新构建遍历序列。
二叉树路径的记录
有时,需要记录找到一个结点的路径,实际上是一种搜索的思想。
考虑这么一个问题:给定一个二叉树,要求打出所有符合求和要求的路径,
参考题目:二叉树中和为某一数值的路径
class Solution {
public:
vector<vector<int>> pathSum(TreeNode* root, int target) {
vector<vector<int>> key;
vector<int> path;
subFunc(root,target,path,key);
return key;
}
int subFunc(TreeNode * root,int target,vector<int> & path,vector<vector<int>> & key)
{
if(root == nullptr)
return 0;
if(root->val==target && root->left == nullptr && root->right ==nullptr)
{
path.push_back(root->val);
key.push_back(path);
path.pop_back();
}
if(root->left != nullptr)
{
path.push_back(root->val);
subFunc(root->left,target-root->val,path,key);
path.pop_back();
}
if(root->right != nullptr)
{
path.push_back(root->val);
subFunc(root->right,target-root->val,path,key);
path.pop_back();
}
return 1;
}
};
那么朴素的想法就是采用回溯法。因为每一个结点都有最多两种可能性(左结点,右结点),那么就要不断的尝试,如果做错了回退到上一步换另一个方向,这样就能遍历所有的路径。
回溯法的关键在于能够完好如初的回到上一状态,也就是代码中的path
在回溯之后,需要弹出上一次的输入项。而且这个题目中,无论是否找到符合要求的路径,都要进行回溯。因为我们的目的是遍历所有的路径,找到所有符合要求的路径,不是只找到其中一条。
脱离这道题,回溯也是一种对二叉树的遍历方法,或者从某种层次讲,它就是深度优先搜索。
如何找到相同的父结点?
这一类问题可以归结于如何找到父节点? 根据节点的性质,我们可以很容易找到其的子节点(用指针不断的向下寻找即可),但对于其父节点却不是很方便。因为树的结点并没有指向上层结点的指针。参考的题目有两道:
其实这两者的解决思路都是一样的,只不过二叉搜索树具有一定的大小性质,所以可以更快一些。而二叉树需要较多的运算。
最深的公共祖先应该具有如下的性质,其左子树内有一个节点,右子树有另一个节点,或者自身是一个节点。
对于二叉树,相应的解法如下:
class Solution {
public:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if(root==NULL)
return root;
TreeNode *temp=root;
while(1)
{
if(inNode(temp->left,p) && inNode(temp->left,q)) //都是其左子树的节点,则进入左子树
{
temp=temp->left;
}
else if(inNode(temp->right,p) && inNode(temp->right,q)) //都是其右子树的节点,则进入右子树
{
temp=temp->right;
}
else
{
return temp;
}
}
return temp;
}
bool inNode(TreeNode * root, TreeNode *p) //判断p是不是属于root的子节点,或者是root自身
{
if(root == NULL)
return false;
if(root == p)
return true;
bool left_flag = inNode(root->left,p);
bool right_flag = inNode(root->right,p);
if(left_flag || right_flag)
{
return true;
}
else
{
return false;
}
}
};
而对于二叉搜索树,
bool inNode(TreeNode * root, TreeNode *p)
可以基本省略,用二叉搜索树的性质直接进行判断。此处不再详说。
在此提供一个较快的思路,找到p
和q
的路径,直接对路径进行比对,就可以以很低的运算复杂度来解决该问题:
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
class Solution {
public:
vector<TreeNode *> p_path;
vector<TreeNode *> q_path;
vector<TreeNode *> path;
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
if(root==NULL)
return root;
TreeNode *temp=root;
bool left_flag;
bool right_flag;
inNode(root,p);
p_path=path;
path.clear();
inNode(root,q);
q_path=path;
int p_num=p_path.size();
int q_num=q_path.size();
for(int i=1;1;i++) //对节点路径进行比对
{
if(p_path[p_num-i]!=q_path[q_num-i])
{
temp = p_path[p_num-i+1];
return temp;
}
if(p_num-i==0)
{
temp = p_path[0];
break;
}
else if(q_num-i==0)
{
temp =q_path[0];
break;
}
}
return temp;
}
bool inNode(TreeNode * root, TreeNode *p) //获取节点的路径
{
if(root == NULL)
return false;
if(root == p)
{
path.push_back(root);
return true;
}
bool left_flag = inNode(root->left,p);
bool right_flag = inNode(root->right,p);
if(left_flag || right_flag)
{
path.push_back(root);
}
else
{
return false;
}
return true;
}
};
不过需要注意不同情况的处理,
比如p
和q
分居两侧,情况容易得到考虑;而p
或q
是另一个节点的祖先节点,就需要小心谨慎的判断。