数据结构-树

    • 二叉树
      • 性质
      • 存储结构
    • 二叉树的遍历和线索二叉树
      • 遍历
      • 线索二叉树
      • 树和森林
        • 树的存储结构
        • 森林与二叉树的转换
    • 树与并查集
    • 哈夫曼树及其应用
      • 哈夫曼树
      • 哈夫曼算法生成树
      • 哈夫曼编码
    • 回溯法与树的遍历
    • 树的计数

基本概念:树是 n ( n > = 0 ) n(n>=0) n(n>=0)结点的有限集

  1. 有且仅有一个特定的称为根的结点
  2. n > 1 n>1 n>1时,其余结点可分为m个互不相交的有限集,每个集合都是一棵树,是根的子树。

其他概念:

  1. 结点的度:子树的个数;
  2. 树的度:树的节点中最大度
  3. 叶节点(终端结点):度为0的节点;而分支结点是度不为0的结点。
  4. 树的深度:树所有结点中最大层次(根节点的层次为1)
  5. 路径长度:结点个数-1,或者说是分支条数
  6. 兄弟结点是父结点相同的结点
  7. 森林是 m ( m > = 0 ) m(m>=0) m(m>=0)棵互不相交的树的集合

二叉树

二叉树是另一种树形结构,每个结点至多只有两棵子树,且有左右之分,需要注意的是,从树的概念衍生来的二叉树是可以为空树的。

性质

  1. 在二叉树的第 i i i层上至多有 2 i − 1 2^{i-1} 2i1个结点(i>=1)
  2. 深度为 k k k的二叉树至多有 2 k − 1 2^k-1 2k1个结点(k>=1),由此,深度为 k k k且有 2 k − 1 2^k-1 2k1个结点的二叉树是满二叉树;而完全二叉树是其 n n n个结点与满二叉树的 n ( n < = k ) n(n<=k) n(n<=k)个结点编号一致。
  3. 对任何一棵二叉树T,若叶结点数为 n 0 n_0 n0,度为2的结点数为 n 2 n_2 n2,则 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1
  4. 对于完全二叉树T,具有n个结点,其深度为 l o g 2 n + 1 log_2n+1 log2n+1
  5. 对于有 n n n个结点的完全二叉树,i为结点编号,如果 i = 1 i=1 i=1 i i i是二叉树的根,若 i > 1 i>1 i>1则其双亲是 i / 2 i/2 i/2(向下取整);另外,如果 2 i > n 2i>n 2i>n i i i无左孩子, 2 i + 1 > n 2i+1>n 2i+1>n i i i无右孩子

存储结构

  1. 顺序存储,以数组下标表示结点编号,明显可以看出,如果树不为完全二叉树,则浪费了很多存储空间
  2. 链式存储 分为数据域和左右指针域,链的头指针指向根节点,还有一种三叉链表,多加了一个指向双亲结点的指针。

二叉树的遍历和线索二叉树

遍历

  1. 前序遍历 根结点-左子结点-右子结点
  2. 中序遍历 左子结点-根结点-右子结点
  3. 后序遍历 左子结点-右子结点-根结点
    三种遍历方式正好对应波兰式,中缀表达式和逆波兰式
bool PreOrderTraverse(BinTree T)
{
     
	if (!T)
		return 1;
	else
	{
     
		Visit(T->data);
		PreOrderTraverse(T->left); s1 
		PreOrderTraverse(T->right); s2
		
	}
	return 1;
}

遍历算法的不同之处仅仅为访问结点的先后关系,其实递归执行过程是完全一样的,仿照递归执行状态中递归栈的变化可写出相应的非递归算法,若栈顶记录的指针非空,则遍历左子树将左指针入栈;若栈顶记录的指针为空,说明应当返回上一层,若从右子树返回,不必保存当前的根指针,直接修改指针即可。

bool InOrderTraverse(BinTree *p)
{
     
	std::stack<BinTree *> stk;
	auto T = p;
	while(T||!stk.empty())
	{
     
		if(T)
		{
     
			stk.push(T);
			T=T->left;
		}
		else
		{
     
			T=stk.top();
			stk.pop();
			T=T->right;
		}
	}
}

