数据结构与算法分析(十)--- 二叉树的本质与实现 + 递归树与决策树应用

一、为何要有二叉树?

在我们真实的世界里,到底是具体的数值重要,还是数值之间相对的大小关系更重要,或者说相对的次序更重要?我想绝大部分人会说:“这得看具体情况”,如果必须让你在上述问题中做出二选一的回答,你会怎么办?

具体到上面这个问题,在计算机科学中其实是有明确答案的,那就是相对的大小要比绝对的数值更重要。这一点,计算机科学和数、理、化都不相同,在数学上,数字是准确的;在物理中,水的冰点有一个确切的温度,水不到0°是无法结冰的。但在计算机科学里并不是这样的,AlphaGo在和柯洁下棋时,讲解棋局的几位高手有时会觉得AlphaGo下得莫名其妙,因为明明有一步棋可以赢20目,它却要走一步稳妥的,原因其实很简单,计算机只看重相对的输赢。

世界上有什么样的问题,就有什么样的工具,或者说工具的发明是针对问题来的。在数学上要计算数字,人类就发明了算盘;在物理学上,要测量绝对的数值,人们就发明了各种度量长度的尺子、计时的钟、称重量的天平等;在化学上要测量化学反应的当量,人类就发明了各种有刻度的量器。在计算机中,由于经常要做的事情是判断真假、比较大小、排序、挑选最大值这类的操作,而它们在计算机的世界里又如此重要,当然也就值得为这些事情专门设计一种数据结构这种数据结构被称为二叉树

你可以看一眼下面二叉树的图示,有一个感性的认识:
二叉树概念图示
为了便于理解二叉树,我们可以将它和自然界的树木对应起来:

  • 首先,它们都有一个根,二叉树的根就是上图最顶端的结点。为了将来把二叉树一层层扩展,二叉树的根画在了最顶端而不是下面,你把自然界的大树旋转180°就可以了;
  • 其次,从根开始,它们都有分叉,只不过二叉树为了简单起见,只能有两个分叉,不能更多,这一点和自然界的树不同。每一个分叉也有一个自己的根结点,你可以把它们想象成大数各级主干分叉前的部分。你可能会问,遇到特殊问题需要三个或更多分叉怎么办?在数学上,两个分支与N个分支是等价的,或者说N个分支的情况都可以通过两个分支的来实现,为了简单起见,也为了更好地通用性,我们只研究二叉树就可以了;
  • 最后,我们知道一棵树的树杈还能再分叉,二叉树也是如此,任何一个树杈都可以再分出两枝,这个过程可以无限持续下去。

我们在生活中,一些组织结构其实就是树状结构,比如一个公司的大老板就是根节点,每一个部门的老板是主干的根,下面职能部门的总监是枝干的根,再下面小组的经理是更小枝子的根,最后基层员工是叶子结点。

接下来你可能会问,这种怪怪的数据结构在计算机科学中有什么用处呢?它的用途很多,比如说可以用于排序。我们在前面介绍的排序算法,无论是高效率的快速排序还是最直观的插入排序,使用的都是数组或者说线性的列表。其实,二叉树可以直接完成排序,因为它有一个很好的性质,就是它左右两个分叉可以和比较大小后的两种结果自然对应起来。具体讲,用二叉树排序的过程中只要遵循下面两条规则就可以了:

  1. 先来的占据根部,以及靠近顶部层级比较高的位置,后来的放在相对靠下的位置;
  2. 每当一个分支的根部被占据之后,接下来的数字,是和根部的数字进行比较,小的放在左边分叉中,大的放到右边分叉中。

这样安排得出来的二叉树,里面的数字从左到右自然是从小到大排好序的。假设共N个数字,每个数字按照一定的规则放到二叉树中的时间为logN(类似二分查找的时间复杂度分析),所有数字放进二叉树使其有序的总时间是NlogN,这个时间和快速排序是同一个量级的。

二叉树这种数据结构的用途远不止排序,前面举了排序的例子只是为了说明它有用,在现实的工程中,二叉树可以做很多事情,比如快速查找到某一个数值(二分查找,时间复杂度logN)。另外,由于网站的目录结构也是树状的(当然它们有N个分叉),因此针对二叉树的各种算法,稍加改变就可以用于互联网。比如我们找到一个网站后,要下载一个网站里面所有的网页,就会用到二叉树中的一种遍历算法。

