当前位置:   article > 正文

《C/C++数据结构与算法》第二讲——二叉树_二叉树的数组表示法

二叉树的数组表示法

第1节    二叉树的概念及其性质

2.1.1  二叉树的概念

        什么是二叉树?顾名思义,它是每个非叶子结点最多只能有两个儿子的树结构。为了方便研究,我们对结点的两个儿子进行命名和编号,将左边的结点称为左儿子(left child,简写为lchild或lc),右边的结点称为右儿子(right child,简写为rchild或rc),那么以左右儿子为根的子树分别称为“左子树”(left subtree,简写为ltree)和“右子树”(right subtree,简写为rtree)。

        一般情况下左右儿子有序,不能相互颠倒。

2.1.2  二叉树的性质

        由于二叉树结构的优美性,因此具有很好的性质。

        性质1:若二叉树的叶子数为n_0,度为2的结点数为n_2,则n_0=n_2+1

        这个性质表明在二叉树中,叶子结点的个数与度为1的结点个数无关。

        性质2:深度为k的二叉树,最多只有2^k-1个结点。 

        特别地,深度为k,且有2^{k-1}个结点的二叉树,称为满二叉树。这种树的特点是每一层上的结点数都是最大结点数。而在一棵二叉树中,除最后一层外,其余层都是满的,并且最后一层要么是满的,要么是在右边缺少连续若干结点,此二叉树称为完全二叉树。显然,深度为k的完全二叉树,至少有2^{k-1}个结点,至多有2^k-1个结点。   

        性质3:具有n个结点的完全二叉树的深度为log_2n+1

        性质4:若将完全二叉树的每个结点从上至下,从左至右进行编号,那么,对于标号为x的点,若x存在左儿子,则左儿子的编号为2x;若x存在右儿子,则右儿子的编号为2x+1。反之,若x有父亲,则父亲的编号为\left\lfloor\frac{x}{2}\right\rfloor。     

第2节    二叉树的存储方法

2.2.1  二叉树的儿子表示法

        对每一个结点,存储该结点的左右儿子。

  1. struct node
  2. {
  3. node *lc,*rc;
  4. node()
  5. {
  6. lc=rc=NULL;
  7. }
  8. };

2.2.2  二叉树的数组表示法

int ch[MAXN][2];//ch[x][0]表示x的左儿子,ch[x][1]表示x的右儿子

2.2.3  完全二叉树的数组表示法

        根据之前所讲的完全二叉树的性质,只要确定了结点个数n,完全二叉树的形态也就确定了,对于结点x,它的左儿子是2x,右儿子是2x+1,父亲是\left\lfloor\frac{x}{2}\right\rfloor。因此无需存储任何和树的形态有关的信息,只需要用一个一维数组来表示每个结点上的信息。这种优美的存储方法在接下来“二叉堆”的内容中将非常有用。  

第3节    二叉树的遍历

        前序、中序、后序遍历是二叉树的三种遍历方式,本质上都属于DFS(深度优先搜索),它们之间的区别仅仅在于根在遍历中的出现顺序:
        前序遍历:根—左—右。

  1. void preorder(node *now)
  2. {
  3. if(now==NULL)
  4. return;
  5. std::cout<<now->val<<' ';
  6. preorder(now->lc);
  7. preorder(now->rc);
  8. }

        中序遍历:左—根—右。

  1. void inorder(node *now)
  2. {
  3. if(now==NULL)
  4. return;
  5. inorder(now->lc);
  6. std::cout<<now->val<<' ';
  7. inorder(now->rc);
  8. }

        后序遍历:左—右—根。

  1. void postorder(node *now)
  2. {
  3. if(now==NULL)
  4. return;
  5. postorder(now->lc);
  6. postorder(now->rc);
  7. std::cout<<now->val<<' ';
  8. }

        除了以上三种遍历方式以外,还有一种被称为层序遍历的遍历方式,本质上属于BFS(广度优先搜索)。

  1. void bfs(node *root)
  2. {
  3. std::queue<node*> q;
  4. q.push(root);
  5. while(q.size())
  6. {
  7. node *now=q.front();
  8. q.pop();
  9. if(now==NULL)
  10. continue;
  11. std::cout<<now->val<<' ';
  12. q.push(now->lc);
  13. q.push(now->rc);
  14. }
  15. }

        【例2.3.1】美国血统

        输入一棵二叉树的中序遍历和前序遍历,输出该树的后序遍历。

        【例2.3.2】新二叉树

        输入一棵二叉树,输出其前序遍历。

        【例2.3.3】求先序排列

        输入一棵二叉树的中序遍历和后序遍历,输出该树的前序遍历。

        【例2.3.4】二叉树深度

        输入一棵二叉树,输出其深度。

        【例2.3.5】遍历问题

        输入一棵二叉树的前序遍历和后序遍历,输出可能的中序遍历的总数。

