Algorithm

Reference:

  1. 陈慧南版算法设计与分析书(C++版)
  2. 北大屈婉玲教授公开课:https://www.bilibili.com/video/av7134874

一、基本概念

  1. 定义:对特定问题求解步骤的一种描述,是指令的有序序列。

  2. 算法的五个重要特征

    1)输入:由零个或多个

    2)输出:至少一个

    3)确定性:每条指令都有确切的定义,无二义性

    4)可行性:通过基本的运算执行有限次来实现

    5)有穷性:在有限步之后终止

  3. 一个好的算法需要具备的四个特征

    1)正确性:满足预先规定的功能和性能要求

    2)简明性:思路清晰、层次分明、容易理解、利于编码和测试

    3)效率:有效的存储空间以及高的时间效率

    4)最优性:执行时间已达到所需时间下限

  4. 如何进行算法设计

    • 考虑问题规模
    • 选择什么算法?如何描述这个方法
    • 这个方法是否对所有实例都得到最优解?如何证明?
    • 如果不是,能否找到反例
  5. 算法时间复杂度

    针对指定基本运算(比较、加法、乘法、指针、交换...),计算算法所作运算次数

  6. 函数渐近的界

    (1)大O符号(渐近上界):0<=f(n)<=cg(n),c为正常数

    (2)大Ω符号(渐近下界):f(n)>=cg(n)

    (3)小o符号(非紧上界):0<f(n)<cg(n)

    (4)小ω符号(非紧下界):f(n)>cg(n)

    (5)Θ符号(紧渐近界):c1g(n)<=f(n)<=c2g(n)

  7. 常见的多项式时间算法的渐近时间复杂度

    O(1)<O(logn)<O(n)<O(nlogn)<O(n^2)<O(n^3)

二、分治法

定义:将一个复杂的问题分解成若干个规模较小相互独立,但类型相同的子问题求解;然后再将各个子问题的解组合成原始问题的一个答案。

算法框架:

SolutionType DandC(ProblemType P)
{
    ProblemType P1,P2,...,Pk;
    if (Small(P)) return S(P);     //解决小规模的问题
    else {
		Divide(P,P1,P2,...,Pk);    //将问题分解成子问题P1,P2,...,Pk
		Return Combine(DandC(P1),DandC(P2),...,DandC(Pk));
		//递归的解各子问题,并将各子问题的解合并为原问题的解
		}
}

递归定义:一种直接或间接引用自身的定义方法,主要包括两部分:基础部分和递归部分

​ 基础部分:以直接形式明确列举新事物的若干简单对象

​ 递归部分:由简单或简单对象定义新对象的条件和方法

实例1. 递归求解最大公约数和最小公倍数:

def gcd(m: int, n: int):
    if m == 0:
        return n
    else:
        if m > n:
            m, n = n, m  # 保证n始终大于m
        temp = n % m
        return gcd(temp, m)


def gcm(m, n):
    temp = gcd(m, n)
    return m * n // temp

实例2.汉诺塔问题

def hanoi(n, a, b, c):
    """
    有三个柱子,在A柱子上有n个圆盘(底下圆盘最大,上面最小),现需要将A柱子上圆盘移动到C上
    要求:每次只能移动一个圆盘,且在移动的过程中,小圆盘不能放在大圆盘的上面
    :param n: 需要移动的圆盘数量
    :param a: 柱子A
    :param b: 柱子B
    :param c: 柱子C
    :return: 移动的过程
    """
    if n == 1:
        print('move:', a, '-->', c)  # 递归的收敛条件
        return
    else:
        hanoi(n-1, a, c, b)  # 先把n-1个盘子,从a移到b
        hanoi(1, a, b, c)    # 再将剩下的1个盘子,从a移动到c
        hanoi(n-1, b, a, c)  # 将主子b上面的n-1个盘子,从b再移到c

分治法常解决的问题:汉诺塔、快排、归并、二分搜索等

