【二叉树与堆】:从“根”本说起,一起爬满数据的枝桠!

前言:由于后面C++的学习需要大量用到树的知识,且本人之前在这一块的学习也没有特别扎实,所以打算和大家一起在学习一下树🌳。有考虑到还没学C++的小伙伴查阅,所以本文除了堆的实现那一块用到了C++的语法,其他基本都用了C语言,如果有其他地方,我会在那里加以说明。

目录

🌲 第一章:树——不止是植物,更是数据结构!

1. 核心特点

2. 树的相关概念

3. 树的表示

🌴 第二章:二叉树——每个结点都是“人生赢家”(最多俩娃)

1. 二叉树的概念

2. 满二叉树的概念

3. 完全二叉树的概念

4. 二叉树的性质

🏕️ 第三章:顺序结构——用数组给二叉树“安家”(堆的实现)

1. 堆的概念

2. 堆的库函数

3. 堆的模拟实现

4. 堆排序(大堆为例)

🌿 第四章:链式结构——让二叉树自由生长(递归算法)

1. 链式结构说明

2. 二叉树的遍历

3. 二叉树的结点及高度问题

4. 二叉树的创建和销毁

5. 二叉树的其他问题

五、选择小练习



🌲 第一章:树——不止是植物,更是数据结构!

        树在我们生活中很常见  

        而在编程里,树指的是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。

1. 核心特点

1. 层次结构

  • 具有根节点,作为整个树的起点

  • 除根节点外,每个节点有且仅有一个父节点

  • 节点间形成明确的父子关系,呈现自然的层次关系

2. 递归定义(认真理解,后面内容玩的全是递归)

  • 树由根节点和若干子树组成

  • 每个子树本身又是一棵树

  • 这种递归结构使得树的很多操作可以通过递归实现

3. 离散性

  • 各子树之间互不相交

  • 每个节点(除根节点)都有唯一的父节点

什么时候不是树

2. 树的相关概念

树很多定义都和我们人类的亲缘关系有关

  • 结点的度:一个结点拥有的子树的个数称为该结点的度。例如:结点 A 的度为 6。

  • 叶结点/终端结点度为 0 的结点。例如:B、C、H、I 等结点都是叶结点。

  • 非终端结点/分支结点度不为 0 的结点。例如:D、E、F、G 等结点都是分支结点。

  • 双亲结点/父结点:如果一个结点含有子结点,那么这个结点就是其子结点的父结点。例如:A 是 B 的父结点。

  • 孩子结点/子结点:一个结点所拥有的子树的根结点就是该结点的子结点。例如:B 是 A 的孩子结点。

  • 兄弟结点:拥有相同父结点的结点互称为兄弟结点。例如:B 和 C 是兄弟结点。

  • 树的度:一棵树中,最大的结点的度就是这棵树的度。例如:这棵树的度为 6。

  • 结点的层次:从根结点开始定义,根结点为第 1 层,根结点的子结点为第 2 层,依此类推。例如:J结点在第 3 层。(一般是从1层数,也有从0层数的,即根结点为第0层)

  • 树的高度/深度:树中结点的最大层次。例如:这棵树的高度为 4(P在的层数)。

  • 堂兄弟结点父结点在同一层的结点互为堂兄弟,注意和兄弟结点区分。例如:H 和 I 是堂兄弟结点。

  • 结点的祖先:从根结点到该结点所经过的所有分支上的结点,都是该结点的祖先。例如:A 是所有结点的祖先。

  • 子孙:以某个结点为根的子树中的任何结点,都是该结点的子孙。例如:所有结点都是 A 的子孙。

  • 森林:由 m (m > 0) 棵互不相交的树组成的集合被称为森林。

(标红的为很重要的概念)

3. 树的表示

        树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。我们这里就简单的了解其中最常用的孩子兄弟表示法

//树的孩子兄弟表示法
typedef int DataType;
struct Node
{
	struct Node* firstChild1; // 第一个孩子结点
	struct Node* pNextBrother; // 指向其下一个兄弟结点
	DataType data; // 结点中的数据域
};

孩子兄弟表示法的优点:

  1. 统一结构 - 无论节点有多少孩子,每个节点结构相同(数据域 + 两个指针域),实现简单

  2. 节省空间 - 相比为每个节点维护一个子节点列表,这种固定两指针的设计更节省内存

  3. 树↔二叉树转换 - 能够将任意普通树转换为二叉树,从而复用二叉树的算法和处理方法

  4. 遍历简单 - 通过firstChild纵向遍历,通过pNextBrother横向遍历,逻辑清晰