第4节    树、森林与二叉树的转化

2.4.1  儿子兄弟表示法

        在处理一些多叉树的问题时,常常因为树的分支过多而不好处理。

        事实上,可以采用“左儿子右兄弟”的方法,将一棵多叉树转为二叉树,方便处理一般的多叉树问题。

        将一棵多叉树(称之为原树)转为二叉树(称之为新树)。对于某个结点,把其在原树上的第一个儿子结点作为在新树上的左儿子,把原树中它的下一个兄弟结点作为它的右儿子。

        这样,可以直接在原树上进行遍历,来实现二叉树的转换过程。

  1. std::vector<int> v[MAXN];//原树
  2. int ch[MAXN][2];//新树
  3. void dfs(int x)
  4. {
  5. if(!v[x][0])
  6. return;
  7. ch[x][0]=v[x][0];
  8. dfs(ch[x][0]);
  9. int now=ch[x][0];
  10. for(int i=1;i<v[x].size();i++)
  11. {
  12. ch[now][1]=v[x][i];
  13. dfs(ch[now][1]);
  14. now=v[x][i];
  15. }
  16. }

2.4.2  森林转化为二叉树

        事实上,可以注意到一棵树转化为二叉树后,根结点一定没有右儿子——这是因为根节点没有兄弟。这体现出树转二叉树的方法似乎可以进一步拓展到根有右儿子的情况。不难发现,还可以将森林也转化为二叉树。

        具体地,新建一个虚结点,让森林中的所有树的根依次成为它的儿子,这样就得到了一棵新的树。对这棵树进行树转二叉树,可以发现虚结点成为了一个只有左儿子的根结点,那么此时将虚结点去掉,剩下的依然是一棵二叉树,并且新的根结点也可以拥有右儿子了。这使得二叉树的形态更加优美。

2.4.3  二叉树转化为树、森林

        转化回去的过程也并不困难,只需要牢记住:二叉树上的左儿子是它的第一个儿子,右儿子是它的兄弟即可。

第5节    哈夫曼树及其应用

2.5.1  哈夫曼树的概念

        一棵具有n个带权叶结点的二叉树,使得所有叶结点的带权路径长度(叶结点权值\times叶结点到根结点的路径长度)之和最小,这样的二叉树被称为最优二叉树,也称哈夫曼树(Huffman Tree)。

        一个简单性质:哈夫曼树的每个非叶结点都有两个儿子,否则不优。

2.5.2  哈夫曼树的构建

        (1)将n个带权结点,作为n棵只有一个结点的树。

        (2)选择两棵根结点权值最小的树,将这两棵树分别作为左右儿子合并成一棵树,并将根结点的权值赋为左右儿子的权值之和。

        (3)重复执行第2步n-1次,直到所有点都在一棵树中,此时这棵树就是哈夫曼树。 

        上述构建方法,满足每个非叶节点都有两个儿子结点。每次合并操作,其意义是让两个树中的叶结点的深度增加1,总权值也相应增加一次。因为每次选择的权值是最小的,所以每次增加的 权值也是最小的,所以总带权路径长度之和也是最小的。

  1. priority_queue<node> q;//将用于构成哈夫曼树的n个元素存在堆q中
  2. for(int i=1;i<n;i++)
  3. {
  4. node x,y,z;
  5. x=q.top(),q.pop();
  6. y=q.top(),q.pop();//将最小的两个元素x,y取出
  7. z=x+y;//将x与y合并存入z结点中
  8. q.push(z);//将z插入堆中
  9. }

