刷题这事,最难的不是题目本身,而是“觉得自己不配”。我一开始就是这么想的。没学过算法课,数据结构全靠自学,C语言也只算半个熟手,看到力扣上那些讨论区的神仙解法,第一反应是“这题我也配做?”后来真正静下心刷下来,发现没算法背景不是劣势,反而是个特别好的入局状态——没有包袱,不懂就查,不会就背,每一步都踩得实实在在。这篇是“力扣刷题记录3”,依旧全程C语言,依旧没有任何算法基础,但比前两篇更想和你聊一些具体的:题怎么拆、坑怎么踩、循环怎么打、信心怎么稳住。
这篇记录适合谁?两类人。第一类,和我一样非科班或半路出家,想用代码能力证明自己,却一直卡在“看题好像会,动手就空白”阶段的同学。第二类,已经开始用C语言刷题,但主要停留在数组、循环、if else这种平铺直叙的题目,一遇到指针、哈希、二分就直接看答案的兄弟。这篇里没有炫技,不会给你掏什么状态压缩DP或AC自动机,全是基于基础知识堆出来的C语言解法,以及比解法更重要的——思考方式和排错流程。
我把这段时间的刷题过程拆成四个部分:设计自己的刷题路线、几道典型题目的完整拆解、C语言刷题特有的坑合集、以及如何维持长期刷题不摆烂的经验心得。每一条都是自己趟出来的,不是复制粘贴官方题解,也不是抄讨论区热门评论。你跟着走一遍,至少能解决“刷了忘、忘了刷”的循环问题。
1. 无算法背景刷题的核心路线:先堆量,再补理论,最后才谈技巧
无算法背景的人最容易犯的错,是一上来就刷“热门困难题”,或者跑去啃《算法导论》。前者会让你直接怀疑智商,后者会让你直接放弃编程。我自己摸索出来的顺序是:先通过简单题把C语言的常见操作练成肌肉记忆,再通过中等题接触最常用的几种算法模式,最后再回头补理论。这个顺序不是拍脑袋想的,是有实际依据的。
1.1 为什么先堆简单题:建立“代码翻译”的语感
没有算法背景,意味着你缺的其实不是算法知识,而是“把思路翻译成代码”的语感。比如“交换两个数”这回事,你看别人写temp = a; a = b; b = temp;觉得简单,但真到自己上手处理数组逆序的时候,十个有八个会在边界上栽跟头。简单题的用途就是固化这类基础操作:数组遍历、字符串处理、链表节点的增删改查、递归的简单应用。
以我自己的经验,前100道简单题啃完,最大的变化不是会做多少题,而是看到一个题面,脑子里能快速冒出“这题要用双重循环”“这题需要先排序”“这题可以快慢指针”这类方向感。方向感比答案重要得多,因为它是你独立推导的起点。别嫌简单题没营养,真正在面试里让你活下来的,往往就是把这些基础操作写快、写稳、写不越界。
1.2 中等题阶段:集中突击最常用的五类模式
过了简单题阶段,我的做法是专门花时间集中刷五类高频模式:哈希表计数、双指针(左右指针和快慢指针)、二分查找、链表反转类操作、简单的递归回溯。这五类覆盖力扣上至少一半以上的中等题。为什么是这五类?因为它们不需要复杂的数学背景,也不需要高深的数据结构,C语言里用数组、链表、指针就能实现,非常适合无基础选手作为突破口。
比如哈希表,C语言没有现成的unordered_map,很多人一看就觉得麻烦。但你仔细想,面试题里涉及到“字母异位词”“两数之和”“子数组和等于K”这类问题,核心无非是用数组下标去模拟键值映射,或者手写一个简单的结构体数组加链表处理冲突。这不叫算法,这叫工程能力。等我把这些模式刷熟之后,再看有些题确实一眼就能看出考什么,心态会完全不一样。
1.3 需要补的基础理论很少:能跑通代码才是硬指标
没算法背景,是不是必须把时间复杂的推到公式都背下来?我的答案是:不用。你需要掌握的复杂度概念,其实就三个等级——常数级、线性级、对数级或乘方级。能够自己在写代码之前估一下“这个方案最坏情况下要跑多少次”就够了。真实情况是,力扣的题卡复杂度卡得很明显,你一不小心写了个双层循环嵌套,数据量一大就超时,它不会给你解释的余地。
所以我的实操策略是:每道题写完以后,心里默念一遍数据范围,估算一下自己写了几个循环嵌套层数。如果题目n <= 10^5但我的写法是O(n^2),就别提交浪费时间了,直接换思路。这个习惯养成了,比背十篇题解都管用。
2. 核心细节解析与实操要点:几道典型题目的完整拆解与思考过程
这阶段刷的题有十几道比较有代表性,我不可能全部罗列,挑四道覆盖不同模式的题,把从读题到提交的完整过程还原出来。你不是来看标准答案的,你是来看一个没算法背景的人,是怎么从懵到懂、从超时到AC的。
2.1 最小覆盖子串的退而求其次策略:先用哈希表场景练手
有一道题是找字符串中的“最小覆盖子串”,经典滑动窗口+哈希表的高频题。但说实话,无基础阶段直接上滑动窗口,思维跳跃太大。我的做法是退一步,先做一道类似但更简单的问题:给定两个字符串,判断其中一个字符串的某个子串是否包含另一个字符串的所有字符(不考虑顺序)。这个版本只需要固定窗口长度+哈希表计数,思路清晰很多。
C语言实现上,我声明了两个长度为128的int数组——hash_t和hash_s,分别记录目标字符串字符频次和窗口内字符频次,然后每个窗口滑动时更新hash_s并逐字符比较。这里有几个关键点:字符数组长度开128而不是26,因为ASCII可打印字符不只字母;窗口滑动的临界条件,end指针走到len_t + len_s - l之类的边界最容易写错;比较两个哈希表是否相等,我是用memcmp一次完成,简洁且不容易漏判。这道题练完,哈希表计数的手感就有了。
2.2 反转链表的递归写法:在“递”与“归”之间找到节奏
链表题对C语言选手来说,其实比Java选手更有优势,因为指针是你熟悉的。反转链表这道题,很多人喜欢写迭代版,我也写,但更建议你亲手写一遍递归版。递归的视觉冲击力很强:一句head->next->next = head就把“当前节点下一位的指针指向当前节点”完成了,然后head->next = NULL断开原来的方向。这两行的先后顺序不能反,一旦反了,链表就断成一截截,我在调试时见过这种情况,输出结果会从中间开始一堆随机地址。
但递归一定要理解“归”的过程:函数执行到newHead = reverseList(head->next)时会进入下一层,直到最后一个节点返回它自己,再逐层往回翻转指针。我在代码里特意加了一句注释“相信递归的返回值:它一定是反转后的新头”,因为没什么算法背景的人,最需要这种心理暗示来建立信心。
2.3 二分查找的边界哲学:为什么“+1”和“-1”决定生死
二分查找对无基础选手来说,看似简单实则陷阱极多。我刷的题目里,有一道是在升序数组里找目标值,标准写法没什么问题。但换一道找“第一个大于等于目标值的位置”(也就是lower_bound),边界处理就变得敏感。
我当时的错误写法是:
while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] >= target) { right = mid - 1; } else { left = mid + 1; } }这个写法最终返回left,看起来正确,但如果你把right = mid - 1想当然地写成right = mid,就会陷入死循环。因为当left == right且满足条件时,right不变,循环条件恒成立。这类边界问题的根源,在于对区间不变量的理解不够。我的解决办法是固定一种风格:定义区间为“左闭右开[left, right)”,然后严格按照这个定义调整边界,再也不混。你刷题的时候,一定要选定一种区间风格并始终如一,不要今天左闭右开明天左闭右闭,那是自己坑自己。
2.4 用数组模拟邻接表:当递归回溯遇上C语言的内存分配
有一道矩阵搜索题,需要在二维数组里寻找匹配的单词路径。官方讨论区往往会用DFS回溯。C语言做这类题,最麻烦的是路径访问状态的保存。我的做法是直接把访问状态数组visited定义成二维bool,然后每次递归前标记、回溯后恢复标记。关键点在于恢复标记的时机:递归返回之后立刻恢复,否则其他路径会被错误拦截。
另外,二维数组传参在C语言里容易让人头大。我习惯把矩阵当成一维数组处理,通过index = row * col + col来计算下标,这样传参只剩一个指针加两个边长,简洁也不容易出错。这道题给我最大的教训是:递归回溯没有想象的难,难的是每次都把“当前路径标记”和“全局路径标记”搞混。
3. 实操过程与核心环节实现:从超时到AC,C语言常见性能与方法论关卡
说了题目思路,再聊实操。同样一道题,别人用C++几毫秒过了,我用C语言却超时,这很正常。C语言没有STL,很多数据结构和算法得自己搭,但反过来,正因为自己控制每一步操作,反而更容易发现哪些地方浪费了时间。
3.1 一键式提交并不可靠:先造测试用例再跑
我给自己定的规矩是:任何一道题写完之后,先跑到本地的测试环境里,至少构造五组用例,包括普通输入、边界输入、空输入、单元素输入、超大数据范围输入。这道工序不能省,因为力扣的编译器有时候反馈不够直观,报错信息也不一定指向真正的原因。自己在本地测试,能利用调试器断点单步追踪,这对C语言指针类问题特别管用。
以那道矩阵搜索题为例,我本地构造了一个5x5的地图和一个长度5的单词,单步调试时发现递归的visited状态有问题:最内层返回时状态没有被正确清除。如果没有这个本地案例,直接提交,大概率得到一个模糊的Stack Overflow或者Wrong Answer,你甚至都不知道该从哪里查起。所以,本地调试这一套流程,基本上是我每道题的标准动作。
3.2 数组越界的常见隐蔽来源:循环边界和下标计算
C语言不像Java有数组越界检查,越界了运气好不崩溃,运气不好直接heap-buffer-overflow,运气更差的是输出结果看似正常但内存已经被污染。我在一道数组题中遇到过:for (int i = 0; i <= len; i++),多写了一个等号,导致最后一个循环访问了nums[len]——越界读取,而且因为那个内存刚好是0,程序居然没崩,只是算出来的结果完全不对。
排查这类问题的方法,我推荐给所有C语言刷题者:第一步,检查所有循环条件里的等号;第二步,检查数组下标与长度的关系,凡是下标等于长度,需要立刻警惕;第三步,如果你用了malloc,心里必须明确这块内存的长度,千万别等下标的偏移量超过它。过程中我用-fsanitize=address编译过一次,它能精准地告诉你越界发生在哪一行,这个工具请务必掌握。
3.3 指针与内存动态分配:malloc之后的三件事
C语言刷题少不了动态分配数组,最常见的错误就是分配完忘记释放,或者只释放了一半指针。我的建议是遵循三件事:分配之后立刻检查是否为空;用完之后及时free;如果一个结构体里有嵌套指针,释放时要先释放内部再释放外部,否则就泄漏了。
实际操作中,我养成了一个习惯:每次malloc前先想好“这个内存谁会释放,在哪里释放”。绝大多数时候,主函数里分配的函数段,释放也在主函数里,但如果你封装的函数返回了分配好的指针,接收者就要承担释放义务。这种责任边界想清楚了,内存问题至少少一半。
3.4 从超时到AC的一次完整重构:哈希表的空间换时间
有一道题是统计某些数组元素出现的频率,我最初采用双重循环:
for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { if (nums[i] == nums[j]) count++; } }本地测试小数据量没毛病,一提交直接超时。这个时候我没有继续优化小细节,而是重新审视了题目:既然只需要统计频率,为什么不直接用哈希表一趟扫描?于是改为一个定长数组,下标代表数字本身,值代表出现次数。一次循环更新,再根据条件筛选答案。
这个重构看起来不难,但关键在于你愿不愿意放弃已经写好的代码。很多无基础选手的通病是:代码写了一百行,明知道方案不够好,却舍不得删。我在刷题时反复提醒自己:代码不是你的孩子,思路才是。重写不是否定之前的努力,而是榨干那道题的价值。
4. 常见问题与排查技巧实录:C语言刷题最容易踩的坑和避坑手册
把这段时间遇到的典型问题整理成一张速查表,方便你刷题的时候随时对照。这些坑不是来自理论推演,每一行都是我实际提交时踩过、查过、改过的。
| 现象 | 根因 | 排查技巧 | 避坑方案 |
|---|---|---|---|
提交后报AddressSanitizer: heap-buffer-overflow | 访问了malloc区域外的地址 | 加-fsanitize=address编译并查看调用栈 | 所有下标在写入前和len做比较 |
| 输出结果全部是随机大数 | 访问了未初始化内存 | 用calloc代替malloc,或初始化所有数组 | 养成声明变量就初始化的习惯 |
递归直接Stack Overflow | 递归终止条件有问题或者深度过深 | 在递归入口打印信息,观察有没有死循环调用 | 先写终止条件再写递归主干 |
| 逻辑正确但超时 | 算法复杂度过高 | 估算n的大小和循环嵌套层数 | 考虑空间换时间,引入哈希或预处理 |
指针段错误Segmentation fault | 空指针或野指针 | 打印指针地址,检查是否传递了修改过后的指针 | 函数传参时优先传递结构体指针而非结构体本身 |
| 边界数据报错 | 没有考虑数组长度0、1、2这些情况 | 针对len=0、len=1分别构造用例 | 代码开头先处理极端边界的return |
4.1 哈希表冲突:当数组下标不够用时的自建链式哈希
当题目给的数值范围很大,比如10^9,就不能直接开定长数组。我的做法是构建一套简单的链式哈希表:定一个素数模数,比如10007,再用struct Node数组加next指针挂冲突链。
#define MOD 10007 typedef struct Node { int key; int val; struct Node *next; } HashNode; HashNode* hashTable[MOD]; void hashInsert(int key, int val) { int idx = abs(key) % MOD; HashNode* node = malloc(sizeof(HashNode)); node->key = key; node->val = val; node->next = hashTable[idx]; hashTable[idx] = node; }这里的新节点是头插法插入,每次插入都在头部,时间复杂度O(1),查找时先定位桶,再挂链逐个比较。这个实现虽然没有工业级哈希表那么精细,但应付刷题完全够用,而且能帮你真正理解哈希结构,比直接用现成语言库更有价值。
4.2 递归改循环的时机:别让函数调用栈成为性能瓶颈
递归写起来方便,但C语言里每次递归调用都有栈开销。如果深度达到几万层,即使逻辑正确也可能爆栈。遇到这种情况,我一般是两个选择:改写成显式栈的迭代版本,或者用循环结构代替。
比如处理二叉树的前序遍历,递归写法两三行就能搞定:
void preorder(struct TreeNode* root, int* arr, int* size) { if (root == NULL) return; arr[(*size)++] = root->val; preorder(root->left, arr, size); preorder(root->right, arr, size); }但如果树的深度极端,这就不安全。我后来改成了用数组模拟栈的迭代版,虽然代码变长,但胜在可控。刷题的时候一定要心里有数:递归只是手段,不是目的;如果它让你不稳定,就果断放弃,不要为了炫技硬撑。
4.3 输出格式的隐藏坑:空格、换行和“末尾多一个空格”
力扣平台有的题目要求输出数组时用空格分隔,但末尾不能有多余空格。C语言选手最容易在这里被判Presentation Error,虽然不扣运行时间,但就是让你不能AC。解决办法是统一用“先输出第一个元素,后面加空格再输出下一个”的方案:
for (int i = 0; i < n; i++) { if (i > 0) printf(" "); printf("%d", arr[i]); } printf("\n");这个模板我刷题期间一直用,从没在格式上翻过车。所谓经验,就是这类提前预判的细节积累。
4.4 本地能过一提交就错:环境差异和未定义行为的真相
最玄学的一种情况是本地运行结果正确,提交却报错。这里多半是未定义行为在作祟。比如数组越界读取后,在本地那块内存恰好是0,程序就侥幸通过;在评测机上,那块内存是垃圾值,结果直接错误。C语言标准规定未定义行为什么事都可能发生,但现实是“本地正常”往往给了你极大的错觉。
排查手段只有一个:开启编译器警告就好。我最常用的是gcc -Wall -Wextra,警告比人眼锐利得多。比如signed和unsigned比较、未初始化的变量,它都会提醒你。别觉得警告烦,这可是白送的安全网。
4.5 关于力扣测试用例的童年阴影:样本先进、方差巨大
很多人以为力扣的测试用例分布均匀,其实并非如此。我遇到一次很特殊的翻车:数组长度本身不大,但内部元素的取值范围极大,按我原来的定长数组方案,会直接开出几百万大小的数组,内存直接爆炸。解决方案是把数组下标逻辑改为“下标映射”:通过取余或者差值偏移,把宽度压缩到可接受范围。这个“通过数据分布预判存储策略”的意识,比那道题本身值钱得多。
5. 刷题节奏与心态维持:从短期冲刺到长期习惯的经验分享
道理都懂,但坚持刷题这件事,比任何题目都难。我必须跟你说实话:我也有过连续一周都不想打开力扣的阶段,也有过刷了三道题全看答案、心情崩坏的下午。但最终还是找到了一套适合自己的节奏,分享给你参考。
5.1 刷题时间规划:一天一道,但必须是有效刷题
我的策略从来不是“一天刷十道简单题”,而是“一天认真吃透一道题”。因为通勤时间有限,集中注意力的时间更有限。一天一道题意味着你可以做到:读题、思考、尝试写代码、卡住查资料、看题解、分析差异、总结纪录。整个过程大约1-2小时,但收获远大于十个“秒了”的简单题。
具体的日常安排分三步。第一步,打开力扣,看今日推荐的题目,结合自己的水平判断难度,如果觉得太简单可以直接跳过换一道;第二步,写代码之前先在纸上或者注释里写出大致思路,哪怕只有三五行“1.先排序 2.用两个指针印证 3.注意相等时的移动顺序”,写出来让你更容易抓住主线;第三步,提交AC后,花五分钟看讨论区的优秀做法,记录自己能吸收的点,这一步叫“对比差距但不受打击”。
5.2 如何对抗“题解依赖症”和“忘记效应”
无算法背景的最大障碍是看题解看懂了,过两天又忘。我摸索出来的办法是“错题重刷法”:标记自己做错或者没思路的题,隔三天、隔一周、隔两周各回头重做一次。重做的要求是——不能看任何提示,完全靠记忆还原解题逻辑,如果两次重做都顺利通过,才算真正消化。
这个方法看起来笨,但效果极好。因为“看得懂”和“写得出来”中间隔着一条巨大的鸿沟,只有亲自在时间压力下重写才能跨越。我一度觉得自己“刷了四十题,全忘光了”,但是按错题重刷法走完一轮以后,再遇到同类题,明显能自主推导了。
5.3 写解题笔记的边界:记录思路而非抄写题解
我每一道题都会在本地维护一个Markdown文件,记录三个部分:题目的核心考点(比如“双向指针也可以,但这里为什么选哈希”)、我犯的错误(比如“mid加不加1搞错了”)、以及下次遇到同类的提示词(比如“看到两个数组找交集,优先想到排序和哈希”)。注意,这里不记录完整代码,只记录思路和反思,因为代码写了就忘了,但思路能内化。这个文件是我的核心资产,价值远大于收藏夹里的几百个题解链接。
5.4 善用社区但不要陷入大神焦虑
我经常刷讨论区,但给自己定了规矩:只看精选题解,只学自己能吸收的,不嫉妒别人的方法。很多人喜欢说“这题双指针三行秒了”,但你不知道人家刷了多少年,比这个就是自找不痛快。我的比较对象只有一个:昨天的自己。昨天这道题我没思路,今天有思路了;昨天这个哈希表还写错边界,今天一遍过,这就是进步。这种内在参照系的建立,是长期刷题不崩心态的关键。
6. 无算法背景的C语言选手,还能怎么进一步扩展
说了这么多,其实都是围绕“从0到1”这个阶段。如果你已经把基本题单刷完一遍,感觉常规题目开始顺手,不妨把视角再抬高一点。某些方向是C语言选手特别容易发力的,值得考虑。
6.1 从“写对”迈向“写美”:可读性也是一项能力
一道题AC之后,别急着下一题。尝试对自己问三个问题:这个代码别人能一眼看懂吗?有没有更少的变量纠缠?如果面试官让我现场讲,我能把每一步讲清楚吗?如果不能,说明这个题还是没吃透。C语言代码容易写得晦涩,但你可以刻意练习:用有语义的变量名替代tmp1、tmp2;把重复逻辑抽成小函数;循环条件命名清晰。这些看似不是算法能力,但绝对是你从新手迈向工程师的必要步骤。
6.2 从“刷题”迈向“专题”:构建自己的知识矩阵
散装刷题会带来“每道题都见过,但每道题都说不透”的迷雾感。我的建议是,每刷20道题以后,花一个周末做一次专题整理,比如“这二十道题里哪些用到了滑动窗口”“哪些用了递归回溯”“哪些考的是排序加双指针”。整理完,你会发现原本孤立的题目被串成了线,那条线就是你要建立的算法思维。无基础背景的人,尤其需要这种主动归纳,因为没有人帮你从高处俯瞰。
6.3 从“力扣”迈向“实战”:把刷题能力迁移到工程项目上
刷题不是终点,而是起点。当你发现自己在处理字符串、链表、哈希这类基础结构时不再慌张,可以考虑去写一个小工具:比如用C语言实现一个命令行单词频率统计器、一个简易的文件索引工具、或者一个链表通讯录管理系统。这些项目比题目复杂,但没有题目的“标准答案”,必须靠你自己设计。当你成功把一个真正的工程跑起来,那种成就感会彻底对冲刷题带来的疲惫。
根据我个人的经验,持续刷题九个月之后,最明显的变化不是AC数量的堆积,而是心态的变化:遇到难题第一反应不再是“我不会”,而是“让我想想它像哪一类问题”。那个时刻,你才真正感觉到“代码是自己的了”。希望这篇记录能给你一点参考,让你在无算法背景、纯C语言的路上走得稳一点、快一点。