面向前端的算法——树

141 阅读6分钟

本篇介绍的是,前端经常遇到的数据类型树类结构,以前端的视角来学习树类算法,切勿生搬硬套

1 基本概念

【树的基本概念图】

树的基本概念.png

2. 二叉树

如果用数据对象表示就是这样:

const binaryTtree = {
	left:{
		left:null,
		right:null
	};
	right:{
		left:null,
		right:null
	};
}

上面就是最简单明了的二叉树表示方式:

这里强调几个点:

  1. 度为二的树和二叉树不是一回事,度为二的树至少3个节点,二叉树可以为空
  2. 如果二叉树的度为一,那么必有左子树而无右子树
  3. 一个子节点如果有双亲,则其双亲编号为Math.floor(i/2)(编号以根节点为1为基准),若有左孩子则为2i,有右孩子则为2i+1

2.1 二叉树的存储结构

【二叉树】

二叉树.png

  1. 使用数组存储 二叉树的数据对象,使用数组存储在规范的教材上都称它为顺序存储。

如果节点编号是以i=1为基准。那么就将该标号的树节点存入数组下标为i-1的空间中。没有存入树节点的下标就记为0或者null ,怎么理解呢?上图的二叉树使数组存储就是这样,[{data:0},{data:1},{data:2},null,null,{data:5},{data:6}]

  1. 使用对象链式存储 链式存储就是前面说的二叉树的数据对象表示方式

2.2 二叉树的遍历

【二叉树遍历】

二叉树遍历.png 下面是基于上图用对象链表示的二叉树,其中left是指其左孩子,right指其右孩子,data为存储的数据

const binaryTree =  {
	data:'A',
	right:{
		left:{
			left:null,
			right:null,
			data:'C'
		}
	},
	left:{
		data:'B',
		left:{
			data:'D',
			left:{
				data:'F',
				left:null,
				right:null
			},
			right:{
				data:'G',
				left:{
					data:'H',
					right:null,
					left:null
				},
				left:{
					data:'I',
					left:null,
					right:null
				}
			}
		},
		right:{
			data:'E',
			left:null,
			right:null
		}
	}
}

在遍历树当中,非常适合使用递归算法

  1. 前序遍历 意思就是先访问根节点,然后遍历左子树,再遍历右子树;上面的二叉树,经前序遍历的结果就是A->B->D->F>G->H->I->E->C
const PreOrder = (bitree)=>{
	const recored = [];
	const Order = (bitree)=>{
		if(bitree !== null){
			recored.push(bitree.data)
			Order(bitree.left);
			Order(bitree.right);
		}
	}
	Order(bitree)
	console.log(recored)
}
PreOrder(binaryTree)

用栈将递归改为循环也是一个很重要的知识点,因此前序遍历的循环如下

const PreOrder = function(root) {
	const queue = [];
	const record = [];
	let p = root;
	const time = setInterval(()=>{
		if(!p && queue.length === 0){
			clearInterval(time)
		}
		if(p){
			record.push(p.val)
			queue.push(p)
			p = p.left
		}else{
			const treeNode = queue.pop();
			p = treeNode?treeNode.right:null
		}
	},1000)
};
PreOrder(binaryTtrees)

上面是用了js的定时器来代替了while语句,方便阅读的同学自己试的时候,看打印结果,增强印象

  1. 中序遍历 先遍历左子树,在访问根节点,在遍历右子树;其遍历结果就是F->D->H->G->I->B->E->A->C
const inOrder = (bitree) =>{
	const recored = [];
	const Order = (bitree)=>{
		if(bitree !== null){
			Order(bitree.left);
			recored.push(bitree.data);
			Order(bitree.right)
		}
	}
	Order(bitree)
	console.log(recored)
}
inOrder(binaryTree)
  1. 后序遍历 先遍历左子树,后遍历右子树,最后访问根节点;其遍历结果就是F->H->I->G->D->E->B->C->A
const PostOrder = (bitree)=>{
	const recored = [];
	const Order = (bitree)=>{
		if(bitree !== null){
			Order(bitree.left);
			Order(bitree.right);
			recored.push(bitree.data);
		}
	}
	Order(bitree)
	console.log(recored)
}
PostOrder(binaryTree)
  1. 层次遍历 要进行层次遍历,需要借助于队列,先将二叉树的根节点入队,然后出队访问该节点。如果它有左子树,则将左子树根入队列,如果它有右子树,则将他的右子树根节点入队列。然后出队,对出队节点访问一直到队列为空;

上图,其层次遍历的结果为:A->B->C->D->E->F->G->H->I

const LevelOrder = (bitree)=>{
	const recored = [];
	const queueRecored = [];
	// 根节点数据入队
	queueRecored.unshift(bitree);
	while(queueRecored.length !== 0){
	   const treeNode = queueRecored.shift();
	   recored.push(treeNode.data);
	   if(treeNode.left!==null){
		   queueRecored.push(treeNode.left)
	   }
	   if(treeNode.right!==null){
		   queueRecored.push(treeNode.right)
	   }
	}
	console.log(recored)
}
LevelOrder(binaryTree)

2.3 二叉树的构建

由遍历构建二叉树,因为篇幅有限,这里就简单说明一下

  1. 前序序列 + 中序序列 可以确定一个唯一的二叉树
  2. 后序序列 + 中序序列
  3. 层次序列 + 中序序列

3. 树

3.1 树的存储方式

  1. 双亲表示法

一般用的不多,而且是采用数组存储,每个节点设置一个伪指针,指示双亲节点的位置,如下:

const Tree = [
	{
		data:'A',
		index:-1 // 表示根节点
	},
	{
		data:'B',
		index:0, // 表示父节点为数据域为A的节点
	},
	{
		data:'C',
		index:0
	},
	{
		data:'D',
		index:0
	}
]
  1. 孩子表示法 这个其实就很容易理解,也是我们前端遇到的最多的一种;其方式就是将每个节点都用单链表链接起来形成一个线性结构
const Tree = {
	data:'A',
	child:[
		{
			data:'B'
			child:[
				{
					data:'D',
					child:[]
				}
			]
		},
		{
			data:'C',
			child:[]
		}
	]
}
  1. 孩子兄弟表示法 孩子兄弟表示法,又叫二叉树表示法。如下图 【孩子兄弟表示法】

孩子兄弟表示法.png

const Tree = {
	data:'A',
	sibling:null,
	child:{
		data:'B',
		sibling:{
			data:"C",
			sibling:{
				data:'D',
				child:null,
				sibling:null
			},
			child:{
				data:'F',
				child:null,
				sibling:null
			}
		},
		child:{
			data:'E',
			child:null,
			sibling:null
		}
	}
}

3.2 树的遍历方式

  1. 先根遍历

先根遍历,就是访问根节点,然后从左到右访问各个子树

这里我们选取孩子兄弟表示法进行先根遍历(react的fiber树就是兄弟孩子表示法,只是他做了一个加强,就是给每个节点增设了一个parent域指向了其父节点)

const ProOrderTree = (tree)=>{
	const record = [];
	const parentNodeTree = [];
	const OrederTree = (tree)=>{
		if(tree !== null){
			// 访问节点
			console.log(tree,'访问节点')
			record.push(tree.data)
			if(tree.child !== null){
				// 如果存在child,就继续递归
				// 因为这里没有给node节点增设parent域,因此需要记录parent
				parentNodeTree.push(tree);
				// console.log(parentNodeTree)
				OrederTree(tree.child)
			}else{
				// 如果不存在,就遍历该节点的兄弟
				if(tree.sibling){
					OrederTree(tree.sibling)
				}else{
					const node = parentNodeTree.pop();
					OrederTree(node.sibling)
				}
			}
		}
	}
	OrederTree(tree)
	console.log(record)
}
ProOrderTree(Tree)
  1. 后根遍历 后根遍历就是先从左到右访问各个子树,然后访问根节点,具体实现篇幅有限,不在赘述。

这里也同样选取孩子兄弟表示法进行后根遍历,自己尝试写写吧~

4.实战练习(leetCode)

4.1 验证二叉搜索树

给定一个二叉树,判断其是否是一个有效的二叉搜索树。

假设一个二叉搜索树具有如下特征:

  • 节点的左子树只包含小于当前节点的数。
  • 节点的右子树只包含大于当前节点的数。
  • 所有左子树和右子树自身必须也是二叉搜索树。

力扣LeetCode

const binaryTtrees = {
	val:5,
	left:{
		val:4,
		left:null,
		right:null
	},
	right:{
		val:6,
		left:{
			val:3,
			left:null,
			right:null
		},
		right:{
			val:7,
			left:null,
			right:null
		}
	}
}

基本思路:

  1. 因为二叉树适合递归,因此就构造一个递归函数order,同时外面命一个result = true的变量,先默认他是二叉搜索树
  2. 这个order接受一个tree,按照前面学过的前序遍历,如果tree不为空,就order(tree.left);order(tree.right)
  3. 因为是要比较,因此order必须增设2个参数一个是lv表示是作为左子树父节点值rv表示是作为右子树父节点的值
  4. 根据上面二叉搜索树的定义,很快就能得出当:
    • tree.val 大于 左子树父节点的值是不合理的
    • tree.val 小于 右子树父节点的值也是不合理的
const isValidBST = (root)=>{
	let result = true;
	const Order = (tree,lv=null,rv=null) =>{
		if(tree !== null){
			const value = tree.val;
			// console.log(value,lv,rv)
			if(lv!=null && value>=lv ){
				result = false
				return
			}
			if(rv!==null && value <= rv){
				result = false
				return
			}
			Order(tree.left,value,rv)
			Order(tree.right,lv,value)
		}
	}
	Order(root)
	console.log(result)
	return result
}
isValidBST(binaryTtrees)

扩展 如果熟悉二叉搜索树的性质,其实这个题也特别简单,要知道二叉搜索树的一个很重要的性质就是 对二叉搜索树进行中序遍历,总会得到一个从小到大的一个排列

4.2 路径总和

给你二叉树的根节点root 和一个表示目标和的整数targetSum,判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和targetSum

叶子节点 是指没有子节点的节点。

来源:力扣LeetCode

const binaryTtrees = {
	val:5,
	left:{
		val:4,
		left:null,
		right:null
	},
	right:{
		val:6,
		left:{
			val:3,
			left:null,
			right:null
		},
		right:{
			val:7,
			left:null,
			right:null
		}
	}
}

看到这个题,一下子就想到用前序遍历 对前面的前序遍历进行改造,就能得到本题的结果。

const hasPathSum = function(root, targetSum) {
	const recored = [];
	const sum = (arry)=>{
		let add = 0;
		arry.forEach( item=>{
			add += item
		})
		return add
	}
	const PreOrder = (root,path=[])=>{
		if(root){
			const route = [..path,root.val]
			// 这样就是查询所有路径
			if(root.left === null && root.right === null && sum(route) === targetSum){
				recored.push(route)
			}
			PreOrder(root.left,route);
			PreOrder(root.right,route);
		}
	}
	PreOrder(root,[])
	console.log(recored)
};
hasPathSum(binaryTtrees)