分治法的应用实例:

  1. 最大最小元问题:在互不相同的n个数{x1, x2,…, xn}中找出最大和最小的数

    # 基本子算法(子问题规模小于等于 2 时)
    def get_max(max_list):
        return max(max_list) 
     
    def get_min(min_list):
        return min(min_list)
     
    # 分治法 最小元素
    def solve(init_list):
        n = len(init_list)
        if n <= 2: # 若问题规模小于等于 2,最终解决
            return get_min(init_list)
     
        # 分解(子问题规模为 2,最后一个可能为 1)
        temp_list=(init_list[i:i+2] for i in range(0, n, 2))
        
        # 分治,合并
        min_list = list(map(get_min, temp_list))
        # 递归(树)
        return solve(min_list)
     
        
            
    # 分治法 最大元素
    def solve2(init_list):
        n = len(init_list)
        if n <= 2: # 若问题规模小于等于 2,解决
            return get_max(init_list)
     
        # 分解(子问题规模为 n/2)
        left_list, right_list = init_list[:n//2], init_list[n//2:]
        
        # 递归(树),分治
        left_max, right_max = solve2(left_list), solve2(right_list)
        
        # 合并
        return get_max([left_max, right_max])
    

​ 时间复杂度分析:

​ 分治法求解最大元最小元问题会2次递归调用自身,由迭代计算得到:T(n)=3n/2-2。分治法求最大最小元的算法最好、最坏、平均情况下都需要3n/2-2次比较。

2、二分搜索:在已按关键字值非减排序的有序表中,搜索给定元素的问题。

(1)分治法求解思路:

将有序表分解为若干个子表,若分割点元素和搜索元素相等,返回结果;若分割点元素小于分割点元素,则在分割点前面的子表进行搜索;否则在分割点后面的子表进行搜索。

(2)时间复杂度分析:

二分搜索每次待搜索数组的大小减少一半,因此,在最坏情况下执行了O(logn) 次,整个算法在最坏情况下的计算时间复杂性为O(logn) 。

3、二叉判定树:二分搜索过程的描述

(1)二叉判定树的性质:

· 具有n(n>0)个内节点的对半搜索二叉判定树的左子树上有(n-1)/2向下取整个内节点,柚子树上有n/2向下取整个内节点

· 具有n个内节点的二叉判定树的高度为logn向下取整+1

· 若n=2n-1,则对半搜索二叉判定树是满二叉树

· 若n=2h-1,则对半搜索二叉判定树的外节点均在h+1层上,否则,在第h或h+1层上,h=logn向下取整+1

4、归并排序

(1)分治法求解思路:

①将待排序的元素序列一分为二,得到长度基本相等的两个子序列,分别排序。

②如果子序列较长,还可继续细分,直到子序列的长度不超过1为止。

③当分解所得的子序列已排列有序时,将两个有序子序列合并成一个有序子序列,得到原问题的解。

(2)时间复杂度分析:

通过迭代计算得到两路合并排序的时间复杂度为T(n)=O(nlogn) 。

(3)空间复杂度分析:

两路合并排序一般需要一个与原序列相同长度的辅助数组temp,因此它所需的额外空间为O(n)。

5、快速排序

(1)快速排序算法求解思路:

在待排序的序列(k0,k1,...,kn-1)中选择一个元素作为分划元素,也称为主元。以主元为轴心,对原序列重新排列,分成左右两个子序列,使主元左侧子序列中所有元素都不大于主元,主元右侧子序列中所有元素都不小于主元。

  1. 以首元素作为主元
def quick_sort(self, a: list, first: int, last: int):
    """快排"""
    # 终止条件
	if first >= last:
    	return
    low = first
    high = last
    mid_value = a[low]  # 以首元素作为主元
    while low < high:
    	while low < high and a[high] >= mid_value:
        	# 左移,找到一个不大于主元的值
            high -= 1
        a[low] = a[high]
        while low < high and a[low] < mid_value:
        	# 右移,找到一个大于主元的值
            low += 1
        a[high] = a[low]
     # 循环退出时,low == high
     a[low] = mid_value

     # 递归调用,对mid_value两边的元素进行快排
     self.quick_sort(a, first, low-1)
     self.quick_sort(a, low+1, last)

     return a

(2)时间复杂度分析:

最好情况下:如果每次分划中,主元都恰好是序列(子序列)的中值,分划得到左右两个几乎相等的子序列,则快速排序的效率最高。得到的递推公式为:

B(n)=2B(n/2)+ Θ(n)

递归计算的,B(n)=Θ(n logn)。

最坏情况下:如果每次划分操作产生的两个子序列,其中之一为空序列,则快速排序效率最低。递推公式为:

w(n)≤w(n-1)+n+1 ≤W(1)+(n+1)+...+3

平均情况下:假定序列中n个元素的各种可能排列是等概率的,则主元在序列中的大小次序是随机的。因此在一趟分划操作后,它位于下标[0,n-1]的任何位置的可能性是相等的。这意味着序列中任何一个元素为主元的概率是1/n。

递推解得:A(n)<2(n+1)ln(n+1)=O(nlogn)

(3)空间复杂度分析:

最坏情况下,程序所需的系统栈调用深度,最大为O(n)。

如果每次分划后在栈中保存较大子序列的上、下界,而对较小的子序列先进行排序。这样可使所需的栈空间大小降为O(logn)。

  1. 随机选择主元
  2. 改进选择主元(两次选中)

三、贪心法

定义:求解最优化问题的算法设计策略。其每一步上用作策略依据的选择准则被称为最优量度标准(局部最优解)。

性质:

  1. 贪心选择性质:指所求问题的整体最优解可以通过一系列最优的选择来达到。
  2. 最优子结构性质:一个问题的最优解包含其子问题的最优解。

算法框架:

SolutionType Greedy(SType a[], int n)
{
     SolutionType solution=; //解向量为空               
     for(int i=0; i<n; i++)           //每次选一个分量
     {	SType x=Select(a);        //遵循最优量度标准
                                                           //选择一个分量               
    	if (Feasiable(solution, x))     //判断是否可行        
	    solution=Union(solution, x); //形成新的部分解     
     }
     return solution;                   //返回生成的最优解             
}

实例:

  1. 一般背包问题(物品是可以分割的):

将可以分割的物品,每次按照单位重量价值最大的物品装包,得到最优解。

基本步骤:

​ 1. 计算每种物品单位重量的价值Pi/Wi并按非增序列排序

​ 2. 贪心选择策略,选择单位重量价值最高的物品放入背包,尽可能多的放入,直到将背包装满

​ 3. 若装入某个物品时,不能全部装下,而背包内的物品总重量仍未达到W,则根据背包的剩余载重,选择单 位重量价值次高的物品尽可能多地装入背包

  1. 最小代价生成树的两种方法:Prim算法和Kruskal算法

    问题描述:一个无向连通图的生成树是一个极小连通子图,它包括图中所有的结点,并且尽可能少的边,遍历一个连通图得到图的一颗生成树,有很多种遍历方法,对于带权的连通图(网络),具有最小代价的生成树称为该网络的最小代价生成树

    Prim算法贪心准则:按边代价的非减次序考察E中的边,在保证S所代表子图是一棵树的前提下选择一条代价最小的边

    时间复杂度:O(n^2)

    Kruskal算法贪心准则:按边代价的非减次序考察E中的边,从中选择一条代价最小的边

    时间复杂度:O(eloge)

    两者比较:

    1. Kruskal算法每次选完边后都要进行判断是否会构成回路
    2. Prim适合于边数相对较多的带权图,而Kruskal适合于边数相对较少的带权图
    3. 两种算法最终生成的树是否一定是最小代价生成树还需要额外的证明,因为每一步的选择只能保证是当前最好的选择(局部最优性)
  2. 贪心法求解迪杰斯特拉算法(单源最短路径问题)

    问题描述:给定带权的有向图G=(V,E)和图中结点s,求从s到其余各结点的最短路径,使得路径长度最小(权值之和),其中,s称为源点。

    Dijkstra算法(贪心法):

    首先求得长度最短的一条最短路径,再求得长度次短得一条最短路径,依此类推,直到源点到其它所有结点只之间得最短路径都已求得为止。

四、动态规划

定义:利用最优子结构,自底向上从子问题的最优解逐步构造出整个问题的最优解,可以处理不具备贪心准则的问题。

适合用动态规划求解两要素:

  1. 最优子结构特性:不论过去状态和决策如何,对前面的决策所形成的状态而言,其余决策必定构成最优策略
  2. 重叠子问题:递归求解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。

实例

  1. 弗罗伊德算法求解(每对节点间的最短路径问题)

    思想:令K=0,1....,n-1,每次考察一个结点k。二维数组d用于保存各条最短路径的长度,其中d[i][j]存放从结点i到结点j的最短路径长度,在算法的第k步上应作出决策:从i到j的最短路径上是否包含结点k

    为了得到最优解,使用另外一个二维数组path记录相应的最短路径,path[i][j]给出Vi到Vj的最短路径上顶点Vj前面的那个顶点。采用的是将有向图存储在邻接矩阵种实现。

    时间复杂度:O(n^3)

  2. 矩阵连乘

    问题描述:矩阵乘法满足结合律,因此可以有许多不同的计算次序,矩阵连乘问题是确定计算矩阵连乘的计算次序,使得按照这一次序计算矩阵连乘,需要的 “ 数乘 ” 次数最少。

    备忘录方法:将每个已经计算过的子问题建立备忘录,即保存子问题的计算结果以备需要时引用,从而避免了相同子问题的重复求解

  3. 最长公共子序列求解

    问题描述:给定序列X={x1,x2,...,xm},则另一序列 Z = {z1,z2,...,zk}为X的子序列是指存在一个严格递增下标序列(i1,i2,...,ik)使得对于所有j=1,...,k,有 zj = xi,起始下标设为1。

    设 X = {x1,x2,...,xm} 和 Y = {y1,y2,...,yn} 两个序列,Z = {z1,z2,...,zk} 是它们的最长公共子序列,则

    1. 若 xm = yn,则 zk = xm = yn,且 Zk-1 是 Xm-1和 Yn-1的 LCS
    2. 若 Xm ≠ yn,且 zk ≠ xm,则 Z 是 Xm-1和 Y 的 LCS
    3. 若 xm ≠ yn,且zk ≠ yn,则 Z 是 X和Yn-1的 LCS

    最优解的递推公式:

    时间复杂度:O(m*n)

  4. 0/1背包问题求解

    问题描述:物品不能分割,只能作为整体装入背包,求一种最佳装载方案使得总收益最大

    代码:

    def bag(n,c,w,v):
    	res=[[-1 for j in range(c+1)] for i in range(n+1)]
    	for j in range(c+1):
    		res[0][j]=0
    	for i in range(1,n+1):
    		for j in range(1,c+1):
    			res[i][j]=res[i-1][j]
    			if j>=w[i-1] and res[i][j]<res[i-1][j-w[i-1]]+v[i-1]:
    				res[i][j]=res[i-1][j-w[i-1]]+v[i-1]
    	return res
     
    def show(n,c,w,res):
    	print('最大价值为:',res[n][c])
    	x=[False for i in range(n)]
    	j=c
    	for i in range(1,n+1):
    		if res[i][j]>res[i-1][j]:
    			x[i-1]=True
    			j-=w[i-1]
    	print('选择的物品为:')
    	for i in range(n):
    		if x[i]:
    			print('第',i,'个,',end='')
    	print('')
     
    if __name__=='__main__':
    	n=5
    	c=10
    	w=[2,2,6,5,4]
    	v=[6,3,5,4,6]
    	res=bag(n,c,w,v)
    	show(n,c,w,res)
    

分治法、贪心法和动态规划异同:

  • 分治法将问题分解成子问题时,他们并不总是相互独立的,常常共享更小的子问题(重叠子问题),在采用递归的分治法求解时,相同的子问题被重复计算,动态规划解决这种子问题重叠现象
  • 贪心法要求针对问题设计最优量度标准,用于决策的贪心准则仅依赖于局部的和以前的选择,但不依赖于尚未做出的选择和子问题的解,但在很多情况下难以实现
  • 动态规划利用最优子结构自底向上从子问题的最优解逐步构造出整个问题的最优解,其每一步的决策依赖子问题的解,可以处理不具备贪心准则的问题

五、回溯法和分支限界法

定义:采用深度优先产生状态空间树得结点,并使用剪枝函数的方法

基本概念和特征:

  1. 回溯法是比贪心法和动态规划更一般的方法
  2. 解为n-元组(x0,x1,...,xn-1)形式
  3. 通过搜索状态空间树来求问题的可行解(满足约束条件)或最优解(使目标函数取最大或最小值)
  4. 回溯法使用约束函数和限界函数来压缩需要实际生成的状态空间树的结点数
  5. 通常情况下,回溯法是为了找出满足约束条件的所有可行解
  6. 显示约束:规定每个xi取值的约束条件(解空间)
  7. 隐式约束:给出了判断一个候选解是否为可行解的条件。为满足问题的解而对不同分量之间施加的约束
  8. 回溯法的求解目标是在状态空间树上找出满足约束条件的所有可行解
  9. 目标函数:衡量每个可行解的优劣,是目标函数取最大(或最小)值得可行解为问题得最优解

剪枝函数得设计和状态空间搜索树得生成:

  • 状态空间树:描述问题解空间得树形结构

    1)树中每个结点称为一个问题状态

    2)若从跟到树中某个状态得路径代表一个候选解元组,则该状态为解状态

    3)若从根到某个解状态得路径代表一个可行解元组,则该解状态为答案状态

    4)如果求解最优化问题,还要用目标函数衡量每个答案结点,找出其中目标函数取最优质得最优答案结点

    常用剪枝函数:用约束函数剪去不含答案状态的子树;用限界函数剪去得不到最优答案结点的子树

