十八. 数据结构和算法 DRAFT
数据结构和算法是计算机科学中非常重要的两个概念。数据结构是组织和存储数据的方式,而算法则是解决问题的步骤和方法。数据结构和算法之间存在密切的联系,一个好的数据结构能够提高算法的效率,而一个高效的算法也需要合适的数据结构来支持。
常见的数据结构包括数组、链表、栈、队列、树等,每种数据结构都有其特点和适用场景。而算法则是对数据结构进行操作和处理的方法,常见的算法有查找、排序、遍历等。
学习数据结构和算法可以帮助我们更好地理解计算机程序的运行原理,提高程序的效率和性能。同时,数据结构和算法也是计算机科学领域的基础知识,对于编程人员来说是必不可少的技能。因此,掌握数据结构和算法对于提升编程能力和解决实际问题非常重要。
时间复杂度
时间复杂度是对算法运行时间的一种度量,即算法的时间效率。时间复杂度通常用O(n)表示,其中n是问题规模或者输入的数据量。具体来说,时间复杂度描述的是当n趋近于无穷大时,算法时间的增长率。
常见的时间复杂度有以下几种:
-
O(1):常数时间复杂度,表示算法的运行时间不随着问题规模的增加而增加。
-
O(logn):对数时间复杂度,表示算法的运行时间随着问题规模的增加而增加,但增长速度很慢,以2为底的对数是我们最常见的对数。
-
O(n):线性时间复杂度,表示算法的运行时间与问题规模成正比,当问题规模增加一倍时,算法的时间复杂度也会增加一倍。
-
O(nlogn):线性对数时间复杂度,表示算法的运行时间随着问题规模增加而增加,并且增长速度比线性时间复杂度快。
-
O(n^2):平方时间复杂度,表示算法的运行时间随着问题规模的增加而增加,并且增长速度非常快。
-
O(2^n):指数时间复杂度,表示随着问题规模的增加,算法的运行时间呈指数级增长,是效率最低的一种时间复杂度。
在计算时间复杂度时,通常需要考虑最坏情况,因为算法的运行时间在最坏情况下是最长的,也是我们需要关注的情况。此外,算法的时间复杂度并不是唯一的,对于同一个算法,可以有多种不同的实现方式,它们的时间复杂度也可能不同。因此,在设计算法时,需要考虑运行时间和空间复杂度的平衡,以便在不同的应用场景中取得最佳的性能表现。
下面举几个例子来讲解时间复杂度:
- 循环求和
对于一个整数数组nums
,我们需要计算其中所有元素的和。代码如下:
def sum(nums):
result = 0
for num in nums:
result += num
return result
这个算法的时间复杂度为O(n),其中n是数组中元素的个数。因为算法需要遍历整个数组,并对每个元素执行一次加法运算,所以时间复杂度与数组的大小呈线性关系。
- 二分查找
对于一个有序数组nums
和一个目标值target
,我们需要查找目标值在数组中的位置。代码如下:
def binary_search(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
这个算法的时间复杂度为O(logn),其中n是数组中元素的个数。因为算法每次都将搜索范围缩小一半,所以时间复杂度与数组的大小呈对数关系。
- 归并排序
对于一个无序数组nums
,我们需要对它进行归并排序,将数组中的元素从小到大排列。代码如下:
def merge_sort(nums):
if len(nums) <= 1:
return nums
mid = len(nums) // 2
left = merge_sort(nums[:mid])
right = merge_sort(nums[mid:])
return merge(left, right)
def merge(left, right):
result = []
i, j = 0, 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
if i < len(left):
result.extend(left[i:])
if j < len(right):
result.extend(right[j:])
return result
这个算法的时间复杂度为O(nlogn),其中n是数组中元素的个数。因为算法每次都将数组平均分成两个子数组,并对子数组进行排序,然后将它们合并起来,所以时间复杂度与数组的大小呈对数线性关系。
这些例子展示了时间复杂度的不同级别和运算规则,在设计和评估算法时需要仔细考虑。
下面举一个用Python实现的示例,介绍时间复杂度及其分析方法。
问题:给定一个长度为n的无序列表,找出其中的最大数和最小数。
解法一:暴力枚举
第一种解法是直接枚举所有数,找出其中的最大值和最小值。
def find_min_max(nums):
min_num = float('inf')
max_num = float('-inf')
for num in nums:
if num < min_num:
min_num = num
if num > max_num:
max_num = num
return min_num, max_num
该算法需要对每个数进行两次判断,因此时间复杂度为O(2n)=O(n),其中n是列表的长度。
解法二:分治法
第二种解法是采用分治法,将列表平分为两个子列表,然后分别找出每个子列表的最大值和最小值,最后再将这些最大值和最小值进行比较。
def find_min_max(nums):
if len(nums) == 1:
return nums[0], nums[0]
elif len(nums) == 2:
return min(nums), max(nums)
else:
mid = len(nums) // 2
left_min, left_max = find_min_max(nums[:mid])
right_min, right_max = find_min_max(nums[mid:])
return min(left_min, right_min), max(left_max, right_max)
该算法将列表每次平分为两半,因此需要进行log2(n)次操作,每次操作需要比较4个数,因此时间复杂度为O(4 * log2(n))=O(logn),其中n是列表的长度。
可以看出,采用分治法可以减少比较次数,提高算法效率。
总结
本例中,我们采用两种不同的算法分别寻找一个无序列表的最大值和最小值。第一个算法是暴力枚举,需要对每个数进行两次判断,时间复杂度为O(n)。第二个算法采用分治法,每次将问题规模减半,时间复杂度为O(logn)。
从本例可以看出,对于同一个问题,不同的算法可能会产生不同的时间复杂度。在实际工作中,设计和选择恰当的算法可以大幅提高程序效率。
好的,下面以不同的时间复杂度为例,使用 Python 语言来演示每种时间复杂度的代码实现。
- O(1) 时间复杂度
我们先来举例 O(1) 时间复杂度,这种时间复杂度表示算法的执行时间固定,与数据规模无关。这种算法的特点是固定时间完成固定的操作,通常只涉及固定数量的计算和赋值运算。下面是一个 O(1) 时间复杂度的例子:
def add(a: int, b: int) -> int:
return a + b
result = add(2, 3)
print(result) # 5
这个例子的时间复杂度为 O(1),因为它只执行了常数级别的操作,即执行一次加法运算和一次返回操作,与数据规模无关。
- O(log n) 时间复杂度
O(log n) 时间复杂度通常与分治、二分查找、递归等算法有关,每次都将算法的数据规模缩小一半,例如二分查找的案例。下面是一个 O(log n) 时间复杂度的例子:
def binary_search(arr: List[int], target: int) -> int:
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
# 测试
arr = [2, 4, 6, 8, 10]
target = 8
print(binary_search(arr, target)) # 3
这个例子的时间复杂度为 O(log n),因为它使用了二分查找的算法,每次将数据的规模减半。在最坏情况下,它需要进行 log n 次查找,因此具有很快的运算速度。
- O(n) 时间复杂度
O(n) 时间复杂度的算法的执行时间与数据规模成正比,通常都涉及单次的循环遍历或简单的迭代等操作。下面是一个 O(n) 时间复杂度的例子:
def sum(arr: List[int]) -> int:
s = 0
for i in arr:
s += i
return s
# 测试
arr = [1, 2, 3, 4, 5]
print(sum(arr)) # 15
这个例子的时间复杂度为 O(n),因为它使用一个循环来逐个访问数组中的所有元素,时间复杂度与数据规模呈线性关系。
- O(n^2) 时间复杂度
O(n^2) 时间复杂度通常涉及到两层循环嵌套等操作,每次操作时间耗时与数据规模的乘积成正比,因此用于大规模数据处理时可能显得比较耗时。下面是一个 O(n^2) 时间复杂度的例子:
def bubble_sort(arr: List[int]) -> List[int]:
n = len(arr)
for i in range(n):
for j in range(0, n-i-1):
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
return arr
# 测试
arr = [4, 2, 8, 1, 5, 2, 3]
print(bubble_sort(arr)) # [1, 2, 2, 3, 4, 5, 8]
这个例子的时间复杂度为 O(n^2),因为它使用了一个嵌套循环,用来冒泡排序一个长度为 n 的数组,时间复杂度为 O(n^2)。冒泡排序算法时间复
好的,下面我将使用 Python 语言从小到大的顺序,以一些简单的算法为例,逐个讲解每一种时间复杂度。
- O(1)
O(1) 时间复杂度的算法表示运行时间是固定的,不会随着问题规模的增大而增加。下面是一个 O(1) 时间复杂度的算法(获取列表中第一个元素)的 Python 实现:
def get_first_element(lst):
return lst[0]
这个算法的运行时间与列表中元素的数量无关,始终只需要一步就可以完成操作。因此,这个算法的时间复杂度为 O(1)。
- O(log n)
O(log n) 时间复杂度的算法表示运行时间是随问题规模 logarithmic(对数级别)增长的。下面是一个 O(log n) 时间复杂度的算法(二分查找)的 Python 实现:
def binary_search(lst, target):
low, high = 0, len(lst) - 1
while low <= high:
mid = (low + high) // 2
if lst[mid] == target:
return mid
elif lst[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1
这个算法使用二分查找的方式,每次将问题规模折半,因此它的时间复杂度为 O(log n)。
- O(n)
O(n) 时间复杂度的算法表示运行时间直接随问题规模 linear(线性)增长的。下面是一个 O(n) 时间复杂度的算法(遍历列表)的 Python 实现:
def print_list(lst):
for i in lst:
print(i)
这个算法的运行时间直接随着列表中元素的数量 n 线性增长,因此它的时间复杂度为 O(n)。
- O(n log n)
O(n log n) 时间复杂度的算法表示运行时间随问题规模增长的速度略快于线性但远慢于平方级别。下面是一个 O(n log n) 时间复杂度的算法(归并排序)的 Python 实现:
def merge_sort(lst):
if len(lst) <= 1:
return lst
mid = len(lst) // 2
left_lst = merge_sort(lst[:mid])
right_lst = merge_sort(lst[mid:])
return merge(left_lst, right_lst)
def merge(left_lst, right_lst):
result = []
i = j = 0
while i < len(left_lst) and j < len(right_lst):
if left_lst[i] < right_lst[j]:
result.append(left_lst[i])
i += 1
else:
result.append(right_lst[j])
j += 1
result += left_lst[i:]
result += right_lst[j:]
return result
这个算法使用归并排序的方式,在对一个包含 n 个元素的列表进行排序时,它的时间复杂度为 O(n log n)。
- O(n^2)
O(n^2) 时间复杂度的算法表示运行时间随问题规模增长的速度远快于线性但远慢于指数级别,尤其对于大规模问题运行时间很长。下面是一个 O(n^2) 时间复杂度的算法(选择排序)的 Python 实现:
def selection_sort(lst):
for i in range(len(lst)):
min_index = i
for j in range(i+1, len(lst)):
if lst[j] < lst[min_index]:
min_index = j
lst[i], lst[min_index] = lst[min_index], lst[i]
return lst
这个算法使用选择排序的方式,每次从列表中挑选出最小的元素并将其放在当前位置,因此它的时间复杂度为 O(n^2)。
好的,下面是几个常见的时间复杂度以及对应的python代码举例:
- 常数时间复杂度:O(1)
常数时间复杂度表示随着输入数据的增加,算法执行时间不会发生变化。例如取一个数组的第一个元素,无论数组的大小如何,所需时间都是恒定的。
示例代码:
def get_first_element(arr):
return arr[0]
- 线性时间复杂度:O(n)
线性时间复杂度意味着随着输入数据量的增加,算法的执行时间会以线性的方式递增。例如遍历一个长度为n的数组。
示例代码:
def linear_search(arr, target):
for i in range(len(arr)):
if arr[i] == target:
return i
return -1
- 对数时间复杂度:O(log n)
对数时间复杂度表示当输入数据量呈指数级增长时,算法执行时间只会以对数级别递增。例如二分查找算法。
示例代码:
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
- 平方时间复杂度:O(n²)
平方时间复杂度意味着算法执行时间与输入数据量的平方成正比。例如冒泡排序算法。
示例代码:
def bubble_sort(arr):
for i in range(len(arr)):
for j in range(0, len(arr) - i - 1):
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j]
return arr
以上是几个常见的时间复杂度和对应的python代码示例,当然还有其他更复杂的时间复杂度这里没有提到。
递推算法
递推算法(Recursion)是一种经典的算法设计方法,它是一种通过将问题分解为一个或多个小问题的方法来解决复杂问题的算法。在递归算法中,一个函数调用自身一次或多次,每一次调用都是为了解决问题的一个子问题,直到问题最终被解决。
递推算法常见应用:
-
斐波那契数列:斐波那契数列是一个以递归方式定义的数列,其中每个数字是前两个数字的和。使用递推算法,可以通过递推的方式计算斐波那契数列。
-
分治算法:分治算法是一种递归算法,该算法将一个问题分成多个子问题,在子问题上递归调用自身,最终将所有子问题的结果合并成一个整体的解。
-
排序算法:快速排序、归并排序等排序算法也是递归算法。它们使用递归的方式将原始数据分成多个子问题并解决,最终将结果合并起来得到排序后的序列。
-
树遍历:树遍历也是递归算法的一个常见应用。树的遍历可以分为前序遍历、中序遍历和后序遍历,这些遍历方式都可以使用递归算法来实现。
-
动态规划:动态规划是一种递归算法,它将原问题分解为子问题,每个子问题只求解一次,并将结果保存下来,避免重复求解。通过这种方式,可以在合理的时间内解决一些复杂的问题,例如任务调度、邮局选址等问题。
总之,递推算法是一种非常有用和灵活的算法设计方法,在算法和数据结构中有着广泛的应用,非常适合处理分治、排序、遍历、动态规划等问题。对于算法设计和代码实现都有很大的帮助作用。
队列和栈
队列和栈都是一种数据结构,它们有着很多相似之处,同时也有很多区别。它们都是管理数据的一种方式,队列是按照先进先出(FIFO)的顺序管理数据的,而栈是按照后进先出(LIFO)的顺序管理数据的。
队列将元素添加到队列的尾部,从队列的头部取出元素。这种数据结构常用于在多个处理过程中共享数据,从而实现数据的流水线处理。
栈将元素添加到栈顶,从栈顶取出元素。栈常用于需要倒序处理数据的场景,例如计算表达式和回退功能。
队列和栈也有一些应用:
-
队列的应用
-
消息队列:用于分布式系统中异步通信,消息发送和消费过程之间是队列操作。
-
缓冲区:用于缓存数据,例如音视频播放等场景。
-
线程池:用于管理并发任务的执行顺序。
-
负载均衡:用于管理任务的分发和处理,调度任务的顺序。
-
-
栈的应用
- 计算表达式:在中缀表达式和后缀表达式之间进行转换,或者直接利用栈来计算后缀表达式。
- 回退功能:浏览器中的返回、前进功能。
- 进制转换:将十进制数转换为其他进制数,例如二进制和八进制。
- 函数调用:每个函数调用会将上下文压入栈,调用结束后再弹出,即实现了函数调用的递归调用和返回功能。
栈是一种具有后进先出(LIFO,Last-In-First-Out)特点的数据结构,它的基本操作包括压栈(将数据插入栈顶)和弹栈(将栈顶数据取出)。栈通常用来实现程序调用、表达式求值、括号匹配等场景。
堆栈(Heap Stack)通常也称为内存堆(Memory Heap),它是程序运行时的内存空间之一。堆栈区别于程序中的静态内存(Static Memory,如常量和全局变量)和堆内存(Heap Memory,如malloc、new等运行时分配的内存)。堆栈是由程序自动进行开辟和释放,堆栈上的数据可使用指针(Pointer)进行访问,其访问速度比堆内存快但空间有限。
在函数调用时,函数内的局部变量通常会被放在堆栈上,随着函数的退出,这些变量所占用的空间也会被立即释放,堆栈的空间也会重新变得可使用。由于堆栈上的数据是按顺序存储的,因此可以通过栈帧(Stack Frame)来管理这些数据。栈帧通常包含了当前函数的调用信息、返回地址、以及函数参数和局部变量等数据。
总之,栈是一种逻辑模型,而堆栈则是内存管理的一个重要概念。在程序设计过程中,栈和堆栈这两种概念经常出现,我们需要根据具体的情况,来选择使用合适的数据结构和内存管理方式。
队列和栈是两种常见的数据结构,它们在算法和程序设计中都有着很广泛的应用。
- 队列
队列是一种先进先出(FIFO)的数据结构,类似于排队的场景。在队列中,数据是按顺序排列的,从队列的一端(队尾)添加数据,从队列的另一端(队首)取出数据,确保了先加入的数据先被取出来。
队列主要用于在一个程序中按顺序处理一批任务,或者在在网络应用中处理基于服务请求的任务。队列可以用数组或链表实现。在队列执行过程中,通常包括以下操作:
- 入队:将数据添加到队列的尾部
- 出队:从队列的头部取出数据,并移除该数据
- 获取队首元素和队尾元素:获取队列中第一个和最后一个元素
队列应用案例:
- 网络请求任务队列:将所有的网络请求任务按照顺序加入到队列中,并依次执行任务。
- 消息队列:将所有的信息都添加到队列中,并按照消息的顺序逐一处理。
- 栈
栈是一种后进先出(LIFO)的数据结构,类似于一摞书或者一堆盘子。在栈中,最后一个加入的元素最先被取出,而第一个加入的元素最后被取出。
栈通常用于解决问题,称为堆栈。栈可以用数组或链接列表实现。在栈的执行过程中,通常包括以下操作:
- 入栈:将数据添加到栈的顶部
- 出栈:从栈的顶部取出数据,并移除该数据
- 获取栈顶元素:获取栈中第一个元素的值,但不移除
栈应用案例:
- 网页浏览器:浏览器中有一个后退按钮,使用一个栈的结构来实现该功能。将浏览器访问的页面组成一个栈,每次点击后退按钮时,就从栈顶取出上一个页面显示。
- 系统调用:函数调用栈是一个很好的例子,每次调用函数时,程序将函数的返回地址入栈,当函数返回时,再从栈中取出返回地址。
总之,队列和栈是两个简单但是非常有用的数据结构,在算法和程序设计中有着广泛的应用。在具体应用时,可以选择使用队列或栈来满足需要,以提高程序的效率和可靠性。
队列和栈作为经典的数据结构,在程序设计和算法实现中都有着广泛的应用,以下是它们在实际应用中的一些常见案例:
- 队列应用
a. 消息队列(Message Queue):消息队列作为一种高效并且经常使用的通信方式,用于实现不同组件间的异步通信或解耦。在使用消息队列时,每个组件会将消息放入消息队列中,另一方则通过从队列中获取消息来达到通信的目的。消息队列可以支持异构组件、消息流量控制、持久化、可靠性投递等功能。
b. 线程池(ThreadPool):线程池是一种用于管理和复用一组线程的机制,主要用于处理任务队列。线程池通常使用队列来存储任务,每个线程从队列中取出一个任务,并执行该任务。使用线程池可以有效地避免创建过多的线程,减少 CPU 的上下文切换,并提高任务执行的效率和可靠性。
c. 缓存(Cache):常见的缓存通常采用类似 LRU(Least Recently Used)算法的方式进行淘汰,即将最久未被使用的缓存项从队列中移除。例如,Redis中的 LRU 算法就是通过先进先出的队列来维护缓存的。
- 栈应用
a. 函数调用(Function Call):在函数调用时,程序会将函数的返回地址、参数、局部变量等信息保存在栈中,当函数返回时,再按照相反的顺序依次取出这些信息。因此在函数调用时,栈扮演了一个非常重要的角色,包括保存函数调用过程中的上下文信息和返回结果等。
b. 表达式求值(Evaluate Expression):在计算器等应用程序中,常常需要对表达式进行求值。在表达式求值时,程序通常会使用到数据栈和操作符栈来实现。程序会将表达式中的数字压入数据栈中,将操作符压入操作符栈中,并按照相应的规则进行计算,直到最终得出表达式的结果。
c. 浏览器历史记录(Browser History):浏览器中的历史记录是通过栈结构来实现的。每当访问一个新页面时,浏览器会将该页面的 URL 压入栈中,并在点击后退按钮时弹出栈顶元素,以实现浏览器历史记录的功能。
综上,队列和栈作为两个经典的数据结构,在编程和算法领域都有着广泛的应用,它们可以增加程序的运行效率和可靠性,减少内存占用。因此,熟练了解队列和栈的应用场景,对于程序员来说是非常重要的。
以下是队列和栈在Python中的编程实例:
- 队列的Python编程实例
class Queue:
def __init__(self):
self.items = []
def is_empty(self):
return self.items == []
def enqueue(self, item):
self.items.append(item)
def dequeue(self):
return self.items.pop(0)
def size(self):
return len(self.items)
以上代码定义了一个队列数据结构,包含以下方法:
- is_empty:判断队列是否为空
- enqueue:入队操作
- dequeue:出队操作
- size:获取队列的大小
- 栈的Python编程实例
class Stack:
def __init__(self):
self.items = []
def is_empty(self):
return self.items == []
def push(self, item):
self.items.append(item)
def pop(self):
return self.items.pop()
def peek(self):
return self.items[-1]
def size(self):
return len(self.items)
以上代码定义了一个栈数据结构,包含以下方法:
- is_empty:判断栈是否为空
- push:入栈操作
- pop:出栈操作
- peek:获取栈顶元素
- size:获取栈的大小
以上是队列和栈在Python中的编程实例,均为使用Python的列表来实现队列和栈。在实际应用中,可以根据具体需求使用不同的数据结构,并使用Python提供的相应库来实现队列和栈,例如使用Queue库来实现队列,使用LifoQueue库来实现栈等等。
以下是队列和栈在Python中的常见应用实例:
- 队列应用实例
a. 模拟银行排队场景
from queue import Queue
class Client:
def __init__(self, name, time):
self.name = name
self.time = time
def bank_queue():
# 创建一个队列用于存储客户
client_queue = Queue()
# 将客户加入队列中
client_queue.put(Client("客户1", 3))
client_queue.put(Client("客户2", 5))
client_queue.put(Client("客户3", 2))
# 取出队列中的客户,模拟排队过程
while not client_queue.empty():
client = client_queue.get()
print(f"{client.name} 开始办理业务")
time.sleep(client.time)
print(f"{client.name} 业务办理完成")
if __name__ == '__main__':
bank_queue()
以上代码模拟了银行排队场景,使用Python的Queue库实现队列数据结构。在程序中,首先创建一个队列,然后将客户加入队列中,并逐个取出客户进行业务办理模拟。
b. 多线程任务调度
from queue import Queue
import threading
import time
def worker(worker_id, task_queue):
while True:
task = task_queue.get()
if task is None:
break
print(f"worker-{worker_id} 开始执行任务:{task}")
time.sleep(1)
print(f"worker-{worker_id} 执行任务完成:{task}")
task_queue.task_done()
def main():
task_queue = Queue()
# 创建10个线程用于执行任务
for i in range(10):
t = threading.Thread(target=worker, args=(i, task_queue))
t.start()
# 向队列中添加任务
for i in range(100):
task_queue.put(f"任务-{i}")
# 等待所有任务执行完成
task_queue.join()
# 停止所有线程
for i in range(10):
task_queue.put(None)
for t in threading.enumerate():
if t != threading.current_thread():
t.join()
if __name__ == "__main__":
main()
以上代码使用Python的Queue库实现了一个多线程任务调度,首先创建了一个任务队列,然后创建了10个线程用于执行任务。在程序中,主线程向任务队列中添加100个任务,然后等待所有任务执行完成。最后,主线程会向任务队列中添加10个停止信号,停止后续的任务。
- 栈应用实例
a. 计算中缀表达式
class Calculator:
def __init__(self):
self.data_stack = [] # 数据栈
self.op_stack = [] # 操作符栈
def priority(self, op):
if op in ('+', '-'):
return 1
elif op in ('*', '/'):
return 2
else:
return 0
def calculate(self, x, y, op):
if op == '+':
return x + y
elif op == '-':
return x - y
elif op == '*':
return x * y
elif op == '/':
return x / y
def evaluate(self, expr):
tokens = expr.split()
for token in tokens:
if token.isdigit():
self.data_stack.append(int(token))
elif token == '(':
self.op_stack.append(token)
elif token == ')':
while self.op_stack[-1] != '(':
y = self.data_stack.pop()
x = self.data_stack.pop()
op = self.op_stack.pop()
self.data_stack.append(self.calculate(x, y, op))
self.op_stack.pop()
else:
while len(self.op_stack) > 0 and self.priority(self.op_stack[-1]) >= self.priority(token):
y = self.data_stack.pop()
x = self.data_stack.pop()
op = self.op_stack.pop()
self.data_stack.append(self.calculate(x, y, op))
self.op_stack.append(token)
while len(self.op_stack) > 0:
y = self.data_stack.pop()
x = self.data_stack.pop()
op = self.op_stack.pop()
self.data_stack.append(self.calculate
以下是队列和栈在Python中的应用实例:
- 队列的Python应用实例
a. 使用队列完成任务的调度
from queue import Queue
import time
def worker(q):
while True:
task = q.get()
if task is None:
print('All tasks are done.')
q.task_done()
break
time.sleep(1.0)
print(f'Completed {task}')
q.task_done()
q = Queue()
for i in range(5):
q.put(i)
# 添加 None 到队列中来标识所有任务都处理完毕
q.put(None)
# 创建5个worker线程处理任务队列
for i in range(5):
t = threading.Thread(target=worker, args=(q,))
t.start()
# 等待任务队列处理完毕
q.join()
以上代码使用Queue类实现了一下任务调度的示例,程序创建5个worker线程处理任务队列中的任务,当队列中的任务都处理完毕时程序结束。
b. 使用Pandas库读取文件时使用队列提高读取效率
import pandas as pd
import threading
from queue import Queue
def read_csv_chunks(f, chunksize, q):
for chunk in pd.read_csv(f, chunksize=chunksize):
q.put(chunk)
q.put(None)
def process_csv_chunks(q):
while True:
chunk = q.get()
if chunk is None:
break
# do some processing here
q.task_done()
def main():
f = '/path/to/large_file.csv'
chunksize = 10000
q = Queue()
t1 = threading.Thread(target=read_csv_chunks, args=(f, chunksize, q))
t2 = threading.Thread(target=process_csv_chunks, args=(q,))
t1.start()
t2.start()
q.join()
以上代码使用Queue类实现了对大文件进行数据处理任务,程序将大文件分块读取,使用队列将每个数据块传递给另外一个线程,另一个线程则从队列中取出数据块并进行处理。
- 栈的Python应用实例
a. 通过栈判断字符串中的括号是否匹配
def is_valid_parentheses(s):
stack = []
for i in s:
if i == '(' or i == '[' or i == '{':
stack.append(i)
elif i == ')' and (not stack or stack.pop() != '('):
return False
elif i == ']' and (not stack or stack.pop() != '['):
return False
elif i == '}' and (not stack or stack.pop() != '{'):
return False
return not stack
print(is_valid_parentheses('()[]{}')) # True
print(is_valid_parentheses('([)]')) # False
以上代码使用栈的数据结构判断字符串中的括号是否匹配。循环遍历字符串中的每一个字符,如果是左括号则入栈,如果是右括号则从栈中弹出对应的左括号,判断是否匹配。
b. 使用栈实现简单的计算器
class Calculator:
def __init__(self):
self.stack = []
def calculate(self, s):
tokens = s.split()
for token in tokens:
if token.isdigit():
self.stack.append(int(token))
else:
a = self.stack.pop()
b = self.stack.pop()
if token == '+':
self.stack.append(b + a)
elif token == '-':
self.stack.append(b - a)
elif token == '*':
self.stack.append(b * a)
else:
self.stack.append(b // a)
return self.stack[0]
calc = Calculator()
print(calc.calculate('3 4 + 5 *')) # 35
print(calc.calculate('4 2 / 3 +')) # 5
以上代码使用栈的数据结构实现一个简单的计算器,程序将算数表达式转换为数字和运算符,运用栈来计算出最终结果。
深度优先搜索
深度优先搜索是一种图遍历算法,它从图的某个顶点开始遍历,沿着深度方向遍历直到这条路走到底,然后回溯到最近的一个有未访问过的分支的节点,继续遍历,直到遍历完整个图。通常使用栈进行实现,具体实现可以通过递归或非递归方式实现。
深度优先搜索可用于求解迷宫、拓扑排序等问题,具有较低的时间和空间复杂度,但可能会陷入死循环,因此需要对访问过的节点进行标记,以避免反复访问。
图
图是一种非线性的数据结构,它由一组顶点和一组边组成。顶点表示实体,边则表示实体之间的关系。在计算机科学中,图通常用来描述网络拓扑、路由、社交网络、电子电路等问题。
图的基本组成成分有以下几个部分:
- 顶点(Vertex): 表示实体,通常用数字或字母来标识。
- 边(Edge): 表示实体之间的关系,根据有方向和无方向的不同可以分为有向边和无向边。
- 权重(Weight): 边上可能带有这个边的权重,通常用于表示边之间的距离或者代价,例如两个城市之间的距离或两个货物之间的运费等。
- 路径(Path): 图中从起点到终点所经过的边的集合。
- 无向图(Undirected Graph): 没有方向的图,任意两个顶点之间的边都是双向的。
- 有向图(Directed Graph): 有方向的图,每条边只能从一个顶点出发,并指向另一个顶点。
- 完全图(Complete Graph): 任意两个顶点之间都存在一条边。
- 连通图(Connected Graph): 图中任意两个顶点之间都存在一条路径。
- 生成树(Spanning Tree): 连通图的一个子图,其包含所有n个顶点,并且只有n-1条边。
在计算机中,图的表示方式主要有两种:邻接矩阵和邻接表。
邻接矩阵是一种二维数组,行列分别表示顶点,数组元素表示相连的边。如果两个顶点之间有边相连,值为1;如果没有边相连,值为0。邻接矩阵内元素的数量通常为$V^2$,其中V为节点数量,因此邻接矩阵在空间上的耗费较大。
邻接表则是用链表来表示边和边的关系,每个顶点对应的一个链表,该链表中存储与该顶点相邻的顶点。邻接表的空间复杂度为$O(E)$,其中E为边的数量,通常情况下,E«$V^2$,因此邻接表通常需要更少的内存空间。
以上是图数据结构的基本概念,对理解图算法和实现图算法是非常重要的。
图遍历算法是一种对图进行搜索的算法,它可以从任意一个顶点开始遍历整个图。常见的图遍历算法包括深度优先搜索和广度优先搜索。
深度优先搜索从一个起始顶点开始,选择一个邻接的未访问顶点进行遍历,直到无法继续前进,然后回溯到前一个顶点,继续选择其他未访问的顶点进行遍历,直到所有顶点都被访问。深度优先搜索使用栈进行实现,可用于求解拓扑排序、迷宫等问题。
广度优先搜索从一个起始顶点开始,先访问起始顶点的所有邻居,然后访问邻居的邻居,直到所有可达的顶点都被访问。广度优先搜索使用队列进行实现,可用于求解最短路径等问题。
图遍历算法对于解决一些图论问题非常重要,在计算机科学的很多领域都得到了广泛应用,例如计算机网络、数据挖掘、人工智能等。
下面是一个使用Python实现深度优先搜索的例子,假设我们有一个图,用邻接表来表示:
graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['F'],
'D': [],
'E': ['F'],
'F': []
}
其中,字母表示顶点,列表表示该顶点所连接的其他顶点。现在我们从’A’顶点开始进行深度优先搜索,遍历整个图:
def dfs(graph, start):
visited = set() # 存储已经访问过的顶点
stack = [start] # 栈用于存储待访问的顶点
while stack:
vertex = stack.pop() # 取出栈顶元素进行访问
if vertex not in visited:
visited.add(vertex) # 将访问过的顶点加入到visited中
stack.extend(graph[vertex] - visited) # 将未访问的邻居加入栈中
return visited
我们可以将该算法保存在文件中,命名为dfs.py
。然后在终端中输入以下命令运行:
$ python dfs.py
输出结果如下:
{'E', 'D', 'F', 'C', 'B', 'A'}
这是一个深度优先搜索从起始顶点’A’开始遍历图,并返回所有已访问的顶点。
广度优先搜索
广度优先搜索是一种图形搜索算法,从一个起点开始,逐层遍历地搜索图形中的所有节点。具体算法实现的过程是:首先访问起点节点,接着依次访问它的邻居节点,然后再依次访问它们的邻居节点,以此类推。广度优先搜索一般使用队列来实现,保证在遍历同一层节点的所有邻居节点之前,先遍历前一层节点的所有邻居节点。
广度优先搜索可以用于解决迷宫、无权图中的最短路径等问题,由于搜索的顺序是逐层遍历,因此找到的路径就是从起点到终点的最短路径,但广度优先搜索的缺点在于:当图非常大以及图中的各个节点之间的边缘数已经超过内存的大小时,使用广搜往往会使程序陷入内存泄漏的风险。
广度优先搜索与深度优先搜索类似,但它们在遍历方式上有所不同。深度优先搜索是从一个节点开始,沿着路径直到不能再继续遍历,再回溯到前一节点,而广度优先搜索则是从一个节点开始,遍历它所连接的所有节点,并逐层延伸到其他节点。
下面是一个使用Python实现广度优先搜索的例子,假设我们有一个图,用邻接表来表示:
graph = {
'A': ['B', 'C'],
'B': ['D', 'E'],
'C': ['F'],
'D': [],
'E': ['F'],
'F': []
}
其中,字母表示顶点,列表表示该顶点所连接的其他顶点。现在我们从’A’顶点开始进行广度优先搜索,遍历整个图,输出访问过的所有顶点:
from collections import deque
def bfs(graph, start):
visited = set() # 存储已经访问过的顶点
queue = deque([start]) # 队列用于存储待访问的顶点
while queue:
vertex = queue.popleft() # 取出队首元素进行访问
if vertex not in visited:
visited.add(vertex) # 将访问过的顶点加入visited中
queue.extend(graph[vertex] - visited) # 将未访问的邻居加入队列中
return visited
我们可以将该算法保存在文件中,命名为bfs.py
。然后在终端中输入以下命令运行:
$ python bfs.py
输出结果如下:
{'B', 'C', 'A', 'E', 'D', 'F'}
这是一个广度优先搜索从起始顶点’A’开始遍历图,并返回所有已访问的顶点。
二分法
二分法,也称折半查找,是一种在数列中查找特定值的算法。二分法的基本思想是:将有序数组一分为二,由于数组是有序的,因此可以判断特定值会在数组的哪一半中出现,继续将该部分再一分为二,直到找到该特定值为止。这个过程类似于让计算机不断猜测特定值的位置,并根据是否猜对向正确方向进行猜测。因为每次猜测都能排除掉一半的数据,所以二分法的时间复杂度是$O(log_2n)$,效率很高。
二分法的过程可以分为以下三步:
- 将有序数组一分为二,取中间数值。
- 对比中间数值和目标值的大小。
- 如果中间值等于目标值,直接返回中间值对应的数组下标;
- 如果中间值大于目标值,说明目标值在左侧部分,重复步骤1;
- 如果中间值小于目标值,说明目标值在右侧部分,重复步骤1。
- 如果整个过程中数组都被猜完了目标值还没有找到,说明目标值不在数组中,返回-1。
以下是一个Python的实现例子:
def binary_search(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
该函数接受一个有序列表和一个目标值,并返回目标值在列表中的下标。如果未找到目标值,返回-1。函数实现中,我们不断将列表一分为二,判断目标值在左侧部分还是右侧部分,并重复上述过程,直到找到目标值或者整个列表都被猜完。
这里提供一个二分法在Python中的实现,以搜索一个有序数组中的数为例。假设我们要在以下有序数组中搜索数字5的位置:
nums = [1, 3, 4, 5, 6, 7, 8, 9, 11, 13]
下面是一个简单的二分法实现:
def binary_search(nums, target):
left, right = 0, len(nums) - 1 # 确定区间左右边界
while left <= right: # 当 left 和 right 重合时停止
mid = (left + right) // 2 # 取中间值
if nums[mid] == target: # 如果中间值等于目标值
return mid # 返回中间值的下标
elif nums[mid] < target: # 如果中间值小于目标值,说明目标值在右侧
left = mid + 1 # 缩小搜索区间的左边界
else: # 如果中间值大于目标值,说明目标值在左侧
right = mid - 1 # 缩小搜索区间的右边界
return -1 # 未找到目标值
我们可以将该算法保存在文件中,命名为binary_search.py
。然后在终端中输入以下命令运行:
$ python binary_search.py
输出结果如下:
3
这是一个二分法搜索数字5在有序数组中的位置,并返回其下标。
分治算法
分治算法(Divide and Conquer)是一种递归求解问题的思想,将一个较大的问题分成较小的子问题进行求解,然后再将子问题的解合并起来,得到原问题的解。分治算法主要包含以下三个步骤:
- 分解:将原问题分解为若干个规模较小、相互独立并与原问题结构相同的子问题。
- 解决:递归地求解各子问题。如果子问题规模足够小,则直接求解。
- 合并:将各子问题的解合并为原问题的解。
分治算法的经典应用包括快速排序、归并排序、Strassen矩阵乘法等。其中,归并排序和快速排序是最常用的两种排序算法,它们都采用了分治策略。此外,分治算法也在计算几何、图像处理、机器学习等领域上得到了广泛的应用。
以下是一个使用分治算法求解最大子数组的例子。给定一个整数数组,我们要找出一个连续的子数组,使得该子数组的元素之和最大。例如,对于以下数组:
[-2, 1, -3, 4, -1, 2, 1, -5, 4]
其最大连续子数组为[4, -1, 2, 1]
,其元素之和为6。下面是该问题的分治算法实现:
def max_subarray(nums, left, right):
if left == right:
return nums[left]
mid = (left + right) // 2
left_max = max_subarray(nums, left, mid)
right_max = max_subarray(nums, mid + 1, right)
cross_max = cross_subarray(nums, left, right, mid)
return max(left_max, right_max, cross_max)
def cross_subarray(nums, left, right, mid):
left_sum = float('-inf')
right_sum = float('-inf')
cur_sum = 0
for i in range(mid, left - 1, -1):
cur_sum += nums[i]
left_sum = max(left_sum, cur_sum)
cur_sum = 0
for i in range(mid + 1, right + 1):
cur_sum += nums[i]
right_sum = max(right_sum, cur_sum)
return left_sum + right_sum
该算法首先将原数组递归分成两部分,分别求解左右子数组中的最大连续子数组和。注意到最大连续子数组可能会跨越中点,我们需要另外求解出横跨中点的最大连续子数组,并取三者中的最大值作为该数组的最大连续子数组。
以下是一个使用分治算法对一个无序数组进行排序的例子。假设我们有以下无序数组:
nums = [14, 2, 6, 8, 4, 1, 11, 18]
为了对该数组进行排序,我们需要将其分成两个子数组,并对两个子数组分别进行排序,最后将两个有序子数组合并起来。这个过程类似于对两个无序的数组进行排序,然后将它们合并成一个有序的数组。
下面是分治算法实现:
def merge_sort(nums):
# 定义递归出口
if len(nums) <= 1:
return nums
# 将列表平分为两个子列表
mid = len(nums) // 2
left = nums[:mid]
right = nums[mid:]
# 递归对子列表进行排序
left = merge_sort(left)
right = merge_sort(right)
# 合并结果
return merge(left, right)
def merge(left, right):
result = []
i, j = 0, 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
if i < len(left):
result.extend(left[i:])
if j < len(right):
result.extend(right[j:])
return result
我们可以将该算法保存在文件中,命名为merge_sort.py
。然后在终端中输入以下命令运行:
$ python merge_sort.py
输出结果如下:
[1, 2, 4, 6, 8, 11, 14, 18]
这是一个分治算法对无序数组进行排序,并返回排序后的结果。
贪心算法
贪心算法是一种基于贪心思想的算法策略。贪心思想是指,在每一步选择中,选择当前状态下最优的选择,以期最终获得全局最优解。因此,贪心算法的核心是寻找局部最优解,并用数学归纳法证明这种策略可以得到全局最优解。
贪心算法通常用于优化问题,这类问题通常有一个最优化目标,即希望在满足约束条件的前提下,使得某个目标函数达到最大或最小。例如,在求解最小生成树、霍夫曼编码、背包问题等中,贪心算法都是实现效率高、思路简单的一种有效解决方法。
贪心算法的一般流程如下:
-
将问题分解为若干个子问题,每个子问题的解决都是局部最优的。
-
定义一个优化函数或策略,用于评估每个子问题的局部最优解,并选择一个最优策略。
-
将所有子问题的最优解组合起来,得到问题的全局最优解。
下面通过一个具体的例子来说明贪心算法的应用。
问题:假设你需要购买某个商品,现在有n个商家出售该商品,你需要选出k个商家进行比较,以获得最低的价格。其中n和k已知。
解法:一个可行的贪心算法是,首先随机选择k个商家进行比较,记录下最低价和对应的商家编号。然后遍历剩余的商家,对于每个商家,如果其商品的价格低于已经选择的k个商家之一,那么就用这个商家的价格和编号替换最高的商家。最后留下的k个商家就是价格最低的k个商家。
该贪心算法时间复杂度为O(nlogk),其中k表示需要选择的商家数量。这个算法的贪心策略是在局部具有最佳性质的基础上,通过不断调整来寻找全局最优解。
需要注意的是,贪心算法通常无法保证得到全局最优解,因为贪心策略不一定总是成立。因此,在实际应用中,需要对算法得到的结果进行有效性验证,并考虑是否需要采用别的算法对结果进行优化。
下面通过一个具体的例子,用Python语言来实现贪心算法。
问题:假设你有一个邮包,最多能装进重量为W的物品。现在有n个物品,每个物品的重量为wi,价值为vi。你需要选出其中的若干个物品,使得总重量不超过W,同时总价值最大。其中n和W已知。
解法:一个可行的贪心算法是,先计算每个物品的单位重量价值vi/wi,然后从中选择单位重量价值最高的物品,直到邮包不能再放下为止。这个贪心策略的核心思想是,每次优先选择单位重量价值最高的物品,希望通过这种方式获得尽可能大的总价值。
下面是Python代码实现:
def knapsack(items, capacity):
# 计算每个物品的单位重量价值
for item in items:
item.append(item[1] / item[0])
# 按照单位重量价值从大到小排序
items = sorted(items, key=lambda x: x[2], reverse=True)
# 逐个放入物品,直至不能装下为止
result = 0
for item in items:
if capacity >= item[0]:
capacity -= item[0]
result += item[1]
else:
result += item[1] * capacity / item[0]
break
return result
该算法时间复杂度为O(nlogn),其中n表示物品的数量。需要注意的是,该算法只能得到近似最优解,因为贪心策略并不能保证总价值最大,只是局部最优。
以上是一个简单的贪心算法实例,通过这个例子可以看出,贪心算法思路简单,容易理解和实现,但是需要严格的问题建模和贪心策略设计,只有在符合特定条件下,贪心策略才能得到全局最优解。
下面以求解货车装载问题为例,来演示如何使用 Python 实现贪心算法。
问题描述:
有一批物品,重量分别为 $w_1,w_2,…,w_n$,其重量之和为 $W$。现在需要将这些物品装入两辆载重量相同的货车中,问是否能够找到一种方案,满足两辆货车中装载物品的重量之和相等。
解题思路:
这是一道经典的 0-1 背包问题,但是因为题目中需要将物品分配到两个背包中,而每个物品不能分割,所以无法使用动态规划算法。但是,可以使用贪心算法来求解该问题。
具体的,我们先将物品按重量从大到小排序,然后将物品依次放入两个货车中。为了使两个货车的载重量尽可能平衡,每个物品应该尽可能放在载重量较小的货车中。当无法再将新的物品放入载重量较小的货车时,就开始放入载重量较大的货车。
Python 代码实现如下:
def can_load_equally(w: List[int]) -> bool:
W = sum(w)
if W % 2 != 0:
return False
w.sort(reverse=True)
n = len(w)
i = 0
j = 0
while i < n and j <= W // 2:
j += w[i]
i += 1
return j == W // 2
# 示例用法
w = [2, 3, 4, 5, 6]
print(can_load_equally(w)) # True
这里的 can_load_equally
函数返回布尔值 True
表示可以找到一个方案,将这批物品分别分配给两辆货车,使得两辆货车的载重量相等。如果返回布尔值 False
,则意味着无法找到这样的方案。
算法时间复杂度为 $O(n \log n)$,其中 $n$ 是物品的数量。这是因为算法中执行了一次排序操作。
需要注意的是,由于贪心算法对问题做了很强的简化假设,所以贪心算法并不适用于所有问题,无法保证得到全局的最优解。因此,需要针对具体的问题,仔细验证贪心算法是否可行,以及它能否得到满足要求的解。
动态规划
动态规划(Dynamic Programming,简称 DP)是一种高效的算法思想,它利用子问题之间的关系,将问题分解成一系列相互依赖的子问题,并逐个求解这些子问题,从而得到原问题的最优解。动态规划算法通过保存每个子问题的解,并根据子问题之间的关系逐步推导得到原问题的解,从而避免了重复计算,提高了算法的效率。
通常,动态规划算法可以分为以下四个步骤:
- 定义子问题;
- 构建子问题之间的关系,通常使用状态转移方程描述;
- 计算子问题的解,保存子问题的解;
- 计算原问题的解。
动态规划算法通常适用于寻找具有最优子结构的问题,即原问题的最优解可以由子问题的最优解推导得到。例如,常见的背包问题、最长递增子序列问题、编辑距离问题、最短路径问题等都可以使用动态规划算法解决。
需要注意的是,在实际使用动态规划算法时,需要注意空间复杂度的问题。因为动态规划算法通常需要使用数组来保存中间状态,而数组的空间复杂度可能非常高。因此,为了避免浪费过多的空间,需要寻找合适的数据结构和算法优化方式,以尽可能减少空间占用。
下面以 “最长递增子序列” 问题为例,讲解一下动态规划算法的思路和实现。
问题描述:
给定一个长度为 $n$ 的序列 $A$,找出一个最长的子序列(不一定要连续) $B$,使得 $B$ 中元素严格递增,并返回该最长递增子序列的长度。
解题思路:
该问题可采用动态规划算法求解。具体而言,假设 $dp[i]$ 表示以 $A[i]$ 结尾的最长递增子序列的长度。则有状态转移方程:
$$
dp[i] = \max_{j=0}^{i-1}\begin{cases}
dp[j] + 1, & A[j] < A[i] \
1, & A[j] \geq A[i]
\end{cases}
$$
其中,$1 \leq i \leq n$,表示以 $A[i]$ 结尾的递增子序列长度的最大值。从对状态转移方程的分析中,可以发现状态 $dp[i]$ 可以由前面的状态 $dp[j]$ 转移而来,因此该问题可以用动态规划算法求解。具体而言,我们从 $1$ 到 $n$ 枚举 $i$,并在枚举时使用内层循环查找 $A[j]$,更新 $dp[i]$ 值,最终返回 $\max_{i=1}^n dp[i]$ 即为最长递增子序列的长度。
Python 实现代码如下:
def length_of_lis(nums: List[int]) -> int:
if not nums:
return 0
n = len(nums)
dp = [1] * n
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
时间复杂度为 $O(n^2)$,其中 $n$ 表示序列 $A$ 的长度。
需要注意的是,该算法并不能给出最长递增子序列 $B$ 中的具体元素。如果需要找到具体的子序列,可以在动态规划过程中,使用一个额外的数组,记录以 $A[i]$ 结尾的最长递增子序列中,上一个元素的下标 $prev[i]$,当得到最长递增子序列长度后,倒序查找 $prev$ 数组,即可得到最长递增子序列 $B$ 。
下面以 “硬币找零” 问题为例,来演示如何使用 Python 实现动态规划算法。
问题描述:
假设有不同币值的硬币若干枚,包括面值分别为 $d_1,d_2,…,d_n$。现在需要用这些硬币组合出总价值为 $C$ 的零钱,问最少需要多少枚硬币。
解题思路:
这是一道经典的动态规划问题,以找零 $C$ 为目标,每种硬币面值可以看做是一个子问题。具体而言,我们将硬币从小到大排序,依次考虑每种面额的硬币,使用 $dp[i]$ 表示组合出总价值为 $i$ 的零钱,需要的最少硬币数量,则有状态转移方程:
$$ dp[i] = \min_{j=1}^{n}{ dp[i-d_j] + 1 } \quad (d_j \leq i \leq C) $$
其中 $1 \leq i \leq C$,表示组合出总价值为 $i$ 的零钱,所需的最少硬币数量。在状态转移方程中,我们枚举每种硬币面额 $d_j$,并计算出使用该硬币时所需的最少硬币数量 $dp[i-d_j]+1$,然后取最小值即可更新 $dp[i]$。
Python 实现代码如下:
def coin_change(coins: List[int], amount: int) -> int:
# dp[i] 表示组合出总价值为 i 的零钱,需要的最少硬币数量
INF = amount + 1
dp = [INF] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for j in range(len(coins)):
if coins[j] <= i:
dp[i] = min(dp[i], dp[i - coins[j]] + 1)
return dp[amount] if dp[amount] < INF else -1
其中,dp[0] = 0
用于初始化 $dp$ 数组,表示组合出总价值为 $0$ 的零钱,需要的最少硬币数量为 $0$。当无法组合出总价值为 $C$ 的零钱时,返回 $-1$。
该算法时间复杂度为 $O(nm)$,其中 $n$ 表示硬币种类数量,$m$ 表示找零的金额。
下面以 “最长公共子序列” 问题为例,演示一下如何使用 Python 实现动态规划算法。
问题描述:
现有两个序列 $A$ 和 $B$,求它们的最长公共子序列。最长公共子序列是指两个序列都包含的最长的子序列。
解题思路:
该问题可采用动态规划算法求解。具体而言,假设 dp[i][j] 表示以 A[i-1] 和 B[j-1] 结尾的最长公共子序列的长度,则有状态转移方程:
$$ dp[i][j] = \begin{cases} 0, & i=0 \textrm{ or } j=0 \ dp[i-1][j-1]+1, & A[i-1] = B[j-1] \ \max (dp[i-1][j],dp[i][j-1]), & A[i-1] \neq B[j-1] \ \end{cases} $$
其中,$1 \le i \le len(A)$,$1 \le j \le len(B)$。在状态转移方程中,$A[i-1]$ 表示原序列 $A$ 的第 $i$ 个元素,$B[j-1]$ 表示原序列 $B$ 的第 $j$ 个元素,表示当前处理到两个序列的第 $i$ 个元素和第 $j$ 个元素。如果 $A[i-1]$ 等于 $B[j-1]$,则说明当前处理到的这个元素必然在最长公共子序列中。反之,如果 $A[i-1]$ 不等于 $B[j-1]$,则说明当前的元素不在最长公共子序列中,需要丢弃其中一个序列的这个元素,使问题转化为原问题的子问题。
Python 实现代码如下:
def longest_common_subsequence(text1: str, text2: str) -> int:
n, m = len(text1), len(text2)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
if text1[i - 1] == text2[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[n][m]
时间复杂度为 $O(n m)$,其中 $n$ 和 $m$ 分别为两个序列的长度。
需要注意的是,动态规划算法虽然能够有效地解决该问题,但是空间复杂度较高。上述实现使用了一个二维数组 $dp$ 来保存中间结果,因此空间复杂度为 $O(nm)$。如果需要优化空间复杂度,可以考虑使用滚动数组等技巧。
单调队列
单调队列是一种特殊的队列,其插入和删除的元素具有单调性,常用于求解滑动窗口最大/最小值等问题。单调队列主要包括两个基本操作:入队和出队。
入队操作:
当一个元素需要入队时,首先需要将其与队列尾部的元素做比较。如果比队列尾部的元素大(或小,根据问题而定),则该元素能够对队列尾部保存的信息产生影响(如影响滑动窗口的最大/最小值),因此需要将对队列尾元素不再有用的元素出队。直到队列尾部元素大于等于(或小于等于)当前元素,当前元素入队。
出队操作:
当一个元素需要出队时,如果队头元素等于当前滑动窗口中的最大值(或最小值),则出队。否则,该元素已经不对队列所表示的信息产生影响,不需要考虑它。
单调队列常用于求解滑动窗口问题,例如:给定一个长度为 $n$ 的整数数组 $nums$ 和一个大小为 $k$ 的滑动窗口,移动窗口时返回每个窗口内的最大值。
解题思路:
维护一个单调递减的队列。该队列的队头元素即为窗口内的最大值。
具体而言,从左到右枚举数组 $nums$ 中每个元素 $nums[i]$,其对应的滑动窗口为 $[i-k+1, i]$。为了维护这个滑动窗口的最大值,需要用一个单调队列来保存滑动窗口中的元素。
当一个元素 $nums[i]$ 加入滑动窗口时,从队列尾将小于等于 $nums[i]$ 的元素全部删除,并将 $nums[i]$ 加入队列。如果队头元素的下标不在滑动窗口的范围内,则该队头元素出队。
Python 实现代码如下:
from collections import deque
def max_sliding_window(nums: List[int], k: int) -> List[int]:
n = len(nums)
if n * k == 0:
return []
if k == 1:
return nums
q = deque()
res = []
for i in range(n):
while q and nums[i] > nums[q[-1]]:
q.pop()
q.append(i)
if i - k >= q[0]:
q.popleft()
if i >= k - 1:
res.append(nums[q[0]])
return res
该算法时间复杂度为 $O(n)$,其中 $n$ 表示数组 $nums$ 的长度。因为每个元素最多会进队一次、出队一次,所以遍历数组 $nums$ 的时间复杂度为 $O(n)$。每个元素最多会进队、出队一次,所以队列操作的时间复杂度同样为 $O(n)$。
需要注意的是,单调队列的应用场景不限于滑动窗口问题,也可以用于其他需要维护某些特定信息的场景。
下面以 “滑动窗口的最大值” 问题为例,讲解如何使用 Python 实现单调队列的应用。
问题描述:
给定一个长度为 $n$ 的整数数组 $nums$,以及一个整数 $k$,移动一个长度为 $k$ 的窗口,求每个滑动窗口的最大值。
解题思路:
维护一个单调递减的队列,该队列的队头元素即为窗口内的最大值。
具体而言,我们首先使用双端队列 $deque$ 来实现单调队列。从左到右枚举数组 $nums$ 中每个元素 $nums[i]$,其对应的滑动窗口为 $[i-k+1, i]$。为了维护这个滑动窗口的最大值,需要用单调队列来保存滑动窗口中的元素。对于每个元素 $nums[i]$,我们需要将队列中所有小于 $nums[i]$ 的元素都弹出,然后将 $nums[i]$ 加入队列。此时,队列的队头元素即为滑动窗口中的最大值。如果队头元素的下标不在滑动窗口的范围内,则该队头元素出队。
Python 实现代码如下:
from collections import deque
def max_sliding_window(nums: List[int], k: int) -> List[int]:
n = len(nums)
if n * k == 0:
return []
if k == 1:
return nums
q = deque()
res = []
for i in range(n):
while q and nums[i] > nums[q[-1]]:
q.pop()
q.append(i)
if i - k >= q[0]:
q.popleft()
if i >= k - 1:
res.append(nums[q[0]])
return res
时间复杂度为 $O(n)$,其中 $n$ 表示数组 $nums$ 的长度。因为每个元素最多会进队一次、出队一次,所以遍历数组 $nums$ 的时间复杂度为 $O(n)$。每个元素最多会进队、出队一次,所以队列操作的时间复杂度同样为 $O(n)$。
需要注意的是,单调队列的应用场景不限于滑动窗口问题,也可以用于其他需要维护某些特定信息的场景。
以下是 Go 语言实现 “滑动窗口的最大值” 问题的代码示例:
func maxSlidingWindow(nums []int, k int) []int {
n := len(nums)
if n * k == 0 {
return []int{}
}
if k == 1 {
return nums
}
q := make([]int, 0)
res := make([]int, 0)
for i := 0; i < n; i++ {
// 删除队列中所有小于 nums[i] 的元素
for len(q) > 0 && nums[i] > nums[q[len(q)-1]] {
q = q[:len(q)-1]
}
q = append(q, i)
// 当窗口超出 k 时,删除队头元素
if i - k >= q[0] {
q = q[1:]
}
// 更新窗口最大值
if i >= k - 1 {
res = append(res, nums[q[0]])
}
}
return res
}
该算法的时间复杂度为 $O(n)$,其中 $n$ 表示数组 $nums$ 的长度。
并查集
并查集是一种数据结构,用于维护一个集合分割和集合合并的问题。并查集的元素被分为不同的集合,每个集合可以根据需要合并。
并查集主要支持两种操作:查找(find)和合并(union)。查找操作确定一个元素属于哪个集合;合并操作将两个集合合并为一个集合。并查集的数据结构通常是一个数组,其中每个元素有一个指向其父节点的指针,自己是自己所在集合中的代表元素。
查找操作通过不断追溯节点的父亲指针,找到该元素在集合中的代表元素,即根节点。根据每个集合的代表元素,可以判断两个元素是否在同一个集合中。合并操作要合并两个集合,需要找到每个集合的代表元素的根节点,然后将其中一个根节点指向另一个根节点。
并查集的应用非常广泛,例如用于连接问题、数学问题等等。并查集具有快速的查找和合并操作,尤其适用于动态维护集合分割的场景。
好的,让我详细介绍一下并查集。
并查集是一种非常简单且高效的数据结构,主要用于处理一些动态连通性问题。动态连通性问题是指一个集合中的元素可能不断地被添加或删除。在这种情况下,如果需要快速回答“两个元素是否连通”的问题,就可以运用并查集来解决。
在并查集中,每个元素都是一个节点,每个节点一开始都被认为是一个单独的集合。并查集维护的是一个“森林”,每棵树代表一个集合。在一开始,每个节点都是它所在的树的根节点。
并查集主要支持两种操作:查找和合并。查找操作查找某个节点所在的集合,而合并操作将两个集合合并成一个集合。
具体来说,查找操作就是一直从一个节点向上“查找”,直到找到这个节点所在树的根节点,这个根节点即为该节点所在集合的“代表”。在这个过程中,可以使用路径压缩的方法来加速查找操作,即将路径上的所有节点都指向根节点,使得下次查找时可以更快地找到根节点。
合并操作则是将两个节点所在的集合合并成一个集合,具体地,找到两个集合的代表节点,然后将其中一个集合的代表节点的指针指向另一个集合的代表节点。
在合并时,为了保证并查集的性能,应该选择按秩合并,即把高度小的集合作为子树将其加到高度大的集合的子树下。这样能够避免树的深度过大,使得查找和合并操作的复杂度都能控制在 O(log n) 以内。
总的来说,使用并查集可以非常方便地处理一些动态连通性问题,例如广泛应用的最小生成树算法 Kruskal ,以及一些图论算法中的连通性问题。并查集的实现也非常简单,只需要维护节点的父亲指针以及集合的大小和秩即可。
下面是一个简单的 Python 实现并查集的例子:
class UnionFind:
def __init__(self, n):
# 父节点指针
self.parent = [i for i in range(n)]
# 树的大小(秩)
self.rank = [0] * n
def find(self, x):
# 查找根节点
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
# 合并两个节点所在的集合
x_root, y_root = self.find(x), self.find(y)
if x_root != y_root:
# 按秩合并
if self.rank[x_root] < self.rank[y_root]:
self.parent[x_root] = y_root
elif self.rank[x_root] > self.rank[y_root]:
self.parent[y_root] = x_root
else:
self.parent[y_root] = x_root
self.rank[x_root] += 1
# 使用示例
uf = UnionFind(5)
uf.union(0, 2)
uf.union(1, 3)
uf.union(2, 4)
print(uf.parent) # [0, 1, 0, 2, 0]
在上面的代码中,初始化时设置了每个节点的父节点为自己,秩都为 0。在查找操作中,使用路径压缩来加速查找。在合并操作中,按秩合并,即将高度小的树作为子树加入到高度大的树中。最后,对于任意两个节点 x 和 y,可以通过判断它们的代表节点是否相同来判断它们是否在同一个集合中。
这是一个比较简单的并查集实现,您可以根据需要对它进行优化,例如加入路径分裂和路径减少等技巧,以进一步提高性能。
好的,下面给出一个简单的 Python 实现并查集应用实例——LeetCode 上的一道题目:
题目描述
给定一个网格,每个格子要么是空格子(用 ‘.’ 表示),要么是障碍物(用 ‘#’ 表示),每个障碍物都可以被一次消除(变成一个空格子)。
问给定的网格是否可以经过一些消除操作后,让起始点(左上角)到达结束点(右下角)。在消除障碍物时,只能消除连通的所有障碍物。
注:起始点和结束点一定为空格子。
示例
输入:
[
['.', '.', '.', '.', '.'],
['.', '#', '#', '#', '.'],
['.', '.', '.', '.', '.'],
['.', '.', '#', '.', '.']
]
输出: true
解题思路
这是一道典型的使用并查集解决连通性问题的题目。我们可以将每个被障碍物包围的区域看做一个连通的集合,这些集合可以被消除,从而使得起始点到达结束点。因此,我们可以先将障碍物转化成一个集合,最后判断起始点与结束点是否在同一集合中即可。
具体来说,我们可以遍历每个障碍物所在的位置,将其与上、下、左、右相邻的障碍物合并为一个集合,最后判断起始点和结束点是否在同一集合中即可。
Python 代码实现
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.size = [1] * n
self.count = n
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x, y):
root_x, root_y = self.find(x), self.find(y)
if root_x == root_y:
return
if self.size[root_x] < self.size[root_y]:
root_x, root_y = root_y, root_x
self.parent[root_y] = root_x
self.size[root_x] += self.size[root_y]
self.count -= 1
def get_count(self):
return self.count
class Solution:
def __init__(self):
self.dirs = [(0, -1), (0, 1), (-1, 0), (1, 0)]
def index(self, x, y, n, m):
return x * m + y
def in_area(self, x, y, n, m):
return x >= 0 and x < n and y >= 0 and y < m
def validate(self, grid):
n, m = len(grid), len(grid[0])
count = 0
uf = UnionFind(n * m)
for i in range(n):
for j in range(m):
if grid[i][j] == '.':
count += 1
if grid[i][j] == '#':
for (dx, dy) in self.dirs:
new_x, new_y = i + dx, j + dy
if self.in_area(new_x, new_y, n, m) and grid[new_x][new_y] == '#':
uf.union(self.index(i, j, n, m), self.index(new_x, new_y, n, m))
return uf.get_count() == count + 2
def uniquePathsIII(self, grid: List[List[int]]) -> int:
if not grid:
return 0
if not self.validate(grid):
return 0
n, m = len(grid), len(grid[0])
start, end = None, None
for i in range(n):
for j in range(m):
if grid[i][j] == 1:
start = self.index(i, j, n, m)
elif grid[i][j] == 2:
end = self.index(i, j, n, m)
uf = UnionFind(n * m)
for i in range(n):
for j in range(m):
if grid[i][j] == '.':
for (dx, dy) in self.dirs:
new_x, new_y = i
倍增法
倍增法是一种高效计算指数函数的算法,也称为“快速幂”算法,可以用于计算 x 的 n 次方(x^n)。
常规方法计算 x^n 的时间复杂度是 O(n),如果 n 很大,计算的时间将非常长。而倍增算法则可以将计算时间降至 O(log n)。
工作原理是将 n 拆分成若干个 2 的幂次方的和,然后每次计算幂次方时,都将指数折半。例如,计算 x^9 时,可以拆分为 x^8 * x,然后计算 x^8 时再将其拆分为 x^4 * x^4,直到将指数变为 1 为止。
具体来说,可以使用一个变量 res 来存储累计结果,一个变量 base 来存储底数。每一次将指数 n 右移一位(相当于将 n 折半),如果 n 的最低位是 1(即 n&1==1),则将结果乘上当前的底数 base。然后让底数自乘一次,即变为底数的平方。这样循环进行,直到 n 变为 0。
算法优点是计算速度快,并且可以应用于大数的幂的计算。
下面是 Python 代码实现:
def power(base, n):
res = 1
while n > 0:
if n & 1 == 1:
res *= base
base *= base
n >>= 1
return res
在这个实现中,函数 power 用来计算底数 base 的 n 次方。在 while 循环中,不断将指数 n 折半,同时将底数自乘,直到指数变为 0 或者底数是变成了求次方的结果。如果每次把 n 视为二进制数,每个 1 位代表的数都是相同的,所以循环次数为 O(log n),所以这个算法的复杂度是 O(log n),是一种十分高效的算法。
下面是一个简单的 Python 实现,演示如何使用倍增法计算矩阵的 n 次幂,其中矩阵采用二维数组表示:
def matrix_power(mat, n):
"""
计算矩阵 mat 的 n 次幂
"""
m = len(mat)
res = [[0] * m for _ in range(m)]
for i in range(m):
res[i][i] = 1 # 初始化结果矩阵为单位矩阵
while n > 0:
if n & 1 == 1:
res = matrix_multiply(res, mat) # 将结果与当前矩阵相乘
mat = matrix_multiply(mat, mat) # 将矩阵自乘一次
n >>= 1
return res
def matrix_multiply(mat1, mat2):
"""
计算两个矩阵的乘积
"""
m = len(mat1)
n = len(mat2[0])
res = [[0] * n for _ in range(m)]
for i in range(m):
for j in range(n):
for k in range(len(mat2)):
res[i][j] += mat1[i][k] * mat2[k][j]
return res
# 测试
mat = [[1, 2], [3, 4]]
res = matrix_power(mat, 3)
for row in res:
print(row)
# 输出:[[37, 54], [81, 118]]
在上面的实现中,函数 matrix_power()
用来计算矩阵的 n 次幂,函数 matrix_multiply()
用来计算两个矩阵的乘积。在 matrix_power()
中,我们首先初始化结果矩阵为单位矩阵,然后每次将指数 n 折半,直到指数变为 0,过程中不断自乘原始矩阵,并将自乘后的结果累加到结果矩阵中,最终返回结果矩阵。
这是一个简单的例子,类似的方法也可以用于许多其他计算中。例如,我们可以使用倍增法计算斐波那契数列,或者快速计算多项式求和等。这样做的好处是,这些计算过程都可以通过矩阵乘法来实现,大大提高了计算的效率。
矩阵是一个矩形的数学对象,其中按照一定顺序排列了一组数。在数学中,矩阵一般表示成大写字母,例如 A、B、C 等等。
矩阵的主要特点是:矩阵的行和列可以进行加法和乘法运算。矩阵的加法和乘法是定义在矩阵之间的二元运算,具有封闭性、结合律、分配律和满足单位元素的性质。
一般来说,矩阵是由一个矩形的元素按照一定的顺序排列得到的。矩阵的元素可以是实数、复数等等。矩阵有时候也被称为数组或矩阵数组。
在计算机科学中,矩阵经常被用来表示线性变换、矩阵求逆、矩阵求行列式、矩阵对角化等等。因为矩阵可以将多维数据组织为二维形式,所以在计算机图形学、机器学习等领域中也有广泛的应用。
矩阵可以看作是一个二维数组,其中的每个元素可以是实数、复数或其他类型的数值。
一般地,一个 m×n 的矩阵 A 可以表示为:
[ a11 a12 ... a1n ] [ a21 a22 ... a2n ] [ ... ... ... ... ] [ am1 am2 ... amn ]
其中,aij 表示第 i 行第 j 列的元素。
矩阵有多种运算方式,下面列出并解释几个常见的矩阵运算:
- 矩阵加法
令 A、B 分别为同型矩阵(即具有相同的行和列数),则矩阵 A+B 由 A、B 对应元素相加得到,即:
(A+B)ij = Aij + Bij
- 矩阵数乘
令 A 为矩阵,k 为实数或复数,则 kA 由 A 的每个元素乘以数 k 得到,即:
(kA)ij = k * Aij
- 矩阵乘法
令 A 为 m×n 的矩阵,B 为 n×p 的矩阵,则 A×B 的第 i 行第 j 列的元素为:
(A × B)ij = Σk=1~n (Aik × Bkj)
其中,Σk=1~n 表示对 k 从 1 到 n 进行求和。也就是说,A 的第 i 行与 B 的第 j 列对应位置的元素分别相乘,然后将它们的乘积加起来,作为 A×B 的第 i 行第 j 列的元素。
需要注意的是,矩阵乘法要求左边矩阵的列数等于右边矩阵的行数。
- 转置
令 A 为矩阵,则 A 的转置记为 AT,由 A 的行与列互换而得。例如,如果 A 是一个 m×n 的矩阵,则 AT 是一个 n×m 的矩阵,满足:
(AT)ij = Aji
矩阵在线性代数中有着广泛的应用,可以用来求矩阵的逆、矩阵的秩、求特征值等一系列问题。同时,在计算机科学领域中,矩阵用来表示图像、视频和音频信号等多维数据具有很大的作用。
矩阵在很多领域都有广泛的应用,下面列举几个比较常见的应用实例:
- 线性方程组求解
矩阵可以用来求解线性方程组。例如,对于以下的线性方程组:
x + 2y + 3z = 6 4x + 5y + 6z = 15 7x + 8y + 10z = 24
我们可以将其用矩阵表示:
[ 1 2 3 ][ x ] [ 6 ] [ 4 5 6 ][ y ] = [ 15 ] [ 7 8 10 ][ z ] [ 24 ]
然后,通过高斯消元法或矩阵求逆的方法求出变量的值。
- 图像处理
在图像处理中,矩阵被广泛应用于颜色空间变换、亮度调整、滤波、边缘检测、图像变形等各种操作。例如,我们可以用一个 3×3 的矩阵表示一个卷积核,通过卷积核对图像进行卷积运算,从而实现各种滤波操作。
- 机器学习
在机器学习中,矩阵被广泛用来表示数据,例如图像数据、声音数据等等。矩阵可以用来描述特征,描述样本之间的相似度等信息,从而方便机器学习算法进行处理。例如,矩阵分解就是一种常见的机器学习技术,可以用来推荐系统、文本分类、网络分析等。矩阵被广泛地用于表示特征向量、数据样本和模型参数等。矩阵可以提供一种方便和高效的编程工具,以应对高维数据和复杂模型的挑战。例如,线性回归、PCA、SVM、神经网络等机器学习算法都需要用到矩阵和向量。
- 统计学
在统计学中,矩阵被广泛应用于矩阵分析、多元统计分析、因子分析等领域。矩阵可以用来表示数据集,将多个变量或者多个样本用一个矩阵进行描述,方便后续的统计分析和建模。
- 三维计算机图形学
在三维计算机图形学中,矩阵广泛地被应用于三维变换、透视投影、动画效果的计算等等。矩阵可以用来表示三维空间中的点、向量和变换矩阵,以及用于对三维物体进行变换和投影,从而实现各种效果。
- 信号处理
在信号处理中,矩阵广泛被用来表示时间序列、频谱等信号的数学模型和变换。例如,离散傅里叶变换可以通过矩阵运算的形式进行计算,从而实现在频域中进行信号分析和处理。
- 数值计算
在科学计算中,矩阵是求解线性方程组、差分方程、微分方程等数值计算问题的重要工具。例如,迭代法、LU分解、QR分解、Cholesky 分解等算法都是基于矩阵的分解和运算,用于高效求解大规模数值计算问题。
- 量子力学
在量子力学中,矩阵广泛地被用来描述量子态、运算符和观测量等概念。矩阵可以用来计算量子态的变换和演化,从而可以预测微观系统的行为和性质。
总之,矩阵作为一种数学工具,具有许多重要的应用,涉及许多不同的领域和学科。无论是工程、科学、计算机科学、统计学等都需要用到矩阵。
记忆化搜索
记忆化搜索(Memoization)是一种动态规划优化技术,基于递归和备忘录思想实现。在计算机科学中,它是指当递归算法被重复调用时,对于相同的输入值,在第一次计算后将返回值保存下来,以便以后调用直接取用,避免重复计算。
当问题的解可以通过不断分解为子问题求解的方式得到时,使用记忆化搜索可以大大提高算法效率。在递归过程中,任何计算过的子问题的结果都被存储,如果下次要求解同一个子问题,就可以直接使用已有的结果,从而避免了重复计算。
记忆化搜索一般需要使用一个记录数组或哈希表来保存结果。在每次递归调用时,首先检查记录数组中是否已经存在当前输入值的结果。如果有就直接返回结果,否则就继续递归求解,并将求解结果保存在记录数组中。
记忆化搜索在很多算法问题中都非常有用,比如最长公共子序列(LCS)、背包问题等。使用它可以避免递归树中大量的重叠子问题被重复求解,可以让程序的时间复杂度从指数级别下降到多项式级别。
需要注意的是,需要合理处理递归调用中的依赖关系,确保每个子问题只被计算一次,避免死循环或者计算错误。同时,需要将记录数组定义为足够大,以避免溢出操作系统的内存。记忆化搜索是一种很强大的算法优化技术,可以大大缩短程序的运行时间,减少不必要的计算,提高算法的效率。
下面以斐波那契数列为例,介绍如何使用 Python 语言实现记忆化搜索。
斐波那契数列是一个递归定义的数列,它的第 n 项是前两项的和,即:
F(0) = 0
F(1) = 1
F(n) = F(n-1) + F(n-2) (n >= 2)
首先,我们可以使用简单的递归来实现斐波那契数列的计算:
def fib(n):
if n < 2:
return n
return fib(n-1) + fib(n-2)
虽然这个递归函数能够正确计算斐波那契数列,但是它的效率非常低。例如,当 n=40 时,计算会非常缓慢。
现在,我们可以使用记忆化搜索来改进这个递归函数。具体地,我们可以使用一个记录数组 memo 来保存已经计算过的斐波那契数列值。在递归调用之前,首先检查记录数组中是否已经存在当前输入值的结果,如果有就直接返回,否则就继续递归求解,并将求解结果保存在记录数组中。这个过程可以使用 Python 的装饰器来实现,如下所示:
def memoize(func):
memo = {}
def wrapper(n):
if n not in memo:
memo[n] = func(n)
return memo[n]
return wrapper
@memoize
def fib(n):
if n < 2:
return n
return fib(n-1) + fib(n-2)
在这个改进后的版本中,如果输入的 n 值已经在记录数组 memo 中存在了,就直接返回对应的结果,否则就计算新的结果并保存在 memo 中。这里使用了 Python 的函数装饰器语法来简化代码实现。
相比简单的递归版本,使用记忆化搜索后的斐波那契数列函数能够更快地计算出数列的值,而且对于大规模计算来说更加高效。使用记忆化搜索能够使递归函数的时间复杂度从指数级别下降到线性对数级别,从而极大提高程序的效率。
下面以计算斐波那契数列为例,介绍记忆化搜索的应用实例。
斐波那契数列是一个非常典型的递归计算问题,它的定义如下:
F(0) = 0
F(1) = 1
F(N) = F(N-1) + F(N-2),其中 N > 1
这个数列可以用递归方式来计算,但是递归的计算复杂度比较高,因此我们可以使用记忆化搜索来进行优化。具体来说,我们可以使用一个字典来记录已经求解过的斐波那契数列的值,下面是 Python 代码实现:
def fib(N: int) -> int:
# 定义一个字典存储已经计算过的数值
memo = {}
# 递归函数定义
def helper(n: int) -> int:
# 如果 n 已经计算过,直接从字典中返回对应的值
if n in memo:
return memo[n]
# 计算并缓存数值
if n < 2:
ans = n
else:
ans = helper(n-1) + helper(n-2)
memo[n] = ans
return ans
# 调用递归函数计算斐波那契数列
return helper(N)
在这个实现中,如果 n 已经存在于 memo 中,就直接返回对应数值;否则,计算出数值后存储到 memo 中。通过这种方式,我们可以避免大量重复计算,从而将计算时间从指数级别降低到了线性对数级别,大大提高计算效率。
记忆化搜索不仅在计算斐波那契数列这样的简单问题中有应用,也可以应用于更复杂的算法中,对算法的效率和性能进行优化。
数论
数论是研究整数及其性质的数学分支,主要研究整数的基本性质、整数运算规律、整数的质数分解等问题。数论是数学的一个古老而重要的分支,在计算机科学、密码学、加密和安全领域都有着广泛的应用。
数论的核心是对整数的因数分解和关于整数之间的最大公约数、最小公倍数等性质进行研究。一些重要的数论概念和定理包括:
-
质数:只有1和自己两个因数的正整数,比如2、3、5、7、11等。
-
素数分解定理(又称质因数分解定理):每个大于1的自然数,要么是一质数,要么是几个质数相乘的积。
-
最大公约数:两个整数的公共因数中最大的那个数,用符号gcd(a,b)表示。
-
最小公倍数:两个整数的公共倍数中最小的那个数,用符号lcm(a,b)表示。
-
模运算:用一个数除以另一个数后的余数。比如:9 mod 4 等于1。
-
费马小定理:如果p是一个质数,a是任意正整数,那么a的p次方减去a必能被p整除。
-
欧拉函数:小于n的正整数中与n互质的数的个数,用phi(n)表示。
-
扩展欧几里得算法:用于求解两个整数间的最大公约数,同时能够计算出满足ax+by=gcd(a,b)的一组整数解。
数论可以应用于很多算法和问题中,例如质数判定、离散对数、RSA密码算法、ElGamal加密算法、Diffie-Hellman密钥协商算法等。数论不仅是计算机科学中的基础学科,同时也是一门具有深厚历史背景的数学学科。
下面以求解最大公约数和最小公倍数为例,介绍使用 Python 语言实现数论相关算法的应用实例。
- 最大公约数
最大公约数是两个数的公共因数中最大的那个数,可以使用辗转相除法求解。Python 中提供了 math 模块中的 gcd 函数来计算两个数的最大公约数。下面是求解最大公约数的 Python 代码实现:
import math
a = 42
b = 56
# 使用 math 模块中的 gcd 函数求解最大公约数
gcd = math.gcd(a, b)
print("最大公约数为:", gcd)
- 最小公倍数
最小公倍数是两个数的公共倍数中最小的那个数,可以通过求解两个数的最大公约数来计算,具体计算公式为:
lcm(a, b) = a * b / gcd(a, b)
下面是求解最小公倍数的 Python 代码实现:
import math
a = 42
b = 56
# 使用 math 模块中的 gcd 函数求解最大公约数
gcd = math.gcd(a, b)
# 计算最小公倍数
lcm = a * b // gcd
print("最小公倍数为:", lcm)
在这个实现中,我们首先计算出两个数的最大公约数,然后使用上面的公式计算出最小公倍数。需要注意的是,在 Python 3.x 中算术运算符 / 表示浮点数除法,// 表示整数除法。
除了使用 math 模块外,我们还可以使用更简单的 Euclid 算法来实现最大公约数的计算,这个算法也是辗转相除法的一种变种。下面是使用 Euclid 算法实现最大公约数的 Python 代码:
def gcd(a, b):
if b == 0:
return a
else:
return gcd(b, a % b)
a = 42
b = 56
# 使用 Euclid 算法求解最大公约数
gcd = gcd(a, b)
print("最大公约数为:", gcd)
在这个实现中,我们定义了一个 gcd 函数来计算两个数的最大公约数。如果 b 等于0,那么最大公约数就等于 a;否则,我们将 a 对 b 取模,然后递归计算 gcd(b, a%b) 来实现计算。
这些例子展示了 Python 语言在数论方面应用的一些实例,它们可以方便地计算数学问题,并且可以在日常编程中使用。
下面以素数判定为例,介绍使用 Python 语言实现数论相关算法的应用实例。
素数是指只能被1和它本身整除的正整数,包括2、3、5、7等。在数字领域,素数有着广泛的应用,例如加密算法、哈希算法等。下面是使用 Python 语言实现素数判定的代码:
def is_prime(n: int) -> bool:
if n <= 1:
return False
for i in range(2, int(n**0.5) + 1):
if n % i == 0:
return False
return True
# 测试代码
print(is_prime(2)) # True
print(is_prime(3)) # True
print(is_prime(4)) # False
这个实现中,我们首先判断 n 是否小于等于 1,如果是,就直接返回 False。否则,我们从 2 到 ${\sqrt n}$ 进行遍历,依次判断 n 是否能够被当前数字整除。如果遍历完所有可能的因数后仍然没有找到约数,那么就判定 n 为素数,返回 True;否则,返回 False。
在实际运用中,判断一个更大的数是否为素数时,我们可以使用更高效的质数测试算法,例如 Miller-Rabin 算法,这种算法在实际应用中具有很高的效率和准确率。
总之,Python 语言在数字领域中的应用非常广泛,包括算数、代数、几何、拓扑、数论等方面。同时,Python 还提供了很多优秀的数值计算库,例如 NumPy、SciPy等,方便我们进行高效的数值计算和科学计算。
好的,下面再举一个使用 Python 语言实现判断两个数是否互质的例子。所谓互质是指两个数的最大公约数为1,例如2和3、5和7等。
判断两个数是否互质可以利用它们的最大公约数来实现,若最大公约数为1,则两个数互质。使用 Python 语言实现如下:
def are_coprime(a: int, b: int) -> bool:
if a == 0 or b == 0:
return False
return (gcd(a, b) == 1)
# 求最大公约数
def gcd(a: int, b: int) -> int:
if b == 0:
return a
return gcd(b, a % b)
# 测试代码
print(are_coprime(2, 3)) # True
print(are_coprime(4, 6)) # False
这个实现中,我们定义了两个函数,are_coprime 用来判断两个数是否互质,gcd 用来求两个数的最大公约数。在 are_coprime 函数中,我们首先判断 a 和 b 是否有一个等于0,如果相等则不互质,返回 False。否则,我们调用 gcd 函数来求解 a 和 b 的最大公约数,如果它等于1,说明 a 和 b 互质,返回 True,否则返回 False。
这个例子展示了 Python 语言在数论方面的应用,用 Python 解决数论问题可以易如反掌,而且 Python 还提供了众多高效的数值计算和科学计算库,方便我们进行各种数学计算。
环滁皆山也。其西南诸峰,林壑尤美,望之蔚然而深秀者,琅琊也。山行六七里,渐闻水声潺潺,而泻出于两峰之间者,酿泉也。峰回路转,有亭翼然临于泉上者,醉翁亭也。作亭者谁?山之僧智仙也。名之者谁?太守自谓也。太守与客来饮于此,饮少辄醉,而年又最高,故自号曰醉翁也。醉翁之意不在酒,在乎山水之间也。山水之乐,得之心而寓之酒也。
若夫日出而林霏开,云归而岩穴暝,晦明变化者,山间之朝暮也。野芳发而幽香,佳木秀而繁阴,风霜高洁,水落而石出者,山间之四时也。朝而往,暮而归,四时之景不同,而乐亦无穷也。
至于负者歌于途,行者休于树,前者呼,后者应,伛偻提携,往来而不绝者,滁人游也。临溪而渔,溪深而鱼肥。酿泉为酒,泉香而酒洌;山肴野蔌,杂然而前陈者,太守宴也。宴酣之乐,非丝非竹,射者中,弈者胜,觥筹交错,起坐而喧哗者,众宾欢也。苍颜白发,颓然乎其间者,太守醉也。
已而夕阳在山,人影散乱,太守归而宾客从也。树林阴翳,鸣声上下,游人去而禽鸟乐也。然而禽鸟知山林之乐,而不知人之乐;人知从太守游而乐,而不知太守之乐其乐也。醉能同其乐,醒能述以文者,太守也。太守谓谁?庐陵欧阳修也。