链表最大孪生和怎么解?快慢指针与反转链表实战
看到“链表最大孪生和”这个题我第一反应是这题是不是又在名字上吓唬人。读完题发现它其实非常直白给你一条链表长度固定为偶数第 i 个节点和倒数第 i 个节点是一对“孪生节点”把每一对的节点值相加取整个序列里的最大值。这个题在链表题里属于“看着高级、做起来顺手”的类型特别适合用来练快慢指针和反转链表这两项基本功。我最初的想法是先转成数组直接算毕竟数组下标访问最省心但转念一想链表题不练链表操作总觉得亏了。于是我把两种做法都写了一遍从数组版一路优化到 O(1) 额外空间版这中间有不少值得记录的细节下面完整展开。1. 题目在问什么从样例倒推“孪生”配对规则1.1 配对规则拆解力扣第 2130 题的题干很短核心信息就几条链表长度 n 是偶数第 i 个节点从 0 开始数和倒数第 i 个节点也就是第 n-1-i 个节点互为孪生节点要求所有孪生对的和里的最大值。举个例子链表是5 - 4 - 2 - 1长度 n 4。那么i 0 时第 0 个节点和倒数第 0 个节点配对即 5 和 1和为 6i 1 时第 1 个节点和倒数第 1 个节点配对即 4 和 2和为 6。所以最大孪生和就是 6。再换个例子4 - 2 - 2 - 3配对是 437、224答案是 7。题目还有一个好处因为 n 是偶数所以每个节点都在某一对里不会出现落单的节点。如果 n 是奇数配对规则就需要额外定义但题目直接把这个边界堵死了我们不需要考虑。1.2 临界条件与输入限制我特意去确认了题目的数据范围节点个数在[2, 10^5]之间且一定是偶数每个节点的值在[1, 10^5]之间。这两个信息在实际写代码时非常有用。节点值恒为正数意味着一个简化最大值变量可以放心初始化成0不需要用-inf或者负无穷。如果题目改成节点值可能为负那初始化就必须用float(-inf)或者干脆用第一对的和来初始化否则所有配对和都是负数时答案会错误地留在0。这一点在刷题时容易忽略但面试里偶尔会被追问。长度上限是10^5其实不算特别大所以第一版用数组解法的空间开销完全能接受LeetCode 上也能直接通过。真正需要抠空间的场景是面试官追问“能不能不用额外数组”这就自然引出了后面的优化思路。1.3 为什么这道题容易翻车这个题看起来简单翻车点其实不少很多人拿到题第一反应是“转成数组再算”这没错但代码里容易把n // 2的范围写错多算一对或者少算一对用快慢指针找中点时循环条件写错会导致 slow 停在不该停的位置反转后半段链表时指针顺序搞乱链表直接断掉最后并行遍历时循环终止条件选错空指针异常直接送出去。这些坑我在后面每一节里都会结合代码详细说。先把最简单的跑通版本写出来。2. 第一版解转数组求和先跑通再说2.1 数组解法的核心逻辑转数组的思路非常朴素链表没法按下标随机访问那我就把节点值全部复制到一个数组里数组按下标访问是 O(1) 的配对关系瞬间清晰。具体步骤只有三步第一遍历链表把值存进数组第二用左右双指针从数组两端往中间走第三每次计算vals[i] vals[n-1-i]用最大值变量记录结果。这一步本质上把链表问题转化成了数组问题属于“用空间换时间”的典型操作。很多链表题都可以这么干比如回文链表的判断转成数组后用双指针做也是可行的。它的优势是直观、不易写错适合在笔试场景下快速提交一个正确答案。2.2 代码实现与注意点def pairSum(head): vals [] cur head while cur: vals.append(cur.val) cur cur.next n len(vals) ans 0 for i in range(n // 2): ans max(ans, vals[i] vals[n - 1 - i]) return ans这个版本我实测能直接通过所有测试用例。注意点有三个第一vals列表在 Python 里存的是节点值不是节点本身所以我们不需要关心链表结构在遍历过程中有没有被修改。第二循环范围是range(n // 2)因为每次配对访问的是i和n-1-i两个位置循环跑一半正好覆盖所有配对。如果写成range(n)会重复计算并有可能数组越界。第三ans初始化为0的前提是节点值全为正前文已经确认过题目范围所以这里没问题。如果面试官让你写一个通用版本我会建议用ans float(-inf)或先把第一对赋值进去这样更稳妥。2.3 数组解法的局限数组解法的时间复杂度是 O(n)空间复杂度是 O(n)。它在本题的限制下完全够用但问题在于如果链表特别长比如百万级甚至更多额外数组会带来可观的存储浪费。面试官如果问“能不能在 O(1) 额外空间内完成”这个版本就答不上来了。我当初刷这道题的时候第一个版本就是数组解跑通之后对着题目标签里的“双指针”“链表”发了会儿呆——题目想考察的明显不是数组。所以我决定重新想一个不依赖额外容器的方案核心思路是既然要配对的是第 i 个节点和倒数第 i 个节点那能不能直接让两个指针从链表两端往中间走链表是单向的从尾部往前不可能但我们可以把后半段反转人为制造一个“从尾部往回走”的通道。3. 快慢指针找中点空间压缩的第一步3.1 快慢指针的工作原理要在 O(1) 额外空间内解决问题第一步是找到链表的中点然后把后半段拆出来处理。找中点最标准的做法就是快慢指针慢指针每次走一步快指针每次走两步等快指针走到链表末尾时慢指针刚好走到中间位置。这里有一个细节因为 n 是偶数链表有两个“中间节点”。比如1 - 2 - 3 - 4 - 5 - 6中间位置可以是 3 和 4 之间。我们的目标是让 slow 停在后半段的起点也就是节点 4 的位置这样后面反转后半段时才不会多带一个节点。用快慢指针实现这个效果最常用的写法是slow fast head while fast and fast.next: slow slow.next fast fast.next.next手动推演一下链表长度 n6初始时 slow 和 fast 都在节点 1。第一轮slow 走到 2fast 走到 3fast 原来在 1走两步到 3第二轮slow 走到 3fast 走到 5第三轮slow 走到 4fast 走到 7也就是 None循环条件fast为假停止。此时 slow 正好停在节点 4也就是索引 n/2 3 的位置完美。如果写成while fast and fast.next.next则 fast 在第三轮时fast.next是节点 6 不是 None但fast.next.next是 None条件为假停止此时 slow 只走到 3也就是左半段的末尾。这个位置并不是完全不能用但后面处理时需要调整反转范围容易出边界问题。所以在这个题里我用的是第一种写法让 slow 直接指向右半段起点最省心。3.2 快慢指针的边界细节快慢指针的循环条件可以排成一个优先级表供参考条件写法slow 停的位置n 为偶数适用场景while fast and fast.next:右半段起点索引 n/2本题目标准写法while fast.next and fast.next.next:左半段末尾索引 n/2 - 1回文链表判断常用while fast and fast.next and fast.next.next:取决于链表长度通用但冗余如果你在写回文链表题第 234 题时会把 slow 停在左半段末尾然后反转 slow.next 之后的链表最后同时从头和反转后的头开始比较。而本题因为要配对的正好是 i 和 n-1-i让 slow 停在右半段起点更直观后面直接反转这一段再和左半段同步遍历就行。实测中还有一个隐患如果链表长度很大while 循环里 fast 走两步的前提是 fast 不为 None 且 fast.next 不为 None。上面的条件已经保证了这两点不会出现对 None 取.next的报错。但如果你手滑写成while fast.next:遇到偶数长度链表时fast 走到倒数第二个节点后下一轮会对 None 取.next直接抛异常。3.3 找到中点后链表在哪里切开slow 停在右半段起点之后我们面临一个选择把链表从中间“断开”还是“不断开”这个题目比较特殊我们最后只需要返回一个整型最大值不需要输出处理后的链表所以不断开也可以。但是在反转后半段时如果不切断后半段头节点和左半段尾节点的连接反转操作会破坏左半段的链表结构导致后续从左往右遍历左半段时提前走进已经被反转的区域结果全乱。我在实际写的时候选择在反转前手动切断连接具体做法是在找到 slow 之后记录second slow然后找一个prev指针迭代反转以second开头的链表。反转完成之后second变成了后半段反转后的尾节点此时原本指向second的前半段尾节点还在反转操作本身已经让它指向了 None。换句话说反转操作天然会切断原链表不需要额外做“断开”动作这一点我会在第 4 节详述。4. 反转后半段裸写反转最容易串指针4.1 迭代反转的三个指针反转单链表是链表操作里的基石这里必须手写得非常熟练。标准的三指针迭代写法如下def reverse_list(head): prev None cur head while cur: nxt cur.next cur.next prev prev cur cur nxt return prev三个指针分别是prev表示已反转部分的新头cur表示当前待处理节点nxt临时保存cur.next防止修改指针后丢失后续链表。这个过程可以拿生活类比就像把一摞盘子一个个翻面你每次只能拿起最上面一个翻好后放到另一摞上在拿起下一个之前必须看清它原来的位置在哪否则一摞盘子就会散架。nxt就是这个“看清原来位置”的动作。4.2 反转过程中遇到的典型 bug我刷这道题时第一次写反转犯了一个非常低级的错误先执行了cur.next prev然后才写nxt cur.next结果nxt拿到的是prev链表直接原地断掉后半段只剩一个节点。当时的报错是空指针异常因为接着循环cur nxt时cur 变成了 None看起来像是“链表只有一个节点”。这类 bug 的排查方式其实很简单反转前先打印一次链表所有节点值反转后打印一次对照一下长度和顺序。如果反转后节点数明显变少几乎可以断定指针保存顺序出了问题。正确顺序永远是先保存后继再改指针。写成口诀就是“先记后路再拆桥”在面试中把这个顺序说清楚对方会觉得你基本功扎实。4.3 切断连接与否的差异回到整体流程。假设原始链表是head - 1 - 2 - 3 - 4 - 5 - 6 - None快慢指针跑完后slow 停在 4。此时我把second slow作为后半段的头然后对它执行反转。反转过程中4 的next原本是 5反转后变成 None因为 4 变成了后半段的尾节点同时3 的next依然指向 4。这一来链表整体看就是1 - 2 - 3 - 4 - None 5 - 4 这种情况不会发生因为 5 的 next 变成了 4实际上反转后是左半段1 - 2 - 3 - 4 - None这里 4 还挂在左半段末尾右半段反转后6 - 5 - 4 - None注意这里 4 同时出现在两条“链”里听起来很不对劲但真实指针结构里 4 只有一份它的 next 已经被改成 None左半段的 3 指向它右半段的 5 也指向它形成了两个链表共享同一个尾节点的情况。这会不会造成问题并行遍历时我们用right reverse_head也就是 6 - 5 - 4用left head也就是 1 - 2 - 3 - 4。当 left 走到 3 时right 走到 4Pair (3, 4)此时 left.next 指向 4right.next 是 None循环结束。整个过程没有无限循环也没有重复访问同一个节点两次因为 left 和 right 的有效长度都是 3。但这里有一个隐患如果反转前不切断左半段和右半段之间的联系比如反转函数只处理 slow 之后的节点而 slow 的 next 在反转完成后指向了反转后的新头那么就完全乱套了。好在我用的三指针反转天然把slow变成了右半段反转链表的尾节点且slow.next None所以不会发生这种情况。如果是用递归方式反转返回的是新头但旧头的 next 可能还残留旧链接需要手动处理这里我不推荐用递归因为在 10^5 节点长度下递归深度可能触发栈溢出。5. 并行遍历求最大和终止条件与初始值5.1 同时推进两个链表的循环设计反转完成后我们得到两条链表left head是原始链表的前半段顺方向right second_reversed是原始链表后半段反转后的新头。孪生配对正好是一一对应的left 的第一个节点和 right 的最后一个节点配对left 的第二个节点和 right 的倒数第二个节点配对依此类推。因为 right 已经反转所以只需要从两条链表的头节点开始同步向后走每一步计算left.val right.val更新最大值。循环终止条件两种写法都可以写法 A最常用while right: ans max(ans, left.val right.val) left left.next right right.next写法 B用 left 作为终止条件while left: ans max(ans, left.val right.val) left left.next right right.next注意在这个具体场景里左右两半的长度是相等的所以无论用哪个都不会越界。但写法 B 有一个小坑如果反转出现了 bug导致 right 比 left 短right right.next会对 None 取.next直接崩溃。写法 A 以右半段是否走完为准能相对安全地暴露问题。我习惯用while right原因只有一个右半段长度代表有效配对数量走到头说明算完了。5.2 最大值初始化的取舍前面已经说过本题节点值在[1, 10^5]所以ans 0足够。但我在第一次写的时候其实用了ans -1后来看题解发现大家普遍用0也没毛病。如果想把代码写得更有通用性可以这样处理ans left.val right.val # 第一对先赋值 while right.next: left left.next right right.next ans max(ans, left.val right.val)这样哪怕节点值全是负数答案也不会错误地变成 0。刷题时这种细节未必每次都被测出来但面试官扩展提问时能答上就很加分。5.3 完整代码与逐步演示最终 Python 版本如下def pairSum(head): # 1. 快慢指针找中点slow 指向右半段起点 slow fast head while fast and fast.next: slow slow.next fast fast.next.next # 2. 反转右半段 prev None cur slow while cur: nxt cur.next cur.next prev prev cur cur nxt rev_head prev # 3. 并行遍历求最大和 left, right head, rev_head ans 0 while right: ans max(ans, left.val right.val) left left.next right right.next return ans我用[1, 6, 2, 5, 3, 4]这个例子手动走一遍快慢指针结束后 slow 指向索引 3即节点 5反转从 5 开始的部分得到4 - 3 - 5注意原始右半段是5 - 3 - 4反转后变成4 - 3 - 5left 从 1 开始right 从 4 开始配对145639257最大值 9。手算验证原始链表的孪生对是 (1,4)、(6,3)、(2,5)和分别是 5、9、7最大确实是 9。6. 边界用例与复杂度对比6.1 从 2 节点到 10 万节点的用例验证边界测试是写链表题的基本素养至少想清楚这几类数据用例链表预期结果验证点最小长度[5, 4]9只有一对时循环是否正常结束两个重复值[3, 3]6偶数长度最小场景对称结构[1, 2, 2, 1]3配对和相同的情况递增序列[1, 2, 3, 4]5最大和出现在两端最大数值[100000, 100000]200000数值上限不溢出最小长度[5, 4]时快慢指针一轮就停止slow 指向 4反转后 right 指向 4循环只跑一次返回 549。这里最容易出的 bug 是快慢指针写错导致 slow 落在第一个节点然后反转长度为 2 的链表反而把左半段也反转进去最后算出来的数字完全不对。长度达到 10^5 时Python 的迭代反转不会栈溢出时间上也很快实测大约几十毫秒量级。如果用递归反转在同样规模下很容易触达默认递归深度上限直接 RuntimeError。6.2 时间空间复杂度对比两种解法放在一起看方案时间复杂度额外空间复杂度是否修改原链表实现难度数组法O(n)O(n)否低快慢指针 反转O(n)O(1)是右半段被反转中时间上两者都是线性差距在于空间。数组法多出最多 10^5 个整数的存储以 Python 的 int 对象来看内存开销很可观反转法只用了几个指针变量与链表长度无关。所以面试时如果候选者先给出数组解然后主动提出“可以用快慢指针 反转优化到 O(1) 空间”这本身就是一个完整的解题叙事面试官一般会比较认可。有一点要主动说明反转法修改了原链表结构。如果题目要求函数执行后链表保持原样这题只返回整数所以没问题但同类题如果要求返回处理后的链表就必须在最后把右半段再反转回来。这个细节我在写“重排链表”题时经常用到放到做这道题上属于提前打了基础。6.3 与回文链表、重排链表的关联我把这道题和另外两个链表高频题归为一类它们的代码骨架高度相似回文链表第 234 题快慢指针找中点反转右半段同步比较值是否相等重排链表第 143 题快慢指针找中点反转右半段然后交替合并两个链表本题快慢指针找中点反转右半段同步求和取最大。三个题共享“找中点 反转 双链遍历”三段式模版区别只在于最后一步的操作。我建议把这三题放在一起刷每道题都用自己的话把三段式流程讲清楚之后遇到类似的“对链表前后对应位置做操作”的题目基本都能快速定位套路。有人会觉得每道题都转成数组更省事但链表操作练熟了之后这套三段式写起来其实比数组转来转去更快而且空间上明显更优。更关键的是面试中面试官常常会顺着“你还能优化空间吗”往下追问提前掌握这套方案能多一层准备。7. 刷完这道题我的一些体会这道题本身难度不高但我每次回看都觉得它是一道特别适合“练基本功”的题。它不像很多中等题那样需要复杂的算法思想核心考点就是三个快慢指针的边界处理、单链表反转的指针顺序、双链同步遍历的终止条件。这三样东西几乎是所有链表题的共同地基单独拎出来练会显得枯燥放进这道题里反而有了明确目标——你要凑出最大孪生和就必须把每一步都做对。我个人刷题的习惯是一题至少写两版。第一版用最直观的思路跑通第二版再尝试优化。数组版和反转版写完之后我还会故意给自己出几个变体比如“如果链表长度可以随意呢”“如果要求不修改原链表呢”把代码相应地调整一下。这样做的好处是考试或面试时碰到稍微改头换面的题目脑子里不会只有一套死答案。最后分享一个小技巧写链表题不要急着一次性从头写到尾。先画个小例子比如 6 个节点的链表把每个指针的指向变化手动画一遍再落笔写代码。尤其是反转链表那一步纸上走一遍比空想十遍都管用。我第一次裸写反转的时候就在指针顺序上栽了跟头后来每次动手前先画指针图这类低级错误基本就绝迹了。这道“链表最大孪生和”值得你花十分钟把两个版本都写一遍写完你会发现链表题的套路感其实比想象中重得多。