2.5.3  哈夫曼编码

        哈夫曼编码是哈夫曼树的重要应用之一。

        在数据通信中,需要将传送的信息转换成二进制编码,用0、1的不同排列来表示。例如,某篇文章有a、b、c、d、e五种字符,若用等长的二进制编码表示这五个字符,至少需要用长度为3的编码(000-a, 001-b, 010-c, 011-d, 100-e)。

        对于任何一种有效的编码,它不能是另一个编码的前缀,否则会无法正确识别。例如01表示a,则不能用011表示b。因为011可能会分解为01和1两个编码,从而引起歧义。

        也就是说,如果把所有的二进制编码构成一棵二叉树,将连向左儿子的边设置为0,连向右儿子的边设置为1,则第L层的每个结点对应一个长度为L-1的二进制编码,显然叶子的编码不是任何一个编码的前缀,因此所有叶子结点均为有效编码。

        若考虑到一篇文章的字符频率,要使得每个字符的带权路径长度最小,则可以以每个字符作为叶结点构造出一棵哈夫曼树,叶结点的编码即为这篇文章的哈夫曼编码。

        现有5个带权值的字符A、B、C、D、E,它们的权值分别为15、7、6、6、5,则构建一棵哈夫曼树的过程如下:

        (1)将每个结点按权值从大到小排序,每次选择根结点权值最小的两棵树。

        (2)将D和E合并,形成了一棵根结点权值为11的树。

        (3)将目前权值最小的两个结点B和C合并,形成了一棵根结点权值为13的树。

        (4)合并BC所在的树和DE所在的树,形成了一棵根结点权值为24的树。

        (5)合并A和BCDE所在的树,形成了一棵根结点权值为39的树。

        按左0右1安排每条树边的编码,可以得到每个字符的编码:A-0,B-100,C-101,D-110,E-111。这样平均编码长度就是(1+3+3+3+3)/5=2.6,是所有编码方案中的最小值。

        这里另外介绍一个更加简单的方法:维护两个单调队列

        具体做法是,初始化两个队列queue1和queue2,其中queue1为初始的从小到大排序的n个数,queue2为空。每次从queue1和queue2的队首中,取出两个最小的数,相加后添加到queue2的队尾,重复上述操作n-1次即可。

        【例2.5.1】合并果子

        题目描述:

        在一个果园里,多多已经将所有的果子打了下来,而且按果子的不同种类分成了不同的堆。多多决定把所有的果子合成一堆。

        每一次合并,多多可以把两堆果子合并到一起,消耗的体力等于两堆果子的重量之和。可以看出,所有的果子经过n−1次合并之后,就只剩下一堆了。多多在合并果子时总共消耗的体力等于每次合并所耗体力之和。

        因为还要花大力气把这些果子搬回家,所以多多在合并果子时要尽可能地节省体力。假定每个果子重量都为1,并且已知果子的种类数和每种果子的数目,你的任务是设计出合并的次序方案,使多多耗费的体力最少,并输出这个最小的体力耗费值。

        例如有3种果子,数目依次为1,2,9。可以先将1、2堆合并,新堆数目为3,耗费体力为3。接着,将新堆与原先的第三堆合并,又得到新的堆,数目为12,耗费体力为12。所以多多总共耗费体力=3+12=15=3+12=15。可以证明15为最小的体力耗费值。

        题解:

        原本的一堆果子,经过了几次合并,它的大小就对答案进行了几次贡献。要最小化每堆原本的果子的贡献之和,就是最小化每个叶结点权值的贡献之和,叶结点的权值就是某堆果子的大小,其到根的路径长度就是该堆果子的合并次数。因此本题相当于求带权路径长度之和最小,即哈夫曼树的构建。

        对于如何选出两个最小值和添加当前值,采用优先队列(详见第6节)做的时间复杂度为O(nlog_2n)。若选择维护两个单调队列,则可以将时间复杂度优化到O(n)。

        【例2.5.2】哈夫曼编/译码器

        利用哈夫曼编码进行通信可以大大提高对信道的利用率,缩短信息传输时间,降低传输成本。但是,这要求在发送端通过一个编码系统对待传输数据预先编码,在接收端对接收的数据进行译码(复原),对于双工信道(可以双向传输信息的信道),每端都需要一个完整的编/译码系统。试为这样的信息收发站写一个哈夫曼码的编/译码系统。

        一个完整的哈夫曼编/译码器应具有以下功能:

        (1)I:初始化(Initialization)。从标准输入流读入字符集的大小n,n个字符及权值,建立哈夫曼树,并为每个叶子结点编码。

        (2)E:编码(Encoding)。对文本文件ToBeTran中的正文进行编码,然后将结果存入文件CodeFile中。

        (3)D:译码(Decoding)。对文本文件CodeFile中的代码进行翻译,结果存入TextFile中。

        (4)V:比较文件ToBeTran和TextFile的内容是否相同,若不同,则编译码过程中有错误。

  1. #include<bits/stdc++.h>
  2. using std::string;
  3. using std::ifstream;
  4. using std::ofstream;
  5. int read()
  6. {
  7. int x=0;
  8. char c=getchar();
  9. while(c<'0'||c>'9')
  10. c=getchar();
  11. while('0'<=c&&c<='9')
  12. {
  13. x=(x<<3)+(x<<1)+(c^'0');
  14. c=getchar();
  15. }
  16. return x;
  17. }
  18. struct tree
  19. {
  20. private:
  21. struct node
  22. {
  23. char data;
  24. int value;
  25. node *lc,*rc;
  26. node(char _data,int _value,node *_lc=NULL,node *_rc=NULL)
  27. {
  28. data=_data;
  29. value=_value;
  30. lc=_lc,rc=_rc;
  31. }
  32. friend bool operator<(node x,node y)
  33. {
  34. return x.value>y.value;
  35. }
  36. friend node operator+(node x,node y)
  37. {
  38. node *lc=new node(x.data,x.value,x.lc,x.rc);
  39. node *rc=new node(y.data,y.value,y.lc,y.rc);
  40. return node(0,x.value+y.value,lc,rc);
  41. }
  42. }*root;
  43. string dirt[200];
  44. void init(node *now,string str="")
  45. {
  46. if(now->data)
  47. {
  48. dirt[now->data]=str;
  49. return;
  50. }
  51. init(now->lc,str+'0');
  52. init(now->rc,str+'1');
  53. }
  54. public:
  55. int sum=0;
  56. tree(int n)
  57. {
  58. std::priority_queue<node> q;
  59. for(int i=1;i<=n;i++)
  60. {
  61. char c=getchar();
  62. while((c<'0'||c>'9')&&(c<'A'||c>'Z')&&(c<'a'||c>'z')&&c!='_')
  63. c=getchar();
  64. q.push(node(c,read()));
  65. }
  66. while(q.size()>1)
  67. {
  68. node x=q.top();q.pop();
  69. node y=q.top();q.pop();
  70. q.push(x+y);
  71. }
  72. node top=q.top();
  73. root=new node(top.data,top.value,top.lc,top.rc);
  74. init(root);
  75. }
  76. Encode(const string ifname,const string ofname)
  77. {
  78. ifstream input(ifname);
  79. ofstream output(ofname);
  80. string str;
  81. while(input>>str)
  82. {
  83. string ans;
  84. for(int i=0;i<str.size();i++)
  85. ans+=dirt[str[i]];
  86. output<<ans<<'\n';
  87. }
  88. }
  89. Decode(const string ifname,const string ofname)
  90. {
  91. ifstream input(ifname);
  92. ofstream output(ofname);
  93. string str;
  94. while(input>>str)
  95. {
  96. string ans;
  97. node *now=root;
  98. for(int i=0;i<str.size();i++)
  99. {
  100. now=str[i]=='0'?now->lc:now->rc;
  101. if(now->data)
  102. {
  103. ans+=now->data;
  104. now=root;
  105. }
  106. }
  107. output<<ans<<'\n';
  108. }
  109. }
  110. bool Compare(const string ifname1,const string ifname2)
  111. {
  112. ifstream input1(ifname1),input2(ifname2);
  113. string str1,str2;
  114. while(!input1.eof())
  115. str1+=input1.get();
  116. while(!input2.eof())
  117. str2+=input2.get();
  118. return str1==str2;
  119. }
  120. };
  121. int main()
  122. {
  123. tree t(read());
  124. t.Encode("ToBeTran.txt","CodeFile.txt");
  125. t.Decode("CodeFile.txt","TextFile.txt");
  126. std::cout<<t.Compare("ToBeTran.txt","TextFile.txt");
  127. return 0;
  128. }

