本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:李春葆教授的《数据结构》课程深入讲解了计算机中数据组织和管理的关键概念。该课程涵盖了线性结构、树形结构、图结构、散列表和排序算法等核心数据结构与算法,并探讨了它们在实际编程和系统设计中的应用。该课程的课件资料为学习者提供了理论知识与实际操作的结合,强调了不同数据结构如数组、链表、栈、队列、树、图、散列表以及排序方法的重要性,并通过实例和习题帮助学生深入理解并应用这些概念。
数据结构

1. 数据结构概述

数据结构是计算机存储、组织数据的方式,它决定了算法处理数据的效率。本章将从宏观角度审视数据结构,为理解后续章节的线性结构、树形结构、图结构以及散列表等复杂数据结构打下基础。

1.1 数据结构的重要性

数据结构的选择直接影响到程序的性能。在软件开发过程中,合理选择和设计数据结构,可以优化数据处理速度、降低内存消耗,甚至决定算法能否在有限的时间内完成任务。

1.2 数据结构的分类

数据结构主要分为两大类:线性结构和非线性结构。线性结构包括数组、链表、栈和队列等,而非线性结构则包括树、图和散列表等。不同的数据结构满足了不同的数据组织和管理需求。

1.3 数据结构与算法的关系

数据结构与算法紧密相关,数据结构为算法提供基础,而算法则是对数据结构的操作。良好的数据结构设计能简化算法流程,提高算法效率。因此,理解和熟练应用各种数据结构是成为高级程序员的必经之路。

2. 线性结构基础

2.1 数组与链表

2.1.1 数组的定义与特性

数组是一种线性数据结构,由相同类型的数据元素组成,这些元素可以通过数组下标进行访问。数组的下标通常从0开始,它允许我们在一个连续的内存空间内快速访问任意位置的元素。数组的大小是固定的,一旦声明了数组,它的长度就无法改变。数组的这种特性使得它在内存空间利用上非常高效,但同时也限制了其在动态数据存储方面的应用。

数组的另一个重要特性是其访问时间的常数性(O(1)),意味着无论数组的大小如何,访问元素的时间都是固定的。这归功于数组在内存中连续存储的特性,CPU可以直接通过计算基址加上偏移量的方式来快速定位到元素。

数组的局限性在于插入和删除操作需要移动大量元素以保持连续性,这会导致较高的时间复杂度,特别是当数组大小较大时。

下面是一个简单的数组定义和初始化的例子:

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

此代码定义了一个整型数组,包含5个元素,初始化为1到5。

2.1.2 链表的基本概念与分类

链表是由一系列节点组成的数据结构,每个节点包含数据字段和一个或多个指向其他节点的指针,这种结构使链表能够高效地进行元素的插入和删除操作。链表中的节点通常通过指针相连,因此链表并不需要连续的内存空间。

根据指针的类型和数量,链表可以被分类为单向链表、双向链表和循环链表。单向链表的节点只有指向下一个节点的指针,双向链表的节点除了指向下个节点的指针,还有一个指向前一个节点的指针,循环链表的最后一个节点则指向第一个节点,形成一个环。

链表的优缺点与数组相反,其插入和删除操作的时间复杂度是常数级的(O(1)),但是访问任一元素需要从头节点开始遍历链表,其时间复杂度为线性(O(n))。

下面是一个简单的单向链表节点定义的例子:

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

这段代码定义了一个链表节点的结构体,其中包含了一个整型数据和一个指向下一个节点的指针。

2.2 栈和队列的应用

2.2.1 栈的实现与应用实例

栈是一种后进先出(LIFO)的数据结构,它只允许在一端进行插入和删除操作。栈的这种特性使得它在处理括号匹配、撤销操作、深度优先搜索等问题中非常有用。

栈的实现通常使用数组或链表,实现方式简单,关键操作有 push (压栈)和 pop (弹栈)。栈的这两种操作的复杂度都是常数级的(O(1))。

下面是一个使用C语言实现栈的例子,包括初始化、压栈和弹栈的基本操作:

#include <stdio.h>
#define MAXSIZE 100

typedef struct {
    int data[MAXSIZE];
    int top;
} Stack;

void initStack(Stack *s) {
    s->top = -1;
}