二叉树虽然是一种抽象的东西,在自然界中并不存在,但是它却浓缩了自然界很多事物的共性,那就是分叉、层层递进和有序。而针对这些共性,科学家们又总结出一些具有普遍性的算法,能够回过头来,应用到各种实际问题中。

二、何为二叉树?

2.1 树的基本概念

前面已经给出了二叉树的图示,二叉树是最常用的树结构,有二叉树自然就有三叉树、四叉树等其它数量分叉的树,那什么是“树”呢?再完备的定义,都没有图直观,下面给出几棵“树”的图示,这些“树”都有什么特征呢?
几棵树的示例
“树”这种数据结构真的很像我们现实生活中的“树”,这里面每个元素我们叫作“结点”;用连线来表示相邻结点之间的关系,我们叫作“父子关系”。比如下面这幅图,A 结点就是 B 结点的父结点,B 结点是 A 结点的子结点。B、C、D 这三个结点的父结点是同一个结点,所以它们之间互称为兄弟结点。我们把没有父结点的结点叫作根结点,也就是图中的结点 E。我们把没有子结点的结点叫作叶子结点或者叶结点,比如图中的 G、H、I、J、K、L 都是叶子结点。
树的图示
除此之外,关于“树”,还有三个比较相似的概念:高度(Height)、深度(Depth)、层(Level)。它们的定义是这样的:

  • 结点的高度 = 结点到叶子结点的最长路径或边数;
  • 结点的深度 = 根结点到这个结点所经历的边的个数;
  • 结点的层数 = 结点的深度 + 1;
  • 树的高度 = 根结点的高度。

这三个概念的定义比较容易混淆,描述起来也比较空洞,下面举个例子说明一下:
树的高度、深度和层
在我们的生活中,“高度”这个概念,其实就是从下往上度量,比如我们要度量第 10 层楼的高度、第 13 层楼的高度,起点都是地面。所以,树这种数据结构的高度也是一样,从最底层开始计数,并且计数的起点是 0。

“深度”这个概念在生活中是从上往下度量的,比如水中鱼的深度,是从水平面开始度量的。所以,树这种数据结构的深度也是类似的,从根结点开始度量,并且计数起点也是 0。

“层数”跟深度的计算类似,不过,计数起点是 1,也就是说根结点的位于第 1 层。

2.2 二叉树的定义与存储

前面我们也说了,树结构多种多样,我们最常用却是二叉树。二叉树,顾名思义,每个结点最多有两个“叉”,也就是两个子结点,分别是左子结点右子结点。不过,二叉树并不要求每个结点都有两个子结点,有的结点只有左子结点,有的结点只有右子结点。
二叉树图示
上图中有两个比较特殊的二叉树,分别是编号 2 和编号 3 这两个。其中,编号 2 的二叉树中,叶子结点全都在最底层,除了叶子结点之外,每个结点都有左右两个子结点,这种二叉树就叫作满二叉树。编号 3 的二叉树中,叶子结点都在最底下两层,最后一层的叶子结点都靠左排列,并且除了最后一层,其他层的结点个数都要达到最大,这种二叉树叫作完全二叉树

满二叉树的特征非常明显,我们把它单独拎出来,这个可以理解。但是完全二叉树的特征不怎么明显,单从长相上来看,完全二叉树并没有特别特殊的地方,更像是“芸芸众树”中的一种。那我们为什么还要特意把它拎出来呢?为什么偏偏把最后一层的叶子结点靠左排列的叫完全二叉树?如果靠右排列就不能叫完全二叉树了吗?这个定义的由来或者说目的在哪里?

要理解完全二叉树定义的由来,我们需要先了解,如何表示(或者存储)一棵二叉树?想要存储一棵二叉树,我们有两种物理存储结构,一种是基于数组的顺序存储法,另一种是基于指针或者引用的链式存储法。

我们先来看比较简单、直观的链式存储法,从图中你应该可以很清楚地看到,每个结点有三个字段,其中一个存储数据,另外两个是指向左右子结点的指针,我们只要拎住根结点,就可以通过左右子结点的指针,把整棵树都串起来。这种存储方式我们比较常用,大部分二叉树代码都是通过这种结构来实现的。
二叉树链式存储法

struct BinaryTree_Node{
    DataType                data;
    struct BinaryTree_Node *LeftChild;
    struct BinaryTree_Node *RightChild;
};

typedef struct BinaryTree_Node   *pTreeNode;

