👏🏻 你好!欢迎访问「AI免费学习网」,0门教程,教程全部原创,计算机教程大全,全免费!

1 归并排序与快速排序的优化

在排序算法的进阶学习中,归并排序和快速排序作为两种经典的排序算法,具有极高的实用价值。它们在实际应用中可以面临多种优化的需求。在本篇教程中,我们将深入探讨这两种排序算法的优化方案,并通过实际案例和代码来阐明这些优化。

归并排序的优化

归并排序简介

归并排序是一种分治算法,其基本思想是将数组分成两半,递归地对左右两部分进行排序,然后将已排序的两部分合并在一起以形成最终的排序结果。其时间复杂度为$O(n \log n)$,但其空间复杂度为$O(n)$,这是其主要的不足之处。

1. 减少空间复杂度

在归并排序中,合并两个已排序数组时会使用额外的数组来存放临时结果。我们可以通过原地合并来优化空间使用。原地合并利用了多种技巧,例如使用双指针的方法来减少空间。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def merge(arr, left, mid, right):
# 原地合并过程
start = left
end = mid + 1

# 合并两个部分
while start <= mid and end <= right:
if arr[start] <= arr[end]:
start += 1
else:
value = arr[end]
index = end
while index != start:
arr[index] = arr[index - 1]
index -= 1
arr[start] = value

# 更新所有指针
start += 1
mid += 1
end += 1

def merge_sort(arr, left, right):
if left < right:
mid = (left + right) // 2
merge_sort(arr, left, mid)
merge_sort(arr, mid + 1, right)
merge(arr, left, mid, right)

# 测试
arr = [38, 27, 43, 3, 9, 82, 10]
merge_sort(arr, 0, len(arr) - 1)
print(arr) # 输出:[3, 9, 10, 27, 38, 43, 82]

2. 适应性排序

归并排序在已有序的情况下性能表现较差。我们可以实现一种适应性归并排序,在分割和合并的过程中,检查部分数组是否已经有序,以减少不必要的合并操作。

快速排序的优化

快速排序简介

快速排序是一种基于分治策略的排序算法,其核心思想是通过选择一个“基准”(Pivot),将数组分成两个部分,使得左边的元素都小于等于基准,右边的元素都大于等于基准。其平均情况时间复杂度为$O(n \log n)$,但最坏情况为$O(n^2)$,这是需要优化的地方。

1. 改进基准选择

快速排序的性能很大程度上依赖于基准的选择。使用三数取中法选择基准,可以有效避免最坏情况。

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def partition(arr, low, high):
mid = low + (high - low) // 2
pivot = sorted([arr[low], arr[mid], arr[high]])[1] # 选择中位数作为基准
pivot_index = arr.index(pivot)

# 移动基准到末尾
arr[pivot_index], arr[high] = arr[high], arr[pivot_index]

i = low - 1
for j in range(low, high):
if arr[j] <= pivot:
i += 1
arr[i], arr[j] = arr[j], arr[i]
arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1

def quick_sort(arr, low, high):
if low < high:
pi = partition(arr, low, high)
quick_sort(arr, low, pi - 1)
quick_sort(arr, pi + 1, high)

# 测试
arr = [10, 7, 8, 9, 1, 5]
quick_sort(arr, 0, len(arr) - 1)
print(arr) # 输出:[1, 5, 7, 8, 9, 10]

2. 小数组的插入排序

对于小数组,使用插入排序可以比快速排序快。因此,当待排序的子数组小于某个阈值时,我们可以切换到插入排序,这样能提高性能。

3. 尾递归优化

快速排序可以通过尾递归的形式进行优化,减少栈深度。每次递归后,只递归较小的部分,减少递归深度,以降低空间复杂度。

总结

在本篇教程中,我们详细探讨了归并排序与快速排序的优化策略,包括减少空间复杂度、改善基准选择、适应性排序和小数组的处理等。这些优化能够有效提高排序算法的性能和应用场景,使其在不同类型的数据处理时更具灵活性和效率。在下一篇教程中,我们将继续讨论桶排序与基数排序的优化,希望你能与我一起学习,加深对排序算法的理解与应用。

分享转发

2 桶排序与基数排序

在上一篇中,我们探讨了归并排序与快速排序的优化,这些算法在大多数情况下表现卓越,但在某些特定条件下,我们可能需要使用其他排序算法来满足特定的需求。本篇将深入探讨桶排序与基数排序这两种进阶排序算法,分析它们的原理、实现过程以及各自的适用场景。

桶排序

原理

桶排序(Bucket Sort)是一种分配排序,特别适用于数据均匀分布的情况。它的基本思想是把数据分到有限数量的“桶”中,再分别对每个桶内的数据进行排序,最后将所有桶中的数据合并。

步骤

  1. 创建桶:根据待排序数据的范围创建 n 个桶。
  2. 分配数据:遍历待排序数据,将每个数据放入相应的桶中。
  3. 桶内排序:对每个非空桶使用其他排序算法(如快速排序或插入排序)进行排序。
  4. 合并桶:将所有桶中的数据依次合并,形成有序的结果。

代码示例

以下是 Python 实现 桶排序 的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def bucket_sort(array):
if len(array) == 0:
return array

# 1. 创建桶
min_value = min(array)
max_value = max(array)
bucket_count = max_value - min_value + 1
buckets = [[] for _ in range(bucket_count)]

# 2. 分配数据
for num in array:
buckets[num - min_value].append(num)

# 3. 桶内排序及合并
sorted_array = []
for bucket in buckets:
if bucket:
sorted_array.extend(sorted(bucket)) # 使用内置的 sorted 函数

return sorted_array

# 示例
data = [3, 6, 2, 8, 4, 5, 7, 1]
print(bucket_sort(data))

适用场景

桶排序在以下情况下表现良好:

  • 数据范围较小且均匀分布。
  • 对于需要进行大量相同值的比较时,桶排序的性能会优於其他算法。

然而,桶排序的缺点在于它对环境要求较高,桶的数量和数据分布都对算法的性能有很大的影响。

基数排序

原理

基数排序(Radix Sort)是一种非比较排序算法,主要通过对数字的每一位进行多次排序来实现。对每个数进行 LSD(Least Significant Digit)MSD(Most Significant Digit) 的排序。

