二叉树详解

201 阅读7分钟

一.数据结构中的树存储结构

树结构是一种非线性的存储结构,存储的是具有“一对多”关系的数据元素的集合

img

图中是使用树结构存储的集合{A,B,C,D,E,F,G,H,I,J,K,L,M},每一个具有度的节点都会和与之向下关联的叶子具有一对多的关系,而这种关系按照上图的形式进行存储,从整个存储结构上来说类似于一颗倒着的树。

img

根节点:树1和树2的节点A都是该树的根节点。

父节点:树1里节点AB的父节点,B又是CD的父节点。

左/右孩子:树2BCA的左右孩子,D又是B的左孩子节点。

兄弟节点:当两个节点的父节点相同时,他们之前就是兄弟关系,例如树2里的BC

左/右子树:以左右孩子为起点,形成的完整的子树,就是其父节点的左右子树,如树2BD形成的二叉树就是A的左子树,CE就是其右子树。

叶子节点:一棵树里位于最底层的节点,也就是没有孩子节点的节点,就是叶子节点,如树2里的DE

路径:从一个节点到另外一个节点时,其间经过节点形成的线路就是路径。

高度:某节点到叶子节点的路径,例如树1里节点A的高度就是2B的高度是1

深度:根节点到某个节点的路径,例如树2里节点A的深度是0,节点BC的深度是1,

层数:节点位于整棵树的第几层,值为节点的深度值加一。

二.什么是二叉树

满足一下两个条件的树就是二叉树:

树种包含各个节点的度不能超过2,所以只能是0,1,2;

二叉树示意图

图a就是一颗二叉树,而图b的1节点的出度为3,所以b不是二叉树,从字面上也容易理解二叉树就俩叉儿。

1.二叉树的性质

经过前人的总结,二叉树具有以下几个性质:

  1. 1. 二叉树中,第 i 层最多有 2^(i-1) 个结点。
    2. 如果二叉树的深度为 K,那么此二叉树最多有 2^(K+1)-1 个结点。
    3. 二叉树中,终端结点数(叶子结点数)为 n0,度为 2 的结点数为 n2,则 n0=n2+1。
    
2.满二叉树

二叉树中除了叶子节点,每一个节点的度都是2的树就是满二叉树

满二叉树示意图

3.完全二叉树

如果二叉树中除去最后一层节点是满二叉树,并且最后一层的节点按照有序排布,那么这棵树就是完全二叉树

完全二叉树示意图

a树满足上述定义,而b树虽然满足出去最后一层节点但是却不是有序的

完全二叉树除了具有普通二叉树的性质,它自身也具有一些独特的性质,比如说,n 个结点的完全二叉树的深度为 ⌊log2n⌋+1

⌊log2n⌋ 表示取小于 log2n 的最大整数。例如,⌊log24⌋ = 2,而 ⌊log25⌋ 结果也是 2。

对于任意一个完全二叉树来说,如果将含有的结点按照层次从左到右依次标号(如上图 a)),对于任意一个结点 i ,完全二叉树还有以下几个结论成立:

1. 当 i>1 时,父亲结点为结点 [i/2] 。(i=1 时,表示的是根结点,无父亲结点)
2. 如果 2*i>n(总结点的个数) ,则结点 i 肯定没有左孩子(为叶子结点);否则其左孩子是结点 2*i3. 如果 2*i+1>n ,则结点 i 肯定没有右孩子;否则右孩子是结点 2*i+1

三.二叉树存储结构

1.二叉树的顺序存储结构

​ 顺序存储结构值适用于完全二叉树,但如果应用在普通的二叉树上就需要去做完全二叉树的转换

img

上图就是普通转换完全二叉树,就是额外添加一些节点拼凑成完全二叉树

完全二叉树的顺序存储,仅需从根节点开始,按照层次依次将树中节点存储到数组即可。

img

例如,储存上图所示的完全二叉树,存储状态如下所示:

img

​ 完全二叉树存储状态示意图

同样,存储由普通二叉树转化来的完全二叉树也是如此。例如,图 1 中普通二叉树的数组存储状态如图 4 所示:

img

​ 普通二叉树的存储状态

2.二叉树的链式存储结构

普通二叉树示意图

二叉树链式存储结构示意图

就是从树的根节点开始,讲各个节点以及节点的左右叶子节点使用链表进行存储。

