6.3树和二叉树
写在前面的一些杂谈
reference to 'next' is ambiguous//出现该错误的原因一般是因为代码中出现了关键字,使得编译器无法正常编译
对于一个完全二叉树来说,其第k个节点下的左右节点分别是2k和2k+1,同理可以不严谨的推断出对于一个完全n叉树来说,其第k个节点下的子节点的编号为nk-n+2,nk-n+3,……,nk-n+n+1(注意这边节点的编号是按照从左到右,从上到下实现的)
6.3.2二叉树的层次遍历
树仍然可以看作是一种节点的特殊组合
首席按我们需要采用动态结构,根据需要建立新的节点,否则建立一棵完全二叉树,其所耗费的内存是难以想象的,冗余内存过多
strchr函数所实现的是在s1字符串中查找char字符第一次出现的地址
同时利用C语言中对于字符串的处理,将任意指向字符的指针看成是字符串(从该位置开始,一直读到'\0'开始)
一般情况下,我们会选择通过结构体来创建节点
该节点最基本的功能休要实现存值,子节点数组
关于C++中新增的delete和new的一些说法
注意在C++中申请动态内存会更加方便,可以直接使用new函数就可以了,而不用再繁琐的通过C中的malloc函数来申请动态内存
new/delete是C++中的关键字,当然C++中也支持malloc/free但是效率是不及new/delete,使用new申请内存分配无需指定内存块的大小,编译器会根据类型信息自行计算,总的来说new在安全性能上高于malloc
同时new使用时会先调用operator new函数,申请足够的内存,然后调用类型的构造函数,初始化成员变量,delete则是先调用析构函数,然后调用operator delete函数释放内存这二者的底层通常通过malloc和free来实现
析构函数是于构造函数相反,其在对象结束生命周期的时候调用,清理善后的工作。
可以用new运算符申请空间并执行构造函数,如果返回值为NULL,说明空间不足,申请失败
宽度优先遍历(Breadth-First Search, BFS),可以使用队列实现二叉树的层次遍历。层次遍历又叫做宽度优先遍历,注意与后面的广度优先遍历做区别。也就是通过队列的先进先出来实现对于树的同层次的优先遍历。
注意如果使用new的时候,如果没有释放内存的时候,丢失了指向该块内存的所有内存,此时就会出现内存泄漏,从技术上来说,还是可以访问到那些内存的,如果可以猜到那些地址,之所以访问不到,是因为丢失了指向这些内存的指针。通常这种情况我们称作内存泄漏。
如果程序动态申请内存,请注意内存泄漏。程序执行完毕后,操作系统会回收该程序申请的所有内存(包括泄露的),所以在算法竞赛中内存泄漏往往不会造成什么影响。但是,从专业素养的角度考虑,请从现在开始养成好习惯,对内存泄露保持警惕。
注意虽然delete可以解决内存泄露的情况,但是同时可能会带来内存碎片的新问题,因为此时虽然看似动态分配的内存总体上来说是碎片式分布,但是其申请的内存应该是连续的,所以会造成内存碎片分布,另一种对内存的浪费情况的出现。
接下来将介绍对于树来说更加深层次的理解。
二叉树不一定要用指针实现。接下来看看下面的代码
const int root = 1;
void newtree() {
left[root] = right[root] = 0; have_value[root] = false; cnt = root;
}
int newnode() {
int u = ++ cnt; left[u] = right[u] = 0; have_value[root] = false; return u;
}
这边没有了动态内存的申请和释放,只需要重置结点计数器和根节点的左右子树即可,这边的思想与链表的静态数组实现的思想类似
可以用数组来实现二叉树,方法使用整数表示节点编号,left[u]和right[u]来分别表示u的左右子节点的编号
用数组的方式实现二叉树,编程简单,调试较为方便,但是具体问题仍需要具体分析,例如用指针直接访问比数组+下标的方式略快,或许数组+下标需要先进行偏移运算,因此有的选手喜欢用结构体+指针的方式处理动态数据结构,但在申请结点的时候仍然用到这里的动态化静态的思想,把newnode函数写成
Node* newnode() {
Node* u = &node[++cnt]; u->left = u->right = NULL; u->have_value = false; return u;
}
上述的代码是对于数组与指针的结合,以来避免了数组+下标的调用形式,直接通过指针访问,二来申请新指针的时候,其申请的并非是动态指针,而是静态数组模拟而成的指针。
node是静态申请的结构体数组。这样写的坏处在于释放内存,笔者认为这边的示释放内存有以下几大坏处:首先不能通过free函数直接删除,而是需要自己手动一个个二去清零,同时极易产生内存碎片,如果该树存在大量的删除和增加节点的操作,那么cnt并不能回去,就只能一直增加,也就是说用过的结点将布恩那个再次被使用,这与动态数组中的free相违背,虽然在大多数算法竞赛中并没有太大问题,但是如果内存紧张的情况下,对内存的一点浪费就会出现内存溢出的错误。常用的解决方案就是写一个内存池(memory pool),具体来说就是维护一个空闲列表(free list),初始时把上述node数组中所有元素的指针放到该列表中,如下所示:
queue<Node*> freenodes;
Node node[maxn]
void init() {
for(int i = 0; i < maxn; i++)
freenodes.push(&node[i]);
}
Node* newnode() {
Node* u = freenodes.front();
u->left = u ->right = NULL;
u->have_value = false;
freenodes.pop();
return u;
}
void deletenode(Node* u) {
freenodes.push(u);
}
通过上述我们可以更加清晰的发现原先的动态化静态数组处理的缺陷,它没法精确的找到之前释放过的数组,虽然逻辑上原先的内存已经被释放,但是在实践层面上难以找到,也就是类似内存碎片的大量出现,而通过内存池中(空闲列表)的维护,我们可以清楚的找到其中空闲,也就是被释放的内存,同时利用起来,这是对于内存使用的一种优化。
网上对于内存池的解释:内存池则是在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块,若内存块不够再继续申请新的内存。这样做的一个显著优点是,使得内存分配效率得到提升。
可以用静态数组配合空闲列表来实现一个简单的内存池。虽然在大多数算法竞赛题目中用不上,但是内存池技术在高水平竞赛以及工程实践中都极为重要。
总的来说,前面所说的动态化静态的思想尤为重要,不过很明显,前面的方法难以使用于所申请的结点数未知的情况,因此前面动态建树的方法也是不能忘的,同时数组+下标的访问方法是略慢于指针直接访问的,这也是往后对于程序实现效率上所需要注意的重点。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)