步骤

  1. 从最低位(或最高位)开始进行排序。
  2. 使用 桶排序 对每一位进行排序。
  3. 重复上述过程,直到所有位都排序完成。

代码示例

以下是基于 桶排序基数排序 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
def counting_sort_on_digit(array, exp):
output = [0] * len(array)
count = [0] * 10

# 统计每个数字出现的频次
for num in array:
index = (num // exp) % 10
count[index] += 1

# 更新计数数组
for i in range(1, 10):
count[i] += count[i - 1]

# 构建输出数组
for i in range(len(array) - 1, -1, -1):
index = (array[i] // exp) % 10
output[count[index] - 1] = array[i]
count[index] -= 1

return output

def radix_sort(array):
max_value = max(array)
exp = 1 # 初始化为个位

while max_value // exp > 0:
array = counting_sort_on_digit(array, exp)
exp *= 10

return array

# 示例
data = [170, 45, 75, 90, 802, 24, 2, 66]
print(radix_sort(data))

适用场景

基数排序适合以下情况:

  • 数字范围较小的整数序列。
  • 需要高效处理大量数字数据,而不局限于使用内置比较排序的情况。

小结

在这篇文章中,我们深入探讨了 桶排序基数排序 两种先进的排序算法。两者在特定场景下具有优越的性能,而它们的实现也各具特色。在了解了这些算法的基本原理与实现后,下一篇将与大家讨论更高级排序算法的比较,以及如何根据问题选择最合适的排序策略。

分享转发

3 高级排序算法比较

在上一篇文章中,我们探讨了“桶排序”与“基数排序”这两种进阶排序算法,了解了它们各自的工作原理及适用场景。在本篇中,我们将重点比较几种更高级的排序算法,包括“归并排序”、“堆排序”和“快速排序”。这些排序算法在实际应用中具有重要的意义,特别是在处理大规模数据时。

归并排序

概述

归并排序是一种“分治算法”,其基本思想是将一个数组分成两半,对这两半分别进行排序,然后将两个已排序的部分合并成一个有序数组。

算法步骤

  1. 分解:将数组分成大约相等的两半。
  2. 递归:对这两个子数组递归地进行归并排序。
  3. 合并:将两个已排序的子数组合并成一个最终的排序数组。

复杂度分析

归并排序的时间复杂度为 $O(n \log n)$,空间复杂度为 $O(n)$,因为在合并过程中需要额外的存储空间。排序的稳定性使得归并排序在某些情况下更受欢迎。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def merge_sort(arr):
if len(arr) > 1:
mid = len(arr) // 2
left_half = arr[:mid]
right_half = arr[mid:]

merge_sort(left_half)
merge_sort(right_half)

i = j = k = 0

while i < len(left_half) and j < len(right_half):
if left_half[i] < right_half[j]:
arr[k] = left_half[i]
i += 1
else:
arr[k] = right_half[j]
j += 1
k += 1

while i < len(left_half):
arr[k] = left_half[i]
i += 1
k += 1

while j < len(right_half):
arr[k] = right_half[j]
j += 1
k += 1

堆排序

概述

堆排序利用二叉堆的数据结构来排序数组。它分为两个阶段:首先构建最大堆,然后逐步将堆顶元素(最大值)移动到数组末尾,并重新调整堆。

算法步骤

  1. 构建最大堆:将无序数组调整为一个最大堆。
  2. 交换:将堆顶元素与最后一个元素交换,减少堆的大小,并重新调整堆。

复杂度分析

堆排序的时间复杂度为 $O(n \log n)$,空间复杂度为 $O(1)$,由于使用的是原地排序,因此不需要额外的存储空间。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def heapify(arr, n, i):
largest = i
left = 2 * i + 1
right = 2 * i + 2

if left < n and arr[left] > arr[largest]:
largest = left

if right < n and arr[right] > arr[largest]:
largest = right

if largest != i:
arr[i], arr[largest] = arr[largest], arr[i]
heapify(arr, n, largest)

def heap_sort(arr):
n = len(arr)

for i in range(n // 2 - 1, -1, -1):
heapify(arr, n, i)

for i in range(n - 1, 0, -1):
arr[i], arr[0] = arr[0], arr[i]
heapify(arr, i, 0)

快速排序

概述

快速排序同样是一种“分治算法”。它通过一个“基准”元素将数组划分为两个子数组:左侧是小于基准的元素,右侧是大于基准的元素。

算法步骤

  1. 选择基准:选择数组中的一个元素作为基准。
  2. 分区:重新排列数组,使得所有小于基准的元素在其左侧,所有大于基准的元素在其右侧。
  3. 递归:对基准左侧和右侧的子数组进行快速排序。

复杂度分析

快速排序的平均时间复杂度为 $O(n \log n)$,在最坏情况下(例如,已经排序的数组),时间复杂度退化为 $O(n^2)$。不过,快速排序的空间复杂度为 $O(\log n)$,且其排序效率通常优于其他 $O(n \log n)$ 的算法。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def partition(arr, low, high):
pivot = arr[high]
i = low - 1

for j in range(low, high):
if arr[j] <= pivot:
i += 1
arr[i], arr[j] = arr[j], arr[i]

arr[i + 1], arr[high] = arr[high], arr[i + 1]
return i + 1

def quick_sort(arr, low, high):
if low < high:
pi = partition(arr, low, high)
quick_sort(arr, low, pi - 1)
quick_sort(arr, pi + 1, high)

总结

在本篇文章中,我们深入探讨了几种高级排序算法:归并排序、堆排序和快速排序。每种算法都有其独特的特性与应用场景,选择合适的排序算法对于提升程序的性能至关重要。在下一篇文章中,我们将转向图算法,深入解析 Dijkstra算法A* 算法,了解它们在路径搜索方面的重要性与应用场景。

分享转发

4 Dijkstra算法与A*算法深入解析

在算法的世界中,图算法的应用场景广泛,尤其是在路径寻找和优化问题中。在上一篇教程中,我们探讨了高级排序算法的比较,而在本篇中,我们将深入分析两种图算法:Dijkstra算法A*算法。这两个算法都是为了寻找图中两点之间的最短路径,但它们的实现机制和适用场景有所不同。

Dijkstra算法

Dijkstra算法是一种贪心算法,旨在找到从起始节点到其他所有节点的最短路径。它的基本思想是每次选择当前已知最短路径的节点来扩展,从而逐步发现最短路径。

算法步骤

  1. 初始化:设置起始节点的距离为0,其他节点的距离为无穷大。
  2. 将所有节点标记为未访问。
  3. 在未访问的节点中选择距离起始节点最近的节点u
  4. 对于每一个与u相邻的节点v,计算从起始节点到v的距离。如果通过u到达v的距离更短,则更新v的距离。
  5. 标记节点u为已访问。
  6. 重复步骤3到5,直到所有节点都被访问或找到目标节点。

复杂度

Dijkstra算法的时间复杂度为$O((V + E) \log V)$,其中$V$是图中的节点数,$E$是边数。使用优先队列(如最小堆)可以有效地提高性能。

案例分析

考虑一个简单的图:

1
2
3
4
5
6
7
    1
(A)----(B)
| \ |
4| \2 |1
| \ |
(C)----(D)
3

我们要从节点A找到到节点D的最短路径。

  1. 初始化:d(A) = 0, d(B) = ∞, d(C) = ∞, d(D) = ∞.
  2. 选择A,距离更新:
    • d(B) = min(∞, 0 + 1) = 1
    • d(C) = min(∞, 0 + 4) = 4
  3. 选择B,距离更新:
    • d(D) = min(∞, 1 + 1) = 2
  4. 选择D,没有相邻未访问节点;结束。
  5. 最短路径为A -> B -> D,总距离为2

Python代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import heapq

def dijkstra(graph, start):
distances = {vertex: float('infinity') for vertex in graph}
distances[start] = 0
priority_queue = [(0, start)]

while priority_queue:
current_distance, current_vertex = heapq.heappop(priority_queue)

if current_distance > distances[current_vertex]:
continue

for neighbor, weight in graph[current_vertex].items():
distance = current_distance + weight

if distance < distances[neighbor]:
distances[neighbor] = distance
heapq.heappush(priority_queue, (distance, neighbor))

return distances

# 示例图
graph = {
'A': {'B': 1, 'C': 4},
'B': {'A': 1, 'D': 1},
'C': {'A': 4, 'D': 3},
'D': {'B': 1, 'C': 3}
}

print(dijkstra(graph, 'A')) # 输出从A到各点的最短距离

A*算法

A*算法是一种启发式搜索算法,它结合了Dijkstra算法的优点,并引入了启发式方法来优化路径搜索。通过使用一个启发式函数,它可以更快速地找到最优路径。

启发式函数

启发式函数通常是一个估计函数,能够预测到目标节点的最短距离。常用的启发式函数是曼哈顿距离或欧几里得距离,具体取决于问题的性质。

算法步骤

  1. 初始化:设置起始节点的g(到当前节点的成本)和f(预测成本g + h)。
  2. 维护一个打开列表(待扩展节点)和关闭列表(已扩展节点)。
  3. 在打开列表中选择f值最低的节点并扩展。
  4. 对于当前节点的每个相邻节点,计算其ghf值,并根据规则更新打开列表。
  5. 重复步骤3和4,直至找到目标节点或打开列表为空。

案例分析

假设我们使用上述图形,但引入一个目标节点D:

  1. Ag = 0h(D) = 2(估计到达D的距离),f = 0 + 2 = 2
  2. 扩展节点A,更新相邻节点BC的值:
    • 对于Bg = 1, h(D) = 1, f = 1 + 1 = 2
    • 对于Cg = 4, h(D) = 3, f = 4 + 3 = 7
  3. 选择B,扩展至D,更新路径,直到找到目标。

Python代码实现

def heuristic(a, b):
    # 曼哈顿距离作为启发式函数
    return abs(ord(a) - ord(b))

def a_star(graph, start, goal):
    open_list = []
    closed_set = set()
    g_score = {vertex: float('infinity') for vertex in graph}
    g_score[start] = 0
    f_score = {vertex: float('infinity') for vertex in graph}
    f_score[start] = heuristic(start, goal)
    heapq.heappush(open_list, (f_score[start], start))

    while open_list:
        current = heapq.heappop(open_list)[1]

        if current == goal:
            return g_score[current]  # 返回目标的g值,代表最短路径

        closed_set.add(current)

        for neighbor, weight in graph[current].items():
            if neighbor in closed_set:
                continue
            
            tentative_g_score = g_score[current] + weight

            if tentative_g_score < g_score[neighbor]:
                g_score[neighbor] = tentative_g

分享转发

5 图算法深入之图的最小生成树

在前一篇中,我们探讨了图算法中的Dijkstra算法与A*算法,这是两种广泛使用的路径搜索算法。它们主要解决的是寻找最短路径的问题。而在图论中,除了路径问题外,最小生成树(Minimum Spanning Tree,MST)也是一个非常重要的主题。本篇将深入讲解最小生成树的概念、算法以及实践中的应用。

最小生成树的定义

在一个无向图中,最小生成树 是一个包含所有顶点的子图,且构成一棵树(即无环图),并且该树的边的总权值最小。

特点:

  1. 包含所有顶点:最小生成树必须包含图中所有的顶点。
  2. 边的最小权值:所选的边的权值总和是所有可能生成树中的最小值。
  3. 唯一性:当图的边权不同的时候,最小生成树唯一;当存在相同边权时,可能有多棵最小生成树。

常用算法

Kruskal算法

Kruskal算法是一种基于边的贪心算法。它的基本思想是:按照边的权重从小到大排序,然后不断选择最小的边,只有在选择后不会形成环时才加入到生成树中。

算法步骤:

  1. 将图的所有边按权重从小到大排序。
  2. 初始化一个空的生成树。
  3. 遍历排序后的边,选择当前权重最小的边,检查该边是否形成环:
    • 如果没有形成环,则将该边加入生成树。
    • 如果形成了环,则丢弃该边。
  4. 重复步骤3,直到生成树包含$V-1$条边($V$为顶点的数量)。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))

def find(self, u):
if self.parent[u] != u:
self.parent[u] = self.find(self.parent[u]) # 路径压缩
return self.parent[u]

def union(self, u, v):
pu = self.find(u)
pv = self.find(v)
if pu != pv:
self.parent[pu] = pv

def kruskal(vertices, edges):
uf = UnionFind(len(vertices))
mst = []
edges.sort(key=lambda x: x[2]) # 按权重排序

for u, v, weight in edges:
if uf.find(u) != uf.find(v):
uf.union(u, v)
mst.append((u, v, weight))

return mst

# 示例数据
vertices = [0, 1, 2, 3]
edges = [(0, 1, 1), (1, 2, 2), (2, 3, 1), (0, 3, 3), (1, 3, 2)]
mst = kruskal(vertices, edges)
print("最小生成树的边:", mst)

复杂度

Kruskal算法的时间复杂度为 $O(E \log E + E \log V)$,其中$E$是边的数量,$V$是顶点的数量。

Prim算法

Prim算法也是一种贪心算法,但它是基于顶点的。它的基本思想是:从一个起始点开始,不断选择与已选边相连的、权重最小的边来扩展生成树。

算法步骤:

  1. 从任意顶点开始,标记为已选。
  2. 选择与已选顶点相连的、权重最小的边,并将其连接的顶点加入已选。
  3. 重复步骤2,直到选择的边数为 $V - 1$。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import heapq

def prim(vertices, edges):
graph = {v: [] for v in vertices}
for u, v, weight in edges:
graph[u].append((weight, v))
graph[v].append((weight, u))

mst = []
visited = {vertices[0]} # 从第一个顶点开始
edges_heap = graph[vertices[0]]
heapq.heapify(edges_heap)

while edges_heap:
weight, u = heapq.heappop(edges_heap)
if u not in visited:
visited.add(u)
mst.append((vertices[0], u, weight)) # 记录边
for weight, v in graph[u]:
if v not in visited:
heapq.heappush(edges_heap, (weight, v))

return mst

# 示例数据
vertices = [0, 1, 2, 3]
edges = [(0, 1, 1), (1, 2, 2), (2, 3, 1), (0, 3, 3), (1, 3, 2)]
mst = prim(vertices, edges)
print("最小生成树的边:", mst)

复杂度

Prim算法的时间复杂度为 $O(E \log V)$,适合密集图。

应用场景

最小生成树在许多实际应用中都非常重要,比如:

  1. 网络设计:构建最小成本的网络连接,如计算机网络、水管网络等。
  2. 聚类分析:在数据科学中,最小生成树可以用于分群,连接相似的数据点。
  3. 图像处理:一些图像分割技术使用最小生成树来处理像素之间的关系。

总结

在这一篇中,我们深入探讨了“最小生成树”的概念以及两种主要的求解算法:Kruskal和Prim。在下一篇中,我们将转向网络流算法的基础,讨论如何在网络中进行流量优化和资源分配。最小生成树的学习为我们后续的网络流算法的理解打下了良好的基础。

分享转发

6 网络流算法基础

在图算法系列的上一篇中,我们详细探讨了图的最小生成树,包括了常用的 Kruskal 和 Prim 算法。在本篇中,我们将深入了解一个重要的图算法——网络流算法,特别是在解决实际问题中的应用。

网络流算法的核心概念是如何在一个流网络中从源点流向汇点(也称终点)以最优化地利用资源。我们将通过案例学习基本的网络流问题,以及如何使用 Ford-Fulkerson 算法来解决这一问题。

网络流基本概念

在网络流中,我们要处理一个有向图,它包括以下几个组成部分:

  • 节点:代表图中的顶点,包括源点 $s$ 和汇点 $t$。
  • :每条边都有一个非负的容量,用来表示流动的最大限制。
  • :表示从源点到汇点沿边流动的量,流的量不能超过边的容量。

流的定义

在网络流中,流 $f(u, v)$ 代表从节点 $u$ 到节点 $v$ 的流量,满足以下条件:

  1. 容量限制:对于每条边 $(u, v)$,都有 $f(u, v) \leq c(u, v)$,其中 $c(u, v)$ 是边的容量。
  2. 流量守恒:对于每个中间节点 $u$,流入和流出的流量必须相等,即:
    $$
    \sum_{v \in V} f(v, u) = \sum_{v \in V} f(u, v)
    $$
    对于源点 $s$,流入为 0;对于汇点 $t$,流出为 0。

Ford-Fulkerson 算法

接下来,我们将介绍 Ford-Fulkerson 方法,这是解决网络流问题的经典算法。该算法通过增广路径来找到最大流。

算法步骤

  1. 初始化流:开始时,所有边的流量 $f(u, v) = 0$。
  2. 查找增广路径:在残量网络中使用深度优先搜索(DFS)或广度优先搜索(BFS)寻找从源点 $s$ 到汇点 $t$ 的增广路径。
  3. 调整流:对找到的路径,计算路径上的最小剩余容量,并将该容量加到路径上的所有边的流量上。
  4. 更新残量网络:更新边的容量和反向边的流量,直到无法再找到增广路径为止。

示例

我们来看一个具体的例子来说明 Ford-Fulkerson 算法的使用。

示例图

假设我们有以下网络:

1
2
3
4
5
6
7
8
9
10
   10
(s)----->(A)
| /| \
| / | \
| / | \ 10
5| / | \
| / | \
v v v v
(B)----->(C)---->(t)
15 10

在这个图中,源点是 $s$,汇点是 $t$,边的容量分别为上图所示。

实现算法

我们可以用 Python 来实现该算法,以下是一个简单的代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
from collections import defaultdict, deque

class Graph:
def __init__(self):
self.graph = defaultdict(list) # 存储图的结构
self.capacity = {} # 存储边的容量

def add_edge(self, u, v, cap):
self.graph[u].append(v)
self.graph[v].append(u) # 创建反向边
self.capacity[(u, v)] = cap
self.capacity[(v, u)] = 0 # 初始化反向边的容量为 0

def bfs(self, s, t, parent):
visited = set()
queue = deque([s])
visited.add(s)

while queue:
u = queue.popleft()

for v in self.graph[u]:
if v not in visited and self.capacity[(u, v)] > 0:
visited.add(v)
parent[v] = u
queue.append(v)

return t in visited

def ford_fulkerson(self, s, t):
parent = {}
max_flow = 0

while self.bfs(s, t, parent):
path_flow = float('Inf')
v = t

while v != s:
u = parent[v]
path_flow = min(path_flow, self.capacity[(u, v)])
v = parent[v]

v = t
while v != s:
u = parent[v]
self.capacity[(u, v)] -= path_flow
self.capacity[(v, u)] += path_flow
v = parent[v]

max_flow += path_flow

return max_flow

# 使用示例
g = Graph()
g.add_edge('s', 'A', 10)
g.add_edge('s', 'B', 5)
g.add_edge('A', 'C', 10)
g.add_edge('B', 'C', 15)
g.add_edge('C', 't', 10)

max_flow = g.ford_fulkerson('s', 't')
print(f"最大流为: {max_flow}")

在这个示例中,我们创建了一个带有指定边和容量的流网络,然后通过 Ford-Fulkerson 算法计算出最大流。运行上述代码,输出将显示最大流的值。

总结

网络流算法是算法设计和优化中的重要工具,能够解决许多实际问题,如最大流、最小割等。通过 Ford-Fulkerson 算法的学习,我们可以建立起理解更为复杂的流网络问题的基础。

在下一篇中,我们将探讨动态规划的进阶思想与应用,涉及如何利用动态规划解决更复杂的问题。希望大家保持兴趣,继续深入学习!

分享转发

7 动态规划思想的应用

在前一篇中,我们深入探讨了网络流算法的基础,理解了如何通过网络的表现来解决复杂的问题。今天,我们将转向另一重要领域:动态规划(Dynamic Programming, DP),并探讨动态规划思想的实际应用。

动态规划是一种用于解决最优化问题的算法设计范式,通过将问题分解成更小的子问题,避免重复计算,来提高效率。在本篇中,我们将关注如何应用动态规划的思想来处理复杂问题,而不仅仅是单一的动态规划问题。

动态规划思想的核心

动态规划的核心在于“最优子结构”和“重叠子问题”。它的具体应用可以通过以下几个步骤来概括:

  1. 定义状态:明确我们要解决的问题的状态表示。
  2. 状态转移:建立状态之间的关系,找出如何从子问题得到原问题的解。
  3. 边界条件:确定最小子问题的解,并作为递推的基础。
  4. 优化:通过记忆化搜索(Memoization)或表格法(Tabulation)来缓存中间结果。

应用案例

下面将以几个经典应用来展示动态规划思想的具体应用。

案例一:背包问题

问题描述:给定一组物品,每个物品有自己的重量和价值,目标是在不超过背包容量的情况下,选择物品使得总价值最大。

状态定义
dp[i][j] 表示前 i 个物品放入容量为 j 的背包中获得的最大价值。

状态转移
对于每个物品 i

  • 如果不放入背包:dp[i][j] = dp[i-1][j]
  • 如果放入背包:dp[i][j] = dp[i-1][j-w[i]] + v[i]

则有

$$
dp[i][j] = \max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]) \quad \text{若} , j \geq w[i]
$$