层序遍历

//一个队列保存节点,另一个队列保存节点深度
vector<vector<int>> levelOrder(TreeNode* root) {
     
    std::deque<TreeNode*> queue;
    std::deque<int> depth;
    std::vector<std::vector<int>> res;
    std::vector<int> curr;
    if(!root)
        return std::vector<std::vector<int>>();
    else
    {
     
        int prev_depth=0;
        queue.push_back(root);
        depth.push_back(0);
        while(!queue.empty())
        {
     
            auto dep=depth.front();
            depth.pop_front();
            if(dep!=prev_depth)
            {
     
                res.push_back(curr);
                curr.clear();
                prev_depth=dep;
            }
            auto node=queue.front();
            queue.pop_front();
            curr.push_back(node->val);
            if(node->left)
                {
     queue.push_back(node->left);depth.push_back(dep+1);}
            if(node->right)
                {
     queue.push_back(node->right);depth.push_back(dep+1);}

        }
        res.push_back(curr);
    }
    
    return res;
}

线索二叉树

二叉链表作为存储结构时,不能找到结点的前驱和后继信息,所以,将树的空链域用来存放结点的前驱和后继信息。规定:如果结点有左子树,则lchild域指示其左孩子,否则指示前驱,rchild域指示右孩子,否则指示后继。为避免混淆,再增加两个标志域
这种结点结构组成的二叉链表作为二叉树的存储结构,叫做线索链表,指向前驱和后继的指针称为线索,树也为线索二叉树

  1. 中序线索二叉树:树中所有叶子结点的后继由右链域指出。非叶子结点右链指向孩子,但是由中序遍历可知,其后继为遍历该结点右子树时的最左下端结点;同样,找前驱时叶子结点左链域指向前驱,否则是该结点左子树的最右下端结点
  2. 后序线索二叉树:因为根节点在遍历顺序之后,所以根的后继为空;若为右孩子或者左孩子(但没有兄弟),则后继结点就为根结点,若为左孩子且有右子树,就是遍历右子树的第一个结点。
//遍历 中序线索树
void InOrderTraverse(Tree *T)
{
     
	auto p=T->lchild;
	while(p!=T)
	{
     
		while(p->LTag==Link)
			p=p->lchild;
		std::cout<<p->data<<std::endl;
		while(p->RTag==Thread&&p->rchild!=T)
		{
     
			p=p->rchild;
			std::cout<<p->data<<std::endl;
		}
		p=p->rchild;
	}
}

树和森林

树的存储结构

  1. 双亲表示法,用线性表存储结点,并设置一个flag以标识其父结点在线性表中的位置,所以求双亲的操作较为简单,但求孩子结点时,需要对整个线性表做遍历
  2. 孩子表示法,用线性表存储结点,设置一个链表存储该结点的孩子,直接访问链表就可求得结点的孩子,但是根本无法求取父结点
  3. 二叉链表表示法,结点的两个链域分别指向结点的第一个孩子结点和下一个兄弟结点,分别围为firstchildnextsibling域,求结点孩子时,先访问firstchild域找到第一个孩子,再向兄弟结点域走i-1步就可访问到结点的第 i i i个孩子

森林与二叉树的转换

