【算法】二叉树存储及遍历实现

最近碰到一些问题涉及到二叉树,故写一篇博客便于记录。 

“二叉树”的定义

“二叉树”是指每个节点拥有不超过 2 个分支的树状结构。 2 个分支称为“左树”和“右树”,其顺序不能随意更改。

二叉树的每一层(i)最多拥有 2i-1 个节点;

如果二叉树的深度为 k (定义根节点深度为 0),那么这个二叉树最多能拥有 2k+1-1 个节点;此时这个二叉树也叫“满二叉树”;

如果二叉树的深度为 k,当且仅当其中的每一个节点,都可以和同样深度的满二叉树,序号为 1 到 n 的节点一对一对应,此时这个二叉树称为“完全二叉树”,其总节点数 m 的范围为 2k-1 <= m <= 2k-1。

“二叉树”的存储

在程序语言中有各种方式实现二叉树,这里提一下比较常用的三种方式。

  • 顺序存储:用数组或链接串列来存储,结构类似下图。

就是将二叉树的节点位置信息固定成数组的下标,好处是拥有更好的局部访问性,对于一个满二叉树,如果某个节点索引为 i,那么它的左子节点的索引是 2i+1,右子节点的索引是 2i+2,父节点的索引是|(i-1)/2|。这种存储方式在存储满二叉树时结构能最紧凑,但是在存储其它二叉树时,就会产生很多空白,造成空间浪费。

  • 二叉链表:在拥有地址指针或者存储记录的语言中,可以用树状结构存储二叉树,数据结构如下图:

使用链表能避免顺序存储浪费空间的问题,算法和结构相对简单,但使用二叉链表,由于缺乏父链的指引,在找回父节点时需要重新扫描树得知父节点的节点地址。

  • 三叉链表:在二叉链表的基础上增加了指向父节点的指针。

“二叉树”的遍历

二叉树的遍历方式有两种,一种是“广度优先遍历”,另一种是“深度优先遍历”。

广度优先遍历是指优先访问离根节点最近的节点,也称为按层次遍历,可通过队列算法实现。

深度优先遍历是指优先访问离根节点最远的节点,通过递归实现遍历。其中在深度优先遍历中,对访问节点值的处理顺序的不同,也分“前序”、“中序”和“后序”遍历。

“二叉树”构造

鉴于 JavaScript 中有数据引用,这里我们只实现二叉链表存储的满二叉树,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Binary Tree Sort</title>
</head>
<body>
    <p>生成二叉树</p>
    <script>
        function genFullBinaryTree(h = 0, value = 'root') {
            if (h > 0) {
                return {
                    value: value,
                    left: genFullBinaryTree(h - 1, `${value}_left`),
                    right: genFullBinaryTree(h - 1, `${value}_right`)
                }
            } else {
                return;
            }
        }

        console.log(genFullBinaryTree(5))
    </script>
</body>
</html>

“二叉树”遍历的JS实现

  • 广度优先遍历

广度优先遍历是优先遍历本层节点,遍历完成后再进行下一层节点遍历,可通过队列方式实现,代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Binary Tree</title>
</head>
<body>
    <p>广度优先遍历满二叉树</p>
    <div id="result"></div>
    <script>
        function genFullBinaryTree(h = 0, value = 'root') {
            if (h > 0) {
                return {
                    value: value,
                    left: genFullBinaryTree(h - 1, `${value}_left`),
                    right: genFullBinaryTree(h - 1, `${value}_right`)
                }
            } else {
                return;
            }
        }

        const bt = genFullBinaryTree(5)
        const result = document.getElementById('result')

        // 广度优先遍历
        function breadthFirstSearch(root) {
            const nodes = [root]
            while(nodes.length) {
                const node = nodes.shift()

                const p = document.createElement('p')
                p.innerText = node.value
                result.appendChild(p)

                if (node.left) {
                    nodes.push(node.left)
                }
                if (node.right) {
                    nodes.push(node.right)
                }
            }
        }

        breadthFirstSearch(bt)
    </script>
</body>
</html>
  • 深度优先遍历

深度优先遍历是优先访问子节点,因为对访问节点数据处理的不同,又分为“前序”、“中序”和“后序”,这里我们对这三种遍历方式进行实现。

  • 前序遍历:
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>Binary Tree</title>
</head>
<body>
	<p>深度优先遍历满二叉树</p>
	<div id="result"></div>
	<script>
		function genFullBinaryTree(h = 0, value = 'root') {
			if (h > 0) {
				return {
					value: value,
					left: genFullBinaryTree(h - 1, `${value}_left`),
					right: genFullBinaryTree(h - 1, `${value}_right`)
				}
			} else {
				return {
					value: ''
				};
			}
		}

		const bt = genFullBinaryTree(5)
		const result = document.getElementById('result')

		// 前序优先遍历
		function DLRSearch(node) {
			const p = document.createElement('p')
			p.innerText = node.value
			result.appendChild(p)
			
			if (node.left) {
				DLRSearch(node.left)				
			}
			if (node.right) {
				DLRSearch(node.right)
			}
		}

		DLRSearch(bt)
	</script>