int push(Stack *s, int value) {
    if (s->top == MAXSIZE - 1) {
        return 0; // Stack is full
    }
    s->data[++s->top] = value;
    return 1;
}

int pop(Stack *s, int *value) {
    if (s->top == -1) {
        return 0; // Stack is empty
    }
    *value = s->data[s->top--];
    return 1;
}

int main() {
    Stack s;
    initStack(&s);
    push(&s, 10);
    push(&s, 20);
    int value;
    if (pop(&s, &value)) {
        printf("Popped: %d\n", value);
    }
    return 0;
}

此代码定义了一个栈的结构,并实现了初始化、压栈和弹栈的操作。

2.2.2 队列的原理与应用场景

队列是一种先进先出(FIFO)的数据结构,它允许在一端进行插入操作,在另一端进行删除操作。队列在多个领域有广泛的应用,比如打印任务的排队、事件处理、缓冲机制等。

队列的实现也常用数组或链表。与栈不同,队列的操作主要包括 enqueue (入队)和 dequeue (出队)。队列操作的时间复杂度同样是常数级的(O(1))。

下面是一个使用链表实现队列的例子:

#include <stdio.h>
#include <stdlib.h>

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

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

void initQueue(Queue *q) {
    q->front = q->rear = NULL;
}

void enqueue(Queue *q, int value) {
    Node *temp = (Node*)malloc(sizeof(Node));
    temp->data = value;
    temp->next = NULL;
    if (q->rear == NULL) {
        q->front = q->rear = temp;
        return;
    }
    q->rear->next = temp;
    q->rear = temp;
}

int dequeue(Queue *q, int *value) {
    if (q->front == NULL) {
        return 0; // Queue is empty
    }
    Node *temp = q->front;
    *value = temp->data;
    q->front = q->front->next;
    if (q->front == NULL) {
        q->rear = NULL;
    }
    free(temp);
    return 1;
}

int main() {
    Queue q;
    initQueue(&q);
    enqueue(&q, 10);
    enqueue(&q, 20);
    int value;
    if (dequeue(&q, &value)) {
        printf("Dequeued: %d\n", value);
    }
    return 0;
}

在这个例子中,我们定义了一个队列的结构,并实现了队列的初始化、入队和出队操作。

总结来说,本章我们探讨了线性数据结构中的数组与链表,以及它们在内存分配和管理上的区别。同时,我们也了解了栈和队列的基本概念,并通过实际代码展示了如何用C语言实现这些数据结构,以及它们在各种场景下的实际应用。在下一章,我们将深入探讨树形结构,了解二叉树及其高级形态如AVL树和红黑树的原理与应用场景。

3. 树形结构深入

树形结构是计算机科学中的一个重要概念,它通过层次化的方式组织数据,广泛应用于数据库系统、文件系统、编译器的语法分析等领域。本章节我们将深入探讨二叉树、高级平衡树以及B树和B+树的原理、特性和应用。

3.1 二叉树的探索

3.1.1 二叉树的基本属性和遍历方法

二叉树是一种特殊的树结构,其中每个节点最多有两个子节点,通常称为左子节点和右子节点。二叉树在数据组织上具有很多独特的属性和遍历方法,这使得它在实际应用中非常灵活。

基本属性:
- 节点的度(Degree):节点拥有的子节点数。
- 树的高度(Height)或深度(Depth):从根节点到最远叶子节点的最长路径的边数。
- 叶子节点(Leaf):没有子节点的节点。
- 内部节点(Internal Node):至少有一个子节点的节点。

遍历方法:
- 前序遍历(Pre-order Traversal):先访问根节点,然后递归地先序遍历左子树,接着递归地先序遍历右子树。
- 中序遍历(In-order Traversal):先递归地中序遍历左子树,然后访问根节点,最后递归地中序遍历右子树。中序遍历二叉搜索树时能够得到有序的数据序列。
- 后序遍历(Post-order Traversal):先递归地后序遍历左子树,然后递归地后序遍历右子树,最后访问根节点。

下面展示一个简单的二叉树遍历代码实现,使用递归方法实现中序遍历:

class TreeNode:
    def __init__(self, value):
        self.value = value
        self.left = None
        self.right = None