可以看出,与树对应的二叉树的右子树必为空

  1. 森林转换为二叉树,如果 F = { T 1 , T 2 , … … T m } F={\{T_1,T_2,……T_m}\} F={ T1,T2,Tm}为森林,则有以下规则转换为二叉树 B B B
    (1) 若 F F F为空,则 m = 0 m=0 m=0,则 B B B为空树
    (2)若 F F F非空,则 B B B的根 r o o t root root为森林中第一棵的树的根 r o o t ( T 1 ) root(T_1) root(T1) B B B的左子树 L B LB LB为从 T 1 T_1 T1根结点的子树森林转换成为的二叉树,右子树是从森林 F ′ = { T 2 , T 3 , … … T m } F'={\{T_2,T_3,……T_m}\} F={ T2,T3,Tm}转换成为的二叉树。
  2. 二叉树转换为森林,如果 B = r o o t , L B , R B B={root, LB,RB} B=root,LB,RB是一棵二叉树,可按如下规则转换成森林 F = T 1 , T 2 … … T m F={T_1,T_2……T_m} F=T1,T2Tm
    (1)若 B B B为空,则 F F F为空
    (2)若 B B B为非空,则F中第一棵树的根 r o o t ( T 1 ) root(T_1) root(T1)即为二叉树的根 r o o t root root T 1 T_1 T1中根结点的子树森林 F 1 F_1 F1是由 B B B的左子树 L B LB LB转换而成的森林, F F F中除 T 1 T_1 T1之外其他树组成的森林是由 B B B的右子树 R B RB RB转换而成的森林。
  3. 树和森林的遍历,一种是按先根次序即先访问树的根结点,然后依次先根遍历根的每棵子树,另一种是后根遍历,即先后根遍历每颗子树再访问根节点
    因此森林的遍历方式为:
    (1)第一种,先序遍历:访问森林中第一棵树的根节点,先序遍历第一棵树除去根节点的子树森林,再先序遍历其他子树构成的森林
    (2)第二种,中序遍历:中序遍历第一棵树根结点的子树森林,访问第一棵树的根节点,再中序遍历其他子树构成的森林。

树与并查集

  1. 如果集合S中的关系R是自反的,对称的和传递的,则称它为一个等价关系
  2. 并查集是一种建立在数组上的树形结构,并且这棵树的特点是孩子结点指向父亲结点;
  3. 主要用于解决动态连通问题,重点关注的是连接问题,并不关注路径问题;
  4. 两种优化方式来减少树的高度,按秩合并和路径压缩
  • 按秩合并,另开一个数组保存每个根的孩子数量,总是将较少孩子的集合合并到较多孩子的集合。
  • 路径压缩
  1. quickfind,数组保存下标的元素所属的集合,quickunion,数组保存下标元素的父节点。
class UnionFind{
     
private:
    vector<int> parent;
    int count; // 连通分量的数量
public:
    UnionFind(int n) {
     
        count = n;
        parent.resize(n);
        for (int i = 0; i < n; i++) {
     
            parent[i] = i;
        }
    }
    int find(int p) {
     
        while(p!=parent[p])
        {
     
            parent[p]=parent[parent[p]];
            p=parent[p];
        }
        return parent[p];
    }
    bool isConnected(int p, int q) {
     
        return find(p) == find(q);
    }
    void Union(int p, int q) {
     
        int proot = find(p);
        int qroot = find(q);
        if (proot == qroot) {
     
            return;
        }
        parent[proot] = qroot;
        count--;
    }
};

哈夫曼树及其应用

哈夫曼树

  1. 从树中一个结点到另一个结点之间的分支构成这两个结点之间的路径,路径上的分支数目称为路径长度
  2. 树的路径长度是树根到每一个结点的路径长度之和
  3. 结点带权路径长度为从结点到树根之间的路径长度与结点上权的乘积
  4. 树的带权路径长度为树中所有叶子结点的带权路径长度之和,通常记作 W P L = ∑ k = 1 N ω k l k WPL=\sum_{k=1}^N \omega_k l_k WPL=k=1Nωklk
  5. 引出哈夫曼树的概念:假设有n个权值 { ω 1 , ω 2 … … ω n } \{\omega_1, \omega_2……\omega_n\} { ω1,ω2ωn}构造一个有n个叶子结点的二叉树,带权路径WPL最小的被称为最优二叉树或者哈夫曼树。

哈夫曼算法生成树

  1. 根据给定n个权值,构成n棵二叉树的集合 F F F,每棵二叉树都只有一个带权为 ω i \omega_i ωi的结点,其左右子树为空
  2. F F F中选取两棵根结点权值最小的树作为左右子树构造一棵新的二叉树,置新的二叉树根节点的权值为左右子树上根结点权值之和
  3. F F F中删除这两棵树,同时将新得到的二叉树加入 F F F
  4. 重复 2 和 3 直到剩余一棵树为止,这棵树就是哈夫曼树。

哈夫曼编码

