目录

题目描述

707. 设计链表

题意分析

要从零实现一个链表类,对外暴露五个操作:按下标取值、头部插入、尾部插入、指定下标插入、指定下标删除。下标从 0 开始计数。

真正决定实现难度的是各个操作对非法下标的约定,题面写得很细而且并不统一:get 遇到越界要返回 -1 而不是报错;addAtIndex 允许 index 恰好等于当前长度,此时等价于追加到末尾,只有严格大于长度时才什么都不做;deleteAtIndex 遇到越界则静默忽略。这三种约定各不相同,是本题最主要的失分点。

约束方面调用次数只有两千级,说明单次操作走 $O(n)$ 的遍历完全够用,题目考的不是复杂度,而是能不能把指针操作和边界写干净。另外要注意插入位置合法的下标范围是 [0, size],而取值与删除位置合法的下标范围是 [0, size - 1],两者差一。

解法:哨兵节点 + 单链表

核心思路

最朴素的写法是直接用一个 head 指针表示链表。这样每个写操作都要分裂成两套逻辑:往头部插入要改 head 本身,往中间插入要改前驱节点的 next;删除第 0 个节点要改 head,删除其他节点要改前驱的 next。五个方法乘以两套分支,写出来又长又容易漏。

瓶颈不在时间,而在于「头部」这个位置没有前驱节点,导致它必须被特殊对待。

观察到只要在真正的首节点前面额外挂一个不存放业务数据的节点,这个特殊性就消失了:此时任何一个合法位置都存在前驱,头部插入退化成「在这个额外节点后面插入」,删除第 0 个节点退化成「改这个额外节点的 next」。分支从两套合并成一套。

整个类由两条不变量支撑。其一,dummy 这个节点永远存在、永远不被删除、它的值永远不参与任何答案,链表的真实首节点始终是 dummy.next。其二,size 恒等于 dummy 之后真实节点的个数,任何一次成功的插入让它加一、任何一次成功的删除让它减一。有了这两条,对任意满足 $0 \le index \le size$ 的下标,从 dummy 出发走 index 步一定能落在「第 index 个节点的前驱」上,五个方法都可以复用这一段定位代码。

解题步骤

  • 构造函数里建出 dummy 节点并把 size 置 0。dummy 的值取什么都无所谓,因为不变量保证它不会被读到。
  • get:先用 index < 0 || index >= size 挡掉越界并返回 -1,再从 dummy.next 出发走 index 步。这里从 dummy.next 起步而不是从 dummy 起步,是因为要拿的是节点本身而不是它的前驱。
  • addAtIndex:先挡掉 index > size 的非法调用,注意是严格大于,因为等于 size 表示合法的尾部追加;index 为负则按题面语义夹到 0。然后从 dummy 出发走 index 步拿到前驱,再做插入。
  • 插入的两行赋值必须先写 node.next = prev.next 再写 prev.next = node。顺序反了的话 prev.next 已经被改成新节点,新节点的 next 就会指向自己,链表当场成环。
  • addAtHead 与 addAtTail 直接转调 addAtIndex,参数分别是 0 和 size。复用而不是重写,是为了让边界判断只存在一份。
  • deleteAtIndex:越界判断用 index >= size 而不是 index > size,因为可删除的最大下标是 size - 1;拿到前驱后一行 prev.next = prev.next.next 完成摘除,最后 size 减一。

以官方样例的调用序列走一遍:初始时链表为空,size = 0,只有 dummy。addAtHead(1) 转调 addAtIndex(0, 1),0 > 0 不成立,前驱走 0 步就是 dummy,插入后链表为 1,size = 1。addAtTail(3) 转调 addAtIndex(1, 3),1 > 1 不成立,前驱从 dummy 走 1 步到达值为 1 的节点,插入后链表为 1 -> 3,size = 2。addAtIndex(1, 2),1 > 2 不成立,前驱仍是值为 1 的节点,新节点的 next 先接上原来的 3,再让 1 指向 2,链表变成 1 -> 2 -> 3,size = 3。get(1),下标合法,从 dummy.next 也就是值 1 的节点出发走 1 步到达值 2 的节点,返回 2。deleteAtIndex(1),下标合法,前驱从 dummy 走 1 步到值 1 的节点,把它的 next 从 2 改成 3,链表变成 1 -> 3,size = 2。最后 get(1),从值 1 的节点走 1 步到值 3 的节点,返回 3。整段输出依次是 2 和 3,与预期一致。