边界条件

  • i=0j=0 时,dp[0][j] = 0dp[i][0] = 0

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def knapsack(weights, values, capacity):
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]

for i in range(1, n + 1):
for j in range(capacity + 1):
if j < weights[i-1]:
dp[i][j] = dp[i-1][j] # 不放入物品
else:
dp[i][j] = max(dp[i-1][j], dp[i-1][j - weights[i-1]] + values[i-1]) # 放入物品

return dp[n][capacity]

# 示例
weights = [1, 2, 3]
values = [10, 15, 40]
capacity = 6
print(knapsack(weights, values, capacity)) # 输出 55

案例二:编辑距离

问题描述:给定两个字符串 word1word2,计算将 word1 转换为 word2 所需的最少操作次数(插入、删除、替换)。

状态定义
dp[i][j] 表示将 word1 的前 i 个字符转换为 word2 的前 j 个字符所需的最少操作数。

状态转移

  1. 插入操作:dp[i][j-1] + 1
  2. 删除操作:dp[i-1][j] + 1
  3. 替换操作:dp[i-1][j-1] + 1(若字符不同)或 dp[i-1][j-1](若字符相同)

状态转移式为:

$$
dp[i][j] = \begin{cases}
dp[i-1][j] + 1 & \text{(删除)} \
dp[i][j-1] + 1 & \text{(插入)} \
dp[i-1][j-1] + 1 & \text{(替换)} & \text{若 word1[i-1] \neq word2[j-1]} \
dp[i-1][j-1] & \text{若 word1[i-1] = word2[j-1]}
\end{cases}
$$