def inorder_traversal(root):
    if root is not None:
        inorder_traversal(root.left)
        print(root.value)  # 逻辑分析:访问根节点
        inorder_traversal(root.right)

# 示例
root = TreeNode(1)
root.left = TreeNode(2)
root.right = TreeNode(3)
root.left.left = TreeNode(4)
root.left.right = TreeNode(5)

# 输出结果将是:4 2 5 1 3
inorder_traversal(root)

3.1.2 二叉树的构建与平衡技术

构建二叉树:
构建二叉树通常基于递归方法,利用前序、中序或后序遍历的结果重建原始二叉树。其中,中序和前序遍历结果结合(或中序和后序遍历结果结合)能够唯一确定一棵二叉树。

平衡技术:
在实际应用中,为了保持树的平衡性,需要采用一些平衡技术来优化二叉树的性能,避免二叉树退化成链表形式,这样能够保证操作的时间复杂度维持在对数级别。

常见的平衡二叉树有:
- AVL树:平衡因子(左右子树的高度差)绝对值不超过1的二叉搜索树。
- 红黑树:一种带有颜色属性的平衡二叉搜索树,通过旋转和重新着色操作来维持平衡。

3.2 高级平衡树

3.2.1 AVL树的原理与旋转操作

AVL树是最早被发明的自平衡二叉搜索树,它通过旋转操作来保持树的平衡。旋转分为四种:左旋、右旋、左右旋、右左旋。通过适当的旋转,可以在插入或删除节点后快速恢复树的平衡性。

旋转操作:
- 单旋:
- 左旋(LL旋转):向右的旋转。
- 右旋(RR旋转):向左的旋转。

  • 双旋:
  • 左右旋(LR旋转):左旋后右旋。
  • 右左旋(RL旋转):右旋后左旋。

旋转操作的代码示例:

def rotate_right(x):
    y = x.left  # 将y设置为x的左子节点
    T2 = y.right  # 将T2设置为y的右子节点
    y.right = x  # y的右子节点指向x
    x.left = T2  # x的左子节点指向T2
    return y  # 返回新的根节点y

def rotate_left(y):
    x = y.right  # 将x设置为y的右子节点
    T2 = x.left  # 将T2设置为x的左子节点
    x.left = y   # x的左子节点指向y
    y.right = T2 # y的右子节点指向T2
    return x     # 返回新的根节点x

# 在实际代码中,旋转操作被集成到插入和删除函数中,作为恢复平衡的手段。
3.2.2 红黑树的特性与应用

红黑树是一种自平衡的二叉搜索树,它通过在节点中引入一个颜色属性(红色或黑色),并遵循特定的性质,来确保树在插入和删除操作后仍保持平衡。

红黑树的性质:
1. 每个节点要么是红色,要么是黑色。
2. 根节点总是黑色。
3. 所有叶子节点(NIL节点,空节点)都是黑色。
4. 如果一个节点是红色,则它的两个子节点都是黑色(从每个叶子到根的所有路径上不能有两个连续的红色节点)。
5. 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。

红黑树的应用:
红黑树广泛应用于诸如Java的TreeMap和TreeSet、C++ STL中的map/multimap和set/multiset等数据结构。

3.3 B树与B+树

3.3.1 B树的结构与性能分析

B树是一种自平衡的树数据结构,它能够保持数据排序,并允许搜索、顺序访问、插入和删除在对数时间内完成。B树特别适合读写相对较大的数据块的系统,比如磁盘。B树通过多路分支(节点可以拥有多个子节点)来减少树的高度,从而优化磁盘访问次数。

B树的性质:
- 每个节点最多包含m个子节点。
- 除了根节点和叶子节点外,其他每个节点至少包含 ceil(m/2) 个子节点。
- 所有叶子节点都在同一层级。
- 节点的关键字(key)用于导航树中的搜索路径。

性能分析:
B树适合于读写成本较高的存储系统,因为它最小化了磁盘I/O次数。

3.3.2 B+树的特点及数据库中的应用

B+树是B树的一种变体,其主要特点是所有数据记录都存储在叶子节点上,内部节点仅用于导航。由于所有数据记录都位于叶子节点,使得数据范围查询更为高效。

