本文大部分使用ChatGPT生成。
更详细的数据结构与算法教程可以看更系列的文章:
https://blog.youkuaiyun.com/weixin_50886514/category_10749771.html
https://blog.youkuaiyun.com/real_fool_/category_10818355.html
以下为整体知识架构图
基本概念
数据结构是指数据在计算机存储器中的组织方式,是计算机存储、处理和管理数据的基础,它主要涉及到数据的存储结构和操作方法。
算法是指解决问题的步骤和方法,是一种描述问题解决过程的有限而清晰的指令集。数据结构和算法是计算机科学的基础,是计算机程序设计的重要组成部分。
数据结构主要包括以下几种常用的数据结构:
- 数组:一种连续存储数据的结构,可以随机访问数组中的任意元素。
- 链表:一种非连续存储数据的结构,每个节点包含数据和指向下一个节点的指针。
- 栈:一种先进后出(LIFO)的数据结构,只能在栈顶进行插入和删除操作。
- 队列:一种先进先出(FIFO)的数据结构,只能在队尾进行插入操作,在队头进行删除操作。
- 树:一种非线性的数据结构,由节点和边组成,每个节点可以有多个子节点。
- 图:一种非线性的数据结构,由节点和边组成,节点之间可以有多个连接。
算法主要包括以下几种常用的算法:
- 排序算法:如冒泡排序、插入排序、选择排序、归并排序、快速排序等。
- 查找算法:如线性查找、二分查找、哈希查找等。
- 字符串匹配算法:如暴力匹配、KMP算法、Boyer-Moore算法等。
- 图算法:如最短路径算法、最小生成树算法、拓扑排序算法等。
- 动态规划算法:可以用于解决一些复杂的最优化问题,如背包问题、最长公共子序列等。
以下为数据结构
一,线性表
其实数据结构还可以这样分类: 线性表(数组,链表,队列,栈),散列表,树,图。
1.1 数组
数组通常由一组连续的内存位置组成,每个位置都有一个唯一的索引,可以用来访问该位置存储的数据。
在大多数编程语言中,数组的长度是固定的,一旦数组被创建,其长度就无法更改。数组可以包含任何类型的数据,包括整数、浮点数、字符和其他数组等。
使用数组的主要优点是可以在一个变量中存储多个值,而不需要为每个值创建一个单独的变量。这使得处理大量数据时变得更加简单和有效。数组还可以用来表示矩阵和其他数学结构。
以下是一个简单的 Java 程序,演示如何声明和初始化一个数组,以及如何使用循环遍历数组并计算其元素的总和:
public class ArrayExample {
public static void main(String[] args) {
// 声明一个包含 5 个整数的数组
int[] myArray = new int[5];
// 初始化数组的元素
myArray[0] = 1;
myArray[1] = 2;
myArray[2] = 3;
myArray[3] = 4;
myArray[4] = 5;
// 计算数组中所有元素的总和
int sum = 0;
for (int i = 0; i < myArray.length; i++) {
sum += myArray[i];
}
// 输出数组中所有元素的总和
System.out.println("数组中所有元素的总和为:" + sum);
}
}
1.2 链表
链表由多个节点(Node)组成,每个节点包含数据和指向下一个节点的指针。相邻节点之间通过指针进行连接,形成一个链式结构。
链表有多种类型:
- 单向链表:它的每个节点只有一个指向下一个节点的指针。
- 双向链表:它的每个节点同时具有指向前一个节点和指向下一个节点的指针。
- 循环链表:它的最后一个节点指向第一个节点,形成一个环形结构。
链表的操作包括遍历、插入、删除等。遍历链表可以通过从头节点开始,沿着指针一直遍历到尾节点。插入和删除节点时,需要改变相邻节点之间的指针,以维护链表的连续性。
链表相比于数组,有以下几个优点:
- 动态扩容:链表可以动态扩容,不需要像数组一样预先分配固定的空间。
- 插入和删除效率高:由于插入和删除只需要改变相邻节点之间的指针,而不需要移动元素,因此效率更高。
- 空间利用率高:链表不需要像数组一样预留一定的空间,因此空间利用率更高。
但是链表也有一些缺点,主要是访问节点时需要遍历整个链表,效率相对较低。另外,链表的存储空间相对于数组来说会更消耗内存,因为每个节点都需要一个额外的指针来指向下一个节点。
以下是一个单向链表的示例代码实现:
public class ListNode {
int val;
ListNode next;
public ListNode(int val) {
this.val = val;
}
}
public class LinkedList {
ListNode head;
public void add(int val) {
ListNode node = new ListNode(val);
if (head == null) {
head = node;
} else {
ListNode cur = head;
while (cur.next != null) {
cur = cur.next;
}
cur.next = node;
}
}
public void remove(int val) {
if (head == null) {
return;
}
if (head.val == val) {
head = head.next;
return;
}
ListNode cur = head;
while (cur.next != null && cur.next.val != val) {
cur = cur.next;
}
if (cur.next != null) {
cur.next = cur.next.next;
}
}
public void print() {
ListNode cur = head;
while (cur != null) {
System.out.print(cur.val + " ");
cur = cur.next;
}
System.out.println();
}
}
以上是一个基本的单向链表,包含添加节点、删除节点和遍历节点等基本操作。
1.3 队列
可以实现先进先出(First In First Out, FIFO)的操作。类似于现实生活中排队的场景,新来的人会排在队列的末尾,先来的人会在队列的前面等待,当轮到他们时,会从队列的头部出队。
队列的基本操作有两个:入队(enqueue)和出队(dequeue)。入队操作将元素插入队列的尾部,出队操作将队列头部的元素删除并返回。除此之外,还有一些其他的操作,例如查看队列的头部元素、查看队列的长度等等。
队列的实现方式有多种,常见的有数组实现和链表实现。使用数组实现的队列通常需要预先指定队列的容量,而链表实现的队列则可以动态扩容,不过链表实现的队列在空间上比数组实现的队列要消耗更多的内存。
以下是使用数组实现的队列的示例代码实现:
public class ArrayQueue {
private int[] data;
private int head;
private int tail;
private int capacity;
public ArrayQueue(int capacity) {
this.capacity = capacity;
data = new int[capacity];
}
public boolean enqueue(int x) {
if (tail == capacity) { // 队列已满
return false;
}
data[tail++] = x;
return true;
}
public int dequeue() {
if (head == tail) { // 队列为空
return -1;
}
return data[head++];
}
public int peek() {
if (head == tail) { // 队列为空
return -1;
}
return data[head];
}
public boolean isEmpty() {
return head == tail;
}
public int size() {
return tail - head;
}
}
以上是一个基本的使用数组实现的队列,其时间复杂度为 O ( 1 ) O(1) O(1),空间复杂度为 O ( n ) O(n) O(n),其中 n n n为队列的容量。
队列广泛应用于操作系统、网络、算法等领域,例如操作系统中的进程调度、网络中的数据传输、算法中的广度优先搜索等。
1.4 栈
栈(Stack)是一种数据结构,它可以实现后进先出(Last In First Out, LIFO)的操作。类似于现实生活中叠放物品的场景,新来的物品会放在栈的顶部,最先放的物品会被压在底部,当需要取出物品时,会从栈顶开始弹出。
栈的基本操作有两个:入栈(push)和出栈(pop)。入栈操作将元素压入栈顶,出栈操作将栈顶元素弹出并返回。除此之外,还有一些其他的操作,例如查看栈顶元素、查看栈的长度等等。
栈的实现方式有多种,常见的有数组实现和链表实现。使用数组实现的栈通常需要预先指定栈的容量,而链表实现的栈则可以动态扩容,不过链表实现的栈在空间上比数组实现的栈要消耗更多的内存。
以下是使用数组实现的栈的示例代码实现:
public class ArrayStack {
private int[] data;
private int top;
private int capacity;
public ArrayStack(int capacity) {
this.capacity = capacity;
data = new int[capacity];
top = -1;
}
public boolean push(int x) {
if (top == capacity - 1) { // 栈已满
return false;
}
data[++top] = x;
return true;
}
public int pop() {
if (top == -1) { // 栈为空
return -1;
}
return data[top--];
}
public int peek() {
if (top == -1) { // 栈为空
return -1;
}
return data[top];
}
public boolean isEmpty() {
return top == -1;
}
public int size() {
return top + 1;
}
}
以上是一个基本的使用数组实现的栈,其时间复杂度为 O ( 1 ) O(1) O(1),空间复杂度为 O ( n ) O(n) O(n),其中 n n n为栈的容量。
栈广泛应用于操作系统、编译器、算法等领域,例如编译器中的表达式求值、算法中的深度优先搜索等。
二,散列表
散列表(Hash Table),也称哈希表,是一种用于快速查找数据的数据结构。散列表基于数组实现,每个数组元素称为“桶”,每个桶可以存储一个或多个键值对。
散列表的基本思想是,将每个键映射到数组的一个位置,这个位置称为“散列值”。散列值的计算通常采用哈希函数,哈希函数可以将任意大小的数据映射到固定大小的散列值上。
在散列表中,当需要查找某个键时,可以先计算出其散列值,然后根据散列值找到对应的桶,最后在桶中搜索目标键值对。由于散列值可以唯一地确定每个键所在的位置,因此散列表的查找效率非常高。
以下是一个简单的 Java 程序,演示如何使用散列表来存储和查找学生的成绩:
import java.util.HashMap;
public class HashTableExample {
public static void main(String[] args) {
// 创建一个散列表,用于存储学生的成绩
HashMap<String, Integer> scores = new HashMap<String, Integer>();
// 添加学生的成绩
scores.put("Alice", 85);
scores.put("Bob", 92);
scores.put("Charlie", 78);
// 查找某个学生的成绩
int aliceScore = scores.get("Alice");
System.out.println("Alice 的成绩是:" + aliceScore);
}
}
三,树
树是一种非线性数据结构,它由一组节点和一组边组成,节点之间通过边相连。树中最顶部的节点称为“根节点”,根节点下面的节点称为“子节点”,没有子节点的节点称为“叶节点”。
3.1 二叉查找树
二叉查找树(Binary Search Tree,BST)是一种特殊的二叉树,它的每个节点都包含一个键值和指向左右子节点的指针,且满足以下性质:
- 左子树中所有节点的键值小于当前节点的键值;
- 右子树中所有节点的键值大于当前节点的键值;
- 左右子树都是二叉查找树。
二叉查找树可以实现快速的插入、删除、查找等操作,时间复杂度为 O(log n),其中 n 是树中节点的数量。但是,在极端情况下(例如插入的节点按照顺序排列),BST 可能会退化为一个链表,导致操作的时间复杂度变为 O(n)。
以下是一个 Java 实现的二叉查找树示例:
class BSTNode {
int key;
BSTNode left;
BSTNode right;
BSTNode(int key) {
this.key = key;
}
}
class BST {
private BSTNode root;
public void insert(int key) {
root = insert(root, key);
}
private BSTNode insert(BSTNode node, int key) {
if (node == null) {
return new BSTNode(key);
}
if (key < node.key) {
node.left = insert(node.left, key);
} else if (key > node.key) {
node.right = insert(node.right, key);
}
return node;
}
public void delete(int key) {
root = delete(root, key);
}
private BSTNode delete(BSTNode node, int key) {
if (node == null) {
return null;
}
if (key < node.key) {
node.left = delete(node.left, key);
} else if (key > node.key) {
node.right = delete(node.right, key);
} else {
if (node.left == null) {
return node.right;
}
if (node.right == null) {
return node.left;
}
BSTNode minNode = getMin(node.right);
node.key = minNode.key;
node.right = delete(node.right, minNode.key);
}
return node;
}
public boolean search(int key) {
return search(root, key);
}
private boolean search(BSTNode node, int key) {
if (node == null) {
return false;
}
if (key == node.key) {
return true;
} else if (key < node.key) {
return search(node.left, key);
} else {
return search(node.right, key);
}
}
private BSTNode getMin(BSTNode node) {
while (node.left != null) {
node = node.left;
}
return node;
}
}
3.2 平衡二叉查找树
平衡二叉查找树(Balanced Binary Search Tree)是一种特殊的二叉查找树,它能够保证树的左右子树高度差不超过 1,从而保证树的高度平衡。平衡二叉查找树能够保证插入、删除、查找等操作的时间复杂度稳定在 O(log n) 级别。
常见的平衡二叉查找树包括 AVL 树、红黑树等。这里以 AVL 树为例,介绍平衡二叉查找树的实现。
AVL 树的定义是一棵空树或者具有以下性质的二叉查找树:
- 对于任意节点,左右子树的高度差不超过 1;
- 对于任意节点,其左子树和右子树都是 AVL 树。
为了保持 AVL 树的平衡,需要在插入或删除节点时对树进行平衡操作,使得树的左右子树高度差不超过 1。一般的平衡操作包括左旋、右旋、左右旋和右左旋等。在左旋操作中,以当前节点为轴心进行左旋操作,即将当前节点的右子节点旋转到当前节点的位置,当前节点成为新的左子节点,原左子节点成为当前节点的右子节点;右旋操作与之相反。
以下是一个 Java 实现的 AVL 树示例:
class AVLNode {
int key;
int height;
AVLNode left;
AVLNode right;
AVLNode(int key) {
this.key = key;
this.height = 1;
}
}
class AVL {
private AVLNode root;
public void insert(int key) {
root = insert(root, key);
}
private AVLNode insert(AVLNode node, int key) {
if (node == null) {
return new AVLNode(key);
}
if (key < node.key) {
node.left = insert(node.left, key);
} else if (key > node.key) {
node.right = insert(node.right, key);
} else {
return node;
}
node.height = 1 + Math.max(getHeight(node.left), getHeight(node.right));
int balance = getBalance(node);
if (balance > 1 && key < node.left.key) {
return rightRotate(node);
}
if (balance < -1 && key > node.right.key) {
return leftRotate(node);
}
if (balance > 1 && key > node.left.key) {
node.left = leftRotate(node.left);
return rightRotate(node);
}
if (balance < -1 && key < node.right.key) {
node.right = rightRotate(node.right);
return leftRotate(node);
}
return node;
}
public void delete(int key) {
root = delete(root, key);
}
private AVLNode delete(AVLNode node, int key) {
if (node == null) {
return null;
}
if (key < node.key) {
node.left = delete(node.left,
3.4 红黑树
红黑树是一种平衡二叉查找树的变体,它的左右子树高差有可能大于 1,所以红黑树不是严格意义上的平衡二叉树(AVL),但 对之进行平衡的代价较低, 其平均统计性能要强于 AVL 。
红黑树是每个结点都带有颜色属性的二叉查找树,颜色或红色或黑色。在二叉查找树强制一般要求以外,对于任何有效的红黑树我们增加了如下的额外要求:
- 根结点是黑色。
- 结点是红色或黑色。
- 所有叶子都是黑色。(叶子是NIL结点)
- 每个红色结点的两个子结点都是黑色。
- 从任一结点到其每个叶子的所有路径都包含相同数目的黑色结点。
我们在对红黑树进行插入和删除等操作时,对树做了修改,那么可能会违背红黑树的性质。为了保持红黑树的性质,我们可以对相关结点做一系列的调整,通过对树进行旋转(例如左旋和右旋操作),即修改树中某些结点的颜色及指针结构,以达到对红黑树进行插入、删除结点等操作时,红黑树依然能保持它特有的性质。
对于更详细的理解,可以看这篇文章:
https://juejin.cn/post/6844903519632228365
3.5 完全二叉树
完全二叉树(Complete Binary Tree)是一种特殊的二叉树,对于除最后一层以外的所有层,节点数都是最大值,即 2 h − 1 2^h-1 2h−1( h h h 为树的高度)。最后一层节点从左到右依次排列,不留空缺。
完全二叉树的性质可以用数组来表示,对于一个完全二叉树,可以按照层序遍历的顺序将节点从左到右依次存储在数组中。假设某个节点在数组中的下标为 i i i,则其左子节点的下标为 2 i + 1 2i+1 2i+1,右子节点的下标为 2 i + 2 2i+2 2i+2,其父节点的下标为 ⌊ i − 1 2 ⌋ \lfloor \frac{i-1}{2} \rfloor ⌊2i−1⌋。
以下是一个 Java 实现的完全二叉树示例:
class CompleteBinaryTree {
private int[] array;
public CompleteBinaryTree(int[] array) {
this.array = array;
}
public void printTree() {
for (int i = 0; i < array.length; i++) {
System.out.print(array[i] + " ");
}
System.out.println();
}
public void printTreeInLevelOrder() {
int h = (int) (Math.log(array.length) / Math.log(2)) + 1;
for (int i = 1; i <= h; i++) {
for (int j = (int) Math.pow(2, i - 1) - 1; j < Math.min(Math.pow(2, i) - 1, array.length); j++) {
System.out.print(array[j] + " ");
}
System.out.println();
}
}
}
public class Main {
public static void main(String[] args) {
int[] array = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15};
CompleteBinaryTree tree = new CompleteBinaryTree(array);
tree.printTree();
tree.printTreeInLevelOrder();
}
}
在上面的示例中,构造函数的参数为一个数组,数组中存储了完全二叉树的节点值。printTree 方法可以按照数组的顺序打印完全二叉树,printTreeInLevelOrder 方法可以按照层序遍历的顺序打印完全二叉树。注意,在计算树的高度时,需要使用以下公式: h = ⌊ log 2 ( n ) ⌋ + 1 h = \lfloor \log_2(n) \rfloor + 1 h=⌊log2(n)⌋+1,其中 n n n 表示节点数。
3.6 堆
堆(Heap)是一种基于完全二叉树的数据结构,具有以下两个性质:
- 堆中某个节点的值总是不大于(或不小于)其父节点的值;
- 堆总是一棵完全二叉树。
根据第一个性质,如果父节点的值总是不大于(或不小于)其子节点的值,那么这样的堆称为最大堆(Max Heap)或最小堆(Min Heap),并且根节点是堆中的最大值或最小值。
堆可以用数组来表示,对于某个节点的下标 i i i,它的左子节点的下标为 2 i + 1 2i+1 2i+1,右子节点的下标为 2 i + 2 2i+2 2i+2,其父节点的下标为 ⌊ i − 1 2 ⌋ \lfloor \frac{i-1}{2} \rfloor ⌊2i−1⌋。在实现堆时,通常采用数组来存储堆中的元素,并按照完全二叉树的形式排列。在堆中,我们可以进行以下操作:
- 插入一个元素:将新元素添加到堆的末尾,然后通过不断交换该元素和其父节点,使其满足堆的性质;
- 删除堆顶元素:将堆顶元素删除,然后将堆中最后一个元素移到堆顶,并通过不断交换该元素和其子节点,使其满足堆的性质;
- 获取堆顶元素:返回堆顶元素;
- 获取堆的大小:返回堆中元素的个数。
以下是一个 Java 实现的最大堆示例:
class MaxHeap {
private int[] heap;
private int size;
public MaxHeap(int capacity) {
heap = new int[capacity];
size = 0;
}
public void insert(int value) {
if (size >= heap.length) {
return;
}
heap[size] = value;
int index = size;
while (index > 0 && heap[(index - 1) / 2] < heap[index]) {
swap((index - 1) / 2, index);
index = (index - 1) / 2;
}
size++;
}
public int delete() {
if (size == 0) {
return -1;
}
int max = heap[0];
heap[0] = heap[size - 1];
size--;
heapify(0);
return max;
}
public int peek() {
if (size == 0) {
return -1;
}
return heap[0];
}
public int size() {
return size;
}
private void heapify(int index) {
int left = index * 2 + 1;
int right = index * 2 + 2;
int largest = index;
if (left < size && heap[left] > heap[largest]) {
largest = left;
}
if (right < size && heap[right] > heap[largest]) {
largest = right;
}
四,图
图是一种由节点(顶点)和边组成的数据结构,用于表示事物之间的关系。在图中,节点表示实体,边表示节点之间的关系。图可以用于模拟许多真实世界中的场景,如社交网络、交通路线、电子电路等。
图可以分为有向图和无向图。在有向图中,边有一个方向,从一个节点指向另一个节点;在无向图中,边没有方向,连接的两个节点之间没有先后之分。
4.1 图的存储
邻接矩阵
邻接矩阵(Adjacency Matrix)是一种表示图的方式,通常用于稠密图,即图中边数接近于最大可能边数的情况。
邻接矩阵可以用一个 n × n n\times n n×n 的矩阵来表示,其中 n n n 是图中顶点的数目。如果顶点 i i i 与顶点 j j j 之间有一条边,则在矩阵的 ( i , j ) (i,j) (i,j) 和 ( j , i ) (j,i) (j,i) 两个位置上分别填入 1 1 1(有向图)或 2 2 2(无向图),表示存在一条边,如果不存在,则填入 0 0 0。
下面是一个 Java 实现的邻接矩阵示例:
class AdjacencyMatrixGraph {
private int[][] matrix;
private int vertexCount;
public AdjacencyMatrixGraph(int vertexCount) {
this.vertexCount = vertexCount;
this.matrix = new int[vertexCount][vertexCount];
}
public void addEdge(int source, int destination) {
matrix[source][destination] = 1; // for undirected graph, also set matrix[destination][source] = 1
}
public void removeEdge(int source, int destination) {
matrix[source][destination] = 0; // for undirected graph, also set matrix[destination][source] = 0
}
public boolean hasEdge(int source, int destination) {
return matrix[source][destination] != 0;
}
public int getVertexCount() {
return vertexCount;
}
public List<Integer> getAdjacentVertices(int vertex) {
List<Integer> adjacentVertices = new ArrayList<>();
for (int i = 0; i < vertexCount; i++) {
if (matrix[vertex][i] != 0) {
adjacentVertices.add(i);
}
}
return adjacentVertices;
}
public void print() {
for (int i = 0; i < vertexCount; i++) {
for (int j = 0; j < vertexCount; j++) {
System.out.print(matrix[i][j] + " ");
}
System.out.println();
}
}
}
上述代码中,matrix 数组表示邻接矩阵,vertexCount 表示图中顶点的数量。addEdge 方法用于添加边,removeEdge 方法用于删除边,hasEdge 方法用于判断两个顶点之间是否有边相连,getAdjacentVertices 方法用于获取与给定顶点相邻的顶点列表,print 方法用于打印邻接矩阵。
邻接表
邻接表(Adjacency List)是一种表示图的方式,通常用于稀疏图,即图中边数远小于最大可能边数的情况。
邻接表由若干个链表组成,每个链表代表一个顶点及其相邻的顶点。链表中的每个结点包含两个字段,一个是指向相邻顶点的指针,另一个是权重(如果有的话)。
下面是一个 Java 实现的邻接表示例:
class AdjacencyListGraph {
private Map<Integer, List<Integer>> adjacencyList;
private int vertexCount;
public AdjacencyListGraph(int vertexCount) {
this.vertexCount = vertexCount;
this.adjacencyList = new HashMap<>();
for (int i = 0; i < vertexCount; i++) {
adjacencyList.put(i, new LinkedList<>());
}
}
public void addEdge(int source, int destination) {
adjacencyList.get(source).add(destination); // for undirected graph, also add adjacencyList.get(destination).add(source);
}
public void removeEdge(int source, int destination) {
adjacencyList.get(source).remove(Integer.valueOf(destination)); // for undirected graph, also remove adjacencyList.get(destination).remove(Integer.valueOf(source));
}
public boolean hasEdge(int source, int destination) {
return adjacencyList.get(source).contains(destination);
}
public int getVertexCount() {
return vertexCount;
}
public List<Integer> getAdjacentVertices(int vertex) {
return adjacencyList.get(vertex);
}
public void print() {
for (int i = 0; i < vertexCount; i++) {
System.out.print(i + ": ");
for (int j : adjacencyList.get(i)) {
System.out.print(j + " ");
}
System.out.println();
}
}
}
上述代码中,adjacencyList 是一个 Map,它的 key 是每个顶点的编号,value 是以该顶点为起点的边的目标顶点列表。vertexCount 表示图中顶点的数量。addEdge 方法用于添加边,removeEdge 方法用于删除边,hasEdge 方法用于判断两个顶点之间是否有边相连,getAdjacentVertices 方法用于获取与给定顶点相邻的顶点列表,print 方法用于打印邻接表。
4.2 最短路径
最短路径问题是指在一个有向或无向加权图中,找到两个顶点之间的最短路径,其中路径的长度是路径上所有边的权重之和。
下面介绍两种经典的最短路径算法:
Dijkstra 算法
Dijkstra 算法是一种用于解决单源最短路径问题的贪心算法。它从起点开始,逐步扩展路径,直到找到终点或者所有能到达的顶点都已经被遍历。具体步骤如下:
- 初始化一个距离数组 dist[],其中 dist[i] 表示起点到顶点 i 的最短距离,将其全部初始化为无穷大,起点的距离为 0。
- 创建一个集合 visited,用于存储已经找到最短路径的顶点。
- 对于当前距离起点最近的顶点 v,将其添加到 visited 集合中,并更新所有与 v 相邻的未访问的顶点 u 的距离。如果通过 v
能够到达 u 的距离比 dist[u] 更短,则更新 dist[u]。 - 重复步骤 3 直到所有顶点都被加入到 visited 集合中或者终点已经被找到。
下面是一个 Java 实现的 Dijkstra 算法:
import java.util.*;
class DijkstraAlgorithm {
public int[] dijkstra(int[][] graph, int source) {
int n = graph.length;
int[] dist = new int[n];
boolean[] visited = new boolean[n];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[source] = 0;
for (int i = 0; i < n - 1; i++) {
int u = getMinimumDistance(dist, visited);
visited[u] = true;
for (int v = 0; v < n; v++) {
if (!visited[v] && graph[u][v] != 0 && dist[u] != Integer.MAX_VALUE && dist[u] + graph[u][v] < dist[v]) {
dist[v] = dist[u] + graph[u][v];
}
}
}
return dist;
}
private int getMinimumDistance(int[] dist, boolean[] visited) {
int minIndex = -1;
int minDistance = Integer.MAX_VALUE;
for (int i = 0; i < dist.length; i++) {
if (!visited[i] && dist[i] <= minDistance) {
minIndex = i;
minDistance = dist[i];
}
}
return minIndex;
}
}
上述代码中,graph 是一个邻接矩阵,表示有向加权图。dijkstra 方法返回从起点到所有顶点的最短距离数组 dist。getMinimumDistance 方法用于找到距离起点最近的顶点。
4.3 二分图
二分图是一种特殊的图,可以将图中的所有节点分成两个互不相交的集合,使得同一集合内的节点之间没有边相连。如果一个图可以被分成两个集合,使得同一集合内的节点之间没有边相连,那么这个图就是二分图。
判断一个图是否是二分图,可以使用二分图染色算法。具体步骤如下:
任选一个节点作为起点,将其染成红色。
对于与红色节点相邻的节点,将其染成蓝色。
对于与蓝色节点相邻的节点,将其染成红色。
重复步骤 2 和 3,直到所有节点都被染色为止。
如果在染色过程中发现某个节点的相邻节点已经被染成了相同的颜色,那么这个图不是二分图。
下面是一个 Java 实现的二分图判断算法:
import java.util.*;
class BipartiteChecker {
public boolean isBipartite(int[][] graph) {
int n = graph.length;
int[] colors = new int[n];
Arrays.fill(colors, -1);
for (int i = 0; i < n; i++) {
if (colors[i] == -1) {
if (!dfs(graph, colors, i, 0)) {
return false;
}
}
}
return true;
}
private boolean dfs(int[][] graph, int[] colors, int node, int color) {
if (colors[node] != -1) {
return colors[node] == color;
}
colors[node] = color;
for (int neighbor : graph[node]) {
if (!dfs(graph, colors, neighbor, 1 - color)) {
return false;
}
}
return true;
}
}
上述代码中,graph 是一个邻接表,表示无向图。isBipartite 方法返回一个布尔值,表示这个图是否是二分图。dfs 方法是递归地对节点进行染色的过程。如果相邻节点已经被染成了相同的颜色,那么这个图不是二分图,返回 false。否则返回 true。
注意:如果图是有向图,那么需要使用类似于拓扑排序的算法来判断是否是二分图。
4.4 最大流
最大流问题是指在一个带权有向图中,找到一条从源节点到汇节点的路径,使得路径上的最小权值最大化。这个问题可以通过使用网络流算法来解决。
网络流算法是一种用于解决最大流问题的算法。其中,最著名的算法是 Ford-Fulkerson 算法,它采用增广路的方法寻找最大流。增广路是指一条从源节点到汇节点的路径,可以通过这条路径将流量从源节点传输到汇节点。
下面是一个 Java 实现的最大流算法,使用了 Ford-Fulkerson 算法:
import java.util.*;
class MaxFlowSolver {
public int maxFlow(int[][] graph, int source, int sink) {
int n = graph.length;
int[][] residual = new int[n][n];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
residual[i][j] = graph[i][j];
}
}
int[] parent = new int[n];
int maxFlow = 0;
while (bfs(residual, source, sink, parent)) {
int pathFlow = Integer.MAX_VALUE;
for (int i = sink; i != source; i = parent[i]) {
int j = parent[i];
pathFlow = Math.min(pathFlow, residual[j][i]);
}
for (int i = sink; i != source; i = parent[i]) {
int j = parent[i];
residual[j][i] -= pathFlow;
residual[i][j] += pathFlow;
}
maxFlow += pathFlow;
}
return maxFlow;
}
private boolean bfs(int[][] residual, int source, int sink, int[] parent) {
int n = residual.length;
boolean[] visited = new boolean[n];
Arrays.fill(visited, false);
Queue<Integer> queue = new LinkedList<>();
queue.offer(source);
visited[source] = true;
parent[source] = -1;
while (!queue.isEmpty()) {
int i = queue.poll();
for (int j = 0; j < n; j++) {
if (!visited[j] && residual[i][j] > 0) {
queue.offer(j);
visited[j] = true;
parent[j] = i;
}
}
}
return visited[sink];
}
}
上述代码中,graph 是一个邻接矩阵,表示带权有向图。maxFlow 方法返回一个整数,表示从源节点到汇节点的最大流量。residual 是一个二维数组,表示残余图。在每次迭代中,使用 BFS 算法寻找增广路,并更新残余图和最大流。
注意:如果图是有向无环图,那么可以使用拓扑排序来解决最大流问题。
以下为算法
五,排序算法
按照时间复杂度划分
5.1 时间复杂度为O(n^2)
冒泡排序
冒泡排序是一种简单的排序算法,它的基本思想是通过比较相邻的元素并交换位置,从而把大的元素逐渐“冒泡”到序列的末尾,达到排序的目的。
具体流程如下:
- 从序列的第一个元素开始,依次比较相邻的两个元素,如果前面的元素比后面的元素大,则交换它们的位置。
- 重复上述步骤,直到将序列的最大元素“冒泡”到序列的末尾。
- 对剩余的元素重复上述步骤,直到整个序列排序完成。
public static void bubbleSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
for (int i = 0; i < arr.length - 1; i++) {
boolean swapped = false;
for (int j = 0; j < arr.length - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = true;
}
}
// 如果没有发生交换,说明已经排好序,可以提前结束循环
if (!swapped) {
break;
}
}
}
冒泡排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2),其中 n n n是待排序序列的长度。由于它的每一次比较都会交换两个元素的位置,因此它是一种比较耗时的排序算法,不适合处理大规模的数据。
插入排序
插入排序是一种简单直观的排序算法,它的基本思想是将待排序序列分为已排序区间和未排序区间,每次将未排序区间的第一个元素插入到已排序区间的适当位置,直到整个序列排好序为止。
具体流程如下:
- 从第一个元素开始,将它作为已排序区间,将后面的元素作为未排序区间。
- 从未排序区间的第一个元素开始,依次将它插入到已排序区间的适当位置,使得插入后的序列仍然是有序的。
- 重复上述步骤,直到未排序区间为空,整个序列排好序为止。
public static void insertionSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
int n = arr.length;
for (int i = 1; i < n; i++) {
int temp = arr[i];
int j;
// 在已排序区间中找到合适的插入位置
for (j = i - 1; j >= 0 && arr[j] > temp; j--) {
arr[j + 1] = arr[j];
}
arr[j + 1] = temp;
}
}
插入排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2),其中 n n n是待排序序列的长度。它的优点是实现简单,常数因子小,对于小规模数据排序效率较高,但是对于大规模数据效率不高,不适合处理大规模数据的排序。
选择排序
选择排序是一种简单直观的排序算法,它的基本思路是:从待排序的序列中选择最小(或最大)的元素,然后将其放到序列的起始位置;再从剩余未排序的元素中继续选择最小(或最大)的元素,然后放到已排序序列的末尾;以此类推,直到所有元素都排好序为止。
选择排序的算法流程如下:
- 从待排序序列中选择最小的元素,将其放到序列的起始位置。
- 在剩余未排序的序列中选择最小的元素,将其放到已排序序列的末尾。
- 重复步骤2,直到所有元素都排好序为止。
public static void selectionSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
int minIndex = i; // 最小元素的下标
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
// 将最小元素交换到序列的起始位置
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
选择排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( 1 ) O(1) O(1),它的性能不如快速排序、归并排序等高级排序算法。但是,由于其实现简单,常常被用于初学者的练习和简单数据排序的场景。
希尔排序
希尔排序是一种改进的插入排序算法,也称为“缩小增量排序”。它的基本思想是将待排序序列分成若干个子序列进行插入排序,然后逐步缩小子序列的长度,直到整个序列排好序为止。
具体流程如下:
- 选择一个增量序列,按照增量序列的步长将序列分成若干个子序列。
- 对每个子序列进行插入排序。
- 缩小增量序列的步长,重复上述步骤,直到增量序列缩小到1,完成整个排序过程。
public static void shellSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
int n = arr.length;
// 初始增量设为数组长度的一半
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 - gap; j >= 0 && arr[j] > temp; j -= gap) {
arr[j + gap] = arr[j];
}
arr[j + gap] = temp;
}
}
}
希尔排序的时间复杂度与增量序列的选择有关,最好的情况下为 O ( n log n ) O(n \log n) O(nlogn),最坏的情况下为 O ( n 2 ) O(n^2) O(n2)。尽管希尔排序的时间复杂度不如快速排序和归并排序等高级排序算法,但是它对于中等大小的数据集和小规模的数据集表现良好,而且实现简单。
5.2 时间复杂度为O(nlogn)
快速排序
快速排序是一种高效的排序算法,它的基本思想是通过分治的策略将一个大的待排序序列分成两个小的子序列,其中一个子序列中的所有元素均小于另一个子序列中的所有元素,然后递归地对子序列进行排序,最终得到一个有序序列。
快速排序的算法流程如下:
- 选择一个枢轴元素(pivot),将待排序序列分成两个子序列,其中一个子序列中的所有元素都小于枢轴元素,另一个子序列中的所有元素都大于等于枢轴元素。
- 对子序列进行递归排序,直到每个子序列只剩下一个元素。
- 将所有子序列合并成一个有序序列。
public static void quickSort(int[] arr, int left, int right) {
if (left < right) {
int pivotIndex = partition(arr, left, right); // 将序列分成两个子序列
quickSort(arr, left, pivotIndex - 1); // 对左子序列进行递归排序
quickSort(arr, pivotIndex + 1, right); // 对右子序列进行递归排序
}
}
private static int partition(int[] arr, int left, int right) {
int pivot = arr[left]; // 选择序列的第一个元素作为枢轴元素
int i = left + 1, j = right;
while (i <= j) {
if (arr[i] < pivot) {
i++;
} else if (arr[j] >= pivot) {
j--;
} else {
swap(arr, i, j);
i++;
j--;
}
}
// 将枢轴元素交换到它最终的位置
swap(arr, left, j);
return j;
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
快速排序的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn),空间复杂度为 O ( l o g n ) O(logn) O(logn)。但是,快速排序的性能依赖于选取的枢轴元素,最坏情况下时间复杂度会退化为 O ( n 2 ) O(n^2) O(n2),因此需要合适的优化策略来解决这个问题。
归并排序
归并排序是一种基于分治思想的排序算法,它的主要思想是将待排序序列分成若干个子序列,每个子序列都是有序的,然后将这些子序列合并成一个有序序列。
归并排序的算法流程如下:
- 将待排序序列递归地分成两个子序列,直到每个子序列只有一个元素。
- 将相邻的子序列合并成一个有序序列,直到所有子序列都合并成一个有序序列。
public static void mergeSort(int[] arr, int left, int right) {
if (left < right) {
int mid = (left + right) / 2; // 将序列分成两个子序列
mergeSort(arr, left, mid); // 对左子序列进行递归排序
mergeSort(arr, mid + 1, right); // 对右子序列进行递归排序
merge(arr, left, mid, right); // 合并左右子序列
}
}
private static void merge(int[] arr, int left, int mid, int right) {
int[] temp = new int[right - left + 1]; // 辅助数组
int i = left, j = mid + 1, k = 0;
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++];
} else {
temp[k++] = arr[j++];
}
}
while (i <= mid) {
temp[k++] = arr[i++];
}
while (j <= right) {
temp[k++] = arr[j++];
}
// 将辅助数组中的元素复制到原数组中
for (int p = 0; p < temp.length; p++) {
arr[left + p] = temp[p];
}
}
归并排序的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn),空间复杂度为 O ( n ) O(n) O(n),相比于快速排序,归并排序的性能更加稳定,但是需要额外的空间来存储辅助数组,因此在空间复杂度有限的场景下可能不适用。
堆排序
堆排序是一种基于堆的选择排序算法,它利用堆的性质来维护待排序序列,并通过不断将堆顶元素与末尾元素交换,然后对剩余部分重新构建堆的方式来实现排序。
堆排序的算法流程如下:
- 将待排序序列构建成一个大根堆(或小根堆),其中堆顶元素为最大(或最小)值。
- 将堆顶元素(最大值或最小值)与末尾元素交换。
- 对除去末尾元素的剩余部分重新构建堆,使其满足堆的性质。
- 重复步骤2-3,直到整个序列有序。
public static void heapSort(int[] arr) {
// 构建初始堆
buildHeap(arr);
// 依次取出堆顶元素,放到末尾,然后调整堆
for (int i = arr.length - 1; i > 0; i--) {
swap(arr, 0, i);
heapify(arr, 0, i);
}
}
private static void buildHeap(int[] arr) {
for (int i = arr.length / 2 - 1; i >= 0; i--) {
heapify(arr, i, arr.length);
}
}
private static void heapify(int[] arr, int i, int len) {
int left = 2 * i + 1, right = 2 * i + 2, largest = i;
if (left < len && arr[left] > arr[largest]) {
largest = left;
}
if (right < len && arr[right] > arr[largest]) {
largest = right;
}
if (largest != i) {
swap(arr, i, largest);
heapify(arr, largest, len);
}
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
堆排序的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn),空间复杂度为 O ( 1 ) O(1) O(1),因此在空间有限的场景下,堆排序可能是一种更好的选择。另外需要注意的是,堆排序是一种不稳定的排序算法。
5.3 时间复杂度为O(n)
基数排序
基数排序是一种非比较排序算法,它将待排序序列按照个位、十位、百位等位数进行排序。这里以排序整数为例,具体流程如下:
- 先取得最大数的位数,比如说最大数为12345,它的位数是5。
- 初始化10个桶(0~9),每个桶都是一个列表(数组、链表等数据结构),用于存放相应位数上的数。
- 从最低位开始,依次将待排序序列的每个数按照当前位数上的数值,放入相应的桶中。
- 将桶中的数按照顺序依次取出来,得到一个新的序列,然后按照下一位数重复步骤3~4。
- 直到所有的数都按照最高位排完序后,整个序列也就有序了。
public static void radixSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
// 获取最大数的位数
int max = arr[0];
for (int i = 1; i < arr.length; i++) {
max = Math.max(max, arr[i]);
}
int digits = 0;
while (max > 0) {
digits++;
max /= 10;
}
// 初始化10个桶
List<Integer>[] buckets = new List[10];
for (int i = 0; i < 10; i++) {
buckets[i] = new ArrayList<>();
}
// 按位数排序
for (int i = 0, exp = 1; i < digits; i++, exp *= 10) {
// 将数放入桶中
for (int j = 0; j < arr.length; j++) {
int digit = (arr[j] / exp) % 10;
buckets[digit].add(arr[j]);
}
// 将桶中的数依次取出来,放回原数组中
int index = 0;
for (int j = 0; j < 10; j++) {
for (int k = 0; k < buckets[j].size(); k++) {
arr[index++] = buckets[j].get(k);
}
buckets[j].clear();
}
}
}
基数排序的时间复杂度为 O ( d ( n + k ) ) O(d(n+k)) O(d(n+k)),其中 d d d是最大数的位数, k k k是桶的个数(这里是10), n n n是待排序序列的长度。由于需要使用桶来存储每个数,因此空间复杂度为 O ( n + k ) O(n+k) O(n+k)。
计数排序
计数排序是一种非比较排序算法,它适用于待排序序列的数据范围比较小的情况。具体流程如下:
- 找出待排序序列中的最大值和最小值,并统计每个数出现的次数。
- 创建一个辅助数组,长度为最大值和最小值之间的差值+1,用于存放每个数在排序后的序列中的位置。
- 依次将每个数的位置计算出来,然后将数放到相应的位置上。
public static void countingSort(int[] arr) {
if (arr == null || arr.length <= 1) {
return;
}
// 找出最大值和最小值
int min = arr[0], max = arr[0];
for (int i = 1; i < arr.length; i++) {
min = Math.min(min, arr[i]);
max = Math.max(max, arr[i]);
}
// 统计每个数出现的次数
int[] count = new int[max - min + 1];
for (int i = 0; i < arr.length; i++) {
count[arr[i] - min]++;
}
// 计算每个数的位置
for (int i = 1; i < count.length; i++) {
count[i] += count[i - 1];
}
// 排序
int[] sorted = new int[arr.length];
for (int i = arr.length - 1; i >= 0; i--) {
sorted[--count[arr[i] - min]] = arr[i];
}
// 将排序后的序列赋值回原数组
for (int i = 0; i < arr.length; i++) {
arr[i] = sorted[i];
}
}
计数排序的时间复杂度为 O ( n + k ) O(n+k) O(n+k),其中 n n n是待排序序列的长度, k k k是数的范围(最大值与最小值之差+1)。由于需要创建一个辅助数组来存放每个数的位置,因此空间复杂度为 O ( k ) O(k) O(k)。
桶排序
桶排序是一种线性排序算法,它的基本思想是将待排序序列分到有限数量的桶子里,然后对每个桶子进行排序,最后依次将每个桶子中的元素输出,即可得到有序序列。
具体流程如下:
- 设置一个定量的数组作为桶,将待排序序列中的元素按照一定的规则分配到桶中。
- 对每个桶中的元素进行排序,可以使用快速排序、归并排序等任意一种排序算法。
- 按照桶的顺序依次将各个桶中的元素输出,即可得到有序序列。
public static void bucketSort(int[] arr, int bucketSize) {
if (arr == null || arr.length <= 1) {
return;
}
int n = arr.length;
// 计算桶的数量
int min = arr[0], max = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] < min) {
min = arr[i];
}
if (arr[i] > max) {
max = arr[i];
}
}
int bucketCount = (max - min) / bucketSize + 1;
// 将数据分到各个桶中
List<Integer>[] buckets = new List[bucketCount];
for (int i = 0; i < bucketCount; i++) {
buckets[i] = new ArrayList<>();
}
for (int i = 0; i < n; i++) {
int index = (arr[i] - min) / bucketSize;
buckets[index].add(arr[i]);
}
// 对每个桶中的数据进行排序
int k = 0;
for (int i = 0; i < bucketCount; i++) {
if (buckets[i].isEmpty()) {
continue;
}
Collections.sort(buckets[i]);
for (int j = 0; j < buckets[i].size(); j++) {
arr[k++] = buckets[i].get(j);
}
}
}
桶排序的时间复杂度为 O ( n + k ) O(n+k) O(n+k),其中 n n n是待排序序列的长度, k k k是桶的数量。桶的数量越多,桶排序的时间复杂度就越接近 O ( n ) O(n) O(n),但是桶的数量过多会造成空间的浪费。桶排序适合用于数据分布比较均匀的排序场景,不适合处理数据分布不均的排序场景。
六,查找算法
6.1 线性表查找
线性表是一种数据结构,它包含一组元素,这些元素按照线性顺序排列,每个元素都有一个相邻的前驱和后继。线性表可以用来实现各种常见的数据结构,例如数组、栈、队列和链表等。
线性表的查找操作是指在线性表中查找指定元素的过程。线性表查找的常见方式有顺序查找和二分查找两种。
顺序查找是一种最简单的查找方式,它顺序遍历线性表中的每个元素,逐个比较是否匹配目标元素,直到找到目标元素或者遍历完整个线性表。如果找到目标元素,返回该元素在线性表中的位置;否则,返回查找失败的标志。
下面是一个 Java 实现的顺序查找算法:
public static int sequentialSearch(int[] arr, int target) {
for (int i = 0; i < arr.length; i++) {
if (arr[i] == target) {
return i;
}
}
return -1;
}
二分查找是一种更高效的查找方式,它要求线性表必须是有序的。二分查找的基本思路是不断地将待查找区间分成两半,直到找到目标元素或者确定目标元素不存在为止。具体实现中,可以维护待查找区间的左右端点,每次将区间中间的元素与目标元素进行比较,并根据比较结果将待查找区间缩小为左半边或右半边,直到找到目标元素或者确定目标元素不存在。
下面是一个 Java 实现的二分查找算法:
public static int binarySearch(int[] arr, int target) {
int left = 0;
int right = arr.length - 1;
while (left <= right) {
int mid = (left + right) / 2;
if (arr[mid] == target) {
return mid;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
6.2 树结构查找
在树结构中,查找操作是指在树中查找指定节点或者值的过程。树的查找操作可以分为两种:深度优先搜索和广度优先搜索。
深度优先搜索(DFS)
是一种通过访问每个节点的深度优先搜索算法。它从根节点开始遍历,访问一个节点后,沿着一条路径尽可能远地访问下去,直到该路径到达某个叶子节点为止。然后回溯到上一个节点,访问其他路径。深度优先搜索一般使用递归或者栈来实现。
下面是一个 Java 实现的深度优先搜索算法:
public static TreeNode dfs(TreeNode root, int val) {
if (root == null) {
return null;
}
if (root.val == val) {
return root;
}
TreeNode left = dfs(root.left, val);
if (left != null) {
return left;
}
TreeNode right = dfs(root.right, val);
if (right != null) {
return right;
}
return null;
}
上述代码中,TreeNode 是树节点的数据结构,包含一个整数 val 和两个子节点 left 和 right。dfs 方法接受一个根节点 root 和一个整数 val,返回一个指向值为 val 的节点的指针,如果不存在这样的节点,返回 null。
广度优先搜索(BFS)
是一种从根节点开始逐层遍历树的算法。它先访问根节点,然后依次访问第一层节点、第二层节点、第三层节点……以此类推,直到找到目标节点或者遍历完整个树为止。广度优先搜索一般使用队列来实现。
下面是一个 Java 实现的广度优先搜索算法:
public static TreeNode bfs(TreeNode root, int val) {
if (root == null) {
return null;
}
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
TreeNode node = queue.poll();
if (node.val == val) {
return node;
}
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
}
return null;
}
上述代码中,Queue 是 Java 中的队列数据结构,LinkedList 是一个常用的队列实现。bfs 方法接受一个根节点 root 和一个整数 val,返回一个指向值为 val 的节点的指针,如果不存在这样的节点,返回 null。
6.3 散列表查找
散列表(hash table)是一种以键值对形式存储数据的数据结构,它的查找操作时间复杂度为 O(1),在很多应用场景下非常高效。
散列表的查找操作是通过哈希函数将关键字映射到散列表的存储位置上,然后在该位置上查找对应的值。如果哈希函数设计得好,每个关键字都能够被映射到唯一的位置上,那么散列表的查找操作就可以在常数时间内完成。
下面是一个 Java 实现的散列表:
public class HashTable<K, V> {
private final int capacity;
private final LinkedList<Entry<K, V>>[] table;
public HashTable(int capacity) {
this.capacity = capacity;
this.table = new LinkedList[capacity];
}
public void put(K key, V value) {
int index = hash(key);
if (table[index] == null) {
table[index] = new LinkedList<>();
}
for (Entry<K, V> entry : table[index]) {
if (entry.key.equals(key)) {
entry.value = value;
return;
}
}
table[index].add(new Entry<>(key, value));
}
public V get(K key) {
int index = hash(key);
if (table[index] == null) {
return null;
}
for (Entry<K, V> entry : table[index]) {
if (entry.key.equals(key)) {
return entry.value;
}
}
return null;
}
private int hash(K key) {
return Math.abs(key.hashCode()) % capacity;
}
private static class Entry<K, V> {
K key;
V value;
Entry(K key, V value) {
this.key = key;
this.value = value;
}
}
}
七,字符串匹配
7.1 KMP
KMP 字符串匹配算法(Knuth-Morris-Pratt Algorithm)是一种高效的字符串匹配算法,可以用于解决在一个文本串 S 内查找一个模式串 P 是否出现的问题。
KMP 算法的核心是利用模式串中已经匹配的部分信息,尽可能减少匹配次数,从而达到快速匹配的目的。具体地,KMP 算法维护一个 next 数组,用来记录模式串中每个位置的最长公共前缀和最长公共后缀的长度。
下面是一个 Java 实现的 KMP 算法:
public class KMP {
public static int kmp(String text, String pattern) {
int n = text.length();
int m = pattern.length();
// 构建 next 数组
int[] next = new int[m];
int j = 0;
for (int i = 1; i < m; i++) {
while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
j = next[j - 1];
}
if (pattern.charAt(i) == pattern.charAt(j)) {
j++;
}
next[i] = j;
}
// 匹配文本串和模式串
j = 0;
for (int i = 0; i < n; i++) {
while (j > 0 && text.charAt(i) != pattern.charAt(j)) {
j = next[j - 1];
}
if (text.charAt(i) == pattern.charAt(j)) {
j++;
}
if (j == m) {
return i - m + 1;
}
}
return -1;
}
public static void main(String[] args) {
String text = "ABABABCABAABABA";
String pattern = "ABA";
int pos = kmp(text, pattern);
if (pos == -1) {
System.out.println("Pattern not found");
} else {
System.out.println("Pattern found at position " + pos);
}
}
}
7.2 Robin-Karp
Rabin-Karp 字符串匹配算法是一种基于哈希的字符串匹配算法,可以用于解决在一个文本串 S 内查找一个模式串 P 是否出现的问题。
Rabin-Karp 算法的核心思想是,通过哈希函数将模式串和文本串的子串映射为一个哈希值,从而在常数时间内判断它们是否相等。在计算哈希值时,Rabin-Karp 算法采用了滚动哈希的技巧,避免了每次重新计算哈希值的开销。具体地,Rabin-Karp 算法维护一个大小为 m 的滑动窗口,它在文本串上从左往右滑动,并计算出每个子串的哈希值,然后将它与模式串的哈希值比较。如果相等,则说明找到了匹配的位置;如果不相等,则继续滑动窗口,直到找到匹配的位置或者窗口到达文本串的末尾。
下面是一个 Java 实现的 Rabin-Karp 算法:
public class RabinKarp {
public static int rabinKarp(String text, String pattern) {
int n = text.length();
int m = pattern.length();
if (n < m) {
return -1;
}
// 计算模式串的哈希值和文本串第一个子串的哈希值
long pHash = 0;
long tHash = 0;
long h = 1;
int d = 26; // 字符集大小为 26
for (int i = 0; i < m; i++) {
pHash = pHash * d + (pattern.charAt(i) - 'a');
tHash = tHash * d + (text.charAt(i) - 'a');
if (i < m - 1) {
h = h * d;
}
}
// 滑动窗口匹配
for (int i = 0; i <= n - m; i++) {
if (pHash == tHash) {
int j;
for (j = 0; j < m; j++) {
if (text.charAt(i + j) != pattern.charAt(j)) {
break;
}
}
if (j == m) {
return i;
}
}
if (i < n - m) {
tHash = (tHash - (text.charAt(i) - 'a') * h) * d + (text.charAt(i + m) - 'a');
}
}
return -1;
}
public static void main(String[] args) {
String text = "ABABABCABAABABA";
String pattern = "ABA";
int pos = rabinKarp(text, pattern);
if (pos == -1) {
System.out.println("Pattern not found");
} else {
System.out.println("Pattern found at position " + pos);
}
}
}
7.3 Boyer-Moore
Boyer-Moore是一种经典的字符串匹配算法,它通过预处理模式串的信息,来实现在匹配阶段尽可能跳过多的文本串字符,从而提高匹配的效率。Boyer-Moore算法主要分为两个阶段:预处理阶段和匹配阶段。
在预处理阶段中,算法会利用两个启发式的策略:坏字符规则和好后缀规则。坏字符规则是指,在出现不匹配字符时,算法会根据该字符在模式串中最后一次出现的位置,将模式串向右移动尽可能远的距离。好后缀规则是指,在出现不匹配字符时,算法会利用模式串中的后缀子串来查找与文本串匹配的部分,从而尽可能跳过多的文本串字符。
在匹配阶段中,算法从文本串的末尾开始匹配。当出现不匹配的字符时,算法会利用预处理阶段中的坏字符规则和好后缀规则,分别计算模式串应该向右移动的距离,然后取两者中的最大值作为移动距离。这样做可以保证算法尽可能地跳过尽可能多的文本串字符。
Boyer-Moore算法的时间复杂度为O(m+n),其中m是模式串的长度,n是文本串的长度。在实际应用中,Boyer-Moore算法表现出了很高的效率和实用性,特别是在处理大规模文本数据时表现出了很强的优势。
八,补充内容
8.1 回溯算法
回溯算法是一种基于深度优先搜索的算法,常用于在搜索问题中找到所有的解,或者找到一种特定的解。在回溯算法中,算法会逐步构建解决方案,并在构建过程中不断地检查当前方案是否符合要求,如果不符合要求,则回溯到上一个步骤,继续探索其他解决方案。
回溯算法的基本思想是通过递归遍历所有的可能解,并在遍历过程中不断剪枝,直到找到符合要求的解,或者所有可能的解都被遍历过。在回溯算法中,通常使用一个栈来保存当前已经遍历过的解决方案,每次探索新的解决方案时,都会将当前方案压入栈中,并在进一步探索时保留该方案。如果当前方案无法满足要求,则会将其从栈中弹出,回溯到上一个步骤,继续探索其他解决方案。
回溯算法通常用于求解组合、排列、子集等问题,例如在N皇后问题中,回溯算法可以用来搜索所有可能的放置位置,直到找到符合要求的解决方案。
回溯算法的时间复杂度通常较高,因为算法需要遍历所有可能的解决方案,但是在实际应用中,回溯算法仍然是一种非常有效的算法,可以用来解决很多实际问题。
8.2 贪心算法
贪心算法是一种基于贪心思想的算法,用于在求解最优化问题中找到局部最优解,从而得到全局最优解。在贪心算法中,算法会根据一定的策略,选择当前看起来最优的解决方案,并将该方案加入到最终解决方案中。贪心算法通常需要满足两个条件:
- 最优子结构性质:问题的最优解可以通过子问题的最优解推导得到。
- 贪心选择性质:局部最优解可以推导出全局最优解。
贪心算法的基本思想是每次选择当前看起来最优的解决方案,并将该方案加入到最终解决方案中。在每次选择最优方案时,算法不考虑之后可能出现的情况,而只考虑当前的局部最优解。这样做可以大大减少算法的时间复杂度,从而提高算法的效率。贪心算法通常适用于问题具有最优子结构性质,并且局部最优解能够推导出全局最优解的情况。例如,在求解霍夫曼编码问题中,贪心算法可以用来构造最优的编码方案。
贪心算法的时间复杂度通常较低,因为算法不需要遍历所有可能的解决方案,而是通过局部最优解推导出全局最优解。但是,贪心算法的局限性也很明显,由于算法只考虑当前的局部最优解,而不考虑之后可能出现的情况,因此可能会导致无法得到全局最优解的情况。在实际应用中,需要根据问题的具体情况,选择合适的算法。
8.3 动态规划算法
动态规划算法是一种常见的算法,用于求解具有最优子结构性质的问题。动态规划算法通常适用于问题可以分解为子问题,并且子问题之间存在重叠的情况,即多个子问题共用相同的子问题。动态规划算法的基本思想是将原问题划分为多个子问题,并先求解子问题的最优解,然后根据子问题的最优解,逐步推导出原问题的最优解。
动态规划算法的具体实现通常包含以下几个步骤:
- 定义状态:将原问题转化为子问题,并定义子问题的状态。
- 状态转移方程:根据子问题之间的重叠性,定义子问题之间的转移方程,用来推导出问题的最优解。
- 初始条件:定义子问题的边界条件,即子问题中的最小子问题的解。
- 求解问题:根据状态转移方程和边界条件,求解问题的最优解。
动态规划算法的时间复杂度通常较高,因为算法需要对所有可能的子问题都进行求解,但是在实际应用中,动态规划算法仍然是一种非常有效的算法,可以用来解决很多实际问题。例如,在求解最长公共子序列问题中,动态规划算法可以用来计算两个字符串之间的最长公共子序列。