代码实现

// get、addAtIndex、deleteAtIndex 均先定位到前驱节点再操作。
class MyLinkedList {
    private final ListNode dummy;
    private int size;

    public MyLinkedList() {
        dummy = new ListNode(0);
        size = 0;
    }

    public int get(int index) {
        if (index < 0 || index >= size) {
            return -1;
        }

        ListNode cur = dummy.next;
        for (int i = 0; i < index; i++) {
            cur = cur.next;
        }

        return cur.val;
    }

    public void addAtHead(int val) {
        addAtIndex(0, val);
    }

    public void addAtTail(int val) {
        addAtIndex(size, val);
    }

    public void addAtIndex(int index, int val) {
        if (index > size) {
            return;
        }

        if (index < 0) {
            index = 0;
        }

        ListNode prev = dummy;
        for (int i = 0; i < index; i++) {
            prev = prev.next;
        }

        ListNode node = new ListNode(val);
        node.next = prev.next;
        prev.next = node;
        size++;
    }

    public void deleteAtIndex(int index) {
        if (index < 0 || index >= size) {
            return;
        }

        ListNode prev = dummy;
        for (int i = 0; i < index; i++) {
            prev = prev.next;
        }

        prev.next = prev.next.next;
        size--;
    }

    private static class ListNode {
        int val;
        ListNode next;

        ListNode(int val) {
            this.val = val;
        }
    }
}
// get、addAtIndex、deleteAtIndex 均先定位到前驱节点再操作。
type MyLinkedList struct {
    dummy *ListNode
    size  int
}

type ListNode struct {
    Val  int
    Next *ListNode
}

func Constructor() MyLinkedList {
    return MyLinkedList{dummy: &ListNode{Val: 0}, size: 0}
}

func (l *MyLinkedList) Get(index int) int {
    if index < 0 || index >= l.size {
        return -1
    }

    cur := l.dummy.Next
    for i := 0; i < index; i++ {
        cur = cur.Next
    }

    return cur.Val
}

func (l *MyLinkedList) AddAtHead(val int) {
    l.AddAtIndex(0, val)
}

func (l *MyLinkedList) AddAtTail(val int) {
    l.AddAtIndex(l.size, val)
}

func (l *MyLinkedList) AddAtIndex(index int, val int) {
    if index > l.size {
        return
    }

    if index < 0 {
        index = 0
    }

    prev := l.dummy
    for i := 0; i < index; i++ {
        prev = prev.Next
    }

    node := &ListNode{Val: val, Next: prev.Next}
    prev.Next = node
    l.size++
}

func (l *MyLinkedList) DeleteAtIndex(index int) {
    if index < 0 || index >= l.size {
        return
    }

    prev := l.dummy
    for i := 0; i < index; i++ {
        prev = prev.Next
    }

    prev.Next = prev.Next.Next
    l.size--
}

复杂度分析

  • 时间复杂度:单次操作 $O(n)$,其中 n 是链表当前长度。get、addAtIndex、deleteAtIndex 都要从 dummy 走到目标前驱,最坏走满整条链;addAtHead 走 0 步是 $O(1)$,addAtTail 因为是单链表且没有维护尾指针,仍然是 $O(n)$。
  • 空间复杂度:$O(n)$,除 dummy 与 size 这两个常数级字段外,只为每个已插入的元素各留一个节点。