由图 2 可知,采用链式存储二叉树时,其节点结构由 3 部分构成(如图 3 所示):

  • - 指向左孩子节点的指针(lchild);
    - 节点存储的数据(data);
    - 指向右孩子节点的指针(rchild);
    

    实现方式:

    function TreeCode() {
        let BiTree = function (el) {
            this.data = el;
            this.lChild = null;
            this.rChild = null;
        }
    
        this.createTree = function () {
            let biTree = new BiTree('A');
            biTree.lChild = new BiTree('B');
            biTree.rChild = new BiTree('C');
            biTree.lChild.lChild = new BiTree('D');
            biTree.lChild.lChild.lChild = new BiTree('G');
            biTree.lChild.lChild.rChild = new BiTree('H');
            biTree.rChild.lChild = new BiTree('E');
            biTree.rChild.rChild = new BiTree('F');
            biTree.rChild.lChild.rChild = new BiTree('I');
            return biTree;
        }
    }
    

四.二叉树的四种遍历方法

1.二叉树先序遍历

二叉树先序遍历的实现思想是:

  1. 1. 访问根节点;
    2. 访问当前节点的左子树
    3. 若当前节点无左子树,则访问当前节点的右子树;
    

以下图为例,采用先序遍历的思想遍历该二叉树的过程为:

img

实现方式:

function ProOrderTraverse(biTree) {
    if (biTree == null) return;
    console.log(biTree.data);
    ProOrderTraverse(biTree.lChild);
    ProOrderTraverse(biTree.rChild);
}

let myTree = new TreeCode();
console.log(myTree.createTree());
console.log('前序遍历')
ProOrderTraverse(myTree.createTree());
2.二叉树中序遍历

二叉树中序遍历的实现思想是:

  1. 1. 访问当前节点的左子树;
    2. 访问根节点;
    3. 访问当前节点的右子树;
    
    img

实现方式:

function InOrderTraverse(biTree) {
    if (biTree == null) return;
    InOrderTraverse(biTree.lChild);
    console.log(biTree.data);
    InOrderTraverse(biTree.rChild);
}
let myTree = new TreeCode();
console.log('中序遍历')
InOrderTraverse(myTree.createTree());
3.二叉树后序遍历

二叉树后序遍历的实现思想是:从根节点出发,依次遍历各节点的左右子树,直到当前节点左右子树遍历完成后,才访问该节点元素。

img

实现方式:

function PostOrderTraverse(biTree) {
    if (biTree == null) return;
    PostOrderTraverse(biTree.lChild);
    PostOrderTraverse(biTree.rChild);
    console.log(biTree.data);
}
let myTree = new TreeCode();
console.log(myTree.createTree());
console.log('后续遍历')
PostOrderTraverse(myTree.createTree());
4.二叉树层次遍历

前边介绍了二叉树的先序、中序和后序的遍历算法,运用了的数据结构,主要思想就是按照先左子后右子树的顺序依次遍历树中各个结点。

本节介绍另外一种遍历方式:按照二叉树中的层次从左到右依次遍历每层中的结点。具体的实现思路是:通过使用队列的数据结构,从树的根结点开始,依次将其左孩子和右孩子入队。而后每次队列中一个结点出队,都将其左孩子和右孩子入队,直到树中所有结点都出队,出队结点的先后顺序就是层次遍历的最终结果。

img

例如,层次遍历上图中的二叉树:

  • - 首先,根结点 1 入队;
    - 根结点 1 出队,出队的同时,将左孩子 2 和右孩子 3 分别入队;
    - 队头结点 2 出队,出队的同时,将结点 2 的左孩子 4 和右孩子 5 依次入队;
    - 队头结点 3 出队,出队的同时,将结点 3 的左孩子 6 和右孩子 7 依次入队;
    - 不断地循环,直至队列内为空。
    

实现方式:

使用队列,将每个节点入队,同时在出队一个节点后,将它的两个孩子节点入队。首先入队根节点,然后出队根节点的同时,入队它的两个孩子节点,此时队列不为空,继续采用同样的方式入及出队接下来的节点。

var Tree = {
    value: "强",
    left: {
        value: '哥',
        left: {
            value: '宇',
        },
        right: {
            value: '宙',
            left: {
                value: '无',
            },
            right: {
                value: '敌',
            }
        }
    },
    right: {
        value: '超',
        left: {
            value: '级',
        },
        right: {
            value: '帅',
        }
    }
}
var levelOrder = function(root) {
	if(root == null) {
		return []
	}
	let result = []
	let queue = [root]
	while(queue.length) {
		// 每一层的节点数
		let level = queue.length
		let currLevel = []
		// 每次遍历一层元素
		for(let i = 0;i < level;i++) {
			// 当前访问的节点出队
			let curr = queue.shift()
			// 出队节点的子女入队
			curr.left ? queue.push(curr.left) : ''
			curr.right ? queue.push(curr.right) : ''
			currLevel.push(curr.val)
		}
		result.push(currLevel)
	}
	return result
};
console.log(levelOrder());