🌴 第二章:二叉树——每个结点都是“人生赢家”(最多俩娃)

(高中数学等比数列,错位相减法忘了的,稍稍复习一下,下面要用)

1. 二叉树的概念

二叉树是满足以下两个条件的结点的有限集合

  • 或者为空(即没有结点)
  • 或者由一个根结点加上两棵别称为左子树和右子树的、互不相交的二叉树组成

简单来说,二叉树的特点是:

  • 每个结点最多有两个孩子,即结点的度不能超过2。
  • 它的子树有明确的左右之分,顺序不能颠倒。因此,二叉树是一棵有序树。

2. 满二叉树的概念

  • 定义:一个二叉树,如果每一层的结点数都达到最大值,那么这个二叉树就是满二叉树。

  • 特征:如果一棵满二叉树的层数为 k,则其结点总数为 2^{_{k}}-1。(每一层的结点数分别是,2^{0}2^{1}2^{2},......,2^{k-1},等比数列)

  • 外观:看起来是一个完美的三角形,没有任何缺失的结点。

3. 完全二叉树的概念

  • 定义:对于一棵深度为 k、有 n 个结点的二叉树,当且仅当其每一个结点都与深度为 k 的满二叉树中编号从 1 到 n 的结点一一对应时,称之为完全二叉树。

  • 特征

    • 叶子结点只能出现在最下两层

    • 最下层的叶子结点一定集中在左侧连续位置。

    • 倒数第二层若有叶子结点,则一定集中在右侧连续位置。

注意满二叉树是一种特殊的完全二叉树


(树能长成这样也是挺牛逼的)


4. 二叉树的性质

性质1:在二叉树的第 i 层上至多2^{(i-1)} 个结点 (i≥1)。

一说最多就是满二叉树,上面说了,一个满二叉树各层的结点数成等比数列,公比为2。

性质2:深度为 h 的二叉树至多2^{h}-1 个结点 (h≥1)。

性质3:对任何一棵二叉树,如果其叶子结点数为 n0​度为2的结点数为 n2​,则必有:n0​=n2​+1(即叶子结点数总比度为2的结点数多一个)

有兴趣可以自己想想为什么,可以不知道原因,但必须知道结论!下面给一个例题:

解析:设叶子结点个数为a,度是1的结点个数为b,度是2的结点个数为c,则总数N=a+b+c。因为a=b+1,一个完全二叉树度是1的结点个数只能是1或0,则有2n=a+a-1+1/0,由于2n是偶数,所以n=a,A。

性质4:具有 n 个结点的完全二叉树的深度 h 为 ⌊log2​n⌋+1 。

一个深度为 h 完全二叉树的节点数量 N 范围是   2^{h-1}\leq N\leq 2_{h}-1,反推便是。

性质5:对于具有n个结点的完全二叉树,如果按照从上至下、从左至右的数组顺序对所有结点从 0 开始编号,则对于序号为i的结点有:

  • 若i>0,i位置结点的双亲序号:(i-1)/2;i=0,i为根结点编号,无双亲结点
  • 若2i+1<n,左孩子序号是2i+1;若2i+1>=n,无左孩子
  • 若2i+2<n,右孩子序号是2i+2;若2i+2>=n,无右孩子

这就引出了我们的顺序结构

🏕️ 第三章:顺序结构——用数组给二叉树“安家”(堆的实现)

        “从上至下、从左至右的数组顺序对所有结点从 0 开始编号”,对,数组存储!二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。但普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费(有很多空)。而完全二叉树更适合使用顺序结构存储,因为它既不浪费,又能很轻易的看出父子关系。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。

1. 堆的概念

堆的性质:

  • 堆中某个结点的值总是不大于或不小于其父结点的值;
  • 堆总是一棵完全二叉树。

所以我们能看出来,树的根一定是这个堆的最值,这是堆最实用的特点!但他无法保证整个数组是有序的!

2. 堆的库函数

STL在 #include<algorithm> 中也提供了有关heap的函数。

其实有了priority_queue,这组函数就没啥必要了,但我们还是简单看一下

(1)make_heap / is_heap / push_heap / pop_heap

支持传随机迭代器,在容器内部调整,也支持传比较器,默认是大根堆。

根据父与子的下标关系进行比较,判断是否为堆,返回bool值。

