Q707 设计链表

题目:不使用内置链表,实现 get、头插、尾插、指定位置插入和指定位置删除。下标从 0 开始,非法查询返回 -1。

使用一个虚拟头节点 dummyHead_ 和一个记录真实节点数量的 size_。虚拟头节点永远存在,但不存题目数据;真正的第一个节点是 dummyHead_->next。

插入和删除都先找到目标位置的前驱节点,再修改 next:

1
2
插入:prev -> next      变成      prev -> newNode -> next
删除:prev -> target -> next     变成      prev -------> next

size_ 只负责判断下标是否合法,不能让链表像数组一样随机访问。访问第 index 个节点仍然需要从头沿着 next 走过去。

操作时间复杂度
get(index)$O(index)$,最坏 $O(n)$
addAtHead$O(1)$
addAtTail$O(n)$
addAtIndex$O(index)$,最坏 $O(n)$
deleteAtIndex$O(index)$,最坏 $O(n)$

总空间复杂度为 $O(n)$。

我的思考顺序是:

  1. 先构造只包含 val 和 next 的单链表节点。
  2. 写 get(index) 时意识到需要判断越界,于是引入 numbers 记录节点数量。
  3. 看到 index 后联想到 operator[],又尝试设计一层 NodeList。
  4. 为了实现按下标查找,一度考虑 vector 和 unordered_map。
  5. 随后发现:用现成容器保存节点,实际上绕开了"自己实现链表"的训练目标。
  6. 回到虚拟头节点,但不知道它应该放在哪里、怎样与真实节点连接。

当时的核心尝试大致是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct st_node {
    int st_val{};
    st_node* st_next{};
};

class NodeList {
    int numbers{};
    vector<st_node> node_list_;
    unordered_map<int, st_node> select_table;
};

这段尝试的价值 方向虽然绕远了,但成功暴露出一个关键区别:下标是接口提供的查询条件,不代表底层必须支持数组式随机访问。

这里其实只需要两层结构:

1
2
3
4
5
6
7
MyLinkedList
├── dummyHead_:虚拟头节点
└── size_:真实节点数量

Node
├── val
└── next

不需要再增加 NodeList,也不需要重载 operator[]。

另外,构造函数里单独写:

1
st_node();

只会创建一个立即销毁的临时对象,并不会初始化链表成员。虚拟头节点必须成为 MyLinkedList 的成员:

1
MyLinkedList() : dummyHead_(new Node()), size_(0) {}

没有虚拟头节点时,头部插入和删除通常要单独修改 head:

1
head -> A -> B

加入虚拟头节点后:

1
dummy -> A -> B -> nullptr

此时 A 也有统一的前驱 dummy。所以在下标 0 处插入、删除,与中间位置的操作完全一样,不需要单独处理真实头节点。

记住这一句 查询节点时找"目标节点";插入和删除时找"目标位置的前驱节点"。

假设要在 index = 2 前插入:

1
2
3
dummy -> 0 -> 1 -> 2
                 ↑
               prev

从 dummy 出发移动 index 次,正好到达原下标 1 的节点,也就是插入位置的前驱。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
class MyLinkedList {
private:
    struct Node {
        int val;
        Node* next;

        Node(int value = 0, Node* nextNode = nullptr)
            : val(value), next(nextNode) {}
    };

    Node* dummyHead_;  // 永远存在,不计入 size_
    int size_;         // 真实数据节点的数量

public:
    MyLinkedList()
        : dummyHead_(new Node()), size_(0) {}

    ~MyLinkedList() {
        Node* cur = dummyHead_;
        while (cur != nullptr) {
            Node* next = cur->next;
            delete cur;
            cur = next;
        }
    }

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

        Node* cur = dummyHead_->next;
        for (int i = 0; i < index; ++i) {
            cur = cur->next;
        }
        return cur->val;
    }

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

    void addAtTail(int val) {
        addAtIndex(size_, val);
    }

    void addAtIndex(int index, int val) {
        if (index < 0) {
            index = 0;
        }
        if (index > size_) {
            return;
        }

        Node* prev = dummyHead_;
        for (int i = 0; i < index; ++i) {
            prev = prev->next;
        }

        prev->next = new Node(val, prev->next);
        ++size_;
    }

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

        Node* prev = dummyHead_;
        for (int i = 0; i < index; ++i) {
            prev = prev->next;
        }

        Node* target = prev->next;
        prev->next = target->next;
        delete target;
        --size_;
    }
};
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
初始:dummy

addAtHead(1)
dummy -> 1

addAtTail(3)
dummy -> 1 -> 3

addAtIndex(1, 2)
dummy -> 1 -> 2 -> 3

deleteAtIndex(1)
dummy -> 1 -> 3

每次成功插入后 size_ 加一,每次成功删除后 size_ 减一。

  1. dummyHead_ 永远存在,而且不计入 size_。
  2. size_ 必须等于真实数据节点的数量。
  3. 最后一个真实节点的 next 必须是 nullptr。

边界区别 addAtIndex 允许 index == size_,因为这代表尾插;但 get 和 deleteAtIndex 要求 index < size_。

  • 插入、删除成功后才修改 size_。
  • 删除前先保存 target,重新连接后再 delete target。
  • 虚拟头节点不是下标 0;真实下标 0 是 dummyHead_->next。
  • vector 和哈希表可以加速按下标访问,但那已经改变了题目要求的数据结构实现方式。

如果额外维护一个尾指针,可以把 addAtTail 优化为 $O(1)$。但删除最后一个节点时也必须正确更新尾指针,维护成本更高。当前版本先把"虚拟头节点 + 前驱定位"掌握牢更重要。

  • 能解释为什么 size_ 不能实现随机访问
  • 能画出虚拟头节点与真实头节点的关系
  • 不看答案写出指定位置插入
  • 说清楚插入和删除的合法下标范围为什么不同

相关内容