数据结构--树存储结构 & 深度优先遍历 & 广度优先遍历 通俗易懂

树的概念

​ 首先,树是一种常用的非线性数据结构,是以边(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;
- 特点 - 优点:根据节点的parent指针很容易找到它的双亲节点,所用时间复杂度为O(1),直到parent为-1时,表示找到了树的根节点。 - 缺点:如果要找到孩子结点,需要遍历整个结构才行。
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;
- 特点 - 优点:查找某个结点的某个孩子,或者找某个结点的兄弟,只需要查找这个结点的孩子单链表即可。 - 缺点:当要寻找某个结点的双亲时,就不是那么方便了。
3、孩子兄弟表示法
  • 原理:任意一棵树,它的节点的第一个孩子如果存在就是唯一的,它的兄弟如果存在也是唯一的。因此,我们设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟。
  • 数据结构
typedef int ElemType;
typedef struct Node
{
    ElemType data;
    struct Node *FirstChild;
    struct Node *NextBrother;
    
}Node,TREE;

​ 其实这个表示法的最大好处是它把一棵复杂的树变成了一棵二叉树,可以将上图转换成下图二叉树表示法,这样就可以充分利用二叉树的特性和算法来处理这棵树了。

  • 特点
    • 优点:查找某个结点的某个孩子带来了方便,只需要通过firstchild找到此结点的长子,然后再通过长子结点的rightsib找到它的二弟,接着一直下去,直到找到具体的孩子。
    • 缺点:如果想找某个结点的双亲,这个表示法就不方便了。
树的分类及特征
1、二叉树
  • 定义:二叉树(binary)是一种特殊的树,它是每个节点最多有两个子树的树结构,通常子树被称作是 “左子树” 和 “右子树”,二叉树常用于实现二叉搜索树和二叉堆。
  • 常见的二叉树有:完全二叉树,满二叉树,二叉搜索树,二叉堆,AVL 树,红黑树,哈夫曼树。
2、完全二叉树
  • 定义:若设二叉树的深度为 h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,第 h 层所有的结点都连续集中在最左边。
3、满二叉树
  • 定义:除最后一层无任何子节点外,每一层上的所有结点都有两个子结点的二叉树被称之为满二叉树。满二叉树一定是完全二叉树,完全二叉树不一定满二叉树。
    在这里插入图片描述
4、二叉搜索树
  • 定义:二叉搜索树是一种特殊的二叉树,也可以称为二叉排序树,二叉查找树,除了具有二叉树的基本性质外,它还具备
    • 树中每个节点最多有两个子树,通常称为左子树和右子树
    • 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值
    • 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值
    • 它的左右子树仍然是一棵二叉搜索树 (recursive)
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 模板就解决了。

  • 算法流程

    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树专栏讲解
数据结构树讲解

  • 4
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值