push_heap:用法和上面两个一样,先使用 push_back()(或类似操作)在容器尾部添加新元素,然后调用 push_heap() 调整堆结构

pop_heap: 用法和上面两个一样,先使用pop_heap() 调整堆结构 (把堆顶元素移动到 last-1 位置,然后调整数组),然后pop_back()(或类似操作)在容器尾部删除元素

(2)sort_heap

也是一种排序方法,支持传随机迭代器,在容器内部调整,也支持传比较器,默认是升序。

int main()
{
	int myints[] = { 10,20,30,5,15 };
	std::vector<int> v(myints, myints + 5);

	std::make_heap(v.begin(), v.end());
	std::cout << "initial max heap   : " << v.front() << '\n';

	std::pop_heap(v.begin(), v.end()); v.pop_back();
	std::cout << "max heap after pop : " << v.front() << '\n';

	v.push_back(99); std::push_heap(v.begin(), v.end());
	std::cout << "max heap after push: " << v.front() << '\n';

	std::sort_heap(v.begin(), v.end());

	std::cout << "final sorted range :";
	for (unsigned i = 0; i < v.size(); i++)
		std::cout << ' ' << v[i];

	std::cout << '\n';
}

/*
initial max heap   : 30
max heap after pop : 20
max heap after push: 99
final sorted range : 5 10 15 20 99
*/

3. 堆的模拟实现

我们就把他看成一个容器适配器,其实和优先队列一样,我们着重讲向上向下调整函数。

(1)向上调整(大根堆为例)

        向上调整用于在堆尾部插入新元素后,恢复堆的性质。我觉得还是比较好理解,我们只要设计一个循坏,只要把他和父结点比,要是大,就交换,要是小就调整好了。记住,因为没加元素之前本身就是一个堆,父亲一定比原有的孩子大,所以只用和父结点比,还有就是边界的检查问题。

    void Adjustup(int n)
    {
        while (n > 0 && arr[(n - 1) / 2] < arr[n])
        {
             std::swap(arr[(n - 1) / 2]], arr[n]);
             n = parents(n);
        }
    

(2)向下调整(大根堆为例)

         pop函数用于删除堆顶元素,我们当然可以从堆顶直接删除,但有一点你要知道,一个堆的兄弟直接可没有任何太小关系,你这一删,认你的兄弟当爸爸了,辈分全乱了,只能重新建堆,所以这样效率太慢了。删除了堆顶以后,它的左右两个子树也还是堆啊,我们为什么不能利用起来,我们把最后一个元素与第一个元素交换,把最后一个元素去掉,再调整第一个元素就好了。

        向下调整的方法是,第一个元素和它的较大孩子比,如果小就交换,否则就调整完毕,交换完再用这个元素和它的孩子比,一直到叶子结点。比的时候先假设左孩子大,如果右孩子更大,就把child++。

最坏情况每一层都调整,时间复杂度为O(logN)

        能用向下调整的前提是左右子树都是堆!

//写法一:
void AdjustDownMaxHeap(vector<int>& heap, int parent, int size) {
    int child = parent * 2 + 1; // 先假设左孩子小
    
    while (child < size) {
        if (child + 1 < size && heap[child + 1] > heap[child]) {
            child++;
        }
        
        if (heap[child] <= heap[parent]) {
            break;
        }
        
        swap(heap[child], heap[parent]);
        parent = child;
        child = parent * 2 + 1;
    }
}
//写法2:
int parents(int child)
{
    return (child - 1) / 2;
}

int leftchild(int parent)
{
    return 2 * parent + 1;
}

int rightchild(int parent)
{
    return 2 * parent + 2;
}

void Adjustdown(int n)
{
    while (n < size())
    {
        int cur = n;
        if (leftchild(n) < size() && arr[cur] < arr[leftchild(n)])  cur = leftchild(n);
        if (rightchild(n) < size() && arr[cur] < arr[rightchild(n)])  cur = rightchild(n); 
        if (cur != n)
        {
            std::swap(arr[cur], arr[n]);
            n = cur;
        }
        else break;
    }
}

(3)完整代码

建堆时就一个一个push就行

#include<iostream>
#include<vector>
#include<assert.h>
using namespace std;

//枚举的用处
enum class HeapType 
{
    MIN_HEAP,
    MAX_HEAP
};
//这里使用枚举是为了不用仿函数,只提供一个模板参数,不和优先队列一样