B+树的特点:
- 所有数据记录都存储在叶子节点上。
- 叶子节点之间通过指针连接,这使得范围查询非常高效。
- 内部节点不存储数据记录,仅存储关键字和指向子节点的指针。

数据库应用:
B+树在数据库索引中得到了广泛应用,它能够有效地支持随机和顺序访问,特别是在处理大量数据时,可以减少磁盘I/O操作次数,从而提高性能。

B+树在数据库中的应用实例代码:

-- 假设我们有一个表tb_student,它有一个主键id和一些其他列。
CREATE TABLE tb_student (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    age INT,
    -- 其他列...
);

-- 创建B+树索引
CREATE INDEX idx_student_id ON tb_student(id);

当执行一个基于id的查询时,数据库可以利用B+树索引快速定位到数据记录所在的位置,从而提高查询效率。

通过上述章节的介绍,我们已经对树形结构有了一个深入的了解。下一章,我们将继续探讨图结构的原理和遍历算法。

4. 图结构原理与遍历算法

4.1 图的表示方法

4.1.1 邻接矩阵与邻接表的对比

在图的数据结构中,表示图的方式有两种主流方法:邻接矩阵和邻接表。理解这两种方法的原理和适用场景对于图的存储和操作至关重要。

邻接矩阵是一种二维数组的形式,用来表示图中的顶点之间的连接关系。具体而言,邻接矩阵的每个元素 M[i][j] ,表示顶点 i 和顶点 j 之间是否存在边。若存在一条从顶点 i 到顶点 j 的边,则 M[i][j] 通常设置为1,否则设置为0。邻接矩阵适用于顶点数目不大的稠密图,因为它需要的存储空间为 O(V^2) ,其中 V 为顶点数。邻接矩阵的另一个优势在于可以直接通过索引访问任意两个顶点之间的关系,时间复杂度为 O(1) 。

相比之下,邻接表的表示更加适合顶点数目多而边较少的稀疏图。邻接表是数组与链表的结合体,通常使用一个数组来存储所有顶点,数组的每个元素是链表的头节点,链表中存储的是与该顶点相邻的其他顶点。邻接表的优点在于节省空间,它只需要 O(V+E) 的空间,其中 E 为边数。但邻接表的缺点是查询两个顶点是否相连的时间复杂度为 O(V) ,即需要遍历一个顶点的链表。

4.1.2 图的存储结构选择与实践

在实际应用中,图的存储结构选择取决于图的类型(稠密图或稀疏图)以及图操作的类型。例如,如果经常需要查询任意两个顶点之间是否存在边,使用邻接矩阵可能更合适。如果图是稀疏图,并且我们需要高效地添加或删除顶点,邻接表可能是更好的选择。

举个例子,在社交网络分析中,由于网络中的好友关系数量相对用户总数而言通常较少,使用邻接表来存储好友关系是更加高效的。而在某些特定的网络拓扑结构中,比如每个节点都与其它所有节点相连的完全图中,使用邻接矩阵会更加合适。

为了说明两种方法的实际应用,以下是使用Python实现的邻接矩阵和邻接表:

# 邻接矩阵实现
class GraphMatrix:
    def __init__(self, vertices):
        self.V = vertices
        self.graph = [[0 for column in range(vertices)] for row in range(vertices)]
    def add_edge(self, u, v):
        self.graph[u][v] = 1
        self.graph[v][u] = 1
    def remove_edge(self, u, v):
        self.graph[u][v] = 0
        self.graph[v][u] = 0

# 邻接表实现
class GraphList:
    def __init__(self, vertices):
        self.V = vertices
        self.graph = [[] for _ in range(vertices)]
    def add_edge(self, u, v):
        self.graph[u].append(v)
        self.graph[v].append(u)  # 无向图情况

    def remove_edge(self, u, v):
        self.graph[u].remove(v)
        self.graph[v].remove(u)  # 无向图情况

4.1.3 小结

选择图的存储结构时,需要根据实际应用场景和需求权衡邻接矩阵和邻接表的利弊。对于稠密图,邻接矩阵在空间和时间上可能更优;而对于稀疏图,邻接表则在存储效率上有显著优势。

4.2 图的遍历技术

4.2.1 深度优先搜索(DFS)