关键点总结

  • 哨兵节点的价值不是省一行代码,而是消除「头部没有前驱」这个唯一的特例,让所有位置在结构上变得同质;凡是链表题里出现「可能改到头节点」的字样,都该条件反射加 dummy。
  • 把 addAtHead 和 addAtTail 实现成对 addAtIndex 的转调,保证边界判断只有一份,改一处就全对;设计类题目里这种收敛入口的做法比性能优化更值钱。
  • 显式维护 size 让三个方法的越界判断都变成 $O(1)$ 的比较,否则每次都要先数一遍链表长度;用一个冗余字段换掉重复遍历,是设计题的常见取舍。
  • 插入时先接后继再改前驱,这个赋值顺序是硬性的,反过来就自环;同理删除时要先把后继取出来再改指针。
  • 面试视角:这题几乎必定被追问「怎么让 addAtTail 变成 $O(1)$」,标准答案是再维护一个 tail 指针,但必须同时说清它带来的负担——删除尾节点时单链表拿不到新的尾节点前驱,所以要么改成双向链表,要么接受删除仍是 $O(n)$。能主动谈这个代价,比直接说「加个 tail 就行」高一档。
  • 面试视角:写之前先把五个方法各自的越界约定复述一遍再动手,面试官会认为你在读题而不是背模板;这题的失分几乎全部来自边界,而不是指针操作本身。

易错点总结

  • 错误写法:addAtIndex 的非法判断写成 index >= size 就返回:新建对象后调用 addAtTail(1),此时 index 与 size 都是 0 → 追加被拒绝,链表永远是空的,后续所有 get 都返回 -1。
  • 错误写法:deleteAtIndex 的越界判断写成 index > size:链表为 1,调用 deleteAtIndex(1) → 前驱走到值 1 的节点,prev.next 已经是 null,执行 prev.next.next 直接空指针异常。
  • 错误写法:定位前驱的循环写成 for (int i = 0; i <= index; i++):链表为 1 -> 3 时调用 addAtIndex(1, 2) → 前驱多走一步落到值 3 的节点,结果变成 1 -> 3 -> 2,插入位置整体后移一格。
  • 错误写法:插入时先写 prev.next = node 再写 node.next = prev.next:任意一次插入 → 新节点的 next 指向它自己,链表成环,之后任何一次遍历都会死循环。
  • 错误写法:get 里从 dummy 而不是 dummy.next 起步:addAtHead(1) 之后调用 get(0) → 走 0 步停在 dummy 上,返回哨兵的占位值 0,而不是 1。
  • 错误写法:addAtHead 直接写成 dummy.next = new ListNode(val):连续调用 addAtHead(1)、addAtHead(2) → 新节点没有接住原来的链,链表只剩 2,前面插入的数据全部丢失。
  • 错误写法:插入或删除成功后忘记同步 size:连续两次 addAtTail 之后 → addAtTail 仍按 size 为 0 定位,新元素被反复插到头部,同时 get 的越界判断也会把合法下标判为非法。
  • 错误写法:get 越界时抛异常或返回 0:链表为 1 -> 3 时调用 get(2) → 题目要求返回 -1,返回 0 会被判成错误答案,抛异常则直接运行时错误。
  • 错误写法:把 addAtIndex 中 index 为负的情况和 index > size 合并成一个「非法就返回」的分支:调用 addAtIndex(-1, 5) → 按题面语义应当插到头部,直接返回会丢掉这次插入。

相似题目

题目 难度 考察点
146. LRU 缓存 中等 双向链表配合哈希表,要求所有操作都是 $O(1)$
155. 最小栈 中等 用辅助栈维护历史最小值,考的是冗余状态的同步
232. 用栈实现队列 简单 双栈倒腾实现顺序反转,考摊还复杂度分析
622. 设计循环队列 中等 定长数组加取模下标,难点是区分队空与队满
705. 设计哈希集合 简单 手写散列与拉链法冲突处理
706. 设计哈希映射 简单 在 705 基础上额外维护键值对的更新语义
1206. 设计跳表 困难 多层链表加随机层高,把查找降到期望 $O(\log n)$