第6节    二叉堆及其应用

2.6.1  什么是二叉堆

        二叉堆是具有堆性质的二叉树。对于每一个结点,它的权值是以该结点为根的子树最小值(小根堆)或最大值(大根堆)。

        由定义可知,堆的最小值或最大值一定在根结点上。

2.6.2  二叉堆构造及其基本操作

        二叉堆有两个重要的操作:插入一个元素和删除堆顶元素。下面以小根堆为例讲述其操作实现。

        因为二叉堆是一棵完全二叉树,因此结点x的父亲是\left\lfloor\frac{x}{2}\right\rfloor,结点x的儿子是2x和2x+1。 

        1.插入一个元素进堆

        一棵有n-1个结点的堆,现在要插入第n个结点。先把插入的元素放在数组的第n个位置上。这时若完全二叉树不满足堆性质,则需要将它调整为堆。

        从下往上调整。从当前结点开始,不断将当前结点和父亲比较,若当前结点较小,就将它与父亲结点交换,重复这个操作直至不能调整为止。

  1. void insert(int n,int val)
  2. {
  3. a[n]=val;
  4. while(n>>1&&a[n]<a[n>>1])
  5. {
  6. swap(a[n],a[n>>1]);
  7. n>>=1;
  8. }
  9. }

        2.删除堆顶元素

        删除根结点,这时第1个位置就成了空位。首先将最后一个结点n移动到第1个结点,此时该完全二叉树不满足堆性质,需要将它调整为堆。

        从上往下调整。从根结点开始,不断将当前结点与左右儿子比较,若当前结点较大,则将左右儿子中较小的结点与之交换,重复这个操作直到不能调整为止。

  1. void del(int &n)
  2. {
  3. a[1]=a[n],--n;
  4. int now=1;
  5. while(now<<1<=n)
  6. {
  7. int son=now<<1;
  8. if((son|1)<=n&&a[son|1]<a[son])
  9. son|=1;
  10. if(a[now]>a[son])
  11. {
  12. swap(a[now],a[son]);
  13. now=son;
  14. }
  15. else
  16. break;
  17. }
  18. }

        由于堆是完全二叉树,无论向上还是向下调整操作,最多调整log_2n次,因此时间复杂度为O(log_2n)。 

