---
marp: true
theme: kurzus
paginate: true
footer: 数据结构 · 第 10 周 · 带哨兵的链表、双向链表、循环链表
---

<!-- _class: cim -->
<!-- _paginate: false -->
<!-- _footer: "" -->

# 数据结构

## 第 10 周 – 带哨兵的链表、双向链表、循环链表

Koczka Ferenc 博士 · 埃斯特哈齐·卡罗伊大学 · 2026

---

## 复习

- <strong>单链表：</strong>Head → … → Nil；顺序访问
- 在表头和已知结点<strong>之后</strong>：O(1)；在表尾和查找：O(n)
- <strong>数组中的哨兵查找</strong>（第 2 周）：把要找的值放在末尾 → 一个条件就够了

### 单链表的问题

- <strong>特殊情况</strong>很多：空链表、第一个结点、最后一个结点
- 查找循环有<strong>两个条件</strong>：`P ≠ Nil and Elem[P].Value ≠ x`
- 不能在某个结点<strong>之前</strong>插入，也不能删除<strong>结点本身</strong>（只能删除它后面的结点）

<!--
时间安排：带哨兵的链表 45' · 双向链表 25' · 循环链表 10' · 比较、小结 10'
旧试卷中有几道关于带哨兵链表的题目（存储空间、查找的伪代码）。
-->

---

<!-- _class: fejezet -->

# 带哨兵的链表

---

## 带哨兵链表的结构

![w:1000](kepek/h10_strazsas.svg)

- 链表末尾有一个<strong>哨兵</strong>（守卫）结点，它不存放真正的数据
- <strong>Head：</strong>指向第一个结点，<strong>End：</strong>指向哨兵
- 空链表：<strong>Head = End</strong> – 但即使这时也有<strong>一个结点</strong>（哨兵）占用空间！

---

## 带哨兵链表的操作

| 操作 | 思路 | 步数 |
|---|---|---|
| <strong>创建</strong> | 分配一个哨兵，Head ← End ← 哨兵 | O(1) |
| <strong>处理</strong> | 从 Head 到 End（而不是到 Nil!） | O(n) |
| <strong>查找</strong> | 把要找的值写入哨兵 | O(n) |
| <strong>在前面插入</strong> | 复制技巧 | O(1) |
| <strong>删除结点</strong> | 复制技巧 | O(1) |
| <strong>清空</strong> | Head ← End（结点放回空闲链表） | O(1) – O(n) |

| 性质 | 同构 · 动态 · 链式 · <strong>顺序</strong>访问 |
|---|---|

---

<!-- _class: kicsi -->

## 创建与处理

<div class="ket-hasab">
<div>

<span class="cimke pszeudo">创建</span>

```text
Procedure Create(Head, End)
  If Reserve(P) then
    Head ← P
    End ← P
  else
    ERROR("no free memory")
  End if
End procedure
```

<span class="cimke pszeudo">处理</span>

```text
Procedure Process(Head, End)
  P ← Head
  While P ≠ End
    Operation(Elem[P].Value)
    P ← Elem[P].Next
  End while
End procedure
```

</div>
<div>

<span class="cimke java">Java</span>

```java
class SentinelList {
    Node head, end;

    SentinelList() {
        end = new Node();   // 哨兵
        head = end;
    }

    boolean isEmpty() { return head == end; }

    void print() {
        for (Node p = head; p != end; p = p.next)
            System.out.print(p.value + " ");
    }
}
```

</div>
</div>

---

## 哨兵查找

![w:1000](kepek/h10_kereses.svg)

```text
Function SentinelSearch(Head, End, x)
  Elem[End].Value ← x                  // 写入哨兵
  P ← Head
  While Elem[P].Value ≠ x              // 只有一个条件!
    P ← Elem[P].Next
  End while
  If P = End then Return Nil  else Return P
End function
```

---

## 在给定结点之前插入

![w:1000](kepek/h10_ele_beszur.svg)