深度优先搜索(DFS)是一种用于遍历或搜索树或图的算法。其基本思想是从一个顶点开始,尽可能沿着路径深入探索,直到无法继续为止,然后回溯到上一个节点并尝试其他路径。DFS使用递归或栈来实现。

DFS算法的基本步骤如下:

  1. 从起始节点开始,标记该节点为已访问。
  2. 查找当前节点的所有未访问的邻居。
  3. 如果存在未访问的邻居,则选择一个未访问的邻居,重复此过程。
  4. 如果当前节点没有未访问的邻居,则回溯到上一个节点,继续探索。

在图的深度优先搜索中,需要注意避免重复访问节点,防止陷入无限循环。在有向图中,为了防止回环,可以记录节点的访问状态,通常使用三个状态:未访问、已访问且完成、已访问但未完成。

下面是DFS的Python实现示例:

def DFS(graph, node, visited):
    if node not in visited:
        print(node)
        visited.add(node)
        for neighbour in graph[node]:
            DFS(graph, neighbour, visited)

visited = set()
graph = {
    'A': ['B', 'C'],
    'B': ['D', 'E'],
    'C': ['F'],
    'D': [],
    'E': ['F'],
    'F': []
}
DFS(graph, 'A', visited)

在上述代码中,我们使用了递归的方式来实现DFS,图是用邻接表的形式表示的,我们对每个节点都进行了访问标记,确保每个节点只访问一次。

4.2.2 广度优先搜索(BFS)

广度优先搜索(BFS)是另一种用于遍历或搜索树或图的算法。与深度优先搜索不同,BFS从起始节点开始,按层次逐级进行,直到所有的节点都被访问过。

BFS的基本步骤如下:

  1. 创建一个队列,并将起始节点加入队列。
  2. 若队列非空,则执行以下步骤:
    a. 从队列中取出一个节点。
    b. 标记该节点为已访问。
    c. 将所有与该节点邻接且未被访问的节点加入队列。

BFS能够找到从起始节点到目标节点的最短路径,如果图中存在这样的路径。它使用队列来保证节点按照访问顺序的层次性进行处理。

下面是一个BFS的Python示例代码:

from collections import deque

def BFS(graph, start):
    visited = set()
    queue = deque([start])
    while queue:
        vertex = queue.popleft()
        if vertex not in visited:
            print(vertex, end=' ')
            visited.add(vertex)
            queue.extend([n for n in graph[vertex] if n not in visited])

graph = {
    'A': ['B', 'C'],
    'B': ['D', 'E'],
    'C': ['F'],
    'D': [],
    'E': ['F'],
    'F': []
}
BFS(graph, 'A')

在这个例子中,我们使用了 collections.deque 来实现队列,这样能够保证从队列的两端进行高效的操作。

4.2.3 小结

DFS和BFS是图遍历中的两种基本方法,它们各有优势:DFS能够发现所有的路径,而BFS能够在未加权图中找到最短路径。在实际应用中,需要根据具体的图结构和问题需求来选择使用DFS或BFS。

5. 散列表的高效操作

5.1 哈希表原理

5.1.1 哈希函数的设计与选择

哈希表是一种通过哈希函数将键值对应到表中一个位置以加快数据检索的数据结构。哈希函数的设计至关重要,它决定了数据在哈希表中的分布和哈希冲突的可能性。

好的哈希函数应满足以下条件:

  • 均匀分布 :哈希函数应尽可能使哈希值均匀分布在哈希表的槽位上,以减少冲突。
  • 高效计算 :计算哈希值需要快速,以保持整体数据操作的效率。
  • 确定性 :对于相同的输入,哈希函数应始终产生相同的输出。

常见的哈希函数设计方法包括:

  • 直接寻址法 :哈希值直接为键的某部分或全部。
  • 除留余数法 :键值除以一个质数后的余数作为哈希值。
  • 乘法法 :先选择一个常数A,然后将键乘以A,取结果的小数部分,再乘以表的大小m,取结果的整数部分作为哈希值。
  • 数字分析法 :从键中提取数字特征作为哈希值。
  • 折叠法 :将键分割成若干部分,将各部分相加以得到哈希值。

选择合适的哈希函数对于构建高效的哈希表是基础。例如,使用除留余数法时,选择一个质数作为除数可以减少哈希冲突。但没有一种哈希函数适用于所有的场景,通常需要根据实际数据的特性来设计或选择。

