一个程序员一生中可能会邂逅各种各样的算法,但总有那么几种,是作为一个程序员一定会遇见且大概率需要掌握的算法。今天就来聊聊这些十分重要的“必抓!”算法吧~
概述
作为程序员,掌握算法是至关重要的。算法是解决问题和优化程序性能的核心。在这篇博客中,我们将介绍一些程序员必须掌握的常见算法,并为每个算法提供详细的解释和举例。
一、算法基础
在学习任何算法之前,了解算法的时间复杂度和空间复杂度以及数组和链表的基本操作是很重要的。
时间复杂度和空间复杂度
时间复杂度描述了算法运行时间随输入规模增长的增长率。空间复杂度描述了算法所需的额外空间随输入规模增长的增长率。理解这些概念可以帮助我们评估算法的效率,并选择最适合特定问题的算法。
数组和链表的基本操作
数组和链表是常见的数据结构,它们在算法中被广泛使用。掌握它们的基本操作,如插入、删除和搜索,对于优化程序性能至关重要。
递归和迭代
- 递归
递归是一种将问题分解为更小子问题的方法,并通过调用自身来解决这些子问题的过程。在递归中,函数会重复执行自己,直到达到某个终止条件才停止。递归函数通常包含两个部分:基本情况和递归调用。
以下是一个计算阶乘的递归函数的例子:
def factorial(n):
if n == 0:
return 1
else:
return n * factorial(n-1)
当调用factorial(5)时,它会进行如下的递归调用:
- factorial(5)返回5 * factorial(4)
- factorial(4)返回4 * factorial(3)
- factorial(3)返回3 * factorial(2)
- factorial(2)返回2 * factorial(1)
- factorial(1)返回1 * factorial(0)
- factorial(0)返回1
最终结果为120
。
递归函数的实现需要注意终止条件的设置,以避免无限递归,导致栈溢出的错误。
- 迭代
迭代是通过重复执行一段代码块来解决问题的过程。与递归不同,迭代不涉及函数调用自身,而是使用循环结构来重复执行特定的代码段。
以下是一个使用迭代计算阶乘的函数的例子:
def factorial(n):
result = 1
for i in range(1, n+1):
result *= i
return result
通过循环,该函数从1到n逐个累乘,得到最终的结果。
当调用factorial(5)时,它会执行如下的迭代过程:
- 初始化result为1
- 进入循环,i从1到5,分别将result乘以i的值
- 循环结束后,返回最终的result值
最终结果为120
。
相比于递归,迭代通常更直观和易于理解。在某些情况下,递归可能导致更简洁的代码,但也可能引起性能问题或栈溢出的风险。因此,在选择使用递归还是迭代时,需要根据具体情况进行评估。
迭代是通过重复执行一段代码块来解决问题的过程。与递归不同,迭代不涉及函数调用自身,而是使用循环结构来重复执行特定的代码段。
二、排序算法
排序算法被广泛应用于数据处理和优化问题。以下是一些常见的排序算法:
2.1 冒泡排序 (Bubble Sort)
冒泡排序是一种简单但效率较低的排序算法。它通过反复交换相邻的元素将最大或最小的元素逐渐“浮”到数组的一端。下面是冒泡排序的实现代码:
def bubble_sort(arr):
n = len(arr)
for i in range(n-1):
for j in range(0, n-i-1):
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
举个例子,我们将对以下数组进行冒泡排序:[5, 3, 8, 2, 1]
初始状态:[5, 3, 8, 2, 1]
第一轮:
比较5和3,交换位置:[3, 5, 8, 2, 1]
比较5和8,不需要交换位置
比较8和2,交换位置:[3, 5, 2, 8, 1]
比较8和1,交换位置:[3, 5, 2, 1, 8]
第二轮:
比较3和5,不需要交换位置
比较5和2,交换位置:[3, 2, 5, 1, 8]
比较5和1,交换位置:[3, 2, 1, 5, 8]
第三轮:
比较3和2,交换位置:[2, 3, 1, 5, 8]
比较3和1,交换位置:[2, 1, 3, 5, 8]
第四轮:
比较2和1,交换位置:[1, 2, 3, 5, 8]
最终排序结果为:[1, 2, 3, 5, 8]
2.2 插入排序 (Insertion Sort)
插入排序是一种简单且高效的排序算法。它将数组分成已排序和未排序两部分,然后逐个将未排序元素插入到已排序部分的适当位置。下面是插入排序的实现代码:
def insertion_sort(arr):
n = len(arr)
for i in range(1, n):
key = arr[i]
j = i - 1
while j >= 0 and arr[j] > key:
arr[j+1] = arr[j]
j -= 1
arr[j+1] = key
举个例子,我们将对以下数组进行插入排序:[5, 3, 8, 2, 1]
初始状态:[5, 3, 8, 2, 1]
第一轮:
已排序部分:[5]
未排序部分:[3, 8, 2, 1]
将3插入到已排序部分的适当位置:[3, 5, 8, 2, 1]
第二轮:
已排序部分:[3, 5]
未排序部分:[8, 2, 1]
将8插入到已排序部分的适当位置:[3, 5, 8, 2, 1]
第三轮:
已排序部分:[3, 5, 8]
未排序部分:[2, 1]
将2插入到已排序部分的适当位置:[2, 3, 5, 8, 1]
第四轮:
已排序部分:[2, 3, 5, 8]
未排序部分:[1]
将1插入到已排序部分的适当位置:[1, 2, 3, 5, 8]
排序完成,最终结果为:[1, 2, 3, 5, 8]
插入排序的时间复杂度为O(n^2),适用于小型数据集或已基本有序的数据集。它是稳定的排序算法,不需要额外的存储空间。
2.3 快速排序 (Quick Sort)
快速排序是一种常用且高效的排序算法。它通过选择一个基准元素,将数组分成小于基准和大于基准的两个子数组,并对子数组进行递归排序,最后合并得到排序结果。下面是快速排序的实现代码:
def quick_sort(arr):
if len(arr) <= 1:
return arr
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)
举个例子,我们将对以下数组进行快速排序:[5, 3, 8, 2, 1]
初始状态:[5, 3, 8, 2, 1]
选择基准元素为5,将数组分成两个子数组:
小于等于基准的子数组:[3, 2, 1]
大于基准的子数组:[8]
对子数组进行递归快速排序:
对子数组[3, 2, 1]进行快速排序:
选择基准元素为3,将数组分成两个子数组:
- 小于等于基准的子数组:[2, 1]
- 大于基准的子数组:[]
- 对子数组[2, 1]进行快速排序:
选择基准元素为2,将数组分成两个子数组:
- 小于等于基准的子数组:[1]
- 大于基准的子数组:[]
对子数组[1]进行快速排序,得到[1]
将排好序的子数组合并:[1, 2]
对子数组[8]进行快速排序,得到[8]
最终排序结果为:[1, 2, 3, 5, 8]
2.4 归并排序 (Merge Sort)
归并排序是一种高效的排序算法,它采用分治的思想将数组分成较小的子数组,然后逐步合并这些子数组以得到最终的有序数组。下面是归并排序的实现代码:
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = arr[:mid]
right = arr[mid:]
left = merge_sort(left)
right = merge_sort(right)
return merge(left, right)
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
while i < len(left):
result.append(left[i])
i += 1
while j < len(right):
result.append(right[j])
j += 1
return result
举个例子,我们将对以下数组进行归并排序:[5, 3, 8, 2, 1]
初始状态:[5, 3, 8, 2, 1]
第一轮(拆分):
分成两个子数组:[5, 3] 和 [8, 2, 1]
对子数组 [5, 3] 进行归并排序:
- 第一轮(拆分):[5], [3]
- 合并两个子数组:[3, 5]
对子数组 [8, 2, 1] 进行归并排序:
- 第一轮(拆分):[8], [2, 1]
- 对子数组 [2, 1] 进行归并排序:
- 第一轮(拆分):[2], [1]
- 合并两个子数组:[1, 2]
- 合并两个子数组:[1, 2, 8]
第二轮(合并):
合并两个已排序的子数组 [3, 5] 和 [1, 2, 8]:[1, 2, 3, 5, 8]
最终得到有序数组:[1, 2, 3, 5, 8]
归并排序的时间复杂度为 O(n log n),是一种稳定的排序算法。它需要额外的存储空间来进行子数组的合并操作,所以空间复杂度为 O(n)。
三、查找算法
查找算法是在给定数据集中寻找特定元素或确定某个元素是否存在的算法。以下是一些常见的查找算法:
3.1 线性查找 (Linear Search)
线性查找是一种简单但效率较低的查找算法。它从头到尾依次检查每个元素,直到找到目标元素或遍历完整个数据集。下面是线性查找的实现代码:
def linear_search(arr, target):
for i in range(len(arr)):
if arr[i] == target:
return i
return -1
举个例子,我们将在以下数组中查找元素5:[3, 2, 7, 5, 1]
第一次比较,元素3不等于目标元素5
第二次比较,元素2不等于目标元素5
第三次比较,元素7不等于目标元素5
第四次比较,元素5等于目标元素5,查找成功
最终结果为索引值3
。
3.2 二分查找 (Binary Search)
二分查找是一种高效的查找算法,适用于已排序的数据集。它通过将数据集分成两半并与目标元素进行比较,从而缩小查找范围。下面是二分查找的实现代码:
def binary_search(arr, target):
left = 0
right = len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
举个例子,我们使用二分查找算法在以下有序数组中查找数字 6:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
初始状态:
左指针:0
右指针:9
第一轮:
中间指针:4
中间值:5
目标值 6 大于中间值 5,所以目标值可能在右半部分
更新左指针为中间指针 + 1:left = 5
第二轮:
中间指针:7
中间值:8
目标值 6 小于中间值 8,所以目标值可能在左半部分
更新右指针为中间指针 - 1:right = 6
第三轮:
中间指针:5
中间值:6
目标值 6 等于中间值 6,找到目标值,返回索引 5
二分查找返回目标值的索引,如果目标值不存在于数组中,则返回 -1。在本例中,目标值 6 存在于数组中,返回索引 5
。
二分查找的时间复杂度为 O(log n),其中 n 是数据集的大小。它是一种高效的查找算法,适用于已排序的数据集。
3.3 哈希表(Hash Table)
哈希表(Hash Table),也称为散列表,是一种常见的数据结构,用于存储键值对。它通过使用哈希函数将键映射到特定的索引位置,从而实现快速的插入、查找和删除操作。
哈希表的核心思想是利用哈希函数将键转换成唯一的索引值,然后将该索引与对应的值存储在数组中。当需要访问某个键的值时,再次应用哈希函数来计算索引,并在数组中查找该索引位置的值。
以下是哈希表的基本原理和操作:
哈希函数:它将键转换为索引值的函数。理想情况下,每个键都应该映射到唯一的索引位置。然而,在实际情况下,可能会出现不同键映射到相同索引的冲突,这称为哈希碰撞。好的哈希函数应该最大程度地减少碰撞的发生。
当发生哈希碰撞时,需要有一种方法来处理。常见的解决方法包括链地址法(Chaining)和开放地址法(Open Addressing)等。
链地址法:每个哈希桶都维护一个链表或其他数据结构,用于存储具有相同哈希值的键值对。当发生碰撞时,新的键值对可以添加到链表中。
**开放地址法:**在发生碰撞时,通过一定的方法找到另一个空闲的桶来存储冲突的键值对。常见的开放地址法包括线性探测(Linear Probing)、二次探测(Quadratic Probing)和再哈希(Rehashing)等。
基本操作:
插入:根据键应用哈希函数计算索引,并将键值对存储在该索引位置。
查找:根据键应用哈希函数计算索引,并在该索引位置查找对应的值。
删除:根据键应用哈希函数计算索引,并从该索引位置删除键值对。
四、图算法
4.1 广度优先搜索(BFS)
广度优先搜索(BFS)是一种图算法,用于遍历或搜索图的节点。它从起始节点开始,逐层扩展搜索,直到找到目标节点或遍历完整个图。
如以下无向图:
A
/ \
B C
/ \ /
D E F
使用广度优先搜索算法,从节点 A 开始搜索,得到的遍历顺序为:A, B, C, D, E, F。
步骤说明:
- 首先将起始节点
A
加入队列。 - 访问节点 A,并将其标记为已访问。
- 将 A 的邻居节点 B 和 C 加入队列。
- 取出队列中的下一个节点
B
,访问并标记为已访问。 - 将 B 的邻居节点 D 和 E 加入队列。
- 取出队列中的下一个节点
C
,访问并标记为已访问。 - 将 C 的邻居节点 F 加入队列。
- 取出队列中的下一个节点
D
,访问并标记为已访问。 - 队列继续依次取出
E、F
节点,并进行访问和标记。
最终遍历完成,得到遍历顺序为:A, B, C, D, E, F。
###广度优先搜索示例
from collections import deque
def bfs(graph, start):
# 创建一个空队列并将起始节点放入队列中
queue = deque([start])
# 创建一个空集合用于记录已访问的节点
visited = set([start])
# 初始化一个空列表用于存储搜索结果
result = []
while queue:
# 从队列中取出一个节点
current_node = queue.popleft()
# 将当前节点添加到结果列表中
result.append(current_node)
# 遍历当前节点的所有邻居节点
for neighbor in graph[current_node]:
if neighbor not in visited:
# 如果邻居节点未被访问过,则将其添加到队列和已访问集合中
queue.append(neighbor)
visited.add(neighbor)
return result
4.2 深度优先搜索(DFS)
深度优先搜索(DFS)是一种图算法,用于遍历或搜索图的节点。它从起始节点开始,沿着路径向下直到达到最深处,然后回溯并探索其他路径。
如以下无向图:
A
/ \
B C
/ \ /
D E F
使用深度优先搜索算法,从节点 A 开始搜索,得到的遍历顺序为:A, B, D, E, C, F。
步骤说明:
- 首先将起始节点
A
加入栈。 - 访问节点 A,并将其标记为已访问。
- 将 A 的邻居节点 B 和 C 加入栈。
- 取出栈顶的下一个节点
C
,访问并标记为已访问。 - 将 C 的邻居节点 F 加入栈。
- 取出栈顶的下一个节点 F,访问并标记为已访问。
- 回溯到节点 C,取出栈顶的下一个节点 B,访问并标记为已访问。
- 将 B 的邻居节点 D 和 E 加入栈。
- 取出栈顶的下一个节点 E,访问并标记为已访问。
- 回溯到节点 B,取出栈顶的下一个节点 D,访问并标记为已访问。
遍历完成后,得到遍历顺序为:A, B, D, E, C, F
。
4.3 Dijkstra算法
Dijkstra算法是一种用于解决单源最短路径问题的图算法。它能够找到从起始节点到其他所有节点的最短路径。
如以下有向加权图:
3 1
A ------> B -----> C
| 2 /| ^
| / | |
| / 4| |
| / | |
v v v |
D <---- E ------
5 6
使用Dijkstra算法,从节点 A 开始搜索最短路径,可以得到从 A 到其他节点的最短路径及其距离:
A 到 B 的最短路径为 A -> B,距离为 3
A 到 C 的最短路径为 A -> B -> C,距离为 4
A 到 D 的最短路径为 A -> D,距离为 5
A 到 E 的最短路径为 A -> B -> E,距离为 6
步骤说明:
- 初始化起始节点 A 距离为 0,其他节点距离为无穷大。
- 创建一个优先队列,并将起始节点 A 加入队列。
- 循环直到队列为空。
- 从队列中取出距离最小的节点 current_node。
- 遍历 current_node 的所有邻居节点 neighbor。
- 如果通过 current_node 可以获得比当前保存的距离更小的距离,则更新距离并将 neighbor 加入队列。
- 最终得到每个节点的最短距离。
在上述例子中,我们从节点 A 开始,首先将其加入队列并设置距离为 0。然后按照距离递增的顺序依次处理队列中的节点。当处理到节点 B 时,发现通过节点 A 可以获得更短的距离 3,因此更新节点 B 的距离并将其加入队列。接着处理节点 C、D、E,更新它们的最短距离。最终得到每个节点的最短距离。
请注意,Dijkstra算法适用于无负权边的图。如果图中存在负权边,则需要使用其他算法,如Bellman-Ford算法。
五、动态规划
动态规划是一种将复杂问题分解为更小子问题并存储中间结果的算法技术。通过使用递推关系式和记忆化方法,可以高效地求解各种优化问题。
5.1 背包问题
背包问题是一个经典的优化问题,目标是在给定的一组物品中选择一些放入背包,使得物品总价值最大,但总重量不能超过背包的容量。
例如,假设有以下物品:
物品 | 重量 | 价值 |
---|---|---|
物品1 | 2 | 3 |
物品2 | 3 | 4 |
物品3 | 4 | 5 |
物品4 | 5 | 8 |
物品5 | 9 | 10 |
背包容量为 10,我们要选择哪些物品放入背包以获得最大的总价值。
步骤说明:
- 创建一个二维数组 dp,其中 dp[i][j] 表示前 i 个物品放入容量为 j 的背包中所能达到的最大价值。
- 初始化第一行和第一列为 0,表示背包容量为 0 或没有物品可选时的最大价值都为 0。
- 遍历每个物品,计算 dp[i][j] 的值:
- 如果物品 i 的重量大于当前容量 j,则 dp[i][j] = dp[i-1][j],不放入该物品。
- 否则,dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]),可以选择放入物品 i
或不放入物品 i,取两者中的较大值。 - 最终结果为 dp[n][C],其中 n 是物品的个数,C 是背包的容量。
在上述例子中,我们通过动态规划求解背包问题。最终得到的最大总价值为 15
,选择放入物品1、物品2和物品4。
5.2 最长公共子序列
最长公共子序列(LCS)问题是指找出两个序列中最长的公共子序列的长度。子序列指的是从原序列中删除一些元素但不改变其余元素的顺序。
例如,给定两个序列 “ABCDGH” 和 “AEDFHR”,这两个序列的最长公共子序列为 “ADH”,长度为 3
。
步骤说明:
- 创建一个二维数组 dp,其中 dp[i][j] 表示序列 X 的前 i 个元素和序列 Y 的前 j 个元素的最长公共子序列的长度。
- 初始化第一行和第一列为 0,表示当一个序列为空时,最长公共子序列的长度为 0。
- 遍历序列 X 和序列 Y,计算 dp[i][j] 的值:
- 如果 X[i-1] == Y[j-1],则 dp[i][j] = dp[i-1][j-1] +
1,两个元素相等,最长公共子序列长度加一。 - 否则,dp[i][j] = max(dp[i-1][j], dp[i][j-1]),取左边或上边的最长公共子序列长度。
- 最终结果为 dp[m][n],其中 m 和 n 分别是序列 X 和序列 Y 的长度。
在上述例子中,我们通过动态规划求解最长公共子序列问题。最终得到的最长公共子序列长度为 3
。
5.3 斐波那契数列问题
斐波那契数列是一个经典的递归序列,其中每个数字都是前两个数字之和。斐波那契数列通常以 F(n) 表示第 n 个数字。
例如,斐波那契数列的前几个数字为:0, 1, 1, 2, 3, 5, 8, 13, …
步骤说明:
- 定义一个数组 dp,其中 dp[i] 表示第 i 个斐波那契数。
- 初始化 dp[0] 和 dp[1] 分别为 0 和 1。
- 使用循环从第 2 个位置开始计算 dp[i] 的值,直到第 n 个位置:
- dp[i] = dp[i-1] + dp[i-2],即前两个数之和。
- 最终结果为 dp[n],即第 n 个斐波那契数。
在上述例子中,我们通过动态规划求解斐波那契数列问题。假设要计算第 6 个斐波那契数,根据步骤 3,我们可以得到结果为 8
。
动态规划方法在这里提供了更高效的解决方案,与传统的递归方法相比,动态规划避免了重复计算的问题,提高了计算效率。
六、字符串匹配算法
字符串匹配算法是用于在一个主串中查找某个模式串的出现位置的算法。下面介绍三种常见的字符串匹配算法:暴力匹配、KMP算法和Boyer-Moore算法。
6.1 暴力匹配
暴力匹配算法,也称为朴素匹配算法,是最简单直观的字符串匹配算法。它的实现思想是从主串的每个位置开始,逐个字符与模式串进行比较,直到找到匹配或遍历完整个主串。
示例说明:
假设有一个主串为:“ABCDABD”,模式串为:“ABD”,我们使用暴力匹配算法来找到模式串在主串中的位置。
- 首先,在主串中从左往右依次比较字符,找到第一个与模式串第一个字符相等的位置(即主串的索引1)。
- 然后,从该位置开始同时遍历主串和模式串,逐个字符进行比较。如果存在不匹配的字符,则回到步骤1找下一个起始位置。
- 如果遍历完整个模式串中的所有字符都匹配成功,则说明找到了匹配的子串,返回匹配的起始位置。
在这个例子中,暴力匹配算法可以找到模式串"ABD"在主串"ABCDABD"中的位置为索引4
。
###暴力匹配示例
def brute_force_match(text, pattern):
n = len(text)
m = len(pattern)
for i in range(n - m + 1):
j = 0
while j < m and text[i+j] == pattern[j]:
j += 1
if j == m:
return i
return -1
6.2 KMP算法
KMP算法(Knuth-Morris-Pratt)是一种高效的字符串匹配算法,通过预处理模式串构建部分匹配表,并根据部分匹配值的信息进行跳过比较,从而避免不必要的回溯。
示例说明:
假设有一个主串为:“ABCDABD”,模式串为:“ABD”,我们使用KMP算法来找到模式串在主串中的位置。
- 首先,构建模式串的最长公共前后缀(即前缀和后缀相同的最长字符串)数组。对于模式串"ABD",其最长公共前后缀数组为[0, 0, 0]。
- 在匹配过程中,通过利用最长公共前后缀数组,可以避免重复比较已经匹配成功的部分字符。
- 从主串的第一个字符开始,逐个遍历主串和模式串。如果当前字符匹配成功,则继续比较下一个字符;如果失败,则根据最长公共前后缀数组确定下一个比较的位置。
- 如果遍历完整个模式串中的所有字符都匹配成功,则说明找到了匹配的子串,返回匹配的起始位置。
在这个例子中,KMP算法可以找到模式串"ABD"在主串"ABCDABD"中的位置为索引4
。
###KMP算法示例
def kmp_match(text, pattern):
n = len(text)
m = len(pattern)
next = compute_next(pattern)
i, j = 0, 0
while i < n:
if text[i] == pattern[j]:
i += 1
j += 1
if j == m:
return i - m
else:
if j > 0:
j = next[j-1]
else:
i += 1
return -1
def compute_next(pattern):
m = len(pattern)
next = [0] * m
i, j = 1, 0
while i < m:
if pattern[i] == pattern[j]:
j += 1
next[i] = j
i += 1
else:
if j > 0:
j = next[j-1]
else:
next[i] = 0
i += 1
return next
6.3 Boyer-Moore算法
Boyer-Moore算法是一种高效的字符串匹配算法,通过从右到左比较字符,可以跳过更多的比较。它使用两个规则:坏字符规则和好后缀规则。
示例说明:
假设有一个主串为:“ABCDABD”,模式串为:“ABD”,我们使用Boyer-Moore算法来找到模式串在主串中的位置。
- 首先,从模式串的最后一个字符开始匹配。
- 如果当前字符匹配成功,则继续比较上一个字符;如果失败,则根据坏字符规则和好后缀规则确定下一个比较的位置。
- 坏字符规则:当出现不匹配的字符时,将模式串向右滑动到使得该字符和主串对齐的位置。如果不在模式串中出现该字符,则可以将模式串整体向右滑动至该字符的位置+1。
- 好后缀规则:当出现不匹配的字符时,根据已经匹配成功的部分好后缀,在模式串中找到最长的可以与主串后缀匹配的子串,将模式串滑动到使得该子串与主串对齐的位置。
- 如果遍历完整个模式串中的所有字符都匹配成功,则说明找到了匹配的子串,返回匹配的起始位置。
在这个例子中,Boyer-Moore算法可以找到模式串"ABD"在主串"ABCDABD"中的位置为索引4
。
###Boyer-Moore算法示例
def boyer_moore_match(text, pattern):
n = len(text)
m = len(pattern)
bad_chars = compute_bad_chars(pattern)
good_suffixes = compute_good_suffixes(pattern)
i = m - 1
while i < n:
j = m - 1
while j >= 0 and text[i] == pattern[j]:
i -= 1
j -= 1
if j == -1:
return i + 1
shift = max(j - bad_chars[ord(text[i])], good_suffixes[j])
i += shift
return -1
def compute_bad_chars(pattern):
m = len(pattern)
bad_chars = [-1] * 256
for i in range(m):
bad_chars[ord(pattern[i])] = i
return bad_chars
def compute_good_suffixes(pattern):
m = len(pattern)
suffixes = [0] * m
good_suffixes = [0] * m
suff = compute_suffix(pattern)
border = 0
for i in range(m - 1, -1, -1):
if suff[i] == i + 1:
while border < m - 1 - i:
if good_suffixes[border] == 0:
good_suffixes[border] = m - 1 - i
border += 1
for i in range(m - 1):
good_suffixes[m - 1 - suff[i]] = m - 1 - i
return good_suffixes
def compute_suffix(pattern):
m = len(pattern)
suffixes = [0] * m
f = g = m - 1
for i in range(m - 2, -1, -1):
if i > g and suffixes[i + m - 1 - f] < i - g:
suffixes[i] = suffixes[i + m - 1 - f]
else:
if i < g:
g = i
f = min(f, i)
while g >= 0 and pattern[g] == pattern[g + m - 1 - f]:
g -= 1
suffixes[i] = f - g
return suffixes
七、树和树的遍历
树是一种常见的数据结构,它由节点组成,并且每个节点可以有零个或多个子节点。在树中,一个节点被称为其父节点的子节点,而该节点则将其子节点作为其孩子。
7.1 二叉树
二叉树是一种特殊的树结构,每个节点最多有两个子节点:左子节点和右子节点。二叉树通常用于表示具有层级关系的数据结构,比如文件系统、表达式等。
示例说明:
下面是一个简单的二叉树示例:
A
/ \
B C
/ \ \
D E F
这棵二叉树包含6个节点,分别为A、B、C、D、E、F。其中A节点的左子节点是B,右子节点是C,B节点的左子节点是D,右子节点是E,C节点的右子节点是F。
####二叉树示例
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
7.2 前序、中序和后序遍历
树的遍历是指按照一定规则访问树的所有节点。常见的树遍历方式包括前序遍历、中序遍历和后序遍历。
示例说明:
以上面的二叉树为例,我们来看一下各种遍历方式的结果:
- 前序遍历结果:A -> B -> D -> E -> C -> F
- 中序遍历结果:D -> B -> E -> A -> C -> F
- 后序遍历结果:D -> E -> B -> F -> C -> A
前序遍历按照根节点、左子树、右子树的顺序访问节点;
中序遍历按照左子树、根节点、右子树的顺序访问节点;
后序遍历按照左子树、右子树、根节点的顺序访问节点。
这些遍历方式在实际应用中有不同的用途,可以根据具体的需求选择合适的遍历方式。
下面是三种遍历方式的代码实现和说明:
# 前序遍历
def preorder_traversal(root):
if root is None:
return []
result = []
result.append(root.val)
result.extend(preorder_traversal(root.left))
result.extend(preorder_traversal(root.right))
return result
# 中序遍历
def inorder_traversal(root):
if root is None:
return []
result = []
result.extend(inorder_traversal(root.left))
result.append(root.val)
result.extend(inorder_traversal(root.right))
return result
# 后序遍历
def postorder_traversal(root):
if root is None:
return []
result = []
result.extend(postorder_traversal(root.left))
result.extend(postorder_traversal(root.right))
result.append(root.val)
return result
八、图论算法
图是由节点(顶点)和边组成的数据结构,在图中,节点表示实体,边表示节点之间的关系。图论算法用于解决与图相关的问题。下面介绍三种常见的图论算法:
8.1 最小生成树
最小生成树算法用于找到一个无向连通图的一棵包含所有顶点且权重和最小的生成树。其中,Prim算法和Kruskal算法是常见的最小生成树算法。
示例说明:
假设有以下带权无向图:
2 3
A ---- B ---- C
| / \ |
|1 4 5 6|
| / \ |
D ---- E ---- F
7 8
我们使用Prim算法来找到该图的最小生成树。
- 从任意一个顶点开始,选择一个初始顶点(例如选择顶点A)。
- 将选中的顶点标记为已访问,并将与该顶点相邻的边加入集合。
- 从集合中选择权重最小的边,找到连接已访问和未访问顶点的最小权重边(例如选择边AB,权重为2)。
- 将选中的边加入最小生成树,并将连接的顶点B标记为已访问。
- 将新访问的顶点E与已访问的顶点的边的权重加入集合。
- 重复步骤3-5,直到所有顶点都被访问。
此时,生成的最小生成树为:
2 4
A ---- B ---- E
| |
1 8
| |
D --------- F
7 6
在这个例子中,Prim算法选择的边依次是:AB, AE, BF
。
# Prim算法示例
def prim(graph):
n = len(graph)
selected = [False] * n
min_cost = [float('inf')] * n
parent = [-1] * n
min_cost[0] = 0 # 从第一个节点开始
for _ in range(n):
u = -1
for i in range(n):
if not selected[i] and (u == -1 or min_cost[i] < min_cost[u]):
u = i
selected[u] = True
for v in range(n):
if not selected[v] and graph[u][v] != 0 and graph[u][v] < min_cost[v]:
min_cost[v] = graph[u][v]
parent[v] = u
return parent
# Kruskal算法示例
def kruskal(graph):
n = len(graph)
edges = []
for i in range(n):
for j in range(i+1, n):
if graph[i][j] != 0:
edges.append((i, j, graph[i][j]))
edges.sort(key=lambda x: x[2])
parent = list(range(n))
rank = [0] * n
minimum_spanning_tree = []
def find(u):
if parent[u] != u:
parent[u] = find(parent[u])
return parent[u]
def union(u, v):
root_u = find(u)
root_v = find(v)
if rank[root_u] < rank[root_v]:
parent[root_u] = root_v
elif rank[root_u] > rank[root_v]:
parent[root_v] = root_u
else:
parent[root_v] = root_u
rank[root_u] += 1
for edge in edges:
u, v, weight = edge
if find(u) != find(v):
union(u, v)
minimum_spanning_tree.append(edge)
return minimum_spanning_tree
8.2 拓扑排序
拓扑排序是一种对有向无环图(DAG)进行排序的算法,它将图中的节点排成线性序列,使得对于每条有向边 (u, v),节点 u 在序列中出现在节点 v 的前面。
示例说明:
假设有以下有向无环图:
1 5
A ------> B ------> C
| | |
| | |
v v v
D ---> E --------> F
我们使用拓扑排序来对该图进行排序。
- 首先选择没有入度的节点作为起始节点(例如选择顶点A),并将其加入结果列表。
- 然后移除顶点A指向的边,并更新相应顶点的入度。
- 重复这个过程直到所有节点都被加入结果列表。
在这个例子中,拓扑排序的结果为A, D, B, E, C, F
。
####拓扑排序示例
def topological_sort(graph):
n = len(graph)
visited = [False] * n
stack = []
def dfs(node):
visited[node] = True
for neighbor in graph[node]:
if not visited[neighbor]:
dfs(neighbor)
stack.append(node)
for node in range(n):
if not visited[node]:
dfs(node)
return stack[::-1]
8.3 强连通分量
强连通分量是指在有向图中,任意两个节点之间存在路径的子图。Tarjan算法是一种常见的用于寻找有向图中强连通分量的算法。
示例说明:
假设有以下有向图:
1 2
A -----> B -----> C
| ^ |
v | v
D -----> E -----> F
4 3
我们使用Tarjan算法来找到该图中的强连通分量。
Tarjan算法基于深度优先搜索(DFS)遍历图的节点,并在遍历过程中维护每个节点的访问顺序和最小访问时间戳。通过比较节点的访问顺序和最小访问时间戳,可以判断是否存在强连通分量。
在这个例子中,Tarjan算法找到的强连通分量是{A, D}, {B, E}, {C, F}
。
九、算法设计技巧
9.1 分治法
分治法是一种将问题分解成更小的子问题,并独立地解决每个子问题的算法设计技巧。
示例说明:
一个经典的应用分治法的例子是归并排序。归并排序将待排序的序列不断拆分成两个子序列,然后分别对子序列进行排序,最后再将排好序的子序列合并成一个有序序列。
9.2 贪心算法
贪心算法是一种通过每一步选择局部最优解来达到全局最优解的算法设计技巧。
示例说明:
一个常见的应用贪心算法的例子是找零钱问题。假设有一定面额的货币,需要支付给顾客某个金额的零钱,如何使用最少数量的货币完成支付。贪心算法可以选择面额最大的货币,先使用尽可能多的该面额货币,然后再选择次大面额的货币,以此类推,直到达到支付金额。
9.3 动态规划
动态规划是一种通过将问题分解成子问题并存储子问题的解来求解整体问题的算法设计技巧。
示例说明:
背包问题是一个经典的动态规划问题。假设有一个背包容量为W,还有一组物品,每个物品有自己的重量和价值。目标是找到一种方式,在不超过背包容量的情况下,装入尽可能多的物品并使得总价值最大化。动态规划可以通过定义状态、确定状态转移方程以及利用动态规划表的方式来解决这个问题。
9.4 回溯算法
回溯算法是一种通过穷举所有可能的解,并在搜索过程中剪枝来求解问题的算法设计技巧。
示例说明:
八皇后问题是一个经典的回溯算法问题。在一个8x8的棋盘上放置8个皇后,使得它们之间互相不能攻击(即不在同一行、同一列、同一对角线)。回溯算法可以通过逐行放置皇后,并根据当前的放置情况进行剪枝,来找到所有合法的解。文章来源:https://www.toymoban.com/news/detail-578089.html
文章来源地址https://www.toymoban.com/news/detail-578089.html
到了这里,关于解密算法王国:揭秘程序员必备技能(超详细)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!