这是本节的多页打印视图。 点击此处打印.

返回本页常规视图.

数据结构

A
B
C
D
E
A
B
C
D
E
11
14
15
22
42
5
11
14
15
22

数据结构在408试卷中占据45分,和计算机组成原理一样,是分数占比最大的科目,包含11道选择题以及两道大题。大题包含算法设计题和概念问答题,算法设计题可能是基于线性表、树或图的某个问题,让你设计算法并且给出时间和空间复杂度的分析,选择题也会均匀地涉及到各个章节的内容。

这门课理解性的内容居多,在知识掌握牢固的情况下不容易遗忘,建议大家优先复习。在复习的过程中可以与实践结合,可以去用代码实现一下一些算法和数据结构,这样可以对知识点的理解更加深刻。总的来说,数据结构中各个板块的内容都比较重要,建议大家将这些内容都理解透彻。


数据结构的考察目标包含如下内容(来自408考研大纲):

  1. 掌握数据结构的基本概念、基本原理和基本方法。
  2. 掌握数据的逻辑结构、存储结构及基本操作的实现,能够对算法进行基本的时间复杂度和空间复杂度的分析。
  3. 能够运用数据结构的基本原理和方法进行问题的分析与求解,具备采用C和C++语言设计与算法实现算法的能力。

1 - 绪论

本章在考研中一般不直接考察,需要了解时间复杂度和空间复杂度的概念,并对算法进行相关分析。

1.1 - 数据结构基本概念

了解数据结构的分类。

什么是数据结构

数据结构是数据对象的集合,以及数据对象之间的关系和在数据上的操作。

分类

  1. 线性结构:
    • 数组:具有固定大小的数据元素的集合,这些元素在存储中是连续的,并可以通过索引访问。
    • 链表:由节点组成,每个节点包含数据部分和指向下一个节点的指针。
    • 栈:后进先出 (LIFO) 的数据结构,只允许在顶部添加或删除元素。
    • 队列:先进先出 (FIFO) 的数据结构,允许在一端添加元素,并在另一端删除元素。
  2. 非线性结构
    • 树:由节点组成的分层结构,其中有一个根节点,其他节点分为多个子树。
      • 二叉树:每个节点最多有两个子节点。
      • 平衡树、搜索树、红黑树 等是树的特殊类型。
    • 图:由节点(或称为顶点)和边组成的结构。
  3. 散列结构:使用哈希函数将键转换为数组的索引,以便能够在常数时间内找到相关的值。

数据结构和算法

有效的算法通常依赖于选择合适的数据结构,而数据结构的实现和操作往往需要算法。

数据结构的选择直接影响到算法的性能。例如,数据的搜索在哈希表中可能是 O(1) 的复杂度,而在数组或链表中可能是 O(n) 的复杂度。

1.2 - 算法基本概念

需了解时间复杂度和空间复杂度的计算方法,是后续内容的基础。

什么是算法

算法 (Algorithm) 是一个明确规定了操作步骤的有限指令集,用于计算函数、处理数据、解决一个特定问题或执行某个任务。

算法的基本属性:

  1. 明确性:每一步骤必须有明确且不含糊的定义。
  2. 有输入和输出:算法应有0个或更多的输入和1个或更多的输出。输入是在算法开始之前提供的,而输出是在算法结束时产生的。
  3. 有限性:如果算法在执行完有限步骤后终止,那么它就是有限的。换句话说,一个算法必须总是在执行有限次操作后结束。
  4. 可行性:算法中的每一步都应该是简单且基本的,这样它们可以在有限的时间内完成并由计算机执行。
  5. 独立性:算法的指令应该有普适性,也就是说,它们不应依赖于任何特定的编程语言或模型。相反,算法应该足够通用,可以在任何编程环境中实现。

效率度量

时间复杂度

