从数组拷贝到原地反转:力扣234「回文链表」的四种解题进化之路

3 阅读10分钟

从数组拷贝到原地反转:力扣234「回文链表」的四种解题进化之路

当“龟兔赛跑”遇上“反转链表”,这道题让你彻底理解如何用O(1)空间判断链表回文。

前言

在刚刚攻克了力扣206「反转链表」之后,我们趁热打铁,来看一道它的“亲兄弟”题目——力扣234. 回文链表(Palindrome Linked List)

这道题在LeetCode上标记为简单(Easy),但千万别被它的难度标签骗了。它在字节跳动、腾讯、美团等国内大厂的出现频率极高,而它的进阶要求直接劝退了无数人:

你能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题?

很多同学都会写“把链表转成数组,然后双指针”,空间复杂度 O(n) 轻松过关。但当面试官幽幽地问一句“如果不让你用额外数组呢?”,很多人就愣在了原地。

今天,我们就从最直观的“数组拷贝法”出发,一路进化到“快慢指针 + 原地反转”的终极解法。这个过程恰好串联了我们之前学过的反转链表(206)双指针技巧,是一道极佳的“综合应用题”。读完这篇文章,你将对链表的“引用操作”产生肌肉记忆般的本能反应。

题目回顾

给你一个单链表的头节点 head ,请你判断该链表是否为回文链表。如果是,返回 true ;否则,返回 false

示例1:

输入:head = [1,2,2,1]
输出:true

示例2:

输入:head = [1,2]
输出:false

进阶: 你能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题?

核心难点:单链表的“单向性”困境

对于数组判断回文,我们有成熟的“左右双指针”模型:左指针从左往右,右指针从右往左,向中间靠拢比较。

但对于单链表,我们只能从 head 开始单向遍历。我们无法直接让一个指针从尾部“倒着走”。因此,这道题要解决的根本矛盾就是——如何在不借助外部数组的情况下,让链表具备“从两端向中间”比较的能力?

这个矛盾催生出了我们今天要讲的几种解法,它们分别从“空间换时间”、“隐式栈(递归)”、“显式反转”三个角度给出了答案。

第一层:辅助数组法 —— 最稳妥的“降维打击”

既然链表不能倒着走,那我把它变成数组总行了吧?数组支持随机访问,左右双指针直接起飞。

核心思想

  1. 遍历链表,将每个节点的值存入 ArrayList 或数组。
  2. 使用双指针 left = 0right = list.size() - 1,向中间靠拢比较。

代码实现

class Solution {
    public boolean isPalindrome(ListNode head) {
        List<Integer> vals = new ArrayList<>();
        ListNode curr = head;
        while (curr != null) {
            vals.add(curr.val);
            curr = curr.next;
        }
        
        int left = 0, right = vals.size() - 1;
        while (left < right) {
            if (!vals.get(left).equals(vals.get(right))) {
                return false;
            }
            left++;
            right--;
        }
        return true;
    }
}
class Solution:
    def isPalindrome(self, head: ListNode) -> bool:
        vals = []
        curr = head
        while curr:
            vals.append(curr.val)
            curr = curr.next
        return vals == vals[::-1]

复杂度分析:

  • 时间复杂度: O(n),遍历两次(一次链表转数组,一次双指针比较)。
  • 空间复杂度: O(n),存储了链表的所有值。

点评: 这是最符合直觉的解法,代码优雅且几乎不会出错。但在面试中,面试官一定会紧接着抛出那个“进阶”问题:能优化空间吗? 所以,这个解法只能作为我们的“保底方案”和“思路起点”。

第二层:栈辅助法(或递归隐式栈) —— “后进先出”的天然逆序

既然数组可以随机访问,那如果我们不允许用数组,还有什么数据结构能帮我们“从后往前”获取数据?

答案是栈(Stack)。栈的“后进先出(LIFO)”特性天生适合处理逆序。

核心思想

  1. 第一遍遍历:把所有节点值压入栈
  2. 第二遍遍历:从 head 开始,依次弹出栈顶元素进行比较。栈顶元素正好对应链表的最后一个节点,依次弹出就是从尾到头。

代码实现

