树的概念
首先,树是一种常用的非线性数据结构,是以边(Edge)相连的节点(Node)的集合,每个节点存储对应的值,当存在子节点时与之相连。
- 根节点:是树的首个节点
- 边:所有节点都由边相连,用于标识节点间的关系
- 叶子结点:树的末端节点,它们没有子节点
- 树的高度:由根节点出发,到子节点的最长路径长度(从下往上)
- 树的深度:指对应节点到根节点路径长度(从上往下)
- 空树:如果树节点个数为零,那么构成的树成为空树
- 树的层:从一棵树的树根开始,树根所在层为第一层,树的孩子节点所在的层为第二层,以此类推。
- 节点的度:对于一个节点,拥有的子树树成为度
- 节点深度:是指对应节点到根节点路径长度
树的存储结构
树的存储可以分为顺序存储和链式存储结构,但为了满足多子树的场景,树的存储方式利用了顺序存储和链式存储的,其方法有双亲表示法、孩子表示法、孩子兄弟表示法。
1、双亲表示法
- 原理
- 采用一段连续的存储空间存储每个节点
- 根节点没有双亲,所以对应的父节点对应数组下标为-1
- 其余的节点,存储其父节点对应数组下标即可
- 数据结构
// 双亲表示法
#define MAX_SIZE 100
typedef int ElemType;
typedef struct PTNode{
ElemType data;
int parent;
}PTNode;
typedef struct PTree
{
PTNode nodes[MAX_SIZE];
int n;
}PTree;
![](https://img-blog.csdnimg.cn/20210421001341466.jpeg?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzcxMDAzNw==,size_16,color_FFFFFF,t_70#pic_center)
2、孩子表示法
- 原理:将每个节点的孩子节点都用单链表连接起来形成一个现行结构,n个节点具有n个孩子链表
- 数据结构
// 孩子表示法
typedef int ElemType;
#define MAX_SIZE 100
typedef struct CNode
{
int child;
struct CNode *next;
}CNode;
typedef struct PNode
{
ElemType data;
struct CNode *child;
}PNode;
typedef struct CTree
{
PNode nodes[MAX_SIZE];
int n;
}CTree;
![](https://img-blog.csdnimg.cn/20210421001405862.jpeg?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzcxMDAzNw==,size_16,color_FFFFFF,t_70#pic_center)
3、孩子兄弟表示法
- 原理:任意一棵树,它的节点的第一个孩子如果存在就是唯一的,它的兄弟如果存在也是唯一的。因此,我们设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟。
- 数据结构
typedef int ElemType;
typedef struct Node
{
ElemType data;
struct Node *FirstChild;
struct Node *NextBrother;
}Node,TREE;
![](https://img-blog.csdnimg.cn/20210421001140662.jpeg?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzcxMDAzNw==,size_16,color_FFFFFF,t_70#pic_center)
其实这个表示法的最大好处是它把一棵复杂的树变成了一棵二叉树,可以将上图转换成下图二叉树表示法,这样就可以充分利用二叉树的特性和算法来处理这棵树了。
- 特点
- 优点:查找某个结点的某个孩子带来了方便,只需要通过firstchild找到此结点的长子,然后再通过长子结点的rightsib找到它的二弟,接着一直下去,直到找到具体的孩子。
- 缺点:如果想找某个结点的双亲,这个表示法就不方便了。
树的分类及特征
1、二叉树
- 定义:二叉树(binary)是一种特殊的树,它是每个节点最多有两个子树的树结构,通常子树被称作是 “左子树” 和 “右子树”,二叉树常用于实现二叉搜索树和二叉堆。
- 常见的二叉树有:完全二叉树,满二叉树,二叉搜索树,二叉堆,AVL 树,红黑树,哈夫曼树。
2、完全二叉树
- 定义:若设二叉树的深度为 h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h 层所有的结点都连续集中在最左边。
3、满二叉树
- 定义:除最后一层无任何子节点外,每一层上的所有结点都有两个子结点的二叉树被称之为满二叉树。满二叉树一定是完全二叉树,完全二叉树不一定满二叉树。
4、二叉搜索树
- 定义:二叉搜索树是一种特殊的二叉树,也可以称为二叉排序树,二叉查找树,除了具有二叉树的基本性质外,它还具备
- 树中每个节点最多有两个子树,通常称为左子树和右子树
- 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值
- 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值
- 它的左右子树仍然是一棵二叉搜索树 (recursive)
![](https://img-blog.csdnimg.cn/20210421001755448.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80MzcxMDAzNw==,size_16,color_FFFFFF,t_70#pic_center)
5、平衡树
树的遍历
左右子节点的访问顺序通常不重要,极个别情况下会有微妙区别,比如说我们想要访问一棵树的最左下角节点,那么顺序就会产生影响,但这种题目会比较少一点。
通常遍历不是目的,遍历时为了更好的做处理,这里处理包括搜索、修改树等。树虽然只能从根节点开始访问,但是我们可以选择在访问完毕回来的时候处理,还是在访问回来之前处理,这两种不同的方式就是后序遍历和先序遍历。
树的遍历可以分为两种类型:深度优先遍历和广度优先遍历。
- DFS可以细分为前中后序遍历;DFS适合做一些暴力枚举的问题,DFS如果借助函数调用栈,则可以轻松的使用递归来实现。
- BFS可以细分为带层和不带层遍历。BFS适合求最短路径。BFS的核心在于求最短路径问题时候可以提前终止,这是其核心价值,层次遍历是一种不需要提前终止的BFS的副产物。这个提前终止不同于 DFS 的剪枝的提前终止,而是找到最近目标的提前终止。比如我要找距离最近的目标节点,BFS 找到目标节点就可以直接返回。而 DFS 要穷举所有可能才能找到最近的,这才是 BFS 的核心价值。实际上,我们也可以使用 DFS 实现层次遍历的效果,借助于递归,代码甚至会更简单。如果找到任意一个满足条件的节点就好了,不必最近的,那么 DFS 和 BFS 没有太大差别。同时为了书写简单,我通常会选择 DFS。
1、深度优先遍历DFS
深度优先搜索算法(英语:Depth-First-Search,DFS)是一种用于遍历树或图的算法。沿着树的深度遍历树的节点,尽可能深的搜索树的分支。当节点 v 的所在边都己被探寻过,搜索将回溯到发现节点 v 的那条边的起始节点。这一过程一直进行到已发现从源节点可达的所有节点为止。如果还存在未被发现的节点,则选择其中一个作为源节点并重复以上过程,整个进程反复进行直到所有节点都被访问为止,属于盲目搜索。
深度优先搜索是图论中的经典算法,利用深度优先搜索算法可以产生目标图的相应拓扑排序表,利用拓扑排序表可以方便的解决很多相关的图论问题
-
算法流程
1、首先将根节点放入栈stack中
2、从stack中取出第一个节点,并检验它是否为目标。如果找到所有的节点,则结束搜寻并回传结果。否则将它某一个尚未检验过的直接子节点加入stack中。
3、重复步骤2
4、如果不存在未检测过的直接子节点。将上一级节点加入stack中。重复步骤2。
5、重复步骤4
6、若stack为空,表示整张图都检查过了——亦即图中没有欲搜寻的目标。结束搜寻并回传“找不到目标”。
这里的栈可以理解为自己实现的栈,也可以理解为调用栈。如果是调用栈的时候就是递归,如果是自己实现的栈的话就是迭代。
-
算法模版
const visited = {}
function dfs(i) {
if (满足特定条件){
// 返回结果 or 退出搜索空间
}
visited[i] = true // 将当前状态标为已搜索
for (根据i能到达的下个状态j) {
if (!visited[j]) { // 如果状态j没有被搜索过
dfs(j)
}
}
}
上面的 visited 是为了防止由于环的存在造成的死循环的。 而我们知道树是不存在环的,因此树的题目大多数不需要 visited,除非你对树的结构做了修改,比如就左子树的 left 指针指向自身,此时会有环。
function dfs(root) {
if (满足特定条件){
// 返回结果 or 退出搜索空间
}
for (const child of root.children) {
dfs(child)
}
}
而几乎所有的题目几乎都是二叉树,因此下面这个模板更常见。
function dfs(root) {
if (满足特定条件){
// 返回结果 or 退出搜索空间
}
dfs(root.left)
dfs(root.right)
}
-
两种常见分类
前序遍历和后序遍历是最常见的两种 DFS 方式。而另外一种遍历方式 (中序遍历)一般用于平衡二叉树。
- 前序遍历
function dfs(root) { if (满足特定条件){ // 返回结果 or 退出搜索空间 } // 主要逻辑 dfs(root.left) dfs(root.right) }
- 后续遍历
function dfs(root) { if (满足特定条件){ // 返回结果 or 退出搜索空间 } dfs(root.left) dfs(root.right) // 主要逻辑 }
- 混合遍历
如上代码,我们在进入和退出左右子树的时候分别执行了一些代码。那么这个时候,是前序遍历还是后续遍历呢?实际上,这属于混合遍历了。不过我们这里只考虑主逻辑的位置,关键词是主逻辑。
如果代码主逻辑在左右子树之前执行,那么就是前序遍历。如果代码主逻辑在左右子树之后执行,那么就是后序遍历。
function dfs(root) { if (满足特定条件){ // 返回结果 or 退出搜索空间 } // 做一些事 dfs(root.left) dfs(root.right) // 做另外的事 }
2、广度优先遍历BFS
BFS 也是图论中算法的一种,不同于 DFS, BFS 采用横向搜索的方式,在数据结构上通常采用队列结构。 注意,DFS 我们借助的是栈来完成,而这里借助的是队列。
BFS 比较适合找最短距离/路径和某一个距离的目标。比如给定一个二叉树,在树的最后一行找到最左边的值。 ,此题是力扣 513 的原题。这不就是求距离根节点最远距离的目标么? 一个 BFS 模板就解决了。
![](https://img-blog.csdnimg.cn/20210421001902456.gif#pic_center)
-
算法流程
1、首先将根节点放入队列中
2、从队列中取出第一个节点,并检查它是否为目标。
- 如果找到目标,则结束搜索并返回结果
- 如果不是目标,将它所有尚未检验过的直接子节点加入队列中
3、若队列为空,表示整张图都检查过了——亦即图中没有欲搜索的目标。结束搜索并回传“找不到目标”。
4、重复步骤2
-
算法模版
const visited = {}
function bfs() {
let q = new Queue()
q.push(初始状态)
while(q.length) {
let i = q.pop()
if (visited[i]) continue
if (i 是我们要找的目标) return 结果
for (i的可抵达状态j) {
if (j 合法) {
q.push(j)
}
}
}
return 没找到
}
-
两种常见分类
前面我提到了“BFS 比较适合找最短距离/路径和某一个距离的目标”。 如果我需要求的是最短距离/路径,我是不关心我走到第几步的,这个时候可是用不标记层的目标。而如果我需要求距离某个节点距离等于 k 的所有节点,这个时候第几步这个信息就值得被记录了。
- 标记层
class Solution: def bfs(k): # 使用双端队列,而不是数组。因为数组从头部删除元素的时间复杂度为 N,双端队列的底层实现其实是链表。 queue = collections.deque([root]) # 记录层数 steps = 0 # 需要返回的节点 ans = [] # 队列不空,生命不止! while queue: size = len(queue) # 遍历当前层的所有节点 for _ in range(size): node = queue.popleft() if (step == k) ans.append(node) if node.right: queue.append(node.right) if node.left: queue.append(node.left) # 遍历完当前层所有的节点后 steps + 1 steps += 1 return ans
- 不标记层
class Solution: def bfs(k): # 使用双端队列,而不是数组。因为数组从头部删除元素的时间复杂度为 N,双端队列的底层实现其实是链表。 queue = collections.deque([root]) # 队列不空,生命不止! while queue: node = queue.popleft() # 由于没有记录 steps,因此我们肯定是不需要根据层的信息去判断的。否则就用带层的模板了。 if (node 是我们要找到的) return node if node.right: queue.append(node.right) if node.left: queue.append(node.left) return -1
公众号:编程之蝉 专注后台开发、CDN、算法、大数据,欢迎关注,阅读最新更新
Leetcode树专栏讲解
数据结构树讲解