- 1.C++中什么是大端、小端?
- 2.重载函数是否能够通过函数返回值的类型不同来区分?
- 3.在C++ 程序中调用被C 编译器编译后的函数,为什么要加extern “C”
- 4.进程间如何通信?
- 5.在网络编程中涉及并发服务器,使用多进程与多线程的区别?
- 6. map的底层原理是什么?
- 7. socket与其他通信方式有什么不同?
- 8.TCP和UDP的区别是什么,TCP什么时候会重传?
- 9. map和unordered_map了解吗?
- 10.hashmap和map的区别,底层数据结构算法是什么?
- 11.介绍一下红黑树?
- 12.介绍一下hash
- 13.介绍一下二叉树?
- 14.编程实现LRU
- 15.编程实现memcopy
- 16.在AI行业中,C/C++编程中的动态库和静态库的含义是什么?两者之间什么差异?
大端(Big-endian)和小端(Little-endian)是指计算机中存储多字节数据时字节的排列顺序:大端将高字节存储在低地址处,低字节存储在高地址处;小端则将低字节存储在低地址处,高字节存储在高地址处。
不能。重载函数不能仅仅通过函数返回值类型的不同来区分。C++的函数重载是基于参数列表的不同(包括参数的数量、类型或顺序)来实现的,编译器无法仅根据返回类型来确定。 首先,`extern` 是C/C++语言中用于指定函数和全局变量作用范围的关键字。它告诉编译器,这些声明的函数和变量可以在当前模块或其它模块中使用。通常在模块的头文件中,对本模块提供给其它模块引用的函数和全局变量使用extern关键字进行声明。extern "C" 是一种连接声明(linkage declaration),被 extern "C" 修饰的变量和函数按照C语言的方式进行编译和连接。作为面向对象的语言,C++支持函数重载,而过程式语言C则不支持。函数被C++编译后在符号库中的名字与C语言的不同。例如,假设某个函数的原型为:void foo(int x, int y); 该函数被C编译器编译后在符号库中的名字为 _foo,而C++编译器则会产生像 _foo_int_int 之类的名字。这样的名字包含了函数名、函数参数数量及类型信息,C++通过这种机制实现函数重载。
总结来说,extern "C" 声明的主要目的是解决名字匹配问题,从而实现C++与C的混合编程。
map的底层原理: map的底层是基于红黑树实现的。 红黑树是一种自平衡二叉搜索树,它能保证基本操作(如查找、插入、删除)的时间复杂度为O(log n),确保树的高度保持在对数级别。 通过键值对自动排序和高效操作维持了其数据结构的稳定性和效率。
Socket 可以实现不同主机间的进程通信,适用于网络中跨操作系统通信。 Socket 通常支持全双工通信,即同一时间可以进行数据的双向传输。 Socket 支持面向连接(如TCP协议)和无连接(如UDP协议)的通信方式。 使用 Socket 进行通信需要创建、配置、使用和关闭套接字,比其他通信方式如管道和信号等有更明确的使用流程。
连接: TCP是面向连接的协议,进行数据传输前需要建立连接。 UDP是无连接的协议,不需要建立连接就可以直接发送数据。 可靠性: TCP提供可靠的数据传输,确保数据完整性和顺序。 UDP提供不可靠的数据传输,可能出现丢包,不保证数据顺序。 速度: TCP相对较慢,因为它需要确认机制和错误校正。 UDP传输速度更快,没有确认机制,适用于对速度要求高的场合。 数据流: TCP提供字节流服务,通过数据流的方式发送数据。 UDP以数据报文的形式发送信息,发送独立的消息。
超时重传:如果发送方在设定的超时时间内没有收到接收方的确认(ACK),它会重传那个数据段。 快速重传:如果发送方收到三个或更多的冗余ACK(即对同一个数据段的连续确认),它会在没有等待超时的情况下立即重传那个被认为丢失的数据段。 接收方提示:接收方可以通过ACK中的SACK选项(选择确认),明确指出哪些数据段已收到,哪些未收到,促使发送方仅重传未被确认接收的数据段。
map: 底层实现是红黑树,一个自平衡的二叉搜索树。 元素根据键值自动排序。 插入、删除和查找操作的时间复杂度为O(log n)。unordered_map: 底层实现是哈希表。 元素不会自动排序。 平均情况下插入、删除和查找操作的时间复杂度为O(1),最坏情况下为O(n)。
在 C++ 中,虽然没有直接对应 HashMap 和 Map 的标准库接口,但可通过 unordered_map 和 map 实现类似功能。二者区别在于: 底层实现不同:unordered_map 是哈希表实现,map 是红黑树实现。 元素顺序不同:unordered_map 不保证元素顺序,map 按照键的自然顺序排序。 对空值的处理不同:从 C++11 开始,unordered_map 允许键和值为 null,map 不允许键为空。 底层数据结构算法方面: unordered_map 利用散列函数将键映射到存储桶,采用链地址法解决哈希冲突。 map 以红黑树为底层数据结构,保证元素有序,插入、删除和查找操作的时间复杂度为 O (log N)。
红黑树是一种自平衡二叉搜索树,在插入和删除操作后会通过调整节点颜色与进行旋转来维持平衡。其特点如下:
- 节点颜色为红或黑。
- 根节点为黑色。
- 叶子节点(NULL 节点)皆为黑色。
- 红色节点的两个子节点必为黑色,不可出现连续红色节点。
- 从任一节点到其后代叶子节点的简单路径上,黑色节点数量一致,空子树到后代叶子节点的简单路径也如此。
凭借这些规则,红黑树可保持良好的平衡性质,在最坏情况下能实现 O(logN)时间复杂度的搜索、插入和删除操作。
对于插入操作,先将新节点设为红色,若违反红黑树性质 4 则进行旋转和变换修正,最后把根节点设为黑色。删除操作时,若被删除节点有两个非空子节点,需找到其后继节点(右子树最小值或左子树最大值),用后继节点替换原节点后再删除后继节点;若被删除节点只有一个子节点或无子节点则直接删除。删除后若违反性质 4,同样要进行一系列旋转和变换来修正。
哈希(Hash)是将任意长度的输入数据通过哈希函数
(Hash Function)转换为固定长度的输出值的过程。哈希函数将输入数据映射到一个固定大小的哈希值,通常表示为一串数字或字母。
哈希函数具有以下特点:
输入相同的数据始终会得到相同的哈希值。
不同的输入数据产生不同的哈希值。
哈希值长度固定,无论输入数据多长,输出结果都是相同长度。
在计算机领域,哈希常被用于以下方面:
数据存储和索引:使用哈希表作为底层数据结构,通过键-值对存储和快速查找
数据。 密码学:密码验证、数字签名
、消息摘要等应用需要使用安全的哈希函数。
数据完整性校验:通过比较两个文件或消息的哈希值来判断是否一致。
数据唯一性标识:例如在分布式系统中使用一致性哈希来确定节点位置。
常见的哈希函数包括MD5、SHA-1、SHA-256等,它们能够快速生成具有较低冲突率(即不同输入得到相同输出的概率很小)的哈希值。然而,在安全敏感场景下,通常需要使用更强大的哈希函数来抵抗攻击,如SHA-3或Blake2。
二叉树(Binary Tree)是一种常见的树形数据结构,它由节点(Node)组成,每个节点最多有两个子节点:左子节点和右子节点。
二叉树的特点如下:
每个节点最多有两个子节点,分别称为左子节点和右子节点。
左子树和右子树也是二叉树,可以为空。
二叉树没有环路(即不存在从某个节点出发经过若干条边回到该节点的路径)。
在二叉树中,通常会定义以下几种特殊类型的二叉树:
完全二叉树
(Complete Binary Tree):除了最后一层外,其他层的所有节点都必须是满的,并且最后一层的所有节点都尽量靠左排列。 满二叉树
(Full Binary Tree):除了叶子节点外,每个内部节点都有两个子节点。
二叉搜索树(Binary Search Tree):对于任意一个节点,其左子树上的值都小于等于该节点的值,右子树上的值都大于等于该节点的值。这种性质使得查找、插入和删除操作非常高效。
二叉树可以用递归或迭代方式进行遍历,常见的遍历方式包括:
前序遍历(Preorder Traversal):先访问根节点,然后按照前序遍历顺序递归地遍历左子树和右子树。
中序遍历(Inorder Traversal):先按照中序遍历顺序递归地遍历左子树,然后访问根节点,最后再递归地遍历右子树。
后序遍历(Postorder Traversal):先按照后序遍历顺序递归地遍历左子树和右子树,最后访问根节点。
二叉树在计算机科学中有广泛应用,例如表示算术表达式、数据库索引结构、图形渲染等。
LRU(Least Recently Used,最近最少使用)是一种常用的缓存淘汰策略。下面是一个手撕LRU缓存的示例实现:
#include #include <unordered_map> #include
using namespace std;
class LRUCache { private: int capacity; unordered_map<int, pair<int, list::iterator>> cache; // 存储键值对和对应迭代器 list lruList; // 存储访问顺序
public: LRUCache(int cap) { capacity = cap; }
int get(int key) {
if (cache.find(key) == cache.end()) { // 若key不存在于缓存中
return -1;
}
// 将该元素移到链表头部表示最新访问
lruList.erase(cache[key].second);
lruList.push_front(key);
cache[key].second = lruList.begin();
return cache[key].first; // 返回对应value值
}
void put(int key, int value) {
if (cache.find(key) != cache.end()) { // 若key已存在于缓存中,则更新value,并将其移到链表头部表示最新访问
lruList.erase(cache[key].second);
lruList.push_front(key);
cache[key] = make_pair(value, lruList.begin());
return;
}
if (cache.size() == capacity) { // 缓存已满,需要淘汰末尾的元素(即最久未使用)
int lastKey = lruList.back();
cache.erase(lastKey);
lruList.pop_back();
}
// 插入新元素到链表头部表示最新访问
lruList.push_front(key);
cache[key] = make_pair(value, lruList.begin());
}
};
int main() { LRUCache cache(2); // 创建容量为2的LRU缓存
cache.put(1, 1);
cache.put(2, 2);
cout << cache.get(1) << endl; // 输出:1
cache.put(3, 3);
cout << cache.get(2) << endl; // 输出:-1,因为key=2被淘汰了
cache.put(4, 4);
cout << cache.get(1) << endl; // 输出:-1,因为key=1被淘汰了
cout << cache.get(3) << endl; // 输出:3
cout << cache.get(4) << endl; // 输出:4
return 0;
}
memcpy是一个用于内存拷贝的标准库函数。下面是一个手撕memcpy函数的简单实现:
#include
void myMemcpy(void* dest, const void* src, size_t size) { char* destPtr = static_cast<char*>(dest); const char* srcPtr = static_cast<const char*>(src);
for (size_t i = 0; i < size; ++i) {
destPtr[i] = srcPtr[i];
}
}
int main() { int source[] = {1, 2, 3, 4, 5}; int destination[5];
myMemcpy(destination, source, sizeof(source));
for (const auto& element : destination) {
std::cout << element << " ";
}
return 0;
}
在这个示例中,myMemcpy函数接受三个参数:目标指针 dest、源指针 src 和要拷贝的字节数 size。通过将指针转换为 char* 类型,我们可以按字节进行拷贝。然后,使用一个循环来逐字节地将源数据复制到目标内存中。
注意,在实际开发中,建议使用标准库提供的 memcpy 函数或其他相关的安全替代品,以确保更高的效率和正确性。上述手撕版本只是为了演示原理,并不考虑边界情况和优化处理。
好的,这个问题考察的是C/C++工程实践的基础知识,在AI算法岗面试中出现,是因为算法工程师不仅需要设计模型,还需要理解模型部署和集成的底层机制。动态库和静态库是模型、算子、预处理/后处理代码集成到最终应用程序的关键方式。下面我将详细解释,并用实际案例和三大领域的应用场景来说明。
核心概念解析
-
静态库 (Static Library, 通常
.ain Linux/macOS,.libin Windows)- 含义:在编译链接阶段,库中的代码会被完整地复制到最终生成的可执行文件(
.exe或 无后缀的二进制文件)中。 - 关键特性:
- 自包含:生成的可执行文件包含了所有需要的库代码,运行时不依赖外部的库文件。
- 文件大小:可执行文件体积较大,因为它包含了库的完整拷贝。
- 内存占用:如果多个程序使用了同一个静态库,每个程序的内存中都会有一份该库代码的副本,内存利用率较低。
- 更新/部署:修改库需要重新编译链接整个程序才能生效。部署时只需发布单个可执行文件。
- 链接时机:编译时链接 (
gcc -lstaticlib)。
- 含义:在编译链接阶段,库中的代码会被完整地复制到最终生成的可执行文件(
-
动态库 (Dynamic Library / Shared Library, 通常
.soin Linux,.dylibin macOS,.dllin Windows)- 含义:在编译链接阶段,编译器/链接器只在可执行文件中记录它需要哪些动态库(符号引用)。在程序运行时,操作系统的动态链接器(
ld.so或dyld)才会将所需的动态库加载到内存中,并将程序中的符号引用绑定到库中实际的函数/变量地址上。 - 关键特性:
- 共享性:同一个动态库在内存中通常只有一份副本,可以被多个运行中的程序共享使用,提高内存利用率。
- 文件大小:可执行文件体积较小,因为它只包含对库的引用。
- 内存占用:内存利用率高(共享代码段)。
- 更新/部署:更新库文件后,只需替换旧的
.so/.dll文件,所有依赖它的程序在下次启动时自动使用新版本(注意ABI兼容性)。部署时需要同时发布可执行文件和它依赖的所有动态库。 - 链接时机:运行时链接 (
gcc -lsharedlib, 程序运行时由系统加载)。
- 含义:在编译链接阶段,编译器/链接器只在可执行文件中记录它需要哪些动态库(符号引用)。在程序运行时,操作系统的动态链接器(
核心差异总结表
| 特性 | 静态库 (.a, .lib) |
动态库 (.so, .dll, .dylib) |
|---|---|---|
| 链接时机 | 编译时 | 运行时 |
| 代码位置 | 嵌入到最终可执行文件中 | 独立的库文件 |
| 可执行文件大小 | 较大 (包含库代码) | 较小 (仅含引用) |
| 内存占用 | 较高 (每个程序独立拷贝) | 较低 (多个程序共享内存中同一份代码) |
| 更新库 | 需重新编译链接整个程序 | 替换库文件即可 (需注意ABI兼容性) |
| 部署复杂度 | 简单 (单文件) | 较复杂 (需同时部署程序+所有依赖的动态库) |
| 运行时依赖 | 无 | 必须有对应版本的库文件存在 |
| 加载速度 | 启动快 (代码已在可执行文件中) | 启动稍慢 (需加载和链接库) |
| 插件化支持 | 困难 | 天然支持 (dlopen/LoadLibrary) |
通俗易懂的实际案例:AI推理引擎集成
-
场景:你开发了一个图像分类的AI模型(比如ResNet50),并编写了C++的推理代码(包含模型加载、预处理、推理、后处理)。现在需要将这个功能集成到两个不同的应用程序中:
- 一个轻量级的命令行工具:用于快速测试单张图片分类。
- 一个大型的图形界面软件:包含图像编辑、管理、分析等多种功能,你的分类功能只是其中一部分。
-
解决方案与对比:
-
方案A (全用静态库):
- 将你的推理代码编译成一个静态库
libresnet.a。 - 编译命令行工具时,链接
libresnet.a,生成独立的classify_tool。部署只需这一个文件。 - 编译图形界面软件时,也链接
libresnet.a。最终生成一个很大的image_editor_app文件。部署也只需这一个文件。
- 优点:两个应用都独立部署,互不影响。
- 缺点:
- 图形界面软件变得非常庞大,因为它包含了完整的ResNet50推理代码。
- 如果你修复了推理库
libresnet.a中的一个bug,你需要重新编译并重新发布两个应用程序,用户需要重新下载安装整个大软件包。命令行工具也需要重新编译发布。 - 如果系统同时运行
classify_tool和image_editor_app,内存中会存在两份完全相同的ResNet50推理代码,浪费内存。
- 将你的推理代码编译成一个静态库
-
方案B (全用动态库):
- 将你的推理代码编译成一个动态库
libresnet.so(Linux) 或resnet.dll(Windows)。 - 编译命令行工具
classify_tool时,链接libresnet.so(告诉它运行时需要这个库)。 - 编译图形界面软件
image_editor_app时,也链接libresnet.so。 - 部署:
- 发布
classify_tool+libresnet.so - 发布
image_editor_app+libresnet.so
- 发布
- 优点:
- 两个应用程序的可执行文件都变小了。
- 如果同时运行
classify_tool和image_editor_app,操作系统通常只会将libresnet.so加载一次到内存,供两个程序共享使用,节省内存。 - 如果你修复了
libresnet.so中的一个bug,你只需重新编译发布libresnet.so。用户只需要替换这个动态库文件,下次启动classify_tool或image_editor_app时,它们就会自动使用修复后的新版本!无需重新编译或重新安装整个大型应用。
- 缺点:
- 部署时需要确保
libresnet.so文件放在程序能找到的地方(系统库路径、程序同目录或LD_LIBRARY_PATH指定)。 - 如果用户不小心删除了
libresnet.so或者版本不匹配,两个程序都将无法运行(运行时错误,提示找不到库或符号)。 - 启动程序时,操作系统需要花一点时间加载和链接动态库。
- 部署时需要确保
- 将你的推理代码编译成一个动态库
-
方案C (混合使用 - 最常见):
- 命令行工具
classify_tool追求极致的独立性和简单部署,使用静态链接libresnet.a,生成一个独立的、无依赖的可执行文件。 - 大型图形界面软件
image_editor_app追求模块化、可更新性和内存共享,使用动态链接libresnet.so。
- 命令行工具
-
在AI三大领域中的应用
-
AIGC (生成式AI - 如Stable Diffusion, LLM 部署)
- 应用场景:
- 模型插件化:图像编辑软件(如Photoshop插件)或聊天应用中集成文生图、文生文功能。模型本身和推理引擎通常被打包成动态库 (
*.so/*.dll)。 - 模型热更新:AIGC模型迭代速度极快。使用动态库,开发者可以在不更新主程序的情况下,仅替换模型推理库或模型文件本身,用户重启应用即可体验新模型效果。
- 多后端支持:同一个AIGC应用可能需要支持CPU、不同厂商GPU(NVIDIA/AMD/Intel)。可以将针对不同硬件优化的推理引擎(ONNX Runtime, TensorRT, OpenVINO, DirectML)编译成不同的动态库(如
sd_cpu.so,sd_cuda.so,sd_metal.so)。主程序在运行时根据检测到的硬件动态加载 (dlopen/LoadLibrary) 最合适的库。
- 模型插件化:图像编辑软件(如Photoshop插件)或聊天应用中集成文生图、文生文功能。模型本身和推理引擎通常被打包成动态库 (
- 典型选择:动态库是绝对主流,因其支持插件化、热更新和灵活的后端切换。静态库可能用于构建非常独立的命令行工具(如独立的SD图像生成器)。
- 应用场景:
-
传统深度学习 (CV/NLP模型部署 - 如ResNet, BERT)
- 应用场景:
- 嵌入式设备部署:在资源受限的边缘设备(摄像头、工控机)上,追求极致的启动速度和确定性,避免动态链接的开销和依赖问题。常将核心模型推理代码静态链接进最终应用程序,甚至和操作系统一起编译成固件。减少文件数量和依赖,启动更快。
- 服务器端推理服务:大型推理服务(如基于TensorFlow Serving, Triton)本身是一个大程序,它加载的模型插件通常实现为动态库。服务本身不需要重启,就能加载新的模型(新动态库)或更新现有模型库。
- 算法SDK分发:AI公司向客户提供人脸识别、OCR等功能的SDK。为了便于客户集成和更新(尤其是修复bug或安全漏洞),SDK的核心逻辑通常封装为动态库。客户应用程序动态链接该库。更新SDK时,客户只需替换动态库文件并重启应用(或服务)。
- 典型选择:混合使用。嵌入式/边缘端倾向静态库或深度优化的单一可执行文件;服务器端SDK分发、插件模型加载普遍使用动态库。
- 应用场景:
-
自动驾驶 (Perception, Fusion, Planning 模块)
- 应用场景:
- 模块化与安全隔离:自动驾驶系统极其复杂,包含感知(摄像头/雷达/Lidar)、融合、预测、规划、控制等多个模块。这些模块通常由不同团队开发。使用动态库可以将每个模块封装成独立的组件,定义清晰的接口(API)。主框架负责加载这些库并在模块间调度数据。一个模块崩溃(如除零错误导致SIGFPE)不一定导致整个系统崩溃(取决于框架设计),便于容错和调试。
- OTA更新:汽车通过OTA更新软件是刚需。如果某个感知算法(如新的障碍物检测模型)需要更新,只需替换对应的动态库文件 (
perception_camera.so),无需更新整个庞大的自动驾驶软件栈,节省带宽和时间,降低更新风险。 - 硬件抽象层:自动驾驶芯片平台多样(NVIDIA Orin, Qualcomm Ride, Horizon Journey, Tesla FSD)。硬件驱动和底层加速库(如针对特定NPU的卷积优化)通常被封装为动态库。上层感知规划等业务逻辑通过HAL动态库接口访问硬件,业务逻辑代码无需为不同平台重新编译。
- 实时性与确定性要求:某些对启动时间或确定性要求极高的核心模块(如最底层的安全监控、看门狗),可能会采用静态链接,确保其绝对独立和快速启动。
- 典型选择:动态库是架构基石。强大的模块化、独立更新、硬件抽象能力是其核心优势。静态库仅用于少数对启动或鲁棒性有极端要求的底层组件。
- 应用场景:
面试回答建议
- 清晰定义:开门见山说明静态库是编译时嵌入代码,生成独立大文件;动态库是运行时加载共享代码,文件小但需依赖。
- 核心差异对比:重点强调链接时机、代码位置、文件大小、内存共享、更新方式、运行时依赖这几点差异。用表格对比更佳。
- 案例生动:使用“AI推理引擎集成到命令行工具VS大型图形软件”的例子,具体说明静态链接和动态链接在不同场景下的优劣和选择逻辑。突出动态库在大型应用中的热更新和内存共享优势。
- 领域应用点睛:
- AIGC:强调动态库支撑插件化、模型热更新、多后端运行时选择,适应快速迭代。
- 传统深度学习:点明嵌入式/边缘端用静态库求快求稳,服务器端SDK/服务用动态库求灵活易更新。
- 自动驾驶:突出动态库是实现复杂系统模块化、OTA增量更新、硬件抽象层的关键技术,提升系统可维护性和安全性。
- 总结升华:说明理解两者差异对于AI工程师设计可部署、可维护、高效的AI系统至关重要,尤其是在模型集成、SDK开发和大型系统架构层面。