我们再来看,基于数组的顺序存储法,我们把根结点存储在下标 i = 1 的位置,那左子结点存储在下标 2 * i = 2 的位置,右子结点存储在 2 * i + 1 = 3 的位置。以此类推,B 结点的左子结点存储在 2 * i = 2 * 2 = 4 的位置,右子结点存储在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。
完全二叉树顺序存储法
如果节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子结点,下标为 2 * i + 1 的位置存储的就是右子结点。反过来,下标为 i/2 的位置存储就是它的父结点。通过这种方式,我们只要知道根结点存储的位置(一般情况下,为了方便计算子结点,根结点会存储在下标为 1 的位置),这样就可以通过下标计算,把整棵树都串起来。

刚刚举的例子是一棵完全二叉树,所以仅仅“浪费”了一个下标为 0 的存储位置。如果是非完全二叉树,其实会浪费比较多的数组存储空间,你可以看下面几个例子。
非完全二叉树顺序存储法
如果使用游标数组来存储二叉树,二叉树结点可以定义如下:

struct BinaryTree_Node{
    DataType    data;
    int			LeftChild;		//指向左子结点下标
    int			RightChild;		//指向右子结点下标
}TreeNode[maxn];				//结点数组,maxn为结点上限个数

所以,如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子结点的指针,这也是为什么完全二叉树会单独拎出来的原因,也是为什么完全二叉树要求最后一层的子结点都靠左的原因。我们后面要介绍的堆其实就是一种完全二叉树,最常用的存储方式就是数组

2.3 二叉树的遍历

一个数据结构的遍历操作是比较重要的,前面介绍的线性表遍历操作比较简单,需要注意的就是明确遍历起点与终点,防止遗漏或访问越界。二叉树结构比线性表复杂,如何将二叉树中所有结点都遍历打印出来呢?经典的方法有三种,前序遍历、中序遍历和后序遍历。其中,前、中、后序,表示的是结点与它的左右子树结点遍历打印的先后顺序。

  • 前序遍历:对于树中的任意节结点来说,先打印这个结点,然后再打印它的左子树,最后打印它的右子树;
  • 中序遍历:对于树中的任意结点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树;
  • 后序遍历:对于树中的任意结点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个结点本身。

树的遍历图示
实际上,二叉树的前、中、后序遍历就是一个递归的过程。比如,前序遍历,其实就是先打印根结点,然后再递归地打印左子树,最后递归地打印右子树。

递归代码的关键,就是看能不能写出递推公式,而写递推公式的关键就是,如果要解决问题 A,就假设子问题 B、C 已经解决,然后再来看如何利用 B、C 来解决 A。你可能已经发现了,二叉树的结构很适合使用前面介绍的分治算法来处理,一棵二叉树有两个子叉,每个子叉也是一棵二叉树,因此二叉树可以使用递归不断分解为左右两个子二叉树去处理,递归的边界条件就是根结点为空。按照上面的逻辑,给出二叉树的前、中、后序遍历实现代码如下:

void preOrder(pTreeNode T)
{  
	if (T == NULL) 
		return;
		  
	printf("%d ", T->data);
	preOrder(T->LeftChild);  
	preOrder(T->RightChild);
}

void inOrder(pTreeNode T) 
{  
	if (T == NULL) 
		return;
		  
	inOrder(T->LeftChild);  
	printf("%d ", T->data);
	inOrder(T->RightChild);
}

void postOrder(pTreeNode T) 
{  
	if (T == NULL) 
		return;
	
	postOrder(T->LeftChild);  
	postOrder(T->RightChild);  
	printf("%d ", T->data);
}

二叉树的前、中、后序遍历的递归实现很简单吧,你知道二叉树遍历的时间复杂度是多少吗?从上面二叉树的前、中、后序遍历顺序图可以看出,每个结点最多会被访问两次,所以遍历操作的时间复杂度,跟结点的个数 n 成正比,也就是说二叉树遍历的时间复杂度是 O(n)。

对于查找二叉树,使用中序遍历可以按大小顺序打印出所有元素,而且中序遍历可以再配合前序遍历或后序遍历中的任何一种来构建唯一的一棵二叉树,但前序遍历与后序遍历配合并不能构建唯一的一棵二叉树。原因是前序与后序遍历都是提供根结点,作用是相同的,必须由中序遍历来区分左右子树。

三、二叉查找树