5.1.2 哈希冲突的解决方法

在哈希表中,两个不同的键可能产生相同的哈希值,这称为哈希冲突。解决哈希冲突的方法主要有以下几种:

  • 开放寻址法 :当冲突发生时,从新计算的哈希地址开始,按照某种顺序进行探测,直到找到空槽位。
  • 线性探测:按顺序检查每个槽位。
  • 二次探测:按二次方的距离进行探测。
  • 双重哈希:使用另一个哈希函数来计算探测序列。
  • 链地址法 :将所有哈希到同一个槽位的键值对组成一个链表,冲突时将元素添加到链表中。
  • 再哈希法 :使用多个哈希函数,当冲突发生时,使用另一个哈希函数进行计算。

链地址法通常实现简单,适用于哈希表大小动态变化的情况,而开放寻址法的空间利用率更高,但是对哈希函数的均匀性要求更高,否则可能会导致性能显著下降。

5.2 哈希表的应用

5.2.1 快速查找与插入的实现

哈希表的基本操作包括快速查找和插入。查找和插入操作的时间复杂度平均情况下为O(1)。以下是查找和插入操作的伪代码:

def hash_function(key, table_size):
    # 实现哈希函数
    return key % table_size

def hash_table_insert(hash_table, key, value):
    index = hash_function(key, len(hash_table))
    # 处理冲突,这里使用链地址法
    if hash_table[index] is None:
        hash_table[index] = [(key, value)]
    else:
        for i in range(len(hash_table[index])):
            if hash_table[index][i][0] == key:
                hash_table[index][i] = (key, value)
                break
        else:  # 表示没有找到,添加新的键值对
            hash_table[index].append((key, value))

def hash_table_search(hash_table, key):
    index = hash_function(key, len(hash_table))
    if hash_table[index] is not None:
        for i in range(len(hash_table[index])):
            if hash_table[index][i][0] == key:
                return hash_table[index][i][1]
    return None

代码逻辑分析:
- hash_function :该函数根据键和哈希表的大小返回哈希值。
- hash_table_insert :插入操作首先通过哈希函数计算出键应该存储的槽位,然后检查该槽位是否已经有键。如果槽位为空,则创建新的列表;如果不为空,则遍历列表查找是否已存在相同键。如果找到,则更新值;如果没有找到,则添加新的键值对。
- hash_table_search :查找操作首先计算键的哈希值,然后检查该槽位。如果槽位不为空,则遍历列表寻找键。如果找到,则返回对应的值;如果未找到,则返回 None 。

5.2.2 哈希表在实际问题中的应用案例

哈希表广泛应用于各种需要快速查找和插入数据的场景,如数据库索引、缓存机制、集合运算、编译器中的符号表等。

以数据库索引为例,当构建数据库索引时,使用哈希表可以大大提高数据检索速度。索引键(例如,数据表中的某一列)通过哈希函数转换为哈希值,然后将数据存储在对应哈希值的槽位中。当执行查询操作时,通过相同的哈希函数快速定位数据。

例如,在一个用户信息数据库中,如果需要根据用户ID快速检索用户信息,可以通过哈希表实现。每个用户ID作为键,用户信息作为值,当有查询请求时,直接通过用户ID快速找到对应的用户信息。

哈希表的效率使得它成为构建高效数据检索系统的基石之一。然而,在设计实际应用时,开发者需要注意选择合适的哈希函数、处理哈希冲突的方法以及哈希表的动态调整策略,以确保系统的性能和稳定性。

6. 排序算法详解

6.1 基本排序算法

6.1.1 冒泡排序与选择排序的机制

冒泡排序和选择排序是两种最简单的排序算法,它们的原理非常直观,易于理解,但效率并不高,适合用于小规模数据的排序。

冒泡排序 通过重复遍历要排序的数组,比较相邻的元素,如果它们的顺序错误就把它们交换过来。遍历数组的工作是重复进行直到没有再需要交换,也就是说该数组已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。

选择排序 算法则是每次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。

下面是冒泡排序的Python代码实现,展示了如何通过双层循环来实现排序逻辑:

def bubble_sort(arr):
    n = len(arr)
    for i in range(n):
        # 最后i个元素已经是排好序的了,不需要再次比较
        for j in range(0, n-i-1):
            # 每次遍历都将当前较大的元素“冒泡”到后面
            if arr[j] > arr[j+1]:
                arr[j], arr[j+1] = arr[j+1], arr[j]
    return arr

6.1.2 插入排序的特点与优化

插入排序 的基本思想是将数组分成已排序和未排序两部分,初始时已排序部分只有一个元素,从第二个元素开始,依次向前插入,直至整个数组排序完成。

插入排序的优点是,在实现简单的同时,对于数据量不大的数组,它比其他更复杂的排序算法(如快速排序和归并排序)具有更好的性能。其时间复杂度在最坏的情况下是O(n^2),但当数据已经部分有序时,插入排序的性能很好。

为了优化插入排序的效率,可以采用二分插入排序。二分插入排序通过二分查找方法查找插入位置,减少比较次数。

以下是插入排序的Python实现示例:

def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        # 通过比较来找到元素key应该插入的位置
        while j >= 0 and key < arr[j]:
            arr[j + 1] = arr[j]
            j -= 1
        arr[j + 1] = key
    return arr

6.2 高级排序技术

6.2.1 快速排序的分割策略

快速排序是一种高效的排序算法,采用分治法的策略。其基本思想是:先从数列中选取一个数作为基准数,然后将所有比这个数小的数都放到它的左边,比它大的数都放到右边,然后对左右两边的子数列进行同样的操作。

快速排序的核心在于其分割策略,最理想的情况是每次都能将数组分成两个等长的部分,但实际运行中并不总是这样,不过通过随机化选择基准或者使用三数取中法可以较为接近地达到理想状态。

以下是快速排序的Python代码实现:

def quick_sort(arr):
    if len(arr) <= 1:
        return arr
    else:
        pivot = arr[0]
        less = [x for x in arr[1:] if x <= pivot]
        greater = [x for x in arr[1:] if x > pivot]
        return quick_sort(less) + [pivot] + quick_sort(greater)

quick_sort([3,6,8,10,1,2,1])
# [1, 1, 2, 3, 6, 8, 10]

6.2.2 归并排序与堆排序的原理

归并排序 通过递归的方式将数组分成更小的部分,然后对这些部分进行排序,最后将它们合并成一个有序的数组。归并排序的关键在于合并操作,它将两个已排序的数组合并成一个新的已排序的数组。这个算法的时间复杂度是O(n log n),它是一个稳定的排序算法,但因为需要额外的存储空间,所以它不是原地排序。

堆排序 则是一种基于二叉堆的比较排序算法。在堆排序算法中,首先将输入的无序序列构造成一个大顶堆,然后将堆顶元素与堆的最后一个元素交换,再调整剩余元素构成新的大顶堆,重复这个过程,直到所有元素都被排序。

以下为归并排序的Python实现:

def merge_sort(arr):
    if len(arr) > 1:
        mid = len(arr) // 2
        L = arr[:mid]
        R = arr[mid:]

        merge_sort(L)
        merge_sort(R)

        i = j = k = 0

        while i < len(L) and j < len(R):
            if L[i] < R[j]:
                arr[k] = L[i]
                i += 1
            else:
                arr[k] = R[j]
                j += 1
            k += 1

        while i < len(L):
            arr[k] = L[i]
            i += 1
            k += 1

        while j < len(R):
            arr[k] = R[j]
            j += 1
            k += 1
    return arr

堆排序的Python实现则如下:

def heapify(arr, n, i):
    largest = i
    l = 2 * i + 1
    r = 2 * i + 2

    if l < n and arr[i] < arr[l]:
        largest = l

    if r < n and arr[largest] < arr[r]:
        largest = r

    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)

    return arr

上述代码展示了归并排序与堆排序的核心算法。每种排序算法都有其适用场景,选择合适的算法可以在不同的应用场景中获得最佳性能。

7. 文件系统数据结构

7.1 位图的应用

位图是一种高效的存储结构,它使用一个比特位来表示一种状态,广泛应用于文件系统中,以实现快速的空闲空间分配与查找。

7.1.1 位图的概念与存储效率

