算法
有太多的天才程序员、知名学者、行业大佬,都或多或少写过算法相关的书籍。目前算法已经是每个程序员必备的技能之一,是进入大厂的第一道门槛。
本文面向初学者,由浅入深的给出一些算法题解,帮助你理解算法的基本概念和实现方式。
基础
大O表示法是一种表示算法时间复杂度的方法。它表示算法运行时间与输入规模之间的关系。
O(1) # 常数时间复杂度
O(log n) # 对数时间复杂度
O(n) # 线性时间复杂度
O(n log n) # 线性对数时间复杂度
O(n^2) # 平方时间复杂度
对于大O表示法,我们只需要关注最高次项并忽略系数。例如O(2n + 1)可以简化为O(n)。
请你完成下面的练习并给出其时间复杂度。
冒泡排序
描述
给定一个列表,请你对列表的元素进行排序。
示例:
输入: [13, 22, 6, 99, 11, 0]
输出: [0, 6, 11, 13, 22, 99]
输入: [13, 22, 6, 99, 11, 0]
输出: [0, 6, 11, 13, 22, 99]
题解
list1 = [13, 22, 6, 99, 11, 0]
for a in range(len(list1)):
for b in range(a,len(list1)):
if list1[a] < list1[b]: #如果m大于了n
list1[a] ,list1[b] = list1[b],list1[a]#交换位置
print(list1)
二分查找
描述
给定一个有序列表和一个目标值,请你查找目标值在列表中的索引。如果目标值不存在,则返回-1。
arr = [1, 2, 6, 7, 8, 9, 10]
target = 6
输出: 2
arr = [1, 2, 6, 7, 8, 9, 10]
target = 0
输出: -1
题解
def binary_search(arr, target):
left, right = 0, 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
print(binary_search([1, 2, 6, 7, 8, 9, 10], 0))# -1
print(binary_search([1, 2, 6, 7, 8, 9, 10], 6))# 2
去重
给定一个有序的序列,请你在不创建新的空间的情况下,将序列中的重复元素去除。
输入: [1, 2, 2, 3, 3, 3, 4, 4, 4, 4]
输出: [1, 2, 3, 4]
题解
def remove_duplicates(arr):
"""
通过两个指针将重复元素移到一侧,然后切片
:param arr: 待去重的序列
:return: 去重后的序列
"""
slow = 0 # 指向的永远是最后一个不重复元素的位置
fast = 1 # 指向当前遍历的元素
for fast in range(1, len(arr)):
# 不相等则说明新的不重复元素出现
if arr[slow] != arr[fast]:
# 将新的不重复元素赋值给慢指针之后的位置
slow += 1
arr[slow] = arr[fast]
return arr[:slow + 1]
分治
分治法是一种将问题分解为更小的子问题,然后解决子问题,最后合并子问题结果的算法。
快速排序
描述
快速排序(Quicksort)是对冒泡排序的一种改进算法。
该算法的实现基本可分为以下几步:
在数组中选一个基准数(通常为数组第一个)。 将数组中小于基准数的数据移到基准数左边,大于基准数的移到右边 对于基准数左、右两边的数组,不断重复以上两个过程,直到每个子集只有一个元素,即为全部有序。
请你编写它的实现代码。
题解
def quick_sort(arr):
"""
快速排序
:param arr: 待排序的List
:return: 排序后的List
"""
# 递归结束条件:如果列表长度小于等于1,直接返回
if len(arr) <= 1:
return arr
# 创建左右2个空列表
left = []
right = []
# 选择一个选中值(pivot),选择第一个元素
pivot = arr[0]
# 将元素分配到左右列表
for i in range(1, len(arr)):
if arr[i] <= pivot: # 小于等于pivot的元素 放入左列表
left.append(arr[i])
else: # 大于pivot的元素放入右列表
right.append(arr[i])
# 递归排序左右两部分,并合并结果
return quick_sort(left) + [pivot] + quick_sort(right)
# 测试数据
if __name__ == '__main__':
arr = [17, 56, 71, 38, 61, 62, 48, 28, 57, 42, 10, 21, 12, 90] # 长度为14
sorted_arr = quick_sort(arr)
print("快速排序结果:", sorted_arr)
密码组合
描述
Python中的string模块包含了许多字符,请根据以下提示设计一个函数:
参数1:密码可用字符的列表 参数2:密码的长度 输出:一个符合要求的所有的密码组合
题解
def set_password(chars, length):
passwords = []
def get_password(char, length):
if len(char) == length: # 修复:检查当前密码长度,而不是字符集长度
passwords.append(char) # 修复:添加当前构建的密码,而不是字符集
return
for i in chars:
get_password(char + i, length)
get_password("", length)
return passwords
print(set_password(['a', 'b', 'c'], 2))# ['aa', 'ab', 'ac', 'ba', 'bb', 'bc', 'ca', 'cb', 'cc']
print(set_password(['a', 'b', 'c'], 3))# ['aaa', 'aab', 'aac', 'aba', 'abb', 'abc', 'aca', 'acb', 'acc', 'baa', 'bab', 'bac', 'bba', 'bbb', 'bbc', 'bca', 'bcb', 'bcc', 'caa', 'cab', 'cac', 'cba', 'cbb', 'cbc', 'cca', 'ccb', 'ccc']
括号组合
描述
给定一个数字n,请你生成所有可能的括号组合
题解
def generate_parenthesis(n):
def backtrack(s, left, right):
if len(s) == 2 * n:
result.append(s)
return
if left < n:
backtrack(s + '(', left + 1, right)
if right < left:
backtrack(s + ')', left, right + 1)
result = []
backtrack('', 0, 0)
return result
print(generate_parenthesis(3))# ['((()))', '(()())', '(())()', '()(())', '()()()']
质数分解
描述
每个数字可以写成多个质数的乘积,给定一个数字,请你分解为多个质数
题解
def fun(num, list=None):
if list is None:
list = []
for i in range(2, num):
while num % i == 0:
list.append(i)
num = int(num / i)
if num > 1:
fun(num)
return list
x = 9*5
print(fun(x))# [3, 3, 5]
图
最少换乘
描述
假设你要从起点去往终点,路上有多个单向站点,怎么设计一个算法,找到最少换乘的路线?
这是一个经典的图论问题,可以用广度优先搜索(BFS)来解决。每条路线可以看作是一条边,换乘次数就是路径的边数。
图数据结构定义:
edges = {
'A': ['B', 'C'],
'B': ['C'],
'C': ['D'],
'D': [],
}
如下图所示:
思路
- 找到起点能去的所有节点
- 检查可去节点是否包含终点
- 如果不包含则继续前往这些节点的可去节点
- 使用广度优先搜索(BFS)确保找到的是最少换乘路线
- 使用队列记录当前层级的节点,逐层扩展直到找到终点
题解
def min_transfers(edges, start, end):
"""
找到从起点到终点的最少换乘路线
:param edges: 图的邻接表表示,edges[u] = [v1, v2, ...] 表示从u可以到达的站点
:param start: 起点
:param end: 终点
:return: 最少换乘次数和路径,如果无法到达返回-1和None
"""
if start == end:
return 0, [start]
# BFS队列,存储(当 前站点, 换乘次数, 路径)
queue = [(start, 0, [start])]
visited = {start} # 已访问的站点
# 只要队列不为空,就继续遍历
while queue:
# 获取队列中最左侧(最左边是第一个进入队列的元素)元素,并从队列中删除
# 当前站点, 换乘次数, 路径
current, transfers, path = queue.pop(0)
# 遍历所有可达的下一个站点(即当前站点可去的站点)
# 如果当前站点没有下一个站点,则跳过
# 如果当前站点有下一个站点,则将下一个站点加入队列
for next_station in edges.get(current, []):
if next_station == end:
# 找到终点
return transfers + 1, path + [end]
# 如果下一个站点没有被访问过,则将下一个站点加入已访问集合,并加入队列
# 这一步可以避免重复访问同一个站点,提升算法效率
if next_station not in visited:
# 将下一个站点加入已访问集合
visited.add(next_station)
# 将下一个站点加入队列
# 换乘次数加1,路径加上下一个站点
queue.append((next_station, transfers + 1, path + [next_station]))
# 无法到达终点
return -1, None
# 测试数据
if __name__ == '__main__':
# 示例:站点A->B->D, A->C->D, A->D
edges = {
'A': ['B', 'C'],
'B': ['C'],
'C': ['D'],
'D': []
}
transfers, path = min_transfers(edges, 'A', 'D')
print(f"最少换乘次数: {transfers}")
print(path)
最少价格路径
描述
给定一个带权有向图,每条边都有一个价格(权重),请找到从起点到终点的价格总和最小的路径。
这是一个典型的单源最短路径问题,可以使用Dijkstra算法来解决(假设所有边权非负)。
图数据结构定义:
graph = {
'A': [('B', 1), ('C', 4)],
'B': [('C', 2), ('D', 5)],
'C': [('D', 1)],
'D': []
}
如下图所示:
虽然A -> B -> D 或 A -> C -> D 的路径最短,但价格较高。
其中沿着A -> B -> C -> D 的路径,价格总和最小,为1 + 2 + 1 = 4。
思路
- 初始化起点的距离为0,其他所有节点距离为无穷大
- 找到起点能去的所有节点,计算到达这些节点的价格
- 选择价格最小的节点作为下一个访问节点
- 检查该节点的可去节点,更新到达这些节点的最少价格
- 重复步骤3-4,直到访问到终点或所有可达节点都访问完毕
- 使用优先队列(堆)来快速找到价格最小的节点
题解
def dijkstra(graph, start, end):
"""
使用Dijkstra算法找到从起点到终点的最少价格路径
:param graph: 图的邻 接表表示,graph[u] = [(v1, weight1), (v2, weight2), ...]
:param start: 起点
:param end: 终点
:return: 最少价格和路径,如果无法到达返回-1和None
"""
# 初始化节点信息字典:每个节点包含价格、前驱节点等信息
nodes = {}
processed = set() # 已处理的节点集合
# 获取所有节点
# 1. 获取所有节点放入 集合,集合具有去重的特点
all_nodes = set(graph.keys())
# 2. 获取所有邻居节点
for neighbors in graph.values():
for neighbor, _ in neighbors:
all_nodes.add(neighbor) # 将邻居节点加入所有节点集合
# 步骤1:初始化起点的距离为0,其他所有节点距离为无穷大
for node in all_nodes:
nodes[node] = {
'price': 0 if node == start else float('inf'),
'previous': None # 前一个节点
}
"""
all_nodes = {'A', 'B', 'C', 'D'}
nodes = {'A': {'price': 0, 'previous': None},
'D': {'price': inf, 'previous': None},
'C': {'price': inf, 'previous': None},
'B': {'price': inf, 'previous': None}}
"""
def find_lowest_price_node():
"""在所有未处理的节点中,找到价格最小的节点"""
lowest_price = float('inf')
lowest_price_node = None
for node_name, node_info in nodes.items():
# 依次取出每个节点,并检查其价格是否小于最低价格
# 初始选择中,除了起点是0,其他都是无穷大,所以一定会选择起点
if node_name not in processed: # 只从未处理的节点中查找
cost = node_info['price']
if cost < lowest_price:
lowest_price = cost
lowest_price_node = node_name
return lowest_price_node
# 步骤3-5:重复选择价格最小的节点,更新其邻居节点的价格
node = find_lowest_price_node() # 在所有未处理的节点中,找到价格最小的节点
while node is not None: # 只要存在未处理的节点,就继续循环
# 如果到达终点,可以提前结束(可选优化)
if node == end:
break
price = nodes[node]['price'] # 当前节点的价格
# 步骤2和4:找到当前节点能去的所有节点,计算到达这些节点的价格
neighbors = graph.get(node, []) # 当前节点的邻居(从图的邻接表中获取)
for neighbor_name, edge_weight in neighbors: # 遍历当前节点的邻居
# 计算到达邻居的价格 = 当前节点价格 + 边权重
new_price = price + edge_weight
# 如果到达邻居的价格比当前价格更小,则更新邻居的价格
if new_price < nodes[neighbor_name]['price']:
nodes[neighbor_name]['price'] = new_price # 更新邻居的价格
nodes[neighbor_name]['previous'] = node # 更新邻居的前一个节点
processed.add(node) # 将当前节点标记为已处理
node = find_lowest_price_node() # 在所有未处理的节点中,找到价格最小的节点
# 检查是否能到达终点
if nodes[end]['price'] == float('inf'):
return -1, None
# 重建路径
def get_path(node_name):
"""根据前驱节点重建路径"""
path = []
current = node_name
while current is not None:
path.append(current)
current = nodes[current]['previous']
path.reverse()
return path if path else None
path = get_path(end)
return nodes[end]['price'], path
# 测试数据
if __name__ == '__main__':
# 示例图:A->B(1), B->C(2),
# B->D(5),
# A->C(4), C->D(1)
graph = {
'A': [('B', 1), ('C', 4)],
'B': [('C', 2), ('D', 5)],
'C': [('D', 1)],
'D': []
}
price, path = dijkstra(graph, 'A', 'D')
print(f"最少价格: {price}")
print(f"路径: {' -> '.join(path)}") # 最少价格: 4, 路径: A -> B -> C -> D
树
树是一种特殊的图,没有环路。其中更特殊的是二叉树。二叉树是一种每个节点最多有两个子节点的树。
其中左子节点及其所有子节点称为左子树,右子节点及其所有子节点称为右子树。
树兼顾了数组与链表的优点,即可以快速访问任意元素,又可以快速插入和删除元素。
BST树满足普通二叉树的条件,同时:左子节点与左子树总是比父节点要小,右子节点与右子树总是比父节点要大。
假设其根节点为:10 ,左节点为5,右节点为20 则表示为:[10, 5, 20]
如果5有左节点为2,右节点为7。20没有左节点,有右节点为25。则表示为:[10, 5, 20, 2, 7, None,25]
如下图所示:
这棵树从根节点出发,到任意子节点,最多只有两条边。所以树的高度为2。
同样的这组数据,也可绘制以25为根节点的树,所有比25小的都在左侧,但是这样树会很高(去往左侧节点需要走很多步)。
如下图所示:
这棵树高度为5,所有节点都形成了一条链,访问最左侧的节点2需要经过5步,效率很低。
AVL树是一种平衡二叉树,它保证树的左右节点尽量平衡,即左右子树的高度差不超过1。
在上面不平衡的树中,你可以把位于中间的节点10或者节点7,这样可以把树大致对折。持续对折所有节点,直到所有节点都高度差不超过1为止。
在AVL树中,每个节点存储一个平衡因子,如果仅有左子树或仅有右子树,则平衡因子为1或-1。如果左右子树都有,则平衡因子和为0。
如果左子树还有左子树,没有右子树,则总平衡因子为2,其绝对值大于1。此时需要进行左旋转。
如果右子树还有右子树,没有左子树,则总平衡因子为-2,其绝对值大于1。此时需要进行右旋转。
叶子节点由于没有子节点,所以平衡因子为0。
每当数据被插入,其可以通过O(logn)时间复杂度找到 插入位置。
然后对每个父节点更新其平衡因子(父节点数量小于logn)。如果发生不平衡,则最多进行1次旋转操作,即可恢复平衡(常量操作)。
所以插入操作的时间复杂度为:
O(logn) + O(logn) + O(1) = 2O(logn)
大O表示法中,常量可以忽略不计,所以插入操作的时间复杂度为:O(logn)。
另一种是伸展树,其访问性能很高,每次访问后,会将访问节点移动到根节点,从而提高后续访问性能。
其工作原理是:你在伸展树中查找过一次节点后,会将该节点移动到根节点,后续再次访问该节点时,可以一瞬间找到该节点。
所有经常访问的节点,都可以会聚集在根节点附近,从而提高访问性能。代价是无法保持自平衡,偶尔访问很偏远的节点,需要更多的时间。
但在大部分情况下,伸展树的访问性能很高,可以接受。
B树是一种广义的二叉树,其每个节点可以有多个子节点,每个节点可以有多个键值。主要用于数据库索引。
在计算机真实进行数据库检索时,需要移动物理部件到指定位置,这称为寻道。
寻道时间非常长, 好比你去超市买东西,假设你从家走到超市买了一个面包,然后从超市走到家,发现也许应该再买一瓶水。往返很耗时。
B树的理念是:既然都去超市了,不如把这个超市的东西都加载到内存里。免得反复寻道。所以B树的子节点更多。
B+树是B树的一种变种和改进,在数据库系统中应用更加广泛。B+树与B树的主要区别在于:
-
数据存储位置:
- B树:内部节点既存储键值,也存储实际数据
- B+树:只有叶子节点存储实际数据,内部节点只存储键值和指向子节点的指针
-
叶子节点结构:
- B+树的叶子节点通过指针相互连接,形成有序链表
- 这使得范围查询和顺序访问非常高效
-
树的高度:
- 由于B+树内部节点不存储数据,在相同数据量下,B+树通常比B树更矮
- 更少的层级意味着更少的磁盘I/O操作
-
查询性能:
- 对于精确查询,B树和B+树性能相近
- 对于范围查询,B+树性能明显优于B树,因为可以通过叶子节点的链表顺序访问
继续用超市的比喻:B+树就像是在超市门口放了一个目录架,目录架上只写了商品类别和对应的货架编号(内部节点存储键值),而实际的商品(数据)都放在货架上(叶子节点)。当你想找某个商品时,先看目录找到货架位置,然后直接去货架拿商品。如果你要买多个同类商品,可以在同一个货架上依次拿取,非常方便。
由于B+树这些优势,大多数现代数据库系统(如MySQL的InnoDB引擎、PostgreSQL等)都采用B+树作为索引结构,特别适合处理大量的范围查询和排序操作。
接下来,请你根据深度优先和广度优先,分别遍历出下面的B树的所有节点。
我们以之前提到的BST树为例:[10, 5, 20, 2, 7, None, 25]
树的结构如下:
10
/ \
5 20
/ \ / \
2 7 None 25
深度优先(DFS)
深度优先遍历有三种方式:前序遍历、中序遍历、后序遍历。
1. 前序遍历(根-左-右)
访问顺序:先访问根节点,再访问左子树,最后访问右子树。
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def preorder_traversal(root):
"""前序遍历:根-左-右"""
result = []
def dfs(node):
if node is None:
return
result.append(node.val) # 先访问根节点
dfs(node.left) # 再访问左子树
dfs(node.right) # 最后访问右子树
dfs(root)
return result
# 构建树:[10, 5, 20, 2, 7, None, 25]
root = TreeNode(10)
root.left = TreeNode(5)
root.right = TreeNode(20)
root.left.left = TreeNode(2)
root.left.right = TreeNode(7)
root.right.right = TreeNode(25)
print("前序遍历结果:", preorder_traversal(root))
# 输出: [10, 5, 2, 7, 20, 25]
2. 中序遍历(左-根-右)
访问顺序:先访问左子树,再访问根节点,最后访问右子树。对于BST树,中序遍历的结果是有序的。
def inorder_traversal(root):
"""中序遍历:左-根-右"""
result = []
def dfs(node):
if node is None:
return
dfs(node.left) # 先访问左子树
result.append(node.val) # 再访问根节点
dfs(node.right) # 最后访问右子树
dfs(root)
return result
print("中序遍历结果:", inorder_traversal(root))
# 输出: [2, 5, 7, 10, 20, 25] (BST树中序遍历是有序的)
3. 后序遍历(左-右-根)
访问顺序:先访问左子树,再访问右子树,最后访问根节点。
def postorder_traversal(root):
"""后序遍历:左-右-根"""
result = []
def dfs(node):
if node is None:
return
dfs(node.left) # 先访问左子树
dfs(node.right) # 再访问右子树
result.append(node.val) # 最后访问根节点
dfs(root)
return result
print("后序遍历结果:", postorder_traversal(root))
# 输出: [2, 7, 5, 25, 20, 10]