前面介绍了二分查找,对已排序数组使用二分查找的时间复杂度是O(logN),但数组的插入、删除时间复杂度为O(N),效率比较低,因此不适合作为动态数据结构使用。要在频繁插入、删除元素的场景中发挥二分查找的效率,就要摆脱顺序表的邻接关系,改用指针式的指向关系。在介绍二分查找时,也介绍了跳跃链表(简称跳表),通过构建多级索引层,可以实现O(logN)的查找、插入、删除时间复杂度,但需要额外使用O(N)的内存地址空间。有没有更好的实现二分查找算法的数据结构呢?

本章最开头就已经介绍过,二叉树有一个很好的性质,就是它左右两个分叉可以和比较大小后的两种结果自然对应起来。如果目标值比当前元素值小,就到其左子树上继续查找,如果目标值比当前元素值大,则到其右子树上继续查找,这种二叉树就是二叉查找树,也叫二叉搜索树,它不仅仅支持O(logN)时间查找一个数据,还支持O(logN)时间插入、删除一个数据,而且不需要额外占用内存地址空间,它是怎么做到这些的呢?

这些都依赖于二叉查找树的特殊结构,二叉查找树要求,在树中的任意一个结点,其左子树中的每个结点的值,都要小于这个结点的值,而右子树结点的值都大于这个结点的值
二叉查找树

3.1 二叉查找树的实现

前面谈到,二叉查找树支持快速查找、插入、删除操作,这三个操作是如何实现的呢?

  • 二叉查找树的查找操作

首先,我们看如何在二叉查找树中查找一个结点。我们先取根结点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根结点的值小,那就在左子树中递归查找;如果要查找的数据比根结点的值大,那就在右子树中递归查找。
二叉查找树查找示例
按照上述逻辑,二叉查找树的查找操作实现代码如下(为更直观展示逻辑,依然使用了递归实现方式):

// datastruct\binary_tree.c

pTreeNode Find(pTreeNode T, DataType x)
{
    if(T == NULL)
        return NULL;

    if(x < T->data)
        return Find(T->LeftChild, x);
    else if(x > T->data)
        return Find(T->RightChild, x);
    else
        return T;
}

有时候我们想快速找到最大元素或最小元素,但查找前我们并不知道最大或最小元素值,二叉查找树依然可以在O(logN)时间找到并返回最大或最小元素指针。

查找最小元素的逻辑很简单,只需要不断往左子树查找,根据二叉查找树的性质,只要其有左子树,则左子结点的值小于当前结点的值,直到当前结点没有左子结点为止,此时当前结点就是该二叉查找树中元素值最小的结点。查找最大元素的逻辑与此类似,按照这个逻辑给出二叉查找树查找最小与最大元素值结点操作的实现函数如下(分别以递归和非递归的实现方式给出,尾递归借助栈结构转换为迭代循环的方法在前面介绍过了):

// datastruct\binary_tree.c

pTreeNode FindMin(pTreeNode T)
{
    if(T == NULL)
        return NULL;
    
    if(T->LeftChild == NULL)
        return T;
    else
        return FindMin(T->LeftChild);
}

pTreeNode FindMax(pTreeNode T)
{
    if(T != NULL)
        while(T->RightChild != NULL)
            T = T->RightChild;

    return T;
}
  • 二叉查找树的插入操作

二叉查找树的插入过程有点类似查找操作,新插入的数据一般都是在叶子结点上,所以我们只需要从根结点开始,依次比较要插入的数据和结点的大小关系。

如果要插入的数据比结点的数据大,并且结点的右子树为空,就将新数据直接插到右子结点的位置;如果不为空,就再递归遍历右子树,查找插入位置。同理,如果要插入的数据比结点数值小,并且节点的左子树为空,就将新数据插入到左子结点的位置;如果不为空,就再递归遍历左子树,查找插入位置。
二叉查找树插入示例
按照上述逻辑,二叉查找树的插入操作实现代码如下(使用了递归实现方式):

// datastruct\binary_tree.c

pTreeNode Insert(pTreeNode T, DataType x)
{
    if(T == NULL)
    {
        // create and return a one-node tree
        T = malloc(sizeof(struct BinaryTree_Node));
        if(T == NULL){
            printf("Out of space!");
        }else{
            T->data = x;
            T->LeftChild = T->RightChild = NULL;
        }
        return T;
    }

    if(x < T->data)
        T->LeftChild = Insert(T->LeftChild, x);
    else if(x > T->data)
        T->RightChild = Insert(T->RightChild, x);    
    // else x is in the tree already, we will do nothing

    return T;
}

