一、数据结构概述
数据结构是计算机存储、组织数据的方式,它分为逻辑结构和物理结构。逻辑结构反映数据元素之间的逻辑关系,主要有集合结构、线性结构、树形结构和图形结构;物理结构(存储结构)是数据结构在计算机中的实际存储形式,包括顺序存储结构和链式存储结构。
在学习数据结构时,要注意理解不同结构的适用场景。比如,顺序存储结构适合元素个数固定且需要频繁随机访问的情况,而链式存储结构则更适合元素个数动态变化、频繁进行插入和删除操作的场景。
二、线性表
(一)顺序表
顺序表(Sequential List)是用一段物理地址连续的存储单元(通常是数组)依次存储线性表的数据元素。这种存储结构的特点是逻辑上相邻的元素在物理位置上也相邻。
顺序表的主要特点
- 存储方式:采用数组实现,元素在内存中连续存放
- 访问特性:支持随机访问(Random Access),可以通过下标直接访问任意元素
- 存储密度:存储密度高,只存储数据元素本身
顺序表的优缺点分析
优点:
- 随机访问效率高:通过下标可以在O(1)时间内访问任意元素
- 查找效率高:对于有序表,可以使用二分查找等高效算法
- 存储简单:不需要额外的存储空间来维护元素间的关系
缺点:
- 插入/删除效率低:平均需要移动约n/2个元素,时间复杂度为O(n)
- 示例:在长度为1000的顺序表中插入元素到第1个位置,需要移动999个元素
- 空间固定:需要预先分配固定大小的存储空间
- 可能造成空间浪费(分配过大)
- 可能发生溢出(分配过小)
实现注意事项
-
边界检查:
- 数组下标从0开始,最大下标为length-1
- 访问元素前需检查index是否在[0, length-1]范围内
-
插入操作:
// 伪代码示例 int insert(SeqList *L, int index, ElemType e) { if (L->length >= MAXSIZE) return ERROR; // 表满检查 if (index < 0 || index > L->length) return ERROR; // 位置检查 for (int i = L->length; i > index; i--) { L->data[i] = L->data[i-1]; // 后移元素 } L->data[index] = e; L->length++; return OK; }
-
删除操作:
// 伪代码示例 int delete(SeqList *L, int index) { if (L->length == 0) return ERROR; // 空表检查 if (index < 0 || index >= L->length) return ERROR; // 位置检查 for (int i = index; i < L->length-1; i++) { L->data[i] = L->data[i+1]; // 前移元素 } L->length--; return OK; }
应用场景
顺序表适合用于:
- 需要频繁随机访问元素的场景
- 元素数量相对固定的情况
- 对存储空间要求较高的嵌入式系统
不适合用于:
- 需要频繁插入/删除操作的场景
- 元素数量变化较大的情况
(二)链表
链表是一种在计算机科学中常用的基础数据结构,其特点是物理存储单元采用非连续、非顺序的存储方式。与数组不同,链表中的数据元素(称为节点)在内存中可以不连续存放,各个节点之间通过指针相互链接,从而实现了数据元素之间的逻辑顺序关系。每个链表节点通常包含两个部分:数据域用于存储实际数据,指针域用于存储下一个(或前一个)节点的内存地址。
常见的链表类型主要有以下三种:
-
单链表:最基本的链表结构,每个节点包含一个数据域和一个指针域,指针域指向下一个节点。最后一个节点的指针域通常为空(NULL),表示链表结束。单链表只能单向遍历。
-
双链表:在单链表的基础上进行了扩展,每个节点包含两个指针域,分别指向前驱节点和后继节点。这种结构使得链表可以双向遍历,提高了操作的灵活性,但需要更多的内存空间来存储额外的指针。
-
循环链表:在单链表的基础上,将最后一个节点的指针域指向头节点,形成一个环状结构。双链表也可以实现循环,即头节点的前驱指针指向尾节点,尾节点的后继指针指向头节点。
在使用链表时,需要特别注意指针的正确操作。以下是几个关键操作要点:
-
插入节点操作:
- 为新节点分配内存空间
- 设置新节点的数据域
- 将新节点的指针域指向原位置的后继节点
- 修改前驱节点的指针域指向新节点
- 注意特殊情况(如在头部或尾部插入)
-
删除节点操作:
- 保存要删除节点的指针
- 修改前驱节点的指针域,使其指向要删除节点的后继节点
- 释放被删除节点的内存空间
- 注意处理边界情况(如删除头节点或尾节点)
在实际应用中,链表常用于需要频繁插入删除操作的场景,如实现队列、堆栈等数据结构,或作为更复杂数据结构的基础组件。与数组相比,链表在插入删除操作上具有O(1)的时间复杂度优势,但随机访问的效率较低(O(n))。
三、栈和队列
(一)栈
栈是一种特殊的线性表,它遵循"先进后出"(LIFO,Last In First Out)的原则,类似于日常生活中叠放的盘子,只能从最上面取放。栈的操作限制在表的一端进行,这一端称为栈顶(top),另一端称为栈底(bottom)。
栈的基本操作主要包括:
- 入栈(push):将新元素添加到栈顶
- 出栈(pop):删除并返回栈顶元素
- 查看栈顶(peek/top):获取但不删除栈顶元素
- 判空(isEmpty):检查栈是否为空
- 获取栈大小(size):返回栈中元素数量
栈的实现方式主要有两种:
-
顺序栈(数组实现):
- 使用数组存储元素
- 需要预先分配固定大小的空间
- 需要判断栈满(top == capacity-1)和栈空(top == -1)
- 操作时间复杂度为O(1)
-
链栈(链表实现):
- 使用单链表存储元素
- 动态分配内存,理论上不存在栈满问题
- 栈空条件为头指针指向NULL
- 需要注意指针操作顺序
栈的典型应用场景包括:
-
表达式求值:处理运算符优先级,如中缀表达式转后缀表达式 示例:计算3*(4+5)时,栈用于存储运算符和中间结果
-
函数调用:保存函数返回地址、参数和局部变量 示例:函数A调用函数B时,A的上下文入栈,B执行完后出栈恢复A
-
括号匹配:检查各种括号是否成对出现 示例:遇到左括号入栈,遇到右括号时检查栈顶是否匹配
-
浏览器前进后退:使用两个栈分别存储访问历史
-
撤销操作(Undo):将操作记录压入栈中,撤销时弹出
使用栈解决问题的关键点:
- 明确栈中存储的元素类型(操作数、运算符、状态等)
- 确定入栈和出栈的时机(如遇到特定字符时触发)
- 注意边界条件处理(空栈、栈溢出等)
- 考虑多栈配合使用的情况(如双栈实现队列)
在实际编程中,大多数语言都提供了栈的实现(如Java的Stack类,C++的stack模板类),也可以根据需求自行实现特定功能的栈结构。
(二)队列
队列是一种基本的数据结构,它遵循"先进先出"(First In First Out,FIFO)原则的线性表。这意味着最先进入队列的元素将会最先被移除。队列有两个主要的操作端:队头(front)和队尾(rear)。新元素只能从队尾插入(称为入队,enqueue操作),而元素的移除则只能从队头进行(称为出队,dequeue操作)。
队列的实现方式主要有两种:
- 顺序队列:使用数组实现,通过设置队头和队尾指针来管理队列
- 链式队列:使用链表实现,每个节点包含数据和指向下一个节点的指针
顺序队列存在一个常见的"假溢出"问题,即虽然数组尾部还有空间,但因为队头指针已经后移导致无法继续入队。为解决这个问题,可以采用循环队列(Circular Queue)的实现方式。循环队列通过取模运算(%)来判断队满和队空的情况:
- 队空条件:front == rear
- 队满条件:(rear + 1) % size == front 其中size表示队列的总容量。
在实际应用中,队列具有广泛的用途:
- 操作系统中的进程调度(如就绪队列)
- 消息队列系统(如RabbitMQ、Kafka等)
- 广度优先搜索(BFS)算法
- 多线程编程中的任务队列
- 打印机任务队列
- 银行、超市等场景的排队系统
使用队列时需要注意以下几点:
- 确保入队和出队的顺序正确
- 在实现循环队列时,通常会预留一个空间来区分队满和队空
- 在多线程环境下使用时需要考虑同步问题
- 根据实际需求选择合适的队列实现方式(顺序/链式)
示例代码(循环队列的基本操作):
public class CircularQueue {
private int size;
private Integer[] queue;
private int front;
private int rear;
public CircularQueue(int size) {
this.size = size;
this.queue = new Integer[size];
this.front = -1;
this.rear = -1;
}
public void enqueue(int item) {
if ((rear + 1) % size == front) {
System.out.println("Queue is full");
} else if (front == -1) {
front = rear = 0;
queue[rear] = item;
} else {
rear = (rear + 1) % size;
queue[rear] = item;
}
}
public Integer dequeue() {
if (front == -1) {
System.out.println("Queue is empty");
return null;
} else if (front == rear) {
int temp = queue[front];
front = rear = -1;
return temp;
} else {
int temp = queue[front];
front = (front + 1) % size;
return temp;
}
}
}
四、树
(一)二叉树
二叉树是计算机科学中一种重要的树形数据结构,其特点是每个节点最多有两个子节点,分别称为"左子树"和"右子树"。根据不同的结构特征,二叉树可以分为以下几种特殊形式:
- 满二叉树:所有非叶子节点都有两个子节点,且所有叶子节点都在同一层
- 完全二叉树:除了最后一层外完全填满,且最后一层的节点都靠左排列
- 平衡二叉树:任何节点的左右子树高度差不超过1
二叉树的遍历是基础且关键的操作,主要分为以下几种方式:
-
深度优先遍历:
- 前序遍历(根-左-右):先访问根节点,然后递归遍历左子树,最后递归遍历右子树
- 中序遍历(左-根-右):先递归遍历左子树,然后访问根节点,最后递归遍历右子树
- 后序遍历(左-右-根):先递归遍历左子树,然后递归遍历右子树,最后访问根节点
-
广度优先遍历(层次遍历):按层级从上到下,从左到右依次访问每个节点
实现方式上,递归实现简洁直观,而非递归实现通常借助栈(前序/中序/后序)或队列(层次遍历)来完成。例如,非递归前序遍历的伪代码如下:
import java.util.Stack;
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
public class BinaryTreeTraversal {
public static void preorderTraversal(TreeNode root) {
if (root == null) return;
Stack<TreeNode> stack = new Stack<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
visit(node); // 处理当前节点
// 先压入右子节点,再压入左子节点(栈的LIFO特性保证左节点先出栈)
if (node.right != null) {
stack.push(node.right);
}
if (node.left != null) {
stack.push(node.left);
}
}
}
private static void visit(TreeNode node) {
// 这里替换为实际需要的节点处理逻辑
System.out.print(node.val + " ");
}
}
二叉搜索树(BST)是一种特殊的二叉树,它满足以下性质:
- 左子树所有节点的值都小于根节点的值
- 右子树所有节点的值都大于根节点的值
- 左右子树也分别是二叉搜索树
基于这些性质,BST的常见操作效率如下:
- 查找:平均时间复杂度O(log n),最坏O(n)(退化成链表的情况)
- 插入:先查找合适位置,然后插入新节点
- 删除:分三种情况处理:
- 删除叶子节点:直接删除
- 删除只有一个子节点的节点:用其子节点替代
- 删除有两个子节点的节点:用其右子树的最小节点替代
在操作过程中,需要特别注意保持二叉树的性质。例如在BST中插入新节点时,必须确保插入后仍满足BST的定义条件。对于更复杂的平衡二叉树(如AVL树、红黑树),还需要在插入和删除时进行旋转等操作来维持平衡性。
(二)平衡二叉树
平衡二叉树是一种特殊的二叉搜索树,它要求对于树中的每一个节点,其左右两个子树的高度差(平衡因子)的绝对值不超过1。这种严格的高度平衡特性保证了树的最坏情况下查询时间复杂度保持在O(log n),从而显著提高了查询效率。
常见的平衡二叉树实现主要有两种:AVL树和红黑树。它们各有特点:
-
AVL树:
- 采用严格的平衡条件:每个节点的平衡因子必须为-1、0或1
- 通过四种旋转操作来维持平衡:
- 左旋(LL旋转):当右子树过高时使用
- 右旋(RR旋转):当左子树过高时使用
- 左右旋(LR旋转):先左旋后右旋的组合操作
- 右左旋(RL旋转):先右旋后左旋的组合操作
- 优势:查找效率极高,适合读多写少的场景
-
红黑树:
- 通过五个约束条件来保持近似平衡:
- 每个节点非红即黑
- 根节点必须为黑
- 红色节点的子节点必须为黑(不能有连续红节点)
- 从任一节点到其每个叶子的所有路径包含相同数目的黑节点
- 新插入的节点默认为红色
- 优势:插入和删除操作比AVL树更高效,适合频繁更新的场景
- 通过五个约束条件来保持近似平衡:
在实际应用中,平衡二叉树被广泛使用:
- Java的TreeMap和TreeSet基于红黑树实现
- Linux内核的进程调度使用红黑树管理进程控制块
- 数据库索引常使用B树/B+树(平衡多路搜索树)
理解平衡二叉树的调整机制需要掌握几个关键点:
- 平衡因子的计算方法
- 不同失衡情况对应的旋转策略
- 插入和删除后如何递归调整父节点的平衡
- 性能分析(时间复杂度、空间复杂度)
通过研究平衡二叉树的实现原理,可以更好地设计高效的数据结构,提高算法效率。
(三)树的其他应用
树是一种重要的数据结构,除了常见的二叉树、二叉搜索树外,还包括以下几种重要的变体:
-
堆(Heap):
- 分为大顶堆(Max-Heap)和小顶堆(Min-Heap)
- 必须满足完全二叉树的性质
- 堆特性:
- 大顶堆:每个节点的值都大于或等于其子节点的值
- 小顶堆:每个节点的值都小于或等于其子节点的值
- 典型应用:
- 实现优先队列(如Java中的PriorityQueue)
- 堆排序算法(时间复杂度为O(nlogn))
- 典型操作:插入(O(logn))、删除(O(logn))、获取最值(O(1))
-
B树(B-Tree):
- 一种平衡的多路搜索树
- 特性:
- 每个节点最多包含m个子节点(m阶B树)
- 除根节点外,每个节点至少有⌈m/2⌉个子节点
- 所有叶子节点位于同一层
- 优势:
- 适合磁盘等外部存储设备
- 减少磁盘I/O次数
- 应用场景:
- 文件系统索引
- 数据库索引(如MySQL的InnoDB引擎)
-
B+树(B-Plus Tree):
- B树的变体,特性:
- 非叶子节点只存储键值信息
- 所有数据都存储在叶子节点
- 叶子节点通过指针连接形成链表
- 相比B树的优势:
- 查询效率更稳定(都需要查找到叶子节点)
- 范围查询效率更高
- 更适合磁盘存储结构
- 典型应用:
- 关系型数据库索引(如MySQL、Oracle)
- 文件系统(如NTFS、ReiserFS)
- 大数据存储系统
- B树的变体,特性:
这些树结构在计算机科学中都有广泛应用,特别是在需要高效数据存储和检索的场景中。例如,在数据库系统中,B+树索引可以支持每秒数千次的查询操作;在操作系统中,堆结构可以用来管理进程优先级;在搜索引擎中,这些树结构也常用于实现高效的索引机制。
五、图
(一)图的基本概念
图是由顶点集(V)和边集(E)组成的一种非线性数据结构,用于表示对象之间的复杂关系。根据边的方向性,图可以分为两大类:
- 有向图(Directed Graph):边具有方向性,用箭头表示从一个顶点指向另一个顶点
- 无向图(Undirected Graph):边没有方向性,表示双向关系
图的存储方式主要有两种经典实现:
-
邻接矩阵(Adjacency Matrix):
- 使用二维数组表示顶点间的连接关系
- 矩阵元素值为1表示存在边,0表示无边
- 示例:对于包含n个顶点的图,需要n×n的矩阵空间
- 优点:查找任意两个顶点间边的存在性只需要O(1)时间
- 缺点:空间复杂度为O(n²),当顶点数超过1000时内存消耗显著增加
-
邻接表(Adjacency List):
- 使用数组+链表的结构存储
- 每个顶点对应一个链表,存储其邻接顶点
- 示例:社交网络关系适合用邻接表表示
- 优点:空间复杂度为O(V+E),特别适合稀疏图
- 缺点:查询特定边需要O(degree(v))时间
图的基本操作包括:
-
顶点操作:
- 插入顶点:在顶点集V中添加新元素
- 删除顶点:移除顶点及其所有关联边
- 示例:社交网络中新增/删除用户
-
边操作:
- 插入边:在边集E中添加新关系
- 删除边:移除特定顶点对之间的连接
- 示例:好友关系的建立/解除
-
图的遍历:
- 深度优先搜索(DFS):沿着路径深入访问,直到无法继续再回溯
- 广度优先搜索(BFS):分层访问,先访问离起点最近的顶点
- 应用场景:路径查找、连通分量分析、拓扑排序等
在实际应用中,图算法广泛应用于社交网络分析(如好友推荐)、交通路径规划(如导航系统)、任务调度(如项目管理)等领域。选择适当的数据结构和算法对系统性能有重大影响。
(二)图的遍历
图的遍历是图论中的基本操作,主要包括深度优先搜索(Depth-First Search,DFS)和广度优先搜索(Breadth-First Search,BFS)两种经典算法。
深度优先搜索(DFS)
DFS 采用"尽可能深"的搜索策略,其实现方式主要有两种:
- 递归实现:通过函数调用栈自动维护搜索顺序
- 显式栈实现:手动维护栈数据结构
public class DFS {
// 假设图用邻接表表示:List<Integer>[] graph
private static List<Integer>[] graph;
public static void dfsRecursive(int node, Set<Integer> visited) {
// 访问当前节点
visited.add(node);
// 遍历所有邻居节点
for (int neighbor : graph[node]) {
if (!visited.contains(neighbor)) {
dfsRecursive(neighbor, visited);
}
}
}
}
广度优先搜索(BFS)
BFS 采用"层次遍历"的策略,总是优先访问离起点最近的顶点,需要使用队列数据结构:
public class BFS {
// 假设图用邻接表表示,这里使用数组索引作为节点标识
// 例如:graph[i] 包含节点 i 的所有邻居
private static List<Integer>[] graph;
public static void bfs(int start) {
// 使用 ArrayDeque 作为队列实现
Deque<Integer> queue = new ArrayDeque<>();
queue.add(start);
// 使用 HashSet 记录已访问节点
Set<Integer> visited = new HashSet<>();
visited.add(start);
while (!queue.isEmpty()) {
int node = queue.poll(); // 从队列头部取出节点
// 遍历当前节点的所有邻居
for (int neighbor : graph[node]) {
if (!visited.contains(neighbor)) {
visited.add(neighbor);
queue.add(neighbor);
}
}
}
}
}
访问标记的重要性
遍历时必须维护已访问顶点集合(通常用哈希集合实现),否则:
- 在无向图中会导致无限循环
- 在有向图中可能重复访问同一顶点
- 影响算法的时间复杂度(从 O(V+E) 退化为指数级)
应用场景
-
DFS 适用于:
- 拓扑排序(课程安排、任务调度)
- 寻找连通分量
- 检测环
-
BFS 适用于:
- 无权图最短路径(社交网络好友推荐)
- 网页爬虫的层级抓取
- 迷宫最短路径求解
在更复杂的图算法中,如 Dijkstra 最短路径算法、Prim 最小生成树算法等,其核心思想都建立在图的遍历基础之上。实际应用中,选择 DFS 还是 BFS 需根据具体问题特点决定:当需要"深度"信息时选择 DFS,需要"广度"信息时选择 BFS。
(三)图的常见算法
拓扑排序
拓扑排序是一种针对有向无环图(DAG)的线性排序算法,它要求对于图中的每条有向边(u→v),顶点u在排序结果中必须位于顶点v之前。这种排序在实际中有广泛应用,如课程选修顺序安排、任务调度等。
实现方法
-
Kahn算法(基于入度):
- 步骤:
- 计算所有顶点的入度
- 将入度为0的顶点加入队列
- 从队列取出顶点并输出,同时"删除"该顶点(即减少其邻接点的入度)
- 将新产生的入度为0的顶点加入队列
- 重复直到队列为空
- 时间复杂度:O(V+E),其中V是顶点数,E是边数
- 示例:假设有先修课程关系图A→B→C,D→B,拓扑排序结果为[A,D,B,C]或[D,A,B,C]
- 步骤:
-
基于DFS的实现:
- 对图进行深度优先搜索
- 当顶点的所有邻接点都被访问后,将该顶点加入结果列表的前端
- 最终逆序输出即为拓扑排序结果
最短路径算法
迪杰斯特拉(Dijkstra)算法
用于求解单源最短路径问题,即从指定起点到图中所有其他顶点的最短路径。要求图中边的权重必须为非负数。
算法步骤:
- 初始化:起点距离设为0,其他顶点距离设为∞
- 每次从未处理的顶点中选择距离最小的顶点u
- 对u的所有邻接顶点v进行松弛操作:如果通过u到达v的路径比当前记录更短,则更新v的距离
- 将u标记为已处理
- 重复上述过程直到所有顶点都被处理
优化:使用优先队列(最小堆)可将时间复杂度优化到O(E + VlogV)
应用场景:网络路由、地图导航系统、交通网络规划等
弗洛伊德(Floyd)算法
用于求解所有顶点对之间的最短路径问题,可以处理包含负权边但不含负权环的图。
算法特点:
- 基于动态规划思想
- 时间复杂度为O(V³),空间复杂度为O(V²)
- 通过三重循环逐步更新顶点间的最短距离
示例应用:城市间交通费用计算、社交网络中的最短关系链查找
最小生成树算法
最小生成树(MST)是指包含图中所有顶点且边权之和最小的树(无环连通子图)。
普里姆(Prim)算法
实现步骤:
- 随机选择一个顶点作为起始点
- 维护一个优先队列存储连接已选顶点和未选顶点的边
- 每次选择权重最小的边加入生成树
- 更新优先队列
- 重复直到包含所有顶点
特点:
- 适合稠密图(边数接近完全图)
- 使用邻接矩阵实现时时间复杂度为O(V²)
- 使用优先队列优化可达O(ElogV)
应用示例:城市电网布线、通信网络建设
克鲁斯卡尔(Kruskal)算法
实现步骤:
- 将所有边按权重从小到大排序
- 初始化并查集数据结构
- 依次选择权重最小的边
- 如果该边的两个顶点不在同一集合,则加入生成树并合并集合
- 重复直到选择了V-1条边
特点:
- 适合稀疏图
- 时间复杂度主要取决于排序操作,为O(ElogE)
- 必须使用并查集来高效判断环路
并查集优化:
- 路径压缩和按秩合并可将单次操作均摊到接近常数时间
- 使得算法整体效率达到O(Eα(V)),其中α是反阿克曼函数
六、排序算法
(一)插入排序
插入排序是一类基于比较的排序算法,其核心思想类似于我们整理扑克牌的过程:每次将一个待排序的记录(元素)插入到已经排好序的有序序列中的适当位置,直到所有记录都插入完毕为止。这种算法可以细分为两种主要实现方式:直接插入排序和希尔排序。
-
直接插入排序(Straight Insertion Sort)
- 实现步骤: a. 将数组分为已排序区和未排序区,初始时已排序区只有一个元素(即数组第一个元素) b. 每次从未排序区取出第一个元素,在已排序区从后向前扫描 c. 找到相应位置后插入该元素 d. 重复上述过程直到未排序区为空
- 时间复杂度分析:
- 最好情况(完全有序):O(n)
- 最坏情况(完全逆序):O(n²)
- 平均情况:O(n²)
- 适用场景:
- 数据规模较小(n < 50)
- 数据基本有序(如日志数据按时间近乎有序)
- 实现简单,常作为其他复杂排序的子过程
- 示例:对数组 [5, 2, 4, 6, 1, 3] 进行排序时,算法会依次将每个元素插入到前面已排序的子数组中
-
希尔排序(Shell Sort)
- 改进原理:
- 通过将原始列表分割成若干子序列来提高插入排序的效率
- 让元素可以一次移动多位,减少比较和移动的次数
- 增量序列:
- 使用不同的增量(gap)将数组分组
- 常见增量序列有Hibbard序列、Sedgewick序列等
- 时间复杂度:
- 取决于增量序列的选择
- 通常介于O(n)和O(n²)之间
- 使用特定增量序列时可达O(n^(3/2))
- 实现特点:
- 是插入排序的改进版,又称"缩小增量排序"
- 通过多轮排序逐步减小增量,最终完成整体排序
- 适用于中等规模的数据集排序
- 示例应用:
- 嵌入式系统中内存受限时的排序
- 需要平衡实现复杂度和性能的场景
- 改进原理:
比较:
- 直接插入排序实现更简单,但效率较低
- 希尔排序通过分组策略提高了效率,但实现稍复杂
- 两者都是稳定的排序算法(相等元素的相对位置不变)
- 空间复杂度都为O(1),属于原地排序算法
在实际应用中,当数据规模较小时(如n<15),直接插入排序可能比更复杂的算法表现更好;而对于中等规模数据,希尔排序通常能提供更好的性能。现代编程语言的标准库中常常会结合多种排序算法,根据数据特征自动选择最优策略,其中插入排序类算法常被用作子过程或在小数据量时使用。
(二)交换排序
交换排序是一类通过不断交换元素位置来实现排序的经典算法,主要包括冒泡排序和快速排序两种典型方法。
冒泡排序(Bubble Sort)
冒泡排序是最基础的交换排序算法,其工作原理是:
- 从头到尾依次比较相邻的两个元素
- 如果前一个元素大于后一个元素(假设是升序排序),就交换它们的位置
- 每一轮比较都会将当前未排序序列中的最大元素"冒泡"到正确的位置
- 重复这个过程,直到整个序列有序
时间复杂度分析:
- 最好情况(已排序):O(n)
- 平均情况:O(n²)
- 最坏情况(逆序):O(n²)
示例:对数组 [5,3,8,6,4] 进行冒泡排序的过程: 第一轮:3,5,6,4,8 第二轮:3,5,4,6,8 第三轮:3,4,5,6,8
快速排序(Quick Sort)
快速排序是一种高效的交换排序算法,采用分治策略:
- 从数列中选取一个元素作为基准(pivot)
- 分区操作:将小于基准的元素放在左边,大于基准的元素放在右边
- 递归地对左右两个子序列进行快速排序
时间复杂度分析:
- 最好情况:O(nlogn)
- 平均情况:O(nlogn)
- 最坏情况(每次选到最值作为基准):O(n²)
基准选择的优化方法:
- 随机选择法:随机选取基准元素
- 三数取中法:取首、中、尾三个元素的中值作为基准
- 九数取中法:更复杂的取样方法
实际应用场景:
- 快速排序适合处理大规模数据
- 在C++ STL的sort()、Java的Arrays.sort()等标准库中都有应用
- 数据库系统中的排序操作常采用改进的快速排序算法
注意事项:
- 对于小规模数据(n<10),插入排序可能更高效
- 递归实现需要注意栈溢出问题
- 对于重复元素较多的情况,可采用三向切分的改进方法
(三)选择排序
选择排序是一种简单直观的排序算法,其基本思想可以形象地比喻为"打擂台"的方式:在每一轮排序过程中,从待排序序列中主动寻找最小(或最大)的元素,并将其放置到已排序序列的末尾。具体来说,算法通过以下步骤实现:
- 初始状态:将整个待排序序列视为无序区
- 选择过程:在无序区中进行n-1轮比较(n为元素总数)
- 第i轮(i从0开始): a) 设当前元素为最小值 b) 与后续元素逐个比较,记录真正的最小值位置 c) 将找到的最小值与第i个位置元素交换
- 终止条件:当无序区只剩下一个元素时排序完成
选择排序主要分为两种实现方式:
1. 直接选择排序
- 每次遍历都完整扫描无序区
- 时间复杂度分析:
- 最好/最坏/平均情况下均为O(n²)
- 比较次数固定为n(n-1)/2次
- 交换次数为n-1次
- 空间复杂度:O(1),原地排序
- 稳定性:不稳定(可能改变相同元素的相对位置)
- 适用场景:小规模数据或对交换次数敏感的场景
2. 堆排序
- 利用堆这种完全二叉树的数据结构特性
- 实现步骤: a) 构建初始堆(大顶堆或小顶堆) b) 将堆顶元素与末尾元素交换 c) 调整剩余元素为新堆 d) 重复b-c步骤直到堆大小为1
- 时间复杂度分析:
- 建堆过程:O(n)
- 每次调整堆:O(logn)
- 总体复杂度:O(nlogn)
- 空间复杂度:O(1)
- 稳定性:不稳定
- 优势:适合大规模数据,在空间受限的环境中表现优异
应用场景对比:
- 直接选择排序常用于:
- 嵌入式系统等内存受限环境
- 数据量小于1000的排序任务
- 需要减少数据移动次数的场景
- 堆排序适合:
- 大数据量的排序(如超过10万条记录)
- 需要稳定O(nlogn)时间复杂度的场景
- 优先级队列的实现
示例说明(直接选择排序): 假设对数组[29,10,14,37,13]进行升序排序:
- 第一轮:找到最小值10,与29交换 → [10,29,14,37,13]
- 第二轮:在剩余部分找到13,与29交换 → [10,13,14,37,29]
- 第三轮:37已是剩余部分最小值,位置不变
- 第四轮:找到29与37交换 → [10,13,14,29,37]
堆排序的优势演示: 当n=100万时:
- 直接选择排序需要约5×10^11次操作
- 堆排序仅需约2×10^7次操作 效率差异达到4个数量级
(四)归并排序
归并排序(Merge Sort)是一种基于分治思想的高效排序算法,其核心操作可以分解为以下三个步骤:
-
分解阶段: 将待排序的数组递归地分成两个近乎相等的子数组,直到每个子数组只包含一个元素(此时自然有序)。例如,对于一个长度为8的数组[5,3,8,6,2,7,1,4],首先被分成[5,3,8,6]和[2,7,1,4],然后继续分解直到获得8个单元素数组。
-
排序阶段: 对分解得到的单元素数组进行两两合并。这个过程是递归进行的,从最底层开始,将相邻的有序子数组合并成更大的有序数组。例如将[5]和[3]合并为[3,5],将[8]和[6]合并为[6,8]。
-
合并阶段: 合并操作是归并排序的关键步骤,需要额外的存储空间(空间复杂度O(n))。合并时,使用双指针法比较两个子数组的元素,将较小的元素先放入临时数组。例如合并[3,5]和[6,8]时,依次比较3和6、5和6,最终得到[3,5,6,8]。
时间复杂度分析:
- 最优/最差/平均时间复杂度均为O(nlogn)
- 分解过程形成递归树,树高为logn
- 每层合并操作的时间复杂度为O(n)
- 总时间复杂度为O(nlogn)×O(1)=O(nlogn)
稳定性分析: 归并排序是稳定的排序算法,因为在合并过程中,当两个元素相等时,优先选择左边子数组的元素,保持了原始顺序。
应用场景:
- 适合处理大规模数据,特别是需要稳定排序的场合
- 常用于外部排序(如大文件排序)
- Java中的Arrays.sort()在对象排序时采用Timsort(基于归并排序的改进算法)
与其他排序算法的比较:
- 相比快速排序,在最坏情况下仍保持O(nlogn)的时间复杂度
- 但需要额外的O(n)空间,不适合内存受限的场景
- 相比堆排序,虽然时间复杂度相同,但归并排序是稳定的
实际实现时,可以采用自顶向下(递归)或自底向上(迭代)两种方式。对于接近有序的数组,可以加入优化策略,如在子数组较小时切换为插入排序。
(五)排序算法的选择
在选择排序算法时,需要综合考虑多个关键因素,以确保选择最适合当前场景的排序方法。以下是需要重点考虑的方面:
-
数据规模
- 小规模数据(n<100):
- 直接插入排序:时间复杂度O(n²),但实际对小数据效率很高
- 简单选择排序:实现简单,但性能较差
- 冒泡排序:适合几乎已排序的数据
- 中等规模数据(100<n<10000):
- 希尔排序:插入排序的改进版
- 快速排序的简单实现
- 大规模数据(n>10000):
- 快速排序:平均O(nlogn),但最坏情况O(n²)
- 堆排序:稳定的O(nlogn),但缓存不友好
- 归并排序:稳定的O(nlogn),但需要额外空间
- 小规模数据(n<100):
-
数据初始状态
- 几乎有序的数据:插入排序表现最好
- 完全随机数据:快速排序通常最优
- 包含大量重复元素:三向切分的快速排序
- 逆序数据:堆排序或归并排序更稳定
-
稳定性要求
- 需要稳定排序的场景:
- 归并排序是首选
- 基数排序(针对特定类型数据)
- 可考虑带稳定性的插入排序
- 不需要稳定性的场景:
- 快速排序通常更快
- 堆排序空间效率高
- 需要稳定排序的场景:
-
其他考虑因素
- 内存限制:归并排序需要O(n)额外空间
- 实现复杂度:快速排序的优化实现较复杂
- 数据分布:针对特定分布可能有更优算法
- 并行需求:归并排序易于并行化
应用场景示例:
- 数据库索引构建:通常使用归并排序(稳定且可处理大数据)
- 游戏排行榜:可能使用堆排序(只需要topN时效率高)
- 内存受限的嵌入式系统:选择原地排序算法如堆排序
- 大数据分析:可能使用外部排序(归并排序的变种)
实际选择时,往往需要权衡这些因素,有时还需要进行基准测试来确定最优算法。
七、查找算法
(一)顺序查找
顺序查找(Sequential Search)是一种最基本的线性查找算法,其核心思想是从数据集合的起始位置开始,逐个元素进行比较,直到找到目标值或遍历完整个集合。
算法特点
- 实现简单:只需要一个循环结构即可实现
- 无结构要求:适用于任何线性结构(数组、链表等)
- 时间复杂度:
- 最好情况 O(1)(目标元素在第一个位置)
- 最坏情况 O(n)(目标元素在最后位置或不存在)
- 平均情况 O(n/2)≈O(n)
具体实现步骤
以数组为例:
- 从数组的第一个元素开始
- 将当前元素与目标值比较
- 如果匹配则返回当前索引
- 如果不匹配则移动到下一个元素
- 重复步骤2-4直到找到目标或遍历结束
示例代码
public class SequentialSearch {
public static int sequentialSearch(int[] arr, int target) {
for (int i = 0; i < arr.length; i++) {
if (arr[i] == target) {
return i; // 找到目标,返回索引
}
}
return -1; // 未找到目标
}
public static void main(String[] args) {
int[] numbers = {3, 7, 2, 9, 5, 1, 8};
int target = 5;
int result = sequentialSearch(numbers, target);
if (result != -1) {
System.out.println("元素 " + target + " 在数组中的索引是: " + result);
} else {
System.out.println("元素 " + target + " 不在数组中");
}
}
}
适用场景
- 小规模数据集(n<100)
- 无序数据集合
- 对查找效率要求不高的场景
- 作为其他高级算法的备选方案
优缺点分析
优点:
- 实现简单直观
- 不需要预先排序
- 适用于各种数据结构
缺点:
- 查找效率低
- 不适合大规模数据
- 无法利用数据的有序性优化查找
(二)二分查找
二分查找是一种高效的查找算法,又称折半查找。该算法要求待查找的数据集合必须满足两个前提条件:一是表中的元素必须按关键字有序排列(通常为升序或降序),二是必须采用顺序存储结构(如数组)。这些特性使得二分查找能够充分利用数据的有序性,快速缩小查找范围。
二分查找的基本思想是:首先将给定值与表中间位置(mid)的元素关键字进行比较,根据比较结果会出现三种情况:
- 若给定值等于中间元素,则查找成功
- 若给定值小于中间元素,则在表的前半部分继续查找
- 若给定值大于中间元素,则在表的后半部分继续查找
通过这种不断折半的方式,每次都将查找范围缩小一半,因此其时间复杂度为 O(logn),相较于线性查找的 O(n) 具有显著优势。例如,在一个包含 100 万个元素的有序数组中,最多只需要 20 次比较就能确定目标元素是否存在。
在实际应用中,二分查找常用于以下场景:
- 有序数组中的元素查找
- 数据库索引的快速定位
- 游戏中的分数排名系统
- 内存中的快速地址查找
需要注意的是,实现二分查找时要特别注意区间边界的处理。常见的区间表示方法有:
- 左闭右闭区间 [left, right]:
- while(left <= right)
- right = mid - 1
- left = mid + 1
- 左闭右开区间 [left, right):
- while(left < right)
- right = mid
- left = mid + 1
错误处理边界条件可能导致两种常见问题:
- 死循环:如忘记更新 left 或 right 指针
- 漏查:如区间开闭处理不当导致跳过关键元素
以下是一个经典的二分查找实现示例(升序数组):
public class BinarySearch {
// 整数数组版本
public static int binarySearch(int[] arr, int target) {
int left = 0;
int right = arr.length - 1; // 闭区间 [left, right]
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; // 未找到目标
}
// 泛型版本(支持任何可比较的类型)
public static <T extends Comparable<T>> int binarySearch(T[] arr, T target) {
int left = 0;
int right = arr.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
int cmp = arr[mid].compareTo(target);
if (cmp == 0) {
return mid; // 找到目标
} else if (cmp < 0) {
left = mid + 1; // 目标在右半部分
} else {
right = mid - 1; // 目标在左半部分
}
}
return -1; // 未找到目标
}
public static void main(String[] args) {
// 整数数组测试
int[] numbers = {1, 3, 5, 7, 9, 11, 13, 15};
int target1 = 7;
int result1 = binarySearch(numbers, target1);
System.out.println("整数搜索: 元素 " + target1 + " 的索引是 " + result1);
// 字符串数组测试
String[] words = {"apple", "banana", "grape", "orange", "pear", "strawberry"};
String target2 = "orange";
int result2 = binarySearch(words, target2);
System.out.println("字符串搜索: 元素 " + target2 + " 的索引是 " + result2);
// 自定义对象测试
Person[] people = {
new Person("Alice", 25),
new Person("Bob", 30),
new Person("Charlie", 35),
new Person("David", 40)
};
Person target3 = new Person("Charlie", 35);
int result3 = binarySearch(people, target3);
System.out.println("对象搜索: 元素 Charlie 的索引是 " + result3);
}
}
// 自定义对象示例
class Person implements Comparable<Person> {
String name;
int age;
Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public int compareTo(Person other) {
// 先按姓名比较,姓名相同再按年龄比较
int nameCompare = this.name.compareTo(other.name);
if (nameCompare != 0) {
return nameCompare;
}
return Integer.compare(this.age, other.age);
}
@Override
public String toString() {
return name + "(" + age + ")";
}
}
虽然二分查找效率很高,但它也存在局限性:必须事先对数据进行排序,且主要适用于静态数据集合。如果数据需要频繁插入或删除,维护有序性的成本可能会抵消其查找优势。
(三)哈希查找
哈希查找(Hash Search)是一种基于哈希表(Hash Table)实现的高效查找算法。其核心思想是通过哈希函数(Hash Function)将关键字(Key)直接映射到哈希表中的某个特定位置(称为哈希地址或桶),从而快速定位到目标数据。
1. 哈希函数的设计原则
哈希函数的设计直接影响查找效率,理想情况下应满足以下条件:
- 均匀性:尽可能均匀地将关键字分布到哈希表的各个位置,减少冲突概率
- 计算简单:哈希函数的计算时间复杂度应为 O(1)
- 确定性:相同关键字必须始终映射到相同的哈希地址
常见哈希函数示例:
- 除留余数法:
h(key) = key % p
(p通常取质数) - 平方取中法:先平方再取中间几位
- 字符串哈希:如BKDR哈希
h(str) = (seed * h + ch) % size
2. 哈希冲突解决方案
当不同关键字映射到相同位置时(n>m,n为关键字数,m为表长),需要处理冲突:
2.1 开放定址法
- 线性探测:冲突时顺序查找下一个空位
public class LinearProbingHashTable { private Integer[] table; // 哈希表数组 private int size; // 当前元素数量 private final int capacity; // 哈希表容量 // 构造函数 public LinearProbingHashTable(int capacity) { this.capacity = capacity; this.table = new Integer[capacity]; this.size = 0; } // 主哈希函数 private int hash(int key) { return key % capacity; } // 线性探测函数 private int probe(int key, int i) { return (hash(key) + i) % capacity; } // 插入键值对 public void insert(int key) { if (size == capacity) { throw new IllegalStateException("Hash table is full"); } int i = 0; int index; do { index = probe(key, i); // 如果找到空槽或已删除的位置(这里用null表示空) if (table[index] == null) { table[index] = key; size++; return; } i++; } while (i < capacity); throw new IllegalStateException("Failed to insert key: " + key); } // 查找键 public boolean contains(int key) { int i = 0; int startIndex = hash(key); int index = startIndex; do { if (table[index] == null) { return false; // 找到空槽,说明键不存在 } if (table[index] == key) { return true; // 找到键 } i++; index = probe(key, i); } while (i < capacity && index != startIndex); return false; } // 删除键 public void delete(int key) { int i = 0; int startIndex = hash(key); int index = startIndex; do { if (table[index] == null) { return; // 键不存在 } if (table[index] == key) { table[index] = null; // 标记为已删除 size--; return; } i++; index = probe(key, i); } while (i < capacity && index != startIndex); } // 打印哈希表 public void printTable() { System.out.println("Hash Table:"); for (int i = 0; i < capacity; i++) { System.out.println("[" + i + "]: " + (table[i] == null ? "null" : table[i])); } } public static void main(String[] args) { LinearProbingHashTable ht = new LinearProbingHashTable(10); // 插入键 ht.insert(5); ht.insert(15); // 冲突,线性探测 ht.insert(25); // 冲突,线性探测 ht.insert(35); // 冲突,线性探测 // 打印哈希表 ht.printTable(); // 查找键 System.out.println("Contains 15: " + ht.contains(15)); // true System.out.println("Contains 20: " + ht.contains(20)); // false // 删除键 ht.delete(15); System.out.println("After deleting 15:"); ht.printTable(); System.out.println("Contains 15: " + ht.contains(15)); // false } }
- 二次探测:解决线性探测的聚集问题
public class QuadraticProbingHashTable { private static final Object DELETED = new Object(); // 特殊删除标记 private Integer[] table; // 哈希表数组 private int size; // 当前元素数量 private final int capacity; // 哈希表容量 // 构造函数 public QuadraticProbingHashTable(int capacity) { this.capacity = getNextPrime(capacity); // 使用质数容量 this.table = new Integer[this.capacity]; this.size = 0; } // 主哈希函数 private int hash(int key) { return key % capacity; } // 二次探测函数 private int probe(int key, int i) { return (hash(key) + i * i) % capacity; } // 插入键值对 public void insert(int key) { if (size >= capacity / 2) { // 负载因子 > 0.5 时扩容 rehash(); } int i = 0; int deletedIndex = -1; // 记录第一个DELETED位置 while (i < capacity) { int index = probe(key, i); if (table[index] == null) { // 优先使用已删除的槽位 if (deletedIndex != -1) { table[deletedIndex] = key; } else { table[index] = key; } size++; return; } else if (table[index] == key) { return; // 键已存在,无需插入 } else if (table[index] == DELETED && deletedIndex == -1) { deletedIndex = index; // 记录第一个可重用的位置 } i++; } // 如果遍历过程中找到DELETED位置 if (deletedIndex != -1) { table[deletedIndex] = key; size++; return; } throw new IllegalStateException("Failed to insert key: " + key); } // 查找键 public boolean contains(int key) { int i = 0; int startIndex = hash(key); while (i < capacity) { int index = probe(key, i); if (table[index] == null) { return false; // 找到空槽,键不存在 } if (table[index] == key) { return true; // 找到键 } i++; } return false; } // 删除键 public void delete(int key) { int i = 0; while (i < capacity) { int index = probe(key, i); if (table[index] == null) { return; // 键不存在 } if (table[index] == key) { table[index] = (Integer) DELETED; // 标记为已删除 size--; return; } i++; } } // 重新哈希(扩容) private void rehash() { int newCapacity = getNextPrime(capacity * 2); Integer[] oldTable = table; table = new Integer[newCapacity]; capacity = newCapacity; size = 0; for (Integer key : oldTable) { if (key != null && key != DELETED) { insert(key); } } } // 获取下一个质数(用于哈希表扩容) private int getNextPrime(int n) { while (!isPrime(n)) { n++; } return n; } // 检查是否为质数 private boolean isPrime(int n) { if (n <= 1) return false; if (n == 2) return true; if (n % 2 == 0) return false; for (int i = 3; i * i <= n; i += 2) { if (n % i == 0) { return false; } } return true; } // 打印哈希表 public void printTable() { System.out.println("Hash Table (Capacity: " + capacity + ", Size: " + size + ")"); for (int i = 0; i < capacity; i++) { System.out.println("[" + i + "]: " + (table[i] == null ? "null" : table[i] == DELETED ? "DELETED" : table[i])); } } public static void main(String[] args) { QuadraticProbingHashTable ht = new QuadraticProbingHashTable(10); // 插入键 ht.insert(5); ht.insert(15); // 冲突,二次探测 ht.insert(25); // 冲突,二次探测 ht.insert(35); // 冲突,二次探测 // 打印哈希表 ht.printTable(); // 查找键 System.out.println("Contains 15: " + ht.contains(15)); // true System.out.println("Contains 20: " + ht.contains(20)); // false // 删除键 ht.delete(15); System.out.println("After deleting 15:"); ht.printTable(); System.out.println("Contains 15: " + ht.contains(15)); // false // 重新插入 ht.insert(45); System.out.println("After inserting 45:"); ht.printTable(); } }
- 双重哈希:使用第二个哈希函数
public class DoubleHashingHashTable { private static final Object DELETED = new Object(); // 特殊删除标记 private Integer[] table; // 哈希表数组 private int size; // 当前元素数量 private int capacity; // 哈希表容量 private final double loadFactorThreshold = 0.7; // 负载因子阈值 // 构造函数 public DoubleHashingHashTable(int initialCapacity) { this.capacity = getNextPrime(initialCapacity); // 使用质数容量 this.table = new Integer[this.capacity]; this.size = 0; } // 主哈希函数 h1 private int hash1(int key) { return key % capacity; } // 辅助哈希函数 h2(必须与容量互质) private int hash2(int key) { // 确保返回值为 1 到 capacity-1 之间的奇数(与容量互质) return 1 + (key % (capacity - 1)); } // 双重哈希探测函数 private int probe(int key, int i) { return (hash1(key) + i * hash2(key)) % capacity; } // 插入键值对 public void insert(int key) { if ((double) size / capacity >= loadFactorThreshold) { rehash(); } int i = 0; int startIndex = hash1(key); int index = startIndex; int deletedIndex = -1; // 记录第一个DELETED位置 do { if (table[index] == null) { // 优先使用已删除的槽位 if (deletedIndex != -1) { table[deletedIndex] = key; } else { table[index] = key; } size++; return; } else if (table[index] == key) { return; // 键已存在,无需插入 } else if (table[index] == DELETED && deletedIndex == -1) { deletedIndex = index; // 记录第一个可重用的位置 } i++; index = probe(key, i); } while (i < capacity && index != startIndex); // 如果遍历过程中找到DELETED位置 if (deletedIndex != -1) { table[deletedIndex] = key; size++; return; } throw new IllegalStateException("Failed to insert key: " + key); } // 查找键 public boolean contains(int key) { int i = 0; int startIndex = hash1(key); int index = startIndex; do { if (table[index] == null) { return false; // 找到空槽,键不存在 } if (table[index] == key) { return true; // 找到键 } i++; index = probe(key, i); } while (i < capacity && index != startIndex); return false; } // 删除键 public void delete(int key) { int i = 0; int startIndex = hash1(key); int index = startIndex; do { if (table[index] == null) { return; // 键不存在 } if (table[index] == key) { table[index] = (Integer) DELETED; // 标记为已删除 size--; return; } i++; index = probe(key, i); } while (i < capacity && index != startIndex); } // 重新哈希(扩容) private void rehash() { int newCapacity = getNextPrime(capacity * 2); Integer[] oldTable = table; table = new Integer[newCapacity]; int oldCapacity = capacity; capacity = newCapacity; size = 0; for (int i = 0; i < oldCapacity; i++) { if (oldTable[i] != null && oldTable[i] != DELETED) { insert(oldTable[i]); } } } // 获取下一个质数(用于哈希表扩容) private int getNextPrime(int n) { if (n <= 1) return 2; int prime = n; boolean found = false; while (!found) { prime++; if (isPrime(prime)) { found = true; } } return prime; } // 检查是否为质数 private boolean isPrime(int n) { if (n <= 1) return false; if (n == 2) return true; if (n % 2 == 0) return false; for (int i = 3; i * i <= n; i += 2) { if (n % i == 0) { return false; } } return true; } // 打印哈希表 public void printTable() { System.out.println("Hash Table (Capacity: " + capacity + ", Size: " + size + ")"); for (int i = 0; i < capacity; i++) { System.out.println("[" + i + "]: " + (table[i] == null ? "null" : table[i] == DELETED ? "DELETED" : table[i])); } } public static void main(String[] args) { DoubleHashingHashTable ht = new DoubleHashingHashTable(10); // 插入键 ht.insert(5); ht.insert(15); // 冲突,双重哈希 ht.insert(25); // 冲突,双重哈希 ht.insert(35); // 冲突,双重哈希 // 打印哈希表 ht.printTable(); // 查找键 System.out.println("Contains 15: " + ht.contains(15)); // true System.out.println("Contains 20: " + ht.contains(20)); // false // 删除键 ht.delete(15); System.out.println("After deleting 15:"); ht.printTable(); System.out.println("Contains 15: " + ht.contains(15)); // false // 重新插入 ht.insert(45); System.out.println("After inserting 45:"); ht.printTable(); } }
2.2 链地址法(拉链法)
- 每个桶维护一个链表(或树)
- JDK的HashMap实现采用:数组+链表+红黑树(当链表长度>8时转红黑树)
3. 性能分析
最优情况下时间复杂度为 O(1),但实际性能受以下因素影响:
- 装填因子 α = n/m(一般建议 α ≤ 0.75)
- 冲突处理方式(链地址法通常优于开放定址法)
- 哈希函数的均匀性
应用场景示例:
- 数据库索引(如MySQL的hash索引)
- 缓存系统(如Redis的字典实现)
- 编译器符号表管理
- 网络安全(如密码哈希存储)
4. 优化方向
- 动态扩容(当α超过阈值时重建哈希表)
- 完美哈希(适用于静态数据集)
- 一致性哈希(分布式系统场景)
- 布谷鸟哈希(新型冲突解决方法)
八、算法复杂度分析
时间复杂度
时间复杂度是指算法执行所需的时间与问题规模之间的关系,它描述了算法运行时间随输入数据规模增长的变化趋势。常用大 O 符号表示时间复杂度,常见的有:
-
O(1) 常数时间复杂度:表示算法的执行时间不随输入规模变化。例如,访问数组的某个元素:
public class ArrayUtils { public static <T> T getFirstElement(T[] arr) { return arr[0]; // 只需一次操作 } }
-
O(n) 线性时间复杂度:表示算法的执行时间与输入规模成正比。例如,遍历数组:
public class ArraySum { // 处理基本类型数组 public static int sumArray(int[] arr) { int total = 0; for (int num : arr) { // 循环n次 total += num; } return total; } // 处理double类型数组 public static double sumArray(double[] arr) { double total = 0.0; for (double num : arr) { total += num; } return total; } // 泛型方法处理对象数组(如Integer[], Double[]等) public static <T extends Number> double sumArray(T[] arr) { double total = 0.0; for (T num : arr) { total += num.doubleValue(); // 转换为double计算 } return total; } }
-
O(n²) 平方时间复杂度:常见于嵌套循环,例如冒泡排序:
public class BubbleSort { // 泛型版本(支持任何实现了Comparable接口的类型) public static <T extends Comparable<T>> void bubbleSort(T[] arr) { int n = arr.length; for (int i = 0; i < n; i++) { // 外层循环n次 for (int j = 0; j < n - i - 1; j++) { // 内层循环n-i-1次 if (arr[j].compareTo(arr[j + 1]) > 0) { // 交换元素 T temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } } // 基本类型int版本(优化性能) public static void bubbleSort(int[] arr) { int n = arr.length; for (int i = 0; i < n; i++) { for (int j = 0; j < n - i - 1; j++) { if (arr[j] > arr[j + 1]) { // 交换元素(无需创建临时对象) int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } } } } }
-
O(log n) 对数时间复杂度:常见于二分查找等分治算法:
public class BinarySearch { // 泛型版本(支持任何实现了Comparable接口的类型) public static <T extends Comparable<T>> int binarySearch(T[] arr, T target) { int low = 0; int high = arr.length - 1; while (low <= high) { // 每次搜索范围减半 int mid = low + (high - low) / 2; // 避免整数溢出 int comparison = arr[mid].compareTo(target); if (comparison == 0) { return mid; // 找到目标 } else if (comparison < 0) { low = mid + 1; // 目标在右半部分 } else { high = mid - 1; // 目标在左半部分 } } return -1; // 未找到 } // 基本类型int版本(优化性能) public static int binarySearch(int[] arr, int target) { int low = 0; int high = arr.length - 1; while (low <= high) { int mid = low + (high - low) / 2; // 安全计算中点 if (arr[mid] == target) { return mid; } else if (arr[mid] < target) { low = mid + 1; } else { high = mid - 1; } } return -1; } }
空间复杂度
空间复杂度是指算法执行所需的存储空间与问题规模之间的关系,同样使用大 O 表示法:
-
O(1) 常数空间复杂度:算法使用固定大小的额外空间,与输入规模无关。例如:
public class ArrayMax { // 泛型版本(支持任何实现了Comparable接口的类型) public static <T extends Comparable<T>> T findMax(T[] arr) { if (arr == null || arr.length == 0) { throw new IllegalArgumentException("数组不能为空"); } T maxVal = arr[0]; // 只使用一个额外变量 for (T num : arr) { if (num.compareTo(maxVal) > 0) { maxVal = num; } } return maxVal; } // 基本类型int版本(优化性能) public static int findMax(int[] arr) { if (arr == null || arr.length == 0) { throw new IllegalArgumentException("数组不能为空"); } int maxVal = arr[0]; // 只使用一个额外变量 for (int num : arr) { if (num > maxVal) { maxVal = num; } } return maxVal; } // 其他基本类型版本(double示例) public static double findMax(double[] arr) { if (arr == null || arr.length == 0) { throw new IllegalArgumentException("数组不能为空"); } double maxVal = arr[0]; for (double num : arr) { if (num > maxVal) { maxVal = num; } } return maxVal; } }
-
O(n) 线性空间复杂度:算法使用的额外空间与输入规模成正比。例如复制数组:
import java.util.Arrays; public class ArrayCopy { // 泛型版本(支持任意对象类型) public static <T> T[] copyArray(T[] arr) { if (arr == null) { return null; } // 创建相同类型的新数组 T[] newArr = Arrays.copyOf(arr, arr.length); // 显式复制元素(保持原始逻辑) for (int i = 0; i < arr.length; i++) { newArr[i] = arr[i]; } return newArr; } // 基本类型int版本 public static int[] copyArray(int[] arr) { if (arr == null) { return null; } int[] newArr = new int[arr.length]; for (int i = 0; i < arr.length; i++) { newArr[i] = arr[i]; } return newArr; } // 基本类型double版本 public static double[] copyArray(double[] arr) { if (arr == null) { return null; } double[] newArr = new double[arr.length]; for (int i = 0; i < arr.length; i++) { newArr[i] = arr[i]; } return newArr; } // 其他基本类型版本(如char, boolean等)可类似添加 }
-
O(n²) 平方空间复杂度:例如生成二维矩阵:
public class MatrixCreator { public static int[][] createMatrix(int n) { // 处理边界情况(n为负数或0) if (n <= 0) { return new int[0][0]; // 返回空数组 } int[][] matrix = new int[n][n]; // 创建n×n的二维数组 for (int i = 0; i < n; i++) { // n行 for (int j = 0; j < n; j++) { // 每行n列 matrix[i][j] = i * j; // 计算元素值 } } return matrix; // 总共需要n²的空间 } }
复杂度分析方法
-
循环次数分析:计算循环的迭代次数
- 单层循环通常为 O(n)
- 嵌套循环通常是各层循环复杂度的乘积
-
递归树分析:适用于递归算法
- 画出递归调用树
- 计算每层的工作量
- 求和得到总复杂度
-
主定理:适用于形式为 T(n) = aT(n/b) + f(n) 的递归关系
实际应用中的考虑
-
最坏情况 vs 平均情况:
- 最坏情况复杂度:保证算法在任何输入下的性能上限
- 平均情况复杂度:算法在随机输入下的期望性能
- 例如快速排序的最坏是 O(n²),但平均是 O(n log n)
-
实际优化策略:
- 用空间换时间:如哈希表提高查找速度
- 减少不必要的计算:如缓存中间结果
- 选择合适的数据结构:如堆优先队列等
-
工程实践建议:
- 小规模数据:简单算法可能更优(常数因子小)
- 大规模数据:优先考虑渐近复杂度低的算法
- 考虑内存局部性:缓存友好的算法可能实际更快