---
marp: true
theme: kurzus
paginate: true
footer: 数据结构 · 第 9 周 · 动态内存、链表
---

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

# 数据结构

## 第 9 周 – 动态数据结构：内存管理模型与单链表

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

---

## 复习

- 到目前为止，我们的数据结构都基于<strong>数组</strong>：顺序存储、直接访问
- 栈、队列：在数组的<strong>两端</strong>操作 → O(1)
- <strong>指针 / 引用</strong>（第 1 周）：存放内存地址的数据

### 今天的问题

- 静态（基于数组的）存储有什么局限？
- 程序怎样分配和释放内存？
- <strong>链表</strong>是怎样构成的？它的操作有多快？

<!--
时间安排：静态存储的局限 10' · 内存模型 20' · 链表的概念 10' · 操作 40' · 比较 10'
本周和下周的内容对应旧的作业（链表和带哨兵的链表）。
-->

---

## 静态数据结构的局限

- 大小必须<strong>事先</strong>给定 → 太小或者浪费
- 需要一块<strong>连续</strong>的空闲内存 – 对于很大的数组，不一定总能找到
- 在中间插入和删除是 <strong>O(n)</strong> – 需要移动元素
- 动态数组（第 3 周）有所帮助，但扩容时<strong>所有元素都要复制</strong>

<div class="doboz">

<strong>思路：</strong>把元素<strong>分散</strong>地存放在内存中的任意位置，并让每个元素<strong>知道下一个元素在哪里</strong>。

</div>

---

<!-- _class: fejezet -->

# 动态内存管理模型

---

## 一个简单的模型

- <strong>内存</strong>是一个大数组，其元素是带有 <strong>Value</strong> 和 <strong>Next</strong> 字段的记录
- <strong>指针</strong>是这个数组的<strong>下标</strong> – 特殊值 <strong>Nil</strong> 表示“不指向任何地方”
- 记号：`Elem[P].Value`、`Elem[P].Next` – P 所指元素的字段

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

---

## 分配与释放

空闲位置也组织成一个<strong>链表</strong>（FreeList）。

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

```text
Function Reserve(P)
  If FreeList = Nil then
    Return false        // 内存已满
  else
    P ← FreeList
    FreeList ← Elem[P].Next
    Return true
  End if
End function
```

</div>
<div>

```text
Procedure Free(P)
  Elem[P].Next ← FreeList
  FreeList ← P
End procedure
```

- 两者都是 <strong>O(1)</strong>
- 从空闲链表的<strong>前端</strong>取出，也放回前端 – 就像栈一样！

</div>
</div>

<span class="halvany">实际中空闲内存由操作系统和运行时管理 – 在旧的作业中也可以省略它的实现。</span>

---

## 实际情况

| | 分配 | 释放 |
|---|---|---|
| C | `p = malloc(sizeof(Node))` | `free(p)` – 程序员的责任 |
| <strong>Java</strong> | `new Node(x)` | <strong>自动</strong> – 垃圾回收器（GC） |
| <strong>Go</strong> | `&Node{Value: x}` 或 `new(Node)` | <strong>自动</strong> – 垃圾回收器（GC） |

<strong>手工管理内存时的典型错误：</strong>

- <strong>内存泄漏：</strong>分配的内存从不释放 → 程序占用的内存越来越多
- <strong>悬空指针：</strong>指向已经释放的内存并继续使用它
- 垃圾回收器可以防止这些错误：<strong>没有任何指针指向</strong>的内存会被释放

---

## 动态数据结构：优点与代价

<strong>优点</strong>

- 只占用<strong>当前需要</strong>的内存 → 内存可以在多个数据结构之间<strong>共享</strong>（资源共享）
- 不需要连续的内存区域
- 通过<strong>修改指针</strong>来插入和删除，不需要移动元素

<strong>代价</strong>

- 每个元素还要存放<strong>一个或多个指针</strong> → 占用更多内存
- <strong>没有直接访问</strong> – 要到达第 <em>i</em> 个元素，必须经过前面的元素
- 分配/释放和跟随指针都<strong>需要时间</strong>

---

<!-- _class: fejezet -->

# 单链表

---

## 链表的结构

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

- 每个<strong>结点</strong>：一个值 + 一个指向<strong>下一个</strong>结点的指针
- <strong>Head：</strong>指向第一个结点的指针 · 最后一个结点的 `Next` 字段为 <strong>Nil</strong>
- <strong>空链表：</strong>Head = Nil

| 性质 | 链表 |
|---|---|
| 元素 | 同构 |
| 大小 | <strong>动态</strong> |
| 存储 | <strong>链式</strong> |
| 访问 | <strong>顺序</strong> |

---

## Java 和 Go 中的链表结点

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

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

```java
class Node {
    int value;
    Node next;      // 指向下一个结点的引用

    Node(int value, Node next) {
        this.value = value;
        this.next = next;
    }
}

Node head = null;   // 空链表
```

</div>
<div>

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

```go
type Node struct {
    Value int
    Next  *Node     // 指向下一个结点的指针
}

type List struct {
    Head *Node      // nil:空链表
}
```