template<class T>
class heap
{
public:
    heap(HeapType heapType = HeapType::MAX_HEAP) : _type(heapType) {}

    heap(const std::vector<T>& arr, HeapType heapType = HeapType::MAX_HEAP)
        : _type(heapType) 
    {
        for (const auto& item : arr)
            push(item);
    }
    void push(T x)
    {
        arr.push_back(x);
        Adjustup(arr.size() - 1);
    }

    void pop()
    {
        if (empty()) return;
        std::swap(arr[0], arr[arr.size() - 1]);
        arr.pop_back();
        Adjustdown(0);
    }

    T top()
    {
        assert(!empty());
        return arr.front();
    }
    size_t size() const
    {
        return arr.size();
    }

    bool empty() const
    {
        return arr.empty();
    }

    bool comp(T parents, T child)
    {
        if (_type == HeapType::MAX_HEAP)
            return parents > child;
        else
            return parents < child;
    }

    int parents(int child)
    {
        return (child - 1) / 2;
    }

    int leftchild(int parent)
    {
        return 2 * parent + 1;
    }

    int rightchild(int parent)
    {
        return 2 * parent + 2;
    }

    void Adjustup(int n)
    {
        while (n > 0 && !comp(arr[parents(n)], arr[n]))
        {
             std::swap(arr[parents(n)], arr[n]);
             n = parents(n);
        }
    }

    void Adjustdown(int n)
    {
        while (n < size())
        {
            int cur = n;
            if (leftchild(n) < size() && !comp(arr[cur], arr[leftchild(n)]))  cur = leftchild(n);
            if (rightchild(n) < size() && !comp(arr[cur], arr[rightchild(n)]))  cur = rightchild(n);
            if (cur != n)
            {
                std::swap(arr[cur], arr[n]);
                n = cur;
            }
            else break;
        }
    }

private:
    vector<T> arr;
    HeapType _type;
};


4. 堆排序(大堆为例)

(这一块儿我用C语言讲)

(1)功能实现

        虽然堆每次只能挑出最值,但如果我们挑出最值把它pop掉,那剩下的会自动再调整为堆,我们又能得到第二大的,以此类推,不就有顺序了吗!其实这也是堆排序的本质,建堆,调整,就是这样。

        现给出一个无序数组,怎么使它变为堆呢?上面我们让元素一个一个push进去,现在我们就从 arr[0] 开始,循环的向上调整不就行了。建好堆后,从第一个元素开始,先与最后一个交换,在向下调整,和pop的思路一样。

//n是数组大小
void HeapSort(int* arr, int n)
{
	for (int i = 1; i < n; i++)
	{
		AdjustUp(arr, i);
	}

	int end = n - 1;
	while (end > 0)
	{
		Swap(&arr[0], &arr[end]);
		AdjustDown(arr, end, 0);
		--end;
	}
}

升序与降序分别建什么堆?

  • 升序排序 → 建大根堆

原因:大根堆的堆顶是当前最大值,每次将堆顶与末尾元素交换,最大值就沉到数组末尾。然后对剩余元素重新调整为大根堆,重复此过程,从后往前得到升序序列。

  • 降序排序 → 建小根堆

原因:小根堆的堆顶是当前最小值,每次将堆顶与末尾元素交换,最小值就沉到数组末尾。然后对剩余元素重新调整为小根堆,重复此过程,从后往前得到降序序列。

***建堆时还有一种思路:从末尾开始,往前依次向下调整***

       因为左右子树都是堆,就能用向下调整,最后两层的叶子结点,本身就可以看作是堆,所以从第二层不是叶子结点的往前调整就行。代码如下:

void HeapSort(int* a, int n)
{

	for (int i = (n-1-1)/2; i >= 0; i--)
	{
		AdjustDown(a, n, i);
	}

	int end = n - 1;
	while (end > 0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		--end;
	}
}

为什么完全二叉树的叶子结点数至少占总结点数的一半

        设叶子结点个数为a,度是1的结点个数为b,度是2的结点个数为c,则总数N=a+b+c。因为a=b+1,一个完全二叉树度是1的结点个数只能是1或0,则有N=a+a-1+1/0,即N/2=a或N/2 + 0.5 = a 。

这是排升序的过程图

(2)时间复杂度分析

这两种建堆方法哪个更好?

1> 向下调整建堆:

向下调整建堆的时间复杂度为O(N)。

2> 向上调整建堆:

