数据结构习题精讲与算法实战解析(B级)
简介:《数据结构习题与解析 B级 李春葆》是计算机专业学生夯实数据结构基础的重要学习资料,涵盖线性结构、树形结构、图结构及哈希表等核心内容,并详解冒泡排序、快速排序、二分查找等常用算法。本书通过大量习题与解析帮助学习者掌握动态规划、贪心算法和回溯法等高级解题策略,提升编程能力和算法设计思维。适合准备面试、项目开发及课程学习的程序员使用,是深入理解数据结构与算法的必备参考书。
1. 数据结构基础概念解析
在计算机科学中, 数据结构 是组织和管理数据的基础方式,它直接影响着程序的效率与可维护性。本章将从最基础的概念入手,帮助读者建立清晰的知识体系框架。
1.1 数据结构的基本组成要素
数据结构包含三个基本组成部分: 数据、数据元素与数据项 。
- 数据 :信息的载体,是计算机操作的基本对象。
- 数据元素 :数据的基本单位,通常由若干个 数据项 组成。
- 数据项 :具有独立含义的最小标识单位,例如一个人的姓名、年龄等。
1.2 数据结构的分类
数据结构主要从两个维度进行分类:
-
逻辑结构 :描述数据之间的逻辑关系,包括:
- 集合结构
- 线性结构(如数组、链表)
- 树形结构(如二叉树、堆)
- 图结构(如有向图、无向图) -
物理结构(存储结构) :数据在计算机中的存储方式,主要包括:
- 顺序存储:如数组
- 链式存储:如链表
- 索引存储
- 散列存储
1.3 抽象数据类型(ADT)
抽象数据类型(Abstract Data Type, ADT) 是一种数学模型,它定义了数据的逻辑结构以及在该结构上的一组操作接口,而隐藏了具体的实现细节。ADT 是理解数据结构如何被使用的关键概念。
例如,栈(Stack)的 ADT 可以定义如下:
public interface Stack<T> {
void push(T item); // 入栈操作
T pop(); // 出栈操作,返回被弹出的元素
T peek(); // 查看栈顶元素
boolean isEmpty(); // 判断栈是否为空
}
通过 ADT,我们可以将数据结构的 定义与实现分离 ,使得程序结构更清晰、易于维护和扩展。
1.4 数据结构的重要性
掌握数据结构的意义在于:
- 提升程序性能:合理选择数据结构可以显著提高程序的时间与空间效率。
- 构建复杂系统:如数据库系统依赖树与图结构,操作系统依赖队列与栈进行任务调度。
- 应对算法面试:几乎所有算法问题都涉及数据结构的灵活应用。
- 培养编程思维:学习数据结构有助于理解抽象建模与问题求解的策略。
下一章我们将从最基础的线性结构开始,深入讲解数组与链表的实现原理与应用场景。
2. 线性结构(数组、链表)详解与习题训练
线性结构是数据结构中最基础也是最常用的一类结构,其核心特点是元素之间存在一对一的线性关系。本章将深入剖析线性结构的核心代表—— 数组 与 链表 的实现机制、操作方式、性能差异及应用场景,并结合习题训练帮助读者巩固理论知识,提升算法思维能力。
2.1 线性结构的基本特征
2.1.1 线性结构的定义与特点
线性结构是指数据元素之间存在“一对一”的前驱和后继关系的结构。常见的线性结构包括:数组、链表、栈、队列等。其基本特征如下:
| 特征 | 描述 |
|---|---|
| 顺序性 | 数据元素按顺序排列 |
| 一对一 | 每个元素有且仅有一个前驱和一个后继(除首尾) |
| 连续存储(数组) | 数组元素在内存中是连续存储的 |
| 非连续存储(链表) | 链表元素通过指针连接,物理上不连续 |
线性结构的抽象数据类型(ADT)通常包括以下基本操作:
- 初始化结构
- 插入元素
- 删除元素
- 查找元素
- 获取长度
- 遍历结构
2.1.2 常见线性结构的分类(顺序表与链表)
线性结构根据存储方式可分为 顺序表 与 链表 :
- 顺序表(数组) :采用连续内存空间存储数据,支持随机访问,插入删除效率低。
- 链表 :采用离散内存空间,通过指针连接节点,插入删除效率高,但访问效率低。
mermaid流程图如下所示:
graph TD
A[线性结构] --> B[顺序结构]
A --> C[链式结构]
B --> D[静态数组]
B --> E[动态数组]
C --> F[单链表]
C --> G[双链表]
C --> H[循环链表]
顺序结构适用于数据量固定、频繁访问的场景;链式结构适用于数据频繁变动、插入删除操作频繁的场景。
2.2 数组的实现与操作
2.2.1 静态数组与动态数组
数组是线性结构中最基本的形式,其本质是 一段连续的内存空间 ,用于存储相同类型的数据。
- 静态数组 :在编译时确定大小,无法动态扩展。
- 动态数组 :在运行时根据需要扩展容量,典型代表如 C++ 的
std::vector、Java 的ArrayList。
以下是一个 C++ 动态数组的实现示例:
#include <iostream>
using namespace std;
class DynamicArray {
private:
int* arr;
int capacity;
int size;
public:
DynamicArray(int cap) {
capacity = cap;
size = 0;
arr = new int[capacity];
}
void push(int value) {
if (size == capacity) {
resize();
}
arr[size++] = value;
}
void resize() {
capacity *= 2;
int* newArr = new int[capacity];
for (int i = 0; i < size; ++i) {
newArr[i] = arr[i];
}
delete[] arr;
arr = newArr;
}
void print() {
for (int i = 0; i < size; ++i) {
cout << arr[i] << " ";
}
cout << endl;
}
~DynamicArray() {
delete[] arr;
}
};
int main() {
DynamicArray da(2);
da.push(1);
da.push(2);
da.push(3);
da.print(); // 输出:1 2 3
return 0;
}
代码逻辑分析:
-
DynamicArray(int cap):构造函数初始化数组容量。 -
push(int value):插入元素,若容量不足则调用resize()扩容。 -
resize():将容量翻倍,创建新数组并将旧数据拷贝过去。 -
print():遍历数组并输出元素。 -
~DynamicArray():析构函数释放动态分配的内存。
参数说明:
-
capacity:当前数组最大容量。 -
size:当前已存储的元素个数。 -
arr:指向动态分配的数组内存。
2.2.2 多维数组的存储与访问
多维数组是数组的数组,其本质是 线性化存储 。例如,一个二维数组 int arr[3][4] 实际上是按行优先顺序存储为一个一维数组。
假设一个二维数组 arr[m][n] ,其元素 arr[i][j] 在内存中的偏移量为:
offset = i * n + j
例如:
int arr[3][4] = {
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
};
访问 arr[1][2] 实际上访问的是线性地址 1*4 + 2 = 6 ,值为 7 。
2.2.3 数组的应用场景与优缺点
| 应用场景 | 优点 | 缺点 |
|---|---|---|
| 存储固定长度数据 | 支持随机访问,速度快 | 插入删除效率低 |
| 实现其他结构(如栈、队列) | 内存管理简单 | 容量固定,扩展性差 |
| 图像像素处理 | 数据连续,访问高效 | 不适合频繁扩容 |
2.3 链表的结构与实现
2.3.1 单链表、双链表与循环链表
链表是一种动态数据结构,每个节点包含数据和指向下一个节点的指针。
- 单链表 :每个节点只有一个指向下一个节点的指针。
- 双链表 :每个节点有两个指针,分别指向前驱和后继。
- 循环链表 :尾节点指向头节点,形成环状。
mermaid流程图如下:
graph LR
A[链表] --> B[单链表]
A --> C[双链表]
A --> D[循环链表]
2.3.2 链表的基本操作(插入、删除、查找)
以单链表为例,实现插入、删除、查找操作。
struct Node {
int data;
Node* next;
};
class LinkedList {
private:
Node* head;
public:
LinkedList() : head(nullptr) {}
void insert(int value) {
Node* newNode = new Node{value, head};
head = newNode;
}
void remove(int value) {
Node* curr = head;
Node* prev = nullptr;
while (curr && curr->data != value) {
prev = curr;
curr = curr->next;
}
if (!curr) return;
if (!prev) {
head = curr->next;
} else {
prev->next = curr->next;
}
delete curr;
}
bool search(int value) {
Node* curr = head;
while (curr) {
if (curr->data == value) return true;
curr = curr->next;
}
return false;
}
void print() {
Node* curr = head;
while (curr) {
cout << curr->data << " -> ";
curr = curr->next;
}
cout << "NULL" << endl;
}
~LinkedList() {
Node* curr = head;
while (curr) {
Node* next = curr->next;
delete curr;
curr = next;
}
}
};
int main() {
LinkedList list;
list.insert(10);
list.insert(20);
list.insert(30);
list.print(); // 输出:30 -> 20 -> 10 -> NULL
list.remove(20);
list.print(); // 输出:30 -> 10 -> NULL
cout << (list.search(10) ? "Found" : "Not Found") << endl; // Found
return 0;
}
代码逻辑分析:
-
insert(int value):在链表头部插入新节点。 -
remove(int value):查找并删除值为value的节点。 -
search(int value):遍历链表查找是否存在该值。 -
print():打印链表内容。 - 析构函数负责释放所有节点内存。
参数说明:
-
head:指向链表头节点。 -
curr:当前节点指针。 -
prev:前驱节点指针,用于删除操作。
2.3.3 链表的内存管理与性能分析
| 项目 | 分析 |
|---|---|
| 内存分配 | 每次插入新节点都需要 new 分配内存,释放时需 delete |
| 插入/删除 | 时间复杂度 O(1)(在已知位置)或 O(n)(需查找) |
| 随机访问 | 不支持,只能顺序访问,时间复杂度 O(n) |
| 适用场景 | 数据频繁变动、插入删除频繁、不需随机访问的场景 |
2.4 线性结构相关习题训练
2.4.1 基础选择题与判断题
-
判断题 :数组的大小在运行时可以动态改变。( )
✅答案:错误。静态数组大小不可变,动态数组通过扩容实现。 -
选择题 :链表相比数组的优点是( )
A. 支持随机访问
B. 插入删除效率高
C. 内存占用更小
D. 访问速度快
✅答案:B
2.4.2 编程题与算法设计题
题目 :反转一个单链表。
Node* reverseList(Node* head) {
Node* prev = nullptr;
Node* curr = head;
while (curr) {
Node* next = curr->next;
curr->next = prev;
prev = curr;
curr = next;
}
return prev;
}
逻辑分析:
- 初始化
prev为nullptr,curr为头节点。 - 遍历链表,每次将当前节点的
next指向前一个节点。 - 最终
prev指向新的头节点。
2.4.3 典型例题解析与思路讲解
题目 :合并两个有序链表。
Node* mergeTwoLists(Node* l1, Node* l2) {
Node dummy(0);
Node* tail = &dummy;
while (l1 && l2) {
if (l1->data < l2->data) {
tail->next = l1;
l1 = l1->next;
} else {
tail->next = l2;
l2 = l2->next;
}
tail = tail->next;
}
tail->next = l1 ? l1 : l2;
return dummy.next;
}
思路讲解:
- 使用一个虚拟头节点
dummy简化边界处理。 - 比较两个链表当前节点的值,选择较小的接入新链表。
- 遍历结束后,将剩余部分直接接入。
本章从线性结构的基本概念入手,深入分析了数组与链表的实现机制、操作方式、性能差异及应用场景,并通过习题训练帮助读者巩固理论知识,提升算法思维能力。下章将进入 栈与队列 的学习,敬请期待。
3. 栈与队列原理与应用
栈与队列是计算机科学中最基础也是最常用的数据结构之一。它们在算法设计、操作系统、编译原理、网络通信等多个领域都发挥着重要作用。本章将从基本原理出发,深入探讨栈与队列的实现方式、操作机制,并结合实际应用场景进行分析,帮助读者建立扎实的理解基础,掌握其在系统设计与算法中的关键作用。
3.1 栈的基本原理
栈(Stack)是一种只能在一端进行插入或删除的线性表,这一端被称为栈顶(Top),另一端固定,称为栈底(Bottom)。栈遵循后进先出(LIFO, Last In First Out)的原则,即最后压入栈的元素最先被弹出。
3.1.1 栈的定义与操作
栈的操作主要包括:
- Push :将元素压入栈顶。
- Pop :将栈顶元素弹出。
- Peek / Top :查看栈顶元素,但不弹出。
- IsEmpty :判断栈是否为空。
- Size :返回栈中元素的数量。
栈的这些操作具有高度的确定性和可预测性,非常适合用于需要回溯处理的场景。
3.1.2 栈的顺序实现与链式实现
顺序栈(基于数组实现)
顺序栈使用数组来实现,通常定义一个数组和一个表示栈顶位置的变量 top 。
#define MAX_SIZE 100
typedef struct {
int data[MAX_SIZE];
int top;
} SeqStack;
// 初始化栈
void initStack(SeqStack *s) {
s->top = -1; // 栈空状态
}
// 入栈
int push(SeqStack *s, int value) {
if (s->top >= MAX_SIZE - 1) {
printf("Stack overflow\n");
return -1;
}
s->data[++(s->top)] = value;
return 0;
}
// 出栈
int pop(SeqStack *s) {
if (s->top == -1) {
printf("Stack underflow\n");
return -1;
}
return s->data[(s->top)--];
}
逐行解读:
-
initStack将栈顶指针初始化为 -1,表示栈为空。 -
push操作先判断是否栈满,若未满,则将栈顶指针加 1,并将新值放入该位置。 -
pop操作先检查栈是否为空,若非空,则取出栈顶元素并将栈顶指针减 1。
参数说明:
- MAX_SIZE :栈的最大容量。
- top :记录栈顶索引,初始为 -1 表示栈空。
- data :存储栈元素的数组。
链式栈(基于链表实现)
链式栈使用链表结构,栈顶通常设置为链表的头节点,这样可以快速进行插入和删除操作。
typedef struct StackNode {
int data;
struct StackNode *next;
} StackNode;
typedef struct {
StackNode *top;
} LinkedStack;
// 初始化栈
void initStack(LinkedStack *s) {
s->top = NULL;
}
// 入栈
int push(LinkedStack *s, int value) {
StackNode *newNode = (StackNode *)malloc(sizeof(StackNode));
if (!newNode) {
printf("Memory allocation failed\n");
return -1;
}
newNode->data = value;
newNode->next = s->top;
s->top = newNode;
return 0;
}
// 出栈
int pop(LinkedStack *s) {
if (s->top == NULL) {
printf("Stack underflow\n");
return -1;
}
StackNode *temp = s->top;
int value = temp->data;
s->top = temp->next;
free(temp);
return value;
}
逻辑分析:
- 链式栈通过头插法实现栈顶操作,避免了顺序栈的空间限制。
- 每次
push创建新节点并插入到栈顶。 -
pop释放栈顶节点,并更新栈顶指针。
参数说明:
- top :指向链表头节点的指针。
- next :指向下一个节点的指针。
- malloc 用于动态分配内存,注意在出栈后要 free 释放内存,避免内存泄漏。
3.2 队列的基本原理
队列(Queue)是一种特殊的线性表,它只允许在表的一端进行插入操作(队尾),在另一端进行删除操作(队头),遵循先进先出(FIFO, First In First Out)的原则。
3.2.1 队列的定义与操作
队列的基本操作包括:
- Enqueue :将元素插入队尾。
- Dequeue :将队头元素移除。
- Front / Peek :查看队头元素。
- IsEmpty :判断队列是否为空。
- Size :返回队列中元素数量。
3.2.2 循环队列与双端队列
循环队列(Circular Queue)
循环队列是顺序队列的一种优化实现,通过将数组的首尾连接,避免顺序队列中“假溢出”问题。
#define MAX_QUEUE_SIZE 100
typedef struct {
int data[MAX_QUEUE_SIZE];
int front; // 队头指针
int rear; // 队尾指针
} CircularQueue;
// 初始化队列
void initQueue(CircularQueue *q) {
q->front = q->rear = 0;
}
// 判断队列是否满
int isFull(CircularQueue *q) {
return (q->rear + 1) % MAX_QUEUE_SIZE == q->front;
}
// 入队
int enqueue(CircularQueue *q, int value) {
if (isFull(q)) {
printf("Queue overflow\n");
return -1;
}
q->data[q->rear] = value;
q->rear = (q->rear + 1) % MAX_QUEUE_SIZE;
return 0;
}
// 出队
int dequeue(CircularQueue *q) {
if (q->front == q->rear) {
printf("Queue underflow\n");
return -1;
}
int value = q->data[q->front];
q->front = (q->front + 1) % MAX_QUEUE_SIZE;
return value;
}
逐行解读:
-
front和rear分别指向队头和队尾。 - 当
(rear + 1) % MAX == front时,队列为满。 - 入队操作将元素放入
rear位置后,rear向后移动。 - 出队操作取出
front位置的元素后,front向后移动。
双端队列(Deque)
双端队列允许在队列两端进行插入和删除操作,适合需要灵活操作的场景,如滑动窗口算法、广度优先搜索的优化实现等。
3.3 栈与队列的实际应用
栈与队列在实际开发中应用广泛,下面将介绍它们在函数调用、表达式求值、任务调度中的典型应用。
3.3.1 函数调用与递归实现
在程序运行时,操作系统使用栈来管理函数调用过程。每次调用函数时,系统会将当前函数的状态压入调用栈(Call Stack),包括局部变量、返回地址等信息。
递归函数的栈机制:
以阶乘函数为例:
def factorial(n):
if n == 0:
return 1
return n * factorial(n - 1)
执行流程:
factorial(3)
-> 3 * factorial(2)
-> 2 * factorial(1)
-> 1 * factorial(0)
-> return 1
return 1 * 1 = 1
return 2 * 1 = 2
return 3 * 2 = 6
每次递归调用都会将当前参数和返回地址压入栈中,直到达到终止条件才开始逐层返回。
3.3.2 表达式求值与括号匹配
栈常用于解析数学表达式和验证括号匹配。
括号匹配问题:
def is_valid_parentheses(s: str) -> bool:
stack = []
mapping = {')': '(', '}': '{', ']': '['}
for char in s:
if char in mapping.values():
stack.append(char)
elif char in mapping:
if not stack or stack[-1] != mapping[char]:
return False
stack.pop()
return not stack
逻辑分析:
- 遇到左括号就入栈。
- 遇到右括号则判断栈是否非空且栈顶是否匹配。
- 最终栈为空则说明括号完全匹配。
3.3.3 操作系统中的任务调度
操作系统中,队列常用于任务调度,例如进程调度、I/O请求排队等。
先来先服务(FCFS)调度算法:
graph TD
A[新进程到达] --> B[加入就绪队列]
B --> C{队列是否为空?}
C -->|否| D[调度第一个进程]
D --> E[运行进程]
E --> F[进程完成或阻塞]
F --> G[移除队列]
G --> C
C -->|是| H[等待新进程]
说明:
- 使用队列实现先来先服务的调度策略。
- 进程按到达顺序排队,CPU依次处理。
3.4 栈与队列的习题与实践
3.4.1 基础练习题
1. 实现一个最小栈(Min Stack)
要求支持 push 、 pop 、 top 和 getMin 四个操作,其中 getMin 返回栈中最小值。
class MinStack:
def __init__(self):
self.stack = []
self.min_stack = []
def push(self, val: int) -> None:
self.stack.append(val)
if not self.min_stack or val <= self.min_stack[-1]:
self.min_stack.append(val)
def pop(self) -> None:
if self.stack.pop() == self.min_stack[-1]:
self.min_stack.pop()
def top(self) -> int:
return self.stack[-1]
def getMin(self) -> int:
return self.min_stack[-1]
逻辑分析:
- 主栈
stack存储所有元素。 - 辅助栈
min_stack只存储当前最小值。 - 每次
push时,若新值小于等于min_stack栈顶,则也压入辅助栈。 -
pop时若主栈弹出的值等于辅助栈栈顶,则同步弹出辅助栈。
3.4.2 综合应用题与算法题
2. 用两个栈实现一个队列
class MyQueue:
def __init__(self):
self.stack1 = [] # 用于入队
self.stack2 = [] # 用于出队
def push(self, x: int) -> None:
self.stack1.append(x)
def pop(self) -> int:
if not self.stack2:
while self.stack1:
self.stack2.append(self.stack1.pop())
return self.stack2.pop()
def peek(self) -> int:
if not self.stack2:
while self.stack1:
self.stack2.append(self.stack1.pop())
return self.stack2[-1]
def empty(self) -> bool:
return not self.stack1 and not self.stack2
逻辑分析:
-
push总是将元素压入stack1。 -
pop和peek操作从stack2弹出元素,若stack2为空则将stack1中所有元素倒序压入stack2。 - 实现了先进先出的队列行为。
3.4.3 面试中常见的栈与队列问题解析
| 问题类型 | 说明 | 常见场景 |
|---|---|---|
| 括号匹配 | 使用栈判断是否成对出现 | 编译器语法检查 |
| 滑动窗口最大值 | 使用双端队列维护窗口内最大值 | 算法题、数据流处理 |
| 用队列实现栈 | 通过两个队列实现栈的 LIFO 特性 | 面试算法题 |
| 最小栈 | 使用辅助栈记录最小值 | 面试高频题 |
| 逆波兰表达式求值 | 使用栈进行表达式求解 | 编译器、计算器实现 |
总结:
栈与队列作为基础但强大的数据结构,在系统设计和算法实现中扮演着重要角色。通过本章的学习,我们不仅掌握了它们的实现方式,还了解了其在函数调用、表达式处理、任务调度等多个实际场景中的应用。在后续章节中,我们将继续深入探讨更复杂的结构,如树与图,并结合栈与队列进行综合应用设计。
4. 二叉树、堆、平衡树等树形结构解析
树形结构是计算机科学中最重要的数据结构之一,广泛应用于文件系统、数据库索引、网络路由、算法优化等领域。本章将深入探讨 二叉树、堆、平衡树 等常见树形结构的定义、操作、实现方式以及其在实际中的应用。通过本章的学习,读者将掌握如何在程序中构建、遍历和优化这些结构,为后续更复杂的算法设计和系统实现打下坚实基础。
4.1 树的基本概念与术语
在深入研究具体树结构之前,我们首先需要理解树的基本定义和相关术语。
4.1.1 树的定义与基本性质
树(Tree)是一种 非线性的层次结构 ,由节点组成。每个节点最多有一个父节点,但可以有多个子节点。树具有以下基本性质:
- 有且仅有一个根节点(Root),没有父节点。
- 每个节点可以有0个或多个子节点。
- 任意两个节点之间有且仅有一条路径。
树结构的抽象表示如下:
graph TD
A[根节点 A] --> B[子节点 B]
A --> C[子节点 C]
B --> D[子节点 D]
B --> E[子节点 E]
C --> F[子节点 F]
C --> G[子节点 G]
树的数学定义是一个 递归结构 ,即每个子树也是一个树。
4.1.2 子树、节点、度与层次
理解树结构,需要掌握以下几个核心术语:
| 术语 | 描述 |
|---|---|
| 节点(Node) | 树中的元素,包含数据和指向子节点的引用 |
| 根节点(Root) | 树的顶层节点,没有父节点 |
| 叶子节点(Leaf) | 没有子节点的节点 |
| 子节点(Child) | 某个节点的直接下级节点 |
| 父节点(Parent) | 某个节点的直接上级节点 |
| 兄弟节点(Siblings) | 同一个父节点下的节点 |
| 子树(Subtree) | 某个节点及其所有后代构成的树 |
| 度(Degree) | 节点拥有的子节点数目 |
| 层次(Level) | 根节点为第1层,子节点为第2层,依此类推 |
| 深度(Depth) | 最大层次数 |
例如,对于下图所示的树:
graph TD
A[根节点 A] --> B[节点 B]
A --> C[节点 C]
B --> D[节点 D]
B --> E[节点 E]
C --> F[节点 F]
C --> G[节点 G]
各节点的属性如下表:
| 节点 | 父节点 | 子节点 | 度 | 层次 |
|---|---|---|---|---|
| A | 无 | B, C | 2 | 1 |
| B | A | D, E | 2 | 2 |
| C | A | F, G | 2 | 2 |
| D | B | 无 | 0 | 3 |
| E | B | 无 | 0 | 3 |
| F | C | 无 | 0 | 3 |
| G | C | 无 | 0 | 3 |
4.2 二叉树的结构与操作
二叉树是树结构中最基础、最常用的一种形式。每个节点最多有两个子节点,通常称为左子节点和右子节点。
4.2.1 二叉树的定义与遍历方式(前序、中序、后序)
定义 :二叉树(Binary Tree)是一种每个节点最多有两个子节点的树结构,通常称为左孩子和右孩子。
遍历方式 :二叉树的遍历有三种基本方式,分别是:
- 前序遍历(Pre-order) :根节点 → 左子树 → 右子树
- 中序遍历(In-order) :左子树 → 根节点 → 右子树
- 后序遍历(Post-order) :左子树 → 右子树 → 根节点
例如,对以下二叉树:
graph TD
A[根节点 A] --> B[左子树 B]
A --> C[右子树 C]
B --> D[左子树 D]
B --> E[右子树 E]
遍历结果为:
| 遍历方式 | 遍历顺序 |
|---|---|
| 前序 | A → B → D → E → C |
| 中序 | D → B → E → A → C |
| 后序 | D → E → B → C → A |
实现代码 (Python):
class TreeNode:
def __init__(self, value):
self.value = value
self.left = None
self.right = None
def preorder_traversal(node):
if node:
print(node.value, end=" ") # 访问根节点
preorder_traversal(node.left) # 递归左子树
preorder_traversal(node.right) # 递归右子树
def inorder_traversal(node):
if node:
inorder_traversal(node.left)
print(node.value, end=" ")
inorder_traversal(node.right)
def postorder_traversal(node):
if node:
postorder_traversal(node.left)
postorder_traversal(node.right)
print(node.value, end=" ")
逻辑分析 :
-
TreeNode类用于定义节点,包含值和左右子节点。 - 每种遍历函数都采用递归方式实现。
- 前序遍历先访问当前节点,再递归左右子树。
- 中序遍历先递归左子树,再访问当前节点,最后递归右子树。
- 后序遍历先递归左右子树,最后访问当前节点。
4.2.2 二叉树的顺序存储与链式存储
二叉树有两种主要的存储方式:
| 存储方式 | 描述 | 优缺点 |
|---|---|---|
| 顺序存储 | 使用数组按层序存储节点 | 适合完全二叉树,空间利用率高,但稀疏树浪费空间 |
| 链式存储 | 使用指针结构,每个节点保存左右子节点指针 | 灵活,适用于任意形状的二叉树,但指针操作复杂 |
示例代码(链式存储) :
class BinaryTreeNode:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
# 构建如下二叉树
# A
# / \
# B C
# / \
# D E
root = BinaryTreeNode('A')
root.left = BinaryTreeNode('B')
root.right = BinaryTreeNode('C')
root.left.left = BinaryTreeNode('D')
root.left.right = BinaryTreeNode('E')
逻辑分析 :
- 使用
BinaryTreeNode类构建节点。 - 每个节点通过
left和right指针连接左右子节点。 - 构建的树结构清晰,便于遍历和修改。
4.2.3 二叉树的构造与线索化
构造二叉树 :通常通过前序和中序遍历结果或中序和后序遍历结果重建二叉树。
线索化二叉树 :将空指针指向某种遍历顺序下的前驱或后继节点,从而提高查找效率。
示例:由前序和中序遍历构造二叉树(LeetCode 105题) :
def build_tree(preorder, inorder):
if not preorder:
return None
root_val = preorder[0]
root = BinaryTreeNode(root_val)
index = inorder.index(root_val)
root.left = build_tree(preorder[1:index+1], inorder[:index])
root.right = build_tree(preorder[index+1:], inorder[index+1:])
return root
逻辑分析 :
- 前序遍历第一个节点为根节点。
- 在中序遍历中找到根节点位置,左边为左子树,右边为右子树。
- 递归构建左右子树。
线索化二叉树示例 (仅展示逻辑结构):
class ThreadedNode:
def __init__(self, val):
self.val = val
self.left = None
self.right = None
self.left_thread = False # 是否为线索
self.right_thread = False
4.3 堆与优先队列
堆是一种特殊的树结构,常用于实现优先队列。
4.3.1 堆的定义与基本操作
堆(Heap)是一种完全二叉树结构,满足堆性质:
- 最大堆 :任意父节点大于等于其子节点。
- 最小堆 :任意父节点小于等于其子节点。
堆的基本操作 :
- 插入元素(上浮)
- 删除根节点(下沉)
- 构建堆
- 堆排序
堆的实现(Python) :
class MinHeap:
def __init__(self):
self.heap = []
def insert(self, val):
self.heap.append(val)
self._heapify_up(len(self.heap) - 1)
def extract_min(self):
if not self.heap:
return None
min_val = self.heap[0]
last_val = self.heap.pop()
if self.heap:
self.heap[0] = last_val
self._heapify_down(0)
return min_val
def _heapify_up(self, index):
while index > 0:
parent_index = (index - 1) // 2
if self.heap[index] < self.heap[parent_index]:
self.heap[index], self.heap[parent_index] = self.heap[parent_index], self.heap[index]
index = parent_index
else:
break
def _heapify_down(self, index):
while True:
left = 2 * index + 1
right = 2 * index + 2
smallest = index
if left < len(self.heap) and self.heap[left] < self.heap[smallest]:
smallest = left
if right < len(self.heap) and self.heap[right] < self.heap[smallest]:
smallest = right
if smallest != index:
self.heap[index], self.heap[smallest] = self.heap[smallest], self.heap[index]
index = smallest
else:
break
逻辑分析 :
- 使用列表实现堆。
- 插入时通过
_heapify_up上浮元素至合适位置。 - 删除根节点后用最后一个元素替代,并通过
_heapify_down下沉。 - 所有操作时间复杂度为 O(log n)。
4.3.2 堆排序与优先队列的应用
堆排序 是一种基于堆的排序算法,其步骤如下:
- 构建最大堆
- 将堆顶元素与最后一个元素交换
- 减少堆的大小并重新调整堆
- 重复上述过程直到堆为空
应用 :
- 优先队列 :如操作系统进程调度、任务优先级管理。
- Top K 问题 :使用最小堆找出大数据集中前 K 个最大值。
- 合并多个有序链表 :利用堆优化合并过程。
堆排序实现 :
def heapify(arr, n, i):
largest = i
left = 2 * i + 1
right = 2 * i + 2
if left < n and arr[left] > arr[largest]:
largest = left
if right < n and arr[right] > arr[largest]:
largest = right
if largest != i:
arr[i], arr[largest] = arr[largest], arr[i]
heapify(arr, n, largest)
def heap_sort(arr):
n = len(arr)
# 构建最大堆
for i in range(n // 2 - 1, -1, -1):
heapify(arr, n, i)
# 提取元素
for i in range(n - 1, 0, -1):
arr[i], arr[0] = arr[0], arr[i]
heapify(arr, i, 0)
逻辑分析 :
-
heapify函数将子树调整为最大堆。 - 先从最后一个非叶子节点开始构建最大堆。
- 每次交换堆顶和最后一个元素,减少堆大小后重新调整堆。
4.4 平衡二叉树与红黑树简介
4.4.1 平衡二叉树的旋转操作
平衡二叉树(AVL Tree)是一种自平衡的二叉搜索树,任何节点的两个子树的高度差不超过1。
旋转操作 包括:
- 左旋(Left Rotation)
- 右旋(Right Rotation)
- 左右旋(Left-Right Rotation)
- 右左旋(Right-Left Rotation)
每种旋转操作用于在插入或删除后恢复平衡。
左旋实现(伪代码) :
def rotate_left(z):
y = z.right
T2 = y.left
y.left = z
z.right = T2
# 更新高度
z.height = 1 + max(get_height(z.left), get_height(z.right))
y.height = 1 + max(get_height(y.left), get_height(y.right))
return y
逻辑分析 :
- 左旋将右子树提升为新的根节点。
- 原根节点变为左子节点。
- 更新节点高度以维护平衡。
4.4.2 红黑树的基本性质与插入调整
红黑树(Red-Black Tree)是一种自平衡二叉搜索树,具有以下性质:
- 每个节点是红色或黑色。
- 根节点是黑色。
- 所有叶子节点(NIL)是黑色。
- 每个红色节点的两个子节点都是黑色。
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
插入操作后需要调整颜色和旋转 ,以恢复红黑树性质。
红黑树插入调整逻辑 :
- 插入新节点为红色。
- 若父节点为黑色,无需调整。
- 若父节点为红色,需根据叔父节点颜色进行调整:
- 叔父为红色:变色并上溯。
- 叔父为黑色:旋转并变色。
由于红黑树实现较为复杂,通常在编程语言中已有实现(如 Java 的 TreeMap )。
4.5 树形结构的典型应用与习题训练
4.5.1 文件系统的树形结构表示
操作系统中的文件系统天然具有树形结构,其中目录为内部节点,文件为叶子节点。
示例代码(模拟文件系统树) :
class FileSystemNode:
def __init__(self, name, is_dir=True):
self.name = name
self.is_dir = is_dir
self.children = []
def add_child(self, child):
self.children.append(child)
# 构建如下结构:
# root/
# ├── home/
# │ └── user/
# └── etc/
root = FileSystemNode("root", is_dir=True)
home = FileSystemNode("home", is_dir=True)
user = FileSystemNode("user", is_dir=True)
etc = FileSystemNode("etc", is_dir=True)
root.add_child(home)
root.add_child(etc)
home.add_child(user)
逻辑分析 :
- 每个节点代表一个目录或文件。
- 目录节点可包含子节点。
- 通过递归可实现文件系统浏览、搜索等操作。
4.5.2 哈夫曼编码与压缩算法
哈夫曼编码是一种基于二叉树的数据压缩算法,使用频率高的字符用较短编码,频率低的用较长编码。
实现步骤 :
- 统计字符频率。
- 构建哈夫曼树。
- 生成编码表。
- 编码数据。
哈夫曼树节点类 :
import heapq
class HuffmanNode:
def __init__(self, char, freq):
self.char = char
self.freq = freq
self.left = None
self.right = None
def __lt__(self, other):
return self.freq < other.freq
逻辑分析 :
- 使用优先队列(堆)构建哈夫曼树。
- 每次取出两个频率最小的节点合并,形成新节点。
- 重复直到队列只剩一个节点(根节点)。
4.5.3 树结构相关的算法题与解题技巧
树结构是算法面试中的高频考点,常见题目包括:
| 题目类型 | 示例题目 |
|---|---|
| 二叉树遍历 | LeetCode 94、144、145 |
| 树的高度 | LeetCode 104 |
| 对称二叉树 | LeetCode 101 |
| 二叉搜索树验证 | LeetCode 98 |
| 树的路径和 | LeetCode 112、113 |
解题技巧总结 :
- 使用递归处理子树。
- 利用栈或队列实现非递归遍历。
- 使用哈希表记录节点信息。
- 注意边界条件(空树、只有一个节点等)。
本章深入讲解了树形结构的基本概念、二叉树的操作与实现、堆与优先队列的原理与应用、平衡树的调整机制以及树结构在文件系统、压缩算法中的实际用途。下一章将进入图结构的探讨,继续深入数据结构的核心领域。
5. 图结构原理与算法实现
5.1 图的基本概念与表示方法
图(Graph)是一种非线性的数据结构,用于表示对象之间的多对多关系。图由顶点(Vertex)和边(Edge)组成,常用于建模社交网络、交通网络、互联网链接等复杂系统。
5.1.1 图的定义与分类(有向图、无向图、带权图)
图 G = (V, E),其中 V 是顶点集合,E 是边集合。
- 无向图 :边没有方向,例如朋友关系图。
- 有向图(Digraph) :边有方向,例如网页之间的链接。
- 带权图(Weighted Graph) :每条边带有权重,表示距离、成本或时间等信息。
例如,一个带权有向图可以表示城市之间的交通网络,边的权重表示两城市之间的距离。
5.1.2 图的邻接矩阵与邻接表存储方式
常见的图存储方式有两种:
1. 邻接矩阵(Adjacency Matrix)
使用一个二维数组 graph[V][V] 表示图,其中 graph[i][j] 表示顶点 i 到顶点 j 的边的权重。若无边,则设为 0 或 INF。
优点:访问边的效率高 O(1)。
缺点:空间复杂度为 O(V²),对于稀疏图浪费空间。
# 邻接矩阵表示法(无向图)
INF = float('inf')
V = 5
graph = [[INF] * V for _ in range(V)]
# 添加边
def add_edge(u, v, weight):
graph[u][v] = weight
graph[v][u] = weight # 无向图双向赋值
add_edge(0, 1, 3)
add_edge(0, 4, 5)
add_edge(1, 2, 2)
add_edge(2, 3, 1)
add_edge(3, 4, 4)
# 打印邻接矩阵
for row in graph:
print(row)
输出示例:
[inf, 3, inf, inf, 5]
[3, inf, 2, inf, inf]
[inf, 2, inf, 1, inf]
[inf, inf, 1, inf, 4]
[5, inf, inf, 4, inf]
2. 邻接表(Adjacency List)
使用列表的列表,每个顶点存储其邻接顶点及其权重。
优点:节省空间,适合稀疏图。
缺点:查找边的时间复杂度为 O(V)。
# 邻接表表示法(有向图)
from collections import defaultdict
graph = defaultdict(list)
def add_edge(u, v, weight):
graph[u].append((v, weight)) # 添加有向边 u -> v
add_edge(0, 1, 3)
add_edge(0, 4, 5)
add_edge(1, 2, 2)
add_edge(2, 3, 1)
add_edge(3, 4, 4)
# 打印邻接表
for u in graph:
print(f"{u}: {graph[u]}")
输出示例:
0: [(1, 3), (4, 5)]
1: [(2, 2)]
2: [(3, 1)]
3: [(4, 4)]
邻接矩阵 vs 邻接表对比表:
| 特性 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 空间复杂度 | O(V²) | O(V + E) |
| 插入边时间 | O(1) | O(1) |
| 查找边时间 | O(1) | O(E/V) 平均 |
| 适用场景 | 稠密图 | 稀疏图 |
5.2 图的遍历算法
图的遍历是访问图中所有顶点的过程,是图算法的基础。常见的图遍历方式有深度优先搜索(DFS)和广度优先搜索(BFS)。
5.2.1 深度优先搜索(DFS)
DFS 使用递归或栈实现,沿着一个路径尽可能深入访问节点,直到无法继续访问为止,再回溯到上一节点继续搜索。
def dfs(graph, start, visited=None):
if visited is None:
visited = set()
visited.add(start)
print(start, end=' ')
for neighbor, _ in graph[start]:
if neighbor not in visited:
dfs(graph, neighbor, visited)
# 示例调用
dfs(graph, 0)
输出示例:
0 1 2 3 4
流程图示意:
graph TD
A[DFS Start] --> B[访问节点]
B --> C{是否已访问?}
C -- 是 --> D[回溯]
C -- 否 --> E[递归访问邻居]
E --> B
5.2.2 广度优先搜索(BFS)
BFS 使用队列实现,按层访问节点,适合寻找最短路径。
from collections import deque
def bfs(graph, start):
visited = set()
queue = deque([start])
visited.add(start)
while queue:
node = queue.popleft()
print(node, end=' ')
for neighbor, _ in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
# 示例调用
bfs(graph, 0)
输出示例:
0 1 4 2 3
流程图示意:
graph TD
A[BFS Start] --> B[访问节点]
B --> C[将邻居入队]
C --> D[标记为已访问]
D --> E{队列是否空?}
E -- 否 --> B
E -- 是 --> F[结束]
5.3 图的经典算法
图的经典算法包括最短路径算法、最小生成树算法和拓扑排序等,广泛应用于网络路由、路径规划、任务调度等领域。
5.3.1 最短路径算法(Dijkstra、Floyd)
Dijkstra算法(单源最短路径)
适用于带权图,不能处理负权边。
import heapq
def dijkstra(graph, start):
dist = {u: float('inf') for u in range(5)}
dist[start] = 0
pq = [(0, start)]
while pq:
current_dist, u = heapq.heappop(pq)
if current_dist > dist[u]:
continue
for v, weight in graph[u]:
if dist[v] > dist[u] + weight:
dist[v] = dist[u] + weight
heapq.heappush(pq, (dist[v], v))
return dist
print(dijkstra(graph, 0))
输出示例:
{0: 0, 1: 3, 2: 5, 3: 6, 4: 5}
Floyd算法(全源最短路径)
使用动态规划,适用于稠密图。
def floyd(graph, V):
dist = graph
for k in range(V):
for i in range(V):
for j in range(V):
if dist[i][j] > dist[i][k] + dist[k][j]:
dist[i][j] = dist[i][k] + dist[k][j]
return dist
5.3.2 最小生成树算法(Prim、Kruskal)
Prim算法
适用于稠密图,基于邻接矩阵实现。
def prim(graph, V):
key = [float('inf')] * V
key[0] = 0
mst_set = [False] * V
parent = [None] * V
for _ in range(V):
u = min((key[i], i) for i in range(V) if not mst_set[i])[1]
mst_set[u] = True
for v in range(V):
if graph[u][v] and not mst_set[v] and key[v] > graph[u][v]:
key[v] = graph[u][v]
parent[v] = u
return parent
Kruskal算法
适用于稀疏图,基于邻接表和并查集实现。
def find(parent, i):
if parent[i] == i:
return i
return find(parent, parent[i])
def union(parent, rank, x, y):
root_x = find(parent, x)
root_y = find(parent, y)
if rank[root_x] < rank[root_y]:
parent[root_x] = root_y
else:
parent[root_y] = root_x
if rank[root_x] == rank[root_y]:
rank[root_x] += 1
def kruskal(graph, V):
result = []
i = 0
e = 0
graph = sorted(graph, key=lambda item: item[2])
parent = list(range(V))
rank = [0] * V
while e < V - 1:
u, v, w = graph[i]
i += 1
x = find(parent, u)
y = find(parent, v)
if x != y:
e += 1
result.append((u, v, w))
union(parent, rank, x, y)
return result
5.3.3 拓扑排序与关键路径
拓扑排序用于有向无环图(DAG),解决任务调度问题。
from collections import deque
def topological_sort(graph, V):
in_degree = [0] * V
for u in graph:
for v, _ in graph[u]:
in_degree[v] += 1
queue = deque([i for i in range(V) if in_degree[i] == 0])
order = []
while queue:
u = queue.popleft()
order.append(u)
for v, _ in graph[u]:
in_degree[v] -= 1
if in_degree[v] == 0:
queue.append(v)
return order if len(order) == V else []
关键路径用于项目管理,找出最长路径。
graph LR
A[开始] --> B[任务1]
B --> C[任务2]
C --> D[任务3]
D --> E[结束]
A --> F[任务4]
F --> D
简介:《数据结构习题与解析 B级 李春葆》是计算机专业学生夯实数据结构基础的重要学习资料,涵盖线性结构、树形结构、图结构及哈希表等核心内容,并详解冒泡排序、快速排序、二分查找等常用算法。本书通过大量习题与解析帮助学习者掌握动态规划、贪心算法和回溯法等高级解题策略,提升编程能力和算法设计思维。适合准备面试、项目开发及课程学习的程序员使用,是深入理解数据结构与算法的必备参考书。
更多推荐


所有评论(0)