五.哈夫曼树

img

1.相关的名词

路径:一个树里面的某一个节点到另一个节点的通路就是路径,上边图根节点到a节点就是一条路径

路径长度:在路径里边每经过一个节点,路径长度就加1,不包括开始节 点;例如在一棵树中,规定根结点所在层数为1层,那么从根结点到第 i 层结点的路径长度为 i - 1 。图中从根结点到结点 c 的路径长度为 3

节点的带权:就是节点上的数值Value

结点的带权路径长度:指的是从根结点到该结点之间的路径长度与该结点的权的乘积。例如,图 1 中结点 b 的带权路径长度为 2 * 5 = 10 。

树的带权路径长度为树中所有叶子结点的带权路径长度之和。通常记作 “WPL” 。例如图示的这颗树的带权路径长度为

WPL = 7 * 1 + 5 * 2 + 2 * 3 + 4 * 3
2.什么是哈夫曼树

当用 n 个结点(都做叶子结点且都有各自的权值)试图构建一棵树时,如果构建的这棵树的带权路径长度最小,称这棵树为“最优二叉树”,有时也叫“赫夫曼树”或者“哈夫曼树”。

在构建哈弗曼树时,要使树的带权路径长度最小,只需要遵循一个原则,那就是:权重越大的结点离树根越近。在图 1 中,因为结点 a 的权值最大,所以理应直接作为根结点的孩子结点。

3.构建哈夫曼树

对于给定的有各自权值的 n 个结点,构建哈夫曼树有一个行之有效的办法:

  1. 在 n 个权值中选出两个最小的权值,对应的两个结点组成一个新的二叉树,且新二叉树的根结点的权值为左右孩子权值的和;
  2. 在原有的 n 个权值中删除那两个最小的权值,同时将新的权值加入到 n–2 个权值的行列中,以此类推;
  3. 重复 1 和 2 ,直到所以的结点构建成了一棵二叉树为止,这棵树就是哈夫曼树。

img

​ 图 1 哈夫曼树的构建过程

图 1中,(A)给定了四个结点a,b,c,d,权值分别为7,5,2,4;第一步如(B)所示,找出现有权值中最小的两个,2 和 4 ,相应的结点 c 和 d 构建一个新的二叉树,树根的权值为 2 + 4 = 6,同时将原有权值中的 2 和 4 删掉,将新的权值 6 加入;进入(C),重复之前的步骤。直到(D)中,所有的结点构建成了一个全新的二叉树,这就是哈夫曼树。

实现:

class Node {  
    constructor(value, char, left, right) {  
        this.val = value; // 字符出现次数   
        this.left = left;  
        this.right = right;  
    }  
}
class huffmanTree{  
    constructor(str){  
        // 第一步,统计字符出现频率  
        let hash = {};  
        for(let i = 0; i < str.length; i++){  
            hash[str[i]] = ~~hash[str[i]] + 1;  
        }  
        this.hash = hash;  
  
        // 构造哈夫曼树  
        this.huffmanTree = this.getHuffmanTree();   
    }  
  
    // 构造哈夫曼树  
    getHuffmanTree(){  
        // 以各个字符出现次数为node.val, 构造森林  
        let forest = []  
        for(let char in this.hash){  
            let node = new Node(this.hash[char], char); 
            forest.push(node);  
        }  
  
        // 等到森林只剩一个节点时,表示合并过程结束,树就生成了  
        let allNodes = []; // 存放被合并的节点,因为不能真的删除森林中任何一个节点,否则.left .right就找不到节点了  
        while(forest.length !== 1){  
            // 从森林中找到两个最小的树,合并之  
            forest.sort((a, b) => {  
                return a.val - b.val;  
            });  
  
            let node = new Node(forest[0].val + forest[1].val, '');  
            allNodes.push(forest[0]);  
            allNodes.push(forest[1]);   
  
            // 删除最小的两棵树  
            forest = forest.slice(2);  
            // 新增的树加入  
            forest.push(node);  
        }  
  
        // 生成的哈夫曼树  
        return forest[0];  
    }  
   
}
let tree = new huffmanTree('ABBCCCDDDDEEEEE')  
console.log(tree)