实例:

  1. n-皇后算法:

问题描述:n-皇后要求在一个n*n的棋盘放置n个皇后,使得它们彼此不受 “ 攻击 ” ,即使得同一行、同一列以及同一斜线上不能同时存在两个皇后

# 检测(x,y)这个位置是否合法(不会被其他皇后攻击到)
def is_attack(queue, x, y):
    for i in range(x):
        if queue[i] == y or abs(x - i) == abs(queue[i] - y):
            return True
    return False


# 按列来摆放皇后
def put_position(n, queue, col):
    for i in range(n):
        if not is_attack(queue, col, i):
            queue[col] = i
            if col == n - 1:    # 此时最后一个皇后摆放好了,打印结果。
                print(queue)
            else:
                put_position(n, queue, col + 1)


n = 4       # 这里是n 就是n皇后
queue = [None for i in range(n)]        # 存储皇后位置的一维数组,数组下标表示皇后所在的列,下标对应的值为皇后所在的行。
put_position(n, queue, 0) 
  1. 子集和数

    问题描述:有n个不同正数的集合,求该集合的所有满足条件的子集,使得每个子集中的正数之和的等于另一个给定的正数M

    两种解法:

    1. 固定长度元组
    2. 可变长度元组

六、分支限界法

定义:使用剪枝函数的广度优先生成结点的求解方法

  1. FIFO分支限界法

    活结点的表是队列;按队列的FIFO原则选取下一个结点为当前扩展结点

  2. LIFO分支限界法

    活结点表是栈;按栈的LIFO原则选取下一个结点为当前扩展结点

  3. LC(优先权队列)分支限界法

    活结点表是优先权队列;按优先权队列中规定的结点优先级选取优先级最高的下一个结点为当前扩展结点

    活结点的搜索代价作为衡量各个结点优先权的标准

    1)一个答案结点X的搜索代价cost(X)定义为:从根节点开始,直到搜索到X为止所消耗的搜索时间

    1. 若X是答案结点,则c(X)是从根节点到X的搜索代价
    2. 若X不是答案结点且子树X上不含任何答案结点,则c(X)=∞
    3. 若X不是答案结点但子树X上包含答案结点,则c(X)等于子树X上具有最小搜索代价的答案结点的代价

    2)相对代价函数g(X):衡量一个结点 X 的相对代价

    1. 衡量标准一:生成答案结点前,子树X上需要生成的结点数目
    2. 衡量标准二:子树X离X最近的答案结点到X的路径长度

