文章目录
- 9.树
- (1).树的基本概念
- #1.基本定义
- #2.树的广义表表示法
- #3.基本术语
- (2).树的存储结构
- #1.标准形式(常用)
- #2.逆存储形式
- #3.孩子兄弟存储法
- (3).并查集
- #1.我们到底想解决什么问题
- #2.并查集结点
- #2.Find(查)
- #3.Union(并)
- #4.例子
- (4).树的遍历
- #1.前序遍历
- #2.后序遍历
- #3.遍历的非递归实现
- i.非递归前序遍历
- ii.非递归后序遍历
- #4.层序遍历
- (5).树的线性表示
- #1.双亲表示法
- #2.层号表示法
- 小结
9.树
接下来我们就要走出一对一的线性结构,来研究一下一对多的树形结构了,树的结构其实非常常见,比如我们在对文件分类的时候,同一个目录下有多个文件、目录,而子目录下还有更多文件,这就是一个比较典型的一对多关系。
(1).树的基本概念
#1.基本定义
树是由 n ( n ≥ 0 ) n(n\ge0) n(n≥0)个结点组成的有限集T,它或为空树( n = 0 n=0 n=0);或为非空树。对于非空树T,它满足以下两个条件:
- 1.有一个特定的结点,称之为根结点(root)
-
- 除根结点以外,其余的结点分成 m ( m > 0 ) m(m>0) m(m>0)个互不相交的有限集 T 0 , T 1 , T 2 , . . . , T m − 1 T_0, T_1, T_2,..., T_{m-1} T0,T1,T2,...,Tm−1,其中每个集合都是一棵树,称为根结点的子树
也就是说,树的定义本身是一个递归定义,例如下图:
其中A是根结点,B,C和D分别构成了三棵子树,你可以依次递归地判断下去,直到遇到一棵空树
#2.树的广义表表示法
例如对于上面这棵树,我们可以表示为A(B(E, F), C, D(G(H))),字母后接的括号内包含该结点所有的子结点
#3.基本术语
- 结点:树中的每个独立单元
- 结点的度(次数):一个结点的子树的个数称为结点的度(类比图中结点的出度/入度)
- 叶子结点:度为0的结点称为叶子结点,他没有子树
- 分支结点:度不为0的结点
- m次完全树:假设树T是一棵m次数,如果T中非叶子结点的次数都为m,则称树T为一棵m次完全树
- 双亲结点和子结点:结点的子树的根结点称为该结点的子结点,而该结点也称为子树根结点的双亲结点(父结点)
- 兄弟结点:如果结点k有两个或两个以上子结点,则结点k的所有子结点互为兄弟结点
- 路径:对于树种的任意两个不同的结点 k i k_i ki和 k j k_j kj,如果 k i k_i ki出发能够“自上而下地”通过树种的结点到达结点 k j k_j kj,则称 k i k_i ki到 k j k_j kj存在一条路径
- 路径长度:路径中所包含的边的数量称为路径长度(路径的长度等于这条路径上的结点个数-1)
- 结点的祖先:如果从结点 k i 0 k_{i_0} ki0到结点 k i n k_{i_n} kin有路径 ( k i 0 , k i 1 , . . . , k i n ) (k_{i_0}, k_{i_1}, ..., k_{i_n}) (ki0,ki1,...,kin),则称 k i 0 , k i 1 , . . . , k i n − 1 k_{i_0}, k_{i_1},..., k_{i_{n-1}} ki0,ki1,...,kin−1都是结点 k i n k_{i_n} kin的祖先
- 结点的后代:结点 k i 1 , k i 2 , . . . , k i n k_{i_1},k_{i_2},...,k_{i_n} ki1,ki2,...,kin都是结点 k i 0 k_{i_0} ki0的后代
- 结点的层次:将根的层次定义为0,其余结点所在的层次都等于其父结点的层次+1(一般也有将根结点层次定义为1的,空树的层次则为0,这里采取ECNU数据结构课中给出的定义)
- 高度(深度):树中层次最大的结点的层次称为树的深度
我们再拿这张图举个例子, k 0 k_0 k0的层次是0, k 1 , k 4 , k 5 k_1, k_4, k_5 k1,k4,k5的层次是1,以此类推,并且该树的深度为3
- 有序树:如果给定的m次树,结点的各子树从左到右是有次序(不可交换)的,那么称该树为有序树
例如在这里我们就使用了一棵有序树来表示 ( A + B ) × 5 ÷ ( 2 × ( C − D ) ) (A+B)\times5\div(2\times(C-D)) (A+B)×5÷(2×(C−D))的值,有趣的是,如果你已经提前了解了二叉树的前、中、后序遍历之后,你就会发现,表达式树的前、中、后序遍历得到的序列正是前缀、中缀、后缀表达式,比如我们用后序遍历可以得到: A B + 5 ∗ 2 C D − ∗ / AB+5*2CD-*/ AB+5∗2CD−∗/,是不是还挺神奇的,之后我们会再讲到树的遍历
- 森林: m ( m ≥ 0 ) m(m\ge0) m(m≥0)棵互不相交的树的集合
(2).树的存储结构
说了这么多,接下来我们应该看看怎么把一棵树存储起来了,不过因为树是非线性结构,我们必须采取一些措施把它映射到一个线性的结构上去,否则我们就没有办法存储了。
#1.标准形式(常用)
我们采取如下的结构体来存储树中的结点:
struct TreeNode
{
int val;
TreeNode* child[MAXN];
};
很简单的结构,首先存储自己结点的值,然后存储所有子结点的指针,以便于找到所有的子结点,它的存储结构图如下:
还是比较好理解的对吧,这里向上的小箭头代表空指针,意味着存储空间的此位不存在子结点,不过这样的结构很有可能造成比较严重的空间浪费,因此在C++中我们可以采取vector来替代原生数组:
struct TreeNode
{
int val;
vector<TreeNode*> child;
};
同理,能够更好利用零散空间的链式存储也比较适合这种场景,我们可以把存储子结点的存储结构换成链表,例如:
struct TreeNode
{
int val;
list<TreeNode*> child;
};
带有vector的在C语言中不好实现,但是使用链表的实现起来就没有那么复杂了,不过这二者对于我们来说不会有很大的差异,因为后续的操作多数都是要对整个包含子结点的存储结构进行遍历的,因此我们可能不太需要随机访问。
这种存储结构对于双亲找子的过程非常方便,我们只要知道一个结点,就可以很轻松地找到它的所有子结点,但是如果我们知道了一个结点,希望找到它的双亲结点,在这种存储方式之下就不是很容易了,这个问题其实看起来就和链表的某个结点希望找到其上一个结点一样,如果我们不额外耗费一点空间,那肯定是需要耗费更多的时间来完成这件事的
#2.逆存储形式
我们把每个结点都存自己的子结点的过程逆转一下,每个结点都存自己的双亲结点,结构体变成这样:
struct TreeNode
{
int val;
TreeNode* parent;
};
这样就可以非常容易地找到自己的双亲结点了,它的存储形式是这样:
但是这样一来,想找到自己的子结点又变得困难了起来,所以怎么办呢?这还不好办,就像链表为了快速找到前后结点一样,我们设置一个双向的结点就可以了:
struct TreeNode
{
int val;
TreeNode* parent;
vector<TreeNode*> child;
};
这就完美了,虽然每个结点占用的空间可能比较大,但是至少我们在时间上有了很大的优化,无论是找子结点还是找双亲结点都可以直接通过指针跳转了
#3.孩子兄弟存储法
这回我们就不存全部的结点了,我们每个结构体中只存储两个结点的指针:自己的第一个子结点以及自己右边的第一个兄弟结点,结构体定义如下:
struct TreeNode
{
int val;
TreeNode* firstChild;
TreeNode* nextSibling;
};
这种存储方式的好处是,我们可以很好地解决上面几种存储方法空间消耗很大的问题,同时我们把多叉树通过这种方式转换成了二叉树,我们对于二叉树的一些操作对于这种存储方式的多叉树也是可以使用的
(3).并查集
#1.我们到底想解决什么问题
我们怎么表示一个集合呢?其实用什么都行,比如我上学期C++课上写过一个用数组实现的集合:
class integerSet
{
private:
int* arr;
size_t _size;
size_t _capacity;
public:
static constexpr size_t npos = -1;
struct bad_integerSet : public std::exception
{
bad_integerSet() = default;
const char* what() const throw ()
{
return "bad_integerSet";
}
};
public:
integerSet() = delete;
integerSet(size_t size)
: arr(new int[size+1] {0})
, _size(0), _capacity(size) {}
integerSet(const integerSet& s1);
integerSet(integerSet&& s1) noexcept;
~integerSet();
size_t find(int elem) const;
void insert(int elem);
integerSet setunion(const integerSet& s1) const;
integerSet setdifference(const integerSet& s1) const;
integerSet setintsection(const integerSet& s1) const;
integerSet setsymmetricdifference(const integerSet& s1) const;
integerSet& operator=(const integerSet& s1);
int& operator[](size_t _index);
bool operator==(const integerSet& s1) const;
size_t size() const;
size_t capacity() const;
bool isSubset(const integerSet& s1) const;
bool isMember(int m) const;
bool isEmpty() const;
void clear();
void erase(int elem);
};
它的实现你倒是确实可以试一试,不是很困难,不过如果你的集合无序,有一些操作做起来会比较困难,所以C++的STL中提供的集合是基于红黑树(set)/哈希表(unordered_set) 实现的,但是这样就有点麻烦了
我们接下来要考虑的问题,是比较简单的,假设说我们的一个数组中存在很多个集合,并且这些集合之间不存在交集(也就是说这些集合一起构成了这个数组的划分),我们如何在更快的时间内,表示出其中每一个元素的集合所属关系呢?要明确的一点是:在我们当前研究的集合中,元素a在集合A中,元素b在集合B中,如果元素b也在集合A中,那么集合A和集合B是一个集合,也就是说,一旦在两个集合中出现同一个元素,那么这两个集合就可以直接合并,那我们是不是可以用树来表达这个结构呢?
例如对于三个集合:{0, 6, 7, 8}, {1, 4, 9}, {2, 3, 5},假设我们把第一个元素作为根,那么就有:
哇哦,我们用双亲存储法构建这棵树,就可以把一个集合描述出来,那么如果
S
1
S_1
S1中的结点4,正好也属于
S
0
S_0
S0呢?就比如下面这样:
这种情况下出现了多对一的结构,那么理论上讲我们用树就不能描述了,可能需要用到图的结构,但是我们好像忘了点什么事情:
在我们当前研究的集合中,元素a在集合A中,元素b在集合B中,如果元素b也在集合A中,那么集合A和集合B是一个集合
既然4是
S
0
S_0
S0和
S
1
S_1
S1共有的元素,那么集合
S
0
S_0
S0和集合
S
1
S_1
S1应当是一个集合,所以我们直接把
S
1
S_1
S1的根合并到
S
0
S_0
S0去,形成一个更大的集合,这样就可以把这些数据全都加到同一个集合里面去了:
在这种集合中,我们怎么判定两个元素是否属于同一个集合呢?这个也简单,因为每个结点都可以找到自己的双亲结点,所以我们只要一直判断到根结点,看看两个元素是不是能够找到同一个根结点即可
所以综上,我们的集合只需要两个操作Union(并) 和Find(查) 两个操作即可,我们研究的这一类集合,也就叫做并查集
#2.并查集结点
并查集的每个结点要怎么存呢?其实比较简单,对于一个数组,下标是它的值,数组内对应的数字是它的双亲结点,而对于根结点,可以存一个负数(表示整个根结点下有多少个结点),也可以存自己的下标(这意味着如果找到下标和存储值相同的,就已经找到根了)
所以在这里我们采用前者,前者对于一些问题的解决来说非常方便,比如找出朋友圈中人数最多的朋友圈的数量,需要注意的是,如果我们采取前者,在初始化的时候就需要我们把数组每一个元素对应的值都改成-1,这样每一个元素都是自己的根,对于后续我们的操作会非常方便
#2.Find(查)
先说查是因为并操作也需要先依赖于查操作,所以我们可以给出如下代码:
int find(vector<int>& dsu, int x)
{
if (dsu[x] < 0) {
return x;
}
return find(dsu, dsu[x]);
}
非常简单的函数,如果找到一个值小于0的,就直接返回,如果不是,则继续向着根找,直到找到一个小于0的就结束了,这样是不是很简单呢?判断两个元素是不是在一个集合里的操作就是这么简单
#3.Union(并)
然后就是并,在这里我们采取一个简单策略,如果x和y需要合并,那就直接把y对应的整棵树合并到x上面去,代码如下:
int Union(vector<int>& dsu, int x, int y)
{
int fx{ find(dsu, x) }, fy{ find(dsu, y) };
if (fx != fy) {
dsu[fx] += dsu[fy];
dsu[fy] = fx;
}
return fx;
}
我们首先找到两个结点对应的根,如果两个对应相同的根,就不需要合并,但如果是不同的根,我们直接把y对应的整棵树都合并进x对应的树,所以在这里只要让fy指向fx,把fx的值加上fy的值即可
#4.例子
就是这样,并查集的代码加起来20行不到,就可以实现我们需要的功能了,所以来看看这个例子:
这里我直接给代码了:
#include <iostream>
#include <vector>
using namespace std;
int find(vector<int>& circle, int x)
{
if (circle[x] < 0) {
return x;
}
else {
return circle[x] = find(circle, circle[x]);
}
}
int Union(vector<int>& circle, int rootX, int rootY)
{
int fx{ find(circle, rootX) }, fy{ find(circle, rootY) };
if (fx != fy) {
circle[fx] += circle[fy];
circle[fy] = fx;
}
return fx;
}
int main()
{
int N{ 0 }, M{ 0 };
cin >> N >> M;
vector<int> circle(N + 3, -1);
for (int i = 0; i < M; i++) {
int cnt{ 0 }, root{ 0 }, in{ 0 };
cin >> cnt;
for (int j = 0; j < cnt; j++) {
cin >> in;
if (j == 0) {
root = in;
}
else if (in != root) {
int rt{ find(circle, in) };
if (root != rt) {
root = Union(circle, rt, root);
}
}
}
}
int min_val{ 0 };
for (auto& i : circle) {
if (i < min_val) min_val = i;
}
cout << -min_val << endl;
return 0;
}
return circle[x] = find(circle, circle[x]);这里利用了一个压缩存储的特性,如果一个元素找到的不是整棵树的根,那就把它合并到根上去,这样可以让一棵树变得更 “扁”,从而提高并查集的查找效率。
(4).树的遍历
我们约定,遍历树的时候总是从左到右,例如:
其中,橙色箭头是向前的过程,蓝色箭头是回溯的过程,我们一直向左找到最左,如果没有子结点了就开始回溯,就这样一直搜索,直到最终回到根结点上,这样的过程实际上是深度优先搜索(Depth-First Search) 的搜索模式,接下来我们就来看看怎么实现对于树的遍历
你有没有觉得,这个问题用递归好像很好解决呢?
#1.前序遍历
首先是前序遍历,又称先序遍历,即每次遇到一个结点,首先把结点本身的值打印出来,然后再打印子结点的值,所以我们可以很轻松地写出递归版本的前序遍历代码:
void PreOrderTraversal(const TreeNode* root)
{
if (!root) {
cout << root->val << " ";
for (const auto child : root) {
PreOrderTraversal(child);
}
}
}
这就结束了?这就结束了,因为树的定义本身是递归的,所以每一个子结点都可以对应一颗子树,而子树的遍历流程跟双亲结点一模一样,所以我们可以用一个非常非常简单的递归函数完成前序遍历的流程
#2.后序遍历
后序遍历就是在所有的子结点打印完再打印本身的值,所以体现到代码上就是:
void PreOrderTraversal(const TreeNode* root)
{
if (!root) {
for (const auto child : root) {
PreOrderTraversal(child);
}
cout << root->val << " ";
}
}
这俩真是一个比一个简单,后序遍历只是打印顺序有差异,所以我们只需要把cout打印放到递归的后面,就实现了!这多简单啊,对吧?
#3.遍历的非递归实现
那么有一些很讨厌的题目,要求你用非递归的方式去实现一遍前序和后序遍历,这当然是可以做的,这就需要我们用一个栈去手动模拟递归遍历树的流程了
i.非递归前序遍历
void NoRecursionPreOrderTraversal(const TreeNode* root)
{
stack<const TreeNode*> st;
st.push(root);
while (!st.empty()) {
const TreeNode* ptr{ st.top() };
st.pop();
cout << ptr->val << " ";
for (int j = ptr->child.size() - 1; j >= 0; j--) {
st.push(ptr->child[j]); // 我们需要倒过来入栈,才能保证后面遍历顺序正确
}
}
}
那么对于下面这棵树,我们就很容易得到它的正确遍历结果了:0 1 4 5 6 2 3 7 8 9,你可以自己用这段代码试试看
ii.非递归后序遍历
void NoRecursionPostOrderTraversal(const TreeNode* root, int _Msize)
{
stack<const TreeNode*> st;
vector<int> mark(_Msize * 2, 0);
st.push(root);
mark[0] = 0;
while (!st.empty())
{
const TreeNode* ptr{ st.top() };
if (mark[st.size() - 1] == 0 && !ptr->child.empty())
{
mark[st.size() - 1] = 1;
for (int j = ptr->child.size() - 1; j >= 0; j--)
{
if (!ptr->child.empty())
{
st.push(ptr->child[j]);
mark[st.size() - 1] = 0;
}
}
}
if (st.top()->child.empty() || mark[st.size() - 1] == 1) {
cout << st.top()->val << " ";
st.pop();
}
}
}
后序遍历则要复杂的多,参数部分我们还要传入树的结点个数,以便mark数组正常工作,这里我不做过多解释,这一部分可以选择性学习。
#4.层序遍历
层序遍历就是如同下图的树,我们从根结点开始一层一层打印出来,比如这里的层序遍历结果应该就是0 1 2 3 4 5 6 7 8 9
所以怎么写呢,我们发现这个东西有点像广度优先搜索(Breath-First Search),所以我们需要采用队列来实现层序遍历:
void LevelOrderTraversal(const TreeNode* root)
{
queue<const TreeNode*> qt;
qt.push(root);
while (!qt.empty()) {
const TreeNode* ptr{ qt.front() };
qt.pop();
for (const auto c : ptr->child) {
qt.push(c);
}
cout << ptr->val << " ";
}
}
非常简单,我们每次取出队首元素之后,把这个结点的所有子结点全都入队,然后把它打印出来即可。
(5).树的线性表示
#1.双亲表示法
双亲表示法是比较简单的表示,比如我们有 n n n个结点,我们依次给出这 n n n个结点的双亲结点的编号(如果是根节点,双亲结点的编号为-1),然后我们需要还原出这棵树来,怎么做呢?
如果你比较厉害,实际上双亲表示法已经足够你做完树的各种操作了,在这里我们只是简单完成一下从双亲表示法到标准存储的转换,理论上讲,我们可以在 O ( n ) O(n) O(n)的时间复杂度内完成这个过程,想法很简单:我们首先新建这 n n n个结点并把它们存储起来,然后去遍历存储了双亲编号的数组,然后把它们添加到对应结点的child数组里面去即可,所以我们可以很快写出下面的代码:
TreeNode* buildTree(const vector<int>& parents)
{
size_t n{ parents.size() };
vector<TreeNode*> nodes;
TreeNode* root{ nullptr };
for (int i = 0; i < n; i++) {
nodes.push_back(new TreeNode{ i, vector<TreeNode*>{} });
}
for (int i = 0; i < n; i++) {
if (parents[i] != -1) {
nodes[parents[i]]->child.push_back(nodes[i]);
}
else root = nodes[i];
}
return root;
}
所以就是这样,我们把某个子结点直接链接到对应的双亲结点上去即可。
#2.层号表示法
我们根据树的前序遍历顺序,依次给出结点的值以及其所在的层号,然后据此建立一棵树,比如(0,A) (1,B) (2,D) (2,E) (1,C)是一棵二叉树,还原的方法其实并不困难,如果层号比上一个结点层号大一个,则说明这个结点是上一个结点的子结点,如果比上一个结点小,则说明回到了已记录结点的某一层的另一棵子树上,所以我们可以写出以下代码:
struct TreeNode
{
TreeNode* parent;
char c;
vector<TreeNode*> child;
};
struct LevelTreeNode
{
int level;
char c;
};
void buildTree(vector<TreeNode*>& nodes, const vector<LevelTreeNode*>& lnodes)
{
int current_level{ 1 }; // 用current_level记录当前层号
nodes[0]->c = lnodes[0]->c; // 前序遍历的第一个结点一定是根
TreeNode* last_root{ nodes[0] }; // 记录上一层的根结点
for (int i = 1; i < lnodes.size(); i++) {
if (lnodes[i]->level != current_level) { // 仅在不同层的时候需要特殊处理
if (lnodes[i]->level > current_level) { // 如果层号变大,说明所有后续结点都是子结点
last_root = *(last_root->child.begin() + last_root->child.size() - 1); // 找到上一个根结点的双亲结点最后的子结点(其实就是当前遍历到结点对应的根)
current_level = lnodes[i]->level; // 层号变更
}
else {
int dif{ current_level - lnodes[i]->level }; // 如果层号变小,则开始回溯
while (dif != 0) { // 回溯到同一层
last_root = last_root->parent;
dif--;
}
current_level = lnodes[i]->level;
}
}
// 标准建树操作
nodes[i]->parent = last_root;
nodes[i]->c = lnodes[i]->c;
last_root->child.push_back(nodes[i]);
}
}
nodes是对应所有结点的vector,这里只是用来保存各个结点的信息,而lnodes则是一个包含层号的结点vector,它内部还包含了前序遍历的顺序
在这里,我们采取了一个辅助手段——保存结点的双亲结点,我们前面已经很轻松地解决了双亲结点建树的问题,所以只要我们能够根据层号和前序遍历顺序还原出双亲的对应关系,我们就可以很轻松地把这个问题解决了。
小结
这一篇中我们比较详细地介绍了关于树的一些内容,不过树的内容还远不止这些,未来的两篇分别是二叉树和树的应用,敬请期待。