25届百度C++实习一面面经
公众号:阿Q技术站
来源:https://www.nowcoder.com/feed/main/detail/a545c01127ea4e5783d19e9eecf390e5
1、数组和链表的区别?
- 内存分配:
- 数组:在编译时分配一块连续的内存空间,数组的大小通常是固定的,不能动态调整。
- 链表:在运行时按需分配内存,节点之间可以存储在不同的内存块中,链表的大小可以动态增长。
- 内存访问:
- 数组:由于内存连续,可以通过索引直接访问元素,访问速度较快。
- 链表:需要遍历链表来访问特定位置的元素,因此访问速度可能较慢。
- 插入和删除:
- 数组:在中间插入或删除元素通常需要将后续元素移动,时间复杂度为O(n),其中n是元素的数量。
- 链表:在中间插入或删除元素时,只需要调整节点的指针,时间复杂度为O(1)。
- 大小调整:
- 数组:通常需要创建一个新数组并将数据复制到新数组以调整大小。
- 链表:可以轻松地添加或删除节点以调整大小。
- 存储结构:
- 数组:线性结构,适用于随机访问元素。
- 链表:非线性结构,适用于插入和删除频繁的情况。
- 空间效率:
- 数组:通常需要分配足够大的内存,可能会浪费空间,但访问速度较快。
- 链表:根据需要分配内存,较灵活,但需要额外的指针存储节点间关系。
- 适用场景:
- 数组:适用于元素数量固定,需要频繁访问元素的情况。
- 链表:适用于元素数量不固定,需要频繁插入和删除元素的情况。
2、说一下智能指针有哪些?使用场景?
-
std::shared_ptr
:-
原理:
std::shared_ptr
是基于引用计数的智能指针,用于管理动态分配的对象。它维护一个引用计数,当计数为零时,释放对象的内存。 -
使用场景:适用于多个智能指针需要共享同一块内存的情况。例如,在多个对象之间共享某个资源或数据。
-
std::shared_ptr<int> sharedInt = std::make_shared<int>(42); std::shared_ptr<int> anotherSharedInt = sharedInt; // 共享同一块内存
-
-
std::unique_ptr
:-
原理:
std::unique_ptr
是独占式智能指针,意味着它独占拥有所管理的对象,当其生命周期结束时,对象会被自动销毁。 -
使用场景:适用于不需要多个指针共享同一块内存的情况,即单一所有权。通常用于资源管理,例如动态分配的对象或文件句柄。
-
std::unique_ptr<int> uniqueInt = std::make_unique<int>(42); // uniqueInt 的所有权是唯一的
-
-
std::weak_ptr
:-
原理:
std::weak_ptr
是一种弱引用指针,它不增加引用计数。它通常用于协助std::shared_ptr
,以避免循环引用问题。 -
使用场景:适用于协助解决
std::shared_ptr
的循环引用问题,其中多个shared_ptr
互相引用,导致内存泄漏。 -
std::shared_ptr<int> sharedInt = std::make_shared<int>(42); std::weak_ptr<int> weakInt = sharedInt;
-
-
std::auto_ptr
(已废弃):-
原理:
std::auto_ptr
是C++98标准引入的智能指针,用于独占地管理对象。但由于其存在潜在的问题,已在C++11中被废弃。 -
使用场景:在C++98标准中,可用于独占性地管理动态分配的对象。不推荐在现代C++中使用。
-
std::auto_ptr<int> autoInt(new int(42)); // 已废弃
-
3、C++的强制类型转换和使用场景?
static_cast
static_cast
用于执行通常的、编译时检查的类型转换,比如数值之间的转换,指针之间的转换,父子类指针之间的转换。
double d = 3.14;
int i = static_cast<int>(d); // double转换为int
class Base { /* ... */ };
class Derived : public Base { /* ... */ };
Base* basePtr = new Derived;
Derived* derivedPtr = static_cast<Derived*>(basePtr); // 基类指针向派生类指针转换
dynamic_cast
dynamic_cast
通常用于处理多态类层次结构,允许在运行时检查对象的真实类型并执行类型安全的指针或引用转换。
class Base { virtual void foo() {} };
class Derived : public Base { /* ... */ };
Base* basePtr = new Derived;
Derived* derivedPtr = dynamic_cast<Derived*>(basePtr); // 运行时检查类型并转换
if (derivedPtr) {
// 转换成功
}
const_cast
const_cast
主要用于去除或添加const
属性,通常用于修改const
对象。
const int value = 42;
int& ref = const_cast<int&>(value); // 去除const属性,允许修改
reinterpret_cast
reinterpret_cast
用于执行低级别的类型转换,通常用于将指针或引用从一种类型转换为完全不同的类型。这通常不受编译器限制。
int num = 42;
double* ptr = reinterpret_cast<double*>(&num); // 重新解释int指针为double指针
4、说一下什么是进程?线程?二者的联系?
- 进程
进程是计算机中运行的程序的实例。每个进程都有自己的内存空间、代码、数据、系统资源和执行环境,它们彼此独立,互不干扰。
特点:
- 进程之间是相互隔离的,一个进程的崩溃通常不会影响其他进程。
- 进程通常拥有自己的地址空间,系统资源(如文件句柄、网络连接等)和调度信息。
- 创建、销毁和切换进程通常会涉及较大的系统开销。
进程通常用于实现并发执行,允许同时运行多个程序,每个程序都是一个独立的进程。
- 线程
线程是进程内的执行单元,一个进程可以包含多个线程。线程共享进程的内存空间和资源,但每个线程拥有独立的执行堆栈。
特点:
- 线程之间共享相同的进程内存,可以更快地进行通信。
- 线程通常比进程更轻量级,创建和切换线程的开销较小。
- 线程之间的错误可能会影响整个进程。
线程通常用于提高程序的并发性能,允许程序同时执行多个任务。常见的例子包括多线程服务器、多线程图形应用程序和多线程计算任务。
联系:
- 共享资源: 进程内的所有线程共享相同的进程内存和资源。这意味着它们可以轻松地共享数据和通信。
- 并发执行: 进程内的多个线程可以同时运行,从而实现并发执行,提高了程序的性能和响应时间。
- 资源分配: 进程和线程都需要操作系统分配资源,例如内存、CPU时间等。操作系统负责管理和调度进程和线程,以确保它们能够合理地竞争资源。
- 相互关联: 多线程通常用于实现多任务处理和并发性,因此进程内的线程可以相互协作,执行不同的任务。线程之间可以通过共享内存或消息传递等方式进行通信。
5、讲一下Tcp三次握手、四次挥手的过程?
三次握手
- 第一次握手: 客户端发送一个TCP报文段,其中包含一个SYN(同步)标志位,以及客户端的初始序列号(ISN,Initial Sequence Number)。这表示客户端请求建立连接,并设置了初始序列号,以便后续的数据传输。
- 第二次握手: 服务器接收到客户端的SYN后,确认收到,并发送回一个带有SYN和ACK(确认)标志位的报文段。服务器也会为自己设置一个初始序列号。
- 第三次握手: 客户端接收到服务器的SYN-ACK后,确认收到,并发送一个ACK标志位的报文段。这个报文段表示连接已经建立,双方可以开始进行数据传输。
此时,TCP连接已经建立,双方可以安全地传输数据。
四次挥手
- 第一次挥手: 当客户端完成了数据传输后,它会发送一个FIN标志位的报文段,表示它不再有数据要发送,但仍然愿意接收数据。客户端进入FIN_WAIT_1状态。
- 第二次挥手: 服务器接收到客户端的FIN后,确认收到,并发送一个ACK标志位的报文段。此时,服务器可以继续向客户端发送数据。
- 第三次挥手: 当服务器也完成了数据传输后,它会发送一个FIN标志位的报文段,表示它不再有数据要发送。服务器进入CLOSE_WAIT状态,等待客户端的确认。
- 第四次挥手: 客户端接收到服务器的FIN后,确认收到,并发送一个ACK标志位的报文段。客户端进入TIME_WAIT状态,等待可能出现的服务器重传。
在TIME_WAIT状态等待一段时间后,客户端和服务器都可以关闭连接。整个四次挥手过程确保双方都完成了数据传输并关闭了连接。
6、解决哈希冲突的方法有哪些?
-
拉链法(Chaining):
- 拉链法是一种开放地址法,它将多个映射到同一槽位的元素存储在一个链表(或其他数据结构)中。
- 当哈希冲突发生时,元素被添加到链表中。在查找、插入和删除元素时,首先找到对应的槽位,然后在链表中搜索或操作。
- 拉链法的优点是简单且易于实现,但在链表变得很长时,性能可能会下降。
-
线性探测法(Linear Probing):
- 线性探测法是一种封闭地址法,当哈希冲突发生时,它会查找下一个可用的槽位。
- 如果槽位被占用,就线性地查找下一个槽位,直到找到一个空槽位或达到表的末尾。
- 线性探测法可能导致聚集现象,即连续的槽位会被多次占用,降低性能。因此,通常需要解决二次聚集或更复杂的聚集问题。
-
二次探测法(Quadratic Probing):
- 二次探测法是线性探测法的变种,它使用二次函数来查找下一个可用的槽位,以减少聚集现象。
- 二次探测法的探测步长是一个二次函数的结果,通常是
c1 * i^2 + c2 * i
,其中c1
和c2
是常数,i
是探测的次数。 - 二次探测法的性能通常比线性探测法好,但仍然可能出现聚集。
-
双重哈希(Double Hashing):
- 双重哈希是一种封闭地址法,它使用两个不同的哈希函数来确定下一个探测位置。
- 当哈希冲突发生时,它首先使用第一个哈希函数来计算下一个槽位,如果该槽位已被占用,则使用第二个哈希函数来计算下一个槽位。
- 双重哈希法可以减少聚集问题,但需要精心选择和设计哈希函数。
-
再哈希(Rehashing):
- 当哈希表中的负载因子(已占用槽位数与总槽位数之比)达到一定阈值时,可以进行再哈希。
- 再哈希是将哈希表的大小扩展一倍,并重新将所有元素插入新的表中。这可以减小负载因子,减少哈希冲突的可能性。
-
建立一个好的哈希函数:
最好的方法是设计一个能够尽可能减少冲突的哈希函数。好的哈希函数应该均匀地分散键,使哈希表的槽位均匀利用。
-
分离链接哈希表(Separate Chaining Hash Table):
这种方法是将哈希表的每个槽位都构建为一个独立的数据结构,如链表、树或其他哈希表。这样,即使发生冲突,也能够高效地存储多个值。
7、讲一下虚函数实现的原理?
虚函数在C++中的实现依赖于虚函数表(Virtual Function Table,简称 vtable)和虚函数指针。
- 虚函数的声明:当你在一个C++类中声明一个虚函数时,该函数会被标记为虚函数。比如:
class Base {
public:
virtual void foo() { /* ... */ }
};
-
虚函数表:对于每个类,编译器会生成一个虚函数表,也称为 vtable。虚函数表是一个指向虚函数的指针数组,其中存储了该类的虚函数的地址。每个包含虚函数的类都有自己的虚函数表。
-
虚函数指针:每个包含虚函数的对象都会在内存中存储一个指向虚函数表的指针,这个指针称为虚函数指针。这个指针通常在对象的内存布局的开头,以便快速访问。
假设有一个Base
类对象:
Base* obj = new Base;
- 动态绑定:当你调用一个虚函数时,实际执行的函数取决于对象的实际类型。这称为动态绑定。编译器会在虚函数调用时插入代码,以通过虚函数指针查找正确的函数。
obj->foo(); // 运行时查找 Base::foo() 或其覆盖版本
- 覆盖(Override):子类可以覆盖(重写)基类的虚函数,这意味着子类可以提供自己的实现。当一个子类覆盖基类的虚函数时,子类的虚函数表中的相应槽将指向子类的实现。例如:
class Derived : public Base {
public:
void foo() override { /* ... Derived's implementation ... */ }
};
虚函数的原理允许在运行时确定函数的调用,使得多态成为可能。通过在类层次结构中使用虚函数,可以实现灵活的、安全的多态代码,其中对象的实际类型在运行时决定,而不是在编译时。
8、讲一下快速排序的逻辑?时间复杂度?空间复杂度?是否是稳定的?复杂度是否都是N log N的?如何解决?
快排逻辑:
- 从数组中选择一个基准元素。这个选择可以影响快速排序的性能。通常,选择第一个元素、最后一个元素或随机元素作为基准元素。
- 将数组中的其他元素与基准元素进行比较,将小于基准元素的元素放在其左边,大于基准元素的元素放在其右边。这一过程称为分割或划分。
- 对左右两个分割后的子数组进行递归排序。重复上述过程,直到子数组的大小为1或0,因为已经是有序的。
- 递归排序完成后,所有子数组已经有序,最后合并这些子数组。
时间复杂度:
- 平均情况下,快速排序的时间复杂度为O(n * log n)。
- 在最坏情况下,当每次选择的基准元素都是数组中的最小或最大元素时,时间复杂度可能达到O(n^2)。为了避免最坏情况,可以采用随机选择基准元素的策略。
空间复杂度:
快速排序的主要消耗是递归调用的栈空间,因此空间复杂度取决于递归的深度。在最坏情况下,空间复杂度为O(n),在平均情况下通常是O(log n)。
稳定性:
快速排序通常是不稳定的,因为相等元素的相对顺序可能会在排序过程中改变。例如,如果有两个相等的元素,快速排序可能会交换它们的位置。
示例代码:
#include <iostream>
#include <vector>
#include <stack>
// 交换两个元素
void swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
// 分割函数,返回基准元素的索引
int partition(std::vector<int>& arr, int low, int high) {
int pivot = arr[high]; // 选择最后一个元素作为基准
int i = (low - 1); // 初始化较小元素的索引
for (int j = low; j <= high - 1; j++) {
if (arr[j] < pivot) {
i++;
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[high]);
return (i + 1);
}
// 非递归快速排序
void quickSort(std::vector<int>& arr, int low, int high) {
std::stack<int> stack;
stack.push(low);
stack.push(high);
while (!stack.empty()) {
high = stack.top();
stack.pop();
low = stack.top();
stack.pop();
int pivotIndex = partition(arr, low, high);
if (pivotIndex - 1 > low) {
stack.push(low);
stack.push(pivotIndex - 1);
}
if (pivotIndex + 1 < high) {
stack.push(pivotIndex + 1);
stack.push(high);
}
}
}
int main() {
std::vector<int> arr = {12, 4, 5, 6, 7, 3, 1, 15, 2, 8};
int arrSize = arr.size();
std::cout << "Original array: ";
for (int i = 0; i < arrSize; i++) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
quickSort(arr, 0, arrSize - 1);
std::cout << "Sorted array: ";
for (int i = 0; i < arrSize; i++) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
return 0;
}
9、讲一下select 和epoll的区别?
-
针对文件描述符的数量:
-
select
:select
通常有文件描述符集合的大小限制。在许多系统中,这个限制是固定的,通常在1024或更少。这意味着你不能同时监视大量的文件描述符,这在高并发情况下可能成为瓶颈。 -
epoll
:epoll
不会受到文件描述符数量的限制。它使用事件通知的方式来通知应用程序哪些文件描述符就绪,所以可以处理数以万计的文件描述符,而不会受限制。
-
-
文件描述符的拷贝:
-
select
: 在调用select
之前,必须将要监视的文件描述符集合拷贝到select
函数的参数中。这意味着每次调用select
时都需要拷贝一份描述符集合。 -
epoll
: 不需要拷贝文件描述符集合。只需要注册文件描述符,epoll
就会自动管理它们,不需要多次拷贝。
-
-
触发方式:
-
select
:select
采用轮询的方式,需要不断轮询所有监视的文件描述符,直到有文件描述符就绪才返回。 -
epoll
:epoll
使用事件通知的方式。一旦有文件描述符就绪,它会立刻通知应用程序,而不需要等待。
-
-
内核空间和用户空间的数据拷贝:
-
select
: 当有文件描述符就绪时,select
返回的就绪文件描述符列表需要从内核空间拷贝到用户空间,这会涉及额外的数据拷贝操作。 -
epoll
:epoll
使用零拷贝技术,就绪文件描述符列表在内核空间和用户空间之间共享,减少了数据拷贝。
-
-
修改监视对象:
-
select
: 如果需要修改监视的文件描述符集合,需要重新设置文件描述符集合并再次调用select
。 -
epoll
: 通过epoll_ctl
函数,可以动态地添加或删除文件描述符。
-
10、删除一个链表第N个节点之后的节点?
- 定位到第N个节点。
- 修改第N个节点的
next
指针,使其指向N+2个节点,从而跳过第N+1个节点。
示例代码:
#include <iostream>
struct Node {
int data;
Node* next;
Node(int val) : data(val), next(nullptr) {}
};
void deleteNAfter(Node* head, int n) {
if (n <= 0) {
std::cerr << "Invalid N" << std::endl;
return;
}
Node* curr = head;
// Traverse to the (N-1)-th node
for (int i = 1; i < n && curr; i++) {
curr = curr->next;
}
if (!curr || !curr->next) {
std::cerr << "N is out of range" << std::endl;
return;
}
Node* toDelete = curr->next;
curr->next = toDelete->next;
delete toDelete;
}
void printList(Node* head) {
Node* curr = head;
while (curr) {
std::cout << curr->data << " -> ";
curr = curr->next;
}
std::cout << "nullptr" << std::endl;
}
int main() {
// Create a sample linked list: 1 -> 2 -> 3 -> 4 -> 5 -> nullptr
Node* head = new Node(1);
Node* current = head;
for (int i = 2; i <= 5; i++) {
current->next = new Node(i);
current = current->next;
}
std::cout << "Original Linked List:" << std::endl;
printList(head);
int N = 2; // Specify the position (N) after which to delete nodes
deleteNAfter(head, N);
std::cout << "Linked List after deleting nodes after position " << N << ":" << std::endl;
printList(head);
return 0;
}
11、写一个LRU算法
大家可以看力扣或者是牛客上详细解释,我这里给出一个参考代码
#include <unordered_map>
using namespace std;
class LRUCache {
private:
struct ListNode {
int key;
int value;
ListNode* prev;
ListNode* next;
ListNode(int k, int v) : key(k), value(v), prev(nullptr), next(nullptr) {}
};
unordered_map<int, ListNode*> cache; // 使用哈希表实现快速查找
ListNode* dummyHead; // 虚拟头结点
ListNode* dummyTail; // 虚拟尾结点
int capacity; // 缓存容量
int size; // 当前缓存大小
public:
LRUCache(int capacity) {
this->capacity = capacity;
this->size = 0;
dummyHead = new ListNode(0, 0);
dummyTail = new ListNode(0, 0);
dummyHead->next = dummyTail;
dummyTail->prev = dummyHead;
}
int get(int key) {
if (cache.find(key) == cache.end()) {
return -1; // 如果关键字不存在于缓存中,返回-1
}
// 如果关键字存在于缓存中,将访问的项移动到链表头部
ListNode* node = cache[key];
moveToFront(node);
return node->value;
}
void put(int key, int value) {
if (cache.find(key) == cache.end()) {
// 如果关键字不在缓存中
if (size >= capacity) {
// 如果缓存已满,需要淘汰最久未使用的项(链表尾部的项)
removeTail();
}
// 创建新节点,添加到链表头部,并更新哈希表
ListNode* newNode = new ListNode(key, value);
cache[key] = newNode;
addToFront(newNode);
size++;
} else {
// 如果关键字已存在于缓存中,更新其值并将其移到链表头部表示最近访问
ListNode* node = cache[key];
node->value = value;
moveToFront(node);
}
}
private:
// 将节点移动到链表头部
void moveToFront(ListNode* node) {
// 从当前位置删除节点
node->prev->next = node->next;
node->next->prev = node->prev;
// 将节点插入到链表头部
node->next = dummyHead->next;
dummyHead->next->prev = node;
dummyHead->next = node;
node->prev = dummyHead;
}
// 移除链表尾部的节点
void removeTail() {
ListNode* tail = dummyTail->prev;
cache.erase(tail->key); // 从哈希表中删除对应项
dummyTail->prev = tail->prev;
tail->prev->next = dummyTail;
delete tail; // 释放内存
size--;
}
// 添加节点到链表头部
void addToFront(ListNode* node) {
node->next = dummyHead->next;
dummyHead->next->prev = node;
dummyHead->next = node;
node->prev = dummyHead;
}
};
get
方法查找缓存项时,如果存在,将该项移动到链表的头部,以表示最近访问。
put
方法在插入新项时,如果容量不足,会删除链表尾部的最久未使用的项,并将新项插入链表头部。如果项已存在,则更新其值,并将其移到链表头部表示最近访问。