一、绪论
数据结构三要素
-
逻辑结构:
-
定义:逻辑结构指数据元素之间的逻辑关系,是对数据在计算机内存中组织和存储的抽象。
-
分类:
-
集合结构:元素之间除了同属于一个集合外,没有其他关系。
-
线性结构:元素之间存在一对一的关系(如数组、链表)。
-
树形结构:元素之间存在一对多的层次关系(如二叉树)。
-
图形结构:元素之间存在多对多的关系(如图、网络)。
-
-
-
存储结构(物理结构):
-
定义:存储结构指数据在计算机内存中的表示形式。
-
分类:
-
顺序存储:数据元素按顺序存放在连续的存储单元中(如数组)。
-
链式存储:数据元素存放在任意存储单元中,通过指针相互链接(如链表)。
-
索引存储:在存储数据的同时,建立附加的索引表来加快查找速度。
-
散列存储:根据关键字直接计算存储地址(如哈希表)。
-
-
-
基本操作:
-
定义:基本操作指在特定的数据结构上进行的基本运算。
-
常见操作:插入、删除、查找、更新、遍历等。
-
1. 基本概念
-
数据:
-
定义:所有能输入到计算机中并被处理的符号的总称。
-
-
数据元素:
-
定义:数据的基本单位,在计算机中进行处理的最小单元。
-
例子:在数组中,每一个元素都是一个数据元素。
-
-
数据对象:
-
定义:性质相同的数据元素的集合,是数据与数据元素的集合。
-
例子:整数数据对象,包括所有整数。
-
-
数据类型:
-
定义:数据及其在计算机中的存储形式和能施加在这些数据上的运算。
-
分类:
-
原子类型:不可再分的数据类型(如整型、字符型)。
-
结构类型:可以再分的数据类型(如数组、结构体)。
-
-
-
数据结构:
-
定义:相互之间存在一种或多种特定关系的数据元素的集合。
-
常见数据结构:数组、链表、栈、队列、树、图。
-
2. 算法分析
-
时间复杂度:
-
定义:衡量算法的运行时间随输入规模增长的变化率。
-
常见符号:大O符号表示,常见的有O(1)、O(n)、O(log n)、O(n²)等。
-
分析方法:通过分析算法的语句执行次数,得出时间复杂度。
-
-
空间复杂度:
-
定义:衡量算法在运行过程中所需存储空间的大小。
-
常见符号:同样使用大O符号,常见的有O(1)、O(n)、O(n²)等。
-
分析方法:通过分析算法中所需的辅助空间(如数组、变量等),得出空间复杂度。
-
好的,让我们详细探讨线性表的各个知识点。
二、线性表
线性表是一种最基本的数据结构,其特点是数据元素之间存在线性关系。
1. 顺序表
顺序表是线性表的一种顺序存储方式,即用一组地址连续的存储单元依次存储线性表的数据元素。
a) 插入/删除过程,平均移动元素个数
-
插入操作:
-
过程:
-
确定插入位置。
-
从插入位置起,将插入位置后的元素依次向后移动一位。
-
在插入位置插入新的元素。
-
-
时间复杂度:在最坏情况下(插入到第一个位置),需要移动n个元素;平均情况下,需要移动n/2个元素,因此平均时间复杂度为O(n)。
-
-
删除操作:
-
过程:
-
确定删除位置。
-
从删除位置起,将删除位置后的元素依次向前移动一位,覆盖删除位置的元素。
-
-
时间复杂度:在最坏情况下(删除第一个元素),需要移动n-1个元素;平均情况下,需要移动(n-1)/2个元素,因此平均时间复杂度为O(n)。
-
2. 单链表
单链表是线性表的一种链式存储方式。每个节点包含数据和指向下一个节点的指针。
a) 插入/删除操作
-
插入操作:
-
过程:
-
创建一个新节点。
-
将新节点的指针指向插入位置后的节点。
-
将插入位置前一个节点的指针指向新节点。
-
-
时间复杂度:在已知插入位置的前一个节点的情况下,时间复杂度为O(1)。若需查找插入位置,最坏情况下时间复杂度为O(n)。
-
-
删除操作:
-
过程:
-
将待删除节点前一个节点的指针指向待删除节点的下一个节点。
-
释放待删除节点的内存。
-
-
时间复杂度:在已知待删除节点的前一个节点的情况下,时间复杂度为O(1)。若需查找待删除节点,最坏情况下时间复杂度为O(n)。
-
3. 基本操作:增删改查
-
增(插入):
-
顺序表:插入时需要移动插入位置后的元素,时间复杂度为O(n)。
-
单链表:在已知插入位置的前一个节点的情况下,插入操作时间复杂度为O(1);否则为O(n)。
-
-
删(删除):
-
顺序表:删除时需要移动删除位置后的元素,时间复杂度为O(n)。
-
单链表:在已知删除位置的前一个节点的情况下,删除操作时间复杂度为O(1);否则为O(n)。
-
-
改(修改):
-
顺序表:直接通过索引访问并修改元素,时间复杂度为O(1)。
-
单链表:需要遍历链表找到待修改的节点,最坏情况下时间复杂度为O(n)。
-
-
查(查找):
-
顺序表:直接通过索引访问元素,时间复杂度为O(1)。
-
单链表:需要遍历链表找到目标元素,最坏情况下时间复杂度为O(n)。
-
让我们详细探讨栈与队列的各个知识点。
三、栈与队列
1. 栈 (Stack)
栈是一种后进先出(FILO, First In Last Out)的数据结构,即最后压入栈的元素最先弹出。
a) 出入栈操作
-
基本操作:
-
入栈(Push):将元素压入栈顶。
-
出栈(Pop):将栈顶元素弹出。
-
获取栈顶元素(Top/Peek):返回栈顶元素但不弹出。
-
-
实现细节:
-
栈顶指针:通常使用一个指针(如
top
)来指示栈顶位置。初始时,栈为空,top
设为-1。 -
入栈操作:将
top
加1,然后在top
位置插入元素。 -
出栈操作:读取
top
位置的元素,然后将top
减1。
-
b) 栈的可能出栈序列
栈的出栈顺序是受其入栈顺序和出栈时的操作顺序共同决定的。对于一个给定的入栈序列,可能的出栈序列取决于具体的出栈顺序。例如,对于入栈序列1, 2, 3
,可能的出栈序列有:
-
1, 2, 3
-
1, 3, 2
-
2, 1, 3
-
2, 3, 1
-
3, 2, 1
2. 队列 (Queue)
队列是一种先进先出(FIFO, First In First Out)的数据结构,即最先进入队列的元素最先被移除。
a) 循环队列
循环队列是一种通过数组实现的队列,采用循环的方式来利用数组空间,以解决顺序队列因数据移动导致的效率低下问题。
-
基本操作:
-
入队(Enqueue):将元素插入队尾。
-
出队(Dequeue):将队首元素移除。
-
-
实现细节:
-
队首指针(front):指示队列的第一个元素位置。
-
队尾指针(rear):指示队列最后一个元素的下一个位置。
-
入队操作:在
rear
位置插入元素,然后将rear
加1(若超出数组末尾则回到数组开头)。 -
出队操作:读取
front
位置的元素,然后将front
加1(若超出数组末尾则回到数组开头)。
-
b) 判空/判满条件
-
判空条件:
-
当
front
等于rear
时,队列为空。
-
-
判满条件:
-
通常会留一个空位以区分队列满和队列空的情况。当
(rear + 1) % 数组长度 == front
时,队列为满。
-
总结如下:
// 队列判空 bool isEmpty() { return front == rear; } // 队列判满 bool isFull() { return (rear + 1) % capacity == front; } // 入队操作 void enqueue(int element) { if (!isFull()) { data[rear] = element; rear = (rear + 1) % capacity; } else { // 处理队列满的情况 } } // 出队操作 int dequeue() { if (!isEmpty()) { int element = data[front]; front = (front + 1) % capacity; return element; } else { // 处理队列空的情况 return -1; // 或抛出异常 } }
四、串
字符串(串)是一种特殊的线性表,数据元素是字符。
串的模式匹配
模式匹配(Pattern Matching)是指在一个文本串(Text)中寻找一个模式串(Pattern)的出现位置。常用的两种模式匹配算法是传统的暴力匹配法和KMP(Knuth-Morris-Pratt)算法。
1. 传统方法(暴力匹配)
暴力匹配是最简单的模式匹配算法,它逐个字符比较模式串和文本串的字符,直到找到匹配或所有位置都尝试过。
-
算法描述:
-
从文本串的第一个字符开始,逐个字符与模式串的第一个字符比较。
-
如果匹配,则继续比较模式串的下一个字符和文本串的下一个字符。
-
如果某个字符不匹配,则回到文本串的下一个字符重新开始比较。
-
重复上述步骤,直到找到匹配或所有字符都比较完。
-
-
时间复杂度:最坏情况下为O(m*n),其中m是文本串的长度,n是模式串的长度。
2. KMP模式匹配
KMP算法通过预处理模式串,生成部分匹配表(Next数组),在匹配过程中利用这个表来避免重复比较,从而提高效率。
-
Next数组:Next数组记录了模式串中各个位置的部分匹配信息。Next[j]表示模式串在位置j之前的子串中,最长的相同前后缀的长度。
-
Next数组的计算:
-
初始化:Next[0] = -1。
-
遍历模式串,根据已知的部分匹配信息,计算每个位置的Next值。
-
-
Next数组计算示例(模式串 "goodgoogle"):
模式串: g o o d g o o g l e Next值: 0 1 1 1 2 3 4 1 1 1
由于Next数组的计算涉及到模式串本身的匹配关系,这里简化为常见的模式串示例。
-
KMP算法描述:
-
预处理模式串,计算Next数组。
-
在文本串中逐个字符比较模式串和文本串的字符。
-
如果某个字符不匹配,则利用Next数组调整模式串的位置,避免重复比较。
-
重复上述步骤,直到找到匹配或所有字符都比较完。
-
-
时间复杂度:O(m+n),其中m是文本串的长度,n是模式串的长度。
KMP算法的实现
#include <iostream> #include <vector> #include <string> // 计算Next数组 void computeNextArray(const std::string &pattern, std::vector<int> &next) { int m = pattern.length(); int j = 0; // 前缀末尾 next[0] = 0; // next数组的第一个值为0 for (int i = 1; i < m; ++i) { while (j > 0 && pattern[i] != pattern[j]) { j = next[j - 1]; // 利用next数组跳转 } if (pattern[i] == pattern[j]) { ++j; } next[i] = j; } } // KMP算法 void KMP(const std::string &text, const std::string &pattern) { int n = text.length(); int m = pattern.length(); std::vector<int> next(m, 0); // 计算next数组 computeNextArray(pattern, next); int j = 0; // 模式串指针 for (int i = 0; i < n; ++i) { while (j > 0 && text[i] != pattern[j]) { j = next[j - 1]; // 利用next数组跳转 } if (text[i] == pattern[j]) { ++j; } if (j == m) { // 找到一个匹配 std::cout << "Pattern found at index " << i - m + 1 << std::endl; j = next[j - 1]; // 寻找下一个匹配 } } } int main() { std::string text = "goodgoogle"; std::string pattern = "google"; KMP(text, pattern); return 0; }
好的,让我们详细探讨数组与广义表的各个知识点。
五、数组与广义表
1. 数组
数组是一种线性数据结构,用于存储相同类型的数据元素,具有随机访问的特点。
a) 特殊矩阵压缩存储
对于特殊矩阵,可以采用压缩存储方式以节省存储空间。常见的特殊矩阵有三角矩阵、带状矩阵和稀疏矩阵。
-
三角矩阵:
-
上三角矩阵:只存储主对角线及其上方的元素,其余元素均为零。
-
存储方法:将上三角矩阵的非零元素按行或列存储在一个一维数组中。
-
存储公式(按行存储):
A[i][j]
(i ≤ j)存储在一维数组B
中的位置为i*n + j - i*(i+1)/2
。
-
-
下三角矩阵:只存储主对角线及其下方的元素,其余元素均为零。
-
存储方法:将下三角矩阵的非零元素按行或列存储在一个一维数组中。
-
存储公式(按行存储):
A[i][j]
(i ≥ j)存储在一维数组B
中的位置为i*(i+1)/2 + j
。
-
-
-
带状矩阵(带状矩阵):
-
带状矩阵是在主对角线及其附近带状区域内有非零元素,带区以外的元素均为零。
-
存储方法:仅存储带区内的元素,将带区按行或列存储在一个一维数组中。
-
存储公式取决于带宽和具体带区位置。
-
-
稀疏矩阵:
-
稀疏矩阵是指非零元素相对较少的矩阵。
-
存储方法:采用三元组(行索引,列索引,非零元素值)或压缩稀疏行(CSR)格式存储。
-
三元组存储:将每个非零元素的信息存储为一个三元组。
-
b) 二维数组元素位置关系
二维数组在内存中通常按行优先或列优先顺序存储。假设二维数组A
有m
行n
列,且按行优先存储。
-
行优先存储公式:
-
A[i][j]
在内存中的位置为:L0 + (i * n + j) * size
,其中L0
是数组首地址,size
是元素的大小。
-
2. 广义表
广义表(Generalized List)是一种递归的数据结构,可以包含原子(单个元素)和子表(广义表本身)。
a) Head/Tail
-
Head:
-
广义表的第一个元素。如果这个元素是原子,则
Head
是这个原子;如果是子表,则Head
是这个子表。
-
-
Tail:
-
广义表中除了第一个元素以外的部分。如果原广义表只有一个元素,则
Tail
为空表。
-
-
广义表的例子:
-
广义表
L = (a, (b, c), d)
:-
Head(L) = a
-
Tail(L) = ((b, c), d)
-
-
-
广义表的递归定义:
-
若广义表为空表,则为空表。
-
若广义表非空,则可表示为
(Head, Tail)
,其中Head
是第一个元素,Tail
是剩余元素构成的广义表。
-
好的,让我们详细探讨树与二叉树的各个知识点。
六、树与二叉树
1. 二叉树基本概念与性质
基本概念
-
二叉树:每个节点最多有两个子节点的树结构,通常称为左子节点和右子节点。
-
节点的度:节点的子节点数目。
-
叶子节点:没有子节点的节点。
-
内部节点:至少有一个子节点的节点。
-
树的高度:树中节点的最大层次。
二叉树的五大性质
-
性质1:在二叉树的第 (i) 层上,最多有 (2^{i-1}) 个节点((i \geq 1))。
-
性质2:深度为 (k) 的二叉树至多有 (2^k - 1) 个节点((k \geq 1))。
-
性质3:对任何一棵二叉树,如果其叶子节点数为 (n_0),度为2的节点数为 (n_2),则 (n_0 = n_2 + 1)。
-
性质4:具有 (n) 个节点的二叉树的高度最少为 (\lceil \log_2 (n + 1) \rceil)。
-
性质5:如果在二叉树中有 (n) 个节点,其中度为1的节点数为 (n_1),度为2的节点数为 (n_2),则 (n = n_0 + n_1 + n_2)。
2. 二叉树的遍历
遍历是指按一定的顺序访问树中的每一个节点。
前序遍历(Preorder)
顺序:根节点 -> 左子树 -> 右子树
void preorderTraversal(Node* root) { if (root) { visit(root); preorderTraversal(root->left); preorderTraversal(root->right); } }
中序遍历(Inorder)
顺序:左子树 -> 根节点 -> 右子树
void inorderTraversal(Node* root) { if (root) { inorderTraversal(root->left); visit(root); inorderTraversal(root->right); } }
后序遍历(Postorder)
顺序:左子树 -> 右子树 -> 根节点
void postorderTraversal(Node* root) { if (root) { postorderTraversal(root->left); postorderTraversal(root->right); visit(root); } }
3. 树、二叉树、森林的转换
树、二叉树、森林的定义
-
树:一种包含n个节点的有序集合,其中每个节点有零个或多个子节点。
-
二叉树:每个节点最多有两个子节点的树。
-
森林:由 (m) 棵互不相交的树的集合。
左孩子右兄弟表示法
将树或森林转换为二叉树的一种方法。
-
转换规则:
-
树的每个节点的左子树是该节点的第一个子节点。
-
树的每个节点的右子树是该节点的右兄弟。
-
遍历关系
-
树的前根遍历等价于二叉树的前序遍历。
-
树的后根遍历等价于二叉树的中序遍历。
-
森林的前序遍历等价于二叉树的前序遍历。
-
森林的中序遍历等价于二叉树的中序遍历。
4. 哈夫曼树
哈夫曼树(Huffman Tree)是一种最优二叉树,用于构造哈夫曼编码以进行无损数据压缩。
构造哈夫曼树
-
步骤:
-
根据给定的字符及其频率,将每个字符视为一个独立的节点。
-
将所有节点按频率升序排列。
-
选择频率最小的两个节点作为新节点的左右子节点,构造一个新节点,其频率为左右子节点频率之和。
-
将新节点加入序列中,重新按频率排列。
-
重复上述步骤,直到所有节点合并为一棵哈夫曼树。
-
哈夫曼编码
-
编码规则:
-
从哈夫曼树根节点出发,向左子节点标记为0,向右子节点标记为1。
-
从根节点到叶子节点的路径即为该叶子节点(字符)的哈夫曼编码。
-
平均编码长度
平均编码长度是各字符编码长度的加权平均值,权值为字符的频率。
[ \text{平均编码长度} = \sum_{i=1}^n (f_i \times l_i) ]
其中 ( f_i ) 是字符 ( i ) 的频率,( l_i ) 是字符 ( i ) 的哈夫曼编码长度。
示例代码
#include <iostream> #include <queue> #include <vector> using namespace std; struct HuffmanNode { char data; int frequency; HuffmanNode* left; HuffmanNode* right; HuffmanNode(char d, int f) : data(d), frequency(f), left(nullptr), right(nullptr) {} }; struct Compare { bool operator()(HuffmanNode* a, HuffmanNode* b) { return a->frequency > b->frequency; } }; void printHuffmanCodes(HuffmanNode* root, string code) { if (!root) return; if (root->data != '$') cout << root->data << ": " << code << endl; printHuffmanCodes(root->left, code + "0"); printHuffmanCodes(root->right, code + "1"); } void buildHuffmanTree(vector<char>& chars, vector<int>& freqs) { priority_queue<HuffmanNode*, vector<HuffmanNode*>, Compare> minHeap; for (int i = 0; i < chars.size(); ++i) { minHeap.push(new HuffmanNode(chars[i], freqs[i])); } while (minHeap.size() > 1) { HuffmanNode* left = minHeap.top(); minHeap.pop(); HuffmanNode* right = minHeap.top(); minHeap.pop(); HuffmanNode* newNode = new HuffmanNode('$', left->frequency + right->frequency); newNode->left = left; newNode->right = right; minHeap.push(newNode); } HuffmanNode* root = minHeap.top(); printHuffmanCodes(root, ""); } int main() { vector<char> chars = {'a', 'b', 'c', 'd', 'e', 'f'}; vector<int> freqs = {5, 9, 12, 13, 16, 45}; buildHuffmanTree(chars, freqs); return 0; }
以上代码展示了构造哈夫曼树及生成哈夫曼编码的过程。
好的,让我们详细探讨图的各个知识点。
七、图
1. 基本概念
-
有向图(Directed Graph, DG):由一组顶点和一组有方向的边组成,边表示从一个顶点到另一个顶点的方向。
-
无向图(Undirected Graph, UDG):由一组顶点和一组无方向的边组成,边表示顶点之间的连接。
-
有向网(Directed Network, DN):有向图中,边具有权值。
-
无向网(Undirected Network, UDN):无向图中,边具有权值。
-
完全有向图:每两个顶点之间都存在方向相反的两条边。
-
完全无向图:每两个顶点之间都存在一条边。
-
连通图:在无向图中,任意两个顶点之间都存在路径。
-
强连通图:在有向图中,任意两个顶点之间都存在双向路径。
2. 邻接矩阵/邻接表
邻接矩阵
邻接矩阵是一种表示图的方法,用一个二维数组来表示顶点之间的连接关系。
-
定义:如果图中有 ( n ) 个顶点,使用一个 ( n \times n ) 的二维数组 ( A ),其中 ( Ai ) 表示顶点 ( i ) 和顶点 ( j ) 之间的关系。
邻接表
邻接表是一种更节省空间的表示图的方法,特别适用于稀疏图。
-
定义:对于图中的每个顶点,使用一个链表来存储与该顶点相邻的所有顶点。
-
无向图中,顶点 ( i ) 和顶点 ( j ) 之间有边,则在顶点 ( i ) 的链表中包含顶点 ( j ),在顶点 ( j ) 的链表中包含顶点 ( i )。
-
有向图中,顶点 ( i ) 到顶点 ( j ) 有边,则在顶点 ( i ) 的链表中包含顶点 ( j )。
-
3. 图的遍历
深度优先搜索(DFS)
深度优先搜索是一种图的遍历算法,从起始顶点开始,尽可能深入每一个未访问的邻接顶点,直到所有顶点都被访问。
void DFS(Graph &G, int v, vector<bool> &visited) { visit(v); visited[v] = true; for (int w : G.adj(v)) { if (!visited[w]) { DFS(G, w, visited); } } }
广度优先搜索(BFS)
广度优先搜索是一种图的遍历算法,从起始顶点开始,首先访问所有相邻顶点,然后再访问这些顶点的相邻顶点,依次类推。
void BFS(Graph &G, int v) { queue<int> q; vector<bool> visited(G.V(), false); visit(v); visited[v] = true; q.push(v); while (!q.empty()) { int u = q.front(); q.pop(); for (int w : G.adj(u)) { if (!visited[w]) { visit(w); visited[w] = true; q.push(w); } } } }
4. 图的应用
a) 最小生成树
普里姆算法(Prim's Algorithm)
普里姆算法从一个起始顶点开始,逐步扩展生成树,每次将具有最小权值的边加入生成树。
void Prim(Graph &G, int start) { vector<bool> inMST(G.V(), false); priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq; pq.push({0, start}); while (!pq.empty()) { int u = pq.top().second; pq.pop(); if (inMST[u]) continue; inMST[u] = true; for (auto &[v, weight] : G.adj(u)) { if (!inMST[v]) { pq.push({weight, v}); } } } }
克鲁斯卡尔算法(Kruskal's Algorithm)
克鲁斯卡尔算法通过排序所有边,按权值从小到大选择边,加入生成树中,确保不形成环。
void Kruskal(Graph &G) { vector<Edge> edges = G.getAllEdges(); sort(edges.begin(), edges.end()); UnionFind uf(G.V()); for (Edge e : edges) { if (!uf.connected(e.u, e.v)) { uf.union(e.u, e.v); mst.push_back(e); } } }
b) AOV网拓扑排序
拓扑排序是对有向无环图(AOV网)顶点的一种线性排序,使得对于每一条有向边 ( u \rightarrow v ),顶点 ( u ) 在排序中出现在顶点 ( v ) 之前。
void topologicalSort(Graph &G) { vector<int> inDegree(G.V(), 0); for (int u = 0; u < G.V(); ++u) { for (int v : G.adj(u)) { inDegree[v]++; } } queue<int> q; for (int i = 0; i < G.V(); ++i) { if (inDegree[i] == 0) { q.push(i); } } while (!q.empty()) { int u = q.front(); q.pop(); cout << u << " "; for (int v : G.adj(u)) { if (--inDegree[v] == 0) { q.push(v); } } } }
c) 最短路径
迪杰斯特拉算法(Dijkstra's Algorithm)
迪杰斯特拉算法用于单源最短路径,计算从起始顶点到其他所有顶点的最短路径。
void Dijkstra(Graph &G, int src) { vector<int> dist(G.V(), INT_MAX); dist[src] = 0; priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq; pq.push({0, src}); while (!pq.empty()) { int u = pq.top().second; pq.pop(); for (auto &[v, weight] : G.adj(u)) { if (dist[u] + weight < dist[v]) { dist[v] = dist[u] + weight; pq.push({dist[v], v}); } } } }
弗洛伊德算法(Floyd-Warshall Algorithm)
弗洛伊德算法用于计算所有顶点对之间的最短路径。
void FloydWarshall(Graph &G) { vector<vector<int>> dist(G.V(), vector<int>(G.V(), INT_MAX)); for (int u = 0; u < G.V(); ++u) { for (int v : G.adj(u)) { dist[u][v] = G.weight(u, v); } dist[u][u] = 0; } for (int k = 0; k < G.V(); ++k) { for (int i = 0; i < G.V(); ++i) { for (int j = 0; j < G.V(); ++j) { if (dist[i][k] != INT_MAX && dist[k][j] != INT_MAX) { dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]); } } } } }
八、查找
1. 顺序查找
顺序查找(Sequential Search),又称线性查找,是最简单的一种查找算法。它从数据结构的第一个元素开始,逐个比较,直到找到目标元素或遍历完整个结构。
算法实现
int sequentialSearch(int arr[], int n, int target) { for (int i = 0; i < n; ++i) { if (arr[i] == target) { return i; // 找到目标,返回其索引 } } return -1; // 未找到目标,返回-1 }
时间复杂度
-
最好情况:目标元素在第一个位置,时间复杂度为 (O(1))。
-
最坏情况:目标元素在最后一个位置或不存在,时间复杂度为 (O(n))。
-
平均情况:时间复杂度为 (O(n))。
2. 二分查找
二分查找(Binary Search)是一种效率较高的查找算法,适用于有序数组。它通过逐步将查找范围缩小一半,快速定位目标元素。
算法实现
int binarySearch(int arr[], int left, int right, int target) { while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == target) { return mid; // 找到目标,返回其索引 } else if (arr[mid] < target) { left = mid + 1; // 目标在右半部分 } else { right = mid - 1; // 目标在左半部分 } } return -1; // 未找到目标,返回-1 }
时间复杂度
-
最好情况:目标元素正好位于中间位置,时间复杂度为 (O(1))。
-
最坏情况:需要多次折半,时间复杂度为 (O(\log n))。
-
平均情况:时间复杂度为 (O(\log n))。
3. 平均查找长度(ASL)
平均查找长度(Average Search Length, ASL)是评价查找算法性能的一个重要指标,表示查找到目标元素时,所需比较的平均次数。
顺序查找的ASL
对于顺序查找,假设数组中有 (n) 个元素,目标元素在各个位置的概率相等,则:
[ \text{ASL} = \frac{1 + 2 + 3 + \cdots + n}{n} = \frac{n(n+1)}{2n} = \frac{n+1}{2} ]
二分查找的ASL
对于二分查找,查找成功时的ASL主要取决于查找次数(比较次数)。假设有 (n) 个元素:
[ \text{ASL} \approx \log_2(n+1) ]
因为二分查找每次将查找范围减半,因此查找次数与数组的对数成正比。
4. 总结
-
顺序查找适用于无序或小规模的数据结构,但时间复杂度较高。
-
二分查找适用于有序数组,具有较高的查找效率。
-
ASL可以用来衡量不同查找算法的平均性能。
好的,让我们详细探讨各种内部排序算法,包括插入类、交换类、选择类排序以及归并排序,并分析它们的时间复杂度、空间复杂度和稳定性。
九、内部排序
插入类排序
1. 直接插入排序
直接插入排序是一种简单的排序算法,通过将每个元素插入到已排好序的部分中。
void insertionSort(int arr[], int n) { for (int i = 1; i < n; ++i) { int key = arr[i]; int j = i - 1; while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } }
-
时间复杂度:平均 (O(n^2)),最好 (O(n)),最坏 (O(n^2))
-
空间复杂度:(O(1))
-
稳定性:稳定
2. 希尔排序
希尔排序(Shell Sort)是直接插入排序的改进,通过分组进行插入排序,然后逐渐缩小间隔进行排序。
void shellSort(int arr[], int n) { for (int gap = n / 2; gap > 0; gap /= 2) { for (int i = gap; i < n; ++i) { int temp = arr[i]; int j; for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) { arr[j] = arr[j - gap]; } arr[j] = temp; } } }
-
时间复杂度:平均 (O(n \log n)) 到 (O(n^{1.5})),最好 (O(n \log^2 n)),最坏 (O(n^2))
-
空间复杂度:(O(1))
-
稳定性:不稳定
交换类排序
1. 冒泡排序
冒泡排序通过多次遍历数组,每次比较相邻元素并交换,将较大的元素逐渐移动到数组末尾。
void bubbleSort(int arr[], int n) { for (int i = 0; i < n - 1; ++i) { for (int j = 0; j < n - i - 1; ++j) { if (arr[j] > arr[j + 1]) { swap(arr[j], arr[j + 1]); } } } }
-
时间复杂度:平均 (O(n^2)),最好 (O(n)),最坏 (O(n^2))
-
空间复杂度:(O(1))
-
稳定性:稳定
2. 快速排序
快速排序通过选择一个“基准”元素,将数组分成两部分,一部分比基准小,另一部分比基准大,然后递归排序。
int partition(int arr[], int low, int high) { int pivot = arr[high]; int i = low - 1; for (int j = low; j < high; ++j) { if (arr[j] <= pivot) { ++i; swap(arr[i], arr[j]); } } swap(arr[i + 1], arr[high]); return i + 1; } void quickSort(int arr[], int low, int high) { if (low < high) { int pi = partition(arr, low, high); quickSort(arr, low, pi - 1); quickSort(arr, pi + 1, high); } }
-
时间复杂度:平均 (O(n \log n)),最好 (O(n \log n)),最坏 (O(n^2))
-
空间复杂度:平均 (O(\log n))
-
稳定性:不稳定
选择类排序
1. 简单选择排序
简单选择排序每次从未排序部分选择最小(或最大)元素,放到已排序部分的末尾。
void selectionSort(int arr[], int n) { for (int i = 0; i < n - 1; ++i) { int minIdx = i; for (int j = i + 1; j < n; ++j) { if (arr[j] < arr[minIdx]) { minIdx = j; } } swap(arr[minIdx], arr[i]); } }
-
时间复杂度:(O(n^2))
-
空间复杂度:(O(1))
-
稳定性:不稳定
2. 堆排序
堆排序利用堆这种数据结构来排序,通过构建最大堆或最小堆,每次取出堆顶元素实现排序。
void heapify(int arr[], int n, int i) { int largest = i; int left = 2 * i + 1; int right = 2 * i + 2; if (left < n && arr[left] > arr[largest]) largest = left; if (right < n && arr[right] > arr[largest]) largest = right; if (largest != i) { swap(arr[i], arr[largest]); heapify(arr, n, largest); } } void heapSort(int arr[], int n) { for (int i = n / 2 - 1; i >= 0; --i) { heapify(arr, n, i); } for (int i = n - 1; i > 0; --i) { swap(arr[0], arr[i]); heapify(arr, i, 0); } }
-
时间复杂度:(O(n \log n))
-
空间复杂度:(O(1))
-
稳定性:不稳定
归并排序
归并排序利用分治法,将数组递归地分成两半,然后合并两个有序子数组。
void merge(int arr[], int left, int mid, int right) { int n1 = mid - left + 1; int n2 = right - mid; int L[n1], R[n2]; for (int i = 0; i < n1; ++i) L[i] = arr[left + i]; for (int i = 0; i < n2; ++i) R[i] = arr[mid + 1 + i]; int i = 0, j = 0, k = left; while (i < n1 && j < n2) { if (L[i] <= R[j]) { arr[k] = L[i]; i++; } else { arr[k] = R[j]; j++; } k++; } while (i < n1) { arr[k] = L[i]; i++; k++; } while (j < n2) { arr[k] = R[j]; j++; k++; } } void mergeSort(int arr[], int left, int right) { if (left < right) { int mid = left + (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid + 1, right); merge(arr, left, mid, right); } }
-
时间复杂度:(O(n \log n))
-
空间复杂度:(O(n))
-
稳定性:稳定
排序算法总结表
排序算法 | 平均时间复杂度 | 最好时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
直接插入排序 | (O(n^2)) | (O(n)) | (O(n^2)) | (O(1)) | 稳定 |
希尔排序 | (O(n \log n)) | (O(n \log^2 n)) | (O(n^2)) | (O(1)) | 不稳定 |
冒泡排序 | (O(n^2)) | (O(n)) | (O(n^2)) | (O(1)) | 稳定 |
快速排序 | (O(n \log n)) | (O(n \log n)) | (O(n^2)) | (O(\log n)) | 不稳定 |
简单选择排序 | (O(n^2)) | (O(n^2)) | (O(n^2)) | (O(1)) | 不稳定 |
堆排序 | (O(n \log n)) | (O(n \log n)) | (O(n \log n)) | (O(1)) | 不稳定 |
归并排序 | (O(n \log n)) | (O(n \log n)) | (O(n \log n)) | (O(n)) | 稳定 |