</div>
</div>

| 伪代码 | Java | Go |
|---|---|---|
| `Elem[P].Value` · `Elem[P].Next` | `p.value` · `p.next` | `p.Value` · `p.Next` |
| `Nil` · `Head` | `null` · `head` | `nil` · `l.Head` |

---

<!-- _class: kicsi -->

## 遍历与查找

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

<span class="cimke pszeudo">遍历（处理）</span>

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

<span class="cimke pszeudo">查找</span>

```text
Function Search(Head, x)
  P ← Head
  While P ≠ Nil and Elem[P].Value ≠ x
    P ← Elem[P].Next
  End while
  Return P             // 未找到时为 Nil
End function
```

</div>
<div>

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

```java
for (Node p = head; p != null; p = p.next)
    System.out.println(p.value);

Node find(int x) {
    Node p = head;
    while (p != null && p.value != x)
        p = p.next;
    return p;
}
```

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

```go
for p := l.Head; p != nil; p = p.Next {
    fmt.Println(p.Value)
}
```

两者都是 <strong>O(n)</strong>。即使链表有序也不能二分查找 – 没有直接访问！

</div>
</div>

---

## 在表头插入

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

---

<!-- _class: kicsi tomor -->

## 在表头插入和删除

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

<span class="cimke pszeudo">在表头插入 – O(1)</span>

```text
Procedure InsertFirst(Head, Data)
  If Reserve(P) then
    Elem[P].Value ← Data
    Elem[P].Next ← Head
    Head ← P
  else
    ERROR("no free memory")
  End if
End procedure
```

<span class="cimke pszeudo">删除表头 – O(1)</span>

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

</div>
<div>

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

```java
void insertFirst(int x) {
    head = new Node(x, head);
}

int removeFirst() {
    int x = head.value;
    head = head.next;   // 由 GC 释放
    return x;
}
```

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

```go
func (l *List) InsertFirst(x int) {
    l.Head = &Node{Value: x, Next: l.Head}
}

func (l *List) RemoveFirst() int {
    x := l.Head.Value
    l.Head = l.Head.Next
    return x
}
```

</div>
</div>

---

## 在给定结点之后插入

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

<div class="doboz figyelem">

如果顺序反过来（先执行 `Next[Q] ← P`），链表的剩余部分就会<strong>丢失</strong> – 再也没有指针指向它了！

</div>

---

## 删除给定结点之后的结点

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

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

```text
Procedure InsertAfter(Q, Data)
  If Reserve(P) then
    Elem[P].Value ← Data
    Elem[P].Next ← Elem[Q].Next
    Elem[Q].Next ← P
  End if
End procedure
```

</div>
<div>

```text
Procedure DeleteAfter(Q)
  P ← Elem[Q].Next
  If P ≠ Nil then
    Elem[Q].Next ← Elem[P].Next
    Free(P)
  End if
End procedure
```

</div>
</div>

两者都是 <strong>O(1)</strong> – 前提是已经有了 Q。（找到 Q 需要 O(n)。）

---

<!-- _class: kicsi tomor -->

## 在表尾的操作

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

<span class="cimke pszeudo">在表尾插入 – O(n)</span>

```text
Procedure InsertLast(Head, Data)
  If Reserve(P) then
    Elem[P].Value ← Data
    Elem[P].Next ← Nil
    If Head = Nil then
      Head ← P
    else
      Q ← Head
      While Elem[Q].Next ≠ Nil
        Q ← Elem[Q].Next
      End while
      Elem[Q].Next ← P
    End if
  End if
End procedure
```

</div>
<div>

<span class="cimke pszeudo">删除表尾 – O(n)</span>

```text
Procedure DeleteLast(Head)
  If Head = Nil then exit
  If Elem[Head].Next = Nil then
    Free(Head) ; Head ← Nil
  else
    Q ← Head      // 寻找倒数第二个结点
    While Elem[Elem[Q].Next].Next ≠ Nil
      Q ← Elem[Q].Next
    End while
    Free(Elem[Q].Next)
    Elem[Q].Next ← Nil
  End if
End procedure
```

</div>
</div>

<div class="doboz tipp">

如果再保存一个 <strong>End</strong> 指针，在表尾插入就变成 O(1) – 但删除表尾不行（仍然要找到倒数第二个结点）。解决办法：双向链表（第 10 周）。

</div>

---

## 按值删除结点

在单链表中只能删除某个已知结点<strong>之后</strong>的结点 → 查找时还必须记住<strong>前一个</strong>结点。

```java
boolean remove(int x) {
    Node prev = null, p = head;
    while (p != null && p.value != x) {
        prev = p;
        p = p.next;
    }
    if (p == null) return false;       // 没有这个元素
    if (prev == null) head = p.next;   // 删除第一个结点
    else prev.next = p.next;
    return true;
}
```

<div class="doboz figyelem">

一定要考虑<strong>特殊情况</strong>：空链表、第一个结点、最后一个结点、只有一个结点的链表！

</div>

---

## 链表还是数组？

