这是一篇包含一万多字的游戏开发面试经验贴,主要针对实习和校招,并面向游戏程序开发中游戏客户端开发和游戏引擎开发岗位的同学,涵盖了绝大多数游戏公司面试中考察的关于计算机基础方面的高频经典问题。准备秋招,只看这一篇面经就够了(或者说这一个系列的面经)。
前言
对于想要进入游戏行业的人而言,面试就是检验我们是否达到入行标准的最后一关。我从第一次参加游戏公司的面试开始,直到今天已经累计参加了接近30场面试,从一个一问三不知的小白,到能够基本应对大多数面试的问题,并顺利进入了网易和腾讯实习。这期间我也经历过无数次的失败、沮丧和迷茫,更是看过了无数的面经,反复地理解、提炼、背诵,做了一次又一次的总结,最终汇聚成这篇超过一万字的面试经验。我相信,只要能把这篇面经中的问题理解和掌握,一定可以应付绝大多数游戏公司面试中的“计算机基础”相关的问题。
在我这么多次的面试中,面经对我产生了极大的帮助。为了增加这份面经的可信度,我简单列(chui)一下我去年秋招的成果:
腾讯北极光工作室,游戏客户端开发,三面,通过;
广州网易互娱事业群,游戏引擎开发,三面,通过;
杭州网易雷火事业群,游戏引擎开发,五面,通过;
字节跳动多媒体部门,渲染引擎开发,三面,通过;
华为Cloud & AI部门,渲染引擎开发,三面,通过。
上面的面试都是指技术面,都拿到了OFFER。我也只投了这些公司。当然,能拿到OFFER并不完全是面经的功劳,但对于面试而言它确实是十分重要的。
在游戏开发当中,面试官主要考察候选人的内容包括以下几个方面:计算机基础、算法和代码能力、图形学基础和项目经验。在这几点当中,我认为计算机基础是最重要,或者说性价比最高的。因为它是可控性和已知性比较强的,也就是我们常说的八股文。并且我的个人感受是,面试就是一个面试官印象分积累的过程,当他问你计算机基础的时候,你回答上一个问题就加一点分,如果答得比较好还比较深入,就会很加分,但是如果你连一些比较基础的问题都答不上来,不仅不会加分,甚至还会扣很多分。直到结束的时候,如果这个印象分达到了面试官的标准,你就通过了,否则就只能“下次又是一条好汉”了。
因此我把“计算机基础”当作这个面经系列文章的第一篇,主要涵盖以下四个方面:C++;STL、数据结构和算法;工程问题;操作系统、计算机组成原理和计算机网络。我会挑选出面试中最高频的一些问题来进行解答,尝试以尽可能简练和准确的语言去描述,以方便记忆。所谓高频,就是指那些我在面试或者其他面经中至少碰到两次以上的问题,对于那些非常高频、必须要掌握的问题,我也都加粗标明了,有一些需要注意的地方,也做划线标记。对于学习C#和Unity的同学也不用紧张,面试可以选择考察哪一门语言(主要就是C++、C#和Lua),但我强烈建议学C#之前也要把C++学好,毕竟C#的底层就是C++,很多引擎源码也是用C++写的。真正面试时,考察的也是C++偏多。
最后,我想说的是我并不鼓励只会背诵八股文,作为一名程序员,如果没有一定的真才实学,只想着鱼目混珠,是不可能混得下去的。因此,我在每一个问题下都尽可能放出一些相关的参考资料,希望读者在看我总结出来的答案之前,先去查阅相关的参考资料,并能够真正沉下心去学习和理解。而且我想提醒,只会八股文是不够的,如果你不真正去深入理解,自己写代码验证,只要面试官换一个问法,或者问得更深入一点,又或者是出一个具体的情景题,你还是会翻车。所以,不要心存侥幸。
但八股文又还是必须的,正如应试教育一般,有些事情是我们不得不面对的,这些八股文的存在一定是有意义的,一方面它是考察知识技能的高效方式,一方面很多八股文的东西是真正会在实际开发中产生价值的,例子可以举很多。如果你连八股文都没有准备好,说明你并没有足够的热情以及端正的态度来加入这个行业。所以,为了你想做的事情,该做什么就做吧,就像高考一样,别管什么技巧,只要不抄袭不作弊,依靠正当的手段能考高分那就是牛逼。
最后的最后,如果你觉得这篇文章对你有帮助,就麻烦点个赞啦,至少在收藏之前意思一下吧,毕竟点赞和关注才是写文章的最大动力呀~(强烈谴责那些悄咪咪收藏却不点赞的家伙!) 如果你还没有发现这篇文章的价值,也可以先收藏一下,等到实习或秋招的时候你可以去参加几次面试,我相信你会回来点赞的,我只能说:“懂的都懂”。
好了,话不多说,开始吧。
一、C++
1.1 多态、虚函数(⭐⭐⭐)
- 什么是多态?C++的多态是如何实现的?
答:所谓多态,就是同一个函数名具有多种状态,或者说一个接口具有不同的行为;C++的多态分为编译时多态和运行时多态,编译时多态也称为为静态联编,通过重载和模板来实现,运行时多态称为动态联编,通过继承和虚函数来实现。
- 虚函数的实现机制是什么?
【参考资料】:《游戏引擎架构》P115、 https://zhuanlan.zhihu.com/p/98776075
答:虚函数是通过虚函数表来实现的,虚函数表包含了一个类(所有)的虚函数的地址,在有虚函数的类对象中,它内存空间的头部会有一个虚函数表指针(虚表指针),用来管理虚函数表。当子类对象对父类虚函数进行重写的时候,虚函数表的相应虚函数地址会发生改变,改写成这个虚函数的地址,当我们用一个父类的指针来操作子类对象的时候,它可以指明实际所调用的函数。
- 虚函数调用是在编译时确定还是运行时确定的?如何确定调用哪个函数?
答:运行时确定,通过查找虚函数表中的函数地址确定。
更正:此处说法不严谨,应该是只有通过指针或者引用的方式调用虚函数是运行时确定,通过值调用的虚函数是编译期就可以确定的,参考这篇文章,虚函数一定是运行期才绑定么? - 知乎 (zhihu.com)
- 虚函数是存在类中还是类对象中(即是否共享虚表)?
答:存在类中,不同的类对象共享一张虚函数表(为了节省内存空间)。
- 在(基类的)构造函数和析构函数中调用虚函数会怎么样?
【参考资料】:《Effective C++》条款9、https://www.cnblogs.com/sylar5/p/11523992.html
答:从语法上讲,调用没有问题,但是从效果上看,往往不能达到需要的目的(不能实现多态);因为调用构造函数的时候,是先进行父类成分的构造,再进行子类的构造。在父类构造期间,子类的特有成分还没有被初始化,此时下降到调用子类的虚函数,使用这些尚未初始化的数据一定会出错;同理,调用析构函数的时候,先对子类的成分进行析构,当进入父类的析构函数的时候,子类的特有成分已经销毁,此时是无法再调用虚函数实现多态的。
- C语言可以实现虚函数机制吗,如何实现?
【参考资料】:C语言实现虚函数机制
答:需要做的工作:手动构造父子关系、创建虚函数表、设置虚表指针并指向虚函数表、填充虚函数表;当虚函数重写的时候还需要手动修改函数指针等等。
1.2 内存模型,继承问题(⭐)
- c++中类对象的内存模型(布局)是怎么样的?
参考资料】:C++内存模型 - MrYun - 博客园 (cnblogs.com)、C++内存布局(上)_qinm的专栏-CSDN博客
答:一般遵循以下几点原则:
(1)如果是有虚函数的话,虚函数表的指针始终存放在内存空间的头部;
(2)除了虚函数之外,内存空间会按照类的继承顺序(父类到子类)和字段的声明顺序布局;
(3)如果有多继承,每个包含虚函数的父类都会有自己的虚函数表,并且按照继承顺序布局(虚表指针+字段);如果子类重写父类虚函数,都会在每一个相应的虚函数表中更新相应地址;如果子类有自己的新定义的虚函数或者非虚成员函数,也会加到第一个虚函数表的后面;
(4)如果有钻石继承,并采用了虚继承,则内存空间排列顺序为:各个父类(包含虚表)、子类、公共基类(最上方的父类,包含虚表),并且各个父类不再拷贝公共基类中的数据成员。
- 钻石(菱形)继承存在什么问题,如何解决?
【参考资料】:C++之钻石问题和解决方案(菱形继承问题)_Benson的专栏-CSDN博客、C++:钻石继承与虚继承 - Tom文星 - 博客园 (cnblogs.com)
答:会存在二义性的问题,因为两个父类会对公共基类的数据和方法产生一份拷贝,因此对于子类来说读写一个公共基类的数据或调用一个方法时,不知道是哪一个父类的数据和方法,也会导致编译错误。可以采用虚继承的方法解决这个问题(父类继承公共基类时用virtual修饰),这样就只会创造一份公共基类的实例,不会造成二义性。
1.3 内存管理(内存分配、内存对齐)(⭐⭐⭐)
- C++是如何做内存管理的(有哪些内存区域)?
【参考资料】:C++内存管理 - 还没放弃的老张 - 博客园 (cnblogs.com)
(1)堆,使用malloc、free动态分配和释放空间,能分配较大的内存;
(2)栈,为函数的局部变量分配内存,能分配较小的内存;
(3)全局/静态存储区,用于存储全局变量和静态变量;
(4)常量存储区,专门用来存放常量;
(5)自由存储区:通过new和delete分配和释放空间的内存,具体实现可能是堆或者内存池。
补充:堆是C和操作系统的术语,自由存储区是C++的术语,指的是通过new和delete动态分配和释放对象的抽象概念;基本上C++也会用堆区实现自由存储,但程序员可以通过重载操作符,改用其他内存实现自由存储,比如全局变量做的对象池。
- 堆和栈的内存有什么区别?
(1)堆中的内存需要手动申请和手动释放,栈中内存是由OS自动申请和自动释放;
(2)堆能分配的内存较大(4G(32位机器)),栈能分配的内存较小(1M);
(3)在堆中分配和释放内存会产生内存碎片,栈不会产生内存碎片;
(4)堆的分配效率低,栈的分配效率高;
(5)堆地址从低向上,栈由高向下。
- C++和C分别使用什么函数来做内存的分配和释放?有什么区别,能否混用?
C使用malloc/free,C++使用new/delete,前者是C语言中的库函数,后者是C++语言的运算符,对于自定义对象,malloc/free只进行分配内存和释放内存,无法调用其构造函数和析构函数,只有new/delete能做到,完成对象的空间分配和初始化,以及对象的销毁和释放空间,不能混用,具体区别如下:
(1)new分配内存空间无需指定分配内存大小,malloc需要;
(2)new返回类型指针,类型安全,malloc返回void*,再强制转换成所需要的类型;
(3)new是从自由存储区获得内存,malloc从堆中获取内存;
(4)对于类对象,new会调用构造函数和析构函数,malloc不会(核心)。
- 什么是内存对齐(字节对齐),为什么要做内存对齐,如何对齐?
【参考资料】:《游戏引擎架构》P111、前文内存管理部分的参考资料
(1)内存对齐的原因:关键在于CPU存取数据的效率问题。为了提高效率,计算机从内存中取数据是按照一个固定长度的。比如在32位机上,CPU每次都是取32bit数据的,也就是4字节;若不进行对齐,要取出两块地址中的数据,进行掩码和移位等操作,写入目标寄存器内存,效率很低。内存对齐一方面可以节省内存,一方面可以提升数据读取的速度;
(2)内容:内存对齐指的是C++结构体中的数据成员,其内存地址是否为其对齐字节大小的倍数。
(3)对齐原则:1)结构体变量的首地址能够被其最宽基本类型成员的对齐值所整除;2)结构体内每一个成员的相对于起始地址的偏移量能够被该变量的大小整除;3)结构体总体大小能够被最宽成员大小整除;如果不满足这些条件,编译器就会进行一个填充(padding)。
(4)如何对齐:声明数据结构时,字节对齐的数据依次声明,然后小成员组合在一起,能省去一些浪费的空间,不要把小成员参杂声明在字节对齐的数据之间。
1.4 类型转换(⭐⭐)
- C++有哪些类型转换的方法(关键字),各自有什么作用?
【参考资料】:《C++Primer》P144/730、《Effective C++》条款27
(1)const_cast: 把const属性去掉,即将const转换为非const(也可以反过来),const_cast只能用于指针或引用,并且只能改变对象的底层const(顶层const,本身是const,底层const,指向对象const);
(2)static_cast: 隐式类型转换,可以实现C++中内置基本数据类型之间的相互转换,enum、struct、 int、char、float等,能进行类层次间的向上类型转换和向下类型转换(向下不安全,因为没有进行动态类型检查)。它不能进行无关类型(如非基类和子类)指针之间的转换,也不能作用包含底层const的对象;
(3)dynamic_cast:动态类型转换,用于将基类的指针或引用安全地转换成派生类的指针或引用(也可以向上转换),若指针转换失败返回NULL,若引用返回失败抛出bad_cast异常。dynamic_cast是在运行时进行安全性检查;使用dynamic_cast父类一定要有虚函数,否则编译不通过;
(4)reinterpret_cast:reinterpret是重新解释的意思,此标识符的意思即为将数据的二进制形式重新解释,但是不改变其值,有着和C风格的强制转换同样的能力。它可以转化任何内置的数据类型为其他任何的数据类型,也可以转化任何指针类型为其他的类型。它甚至可以转化内置的数据类型为指针,无须考虑类型安全或者常量的情形。不到万不得已绝对不用(比较不安全)
- static_cast和dynamic_cast的异同点?
答:二者都会做类型安全检查,只是static_cast在编译期进行类型检查,dynamic_cast在运行期进行类型检查。后者需要父类具备虚函数,而前者不需要。
- dynamic_cast的原理,如何自行实现?
https://blog.csdn.net/zqxf123456789/article/details/106245816/ (这个问题暂时没有找到写得很详细的文章,自己也不是很了解,但是面试中的确出现过一两次,就暂时不写了)
1.5 智能指针(⭐)
- C++中的智能指针有哪些,各自有什么作用?
【参考资料】《C++Primer》13.5.1章节
智能指针主要解决一个内存泄露的问题,它可以自动地释放内存空间。因为它本身是一个类,当函数结束的时候会调用析构函数,并由析构函数释放内存空间。智能指针分为共享指针(shared_ptr), 独占指针(unique_ptr)和弱指针(weak_ptr):
(1)shared_ptr ,多个共享指针可以指向相同的对象,采用了引用计数的机制,当最后一个引用销毁时,释放内存空间;
(2)unique_ptr,保证同一时间段内只有一个智能指针能指向该对象(可通过move操作来传递unique_ptr);
(3)weak_ptr,用来解决shared_ptr相互引用时的死锁问题,如果说两个shared_ptr相互引用,那么这两个指针的引用计数永远不可能下降为0,资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和shared_ptr之间可以相互转化,shared_ptr可以直接赋值给它,它可以通过调用lock函数来获得shared_ptr。
- shared_ptr的实现原理是什么?构造函数、拷贝构造函数和赋值运算符怎么写?shared_ptr是不是线程安全的?
(1)shared_ptr是通过引用计数机制实现的,引用计数存储着有几个shared_ptr指向相同的对象,当引用计数下降至0时就会自动销毁这个对象;
(2)具体实现:
1)构造函数:将指针指向该对象,引用计数置为1;
2)拷贝构造函数:将指针指向该对象,引用计数++;
3)赋值运算符:=号左边的shared_ptr的引用计数-1,右边的shared_ptr的引用计数+1,如果左边的引用技术降为0,还要销毁shared_ptr指向对象,释放内存空间。
(3)shared_ptr的引用计数本身是安全且无锁的,但是它指向的对象的读写则不是,因此可以说shared_ptr不是线程安全的。shared_ptr是线程安全的吗? - 云+社区 - 腾讯云 (tencent.com)
- weak_ptr是为了解决shared_ptr的循环引用问题,那为什么不用raw ptr来解决这个问题?
答:一个weak_ptr绑定到shared_ptr之后不会增加引用计数,一旦最后一个指向对象的shared_ptr被销毁,对象就会被释放,即使weak_ptr指向对象,也还是会释放;raw指针,当对象销毁之后会变成悬浮指针。
1.6 各种关键字(⭐)
- const的作用?指针常量和常量指针?const修饰的函数能否重载?
【参考资料】:《C++Primer》2.4节
(1)const修饰符用来定义常量,具有不可变性。在类中,被const修饰的成员函数,不能修改类中的数据成员;
(2)指针常量指的是该指针本身是一个常量,不能被修改,但是指针指向的对象可以被修改,常量指针指的是这个指针指向的对象是一个常量,不能被修改,但是指针本身可以被修改。这涉及到一个顶层const和底层const的概念:顶层const,本身是const,底层const,指向的对象是const;
(3)const修饰的函数可以重载。const成员函数既不能改变类内的数据成员,也无法调用非const的成员函数;const类对象只能调用const成员函数,非const对象无论是否是const成员函数都能调用,但是如果有重载的非const函数,非const对象会优先调用重载后的非const函数。
- static的作用?static变量什么时候初始化?
【参考资料】《游戏引擎架构》P111、《C++Primer》12.6小节、静态变量的初始化
static即静态的意思,可以对变量和函数进行修饰。分三种情况:
(1)当用于文件作用域的时候(即在.h/.cpp文件中直接修饰变量和函数),static意味着这些变量和函数只在本文件可见,其他文件是看不到也无法使用的,可以避免重定义的问题。
(2)当用于函数作用域时,即作为局部静态变量时,意味着这个变量是全局的,只会进行一次初始化,不会在每次调用时进行重置,但只在这个函数内可见。
(3)当用于类的声明时,即静态数据成员和静态成员函数,static表示这些数据和函数是所有类对象共享的一种属性,而非每个类对象独有。
(4)static变量在类的声明中不占用内存,因此必须在.cpp文件中定义类静态变量以分配内存。全局变量、文件域的静态变量和类的静态成员变量在main执行之前的静态初始化过程中分配内存并初始化;局部静态变量在第一次使用时分配内存并初始化。
- extern的作用?
答:当它与"C"一起连用时,如: extern "C" void fun(int a, int b);则告诉编译器在编译fun这个函数名时按着C的规则去翻译相应的函数名而不是C++的;当它作为一个对函数或者全局变量的外部声明,提示编译器遇到此变量或函数时,在其它模块中寻找其定义。
- explicit的作用?
答:标明类的构造函数是显式的,不能进行隐式转换。
- constexpr的作用?
答:这个关键字明确的告诉编译器应该去验证(函数或变量)在编译期是否就应该是一个常数(这样编译器就可以大胆进行优化)。
- volatile的作用?
答:跟编译器优化有关,告诉编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的备份。
- mutable的作用?
答:可变的意思,使类中被声明为const的函数可以修改类中的非静态成员.
- auto和deltype的作用和区别?
答:用于实现类型自动推导,让编译器来操心变量的类型;auto不能用于函数传参和推导数组类型,但deltype可以解决这个问题。
1.7 左值右值,构造函数(⭐)
- 什么是左值和右值,什么是右值引用,为什么要引入右值引用?
【参考资料】《C++Primer》P121/471、左值和右值_coolwriter的博客-CSDN博客
(1)左值就是具有可寻址的存储单元,并且能由用户改变其值的量,比如常见的变量:一个int,float,class等。左值具有持久的状态,直到离开作用域才销毁;右值表示即将销毁的临时对象,具有短暂的状态,比如字面值常量“hello”,返回非引用类型的表达式int func()等,都会生成右值;
(2)右值引用就是必须绑定到右值的引用,可以通过&&(两个取地址符)来获得右值引用;右值引用只能绑定到即将销毁的对象,因此可以自由地移动其资源;
(3)右值引用是为了支持移动操作而引出的一个概念,它只能绑定到一个将要销毁的对象,使用右值引用的移动操作可以避免无谓的拷贝,提高性能。使用std::move()函数可以将一个左值转换为右值引用。(可以通过两个很长的字符串的直接赋值和移动赋值来测试一下性能的差距)。
- 为什么要自己定义拷贝构造函数?什么是深拷贝和浅拷贝?
(1)拷贝构造函数的作用就是定义了当我们用同类型的另外一个对象初始化本对象的时候做了什么,在某些情况下,如果我们不自己定义拷贝构造函数,使用默认的拷贝构造函数,就会出错。比如一个类里面有一个指针,如果使用默认的拷贝构造函数,会将指针拷贝过去,即两个指针指向同个对象,那么其中一个类对象析构之后,这个指针也会被delete掉,那么另一个类里面的指针就会变成野指针(悬浮指针);
(2)这也正是深拷贝和浅拷贝的区别,浅拷贝只是简单直接地复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存。 但深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会改到原对象。
- 什么是移动构造函数,和拷贝构造函数的区别?
C++11 移动构造函数_项脊轩-CSDN博客_c++ 移动构造
答:移动构造函数需要传递的参数是一个右值引用,移动构造函数不分配新内存,而是接管传递而来对象的内存,并在移动之后把源对象销毁;移动拷贝构造函数需要传递一个左值引用,可能会造成重新分配内存,性能更低。
1.8 内联函数与宏
- 内联函数有什么作用?存不存在什么缺点?
(1)作用是使编译器在函数调用点上展开函数,可以避免函数调用的开销;
(2)内联函数的缺点是可能造成代码膨胀,尤其是递归的函数,会造成大量内存开销,exe太大,占用CPU资源。此外,内联函数不方便调试,每次修改会重新编译头文件,增加编译时间。
- 内联函数和宏有什么区别,有了宏为什么还需要内联函数?
(1)define宏命令是在预处理阶段对命令进行替换,inline是在编译阶段在函数调用点处直接展开函数,节省了函数调用的开销;
(2)define的话是不会对参数的类型进行检查的,因此会出现类型安全的问题,比如定义一个max命令,但是传递的时候可能会传递一个整数和一个字符串,就会出错,但是内联函数在编译阶段会进行类型检查;
(3)使用宏的时候可能要添加很多括号,比较容易出错。
1.9 杂项
- C++11的新特性
(1)auto关键字,可以自动推断出变量的类型;
(2)nullptr来代替NULL,可以避免重载时出现的问题(一个是int,一个是void*);
(3)智能指针,那三个智能指针,对内存进行管理;
(4)右值引用,基于右值引用可以实现移动语义和完美转发,消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率;
(5)lambda表达式,可以理解为一个匿名的内联函数。
不足之处:没有CG(垃圾回收机制)、没有反射机制等。
- 指针和引用的区别
(1)指针本质是一个地址,有自己的内存空间,引用只是一个别名;
相关影片资源迅雷下载推荐
学软件开发有哪些基本要求?
想了挺久,决定还是来知乎对寻找一下答案。90后屌丝一个,中专毕业自考大专,大二上完果断跑路做销售去了。大三没有回学校拿毕业证。现在刚到北京一个多月,在亲戚的安排下现在一家影视制片公司实习中。觉得自己的路 ...
软件开发,学软件开发有哪些基本要求?
(2)指针可以指向其他的对象,但是引用不能指向其他的对象,初始化之后就不能改变了;
(3)指针可以初始化为nullptr,而引用必须被初始化为一个已有对象的引用;
(4)指针可以是多级指针,引用只能是一级。
- 重载、重写和隐藏的区别
(1)重载指的是同一个名字的函数,具有不同的参数列表(参数类型、个数),或不同的返回类型,根据参数列表和返回类型决定调用哪一个函数;
(2)重写(覆盖)指的是,派生类中的函数重写了基类中的虚函数,重写的基类的中函数必须被声明为virtual,并且返回值,参数列表和基类中的函数一致;
(3)隐藏是指,派生类中的同名函数把基类中的同名函数隐藏了,即基类同名函数被屏蔽掉;此时基类函数不能声明为virtual。
- Delete和Delete[]的区别,delete[]如何知道要delete多少次,在类的成员函数中能否Delete This?
【参考资料】:https://blog.csdn.net/cbNotes/article/details/38900799、
https://blog.csdn.net/kuimzzs/article/details/81517451
(1)若是基本类型,delete和delete[]效果是一样的,因为系统会自动记录分配的空间,然后释放;对于自定义数据类型而言(比如类)就不行了,delete仅仅释放数组第一个元素的内存空间,且仅调用了第一个对象的析构函数,但delete[]会调用数组所有元素的析构函数,并释放所有内存空间;
(2)这个问题直接导致我们需要在new []一个对象数组时,需要保存数组的维度,C++的做法是在分配数组空间时多分配了4个字节的大小,专门保存数组的大小,这个数据应该就存在这个分配返回的指针周围,在 delete[]时就可以取出这个保存的数,就知道了需要调用析构函数多少次了;
(3)在类的成员函数可以调用delete this,并且delete this之后还可以调用该对象的其他成员,但是有个前提:被调用的方法不涉及这个对象的数据成员和虚函数。当一个类对象声明时,系统会为其分配内存空间。在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中。
二、STL、数据结构、算法
【参考资料】:《STL源码剖析》相应章节
2.1 STL各种容器的底层实现?(⭐⭐⭐)
(1)vector,底层是一块具有连续内存的数组,vector的核心在于其长度自动可变。vector的数据结构主要由三个迭代器(指针)来完成:指向首元素的start,指向尾元素的finish和指向内存末端的end_of_storage。vector的扩容机制是:当目前可用的空间不足时,分配目前空间的两倍或者目前空间加上所需的新空间大小(取较大值),容量的扩张必须经过“重新配置、元素移动、释放原空间”等过程。
(2)list,底层是一个循环双向链表,链表结点和链表分开独立定义的,结点包含pre、next指针和data数据。
(3)deque,双向队列,由分段连续空间构成,每段连续空间是一个缓冲区,由一个中控器来控制。它必须维护一个map指针(中控器指针),还要维护start和finish两个迭代器,指向第一个缓冲区,和最后一个缓冲区。deque可以在前端或后端进行扩容,这些指针和迭代器用来控制分段缓冲区之间的跳转。
(4)stack和queue,栈和队列。它们都是由由deque作为底层容器实现的,他们是一种容器配接器,修改了deque的接口,具有自己独特的性质(此二者也可以用list作为底层实现);stack是deque封住了头端的开口,先进后出,queue是deque封住了尾端的开口,先进先出。
(5)priority_queue,优先队列。是由以vector作为底层容器,以heap作为处理规则,heap的本质是一个完全二叉树。
(6)set和map。底层都是由红黑树实现的。红黑树是一种二叉搜索树,但是它多了一个颜色的属性。红黑树的性质如下:1)每个结点非红即黑;2)根节点是黑的;3)如果一个结点是红色的,那么它的子节点就是黑色的;4)任一结点到树尾端(NULL)的路径上含有的黑色结点个数必须相同。通过以上定义的限制,红黑树确保没有一条路径会比其他路径多出两倍以上;因此,红黑树是一种弱平衡二叉树,相对于严格要求平衡的平衡二叉树来说,它的旋转次数少,所以对于插入、删除操作较多的情况下,通常使用红黑树。
补充:平衡二叉树(AVL)和红黑树的区别:AVL 树是高度平衡的,频繁的插入和删除,会引起频繁的rebalance(旋转操作),导致效率下降;红黑树不是高度平衡的,算是一种折中,插入最多两次旋转,删除最多三次旋转。
2.2 STL各种容器的查找、删除和插入的时间复杂度(性能比较)?(⭐⭐)
【参考资料】:C++STL各种容器的性能比较、【C++】STL各容器的实现,时间复杂度,适用情况分析_Y先森0.0-CSDN博客
(1)vector,vector支持随机访问(通过下标),时间复杂度是O(1);如果是无序vector查找的时间复杂度是O(n),如果是有序vector,采用二分查找则是O(log n);对于插入操作,在尾部插入最快,中部次之,头部最慢,删除同理。vector占用的内存较大,由于二倍扩容机制可能会导致内存的浪费,内存不足时扩容的拷贝也会造成较大性能开销;
(2)list由于底层是链表,不支持随机访问,只能通过扫描的方式查找,复杂度为O(n),但是插入和删除的速度快,只需要调整指针的指向。(有一种说法是链表每次插入和删除都需要分配和释放内存,会造成较大的性能开销,所以如果频繁地插入和删除,list性能并不好,但很多地方都说list插入删除性能好,这点我还没有验证,希望有人能指出);list不会造成内存的浪费,占用内存较小;
(3)deque支持随机访问,但性能比vector要低;支持双端扩容,因此在头部和尾部插入和删除元素很快,为O(1),但是在中间插入和删除元素很慢;
(4)set和map,底层基于红黑树实现,增删查改的时间复杂度近似O(log n),红黑树又是基于链表实现,因此占用内存较小;
(5)unordered_set和unordered_map,底层是基于哈希表实现的,是无序的。理论上增删查改的时间复杂度是O(1)(最差时间复杂度O(n)),实际上数据的分布是否均匀会极大影响容器的性能。
2.3 STL怎么做内存管理的,Allocator次级分配器的原理,内存池的优势和劣势?
(1)为了提升内存管理的效率,减少申请小内存造成的内存碎片问题,SGI STL采用了两级配置器,当分配的空间大小超过128B时,会使用第一级空间配置器,直接使用malloc()、realloc()、free()函数进行内存空间的分配和释放。当分配的空间大小小于128B时,将使用第二级空间配置器,采用了内存池技术,通过空闲链表来管理内存。
(2)次级配置器的内存池管理技术:每次配置一大块内存,并维护对应的自由链表(free list)。若下次再有相同大小的内存配置,就直接从自由链表中拔出。如果客户端释还小额区块,就由配置器回收到自由链表中;配置器共要维护16个自由链表,存放在一个数组里,分别管理大小为8-128B不等的内存块。分配空间的时候,首先根据所需空间的大小(调整为8B的倍数)找到对应的自由链表中相应大小的链表,并从链表中拔出第一个可用的区块;回收的时候也是一样的步骤,先找到对应的自由链表,并插到第一个区块的位置。
(3)优势:避免内存碎片(这里应该指的是外部碎片),不需要频繁从用户态切换到内核态,性能高效;劣势:依旧会造成一定的内存浪费,比如申请120B就必须分配128B(内部碎片)。
2.4 STL容器的push_back和emplace_back的区别?
【参考资料】《C++ Primer》P308、C++11使用emplace_back代替push_back_华秋实的专栏-CSDN博客
答:emplace/emplace_back函数使用传递来的参数直接在容器管理的内存空间中构造元素(只调用了构造函数);push_back会创建一个局部临时对象,并将其压入容器中(可能调用拷贝构造函数或移动构造函数)
2.5 STL的排序用到了哪种算法,具体如何执行?
【参考资料】:https://feihu.me/blog/2014/sgi-std-sort/
答:快速排序、插入排序和堆排序;当数据量很大的时候用快排,划分区段比较小的时候用插入排序,当划分有导致最坏情况的倾向的时候使用堆排序。
2.6 各种排序算法的原理和时间复杂度?
【参考资料】:八大常用排序算法详细分析
(1)快排:一轮划分,选择一个基准值,小于该基准值的元素放到左边,大于的放在右边,此时该基准值在整个序列中的位置就确定了,接着递归地对左边子序列和右边子序列进行划分。时间复杂度o(nlogn),最坏的时间复杂度是o(n2);
(2)堆排序:利用完全二叉树性质构造的一个一维数组,用数组下标代表结点,则一个结点的左孩子下标为2i+1,右孩子为2i+2,一个结点的父节点为(i-1)/2。堆排序的思想就是,构造一个最大堆或者最小堆,以最大堆为例,那么最大的值就是根节点,把这个最大值和最后一个结点交换,然后在从前n-1个结点中构造一个最大堆,再重复上述的操作,即每次将现有序列的最大值放在现有数组的最后一位,最后就会形成一个有序数组;求升序用最大堆,降序用最小堆。时间复杂度O(nlogn);
(3)冒泡排序:从前往后两两比较,逆序则交换,不断重复直到有序;时间复杂度O(n2),最好情况O(n);
(4)插入排序,类似打牌,从第二个元素开始,把每个元素插入前面有序的序列中;时间复杂度O(n2),最好情况O(n);
(5)选择排序,每次选择待排序列中的最小值和未排序列中的首元素交换;时间复杂度O(n2);
(6)归并排序,将整个序列划分成最小的>=2的等长序列,排序后再合并,再排序再合并,最后合成一个完整序列。时间复杂度O(nlogn)。
(7)希尔排序,是插入排序的改进版,取一个步长划分为多个子序列进行排序,再合并(如135一个序列,246一个序列),时间复杂度O(n1.3),最好O(n),最坏O(n2);
(8)桶排序,将数组分到有限数量的桶里。每个桶再个别排序,最后依次把各个桶中的记录列出来记得到有序序列。桶排序的平均时间复杂度为线性的O(N+C),其中C=N*(logN-logM),M为桶的数量。最好的情况下为O(N)。
2.7 如何在一个序列中求前k个最大或者最小的数?
(1)基于快排,每轮划分选择一个基准值,把比它小的数放在左边,大的放在右边,函数返回基准值的位置,如果该位置恰好是K,就说明了这是第K小的数,所以从0-基准值位置的数是序列中的前K小数。若返回基准值的位置小于或者大于K,再进行相应调整:如果返回的基准值大于k,在基准值左边序列查找,如果大于,在基准值右边进行查找。递归地进行快排,直到返回的结果=K;时间复杂度为O(n)。
(2)基于堆排序,求前K个最小的数用最大顶堆,求前K个最大的数用最小顶堆。以最大顶堆为例,要维护一个大小为K的顶堆,就是先将K个数插入堆中,随后,对每一个数,与堆顶的最大元素比较,若该数比堆顶元素小,则替换掉堆顶元素,然后调整堆,若大于堆顶元素,则不管,那么将所有元素比较和插入后,该堆维护的就是最小的K个数。求前k小的数用最大顶堆的目的(原理):这是一种局部淘汰的思想,尽量的把小的数都放在堆中,最后使得即使堆中最大的数,也比外界的所有数都小,就达到了目的。
8. 如何尽可能快地在一个序列中移除指定条件的元素,并不改变其他元素的相对顺序(Remove_If算法的原理),比如一个序列{1,3,2,5,2,2,6,7}移除序列中所有的2?
先找到第一个符合条件的元素的位置,然后从这个位置开始,往后扫描不符合条件的元素,逐个的安插到这个位置以及之后的位置,无论是什么元素都直接覆盖(可以用一个指针指向这个要安插和覆盖的位置,往后步进)。该算法时间复杂度为O(n)。
2.8 什么是哈希表?哈希表的长度为什么要是质数?如何处理冲突?哈希表怎么删除一个元素?
【参考资料】图文并茂详解数据结构之哈希表 - 知乎 (zhihu.com)
(1)哈希表是一种根据关键码值直接访问数据的数据结构,它通过把关键码值映射到表中的一个位置来访问元素,以加快查找的速度。这个映射函数叫做哈希函数;
(2)哈希表的长度使用质数,可以降低发生冲突的概率,使哈希后的数据更加均匀,如果使用合数,可能会导致很多数据集中分布到一个点上,造成冲突;
(3)解决冲突的办法有开放定址法和拉链法,开放定址法包括线性测探、平方测探法;
(4)线性测探法并不会真正的删除一个元素,而是做一个标记,否则可能会导致正常的查找出错(利用线性探测法解决hash冲突 - 寻觅beyond - 博客园 (cnblogs.com))
2.9 如何用栈实现一个队列?
经典问题,用栈实现队列和用队列实现栈 - wzyy - 博客园 (cnblogs.com)
2.10 如何判断链表是否有环?
最快的方法就是用快慢指针法,【算法】如何判断链表有环_Mlib-CSDN博客_判断链表是否有环
三、工程问题
3.1编译链接原理,从C++源文件到可执行文件的过程?(⭐⭐)
【参考资料】:https://www.cnblogs.com/dongdongweiwu/p/4743709.html
答:包括四个阶段:预处理阶段、编译阶段、汇编阶段、连接阶段。
(1)预处理阶段处理头文件包含关系,对预编译命令进行替换,生成预编译文件;
(2)编译阶段将预编译文件编译,生成汇编文件(编译的过程就是把预处理完的文件进行一系列的词法分析,语法分析,语义分析及优化后生成相应的汇编代码);
(3)汇编阶段将汇编文件转换成机器码,生成可重定位目标文件(.obj文件)(汇编器是将汇编代码转变成机器可以执行的命令,每一个汇编语句几乎都对应一条机器指令。汇编相对于编译过程比较简单,根据汇编指令和机器指令的对照表一一翻译即可);
(4)链接阶段,将多个目标文件和所需要的库连接成可执行文件(.exe文件)。
3.2 动态库静态库,二者的含义、区别,以及如何选择?(⭐)
【参考资料】:C++静态库与动态库 - 吴秦 - 博客园 (cnblogs.com)、计算机那些事(5)——链接、静态链接、动态链接 | 楚权的世界 (chuquan.me)、动态链接库与静态链接库有什么区别? - 知乎 (zhihu.com)(这部分自己还没有梳理清楚,先放几个参考链接,回头再补上)
3.3 vector如何手动释放内存
【参考资料】https://www.cnblogs.com/summerRQ/articles/2407974.html
比如有一个vector<int>nums, 比较hack的一种方式是nums = {},这样既可以清空元素还会释放内存(从一个大佬身上学来的);规范的做法是,vector<int>().swap(nums)或者nums.swap(vector<int> ())。
3.4 如何拷贝一段数据
(1)C++语法:std::copy(src.begin(), src.end(), dest.begin());
std::copy_n(src.begin(), int n, dest.begin());
(2)C风格语法:memcpy(void* dest, const void* src, size_t num)
3.5 对象池思想
【参考资料】:对象池--C++对象池的实现_码农之初心-CSDN博客
对于那些需要频繁创建和销毁的对象,对象池的思想是,首先从对象池中寻找有没有可用的对象,如果没有,就创建对象来使用,然后当一个对象不使用的时候,不是把它删除,而是将它设置为不激活的状态并放入对象池中,等待需要使用的时候再去对象池中寻找,并把它激活。
3.6 设计模式
(1)单例模式:(⭐⭐)
【参考资料】:C++ 单例模式 - 知乎 (zhihu.com)
单例模式保证保证全局只有唯一一个自行创建的实例对象,并由单例类提供获取这个唯一实例的接口。主要有两种实现方法:
1)懒汉式,用到的时候才会加载,线程不安全,需要加锁;
2)饿汉式,在main函数开始的时候即创建对象,线程安全;
C++11标准之后的最佳的选择是Meyers' Singleton(属于懒汉式),它利用了局部静态变量在第一次使用时才初始化的特性,并且由于C++11标准解决了局部静态变量的线程安全问题,使得它成为当前最简洁也最高效的实现方式。
(2)工厂模式:
【参考资料】:C++ 深入浅出工厂模式(初识篇) - 知乎 (zhihu.com)
该模式用来封装和管理类的创建,终极目的是为了解耦,实现创建者和调用者的分离。
工厂模式分为三种:
1)简单工厂,一个工厂生产多种产品,要指定产品的名字进行生产;
2)普通工厂,将产品生产分配给多个工厂,但是每个工厂只生产一种产品;
3)抽象工厂,将产品生产分配给多个工厂,每个工厂可以生产多种产品;
四、操作系统、计算机组成、计算机网络
4.1 操作系统
【参考资料】:操作系统最强面经面试题总结 | 春招秋招必备 | Offer收割_LonelyPlanet_的博客-CSDN博客_操作系统面经
- 进程和线程的区别?(⭐⭐)
(1)进程是运行时的程序,是系统进行资源分配和调度的基本单位,它实现了系统的并发;
(2)线程是进程的子单位,也称为轻量级进程,它是CPU进行分配和调度的基本单位,也是独立运行的基本单位,它实现了进程内部的并发;
(3)一个程序至少拥有一个进程,一个进程至少拥有一个线程,线程依赖于进程而存在;
(4)进程拥有独立的内存空间,而线程是共享进程的内存空间的,自己不占用资源;
(5)线程的优势:线程之间的信息共享和通讯比较方便,不需要资源的切换等.
- 什么是死锁,死锁的条件以及如何防止?什么是银行家算法?(⭐⭐)
(1)死锁就是多个进程并发执行,在各自占有一定资源的情况下,希望获得其他进程占有的资源以推进执行,但是其他资源同样也期待获得另外进程的资源,大家都不愿意释放自己的资源,从而导致了相互阻塞、循环等待,进程无法推进的情况。
(2)死锁条件:1)互斥条件(一个资源每次只能被一个进程使用);2)请求并保持条件(因请求资源而阻塞时,对已获得的资源保持不放);3)不剥夺条件(在未使用完之前,不能剥夺,只能自己释放);4)循环等待(若干进程之间形成一种头尾相接的循环等待资源关系)。
(3)死锁防止:1)死锁预防,打破四个死锁条件;2)死锁避免,使用算法来进行资源分配,防止系统进入不安全状态,如银行家算法;3)死锁检测和解除,抢占资源或者终止进程;
(4)银行家算法是一种最有代表性的避免死锁的算法。在避免死锁方法中允许进程动态地申请资源,但系统在进行资源分配之前,应先计算此次分配资源的安全性,若分配不会导致系统进入不安全状态,则分配,否则等待。为实现银行家算法,系统必须设置若干数据结构。安全的状态指的是一个进程序列{P1,P2,...Pn},对于每一个进程Pi,它以后尚需要的资源不大于当前资源剩余量和其余进程所占有的资源量之和。
- 操作系统如何管理内存,什么是虚拟内存?
通过一种分页管理机制来进行内存管理。分页管理机制将程序的逻辑地址划分为固定大小的页,而物理内存划分为同样大小的帧,程序加载时,可以将任意一页放入内存中任意一个帧,这些帧不必连续,从而实现了离散分离。虚拟内存是基于分页存储管理机制的,它允许程序不必将所有的页都放入内存中,而只是将一部分页映射到内存中,另一部分页放在外存上(如磁盘、软盘、USB),当引用到不在内存中的页时,系统产生缺页中断,并从外存中调入该部分页进来,从而产生一种逻辑上内存得到扩充的感觉,实际上内存并没有增大。
- 什么是内存碎片,内存碎片是在虚拟内存还是物理内存?
【参考资料】:内存碎片_百度百科 (baidu.com)
采用分区式存储管理的系统,在储存分配过程中产生的、不能供用户作业使用的主存里的小分区称成“内存碎片”。内存碎片分为内部碎片和外部碎片。内存碎片只存在于虚拟内存上。
4.2 计算机组成
【参考资料】:《计算机组成原理》课本(唐朔飞版)、Cache的基本原理 - 知乎 (zhihu.com)
- 什么是缓存(Cache)?为什么需要缓存?如何提高缓存的命中率?缓存是不是最快的?(⭐⭐)
(1)Cache即CPU的高速缓冲存储器,是一种是用于减少处理器访问内存所需平均时间的部件;
(2)由于CPU的计算速度远远大于从CPU向内存取数据的速度,如果每次都让CPU去内存取数据,会导致CPU计算能力的浪费,所以人们设计了缓存,CPU通过读写缓存来获取操作数,结果也通过缓存写入内存;
(3)注意程序的局部性原理,在遍历数组时按照内存顺序访问;充分利用CPU分支预测功能,将预测的指令放到缓存中执行;此外缓存的容量和块长是影响缓存效率的重要因素。如何提升CPU的缓存命中率? - 知乎 (zhihu.com)
(4)缓存不是最快的,寄存器更快。
- 什么是缓存一致性,如何保证缓存一致性?
(1)在多核CPU中,每个核有自己的缓存,在两个核进行独自修改缓存中的数据的时候,就可能会造成数据不一致的问题,就是缓存的一致性问题;
(2)一个是在总线中加锁,一个是采用缓存一致性协议。
- 一个缓存块的大小是多少,读取内存中的字段是读多少数据取多少内存吗?
如果缓存没有命中(即读取一个数据没有在缓存中),不仅需要把该字(数据)从主存中取出,还需要从主存中将它所在的整个字块一次调入缓存中。缓存线(块)的长度是64B。
4.3 计算机网络
- TCP和UDP的区别?
(1)TCP是传输控制协议,UDP是用户数据报协议;
(2)TCP是面向连接的,可靠的数据传输协议,它要通过三次握手来建立连接,UDP是无连接的,不可靠的数据传输协议,采取尽力而为的策略,不保证接收方一定能收到正确的数据;
(3)TCP面向的是字节流,UDP面向的是数据报;
(4)TCP只支持点对点,UDP支持一对一,一对多和多对多;
(5)TCP有拥塞控制机制,UDP没有。
尾记
因为个人能力有限,这篇文章难免会有一些错漏和不够准确的地方,还希望各位大佬指正。尤其是如果你遇到一些面试中的高频问题但没有被这篇文章收录的,也欢迎评论或者私信指出。
最后我想引用我最喜欢的游戏:《侠客风云传》中主角的一句台词来结束 ——“一点一滴的磨练,都是通往强者之路的基石。”希望大家都能向着自己热爱的事情去努力和坚持,总有一天会看到收获的。
哎~ 码字不易,终于结束了。如果你觉得有帮助,请不要吝惜点赞哦,谢谢!我的愿望给那些热爱游戏、想要进入游戏行业的人带来一些真正的帮助,让那些迷茫着准备秋招的人在苦苦搜寻面经的时候能看到这篇文章,也让这篇文章发挥最大的价值。祝大家都能早日拿到理想的OFFER~(尤其是点赞的人)。
下一篇将是客户端面试考察得比较多的《图形学基础篇》,对于引擎岗的同学,这几乎是必问的,敬请期待~
11.8更新:
企业网站建设与开发最低只要299元起,包含域名服务器,需要的联系QQ345424724,电话15516990022,18530226930相关影片资源迅雷下载推荐
小程序开发快速入门教程(附源码)
五分钟上手-微信小程序1:用没有注册过微信公众平台的邮箱注册一个微信公众号, 申请帐号,点击 https://mp.weixin.qq.com/wxopen/waregister?action=step1 根据指引填写信息和提交相应的资料,就可以拥有自己的小程 ...
小程序开发,小程序开发快速入门教程(附源码)