插入元素一般并不需要返回值,上面的实现函数中为何需要返回值呢?我们知道以值传递形式传给函数的参数,在函数体内其实只是一份拷贝,对其修改并不能反映到参数自身。这里虽然传进去的是指向某结点的指针,对其指向对象的修改有效,对指针本身的修改无效,如果我们想修改指针值,需要将指向指针的指针传递进去(C语言不支持引用传参,C++支持引用传参),或者将修改后的指针值返回,在函数调用中以赋值形式更新指针值。上面的实现代码选择了第二种方式,下面的删除操作实现代码也是类似的考虑。

重复元素的插入可以通过在结点记录中保留一个附加域以指示发生的频率来处理,这使整棵树增加了部分附加空间,但却比将重复信息放到树中有更小的深度。当然,如果关键字只是一个更大结构的一部分,那么这种方法就行不通了。

  • 二叉查找树的删除操作

二叉查找树的查找、插入操作都比较简单易懂,但是它的删除操作就比较复杂了 。针对要删除结点的子结点个数的不同,我们需要分三种情况来处理:

  1. 第一种情况是,如果要删除的结点没有子结点,我们只需要直接将父结点中,指向要删除结点的指针置为 null,比如下图中删除结点 55;
  2. 第二种情况是,如果要删除的结点只有一个子结点(只有左子结点或者右子结点),我们只需要更新父结点中,指向要删除结点的指针,让它指向要删除结点的子结点就可以了,比如下图中删除结点 13;
  3. 第三种情况是,如果要删除的结点有两个子结点,这就比较复杂了。我们需要找到这个结点的右子树中的最小结点,把它替换到要删除的结点上。然后再删除掉这个最小结点,因为最小结点肯定没有左子结点(如果有左子结点,那就不是最小结点了),所以,我们可以应用上面两条规则来删除这个最小结点,比如下图中删除节点 18。
    二叉查找树删除示例
    按照上述逻辑,二叉查找树的删除操作实现代码如下(使用了递归实现方式):
// datastruct\binary_tree.c

pTreeNode Delete(pTreeNode T, DataType x)
{
    if(T == NULL)
    {
        printf("Element not found");
        return NULL;
    }

    if(x < T->data)
        T->LeftChild = Delete(T->LeftChild, x);
    else if(x > T->data)
        T->RightChild = Delete(T->RightChild, x);
    // found target element to delete
    else
    {
        pTreeNode tmp;
        // have two children
        if(T->LeftChild != NULL && T->RightChild != NULL)
        {
            // replace with samllest in right subtree
            tmp = FindMin(T->RightChild);
            T->data = tmp->data;
            T->RightChild = Delete(T->RightChild, T->data);
        }else{
            // one or zero children
            tmp = T;
            T = (T->LeftChild != NULL) ? T->LeftChild : T->RightChild;
            free(tmp);
        }
    }
    
    return T;
}

实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法 — 懒惰删除,就是单纯将要删除的节点标记为“已删除”,但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。而且,这种处理方法也并没有增加插入、查找操作代码实现的难度,如果被删除的元素再次重新插入,还能节省分配一个新元素的开销。

二叉查找树除了支持上面几个操作之外,还有一个重要的特性,就是中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 O(n),非常高效。因此,二叉查找树也叫作二叉排序树。

我们使用上面实现的二叉查找树操作函数完成一个示例,以验证我们实现的操作函数是否存在bug,二叉查找树的示例程序如下:

#include <stdio.h>
#include <stdlib.h>

#define DataType int

struct BinaryTree_Node{
    DataType                data;
    struct BinaryTree_Node *LeftChild;
    struct BinaryTree_Node *RightChild;
};
typedef struct BinaryTree_Node   *pTreeNode;

pTreeNode Find(pTreeNode T, DataType x);
pTreeNode FindMin(pTreeNode T);
pTreeNode FindMax(pTreeNode T);
pTreeNode Insert(pTreeNode T, DataType x);
pTreeNode Delete(pTreeNode T, DataType x);
void preOrder(pTreeNode T);
void inOrder(pTreeNode T);
void postOrder(pTreeNode T);