<strong>技巧：</strong>我们无法在 P <strong>之前</strong>插入（不知道前一个结点），但可以在它<strong>之后</strong>插入。所以把 P 的<strong>内容</strong>复制到新结点中，再把新数据写入 P。

---

<!-- _class: kicsi tomor -->

## 在前面插入 – 伪代码和代码

<div class="ket-hasab">
<div>

<span class="cimke pszeudo">伪代码</span>

```text
Procedure InsertBefore(Head, End, P, Data)
  If Reserve(Q) then
    Elem[Q] ← Elem[P]       // Value 和 Next
    Elem[P].Value ← Data
    Elem[P].Next ← Q
    If P = End then
      End ← Q               // Q 是新的哨兵
    End if
  else
    ERROR("no free memory")
  End if
End procedure
```

<strong>在表尾插入</strong> = 在哨兵之前插入：
`InsertBefore(Head, End, End, Data)` → <strong>O(1)</strong>!
<strong>在表头插入</strong> = 在 Head 之前插入

</div>
<div>

<span class="cimke java">Java</span>

```java
void insertBefore(Node p, int x) {
    Node q = new Node();
    q.value = p.value;
    q.next = p.next;
    p.value = x;
    p.next = q;
    if (p == end) end = q;
}

void insertLast(int x)  { insertBefore(end, x); }
void insertFirst(int x) { insertBefore(head, x); }
```

<span class="cimke go">Go</span>

```go
func (l *SentinelList) InsertBefore(p *Node, x int) {
    q := &Node{Value: p.Value, Next: p.Next}
    p.Value = x
    p.Next = q
    if p == l.End {
        l.End = q
    }
}
```

</div>
</div>

---

<!-- _class: kicsi -->

## 删除给定的结点

![w:900](kepek/h10_torles.svg)

```text
Procedure Delete(Head, End, P)       // P ≠ End
  Q ← Elem[P].Next
  Elem[P] ← Elem[Q]                  // 把后继结点的内容放入 P
  If Q = End then End ← P            // P 成为新的哨兵
  Free(Q)
End procedure
```

删除<strong>第一个</strong>结点也不再是特殊情况 – Head 不变！

---

## 哨兵带来了什么好处？

| | 单链表 | 带哨兵的链表 |
|---|---|---|
| 空链表 | Head = Nil | Head = End（空间：1 个结点） |
| 查找的循环条件 | 2 个条件 | <strong>1 个条件</strong> |
| 在前面插入 | 不可能（只能在后面） | <strong>O(1)</strong> |
| 删除给定结点 | 不可能（只能删除后面的） | <strong>O(1)</strong> |
| 在表尾插入 | O(n) | <strong>O(1)</strong> |
| 特殊情况（第一个结点） | 很多 | <strong>没有</strong> |

<div class="doboz figyelem">

复制技巧会<strong>改变哪个结点存放哪个值</strong> – 如果外部有指针指向这些结点，它们可能会“错位”。对于很大的记录，复制的代价也很高。

</div>

---

## 有序的带哨兵链表

哨兵也简化了有序插入：我们把一个<strong>无穷大</strong>的值写入哨兵，这样查找一定会停止。

```text
Procedure SortedInsert(Head, End, Data)
  Elem[End].Value ← +∞
  P ← Head
  While Elem[P].Value < Data
    P ← Elem[P].Next
  End while
  InsertBefore(Head, End, P, Data)
End procedure
```

依次插入 `5, 2, 8, 1, 9, 3` 之后：`[1 2 3 5 8 9]` – 空链表、表头和表尾都用同一段代码！

---

<!-- _class: fejezet -->

# 双向链表

---

## 双向链表的结构

![w:1000](kepek/h10_ketiranyu.svg)

- 每个结点都存放<strong>前一个</strong>和<strong>后一个</strong>结点的地址
- <strong>Head</strong> 和 <strong>End</strong> 指针：链表可以双向遍历
- 空间：每个结点<strong>两个</strong>指针