时间复杂度(Time Complexity)描述的是执行某个算法所需要的时间如何随着输入数据量的增长而变化。这是一个估计,通常只考虑最高阶的项,并忽略常数因子。常见的时间复杂度(按增长速度排序)有:

  • $O(1)$:常数时间。无论输入数据有多大,算法都在恒定的时间内完成。
  • $O(\log n)$:对数时间。例如:二分搜索。
  • $O(n)$:线性时间。例如:简单的搜索算法。
  • $O(n \log n)$:线性对数时间。例如:高效的排序算法,如归并排序。
  • $O(n^2)$、$O(n^3)$ …:多项式时间。例如:冒泡排序、插入排序和选择排序是 (O(n^2)$。
  • $O(2^n)$:指数时间。例如:计算斐波那契数列的递归实现。
  • $O(n!)$:阶乘时间。例如:旅行商问题的暴力解决方法。

空间复杂度

空间复杂度(Space Complexity)描述的是执行某个算法所需要的存储空间如何随着输入数据量的增长而变化。与时间复杂度类似,我们只关心增长的速度,并通常只考虑最高阶的项。

例如:

  • $O(1)$:算法使用恒定的额外空间。
  • $O(n)$:算法的存储需求与输入数据的大小成线性关系。

2 - 线性表

本章是后序内容的基础,可能会涉及到在选择题中的概念考察。除此外,需要能够手写代码实现基于数组或链表的相关操作。

学习思维导图:

# 线性表

## 线性表的基本概念

## 线性表的实现

- 顺序存储
- 链式存储

## 线性表的应用

2.1 - 定义和基本操作

线性表可分为顺序表和链表,本节讨论线性表的定义和操作。

线性表定义

线性表是L数据结构中的一种基本结构。它是零个或多个数据元素的有限序列。通常,线性表中的数据元素之间是有序的,它们之间存在着前驱和后继的关系。

$$L = (a_1, a_2, \cdots, a_i, a_{i+1}, \cdots, a_n)$$

特点:

  • 个数有限
  • 表中元素数据类型都相同,每个元素占有相同大小的存储空间
  • 仅讨论元素间的逻辑关系,表中元素有先后顺序

操作

  • 初始化 (InitList): 创建一个空的线性表。
  • 插入 (Insert): 在线性表的指定位置插入一个新的元素。
  • 删除 (Delete): 删除线性表中的指定位置的元素。
  • 查找 (LocateElem): 根据给定的条件查找线性表中的元素。
  • 获取元素 (GetElem): 获取线性表中指定位置的元素。
  • 设置元素 (SetElem): 修改线性表中指定位置的元素的值。
  • 长度 (Length): 返回线性表中的元素数量。
  • 判空 (IsEmpty): 判断线性表是否为空。
  • 清空 (ClearList): 清除线性表中的所有元素。
  • 遍历 (Traverse): 对线性表中的每个元素执行某种操作。

2.2 - 顺序表示

需熟练掌握顺序表的定义,并且能够用手写代码实现顺序表上的各种操作。

顺序表定义

顺序表(Sequential List) 是一种常见的数据结构,用于存储一组元素,并按照它们在内存中的物理顺序来排列和访问这些元素。顺序表通常由一个数组或列表构成,其中每个元素都占据一个连续的内存位置,并且可以通过索引值来访问。

假设线性表 A 存储的其实位置为LOC(A),每个元素占用的存储空间的大小为sizeof(Elem),则 A 所对应的顺序存储结构为:

a1
ai
a2
an
Sequential List A
Index
0
1
i-1
n-1
Memory Address
LOC(A)
LOC(A) + sizeof(Elem)
LOC(A) + (i-1) x sizeof(Elem)
Maxsize - 1
LOC(A) + (n-1) x sizeof(Elem)
LOC(A) + (Maxsize-1) x sizeof(Elem)
  • 顺序表 优点
    • 随机访问性强:顺序表支持通过索引直接访问元素,访问速度快,时间复杂度为O(1)。
    • 空间使用连续:顺序表中的元素存储在连续的内存块中,这有助于提高缓存的局部性,从而提高访问速度。
    • 操作简单:无需处理复杂的指针操作。
  • 顺序表 缺点
    • 固定大小或调整大小的开销:对于静态数组,大小是固定的,如果预分配的空间不足或过大,会导致内存浪费或数组溢出。动态数组可以重新分配大小,但这会增加时间和空间开销。
    • 插入和删除的时间开销:如果要在顺序表的中间插入或删除元素,可能需要移动大量的元素,时间复杂度为O(n)。

操作

#define MAXSIZE 100
typedef struct {
    ElementType data[MAXSIZE];
    int length;
} SeqList;

void InitList(SeqList *L) {
    L->length = 0;
}
bool Insert(SeqList *L, int pos, ElementType e) {
    if (L->length == MAXSIZE || pos < 1 || pos > L->length + 1) {
        return false;
    }
    for (int i = L->length; i >= pos; i--) {
        L->data[i] = L->data[i - 1];
    }
    L->data[pos - 1] = e;
    L->length++;
    return true;
}
bool Delete(SeqList *L, int pos, ElementType *e) {
    if (pos < 1 || pos > L->length) {
        return false;
    }
    *e = L->data[pos - 1];
    for (int i = pos; i < L->length; i++) {
        L->data[i - 1] = L->data[i];
    }
    L->length--;
    return true;
}
int LocateElem(SeqList L, ElementType e) {
    for (int i = 0; i < L.length; i++) {
        if (L.data[i] == e) {
            return i + 1;  // 返回元素位置,而不是下标
        }
    }
    return 0;  // 未找到返回0
}
bool GetElem(SeqList L, int pos, ElementType *e) {
    if (pos < 1 || pos > L.length) {
        return false;
    }
    *e = L.data[pos - 1];
    return true;
}
bool IsEmpty(SeqList L) {
    return L.length == 0;
}
void ClearList(SeqList *L) {
    L->length = 0;
}
int Length(SeqList L) {
    return L.length;
}

2.3 - 链式表示

需熟练掌握链表的定义,并且能够用手写代码实现链表上的各种操作。

单链表定义

线性表的链式表示通常指的是使用链表来实现线性表。链表是由一系列节点组成的,每个节点都包含一个数据元素和一个指向下一个节点的指针。这种结构允许我们动态地插入和删除元素,而不需要移动其他元素。

a1
a2
an
Linked List L
header node
NULL
  • 单链表的优点:
    • 动态分配存储空间:链表的节点是在需要时动态分配的,因此在使用过程中不需要提前分配固定大小的存储空间。这使得内存利用率更加高效,可以减少内存浪费。
    • 插入和删除方便:只要知道了某个节点的位置,就可以在O(1)的时间复杂度内插入或删除该节点。而顺序表可能需要移动大量的元素。
    • 无需预估数据大小:链表可以灵活地增长,而顺序表需要预先定义一个固定大小,或者使用动态数组实现,但重新分配和拷贝的开销可能会很大。
  • 单链表的缺点:
    • 空间开销较大:除了数据元素的存储外,每个节点还需要额外的空间存储一个指针,这增加了链表的存储开销。
    • 随机访问较慢:链表不支持直接通过索引进行随机访问,必须从头部开始逐个遍历节点,直到找到所需的节点,所以访问链表中的元素需要O(n)的时间复杂度。
    • 复杂的操作:链表需要处理更多的指针操作,容易出错。

为了方便链表的操作,常常在单链表中常常引入头结点,头结点中的指针为头指针,指向链表中的第一个结点,如果头指针指向NULL的话,则表示链表为空。

操作

// 链表定义
typedef struct Node {
    ElementType data;
    struct Node *next;
} Node;

typedef Node *LinkList;

// 初始化
LinkList InitList() {
    LinkList L = (Node *)malloc(sizeof(Node));  // 创建头结点
    if (L == NULL) exit(1);  // 内存分配失败
    L->next = NULL;  // 初始为空链表
    return L;
}
bool Insert(LinkList L, int pos, ElementType e) {
    Node *p = L;
    int i = 0;
    while (p && i < pos - 1) {  // 找到第pos-1个节点
        p = p->next;
        i++;
    }
    if (!p || i > pos - 1) return false;

    Node *s = (Node *)malloc(sizeof(Node));
    s->data = e;
    s->next = p->next;
    p->next = s;
    return true;
}
bool Delete(LinkList L, int pos, ElementType *e) {
    Node *p = L;
    int i = 0;
    while (p->next && i < pos - 1) {  // 找到第pos-1个节点
        p = p->next;
        i++;
    }
    if (!(p->next) || i > pos - 1) return false;

    Node *q = p->next;
    p->next = q->next;
    *e = q->data;
    free(q);
    return true;
}
int LocateElem(LinkList L, ElementType e) {
    Node *p = L->next;
    int i = 1;
    while (p) {
        if (p->data == e) return i;
        p = p->next;
        i++;
    }
    return 0;  // 未找到返回0
}
bool GetElem(LinkList L, int pos, ElementType *e) {
    Node *p = L->next;
    int i = 1;
    while (p && i < pos) {
        p = p->next;
        i++;
    }
    if (!p || i > pos) return false;

    *e = p->data;
    return true;
}
bool IsEmpty(LinkList L) {
    return L->next == NULL;
}
void ClearList(LinkList L) {
    Node *p = L->next, *q;
    L->next = NULL;
    while (p) {
        q = p->next;
        free(p);
        p = q;
    }
}

3 - 线性数据结构

本章以选择题形式考察,需要熟练掌握栈和队列的操作以及应用,另外还需要了解如何用数组实现栈和队列,可能会在代码题中考察。

学习思维导图:

# 栈、队列和数组

## 栈和队列基本概念

## 栈和队列的顺序存储结构

## 栈和队列的链式存储结构

## 多维数组的存储

## 特殊矩阵的压缩矩阵

## 栈、队列和数据的应用

3.1 - 栈

需熟练掌握栈的定义、操作和实现方式。

定义

栈是一个元素的集合,加入元素的操作叫做“压栈”(push),而移除元素的操作叫做“出栈”(pop)。它遵循后进先出(LIFO, Last In First Out)的原则。这意味着最后被压入栈的元素是第一个被弹出的元素。

dépilerPopempilerPush

基本操作

  • push(element): 将元素添加到栈的顶部。
  • pop(): 移除并返回栈顶的元素。如果栈为空,这个操作可能会抛出一个错误或返回特定的值(例如null或undefined),具体取决于实现。
  • peek()top(): 返回栈顶的元素但不移除它。这只是一个查看操作,栈的内容不会改变。如果栈为空,这个操作可能会抛出一个错误或返回特定的值。
  • isEmpty(): 判断栈是否为空。如果栈为空,返回true;否则返回false。
  • size()length(): 返回栈中元素的数量。

实现

顺序栈

顺序栈是使用数组来实现的栈,利用数组的索引来模拟栈的操作。

#define MAX_SIZE 100 // 定义栈的最大容量

// 定义顺序栈的结构
typedef struct {
    int data[MAX_SIZE]; // 使用数组存储数据
    int top;            // 栈顶指针
} SeqStack;
// 初始化栈
SeqStack* initStack() {
    SeqStack* stack = (SeqStack*)malloc(sizeof(SeqStack));
    if(!stack) {
        printf("Failed to allocate memory for stack\n");
        exit(1);
    }
    stack->top = -1;  // 栈顶指针初始化为-1,表示栈为空
    return stack;
}
// 入栈操作
bool push(SeqStack* stack, int value) {
    if (isFull(stack)) {
        printf("Stack is full!\n");
        return false;
    }
    stack->data[++stack->top] = value; // 先移动栈顶指针,再存放元素
    return true;
}
// 出栈操作
bool pop(SeqStack* stack, int* value) {
    if (isEmpty(stack)) {
        printf("Stack is empty!\n");
        return false;
    }
    *value = stack->data[stack->top--]; // 先取出元素,再移动栈顶指针
    return true;
}
// 获取栈顶元素
bool peek(SeqStack* stack, int* value) {
    if (isEmpty(stack)) {
        printf("Stack is empty!\n");
        return false;
    }
    *value = stack->data[stack->top];
    return true;
}
// 判断栈是否为空
bool isEmpty(SeqStack* stack) {
    return stack->top == -1;
}

// 判断栈是否已满
bool isFull(SeqStack* stack) {
    return stack->top == MAX_SIZE - 1;
}

链式栈

栈的链式存储结构通常是使用单链表来实现。在这种实现中,链表的头部代表栈顶,这样可以确保入栈和出栈操作的时间复杂度为O(1)。

// 定义链式栈的节点结构
typedef struct Node {
    int data;
    struct Node* next;
} Node;

// 定义链式栈的结构
typedef struct {
    Node* top;
} LinkedStack;
// 初始化栈
LinkedStack* initStack() {
    LinkedStack* stack = (LinkedStack*)malloc(sizeof(LinkedStack));
    if(!stack) {
        printf("Failed to allocate memory for stack\n");
        exit(1);
    }
    stack->top = NULL; // 栈顶指针初始化为NULL,表示栈为空
    return stack;
}
// 入栈操作
void push(LinkedStack* stack, int value) {
    Node* newNode = (Node*)malloc(sizeof(Node));
    if(!newNode) {
        printf("Failed to allocate memory for new node\n");
        exit(1);
    }
    newNode->data = value;
    newNode->next = stack->top;
    stack->top = newNode;
}
// 出栈操作
bool pop(LinkedStack* stack, int* value) {
    if (isEmpty(stack)) {
        printf("Stack is empty!\n");
        return false;
    }
    Node* temp = stack->top;
    *value = temp->data;
    stack->top = temp->next;
    free(temp);
    return true;
}
// 获取栈顶元素
bool peek(LinkedStack* stack, int* value) {
    if (isEmpty(stack)) {
        printf("Stack is empty!\n");
        return false;
    }
    *value = stack->top->data;
    return true;
}
// 判断栈是否为空
bool isEmpty(LinkedStack* stack) {
    return stack->top == NULL;
}

3.2 - 队列

需熟练掌握队列的定义、操作和实现方式,尤其是循环队列。

定义

队列是一种遵循先入先出 (FIFO, First In First Out) 原则的线性数据结构。元素在队尾添加,在队首删除。

Back
Front
Enqueue
Dequeue

基本操作

  • enqueue(element): 将元素添加到队列的尾部。
  • dequeue(): 从队列的头部移除并返回元素。如果队列为空,此操作可能会返回特定的值或引发错误。
  • front()peek(): 返回队首的元素但不移除它。
  • isEmpty(): 判断队列是否为空。
  • size(): 返回队列中元素的数量。

实现

顺序队列

顺序队列通常是指使用固定大小的数组来存储队列中的元素。在顺序队列中,通常有两个指标:一个是队头(front),另一个是队尾(rear)。当插入(入队)或删除(出队)元素时,这两个指标会移动。

front
rear
front
a
b
c
d
e
rear
front
e
rear
空队
5个元素入队
出队4次
再出队一次
会出现
“假溢出”

顺序队列有一个明显的问题:随着时间的推移,队列中的元素可能向数组的末尾移动,即使队列并不满,也可能无法再插入新的元素,因为队尾已经达到了数组的末尾。这种现象称为"假溢出"。

循环队列

为了解决上述问题,可以使用循环队列(也称为环形队列)。循环队列是顺序队列的一个变种,它把数组视为一个循环的结构。当队尾指标达到数组的最后一个位置并且还需要进一步移动时,它会回到数组的起始位置。

0
1
2
3
4
front
rear
0
1
2
3
4
a
b
c
d
front
rear
队空
入队4个元素,队满

需要注意的是,在循环队列中需要牺牲一个存储单元以区分队空和队满的情况。

  • front == rear 时,队列为空
  • (rear + 1) % size == front 时,队列为满
#define MAX_SIZE 100

typedef struct {
    int data[MAX_SIZE];
    int front, rear;
} CircularQueue;

// 初始化队列
void initQueue(CircularQueue* q) {
    q->front = q->rear = 0;
}
// 入队操作
bool enqueue(CircularQueue* q, int value) {
    if (isFull(q)) return false;
    q->data[q->rear] = value;
    q->rear = (q->rear + 1) % MAX_SIZE;
    return true;
}
// 出队操作
bool dequeue(CircularQueue* q, int* value) {
    if (isEmpty(q)) return false;
    *value = q->data[q->front];
    q->front = (q->front + 1) % MAX_SIZE;
    return true;
}
// 获取队首元素
bool front(CircularQueue* q, int* value) {
    if (isEmpty(q)) return false;
    *value = q->data[q->front];
    return true;
}

// 获取队尾元素
bool rear(CircularQueue* q, int* value) {
    if (isEmpty(q)) return false;
    *value = q->data[(q->rear - 1 + MAX_SIZE) % MAX_SIZE];
    return true;
}
// 判断队列是否为空
bool isEmpty(CircularQueue* q) {
    return q->front == q->rear;
}

// 判断队列是否满
bool isFull(CircularQueue* q) {
    return (q->rear + 1) % MAX_SIZE == q->front;
}

链式队列

链式队列是使用链表结构来实现的队列。它充分利用了链表的动态性质,允许队列在运行时动态地增长或缩小。链式队列通常有两个指针:一个指向队头,另一个指向队尾。

链式队列通常使用一个简单的单链表来实现,但与普通的单链表不同的是,链式队列有两个指针:一个指向链表的头部(队头),另一个指向链表的尾部(队尾)。

typedef struct Node {
    int data;
    struct Node* next;
} Node;

typedef struct {
    Node* front, *rear;
} LinkedQueue;

// 初始化队列
void initLinkedQueue(LinkedQueue* q) {
    q->front = q->rear = NULL;
}
// 入队操作
void enqueueLinked(LinkedQueue* q, int value) {
    Node* newNode = (Node*)malloc(sizeof(Node));
    newNode->data = value;
    newNode->next = NULL;
    if(isEmptyLinked(q)) {
        q->front = q->rear = newNode;
    } else {
        q->rear->next = newNode;
        q->rear = newNode;
    }
}
// 出队操作
bool dequeueLinked(LinkedQueue* q, int* value) {
    if (isEmptyLinked(q)) return false;
    Node* temp = q->front;
    *value = temp->data;
    q->front = q->front->next;
    if (q->front == NULL) q->rear = NULL;  // 当队列中最后一个元素被出队后
    free(temp);
    return true;
}
// 获取队首元素
bool frontLinked(LinkedQueue* q, int* value) {
    if (isEmptyLinked(q)) return false;
    *value = q->front->data;
    return true;
}
// 判断队列是否为空
bool isEmptyLinked(LinkedQueue* q) {
    return q->front == NULL && q->rear == NULL;
}

3.3 - 栈和队列的应用

需能够手工模拟中序和后序表达式的计算过程,包括两者之间的转换,常常在选择题中出现。

表达式种类

前序表达式(Preorder,也叫做波兰表示法)、中序表达式(Infix)和后序表达式(Postorder, 也称为逆波兰表示法)都是二叉树的遍历策略,通常用于算术表达式的表示。不同的表示方法对操作符和操作数的顺序有不同的规定。

表达式类型描述示例表达式对应的公式
前序操作符位于其操作数之前* + A B C(A + B) * C
中序操作符位于其两个操作数之间,最常见的表示法(A + B) * C(A + B) * C
后序操作符位于其操作数之后A B + C *(A + B) * C
*
+
A
B
C

上图是表达式 (A + B) * C 对应的二叉树,前序、后序表达式分别是对这棵树的 前序遍历和后序遍历。

中序表达式求值

中序表达式求值涉及到两个主要的数据结构:操作符栈与操作数栈。其核心思路是逐个读取中序表达式的字符,根据字符的类型(操作数、操作符、括号)进行不同的处理,最终得到表达式的值。

  1. 初始化两个栈
    • 操作数栈:用来存储数字。
    • 操作符栈:用来存储操作符和左括号。
  2. 逐个读取中序表达式的字符
    • 如果是 操作数(数字),则直接压入操作数栈。
    • 如果是 左括号,则直接压入操作符栈。
    • 如果是 右括号,则:
      • 从操作符栈中弹出一个操作符。
      • 从操作数栈中弹出所需的操作数。
      • 执行该操作符对应的运算。
      • 将结果压入操作数栈。
      • 重复以上步骤,直到从操作符栈中弹出一个左括号。
    • 如果是操作符(如+、-、*、/等):
      • 如果操作符栈为空,或栈顶是左括号,或当前操作符优先级高于栈顶操作符的优先级,则直接压入操作符栈。
      • 否则:
        • 从操作符栈中弹出一个操作符。
        • 从操作数栈中弹出所需的操作数。
        • 执行该操作符对应的运算。
        • 将结果压入操作数栈。
        • 重复以上步骤,直到满足上述的压栈条件。
  3. 处理完所有字符后
    • 如果操作符栈不为空,则:
      • 从操作符栈中弹出一个操作符。
      • 从操作数栈中弹出所需的操作数。
      • 执行该操作符对应的运算。
      • 将结果压入操作数栈。
      • 重复以上步骤,直到操作符栈为空。
  4. 得到结果
    • 此时,操作数栈中仅存一个数字,这就是整个中序表达式的值。

以表达式 3 + (5 * 2 - 8) 说明计算过程:

步骤输入字符操作操作符栈数据栈
13压入数据栈3
2+压入操作符栈+3
3(压入操作符栈+, (3
45压入数据栈+, (3, 5
5*压入操作符栈+, (, *3, 5
62压入数据栈+, (, *3, 5, 2
7-* 比 - 优先级高,直接压入操作符栈+, (, *, -3, 5, 2
88压入数据栈+, (, *, -3, 5, 2, 8
9)处理到(之前的所有操作符+3, 9
10用 + 运算符计算12

后序表达式求值

后序表达式求值主要依赖一个操作数栈。其核心思路是逐个读取后序表达式的元素,并根据元素的类型(操作数或操作符)进行处理。

  1. 初始化一个操作数栈
    • 用于存储后序表达式中的数字。
  2. 逐个读取后序表达式的元素
    • 如果是操作数,则直接压入操作数栈。
    • 如果是操作符(如+、-、*、/等):
      • 从操作数栈中弹出所需的操作数。注意,因为操作符是后置的,所以先弹出的数字是第二操作数,后弹出的是第一操作数。
      • 根据操作符执行相应的运算。
      • 将计算结果压入操作数栈。
  3. 完成后序表达式的读取
    • 操作数栈中的顶部元素即为整个后序表达式的值。

以表达式 3 5 2 * 8 - + 说明计算过程:

步骤输入字符操作操作数栈
13压入操作数栈3
25压入操作数栈3, 5
32压入操作数栈3, 5, 2
4*弹出两个操作数并相乘,结果压入操作数栈3, 10
58压入操作数栈3, 10, 8
6-弹出两个操作数并相减,结果压入操作数栈3, 2
7+弹出两个操作数并相加,结果压入操作数栈5

3.4 - 数组和特殊矩阵

掌握多维数组的存储方式,了解特殊矩阵的压缩存储,可能在选择题中出现概念考察以及某个矩阵元素下标的计算。

多维数组的存储

如果数组的第一个元素位于内存地址 $A$,且每个元素占用 $B$ 个字节,行列总数分别为 $R$ 和 $C$,那么数组的 $(i, j)$ 元素(其中 $i$ 是行号,$j$ 是列号)的内存地址可以计算为 $A + (i \times C + j) * B$。

例子

在C语言中定义定义如下多维数组

int a[2][3] = {{1, 2, 3}, {4, 5, 6}};

这个数组的内存布局可以视为一个一维数组,如下所示:

  +---------+---------+---------+---------+---------+---------+
  | a[0][0] | a[0][1] | a[0][2] | a[1][0] | a[1][1] | a[1][2] |
  +---------+---------+---------+---------+---------+---------+
0x100      0x104    0x108     0x10C     0x110     0x114

特殊矩阵的压缩存储

对称矩阵

什么是对称矩阵:对于矩阵$A$中的任意一个元素$a_{i, j}$都有 $a_{i, j} = a_{j ,i}$,

Produced by GNUPLOT 4.4 patchlevel 0

所以为了节省存储空间,可以使用一位数组$B$进行存储

symmtry_matrix_storage

如何计算对称矩阵中元素的下标

$A$ 中的元素 $a_{i, j}$ 在数组 $B$ 的下标

$$k = 1 + 2 + \cdots + (i-1) + j - 1 = i(i-1)/2+j-1$$

元素为

$$a_{i, j} = \begin{cases} \frac{i(i-1)}{2}+j-1,\text{ } i \ge j \\ \frac{j(j-1)}{2}+i-1,\text{ } i \lt j \end{cases}$$

三角矩阵

{\displaystyle \mathbf {L} ={\begin{bmatrix}l_{1,1}&&\cdots &&0\\l_{2,1}&l_{2,2}&&(0)&\\l_{3,1}&l_{3,2}&\ddots &&\vdots \\\vdots &\vdots &\ddots &\ddots &\\l_{n,1}&l_{n,2}&\ldots &l_{n,n-1}&l_{n,n}\end{bmatrix}}}

{\displaystyle \mathbf {U} ={\begin{bmatrix}u_{1,1}&u_{1,2}&u_{1,3}&\ldots &u_{1,n}\\&u_{2,2}&u_{2,3}&\ldots &u_{2,n}\\\vdots &&\ddots &\ddots &\vdots \\&(0)&&\ddots &u_{n-1,n}\\0&&\cdots &&u_{n,n}\end{bmatrix}}}

  • 下三角矩阵也是一个方阵,其主对角线及其以下(右下部分)的所有元素都不为零,而主对角线以上的所有元素都为零。
  • 上三角矩阵是是一个方阵,其主对角线及其以上(左上部分)的所有元素都不为零,而主对角线以下的所有元素都为常数。

如何计算三角矩阵中元素的下标

以上三角矩阵$A$为例,上半部分元素首先按序存储在一位数组$B$中,在最后一个位置添加一个元素,用于存储下三角位置对应的元素

$A$ 中的元素 $a_{i, j}$ 在数组 $B$ 的下标

$$k = n + (n+1) _ \cdots + (n-i+2) + (j-i+1) - 1 = (i-1)(2n-i+2)/2 + (j-i)$$

元素为

$$a_{i, j} = \begin{cases} \frac{(i-1)(2n-i+2)}{2}+(j-i),\text{ } i \le j(上三角区和对角线元素) \\ \frac{n(n+1)}{2},\text{ } i \gt j(下三角区元素) \end{cases}$$

三对角矩阵

$$\begin{vmatrix} a_{1,1} & a_{1,2} & & & & \\ a_{2,1} & a_{2,2} & a_{2,3} & & & \\ & a_{3,2} & a_{3,3} & a_{3,4} & & \\ & & \ddots & \ddots & \ddots & \\ & & & a_{n-1,n-2} & a_{n-1,n-1} & a_{n-1,n}\\ & & & & a_{n,n-1} & a_{n,n} \end{vmatrix}$$

三对角矩阵可以使用压缩存储,将3条对角线上的元素按照行优先的方式存放在一位数组B中。

稀疏矩阵

稀疏矩阵中非零元素的个数相比矩阵元素的总个数非常少,通常,稀疏矩阵中的非零元素的分布是没有规律的。因此,非零元素可以用一个三元组来存储(行,列,值),使用三元组组成的线性表来存储稀疏矩阵中南欧给所有的元素。

矩阵

$$M = \begin{vmatrix} 4 & 0 & 0 & 0\\ 0 & 0 & 6 & 0\\ 0 & 9 & 0 & 0\\ 0 & 23 & 0 & 0 \end{vmatrix}$$

4 - 字符串

本章可能在选择题中出现,掌握KMP算法的思想,能够手工模拟KMP过程即可。

4.1 - 定义和实现

了解字符串的不同存储结构和基本操作即可,一般不会直接考察。

串的定义

字符串是计算机科学中的一个基本概念,通常被定义为有限序列中字符的有序集合。

形式定义:

字符串 $S$ 时字符集 $\Sigma$ 上的一个有限序列:$S = a_1 a_2 \cdots a_n$,其中 $a_i \in \Sigma$,且 $n$ 为字符串的长度, 当 $n = 0$ 时,称为空字符串。

串的存储结构

1. 定长顺序存储表示

在定长顺序存储表示中,每个字符串都有固定的长度,通常用一个预定义的最大大小来表示。这种表示方法使用一个一维数组,数组的大小等于预定的最大长度。

在某些语境中(例如C语言),使用特殊字符(如 '\0')来标识字符串结束。

#define MAX_SIZE 100 // 预定义的最大大小

typedef struct {
    char data[MAX_SIZE];  // 字符数组
    int length;           // 当前字符串的长度
} FixedString;

2. 堆分配存储表示

在堆分配存储表示中,每个字符串具有其自己的长度,因此可以动态地分配存储空间。字符串在堆中分配空间,只需要那么多的空间来存储实际的字符以及结束字符或长度信息。

typedef struct {
    char *data;           // 指向动态分配空间的指针
    int length;           // 字符串的长度
} HeapString;

// 创建一个新的字符串
void initHeapString(HeapString *s, const char *str) {
    s->length = strlen(str);
    s->data = (char *)malloc((s->length + 1) * sizeof(char));
    if (s->data == NULL) {
        exit(1);  // 分配失败
    }
    strcpy(s->data, str);
}

// 释放字符串
void freeHeapString(HeapString *s) {
    free(s->data);
    s->length = 0;
}

3. 块链存储表示

块链存储表示是结合了链表与块存储的思想。串分为较小的块,每个块存储一部分字符。块之间使用指针链接。这样,整个字符串就变成了一个字符块的链表。

#define BLOCK_SIZE 4 // 为了简单起见,设块大小为4

typedef struct StringBlock {
    char data[BLOCK_SIZE];   // 块中的字符数据
    struct StringBlock *next;  // 指向下一个块的指针
} StringBlock;

typedef struct {
    StringBlock *head;      // 指向第一个块的指针
    int length;             // 字符串的总长度
} BlockString;

// 初始化块链字符串
void initBlockString(BlockString *s) {
    s->head = NULL;
    s->length = 0;
}

// 为了简化,这里只展示了数据结构定义和初始化函数。实际应用中还需要考虑其他函数,如插入、删除、销毁等。

串的基本操作

  • 赋值(Assign):
    • 将一个串的内容复制到另一个串。
  • 比较(Compare):
    • 根据字符的字典顺序比较两个串。可以判断两个串是否相等,或者一个串是否小于或大于另一个串。
  • 长度(Length):
    • 返回串的长度,即串中字符的数目。
  • 连接(Concat):
    • 将两个串连接成一个新的串。例如,将串"Hello"和串"World"连接为"HelloWorld"。
  • 子串提取(Substr):
    • 从串中提取某个指定位置开始的指定长度的字符序列作为一个新的串。例如,从串"HelloWorld"中提取从位置1开始的5个字符,得到"Hello"。
  • 插入(Insert):
    • 在串的指定位置插入另一个串。例如,在串"HelloWorld"的第6个位置插入串"Beautiful", 得到"HelloBeautifulWorld"。
  • 删除(Delete):
    • 从串中删除从指定位置开始的指定长度的字符。
  • 替换(Subsititude):
    • 将串中某个子串的所有出现替换为另一个子串。例如,将串"apple, banana, apple"中的"apple"替换为"orange",得到"orange, banana, orange"。
  • 模式匹配(Index):
    • 在串中查找另一个子串的位置。这通常使用算法如KMP、BM或Sunday来加速查找。
  • 清空(Clear):
    • 释放串的存储空间并将其设置为空串。

4.2 - 模式匹配

需熟练掌握KMP算法,包含next数组的计算,以及手工模拟主串和模式串的移动过程,可能在选择题中考察。

字符串模式匹配是计算机科学中的基础问题,主要是在一个主字符串中查找一个子字符串模式。

简单模式匹配算法

简单模式匹配算法通常指的是暴力方法(或称为朴素算法),其基本思想是逐个检查主字符串的每一个位置是否开始与子字符串匹配。

算法描述:

  1. 从主字符串的第一个字符开始匹配。
  2. 如果第一个字符匹配,那么匹配下一个字符,依此类推。
  3. 如果在任何时刻字符不匹配,重新从主字符串的下一个字符开始匹配。
  4. 如果子字符串完全匹配,返回主字符串中的开始位置。
  5. 如果到达主字符串的末尾都没有完全匹配的子字符串,则返回-1。
int simplePatternMatching(const char* mainStr, const char* pattern) {
    int m = strlen(mainStr);
    int n = strlen(pattern);

    // 如果主字符串的长度小于模式字符串的长度,直接返回-1
    if (m < n) return -1;

    for (int i = 0; i <= m - n; i++) {
        int j;
        for (j = 0; j < n; j++) {
            if (mainStr[i + j] != pattern[j]) {
                break;
            }
        }
        // 如果j等于模式串的长度,说明已经找到匹配
        if (j == n) return i;
    }
    return -1;  // 没有找到匹配
}

这个简单模式匹配算法的时间复杂度是 $O(mn)$ ,其中 $m$ 是主字符串的长度, $n$ 是模式字符串的长度。

KMP算法

与简单模式匹配算法不同,KMP算法在发现不匹配的字符时能够避免不必要的比较,从而提高效率。

基本原理

简单模式匹配时间复杂度过高,因为每一次匹配失败都得从下一个位置重新开始。这就没有充分应用模式串的特性,比如对于字符串 abcdabc,我们可以发现:

  • 其前缀包含a, ab, abc, abcd
  • 起后缀包含c, bc, abc, dabc

abc是在其前缀和后缀中都包含的部分,在字符串匹配时,我们可以充分利用该信息,匹配失败时,我们可以不用从下一个位置开始,而是从模式串内部的某个位置开始。

KMP算法就是基于这个思想,其核心是一个称为“部分匹配表”或“前缀函数”的辅助数组(通常称为next数组),该数组用于确定当模式串与主串不匹配时应该如何有效地移动模式串。

算法描述:

  • 根据模式串计算next数组。
  • 使用next数组,对主串和模式串进行比较。
  • 在发现不匹配的情况下,利用next数组调整模式串的位置。

next数组计算

next数组的计算方式如下:

$$next[j] = \begin{cases} 0 &\text{if } j = 1 \\ max\{k \text{ | } 1 \lt k \lt j\ \text{ and }\ p_1 \cdots p_{k} = p_{j-k+1} \cdots p_{j}\} &\text{if k exists}\\ 0 &\text{if k not exists} \end{cases}$$

如果模式串为pattern,用通俗的话来说,next[j]就是字串pattern[0:k](pattern的前k个字符构成的子串)的最长相同前缀和后缀的长度。

根据next数组调整位置

i表示主串当前下标,用j表示模式串当前下标:

  • 如果main[i] == pattern[j],将ij向后向后移动一位。
  • 如果main[i] != pattern[j]
    • 如果j == 0,将i向后移动一位。
    • 如果j != 0,将j移动到next[j-1]

以下图为例,说明主串 “ababcabcabababd” 和 模式串 “ababd” 的匹配过程。

index
0
1
2
3
4
char
a
b
a
b
d
next
0
0
1
2
0
字符串 "ababd" 对应的next数组
a
b
a
b
c
a
b
c
a
b
a
b
a
b
d
string
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
a
b
a
b
d
i
j
在 i = 4, j = 4 时匹配失败,
next[j-1] = next[3] = 2,
移动 j = 2
a
b
a
b
d
j
在 i = 4, j = 2 时匹配失败,
next[j-1] = next[1] = 2,
移动 j = 0
a
b
a
b
d
j
在 i = 4, j = 0 时匹配失败,
j=0,移动 i = i+1 = 5
a
b
a
b
c
a
b
c
a
b
a
b
a
b
d
string
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
i
a
b
a
b
d
j
在 i = 5,j = 0 时匹配失败,
j=0,移动 i++, j++
i = 6, j = 1
pattern
pattern
void computeNextArray(const char* pattern, int m, int* next) {
    int len = 0;
    next[0] = 0;
    int i = 1;

    while (i < m) {
        if (pattern[i] == pattern[len]) {
            len++;
            next[i] = len;
            i++;
        } else {
            if (len != 0) {
                len = next[len - 1];
            } else {
                next[i] = 0;
                i++;
            }
        }
    }
}

int KMP(const char* mainStr, const char* pattern) {
    int m = strlen(mainStr);
    int n = strlen(pattern);

    int next[n];
    computeNextArray(pattern, n, next);

    int i = 0, j = 0;
    while (i < m) {
        if (pattern[j] == mainStr[i]) {
            i++;
            j++;
        }

        if (j == n) {
            return i - j;
        } else if (i < m && pattern[j] != mainStr[i]) {
            if (j != 0)
                j = next[j - 1];
            else
                i++;
        }
    }

    return -1; // 没有找到匹配
}

视频讲解

5 - 树与二叉树

本章在选择题中考察,需熟练掌握树的各种概念,并且能够手工模拟基于树的各种算法流程。

学习思维导图

# 树和二叉树

## 树的基本概念

## 二叉树

- 定义和主要特性
- 顺序存储结构和链式存储结构
- 遍历
- 线索二叉树的基本概念和构造

## 树、森林

- 树的存储结构
- 森林和二叉树的转化
- 树和森林的遍历

## 树和二叉树的应用

- 哈夫曼树和哈夫曼编码
- 并查集及其应用

5.1 - 树

需掌握树的概念和存储结构,以及与森林和二叉树之间的转换,在选择题中考察。

基本概念

  1. 双亲、兄弟、孩子
  2. 结点的度:结点的孩子数量
  3. 树的度:等于树中所有结点度的最大值
  4. 分支结点(非终端结点):度大于0的结点
  5. 叶子结点(终端结点):度等于0的结点
  6. 树的高度:树的结点有几层
  7. 节点间路径长度:从一个结点到另一个结点需要经过的边的个数(用边连接两个结点)
  8. 森林:m (m ≥ 0) 颗互不相交的数的集合

树的存储结构

1. 双亲表示法

双亲表示法主要是使用一个数组,其中每个结点都有一个指示其双亲结点在数组中位置的索引。

#define MAXSIZE 100
typedef struct {
    int data;         // 节点数据
    int parent;       // 双亲的位置
} PTNode;

typedef struct {
    PTNode nodes[MAXSIZE];  // 结点数组
    int n;                  // 结点数
} PTree;
A
B
C
D
E
F
G
H
I
J
A
-1
B
0
C
0
D
0
E
1
F
1
G
3
H
6
I
6
J
6
Data
Parent
0
1
2
3
4
5
6
7
8
9
A
B
C
D
E
F
G
H
I
J
Tree
Data Structure
Representation

2. 孩子表示法

孩子表示法将每个结点的孩子结点排列起来,以单链表作为存储结构。然后再用一个数组与之相配合。

#define MAXSIZE 100

// 孩子结点
typedef struct ChildNode {
    int child;                   // 孩子结点在数组中的位置
    struct ChildNode* next;     // 下一个孩子
} *ChildPtr;

// 表头结构
typedef struct {
    int data;                   // 结点数据
    ChildPtr firstchild;        // 第一个孩子的指针
} CTBox;

typedef struct {
    CTBox nodes[MAXSIZE];       // 结点数组
    int n;                      // 结点数
} CTree;
A
B
C
D
E
F
G
H
I
J
Tree
0
A
1
B
2
C
3
D
4
E
5
F
6
G
7
H
8
I
9
J
1
4
2
5
3
6
7
8
9
Representation

3. 孩子兄弟表示法

孩子兄弟表示法是将树转化为二叉树的形式来存储。每个结点有两个指针,一个指向它的第一个孩子,另一个指向它的右兄弟。

typedef struct CSNode {
    int data;                     // 结点数据
    struct CSNode* firstchild;   // 第一个孩子
    struct CSNode* rightsib;     // 右兄弟
} CSNode, *CSTree;
A
B
C
D
E
F
G
H
I
J
Tree
A
^
B
^
E
^
F
^
C
D
^
^
H
^
I
^
J
^
G
^
Representation

树、森林和二叉树的转换

树转化为二叉树

  • 若树的根结点有孩子,那么第一个孩子是二叉树的左孩子,其他的孩子结点依次作为前一个孩子结点的右孩子。
  • 对每个孩子执行上述步骤。
A
B
C
D
E
F
G
A
B
C
D
E
F
G
树转化为二叉树

森林转二叉树

  • 把森林中的每一棵树转换为二叉树。
  • 第一棵二叉树不动,从第二棵二叉树开始,依次将后一棵二叉树的根作为前一棵二叉树的右孩子。其结果是一个二叉树。
A
B
C
D
E
F
G
H
I
A
B
C
D
E
F
G
H
I
A
B
C
D
E
F
G
H
I
森林转化为二叉树

树和森林的遍历

树的遍历

对于一个给定的树,通常有以下两种遍历方式:

  1. 先根遍历 (类似于二叉树的前序遍历):
    • 访问树的根结点。
    • 递归地先根遍历根的每一棵子树。
  2. 后根遍历 (类似于二叉树的后序遍历):
    • 递归地后根遍历根的每一棵子树。
    • 访问树的根结点。

注意树是没有中根遍历的,除非这棵树是二叉树。

#define MAXCHILD 20

typedef struct TreeNode {
    int value;
    int numChildren; // 子节点的数量
    struct TreeNode *children[MAXCHILD]; // 子节点指针数组
} TreeNode;
void preOrderTraversal(TreeNode* root) {
    if (root == NULL) {
        return;
    }
    // 先访问根节点
    // 然后遍历子节点
    for (int i = 0; i < root->numChildren; ++i) {
        preOrderTraversal(root->children[i]);
    }
}
void postOrderTraversal(TreeNode* root) {
    if (root == NULL) {
        return;
    }
    
    // 先遍历子节点
    for (int i = 0; i < root->numChildren; ++i) {
        postOrderTraversal(root->children[i]);
    }
    // 然后访问根节点
}

对于上图所示的树:其先根遍历为 A, B, E, F, C, D, G,后根遍历为E, F, B, C, G, D, A

观察可以得到如下结论:

  • 树的先根遍历和其对应的二叉树的先序遍历相同
  • 树的后根遍历和其对应的二叉树的中序遍历相同

森林的遍历

对于一个给定的森林,遍历方式如下:

  1. 先根遍历 (与树的先根遍历相似):依次先根遍历森林中的每一棵树。
  2. 后根遍历 (与树的后根遍历相似):依次后根遍历森林中的每一棵树。
  3. 中根遍历(普通的树构成的森林是不存在中序遍历的,这里的中序遍历指代的是二叉树森林):依次中序遍历森林中的每一棵二叉树。

对于上图所示的森林:其先根遍历为 A, B, C, D, E, F, G, H, I,后根遍历为 B, C, D, A, F, E, H, I, G,中根遍历为 B, C, D, A, F, E, H, I, G

观察可以得到如下结论:

  • 森林的先根遍历和其对应的二叉树的先序遍历相同
  • 森林的中根遍历和其对应的二叉树的中序遍历相同

5.2 - 二叉树

重点内容,需熟练掌握多种二叉树的概念,在选择题中会考察,在大题中也可能出相关的概念题。

二叉树存储结构

  1. 链式存储结构:
    • 链式存储是最常见的二叉树存储方式。在链式存储中,每个节点都包含一个数据元素以及指向其左子树和右子树的指针。
    • 链式存储结构适用于表示任意形状和大小的二叉树,并且对于树的动态操作(插入、删除节点)非常方便。
  2. 顺序存储结构(数组表示):
    • 顺序存储结构使用数组来表示二叉树。通常,数组的索引与二叉树的节点之间存在特定的关系,例如,对于索引 $i$ 的节点,其左子节点在索引 $2i+1$ 处,右子节点在索引 $2i+2$ 处,父节点在索引 $(i-1)$ / $2$ 处。
    • 顺序存储结构通常用于堆数据结构(如二叉堆)的实现,其中对于堆的性质要求,使得数组表示变得非常有效。
typedef struct TreeNode {
    ElementType data;
    struct TreeNode* left;
    struct TreeNode* right;
} TreeNode;
#define MAX_SIZE 100

ElementType tree[MAX_SIZE];

ElementType lchild(int index) {
    return tree[2 * i + 1];
})

ElementType rchild(int index) {
    return tree[2 * i + 2];
}

特殊的二叉树

Full Binary Tree
A
B
C
D
E
F
G
Complete Binary Tree
A
B
C
D
E
  • 满二叉树(Full Binary Tree)
    • 每个节点要么没有子节点,要么有两个子节点。
    • 每一层都被完全填满。
    • 高度为 $h$ 的满二叉树节点数目为 $2^h - 1$。
  • 完全二叉树(Complete Binary Tree)
    • 由满二叉树通过删除最后一层的一些节点而得到的。
    • 除了最后一层外,所有其他层都是完全填满的,而且最后一层的节点都集中在左侧。
    • 结点数量为 $n$ 的完全二叉树的高度为 $log_2^{}{(n+1)}$。

遍历方式

  • 前序:根左右
  • 中序:左根右
  • 后续:左右根
void preorderTraversal(struct TreeNode* root) {
    if (root == NULL) {
        return;
    }
    // operation here
    preorderTraversal(root->left); // 遍历左子树
    preorderTraversal(root->right); // 遍历右子树
}
void inorderTraversal(struct TreeNode* root) {
    if (root == NULL) {
        return;
    }
    inorderTraversal(root->left); // 遍历左子树
    // operation here
    inorderTraversal(root->right); // 遍历右子树
}
void inorderTraversal(struct TreeNode* root) {
    if (root == NULL) {
        return;
    }
    inorderTraversal(root->left); // 遍历左子树
    inorderTraversal(root->right); // 遍历右子树
    // operation here
}
// 层次遍历二叉树
void levelOrderTraversal(struct TreeNode* root) {
    if (root == NULL) {
        return;
    }

    struct Queue* queue = createQueue();
    enqueue(queue, root);

    while (queue->front != NULL) {
        struct TreeNode* current = dequeue(queue);
        // operation here

        if (current->left != NULL) {
            enqueue(queue, current->left);
        }

        if (current->right != NULL) {
            enqueue(queue, current->right);
        }
    }
}

线索二叉树

Threaded Binary Tree
A
B
C
D
E
F
G
H
I

线索二叉树(Threaded Binary Tree)是一种对普通二叉树进行优化的数据结构。它的主要目的是在二叉树上添加一些线索(thread),使得在遍历树的过程中可以更加高效地找到前驱和后继节点。线索二叉树在某些应用中能够提高对树的遍历性能,尤其是中序遍历。

线索二叉树中的每个节点都可以包含以下线索信息:

  • 前驱线索(Inorder Predecessor Thread): 如果节点有左子树为空,那么它的左子树指针可以指向其前驱节点,即在中序遍历中位于它之前的节点。
  • 后继线索(Inorder Successor Thread): 如果节点有右子树为空,那么它的右子树指针可以指向其后继节点,即在中序遍历中位于它之后的节点。

线索二叉树的主要优点是,在不使用递归或栈的情况下,可以通过线索信息直接找到下一个要访问的节点,从而提高了中序遍历的效率。这对于需要频繁进行中序遍历的应用非常有用,例如数据库中的 B 树和 AVL 树的实现。

// ltag = 0 时表示 lchild 域指向节点的左孩子,ltag = 1 时表示 lchild 域指向节点的前驱
// rtag = 0 时表示 rchild 域指向节点的右孩子,rtag = 1 时表示 rchild 域指向节点的后继
typedef struct ThreadNode {
    ElementType data;
    struct ThreadNode *lchild, *rchild;
    int ltag, rtag;
} ThreadNode;
// 中序遍历,找到中序遍历的第一个节点,然后遍历 rchild 即可
void InOrder(ThreadNode *n) {
    ThreadNode *p = n;
    while (p->lchild) {
        p = p->lchild;
    }
    for (; p != NULL; p = p->rchild) {
        visit(p);
    }
}

常见操作

int getHeight(struct TreeNode* node) {
    if (node == NULL) {
        return 0;
    }
    return max(getHeight(node->left), getHeight(node->right)) + 1;
}

二叉树构建

从前序、中序、后序序列中选两个构造二叉树

  1. 前序 + 中序:这是最常见的组合,因为前序确定了根节点的位置,而中序则可以区分哪些节点在左子树,哪些在右子树。有了这两个信息,就可以唯一确定一棵二叉树。
  2. 后序 + 中序:后序确定了根节点的位置(它是后序遍历的最后一个节点),而中序同样可以帮助我们区分左、右子树。因此,这两者也可以唯一确定一棵二叉树。
  3. 前序 + 后序:前序和后序遍历是不足以唯一确定一个二叉树的。原因是前序和后序遍历本身不足以提供足够的信息来唯一确定一个树的结构。

例子

前序遍历为:ABDCE,中序遍历为:BDACE,树的结构如下:

    A
   / \
  B   C
   \   \
    D   E

我们如何从这两个遍历中恢复这棵树?

  • 前序遍历的第一个元素 A 是根节点。
  • 在中序遍历中, A 将序列分为两部分: BDCE 。左边部分 BD 对应于左子树,右边部分 CE 对应于右子树。
  • 在前序遍历中,继 A 之后的 BD 对应于左子树,而 CE 对应于右子树。
  • 对于左子树,其前序遍历为 BD ,中序遍历为 BD 。从这可以确定 B 是左子树的根,而 D 是它的右孩子。
  • 对于右子树,其前序遍历为 CE ,中序遍历为 CE 。从这可以确定 C 是右子树的根,而 E 是它的右孩子。

BST

二叉排序树,也称为二叉查找树(Binary Search Tree, BST),是一种特殊的二叉树。它或者是一棵空树,或者是满足以下性质的二叉树:

  1. 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值。
  2. 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值。
  3. 它的左、右子树也分别为二叉排序树。

二叉查找树的 主要操作 有:

  1. 插入:从根节点开始,如果待插入的值小于当前节点的值,就将其插入到左子树中,否则插入到右子树中。
  2. 查找:从根节点开始,如果待查找的值小于当前节点的值,就在左子树中查找,否则在右子树中查找。
  3. 删除:有三种情况:
    • 如果是叶子节点,直接删除。
    • 如果只有一个子节点,删除它并将其子节点连接到它的父节点。
    • 如果有两个子节点,找到其右子树的最小值或左子树的最大值来替换该节点,然后删除那个节点。
typedef struct TreeNode {
    int data;
    struct TreeNode* left;
    struct TreeNode* right;
} TreeNode;

// 创建新节点
TreeNode* newNode(int data) {
    TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
    node->data = data;
    node->left = NULL;
    node->right = NULL;
    return node;
}
// 插入新节点
TreeNode* insert(TreeNode* root, int data) {
    if (root == NULL) {
        return newNode(data);
    }
    if (data < root->data) {
        root->left = insert(root->left, data);
    } else if (data > root->data) {
        root->right = insert(root->right, data);
    }
    return root;
}
// 查找节点
TreeNode* search(TreeNode* root, int data) {
    if (root == NULL || root->data == data) {
        return root;
    }
    if (data < root->data) {
        return search(root->left, data);
    }
    return search(root->right, data);
}
// 删除节点
TreeNode* deleteNode(TreeNode* root, int data) {
    if (root == NULL) {
        return root;
    }
    if (data < root->data) {
        root->left = deleteNode(root->left, data);
    } else if (data > root->data) {
        root->right = deleteNode(root->right, data);
    } else {
        // 节点有一个或没有子节点
        if (root->left == NULL) {
            TreeNode* temp = root->right;
            free(root);
            return temp;
        } else if (root->right == NULL) {
            TreeNode* temp = root->left;
            free(root);
            return temp;
        }

        // 节点有两个子节点,找到右子树的最小节点
        TreeNode* temp = findMin(root->right);

        // 复制右子树的最小节点的值
        root->data = temp->data;

        // 删除右子树的最小节点
        root->right = deleteNode(root->right, temp->data);
    }
    return root;
}

AVL

平衡二叉树(Balanced Binary Tree),也叫做 AVL 树。它的特点是任意节点的左右子树高度差(平衡因子)不超过 1,这确保了树的高度始终保持在 $O(log_2 n)$ 的水平,使得查找、插入和删除操作的时间复杂度都保持在 $O(log_2 n)$。

理解 AVL 的关键在于理解 AVL 的旋转过程,以下针对几个疑问带大家迅速了解关键知识:

  1. 为什么需要旋转?

我们时常需要对 AVL 中的结点进行插入和删除操作,这些操作可能导致 AVL 中的某个子树进入不平衡的状态。 通过旋转操作可以使得不平衡的子树 和 整棵 AVL 树 都变得重新平衡。

  1. 从哪个节点开始旋转?

从最小不平衡子树的根节点旋转,旋转操作会使得最小不平衡子树从不平衡变得平衡。

理解这句话你需要掌握以下概念:

  • 平衡因子:每个节点的平衡因子是其左子树的高度减去右子树的高度
  • 最小不平衡子树:最小不平衡子树是指在整棵二叉树中,高度差(平衡因子的绝对值)最小的子树,该子树的平衡因子绝对值超过 1,即它是导致整棵树不平衡的最小子树。
  1. 如何旋转?

执行插入操作时,如果该次插入使得 AVL 不平衡的话,首先找到最小不平衡子树根节点(用 A 表示该节点)的位置,然后根据插入节点(用 N 表示)相对于 A 的位置,可能有四种不同的旋转操作:

  1. LL,N 在 A 的左子树的左子树中(A 的平衡因子 +2,A 的左子树根节点平衡因子 +1):A 右旋
  2. RR,N 在 A 的右子树的右子树中(A 的平衡因子 -2,A 的右子树根节点平衡因子 -1):A 左旋
  3. LR,N 在 A 的左子树的右子树中(A 的平衡因子 +2,A 的左子树根节点平衡因子 -1):A 的左子树左旋,然后 A 右旋
  4. RL,N 在 A 的右子树的左子树中(A 的平衡因子 -2,A 的右子树根节点平衡因子 +1):A 的右子树右旋,然后 A 左旋
A
B
(+1)
(0)
BL
BR
AR
h
A
B
(+2)
(+1)
BL
BR
AR
h
h+1
X
X
Insert X into BL
LL Rotation
B
A
BR
AR
BL
h+1
X
h
h
h
(0)
(0)
A
B
(-1)
(0)
BL
BR
AL
h
h
Insert X into BR
A
B
(-2)
(-1)
BL
BR
AL
h
X
h
h+1
RR Rotation
B
A
AL
BL
h
(0)
(0)
BR
X
h+1
A
B
C
BL
CL
CR
(0)
(+1)
(0)
h
h-1
h
AR
Insert X into CR
A
B
C
BL
CL
CR
(-1)
(+2)
(-1)
h
h-1
h
AR
X
h
LR Rotation
C
A
B
BL
h
CL
CR
X
h
h
AR
h-1
(0)
(+1)
A
B
C
CL
CR
(0)
h-1
BR
h
h
(0)
(-1)
Insert X into CR
AL
A
B
C
CL
CR
(-1)
h-1
BR
h
(-2)
AL
X
h
RL Rotation
C
A
B
BR
h
CR
X
h
h
AL
CL
h-1
(+1)
(+1)
LL Rotation
RR Rotation
LR Rotation
RL Rotation
(0)
(0)
(0)

AVL 平衡旋转的代码实现如下所示,过程比较繁琐,了解即可,重点在与如何掌握如何 “人脑” 模拟旋转过程。

struct TreeNode {
    int data;
    struct TreeNode* left;
    struct TreeNode* right;
    int height; // 节点高度
};
struct TreeNode* leftRotate(struct TreeNode* x) {
    struct TreeNode* y = x->right;
    struct TreeNode* T2 = y->left;

    // 执行旋转
    y->left = x;
    x->right = T2;

    // 更新高度
    updateHeight(x);
    updateHeight(y);

    return y;
}
struct TreeNode* rightRotate(struct TreeNode* y) {
    struct TreeNode* x = y->left;
    struct TreeNode* T2 = x->right;

    // 执行旋转
    x->right = y;
    y->left = T2;

    // 更新高度
    updateHeight(y);
    updateHeight(x);

    return x;
}
// 计算以 node 为根节点的子树高度
int getHeight(struct TreeNode* node) {
    if (node == NULL) {
        return 0;
    }
    return node->height;
}

// 获取平衡因子
int getBalanceFactor(struct TreeNode* node) {
    if (node == NULL) {
        return 0;
    }
    return getHeight(node->left) - getHeight(node->right);
}
// 在插入时需要使用该操作,重新计算子树高度
void updateHeight(struct TreeNode* node) {
    int leftHeight = getHeight(node->left);
    int rightHeight = getHeight(node->right);
    node->height = (leftHeight > rightHeight ? leftHeight : rightHeight) + 1;
}
struct TreeNode* insert(struct TreeNode* node, int data) {
    // 步骤 1:执行标准 BST 插入
    if (node == NULL) {
        return createNode(data);
    }

    if (data < node->data) {
        node->left = insert(node->left, data);
    } else if (data > node->data) {
        node->right = insert(node->right, data);
    } else { // 如果键值相等,则不插入
        return node;
    }

    // 步骤 2:更新节点的高度
    updateHeight(node);

    // 步骤 3:获取节点的平衡因子
    int balanceFactor = getBalanceFactor(node);

    // 步骤 4:平衡调:根据新插入结点的位置 相对于 最小不平衡子树根节点的位置进行旋转
    // LL 型:右旋
    if (balanceFactor > 1 && data < node->left->data) {
        return rightRotate(node);
    }
    // RR 型:左旋
    if (balanceFactor < -1 && data > node->right->data) {
        return leftRotate(node);
    }
    // LR 型:先左旋,再右旋
    if (balanceFactor > 1 && data > node->left->data) {
        node->left = leftRotate(node->left);
        return rightRotate(node);
    }
    // RL 型:先右旋,再左旋
    if (balanceFactor < -1 && data < node->right->data) {
        node->right = rightRotate(node->right);
        return leftRotate(node);
    }

    // 返回未被修改的节点指针
    return node;
}

红黑树

为了保证 AVL 的平衡性,插入和删除操作后,非常频繁地调整全树整体拓扑结构,代价很大。为此在 AVL 树的平衡标准上进一步放宽条件,引入了红黑树的结构。

Red-black_tree_example_with_NIL

红黑树的考察不会很深,了解以下概念即可:

  1. 从根结点到叶结点的最长路径不大于最短路径的 2 倍。
  2. 根节点和叶结点是黑色的。
  3. 不存在两个相邻的红结点。
  4. 对每个结点,从该结点到任一叶结点的简单路径上,所含黑结点的数量相同。

视频讲解

5.3 - 树的应用

需熟练掌握哈夫曼树的构建过程,可能在选择题或大题中考察,另外需了解并查集的概念。

哈夫曼树

哈夫曼树(Huffman Tree)是一种特殊的二叉树,通常用于数据压缩算法中,特别是用于构建哈夫曼编码(Huffman Coding)。哈夫曼树的主要目标是实现数据的无损压缩,通过赋予不同的数据符号不同长度的编码来减少数据的存储空间。

以下是哈夫曼树的关键特点和构建过程:

特点

  1. 哈夫曼树是一棵二叉树,通常是带权二叉树,其中每个叶子节点都对应一个数据符号,而每个内部节点都没有数据,只有权值。
  2. 哈夫曼树的叶子节点的权值通常表示数据符号的出现频率,而内部节点的权值等于其子节点权值之和。
  3. 哈夫曼树的构建目标是找到一棵树,使得权值较高的数据符号拥有较短的编码,权值较低的数据符号拥有较长的编码。

构建过程

  1. 创建一个包含所有数据符号的森林(初始状态下,每个数据符号都是一棵单节点树)。
  2. 从森林中选择两棵树,这两棵树的权值最小。将它们合并为一棵新的树,新树的权值为两棵树的权值之和。
  3. 将新的树放回森林中,重复步骤2,直到森林中只剩下一棵树,这棵树就是哈夫曼树。
  4. 构建好的哈夫曼树具有一个重要的性质:权值较高的数据符号在树中的深度较浅,而权值较低的数据符号在树中的深度较深。

一旦构建了哈夫曼树,就可以生成数据符号的哈夫曼编码。哈夫曼编码是一种变长编码,用于表示不同数据符号。在哈夫曼编码中,权值较高的数据符号通常对应较短的编码,权值较低的数据符号对应较长的编码。这种编码方式可以实现数据的高效压缩和解压缩。

哈夫曼树和哈夫曼编码在数据压缩领域具有广泛的应用,例如在无损压缩算法中,如ZIP文件压缩,图像压缩(如JPEG)等。通过构建适用的哈夫曼树和编码,可以大幅减少数据的存储和传输成本。

哈夫曼编码

遍历哈夫曼树,为每个数据符号生成相应的哈夫曼编码。编码的生成方式如下(左0右1):

  • 向左走时添加一个0位。
  • 向右走时添加一个1位。
  • 沿着树的路径一直到达叶子节点时,即可生成该叶子节点对应的数据符号的编码。

实例

为a, b, c, d四个字母生成哈夫曼编码,其对应的权值分别为7, 5, 2, 4

a
b
c
d
7
5
2
4
a
b
7
5
c
d
6
a
b
7
c
d
11
a
b
c
d
18
0
0
0
1
1
1

并查集

并查集(Union-Find)是一种数据结构,主要用于解决集合划分及查询问题。它主要支持两种操作:查找(Find)和合并(Union)。其核心思想是使用一个数组(或其他数据结构)来存储每个元素的父节点信息。

查找

查找操作的目的是找到给定元素所属集合的代表。这可以通过追踪父节点来实现,直到找到根元素(即父节点为其自身的元素)。路径压缩可以在查找过程中应用,使得从指定节点到其根的路径上的每个节点都直接指向根,从而提高后续查找的效率。

合并

合并操作的目的是将两个集合合并为一个集合。为了执行合并,首先使用Find操作找到两个集合的代表,然后决定哪个代表成为新的根。为了保持树的平衡性,并减少查找时间,常用的策略是按秩合并。其中,秩通常表示树的高度。较低的树会被附加到较高的树的根上。

0
6
7
8
1
6
8
2
3
5
S1
S2
S3
index
0
1
2
3
4
5
6
7
8
9
parent
0
1
2
2
1
2
0
0
0
1
Array Representation of S1, S2, S3
0
6
7
8
1
6
8
2
3
5
S3
index
0
1
2
3
4
5
6
7
8
9
parent
0
0
2
2
1
2
0
0
0
1
After union  S1, S2

代码实现

#define MAXN 1000

int parent[MAXN];  // 存储每个点的父节点
int rank[MAXN];    // 秩

// 初始化
void initialize(int n) {
    for (int i = 0; i < n; i++) {
        parent[i] = i;  // 初始时,每个元素的父节点是其自身
        rank[i] = 0;    // 初始时,每个元素的秩为0
    }
}

// 查找
int find(int x) {
    if (parent[x] != x) {
        // 路径压缩
        parent[x] = find(parent[x]);
    }
    return parent[x];
}

// 合并
void unionSet(int x, int y) {
    int rootX = find(x);
    int rootY = find(y);
    if (rootX != rootY) {
        if (rank[rootX] > rank[rootY]) {
            parent[rootY] = rootX;
        } else if (rank[rootX] < rank[rootY]) {
            parent[rootX] = rootY;
        } else {
            parent[rootY] = rootX;
            rank[rootX]++;
        }
    }
}

6 - 图

本章在选择题中考察,也有可能作为一道概念题在大题中出现,需要熟练掌握图的存储结构(代码实现),并且要求在概念上理解图的应用,要求能够手工模拟。

学习思维导图:

# 图

## 图的基本概念

## 图的存储结构及基本操作

- 邻接矩阵
- 邻接表
- 邻接多重表、十字链表

## 图的遍历

- 深度优先搜索
- 广度优先搜索

## 图的基本应用

- 最小生成树
- 最短路径
- 拓扑排序
- 关键路径

6.1 - 定义和操作

需熟练掌握图的定义以及邻接矩阵和邻接表的表示方式,常常在大题中考察。

图的定义

图是由顶点和边组成的非线性数据结构。顶点有时也被称为节点,而边是连接图中任意两个节点的线或弧。更正式地说,图是由一组顶点(V)和一组边(E)组成的。图用G(E, V)表示。

5
1
4
3
5
1
4
3
Undirected Graph
Directed Graph
  • 有向图(directed graph):边是有方向的,从一个定点指向另一个定点
  • 无向图(undirected graph):边是没有方向的
5
1
4
3
5
1
4
3
Connected Graph
Unconnected Graph
5
1
4
3
Complete Graph
  • 连通图(Connected Graph):图中的每一对不同顶点都可以通过一条或多条边相互连接,也就是说,从图中的任意一个顶点出发,都可以到达图中的任意其他顶点。
  • 非连通图(Disconnected Graph):图中存在两个或多个互不相连的子图,也就是说,其中至少存在一个顶点集合,无法通过边连接到图中的其他顶点集合。
  • 完全图(Complete Graph):完全图是一种特殊的图,其中每一对不同的顶点都直接相连,也就是说,完全图中的任意两个顶点之间都存在一条边。如果一个完全图有n个顶点,那么它将有 $C(n, 2) = n(n-1)/2$ 条边,其中 $C(n, 2)$ 表示从n个顶点中选择2个顶点的组合数。
A
D
C
Undirected Graph G
E
B
I
J
F
G
H
A
D
C
E
B
I
J
F
G
H
Three Connected Components of G
  • 连通分量(Connected Components):连通分量(Connected Components),也称为连通子图,是一个无向图中的一个重要概念。一个连通分量是指在无向图中,如果从其中一个顶点出发,可以通过边的路径到达该连通分量内的任何其他顶点,而无法通过图中的边到达其他连通分量内的顶点。
  • 顶点的度(Degree):顶点的度是指与该顶点相邻的边的数量,也就是与该顶点直接相连的其他顶点的个数。对于有向图和无向图都适用。
    • 在无向图中,顶点的度就是与该顶点相邻的边的数量。
    • 在有向图中,顶点的度分为入度和出度,分别表示指向该顶点的边的数量和从该顶点出发的边的数量的总和。
  • 入度(In-Degree):入度是指在有向图中指向某个顶点的边的数量,也就是与该顶点关联的边中以该顶点为终点的边的数量。
  • 出度(Out-Degree):出度是指在有向图中从某个顶点出发的边的数量,也就是与该顶点关联的边中以该顶点为起点的边的数量。

树和图的区别?

树是受限制的图类型,只是有更多的规则。每棵树都是一个图,但不是所有的图都是树。链表、树和堆都是图的特殊情况。

Tree
Graph

表示方式

邻接矩阵(Adjacent Matrix)

图的邻接矩阵(Adjacency Matrix)是一种常用的图表示方法,特别适用于稠密图,它以矩阵的形式表示图的连接关系。在邻接矩阵中,行和列分别代表图的顶点,矩阵的元素表示顶点之间是否相邻或者边的权重。

v2
v4
v3
v1
Directed Graph G(V, E)
0
0
0
1
1
0
0
1
0
1
0
0
0
0
1
0
v1
v2
v3
v4
v1
v2
v3
v4
v2
v4
v3
v1
Undirected Graph G(V, E)
0
1
0
1
1
0
1
1
0
1
0
0
1
1
1
0
v1
v2
v3
v4
v1
v2
v3
v4
v2
v4
v3
v1
Weighted Undirected Graph G(V, E)
0
0.4
0
0.4
0.4
0
0.3
0.1
0
0.3
0
0.5
0.4
0.1
0.5
0
v1
v2
v3
v4
v1
v2
v3
v4
0.4
0.4
0.3
0.5
0.1
  1. 对于无向图:
    • 如果顶点 $i$ 和顶点 $j$ 之间存在边,则邻接矩阵中 $(i, j)$ 和 $(j, i)$ 位置的元素都被标记为 $1$ (或者表示边的权重)。
    • 如果顶点 $i$ 和顶点 $j$ 之间不存在边,则邻接矩阵中 $(i, j)$ 和 $(j, i)$ 位置的元素都被标记为 $0$ 。
  2. 对于有向图:
    • 如果有一条从顶点 $i$ 到顶点 $j$ 的有向边,则邻接矩阵中 $(i, j)$ 位置的元素被标记为 $1$ (或者表示边的权重)。
    • 如果没有从顶点 $i$ 到顶点 $j$ 的有向边,则邻接矩阵中 $(i, j)$ 位置的元素被标记为 $0$ 。

邻接表

图的邻接表(Adjacency List)是一种常见的图表示方法,特别适用于稀疏图,它使用链表或数组的形式来表示图的连接关系。每个顶点都对应一个链表,链表中存储与该顶点相邻的其他顶点。

邻接表的主要思想是为每个顶点创建一个链表,链表中的每个节点表示与该顶点相邻的另一个顶点。对于无向图,通常需要为每一条边创建两个链表节点,分别表示两个相邻的顶点。

v2
v4
v3
v1
Directed Graph G(V, E)
v2
v4
v3
v1
Undirected Graph G(V, E)
v1
v4
v2
v1
v3
v4
v1
v4
v3
v1
v2
v4
v2
v1
v3
v4
v3
v2
v4
v4
v1
v2
v3

代码实现

#define MAX_VERTICES 100

int adjMatrix[MAX_VERTICES][MAX_VERTICES]; // 邻接矩阵

void initializeMatrix(int vertices) {
    for (int i = 0; i < vertices; i++) {
        for (int j = 0; j < vertices; j++) {
            adjMatrix[i][j] = 0; // 初始化所有元素为0
        }
    }
}

void addEdge(int start, int end) {
    adjMatrix[start][end] = 1; // 添加边,将对应位置的元素设为1
    adjMatrix[end][start] = 1; // 无向图需要将对称位置的元素也设为1
}
// 邻接表节点结构
struct Node {
    int data;
    struct Node* next;
};

// 图的结构
struct Graph {
    int vertices;
    struct Node** adjacencyList;
};

// 创建新节点
struct Node* createNode(int data) {
    struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
    newNode->data = data;
    newNode->next = NULL;
    return newNode;
}

// 创建图
struct Graph* createGraph(int vertices) {
    struct Graph* graph = (struct Graph*)malloc(sizeof(struct Graph));
    graph->vertices = vertices;
    graph->adjacencyList = (struct Node**)malloc(vertices * sizeof(struct Node*));
    for (int i = 0; i < vertices; i++) {
        graph->adjacencyList[i] = NULL;
    }
    return graph;
}

// 添加边
void addEdge(struct Graph* graph, int start, int end) {
    // 添加边到起始顶点的链表
    struct Node* newNode = createNode(end);
    newNode->next = graph->adjacencyList[start];
    graph->adjacencyList[start] = newNode;

    // 对于无向图,需要添加一条反向边
    newNode = createNode(start);
    newNode->next = graph->adjacencyList[end];
    graph->adjacencyList[end] = newNode;
}

6.2 - 算法和应用

需熟练掌握图对于各种问题的应用,在选择题和大题中都是考察重点。

DFS

深度优先搜索(Depth-First Search,简称 DFS)是一种用于图的遍历和搜索的算法,它从图中的一个起始顶点开始,尽可能深地访问顶点,直到无法继续前进,然后回溯到之前的顶点,继续深入其他分支。DFS 通常用递归或栈来实现,确保在深度方向上优先遍历。

以下是 DFS 算法的一般步骤:

  1. 从起始顶点开始,将其标记为已访问。
  2. 遍历与当前顶点相邻的未访问顶点,选择其中一个未访问顶点作为下一个深度遍历的目标,然后递归地进行 DFS。
  3. 如果无法继续深度遍历(即当前顶点没有未访问的邻居),则回溯到上一个顶点,继续遍历其他未访问的邻居。
  4. 重复步骤 2 和步骤 3,直到所有顶点都被访问。
int visited[MAX_VERTICES];

void dfs(int start, int vertices) {
    visited[start] = 1;
    printf("%d ", start);

    for (int i = 0; i < vertices; i++) {
        if (adjMatrix[start][i] == 1 && !visited[i]) {
            dfs(i, vertices);
        }
    }
}
void dfs(struct Graph* graph, int vertex, int* visited) {
    visited[vertex] = 1;
    printf("%d ", vertex);

    struct Node* temp = graph->adjacencyList[vertex];
    while (temp) {
        int adjVertex = temp->data;
        if (!visited[adjVertex]) {
            dfs(graph, adjVertex, visited);
        }
        temp = temp->next;
    }
}

void dfsTraversal(struct Graph* graph, int startVertex) {
    int* visited = (int*)malloc(graph->vertices * sizeof(int));
    for (int i = 0; i < graph->vertices; i++) {
        visited[i] = 0;
    }

    dfs(graph, startVertex, visited);

    free(visited);
}

BFS

广度优先搜索(Breadth-First Search,简称 BFS)是一种用于图的遍历和搜索的算法,它从图中的一个起始顶点开始,逐层地访问与该顶点相邻的顶点,然后再访问这些相邻顶点的邻居,以此类推。BFS 通常用队列来实现,确保按照广度优先的顺序访问顶点。

以下是 BFS 算法的一般步骤:

  1. 将起始顶点放入队列中,标记为已访问。
  2. 从队列中弹出一个顶点并访问它。
  3. 遍历该顶点的所有未被访问的邻居,将它们放入队列中,并标记为已访问。
  4. 重复步骤 2 和步骤 3,直到队列为空。
// 从 start 开始遍历图
void bfs(int start, int vertices) {
    enqueue(start);
    visited[start] = 1;

    while (front != -1) {
        int currentVertex = dequeue();
        printf("%d ", currentVertex);

        for (int i = 0; i < vertices; i++) {
            if (adjMatrix[currentVertex][i] == 1 && !visited[i]) {
                enqueue(i);
                visited[i] = 1;
            }
        }
        printf("\n");
    }
}
void bfs(struct Graph* graph, int startVertex) {
    int* visited = (int*)malloc(graph->vertices * sizeof(int));
    for (int i = 0; i < graph->vertices; i++) {
        visited[i] = 0;
    }

    int queue[graph->vertices];
    int front = -1, rear = -1;
    queue[++rear] = startVertex;
    visited[startVertex] = 1;

    printf("BFS traversal starting from vertex %d: ", startVertex);

    while (front != rear) {
        int currentVertex = queue[++front];
        printf("%d ", currentVertex);

        struct Node* temp = graph->adjacencyList[currentVertex];
        while (temp) {
            int adjVertex = temp->data;
            if (!visited[adjVertex]) {
                queue[++rear] = adjVertex;
                visited[adjVertex] = 1;
            }
            temp = temp->next;
        }
    }

    printf("\n");

    free(visited);
}

最小生成树

最小生成树(Minimum Spanning Tree,MST)是在一个连接所有顶点的无向图中,通过选择一部分边而形成的树,使得树的总权重(或成本)最小。

最小生成树满足以下特点:

  • 包含图中的所有顶点。
  • 通过选择一些边而形成一个树结构,没有环路。
  • 边的总权重最小。

prim 算法

Prim 算法的主要思想是从一个初始顶点开始,逐步扩展生成树,每次选择与当前生成树相邻且权重最小的边所连接的顶点,并将该顶点加入生成树。算法的运行过程可以概括为以下步骤:

  1. 选择一个起始顶点作为生成树的根节点。
  2. 初始化一个空的生成树,包含初始顶点。
  3. 将与生成树中的顶点相邻且权重最小的边的另一端顶点加入生成树,即选择一条最小权重边。
  4. 重复步骤 3,直到生成树包含了所有的顶点。
  5. 最终生成树即为原图的最小生成树。
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
2
2
2
2
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2

kruskal 算法

Kruskal 算法是一种用于求解最小生成树(Minimum Spanning Tree,MST)的贪心算法。Kruskal 算法的基本思想是从边的权重最小的边开始,逐步构建生成树,确保不会形成环路。

以下是 Kruskal 算法的一般流程:

  1. 初始化一个空的生成树,开始时生成树中没有边。
  2. 将图中的所有边按照权重从小到大进行排序。
  3. 依次从排序后的边列表中选取边,加入生成树,但要确保添加该边不会形成环路。可以使用并查集来检查是否形成环路。
  4. 重复步骤 3,直到生成树包含了所有的顶点,即生成树中的边数等于顶点数减 1。
  5. 返回生成树作为最小生成树。
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2

最短路径

djkstra 算法

Dijkstra 算法的基本思想是从起始顶点开始,逐步扩展已知的最短路径,直到到达所有其他顶点。算法维护一个集合,其中包含已知的最短路径,以及一个优先队列(最小堆)用于选择下一个要探索的顶点。算法的主要步骤如下:

  1. 初始化距离数组和集合:
    • 创建一个距离数组,用于存储从起始顶点到每个顶点的最短距离。初始时,距离数组中的距离值设置为无穷大,除了起始顶点的距离值为 0。
    • 创建一个空的集合,用于存储已知的最短路径。
  2. 将起始顶点添加到优先队列中,距离为 0。
  3. 重复以下步骤,直到优先队列为空:
    • a. 从优先队列中取出距离最短的顶点,将其添加到集合中。
    • b. 遍历该顶点的所有邻居,对于每个邻居顶点:
      • 如果该邻居顶点不在集合中(即尚未找到最短路径),则计算从起始顶点经过当前顶点到达邻居顶点的距离。如果这个距离小于目前已知的距离,则更新距离数组中的距离值。
      • 将邻居顶点添加到优先队列中,以备以后探索。
  4. 当优先队列为空时,Dijkstra 算法完成,距离数组中存储了从起始顶点到每个顶点的最短距离。
  5. 可以使用距离数组来重构最短路径。

Dijkstra_Animation

function Dijkstra(Graph, source):

    for each vertex v in Graph.Vertices:
        dist[v] ← INFINITY
        prev[v] ← UNDEFINED
        add v to Q
    dist[source] ← 0

    while Q is not empty:
        u ← vertex in Q with min dist[u]
        remove u from Q

        for each neighbor v of u still in Q:
            alt ← dist[u] + Graph.Edges(u, v)
            if alt < dist[v]:
                dist[v] ← alt
                prev[v] ← u

    return dist[], prev[]

floyed 算法

Floyd-Warshall 算法是一种用于求解图中所有顶点对之间最短路径的动态规划算法。它适用于有向图和带有权重的边,可以找到从一个顶点到另一个顶点的最短路径,并计算所有顶点对之间的最短路径。

以下是 Floyd-Warshall 算法的一般流程:

  1. 初始化距离矩阵:
    • 创建一个二维矩阵 dist,其中 dist[i][j] 表示从顶点 i 到顶点 j 的最短路径距离。
    • 初始时,将 dist[i][j] 设置为正无穷(表示无法直接从顶点 i 到顶点 j),但当 i 等于 j 时,将 dist[i][j] 设置为 0(表示从顶点 i 到自身的距离为 0)。
    • 对于图中的每一条边 (u, v),如果存在从顶点 u 到顶点 v 的直接路径,则将 dist[u][v] 设置为边的权重。
  2. 三重循环进行动态规划:
    • 对于每一对顶点 (i, j),遍历一个中间顶点 k(从 1 到顶点的数量),检查是否存在一条从顶点 i 经过顶点 k 到顶点 j 的路径距离更短。具体步骤如下:
      • 如果 dist[i][j] 大于 dist[i][k] + dist[k][j],则更新 dist[i][j]dist[i][k] + dist[k][j]
      • 这意味着通过中间顶点 k 可以找到一条更短的路径。
  3. 最终的距离矩阵 dist 包含了所有顶点对之间的最短路径距离。
  4. 如果需要,可以使用距离矩阵 dist 来重构具体的最短路径。

拓扑排序

拓扑排序是一种用于有向图中的顶点排序的算法,它使得图中的每一条有向边都从前面的顶点指向后面的顶点。拓扑排序主要应用于有向无环图(DAG)或 AOV 网,这种图没有环路,因此可以对其进行拓扑排序。

AOV 网和 AOE 网

  • AOV 网(Activity on Vertex Network):AOV 网是一种以顶点表示活动或任务的网络模型。每个顶点代表一个任务或活动,而边表示任务之间的依赖关系。在 AOV 网中,任务或活动通常表示为顶点,而依赖关系(任务的先后顺序)表示为有向边。
  • AOE 网(Activity on Edge Network):AOE 网是一种以边表示活动或任务的网络模型。每条边代表一个任务或活动,而顶点通常表示事件,表示任务的开始或完成时间。在 AOE 网中,任务的持续时间通常不包含在边上,而是与边相连的事件顶点之间的时间差。

算法流程

  1. 选择一个没有前驱顶点(入度为 0)的顶点作为起始顶点,将其加入拓扑排序的结果序列中。
  2. 从图中移除该顶点以及与之相关的边(即将与之相邻的顶点的入度减 1)。
  3. 重复步骤 1 和步骤 2,直到所有的顶点都被加入到拓扑排序的结果序列中,或者发现图中存在环路。
  4. 如果所有的顶点都被成功加入结果序列,那么这个序列就是拓扑排序的结果。
1
2
4
3
5
2
4
3
5
4
3
5
3
5
5
Output 1
Output 2
Output 4
Output 3
Node Number
1
2
3
4
5
Inital In Degree
0
1
2
2
2
Round1
0
2
1
2
Round2
1
0
2
Round3
0
1
Round4
0
Round5

拓扑排序的结果序列可能不唯一,因为有多个入度为 0 的顶点可以作为起始顶点。如果图中存在环路,那么无法进行拓扑排序,因为无法找到入度为 0 的顶点作为起始顶点。

拓扑排序的时间复杂度通常为 $O(V + E)$ ,其中 $V$ 是顶点的数量, $E$ 是边的数量。这个算法非常适合于解决任务调度和依赖关系问题,因为它可以确定任务的执行顺序或依赖关系的合理性。

关键路径

前提概念
  • 源点:在 $AOE$ 网中仅有一个入度为 $0$ 的顶点,称为开始顶点,表示整个工程的开始。
  • 汇点:在 $AOE$ 网中仅有一个出度为 $0$ 的顶点,称为结束结点,表示整个工程的结束。
  • 关键路径的长度:完成整个工程的最短时间,也是 $AOE$ 网中从源点到汇点的最长路径。
  • 关键活动:关键路径上的活动。
  • $ve(k)$:事件 $v_k$ 的最早可以发生时间。
  • $vl(k)$:事件 $v_k$ 的最晚可以发生时间。
算法步骤

概要总结:找到所有最早发生时间和最晚发生时间相同的结点,连接这些结点即可得到关键路径。

具体步骤

  1. 从源点出发,令 $ve(start) = 0$ ,按照拓扑排序求其他顶点的最早发生时间 $ve$ 。
    • 对于源点:$ve(start) = 0$。
    • 对于后续结点:$ve(k) = \text{Max} \{ ve(j) + weight(v_j, v_k) \}$,$v_k$ 为 $v_j$ 的任意后继,$weight(v_j, v_k)$ 表示 $<v_j, v_k>$ 的权值。
  2. 从汇点出发,令 $vl(end) = ve(end)$,按逆拓扑有序求其余顶点的最迟发生时间 $vl$。
    • 对于汇点:$vl(end) = 0$。
    • 对于前继结点:$vl(k) = \text{Min} \{ vl(j) - weight(v_k, v_j) \}$,$v_k$ 为 $v_j$ 的任意前驱。
  3. 连接所有 $ve(i) = vl(i)$ 的结点 $i$,得到关键路径。下图中例子中所有 $ve$ 和 $vl$ 相同的结点为$v_1, v_3, v_4, v_6$,其对应的关键路径为 $v_1 \rightarrow v_3 \rightarrow v_4 \rightarrow v_6$ 即 $a_2, a_5, a_7$。
V1
V2
V3
V4
V6
V5
a1 = 3
a2 = 3
a3 = 2
a5 = 4
a4 = 3
a6 = 3
a7 = 2
a8 = 1
v1
v2
v3
v4
v5
v6
ve(i)
0
3
3
7
6
9
vl(i)
0
5
3
7
8
9

关键路径的特点:

  • 关键路径上的所有活动都是关键活动,它是决定整个工程的关键因素,因此可通过加快关键活动来缩短整个工程的工期。
  • AOE 网中的关键路径并不唯一,对于有几条关键路径的网,只加快一条关键路径,需要同时加快多个关键路径。

用图表达树

当表达式中存在共享的子表达式时,二叉树可能不是存储这些表达式的最有效方式,因为它会重复存储共享的子表达式。在这种情况下,可以使用有向无环图(Directed Acyclic Graph, DAG)来表示表达式,这样可以避免重复,并且更有效地表示表达式。

比如对于表达式 (x + y) * ((x + y) / x),用二叉树和DAG的表示如下图:

*
+
x
y
+
x
y
/
x
*
+
x
y
/
Binary Tree
Representation
DAG
Representation

视频讲解

7 - 查找

本章在选择题中考察,重点理解折半查找的思想以及散列表查找的冲突处理方法。
# 查找

## 基本概念

## 顺序查找法

## 分块查找法

## 折半查找法

## 树形查找

- 二叉搜索树
- 平衡二叉树
- 红黑树

## B树和B+树的基本概念

## 散列表

## 字符串匹配模式

## 查找算法的分析和应用

7.1 - 数组查找

重点掌握折半查找的思想,需要能够手写相关代码。

顺序查找

  • 适用于无序和有序线性表
  • 从线性表的一端开始,逐个检查每个元素,直到找到所需的元素或检查完所有元素为止。
FUNCTION SequentialSearch(A[0...n-1], value):
    FOR i = 0 TO n-1 DO
        IF A[i] == value THEN
            RETURN i    // index of the found value
        END IF
    END FOR
    RETURN -1   // value not found
END FUNCTION

时间复杂度为$O(n)$

折半查找

  • 只能应用于有序列表
  • 它涉及将列表分成两半,并确定所需元素可能在哪一半中,然后对所选的那一半重复此过程。
FUNCTION BinarySearch(A[0...n-1], value):
    low = 0
    high = n-1
    WHILE low <= high DO
        mid = (low + high) / 2
        IF A[mid] == value THEN
            RETURN mid  // index of the found value
        ELSE IF A[mid] < value THEN
            low = mid + 1
        ELSE
            high = mid - 1
        END IF
    END WHILE
    RETURN -1  // value not found
END FUNCTION

时间复杂度为$O(log_2{n})$

分块查找

线性表的分块查找通常应用于一种特定的情境:线性表(例如数组)被分为多个大小相等(或者最后一个块可能较小)的块,并且块内的元素是无序的,但是块与块之间是有序的。也就是说,每个块内的最大(或最小)元素小于下一个块的任意元素。

常用的分块查找策略如下:

  1. 先对块索引进行顺序或二分查找以确定所需元素可能所在的块。
  2. 然后在确定的块中进行顺序查找。
24
54
78
88
0
6
9
13
24
21
6
11
8
22
32
31
54
72
61
78
88
83
Index Table
Array

7.2 - 树形查找

掌握B树和B+树的定义和相关操作,可能在选择题中考察。

基于BST的查找

BST, AVL, 红黑树定义

使用BST、AVL或红黑树来存储数据,查询方式如下:

  1. 从根节点开始。
  2. 如果查询的值等于当前节点的值,返回当前节点。
  3. 如果查询的值小于当前节点的值,向左子树查询。
  4. 如果查询的值大于当前节点的值,向右子树查询。
  5. 如果到达空节点,则查询失败,返回NULL。
树类型平均情况时间复杂度最坏情况时间复杂度
BST$O(log_2{n})$$O(n)$
AVL$O(log_2{n})$$O(log_2{n})$
红黑树$O(log_2{n})$$O(log_2{n})$

对于BST,如果树是极度不平衡的(例如,成为链状结构),查询的最坏时间复杂度为 O(n)。

B树

定义

B树,也叫做多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用 $m$ 表示。一颗 $m$ 阶B树,满足如下特性:

  • 树中每个结点最多有 $m$ 棵子树,最多有 $m-1$ 个关键字。
  • 若根节点不是叶子结点,至少有两个子树
  • 除了根节点外的所有非叶结点最少有 $\lceil m / 2 \rceil$ 棵子树,即最少有 $\lceil m/2 \rceil - 1$ 个关键字。

结点结构如下:

n
P0
K1
P1
K2
P2
Kn
Pn

其中

  • $n$ 为结点中关键字的个数
  • $K_i (i = 1,2,\cdots,n)$ 为接点中存储的关键字,且满足 $K_1 \lt K_2 \lt \cdots \lt K_n$
  • $P_i (i = 0,1,\cdots,n)$ 为指向子树根结点的指针,且指针 $P_{i-1}$ 所指子树中所有结点的关键字均小于 $K_i$,$P_i$ 所指子树中所有结点的关键字均大于 $K_i$
22
5     11
36     45
1        3
6       8      9
 13     15
30     35
40     42
47       48      50       56

以上图中的5阶B树为例,说明B树的性质:

  • 结点中的孩子个数等于结点中关键字的个数加1。
  • 除根节点外所有非叶子结点最少有 $\lceil m / 2 \rceil = \lceil 5 / 2 \rceil = 3$ 棵子树(2个关键字),最多有5颗子树(4个关键字)。
  • 结点中关键字从左到右递增有序,关键字左侧指针所指子树的所有关键字均小于该关键字,右侧正直镇所指子树的所有关键字均大于该关键字。

查找

B树查找与普通二叉查找树相似,但在每个节点上,需要进行多次比较。

  1. 从根节点开始。
  2. 在当前节点中,从左到右搜索一个关键字 $k$ ,直到找到一个关键字 $x$ 满足 $k \le x$ 或者已检查完所有关键字。
  3. 如果找到的关键字 $x$ 等于 $k$ ,则查找成功并返回该节点。
  4. 如果没找到关键字 $x$ 或者 $x$ 不等于 $k$ ,则根据 $x$ 的位置,进入对应的子节点继续查找。
  5. 重复上述步骤,直到找到关键字或达到叶子节点。

插入

  1. 查找关键字位置:首先在 $B$ 树中查找要插入的关键字位置,但停在最后的叶子节点上,而不是回退。
  2. 插入关键字:在找到的叶子节点中插入新关键字。
  3. 节点分裂:如果该叶子节点关键字数超过了最大值(通常表示为 $2t-1$ ,其中 $t$ 是树的最小度数),则需要分裂该节点: $- $ 把节点分成两部分。 $- $ 上升中间的关键字到父节点。 $- $ 分裂后的两部分成为新的子节点。
  4. 递归分裂:如果因为上升的关键字导致父节点也超过了最大关键字数,那么继续递归分裂。
  5. 新的根节点:如果根节点分裂,则其中间关键字上升为新的根节点。
30
20
50      52
30
20
50      52       60
插入60后,溢出
结点分裂
30     52
20
60
50
3阶B树中的插入操作

删除

删除操作较为复杂,需要考虑多种情况。

  1. 查找关键字:首先查找要删除的关键字 $k$ 。
  2. 叶子节点中的关键字:如果关键字 $k$ 在叶子节点中,直接删除。
  3. 内部节点中的关键字: $- $ 如果 $k$ 的前驱关键字在叶子节点中,找到 $k$ 的前驱关键字,删除它,并在内部节点中用它替换 $k$ 。 $- $ 否则,使用类似的方法与 $k$ 的后继关键字。 $- $ 如果都不行,必须合并节点并递归地删除 $k$ 。
  4. 合并节点:如果一个节点在删除后少于 $t-1$ 个关键字,那么它就太小了,需要进行合并或关键字借用操作: $- $ 从兄弟节点借用一个关键字。 $- $ 如果无法借用,就和兄弟节点合并。
  5. 递归删除:合并操作可能需要递归到树的上一层。
  6. 更新根节点:如果根节点没有关键字,且只有一个子节点,那么那个子节点成为新的根节点。
60     80
68      78
删除80
60     78
68
60     75
65
80      86
删除65
60     80
75
86
60     75
5
65
80      86
删除5
75
60       65
80      86
在非叶子结点中删除
在叶子结点中删除
(1) 兄弟够借
(2) 兄弟不够借

B+树

B+树是B树的一种扩展。

一颗m阶B+树满足如下条件:

  • 每个分支结点最多有m颗子树
  • 结点的子树个数和关键字个数相同
  • 所有的值都出现在叶子节点,内部节点只包含键不包含实际的数据。
  • 叶子节点通过指针相互连接,这为范围查询提供了高效性。
  • 由于内部节点不存储数据,因此每个内部节点可以存储更多的键,从而增加树的扇出,减少树的高度。
特性B树B+树
数据存储位置关键字和数据存在于内部和叶子节点所有数据都存储在叶子节点,内部节点只保存关键值和子节点的指针
叶子节点结构叶子节点与内部节点类似,保存关键字和数据所有叶子节点通过指针链接成链表
分支因子由于同时保存数据和关键字,可能较小通常较大,因为内部节点只保存关键字和指针
稳定性关键字位置可能会频繁变动数据位置相对稳定
应用场景适用于小至中等规模的数据存储系统更常见于大型数据库系统和文件系统
查找效率在内部节点找到关键字后,查找即完成查找必须遍历到叶子节点,但由于通常高度较低,效率也很高

7.3 - 散列表查找

需熟练掌握散列表的冲突处理方法,对于其他知识点了解即可,可能在选择题中考察。

散列表

散列表,也叫哈希表,是一种常用的数据结构,提供了快速的数据存储和检索操作。它使用一个数组(通常称为桶或槽)来存储数据。为了将数据存储到散列表中,数据项首先与一个键关联,然后使用一个散列函数将该键转换为数组的索引。这样,通过该键可以快速找到相应的数据项。

散列表的关键性能指标是其装载因子,通常表示为λ。装载因子是散列表中当前存储的元素数量与散列表的容量之比。随着装载因子的增加,散列冲突的可能性也会增加,这可能会降低散列表的性能。

散列函数

散列函数(Hash Function)是一种函数,它接受一个输入(或“键”)并返回一个固定大小的数字序列,通常用作数组的索引。其主要目的是均匀地分布键到数组中,以便在可能的范围内平均分配值,从而最大限度地减少冲突。

一个好的散列函数应具有以下特性:

  • 均匀分布:无论输入数据的分布如何,散列函数都应该确保输出均匀分布在其范围内,以减少冲突。
  • 计算速度:散列函数应该快速计算,这样就不会成为整个哈希过程的瓶颈。
  • 确定性:对于同一输入,散列函数应始终产生相同的输出。
  • 最小冲突:尽管冲突是不可避免的,但好的散列函数应该使它们降到最低。

冲突处理方法

1. 开放定址法

开放定址法(Open Addressing)使用单个数组来存储所有的键值对。当发生冲突时,根据某种系统的方法在散列表中寻找另一个空槽,将键值对存储在那里。

keysJohn SmithLisa SmithSam DoeSandra DeeTed Bakerbuckets000001Lisa Smith521-8976002:::151152John Smith521-1234153Sandra Dee521-9655154Ted Baker418-4165155:::253254Sam Doe521-5030255

常用的开放定址策略有:线性探测、平方探测和双散列,具体如下:

线性探测法

  • 原理:当发生冲突时,线性探测法会查找下一个可用的槽位(通常是下一个连续的位置),直到找到一个空槽位为止。
  • 操作:
    • 插入:当要插入一个新元素并遇到冲突时,它会向前移动到下一个槽位,直到找到一个空槽位。
    • 查找:查找时也是一样的,如果在预期的槽位中没有找到元素,它会继续向前移动,直到找到该元素或遇到一个空槽位为止。
    • 删除:删除稍微复杂一些,因为直接删除一个元素可能会中断查找其他元素的连续性。通常的做法是用一个特殊的标记替换被删除的元素,表明该槽位已被删除但仍可能在查找时被访问。

平方探测法

  • 原理:与线性探测法相似,但它不是每次冲突后移动到下一个连续的槽位,而是移动到 $1^2$ 、$-1^1$、$2^2$ 、$-2^2$、$3^2$、 $-3^2…$ 位置直到找到一个空槽位。
  • 操作:
    • 插入:遇到冲突时,首先尝试移动 $1$ 的平方( $1^2=1$ )个位置,然后 $2$ 的平方( $2^2=4$ )个位置,接着 $3$ 的平方( $3^2=9$ )个位置,以此类推,直到找到一个空槽位。
    • 查找:与插入操作类似,也按照平方的序列移动。
    • 删除:和线性探测法类似,可以使用特殊标记表示槽位已被删除。

双散列法

  • 原理:双散列法使用两个独立的散列函数:一个是常规的散列函数 $Hash_1$,另一个是用于冲突解决的散列函数 $Hash_2$。
  • 操作:
    • 插入:当发生冲突时,首先使用第一个散列函数得到基本的索引位置,如果该位置已被占用,则使用第二个散列函数得到一个步长,按这个步长查找下一个槽位,直到找到一个空槽位。
    • 查找:与插入相似,首先使用第一个散列函数,如果没找到,则使用第二个散列函数得到的步长继续查找。
    • 删除:和上述方法类似,使用特殊标记表示槽位已被删除。

计算公式为

$$H_i = (Hash_1{(key)} + i \times Hash_2{(key)}) \mod m$$

其中 $i$ 为冲突次数,初始为0。

2. 拉链法

拉链法(Seperate Chaining)使用数组与链表相结合的方式。散列表的每个槽位都包含一个链表(或其他数据结构,如平衡树)。当发生冲突时,键值对被添加到相应槽位的链表中。

keysJohn SmithLisa SmithSam DoeSandra DeeTed Bakerbuckets000001002::151152153154::253254255entriesLisa Smith521-8976John Smith521-1234Sandra Dee521-9655Ted Baker418-4165Sam Doe521-5030
  • 操作:
    • 查找:通过散列函数找到对应的索引位置,在该索引的链表中顺序查找键。
    • 插入:通过散列函数找到对应的索引位置。若该键在链表中已存在,更新其值;否则,在链表中添加新的键值对。
    • 删除:通过散列函数找到对应的索引位置。在链表中查找并删除对应的键值对,若未找到则无操作。

平均查找长度

查找失败

在考题中常常需要计算散列表查找失败时的平均查找长度,这里举一个实例说明。

假设哈希表如下图所示,哈希表的长度为11,哈希函数为H(key) = key % 7, 采用线性探测法检测冲突。

散列地址
0
1
2
3
4
5
6
7
8
9
10
11
关键字
98
22
30
87
11
40
6
20

假如根据哈希函数计算出的初始查询位置为0,查询失败时根据线性探测法一直向后探测,查找到位置8发现该位置为空得出查找失败,查询次数为9,其他位置可以以此类推计算出来。

当查找一个新的key时,初始查询位置根据哈希函数计算可能在0到6之间,对于每个位置,查询失败时,需要查找的长度如下表所示:

位置
0
1
2
3
4
5
6
查找次数
9
8
7
6
5
4
3

查找失败的平均查找长度为 (9 + 8 + 7 + 6 + 5 + 4 + 3) / 7 = 6

查找成功

查找成功时的平均查找长度如何计算呢?

只有存储在散列表中的元素才能被成功查找,对于这些元素,查找成功的长度 分为两种情况:

  • 使用散列函数 H(key) 查找到某个位置,该位置存储的元素就是 key,查找次数为 1
  • 对于上述情况,该位置存储的元素不是 key,则向下一个位置探测,直到找到元素 key,探测次数为 N,则查找次数为 1 + N

将散列表中的所有元素的 查找次数 求和,然后处以 元素个数,即可得到 查找成功时的平均查找长度。

装填因子

装填因子(load factor)是一个衡量散列表“满”的程度的指标,其中装填因子 = 散列表中已存储的项数 / 散列表的总大小。

例如,如果一个容量为100的散列表中已经有70项,那么装填因子为0.7。

装填因子的值影响散列表的性能:

  • 当装填因子太小,意味着散列表中有很多空位,这可能导致内存浪费。
  • 当装填因子太大,冲突的概率会增加,从而降低查找、插入和删除的速度。

因此,常常在装填因子达到某个阈值时进行散列表扩容,例如当装填因子大于0.7或0.75。

扩容

扩容(Rehasing)是增加散列表的容量以容纳更多的项并降低装填因子的过程。扩容的主要步骤包括:

  1. 创建一个新的、更大的散列表。
  2. 遍历旧散列表中的每一项,并使用新的散列函数将它们插入到新的散列表中。
  3. 释放旧散列表的内存。

扩容会消耗时间,特别是当散列表中的项数很多时。但是,由于扩容不是经常发生,所以它的平均开销被分摊到了每一次的插入操作上,使得插入操作的平摊时间复杂度仍然为O(1)。

视频讲解

8 - 排序

本章在选择题中会考察,在大题中也可能会基于本章的排序算法思想出一道相关的代码题。需要熟练掌握各个排序算法的过程,并且能够手写快速排序的代码。
# 排序

## 排序的基本概念

## 内部排序

- 直接插入排序
- 折半插入排序
- 冒泡排序
- 简单选择排序
- 希尔排序
- 快速排序
- 堆排序
- 二路归并排序
- 基数排序

## 外部排序

## 排序算法的分析

8.1 - 内部排序

需熟练掌握各种排序的过程和算法分析,要求能够手工模拟算法过程,另外还需要能手写快速排序的代码。

排序总结

排序方式时间复杂度空间复杂度是否稳定
冒泡排序$O(n^2)$$O(1)$稳定
插入排序$O(n^2)$$O(1)$稳定
选择排序$O(n^2)$$O(1)$不稳定
基数排序$O(d*(n+k))$$O(n+k)$稳定
归并排序$O(n*log(n))$$O(n)$稳定
希尔排序取决于增量序列
最坏情况为$O(n^2)$
$O(1)$不稳定
快速排序$O(n^2)$(最坏情况)
$O(n*log(n))$(平均情况)
$O(log(n))$不稳定
堆排序$O(n*log(n))$$O(n)$不稳定
BubbleSort(arr)
    n = arr.length
    for i from 0 to n-1
        for j from 0 to n-i-1
            if arr[j] > arr[j+1]
                swap(arr[j], arr[j+1])
InsertionSort(arr)
    n = arr.length
    for i from 1 to n-1
        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key
            arr[j+1] = arr[j]
            j = j - 1
        arr[j+1] = key
SelectionSort(arr)
    n = arr.length
    for i from 0 to n-1
        minIndex = i
        for j from i+1 to n
            if arr[j] < arr[minIndex]
                minIndex = j
        swap(arr[i], arr[minIndex])
RadixSort(arr)
    Find the maximum number in arr, determine the number of digits in it, denoted as d
    for i from 1 to d
        Using a stable sort (e.g., counting sort) to sort arr based on the i-th digit
MergeSort(arr)
    if arr.length <= 1
        return arr
    mid = arr.length / 2
    left = arr[0 to mid-1]
    right = arr[mid to end]
    left = MergeSort(left)
    right = MergeSort(right)
    result = Merge(left, right)
    return result

Merge(left, right)
    result = []
    while left is not empty and right is not empty
        if left[0] <= right[0]
            append left[0] to result
            remove left[0] from left
        else
            append right[0] to result
            remove right[0] from right
    append remaining elements of left and right to result
    return result
ShellSort(arr)
    n = arr.length
    gap = n / 2
    while gap > 0
        for i from gap to n-1
            temp = arr[i]
            j = i
            while j >= gap and arr[j - gap] > temp
                arr[j] = arr[j - gap]
                j = j - gap
            arr[j] = temp
        gap = gap / 2
QuickSort(arr, low, high)
    if low < high
        pivotIndex = Partition(arr, low, high)
        QuickSort(arr, low, pivotIndex - 1)
        QuickSort(arr, pivotIndex + 1, high)

Partition(arr, low, high)
    pivot = arr[high]
    i = low - 1
    for j from low to high-1
        if arr[j] <= pivot
            i = i + 1
            swap arr[i] and arr[j]
    swap arr[i + 1] and arr[high]
    return i + 1
HeapSort(arr)
    BuildMaxHeap(arr) // 构建最大堆
    for i from n-1 down to 1
        swap arr[0] and arr[i] // 将最大值移到末尾
        MaxHeapify(arr, 0, i) // 修复剩余的堆

BuildMaxHeap(arr)
    n = arr.length
    for i from n/2 - 1 down to 0
        MaxHeapify(arr, i, n)

MaxHeapify(arr, i, n)
    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
        swap arr[i] and arr[largest]
        MaxHeapify(arr, largest, n)

冒泡排序

冒泡排序(Bubble Sort)的名称来源于它的操作方式:重复地遍历要排序的序列,比较每对相邻的项,若它们的顺序错误就交换过来,就像“小泡泡”从底部冒向顶部一样。

比如,对于数组 5, 1, 4, 2, 8的前两次冒泡过程如下:

  • 第一次(效果相当于将最大的数放到最后一个位置):

( 5 1 4 2 8 ) → ( 1 5 4 2 8 )

( 1 5 4 2 8 ) → ( 1 4 5 2 8 )

( 1 4 5 2 8 ) → ( 1 4 2 5 8 )

( 1 4 2 5 8 ) → ( 1 4 2 5 8 )

  • 第二次(效果相当于将第二大的数放在倒数第二个位置):

( 1 4 2 5 8 ) → ( 1 4 2 5 8 )

( 1 4 2 5 8 ) → ( 1 2 4 5 8 )

( 1 2 4 5 8 ) → ( 1 2 4 5 8 )

插入排序

插入排序(Insertion Sort)将数组分为未排序部分和已排序部分,每次选取未排序部分的第一个元素作为插入元素,在已排序序列中从后向前扫描,找到相应位置并插入。

对于数组3, 7, 4, 9, 5, 2, 6, 1执行插入排序的过程:

3  7  4  9  5  2  6  1
3* 7  4  9  5  2  6  1
3  7* 4  9  5  2  6  1
3  4* 7  9  5  2  6  1
3  4  7  9* 5  2  6  1
3  4  5* 7  9  2  6  1
2* 3  4  5  7  9  6  1
2  3  4  5  6* 7  9  1
1* 2  3  4  5  6  7  9

插入排序可以分为直接插入排序和折半插入排序,其不同点在于 寻找插入位置时 使用的是顺序查找还是折半查找。

选择排序

选择排序(Selection Sort)的思想是 从未排序的部分中找到最小(或最大)的元素,然后与未排序部分的第一个元素交换位置。这样,每一次选择操作后,未排序部分减少一个元素,而有序部分则增加一个元素。

以排序数组11, 25, 12, 22, 64为例:

Sorted sublistUnsorted sublistLeast element in unsorted list
()(11, 25, 12, 22, 64)11
(11)(25, 12, 22, 64)12
(11, 12)(25, 22, 64)22
(11, 12, 22)(25, 64)25
(11, 12, 22, 25)(64)64
(11, 12, 22, 25, 64)()

归并排序

归并排序(Merge Sort)是一个典型的“分而治之”策略的应用。它将一个大问题分解成若干小问题分别解决,然后将这些小问题的解合并为大问题的解。归并排序的主要思想是将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。

基本思想:

  1. 分解:分解待排序的数据数组为两个长度相等(或几乎相等)的子数组。
  2. 递归:递归地排序两个子数组。
  3. 合并:合并(归并)两个已排序的子数组以产生排序好的数据数组。
38
27
43
3
9
82
10
38
27
43
3
9
82
10
38
27
43
3
9
82
10
38
27
43
3
9
82
10
27
38
3
43
9
82
10
3
27
38
43
9
82
10
3
9
10
27
38
43
82

快速排序

快速排序(Quick Sort)的核心思想是分而治之 (Divide and Conquer):

  1. 选择一个“基准”元素:从数组中选择一个元素作为基准。
  2. 分区 (Partitioning):重新排列数组,使得所有小于基准的元素都在其左侧,所有大于基准的元素都在其右侧。在这个分区结束之后,该基准就处于数组的最终排序位置。
  3. 递归地排序子序列:递归地对基准左侧和右侧的子数组进行快速排序。
// 寻找数组中第k大的数字
QuickSelect(arr, low, high, k)
    if low <= high
        pivotIndex = Partition(arr, low, high)
        
        // 如果pivotIndex与k相等,那么arr[pivotIndex]就是我们要找的第k大的数
        if pivotIndex == k
            return arr[pivotIndex]
        
        // 如果pivotIndex小于k,说明第k大的数在右子数组
        if pivotIndex < k
            return QuickSelect(arr, pivotIndex + 1, high, k)
        
        // 否则,第k大的数在左子数组
        return QuickSelect(arr, low, pivotIndex - 1, k)

Partition(arr, low, high)
    pivot = arr[high]
    i = low - 1
    for j from low to high-1
        if arr[j] <= pivot
            i = i + 1
            swap arr[i] and arr[j]
    swap arr[i + 1] and arr[high]
    return i + 1

堆排序

堆的概念

堆的概念

满足以下性质的树 被成为 堆(Heap):

  1. 结构性质:堆总是一颗 完全二叉树,即除了最后一层之外的其他每一层都被元素填满,且最后一层的元素都尽可能地靠左排列。
  2. 有序性质:树中的每个结点 相对于 孩子结点都保证有序关系,要么父结点的值都 大于等于 子结点,要么都 小于等于 子结点,按照这种有序关系堆可以分为两种类型:
    • 大根堆 (Max-Heap):堆中的任意节点,其值都不小于其子节点的值。这意味着根节点是最大值。
    • 小根堆 (Min-Heap):堆中的任意节点,其值都不大于其子节点的值。这意味着根节点是最小值。
10
15
30
40
50
100
40
100
40
50
10
15
50
40
小根堆
大根堆

基于这些性质,堆常常被用于实现优先队列。对于大根堆,我们总是可以在 O(1) 的时间内得到最大的元素(即根节点),而对于小根堆,我们总是可以在 O(1) 的时间内得到最小的元素。

堆的操作

  • 插入 (Insertion):将一个新的元素添加到堆中,并保持堆的性质。这个操作的时间复杂度为 O(logn)。
  • 删除 (Deletion):删除堆中的最大(或最小)元素,并保持堆的性质。这个操作的时间复杂度也为 O(logn)。

堆的实现

100
19
36
19
36
19
36
19
36
Tree Representation
Array Representation
100
19
36
17
3
25
1
2
7
0
1
2
3
4
5
6
7
8

堆通常使用数组来实现,而不需要使用真正的树或链表结构。利用数组实现堆时,每个元素都有一个固定的位置,而该位置与元素在树中的位置有关。

假设数组的起始索引为1,若某元素的索引为 i,则它的:

  • 左孩子的索引为 2i
  • 右孩子的索引为 2i + 1
  • 父节点的索引为 i/2 (整数除法)

如果数组的起始索引为0,若某元素的索引为 i,则它的:

  • 左孩子的索引为 2i + 1
  • 右孩子的索引为 2i + 2
  • 父节点的索引为 (i-1)/2(整数除法)

推排序流程

堆排序(Heap Sort)其主要思想是将待排序的序列构造成一个大顶堆或小顶堆,然后交换堆顶和最后一个元素的位置,使最大或最小的元素放到序列的末尾。这样,就得到一个部分有序的序列。接下来,再对剩下的部分重新调整为大顶堆或小顶堆,并重复上述过程,直到整个序列有序。

堆排序的主要步骤:

  1. 构造初始堆:将给定无序序列构造成一个大顶堆(对于升序排序)或小顶堆(对于降序排序)。
  2. 交换数据:将堆顶元素与末尾元素交换,这样最大元素就位于序列的末尾。然后,将未排序的序列的长度减1,因为最后一个元素已经排序好了。
  3. 重建堆:对于剩下的未排序的序列,重新调整为大顶堆。
  4. 重复步骤2和3,直到整个序列有序。

调整堆过程:

  1. 选择一个节点 i,它的左子节点为 2i,右子节点为 2i+1。
  2. 如果节点 i 的值小于其子节点的值,则找到最大的子节点。
  3. 将节点 i 与最大的子节点交换。
  4. 交换后可能会破坏下一层的堆结构,所以需要对换到的子节点重复步骤1-3的调整,直到整个子树满足堆的性质。
24
23
18
19
14
24
23
18
19
14
24
23
18
19
14
23
19
18
14
24
23
19
18
14
24
19
14
18
23
24
19
14
18
23
24
18
14
19
23
24
18
14
19
23
24
14
18
19
23
24
14
18
19
23
24
14
18
19
23
24
Original Array
After Heap Sort
14
18
19
23
24

希尔排序

希尔排序(Shell Sort)是一种基于插入排序的算法,通过比较较远距离的元素来工作,然后逐渐减少这种距离。它的核心思想是使数组中的任何给定部分变得有序,这样在最后的排序阶段,整个数组都将近似排序,使插入排序可以更快地完成工作。

基本思想:

  1. 选择一个增量序列:通常开始于数组长度的一半,并逐渐减至1。这个增量称为“间隔”或“步长”。
  2. 按增量进行插入排序:对于每个增量值,用此增量将数据分成几个子序列,然后对每个子序列进行插入排序。
  3. 减少增量并重复:增量减少(通常减半),然后重复上述过程,直到增量为1,此时执行一次标准的插入排序。
OriginalAfter 5-sortAfter 3-sortAfter 1-sort32323232959595951616161682828282242424246666666635353535191919197575757554545454404040404343434393939393686868686 swaps5 swaps15 swaps

基数排序

基数排序(Radix Sort)其核心思想是将整数分解为单独的数字,然后根据每个位的数字对整数进行排序。基数排序的速度很快,但它有特定的应用场景:当你知道要排序的整数的范围时,它特别有用。

过程:

  1. 分解数字:从最低位(如个位)开始,逐个考虑整数的每一位。
  2. 使用稳定排序算法:对每一位使用稳定的排序算法(如计数排序或桶排序)将数字排序。稳定性在这里很重要,因为我们要保证在前一个位置上的排序顺序不会因后一个位置上的排序而被打乱。
  3. 逐位排序:处理完一位后,再处理下一位,直到处理完所有的位。
0
1
2
3
4
5
6
7
8
9
278
278, 109, 063, 930, 589, 184, 505, 269, 008, 083
109
063
930
599
184
505
269
008
083
930, 063, 083, 184, 505, 178, 008, 109, 599, 269
0
1
2
3
4
5
6
7
8
9
930
083
063
184
505
278
008
109
599
269
505, 008, 109, 930, 063, 269, 278, 083, 184, 599
0
1
2
3
4
5
6
7
8
9
505
008
109
930
063
269
278
083
008, 063, 083, 109, 184, 269, 278, 505, 599, 930
184
599
第一次排序,按照个位将数字加入桶中
第二次排序,按照十位将数字加入桶中
第三次排序,按照百位将数字加入桶中

8.2 - 外部排序

了解外部排序的思想即可,可能在选择题中考察一题。

外部排序是一种处理大量数据的排序方法,尤其是当数据太大而不能完全装入内存时。这种排序方法通常使用分治策略,将大问题分解为小问题,对小问题进行解决,然后再将解决方案组合成大问题的解决方案。

以下是外部排序的基本步骤,其中最典型的方法是多路归并排序:

  1. 分阶段 (Split Phase):
    • 数据被分割成多个小文件。每个小文件的大小基本上与可用内存相匹配。
    • 读取一个小文件大小的数据到内存中,使用传统的排序算法(如快速排序、堆排序等)对数据进行排序。
    • 将排序后的数据写回到磁盘。
  2. 归并阶段 (Merge Phase):
    • 采用多路归并算法,从每个小文件中读取一部分数据到内存。
    • 将这些数据进行合并,并将结果写回到一个新的输出文件中。
    • 当所有小文件都被完全读取并归并后,输出文件就是完全排序好的数据。
1G File x 12
Memory 3G
3 way external merge sort