☰
C++刷题必备:STL容器、复杂度分析与笔试模式全解析
2026/10/13 4:09:41 网站建设 项目流程

如果你正在用 C++ 刷 LeetCode,却总是觉得自己进步慢,那大概率不是题做得少,而是“刷题基础知识”这块地基没打牢。这不是客套话,我见过不少刷了两三百题的选手,代码也在本地跑得通,一上牛客网笔试就挂得一塌糊涂。挂的原因往往不是算法想不出来,而是不知道读入多组数据、不知道输出格式该怎么凑、不知道这题用 map 还是 unordered_map、不知道自己的解法在数据规模下注定超时。这些才是真正决定刷题上限的东西。

这篇文章不打算讲某一道具体的算法题,而是把那些你刷题时要面对的公共问题串起来聊一遍:LeetCode 和牛客网两种答题模式的差异、C++ 刷题最常用的 STL 容器和函数、复杂度分析的直觉、高频基础算法模板、调试技巧和刷题计划。适合刚开始刷题准备面试的新手,也适合刷了几百道但心里没底、想回头补基础的人。

1. 刷题之前先搞清楚的几件事:LeetCode 与牛客网的真实差异

很多人以为刷题平台都一样,其实 LeetCode 和牛客网的答题模式完全是两套玩法。如果你只在一个平台上刷题,另一个平台上的笔试就很容易翻车。这一章先把这两者的差异讲透,这是后续所有技巧的基础。

1.1 核心答题模式差异:函数填空与完整程序

LeetCode 最常见的是“核心代码模式”,也就是题目给你一个函数签名,你只需要在编辑区里补全函数体,返回值做好,后台会自动调用你写的函数并传入测试参数。你不需要写 main、不需要读输入,也不需要控制输出格式。这种模式像开卷答题,环境帮你把杂事都处理好了,你专心写核心逻辑就行。

牛客网的情况完全不同。很多笔试题要求你提交一个完整的可运行程序,包括头文件、main 函数、数据读入、业务计算、结果输出,一步都不能少。这就像闭卷考试时要求你从草稿纸开始自己画答题卡,写错一个地方就全盘崩溃。

我见过最典型的翻车案例:某位同学 LeetCode 中等题能稳定做出来,但第一次用牛客网练题时,被一道读入不规则输入的简单题整懵了。他不知道怎么判断输入是否结束,不知道第一行的整数到底是有用数据还是测试组数,最后写出来的程序要么崩溃要么死循环。这纯粹是“解题模式”不熟练,跟算法水平无关。

所以我给所有准备笔试的人一个建议:每周至少抽出一天,专门在牛客网按照完整程序模式练题,强迫自己每次都写#include <bits/stdc++.h>、写using namespace std;、写 main 函数、思考输入输出。这件事没有技术含量,但必须练出肌肉记忆,否则真上了考场心态容易崩。

1.2 输入输出细节:牛客网刷题最容易踩的坑

牛客网笔试的输入格式五花八门,最常见的几种是:

  • 单组输入:程序里只需要读一次数据。
  • 多组输入直到 EOF:代码里需要while (cin >> x)循环读入。
  • 第一行给一个整数 n,接下来 n 行才是正式数据。
  • 一行字符串中包含多个空格或逗号分隔的数字,需要解析。