int main(void)
{
    pTreeNode T = NULL, tmp = NULL;

    T = Insert(T, 16);
    T = Insert(T, 18);  
    T = Insert(T, 13);
    T = Insert(T, 17);
    T = Insert(T, 15);
    T = Insert(T, 25);
    T = Insert(T, 27);

    printf("preOrder: ");
    preOrder(T);
    printf("\n");
    printf("inOrder: "); 
    printf("\n");
    printf("postOrder: ");
    postOrder(T);
    printf("\n");

    tmp = Find(T, 18);
    printf("find target result: %d\n", tmp->data);
    tmp = FindMin(T);
    printf("find min result: %d\n", tmp->data);
    tmp = FindMax(T);
    printf("find max result: %d\n", tmp->data);

    T = Delete(T, 27);
    T = Delete(T, 13);
    T = Delete(T, 18);

    printf("preOrder: ");
    preOrder(T);
    printf("\n");
    printf("inOrder: "); 
    inOrder(T);
    printf("\n");
    printf("postOrder: ");
    postOrder(T);
    printf("\n");

    return 0;
}

上面二叉查找树的示例程序运行结果如下:
二叉查找树示例程序运行结果

3.2 支持重复数据的二叉查找树

前面介绍二叉查找树的时候,我们默认树中结点存储的都是数字。很多时候,在实际的软件开发中,我们在二叉查找树中存储的,是一个包含很多字段的对象。我们利用对象的某个字段作为键值(key)来构建二叉查找树,我们把对象中的其它字段叫作卫星数据

前面介绍的二叉查找树的操作,针对的都是不存在键值相同的情况。那如果存储的两个对象键值相同,除了增加记录频率信息的附加字段外,还有哪些更好的解决方法呢?

第一种方法比较容易:二叉查找树中每一个结点不仅会存储一个数据,因此我们可以通过链表、支持动态扩容的数组、甚至另一棵二叉查找树等数据结构,把值相同的数据都存储在同一个结点上。

第二种方法比较不好理解,不过更加优雅:每个节点仍然只存储一个数据,在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。
插入值相等的元素
当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止,这样就可以把键值等于要查找值的所有节点都找出来。
查找值相同的元素
对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。
删除值相同的元素

3.3 二叉查找树的时间复杂度分析

实际上,二叉查找树的形态各式各样。比如下图中,对于同一组数据,我们构造了三种二叉查找树,它们的查找、插入、删除操作的执行效率都是不一样的。图中第一种二叉查找树,根节点的左右子树极度不平衡,已经退化成了链表,所以查找的时间复杂度就变成了 O(n)。
几种二叉查找树
前面分析的其实是一种最糟糕的情况,我们现在来分析一个最理想的情况,二叉查找树是一棵完全二叉树(或满二叉树)。这个时候,插入、删除、查找的时间复杂度是多少呢?

我前面的图示和实现代码来看,不管操作是插入、删除还是查找,时间复杂度其实都跟树的高度成正比,也就是 O(height)。既然这样,现在问题就转变成另外一个了,也就是,如何求一棵包含 n 个结点的完全二叉树的高度?

树的高度就等于最大层数减一,为了方便计算,我们转换成层来表示。从图中可以看出,包含 n 个结点的完全二叉树中,第一层包含 1 个结点,第二层包含 21 个结点,第三层包含 22 个结点,依次类推,下面一层结点个数是上一层的 2 倍,第 K 层包含的结点个数就是 2(K-1)

不过,对于完全二叉树来说,最后一层的结点个数有点儿不遵守上面的规律了,它包含的结点个数在 1 个到 2(L-1) 个之间(我们假设最大层数是 L)。如果我们把每一层的结点个数加起来就是总的结点个数 n,也就是说,如果结点的个数是 n,那么 n 满足这样一个关系:

n >= 1+2+4+8+…+2(L-2)+1
n <= 1+2+4+8+…+2(L-2)+2(L-1)

借助等比数列的求和公式,我们可以计算出,L 的范围是 [log2(n+1), log2n +1]。完全二叉树的层数小于等于 log2n +1,也就是说,完全二叉树的高度小于等于 log2n。

显然,极度不平衡的二叉查找树,它的查找性能肯定不能满足我们的需求。我们需要构建一种不管怎么删除、插入数据,在任何时候,都能保持任意结点左右子树都比较平衡的二叉查找树,这就是我们下一篇博客要介绍的一种特殊的二叉查找树,平衡二叉查找树。平衡二叉查找树的高度接近 logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是 O(logn)。

3.4 二叉查找树与散列表优劣对比

我们在散列表那篇博客中介绍过,散列表的插入、删除、查找操作的时间复杂度可以做到常量级的 O(1),非常高效。而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是 O(logn),相对散列表,好像并没有什么优势,那我们为什么还要用二叉查找树呢?