2.6.3  二叉堆的STL实现

        因为堆的作用主要用来获取最小/大值,类似队列的取最值操作,因此堆有一个别名叫做优先队列。STL中的优先队列的用法如下:

        (1)priority_queue<int> q:建立一个优先队列,其内部元素类型是int。

        (2)q.push(x):将元素x插入到堆q中。

        (3)q.pop():将q的堆顶元素弹出。

        (4)q.top():查询q的堆顶元素。

        (5)q.size():查询q的元素个数。

        (6)q.empty():查询q是否为空。

        值得注意的是,STL中的优先队列是一个大根堆。如果你只是想用小根堆,那么你不妨将所有数乘以-1以后再插入,这样就可以得到想要的结果了。

2.6.4  二叉堆的应用

        【例2.6.1】超级钢琴

        题目描述:

        小Z是一个小有名气的钢琴家,最近C博士送给了小Z一架超级钢琴,小Z希望能够用这架钢琴创作出世界上最美妙的音乐。

        这架超级钢琴可以弹奏出n个音符,编号为1至n。第i个音符的美妙度为A[i],其中A[i]可正可负。

         一个“超级和弦”由若干个编号连续的音符组成,包含的音符个数不少于L且不多于R。我们定义超级和弦的美妙度为其包含的所有音符的美妙度之和。两个超级和弦被认为是相同的,当且仅当这两个超级和弦所包含的音符集合是相同的。

        小Z决定创作一首由k个超级和弦组成的乐曲,为了使得乐曲更加动听,小Z要求该乐曲由k个不同的超级和弦组成。我们定义一首乐曲的美妙度为其所包含的所有超级和弦的美妙度之和。小Z想知道他能够创作出来的乐曲美妙度最大值是多少。

        所有数据满足:n\leq5\times10^5k\leq5\times10^5-1000\leq A[i]\leq10001\leq L,R\leq n

        题解:

        从长度为n的数列中选取k个区间,每个区间的长度属于[L,R],使得这k个区间之和最大。

        最直接的做法是枚举所有区间,求出每个合法区间的和,最后选出最大的k个加起来,时间复杂度O(n^2log_2n),无法通过此题。

        考虑每一个点作为左端点的最优解。对于一个左端点i,右端点j可以取的范围是[i+L-1,i+R-1]。由于左端点已经固定了下来,那么区间和s[j]-s[i-1](s为前缀和数组)的最大值只与s[j]有关,我们取[i+L-1,i+R-1]中s最大的那个点作为j即可。

        因此,我们可以用四元组(i, l, r, j)描述一个最优解,表示左端点为i,右端点范围为[l,r],且[l,r]中s最大的那个点为j。一开始枚举每个点作为i,求出对应的l、r、j,并将这些最优解用大根堆存起来。

        对于每一轮操作,我们先取出堆顶now,则s[now.j]-s[now.i-1]必为当前的全局最优解,直接将其加入答案。接着,我们把[now.l,now.r]这个区间裂解成[now.l,now.j-1]和[now.j+1,now.r]两部分,对这两部分再求最优解并插入大根堆。重复上述操作k轮即可。

        其中,区间求最大值位置可以用线段树实现(详见第四讲),总时间复杂度O(nlog_2n+klog_2(n+k))
        【例2.6.2】种树

        题目描述:

        A城市有一个巨大的圆形广场,为了绿化环境和净化空气,市政府决定沿圆形广场外圈种一圈树。

        园林部门得到指令后,初步规划出n个种树的位置,顺时针编号1到n。并且每个位置都有一个美观度A[i],如果在这里种树就可以得到这A[i]的美观度。但由于A城市土壤肥力欠佳,两棵树决不能种在相邻的位置(i号位置和i+1号位置叫相邻位置,值得注意的是1号和n号也算相邻位置)。

        最终,市政府给园林部门提供了m棵树苗并要求全部种上,请你帮忙设计种树方案使得美观度总和最大。如果无法将m棵树苗全部种上,给出无解信息(Error!)。

        对于全部数据:m\leq n\leq2\times10^5, -1000\leq A[i]\leq1000

        题解:

        首先,考虑如果没有“相邻位置不能再种”这一限制会怎么样。这时就是一个简单的贪心——按照A[i]从大到小排序,然后取前m个。

        那么加上限制以后会发生什么呢?

        假设A[3]最大,那就试图去选A[3]。选中之后首先要去掉3,并且,A[2]和A[4]也不能选了,所以将它们删掉。

        但是慢着!这可能会导致问题。假设A[2]+A[4]>A[3],那么同时选A[2]和A[4]可能比选A[3]更优!在最优的方案中可能是A[2]+A[4]而非A[3]。这种情况要怎么解决呢?

        可以发现一点:由于A[3]最大,所以在最后的方案中,不可能只选A[2]和A[4]中的一个。

        原因很简单:假设在最优方案中选了A[2]但未选A[4],那可以简单地把A[2]换成A[3],由于未选A[4],所以这样不会产生任何矛盾,并且把A[2]换成A[3]之后,总的美观度会变得更大,与当前方案为最优方案矛盾。

        因此,我们可以先去掉2、3、4,然后加入一个新的“物品”,其权值为A[2]+A[4]-A[3],这个物品代表同时选2、4,删去3。这样,在选了3之后如果再选这个新物品,就等效于刚才所说的,把A[3]换成A[2]+A[4]。

         这个新物品应该放在哪里呢?它的含义是“选2、4”,所以很容易想到,应该把它放在1、5之间。出于方便起见,不妨在删掉2、4后,直接把A[3]改成A[2]+A[4]-A[3],显然这个位置是正确的。

        这样,我们就将在n个物品中选m个的问题,转化为了在n-1个物品中选m-1个的问题。

        如此下去,直到选择m次,就可以得到答案(即使选择的是新添加的物品,依然相当于又选择了1个原来的物品)。

        具体实现上,我们以A[i]为关键字建大根堆,用一个链表存放当前物品。

        执行m次操作,每次从堆顶取出一个尚未被删除的元素k,ans+=A[k]。然后在链表中删除k的前驱prev和后继next,令A[k]=A[prev]+A[next]-A[k],并更新堆。时间复杂度O((n+m)log_2n)