前缀编码:任意一个字符的编码都不是另一个字符的编码的前缀

  1. 若使报文总长最短,假设每种字符出现的次数为 ω i \omega_i ωi ,编码长度为 l l l 则报文总长为 ∑ i = 1 n ω i l \sum_{i=1}^n \omega_il i=1nωil 恰好是二叉树的带权路径长度,所以问题就转化为如何构造一棵哈夫曼树了 考虑一下我们不仅要从叶子结点出发走一条叶子到根的路径来求编码,也要从根结点出发走一条到叶子结点的路径,所以要设计一个新的结点结构:
typedef struct
{
     
	unsigned int weight;
	unsigned int parent, lchild, rchild;
}HTNode, *HuffmanTree;
typedef char **HuffmanCode;
void HuffmanCoding(HuffmanTree &HT, HuffmanCode &HC, int *w, int n)
{
     
	if(n<=1)
		return;
	m = 2*n-1;//对于n个子结点的哈夫曼树,共有2n-1个结点
	HT=(HuffmanTree)malloc((m+1)*sizeof(HTNode));
	/*初始化结点*/
	for(p=HT,i=1;i<n;++i,++p,++w)
		*p={
     *w, 0, 0, 0};
	for(;i<m;++i)
		*p={
     0, 0, 0, 0};
	for(i=n+1;i<=m;++i)
	{
     
		Select(HT,i-1,s1,s2);//该函数从HT中取出weight最小的两个结点,parent为0 ,分别设置为s1 s2
		HT[s1].parent = i;
		HT[s2].parent = i;
		HT[i].lchild = s1;
		HT[i].rchild = s2;
		HT[i].weight = HT[s1].weight+HT[s2].weight;
	}
	HC=(HuffmanCode)malloc(n+1*sizeof(char*));
	cd = (char*)malloc(n*sizeof(char));
	cd[n-1]="\0";
	for(i=1;i<=n;++i)
	{
     
		start = n-1;
		for(c=i,f=HT[i].parent;f!=0;c=f,f=HT[f].parent)//从叶子结点开始,到根结点逆向求编码
			if(HT[f].lchild==c)
				cd[--start]="0";
			else
				cd[--start]="1";
		HC[i]=(char*)malloc((n-start)*sizeof(char));
		strcpy(HC[i],&cd[start]);
	}
}

这个算法通过叶子结点到根对编码逆向处理,也可以从根出发遍历整个哈夫曼树获得各个叶子节点对应的字符

	HC = (HuffmanCode)malloc(n+1*sizeof(char*));
	p=m;//第m个结点是根节点
	cdlen=0;
	for(i=1;i<=m;++i)
		HT[i].weight=0;//结点状态标识
	while(p)
	{
     
		if(HT[p].weight==0)
		{
     
			HT[p].weight = 1;
			if(HT[p].lchild!=0)
			{
     
				p=HT[p].lchild;//如果左孩子不为空 向左访问 由于是else if 访问后进入下一循环
				cd[cdlen++] = "0";
			}
			else if(HT[p].rchild==0)//向左访问条件不成立,说明没有左孩子,检查右孩子,如果没有,就说明该节点为叶子节点,产生编码
			{
     
				HC[p] = (char*)malloc((cdlen+1)*sizeof(char));
				cd[cdlen]="\0";
				strcpy(HC[p],cd);
			}
		}
		else if(HT[p].weight==1)
		{
     
			HT[p].weight=2;
			if(HT[p].rchild!=0)//如果右孩子不为空 向右访问 由于是else if 访问后进入下一循环
			{
     
				p=HT[p].rchild;
				cd[cdlen++]="1";
			}
			else//右孩子条件不成立,说明在上一个代码块已经拷贝了编码了,返回上层即可
			{
     
				HT[p].weight=0;
				p=HT[p].parent;
				--cdlen;
			}
		}
	}

回溯法与树的遍历

回溯法是设计递归过程的重要方法,实际上是先序建立一个状态树的过程,这棵树是隐含在遍历过程中的

  1. 求n个元素的集合的幂集 对于集合A来说,其中元素只有两种状态:属于幂集中元素集合,不属于幂集中元素集合,则求幂集的过程可以化为,依次对A中元素进行取(或舍)的过程,这对应两种状态、
