链表的题刷多了之后会有一个明显的感受:难的不是某个操作本身,而是指针一变多,脑子就转不过来。这篇笔记是 Hot 100 链表专题的下篇,一共五道题:相交链表、回文链表、两数相加、两两交换链表中的节点、随机链表的复制。乍看之下题目之间没什么关联,但真正动手写之后会发现,它们反复用到的核心技巧无非就是那么几招——双指针找位置、哑节点处理头部的变化、指针重连的顺序。这篇我会把每道题的思考过程、实现细节和踩过的坑写清楚,也给正在用 C++ 刷 LeetCode 的朋友一个可以直接照抄的参考。
1. 相交链表:先想清楚“相遇”意味着什么
1.1 从哈希集合到双指针:空间的取舍
相交链表这道题,最直观的解法是拿一个哈希集合记录链表 A 的所有节点,然后遍历链表 B,每到一个节点就查一下这个节点是否已经在集合里。第一个命中就说明是交点。这个解法逻辑上没有任何问题,时间复杂度 O(m+n),空间复杂度 O(n),小数据量时跑得也很快。
但 LeetCode 把这题归到 Hot 100 里,显然不是只想让你用哈希集合。你只要稍微留意下评论区和题解区的风向,就会发现大家讨论的重点几乎全在“双指针能不能做到 O(1) 空间”上。这里说的 O(1) 空间,是指不借助额外的哈希表、数组这些结构,只靠几个指针变量完成遍历。
我当时第一次自己写的时候,用的就是哈希集合,写得确实快,大概三分钟就过掉了。但后来我复盘的时候意识到一个问题:哈希集合存的是指针,而指针的比较本身就是地址比较,这在 C++ 里其实是很廉价的操作,但整套解法的问题在于空间。如果链表 A 有一百万个节点,哈希表就得有一百万个条目,这在生产环境里是不可接受的。所以双指针解法值得好好理解,因为这种“不额外占用空间”的思路在工程场景里更实用。
1.2 双指针的“路程相等”原理
双指针解法为什么能工作?关键在于一个很简单的数学事实:两个指针,一个从 A 出发,一个从 B 出发,如果各自把 A 和 B 都走一遍,那么它们走过的总节点数是一样的。
具体来说,假设链表的交点把两条链表分成三部分:A 的独有部分是 a,B 的独有部分是 b,公共部分是 c。从 A 出发的指针 pA,走完 A 再走 B,一共走 a+c+b 个节点;从 B 出发的指针 pB,走完 B 再走 A,一共走 b+c+a 个节点。这两个数完全相等,所以如果两条链表相交,那么 pA 和 pB 一定会在某个时刻同时走到交点;如果两条链表不相交,那么它们会同时到达链表末尾的 nullptr,此时 pA 和 pB 相等,循环结束,返回 nullptr。
理解了这个原理,代码就非常简洁了:
cpp复制class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
if (!headA || !headB) return nullptr;
ListNode *pA = headA, *pB = headB;
while (pA != pB) {
pA = pA ? pA->next : headB;
pB = pB ? pB->next : headA;
}
return pA;
}
};
这段代码里有个细节很容易被忽略:pA = pA ? pA->next : headB 这句话,当 pA 是 nullptr 时,跳到了 headB,而不是停在 nullptr。这个“跳到另一条链表头部”的动作,就是实现“路程相等”的关键。如果不跳,那么两条不相交的链表永远等不到 pA 和 pB 相等,程序会死循环;跳了之后,总路程一致,不相交时最后都会变成 nullptr,循环自然结束。
1.3 这题的坑:值相等不等于节点相同
相交链表这题最常见的错误是拿节点的值做比较,想着“这个节点的 val 和另一个节点的 val 一样,是不是就是交点了”。太容易踩这个坑了。链表里值相同的节点可能到处都是,相交的本质是“同一个节点”,也就是地址相同。所以代码里的判断始终是 pA != pB 这种指针比较,而不是 pA->val != pB->val。
另外还有一个容易写错的点:很多人会把 while 循环写成 while (pA->next && pB->next)。这种写法在链表不相交时会得到错误结果,而且还会漏掉一条链表比另一条短的情况。正确做法是让指针能够走到 nullptr,再用三元表达式切到另一条链表,而不是在 pA->next 为空的时刻原地打转。记住一句话:双指针相遇的前提是“两个指针都走完了两条链表”,而不是“两个指针同时到达末尾”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 回文链表:快慢指针定位中点,空间复杂度是怎么降下来的
2.1 最容易想到:转成数组再对撞
回文链表这题,最省脑子的做法是把链表遍历一遍,把所有节点的值存进一个 vector,然后左右两个索引往中间走,比较值是否相等。代码很简单,二十分钟内肯定能写完,而且不容易出错:
cpp复制class Solution {
public:
bool isPalindrome(ListNode* head) {
vector<int> vals;
while (head) {
vals.push_back(head->val);
head = head->next;
}
for (int i = 0, j = vals.size() - 1; i < j; ++i, --j) {
if (vals[i] != vals[j]) return false;
}
return true;
}
};
这题如果你只是追求“通过”,那转数组完全够用。时间 O(n),空间 O(n)。但这里有个问题:如果面试官追问一句“能不能做到 O(1) 空间”,你总不能只说不会吧。所以进阶解法必须掌握,而进阶解法的核心就两个操作:找中点、反转链表。
其实再往深处想一层,转数组这种解法之所以被很多人批评,倒不是因为它空间复杂度高,而是因为它没有利用链表的特性。数组天然支持随机访问,从两端对撞很自然;但链表是单向的,你没法从尾往前遍历。所以真正考链表操作的回文题,考的是你能不能在没有反向访问能力的前提下完成判定——这才是这道题的真正价值所在。
2.2 快慢指针找中点 + 反转后半段
进阶解法的第一步,是用快慢指针找到链表的中点。慢指针每次走一步,快指针每次走两步,快指针到末尾时,慢指针正好在中点。这个技巧在链表题里出场率极高,找环、找中点、找交点都用得上。
第二步是把后半段链表反转。反转之后,从 head 开始的前半段和从反转后的后半段头开始的后半段,就可以逐个比较了。整体代码长这样:
cpp复制class Solution {
public:
bool isPalindrome(ListNode* head) {
if (!head || !head->next) return true;
ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
ListNode* tail = reverseList(slow);
ListNode *left = head, *right = tail;
while (right) {
if (left->val != right->val) return false;
left = left->next;
right = right->next;
}
return true;
}
private:
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
while (head) {
ListNode* next = head->next;
head->next = prev;
prev = head;
head = next;
}
return prev;
}
};
这里有个细节要说清楚:快指针走完时,慢指针的位置到底是哪。如果链表长度是奇数,慢指针正好停在正中间的节点上;如果长度是偶数,慢指针停在第二个半段的开头。我们反转的是从 slow 开始的整段链表,所以偶数长度时反转的就是完整的后半段;奇数长度时反转的是“中间节点+后半段”,但因为中间节点和它自己天然相等,多比较一次也没有影响。
我自己推演过几个例子来验证这段逻辑:
- 链表 1->2->2->1:slow 会停在第二个 2 的位置,反转得到 1->2,比较 head 的 1 和右半段的 1,再比较 2 和 2,返回 true。
- 链表 1->2->3->2->1:slow 停在 3,反转得到 3->2->1,比较 1 和 1、2 和 2、3 和 3,返回 true。
- 链表 1->2->3->4:slow 停在 3,反转得到 4->3,比较 1 和 4 直接返回 false。
从这三个例子可以看出来,这个写法最妙的地方是不用单独判断链表长度的奇偶,统一处理就对了。
2.3 要不要恢复链表?工程思维的体现
LeetCode 的判题只看返回值,不会管你运行结束之后链表是不是被改乱了。所以大部分题解,包括我上面给的代码,都没有恢复原链表。但如果你是在面试里写这道题,面试官很可能会追一句:“你把原链表后半段反转了,原链表结构被破坏了,这在实际代码里是不能接受的,你能恢复吗?”
恢复的方法很简单:比较结束之后,把后半段再反转一次,接回原来的位置。问题是代码会变长,面试现场边写边想容易乱。我的建议是:先写成不恢复的版本,跑通了之后,再在代码后面补一个恢复操作,并且主动跟面试官说明“生产环境里应该恢复现场”。主动说这句话,往往比闷头写一段长代码更加分。
我个人在实际刷题时,其实很少写恢复链表的版本,因为 LeetCode 不太关心这个,刷题追求的是把算法本身练熟。但有一次我用 C++ 写了一个本地测试程序,把 LeetCode 的解法跑完后又去遍历链表,发现后半段顺序变了,调试了半天才反应过来是解法改变了链表本身。这个经历让我明白了一个道理:凡是会修改传入参数的函数,写完之后都应该在注释里标明“注意:此函数会修改链表结构”,这是 C++ 工程师的基本素养。
3. 两数相加:模拟竖式加法时最容易漏掉的边界
3.1 链表头是低位,这个设计和竖式加法天然匹配
两数相加这道题,输入的两个链表都是逆序存储数字的。比如数字 342 会存成 2->4->3,个位在链表头。这种设计的用意很直接:两个数相加时,从低位往高位加,而链表头恰好是低位,所以可以直接从头开始逐位相加。
如果链表存的是正常顺序(高位在前),那低位在尾巴上,你还得先反转链表,或者用栈把节点压进去,再倒着处理。LeetCode 之所以把链表头设为低位,就是为了免掉这个麻烦。看清楚这个设计意图之后,这道题的思路就很清晰了:维护一个进位标志 carry,每次取两个链表当前位置的值,加上 carry,得到 sum,新节点的值是 sum % 10,新的进位是 sum / 10,然后指针往后移动。
3.2 迭代实现:循环条件里加上 carry,省掉一半边界判断
我见过很多第一次写这题的人,代码长这样:先 while (l1 && l2) 处理等长部分,再分别写 l1 剩余部分和 l2 剩余部分的处理,最后还要单独判断 carry 是否还等于 1。这个写法逻辑上没问题,但代码冗长,而且特别容易漏掉最后一个分支。
我在实际写的时候,更推荐把循环条件写成 while (l1 || l2 || carry),把所有情况一次性处理完:
cpp复制class Solution {
public:
ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) {
ListNode dummy(0);
ListNode* cur = &dummy;
int carry = 0;
while (l1 || l2 || carry) {
int sum = carry;
if (l1) {
sum += l1->val;
l1 = l1->next;
}
if (l2) {
sum += l2->val;
l2 = l2->next;
}
carry = sum / 10;
cur->next = new ListNode(sum % 10);
cur = cur->next;
}
return dummy.next;
}
};
注意循环终止条件里包含了 carry,也就是说当 l1 和 l2 都走完,但进位还等于 1 时,循环还会再执行一次,会把最高位的那个 1 以新节点的方式挂上去。这样就不需要最后再手写一个 if (carry) 的收尾逻辑。
这题最容易踩的坑就是漏掉最后的进位。比如 5 + 5 在两个链表里都是单个节点,各位相加得到 0,进位 1,如果不额外处理,答案就变成了 0,而正确答案应该是 1->0。加了 || carry 条件之后,这个问题就自动解决了。
3.3 递归写法与后续变形
如果主循环写熟练了,可以试试递归版本。递归的想法是:当前位的和 = carry + l1 的当前值 + l2 的当前值(如果为空则视为 0),新节点的值是 sum 的个位,然后递归处理剩余部分。递归的终止条件是 l1、l2 都为空且 carry 为 0。
cpp复制ListNode* dfs(ListNode* l1, ListNode* l2, int carry) {
if (!l1 && !l2 && carry == 0) return nullptr;
int sum = carry;
if (l1) sum += l1->val;
if (l2) sum += l2->val;
ListNode* node = new ListNode(sum % 10);
node->next = dfs(l1 ? l1->next : nullptr, l2 ? l2->next : nullptr, sum / 10);
return node;
}
递归版本代码很短,但有两个问题:一是链表很长时可能栈溢出,LeetCode 的测试数据一般不会触发,但生产环境要小心;二是可读性对新手来说差一些,迭代版本更直观。我的建议是:笔试用迭代,面试可以两种都提一下,展示你理解了递归的本质。
另外,这题有个很常见的变形:如果链表头存的是最高位,而不是最低位,怎么做?思路是先反转两个链表,相加完再反转回去;或者用栈先压入所有节点,再逐位弹出相加。无论哪种做法,核心都是把高位的对齐问题转换成低位加法的问题。这个变形在面试里出现的频率不低,刷完这道题之后值得顺手想一想。
4. 两两交换链表节点:为什么说哑节点是链表的“安全气囊”
4.1 必须引入哑节点的原因
两两交换链表中的节点,题目要求把 1->2->3->4 变成 2->1->4->3。翻译成人话就是:每两个节点一组,交换组内两个节点的位置,但不改变组与组之间的相对顺序。
一开始我犯过一个错误:直接把 head->next 指向 head->next->next,然后 head->next->next = head。第一次交换看起来是对的,但下一次交换时,需要找到上一组交换完之后的末尾节点,把它和下一组的新头接上。这就涉及一个重要问题:头节点变了,而所有处理头节点的代码都需要特殊判断。最典型的场景是循环前需要保存新头,循环结束后又要让新头生效,处理起来非常绕。
最佳实践是引入哑节点(dummy node)。哑节点是一个不参与实际数据存储的额外节点,它的 next 指向真正的头节点。有了它,头节点的变化就不需要单独处理了,所有逻辑统一从“哑节点的 next 后面开始交换”这个视角出发。我把哑节点比喻成链表的“安全气囊”:它不直接参与业务逻辑,但能在头节点被换掉的时候兜底,让代码不至于因为第一个节点特殊而分叉。
4.2 迭代实现:指针重连的顺序不能错
迭代写法的核心循环如下:每次找到当前这组的两个节点 first 和 second,然后把它们和前后节点的连接关系调整好。
cpp复制class Solution {
public:
ListNode* swapPairs(ListNode* head) {
ListNode dummy(0, head);
ListNode* prev = &dummy;
while (prev->next && prev->next->next) {
ListNode* first = prev->next;
ListNode* second = first->next;
first->next = second->next;
second->next = first;
prev->next = second;
prev = first;
}
return dummy.next;
}
};
很多人写这道题时容易在指针重连的步骤里顺序写乱。我个人的经验是:在修改任何指针之前,先把需要操作的节点保存到局部变量里。比如上面的 first 和 second,在处理之前就取出来了,后面不管怎么改连接,这两个局部变量都还指向原来的节点,不会因为别的指针被修改而找不到它们。
具体重连的顺序是:先把 first 的 next 指向 second 的 next,这一步是把原链表里 second 后面的节点接过来;再把 second 的 next 指向 first,这一步完成组内两个节点的反转;最后把 prev 的 next 指向 second,把这一组的新头接到上一组后面。注意顺序不能反,如果先把 prev->next 改成 second,再改 first->next,虽然也能成立,但思考回路容易混乱,debug 起来也更困难。
循环结束后,如果链表长度是奇数,最后一个节点没有配对,直接保留原样即可,while 条件 prev->next && prev->next->next 已经处理好了这个情况。
4.3 递归写法:把后续问题完全丢给递归
两两交换还有一个很漂亮的递归版本:
cpp复制class Solution {
public:
ListNode* swapPairs(ListNode* head) {
if (!head || !head->next) return head;
ListNode* newHead = head->next;
head->next = swapPairs(newHead->next);
newHead->next = head;
return newHead;
}
};
理解这个递归的关键在于:swapPairs(newHead->next) 处理的是当前两个节点之后的所有节点,处理完之后返回的是一长串交换好的链表的头。当前节点 head 的 next 指向这串处理好的链表,然后让 newHead 的 next 指向 head,这样整条链表就完成了交换。
递归版本的代码长度只有迭代版本的一半,但代价是空间复杂度变成 O(n),因为递归调用要占用栈空间。在刷题场景里,两者都能通过;如果追求极致性能,选迭代;如果面试官问“还有没有其他写法”,再给出递归版本,同时把空间复杂度差异讲清楚。
5. 随机链表的复制:深拷贝的两种路线与映射关系是关键
5.1 为什么不能边复制边定 random
随机链表的复制这题,节点除了 next 指针之外,还有一个 random 指针,可以指向链表中的任意节点或者 nullptr。题目让你拷贝出一份全新的链表,且新链表中每个节点的 random 指向的节点,必须是新链表里对应的节点,而不是原链表里的节点。
我第一次看到这道题,觉得很简单:遍历原链表,每到一个节点,就 new 一个新节点,把值复制过去,然后把 next 连上。等到处理 random 的时候就发现问题了:random 指向的节点,可能还没有被创建出来。比如原链表的第一个节点的 random 指向最后一个节点,但此时最后一个节点还没被遍历到,你拿不到它的拷贝副本,自然也没法给当前拷贝节点的 random 赋值。
这个问题的本质是:random 指针表达的是“原节点之间的相对关系”,而当你逐个复制节点时,这种关系无法在创建拷贝节点的瞬间实时建立。解决办法是先完整创建所有新节点,建立“原节点 -> 新节点”的映射关系,然后再统一设置新节点之间的指针。
5.2 哈希表法:两遍遍历,清晰直白
哈希表法的思路很直接:第一遍遍历原链表,为每个原节点创建一个新节点,同时用一个 unordered_map 记录原节点指针到新节点指针的映射;第二遍遍历原链表,根据映射设置新节点的 next 和 random 指针。
cpp复制class Solution {
public:
Node* copyRandomList(Node* head) {
if (!head) return nullptr;
unordered_map<Node*, Node*> old2new;
Node* cur = head;
while (cur) {
old2new[cur] = new Node(cur->val);
cur = cur->next;
}
cur = head;
while (cur) {
old2new[cur]->next = old2new[cur->next];
old2new[cur]->random = old2new[cur->random];
cur = cur->next;
}
return old2new[head];
}
};
这里有个 C++ 特有的坑值得说一下:old2new[cur->random] 在 cur->random 是 nullptr 时会向 map 里插入一个以 nullptr 为 key 的条目。虽然结果是 nullptr,赋值没有错误,但这个副作用会让 map 里多出一个无用条目。如果继续对这个 map 做其他操作,可能会产生预期之外的行为。更严谨的写法是用 old2new.count(cur->random) ? old2new[cur->random] : nullptr,或者直接 if (cur->random) old2new[cur]->random = old2new[cur->random]; else old2new[cur]->random = nullptr;。
哈希表法的优点是逻辑清晰,两个 while 循环各管一件事,互不干扰,写的时候不用太动脑。缺点是额外 O(n) 的空间。如果你在面试中先写出哈希表法,面试官追问“能不能不用哈希表,把空间降到 O(1)”,那就得用原地拼接法了。
5.3 原地拼接法:三次遍历,空间 O(1)
原地拼接法的核心思想是:在每个原节点后面紧接着插入它的拷贝节点,这样原节点和拷贝节点之间就建立起了一种“邻居关系”。利用这种关系,拷贝节点的 random 就可以通过“原节点 random 的 next”快速找到。
具体分三步:
第一步,在原链表的每个节点后插入一个拷贝节点。遍历原链表,对每个原节点 cur,创建一个新节点 copy,把 copy 插到 cur 和 cur->next 之间,然后 cur 跳到 copy->next(也就是原链表的下一个节点)。
第二步,设置拷贝节点的 random。遍历链表,对每个原节点 cur,如果 cur->random 不为空,那么 cur->next 就是它的拷贝节点,而 cur->random->next 就是拷贝节点 random 应该指向的对象。原因很简单:每个拷贝节点都紧跟在原节点后面,所以 cur->random 的拷贝节点肯定在 cur->random->next 位置上。
第三步,拆开链表。把拷贝节点从原链表中抽出来,连成一条新链表,同时恢复原链表的 next 链接。
cpp复制class Solution {
public:
Node* copyRandomList(Node* head) {
if (!head) return nullptr;
Node* cur = head;
while (cur) {
Node* copy = new Node(cur->val);
copy->next = cur->next;
cur->next = copy;
cur = copy->next;
}
cur = head;
while (cur) {
if (cur->random) {
cur->next->random = cur->random->next;
}
cur = cur->next->next;
}
Node dummy(0);
Node* tail = &dummy;
cur = head;
while (cur) {
Node* copy = cur->next;
cur->next = copy->next;
tail->next = copy;
tail = copy;
cur = cur->next;
}
return dummy.next;
}
};
第三步拆链表时很容易出错,原因在于 cur->next 被改掉之后,原链表的下一个节点就找不到了。所以在修改之前,先用局部变量 copy 保存当前节点的拷贝节点,而 copy->next 此时还指向原链表的下一个节点。然后 cur->next = copy->next 恢复原链表,tail->next = copy 把拷贝节点接到新链表尾部。最后 cur = cur->next 移动当前指针,此时 cur 已经通过 copy->next 指向了原链表的下一个节点,所以不会丢。
原地拼接法第一次写的时候容易懵,尤其是拆链那一步,建议在纸上画一画。但一旦理解了“拷贝节点紧随原节点”这个布局,后面对照代码就好懂多了。
对比一下两种写法:哈希表法适合笔试抢时间,原地拼接法空间更优,但代码复杂度高,面试时如果你能流畅写出来,绝对是个加分项。我个人在刷题时会两种都练,先写哈希表法保证正确,再默写一遍原地拼接法确认理解到位。
6. 五道题串起来:链表基本功和效率心得
6.1 链表题的三件套:哑节点、双指针、画图
把这几道题放在一起看,会发现套路高度集中。哑节点出现在两数相加和两两交换里,用来统一头节点的处理;双指针出现在相交链表和回文链表里,分别用来消除长度差和定位中点;而画图,几乎是所有链表题解题时都绕不开的步骤。
我刷链表题最大的体会是:画图比写代码更花时间,但值得。链表的所有操作本质都是“修改指针的指向”,节点本身不会移动。画图时把每个节点画成方块,把指针画成箭头,每执行一步修改,就把箭头擦掉重画一次,正确的操作顺序自然而然就浮现出来了。等图上箭头都画对了,代码只是把箭头重画的过程翻译成语法而已。
6.2 边界条件清单:每次写完先自查
链表题的错误,绝大多数出在边界条件上。我给自己列了一个检查清单,每次写完链表题都会过一遍:
- 链表为空时,函数是否返回了正确结果?比如回文链表中空链表应该返回 true。
- 链表只有一个节点时,代码是否还能正常运行?比如两两交换中单节点链表应该原样返回。
- 链表长度是偶数时,快慢指针会停在什么位置?奇数时呢?
- 指针重连时,是否先保存了后继节点?比如两两交换里的 second,原地拼接里的 copy。
- 循环结束条件是否覆盖了所有情况?比如两数相加里 carry 是否被纳入循环条件。
- 修改了传入的链表之后,是否需要恢复原链表?
这些问题几乎覆盖了 Hot 100 链表题里所有常见的隐蔽坑。我每次写完提交之前,都会假装自己是测试用例生成器,把空链表、单节点、双节点、长链表、值重复的链表逐个在脑子里跑一遍。这个过程看着费时间,实际执行起来也就两三分钟,但能有效避免无谓的 Submission 报错。
6.3 C++ 链表编程的小习惯
最后分享几个 C++ 写链表题时的习惯,都是我实际刷题过程中总结出来的。
第一,统一用 nullptr 而不是 NULL 或 0。C++11 之后 nullptr 是类型安全的空指针常量,避免重载函数时出现歧义。
第二,多用哑节点,少写 if (head == nullptr) 这种特殊分支。哑节点让代码变得更规整,不容易漏掉头节点变化的情况。
第三,创建节点时,如果需要同时初始化 next,可以写成 ListNode* node = new ListNode(val); node->next = ...; 或者直接 new ListNode(val, next)。LeetCode 的 ListNode 结构体在 C++ 版本里支持两个参数的构造函数,用起来比手动赋值更简洁。
第四,循环里涉及指针移动时,先判断是否为空。比如 while (fast && fast->next) 这种写法比 while (fast->next->next) 更安全,后者在链表只有两个节点时会直接访问空指针,导致运行时错误。
第五,写完所有操作后,如果这道题的解法会修改原链表,在注释里明示“注意:此函数会修改传入链表的结构”。这在 LeetCode 提交时无关紧要,但挪到实际工程代码里,对团队协作至关重要。
链表专题刷到这,最深的感受是:链表题不考奇技淫巧,考的是对指针操作的掌控力和对边界条件的敏感度。这几道题背后的技巧——双指针、哑节点、快慢指针、哈希映射——在后面的二叉树、图论题里也会反复用到。所以不需要追求刷得多,把每一道题的原理和坑都吃透,比盲目刷几十道题有用得多。