| 操作 | 数组 | 动态数组 | 链表 |
|---|---|---|---|
| 访问第 <em>i</em> 个元素 | <strong>O(1)</strong> | <strong>O(1)</strong> | O(n) |
| 查找 | O(n) / 有序时 O(log n) | O(n) / O(log n) | O(n) |
| 在开头插入 / 删除 | O(n) | O(n) | <strong>O(1)</strong> |
| 在已知结点之后插入 / 删除 | O(n) | O(n) | <strong>O(1)</strong> |
| 在末尾插入 | 有空位时 O(1) | 均摊 O(1) | O(n)，有 End 指针时 O(1) |
| 每个元素占用的空间 | 值 | 值（+ 预留） | 值 + <strong>指针</strong> |

<strong>实测</strong>（在开头插入 n = 160 000 个元素）：`ArrayList` 870 ms · `LinkedList` 2 ms
<strong>读取中间元素</strong> 20 000 次：`ArrayList` 0.1 ms · `LinkedList` 357 ms

---

## 小结

- <strong>动态数据结构：</strong>元素分散在内存中，由指针连接；大小在运行时改变
- <strong>内存模型：</strong>记录数组 + 空闲链表；`Reserve(P)`、`Free(P)` – 在 Java/Go 中是 `new` 和垃圾回收器
- <strong>链表：</strong>Head → 结点 → 结点 → … → Nil；<strong>顺序</strong>访问
- 在表头和已知结点之后：插入和删除 <strong>O(1)</strong> · 查找、在表尾：<strong>O(n)</strong>
- 修改指针的<strong>顺序</strong>很重要 · 特殊情况：空链表、第一个结点

<strong>下周：</strong>带哨兵的链表、双向链表、循环链表。

---

## 复习题

1. 判断对错：使用动态数据结构可以实现资源共享。
2. 判断对错：链表的元素可以直接（按下标）访问。
3. 判断对错：动态数据结构使用不当会导致内存泄漏。
4. 在 Q 之后插入时，如果先执行 `Elem[Q].Next ← P`，再执行 `Elem[P].Next ← Elem[Q].Next`，会怎样？
5. 用伪代码写出：在 Head 指针给出的链表<strong>表头</strong>插入值 `Data`!（`Reserve(P)` 成功时返回 true。）
6. 为什么即使知道 End 指针，删除表尾仍然是 O(n)？

<!--
答案：1. 对  2. 错（顺序访问）  3. 对
4. Elem[P].Next 指向 P 自己 → 链表的剩余部分丢失，并形成一个环。
5. 见幻灯片上的 InsertFirst 过程（旧试卷中的题目）。
6. 必须找到倒数第二个结点（把它的 Next 字段设为 Nil），而只能从 Head 开始才能到达它。
-->

---

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

# 实践课

---

<!-- _class: gyak -->

## 示例程序

| 程序 | Java | Go | 演示内容 |
|---|---|---|---|
| 内存模型 | `MemoryModel.java` | `w09/memorymodel` | 记录数组、空闲链表、Reserve/Free |
| 链表 | `SinglyLinkedList.java` | `w09/singlylist` | 所有基本操作 |
| 链表与数组 | `ListVsArray.java` | – | 在开头插入、读取中间元素 |

<div class="doboz tipp">

`MemoryModel` 程序完全按照讲课中的伪代码实现 – 跟着打印出的表格看！

</div>

---

<!-- _class: gyak -->

## 练习

1. <strong>和与最大值：</strong>只遍历一次计算出来！
2. <strong>第 i 个元素：</strong>返回链表的第 <em>i</em> 个元素！需要多少步？
3. <strong>计数：</strong><em>x</em> 在链表中出现了多少次？
4. <strong>原地逆置：</strong>通过修改指针把链表逆置，不创建新结点！
5. <strong>删除所有 x：</strong>删除所有值为 <em>x</em> 的结点！（注意开头的那些！）
6. <strong>中间结点：</strong>只遍历一次 – 一个指针每次走一步，另一个每次走两步！
7. <strong>有序插入：</strong>插入元素，使链表保持递增顺序！

参考答案：`Exercises09.java`、`w09/exercises`

---

<!-- _class: gyak -->

## 课后作业

1. 为链表增加一个 <strong>End</strong> 指针！哪些操作变快了？哪些操作需要修改？
2. 把两个<strong>有序</strong>链表合并成一个有序链表，不创建新结点，用 O(n + m) 步！
3. 判断链表中是否有<strong>环</strong>（某个结点的 `Next` 字段指向前面的结点）！（提示：龟兔赛跑。）
4. 用链表实现栈（push = 在表头插入，pop = 删除表头）！为什么这比在表尾操作更好？

<!--
解题思路：
1. InsertLast 变为 O(1)；需要修改的：向空链表插入（End ← P）、删除最后一个结点（更新 End）；DeleteLast 仍然是 O(n)。
2. 两个指针；总是把较小的元素接到结果的末尾（用一个辅助头结点 / 哨兵会更简单）。
3. Floyd 算法：慢指针走一步，快指针走两步；如果某时 slow == fast → 有环。
4. 因为在表头两个操作都是 O(1)；在表尾删除需要 O(n)。
-->