我认为有下面几个原因:

  1. 散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列;
  2. 散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn);
  3. 笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高;
  4. 散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定;
  5. 为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。

综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。我们在实际的开发过程中,需要结合具体的需求来选择使用哪一个。

四、二叉树应用之递归树与决策树

我们都知道,递归代码的时间复杂度分析起来很麻烦。我们在博客:排序算法分析 中介绍过,如何利用递推公式,求解归并排序、快速排序的时间复杂度,但是,有些情况,比如快排的平均时间复杂度的分析,用递推公式的话,会涉及非常复杂的数学推导,借助递归树来分析递归算法的时间复杂度,则可以简化数学推导过程。

4.1 用递归树分析归并排序时间复杂度

我们前面讲过,递归的思想就是,将大问题分解为小问题来求解,然后再将小问题分解为小小问题,这样一层一层地分解,直到问题的数据规模被分解得足够小,不用继续递归分解为止。如果我们把这个一层一层的分解过程画成图,它其实就是一棵树,我们给这棵树起一个名字,叫作递归树。下面给出一棵斐波那契数列的递归树图示,结点里的数字表示数据的规模,一个结点的求解可以分解为左右子结点两个问题的求解。
斐波那契数列递归树
通过这个例子,你对递归树的样子应该有个感性的认识了,看起来并不复杂。现在,我们就来看,如何用递归树来求解时间复杂度?

归并排序算法你还记得吧?它的递归实现代码非常简洁。现在我们就借助归并排序来看看,如何用递归树,来分析递归代码的时间复杂度。归并排序每次会将数据规模一分为二。我们把归并排序画成递归树,就是下面这个样子:
归并排序递归树
因为每次分解都是一分为二,所以代价很低,我们把时间上的消耗记作常量 1。归并算法中比较耗时的是归并操作,也就是把两个子数组合并为大数组。从图中我们可以看出,每一层归并操作消耗的时间总和是一样的,跟要排序的数据规模有关,我们把每一层归并操作消耗的时间记作 n。现在,我们只需要知道这棵树的高度 h,用高度 h 乘以每一层的时间消耗 n,就可以得到总的时间复杂度 O(n∗h)。

从归并排序的原理和递归树,可以看出来,归并排序递归树是一棵满二叉树,满二叉树的高度大约是 log2​n,所以归并排序递归实现的时间复杂度就是 O(nlogn)。我这里的时间复杂度都是估算的,对树的高度的计算也没有那么精确,但是这并不影响复杂度的计算结果。

4.2 用递归树分析快速排序时间复杂度

快速排序在最好情况下,每次分区都能一分为二,这个时候用递推公式 T(n)=2T(n/2​)+n,很容易就能推导出时间复杂度是 O(nlogn)。但是,我们并不可能每次分区都这么幸运,正好一分为二。

我们假设平均情况下,每次分区之后,两个分区的大小比例为 1:k。当 k=9 时,如果用递推公式的方法来求解时间复杂度的话,递推公式就写成 T(n)=T(n/10​)+T(9*n/10​)+n。这个公式可以推导出时间复杂度,但是推导过程非常复杂。那我们来看看,用递归树来分析快速排序的平均情况时间复杂度,是不是比较简单呢?

我们还是取 k 等于 9,也就是说,每次分区都很不平均,一个分区是另一个分区的 9 倍。如果我们把递归分解的过程画成递归树,就是下面这个样子:
快速排序递归树
快速排序的过程中,每次分区都要遍历待分区区间的所有数据,所以,每一层分区操作所遍历的数据的个数之和就是 n。我们现在只要求出递归树的高度 h,这个快排过程遍历的数据个数就是 h∗n ,也就是说,时间复杂度就是 O(h∗n)。

因为每次分区并不是均匀地一分为二,所以递归树并不是满二叉树。这样一个递归树的高度是多少呢?我们知道,快速排序结束的条件就是待排序的小区间,大小为 1,也就是说叶子节点里的数据规模是 1。从根节点 n 到叶子节点 1,递归树中最短的一个路径每次都乘以 1/10​,最长的一个路径每次都乘以 9/10​。通过计算,我们可以得到,从根节点到叶子节点的最短路径是 log10​n,最长的路径是 log10/9​​n。

所以,遍历数据的个数总和就介于 nlog10​n 和 nlog10/9n 之间。根据复杂度的大 O 表示法,对数复杂度的底数不管是多少,我们统一写成 logn,所以,当分区大小比例是 1:9 时,快速排序的时间复杂度仍然是 O(nlogn)。