void PowerSet(int i,int n)//求含n个元素集合A的幂集
{
     
	if(i>n)
		cout<<setA<<ends;
	else
	{
     
		setA[i]=A[i];
		PowerSet(i+1,n);//最先输出的是取所有A中元素,然后erase掉一个(返回了上层结点),再输出 
		setA.drop(i);//舍弃掉第i个元素,如果存在set中,就删除该元素
		PowerSet(i+1,n);
	}
}

回溯法生成的状态树是一颗满二叉树,每个叶子结点的状态都是求解过程中可能出现的,但是有些问题描述求解过程的状态树不是一棵满二叉树,当试探过程中的状态和问题所求解产生矛盾时,不再继续试探,这时出现的叶子结点不是求解的最终状态。
这类问题在约束条件下进行先序遍历,在遍历过程中剪去那些不满足条件的分支 四皇后问题:

树的计数

树的计数问题:具有n个结点的不同形态的树有多少棵?我们先探讨问题的关键,如何定义不同形态?
相似:两个二叉树都为空树或者都不为空树,且左右子树分别相似
等价:两者不仅相似,且所有对应结点上的数据元素均相同
树的计数问题就是讨论具有n个结点,互不相似的二叉树的数目 b n b_n bn

  1. 从二叉树的遍历直到,任意一棵二叉树前序序列和中序序列是唯一的,反过来能否根据给定的前序序列和中序序列来确定一棵二叉树?是否唯一?
  2. 前序遍历是先访问根节点,其次遍历左子树,最后遍历右子树,而在中序遍历中先访问左子树,然后访问根结点,再访问右子树,所以可以先找出根节点,再将中序遍历分为两部分。在从前序遍历找子树的根节点,进而一步一步确定
  3. 假设二叉树的 n n n个结点从1到 n n n加以编号,对于同一个前序序列,不同二叉树的中序序列是不同的,因此不同形态的二叉树的数目恰好是前序序列相同的中序序列的数目 总计为 1 n + 1 C 2 n n \frac{1}{n+1}C_{2n}^n n+11C2nn
  4. 对于树来说,由于具有n个结点的不同形态的树的数目 t n t_n tn和具有 n − 1 n-1 n1个结点互不相似的二叉树数目 b n − 1 b_{n-1} bn1相同。
    查找最近的公共祖先
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
     
        auto ptr=root;
        if(ptr==NULL||p==ptr||q==ptr)
            return ptr;
        TreeNode* left=lowestCommonAncestor(root->left,p,q);
        TreeNode* right=lowestCommonAncestor(root->right,p,q);
        if(left!=NULL&&right!=NULL)
            return ptr;
        else
        {
     
        	if(left)
                return left;
            else if(right)
                return right;
            return NULL;
         }
    }

通过先序中序得到后序

TreeNode* build(vector<int>& preorder, int pl,int pr,vector<int>& inorder,int ml,int mr)
    {
     
        if(pl>pr||ml>mr)
            return nullptr;
        TreeNode *ptr=new TreeNode(preorder[pl]);
        int i=0;
        for(i=ml;i<=mr;i++)//找到中序的根结点
        {
     
            if(inorder[i]==preorder[pl])
                break;
        }
        int length=i-ml;
        ptr->left=build(preorder,pl+1,pl+length,inorder,ml,i-1);
        ptr->right=build(preorder,pl+length+1,pr,inorder,i+1,mr);
        return ptr;
    }
    TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) 
    {
     
        return build(preorder,0,preorder.size()-1,inorder,0,inorder.size()-1);
    }

二叉树翻转

class Solution {
     
public:
    TreeNode* invertTree(TreeNode* root) {
     
        if (root==NULL)
            return NULL;
        else
        {
        TreeNode* left=invertTree(root->left);
            TreeNode* right=invertTree(root->right);
            root->left=right;
            root->right=left;
            return root;
        }
    }
};

  1. 堆是用完全二叉树表示的,根节点索引为1,子节点为2i ,2i+1 如果根节点从0开始, 则子节点为2i+1 和 2i+2

你可能感兴趣的:(数据结构-树)