C++笔试入门,最怕的不是不会写,而是会写但没拿到分。很多刚入门的同学刷题时经常遇到这种情况:题目一看就懂,代码也能跑通,但一到笔试现场就处处碰壁。这份C++入门级笔试题合集(一),就是我结合这些年带新人、改考卷、自己面试的经验,把最常出现、最容易被忽略的C++基础考点重新梳理了一遍。它不是什么高深算法合集,而是适合正在准备C++岗位笔试、刚学完C++语法想检验水平、或者自学C++遇到瓶颈的同学,帮你把那些“看似简单但一考就错”的知识点一次性理清。
1. 输入输出与基础语法类问题
1.1 cin/cout与scanf/printf的选择之争
笔试里第一题往往是输入输出,看起来毫无技术含量,但栽在这里的人真不少。很多同学习惯性用cin/cout,因为平时写代码方便。可到了笔试环境,数据量一大,cin/cout的效率问题就暴露出来了。
先说结论:在入门级笔试中,直接用cin/cout问题不大,但你要知道背后的机制和优化方式。cin/cout慢,核心原因是C++为了兼容C语言,默认让iostream和stdio同步,这就导致每次输入输出都有额外的同步开销。如果你确定代码里只用cin/cout,不会混用scanf/printf,可以在main函数开头加上这一行:
ios::sync_with_stdio(false); cin.tie(nullptr);这行代码的作用就是切断和C标准库的同步,同时解除cin和cout的绑定。很多同学不知道,cin和cout默认是绑定的,意思是每次cin读入之前,cout都会先刷新缓冲区,这也会拖慢速度。
但这里有个操作禁忌要记住:如果你关闭了同步,就绝对不能混用cin和scanf,否则数据读入顺序会乱掉,这是个很隐蔽的坑。我见过一个同学在笔试中,前面用cin,后面突然用scanf读一个字符,结果程序跑挂,找了好久才找到原因。
如果你刷题量大,数据上百万级别的读入,推荐用自己封装的快读函数,或者直接用getchar逐字符读取。下面给一个常见的快读模板:
int read() { int x = 0, f = 1; char ch = getchar(); while (ch < '0' || ch > '9') { if (ch == '-') f = -1; ch = getchar(); } while (ch >= '0' && ch <= '9') { x = x * 10 + ch - '0'; ch = getchar(); } return x * f; }这个函数用getchar逐字符读入,自己处理数字和负号,效率比cin高很多。笔试中如果遇到大数据的题目,这个模板能帮你稳定通过时间限制。
1.2 字符串输入的三种方式和它们的坑
字符串处理是入门级笔试题的常客,而字符串输入又是最容易出问题的地方。很多同学连“读入一行带空格的字符串”都搞不定。
先说最基础的三种读入方式:
第一种,cin >> str。这种方式读入字符串时遇到空格就停,所以无法读入包含空格的字符串。如果题目要求读入一个英文句子,你用cin就错了。
第二种,getline(cin, str)。这种方式可以读入一整行,包括空格。但注意,如果在getline之前用过cin >>,那cin读完后缓冲区里会残留一个换行符,getline会直接读到这个换行符并结束,导致你后面读到的字符串是空的。解决办法是getline前加一个cin.ignore():
int n; cin >> n; cin.ignore(); string line; getline(cin, line);第三种,C语言风格的fgets或gets。在C++里虽然不推荐,但笔试中偶尔也会看到。gets函数因为无法限制读入长度,在较新的编译环境中已经被移除了,如果遇到老题库还有gets,建议用fgets替代:
char buf[256]; fgets(buf, sizeof(buf), stdin);另外,字符串转数字在笔试中也很常见。C++11之后可以用stoi、stol、stod等函数,但注意stoi在转换失败时会抛出invalid_argument异常,如果不做异常处理,程序会直接终止。稳妥的做法是:
try { int num = stoi(str); } catch (const std::invalid_argument& e) { // 处理转换失败的情况 }笔试里字符串和数组之间的转换也是一个高频考点。比如把字符串按逗号分隔成数组,这需要自己实现split逻辑,因为C++标准库没有直接的split函数。一个简单实现是用stringstream配合getline的第三个参数:
vector<int> splitStringToInt(const string& s, char delimiter) { vector<int> result; stringstream ss(s); string item; while (getline(ss, item, delimiter)) { result.push_back(stoi(item)); } return result; }2. 数组、指针与内存管理基础
2.1 数组名和指针的区别——笔试常考的迷惑点
数组和指针是C++入门笔试的重灾区。很多人觉得数组名就是指针,这个理解不完全是错的,但在笔试里要区分清楚。
先跑一个测试:
int main() { int arr[5] = {1, 2, 3, 4, 5}; int* p = arr; cout << "sizeof(arr) = " << sizeof(arr) << endl; // 输出20 cout << "sizeof(p) = " << sizeof(p) << endl; // 输出8(64位系统) return 0; }数组名被用于sizeof操作符时,返回的是整个数组占用的字节数,而不是指针的大小。但在大多数表达式中,数组名会退化为指向首元素的指针。这个“退化”规则是笔试的经典考点。
具体来说,数组名在以下两种情况下不退化:
- 作为sizeof的操作数
- 作为&操作符的操作数(取数组名地址,得到的是指向整个数组的指针)
还有一个常见考点:二维数组的指针操作。比如int a[3][4], a+1指向的是第二行的首地址,步长是一行而不是一个元素。这个知识点很多人一开始搞不清楚,可以用下面的代码来理解:
int a[3][4] = {0}; cout << a[0] << endl; // 第一行首地址 cout << a[1] << endl; // 第二行首地址 cout << a + 1 << endl; // 也是第二行首地址 cout << *a + 1 << endl; // 第一行第二个元素的地址笔试中如果考到了数组指针,核心就是搞清楚指针的步长。对于int来说,+1移动4个字节;对于int()[4]来说,+1移动16个字节(一行的长度)。
2.2 字符串数组初始化:两个容易混淆的写法
字符串数组的初始化也是笔试题中比较容易设陷阱的地方。看下面两行:
char str1[] = "hello"; char str2[] = {'h', 'e', 'l', 'l', 'o'};str1的长度是6,因为编译器自动在末尾加了'\0';str2的长度是5,没有结束符。如果你用strlen去测量,strlen(str1)返回5,但strlen(str2)会继续向后扫描直到遇到随机位置上的'\0',结果不确定。
笔试中经常考到在末尾追加字符:
char str[10] = "hello"; str[strlen(str)] = '!'; str[strlen(str) + 1] = '\0';如果漏掉最后一行的结束符手动添加,后续字符串操作函数可能会读取越界,行为未定义,这是笔试中的隐蔽扣分点。
对于C++中的string类,转字符数组时也有类似问题。使用c_str()函数获取的是const char*,如果你需要修改,必须拷贝到新的数组中:
string s = "hello"; vector<char> buf(s.begin(), s.end()); buf.push_back('\0'); char* mutable_data = buf.data();笔试里还有一个常见的字符串数组初始化问题,就是指针数组和字符数组的区别:
const char* ptr = "hello"; // 字符串常量,可能存储在只读区,修改会崩溃 char arr[] = "hello"; // 可修改的数组如果要修改字符串内容,必须用数组方式而不是指针方式。这个问题在一些实际操作中被测试过,很容易被忽略。
2.3 内存管理的基本约定
入门级笔试对内存管理的要求不算深,但new和delete、malloc和free这两组的区别基本是必考。
核心区别有几点:
- new自动计算分配大小并调用构造函数,malloc需要手动指定大小且不调用构造函数
- new返回类型安全的指针,malloc返回void*需要强转
- new失败抛出bad_alloc异常,malloc失败返回NULL
笔试中常写的答案框架是:
// C++风格 int* p = new int(10); delete p; // C风格 int* q = (int*)malloc(sizeof(int)); if (q != NULL) { *q = 10; free(q); }还有一个高频追问:new[]和delete必须配对,malloc和free必须配对。混用是未定义行为,程序可能正常运行,也可能会崩溃,不要存侥幸心理。
3. 算法基础:排序、递归与幂运算
3.1 冒泡排序:从写法到优化的完整演进
冒泡排序是入门级笔试题里出现频率最高的排序算法。基本逻辑就是相邻元素两两比较,大的往后移,每轮确定一个最大值的位置。
最基础的写法:
void bubbleSort(vector<int>& arr) { int n = arr.size(); for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { swap(arr[j], arr[j + 1]); } } } }笔试中如果只是写出这个版本,只能算及格。更吸引眼球的是在基础版本上加一个优化——提前退出标志:
void bubbleSort(vector<int>& arr) { int n = arr.size(); for (int i = 0; i < n - 1; i++) { bool swapped = false; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { swap(arr[j], arr[j + 1]); swapped = true; } } if (!swapped) break; // 某一轮没有任何交换,说明已经有序 } }这个优化在数组基本有序的情况下,时间复杂度可以从O(n^2)直接降到O(n)。笔试题里如果给出一个“已经排好序但你还不知道”的数组,这个优化版本可以显著缩短运行时间。
3.2 选择排序的理解和实记技巧
选择排序的核心思想是每轮从未排序区间中选出最小值,放到已排序区间的末尾。
void selectionSort(vector<int>& arr) { int n = arr.size(); for (int i = 0; i < n - 1; i++) { int minIdx = i; for (int j = i + 1; j < n; j++) { if (arr[j] < arr[minIdx]) { minIdx = j; } } if (minIdx != i) { swap(arr[i], arr[minIdx]); } } }选择排序之所以适合入门,是因为它的交换次数只有O(n)次,最多n-1轮。笔试里如果题目要求“只能使用O(1)额外空间”,选择排序是一个有力的选项,内存占用极小。
我实际批改笔试时发现,很多同学能写出冒泡排序,但选择排序会搞混内层循环的起始位置。记法很简单:外层循环i表示要确认的位置,内层循环从i+1开始找剩下的最小值位置。这个“先找下标,最后交换”的模式,就是选择和冒泡的本质区别。
3.3 快速幂:入门算法中性价比最高的一个
快速幂在入门级笔试中出现频率高,而且考察难度不大,只要理解了递归公式就能写出来。
核心公式:
- 当n为偶数时:a^n = (a^(n/2))^2
- 当n为奇数时:a^n = a * (a^((n-1)/2))^2
利用这个公式,计算a^16只需要4次乘法,而朴素计算需要15次。笔试里遇到“求a^b mod m”这种题目,用快速幂是基本要求。
迭代写法:
long long quickPow(long long base, long long exp, long long mod) { long long result = 1 % mod; while (exp > 0) { if (exp & 1) { result = (result * base) % mod; } base = (base * base) % mod; exp >>= 1; } return result; }这个实现里有一个细节:result初始化为1 % mod。原因是在mod=1时,任何数的模运算结果都是0,如果初始化为1,算出的答案就会是错误的1。笔试中这个边界情况容易被忽略,就是那些专门针对边界想法设计的题目。
4. 面向对象与设计模式入门
4.1 构造函数、析构函数和拷贝控制
面向对象的入门笔试基本都会考构造函数和析构函数的调用顺序。很多同学死记硬背,但遇到继承体系就懵了。
核心规则如下:
- 构造时:先构造基类子对象,再构造派生类成员,最后执行派生类构造体
- 析构时:顺序完全相反,先执行派生类析构体,再析构成员,最后析构基类子对象
下面这个例子在笔试题中反复出现:
class A { public: A() { cout << "A" << endl; } virtual ~A() { cout << "~A" << endl; } }; class B : public A { public: B() { cout << "B" << endl; } ~B() { cout << "~B" << endl; } }; int main() { A* p = new B(); delete p; return 0; }输出结果应该是:A、B、~B、~A。关键在于析构函数必须声明为virtual。如果基类析构函数不是virtual,那么delete p时只会调用A的析构函数,而不会调用B的析构函数,这样就可能造成派生类资源没有正确释放。这个知识点在笔试中考过很多次,基本是必背项。
不过,在C++11以后,更建议使用智能指针而不是手动管理析构。用unique_ptr后,不需要手动delete,异常发生时也能正确释放,安全性更高:
unique_ptr<A> p = make_unique<B>();4.2 常见设计模式在笔试中的出现方式
入门级笔试对设计模式的考察通常不会太深,但单例模式和观察者模式是最常出现的两个。其中单例模式因为代码量少、考点明确,几乎成了笔试常客。
单例模式的经典实现方式是双重检查锁(DCLP),但在C++11之后,更推荐使用Magic Static(魔法静态变量)的方式:
class Singleton { public: static Singleton& getInstance() { static Singleton instance; return instance; } Singleton(const Singleton&) = delete; Singleton& operator=(const Singleton&) = delete; private: Singleton() {} ~Singleton() {} };这种方法利用了C++11中局部静态变量初始化是线程安全的特点,既简洁又安全。笔试中若问到“怎么实现线程安全的单例”,能被完整写出来基本就很稳了。
观察者模式的考题一般不会让完整实现,而是给一个场景让你说出模式名称和核心思想。比如“多个对象依赖某个对象的状态变化,如何解耦”,答“观察者模式”并说清楚发布-订阅的关系即可。
4.3 回调函数的基本理解
C++中的回调函数在笔试中出现的频率越来越高,特别是涉及异步和事件处理的题目。入门级的要求是掌握函数指针和std::function两种写法。
函数指针写法:
void process(int x, void (*callback)(int)) { callback(x); } void printNum(int n) { cout << n << endl; } int main() { process(42, printNum); return 0; }std::function写法更灵活,可以绑定lambda表达式、函数对象等:
#include <functional> void process(int x, std::function<void(int)> callback) { callback(x); } int main() { process(42, [](int n) { cout << n * 2 << endl; }); return 0; }笔试中考回调函数时,一般会问“如何把函数当参数传递”,能写出std::function版本,说明你了解现代C++的写法。不过别只背一种,函数指针的基础还是要熟悉。
5. C++11及以后的关键特性
5.1 constexpr:版本演进与使用场景
constexpr是C++中一个比较特殊的关键字,笔试中经常看到“constexpr是哪个版本的C++引入的”这类问题。答案是C++11引入的。但在C++14和C++17中,constexpr的能力被大幅扩展。
C++11的constexpr相对保守,函数体内只能有一条return语句。从C++14开始,constexpr函数内可以使用循环、局部变量和条件判断,这让它可以处理更复杂的逻辑。
举个例子:
// C++11版本,只能这样写 constexpr int square(int x) { return x * x; } // C++14及以后,可以这样写 constexpr int factorial(int n) { int result = 1; for (int i = 2; i <= n; ++i) { result *= i; } return result; }笔试中如果考到constexpr,大概率会追问“constexpr和const的区别”。重点在于:const强调的是不可修改的约束,但不保证编译期求值;constexpr强调编译期求值,隐含了const的性质。两者定位不同,必须明确区分。
5.2 移动语义与右值引用
这是C++11中非常重要的特性,虽然入门级笔试很少直接考深层的实现原理,但常见的题目是让你说清楚std::move的作用。很多同学刚开始以为std::move是“移动”了对象,其实不是这样的——它只是把左值转换为右值引用,让编译器可以调用移动构造函数或移动赋值操作符,从而避免深拷贝。
一个简单的对比:
// 使用std::move避免不必要的拷贝 std::vector<int> v1 = {1, 2, 3, 4}; std::vector<int> v2 = std::move(v1); // v1被移动后处于有效但未指定的状态,不能再指望它保持原内容笔试中常见追问是:“移动后源对象处于什么状态?”标准答案是:处于有效但未指定的状态,应避免继续使用。
如果你在笔试中写出自定义类的移动构造函数,代码可以是这样:
class MyString { public: MyString(MyString&& other) noexcept : data(other.data), len(other.len) { other.data = nullptr; other.len = 0; } private: char* data; size_t len; };注意移动构造函数通常要标记为noexcept,这样在容器扩容时才会优先使用移动而不是拷贝。这一点在stl容器相关的题目中是一个常见的考察点。
5.3 多线程基础笔试考点
C++多线程在入门级笔试中不会考太深,但“线程创建的基本写法”和“数据竞争的基本概念”是比较常见的题目。
线程创建的基本写法:
#include <thread> #include <iostream> void worker() { cout << "worker thread\n"; } int main() { std::thread t(worker); t.join(); // 等待线程结束 return 0; }笔试中的追问往往是“如果没有调用join或detach会发生什么”。答案是:当std::thread对象被析构时,如果线程仍然joinable,程序会调用std::terminate导致崩溃。解决办法是在异常发生时也要确保join或者detach被调用,正确使用方法是在对象析构前完成线程生命周期管理。
入门级笔试中数据竞争相关的问题,通常考察互斥锁的使用:
std::mutex mtx; int counter = 0; void increment() { std::lock_guard<std::mutex> lock(mtx); ++counter; }lock_guard和unique_lock的区别也是容易被问到的问题。简单记:lock_guard构造时加锁、析构时解锁,不可手动操作;unique_lock更灵活,可以手动unlock和lock,适合需要条件变量的场景。笔试中写简单同步逻辑用lock_guard就够了。
6. 编译环境与笔试现场实战经验
6.1 在VS Code中配置C/C++环境的实用方法
笔试前不少人会在本地环境上栽跟头,特别是刚接触C++的同学。VS Code是很多人用到的编辑器,但部署配置经常有偏差,这里说一下我推荐的配置流程。
VS Code本身只是一个编辑器,编译任务需要靠插件和后端的编译器来完成。你需要安装的插件是“C/C++ Extension Pack”,由微软官方维护,同时建议搭配“Code Runner”来快速运行代码。编译器方面,Windows系统可以用MinGW-w64,macOS直接用clang(系统自带),Linux用g++。
配置的关键步骤其实不多,但有一个容易踩坑的地方:MinGW安装完成后,bin目录的路径必须添加到系统的PATH环境变量中。很多人装完了才发现命令行里输g++找不到命令,就是这个原因。
添加PATH之后,在终端里输入:
g++ --version如果能看到版本号,说明环境已经就绪。接下来在VS Code中按F5,选择“C++ (GDB/LLDB)”环境,然后生成launch.json和tasks.json,程序就能运行了。
tasks.json中需要配置的核心内容是编译命令。一个常用的配置是:
{ "version": "2.0.0", "tasks": [ { "type": "cppbuild", "label": "C/C++: g++.exe build active file", "command": "g++", "args": [ "-fdiagnostics-color=always", "-g", "-std=c++17", "${file}", "-o", "${fileDirname}\\${fileBasenameNoExtension}.exe" ], "options": { "cwd": "${fileDirname}" }, "problemMatcher": ["$gcc"], "group": "build" } ] }注意到args里我明确写了-std=c++17。如果不指定,g++默认使用C++14,那你想用C++17的特性(比如std::filesystem)就会报错。笔试刷题时遇到莫名其妙的编译错误,先看看是不是标准版本的问题。
6.2 笔试现场的时间分配和调试策略
笔试现场的时间管理,是很多人容易忽略但影响很大的因素。我的建议是“三分钟定生死”策略:拿到题目后,先用三分钟判断这道题是不是你熟悉的套路题。如果是,直接动手写;如果不是,先在草稿纸上把逻辑流程梳理清楚,不要急着敲代码。
很多同学在笔试中犯的最大错误是——拿到题目就开始写代码,写到一半发现思路有问题,又全部删掉重写。在练题阶段,建议培养一个习惯:先写注释把算法步骤列出来,再在注释下填充代码。这样能大幅减少思路跑偏的概率。
另外一个容易踩的坑是:笔试提交前不检查变量类型溢出。C++笔试题中,如果题目的数值范围到了10^9量级,int类型很可能放不下。int的上限大约是2.1×10^9,但中间运算过程中可能会溢出,这时需要改用long long。一个快速判断方法是:看到参与乘法或累加的变量,先问自己“这个数会不会超过10位数”。
批量测试时建议用自定义数据测试而不是只依赖题目给出的例子。题目给的例子往往比较简单,边界情况(空输入、单元素、重复元素、等比数列)需要自己构造。测试时,我习惯把测试数据放在一个.txt文件里,然后通过输入重定向来测试:
g++ -o sol sol.cpp ./sol < test1.txt这样测试数据可以重复利用,比每次手动输入方便很多。
6.3 常见编译错误的排查顺序
笔试现场碰到编译错误,很多人会反复看代码却找不到原因。这里给一个排查顺序:先看错误行号,再往上追一行。因为C++编译器报错时,有时候实际出错的位置会在报错行的上一行(比如缺少分号、括号不匹配)。
另一个高频报错是:linker command failed,通常是因为函数声明了但没定义,或者源文件没有正确编译。在VS Code中如果遇到这个问题,检查一下tasks.json中的args列表有没有把当前文件包含进去。
还有一种情况是包含头文件的顺序导致的问题。比如在同一个翻译单元中,先包含了某个库的头文件,这个头文件又间接包含了另一个头文件,顺序反了会导致找不到定义。虽然这属于较冷门的点,但笔试中如果遇上了会很浪费时间。
我个人总结了一个“三不原则”:不盲改(没确定原因之前不要乱改代码)、不硬扛(超过15分钟卡在同一处就换思路)、不留隐患(输出的结果如果不确定,就多用几组数据验证)。这三位一体地在实战中帮我把不稳定因素处理掉。
7. 从笔试题看C++学习的本质
写到这里,你会发现“入门级笔试题”这个说法其实涵盖的内容远不止语法。它涉及输入输出的底层机制、内存管理的原则、面向对象的抽象思维、泛型与STL的使用、并发编程的基本素养,还有工程层面的环境配置能力。
我个人在学习C++过程中的体会是:笔试刷题只能帮你积累“知识点数量”,真正让你对C++有感觉的,是把每个知识点背后的“为什么”想明白。比如为什么cin慢、为什么析构函数要virtual、为什么移动语义能提高性能——每一个问题背后都牵扯到计算机底层的工作原理。想透了这层,再去做题,很多题目就变得有章可循。
还有一个建议:写每题时都主动去尝试跟标准答案不同的写法。比如冒泡排序可以用递归实现,字符串分割可以用双指针实现。这些扩展练习能让你在考场上有更多选择,而不是只会一个固定模板。
这套合集是为系列准备的,后续会继续整理更复杂的题目类型,比如STL容器进阶、模板元编程入门、算法题中的C++技巧、笔试中常见的多线程与网络编程题目。如果你也有遇到过比较好的题目,欢迎在评论区分享。