---

<!-- _class: kicsi tomor -->

## 双向链表 – 操作

<div class="ket-hasab">
<div>

<span class="cimke pszeudo">删除结点 P – O(1)</span>

```text
Procedure Delete(Head, End, P)
  If Elem[P].Prev ≠ Nil then
    Elem[Elem[P].Prev].Next ← Elem[P].Next
  else
    Head ← Elem[P].Next
  End if
  If Elem[P].Next ≠ Nil then
    Elem[Elem[P].Next].Prev ← Elem[P].Prev
  else
    End ← Elem[P].Prev
  End if
  Free(P)
End procedure
```

不需要复制 – <strong>我们知道两个邻居</strong>。

在前面/后面插入、删除表尾：都是 <strong>O(1)</strong>。

</div>
<div>

<span class="cimke java">Java</span>

```java
void remove(Node p) {
    if (p.prev != null) p.prev.next = p.next;
    else head = p.next;
    if (p.next != null) p.next.prev = p.prev;
    else tail = p.prev;
}

void insertLast(int x) {          // O(1)
    Node n = new Node(x);
    n.prev = tail;
    if (tail != null) tail.next = n;
    else head = n;
    tail = n;
}
```

</div>
</div>

---

## 实际中的双向链表

- Java `LinkedList`：双向链表 – `addFirst`、`addLast`、`removeFirst`、`removeLast` 都是 O(1)
- Go `container/list`：双向、带哨兵的循环链表
- <strong>应用：</strong>
  - 浏览器历史（后退<strong>和</strong>前进）
  - 音乐播放器：上一首 / 下一首
  - 文本编辑器：光标在行之间移动
  - <strong>LRU 缓存：</strong>快速删除最久未使用的元素（与哈希表配合）

<div class="doboz tipp">

<strong>带哨兵的双向链表：</strong>只用一个哨兵，它既是第一个结点之前的结点，也是最后一个结点之后的结点 – 这样<strong>所有</strong>操作都可以不考虑特殊情况（Go 的 `container/list` 就是这样实现的）。

</div>

---

<!-- _class: fejezet -->

# 循环链表

---

## 循环链表

![w:1000](kepek/h10_ciklikus.svg)

- 最后一个结点不指向 <strong>Nil</strong>，而是指向<strong>第一个</strong>结点
- 只需一个指向<strong>最后一个</strong>结点的指针：第一个结点 = `Elem[End].Next` → 在表头<strong>和</strong>表尾插入都是 O(1)
- 遍历：从第一个结点开始，直到<strong>回到</strong>起点（直到型循环！）

<strong>应用：</strong>时间片轮转调度、循环播放列表、约瑟夫问题、轮流进行的多人游戏

---

## 各种链表的比较

| | 单链表 | 带哨兵 | 双向 | 循环 |
|---|---|---|---|---|
| 每个结点的指针数 | 1 | 1 | <strong>2</strong> | 1 |
| 空链表占用的空间 | 0 个结点 | <strong>1 个结点</strong> | 0 个结点 | 0 个结点 |
| 在表头插入 | O(1) | O(1) | O(1) | O(1) |
| 在表尾插入 | O(n) | <strong>O(1)</strong> | <strong>O(1)</strong> | <strong>O(1)</strong> |
| 删除表尾 | O(n) | O(n) | <strong>O(1)</strong> | O(n) |
| 删除给定结点 | O(n)* | <strong>O(1)</strong> | <strong>O(1)</strong> | O(n)* |
| 反向遍历 | ✗ | ✗ | ✓ | ✗ |

<span class="halvany">* 需要找到前一个结点</span>

---

## 小结

- <strong>带哨兵的链表：</strong>Head + End,End 指向一个空的哨兵结点；空链表：Head = End
  - 哨兵查找只需<strong>一个</strong>条件 · 用<strong>复制技巧</strong> O(1) 在前面插入和删除 · 没有特殊情况