</body>
</html>
  • 中序遍历:
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>Binary Tree</title>
</head>
<body>
	<p>深度优先遍历满二叉树</p>
	<div id="result"></div>
	<script>
		function genFullBinaryTree(h = 0, value = 'root') {
			if (h > 0) {
				return {
					value: value,
					left: genFullBinaryTree(h - 1, `${value}_left`),
					right: genFullBinaryTree(h - 1, `${value}_right`)
				}
			} else {
				return {
					value: ''
				};
			}
		}

		const bt = genFullBinaryTree(5)
		const result = document.getElementById('result')

		// 中序优先遍历
		function LDRSearch(node) {
			if (node.left) {
				LDRSearch(node.left)				
			}
			
			const p = document.createElement('p')
			p.innerText = node.value
			result.appendChild(p)

			if (node.right) {
				LDRSearch(node.right)
			}
		}

		LDRSearch(bt)
	</script>
</body>
</html>
  • 后序遍历:
<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>Binary Tree</title>
</head>
<body>
	<p>深度优先遍历满二叉树</p>
	<div id="result"></div>
	<script>
		function genFullBinaryTree(h = 0, value = 'root') {
			if (h > 0) {
				return {
					value: value,
					left: genFullBinaryTree(h - 1, `${value}_left`),
					right: genFullBinaryTree(h - 1, `${value}_right`)
				}
			} else {
				return {
					value: ''
				};
			}
		}

		const bt = genFullBinaryTree(5)
		const result = document.getElementById('result')

		// 后序优先遍历
		function LRDSearch(node) {
			if (node.left) {
				LRDSearch(node.left)				
			}
			
			if (node.right) {
				LRDSearch(node.right)
			}

			const p = document.createElement('p')
			p.innerText = node.value
			result.appendChild(p)
		}

		LRDSearch(bt)
	</script>
</body>
</html>

三者的差别真的就只是对访问节点的处理顺序。

同时我们要知道,递归的本质是“循环+栈”操作,所以栈的高度与树的高度成正比,一旦二叉树的深度太大,很容易造成栈溢出。

如果每个节点都保存了对父节点的引用,那么我们就可以抛开栈,单纯地用循环来实现,比如下面用循环复用的方式实现:

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>Binary Tree</title>
	<style>
		.container {
			display: flex;
		}
		.item {
			flex-basis: 33.33%
		}
	</style>
</head>
<body>
	<p>深度优先遍历满二叉树</p>
	<div class="container">
		<div class="item" id="DLR">
			<p>前序遍历:</p>
		</div>
		<div class="item" id="LDR">
			<p>中序遍历:</p>
		</div>
		<div class="item" id="LRD">
			<p>后序遍历:</p>
		</div>
	</div>
	<script>
		function genFullBinaryTree(h = 0, value = 'root', parent) {
			if (h > 0) {
				const node = {
					value: value,
					parent: parent
				}
				node.left = genFullBinaryTree(h-1, `${value}_left`, node)
				node.right = genFullBinaryTree(h-1, `${value}_right`, node)
				return node
			} else {
				return;
			}
		}

		const bt = genFullBinaryTree(5)

		// 先序优先遍历
		const dlr = document.getElementById('DLR')
		function DLRSearch(node) {
			let prev
			let next
			let current = node

			while(current) {
				// 如果上次遍历节点是当前节点的父节点
				// 则开始当前节点子节点的遍历
				if (prev === current.parent) {
					const p = document.createElement('p')
					p.innerText = current.value
					dlr.appendChild(p)

					prev = current
					next = current.left
				}

				// 如果上次遍历节点是当前节点的左节点
				// 则开始右节点遍历
				if (!next || prev === current.left) {
					prev = current
					next = current.right
				}

				// 如果上次遍历节点是当前节点的右节点
				// 则结束当前节点遍历,返回上层节点开始遍历
				if (!next || prev === current.right) {
					prev = current,
					next = current.parent
				}

				current = next
			}
		}

		DLRSearch(bt)

		// 中序优先遍历
		const ldr = document.getElementById('LDR')
		function LDRSearch(node) {
			let prev
			let next
			let current = node

			while(current) {
				// 如果上次遍历节点是当前节点的父节点
				// 则开始当前节点子节点的遍历
				if (prev === current.parent) {
					prev = current
					next = current.left
				}

				// 如果上次遍历节点是当前节点的左节点
				// 则开始右节点遍历
				if (!next || prev === current.left) {
					const p = document.createElement('p')
					p.innerText = current.value
					ldr.appendChild(p)

					prev = current
					next = current.right
				}

				// 如果上次遍历节点是当前节点的右节点
				// 则结束当前节点遍历,返回上层节点开始遍历
				if (!next || prev === current.right) {
					prev = current,
					next = current.parent
				}

				current = next
			}
		}

		LDRSearch(bt)

		// 后序优先遍历
		const lrd = document.getElementById('LRD')
		function LRDSearch(node) {
			let prev
			let next
			let current = node

			while(current) {
				// 如果上次遍历节点是当前节点的父节点
				// 则开始当前节点子节点的遍历
				if (prev === current.parent) {
					prev = current
					next = current.left
				}

				// 如果上次遍历节点是当前节点的左节点
				// 则开始右节点遍历
				if (!next || prev === current.left) {
					prev = current
					next = current.right
				}

				// 如果上次遍历节点是当前节点的右节点
				// 则结束当前节点遍历,返回上层节点开始遍历
				if (!next || prev === current.right) {
					const p = document.createElement('p')
					p.innerText = current.value
					lrd.appendChild(p)

					prev = current,
					next = current.parent
				}

				current = next
			}
		}

		LRDSearch(bt)
	</script>
</body>
</html>

这样我们就通过循环的方式实现了二叉树的遍历,不过前提是二叉树要是三叉链表存储的二叉树。

总结

通过这篇总结,我们算是对二叉树及其遍历有了一个了解,二叉树的应用场景还是比较多的,后面会将其针对一个场景做应用。