位图利用一个比特位来表示一个数据块的使用状态,其中0通常表示未使用,1表示已使用。例如,在一个文件系统中,每个文件块的使用情况可以用一个位图数组来表示,每个元素对应一个文件块的状态。

000000000000000111111111111111000001111111111100000000000000000

上例中,二进制序列表示了文件系统的状态,从左到右依次为未使用、已使用和未使用状态。

位图具有极高的存储效率。一个字节由8个比特组成,因此可以表示8个文件块的使用状态。例如,如果一个系统块大小为4KB,那么4KB / 8 = 512B的位图数组就能够表示4KB * 8 = 32KB的数据块使用状态。

7.1.2 位图在文件系统中的实现

在实现中,位图通常由一系列连续的字节组成,每字节可以包含8个文件块的状态信息。文件系统在分配和释放文件块时,将位图中相应的比特位置0或置1。

void setBitMapBlockStatus(BlockDevice *device, unsigned int blockNumber, int status) {
    int byteIndex = blockNumber / 8;
    int bitIndex = blockNumber % 8;
    char statusByte = readBlock(device, byteIndex);
    if (status) {
        statusByte |= (1 << bitIndex); // Set bit
    } else {
        statusByte &= ~(1 << bitIndex); // Clear bit
    }
    writeBlock(device, byteIndex, statusByte);
}

在上面的伪代码示例中, setBitMapBlockStatus 函数用于设置位图中文件块的状态。 readBlock 函数用于读取位图中的字节,而 writeBlock 用于将更新后的字节写回设备。这个操作的效率非常高,因为它只涉及到一次读和一次写操作。

7.2 索引节点的设计

索引节点(inode)是文件系统中用于存储文件元数据的结构,它包括文件类型、大小、权限、创建时间、修改时间、访问时间以及指向文件数据块的指针。

7.2.1 索引节点的结构与作用

索引节点使得文件系统可以独立于文件名来管理文件数据,从而提高文件访问的灵活性和效率。文件数据块的指针通常存放在索引节点中,这可能是一个直接指针、一级间接指针、二级间接指针,甚至三级间接指针。

struct inode {
    unsigned short i_mode; /* 文件类型与权限 */
    off_t i_size;          /* 文件大小 */
    time_t i_atime;        /* 文件访问时间 */
    time_t i_mtime;        /* 文件修改时间 */
    time_t i_ctime;        /* 文件创建时间 */
    unsigned int i_blocks; /* 文件占用的块数 */
    unsigned int i_block[15]; /* 指向数据块的指针数组 */
};

索引节点通过包含对文件数据的直接引用或间接引用,使得系统可以快速定位和检索文件内容。

7.2.2 索引节点在文件管理中的应用实例

在文件系统中,当创建一个新文件时,系统会分配一个索引节点,并初始化它的元数据。当文件写入数据时,会根据文件大小和预设的文件系统参数决定是否使用直接指针或间接指针。

void createNewFile(BlockDevice *device, const char *path) {
    struct inode *newInode = allocateInode(device);
    memset(newInode, 0, sizeof(struct inode));
    newInode->i_mode = S_IFREG | 0666; // 普通文件,读写权限
    newInode->i_size = 0;
    // ... 初始化其他元数据 ...
    writeInode(device, newInode);
    // ... 创建目录项,关联文件名和inode ...
}

以上代码展示了创建一个新文件时如何分配和初始化索引节点。分配完索引节点后,系统会为文件内容分配数据块,并更新索引节点中的指针。

通过上述章节的介绍,我们可以看到位图和索引节点在文件系统中的重要应用。位图提高了空闲空间管理的效率,而索引节点则为文件的数据管理提供了灵活性。理解这两种数据结构对于深入探索文件系统的设计至关重要。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:李春葆教授的《数据结构》课程深入讲解了计算机中数据组织和管理的关键概念。该课程涵盖了线性结构、树形结构、图结构、散列表和排序算法等核心数据结构与算法,并探讨了它们在实际编程和系统设计中的应用。该课程的课件资料为学习者提供了理论知识与实际操作的结合,强调了不同数据结构如数组、链表、栈、队列、树、图、散列表以及排序方法的重要性,并通过实例和习题帮助学生深入理解并应用这些概念。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

更多推荐