程序=数据结构+算法。
数据结构指数据在计算机中的组织形式,也是计算机能解决现实问题的基础。此步解决将现实数据以何种形式和结构存储到计算机中,具体有数组、列表、树和图等,不同的数据结构有不同的特性,使用时结合需要和特性进行选择。
算法则是对数据结构的具体操作方法,将数据按照一定规则进行计算,获得想要的结果,本章主要介绍算法中的一些基础方法。
递归法
我们都在现实生活中遇到这样的场景,我们在两个镜子中间,对面的镜子有背后镜子的映像,而背后镜子又有面对着的镜子的映像,不断重复下去;另外一个简短的例子是国徽,国徽里有天安门,天安门里有国徽,计算机中把这种调用自身函数方法的算法称作递归。
使用条件
使用递归的条件为:
1,问题具有自相似性。大问题可以转化为多次相同的小问题求解。
2,有限次运算。递归不能无限进行下去,经过有限次运算必须结束。
3,有递归出口。有结束递归的条件来终止递归。
使用场景
适合使用递归的场景为:
1,问题的定义是递归。如斐波那契数列。
2,数据结构是递归。如链表,去掉结点仍为链表。
3,求解方法是递归的。如汉诺塔问题,基于过程复杂,但可拆解为多次子问题的求解。
递归模型
递归的一般结构如下,以求阶乘为例:
1,递归出口,比如当n==1时返回具体的值,如return 1
;
2,调用自身的递归函数的递归体,修改参数,如return fun(n-1)*n
执行时调用程序相同,但输入的参数数据有变化,有关递归的数学基础可见具体数学,主要是递归和非递归的转化,递归的含义,数学上严谨证明过,一切问题都可以用递归解决。
示例—n皇后问题
算法思想理解起来都好像很简单,但自己实现起来还是会被很多细节条件,比如边界值,循环次数等卡住,所以还是要实现一遍才能理解,有关n皇后问题具体算法步骤可见皇后问题,这是一名大学在岗教师录的视频课程,讲的十分详细,本文仅讲解代码实现及过程分析。
具体代码如下:
n = 4 # 这里以4皇后问题为例,可以修改这个值为任意正整数n来解决n皇后问题
result = [] # 用于存放所有符合要求的皇后放置方案
# 判断在给定的棋盘状态下,在(row, col)位置放置皇后是否合法
def is_valid(board, row, col):
# 遍历之前已经放置的皇后,判断它们是否与当前位置(row, col)有冲突
for i in range(row):
# 检查列冲突和对角线冲突,对角线通过等腰三角形边长判断
if board[i] == col or abs(i - row) == abs(board[i] - col):
return False
return True
# 递归放置皇后,尝试在每一行放置皇后
def place_queens(board, row):
global result
if row == n: # 如果已经成功放置了n个皇后,也就是所有行都放置好了,将当前方案加入结果列表
result.append(board.copy())
return
for col in range(n):
# 与已放置皇后逐个比较
# 判断列和对角线是否合法,合法则修改棋盘状态,并继续放置下一行皇后
if is_valid(board, row, col):
board[row] = col
place_queens(board, row + 1)
# 初始化棋盘,用长度为n的列表来表示棋盘每一行皇后所在的列位置,初始化为-1表示还未放置
board = [-1] * n
place_queens(board, 0)
# 输出所有结果
for solution in result:
print(solution)
该问题其实更适合剪枝的回溯,或分支限界,因为本题目在只是把循环放到了递归方法中,本质还是暴力,只不过增加了可读性。
分治法
凡治众如治寡,分数是也。——《孙子兵法·兵势篇》
分治的思想最早可追溯到封建制的起源,即分而治之,将大问题分解成小问题,利用集体的力量解决问题;计算机上指将问题分解为多个规模较小的子问题,这些子问题互相独立又与原问题的形式相同,可以使用递归解决,最后将结果合并就得到了大问题的解。
特征和使用条件
1,问题规模缩小后易解决。大问题可能需要复杂计算,如果缩小规模后可以直接解决。
2,可分解为若干规模较小的相同问题。
3,子问题的解可合并为该问题的解。
4,子问题相互独立,不包含公共子问题。
分治模型
1,分解。将大问题分解为规模较小,相互独立,且与原问题形式相同的子问题。
2,解子问题。如果能直接求解的就求解,否则可用递归解各个子问题。
3,合并。子问题的结果合并,获得原问题的解。
代码上通常表示为:
def divide_and_conquer(P):
if p<n:# 问题规模小可以直接解决
return solve(P) # 直接解决之
# 否则将问题拆分为P1、P2...Pk
for i in range(k):
# 递归解决问题
yi=divide_and_conquer(Pi)
return merge(y)
示例1—快速排序
排序算法是计算机中很基础也很常用的算法,上学时站队列从大到小排只要体育委员喊一句口令完成了,但计算机是指令型的,它无法理解这句“从大到小排”,具体怎么比较需要事无巨细地告知它,比较基础的方法是冒泡和选择,分别是比较相邻两个移动到对应位置,和直接选取最大/小值放到相应位置,详细解释可见冒泡排序和选择排序。
快排可以说是目前效率最优的排序算法,核心思想是分治。选择基准后将集合分为两段,小于基准的放在左边,大于基准的放在右边,左右两个区间再递归调用该算法,直到每个子序列内只有一个记录,代码展示如下:
number=[4,1,3,6,8,5,2,7,9]
def divide(start,end):
global number
# 选第一个位置做基准
temp=number[start]
# 双向指针赋值
i=start
j=end
while i!=j:
while j>i and number[j]>=temp:
j=j-1
# 找到第一个小于基准的数,放在基准位置
number[i]=number[j]
while i<j and number[i]<temp:
i=i+1
# 找到第一个大于基准的数,放在前面找到的那个小于基准的数的位置
number[j]=number[i]
# 划分完成后,将基准数放在中间
number[i]=temp
# 返回基准位置作为递归参数
return i
def quick_sort(start,end):
if start<end:
# 一次划分完成后,基准位置的左边和右边分别作为新的递归参数
i=divide(start,end)
quick_sort(start,i-1)
quick_sort(i+1,end)
quick_sort(0,len(number)-1)
print(number)
示例2—集合划分
n个元素的集合,划分为不相交的m个的集合有多少种分法,其中m<n,该问题在数学中有特殊名称—第二类斯特林数。
在该问题中首先分析简单情景,当m=n
时和当n=1
以及m=1
时只有一种分法,m>n
有0种分法,这就是递归的出口。
复杂情景当n较大时可以分为两个情景:
1,n-1个数中已选出m种分法,第n个数要添加进去可以选择任意一种,该情景下的划分个数为m×Striling(n-1,m)
2,n作为划分的单独子集,n-1个元素要组成m-1个子集,该情景下划分个数为Striling(n-1,m-1)
所以得出递推式,Striling(n,m)=m×Striling(n-1,m)+Striling(n-1,m-1)
代码如下:
def Striling(n,m):
if n==1 or n==m or m==1:
return 1
return m*Striling(n-1,m) + Striling(n-1,m-1)
print(Striling(4,2))
示例3—第k小问题
给定一串元素,选出其中第k小的元素,通常我们可以对其直接进行排序,但现在我们学了分治以及快排的思想,可以用更快的方式完成。
基于快排的第k小元素选取,
1.选择基准进行划分
2,如果k的值等于基准位置,说明已经找到了,直接返回基准位置的值
3,k的值大于基准位置,说明元素在基准右边,右边元素递归进行
4,k的值小于基准位置,说明元素在基准桌布,左边元素递归进行
使用这种方法可以有效避免对不必要的部分排序,多快好省地选出第k小元素,而无需关注其他顺序,代码如下:
num=[2,3,5,1,8,4,7,9,6]
def quickselect(s,t,k):
global num
i=s
j=t
if(s<t):
# 选择第一个元素作为基准元素
temp=num[s]
while(i!=j):
# 快排划分过程
while(i<j and num[j]>=temp): j=j-1# 找到第一个小于基准的数
num[i]=num[j] # 小数移到左边覆盖原位置
while(i<j and num[i]<temp): i=i+1 # 找到一个大于基准的数
num[j]=num[i] # 大数移到右边覆盖原位置
num[i]=temp # 中间位置的数赋值为基准
if(k-1==i): return num[i] # 只有一个元素时直接返回
elif(k-1<i): return quickselect(s,i-1,k) # 基准左边继续快排
else: return quickselect(i+1,t,k) # 基准右边继续快排
print(quickselect(0,len(num)-1,5))
到这我对递归有了更深的理解,难点是实现过程和人思维的差异。我们编写代码时是自顶向下的,只规定什么时候停止,而不关注如何执行,计算机则是自底向上的构建过程,一直到递归底层出口获得解后,再构建上层答案,所以递归的关键是出口。
示例4—最大连续子序列和
求一串元素中最大连续子序列的和,同样可以用分治解决,当元素个数为1时直接求解,否则递归拆解,详细步骤如下:
1,元素个数为1时直接返回元素的值
2,计算中间值mid=(n-1)/2
,最大子序列只可能出现在三个部分,左边右边,和横跨中间值的序列
3,左边递归求maxleftsum
4,右边递归求maxrightsum
5,中间部分求maxmidsum
6,返回max(maxleftsum,maxrightsum,maxmidsum)
结构图如下:
代码展示如下:
num=[2,-3,5,1,-8,4,-7,9,99]
def maxsum(left,right):
global num
if left==right:
# 只有一个元素时,直接返回该元素
return num[left]
mid=(left+right)//2# 计算中间位置,向下取整
maxleft=maxsum(left,mid) # 递归调用左半部分的最大子序列和
maxright=maxsum(mid+1,right) # 递归调用右半部分的最大子序列和
# 中间位置的最大子序列和
leftsum=0
rightsum=0
leftbordersum=0
rightbordersum=0
# 计算中间偏左的最大子序列和
i=mid
while i>=left:
leftbordersum+=num[i]
# 求左边序列最大连续字段和
if leftbordersum>leftsum:
leftsum=leftbordersum
i-=1
# 计算中间偏右的最大子序列和
j=mid+1
while j>=right:
rightbordersum+=num[j]
# 求右边序列最大连续字段和
if rightbordersum>rightsum:
rightsum=rightbordersum
j-=1
return max(maxleft,maxright,leftsum+rightsum)
print(maxsum(0,8))
示例5—循环赛日程
有 n = 2 m n=2^m n=2m个选手进行比赛,每位选手与其他n-1名选手都要进行一次比赛,每名选手每天只比一次,循环赛进行n-1天,问如何安排。
直接上结论:
可以看到该表对称分布,左下角与右上角相同,左上角与右下角相同,内部每个小块也遵守该规则,直到两个人比赛时需要进行对角线交换,很明显可以用分治处理。
给定
2
m
2^m
2m个选手安排循环赛的大致思路为:
1,设置出口。当m=0时只有一个选手,只能自己和自己比,m=1时有两个选手,构造对角阵,左上移到右下,右下移到左上。也可以直接对一行或一列初始化,根据初始化的序列计算。
2,分治处理。分别取左右和上下的中点,递归调用自身,计算左上和右下的矩阵块。
3,矩阵复制。执行到该步说明最左侧的两层矩阵计算完毕,分别此时是4×4的矩阵计算,将左上复制到右下,左下复制到右上,不断跳出,直到全部复制完毕。
示例代码及输出结果如下:
m=input("输入参赛者的数量(2的m次方):")
for i in range(0,int(m)+1):
num=2**i
# 生成循环赛数组
list=[[0 for i in range(0,num) ]for i in range(0,num)]
for i in range(0,num):
list[i][0]=i+1
def arrange(rstart,rend,cstart,cend):
# 参数表示行列的范围
global list
if rstart==rend: # 只有一个元素
# 因为数组已经初始化了,可直接返回
return
if rstart+1==rend and cstart+1==cend: # 两个元素,对角线赋值
# 主对角线赋值
list[rend][cend]=list[rstart][cstart]
# 副对角线赋值
list[rstart][cend]=list[rend][cstart]
return
# 大于两个元素,递归调用自身
# 取行列中点
rmid=(rstart+rend)//2
cmid=(cstart+cend)//2
# 左上角矩阵
arrange(rstart,rmid,cstart,cmid)
# 左下角矩阵
arrange(rmid+1,rend,cstart,cmid)
# 左上矩阵赋值移到右下
for i in range(rstart,rmid+1):
for j in range(cstart,cmid+1):
# 新更新位置=中间位置+左矩阵当前行位置
list[rmid+i+1-rstart][cmid+j+1]=list[i][j]
# 左上矩阵赋值到右上
for i in range(rmid+1,rend+1):
for j in range(cstart,cmid+1):
list[i-rmid-1+rstart][j+cmid+1]=list[i][j]
return
arrange(0,num-1,0,num-1)
print("循环赛安排如下:")
for i in list:
print(i)
输入参赛者的数量(2的m次方):3
循环赛安排如下:
[1, 2, 3, 4, 5, 6, 7, 8]
[2, 1, 4, 3, 6, 5, 8, 7]
[3, 4, 1, 2, 7, 8, 5, 6]
[4, 3, 2, 1, 8, 7, 6, 5]
[5, 6, 7, 8, 1, 2, 3, 4]
[6, 5, 8, 7, 2, 1, 4, 3]
[7, 8, 5, 6, 3, 4, 1, 2]
[8, 7, 6, 5, 4, 3, 2, 1]
回溯法
回溯法本质是一种暴力解法,通过深度优先遍历所有可能,如果不满足则回退一步重新遍历。运行时在逻辑上会产生类似决策树的结构,该方法比起纯暴力的for
循环就好在可以剪枝,通过剪枝限制函数提前放弃可能走不通的分支,一定程度上加速了计算过程。
回溯框架
1,深度优先,搜索解空间树
2,将根结点设置为活节点,进行扩展
3,遇到不可深入的死节点,回溯至最近一个活结点
4,直到找到所有解活队列中无活结点
该方法需要保存搜索过的结点,可以使用栈结构,或使用递归。
为了提高效率,每步扩展可以使用剪枝函数判断,不满足条件的直接回溯,加速算法执行过程。
代码大致结构如下:
x[n]= # 解空间树
backtrack(i):
if(i>n): # 已经到叶子结点
retrun # 返回输出
else:
for 穷举路径
x[i]=j
if(约束条件)
backtrack(i+1) # 满足约束条件进行下一层
示例1—n皇后
与递归法解n皇后完全一致,深度优先找到解就返回结果,不满足时自动回退到上一层的循环继续执行。
示例2—求子集和
已知正整数集合D={ 2,1,3,3}和正整数c=6,欲寻找满足如下条件的集 合S:S是D的子集且S中所有整数的和等于c。请用回溯法求解满足上述条件的所有集合。
解空间树构建时分枝依据是元素选和不选两种情况,第一层元素2选或不选,第二层元素1选或不选两种情况有2×4这8种分支,一直到3,示例如下图:
该算法中有两种情况需要剪枝:
1,已有元素大于c,不可能满足条件,剪枝;
2,已有元素加上剩下所有元素都小于c,剪枝。
实现代码如下:
def pruning(current_sum, remaining_index, target_sum):
"""
current_sum:当前已选元素的和
remaining_index:剩余还未考虑选择的元素在原集合D中的最小索引
target_sum:目标和(这里是6)
"""
# 如果当前已选元素的和已经超过目标和,肯定不符合要求,剪枝
if current_sum > target_sum:
return True
# 计算剩余所有元素的和(简单求和示例,可优化)
remaining_sum = sum(D[remaining_index:])
# 该方案计算为连续的未选元素和,虽然针对当前数据集能实现效果,但还是可以优化
# 如果当前和加上剩余所有元素的和都小于目标和,说明该分支不可能达到目标和了,剪枝
if current_sum + remaining_sum < target_sum:
return True
return False
回溯法循环遍历元素,能继续进行则递归调用,代码如下:
D = [2, 1, 3, 3]
c = 6
result = [] # 用于存放满足条件的子集
def backtracking(index, current_set, current_sum):
# index当前考虑元素索引,current_set为当前已选元素,current_sum为已选元素和
global result
if current_sum == c:
# 满足条件,添加结果
result.append(current_set.copy())
return
if index >= len(D):
# 越界返回
return
# 尝试选择当前元素
current_set.append(D[index])
if not pruning(current_sum + D[index], index + 1, c):
# 无需剪枝,尝试选取下一个元素
backtracking(index + 1, current_set, current_sum + D[index])
current_set.pop() # 撤销选择,回溯
# 不选择当前元素
backtracking(index + 1, current_set, current_sum)
backtracking(0, [], 0)
print(result)
分支限界法
分支限界法和回溯法类似,也是构建解空间树来寻找问题解的一种方法,不同之处在于,分支限界法采用广度优先策略搜索,同时多用于找一个解或最优解的情景。
分支限界分别表现为:
分支,广度优先,获取活结点的所有分支,加入队列。
限界,计算限界函数值,选最有利的子结点进行扩展。
该算法的关键是三个问题:
1,设计限界函数。 用剪枝函数剪去可能无法产生最优解的分支,比如取最大值时舍弃上界小于最大值的分支。
2,组织活结点表。 由当前结点一次性扩展产生所有可能的结点,这些结点可以进入普通队列先进先出,再由限界函数判断是否扩展,也可以设置为优先级队列,每次取出优先级最高的结点作为扩展结点。
3,确定最优解向量。 将可行解保存为对应解向量,可以在解的构建过程中保存路径,也可以在搜索过程中构建空间树的结构,由最终叶子结点向上推导路径。
分支限界框架
1,根结点加入活结点队列
2,活结点队列中取出头结点进行扩展
3,用约束条件检查扩展结点,满足条件的子结点加入活结点队列
4,重复2和3,直到找到一个解或活结点队列为空
示例1—分支限界01背包问题
物品重量为 w i w_i wi价值为 v i v_i vi,背包容量为 c c c。
分支限界法通过广度优先生成解空间树,在该问题中先生成每个物品取和不取两个子结点,加入队列, 然后使用优先级排序。
限界函数是算法的第一个重点,左子树取物品的限界函数是
w
+
w
i
<
c
w+w_i<c
w+wi<c,即背包当前重量+物品重量小于上限,右子树不取物品的限界函数是
v
+
r
v
>
m
a
x
v
v+rv>maxv
v+rv>maxv,即当前背包价值+不取物品的剩余最大价值>当前最优方法的物品价值。
左子树的限界函数很好理解,只要能放下就放,右子树的限界函数需要稍微说明:
物品按单位重量价值排序,是否还会出现不取当前物品而导致总价值更大呢?这种情景可能有以下两种情况:
1,背包容量不足。4容量而物品重量为5,价值更高但放不下,和左子树的限界函数效果一直。
2,贪心失败,破坏最佳组合。4容量有重量为3价值为6的物品,但后面还有两个重量为2价值为4的物品,如果装了物品3就导致背包无法完全装满,破坏了最佳方案。
基于上述情况,rv的计算方式为sum(i+1,len(item))
即可,前面已经判断过的方案无需讨论。
优先级是算法的第二个重点,活结点以何种优先级在队列中存储呢?该问题中我们取背包上界价值越大的越先出队,这代表本次方案更有可能找到最优解。
解决该问题的算法步骤为:
1,将物品按单位重量价值递减排序,存于数据结构中
2,初始化背包最大价值maxv
3,解空间树根结点入队
4,队列不为空时反复进行如下算法,否则结束:
出队头结点
判断左分支是否可扩展,可扩展看是否是解,可扩展但不是解,入队
判断右分支是否可扩展,同上
该部分不画图不直观,硬想很迷糊,听了优先队列的课才大概明白,结构图如下:
不知道复杂在哪,可能就是各种边界条件吧,如下代码我写了快五个小时:
class Node:
def __init__(self, depth,weight,value,ub):
# 结点信息,包含深度、重量、价值、上界
self.depth = depth
self.weight = weight
self.value = value
self.ub = ub
def upboard(node,item,c):
# 计算上界,正规计算流程是当前重量+后续物品重量,
# 直到不能完整装下,再部分装下最后的物品,计算这三步的和
weight = node.weight
ub=node.ub
i=node.depth
while(weight<=c and i<len(item)-1):
# 更新重量和上界
weight=weight+1
ub=ub+item[i+1]
return ub
def left(node,item,c):
# 左剪枝函数,大于背包容量剪枝
if node.weight>c:
print("当前结点重量为:",node.weight," 左剪枝")
return True
else:
return False
def right(node,maxv):
# 右剪枝函数,结点上界小于最大值剪枝
if node.ub<maxv:
print("上限判定,右剪枝",node.depth,"层")
return True
else:
return False
item=[4,5,1,9,3,2,8,6 ]
c=4
def dfs(item,c):
maxv=0
cout = 0
node=Node(0,0,0,0)
upboard(node,item,c)
list=[] # 初始化队列
list.append(node)
while(len(list)!=0):
# 遍历直到队列为空
e=list.pop(0) # 获取头结点
if e.depth==len(item)-1: # 叶子结点
if e.value>maxv: # 更新最大值
maxv=e.value
print("叶子节点已到达最大值为:", maxv)
if e.weight==c: # 背包已装满
if e.value>maxv:
maxv=e.value
print("背包已装满最大值为:", maxv)
print(e.depth," ",e.weight," ",e.value)
if e.depth<=len(item)-1:
# 和前面判断叶子结点条件重复,但不能在前面return,因为后续还要遍历进行比较
# 该部分可以优化
e1=Node(e.depth+1,e.weight+1,e.value+item[e.depth],0) # 生成左孩子结点
upboard(e1, item, c) # 计算上界,该部分整合到初始化参数中会报错,也可以优化
e2=Node(e.depth+1,e.weight,e.value,0) # 生成右孩子结点
upboard(e2, item, c)
if not left(e1,item,c): # 左剪枝
list.append(e1) # 入队
print("左孩子结点入队")
cout=cout+1
if not right(e2,maxv): # 右剪枝
list.append(e2) # 入队
print("右孩子结点入队")
cout=cout+1
print("总共计算次数为:",cout)
return maxv
item=sorted(item,reverse=True)
print("最大值为:",dfs(item,c))
上述代码构建时还简化了背包问题,默认所有物品重量为1,只有价格差别,但只是少了计算单位重量并排序的部分,输出示例如下:
如果不剪枝需要运算
2
8
2^8
28次即256次,但经过剪枝后可见,指运行了62次,加上头结点63次,减少了很多不必要的运算。
实际问题的求解过程如下图:
示例2—优先队列装载问题
n个集装箱重量分别为
w
1
,
w
2
.
.
.
w
n
w_1,w_2...w_n
w1,w2...wn,要装到载重量为
c
1
,
c
2
c_1,c_2
c1,c2两艘货轮上,问是否能装下,及解决方案。
该问题可分解为:先将一条船装满,剩下的全装到另一条船上,即先让一条船尽可能多装。
该问题同样要构建状态树,包括重量、深度(已处理的集装箱个数)和能装下集装箱个数的上界。
限界函数在该问题中和01背包类似,也是最大值问题,左树剪枝函数是已有重量是否大于船的载重量,右树剪枝函数是不放该集装箱的上界是否大于已有最大值maxn。
主函数load
中实现根节点的初始化,放入优先队列中,从队列头取出结点进行扩展,不断循环直到队列为空。
如果取出的结点是叶子节点,判断更新maxn,否则分别生成左右结点,表示取或不取下一个集装箱,上界大于当前最优maxn才放入队列。
优先队列,该函数决定了队列以何种顺序弹出,本问题中应该选择上界最大的集装箱先出队计算,队列可替换为大根堆数据结构,排序依据是上界。
主题与队列问题一致,核心区别就是原本的list
队列直接添加,此时需要将list
设置为headq
大根堆数据结构,并在加入元素时使用heapq.heappush(queue, (node.bound))
保证上界最大的在队头,实现优先级队列。
贪心策略
贪心法是指在整个问题中不关心最终目标,只考虑当前问题的最优解。比如01背包问题,只拿单位重量价格最高的物品,该类问题使用贪心策略也能获得最优解,但很多问题的局部最优解未必能产生整体最优,实际问题往往要先证明。故贪心法的使用条件是—最优子结构性质,最优解包含所有子问题的最优解。
贪心性质
证明时要同时包含贪心选择性质和最优子结构两个方面,也是贪心法的适用条件:
1,贪心选择性质。指问题的全局最优解可以通过一系列局部最优的选择(贪心选择)来达到,即自底向上,局部最优可以导致整体最优。
2,最优子结构。指一个问题的最优解包含其子问题的最优解,自顶向下,整体最优可以拆解为多个局部最优的整合。
算法框架
1,划分子问题。将大问题分解为多个子问题。
2,设计贪心准则。每个子问题分别求最优解,即局部最优。
3,最优解整合。
示例1—装载问题
n个集装箱要装到货轮上,质量分别为
w
i
w_i
wi,货轮载重上限为c,确定一个装载方案将尽可能多的集装箱装上轮船。
1,划分子问题。该问题可以拆解为多个子问题,先在n个集装箱中确定第一个装上船的集装箱号,再在n-1个集装箱中确定一个装上船的集装箱号,不断转化为规模逐渐减小的子问题。
2,设计贪心准则。轮船没有体积限制,只有装载的质量限制,该方案设计贪心准则为—将最轻的集装箱先装到货轮上。
故算法步骤为:
1,集装箱按质量排序
2,初始化标记数组x[i]和装载质量total
3,遍历数组,如果total+A[i].w不超过c,则将集装箱i上船,更新x[i]和total
4,返回标记数组x和total
证明贪心选择性质:
最优解X和集装箱集合I中,集装箱已按重量排好,
k
=
m
i
n
(
i
∣
x
i
=
1
)
k=min(i|x_i=1)
k=min(i∣xi=1),k表示已有解中集装箱的最小序号,如果
X
1
=
1
X_1=1
X1=1说明最优解中第一个选择的就是重量最小的集装箱,符合贪心选择。
构造一个解
Y
=
y
1
,
y
2
.
.
.
y
n
Y={y_1,y_2...y_n}
Y=y1,y2...yn,令
y
1
=
1
,
y
k
=
0
,
y
i
=
x
i
y_1=1,y_k=0,y_i=x_i
y1=1,yk=0,yi=xi即用重量最小集装箱代替原有解中最小的集装箱,则有如下公式:
w
1
≤
w
k
w_1\le w_k
w1≤wk故不等式成立,因此Y是满足条件的一个可行解,并且集装箱数目与X相同,Y也是一个最优解,表明对于最优装载问题,总是能够找到一个以贪心选择开始的最优解。
证明最优子结构性质:
对集装箱1做出贪心选择后,余下问题变为从
2...
n
2...n
2...n中选择质量不超过
c
−
w
1
c-w_1
c−w1的最优装载子结构问题,证明最优子结构就是要证明:X为原问题的一个最优解,
X
′
=
X
−
1
X'=X-1
X′=X−1是装载质量不超过
c
−
w
1
c-w_1
c−w1的装载问题的一个最优解。
使用反证法,设能找到一个Y’,装载数量比X‘更多,且质量不超过
c
−
w
1
c-w_1
c−w1,则将集装箱1加入Y’后构成的Y就比原最优方案X多,与原有前提X是最优方案矛盾,表明每一次做出的贪心选择都将问题简化为一个更小的与原问题具有相同形式的子问题,满足最优子结构性质。
更复杂的算法示例可见贪心算法一篇够。
动态规划
该算法与前面回溯、递归等算法联系紧密,但又不同,动态规划旨在解决多阶段决策问题。
动态规划将需要求解的问题分为若干相互联系的阶段问题,每个阶段分别作出决策,进而确定完整的活动路线,获得最优解。
各个阶段的决策构成决策序列,每个阶段存在多个决策以供选择,最终得到求解问题的策略空间,多阶段决策时在策略空间中选取一个最优策略,简单来说就是大问题拆成小问题,但此时不要求小问题完全一致,几乎是高级版的回溯。
动态规划算法框架
动态规划需要将问题分解为若干子问题,依次解决,直到推出原问题的解,使用时需满足如下三个条件:
1,最优化原理。子问题最优解构成原问题的最优解。
2,无后效性。前一阶段的状态无法直接影响后续决策。
3,子问题重叠性。为了避免重复计算,可以用表记录已经求解的子问题答案。
该算法没有如递归、回溯等的固定框架,需要具体问题具体分析,但有大致解体思路如下:
1,问题划分。将问题划分为多个子问题的求解过程。
2,确定状态与状态变量。用状态表示不同情景。
3,确定状态转移方程。前一段的决策如何决定下一段的决策。
4,确定边界条件。各阶段停止计算的边界值。
算法设计的代码层面可以分为如下五步:
1,明确dp数组的含义。即用于记录子问题求解答案的数组含义。
2,确定递推公式。上一阶段如何影响下一阶段决策的具体方法。
3,dp数组初始化。初始条件设置。
4,确定遍历顺序。以何种顺序递推求得dp数组。
5,打印dp数组。用于检查问题。
示例1—背包问题
有背包的最大载重和不同物品的价值和重量,问如何选取能使背包装载的价值最大。
该问题用暴力解法首先考虑回溯,每个物品有取或不取两种可能,枚举所有情况,n个物品要判断 2 n 2^n 2n次,复杂度很高。
使用动态规划的思路如下:
1,问题拆解。 n
个物品装入容量为i
的背包可以从最原始的1
个物品装入容量为1
的背包逐步演变而来,最终形成有n
个物品装入容量为i
背包对应的二维数组,这就构成了我们用于存储子问题结果的dp数组。
2,确定状态。 该二维数组的具体含义为0~n
的物品任意选取,放入容量为i
的背包的最大价值,通过小问题朱家构建起大问题的解。
3,确定状态转移方程。 此时可以只着眼于抽象的(n,i)
状态的来源,在上一个状态的最大值已建立的前提下,只需考虑当前物品n
放不放即可,即不放物品n的价值dp[n-1][i]
,和没有物品n时放了物品n的价值dp[n-1][i-weight[n]]+value[n]
。直观可以理解为不管背包状态如何都装下物品n
(比如拿出以前放好的东西再放入物品n
)与不装物品n
时的最大值。
4,边界条件。本题也是遍历顺序,由转移方程可知,dp[n][i]
只能从上方和左上角比较推导而来,所以从左向右计算或从上向下计算都能得到结果,即背包和物品遍历先遍历哪个都可以,边界条件就是背包容量和物品序号-1。
背包容量为[1,2,3]
,物品重量为[1,2,2]
,价值为[15,20,30]
形成的dp数组如下:
背包容量/物品序号 | 0 | 1 | 2 | 3 |
---|---|---|---|---|
0 | 0 | 15 | 15 | 15 |
1 | 0 | 15 | 20 | 35 |
2 | 0 | 15 | 20 | 45 |
本题中第一列表示背包容量为0时的最大价值,故全为0;一行表示背包装物品时的最大价值,除第一列外均初始化为物品0的价值,其余元素计算得来,可任意初始化,不影响计算结果。
示例代码和输出结果如下:
c=3
weight=[1,2,2]
value=[15,20,30]
# 递推生成dp数组
dp=[[0 for i in range(0,c+1)] for i in range(0,len(weight))]
for i in range(0,c+1):# 初始化dp数组
if weight[0]<=i:
# 背包容量大于物品重量时,dp[0][i]=物品价值
dp[0][i]=value[0]
for i in range(1,len(weight)):
for j in range(1,c+1):
# 第一列和第一行已初始化,从第二行第二列开始
# 遍历背包,先物品,后容量
if weight[i]<=j: # 背包能装下物品
takei=dp[i-1][j-weight[i]]+value[i] # 装入物品的价值
nottakei=dp[i-1][j] # 不装入物品的价值
dp[i][j]=max(takei,nottakei) # 取最大值
else: # 背包装不下该物品
dp[i][j]=dp[i-1][j] # 背包上层的最大价值
print(dp)
[[0, 15, 15, 15], [0, 15, 20, 35], [0, 15, 30, 45]]
示例2—最大子段和
给定一个数组,求其最大子段和,因为是子段,所以必须连续,即最大连续子序列的和。同样可以使用两重循环遍历求解,一个放起点一个一直往后遍历,这样全遍历完的算法是n*n
次。
动态规划解该问题不能简单套用01背包思路,需要具体分析,但也大同小异:
1,问题拆解。该问题没有重量价值和背包容量,只有一个数组,故dp数组也为一维,每个元素表示以当前元素结尾的最大字段和。
2,递推公式。该步类似,当前状态dp[i]
的来源也只有两种,延续上一个子段dp[i-1]+num[i]
和不延续子段从头开始num[i]
,对二者取最大值。
3,初始化只需初始化dp[0]=num[0]
,遍历顺序从前往后。
代码和输出如下:
num = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
dp=[num[0] for i in range(len(num))]
# 初始化dp数组,长度与num数组相同,每个元素都等于对应的num数组元素
for i in range(1,len(num)):
# 递推公式
dp[i] = max(num[i], num[i] + dp[i-1])
# 输出dp数组
print(dp)
# 输出dp数组中的最大值,即最大子序列的和
print(max(dp))
[-2, 1, -2, 4, 3, 5, 6, 1, 5]
6
总结
算法总结
算法往往出现搞完一个就忘了前面学过的方法的问题,所以在最后再总结一遍,帮助比较理解和整体把握。
递归:解决问题的步骤完全相同,如斐波那契数列等,每次只要更新参数调用自身方法即可。迭代法与递归相比,实现更简单,代码简洁,可读性强,在数学定义和代码间可以很容易转换。
以皇后问题作为递归示例,流程图如下:
大致过程为:
1,检查放置合法性。遍历当前已放置的皇后,分别检查列和对角线冲突。
2,设置递归出口。当放置行为最后一行时,说明所有皇后都放置好了,将结果保存。
3,遍历循环所有位置,尝试放置,递归调用自身,放置成功则行数加一。
分治:分治是将大问题拆解为各小问题,逐个解决并汇总的方法。该方法与递归联系紧密,大问题的分解与小问题的解决多要使用递归完成。
分治法中子问题解决策略完全一致,代码实现简单,但执行过程与人理解的不完全相同。
代码中只指定出口,在出口之前全是分治处理,人对分治的编码过程是自顶向下的,但分治的执行过程是自底向上构建的答案,想清楚这一点就能大概理解分治了。
该算法使用快速排序作为示例总结,快排执行结构图如下:
大致流程为:
1,指定划分函数,大于基准的放右边,小于基准的放左边。
2,制定出口,起点小于终点就重复划分。
3,分治,以基准为界,左右两端分别划分。
回溯:回溯法的本质仍是一种暴力搜索,只是借助空间树这一数据结构实现遍历,在此基础上可以增加剪枝函数,即对已经不可能产生解的方向提前停止搜索,来优化搜索效率。另外其遍历策略是深度优先,即不断深入解空间,旨在最快找到一个可行解。
使用求子集和作为示例,结构图如下:
分支限界:该算法思想与回溯法类似,都是构建解空间树的暴力搜索方法,不同之处是采取了广度优先的遍历策略,多用于找最优解的情景,另外更强调限界即剪枝函数,比暴力法具有更高效率。
图前面已经画过了,所以这里只重申分支限界解决问题的一般步骤:
1,子问题分解。
2,根结点入队。
3,取出根节点,符合限界函数的进行孩子结点扩展。
4,扩展结点是叶子结点返回结果,否则加入队列。
5,重复3、4,直到找到满足条件的解。
贪心:贪心策略的可行性建立在局部最优能推出整体最优的基础上,故使用贪心必先证明这两条性质:
1,贪心选择性质,即证明贪心选择策略的正确性。方法为将贪心选择的解带入通解集合中,证明有贪心的解也是最优解。
2,最优子结构,即证明每个子问题的解都是子问题的最优解。通常用反证法,原有最优集合中取出一个一个子集,假设有新的集合比该集合更好,那加上原集合去掉的元素就比原最优集合更好,与假设矛盾。
该算法代码实现十分简单,难点和重点在于贪心使用的条件性证明。
动态规划:动态规划与前面分治回溯等十分类似,都是通过解子问题获得原问题的解。但又有很大区别,动态规划需要建立dp数组存储子问题答案,该步算是分治的优化,避免了很多重复计算,另外子问题与原问题的关系可能也更复杂,所以也可以说动态规划是分治的一种改进。
动态规划的代码通常较简单,但dp数组的建立过程和递推公式的确定往往比较复杂,并且不同问题关联性不大,都需要具体问题具体分析。
整体总结
这些算法联系紧密,区别也并不明显,简单来说就是:分治和回溯依赖于递归进行子问题求解,动态规划则是在此基础上增加了dp数组存储子问题避免了重复计算。
算法的思想其实都比较简单,难的是具体问题的分析过程,该使用何种算法处理哪一段过程,以及边界的确定,一个赋值的加一减一看着影响不大,可能直接决定了算法的成功与否。算法就是需要多学、多用、多理解。
现在看起来好像结构和思路都很简单,甚至感觉整个人类文明的知识也很简陋,但这只是因为我们站在前人的肩膀上罢了。回顾以前的原理和公式觉得很简单,可从无到有的探索过程可能穷尽几代人都无法完成,时间对人类来说是真的浪漫,每个人都从零开始,但社会整体却滚滚向前,后来者不断接过前人传递的火把,让它在自己手中熊熊燃烧,烧的是火把,也是自己。
人生感悟
算法的学习过程中我总想,如果我面临新问题,该怎么想到和他们一样的思路呢?我希望能从这些算法的学习中学到解决问题的能力和思路,但是看到网上的一条评论,有的人天生就是能想到。看到这我也意识到我的贪婪,希望能力提高是对的,但是算法和思路这些可能是多少人穷尽一生研究出来的方法,如何能让我获得同样的能力呢?
人学习的意义也在于此,前人在有限生命里取得的突破,我们学会了理解了拿来使用,然后再去突破新的问题,期望学完以后就变成迪杰斯特拉,遇见各种算法都能逢山开路遇水架桥是不切实际的,只有学的够多,想的够多,遇到新问题时找相似再遍历求解才可能解决新问题,所以学习无止境,突破也无止境呀。