C++ 知识点

转载: 

【游戏开发面经汇总】- 计算机基础篇 - 知乎 (zhihu.com)

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)指针本质是一个地址,有自己的内存空间,引用只是一个别名;

(2)指针可以指向其他的对象,但是引用不能指向其他的对象,初始化之后就不能改变了;

(3)指针可以初始化为nullptr,而引用必须被初始化为一个已有对象的引用;

(4)指针可以是多级指针,引用只能是一级。

  • 重载、重写和隐藏的区别

(1)重载指的是同一个名字的函数,具有不同的参数列表(参数类型、个数),或不同的返回类型,根据参数列表和返回类型决定调用哪一个函数;

(2)重写(覆盖)指的是,派生类中的函数重写了基类中的虚函数,重写的基类的中函数必须被声明为virtual,并且返回值,参数列表和基类中的函数一致;

(3)隐藏是指,派生类中的同名函数把基类中的同名函数隐藏了,即基类同名函数被屏蔽掉;此时基类函数不能声明为virtual。

  • Delete和Delete[]的区别,delete[]如何知道要delete多少次,在类的成员函数中能否Delete This?

   delete   ptr   代表用来释放内存,且只用来释放ptr指向的内存。 
   delete[]   rg   用来释放rg指向的内存,!!还逐一调用数组中每个对象的destructor!!

【参考资料】: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博客_判断链表是否有环

 

你可能感兴趣的:(C++,c++)