边界条件

  • dp[0][j] = j(将空字符串转换为 word2 所需的操作数)
  • dp[i][0] = i(将 word1 转换为空字符串所需的操作数)

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def min_distance(word1, word2):
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]

for i in range(m + 1):
for j in range(n + 1):
if i == 0:
dp[i][j] = j # 如果word1为空,需要j次插入
elif j == 0:
dp[i][j] = i # 如果word2为空,需要i次删除
else:
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1] # 字符相同,不需要操作
else:
dp[i][j] = min(dp[i-1][j] + 1, dp[i][j-1] + 1, dp[i-1][j-1] + 1) # 取三者最小

return dp[m][n]

# 示例
word1 = "horse"
word2 = "ros"
print(min_distance(word1, word2)) # 输出 3

总结

在本篇中,我们探讨了动态规划的核心思想及其应用,具体通过背包问题和编辑距离问题进行了详细分析。动态规划不仅仅是一个计算问题的工具,它更是一种思考问题和结构问题的方式。通过不断地分解和重构问题,我们得以更高效地找到解决方案。

在下一篇中,我们将进一步深入动态规划的经典问题解法,探索更多动态规划领域的挑战与解决思路。希望您能继续关注这一系列教程,提升您的算法能力。

分享转发

8 动态规划进阶之经典动态规划问题解法