建堆方式时间复杂度核心原因
向上调整建堆O(nlogn)节点数多的底层需要移动的次数也多
向下调整建堆O(n)节点数多的底层需要移动的次数很少
整个堆排序O(nlogn)建堆后一个个调整的总次数为nlogn

下面是用C实现的最优完整代码:

//降序
void Swap(int* p1, int* p2)
{
	int tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

void AdjustUp(int* a, int child)
{
	int parent = (child - 1) / 2;
	while (a[child] < a[parent])
	{
		Swap(&a[child], &a[parent]);
		child = parent;
		parent = (child - 1) / 2;
	}
}

void AdjustDown(int* a, int n, int parent)
{
	int child = parent * 2 + 1;
	while (child < n)  
	{
		if (child + 1 < n && a[child + 1] < a[child])
		{
			++child;
		}
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

void HeapSort(int* arr, int n)
{
	for (int i = (n-1-1)/2; i >= 0; i--)
	{
		AdjustDown(arr, n, i);
	}
	int end = n - 1;
	while (end > 0)
	{
		Swap(&arr[0], &arr[end]);
		AdjustDown(arr, end, 0);
		--end;
	}
}

void test_heapsort()
{
	int arr[] = { 4,2,8,1,5,6,9,7,2,7,9 };
	HeapSort(arr, 11);
	for (int i = 0; i < 11; i++)
	{
		printf("%d ", arr[i]);
	}
	printf("\n");
}

(3)TOP-K问题

1> 问题定义

从海量数据(通常数据量 N 非常大)中找出前 K 个最大(或最小)的元素。(K远小于N)

典型应用场景有热搜榜前10;全市成绩排名前100;世界500强企业;游戏前50的活跃玩家

2> 解决方法:

        这不就排序吗?用上面的堆排序不就行了,就算N是1亿,也就1秒钟就完成了。时间是快了,那空间呢?去哪开1亿的数组?

        以前 K 个最大为例我们想,它只让找前10个,而一个堆的特点就是取最值,那我开一个大小为K的小根堆,每次只进一个数据,让它和堆顶数据比较,大就替换掉,然后调整堆,小就不用管,因为它连这十个中最小的都比不过,那肯定不是前十。

找最大的前 K 个元素

  1. 建一个大小为 K的小根堆(不是堆排序!)

  2. 用前 K 个元素建堆

  3. 遍历剩余 N-K 个元素:如果当前元素 大于 堆顶,替换堆顶并调整;否则跳过

  4. 最后堆中的 K 个元素就是最大的前 K 个

为什么用小根堆?

  • 堆顶是堆中最小的元素

  • 只要比堆顶大,就说明有资格进入前K

  • 始终保持堆中是最有可能的K个候选者

找最小的前 K 个元素

  1. 建一个大根堆(大小为 K)

  2. 用前 K 个元素建堆

  3. 遍历剩余 N-K 个元素:

    • 如果当前元素 小于 堆顶,替换堆顶并调整

    • 否则跳过

  4. 最后堆中的 K 个元素就是最小的前 K 个

为什么用大根堆?

  • 堆顶是堆中最大的元素

  • 只要比堆顶小,就说明有资格进入前K小的集合

最终时间复杂度:O(NlogK)

3> 完整代码:

// 找最大的前K个元素
void TopKMax(int* heap, int k, int newVal) 
{
	if (heap[0] < newVal)
	{
		heap[0] = newVal;
		AdjustDown(heap, k, 0);
	}
}

// 找最小的前K个元素
void TopKMin(int* heap, int k, int newVal) 
{
	if (newVal < heap[0]) 
	{
		heap[0] = newVal;
		AdjustDown(heap, k, 0);
	}
}

void test_top_kmax() {
	int arr[10] = { 789456, 58, 65, 98, 222, 55, 48, 65, 32, 8 };
	int k = 3;
	int a[] = { 789456, 58, 65 };

    //是建堆,不是堆排序!
	for (int i = (k - 1 - 1) / 2; i >= 0; i--) {
		AdjustDown(a, k, i);
	}
//log(k)

	for (int i = 3; i < 10; i++) {
		TopKMax(a, k, arr[i]);
	}
//(n-k)log(k)

	printf("最大的前%d个: ", k);
	for (int i = 0; i < k; i++) {
		printf("%d ", a[i]);
	}
}

说明:

        数据一定不止10个,我只是方便演示,庞大的数据只能从文件中读取,而且,用C的同学,AdjustDown这个函数中的比较关系是随堆的类型变的,千万不要直接copy哦!

🌿 第四章:链式结构——让二叉树自由生长(递归算法)

我说白了,我白说了,下面的东西几乎都是递归,你要理解递归就非常简单了,不理解就会很痛苦😂😂(点击题目就可以跳转到对应oj题,大家可以用来自测)

1. 链式结构说明

        我们这里只看二叉树,每一棵二叉树都是由根,左子树,右子树组成的,所以我们每个节点就这样存储,当左右子树为空时,就是叶子结点,这也是我们下面递归结束的依据。

typedef int BTDataType;

typedef struct BinaryTreeNode

{
	BTDataType _data;
	struct BinaryTreeNode* _left;
	struct BinaryTreeNode* _right;
}BTNode;

2. 二叉树的遍历

什么是二叉树的遍历?

        学习二叉树结构,最简单的方式就是遍历。所谓二叉树遍历(Traversal)是按照某种特定的规则,依次对二叉树中的结点进行相应的操作,并且每个结点只操作一次。访问结点所做的操作依赖于具体的应用问题。 遍历是二叉树上最重要的运算之一,也是二叉树上进行其它运算的基础。

        按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历:

  • 前/先序遍历(Preorder Traversal)——访问根结点的操作发生在遍历其左右子树之前。
  • 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。
  • 后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。

        由于被访问的结点必是某子树的根,所以N(Node)、L(Left subtree)和R(Right subtree)又可解释为 根、根的左子树和根的右子树。NLR、LNR和LRN分别又称为先根遍历、中根遍历和后根遍历。

图中的树

前序:ABDNNNCENNFNN; 中序:NDNBNANENCNFN; 后序:NNDNBNNENNFCA

(1)前序遍历

        先访问根结点,说的不仅是最开始的根结点,每一个节点都是,既然都是先访问根,方法就是递归。

        递归其实就两个考虑的问题:大问题怎么能拆解成相似的小问题;递归的结束条件是什么。

        我们想整棵树前序遍历,可以拆成遍历根,遍历左树,遍历右树;遍历左树又可以拆成先遍历左子树的根,再遍历左子树的左子树,再遍历左子树的右子树,......,遍历完整个左子树,开始遍历右子树,又能拆成,先遍历右子树的根,再遍历右子树的左子树,再遍历右子树的右子树,......,都是一样的逻辑。那什么时候停止呢,如果根是空结点了,说明这一层就走完了。

代码如下:

// 二叉树前序遍历 
void PreOrder(BTNode* root)
{
	if (root == NULL)  //碰上空结点返回
	{
		printf("N ");  
		return;
	}

	printf("%d ", root->data);  //先遍历该结点的根
	PreOrder(root->left);       //再前序遍历该节点的左子树
	PreOrder(root->right);      //再前序遍历该节点的右子树
}

这是对递归的详细说明图:

内存中递归是怎样进行的?

  • 每递归一次,会出现新的栈帧,新栈帧向低地址增长。

  • 每一次的递归结束,会销毁当前栈帧,包括里边的临时变量,所以每次递归的同名变量互不影响。

  • 销毁后再次建立,用的是与上回递归相同的地址,但编译不一样的内容,说明栈帧销毁后地址可复用

  • 递归就是一个指令的多次执行,但它会不断向下压栈,所以递归太深会导致栈溢出

(2)中序遍历

拆分问题:整个树中序遍历=先中序遍历左子树,再根,最后中序遍历右子树。

结束条件:根为空

void InOrder(BTNode* root)
{
	if (root == NULL)
	{
		printf("N ");
		return;
	}

	InOrder(root->left);
	printf("%d ", root->data);
	InOrder(root->right);
}v

(3)后序遍历

拆分问题:整个树后序遍历=先后序遍历左子树,再后序遍历右子树,最后根。

结束条件:根为空

void PostOrder(BTNode* root)
{
	if (root == NULL)
	{
		printf("N ");
		return;
	}

	PostOrder(root->left);
	PostOrder(root->right);
	printf("%d ", root->data);
}

(4)层序遍历

什么是层序遍历?

        设二叉树的根结点所在层数为1,层序遍历就是从所在二叉树的根结点出发,首先访问第一层的树根结点,然后从左到右访问第2层上的结点,接着是第三层的结点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历。

        这里要用到队列,根节点先入队,然后出队,但在出队前,要把自己的孩子带进来(先左后右),然后再让左孩子出队,在出队前再把它的孩子带进来,(空结点就直接出队,因为它没孩子可带)......,直到队列为空。这也是算法BFS的基础模板,后面会学。代码如下:

//二叉树的层序遍历
/*没学过C++的同学,q就是一个队列,push等操作和C里面我们自己实现的一样,把意思看懂就行*/
void LevelOrder(BTNode* root)
{
	queue<BTNode*> q;
	if (root)
		q.push(root);

	while (!q.empty())
	{
		printf("%d ", q.front()->data);
		if (q.front()->left)
			q.push(q.front()->left);
		if (q.front()->right)
			q.push(q.front()->right);
		
		q.pop();
	}
}

3. 二叉树的结点及高度问题

(1)二叉树结点总个数

拆分问题:结点总个数=左子树结点个数+右子树结点个数+1

结束条件:根为空,结点个数为0。

// 二叉树结点个数
int BinaryTreeSize(BTNode* root)
{
	if (root == NULL)
	{
		return 0;
	}

	int a = BinaryTreeSize(root->left);
	int b = BinaryTreeSize(root->right);

	return a + b + 1;
}

(2)二叉树叶子结点个数

拆分问题:叶子结点个数=左子树叶子结点个数+右子树叶子结点个数

结束条件:根为空,该空树叶子结点个数是0;根的左右子树都是空,该树叶子结点个数是1(它本身)

int BinaryTreeLeafSize(BTNode* root)
{
	if (root == NULL)  return 0;
	if (root->left == NULL && root->right == NULL)  return 1;

	int a = BinaryTreeLeafSize(root->left);
	int b = BinaryTreeLeafSize(root->right);

	return a + b;
}

(3)二叉树的高度

拆分问题:二叉树的高度=max(左子树的高度,右子树的高度)+1

结束条件:根为空,该空树的高度是0;根的左右子树都是空,该树的高度是1。

int BinaryTreeHigh(BTNode* root)
{
	if (root == NULL)  return 0;
	if (root->left == NULL && root->right == NULL)  return 1;

	int a = BinaryTreeHigh(root->left);
	int b = BinaryTreeHigh(root->right);

	return (a > b ? a : b) + 1;

}

注意点:

当然能不新开变量a,b,返回时写

return TreeHeight(root->left) > TreeHeight(root->right) ? 
       TreeHeight(root->left) + 1 : TreeHeight(root->right) + 1;

但这样他们比较完会再去重新递归求值,本来是递归得出来的值会被重复递归计算,效率大大降低,所以我们需要开新变量去记录,减少消耗。

(4)二叉树第k层结点个数

拆分问题:二叉树第k层结点个数=左子树第k-1层的结点个数+右子树第k-1层的结点个数

结束条件:遍历到根为空时,说明该树就没有第k层,返回0;当k=1时,说明当前层就是我们要找的层数,返回1(它本身)。

int BinaryTreeLevelKSize(BTNode* root, int k)
{
	if (root == NULL)  return 0;
	if (k == 1)  return 1;

	int a = BinaryTreeLevelKSize(root->left, k - 1);
	int b = BinaryTreeLevelKSize(root->right, k - 1);

	return a + b;
}

(5)二叉树查找值为x的结点

拆分问题:二叉树查找值为x的结点=检查根+查找左子树+查找右子树

结束条件:遍历到根为空时,说明该树没有找到,返回false;当根找当时返回true,没找到去左子树找;左子树找到了返回true,没找到去右子树找;右子树找到了返回true,没找到返回false。

// 二叉树查找值为x的结点
bool BinaryTreeFind(BTNode* root, BTDataType x)
{
	if (root == NULL)  return false;
	if (root->data == x)  return true;

	bool a = BinaryTreeFind(root->left, x);
	if (a) return true;
	bool b = BinaryTreeFind(root->right, x);
	return b;
}

有没有疑惑和前面不一样,怎么递归完一个就返回,那是因为找到了再继续找没有意义,如果不考虑效率,那这种更好理解:

bool BinaryTreeFind(BTNode* root, BTDataType x)
{
	if (root == NULL)  return false;

	bool a = (root->data == x);
	bool b = BinaryTreeFind(root->left, x);
	bool c = BinaryTreeFind(root->right, x);
	return a || b || c;  //有一个找到就行
}

4. 二叉树的创建和销毁

(1)通过前序遍历的数组构建二叉树

(这个大家自己尝试一下)

(2)二叉树销毁

拆分问题:二叉树销毁=销毁左子树+销毁右子树+根置空并释放。

结束条件:根为空,说明该树销毁完成,直接返回。

// 二叉树销毁
void BinaryTreeDestory(BTNode* root)
{
	if (root == NULL)
		return;

	BinaryTreeDestory(root->left);
	BinaryTreeDestory(root->right);

	root == NULL;
	free(root);
}

(3)判断二叉树是否是完全二叉树

        这个不用递归,用的是层序遍历。我们想,完全二叉树层序遍历时,空结点只可能出现在最后,而且一定是连续的,否则他就不是完全二叉树。所以当我们出队列的时候遇到空结点,我们就要检查这个队列里是否都是空结点,如果是,那就是完全二叉树;反之。

// 判断二叉树是否是完全二叉树
bool BinaryTreeComplete(BTNode* root)
{
	queue<BTNode*> q;
	if (root)
		q.push(root);

	while (q.front())
	{
		q.push(q.front()->left);
		q.push(q.front()->right);
		q.pop();
	}

	while (!q.empty()) 
	{
		if (q.front()) return false;
		q.pop();
	}
	return true;
}

注意点:

  • 这个问题核心是你要想明白,如果是完全二叉树,那么当队头是空结点时,完全二叉树的所有非空结点一定都已经出了队列,留下的只有叶子结点带进去的空结点。
  • 只有空结点的队列可不是空队列哦!

5. 二叉树的其他问题

(1)单值二叉树

        单值二叉树就是每个结点的内容都相同

拆分问题:整棵树是单值=左子树是单值&&右子树是单值。

结束条件:根为空,说明该树是单值,返回true;根的值不等于左孩子或右孩子,说明该树一定不是单值,返回false。

bool isUnivalTree(BTNode* root) 
{
	if (root == NULL)  return true;

	if (root->left != NULL && root->left->data != root->data)
		return false; 
	if (root->right != NULL && root->right->data != root->data)
		return false;

	return isUnivalTree(root->left) && isUnivalTree(root->right);
}

(2)检查两颗树是否相同

拆分问题:两颗树相同=根相同&&左子树相同&&右子树相同

结束条件:根为空,说明该树是单值,返回true;根的值不等于左孩子或右孩子,说明该树一定不是单值,返回false。

bool isSameTree(BTNode* p, BTNode* q) 
{
	if (p == NULL && q == NULL)  return true;
	if (p == NULL || q == NULL)  return false;

	bool a = (p->data == q->data);
	if (a == false) return false;
	bool b = isSameTree(p->left, q->left);
	if (b == false) return false;
	bool c = isSameTree(p->right, q->right);
	return c;
}

(3)对称二叉树

这样的是对称的。

就相当于找这颗二叉树的两个子树是否对称相等,那把上面的改成p的左子树==q的右子树不就行了。

//对称二叉树
bool isLRSameTree(BTNode* p, BTNode* q)
{
	if (p == NULL && q == NULL)  return true;
	if (p == NULL || q == NULL)  return false;

	bool a = (p->data == q->data);
	if (a == false) return false;
	bool b = isSameTree(p->left, q->right);
    if (b == false) return false;
	bool c = isSameTree(p->right, q->left);
	return c;

}
bool isSymmetric(BTNode* root)
{
	if (root == NULL)  return true;
	return isLRSameTree(root->left, root->right);
}

(4)寻找子树

拆分问题:寻找子树=和根相同 || 和左子树相同 || 和右子树相同

结束条件:相同就返回true;要不就没找到

bool isSubtree(BTNode* root, BTNode* subRoot) 
{
	if (root && isSameTree(root, subRoot))  return true;
	if (root->left && isSubtree(root->left, subRoot)) return true;
	if (root->right && isSubtree(root->right, subRoot)) return true;

	return false;
}

到这里,你看完如果能把上面的题再自己独立写出来,那你的递归已经掌握90%了!

五、选择小练习

有一些题目会让你根据遍历画出树,方法就是先序或后序确定根,中序确定左右子树

//A A D A


后记:递归是比较难的,大家慢慢来,这是树这一块的初阶内容,考试面试是会常考的,但在现实生活中并不常用,常用的部分等我们学完C++的继承与多态,就会学到了,如果看完本篇博客对你有帮助,记得点个小红心哦

评论 10
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值