从“看题就懵”到“每天AC”——我用C#刷LeetCode的完整方法论
如果你是一个C#开发者,想靠LeetCode提升算法能力,但打开题库就不知道从哪下手,或者刷了几天觉得“C#写算法怎么这么别扭”,那这篇文章就是写给你看的。我本人是.NET方向的后端开发,断断续续刷了300多道题,用C#完成从链表到动态规划的覆盖,经历过一段“每天抄题解、第二天全忘光”的低效期,也摸索出了一套真正适合C#语言特性的刷题方式。
这篇文章不是题解合集,而是把我每天刷题时的选型逻辑、代码套路、调试技巧、踩坑记录完整拆开,告诉你如何用C#这门语言把LeetCode刷出生产力。
1. 为什么选择C#做日常刷题语言
1.1 C#在算法竞赛和面试中的真实定位
很多人有个误区:算法题就该用C++或Python,C#是“业务语言”,写算法不顺手。这是典型的工具偏见。
C#在LeetCode上的支持其实非常完整,官方题库可以提交C#代码,而且近几年的语法更新让它在算法场景下比Java更简洁、比Python更可控:var类型推断、record、Span<T>、ValueTuple、LINQ等特性,用好了可以大幅压缩样板代码。更重要的是,国内不少中大型公司的面试环节,尤其偏Windows生态、企业服务端的团队,允许甚至希望你用C#作答,因为它直接反映你在实际工程里的编码习惯。
我自己一面时遇到过面试官明确说“用你熟悉的语言就行”,最后我选C#,写完他认为代码“干净、边界清楚”,加分不少。刷题语言和正式工作语言保持一致,最大的价值不是少记一套API,而是你的肌肉记忆、调试习惯、对GC和引用的直觉,全都无缝迁移到日常开发里。
1.2 和C++/Java/Python的直观对比
| 维度 | C# | C++ | Java | Python |
|---|---|---|---|---|
| 代码简洁度 | 中上 | 低 | 中 | 高 |
| 内存可控性 | 中上 | 高 | 中 | 低 |
| 标准库丰富度 | 高 | 中 | 高 | 高 |
| LeetCode国内支持 | 完整 | 完整 | 完整 | 完整 |
| 工程一致性 | 高(.NET生态) | 低 | 中 | 中 |
我的一线体验是:C#写链表、树这类指针语义的题目时,比C++安全太多,不会有悬垂指针的困扰;比Java又多了一点语法糖,比如List<IList<int>>的初始化不需要像Java那样new ArrayList<>()嵌套到底。Python确实短,但性能题的极限场景会吃亏,C#在时间复杂度和常数优化上都更能打。
1.3 每天固定投入30分钟还是2小时
“每日刷题”的核心不是一次刷多少,而是频率稳定。我建议普通开发者从每天30分钟起步,做1道中等题或者2道简单题,周末加量到2小时做一次周赛或专题复习。连续90天后你会发现,看到题目不再是“这题考什么”,而是“这题对应哪个模板”,这是量变到质变的标志。
我自己的节奏是:工作日中午30分钟,先5分钟读题和思考,10分钟写核心逻辑,剩余时间跑测试和看官方题解对比。晚上如果状态好,再花15分钟把题解整理成自己的模板笔记。周末抽一个上午集中补薄弱专题。
2. 刷题环境与调试效率的搭建
2.1 推荐的项目结构和测试框架
不要在LeetCode网页的代码框里直接写大逻辑,调试太痛苦了。我在本地建了一个单独的LeetCodeDaily解决方案,结构如下:
LeetCodeDaily/ ├── Solutions/ │ ├── Array/ │ │ ├── TwoSum.cs │ │ └── MergeIntervals.cs │ ├── String/ │ ├── Tree/ │ └── DynamicProgramming/ ├── Tests/ │ ├── TwoSumTests.cs │ └── ... └── LeetCodeDaily.sln每个题目文件里,我固定写一个静态方法,入参出参尽量和LeetCode一致,再用xUnit或NUnit写几个测试用例。这里有个效率技巧:把LeetCode给的示例输入直接转成测试用例,然后额外补边界输入,比如空数组、单元素、超大数值、重复元素。这样能在提交前拦截70%的边界错误。
重要心得:网页编辑器里写代码你会本能地“求快”,但本地工程里写代码你会自然考虑命名、分层、可测试性。这正是面试官想看的工程素养,也是刷题最大的隐形收益。
2.2 让调试器替你干活
说我见过最多的问题:有人刷C#题,遇到结果不对就到处加Console.WriteLine,打印一堆中间变量,眼睛看花也找不出错。
正确做法是学会用Visual Studio或Rider的断点调试。在关键赋值行、递归调用处、循环迭代点打上断点,逐行看变量值的变化,尤其盯着三类东西:索引有没有越界、递归终止条件是否永远满足不了、引用类型的共享修改。我调试链表反转题时,靠可视化工具把next指针的指向变动看得明明白白,比干想效率高一倍以上。
2.3 一键运行与性能基准测试
性能敏感的题目,比如超大数据集下的排序、查找,我会在本地加一个Stopwatch计时,用随机数据测几组运行时间。这能帮你判断算法到底可不可行,而不是提交后靠超时反馈来猜。
实操上,我会写一个小的性能测试入口:
var sw = Stopwatch.StartNew(); var result = Solution.TwoSum(GenerateLargeArray(100000), target); sw.Stop(); Console.WriteLine($"Elapsed: {sw.ElapsedMilliseconds} ms");同样一套代码,在LeetCode的服务器和本地跑会有差异,但常数级别的问题能提前暴露。比如用List<T>.Contains代替HashSet<T>,数据量小时看不出来,十万级数据差距就是十倍甚至百倍。
3. 刷题前必须掌握的C#核心语法特性
3.1 集合类型选型:数组、List、LinkedList、HashSet、Dictionary
C#做题,一半的坑出在集合选型上。我总结了一套经验法则:
- 需要随机访问且大小固定,用数组
T[],性能最好。 - 需要频繁在尾部增删,且大小动态,用
List<T>。 - 需要频繁在头部/中间插入删除,用
LinkedList<T>,但注意它的节点开销大,LeetCode上链表题更多是给你自定义的ListNode结构,而不是让你用LinkedList。 - 需要快速判断存在性,用
HashSet<T>,复杂度O(1)。 - 需要键值映射、计数统计,用
Dictionary<TKey, TValue>。
有一个新手常犯的错误:在需要频繁查找的场景里选了List<T>,然后调Contains,以为很方便,结果O(n)复杂度直接把算法拖垮。我的原则是“查找先想哈希,排序先想比较,去重先想集合”。
3.2 var、可空类型、元组和模式匹配的合理使用
var在刷题时非常好用,能减少类型书写的干扰,比如:
var map = new Dictionary<int, int>(); var result = new List<IList<int>>();但要把握尺度——如果变量类型不明显会影响读题,比如var node = GetNode(),我还是会显式写成ListNode node = GetNode();,毕竟刷题也是锻炼代码可读性。
可空类型int?在处理“找不到”的语义时很实用,避免用魔法值-1或int.MaxValue代表不存在。元组(int row, int col)在BFS、DFS处理坐标时比两个独立变量清晰得多,也方便放进队列:
var queue = new Queue<(int r, int c)>(); queue.Enqueue((0, 0)); var (r, c) = queue.Dequeue();C# 8+的模式匹配,在遍历链表节点判断空值时能写得简洁:
if (node is null) return; // 或者 while (curr is not null) { ... }3.3 LINQ的利与弊
这是个争议话题。我的态度是:刷题时慎用LINQ,但可以用它写测试用例。
LINQ的链式调用确实短,比如:
var sorted = arr.OrderBy(x => x).ToArray();但这行代码底层创建了迭代器、委托、临时集合,常数很大。在LeetCode的大数据量测试下,容易超时或内存过高。更关键的是,很多算法题考察的就是排序逻辑本身,你用一行OrderBy是“作弊”,完全没有练习到核心能力。
正确姿势:Array.Sort()能用就用,这是底层C++实现,性能极佳;但是需要自定义比较器时,老实写Comparison<T>委托或IComparer<T>。LINQ只用于测试代码里生成数据、验证结果,不用于解题核心逻辑。
3.4 字符串处理:String、StringBuilder、Span
C#字符串是不可变的,频繁拼接会创建大量垃圾。刷字符串类题目时,循环里的+=是性能杀手。需要频繁修改字符串内容的地方,一律用StringBuilder。
另外,Span<T>和ReadOnlySpan<char>在LeetCode C#题解中越来越常见,它们允许你在栈上操作连续内存,避免额外的字符串分配。比如判断回文串时,可以直接用AsSpan()切片,避免多次Substring,实测性能提升明显。但要注意,Span不能用作类字段、不能跨async方法,这是它的限制,笔试中用到的不多,了解即可。
4. 分专题拆解:我如何用C#逐一攻克高频题型
4.1 数组与哈希表:先用暴力解跑通,再谈优化
数组题是刷题的地基。我遇到数组题的第一反应是:这题能不能用哈希表把时间换成空间?
最典型的题目就是“两数之和”,暴力做法是双重循环O(n²),优化做法是边遍历边查哈希表:
public int[] TwoSum(int[] nums, int target) { var map = new Dictionary<int, int>(); for (int i = 0; i < nums.Length; i++) { int complement = target - nums[i]; if (map.TryGetValue(complement, out int index)) { return new[] { map[complement], i }; } map[nums[i]] = i; } return Array.Empty<int>(); }这段代码里有几个值得品味的点:
TryGetValue比先ContainsKey再索引取值要快,因为它只查一次哈希。- 先查再存,避免自己匹配到自己,比如
nums[0]=3, target=6,你不会返回[0,0]。 - 返回
Array.Empty<int>()而不是null,符合调用方的预期,不用判空。
数组题里的双指针技巧也极高频,比如“盛最多水的容器”“三数之和”。写双指针时注意循环的边界条件,我用的是“左闭右开”或“左闭右闭”统一标准,不要这次写left <= right,下次写left < right,混乱是bug的来源。
4.2 链表操作:画图想清楚再动手
C#刷链表题有个天然优势:垃圾回收帮你管理内存,不需要手动释放节点。但指针操作的逻辑一样要小心。
我的习惯是先画出链表变化图,再写代码。以反转链表为例,核心迭代过程是三个指针的移动:
public ListNode ReverseList(ListNode head) { ListNode prev = null; ListNode curr = head; while (curr != null) { ListNode next = curr.next; curr.next = prev; prev = curr; curr = next; } return prev; }特别提醒:保存next那一步别省。很多人写反转链表丢链,就是因为把curr.next改成prev之后,原来的后继节点找不到了。我刷题时会在注释里写下每一步的链表状态,比如prev -> curr -> next,代码写完基本不用调试。
链表题还有一个高频考点是“快慢指针”,比如找链表中间节点、检测环。用快慢指针时,要仔细确认快指针走两步时是否会出现空引用——fast?.next?.next这种保护式写法要熟练。
4.3 栈与队列:括号匹配、单调栈思路
栈的经典应用是括号匹配,这一题能帮你建立“栈顶即最近状态”的直觉:
public bool IsValid(string s) { var stack = new Stack<char>(); var map = new Dictionary<char, char> { [')'] = '(', [']'] = '[', ['}'] = '{' }; foreach (char c in s) { if (map.ContainsKey(c)) { if (stack.Count == 0 || stack.Pop() != map[c]) return false; } else { stack.Push(c); } } return stack.Count == 0; }我还想重点说下单调栈。这个技巧对新手不太友好,但一旦掌握,很多“下一个更大元素”“接雨水”“柱状图最大矩形”都能套用。C#里没有专门的单调栈类型,用Stack<int>存索引即可。核心套路是:遍历数组时,如果当前元素比栈顶索引对应的元素大,就持续出栈并计算结果,否则入栈。
刚开始理解不了的话,你就记住一句话:单调栈是“维护一个有顺序的候选池”,每次出栈时,栈顶元素遇到了它右边第一个比它大的元素。
4.4 二叉树与递归:先序遍历的直觉是递归的直觉
二叉树题基本就是递归题。C#写递归没有特殊语法障碍,但有一个值得注意的点:递归深度。LeetCode上极限情况下树高可能上万,递归过深会触发栈溢出。C#的默认栈空间和JIT行为在不同平台有差异,我遇到过本地能跑、提交就StackOverflowException的情况。
对策有两个:
- 把递归改成显式栈迭代,利用
Stack<T>模拟调用栈。 - 如果允许,用
Array.Sort前的深度限制做个保护,但别依赖它。
日常刷题时,树的遍历我至少手写三种:前序递归、前序迭代(显式栈)、层序迭代(队列)。这三种吃透了,树的绝大多数题目就有基础了。
层序遍历用队列是固定模板:
var queue = new Queue<TreeNode>(); queue.Enqueue(root); while (queue.Count > 0) { int levelSize = queue.Count; for (int i = 0; i < levelSize; i++) { var node = queue.Dequeue(); // 处理当前层节点 if (node.left != null) queue.Enqueue(node.left); if (node.right != null) queue.Enqueue(node.right); } }注意levelSize必须在进入循环前固定,否则队列长度变化会导致分层错乱。
4.5 动态规划:状态定义比转移方程更重要
动态规划是很多人刷题的天花板,我自己的突破点是“先学状态定义,再学转移方程”。每次做题,我先问自己:dp[i]代表什么?是“以第i个元素结尾的最长...”还是“前i个元素的最优方案”?状态定义对了,转移方程往往自己就浮出来了。
C#写DP时,我常用一维和二维数组。二维DP注意下标偏移问题,常见做法是dp数组开到n+1,用dp[i]表示前i个,避免处理i-1为负的边界:
int[] dp = new int[n + 1]; dp[0] = 1; // 空串或空集合的方案数 for (int i = 1; i <= n; i++) { // 转移逻辑 }初始化是DP的一大坑点。我吃过很多亏之后总结出三条自查原则:
dp[0]到dp[n]每个初始值是否自然且不会污染后续状态。- 边界条件是否覆盖空输入。
- 是否存在“无解”状态,用
-1、int.MaxValue - 1还是null表示,需要全局统一。
还有,能用一维DP就别用二维。滚动数组优化(用两个变量交替)在“斐波那契”“打家劫舍”这类题里非常常见,也锻炼空间意识。
4.6 贪心与图论入门
贪心算法入门题比如“分发饼干”“跳跃游戏”,关键是要证明贪心的正确性,但刷题时我常常先用贪心写一版,再对比动态规划解法,理解为什么有些题能贪心,有些题不能。这种对比思考比单纯AC更有价值。
图论的BFS/DFS在C#里主要麻烦是二维网格题的状态表示。我习惯用(int r, int c)的元组做队列元素,并通过一个visited二维数组标记是否访问过,避免用HashSet<(int, int)>导致额外开销和代码啰嗦。网格场景的DFS要注意“先判断越界再判断访问”,不然grid[r][c]会先抛出索引越界异常。
5. 一个完整实操:从读题到AC的25分钟
5.1 现场拆解一道中等题
拿我来演示完整流程。题目是“给你一个整数数组nums,请你返回其中每个元素的下一个更大元素,没有则输出-1”(可理解为循环数组版本)。
我的读题流程是:
- 5分钟内不动键盘,用草稿纸列出输入输出示例。
- 先想暴力怎么做:双重循环,对每个元素向右找第一个更大的,O(n²)。
- 再想优化:因为要找“右边第一个更大的”,这不就是典型的单调栈吗?用单调栈维护一个递减序列,遇到更大的就出栈并记录。
这题的边界条件藏在“循环数组”里。处理方法是把数组逻辑上翻倍——遍历两倍长度,索引取模,但只对第一遍的索引入栈/出栈记录结果。
写代码时我注意两点:栈里存的是数组下标而不是值,因为记录位置才能填充结果;出栈时填充result[stack.Pop()] = nums[i % n]。
写完第一版后,我立刻用LeetCode给的示例跑一遍,再补一个全逆序、全正序的边界用例,最后提交。整个流程从读题到AC大约20分钟内完成,比直接抄题解有用得多。
5.2 如何高效看题解而不复制
很多人的刷题方式是“看不懂就去看题解,看完觉得自己懂了,代码一写又不会”。我的做法是:
- 第一遍先自己硬想20分钟,哪怕想出的是O(n²)暴力解也行。
- 第二遍再做题解,重点看官方的思路是“如何从暴力优化到高效”的推导过程,而不只是最终代码。
- 第三遍合上题解,自己重新写一遍,要求不看任何参考。
- 如果第三遍写不出来,第二天再写一遍。
这个流程配合每日一题,进步非常明显。我有一道动态规划题,第一次看题解以为懂了,第二天复现时卡住,才意识到自己根本没理解状态转移的“后效性”问题。这种“以为自己会了”的假象,只有复现才能戳破。
5.3 把每道题沉淀成自己的模板
我建了一个私有仓库,按专题整理模板,不只是抄题解,而是写成“通用套路+注意事项+变种题列表”。比如单调栈模板,我会记录适用条件(找左边/右边第一个更大/更小)、边界细节(是否循环)、变种题(接雨水、每日温度、柱状图矩形)。
这个模板仓库成了我的面试复习资料。每次面试前,我不再是翻三百道题,而是翻十几个专题的核心模板,效率极高。
6. 每日刷题中常见的问题与排查方法
6.1 超时的典型原因和排查思路
提交后遇到Time Limit Exceeded,我的排查顺序是:
- 算法复杂度是否合理?有没有用了O(n²)却应该用O(n)的题目?比如查找时用了
List.Contains,替换成HashSet。 - 常数优化是否做到了?比如
Dictionary.TryGetValue代替ContainsKey+索引取值;避免在循环内反复new大对象。 - 是否在无意义地复制数据?比如传数组时用了
.ToList()、Substring等,产生额外内存和拷贝。
C#这里有个特殊坑:StringBuilder.ToString()虽然需要,但每次调用都生成新字符串,循环里频繁调用也会拖慢。我建议在循环结束后再统一转换。
6.2 内存溢出与引用类型共享修改
Memory Limit Exceeded通常不是因为变量太多,而是把不必要的大集合留在了引用上。常见错误是递归或迭代时,每一层都复制一个List再传下去,导致内存暴涨。正确做法是使用回溯法时“先添加,递归完再移除”,通过共享同一个集合控制内存。
引用类型共享修改是C#刷题最容易犯的隐蔽错误。比如把List<int>实例传给多个方法,其中一个方法修改了内容,另一个方法读到的数据就变了。排查这类bug时,我会在关键传参处打断点,看对象的GetHashCode或者直接看集合元素是否意外变化。
6.3 边界条件总是漏:建立你的边界清单
LeetCode最残酷的反馈方式是“Wrong Answer”但没告诉你哪个用例挂了。久病成医之后,我给自己列了一份边界自查清单,每次提交前逐一核对:
- 数组/字符串长度是否为0、为1。
- 是否包含负数、0、
int.MaxValue、int.MinValue。 - 目标值是否在数组内、是否重复出现。
- 树是否为空、是否只有左子树/右子树、是否为完全二叉树。
- 期望值是否可能超过
int范围,比如乘法溢出,用long或double。 - 循环数组、环形链表等“回到起点”的场景。
这份清单帮我减少了一半以上的无谓提交。对于重视效率和准确率的人来说,这比多刷十道题更划算。
6.4 本地能过、提交报错的神奇差异
这种情况我遇到不下五次,常见原因有三个:
- 栈空间差异:递归深的题目在本地默认栈空间大,LeetCode的栈空间较小,触发栈溢出。
- 环境版本差异:本地是.NET 8,LeetCode可能是.NET 6或更早,个别API行为不同。比如
Math.Clamp方法在旧环境不存在。 - 输入格式差异:LeetCode的测试用例通过序列化构建输入,某些情况下字符串转义不同,比如包含
"\r\n"。这属于平台展示的,没法完全控制,我就尽量用题目描述中明确格式来处理。
我排查这类问题的方法是:把测试用例在本地完整复制一遍,包含极端输入,然后逐步注释代码,定位到第一个在平台崩溃的位置。千万别说“平台有问题”就跳过,绝大多数时候是代码隐藏问题。
7. 周期复盘:让每日刷题量变为能力增长
7.1 周复盘:整理错题与弱项
我每周日会抽30分钟做周复盘,统计这周刷了多少道题、正确率多少、主要错误类型分布。方法是直接看提交记录,把Wrong Answer和TLE的题分类整理。错误集中出现在“双指针边界”“DP初始化”“栈空判断”就说明下周要针对性补这个专题。
复盘的另一个重点是回顾那些耗时超过1小时才AC的题。这些题暴露的不是知识盲区,就是思维盲区。我会重点分析卡住的环节:是没读懂题意?转化不了数据结构?还是知道思路但代码实现失败?这种归因,才能指导下一周怎么练。
7.2 以输出倒逼输入:写题解和讲给别人听
我强烈推荐一个方法:每天刷完题后,用自己的话把题解写成笔记,哪怕只有200字。写的过程强迫你理清逻辑漏洞。比如我写过一篇“单调栈的三种变体”,写的过程中发现自己对“循环数组索引取模”的边界理解有缺口,又回去补了几个用例。
费曼学习法同样适用。如果你有一个同样在刷题的朋友,互相讲题是最快的提升方式。我经常把当天做的题用10分钟讲给同事听,他不用懂代码,只需要听我的思路是否顺理成章。每次讲不清楚的地方,都是我自己还没完全理解的地方。
7.3 适当拓展:周赛、专题月、旧题重刷
每日一题之外,我会做三件补充:
- 周六或周日参加LeetCode周赛,强制在90分钟内做4道题,训练限时压力下的输出能力。
- 每个月定一个专题主题,比如“本月全刷树题”,把树相关的每日题和周赛题归类。
- 每两周重刷一遍过去30天错过的题,只看题目回忆步骤,不写代码,只说出思路。说得出来就过,说不出来就拉进“复现清单”。
这个组合拳比盲目刷题高效很多,本质是在“碎片化做题”和“系统性归纳”之间建立节奏。
8. 几点掏心窝的实操心得
第一,刷题最怕的不是不会做,而是“看题解看得懂,合上写不出来”。逼自己复现,比多刷三五道新题更重要。
第二,C#刷题时,别把LINQ当成偷懒神器。面试官也许不拦你看文档,但你自己心里要知道,哪些写法练的是思维,哪些写法只是语法糖。练习阶段多用基础API手写逻辑,进步会扎实得多。
第三,放弃“全部刷完”的执念。LeetCode题已经接近两千道,人的精力有限,把高频专题吃透、把经典题型模板化,比追求数量更接近面试和实际工作所需。
第四,坚持比强度重要。哪怕某天就做一道简单题,只要持续天天做,大脑对算法的“熟悉感”就会慢慢建立。我经历过平台期,连续两周正确率没有提升,但坚持下来后,某一天突然开窍,很多以前看不懂的题目一下通了。这就像健身,前面几次看不到肌肉,但不用怀疑训练的价值。
根据我的个人经验,C#刷LeetCode最难的不是语言表达,而是持续保持“带着思考做题”的状态。如果你能不复制题解、不堆积数量、定期复盘模板,每天哪怕只完全吃透一道题,三个月后你的算法能力和代码设计能力都会有明显可见的提升。找个自己最舒服的固定时间,打开工程,今天就去写那道题吧。