来看一个典型的多组输入模板:

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; while (cin >> n) { vector<int> a(n); for (int i = 0; i < n; ++i) { cin >> a[i]; } // 在这里完成当前这一组的业务逻辑 } return 0; }

这个模板在牛客网非常通用。while (cin >> n)会在读不到数据时自动退出循环,你不用自己判断 EOF,cin对象内部会帮你处理。注意ios::sync_with_stdio(false);和cin.tie(nullptr);这两行几乎是必须加的,不加的话大量输入时容易超时,加上后 C++ 流的读入速度能接近scanf的水平。

如果你的数据不是按行分隔,而是混在一行字符串里,就需要getline配合istringstream来解析:

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); string line; while (getline(cin, line)) { istringstream iss(line); int x; vector<int> nums; while (iss >> x) { nums.push_back(x); } // 处理 nums } return 0; }

这里有一个容易被新手踩的坑:如果在getline之前使用了cin >> n,输入缓冲区里会残留一个换行符,导致getline读到一个空行。解决办法是在cin >> n之后立刻调用一次cin.ignore();把换行吞掉。这个细节我在刚开始刷牛客网时栽过不止一次,特意写出来提醒大家。

1.3 刷题环境与工具链准备

LeetCode 网页编辑器对新手很友好,但调试验证始终不如本地方便。我个人的建议是本地准备一套简单的 C++ 刷题环境,不用完整的 IDE,一个编辑器和编译器就够。

我常用的是 VSCode 加 C/C++ 扩展,配合命令行编译运行。新建一个.cpp文件,写完代码后在终端里输入:

g++ -O2 -std=c++17 test.cpp -o test

-O2优化选项能比较接近在线评测机的规格,-std=c++17指定语言标准,避免用了std::gcd这类新特性时编译器报错。本地跑通后,再把代码复制到网页编辑器里提交。

另外强烈建议整理一份自己的刷题模板文件,把常用头文件、读入优化、快排比较函数、二分查找等基础的几行代码提前写好。笔试时直接从这个模板开始改,能节省不少敲代码的时间。但注意一点:面试手撕代码时不要背这种超长模板,面试官更喜欢看到干净、从零写起的代码。模板是给在线笔试准备的,不是给面试准备的。

2. C++ 刷题必须掌握的 STL 容器与算法

C++ 刷题和 C 语言刷题最大的区别就是 STL。用好容器能节省大量时间,但选错容器会让你的程序在超时边缘反复试探。这一章来聊 STL 容器选型和常用函数,这是刷题基础知识里性价比最高的部分。

2.1 容器选型背后的时间开销逻辑

很多刷题者会对容器的底层选择没有概念。实际上,容器选型就是一个时间和空间的博弈。vector是绝大多数题目的主角,它是动态数组,按下标随机访问是 O(1),尾部插入均摊也是 O(1),所以适合读入数据后反复遍历、随机访问的场景。deque支持头部和尾部都是 O(1) 插入删除,适合滑动窗口这类需要两端操作的场景。

map和unordered_map的区别是最容易出问题的点。map底层是红黑树,键值对自动排序,插入、查找、删除都是稳定的 O(log n),适合题目要求按键有序遍历的时候。unordered_map底层是哈希表,平均情况下插入查找是 O(1),适合只做查询、不需要保证顺序的题目。用错导致超时的典型场景是:明明只需要查键,却用map把一个明明 O(n) 能扫完的问题变成 O(n log n)。

打个生活化的比方,map像一本按拼音排序的电话簿,你要找某个人得翻页;unordered_map像一个按首字母直接分桶的档案柜,只要哈希足够分散,取资料就是秒级的事。但哈希表的最大弱点是有极小的概率发生大量冲突,导致单次操作退化,所以碰到对时间复杂度要求极其严格的题目,map的稳定 O(log n) 反而更可靠。

容器选型我整理成一个表,方便查阅:

容器底层结构典型插入复杂度典型查找/访问复杂度适用场景
vector动态数组尾插均摊 O(1)按下标 O(1)几乎所有顺序遍历场景
deque双向队列头尾 O(1)按下标 O(1)滑动窗口、需要头尾操作的场景
list双向链表已找到插入位置时 O(1)随机访问 O(n)频繁中间插入删除,但刷题中用得少
map红黑树O(log n)O(log n)需要按键有序遍历或稳定复杂度
unordered_map哈希表均摊 O(1)平均 O(1)只查键、不关心顺序
set红黑树O(log n)O(log n)去重且需要有序集合
unordered_set哈希表均摊 O(1)平均 O(1)只去重、判断存在
priority_queue堆O(log n)取最值 O(1)TopK、贪心、合并有序序列

priority_queue值得单独说。它默认是大顶堆,直接取top()得到最大值。如果你需要最小值,声明时改成:

priority_queue<int, vector<int>, greater<int>> pq;

再比如最常见的 TopK 问题:从 N 个数中找最大的 K 个数,维护一个大小为 K 的小顶堆,堆顶就是当前候选里的最小值,每当新元素比堆顶大就替换,最后堆里的 K 个元素就是答案,整体时间复杂度 O(N log K),比排序整个数组更优。

2.2 常用 STL 函数与刷题高阶技巧

容器之外,STL 算法函数同样决定你的编码效率。最常用的是sort,大多数情况下直接sort(v.begin(), v.end())就可以了。需要自定义规则时用 lambda:

sort(words.begin(), words.end(), [](const string& a, const string& b) { if (a.size() != b.size()) return a.size() < b.size(); return a < b; });

这段代码先按字符串长度升序排,长度相同再按字典序排。在排序题目里算是百搭写法。注意sort的底层是混合排序算法,平均 O(n log n),绝大多数在线评测都足够快,不要手写快排给自己找麻烦,除非面试官明确要求。

二分查找的三个常用函数是binary_search、lower_bound、upper_bound,它们都要求容器是有序的。lower_bound返回第一个大于等于目标值的迭代器,upper_bound返回第一个大于目标值的迭代器,两者相减还能直接得到目标值的个数。给个常见用法:

auto it = lower_bound(v.begin(), v.end(), target); int pos = it - v.begin(); // 得到下标 if (it != v.end() && *it == target) { // target 存在 }

严格来说使用前要确保v升序排列,否则结果是未定义行为。忘了这一条的人会在某些排序条件写反的题目里得到莫名其妙的结果。

其他常用函数也顺手列一下,都是做题经常用到的:

  • reverse(v.begin(), v.end()):反转区间。
  • accumulate(v.begin(), v.end(), 0):求和,第三个参数是初始值,字符串拼接场景也能用。
  • max_element(v.begin(), v.end()):返回最大值的迭代器。
  • next_permutation(v.begin(), v.end()):生成下一个字典序排列,全排列题目神器。
  • string::substr(pos, len)、to_string()、stoi():字符串和数字互转的常客。

这些函数不需要刻意背,刷题时多用几次就记住了。真正需要理解的不是某个函数怎么调用,而是背后的复杂度:sort是 O(n log n),lower_bound是 O(log n),max_element是 O(n)。你选的每个操作都会叠加到总复杂度里,这也是下一章要讲的复杂度分析。

3. 算法基本功:复杂度分析是刷题的“方向盘”

很多人有个误区,觉得复杂度分析是理论知识,等东西写出来能跑就行。但实际上,复杂度分析能决定你在一道题上少走多少弯路。尤其是面试场景,一道题你有了复杂度的大致想法,就知道该往哪个方向优化。

3.1 数据规模与复杂度目标的对照表

拿到一道题目,第一步不是急着写代码,而是看数据范围。数据范围直接告诉你这道题允许什么复杂度的算法。

我一般心里存着一张常用对照表,写出来给大家参考:

数据规模目标复杂度常见算法方向
n ≤ 10O(n!)暴力全排列、枚举所有排列
n ≤ 20O(2^n)状态压缩、子集枚举
n ≤ 100O(n^3)Floyd、三重循环
n ≤ 1000O(n^2)动态规划、朴素两两比较
n ≤ 10^5O(n log n)排序、二分、线段树、堆
n ≤ 10^6O(n) 或 O(n log n) 常数小线性扫描、哈希
n ≤ 10^9O(log n)二分答案、快速幂
n ≤ 10^18O(log n) 或 O(1)数学推导、矩阵快速幂

这张表在多数 2 秒时限的评测环境下是成立的。为什么 n ≤ 10^5 时 O(n^2) 过不了?因为 10^5 的平方是 10^10,2 秒内不可能跑完上亿次甚至十亿次循环。反过来,n ≤ 1000 时 O(n^2) 基本安全,因为 100 万次操作在 C++ 里是很轻松的。

用这张表指导选型特别实用。比如看到题目里 n 是 10^5,你写的暴力是两重循环,那几乎必超时,你得赶紧想思路优化。看到 n 是 10^9,你如果打算for (int i = 0; i < n; ++i)扫一遍,那大概率不现实,应该考虑二分或者数学公式。

3.2 复杂度分析在题目中的应用实例

举个最简单的例子:两数之和。给定一个数组和一个目标值,找出数组中两个数加起来等于目标值的下标。最容易想到的是两重循环,每对组合判断一次,复杂度 O(n^2)。如果数组长度是 10^5,这个复杂度就会很悬。

用哈希表把问题变成一遍扫描:

vector<int> twoSum(vector<int>& nums, int target) { unordered_map<int, int> val2idx; for (int i = 0; i < nums.size(); ++i) { int need = target - nums[i]; if (val2idx.count(need)) { return {val2idx[need], i}; } val2idx[nums[i]] = i; } return {}; }

这个版本的时间复杂度是 O(n),空间复杂度也是 O(n)。它用“空间换时间”,牺牲了额外存储,换来了扫描一次就能出结果。这种思路在刷题中极其常见,几乎每个用哈希表的题目背后都是这个逻辑。

再举一个例子说明复杂度分析如何影响方案选择:求长度为 k 的滑动窗口最大值。如果 n = 10^5、k = 10^4,双重循环是 10^9 量级,必超时。这时候应该意识到需要用单调队列,每个元素对应窗口一次处理,整体 O(n)。所以复杂度分析不只是“算数学题”,它直接决定了你用暴力还是用高级数据结构。

我自己的做题习惯是:在打开编辑器之前,先默念一遍“数据范围是多少?目标复杂度是什么?”这两句话能过滤掉一半的错误思路。这也是为什么我会说复杂度分析是刷题的方向盘——它不是可有可无的理论,而是每道题动手前的大纲。

4. 高频数据结构与算法:基础中的基础

刷题基础知识的最终落脚点还是算法本身。这里选几个面试中出现频率极高、而且极度依赖基础功的方向:双指针、递归与二叉树、动态规划入门。把这些模板吃透,大部分题目都不会是无从下手的。

4.1 双指针与滑动窗口的模板思路

双指针算法听着高级,本质就是“用两个下标在数组上移动,代替一重或两重循环”。它最常见的两种形态是相向指针和同向指针。相向指针用于排序数组求和、反转字符串、判断回文这类问题;同向指针配合窗口概念,连续子数组、子串问题经常用滑动窗口解。

滑动窗口几乎是刷题必备模板,写法非常固定:

int left = 0; for (int right = 0; right < n; ++right) { // 1. 把 nums[right] 加入窗口 window.add(nums[right]); // 2. 窗口不满足条件时收缩左边界 while (window 需要收缩) { window.remove(nums[left]); ++left; } // 3. 更新答案 update(answer); }

right指针负责扩展窗口,left指针负责在条件不满足时收缩窗口,最终每个元素最多入窗口一次、出窗口一次,整体复杂度 O(n)。我用这个模板做过很多连续子数组、子串类的题目,最典型的是“无重复字符的最长子串”“和大于等于 target 的最短子数组”。如果你还没掌握这个模板,我强烈建议把它当成基本功背下来。

需要注意的一点是:滑动窗口适用的前提是窗口的收缩方向是明确的,也就是说当left向右移动时,窗口的某种约束条件能被单调地修复。如果不满足这个单调性,强行用滑动窗口反而出错。

4.2 递归思维:二叉树题目怎么练

二叉树算法题是递归的最好练习场。很多人觉得递归难,是因为把递归理解成“一个函数调自己”的循环过程,然后试图在大脑里模拟每一层的执行。其实正确的方法是站在抽象层面想:假设左右子树的子问题已经处理完了,当前节点还需要做什么。

所有的二叉树题都可以用这个模板思考:

  1. 如果当前节点为空,返回什么?
  2. 当前节点自己需要做什么?
  3. 左右子树递归调用的返回值怎么组合?

拿二叉树最大深度举例:

int maxDepth(TreeNode* root) { if (!root) return 0; return 1 + max(maxDepth(root->left), maxDepth(root->right)); }

空节点深度是 0,当前节点深度是左右子树较大值加 1。三句话解决。新手最容易卡在第一步,空节点返回值没有想清楚,整个递归就会逻辑混乱。

二叉树题目还分前序、中序、后序、层序几种遍历。层序遍历需要用队列:

vector<vector<int>> levelOrder(TreeNode* root) { if (!root) return {}; vector<vector<int>> res; queue<TreeNode*> q; q.push(root); while (!q.empty()) { int size = q.size(); vector<int> level; for (int i = 0; i < size; ++i) { TreeNode* cur = q.front(); q.pop(); level.push_back(cur->val); if (cur->left) q.push(cur->left); if (cur->right) q.push(cur->right); } res.push_back(level); } return res; }

关键是那个size = q.size()放在循环外,表示当前层有多少个节点,这样能按层次把结果拆开。我见过有人写层序遍历时每轮都重新取q.size(),结果就乱了,这属于细节上的基本功。

4.3 动态规划入门:从状态定义开始

动态规划是很多人最发怵的部分,但它其实是有一套固定套路的。刷动态规划题,最核心的是三件事:状态定义、状态转移方程、初始值。

拿最经典的爬楼梯举例:每次可以走 1 阶或 2 阶,问走到第 n 阶有多少种方法。定义dp[i]为到达第 i 阶的方法数,那么dp[i] = dp[i-1] + dp[i-2],初始值是dp[0] = 1、dp[1] = 1。递推下去答案就是dp[n]。这个例子虽然简单,但已经把动态规划的三个要素全部覆盖到了,后续再复杂的问题也无非是状态变多、维度变高。

但动态规划真正难的是状态定义本身。比如 0-1 背包问题,要定义dp[j]为背包容量 j 时能装下的最大价值,状态转移是“选第 i 个物品”和“不选第 i 个物品”两者取较大值。给一份模板:

vector<int> dp(W + 1, 0); for (int i = 0; i < n; ++i) { for (int j = W; j >= weight[i]; --j) { dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); } }

注意内层循环是从 W 往 weight[i] 倒着扫。为什么必须倒序?因为如果从左到右更新,dp[j - weight[i]]可能已经被本轮更新过,相当于一件物品被重复装入了多次,这就从 0-1 背包变成了完全背包,完全是两回事。倒序更新是 0-1 背包的核心细节,能看懂这一点,动态规划就算是入门了。

刷动态规划题目时,我的经验是先不要急着写代码,先在纸上把状态定义和转移方程写清楚。只要这两样写对,代码基本上就是照抄。很多动态规划写不出来的人,根本不是不会写循环,而是状态没想明白。

5. 调试技巧与刷题方法论

算法题写得快不快是一回事,写得对不对是另一回事。这一章聊的是让代码更容易正确的调试习惯,以及怎么制定一个能坚持的刷题计划。这些属于看起来不起眼、但实际影响很大的基本功。

5.1 本地调试的必备手段

很多刷题者有一个坏习惯:写完代码直接提交,WA 了再看测试用例,然后用眼睛盯着代码硬找 bug。这种效率很低。我在本地调试时常用几个手段。

第一,准备一个暴力解用作“基准答案”。优化解法写完后,找一个数据范围很小的测试用例,同时跑暴力和优化版本,对拍结果。如果两者不一致,就说明优化版本逻辑和暴力解有差异。这在很多复杂题里是快速定位问题的好办法。对拍工具甚至可以写成一个简单的循环脚本,但多数情况下手写一个暴力函数放在代码里就够了。

第二,测试边界条件。空数组、只有一个元素、所有元素都相同、数组最大或者最小的数值范围、负数或零值,这些都是常见 bug 高发点。很多提交产出的 WA 都是因为没把边界情况想进去。测试的时候我还会刻意把n设成 1 或 0,看看代码会不会崩溃。

第三,打印中间结果。在关键循环或递归入口打印当前变量,观察是否和预期一致。这个办法很暴力,但极其有效。用cout打印后记得删掉,或者用条件编译宏包起来,避免影响性能。如果你用断点调试,本地 IDE 也能逐步查看变量。但刷题时我更喜欢打印,因为打印可以让你看到程序“从头到尾”的变量变化轨迹。

5.2 如何制定可执行的刷题计划

刷题计划最怕“贪多嚼不烂”。我见过很多人第一天立 flag 刷 10 道题,坚持三天就放弃了。后来我发现一个更可持续的方式:按知识点分类刷题,而不是按题号顺序刷。

比如一到两周专注链表题,把反转链表、合并有序链表、环形链表这类题练熟,再切换到二叉树,然后是双指针、动态规划入门。这样集中在一个类别里,你能明显感受到套路的存在,也有利于形成题型记忆。等一轮结束之后,再穿插混合练习,模拟考场的知识交错。

每道题给自己一个限时,比如 35 分钟。超时没思路就去看题解。看题解后关掉代码,凭记忆和理解重新写一遍,写完后在笔记里记录这题的关键思路和自己的盲点。过几天再重新做一遍,不要嫌麻烦。复习旧题和做新题同样重要,甚至更重要。

到了临考前一两周,我建议每周安排两场限时模拟笔试,用牛客网这种完整程序模式的在线平台来做,因为限时训练能真实暴露你的读题速度、调 bug 速度和心态问题。英文题目如果读得慢,也要提前适应。

6. 写在最后:一份个人向的刷题准备清单

刷题这件事确实没有捷径,但准备充分的选手和临时抱佛脚的选手,效率差距可以非常大。最后我把前面所有内容浓缩成一份个人向的清单,算是给这篇长文做一个收束。

6.1 环境与工具准备清单

本地刷题准备清单我列在下面:

  • 编译器环境:本地能跑 C++17,我用的是 VSCode 加命令行g++ -O2 -std=c++17编译。
  • 常用模板:头文件、读入优化、常用的容器别名,准备一个.cpp模板,笔试时节省手写时间。
  • 平台账号:LeetCode 用来按专题刷核心代码模式,牛客网用来练完整程序和笔试手感,尽量都保持一定的使用频率。
  • 错题笔记:记录每道错题的错误原因、正确思路、复现时间,这个笔记比收藏夹有价值得多。

拿到一道题之后的思考流程也可以固定成一套动作:

  1. 读数据范围,估算目标复杂度。
  2. 先想暴力能不能过,不能过就往二分、哈希、双指针、动规、前缀和等方向靠。
  3. 决定容器和数据结构。
  4. 想清楚边界条件。
  5. 写代码前先口头说一遍思路,说顺了再动手。

这套流程练熟之后,刷题就从“凭感觉”变成了“有章法”。

6.2 几个最重要的刷题心态

最后再分享几个心态层面的建议。第一,不要怕看题解。一道题自己硬憋四五个小时,收益远低于看题解后自己重写一遍。承认自己不会,是一件很自然的事情。第二,不要只追求 AC 数量。刷了 500 道题,如果不回顾,真的不如刷 200 道题、错题反复嚼透。第三,复盘比做新题重要。每周固定时间翻自己的笔记,比每天盲目刷十道新题更有价值。

我个人刷题最初也经历过“只求通过率”的阶段,后来在一场限时模拟笔试里被一道“会做但没写出完整输入输出”的简单题打回原形之后,才真正意识到:算法题的竞争,不在于你掌握了多少奇技淫巧,而在于基础到底有多扎实。这里说的基础,不只是某个算法模板,而是包括答题模式、容器选型、复杂度意识、调试习惯在内的一整套基本功。先把这些全部过一遍,再去冲难题,这条路我亲测有效。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询