实例:

  1. 15迷问题

    问题描述:在一个4*4的方形棋盘上放置了15块编了号的牌,还剩下一个空格。号牌的一次合法移动是指将位于空格四周(上、下、左、右)的一块号牌移入空格位置的四种移动方式

    要求:对于任意给定的一种初始排列,通过一些列的合法移动,将其转换成目标排列

    快速判断一个初始状态是否能到达目标状态:

    当且仅当

    [sum_{k=1}^{16}{Less(k)+i+j} ]

    偶数时,可以由此初始状态到达目标状态。i、j表示空格的行、列号

    Less(k):小于号牌k但被放在了k后的号牌数目

    如图(a),Less(1)= 0, Less(4) = 1 (在4的后面比4小的只有2)、Less(12)= 6 等

  2. 带时限的作业排序

    问题描述:设由n个作业和一台处理机,每个作业所需的处理时间、要求的时限和收益用三元组(pi,di,ti),其中作业 i 所需时间为 ti,如果作业 i 能够在时限 di 内完成,将可收益 pi ,求使得总收益最大的作业子集 J

    分支限界法求解:

    1. 采用可变大小元组(x0,x1,...,xk)表示解

      隐式约束:对于选入子集 J 的作业(x0,x1,...,xk),存在着一种作业排列使 J 中作业均能完成

      目标函数:使总收益P最大的作业子集是最优解