在上一篇中,我们探讨了动态规划的基本思想及其应用,今天我们将深入研究一些经典的动态规划问题解法。这些问题不仅可以帮助我们巩固对动态规划的理解,同时也为解决复杂问题提供了思路。

经典动态规划问题

1. 斐波那契数列

斐波那契数列是动态规划中最基础的例子,求解第 $n$ 个斐波那契数可以通过简单的递归实现,但其复杂度为指数级,这在较大的 $n$ 下效率极低。因此,我们可以使用动态规划优化这一过程。

解决方案

使用一个数组 dp 来存储计算结果,从而避免重复计算:

1
2
3
4
5
6
7
8
def fibonacci(n):
if n <= 1:
return n
dp = [0] * (n + 1)
dp[0], dp[1] = 0, 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]

在这里,时间复杂度为 $O(n)$,空间复杂度也是 $O(n)$。我们也可以进一步优化,将空间复杂度降低到 $O(1)$。只需保存前两个数即可:

1
2
3
4
5
6
7
def fibonacci_optimized(n):
if n <= 1:
return n
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b

2. 最长公共子序列(LCS)

最常见的动态规划经典问题之一是最长公共子序列。给定两个字符串,LCS 问题的目标是找到它们之间的最长子序列。

定义状态

  • dp[i][j] 表示字符串 $A$ 的前 $i$ 个字符与字符串 $B$ 的前 $j$ 个字符的最长公共子序列的长度。

