一. 各种容器的底层机制和常见面试题:
1.1 vector:
1.1.1 vector的底层原理:
vector的底层实现是三个指针:
struct _Vector_impl : public _Tp_alloc_type { ?? ?pointer ?? ?_M_start; ?? ?pointer ?? ?_M_finish; ?? ?pointer?? ??? ?_M_end_of_storage; };
start ?和?finish ?之间是已经被使用的空间范围(即?vector.size() ?的大小),
start ?和?end_of_stroage ?之间是vector底层数组的整个空间(即?vector.capacity() ?的大小)。
所以 vector 这个类本身并不存储数据的内容,通过指针?start ?指向真正的存储元素的数组的地址。
vector的 内存增长机制:
当现有的内存空间不够装下数据时(push_back(val)触发),vector会自动申请另一块更大的内存(1.5倍 或者 2倍),把原有的数据?拷贝?到新的内存空间,然后释放旧有内存空间。
当 clear清空 vector中的元素时(vector.clear()),其存储空间不释放,仅仅是情况内存上的数据。
例如:
vector<int> v; for(int i = 0; i < 1000; i++) { ? ?v.push_back(i);
} cout << v.size() << " ?" << v.capacity() << endl;
v.clear(); cout << v.size() << " ?" << v.capacity() << endl;
------ 1000 ?1024 0 ? ? 1024
循环插入1000个元素到vector中,vector的size为1000,capacity为1024; 调用clear清空vector中的元素后,vector的size变为0,capacity仍为1024。
1.1.2 vector的reserve和resize的区别:
reserve 的作用是在vector初始化时,根据预估的所需要的内存大小 提前申请内存,目的是避免程序运行中重复的 申请、释放 内存空间,以提高程序的效率; reserver修改的是vector的capacity的值,只表示一种尝试,当vector的现有实际size大于reserve(n)的n值时,vector不做任何修改。
resize 改变vector的实际大小,可能导致vector中的多余元素被删除,接受两个参数的resize不仅改变vector大小,也改变元素的默认值。
1.1.3 vector的size和capacity的区别:
size = finish - start; ?表示的是vector中实际元素的?个数;
capacity = end_of_storage - start; ?表示的是vector当前分配的内存可以容纳的最多元素的?个数。
注意 size 和 capacity 表示的都是元素个数,不是vector内存的字节数。
1.1.4 vector的元素类型不能是引用:
vector的底层存储要求是连续的对象排列,引用不是对象,没有实际地址,因此vector的元素类型不能是引用。
1.1.5 释放vector的内存的方式:
clear、swap、shrink_to_fit。
- clear:v.clear();?? ??? ?//清空vector的元素,占用的内存不会释放,即size变0,capacity不变
- swap:vector<int> v2;?? ?//先定义个空的vector
- v2.swap(v);
swap 函数的效果是将 v1和v2?“整体交换”, 不仅交换两个vector的元素内容,并且还会交换vector的size、capacity,相当于是两个vector中的是?三个指针?都进行调换。
- shrink_to_fit://原型:
v.shrink_to_fit();?? ??? ?//shrink_to_fit 将vector的capacity修改为与size一致。作用是释放多余的内存空间 ?? ??? ??? ??? ??? ??? ?//注意 shrink_to_fit() 函数不接受参数 ?? ??? ??? ??? ??? ??? ? //使用shrink_to_fit 释放vector内存的写法: v.clear(); v.shrink_to_fit();?? ??? ?//先调用clear将vector的size变为0,再使用shrink_to_fit将capacity也变为0
1.2 list:
1.2.1 list的底层原理:
list的底层是一个?“双向链表”。 list不支持随机访问;插入或删除一个元素时,就对应配置或释放一个结点的空间。
1.3 deque:
1.3.1 deque的底层原理:
deque的底层是一个?“双端队列”。 deque在头尾两端插入和删除的时间复杂度都是 O(1)。
1.3.2 什么情况使用vector,什么情况使用list,什么情况使用deque:
随机访问频繁,元素数量变化不大(不会频繁的插入、删除)的场景,优先选用vector; 相反的场景,使用list,即元素频繁的插入删除,但不会经常进行随机反问。
除非必要,应尽可能的选择使用vector而非deque,因为?deque的迭代器比vector的迭代器要复杂的多。
除非真的需要频繁的在容器的首尾两端进行频繁的插入和删除元素的操作。
1.4 priority_queue:
1.4.1 priority_queue的底层原理:
priority_queue(优先队列)的底层使用?堆?来实现的。 在优先队列中,队首元素?一定是当前队列中优先级最高的那一个。
1.5 map、set、multimap、multiset:
map、set、multimap、multiset 的底层实现都是?红黑树。 (epoll模型的底层数据结构也是红黑树,Linux系统中CFS进程调度算法也是红黑树)
红黑树的特性: (1)每个节点是 红色 或者 黑色; (2)根节点 是 黑色; (3)所有叶子节点 都是 黑色; (4)如果有一个节点是红色,则它的两个子结点都是 黑色;(因此红色节点不能相邻,黑色的节点可以相邻) (5)对每个节点,从该节点到其子孙节点(即叶子节点)的所有途径上,包含相同数据的黑色节点。(所以红黑树平衡的是黑色节点的高度,红色节点主要是用来做区分的)
红黑树平衡的是 黑色节点的高度,红色节点主要是用来做区分的。
以红色树实现的map的 查找、插入、删除 的时间复杂度都是?O(logN)。(平均、最坏、最好都是)
在map中判断一个key是否存在,可以使用find或count:
map.find(key) != map.end(); map.count(key) != 0; ?
map和set会对插入的元素自动排序,set中元素不允许重复,multiset中的元素可以重复。
1.6 unordered_map、unordered_set:
1.6.1 unordered_map、unordered_set的底层原理:
unordered_map 的底层数据结构是一个?哈希表。 哈希表的特点是 查找、插入、删除 的时间复杂度是 O(1),缺点是耗费一定的内存(哈希表数组需要预留空间)。
哈希表的实现方法是 使用一个下标范围较大的数组来存储元素,使用 哈希函数(或称“散列函数”)对每个元素的key进行计算,将经过哈希计算得到的值作为数组下标。 当不同的key经过哈希函数计算后得出的结果发生冲突时,使用链表解决。
map的底层数据结构是?红黑树,unordered_map的底层数据结构是?哈希表(数组+链表)。???????
- map:
- 优点:
- map元素有序(这是map最大的优点,其元素的有序性在很多应用中都会简化很多的操作);
- 其红黑树的结构使得map的很多操作都可在O(logn)下完成;
- map的各项性能较为稳定,与元素插入顺序无关;
- map支持范围查找。
- 缺点:
- 占用的空间大:红黑树的每一个节点需要保存其父节点位置、孩子节点位置及红/黑性质,因此每一个节点占用空间大。
- 查询平均时间不如unordered_map。
- 适用场景:
- 元素需要有序;
- 对于单次查询时间较为敏感,必须保持查询性能的稳定性,比如实时应用等等。
- unordered_map
- 优点:
- 缺点:
- 元素无序;
- unordered_map相对于map空间占用更大,且其利用率不高;
- 查询性能不太稳定,最坏时间复杂度可达到O(n)。
- 适用场景:
二、迭代器的底层级制和失效的问题:
2.1 迭代器底层原理:
STL的 算法 和 容器 是分离的,两者通过迭代器连接。 算法的实现并不知道自己被传进来的参数是什么类型的容器。
萃取器(Traits)?相当于在接口和实现之间加一层封装,来隐藏一些细节并协助调用合适的方法,这需要一些技巧(例如,偏特化)。
例如STL中的?distance() ?函数可用于计算容器中两个迭代器之间的距离,对于vector容器来说,由于内存是连续分配的,因此指针直接相减即可获得二者举例,而如果传入 distance函数的容器类型是list,则distance需要遍历list链表中的元素,才能求得两个迭代器之间的距离。
2.2 迭代器的种类:(5种)
- 输入迭代器:?只读迭代器,在每个被遍历的位置上只能读取一次,例如find函数的参数就是输入迭代器(类似于?cbegin()、cend()?只读迭代器);
- 输出迭代器:?只写迭代器,在每个被遍历的位置上只能被写一次;
- 前向迭代器:?具输入和输出迭代器的能力,但是它可以对同一个位置重复进行读和写。但它 **不支持operator–-*,所以只能向前移动;
- 双向迭代器:?可以前、后移动;
- 随机访问迭代器:?有双向迭代器的所有功能,并且提供 “迭代器算数”,支持 iter+n、iter-n、iter1-iter2 等操作。
2.3 迭代器失效的问题:
所谓的“迭代器失效”,指的是 在对容器进行某些操作,导致容器上的旧有迭代器在这些操作后失去了本来的效果。
这些操作一般包括 对容器进行 insert插入、erase删除 等操作, 迭代器失效的本质与其所作用的容器的?底层内存实现方式?有关。
2.3.1 对于vector、string、deque这些?连续存储?的容器:
1. 插入操作:
当对容器进行?insert插入?操作时,可能?会发生现有内存空间不足,需要重新分配内存的情况,此时会导致原容器上的所有迭代器全部失效;
如果在insert插入后,内存未重新分配,则 插入点之前的 迭代器仍然有效,插入点之后的迭代器失效;
对于deque,如果插入点在front和back,则所有迭代器失效,指针和引用仍有效; 如果插入点在非front和back的其他位置,则所有迭代器、指针、引用 都失效。
2. 删除操作:
对于vector和string,删除点之前的引用、指针、迭代器仍有效,删除点之后的失效,尾迭代器(off-the-end)总是失效的;
2.3.2 对于list、forward_list、map这些?非连续存储?的容器:
1. 插入操作:
由于容器中的元素的结点都是离散存储的,每次insert插入新节点时都是要构造一个元素并挂到链表中,所以?不存在内存空间不足需要重新分配内存的情况,因此?插入操作不会引起任何迭代器的失效。
2. 删除操作:
与插入操作的原理相同,map、list这类容器的各个节点都是离散存储不连续,所以删除操作除了会导致?被删除的元素上的迭代器失效?外,其余迭代器都仍然有效。
2.3.3 迭代器失效的处理方式:
map<int, int> m; ?? ?//<fd, time()>
void proxy_timer_callback() { ?? ? ?? ?for(map<int, int>::iterator it = m.begin(); m != end(); ) { ?? ??? ?map<int, int>::iterator old_it = it; ?? ??? ?it++; ?? ??? ? ?? ??? ?if(old_it->second > cur_time) { ?? ??? ??? ?m.erase(old_it); ?? ??? ?} ?? ?} }
因为erase()会将迭代器删除,所以如果将?it++ ?的操作放在for循环条件里面的话,由于it迭代器已经被删除了,再对其进行 ++ 操作会导致程序 Segment Fault 段错误。 正确的处理方式是在for循环体内保存it当前指向,然后先对it++,再删除 old_it指向的元素节点。
三、STL容器的线程安全性:
同一容器的多线程写操作是 线程不安全的,需要进行加锁。
四、八大数据结构
常见的数据结构 首先列出一些最常见的数据结构,我们将逐一说明:
数组 栈 队列 链表 树 图 字典树(这是一种高效的树形结构,但值得单独说明) 散列表(哈希表)
数组 数组是最简单、也是使用最广泛的数据结构。栈、队列等其他数据结构均由数组演变而来。
每个数据元素都关联一个正数值,我们称之为索引,它表明数组中每个元素所在的位置。大部分语言将初始索引定义为零。
以下是数组的两种类型:
一维数组(如上所示) 多维数组(数组的数组) 数组的基本操作
Insert——在指定索引位置插入一个元素 Get——返回指定索引位置的元素 Delete——删除指定索引位置的元素 Size——得到数组所有元素的数量 面试中关于数组的常见问题
寻找数组中第二小的元素 找到数组中第一个不重复出现的整数 合并两个有序数组 重新排列数组中的正值和负值 栈 著名的撤销操作几乎遍布任意一个应用。但你有没有思考过它是如何工作的呢?这个问题的解决思路是按照将最后的状态排列在先的顺序,在内存中存储历史工作状态。这没办法用数组实现。但有了栈,这就变得非常方便了。
可以把栈想象成一列垂直堆放的书。为了拿到中间的书,你需要移除放置在这上面的所有书。这就是LIFO(后进先出)的工作原理。
栈的基本操作
Push——在顶部插入一个元素 Pop——返回并移除栈顶元素 isEmpty——如果栈为空,则返回true Top——返回顶部元素,但并不移除它 面试中关于栈的常见问题
使用栈计算后缀表达式 对栈的元素进行排序 判断表达式是否括号平衡 队列 与栈相似,队列是另一种顺序存储元素的线性数据结构。栈与队列的最大差别在于栈是LIFO(后进先出),而队列是FIFO,即先进先出。
队列的基本操作
Enqueue()?——?在队列尾部插入元素 Dequeue()?——移除队列头部的元素 ?isEmpty()——如果队列为空,则返回true Top()?——返回队列的第一个元素 面试中关于队列的常见问题
使用队列表示栈 对队列的前k个元素倒序 使用队列生成从1到n的二进制数 链表 链表是另一个重要的线性数据结构,乍一看可能有点像数组,但在内存分配、内部结构以及数据插入和删除的基本操作方面均有所不同。
链表就像一个节点链,其中每个节点包含着数据和指向后续节点的指针。 链表还包含一个头指针,它指向链表的第一个元素,但当列表为空时,它指向null或无具体内容。
链表一般用于实现文件系统、哈希表和邻接表。
链表内部结构
程序员面试:八大数据结构及常见面试题
链表包括以下类型:
单链表(单向) 双向链表(双向) 链表的基本操作:
InsertAtEnd - 在链表的末尾插入指定元素 InsertAtHead - 在链接列表的开头/头部插入指定元素 Delete - 从链接列表中删除指定元素 DeleteAtHead - 删除链接列表的第一个元素 Search - 从链表中返回指定元素 isEmpty - 如果链表为空,则返回true 面试中关于链表的常见问题
反转链表 检测链表中的循环 返回链表倒数第N个节点 删除链表中的重复项 图 图是一组以网络形式相互连接的节点。节点也称为顶点。 一对节点(x,y)称为边(edge),表示顶点x连接到顶点y。边可以包含权重/成本,显示从顶点x到y所需的成本。
图的类型
无向图 有向图 在程序语言中,图可以用两种形式表示:
邻接矩阵 邻接表 常见图遍历算法
广度优先搜索 深度优先搜索 面试中关于图的常见问题
实现广度和深度优先搜索 检查图是否为树 计算图的边数 找到两个顶点之间的最短路径 树 树形结构是一种层级式的数据结构,由顶点(节点)和连接它们的边组成。 树类似于图,但区分树和图的重要特征是树中不存在环路。
树形结构被广泛应用于人工智能和复杂算法,它可以提供解决问题的有效存储机制。
树数据结构中使用的基本术语
Root - 根节点 Parent - 父节点 Child - 子节点 ?Leaf - 叶子节点 Sibling - 兄弟节点 以下是树形结构的主要类型
N元树 平衡树 二叉树 二叉搜索树 AVL树 红黑树 2-3树 其中,二叉树和二叉搜索树是最常用的树。
面试中关于树结构的常见问题 求二叉树的高度 在二叉搜索树中查找第k个最大值 查找与根节点距离k的节点 在二叉树中查找给定节点的祖先节点 字典树 字典树,也称为“前缀树”,是一种特殊的树状数据结构,对于解决字符串相关问题非常有效。它能够提供快速检索,主要用于搜索字典中的单词,在搜索引擎中自动提供建议,甚至被用于IP的路由。
面试中关于字典树的常见问题 计算字典树中的总单词数 打印存储在字典树中的所有单词 使用字典树对数组的元素进行排序 使用字典树从字典中形成单词 构建T9字典(字典树+ DFS ) 哈希表 哈希法(Hashing)是一个用于唯一标识对象并将每个对象存储在一些预先计算的唯一索引(称为“键(key)”)中的过程。因此,对象以键值对的形式存储,这些键值对的集合被称为“字典”。可以使用键搜索每个对象。基于哈希法有很多不同的数据结构,但最常用的数据结构是哈希表。哈希表通常使用数组实现。
散列数据结构的性能取决于以下三个因素
哈希函数 哈希表的大小 碰撞处理方法 面试中关于哈希结构的常见问题
在数组中查找对称键值对 追踪遍历的完整路径 查找数组是否是另一个数组的子集 检查给定的数组是否不相交 ?
|