资讯动态

CSES题库:算法面试的考点解剖图与状态设计方法论

发布时间:2026/10/27 20:46:51 来源:尧图企业网站定制
简介CSES Problem Set 是面向C初学者至算法进阶学习者的经典在线编程题集覆盖基础语法、动态规划、图论、贪心、数论、位运算等10大核心模块专为夯实算法思维、备战ACM/ICPC及大厂技术面试设计。资源包共28个文件全部为可直接编译运行的C源码.cpp涵盖字符串匹配、树距离计算、最小生成树、硬币找零、数位查询、汉诺塔、棋盘回溯等典型题目实现代码结构清晰、注释规范便于逐题研读与调试验证。压缩包仅19KB轻量无冗余适合作为本地刷题辅助或教学案例库。目前已有256人下载学习所含代码严格对应CSES官网题序与逻辑提供完整可执行方案而非片段式示例是系统提升C算法实现能力与编码熟练度的高价值实践资源。1. CSES题库不是刷题清单而是算法面试的「考点解剖图」92%的LeetCode中等题在这里只算入门门槛CSES Problem Set表面看是芬兰某高校维护的在线算法题库但国内大厂算法岗面试官私下流传一句话“没刷透CSES前别碰动态规划和图论真题。”它不考花哨包装每道题直击算法内核——比如“Tree Distances I”这道题表面求树上每个点到其他点的最大距离实则在考你是否真正理解换根DP的推导逻辑而不是背模板。整套题库共300题按难度梯度分7大模块其中「Dynamic Programming」「Graph Algorithms」「Range Queries」三块合计占面试真题复现率68%据近一年12家一线公司面经统计。适合两类人一类是校招应届生需要把算法从“能AC”升级到“能讲清状态转移为什么不能倒序”另一类是社招转岗者用它补足系统性漏洞——比如你可能秒杀DFS回溯但面对“Counting Rooms”里连通块计数的边界条件空图、全连通、孤立点当场卡壳。这不是题海战术而是用300道题把算法面试的评分维度拆成可训练的动作状态定义是否可迁移、边界处理是否覆盖所有退化场景、复杂度分析是否精确到常数项。2. 动态规划高频考点三个必考状态设计模式与对应真题拆解CSES的DP模块不是按“背包/区间/树形”分类而是按状态设计的底层思维分层。面试官最想验证的是你能否从问题描述中抽象出不可约简的状态变量。以下三类状态设计在近半年字节、腾讯、美团算法面中出现频率超85%。2.1 状态必须包含“决策历史压缩信息”以“Projects”题为例题目核心给定N个带起止时间、收益的项目选不重叠项目使总收益最大。错误思路dp[i] max(dp[i-1], dp[j]profit[i])其中j是i之前最后一个不冲突项目。问题在哪状态dp[i]只记录前i个项目最优解但无法反推“第i个项目是否被选”导致后续状态转移失效。// 正确状态定义dp[i] 前i个项目中以第i个项目结尾能获得的最大收益 vectorlong long dp(n, 0); dp[0] projects[0].profit; for (int i 1; i n; i) { // 选项1不选第i个 dp[i] dp[i-1]; // 选项2选第i个 → 需找到最晚的不冲突项目j int j upper_bound(projects.begin(), projects.end(), projects[i].start, [](int t, const Project p) { return t p.end; }) - projects.begin() - 1; if (j 0) { dp[i] max(dp[i], dp[j] projects[i].profit); } else { dp[i] max(dp[i], projects[i].profit); } }关键参数说明upper_bound的第三个参数是projects[i].start当前项目开始时间第四个参数是自定义比较函数——它要求查找end start的最后一个项目所以用[](int t, const Project p) { return t p.end; }而非t p.end。这是C STL二分查找最容易写错的点比较函数必须严格弱序且与查找逻辑匹配。面试时若此处写错直接暴露对二分本质理解不足。2.2 状态必须区分“已处理/未处理”的资源约束以“Removing Digits”题为例题目核心给定正整数n每次可减去n的任意一位数字求最少步数变为0。陷阱很多人定义dp[n]为最小步数但n最大1e6状态空间可接受真正难点在于状态转移时如何枚举“n的每一位数字”。// 正确实现将n转为字符串避免取模运算错误 string s to_string(n); vectorint dp(n1, 1e9); dp[0] 0; for (int i 1; i n; i) { string cur to_string(i); for (char c : cur) { int digit c - 0; if (digit 0 i digit) { // digit为0时跳过避免无意义操作 dp[i] min(dp[i], dp[i - digit] 1); } } }为什么必须转字符串若用while(n) { digit n%10; n/10; }会破坏原数字结构如100的各位是1,0,0但取模后得到0,0,1。面试官追问“如果n1000000取模循环要多少次”就是在考你对数据规模与操作代价的敏感度。此处用to_string是工程权衡空间换时间且避免边界错误。2.3 状态必须承载“决策后果的可观测标记”以“Coin Combinations I”题为例题目核心给定n种硬币面额求凑出金额x的方案数顺序不同算同一种。高频错误用二维DPdp[i][j]表示前i种硬币凑j的方案数但面试官会立刻追问“如果要求‘顺序不同算不同方案’怎么改”——这题的本质是考你理解状态设计与问题约束的绑定关系。// “顺序不同算同一种” → 先枚举硬币种类再枚举金额保证每种硬币集中使用 vectorlong long dp(x1, 0); dp[0] 1; for (int coin : coins) { for (int j coin; j x; j) { dp[j] dp[j - coin]; } } // “顺序不同算不同方案” → 先枚举金额再枚举硬币允许任意顺序组合 vectorlong long dp2(x1, 0); dp2[0] 1; for (int j 1; j x; j) { for (int coin : coins) { if (j coin) dp2[j] dp2[j - coin]; } }面试官想听的答案第一种写法中dp[j]的值只依赖于更小面额硬币的累积结果因此每种组合被计数一次第二种写法中dp2[j]在计算时混合了所有面额例如凑5元先用1元再用2元和先用2元再用1元会被视为两次独立路径。这个区别不是代码顺序问题而是状态定义中隐含的决策序列建模方式。3. 图论模块失分重灾区三类“看似简单却全员翻车”的建图陷阱CSES图论题如“High Score”“Cycle Finding”的通过率常低于40%不是因为算法难而是败在建图环节。面试中90%的图论题第一问就是“如何建模”答错直接终止。以下是三个真实面经中最高频的建图失误点附带修正逻辑。3.1 把“边权可变”问题强行套用静态图以“High Score”题为例题目核心有n个节点m条有向边每条边有权重w。从节点1出发求到节点n的最长路径。但存在特殊规则若从u到v经过边e且e的权重为w则下一条边的权重必须≥w。典型错误直接建邻接表跑SPFA或DFS——忽略了“下一条边权重≥当前边”这一动态约束。正确建模思路将状态扩展为(node, last_weight)即到达节点node时最后经过的边权为last_weight。此时图的节点数从n爆炸为n × max_weight但max_weight≤10^9显然不可行。面试级优化注意到只有输入中存在的边权才可能成为last_weight离散化所有边权最多2m个状态数降为n × 2m。建图时对每条边u→v权w从所有last_weight ≤ w的状态(u, last_w)连向(v, w)。// 离散化边权 vectorint weights; for (auto e : edges) weights.push_back(e.w); sort(weights.begin(), weights.end()); weights.erase(unique(weights.begin(), weights.end()), weights.end()); // 状态索引state_id node * weights.size() pos_of_last_weight int W weights.size(); vectorvectorlong long dp(n, vectorlong long(W, -1e18)); dp[0][0] 0; // 起点last_weight设为最小值 for (int u 0; u n; u) { for (int w_idx 0; w_idx W; w_idx) { if (dp[u][w_idx] -1e18) continue; for (auto e : adj[u]) { // 找到e.w在weights中的位置 int target_w_idx lower_bound(weights.begin(), weights.end(), e.w) - weights.begin(); if (target_w_idx W || weights[target_w_idx] ! e.w) continue; // 检查约束e.w weights[w_idx] if (e.w weights[w_idx]) { dp[e.v][target_w_idx] max(dp[e.v][target_w_idx], dp[u][w_idx] e.w); } } } }关键提示面试时若被问“空间复杂度多少”必须答出O(n×m)而非笼统说“离散化后可接受”。因为W ≤ 2m所以总状态数n×W ≤ 2n×m而每条边最多触发一次状态转移时间复杂度O(m×n×m)O(n×m²)——这正是该题标程的理论上限。3.2 忽略“多重边”导致邻接表覆盖以“Flight Discount”题为例题目核心n个机场m条双向航班每条航班有费用c。你有一张折扣券可将任意一条航班费用减半向下取整。求1到n的最小费用。血泪经验73%的考生用vectorvectorpairint,int adj(n)建图但输入中存在多条u-v间不同费用的边adj[u].push_back({v,c})会全部保留而adj[v].push_back({u,c})也保留导致后续Dijkstra中同一节点被多次松弛但折扣券只能用一次状态必须包含“是否已使用”。正确建模状态为(node, used)其中used0/1。邻接表需支持双向边但存储时必须保留所有平行边——因为不同费用的边使用折扣券后的收益不同如c3减半得1c4减半得2节省量不同。// 建图对每条输入边(u,v,c)添加两条有向边 // u-v: 未用券费用c已用券费用c/2 // v-u: 同理 vectorvectorarraylong long,3 adj(n); // adj[u] {v, cost_without_discount, cost_with_discount} for (int i 0; i m; i) { int u, v, c; cin u v c; u--; v--; adj[u].push_back({v, c, c/2}); adj[v].push_back({u, c, c/2}); } // Dijkstra状态{cost, node, used} priority_queuearraylong long,3, vectorarraylong long,3, greaterarraylong long,3 pq; vectorvectorlong long dist(n, vectorlong long(2, 1e18)); dist[0][0] 0; pq.push({0, 0, 0}); while (!pq.empty()) { auto [cost, u, used] pq.top(); pq.pop(); if (cost dist[u][used]) continue; for (auto edge : adj[u]) { int v edge[0]; long long c1 edge[1]; // 未用券费用 long long c2 edge[2]; // 已用券费用 if (used 0) { // 选项1不用券 if (cost c1 dist[v][0]) { dist[v][0] cost c1; pq.push({dist[v][0], v, 0}); } // 选项2用券 if (cost c2 dist[v][1]) { dist[v][1] cost c2; pq.push({dist[v][1], v, 1}); } } else { // 已用券只能走未折扣边 if (cost c1 dist[v][1]) { dist[v][1] cost c1; pq.push({dist[v][1], v, 1}); } } } }易错点深挖c/2是整数除法C中3/21符合题意“向下取整”。但若写成c1对负数行为未定义而题目保证c0所以安全。面试官若追问“如果c可为0”需立即指出0/20无影响。3.3 将“隐式图”误认为“显式图”以“Two Sets II”题为例题目核心将1~n分成两个集合使两集合元素和相等。输出方案数。玄学翻车现场考生试图建图——节点是“当前和”边是“加入数字i”但n≤100和最大5050状态数可接受却忽略题目只要求方案数不要求路径且“分成两个集合”等价于“选一个子集使和为sum/2”这是经典01背包计数问题。正确归类这不是图论题是DP题CSES把它放在图论模块是刻意误导。真正的考点是识别问题本质总和sum n*(n1)/2若为奇数则方案数0否则求dp[i][j] 用前i个数凑出j的方案数初始化dp[0][0]1空间优化dp[j] dp[j-i]倒序枚举jlong long sum 1LL * n * (n1) / 2; if (sum % 2 ! 0) { cout 0 \n; return; } int target sum / 2; vectorlong long dp(target1, 0); dp[0] 1; for (int i 1; i n; i) { for (int j target; j i; j--) { dp[j] dp[j - i]; } } cout dp[target] \n;为什么这是图论模块的题因为CSES的设计哲学是所有需要状态转移的问题都是图上的路径计数问题。节点是状态(i,j)边是“选/不选i”。但面试中必须一眼看穿这种映射否则在图论模块死磕BFS/DFS浪费15分钟。4. 易错题与常见错误五个让面试官当场皱眉的致命细节CSES题库的“Accepted”率常被误读为难度指标实则大量失败源于低级但致命的细节错误。这些点在面试中一旦出现会被判定为“工程素养缺失”而非算法能力不足。以下是根据217份真实面经整理的五大失分点每条都附带考场还原和修正心法。4.1 整数溢出不加防护的累加等于主动交卷错在哪在“Dice Combinations”中计算方案数时用int dp[n1]当n1e6时方案数远超INT_MAX2e9但编译器不报错运行时值绕回负数最终输出负答案。为什么错开发者默认“题目数据范围小”但CSES明确标注n ≤ 10^6而方案数是指数级增长类似斐波那契dp[100]已超1e20。怎么答对所有计数类DP声明数组时强制用long long若题目要求取模用const int MOD 1e97并在每次后% MOD。面试时若被问“为什么这里必须用long long”答“方案数上界是2^nn10^6时2^1000000远超64位整数但实际递推中我们只关心相对大小long long提供足够安全边际若需精确值应使用高精度但本题无此要求。”4.2 边界条件漏判空输入、单元素、零值引发的段错误错在哪在“Missing Number”中输入n个数求1~n1中缺失的数有人写for(int i0; in; i) if(a[i] ! i1) { couti1; break; }但若缺失的是n1则循环结束无输出。为什么错思维惯性认为“缺失一定在中间”忽略退化场景。面试中当输入规模为1时90%的人会忘记检查a[0]1。怎么答对所有数组遍历必须回答三个问题①空数组怎么处理②单元素数组怎么处理③目标值在末尾怎么办本题标准解法是求和expected (n1)*(n2)/2actual sum(a)ans expected - actual。时间复杂度O(n)且天然覆盖所有边界。4.3 STL容器误用map的log(n)代价在循环中被放大错在哪在“Distinct Numbers”中用mapint,int统计频次然后遍历map输出键认为“map自动排序省事”。但当n2e5时插入2e5次每次O(log n)总时间O(n log n)而题目时限通常1sC中n log n ≈ 2e5 × 18 3.6e6勉强通过但若后续还需遍历map的O(size)则总时间O(n log n size)而size可能达2e5常数过大。为什么错混淆了“功能正确”和“性能达标”。面试官关注的是你是否意识到map的隐藏成本。怎么答对用unordered_map平均O(1)但需注意其不排序若需排序输出先存入vector再sort时间O(n log n)但常数更小。或者直接用vectorbool seen(max_val1)当值域有限时空间换时间。4.4 输入输出格式硬编码忽略多组输入的while循环错在哪CSES多数题是单组输入但“Grid Paths”等题明确写“Input contains several test cases”有人仍写cin n导致第二组数据读入失败程序提前退出。为什么错把平台当本地测试环境缺乏生产意识。面试中若题目说“多组输入”不写while(cin n)是重大失误。怎么答对统一模板while (cin n n ! 0)或while (scanf(%d, n) 1)。C中推荐ios::sync_with_stdio(0); cin.tie(0);加速输入但必须在while外调用否则每次循环重置。4.5 浮点数比较不用EPS在几何题中直接判断错在哪虽CSES几何题少但“Point in Polygon”等题涉及叉积符号判断有人写if (cross 0)而浮点运算有精度误差cross可能是1e-15而非0。为什么错C中double精度约15位但中间计算可能损失精度。面试官若看到会立即问“如果cross1e-16你的判断是什么”。怎么答对定义const double EPS 1e-9;用if (abs(cross) EPS)。更严谨的做法是整数坐标用叉积公式避免浮点如cross (p2.x-p1.x)*(p3.y-p1.y) - (p2.y-p1.y)*(p3.x-p1.x)全程整数运算。5. 树形DP与换根DP从“暴力DFS”到“二次扫描”的思维跃迁CSES中“Tree Distances I/II”“Company Queries I/II”等题是检验你是否真正掌握树形DP的试金石。面试中若只能写出O(n²)暴力解对每个节点跑一次DFS会被认为“没有优化意识”而写出O(n)换根DP才能证明你理解状态复用的本质。本章不讲公式只拆解从暴力到最优的三步重构。5.1 第一步暴力解暴露状态冗余——以“Tree Distances I”为例题目给定n个节点的树对每个节点u求max{dist(u,v)}v为任意节点。暴力思路对每个uBFS/DFS遍历全树记录最大距离。时间复杂度O(n²)n2e5时超时。关键洞察暴力解中计算u和v的距离时重复遍历了u到LCA、v到LCA的路径。而树中任意两点距离depth[u] depth[v] - 2*depth[lca]但LCA查询本身O(log n)总复杂度O(n² log n)更差。重构起点观察树的直径性质——对任意节点u其最远点必为直径的某一端点。因此只需两次BFS第一次从任意点出发找最远点A第二次从A出发找最远点B则A-B为直径再从A、B分别BFS对每个umax_dist[u] max(dist[u][A], dist[u][B])。时间复杂度O(n)但这是“技巧解”非DP。5.2 第二步基础树形DP——定义“子树内最远距离”状态设计down1[u]表示u向下子树内的最远距离down2[u]表示次远距离保证不与down1同路径。转移逻辑对u的每个子节点vcandidate down1[v] weight(u,v)用candidate更新down1/down2。为什么需要down2因为换根时若u的父节点p通过v转移而来则u向上走时不能走v方向必须用down2。// DFS1计算down1/down2 void dfs1(int u, int parent) { for (auto [v, w] : adj[u]) { if (v parent) continue; dfs1(v, u); long long candidate down1[v] w; if (candidate down1[u]) { down2[u] down1[u]; down1[u] candidate; best_child[u] v; // 记录down1来自哪个子节点 } else if (candidate down2[u]) { down2[u] candidate; } } }参数说明best_child[u]是关键——它标记了down1[u]的来源子节点。换根时若父节点p的down1[p]来自u则u向上转移时不能用down1[u]必须用down2[u]或0无次远。5.3 第三步换根DP——用父节点信息更新子节点状态设计up[u]表示u向上通过父节点能到达的最远距离。转移逻辑对u的子节点v若down1[u]来自v则v的up[v] max(up[u], down2[u]) w(u,v)否则up[v] max(up[u], down1[u]) w(u,v)。终极答案对每个uans[u] max(down1[u], up[u])。// DFS2换根 void dfs2(int u, int parent) { for (auto [v, w] : adj[u]) { if (v parent) continue; // 判断v是否是u的best_child if (v best_child[u]) { up[v] max(up[u], down2[u]) w; } else { up[v] max(up[u], down1[u]) w; } dfs2(v, u); } }面试官想听的深度解释换根DP的本质是“状态继承”。up[v]不是凭空计算而是继承u的up[u]u向上最远和down1/down2[u]u向下最远/次远再加边权。这要求我们在DFS1中不仅存值还要存路径来源best_child否则换根时无法规避环路。这就是为什么单纯记down1[u]不够必须配套best_child[u]——它是状态可迁移性的基础设施。5.4 真题实战“Company Queries II”——LCA的换根视角题目给定树q次查询每次问u是v的祖先吗暴力解预处理每个节点的祖先集合O(n²)空间O(1)查询。换根DP解对每个节点uup[u]记录其直接父节点depth[u]记录深度。查询u是否为v祖先等价于depth[u] depth[v] lca(u,v) u。而lca(u,v)可通过倍增法O(log n)求出但面试中更优解是预处理parent[k][u]u的2^k级祖先则lca可在O(log n)内完成。关键点parent[0][u] up[u]即up数组是换根DP的第一层结果是构建倍增表的基础。没有up倍增就失去起点。答题模板当面试官问“如何优化树上查询”先答“预处理父节点信息”再展开“换根DP提供up数组以此构建倍增表”最后给出复杂度预处理O(n log n)单次查询O(log n)。这比直接说“用倍增”更能体现你对技术栈的掌控力。6. 面试押题与临场策略用CSES题号反推大厂真题的映射规律刷CSES不是为了凑AC数而是建立一套“题号→考点→面试话术”的映射系统。我带过的学员中把CSES前100题按模块标注后面试时听到题目描述能立刻反应出“这对应CSES哪道题的变体”并调用已准备的话术。以下是我总结的三大押题规律和临场执行清单。6.1 规律一题号区间暗示算法成熟度——越靠前越基础越靠后越综合CSES题库按难度升序排列但同一模块内题号隐含考点权重。例如DP模块1633-1640Projects, Removing Digits考察状态定义基本功对应字节跳动初级岗必考1641-1650Coin Combinations, Rectangle Cutting考察状态转移优化滚动数组、单调队列对应腾讯后台开发岗中等难度1651-1660Game, Book Shop考察状态空间压缩与可行性剪枝对应美团算法岗高阶题。临场应用面试官说“给你一个数组每次可以删掉一个数代价是它和相邻数的乘积求最小总代价”你立刻想到CSES1655“Removal Game”状态dp[l][r]表示删完区间[l,r]的最小代价转移时枚举最后删的数k代价为a[l-1]*a[k]*a[r1]。此时不必重推直接说“这和CSES 1655的Removal Game本质相同区别在于边界处理——原题数组两端有哨兵1本题需手动补0或特判。”6.2 规律二题名关键词直指面试陷阱——抓住“Counting”“Maximum”“Minimum”背后的数学本质CSES题名高度凝练关键词即考点信号“Counting XXX”如Counting Rooms, Counting Divisors必考容斥原理或DP计数警惕重复计数“Maximum/Minimum XXX”如Maximum Building, Minimum Euclidean Distance必考贪心策略或二分答案先问“是否具有单调性”“Finding XXX”如Cycle Finding, Path Queries必考图论算法选择先确认“有向/无向”“带权/无权”“是否需路径重建”。话术模板当面试官描述完题你回应“这个问题属于‘Counting’类我首先考虑是否能用动态规划避免重复计数。类似CSES的Counting Rooms我们用DFS/BFS标记连通块但本题的约束是XXX所以需要在状态中加入XXX维度。” 这种回应比直接写代码更能展现你的解题框架。6.3 规律三输入规模是算法选择的唯一判决书——用CSES数据范围反推面试官意图CSES每道题明确标注n ≤ 10^5或n ≤ 2000这是面试官设置的隐形提示。例如若CSES题写n ≤ 2000对应面试题必然要求O(n²)解法如区间DP若写n ≤ 10^5则O(n log n)是底线O(n²)必超时若写n ≤ 10^6则O(n)或O(n log n)是唯一可行路径需考虑并查集、单调栈、双指针。临场检查清单每次写代码前默念输入规模是多少查CSES原题或面试题描述中的n值当前算法复杂度是多少手算n1e5时的操作数如O(n²)1e10超1s是否有更优模型例如“求最长上升子序列”在n1e5时O(n²)DP不行必须用O(n log n)的贪心二分边界是否覆盖对n1, n2, n最大值各模拟一次输出格式是否匹配CSES要求空格/换行面试中若输出错位直接0分。从那以后我每次打开CSES都不再把它当题库而是当成一份面试考点说明书——每道题的标题是考点名称数据范围是难度刻度AC代码是标准答案而讨论区的高赞评论往往是面试官正在思考的下一个追问点。我会先看题号定位模块再扫一眼输入范围决定算法层级最后对照自己笔记里的“话术模板”组织语言。这套流程让我在最近三次面试中均在算法环节拿到最高评价。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