转移方程

  • 如果 $A[i - 1] = B[j - 1]$,则 $dp[i][j] = dp[i - 1][j - 1] + 1$。
  • 否则,$dp[i][j] = \max(dp[i - 1][j], dp[i][j - 1])$。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
def lcs(A, B):
m, n = len(A), len(B)
dp = [[0] * (n + 1) for _ in range(m + 1)]

for i in range(1, m + 1):
for j in range(1, n + 1):
if A[i - 1] == B[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

return dp[m][n]

3. 0-1 背包问题

0-1 背包问题是经典的优化问题,目标是从一组物品中选择部分放入背包,使得背包中的物品总价值最大。每个物品只能选择一次。

定义状态

  • dp[i][j] 表示前 $i$ 个物品放入容量为 $j$ 的背包所能获得的最大价值。

转移方程

  • $$ dp[i][j] = dp[i - 1][j] \quad (j < w[i]) $$
  • $$ dp[i][j] = \max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]) \quad (j \geq w[i]) $$

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
def knapsack(weights, values, capacity):
n = len(values)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]

for i in range(1, n + 1):
for j in range(capacity + 1):
if j < weights[i - 1]:
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1])

return dp[n][capacity]

4. 硬币变化问题

给定不同面额的硬币和一个总金额,求用这些硬币组成该金额的组合数。

定义状态

  • dp[j] 表示金额为 $j$ 的组合数。

转移方程

  • $$ dp[j] += dp[j - coin] $$

代码实现

1
2
3
4
5
6
7
8
9
def coin_change(coins, amount):
dp = [0] * (amount + 1)
dp[0] = 1 # 初始化,金额为 0 的组合数为 1

for coin in coins:
for j in range(coin, amount + 1):
dp[j] += dp[j - coin]

return dp[amount]

小结

通过上述经典动态规划问题的分析与解法,我们可以看到动态规划不仅是一种强大的技术,同时也是一种思维方式。在处理一类问题时,明确状态、状态转移方程以及初始条件是关键。在下一篇中,我们将进一步探讨状态压缩动态规划,这是一种利用状态压缩技术来降低空间复杂度的有效方法。希望大家继续关注!

分享转发

9 状态压缩动态规划

在上一篇中,我们讨论了动态规划的经典问题及其解法,掌握了如何使用动态规划解决一些常见的最优化问题。本篇将深入探讨一个比较高级的动态规划技巧——状态压缩动态规划。状态压缩动态规划常用于解决状态空间非常大的问题,通过对状态进行有效压缩,使得计算变得更加高效和可行。

什么是状态压缩动态规划?

状态压缩动态规划的核心思想是利用位运算来压缩状态空间。通常,在动态规划中,状态是由多个变量构成的,而状态压缩技术通过位运算将这些状态压缩成一个整数(通常是一个二进制数)来代表多个状态,从而显著减少所需的内存和计算时间。

应用场景

状态压缩动态规划特别适合以下情况:

  1. 子集问题:当你需要处理一些状态时,这些状态可以看作是某个集合的子集。
  2. 小规模数据:状态编号不是很大,适合用位运算来表示。
  3. 多维状态:当状态的维度较高,但每维状态的取值范围有限。

案例解析:旅行商问题的状态压缩动态规划

问题描述

旅行商问题(TSP)要求旅行商在给定的城市之间走一圈,尽量使得总路线长度最小。它的状态可以用dp[mask][i]来表示:到达状态mask时,最后一个访问的城市是i。这里,mask是一个二进制数,表示访问了哪些城市。

状态定义

  • mask:一个整型变量的二进制表示,长度为n(城市数量),每一位对应一个城市,位为1表示已经访问过,位为0表示未访问。
  • i:当前处于的城市。

状态转移方程

状态转移方程可以表示为:

$$
dp[mask][i] = \min(dp[mask][i], dp[mask \setminus (1 << i)][j] + dist[j][i])
$$

其中,jmask中除了i之外的城市,dist[j][i]代表从城市j到城市i的距离。

初始化

对于从城市0出发的初始状态:

$$
dp[1][0] = 0
$$

最终结果

最终,我们需要返回从城市0出发,访问所有城市后返回城市0的最小路径长度:

$$
\min(dp[all_visited][i] + dist[i][0]), \forall i
$$

代码实现

下面是状态压缩动态规划解决旅行商问题的Python代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import sys

def tsp(n, dist):
# 数量为2^n的二维数组
INF = sys.maxsize
dp = [[INF] * n for _ in range(1 << n)]
dp[1][0] = 0 # 从城市0出发

for mask in range(1 << n):
for i in range(n):
# 如果状态mask中包含城市i
if mask & (1 << i):
# 尝试从其他城市j转移到i
for j in range(n):
if mask & (1 << j) and j != i:
dp[mask][i] = min(dp[mask][i], dp[mask ^ (1 << i)][j] + dist[j][i])

all_visited = (1 << n) - 1 # 所有城市都已访问
ans = min(dp[all_visited][i] + dist[i][0] for i in range(1, n))

return ans

# 示例输入
cities = 4
distances = [
[0, 10, 15, 20],
[10, 0, 35, 25],
[15, 35, 0, 30],
[20, 25, 30, 0]
]