回溯法和分支限界法的异同

相同点:都是在状态空间树上搜索问题解的算法;都是活结点表实现,都可以用约束函数剪去不含答案结点的分枝,都可用限界函数剪去不含最优解的分枝

不同点:

  1. 求解目标不同:回溯法是找出解空间树中满足约束条件的所有可行解;而分支限界法则是找出满足约束条件的一个可行解,或某种意义下的最优解
  2. 搜索方式不同:回溯法以深度优先的方式搜索解空间树,而分枝限界法则以广度优先方式搜索解空间树
  3. 对当前扩展结点的扩展方式不同:回溯法中每个活结点可能多次成为当前扩展结点,纵深方向扩展其一个孩子,然后再回溯扩展其他孩子;分支限界法中每一个活结点只有一次机会成为扩展结点,一次产生所有孩子结点,自身成为死结点,之后无需在返回该结点处

七、P和NP问题

P问题:所有能在多项式时间内确定算法求解的判定问题的集合(如:排序)

NP问题:所有在多项式时间内用不确定算法求解的判定问题的集合(并不是NotP,它是指可以在多项式的时间里验证一个解的问题

NP-hard:任意NP问题都可以在多项式时间内归约为该问题,但该问题本身不一定是NP问题。归约的意思是为了解决问题A,先将问题A归约为另一个问题B,解决问题B同时也间接解决了问题A (如:旅行商问题)

NPC:对于问题 Q∈NP,且Q是NP-hard,则称Q是NPC问题。

经典NPC问题:

  1. 点集配对问题:空间里有n个点,P(0),P(1),P(2),...P(n),把他们配对成n/2对(n是偶数),使得每个点都恰好在一个点对中。要求这所有的n/2个点对中,两点距离之和最小
  2. 最大集团判定问题
原文地址:https://www.cnblogs.com/alivinfer/p/12398237.html