class Solution {
    public boolean isPalindrome(ListNode head) {
        Stack<Integer> stack = new Stack<>();
        ListNode curr = head;
        while (curr != null) {
            stack.push(curr.val);
            curr = curr.next;
        }
        
        curr = head;
        while (curr != null) {
            if (curr.val != stack.pop()) {
                return false;
            }
            curr = curr.next;
        }
        return true;
    }
}

复杂度分析:

  • 时间复杂度: O(n)
  • 空间复杂度: O(n)

拓展:递归法(隐式栈) 递归调用本身就是在利用系统调用栈。我们可以利用递归的回溯特性,实现“一个指针往前走,另一个指针在回溯时往回走”。

class Solution {
    private ListNode frontPointer;
    public boolean isPalindrome(ListNode head) {
        frontPointer = head;
        return recursivelyCheck(head);
    }
    
    private boolean recursivelyCheck(ListNode currentNode) {
        if (currentNode != null) {
            if (!recursivelyCheck(currentNode.next)) return false;
            if (currentNode.val != frontPointer.val) return false;
            frontPointer = frontPointer.next;
        }
        return true;
    }
}

递归法虽然代码优雅,但空间复杂度依然是 O(n)(递归调用栈深度),且容易在长链表上爆栈(Stack Overflow)。面试中提一下作为思路拓展即可,不推荐作为最终答案。

第三层:快慢指针 + 反转后半部分 —— 终极O(1)空间解法

终于到了这道题最精彩的解法!它完美地结合了快慢指针找中点原地反转链表(力扣206),是链表综合能力的最佳试金石。

核心思想

我们不复制数据,而是改变链表结构(当然,面试结束后最好能恢复,以示专业)。具体分三步走:

  1. 找中点:使用“快慢指针”(龟兔赛跑)。慢指针 slow 每次走一步,快指针 fast 每次走两步。当 fast 到达尾部时,slow 恰好到达链表中点。
  2. 反转后半部分:从 slow 开始,反转后半部分链表(直接复用 206 题的迭代逻辑)。
  3. 比较两半:用两个指针 p1 指向 headp2 指向反转后的后半部分头节点,逐个比较值。
  4. (可选)恢复链表:为了不破坏原始数据结构,可以再次反转后半部分并拼接回去。LeetCode 不强制,但面试时可以提一句,这是加分项。

为什么这能解决问题?

  • 前半部分(正序)和后半部分(反转后也是正序,即原链表的逆序)进行比较,本质上等价于“从两端向中间”比较。
  • 整个过程只有几个指针变量,完全没有额外数组。

详细步骤图解

1 -> 2 -> 3 -> 2 -> 1 为例:

  1. 找中点slow 停在 3(偶数长度会停在中间偏右的位置,具体看代码实现)。
  2. 反转后半部分2 -> 1(反转后变成 1 -> 2)。
  3. 比较p1: 1, 2, 3p2: 1, 2 比较,前两位相同,p2 走到 null,判定为回文。

代码实现(带恢复链表结构)

class Solution {
    public boolean isPalindrome(ListNode head) {
        if (head == null || head.next == null) return true;
        
        // 1. 快慢指针找中点
        ListNode slow = head;
        ListNode fast = head;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }
        
        // 如果 fast != null,说明链表长度为奇数,slow 正好在中间节点
        // 此时比较时不需要比较中间节点,所以让 slow 再往后移一位(跳过中间节点)
        if (fast != null) {
            slow = slow.next;
        }
        
        // 2. 反转后半部分链表 (从 slow 开始)
        ListNode secondHalfHead = reverseList(slow);
        
        // 3. 比较前半部分和反转后的后半部分
        ListNode p1 = head;
        ListNode p2 = secondHalfHead;
        boolean isPalindrome = true;
        while (p2 != null) { // 只需要比较到 p2 结束即可
            if (p1.val != p2.val) {
                isPalindrome = false;
                break;
            }
            p1 = p1.next;
            p2 = p2.next;
        }
        
        // 4. (可选) 恢复链表原状:再次反转后半部分并接回去
        reverseList(secondHalfHead);
        