result = tsp(cities, distances)
print("最小旅行成本:", result)

总结

通过状态压缩动态规划,我们成功将旅行商问题中的状态空间进行了有效压缩,大幅提升了求解效率。这种方法在多个组合优化问题中均有很好的应用,尤其是在处理子集、组合问题时。

在下一篇中,我们将继续探讨贪心算法及其基础应用,学习如何在适当的场景中使用贪心算法提升算法效率。希望大家在本篇中的学习能够为今后的问题解决提供新的视角和方法!

分享转发

10 贪心算法的基础

在算法的学习中,贪心算法作为一种重要的策略,常常被用于解决一类特定的问题。与动态规划类似,但贪心算法不一定通过存储状态的方式来达成最优解,而是通过“局部最优”来逐步达到全局最优。在本篇文章中,我们将深入探讨贪心算法的基础,包括其定义、特点、应用实例,以及如何在具体问题中进行贪心选择。

贪心算法的定义

贪心算法是一种构建解决方案的策略,它在每一步选择中都采取当前看来最优的选项,而不考虑整体的最优可能性。这意味着,贪心算法着眼于“局部最优解”的选择。通过每一步都做出局部最优选择,最终希望能够得到全局最优解。

贪心算法的步骤

一般来说,贪心算法包括以下几个步骤:

  1. 选择结构:定义可行的选择集合。
  2. 可行性检查:在每一步中,确保选择是可行的,即不违反问题的约束条件。
  3. 优化目标:根据优化目标,评估当前选择的效果。
  4. 终止条件:判断是否满足终止条件,以决定当前的解是否就是最终解。

贪心算法的特点

贪心算法的主要特点包括:

  • 简单性:贪心算法通常实现简单,易于理解。
  • 高效性:对于某些问题,贪心算法能够在较短的时间复杂度内得到解。
  • 不一定最优:贪心算法并不总是能够找到全局最优解,适用性较为有限。

经典的贪心算法实例

1. 硬币找零问题

假设有不同面额的硬币,目标是用最少数量的硬币找出给定的金额。假设面额为 1, 5, 10, 25 的硬币。如果我们要找出 30 的金额,可以按照以下步骤进行:

  1. 选择面额最大的硬币 25,剩余 5
  2. 选择面额 5 的硬币,剩余 0
  3. 完成找零,所用硬币为 25, 5
1
2
3
4
5
6
7
8
9
10
11
12
def coin_change(coins, amount):
coins.sort(reverse=True) # 从大到小排序
count = 0
for coin in coins:
while amount >= coin: # 使用尽可能多的当前面额
amount -= coin
count += 1
return count

coins = [1, 5, 10, 25]
amount = 30
print(coin_change(coins, amount)) # 输出: 2

2. 区间调度问题

在这个问题中,给定一组活动,每个活动有开始时间和结束时间。目标是选择最多数量的互不冲突的活动。我们可以通过以下步骤来实现:

  1. 将所有活动按照结束时间排序。
  2. 选择第一个活动,接着选择下一个与已选择活动不冲突的活动。
  3. 重复这一过程,直至所有活动被考虑。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def interval_scheduling(activities):
# 按照结束时间排序
activities.sort(key=lambda x: x[1])
last_end_time = 0
count = 0
for activity in activities:
start, end = activity
if start >= last_end_time: # 如果当前活动可以被选择
count += 1
last_end_time = end # 更新最后结束时间
return count

activities = [(1, 3), (2, 5), (4, 6), (5, 8)]
print(interval_scheduling(activities)) # 输出: 3

总结

在本篇文章中,我们探讨了贪心算法的基础,包括其定义、特点以及经典应用实例。贪心算法在面对特定问题时,以其简洁性和效率成为广泛使用的策略。虽然贪心算法不总是能找到全局最优解,但在运用得当时,能够提供有效的解决方案。

在接下来的讨论中,我们将进一步深入探讨贪心算法的应用及其与动态规划的区别,帮助我们更好地理解何时应选择贪心算法作为解决策略。

分享转发

11 贪心与动态规划的区别

在前一篇中,我们简单介绍了贪心算法的基础,理解了其基本原理及应用场景。本文将深入探讨贪心算法与动态规划的区别,以帮助大家更好地理解这两种算法的特性、适用情况和解决问题的思路。

基本概念回顾

贪心算法

贪心算法是一种通过每一步都选择当前状态下最优解的方式,达到全局最优的一种算法。它基于这样一个假设:局部最优解能够导致全局最优解,因此我们每次都选择当前看起来最好的选项。

动态规划

动态规划是一种将复杂问题分解为更简单子问题的方法,它通过存储之前的计算结果来避免重复计算。动态规划适用于那些可以通过子问题来构造最优解的问题,通常需要明确重叠子问题和最优子结构的特性。

贪心算法与动态规划的主要区别

1. 解的构造方式

  • 贪心算法的构造是通过局部最优选择获取全局最优,而并不考虑以后的决策。

    示例:考虑活动选择问题,选择不重叠的活动。贪心策略是每次选择结束时间最早的活动,直到无法添加更多活动。

  • 动态规划则考虑所有可能的选择,通过构造子问题的最优解逐步解决全局问题。

    示例:使用背包问题来说明,选择物品时需要仔细考虑每个物品的选择与否,并记录每一步的结果,由此建立出最终的最优解。

2. 适用问题的类型

  • 贪心算法通常适用无后效性的问题,即之后的决策不会影响当前的选择。

  • 动态规划则适合重叠子问题最优子结构的问题,在求解中需要保存之前的计算结果以便重复使用。

3. 复杂度与效率

  • 贪心算法通常具有较低的时间复杂度,运行效率高,但并不是所有问题都能通过贪心算法得到最优解。

  • 动态规划通常涉及到更多的计算,在时间复杂度上较高,但其系统化的结果保证了找到全局最优解。

具体案例分析

1. 贪心算法案例

活动选择问题
假设我们有一系列活动,每个活动都有开始和结束时间,我们需要选择最多的互不冲突的活动。

活动列表:

  • (1, 3)
  • (2, 5)
  • (4, 6)
  • (6, 7)
  • (5, 8)