第7节    二叉排序树及其应用

2.7.1  二叉排序树的概念

        二叉排序树(Binary Sort Tree),又称二叉查找树(Binary Search Tree),亦称二叉搜索树,是指一棵空树或具有下列性质的二叉树:

        (1)若某个结点的左子树不空,则左子树上所有结点的值均小于该结点的值。

        (2)若某个结点的右子树不空,则右子树上所有结点的值均大于该结点的值。

        (3)任意结点的左、右子树也分别为二叉查找树。

        (4)没有键值相等的结点。

        二叉排序树可以用来维护一个数集,其相比于其他数据结构的优势在于查找、插入、删除的期望时间复杂度为O(log_2n)。二叉排序树是基础性数据结构,用于构建更为抽象的期望数据结构,如set、multiset、map等。

        二叉排序树的查找过程和次优二叉树类似,通常采取二叉链表作为二叉排序树的存储结构。中序遍历二叉排序树可得到一个关键字的有序序列,一个无序序列可以通过构造一棵二叉排序树变成一个有序序列,构造树的过程即为对无序序列进行查找的过程。

        每次插入的新的结点都是二叉排序树上新的叶子结点,在进行插入操作时,不必移动其他结点,只需改动某个结点的指针,由空变为非空即可。搜索、插入、删除的时间复杂度等于树高,期望O(log_2n),最坏O(n)(数列有序,树退化为线性表)。

        虽然二叉排序树的最坏效率是O(n),但它支持动态查询,且有很多改进版的二叉排序树(称为平衡树)可以使树高为O(log_2n),如Treap、Splay、SBT、AVL树、红黑树等。故不失为一种好的动态查找方法。 

