1. 第7天上机打卡:从热身到脑力拉满的节奏
今天是DHU上机打卡的第7天。说实在的,坚持到这一周,最大的感受不是“难”,而是“规律”两个字开始起作用了。每天固定时间坐到电脑前,把编辑器打开,先扫一遍昨天的笔记,再进入今天的题目,这种节奏一旦建立起来,学习效率比自己漫无目的地刷题高得多。
DHU的上机打卡一般分几个阶段:前3天是熟悉环境和基础语法,第4到第6天开始进入数据结构专题,到了第7天,基本就是“基础语法已经不用动脑子、数据结构得认真想、算法思维开始被逼着启动”的过渡期。今天这一个session下来,我最大的感受是:题目本身不算特别难,难的是你在有限时间内把思路理清楚,并且把代码写到一次通过。
这个打卡活动适合谁?两类人最合适:一类是学校课程里有上机实验要求,想认真把每次上机都利用起来的同学;另一类是准备找工作、刷题但总觉得一个人坚持不下去,想借助打卡这种外力约束自己的同学。如果你正处于“刷题三天打鱼两天晒网”的状态,这类上机打卡其实是一个很好的自律训练场。
我今天的上机时间大概是两小时,完成了三道编程题和一道附加题。下面把整个过程拆开来讲,包括每道题的思路、踩过的坑、以及事后复盘时的改进方案。这篇文章不是标准答案集,而是“一个普通学生第7天上机打卡”的真实记录和思考过程,适合正在经历类似阶段的同学对照参考。
先说一个经验:上机打卡不是“做完就完事”。做完之后的三分钟复盘,往往比做的时候收获更大。我每次提交完代码,都会强迫自己回答三个问题:这题考的是什么知识点?我最初的思路哪里不够好?如果数据量再扩大十倍,我的代码还能不能跑?这三个问题,基本能榨干一道题的全部价值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. D7实战任务拆解:链表、队列与二分查找
今天的题目分配很有意思,三道题分别对应了三个数据结构/算法模块:链表反转、用两个栈实现队列、二分查找的边界处理。这三道题是面试和课程实验里的“常青树”,难度也是阶梯式上升的,正好适合第7天这个节点。
2.1 第一题:反转链表的边界条件
题目描述很常规:给定一个单链表,反转它,返回反转后的头节点。看起来是“背过就能写”的题,但上机环境下最容易出错的反而不是反转逻辑,而是边界条件。比如链表只有一个节点、只有两个节点、空链表,这三种情况如果不在代码里单独考虑,很容易写出空指针异常。
我当时写的是迭代版本,核心代码是这样的:
cpp复制struct ListNode {
int val;
ListNode *next;
ListNode(int x) : val(x), next(NULL) {}
};
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* prev = NULL;
ListNode* curr = head;
while (curr != NULL) {
ListNode* nextTemp = curr->next;
curr->next = prev;
prev = curr;
curr = nextTemp;
}
return prev;
}
};
这段代码看起来非常简单,但我第一次提交的时候在 if (head == NULL || head->next == NULL) return head; 这个提前退出的判断上犹豫了很久。后来发现,其实迭代版本不写提前退出也能跑,因为空链表时 curr 本身就是 NULL,循环不进入,直接返回 prev 也就是 NULL。这一下让我意识到:很多“保护性代码”其实并不必要,关键是你要想清楚循环的终止条件到底是什么。
这个题还有一个“为什么”值得深挖:为什么需要 nextTemp 这个临时变量?因为反转操作的核心是 curr->next = prev,一旦这句话执行,curr 原本的下一个节点就“丢失”了。如果不提前存下来,循环就无法继续前进。这个道理用一个生活化类比就是:你排队往前递东西,递出去之前得先记住后面那个人是谁,否则队伍就断了。
2.2 第二题:用两个栈实现队列
这道题是一个经典设计题,要求用两个栈实现队列的 push、pop、peek、empty 四个操作。第一次接触的同学很容易懵,因为栈是“后进先出”,队列是“先进先出”,这两个本质上是相反的。
解法思路是“一个栈负责入队,一个栈负责出队”。具体操作如下:
push操作:直接把元素压入stackIn;pop或peek操作:先检查stackOut是否为空,为空就把stackIn里所有元素依次弹出并压入stackOut,然后再从stackOut弹出或查看栈顶。
这个方案的时间复杂度,push 是 O(1),pop 和 peek 均摊下来也是 O(1),因为每个元素只会被“倒”一次。我当时犯的错误是:每次 pop 之前都做一次“全部倾倒”操作,没有判断 stackOut 是不是已经有元素,结果顺序全乱了。
正确代码大概是这个样子:
cpp复制class MyQueue {
private:
stack<int> stackIn;
stack<int> stackOut;
void transfer() {
if (stackOut.empty()) {
while (!stackIn.empty()) {
stackOut.push(stackIn.top());
stackIn.pop();
}
}
}
public:
MyQueue() {}
void push(int x) {
stackIn.push(x);
}
int pop() {
transfer();
int val = stackOut.top();
stackOut.pop();
return val;
}
int peek() {
transfer();
return stackOut.top();
}
bool empty() {
return stackIn.empty() && stackOut.empty();
}
};
这里有一个很微妙的点:peek 和 pop 其实可以使用同一套 transfer 逻辑,但 peek 不能把元素弹出去。我当时图省事,直接在 pop 里调 peek 再弹栈,但这样就意味着 peek 必须保证不改变状态,否则逻辑会乱。所以把两个操作拆开写更稳妥。
这道题的意义在于让你理解“数据结构之间的转换关系”。用生活话讲:栈就像一摞盘子,你只能拿最上面那个;队列就像排队买饭,先来的人先打到饭。两个栈模拟队列的精髓,就是把盘子摞到另一个地方让它倒过来,这样原来“后进”的就变成了“先出”。
2.3 第三题:二分查找的边界写法
第三题是二分查找,要求在一个有序数组中查找目标值,返回下标。这题小学二年级就会做,但第7天上机考你的是“边界写法稳不稳”。我提交之后发现,最容易出问题的地方是 while 条件写 left < right 还是 left <= right,以及 mid 更新的时候到底要不要加一。
我最终的写法是左闭右闭区间:
cpp复制int binarySearch(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
这里有个细节值得展开:mid = left + (right - left) / 2,而不是 (left + right) / 2。原因是后者在 left 和 right 都非常大时可能溢出,虽然上机题的数据量一般不会触发,但养成这个习惯没坏处。
还有一个常见的坑:如果 while (left < right),循环结束后还要额外判断 nums[left] 是否等于目标值,因为这种情况会在区间缩到只剩一个元素时退出循环。如果漏掉这个判断,就会错误地返回 -1。我当时就是先写了 left < right 的版本,提交后有两个测试用例没过,检查了半天才发现是这个原因。
经验总结:二分查找的写法不是唯一的,但你必须选一种自己能讲清楚逻辑的写法,并且固定下来。别今天写左闭右闭,明天写左闭右开,到考试时全凭肌肉记忆,那样最容易翻车。
3. 上机打卡过程中的踩坑实录
每次上机,最宝贵的东西不是“AC了多少题”,而是“踩了哪些坑”。第7天这一天,我踩了三个比较典型的坑,每个都花了不少时间排查。把这些写下来,一方面是给自己留个备忘,另一方面也希望看到这篇文章的同学能直接绕开。
3.1 数组越界与野指针
第一道题反转链表时,我一开始想尝试递归写法,写完之后本地编译通过,但一提交就出现“AddressSanitizer: heap-buffer-overflow”的报错。排查了半天发现,是我在递归终止条件里把 head->next 和 head 的顺序写反了,导致空链表时访问了 head->next,形成了野指针访问。
这个坑特别隐蔽,因为本地测试时我用了非空链表,没暴露问题。上机环境里的测试用例往往更全面,空链表、单节点、双节点都会测一遍,所以任何“想当然”的边界处理都会在提交时炸出来。
排查方法也不复杂:gdb 或者本地加打印信息,一步一步追踪递归调用栈。不过更快的办法是先想清楚递归终止条件:
cpp复制if (head == NULL || head->next == NULL) {
return head;
}
注意这里的顺序,head == NULL 一定要放在前面,因为 head->next 的前提是 head 不为空。这个顺序一旦反了,编译器不会报错,但运行时就炸了。我把这个教训记在了打卡笔记的第一行:“先判空,再访问成员”。
3.2 栈模拟队列时忘记判空
第二题用两个栈实现队列时,我的 pop 写法一开始是:
cpp复制int pop() {
transfer();
int val = stackOut.top();
stackOut.pop();
return val;
}
看起来没问题,但如果用户在队列为空时调用 pop,stackOut.top() 就会访问未定义行为。标准库的 std::queue::pop 在空队列时同样是未定义行为,这里不存在兼容性问题,但上机测试里一般不会要求你处理这种情况。
真正的问题出在 transfer() 里我忘了判断 stackOut.empty()。第一次提交时,我连续 push 了 1、2、3,然后 pop 了两次,再 push 4,再 pop。我的代码在第二次 pop 之后 stackOut 已经空了,此时 push 4 只进了 stackIn,但 transfer() 在第一次 pop 时已经执行过倾倒,第二次 pop 时 stackOut 为空,它又会从 stackIn 倒一次,结果顺序是“先4后2”,完全错误。
正确的做法就是前面代码里写的:transfer() 里必须判断 stackOut 是否为空,只有为空才从 stackIn 倒。这个判断是这个题的核心,也是题目想考察的“设计思维”——你要时刻清楚两个栈各自的状态。
3.3 超时与死循环
第三题二分查找,我一开始写的是文中的左闭右开版本:
cpp复制while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid;
}
}
这个版本在绝大多数情况下能跑,但有一个隐藏风险:当 left == mid 且 nums[mid] < target 时,left = mid + 1,此时 left 和 right 之间的差距会缩小,没问题。但如果 else 分支里 right = mid,而 mid 恰好等于 left,就可能陷入死循环。
实际上这个版本在 left = 0, right = 1,且目标值大于 nums[0] 时,mid = 0,left = 1,退出循环,没毛病。真正危险的是某些特殊条件下 mid 落到 left 而 nums[mid] >= target 时,right = mid,区间不会缩小,就死循环了。
所以我后来干脆统一用左闭右闭版本。不纠结,不逞能,用自己最有把握的写法。这也是上机打卡很重要的一课:考试和训练的目的是验证你的掌握程度,不是展示你掌握了多少种花哨写法。
4. 打卡效率提升:我的D7工具链与习惯
坚持到第7天,我发现真正拉开差距的往往不是编程能力,而是“上机效率”。同样两小时的机时,有人能完成3道题加复盘,有人连第二题都磕磕绊绊。差别在哪里?我总结了一下,主要是工具链和习惯。
4.1 本地环境准备
DHU的上机环境一般是Linux + GCC + 在线判题系统,但我自己习惯在本地也配一套一致的开发环境。具体来说:
- 编辑器选择 VS Code,装上 C/C++ 插件和 Code Runner 插件;
- 编译器版本与上机环境保持一致,避免本地能过线上挂的情况;
- 每一道题单独建一个文件夹,里面放
main.cpp、notes.md、.vscode/launch.json。
这个习惯帮我省了很多时间。有些同学直接在浏览器里的在线编辑器写代码,一旦遇到编译错误,排查起来非常痛苦,因为在线编辑器没有调试功能,只能靠肉眼。本地有调试器,可以一步步看变量值变化,排查效率至少提高三倍。
4.2 打卡记录模板
第7天我开始用固定模板做笔记,效果比之前的杂乱记录好很多。模板大概是这样的:
code复制## D7 - 题目名称
- 涉及知识点:链表、指针
- 我的思路:...
- 参考思路:...
- 代码要点:...
- 踩坑记录:...
- 复杂度分析:...
- 变种题思路:...
这个模板强迫我把每道题的“为什么”写下来,而不是只记录“怎么做”。第7天回头翻前几天的笔记时,我发现凡是只写了代码截图的题目,现在基本忘了思路;凡是有完整分析和踩坑记录的,回忆起来就很快。所以我现在特别推荐这个“模板化记录”的方法。
4.3 代码模板与调试技巧
上机做题,手速也很重要。我提前准备了一个“代码头文件模板”,包含常用的头文件、using namespace std、#define 常量等。这样每次新建文件时不需要浪费时间敲重复代码。
调试方面,我的核心技巧是“分段打印”。比如二分查找,我打印 left、mid、right 的每一步变化;链表反转,我打印 curr 和 prev 的地址变化。打印信息虽然看起来土,但比任何调试器都直观,尤其是在处理递归、循环这类逻辑时。
另外,我习惯在提交前自己构造几个测试用例,而不是写完直接交:
- 空输入(空链表、空数组);
- 最小规模输入(1个元素);
- 最大规模输入(看是否超时);
- 随机规模输入(验证正确性)。
这几个测试用例基本能覆盖80%以上的常见坑。养成这个自查习惯后,我的提交通过率明显提升了,今天三道题中有一道就是靠自查发现漏掉的边界情况,避免了一次罚时。
5. 中期复盘:D1到D6我积累了什么
第7天也是一个自然的复盘节点。我花了一点时间把前6天的笔记重新过了一遍,整理出了一张“知识点地图”,写在这里供大家参考。
| 天次 | 主题 | 核心知识点 | 我的掌握程度 |
|---|---|---|---|
| D1 | 环境配置与基础输入输出 | cin/cout、scanf/printf、字符串处理 | 熟练 |
| D2 | 数组与字符串 | 双指针、滑动窗口、前缀和 | 较熟练 |
| D3 | 排序与查找 | 快排、归并、二分查找 | 较熟练 |
| D4 | 栈与队列 | 单调栈、优先队列、栈模拟队列 | 开始掌握 |
| D5 | 链表基础 | 链表操作、快慢指针、反转链表 | 中等 |
| D6 | 递归与分治 | 递归出口设计、分治思路 | 中等 |
| D7 | 综合练习 | 链表、队列模拟、二分边界 | 稳定 |
回顾这几天,我发现一个规律:前3天是在“学”,后4天是在“用”。从第4天开始,打卡题目明显从“考察固定知识点”转向“综合考察多个知识点”。比如今天第二题,表面上考栈和队列,实际上还考了你对“状态转移”的理解,如果没有前几天的积累,今天很可能卡住。
另外还有一个非常重要的体会:上机打卡的“反馈速度”是刷题软件无法比的。刷题软件通常只告诉你过没过,而线下上机打卡时,旁边的同学、老师、助教都能成为你的即时反馈源。我在第5天卡链表反转时,就是瞄了一眼旁边同学写的代码,刹那之间明白了自己在哪里绕了远路。这种“环境反馈”是打卡模式最大的优势。
基于前6天的积累,我给D7定的目标不是“多做几道题”,而是“把已有知识拧成一股绳”。今天的三道题恰好起到了这个作用:链表题考验基本功,队列模拟考验设计能力,二分查找考验边界思维。做完之后,我对前几天的知识有了更立体的理解。
6. 经验总结:D7上机打卡的实操建议
最后这部分写给正在上机打卡或准备开始打卡的同学。以下建议都是我亲身试过、“踩坑换来的”,不算什么高深的道理,但照着做,至少能让你少走几个弯路。
第一,不要只盯着AC,要看测试用例覆盖。 我之前有一道题是“秒A”,但后来发现自己的代码完全没考虑数组长度为奇数的情况,只是运气好碰上了偶数用例。上机训练的目的不是骗过评测机,而是锻炼“用测试用例逼出代码问题”的能力。
第二,遇到不会的题,先写伪代码,再写代码。 这个道理我从D1听到D7,从D4开始才真正照做。第7天做第三题时,我先用文字把“左闭右闭”的逻辑写了一遍,再翻译成代码,全程没卡壳。伪代码能帮你把“算法逻辑”和“语法实现”分开,避免两者混在一起导致大脑死机。
第三,每次上机结束前留10分钟做“复盘”。 复盘内容就三件事:今天哪些题是一次过的?哪些是修改后过的?有没有反复修改都过不去的?记录下来,第二天上机前花5分钟看一遍。这比临时抱佛脚背答案高效得多。
第四,多和旁边的人交流。 打卡机房里的学习氛围是很好的资源,有时候你看隔壁同学的代码一眼,比自己埋头苦想十分钟更有效。当然,前提是你自己先思考过,不能一上来就看别人答案。
第五,控制好节奏,前松后紧。 第7天实际做题时,我第一道题花了近40分钟,远远超出预期,导致第二、第三题时间压得很紧。事后复盘,第一题根本不需要想那么久,是因为我反复纠结“要不要加提前退出判断”这种与核心逻辑无关的问题。建议每一道题先快速确定思路和复杂度,如果10分钟内没有思路,先跳过,回头再做。
说实话,第7天这个节点很容易让人松懈——一周过去了,新鲜感消失,题目难度还在上升。但恰好在D7,我开始感受到“积累”的力量:前6天背过的模板、记下的易错点、调试时总结的小技巧,在今天的题目中全部派上了用场。上机打卡这件事,难不在“某一天”,难在“每一天都做”。如果你也正好在打卡的第7天前后,希望这篇文章能给你一点参考,也祝你把这一轮打卡坚持到底。