        return isPalindrome;
    }
    
    // 复用 206 题的反转链表代码(迭代法)
    private 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;
    }
}
class Solution:
    def isPalindrome(self, head: ListNode) -> bool:
        if not head or not head.next:
            return True
        
        # 1. 找中点
        slow, fast = head, head
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next
        
        # 如果 fast 不为空,说明奇数长度,跳过中间节点
        if fast:
            slow = slow.next
        
        # 2. 反转后半部分
        prev = None
        curr = slow
        while curr:
            next_temp = curr.next
            curr.next = prev
            prev = curr
            curr = next_temp
        second_half = prev
        
        # 3. 比较
        p1, p2 = head, second_half
        res = True
        while p2:
            if p1.val != p2.val:
                res = False
                break
            p1, p2 = p1.next, p2.next
        
        # 4. (可选) 恢复链表
        # 再次反转 second_half(即 reverseList 逻辑)
        return res

复杂度分析:

  • 时间复杂度: O(n)。找中点 O(n),反转 O(n),比较 O(n)。
  • 空间复杂度: O(1)。只用了若干个指针,完美符合进阶要求!

重点细节:

  • 奇偶数的处理while (fast != null && fast.next != null) 跳出循环时,如果 fast != null,说明链表长度为奇数。此时 slow 正好指向正中央的节点(如 1,2,3,2,1 中的 3),它不需要参与比较,所以 slow = slow.next 跳过它。
  • 为什么只比较到 p2 结束:因为后半部分反转后长度可能比前半部分少一半(奇数长度情况),后半部分遍历完就代表比较结束。

第四层(进阶思考):只反转一半的“部分反转” vs “全部反转”

在第三层中,我们反转的是从中间到结尾的这一半。有些同学会问:“我能把整条链表反转了再比较吗?”

当然可以:你可以复制一个头指针,反转整个原链表,然后和原链表比较。但这样做要么需要 O(n) 空间存储原链表,要么破坏了原结构且无法恢复(因为反转整个链表后,原头节点就变成了尾节点,无法再变回原样)。所以,只反转后半部分是最优雅、最安全的原地做法。

深度总结:四种解法进化图谱

解法时间复杂度空间复杂度核心思想面试推荐度
辅助数组法O(n)O(n)链表转数组,双指针判回文⭐⭐⭐(快速实现)
栈辅助法O(n)O(n)栈的LIFO特性模拟逆序⭐⭐⭐(思路拓展)
递归隐式栈O(n)O(n)利用系统调用栈回溯⭐⭐(代码优雅,易爆栈)
快慢指针 + 原地反转O(n)O(1)分割 + 反转 + 比较⭐⭐⭐⭐⭐(最优解)

从这道题中我们学到了什么?

  1. 链表的“破坏性操作”是一把双刃剑。原地反转是 O(1) 空间的利器,但它会改变链表结构。在面试中,主动提出“我可以恢复原链表”会显得你非常专业和严谨。

  2. 快慢指针的“龟兔赛跑”模板:找中点、找环入口(142)、找倒数第K个节点(19),都是这个模板的变种。记住 fast 走两步,slow 走一步,这能解决 50% 的链表中间值问题。

  3. 问题的转化能力。链表的单向性导致不能“从右往左”,但我们通过“反转后半部分”把“从右往左”转化成了“从左往右(反转后的顺序)”。这种通过变换数据结构来简化问题的能力,是算法进阶的核心标志。

  4. 多道题的组合应用:这道题完美结合了力扣 876(找链表中点) 和力扣 206(反转链表)。如果你能把这三道题一起刷了,你的链表基本功将变得极其扎实。

最后的一些心里话

力扣234是一道非常“刁钻”的题。它用最简单的“回文”概念,包装了链表中最核心的“引用”难点。很多同学学到这里,可能会觉得“为了省这点空间,把代码搞得这么复杂值得吗?”

在真实的大数据场景下(比如流式处理、内存极度受限的嵌入式系统),O(1) 的空间往往意味着可行性,而 O(n) 可能直接导致内存溢出。正因为如此,大厂面试才如此看重这种极致优化的能力。

记住今天的口诀:

龟兔赛跑找中点,反转半截莫犯难。 双头齐步比大小,原地判回文不一般。

如果你能闭着眼睛把“快慢指针 + 反转半段链表”的代码手写出来,那么恭喜你,链表的“引用操作”你已经出师了!


如果你觉得这篇题解帮你彻底搞懂了回文链表,欢迎点赞、收藏、转发!我们链表专题的下一站——环形链表(141/142)见! 🚀