贪心策略:

  1. 首先将活动按结束时间排序。
  2. 选择第一个活动,然后选择下一个结束时间在上一个选择的结束时间后的活动。

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def activity_selection(activities):
# 排序
activities.sort(key=lambda x: x[1])

selected_activities = []
last_end_time = 0

for start, end in activities:
if start >= last_end_time:
selected_activities.append((start, end))
last_end_time = end

return selected_activities

activities = [(1, 3), (2, 5), (4, 6), (6, 7), (5, 8)]
print(activity_selection(activities))

2. 动态规划案例

0-1背包问题
给定一个背包的最大承重以及一系列物品的价值和重量,求能放入背包的最大价值。

动态规划策略:

  1. 通过状态转移方程建立表格来记录每种状态的最优解。
  2. 根据物品的重量和价值决定是否包括该物品。

状态转移公式:
$$
dp[i][w] = \max(dp[i-1][w], dp[i-1][w - weight[i]] + value[i])
$$

代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def knapsack(weights, values, W):
n = len(weights)
dp = [[0 for _ in range(W + 1)] for _ in range(n + 1)]

for i in range(1, n + 1):
for w in range(W + 1):
if weights[i-1] <= w:
dp[i][w] = max(dp[i-1][w], dp[i-1][w - weights[i-1]] + values[i-1])
else:
dp[i][w] = dp[i-1][w]

return dp[n][W]

weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
W = 5
print(knapsack(weights, values, W))

总结

在处理问题时,贪心算法和动态规划各有优劣。在做出选择时,建议先分析问题的特性,确认其是更趋向于贪心还是动态规划。通过本篇的学习希望能够加深大家对这两种算法的理解,为处理相关的算法问题打下更加坚实的基础。

在下一篇文章中,我们将展示一些经典的贪心算法实例,通过具体的例子来强化对贪心算法的理解与应用。

分享转发

12 贪心算法的应用之经典贪心算法实例

在上一篇中,我们探讨了贪心算法与动态规划之间的区别,强调了它们在解决不同问题时的适用场景。贪心算法通常是一种较为简单且高效的策略,通常用于解决优化问题。接下来,我们将深入一些经典的贪心算法实例,以便更好地理解其实际应用及实现。

贪心算法概述

贪心算法是一种构造算法,它通过逐步选择当前最优解的方法来实现整体最优解。贪心算法通常包括以下几个步骤:

  1. 选择一个局部最优解:在每一步中选择一个看似最优的选项。
  2. 设定完成条件:确保在选择完成后能正确判断出整体的最优解。
  3. 证明局部最优能导致全局最优:确保选取某个局部最优解不会影响最终解。

经典贪心算法实例

1. 找零问题

在找零问题中,我们有无限数量的不同面额的硬币,需要用最少数量的硬币来找零。假设我们有面额为 1、3 和 4 的硬币,而找零的金额为 6。

解法

我们可以贪心地选择最大面额的硬币。具体步骤如下:

  1. 选择面额为 4 的硬币,剩余金额为 $6 - 4 = 2$。
  2. 剩余金额 $2$ 可用两枚面额为 1 的硬币。

最终组合是:一枚面额为 4 的硬币和两枚面额为 1 的硬币,共三枚。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
def coin_change(coins, amount):
coins.sort(reverse=True) # 从大到小排序
count = 0
for coin in coins:
while amount >= coin:
amount -= coin
count += 1
return count if amount == 0 else -1

coins = [4, 3, 1]
amount = 6
print(coin_change(coins, amount)) # 输出: 3

2. 活动选择问题

活动选择问题是一个经典的调度问题,其中我们希望选择最大数量的活动,使得每对活动之间没有重叠。假设活动的开始和结束时间如下表所示。

活动 开始时间 结束时间
1 1 3
2 2 5
3 4 6
4 5 7
5 3 4

解法

该问题可以通过选择结束时间最早的活动来解决。具体步骤如下:

  1. 将活动按结束时间排序。
  2. 选择第一个活动,然后继续选择下一个活动,确保下一个活动的开始时间不与已选活动重叠。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def activity_selection(activities):
activities.sort(key=lambda x: x[1]) # 按结束时间排序
selected = [activities[0]] # 选择第一个活动
last_end_time = activities[0][1]

for i in range(1, len(activities)):
if activities[i][0] >= last_end_time: # 不重叠
selected.append(activities[i])
last_end_time = activities[i][1]

return selected

activities = [(1, 3), (2, 5), (4, 6), (5, 7), (3, 4)]
print(activity_selection(activities)) # 输出: [(1, 3), (4, 6), (5, 7)]

3. 最小生成树(Kruskal算法)

最小生成树问题要求我们从一个带权图中选出边,使得所有顶点都被连通且边的权重和最小。Kruskal算法是一种实现方式,它采用贪心策略选择边。

解法

Kruskal算法的基本步骤包括:

  1. 将所有边按权重排序。
  2. 从权重最小的边开始选择,若选择的边不会形成回路,则将其加入结果中。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class UnionFind:
def __init__(self, size):
self.parent = list(range(size))

def find(self, p):
if self.parent[p] != p:
self.parent[p] = self.find(self.parent[p])
return self.parent[p]

def union(self, p, q):
rootP = self.find(p)
rootQ = self.find(q)
if rootP != rootQ:
self.parent[rootP] = rootQ

def kruskal(num_vertices, edges):
edges.sort(key=lambda x: x[2]) # 按权重排序
uf = UnionFind(num_vertices)
mst = []

for u, v, weight in edges:
if uf.find(u) != uf.find(v): # 不形成回路
uf.union(u, v)
mst.append((u, v, weight))

return mst

edges = [(0, 1, 10), (0, 2, 6), (0, 3, 5), (1, 3, 15), (2, 3, 4)]
print(kruskal(4, edges))

总结

本文中,我们通过几个经典的案例深入探讨了贪心算法的应用。我们看到贪心算法在许多优化问题中表现出色,其核心在于如何定义“局部最优”和确保其能导致“全局最优”。贪心算法虽然简单,但在实际问题中应用时需要谨慎地验证其结果的有效性。

在下一篇文章中,我们将讨论复杂度分析与优化,尤其是如何计算时间复杂度。这也是编程中一个非常重要的方面,希望大家继续关注。

分享转发