- <strong>双向链表：</strong>Prev + Next 指针；可以双向遍历；删除 O(1)
- <strong>循环链表：</strong>最后一个结点指向第一个；一个 End 指针就够了
- 选择取决于<strong>最常用的操作</strong>和<strong>所需的空间</strong>

<strong>下周：</strong>基于链表的栈和队列，以及哈希表。

---

## 复习题

1. 判断对错：空的带哨兵链表不需要存储空间，因为其中没有存放任何元素。
2. 判断对错：存放相同序列的带哨兵链表和双向链表所需的空间总是相同的，因为两者都由两个指针（Head 和 End）给出。
3. 写出哨兵查找的伪代码！链表由 Head 和 End 指针给出。
4. 画出带哨兵的链表 `[3, 7]`，以及在 `7` 之前插入（Data = 5）之后的状态！
5. 为什么遍历循环链表需要直到型循环？
6. 如果经常需要删除链表的<strong>表尾</strong>，你会选择哪种链表？

<!--
答案：1. 错 – 哨兵结点要占用空间。
2. 错 – 双向链表每个结点存放 2 个指针，带哨兵的链表是 1 个 + 一个哨兵结点。
3. 见 SentinelSearch 函数（旧试卷中的题目）。
4. [3, 5, 7] – 5 放进原来 7 所在的结点，在它后面放一个存放 7 的新结点。
5. 因为在第一个结点处，“回到起点”的条件一开始就已经成立了。
6. 双向链表（O(1)）。
-->

---

<!-- _class: fejezet -->
<!-- _footer: "" -->

# 实践课

---

<!-- _class: gyak -->

## 示例程序

| 程序 | Java | Go | 演示内容 |
|---|---|---|---|
| 带哨兵的链表 | `SentinelList.java` | `w10/sentinellist` | 创建、哨兵查找、在前面插入、删除、有序插入 |
| 双向链表 | `DoublyLinkedList.java` | `w10/doublylist` | 正向/反向遍历、O(1) 删除、内置链表 |
| 循环链表 | `CircularList.java` | `w10/circularlist` | 遍历、约瑟夫问题 |

<div class="doboz tipp">

`SentinelList` 是旧作业中带哨兵链表部分的完整解答（初始化、处理、查找、插入、删除）。

</div>

---

<!-- _class: gyak -->

## 练习

1. <strong>最大值：</strong>找出带哨兵链表中的最大元素！
2. <strong>用哨兵计数：</strong><em>x</em> 出现了多少次？内层循环只能有一个条件！
3. <strong>删除偶数：</strong>用复制技巧删除带哨兵链表中所有的偶数！
4. <strong>回文：</strong>在双向链表上，用两个指针从两端检查！
5. <strong>循环链表的大小：</strong>统计循环链表中的结点个数！

参考答案：`Exercises10.java`、`w10/exercises`

---

<!-- _class: gyak -->

## 课后作业

1. 实现只有一个哨兵的<strong>带哨兵双向链表</strong>（第一个结点之前和最后一个结点之后是同一个结点）！写出在前面插入、在后面插入和删除 – 不用一个 `if`！
2. 把两个有序的带哨兵链表合并成第三个有序的带哨兵链表！
3. 用双向链表实现 <strong>LRU 缓存</strong>：最近使用的元素放到最前面；满时从表尾删除！
4. 测量：从多少个元素开始，哨兵查找明显比普通查找快？

<!--
解题思路：
1. 哨兵 S:S.next = 第一个结点，S.prev = 最后一个结点；空：S.next = S.prev = S。在 P 之后插入：n.prev = P; n.next = P.next; P.next.prev = n; P.next = n。删除：P.prev.next = P.next; P.next.prev = P.prev。
3. 访问：摘下该结点（O(1)）并插到最前面；新元素：如果 size = capacity，删除表尾。查找用 HashMap（第 11 周）。
-->