2.7.2  二叉排序树的查找

        在二叉排序树b中查找x的算法:

        (1)若b是空树,则搜索失败;否则执行(2)。

        (2)若x等于b的根结点的数据域之值,则查找成功;否则执行(3)。

        (3)若x小于b的根结点的数据域之值,则搜索左子树;否则执行(4)。

        (4)查找右子树。

2.7.3  二叉排序树的插入

        向二叉排序树b中插入一个结点s的算法:

        (1)若b是空树,则将s所指结点作为根结点插入;否则执行(2)。

        (2)若s->data等于b的根结点的数据域之值,则返回;否则执行(3)。

        (3)若s->data小于b的根结点的数据域之值,则把s所指结点插入到左子树中;否则执行(4)。

        (4)把s所指结点插入到右子树中(新插入结点总是叶子结点)。

2.7.4  二叉排序树的删除

        在二叉查找树中删除一个结点p,分三种情况讨论:

        (1)若p结点为叶子结点,即PL(左子树)和PR(右子树)均为空树,由于删去叶子结点不破坏整棵树的结构,则只需修改其父亲结点的指针即可。

        (2)若p结点只有左子树PL或右子树PR,此时只要令PL或PR直接成为其父亲结点f的左子树(当p是f的左子树)或右子树(当p是f的右子树)即可。

        (3)若p结点的左子树和右子树均不空,在删去p之后,为保持其他元素之间的相对位置不变,可按中序遍历保持有序进行调整,可以令p的直接前驱替代p,然后再从二叉查找树中删去它的直接前驱(或直接后继)。

        直接前驱就是指:从p的左儿子开始,如果有右儿子,则不断向右继续查找,否则就找到了直接前驱。由于它的直接前驱最多只有一个儿子,因此可以按照方法2删除。

