要和面试官聊起来
1. C++中引用和指针的概念和区别?
我第一次接触引用和指针的时候是在学C++时,有个swap函数,原来在C语言用swap来交换两个变量的值的话,实参到形参传值是无法交换的,最终在C语言如果要通过swap函数交换两个变量的值的话必须传变量的指针,在C++里面做同样的事情发现不仅可以传指针还可以传引用来交换两个变量的值。最后我把这两个函数都打出来,然后打断点调试,跳到汇编查看汇编代码,虽然这个swap函数在语言层面上传指针和传引用是不一样的,但是在汇编层面他们的汇编指令是一模一样的。
用32位系统来说,定义一个指针底层需要开辟4个字节,然后把它所指向的变量的内存地址放到这4B里面;定义一个引用实际上也是在栈上要开辟4B,然后把它所引用的变量的内存地址放到这4B里。当我通过指针解引用的时候,它实际上是从指针的4B里面取出它所指向的内存的地址,然后去访问它所指向的内存;当通过引用变量来引用它所指向内存的时候,实际上汇编上也是先从之前底层开辟好的那4B的内存里面拿出来自动做了一个解引用操作。可以看出在汇编上指针和引用是很相似的。
我在开发的时候再去写代码尤其在C++里,尽量使用的是引用,因为引用相对指针比较安全,必须初始化的。指针也不是不好,因为C和C++最大的特点就是通过指针可以在内存上漫游,通过指针的加减可以在内存上进行偏移,但是引用不能偏移,因为你只要使用引用它就自动解引用了,而指针给我们的扩展性就比较大了,如果你没有去解引用指针,给指针加加减减就相当于在进行偏移,还可以求两个指针之间的距离,引用就没有办法去做和内存相关的事情,因为它直接就是解引用,它永远访问的是它所引用内存的值。
不应该用指针保留一个临时对象,因为出了这个语句后指针指向的是一个已经析构的临时对象;而可以用引用来保留一个临时对象,出了语句临时对象不析构,因为引用相当于一个别名,临时对象的生命周期会变成引用变量的生命周期。
sizeof(指针) = 4B sizeof(引用) = 引用对象的大小
2. 地址传递和引用传递的区别?
3. 实际应用中传递一个很大的数组,不想去改变它,但是还不想让它拷贝,怎么做?
这个题目是面试官挖的坑,题目就是错的。
回答:在这个问题中,有一部分是成立的,一部分是不成立的,首先在C++中,传递数组根本不会发生拷贝,因为数组在函数传递过程中它实际上传的是数组的起始地址,而不会针对整个数组所有元素进行拷贝,所以在传递数组的时候,既然只传递了数组的起始地址,所以在正确使用数组传递的时候,还应该把数组长度传入函数里面。
您的这个问题里面又出现了不想去改变它,如果说传数组传的是数组的首地址,还是在被调函数中通过地址解引用修改数组内部,如果我想控制只使用数组而不想去改变它,那我需要把形参接收数组首地址的指针设置成const*
不仅是传数组,在C++里面就比如像vector容器,它是STL里面非常重要的一个组件,我们实际使用的时候一般使用vector,因为它相比数组的区别是:直接使用数组的话,就会考虑到数组内存是否够用、是否需要扩容以及扩容代码如何写,将会和使用数组业务逻辑的代码混到一起,看起来很乱。vector是数组的一个面向对象表示,把数组封装起来了。我们想用一个可以自动扩容的数组直接用vector就行,想添加就添加,想删除就删除,永远也不用担心数组是否越界以及内存够不够。
当我去传递一个容器的时候,容器和数组不一样,它是一个对象,实参到形参它是以拷贝构造的方式传递,如果函数中传递一个容器,那么性能就降很多,它要通过拷贝构造的方式,要根据实参的vector容器底层的内存的尺寸要给形参拷贝内存,此时应该按引用传递来接收实参的容器,但通过引用也可以修改容器,那么形参用const常引用就不会改变这个容器。
还可以再说:当我在学C和C++的时候,我发现const在C和C++里面有不同的表现,可以问面试官我需不需要再说一下。
4. const A* p和A* const p的区别?
const A* p
表示不可修改p指针指向的值,A* const p
表示不可改变p指针的指向。
一般前一个用在调用一个函数的时候,实参是通过指针传实参的地址,形参是按指针来接收的,在形参里面只想访问实参而不想去修改实参,形参的一般用const A* p
来修饰,这样一来可以读*p,但是不能去写*p,有效的保护了在函数调用中只使用实参的值而不去修改实参的值。
A* const p
在C++里面的使用是非常常见的,因为C++最大的特点就是类,类里面的普通方法都得经过对象来调用,对象一调用就整成了普通方法调用并把对象的地址当作实参传进去,所以形参会生成一个this指针,this指针需要通过this指向能够修改它所指向对象的值、成员变量的值,所以this指针的指向是不能被修饰成const的,所以*this是可以被赋值的,但是this本身不能让它指向其他的对象,不能给this赋值,所以this指针就是A* const p
。
const修饰的量都是常量,常量不能被修改,定义一个const int a = 10;
怎么修改a的值?int *p = (int*)&a;
const修饰的常量不能被修改只是在编译阶段,实际上在汇编上const修饰的这块内存没有改变内存的属性,所以const只是立足于语法层面,编译阶段编译器能够检测出来你对一个const关键字修饰的常量赋值了,但是编译后产生的汇编指令上可以改变,也可以这样修改const int a = 10 asm {mov dword ptr[ebp-4],14h}
。
还可以说在C和C++上const是有区别的,在C里面const是常变量,在C++里面const是常量。
5. 成员函数后加const是什么意思?会有什么样的限制?
当我去定义一个常对象,用常对象去调用一个普通方法的话,发现是调用不了的,因为C++里面用一个对象调用一个方法汇编以后调用过程还是一个C函数的调用,只是把调用方法的对象地址当作实参传进去了,所以在成员方法编译的时候都会加一个形参this,但是this是一个普通指针,常对象却是一个const常指针,一个普通指针怎么能接收一个const常指针呢?这就涉及了类型转换。所以当常对象调用成员方法的时候这个成员方法要加const的原因,得加在函数的后面,让这个方法普通的this指针变成const* this,这样形参的const* 就可以接收实参的const*,这样常对象就可以调用常成员方法了。
会有什么限制?
在这个常方法里只能访问对象的成员变量而不能修改,因为const Type* this
,const修饰*this,不能通过this修改它所指向的东西。当然这个呢C++还考虑到了,如果给成员变量修饰一个mutable,在常方法里面依然可以修改成员变量。
6. 拷贝构造初始化和列表初始化区别?类中const成员如何初始化?
初始化列表就是指定了初始化,如果有成员对象的话,初始化列表就指定了对象构造的方式。拷贝构造初始化说明了成员变量或者成员对象已经构造过了,实际上在构造函数的{}之间只是做了个赋值操作。
所以像定义变量必须初始化的东西必须放在初始化列表进行初始化,如:const成员变量,引用成员变量。也可以说类成员变量里面有很多容器,比如vector,如果想指定vector初始化就得放在初始化列表。
7. vector如何管理内存?
vector在编程的时候经常会用到,当你想要一个顺序数组的时候就可以用vector,vector相当于把一个数组封装起来,而且提供了带[]运算符的重载函数,可以向使用数组一样去访问容器的下标,所以它底层是一个数组,但是它相比于数组来说,直接使用数组的话,就会考虑到数组内存是否够用、是否需要扩容以及扩容代码如何写,将会和使用数组业务逻辑的代码混到一起,看起来很乱。vector是数组的一个面向对象表示,把数组封装起来了。我们想用一个可以自动扩容的数组直接用vector就行,想添加就添加,想删除就删除,永远也不用担心数组是否越界以及内存够不够。
当我们在windows下的vs2017,2019上用vector会发现它是1.5倍扩容的,当在Linux下用gcc,g++去编译vector相关代码会发现它是2倍扩容的。这个扩容我们是可以通过实践代码测出来的,vector有两个方法:compacity返回的是它底层数据结构的容量,size返回它真正元素的个数。我们可以默认构造一个vector,然后循环去添加值,每次循环打印size和compacity,就能看出来往vector容器放元素的时候它的扩容是怎么进行的。
vector作为C++的一个容器,它的功能非常强大,除了compacity和size方法还有很多非常有用的方法。。。
vector容器,不仅仅是vector容器,所有C++ STL容器在去管理底层对象内存的时候,不可能直接用new和delete。所以容器都必须依赖于空间配置器Allocator,它里面提供四个方法:construct,destroy,allocate和deallocate,前两个方法主要负责对象的构造和析构,后两个方法主要负责内存的开辟和释放,也就是说在vector管理内存的时候,一定要把对象new操作的内存开辟和对象构造分开,把对象delete操作的内存释放和对象析构分开。分开的原因:当我们去初始化一个容器的时候,则这个容器应该是空的,即它底层只有内存不应该有对象,但如果在容器构造直接用new,它不仅会开辟内存还会构造很多不用的对象,但这些对象不是我们需要的;当我们从容器中把对象删除的时候,应该只是把对象析构掉,并不需要释放对象的内存,因为这个内存是我们容器的内存,后面可能还会用,这就不能直接使用delete了。所以vector底层内存管理都是靠allocator来管理,在C++库里面提供了一个默认的allocator,它的allocate和deallocate内存管理方式用的是默认的malloc和free,当然如果说在应用层使用vector比较频繁而且是小块内存的话,可以采用内存池来替换原本的malloc和free。
8. vector什么时候会发生迭代器失效?
当我们去用vector解决问题的时候,有可能会涉及到遍历一个vector,如果仅仅是遍历,通过for或者是foreach来读取它应该是没有什么问题的,迭代器也不会发生失效。但是当用一个循环给vector做添加删除操作通过一个迭代器的时候,就可能出现迭代器失效。首先明确一点vector里的insert和erase接收的都是迭代器,实际上不仅仅是vector,相关的容器它的成员方法一般接收的都是迭代器,为什么容器的方法都接受迭代器呢?我打算一会在后边再说,我先在这阐述一下迭代器失效问题。
迭代器就是迭代一个容器,实质上是对一个指针的封装。用vector来说,vector迭代器实际上底层就是一指针,迭代器底层的指针指向的是vector容器底层的数组,迭代器通过提供运算符重载函数,以相同的方式可以遍历不同的容器,因为不同容器的遍历方式都被封装在迭代器的++--运算符。
假如用一个for循环给vector多次insert,甚至一个insert都会导致vector扩容,内存位置都变了,原来的迭代器肯定失效了,其实我们容器在底层实现的时候就在去判断用迭代器迭代的过程中,容器的元素有没有变化,如果变化了迭代器就失效了,如何去处理呢?那么insert和erase方法插入和删除完成后会返回新位置的迭代器,需要把迭代器的循环变量更新一下。如果是个erase,删除是不会引起扩容的,但是根据数组的删除原理,把当前元素删除后,后面所有元素都应该向前移动,此时erase是从首元素迭代器到当前删除元素迭代器还是有效的,但是从当前删除位置到后面末尾所有元素的迭代器如果之前持有的话,那么就全部失效了不能再使用,因为元素的位置移动了。所以不管是调用insert还是erase,都需要用返回值把迭代器的循环变量更新一下就可以了。
迭代器对于容器来说真的非常重要,首先迭代器不是所有容器所共享的,每一种容器都有自己的迭代器,因为每一种容器底层数据结构是不一样的。但实际上当我去写代码的时候,用迭代器来遍历容器的时候,迭代器遍历容器的代码是一模一样的,因为迭代器提供了++--运算符重载函数,虽然不同容器不同数据结构它的元素迭代方式不一样,比如数组、链表、哈希表、红黑树,但是不同数据结构不同的迭代方式都被封装在迭代器的++--运算符重载函数,所以用迭代器操作不同的容器实际上代码一模一样。不仅仅容器增加删除用的迭代器,泛型算法也都是迭代器,我平常用的时候发现了这么一个特点。
9. 用拷贝构造给一个复杂vector赋值不太好,如何解决?
因为会涉及到一个对象的构造再赋值,赋值的过程中按照右边对象的尺寸给左边对象底层开辟内存,再把右边底层对象的内存一个个拷贝构造给左边对象,然后因为右边是一个临时对象,出语句后要析构,析构又要做一系列的对象析构和内存释放,这效率是非常低的。
解决:调用带右值引用参数的拷贝构造或者是赋值函数,视情况采用std::move调用vector的右值引用拷贝构造,因为右值引用又称作移动语义,不会再根据右边尺寸给左边开辟内存再拷贝对象,而是直接把右边的资源拿过来用。
还可以延伸:vector除了拷贝构造和赋值,它的其他方法也都提供了带右值引用参数的方法,为了提高容器使用中的效率。
10. map和multimap的区别
map和multimap都属于映射表,主要是处理数据有关联关系的键值对,比如说用一个学生的学号对应一个学生的数据,通过快速的查找学号可以得到学生数据。那么map和multimap主要的区别在于map的key不能重复,multimap的key可以重复,它们底层的数据结构都是红黑树,所以增删查的时间复杂度为O(logn),效率还是挺高的。因为底层是红黑树,所以一般用在对key有排序要求的应用场景,实际上大部分场景对key的有序性是没有要求的,所以一般我在使用的时候或者看一些C++标准库里面的代码,实际上unordered_map和unordered_set使用的是比较的多的,他们的底层是链式哈希表,因为哈希表的增删查都比较容易。
红黑树的应用场景很多,比如linux的虚拟内存管理用的就是红黑树,效率很高的,实际上在linux早期的内核版本,虚拟地址空间区域的管理用的并不是红黑树,而是AVL树,最后又改成红黑树了,因为红黑树相比于AVL树的优势是。。。还可以说下平衡树,B树和B+树,比如可以说还有基于IO操作的平衡树B+树,这是数据库里面索引完成用的,它的好处是......
11. 智能指针
在C语言动态分配内存用的是malloc和free,在C++里面用的是new和delete,但是这样手动去开辟和释放内存存在一个很大的问题就是有可能把free和delete忘写了,或者说是其他资源像文件忘关闭了。为了解决这个问题,当我们向操作系统申请资源,一般是希望它能够自动释放,这就是智能指针本质上的一个含义,利用栈上的对象出作用域自动析构的特点,把资源的释放写在智能指针对象的析构函数里面。
智能指针分为不带引用计数的和带引用计数的智能指针。
不带引用计数的智能指针有auto_ptr,scoped_ptr和unique_ptr,auto_ptr有一个问题就是会自动交出它所指向对象的所有权,所以auto_ptr在拷贝构造和赋值以后,原来的auto_ptr就不能再使用了,如果你使用的话就采坑了,关键是原来的东西没有任何的防范措施能使用,如果对auto_ptr的底层不了解的话,万一经过拷贝构造和赋值以后使用原来的auto_ptr就会出错。所以不建议使用auto_ptr,auto_ptr不能用在容器里面,因为容器的拷贝构造会涉及到每一个元素的拷贝构造或者赋值会出错。unique_ptr使我们所推荐的,它去掉了左值的拷贝构造和赋值,又提供了带右值引用的拷贝构造和赋,好处是。。。
如果说我想让多个智能指针去持有资源、管理资源,对于不带引用计数的智能指针是做不到的,得用带引用计数的智能指针,为什么带引用计数,因为多个智能指针指向资源不能释放资源多次,只有最后一个想释放资源的智能指针才能够去释放资源,这就需要用引用计数来做到,对于引用计数的加减,带引用计数的智能指针像shared_ptr底层就是通过CAS操作来保证引用计数加减的原子操作,所以带引用计数的智能指针是可以使用在多线程环境中的。
有了shared_ptr为什么还要weak_ptr?因为如果只使用强智能指针shared_ptr会出现交叉引用问题,解决这个问题就需要weak_ptr。定义对象的时候用强智能指针,引用对象的时候用弱智能指针。 shared_ptr和weak_ptr还可以解决多线程访问共享C++对象的线程安全问题。
enable_shared_from_this和shared_from_this获取当前对象的一个智能指针。
C++11给我们提供了非常强大的东西make_shared,它是补充shared_ptr的坑,实际上shared_ptr存在一个很大的问题,因为它的操作分为两步:第一个就是在构造shared_ptr的时候会给它传入new的一块内存,第二步实际上shared_ptr会给它底层new一个控制资源的控制块,上面记录了资源的地址还有资源的引用计数。假设当我们去构造一个shared_ptr的时候,我们让它管理的外部资源new成功了,但是它底层的控制块new失败的话怎么办,相当于shared_ptr本省没有创建起来,那么最后管理的那个资源它最后是不会帮我们释放的。所以C++11给我们提供了make_shared来获取一个指向资源的shared_ptr,它是非常安全的。
C++14以后提供了make_unique,就是补我们unique_ptr的坑。
12. 设计:10万个正整数,取值范围0到100w,互不重复,给定一个数字判断是否在这些数中出现过?
这道题就是在查重。哈希表,位图法,布隆过滤器
查重首先能想到哈希表。先用这10万个正整数构建一个哈希表,然后再用一个给定的数字在哈希表里面查找,哈希表的增删查时间复杂度都是O(1),相当快。
接下来面试官会问用哈希表解决有什么缺陷或者不好的地方在哪里?
哈希表是空间换时间的结构。比如我们常见的链式哈希表,链式哈希表就是发生哈希冲突的元素放在一个桶中,用一个节点来表示一个元素,一个节点包含了这个元素以及下一个节点的地址,如果拿我们的正整数占4B来说的话,有10万个正整数,意味着构建一张哈希表需要内存80万个字节。
省内存就用位图法。位图法是用一个位来表示数字是否存在,因为一个位要么是0要么是1,因为查重就是看它到底有没有出现过,没有出现相应的位就是0,出现了就是1。位图法的好处就是用一个位就可以记录一个数字,相当于一个字节就可以记录8个数字信息,原来记录一个整数需要4B才能记录这个整数的信息,现在只需要一个位就可以,具体怎么去记录这里有一个除余法这个算法用来标识一个数字在位图数组里面的哪一个位上,然后遍历这10万个正整数,先把相应的数字映射到位图数组相应的位上。这个问题可以用位图法,因为它只是找这个数字有没有出现过,没有说找总共出现过几次,如果找出现过几次用不了位图法,位图法非常节省内存,它只能判断数字是否出现过,而且因为位图法是用数组来记录位,所以在用位图法解决问题的时候一定先要知道这10万个正整数里面最大的数字是多大,因为它要根据最大的数字来定义位图数组的大小,在这个问题里面就可以按最大上限100w来定义这个位图。
位图法的好处是省内存,哈希表好处是非常快但是比较占内存,而布隆过滤器刚好结合了它们两的优缺点,布隆过滤器在缓存里使用场景也是非常多的。布隆过滤器在去处理查重问题的时候是存在一定误判的,布隆过滤器说这个数字不在那肯定不在,但是说这个数字在这个数字却不一定在。如果你想说布隆过滤器的话可以问面试官,这个问题是否允许一定程度上的误判,因为在数据量比较大的情况下,我们既要要求准确还要要求速度还要要求内存占用量相对有限,那么使用布隆过滤器是得承受一定程度的误判,包括像我们在缓存里去命中一个key的话,我们可以允许一定程度的误判来提高整体缓存的效率。
13. 两个有序数组取交集?代码层面的优化?n个数组取交集?
- 两个有序数组取交集 可以先用一个数组里面的元素构建哈希表,然后再用另一个数组里的元素在这个哈希表里面查找。这种方法对于题目中的数组是有序的这点没有用到,不是最优的方案,时间复杂度是O(m),但是占内存了。
- 代码层面的优化? 有序的可以想到二分查找,遍历第一个数组,然后拿第一个数组的每一个元素进行二分搜索,这个比较快,时间复杂度是O(mlogn),不占用任何额外的内存。但是二分查找还是没有把题目中两个数组都是有序的条件用到,只用了一个。
可以用二路归并。用两个指针分别指向两个数组(A,B)刚开始的位置,如果指向元素一样证明这个元素是两个数组交集里面的一个元素,如果A数组当前指向的元素比B数组当前指向的元素大,则A数组后面所有元素都比B大,所以B指向的那个元素肯定不是两个数组的交集,所以B数组指针往后一定一个单位,然后继续重复这些操作。时间复杂度为O(m+n)。
当两个数组元素基本差不多的时候,选择二路归并效率好点;如果相差比较大,选择二分搜索的效率比二路归并好点。
-
n个数组取交集? 可以把n个数组划分成两两取交集后再两两取交集。或者利用二路归并的思想进行n路归并,n路归并用n个指针,每个指针指向各个数组首元素的起始位置,判断方法和二路归并一样,交集必须在每个数组里这个元素必须出现,谁小谁往后移动。