你好呀!
这里记录我的学习经历和内容
希望能够帮到你们
: )
标签对应使用的编程语言
分类中查看对应的板块
标签对应使用的编程语言
分类中查看对应的板块
找到一个比较好的项目Byterun,这里进行一下复现。
Byterun 是一个基于Python实现的Python字节码解释器,简化了Cpython的实现,展示了Python解释器在底层上的实现原理,以及相关的组织过程。
解释器本质上就是一个虚拟机,他会对你产生的字节码信息进行解释,然后基于栈运行,实现运算、分支条件、循环结构等功能。
Python解释器实际上就是一个字节码解释器,它接受字节码,然后输出运行结果。当你写下一段Python代码时,会被词法分析器解析为token流,然后通过语法分析和编译器,作为字节码(code object)进行输出。然后字节码再由Python解释器进行运行。这里字节码的作用有点像汇编语言在C语言编译过程。
我们最简单的解释器开始,它只能识别以下三个指令,我们基于一点一点拓展实现我们的整个项目:
我们对7+5生成以下指令集:
1 | byte2exec = { |
之所以通过这种单指令和零指令的方式进行计算,是因为我们的Python解释器实际上是一个基于栈的虚拟机。
我们将指令和数据分开存放,这样保证了指令是定长的。因此,我们也需要向指令指出数据存放的位置,所以一个完整的指令集有两部分,指令和参数位置。对于不需要参数的零指令,我们用None表示。
现在我们可以基于栈来实现一个简单的解释器结构:
1 | class Interpreter: |
现在我们已经实现了一个虚拟机的基本结构和支持的指令,我们需要一个方法来驱动我们的虚拟机进行对指令的解析与执行:
1 | def run(self, code): |
现在我们可以尝试使用它们进行简单的计算3+4+5:
1 | code = { |
可以看到虚拟机按照预期输出了我们想要的结果。
现在我们向虚拟机添加存储功能,我们可以将数值存入变量中,实现变量和值的映射关系,我们将实现以下两个指令:
为了实现这个功能,我们需要再初始化一个内存空间(这里使用字典比较直观),用于存放变量名和值的绑定关系,同时需要在我们的code中添加变量名。我们的实现如下:
1 | class Interpreter: |
这里我们可以试着运行一下x = 3; y = 7; print(x+y):
1 | code = { |
成功的运行了我们的函数。
随着我们支持的指令增多,我们需要不断的通过if-else结构来进行对指令的执行。在我们的实现中,类的方法名和字节码中的指令是相同的,所以我们通过getattr方法来对run()进行优化:
1 | def execute(self, code): |
现在我们可以尝试解析一下真实的Python字节码,我们可以以下面的这个函数为例:
1
2
3
4
5
6def cond():
x = 3
if(x < 5):
return 'yes'
else:
return 'no'
我们可以通过特殊方法__code__获取函数的字节码和元信息,具体的用法如下:
1 | 函数对象 (function) |
这里我们可以通过这个方式得到我们的字节码指令
1 | codebyte = cond.__code__.co_code |
我们得到了这个函数的字节码,但是我们无法阅读它。所以我们需要用到Python中的字节码反汇编器dis,将字节码进行翻译并以可读的形式输出:
1 | import dis |
其中第一列是该指令对应的字节码索引,第二列是该字节码的可读形式,第三列是指令的参数索引(第4列可能会指出使用的是什么参数)
同时我们也可以使用dis库中的opname方法,将对应的字节码翻译成可读的指令:
1 | codebyte = cond.__code__.co_code |
一个语言中条件分支和循环结构的能力也很重要,我们可以借这一段字节码深入的理解Python运行的实现。在我们的例子中if x>5被翻译成了:
1 | 6 LOAD_FAST 0 |
其中LOAD_FAST将局部变量加载到栈上,LOAD_CONST将常数5加载到栈上,然后通过COMPARE_OP指定的比较类型,对栈顶的两个数值进行比较,然后将比较结果放回栈上。最终POP_JUMP_IF_FALSE,根据比较的结果,跳转到指定的指令。跳转后要被加载的指令我们称之为跳转目标,作为POP_JUMP的参数,dis会用>>指出跳转目标。
有了条件判断和跳转之后,我们就可以实现最基本的循环结构,我们对下面这个循环结构进行分析:
1 | def cond(): |
要理解这个循环结构,我们需要从几个跳转指令入手。首先是第一个POP_JUMP,如果判断为真,就进入循环体结构,不为真就直接退出。第二个POP_JUMP则是判断是否退出循环结构。第三个比较特殊JUMP_BACKWARD,它将无条件跳转到循环体的起点。通过跳转和条件分支的功能,实现了循环结构。
其他的语法结构也是类似的,如if ... elif, for ,...,可以通过dis慢慢探索
实现了数值的计算和控制转移之后,现在我们需要进一步认识Python
函数调用的过程。正如上面的那个例子,我们看到RETURN_VALUES,那么结束这个函数之后会返回到哪里呢?
如果我们在函数中,我们会返回到调用者。如果是在模块顶层,我们可能会直接结束程序。我们将上一层的信息返回给下一层,例如在递归调用时,我们还需要保存每一层的局部状态。这就引出了Frame的概念,frame是函数调用的一次执行的上下文。它在python运行的过程中不断的被创建和销毁。
对于一个code object,我们可能会有多个frame。但是对于一个frame,我们有且仅对应一个code object。
frame存在于调用栈之中(和C一样)。Python中有三种类型的栈结构:
在调用栈的每个frame都有它自己的数据栈和块栈。以下面这个程序为例,它在调用栈中的状态大概就是这样的:
1 | >>> def bar(y): |
至此,我们对Python字节码的基本分析就结束了
上一章中完成了对图形渲染的饿BVH加速,现在我们要尝试将图片纹理映射到物体中。
图形学中的纹理映射是将材质效果应用于场景中的物理过程。其中”纹理”指的是效果(这个效果可以是材质属性,或是部分存在与否)本身,而映射则是将效果映射到物体的表面上。
最为常见的纹理映射就是将图像映射到物体表面上,就像是把世界地图依附到球体表面。和我们的直觉不同,纹理映射是一个逆向的过程,我们首先确定物体上的一个点,然后查找纹理贴图给定的颜色,以实现对图片的映射。
不过在此之前,我们先用程序化的方式生成纹理颜色,并创建一个纹理贴图。为了执行纹理查找,我们需要物体表面的纹理坐标(u,v)以定位纹理中的像素,同时也需要保存当前点的三维坐标(部分纹理需要这一部分的信息)
我们的纹理颜色类将从最简单的恒定色彩纹理开始实现,实际上我们可以将物体的颜色也视作一种纹理:
1 | #ifndef TEXTURE_H |
这里我们先实现对纹理类的接口的一个实现,然后创建一个恒定色彩纹理类,返回恒定的颜色类型。
注意到我们这里需要使用到(u,v)表面坐标,我们还需要更新hit_record结构,对这些射线碰撞信息进行存储:
1 | class hit_record { |
棋盘纹理作为实体纹理中的一种。实体纹理取决点在空间中的位置,我们可以理解为给空间中的指定点进行着色,而不是给空间中的某个物体上色。正因如此,当我们的物体在空间中移动时,纹理并不会随物体进行移动,反而像是物体在穿过纹理。
这里我们将实现一个棋盘纹理类,它是一种空间纹理(即实体纹理)。根据点在空间中给定的位置进行纹理颜色的渲染。
为了实现棋盘格图案,我们需要对当前点的每个坐标分量取floor,这样我们就将整个空间分割为1x1x1的单元格,每个坐标都可以映射到对应的单元格,我们对奇数的单元格赋予一种颜色,对偶数赋予另外的一种颜色,这样就实现了棋盘的样式。同时我们还可以设置一个缩放因子scale,控制单元格的大小,从而实现对棋盘格的大小控制:
1 | class checker_texture : public texture{ |
为了进一步的支持纹理,我们拓展lambertian类,使用纹理代替颜色:
1 | class lambertian : public material { |
这里通过多态的思想实现了对lambertian材质的纹理的实现。
现在我们可以向我们的场景中添加纹理了:
1 | // main.cpp中将地面设置成棋盘样式 |
渲染结果如下:
正如之前所提到的,这种实体纹理的上色方式,更像是物体在穿过纹理,从而完成上色。
我们现在创建一个新的场景来观察这种特殊的情况,我们将先前的main函数保存为一个bounding_ball场景,然后现在我们再来创建一个新的场景:
1 | void checkered_spheres() { |
我们通过main函数中的switch来切换我们想要渲染的场景:
1 | int main(){ |
我们渲染出来的结果如下:
这就是空间纹理渲染的特殊情况,不过你应该能理解这是什么情况。所以为了解决这个问题,我们需要使用表面纹理,这就意味着我们需要根据(u,v)的球体表面位置信息来创建纹理。
空间纹理通过空间中一点的坐标,实现纹理的绘制。但是真如我们先前所提到的空间纹理的局限性,我们希望能够更具球体表面的坐标实现对图像点对点的映射。这就以为着我们需要一种方法来查找三维球体表面任意点的坐标(u,v)。
这里用到一个经纬度的思想,首先确定出这个点在球体上的经纬度(θ, ϕ)(横纬竖经,这里θ从-Y向上,ϕ从-X到+Z到+X到-Z),然后再将球面坐标表示出来,这里的话,如果学过球面坐标,自然能够得到以下式子:
$$ 我们可以简单的推导得到:
$$
所以我们可以写出sphere类中的get_uv方法:
1 | static void get_uv(const point3& p, double& u, double& v){ |
然后我们向hit方法中添加,每次碰撞记录的(u,v):
1 | bool hit(const ray& r, interval t_range, hit_record& rec) const override { |
现在我们就实现了对球体表面的位置的(u,v)二维定位
现在我们需要一种手段,将图片数据解析为一种二维关系,我们希望通过(u,v)访问图像数据上对应的像素值。所以我们需要将图片加载为一个浮点数数组,便于我们访问。这里我们使用第三方库stb_image.h来实现
首先我们创建一个辅助类来帮助我们管理图片信息内容,以提供一个pixel_data(int x,int y)方法,来访问任意像素的8位(unsigned
char)RGB。
我们的实现如下:
1 | #ifndef IMAGE_H |
现在我们封装好了一个加载并获取图像内容的image类,我们可以利用它实现图像纹理image_texture:
1 |
|
现在我们可以尝试将一个图片进行渲染:
1 | void earth() { |
我们使用一张地球的贴图(效果如下):
至此 我们的纹理样式就简单介绍完了
书接上回,上一次实现了物体运动下的图形学渲染。实现了动态模糊的构建。
现在随着我们场景的复杂化,和对图片质量的提升,有时候渲染一张照片都需要十几分钟。为了让代码运行的更快,我们需要引入一个新的结构 包围体积层级BVH,以优化程序渲染的性能。
射线 - 对象求交,是光线追踪过程中的主要性能瓶颈,运行的时间和物体的数量呈线性关系。每当我们发射一条射线,就需要对场景内的所有物体进行求交判断。但是实际上和很多物体的求交是没有必要的,我们向天空发射的一条光线并不会和地上的物体有所交集。
所以我们为了优化这个过程,我们需要避免不必要的射线 - 对象求交计算,接下来我们要学习的方法就是通过包围体积层次来实现对求交场景的优化。
为一组对象创建一个完全包围所有对象的包围盒,假如射线会错过这个包围盒,那么它一定会错过这个包围盒中的所有物体,反之,则有可能击中盒中的任意一个对象,所以我们的思想如下:
1 | if(ray hits bounding object) |
为了提高计算的效率,使得处理时间的增长速度慢于物体数量的增长。首先我们需要构建有一个层次化的包围体结构,这里我们通过自顶向下的方法,生成我们的包围体积的层级结构。
一开始我们需要构建一个层次化的包围体结构,用大的包围体包住一群物体,再逐层细化,就像下面这样:
我们可以写出以下伪代码:
1 | if(hit purple) |
当我们确定击中紫色包围盒时,分别对里面的蓝色组和红色组进行分析,从而进一步缩小碰撞检测的范围
要让整个层次结构高效工作,我们需要要一个好的划分方式,而且我们需要一个较快的检测光线和包围体相交的算法,否则我们检测相交的速度会抵消掉包围结构层次带来的加速效果。这里我们选择轴对齐包围盒作为我们的包围体。我们通常将其称为AABB。
接下来我们需要了解光线和AABB相交检测的slab方法:
首先我们需要知道什么是片层(Slab),片层是在一个坐标轴方向上,由两个平行平面围成的空间区域,比如在
x 轴方向,若 AABB 的 x 范围是 [x_min, x_max],那么这个 slab 就是所有满足
x_min ≤ x ≤ x_max
的点。在我们要用到的3D场景中,AABB = x-slab && y-slab && z-slab。
理解片层之后,我们检测碰撞的关键就在于计算光线和不同片层之间的交点,其中射线的函数定于为P(t) = Q + td,我们可以求出其在x = x0t平面的交点为: $$ \begin{align*} x_0 &= Q_x + t_0d_x \\ t_0 &= \frac{x_0 - Q_x}{d_x} \\ t_1 &= \frac{x_1 - Q_x}{d_x} \end{align*} $$ 知道光线与各个片层之间的交点之后,我们可以进一步判断光线和AABB的相交情况了,以下图为例:
这是一个二维的AABB场景,上面的射线和x、y片层中的交集段并没有重叠,所以我们知道射线并没有和AABB发生交集,下面的射线和x、y片层的交集段发生了重叠,所以说射线和AABB之间是有交集的。
上图我们可以用于以下伪代码确定射线是否和AABB发生碰撞:
1 | interval_x <- compute_intersection_x(ray, x0, x1) |
相应的三维版本如下:
1 | interval_x <- compute_intersection_x(ray, x0, x1) |
我们已经知道了怎么求出射线和片层的相交区间,我们需要进一步的检测这些相交区间是否有交集,现在,我们的关键在于实现overlaps了,在此之前我们需要考虑以下几种特殊情况:
min和max来标识我们的区间-infinity/+infinity,我们通过min/max可以解决这个问题现在我们可以写出伪函数overlaps:
1 | bool overlaps(t_interval1, t_interval2) |
对于第三种特殊情况,此时我们无法给出准确的t_min和t_max,所以我们将这个情况称之为NaN,这里我们需要进行手动的处理,为区间添加一个padding,将NaN的情况视作fasle
为此我们需要为interval添加一个expend函数,它给区间填充给定的值:
1 | class interval { |
现在,我们可以开始实现AABB类了:
1 | #ifndef AABB_H |
这里的关键在于hit函数,我们通过区间收缩的方式实现对交集的判断。
现在我们需要为所有的可击中类构建一个包围盒,对于单个的物体,我们将其包围盒视作包围结构层次中的叶子节点,这个我们之后会提到。
由于我们的物体有部分是动画的,所以我们为其生成的包围盒边界应该等于其运动范围,首先,我们需要升级我们的hittable类:
1 | class hittable{ |
对于静态和动态的球体,我们很容易为其创建出包围盒:
1 | class sphere: public hittable { |
这里注意到我们直接将box1和box2合并成了一个新的包围盒区间,这是我们新定义的一种构造方法,也便于之后的包围盒合并:
1 | aabb(const aabb& box1, const aabb& box2){ |
现在我们需要更新对象列表hittable_list,现在列表中的物体被创建时,会生成相应的包围盒边界。而我们需要随着每个新子节点的加入逐步的更新边界框,于是我们有:
1 | class hittable_list : public hittable { |
BVH本质上也是一个hittable
,它代表一组几何体,光线可以击中它以进行相交判断。我们将它视作一个对象的容器,它封装了几何体,通过包围盒相交测试进行加速检测。
我们通过一个类来实现它,它既可以是节点,也可以是整棵树的根:
1 | #ifndef BVH_H |
接下来的重点在于怎么将hittable_list构建成我们想要的BVH。我们希望每个BVH下至多有两个左右节点,但是我们以什么为依据进行划分呢,以下是我们的做法:
当我们构建BVH的递归生成时,会有以下几种情况:
因此,我们的BVH生成的算法如下:
1 | bvh_node(std::vector<shared_ptr<hittable>>& objects, size_t start, size_t end) { |
这里sort使用的判断逻辑如下:
1 | static bool box_compare( |
不过实际上我们是可以对这一部分优化的。我们希望构造出来的BVH树是均衡的,所以每次对场景内的物体进行划分时,我们希望,左右子树的分布是均衡的,这就要求我们每次选择的检测轴应该是边长最长的轴。为此我们需要一个函数为我们计算出包围盒的范围,我们将以此为依据实现更加平衡的BVH树结构:
1 | bvh_node(std::vector<shared_ptr<hittable>>& objects, size_t start, size_t end) { |
为了实现对最长轴的判断 和
包围盒并集的实现,我们需要优化我们的aabb类:
1 | class aabb{ |
至此我们就实现了图形的加速渲染。太爽了。
速度差不多提升3-4倍,随着场景规模的提升,这个提升的效果更加明显,可以借下面这个图来感受一下。我们差不多实现了从O(n)到O(logn)的优化:
最近思绪有点乱,感觉要做的事情很多,但是不知道何从下手,所以我要从头缕一缕现在的问题。
首先是分析一下现阶段我要做的事情,按优先级进行分类:
这里加粗的部分是需要着重注意的地方,那么我该怎么安排呢?
首先是睡眠,每天十二点必须上床,手机最多玩到十二点半就要睡觉。有早八就七点一十起床,没早八就八点起床。周日可以休息久一点
每天早上至少喝一瓶牛奶,还有早餐。在寝室的期间要喝热水。平时尽量不喝奶茶喝其他饮料,也要控制自己的饮食,不能像以前一样想怎么吃就怎么吃。
平时要坚持锻炼,一周至少锻炼4次,两次耐力,两次力量。没事的时候可以在寝室举哑铃
每天要积极一点,心情不好就出去走一走,要开心。
节假日和周六晚上可以玩久一点游戏,平时尽量少玩一点。要珍惜现在的时间。
除掉周日,单日学概率论,双日学离散数学,每天至少完成一个章节的学习。在什么课上学什么的知识,不能拆东墙补西墙。
这周之内要开始科研训练的内容,首先给出自己一个项目的框架,可行demo
课余的时间学什么由自己决定,优先课程的任务。
别想太多 要接受分开的事实 继续走下去
上个学期也尝试了解过这些,但是那个时候还没有接触编译链接,对DWARF信息的理解不够深刻。最近有计划了解一下调试器的原理,所以重新捡起来好好学一遍。
我参考的教程是DWARF的官方介绍文档Debugging using DWARF,作为对其的简单了解
一开始我并不知道怎么说明这一部分,AI给了我一个很好的比方。如果说程序是一个设计图纸(源代码),它事无巨细的包含一个城市的所有信息,那么编译器就是一个工程师,他根据设计图纸将建造出城市(可执行文件)。而DWARF信息,就相当于这个城市的地图,它告诉你每条街道(机器指令,数据信息)对应设计图中的哪个位置(源代码)。而调试器就是一个导游,它根据这个地图带你去任何地方。
现代的编程语言大多是块状结构的,一个实体往往包含着更多的实体,每个实体中可能都有若干个数据和函数定义,那么在这个实体中,就产生了词法的作用域。这个定义仅在被定义的作用域中有意义。
我们可以用一个常见的文件结构来描述这种特征:
1 | 源文件 |
对于数据和函数一类的内容,我们按照编译链接的习惯,称之为符号。一般情况下,一个符号的作用域属于当前块(也可以通过关键词指定作用域范围)。所以我们要查找特定符号的定义,先从当前作用域中查找定义,然后从连续的外层定义域中依次查找,直到找到该符号。
1 | int global_var; // 全局作用域 |
但是在编译链接的过程中,这些信息会被抛弃或者是简化。因为编译器只在乎对内存和寄存器的管理和操作,所以我们很难根据机器指令去恢复这些信息。
所以这里我们就需要DWARF信息来保存这些信息,DWARF和程序语义一样,通过树状结构来组织信息。DWARF中的所有描述性实体都包含在一个父条目中,且实体中还可以包含更多节点,这些节点可能表示类型,变量或是函数…一个常见的结构可以是下面这样的:
1 | 编译单元 (CU) |
而接下来,我们将学习怎么去理解这些常见的DWARF信息
DWARF 中的基本描述实体是调试信息条目。一个 DIE 包含一个标签——用于指定该 DIE 描述的是什么,以及一个属性列表——用于填充细节并进一步描述该实体。除了最顶层的 DIE 外,每个 DIE 都包含在或归属于一个父 DIE,并且可能拥有兄弟 DIE 或子 DIE。属性可以包含各种值:常量(例如函数名)、变量(例如函数的起始地址),或者指向另一个 DIE 的引用(例如函数返回值的类型)
例如下图中就展示了一个简单的程序的DWARF信息:
最上面的是CU编译单元,它作为DWARF信息的根节点,包含了两个下级DIE。其中一个描述main的信息,如返回类型、行号、函数起始地址…另一个DIE描述的是int类型,通过子程序DIE中的Type属性而被引用。
DIE可以分为两种通用类型:
大多数语言都有复杂的数据类型体系,例如内置数据类型、指针、数据结构、自定义结构等类型。这些基于语言底层设计的主要类型我们称之为基础类型,其他的数据类型都由这些基础类型构造而成。
一个具名变量由一个拥有多种属性的 DIE
描述,其中一个属性是对类型定义的引用。下图就描述了一个名为x的整型变量:
int
的基础类型将其描述为一个占用四个字节的有符号二进制整数。用于变量
x 的 DW_TAG_variable DIE
给出了它的名称和一个类型属性,该属性引用了基础类型 DIE。
同样的,DWARF
也可以使用基础类型通过组合来构建其他数据类型定义。一个新类型是作为对另一个类型的补充而创建的。以下面这个int* px的DIE信息为例:
这个 DIE 定义了一个指针类型,指明其大小为四个字节,并继而引用了
int 基础类型。
还可以更复杂的,比如加上关键词去限定这个变量的属性和类型,也可以将更多类型的DIE链接在一起以描述更复杂的数据类型,例如const char ** argv的DIE信息如下:
总的来说,在DWARF信息中,我们通过组合基本类型的方式来表示程序语言中的数据类型。这样我们无需了解所有程序语言的数据结构,也可以描述出数据类型的信息。
数组类型由DW_TAG_array_type表示,对于int arr[10],其一般DWARF结构如下:
1 | < 1><0x0000002e> DW_TAG_array_type |
其中DW_TAG_subrange_type用来存储描述数组维度的范围(下标范围),这里不仅指示了下标的上界DW_AT_upper_bound 9也指明了下标的数据类型DW_AT_type <0x0000003e>
我们可以看到左边的<1> <2>的符号,这代表当前条目在条目树结构中的深度。
理解了数据类型的结构分析之后,我们看到变量的定义信息:
通过这些信息,我们就可以还原出数组的数据类型、存储结构、以及在源代码中的定义位置等信息
大多数的语言都支持将各种数据类型的组合到一个结构体中,只不过不同的语言叫法不一样而已,这里的我们就简单的介绍一下结构体和类的标签。
结构体相较于类更加纯粹,它主要对数据进行封装,将不同的数据类型整合成一个大的结构体,在结构体中通过字段对这些数据进行索引,我们可以看下它的DWARF结构
1 | < 1><0x0000002e> DW_TAG_structure_type |
结构体类型由DW_TAG_structure_type进行表示,这里我们定义的结构体如下:
1 | struct{ |
我们可以阅读到以下结构体的属性:
然后是类的,类相当于结构体的plus版,既可以组合数据类型,也可以包含函数方法,不过对于类的内存分布,我暂时也不是很清楚。我们可以看看类的DWARF信息:
1 | < 1><0x0000002a> DW_TAG_class_type |
类的类型由DW_TAG_class_type进行表示,这里我们定义的类是这样的:
1 | class Student{ |
我们可以阅读以下类的信息:
DW_ACCESS_public为公有类由还有很多标签,但是这里不过多进行讲解。
变量通常相当简单。它们有一个名称,代表一块可以存储某种值的内存(或寄存器)。变量可以包含的值的种类,以及对其修改方式的限制(例如,是否为
const),都由变量的类型来描述。
区分变量的关键在于其值的存储位置和其作用域。变量的作用域定义了变量在程序中的哪些位置是已知的,并在某种程度上由变量声明的位置决定。在 C 语言中,在函数或块内声明的变量具有函数或块作用域。在函数外声明的变量具有全局或文件作用域。这允许在不同文件中定义同名的变量而不会冲突,也允许不同的函数或编译单元引用同一个变量。
DWARF 将变量分为三类:常量、形式参数和变量。
const
变量只是表示你不能在没有使用显式类型转换的情况下修改变量。)大多数变量都有一个位置属性,用于描述变量的存储位置。
这里的函数和子程序实际上是同一个东西,硬要细分的话,函数是有返回值的,而子程序没有(我们更多是利用子程序的副作用)。我们可以看一下函数会包含的DWARF信息:
1 | < 1><0x00000065> DW_TAG_subprogram |
首先我们可以看到包含源代码位置信息的三元组(文件、行、列),然后是函数的高低内存范围,一般情概况下,我们默认函数的低内存地址(起始地址)为函数的入口。函数的返回类型,由类型属性指定。
这里需要注意的是DW_OP_call_frame_cfa指定的CFA0x9c。CFA就是函数执行时,其调用者的栈帧的栈顶位置,标志着一个函数栈帧的开始边界。以下图结构为例:
1 | (高地址) |
在我们的示例中,DWARF信息指出DW_AT_frame_base : DW_OP_call_frame_cfa,所以这里我们的栈基址等于CFA值。基于栈基址,我们就可以对被调用栈帧中的变量进行访问。我们看到DW_AT_location的属性下,通常有DW_OP_fbreg - 偏移值的形式来计算参数在栈帧上的位置。
DWARF不定义函数的调用约定,这一部分有应用程序二进制接口规范确定(ABI)
大多数的程序室友多个文件构成的,每个文件会被独立编译,然后与系统库链接成最终的程序,DWARF将每个独立编译的源文件称为一个编译单元
每个编译单元的DWARF数据,都会从一个编译单元调试信息项开始。该调试信息项包含编译过程中的通用信息
1 | < 0><0x0000000c> DW_TAG_compile_unit |
包括:
编译单元调试信息项是所有该编译单元调试信息的父项。一般情况下,调试信息会先描述数据类型,接着是全局数据,然后再是子函数。
至此基本的DWARF信息就介绍到这里。
也是好不容易水到第100篇了,我本来准备了好多台词的,但是不知道说什么。我就记录一下我的几个感悟吧
刚开始觉得只要努力学就会有所收获吧,如果没学会就是自己比较笨什么的。但事实不是这样的,认知一个领域的过程是循序渐进的,就像游戏里的科技树一样,解锁一个科技需要一大堆前置科技。有时候一些知识也是这样,所以一开始学不会一些东西很正常,这个时候应该先放一放,去试试其他的方向,或者是从前置的内容开始学。
关于这一点我的感受就很明显吧,我每次遇到学不会的东西,就会放弃然后去学其他的东西。但是过一段时间之后再来看这些内容反而有一种水到渠成的感觉,我猜测一个是知识体系上的补齐,还有一个是你对这个领域的认知能力也在慢慢变强。你能对一些内容做出自己的解释,能说服自己我认为是理解一个知识的开始吧。不管你的猜想是不是对的,只要他能补齐你对这方面的认识,你就可以视作自己理解了。之后随着需求和认知的提升,再一步步完善自己对这方面的理解吧。
我发现生活中也有很多这样的事情,给你一种无从下手或者无能为力的感觉。如果实在不行就放弃吧,可以之后再试试,先把其他的事情做好,也许很多事情时间会给出答案,因为我们每天都在慢慢长大。只要不放弃从头再来的决心,迟早有一天也能找到自己的答案。
大家都在学习凭什么,也不存在谁比谁笨,为什么有的人能学会有的人不能呢?我认为这不是努不努力的事情,大家回宿舍都是打游戏,如果能认真学下去,活该他学的好。我觉得更多是对于这个知识的态度吧,有的人是被动的接受的,有的人是主动的接受的。主动接受的话你就会发现很多问题啥的,就是你的脑子里的东西和答案不一样,或者你脑子里没有这个东西。在这个不断碰撞思考的过程中,你的知识体系会被答案说服或者补全,这样你的认知体系就会慢慢完善,感觉这个就是高中老师说的框架/查漏补缺,这个就是主动学习。但是大多数人就是书上讲得是啥就是啥,老师说啥就是啥,一上来就记住正确答案,对知识很内容知其然不知其所以然,这种效率就很差。中国传统的应试教育就是这样的,所以大家都是这样的,我也是这样的。
这也是为什么比起看网课我更喜欢看书,看网课的时候,老师叽里咕噜讲个不停,你很难有自己思考的时间,更别说有时候发呆什么的。但是看书不一样,你不懂得时候可以对着书上得那句话发呆,等你发完呆它还在那里,你就有充分得时间去理解它,或者再来一遍。就是这个时候你的大脑是属于你自己的,你的想法不是跟着别人走的,更自由一点。这样你就能做出更多自己的思考。
我知道这样子很不好,但是有时候动脑子真的好累,偶尔主动学习一些重要的东西也还行。我的话有时候突然想做项目或者刷视频看到什么好帅的东西,我会迫不及待的去了解一下。主要是对游戏方面感兴趣一点
就是电视上和老师经常说的要坐的住冷板凳吧,有时候学一些东西确实挺牢的,就是很无聊,知识从脑袋里滑走了。这个时候就是看你有没有耐心了,感觉这个和我第一条说的是相反的,按道理这种情况是要放弃的,但是有时候就是不太想放弃,你就需要耐心一点。所以这么看来放弃是一件很理性的事情,坚持反而是一件很感性的事情。回到正题,我想说的是,人生的常态就是失败和无聊,但是不耐心去做一件事的话就会错过很多东西。什么时候放弃,什么时候坚持是一件很哲学的问题吧。我到这里也不知道说什么好了。
今天就暂时写到这些吧,希望之后的时间也能继续加油。现在虽然学了很多东西,但是没办法把知识串联在一起,也有点迷茫。有时候分析一些问题的时候,反而会因为知道的太多而被绕进去。就像之前不知道从哪里看到的三大境界:
我现在可能介于一二之间吧,很难受,技术不到家,看很多东西都是残破不堪的,漏洞百出,我自己也知道。希望以后能慢慢解决这个这个问题吧。还有就是我现在也遇到了一些生活中难以解决的问题,我也不知道怎么做,在迷茫的时候还是要坚定的提升自己,也许以后时间会给出答案吧。之后也要继续加油。
今天接着来认识一下图,结束了图的学习,数据结构的部分就告一段落了。之后就是涉及到一些基本的算法问题,感觉也会越来越难。
和之前的数据结构不同,图是一种非线性的结构,由顶点和边组成,以下面为例,我们可以将图G抽象的表示成一组顶点和一组边的集合:
1 | V = {1,2,3,4,5} |
如果将顶点看作节点,将边看作连接各个节点的引用(指针),我们就可以将图看作一种从链表拓展而来的数 据结构。
结合不同的场景,我们可以从不同的角度对图进行分类:
在不同的场景中我们选择合适的类型,来解决问题。
图的数据结构有以下常用术语:
图通常使用两种表示方式,我们这里均以无向图为例:
设图的顶点数量为𝑛,邻接矩阵使用一个n×n大小的矩阵来表示图,每一行(列)代 表一个顶点,矩阵元素代表边,用1或0表示两个顶点之间是否存在边。
设邻接矩阵为𝑀、顶点列表为𝑉 ,那么矩阵元素𝑀[𝑖,𝑗]=1表示顶点𝑉[𝑖]到顶点𝑉[𝑗] 之间存在边,反之𝑀[𝑖,𝑗]=0表示两顶点之间无边。
邻接矩阵有以下性质:
邻接表使用n个链表来表示图,链表节点表示顶点。第i个链表对应顶点i,其中存储了该顶点的所有邻接顶点(与该顶点相连的顶点)。如下图:
对于邻接矩阵,我们可以直接访问矩阵元素实现对边的CRUD,时间效率高达O(1)。但是矩阵存储的空间复杂度为O(n2)
对于邻接表,由于表中只存储实际已存在的边数,而边数通常小于n2,所以空间存储的效率上来看,邻接表要更加的节省空间。但是对于CRUD的操作,邻接表需要通过遍历搜索的方式来进行,所以时间效率更差。
但是结合先前的只是,对于这种链表较长的情况,我们可以将它优化成哈希表或者AVL树等结构,实现对时间效率的优化,所以对于大规模的图的使用场景,邻接表也更加普遍。
图的操作主要可以分为对顶点和对边的操作,这里分别通过邻接矩阵和邻接表的方式进行实现。
给定给一个顶点数量为n的无向图,我们需要完成以下操作:
我们的具体实现如下:
1 | class GraphMap{ |
设无向图的顶点总数为n、边总数为m,我们需要完成以下操作:
以下是邻接表的代码实现:
1 | class GraphList{ |
这里为了方便,我们使用动态数组代替了链表。用哈希表来表示邻接表结构。
同时需要注意在邻接表中我们使用Vertex类来表示顶点,也是为了方便。如果我们使用列表索引来区分不同德顶点的话,那么每当我们删除一个索引为i的顶点,就需要遍历整个邻接表,把所有索引大于i的顶点重新更新一遍,这样的话效率就很差。
树代表的是一对多的关系,图代表的是多对多的关系,所以我们可以将树视作图的一个特例。所以说树的遍历操作实际上也是图的遍历操作的一种特例。
和树类似的,我们有两种遍历方式实现对图的遍历操作:广度优先遍历和深度优先遍历
广度优先遍历是一种由近及远的遍历方式,从某个节点出发,始终优先访问距离最近的顶点,并一层层向外 扩张。以下图为例:
从左上角顶点出发,首先遍历该顶点的所有邻接顶点,然后遍历下一个顶点的所有邻 接顶点,以此类推,直至所有顶点访问完毕。
BFS通常需要一个辅助队列来实现,通过队列先进先出的性质,实现BFS由近及远的思路。我们的实现流程如下:
startVet加入队列中,开始循环同时我们还需要一个哈希集合visited来记录哪些节点被访问,以防止重复遍历顶点。
我们的具体实现如下:
1 | vector<Vertex*> graphBFS(GraphList &graph, Vertex* startVet){ |
可以通过画图的方法帮助理解一下这个过程
深度优先遍历是一种优先走到底、无路可走再回头的遍历方式。以下图为例
这种走到尽头并返回的样式,我们可以通过递归的方式实现。关键在于怎么设置边界条件,退出递归。我们的实现如下:
1 | void dfs(GraphList &graph, unordered_set<Vertex*> &visited, |
可以自己尝试手推一下这个过程,加深理解。
学一种新的数据结构——“堆”,但是貌似和底层原理中的堆并不一样。之前总是略有耳闻,但是一直都很少仔细去了解。
堆是一种满足特定条件的完全二叉树,根据性质可以分为两种类型:
堆作为完全二叉树的一个特例,有以下规则:
有一种抽象的数据结构——优先队列,定义为具有优先级排序的队列。通常我们会使用堆来实现优先队列,大顶堆就相当于元素按大到小的顺序出队的优先队列。这里我们将其视作等价的数据结构。
这里我们先通过优先队列来认识一下堆的一些操作:
1 | #include <iostream> |
常用的可以总结为以下几类:
现在我们尝试从底层实现一个堆,这里我们用大顶堆演示,小顶堆只需要将所有的大小逻辑判断反转即可。
堆是一种完全二叉树,我们之前提到过完全二叉树十分适合用数组来进行存储,所以这里我们的底层实现选择用数组,和之前的实现一样。我们使用索引的映射公式来代替节点指针:
1 | int left(int i){ |
堆顶元素就是二叉树的根节点,也就是我们数组的首元素:
1 | class Heap{ |
给定val,我们将其添加到堆底。但是此时插入的值可能会破坏当前到根节点的路径,导致堆的成立条件被破坏。所以我们需要遍历修复从插入节点到根节点路径上的所有节点,这个过程我们称之为堆化
从入堆节点开始,我们从底执行堆化。我们依次比较当前节点和父节点的大小,如果插入节点更大就讲他们交换,知道修复所有的节点关系。
我们可以通过以下方式实现这个过程:
1 | void push(int i){ |
堆顶元素是二叉树的根节点,如果我们直接从数组中删除首元素,那么会破坏整个树的树状结构,这导致后续的堆化难以修复。所以我们采取以下步骤将堆顶元素出栈:
自顶向下的堆化和自底向上的堆化逻辑相反,我们将当前节点和较大的子节点进行比较,然后进行交换,直到没有子节点或者无需再交换了:
出堆的实现方式如下:
1 | void pop(int i){ |
我们希望能直接根据一个列表中的所有元素来构建一个堆。我们有两种可行的方案:
O(logn)的入堆操作,时间复杂度会增长到O(nlogn)这里我们着重分析一下第二种方法。我们可以通过以下方法实现:
1 | Heap(vector<int> vec){ |
这个方法下的复杂度被优化到了O(n),更加的便捷。
所谓top-k问题,就是给定一个长度为n的无序数组nums,请返回数组中最大的k个元素。
对于这个问题我们有很多种解决方案:
我们可以像这样对一个长度为n的序列进行k次遍历,每轮遍历都提取出当前序列中的最大的数据。时间复杂度为O(nk)。当k接近n时,时间复杂度增长到O(n^2)
先对数组nums进行排序,然后再返回最右边的k个数据。这个方法主要取决于对数组排序的时间复杂度,对于std::sort,这个方法的复杂度是O(nlogn)
我们可以基于堆更加高效的完成这个任务,我们遵循以下步骤实现:
我们可以写出以下代码:
1 | priority_queue<int, vector<int>, greater<int>> topHeap(vector<int> nums, int k){ |
总共进行了n轮入堆和出堆,由于堆大小是固定的k,所以我们只需要维护k个元素,所以实际上我们的计算复杂度只有O(nlogk),在k较小时,我们的时间复杂度约等于O(n)
由此可以看到通过堆的思路,对解决TopK问题的显著提升。尤其是对于这种方法,适用于动态数据流的使用场景。在不断加入数据时,我们可以持续维护堆内的元素,从而实现
最大的𝑘个元素的动态更新。
书接上回,接着研究一下AVL树。
在二叉搜索树中,我们可以知道,在多次的插入和删除之后,二叉树的左右可能会失去平衡,从而导致退化成链表,在这样的情况下我们的算法的效率会从O(logn)退化到O(n):
但是我们接下来将要提到的AVL树,将解决这些问题
AVL树本质是二叉搜索树和二叉平衡树的结合,所以同时满足这两种树的性质:
“节点高度”是指从该节点到它的最远叶节点的距离,即所经过的“边”的数量。这里我们让叶子节点的高度为0,把空节点的高度设置为-1。我们将根据这些性质来编写我们的数据结构:
1 | struct TreeNode{ |
节点的平衡因子定义为节点左子树的高度减去右子树的高度,同时规定空节点的平衡因子为0。对于AVL树,我们要求树中任意节点的平衡因子-1<=f<=1,否则这个树就进入了失衡状态。我们可以写出以下函数:
1 | int balance(TreeNode* node){ |
AVL树的精髓就在于旋转操作,当我们的树中存在失衡节点时,我们可以通过“旋转”操作,在不影响中序遍历序列的前提下,使失衡的节点重新回到平衡的状态。
我们将|平衡因子| > 1的节点称为失衡节点,根据不同的失衡情况,我们有四种旋转操作:
结合这张图来看,从底至顶看,二叉树中首个失衡节点是“节点3”。我们关注以该失衡 节点为根节点的子树,将该节点记为 node ,其左子节点记为 child ,执行“右旋”操作。完成右旋后,子树 恢复平衡,并且仍然保持二叉搜索树的性质。
但是对于,当节点child有右节点(记作grand_child)的情况下,我们需在右旋中添加一步:将grand_child设置为node的左子节点。
我们可以通过修改节点指针的方法来实现:
1 | TreeNode* rightRotate(TreeNode* node){ |
左旋作为右旋的镜像版本,对应右节点失衡的情况,同样是下面的两种情况:
我们可以写出:
1 | TreeNode* leftRotate(TreeNode* node){ |
对于下面这种情况,我们需要先对child进行左旋,再对node进行右旋:
这种情况依旧是上一种情况的镜像对称情况:
讲完了四种旋转的操作,现在我们需要分析,在什么情况下需要对我们的树进行哪些操作了,这里的话我推荐看一个视频:平衡二叉树(AVL树)_哔哩哔哩_bilibili
我们的步骤总结如下:
child的平衡因子,大于0则需要进行右旋操作,小于0则需要进行左旋操作。node的平衡因子,大于1则需要进行右旋操作,小于1则需要左旋操作。chlid和失衡节点node的需要进行的操作,如果相同则合并,不同则先操作子节点后操作失衡节点。我们也可以用一张表来进行这个判断:
现在我们可以写出AVL树的平衡函数:
1 | TreeNode* rotate(TreeNode* node){ |
AVL树的节点插入操作和二叉搜索树一样,只不过在AVL树插入节点之后,从这个节点到根节点的路径上可能会出现一系列的失衡节点。所以我们需要从这个节点开始,自底向上的执行平衡操作,知道所有的失衡节点都恢复平衡。我们的实现如下:
1 | TreeNode* _insert(TreeNode* node, int num){ |
删除也是差不多,需要在删除的基础之上,从底部到顶部进行平衡操作,确保所有的失衡点都恢复平衡:
1 | TreeNode* _remove(TreeNode* node, int num){ |
和二叉搜索树一样,没有变化