2.7.5  二叉排序树的简单平衡方法

        由于二叉排序树的复杂度很容易退化,因此在实际中的用途没有那么广。但是如果二叉排序树能实现平衡,那么二叉排序树的时间复杂度为O(log_2n),非常优秀,可以得到非常广泛的应用。

        目前最主要的平衡方法有“AVL”、“Treap”、“Splay”等,这些方法都比较通用,在下一讲中会做详细说明。这里主要介绍另一种简单而且高效的平衡方法——“替罪羊树”。

        替罪羊树的一个显著特点是:对于每一个结点,它的左子树和右子树的大小都不超过本身子树大小的0.75倍,那么树的高度为O(log_2n)。但是如何做到这一点呢?

        对于插入的过程,像普通二叉排序树一样插入,完成后从新结点开始向父亲查找,找到深度最小的“不平衡”的结点,“不平衡”的意思是左子树或右子树的大小超过本身子树大小的0.75倍。如果没有这样的点,直接返回即可。否则,记录这个点为x。

        对于删除的过程,可以发现必然会删除一个只有一个儿子的点,删除完成后从那个点的儿子开始向父亲查找,做与插入操作同样的事情,找到结点x。

        插入/删除操作完成后,如果x是存在的,那么中序遍历整棵x的子树,得到这棵子树对应的序列。再O(n)进行一次暴力重构,将x的子树改造成最平衡的样子(只需每次取最中间的数字当做根即可)。用这棵子树取代原来x的子树的位置。

        虽然这样的时间复杂度看上去非常高,但是实际上,用“均摊分析”等较高级的方法进行分析,可以得到一个结论:无论是怎样的输入数据,替罪羊树都可以在O(nlog_2n)的时间内完成给定的n次操作,这样做效率确实较高。

2.7.6  二叉排序树的应用

        【例2.7.1】二叉排序树

        在采用二叉链表存储结构的基础上,实现二叉排序树的以下运算:

        (1)实现在二叉排序树上查找指定关键字的运算;

        (2)实现在二叉排序树上插入一个关键字的运算;

        (3)实现在二叉排序树上删除指定关键字的运算;

        (4)实现二叉排序树的中序遍历运算,输出中序遍历序列;

        (5)从空的二叉排序树开始,根据一个关键字序列,调用实现插入运算的函数,建立二叉排序树。

  1. #include<bits/stdc++.h>
  2. struct tree
  3. {
  4. private:
  5. struct node
  6. {
  7. int val,cnt;
  8. node *lc,*rc;
  9. node(int _val)
  10. {
  11. val=_val,cnt=1;
  12. lc=rc=NULL;
  13. }
  14. }*root;
  15. bool __find(node *now,int x)
  16. {
  17. if(now==NULL)
  18. return false;
  19. if(x==now->val)
  20. return true;
  21. if(x<now->val)
  22. return __find(now->lc,x);
  23. return __find(now->rc,x);
  24. }
  25. void __insert(node* &now,int x)
  26. {
  27. if(now==NULL)
  28. {
  29. now=new node(x);
  30. return;
  31. }
  32. if(x==now->val)
  33. now->cnt++;
  34. else if(x<now->val)
  35. __insert(now->lc,x);
  36. else
  37. __insert(now->rc,x);
  38. }
  39. void __erase(node* &now,int x)
  40. {
  41. if(now==NULL)
  42. return;
  43. if(x==now->val)
  44. {
  45. if(now->cnt>1)
  46. now->cnt--;
  47. else if(now->lc!=NULL&&now->rc!=NULL)
  48. {
  49. node *tmp=find(now->rc);
  50. now->val=tmp->val;
  51. now->cnt=tmp->cnt;
  52. delete tmp;
  53. }
  54. else
  55. {
  56. node *tmp=now;
  57. now=now->lc!=NULL?now->lc:now->rc;
  58. delete tmp;
  59. }
  60. }
  61. else if(x<now->val)
  62. __erase(now->lc,x);
  63. else
  64. __erase(now->rc,x);
  65. }
  66. node* find(node* &now)
  67. {
  68. if(now->lc==NULL)
  69. {
  70. node *tmp=now;
  71. now=now->rc;
  72. return tmp;
  73. }
  74. return find(now->lc);
  75. }
  76. void __print(node *now)
  77. {
  78. if(now==NULL)
  79. return;
  80. __print(now->lc);
  81. for(int i=1;i<=now->cnt;i++)
  82. std::cout<<now->val<<' ';
  83. __print(now->rc);
  84. }
  85. public:
  86. tree(int n=0)
  87. {
  88. for(int i=1;i<=n;i++)
  89. insert(read());//read的定义见2.5.3
  90. }
  91. bool find(int x)
  92. {
  93. return __find(root,x);
  94. }
  95. void insert(int x)
  96. {
  97. __insert(root,x);
  98. }
  99. void erase(int x)
  100. {
  101. __erase(root,x);
  102. }
  103. void print()
  104. {
  105. __print(root);
  106. putchar('\n');
  107. }
  108. };
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Cpp五条/article/detail/494698
推荐阅读
相关标签
  

闽ICP备14008679号