刚刚我们假设 k=9,那如果 k=99,也就是说,每次分区极其不平均,两个区间大小是 1:99,这个时候的时间复杂度是多少呢?我们可以类比上面 k=9 的分析过程。当 k=99 的时候,树的最短路径就是 log100n,最长路径是 log100/99n,所以总遍历数据个数介于 nlog100n 和 nlog100/99​​n 之间。尽管底数变了,但是时间复杂度也仍然是 O(nlogn)。

也就是说,对于 k 等于 9,99,甚至是 999,9999……,只要 k 的值不随 n 变化,是一个事先确定的常量,那快排的时间复杂度就是 O(nlogn)。所以,从概率论的角度来说,快排的平均时间复杂度就是 O(nlogn)。

4.3 用决策树分析分治排序时间复杂度

二叉树除了可以用作递归分解子问题的分析工具 — 递归树外,还可以作为判断真假辅助决策的工具 — 决策树。如果把决策树用于分析排序算法的时间复杂度,这里判断真假的对象就是元素间的比较关系,如下图所示,树的根结点表示“元素的所有可能顺序”,树的每一条边表示“一种可能的结果”,一条边连接的孩子结点则是“父结点经过该边所代表的比较结果后剩余的可能顺序”:
决策树图示
上图是一棵三元素排序决策树,根结点处表示所有可能的顺序,而从根延伸下来的两条边分别表示了两种“决策结果”,或者说“比较结果”,若符合该“决策结果”就可以得出剩余的可能情况,比如根结点的左孩子是经历决策“a<b”后剩余的可能。显然,叶子代表只剩一种可能顺序。

决策树并没有代表任何排序算法,即没有哪个排序算法是这样工作的。但是决策树可以给我们这样一个信息:通过比较来排序的算法,本质上就是沿着该元素集合的决策树从根到某个叶子的路径比较下去。因此,分析这条“路径”平均经过多少条边,就相当于分析使用比较的排序算法平均需要多少次比较。归并排序、快速排序,包括后面将要介绍的堆排序都是基于比较的排序算法,我们可以借助决策树来分析这类基于比较的排序算法的时间复杂度下界。

从决策树的定义和图示可以看出,决策树算是满二叉树,所以决策树的深度是logL(L为叶子结点的个数)。决策树叶子结点的数量是多少呢?假如N个元素排序,每个叶子结点就是这N个元素的一种可能排列结果,叶子结点的数量实际上就是这N个元素的全排列结果数量。从学过的高中数学可知,N个元素的全排列数量共有N!种,所以N个元素决策树的叶子结点数量也有N!个,该决策树的深度就是log(N!)。

基于比较的排序算法的比较次数,借用决策树分析,就是从决策树根结点出发,沿着某条路径,到达一种有序的排列结果,也即其中一个叶子结点。中间经过的边数,也即元素两两比较次数,就是该决策树的深度log(N!),所以基于比较的排序算法,至少需要进行log(N!)次比较,也即基于比较的排序算法的时间复杂度是O(log(N!))。

你可能会问,归并排序与快速排序的平均时间复杂度都是O(NlogN),这里又说基于比较的排序算法的时间复杂度下界是O(log(N!)),这两个时间复杂度哪个更高阶呢?从下面的简单转换可以看出,当N有限时,NlogN比log(N!)更大。

logN!=log(N(N-1)(N-2)……x2x1)
   =logN+log(N-1)+log(N-1)+……+log2+log1
  <=N*logN

但是,大O表示法针对的是N趋近于无穷大时的情况,根据数学中的斯特拉公式可知:
斯特林公式
当N趋近于无穷大时,O(log(N!))与O(NlogN)是等价的(也即忽略上式中的低阶项),从两个函数曲线图上也可以看出二者之间的关系:
nlogn与log(n!)曲线对比
由此可见,基于比较的排序算法的时间复杂度下界为Ω(log(N!)),也等价于Ω(N*logN)。

将该决策树的分析过程推广到一般情形,如果存在P种不同的情况要区分,而问题是Yes/No的真假判断形式,那么通过任何算法求解该问题在某种情形下的解至少需要logP次判断。

本章数据结构实现源码下载地址:https://github.com/StreamAI/ADT-and-Algorithm-in-C/tree/master/datastruct

更多文章:

发布了65 篇原创文章 · 获赞 35 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/m0_37621078/article/details/103827814
今日推荐