渐近记号

O记号

渐近上界记号O (大O)
渐近地给出了一个函数在常量因子内的上界:
O(g(n)) = { f(n) : 存在正常量c和n0,使得对所有n ≥ n0,有0 ≤ f(n) ≤ cg(n)}
f(n) = Θ(g(n))蕴含着f(n) = O (g(n))
O可用于标识最坏情况运行时间
image

Ω记号

渐近下界记号Ω (大Ω)
渐近地给出了一个函数在常量因子内的下界:
Ω(g(n)) = { f(n) :存在正常量 c 和 n0,使得对所有 n>= n0,有 0 ≤ cg(n) ≤ f(n) for all n ≥ n0 }
f(n) = Ω(g(n))蕴含着f(n) = Ω(g(n))
Ω可用于标识最佳情况运行时间
image

Θ记号

渐近紧确界记号Θ
渐近地给出了一个函数的上界和下界:
Θ(g(n)) = { f(n) : 存在正常量c1, c2和n0,使得对所有n ≥ n0,有0 ≤ c1 g(n) ≤ f(n) ≤ c2 g(n)}
image

分治策略

三个步骤:

  1. 分解
    将问题划分为若干个子问题
  2. 求解
    递归地解这些子问题;若子问题Size足够小,则直接解决之
  3. 组合
    将子问题的解结合成原问题的解

分治算法的效率分析

一个递归式是一个函数,它由一个或多个基本情况(base case),它自身,以及小参数组成。
递归式的解可以用来近似算法的运行时间。

迭代法求运行时间

image

递归树法求运行时间

image
image

主定理法求运行时间

该方法可解如下形式的递归式
T(n) = aT(n/b) + f(n)
其中 a >=1 和 b >1 是两个常数, f(n) 是一个渐进非负函数(当n趋于无穷时, f(n) 是非负的. 如果 n/b 不是整数, 取整 n/b
主方法可解包含三种类型 f(n) 的递归式 T(n)
image
关键是看 f(n) 和 nlogba 谁比较大。
image
image
image
image

归并排序

归并排序是一种有效的排序算法,它采用分治法的思想。这个过程可以分为几个主要步骤:

分解:将待排序的数组分成两个子序列,每个子序列包含一半的元素。

递归排序:递归地对两个子序列分别进行归并排序。

合并:将两个已排序的子序列合并成一个完整的排序序列。

二维最近点对问题

二维最近点对问题是一个经典的计算几何问题,可以通过分治法高效地解决。问题的目标是在平面上给定的一组点中找出距离最近的一对点。分治算法的核心思想是将问题分解成更小的子问题,分别解决后再合并结果。以下是解决二维最近点对问题的分治法步骤:

  1. 预处理

    排序:首先,按照 x 坐标对所有点进行排序。如果 x 坐标相同,则按照 y 坐标排序。

  2. 分治步骤

    分割:将点集平均分成两半,分别位于一条垂直于 x 轴的线的两侧。这样就将问题分割成了两个规模减半的子问题。
    递归求解:分别递归地在左右两个子集中寻找最近点对。

  3. 合并步骤

    合并:比较左右两侧子集内的最近点对距离,选出较小的一个作为当前的最近点对距离 d。
    跨越边界的点对:可能存在一个点在左侧,另一个点在右侧的最近点对。为此,需要检查位于分割线两侧、距离分割线不超过 d 的点,因为只有这些点才可能与另一侧的点形成更短的距离。
    在垂直于分割线的条带内(宽度为 2d),按照 y 坐标对点进行排序。
    对于条带内的每个点,只需检查其后的几个点(实际上是常数个,通常不超过 6 个),计算距离并更新最小值。
    原因:

     几何直观
     设想一个点 P 在条带内,我们正在寻找与 P 距离小于 d 的点,其中 d 是当前已知的最近点对的距离。由于我们已经考虑了所有在同一侧(左侧或右侧)的点对,现在我们只关注可能跨越中间分割线的点对。
     矩形区域
    
     考虑围绕点 P 的一个 2d×2d2d×2d 的正方形区域。由于我们已经找到了一个距离为 d 的点对,所以任何距离 P 超过 d 的点都不会是更接近的点对。因此,我们只需考虑这个正方形内的点。
     点的数量限制
    
     根据鸽巢原理,如果正方形内有多于 4 个点,那么至少有两个点的距离会小于 d(因为这些点必须“共享”正方形内的空间)。但这与我们的假设矛盾——我们假设最近的点对距离是 d。因此,在任何 2d×2d2d×2d 的区域内,最多只能有 4 个点。
     进一步细分
    
     进一步地,我们将这个正方形细分为四个 d×d 的小正方形。由于每个小正方形内最多只能有一个点(否则会有更近的点对出现),在纵向相邻的两个小正方形中,最多只能有两个点。考虑到纵向排列,一个点 P 最多需要与其上方和下方的小正方形中的点进行比较。
     每个点的比较数量
    
     由于每个小正方形最多包含一个点,所以 P 最多需要与其上方或下方的两个小正方形中的 2 个点比较。加上正方形中水平排列的点(最多 2 个),所以每个点最多比较 2(上下)+ 2(左右)= 4 个点。
    
  4. 返回结果

    最终结果:合并步骤的结果就是整个点集的最近点对距离。

算法复杂度

这个算法的时间复杂度是 O(n log n)。排序步骤占据 O(n log n),而分治步骤每层的复杂度是 O(n),且总共有 O(log n) 层。

这种分治算法之所以高效,是因为它在合并步骤中巧妙地减少了需要比较的点对数量,从而避免了暴力算法的 O(n²) 复杂度。

最大子数组问题

分割:将数组分成两个相等(或几乎相等)的部分。这通常是通过找到数组的中点来实现的。

递归求解:
    左侧子数组:递归地在左半部分寻找最大子数组。
    右侧子数组:递归地在右半部分寻找最大子数组。

合并:计算跨越中点的最大子数组。这意味着找到包含中点的最大子数组,其元素可以从中点向左和向右延伸。
    向左延伸:从中点开始向左,累加元素,记录最大和。
    向右延伸:从中点的下一个元素开始向右,累加元素,记录最大和。
    合并结果:将左侧和右侧的最大和相加,得到跨越中点的最大子数组和。

选择最大值:
    比较三个值:左侧最大子数组的和、右侧最大子数组的和、跨越中点的最大子数组的和。
    最大的那个值即为当前问题的解。

算法复杂度

时间复杂度:O(n log n),因为数组被递归地分成两半,每一层的合并操作需要线性时间(O(n))。
空间复杂度:O(log n),主要是递归造成的堆栈空间。

Strassen矩阵乘法

image

凸包问题

分治法解决凸包问题是一种高效的算法,通常被称为“分治凸包算法”。它的基本思想是将问题分解成更小的子问题,独立解决这些子问题,然后将这些解合并起来形成最终的解决方案。这种方法特别适用于计算机科学中的许多问题,包括寻找一组点的凸包。

凸包问题的目标是找到最小的凸多边形,该多边形完全包含给定点集。以下是用分治法求解凸包问题的基本步骤:

  1. 分解:将点集按照x坐标(或y坐标,如果x坐标相同)排序,然后将其分成两个大致相等大小的子集。

  2. 递归解决:分别递归地在两个子集上求解凸包。

  3. 合并:将两个子集的凸包合并成一个凸包。合并步骤是该算法的关键,需要找到一个能连接两个凸包的桥梁,通常是通过找到两个凸包之间最左边和最右边的切线来完成。

    • 找到两个凸包之间的上切线和下切线。
    • 去除切线以外的点。
    • 将剩下的点组合成最终的凸包。

分治凸包算法的时间复杂度是 ( O(n \log n) ),其中 ( n ) 是点集中点的数量。这使得它在处理大规模数据集时特别有效。

这里是一个简化的伪代码来说明分治法解决凸包问题的过程:

function divide_and_conquer_convex_hull(points):
    if points 的数量很少:
        直接计算凸包
        return 凸包

    将 points 排序
    left_points, right_points = 分割 points

    left_hull = divide_and_conquer_convex_hull(left_points)
    right_hull = divide_and_conquer_convex_hull(right_points)

    return merge_hulls(left_hull, right_hull)

function merge_hulls(left_hull, right_hull):
    找到连接两个凸包的切线
    去除切线以外的点
    合并剩余点为最终的凸包
    return 最终的凸包

在实际的编程实现中,寻找连接两个凸包的切线和正确合并它们可能是比较复杂的,需要仔细处理几何关系和边界情况。

棋盘覆盖问题

棋盘覆盖问题是一个经典的分治算法应用案例。问题描述是这样的:给定一个大小为 2^n × 2^n 的棋盘,其中有一个格子已经被占用(或者说是缺失),目标是使用形状为“L”的三格骨牌完全覆盖剩余的棋盘。每个骨牌覆盖3个格子,形状类似于字母“L”。

这个问题可以通过分治法有效解决,思路是将大棋盘分成更小的棋盘,递归地解决每个小棋盘的覆盖问题,然后合并这些解以覆盖整个棋盘。下面是具体的步骤:
步骤

分割棋盘:将原始的 2^n × 2^n 棋盘分割成四个 2^(n-1) × 2^(n-1) 的小棋盘。这些小棋盘在逻辑上位于原棋盘的左上、右上、左下、右下。

确定缺失格子所在的小棋盘:判断原始棋盘中缺失的单个格子属于哪一个小棋盘。然后在每个其余的三个小棋盘中,选择一个角落的格子作为“人工缺失”的格子。

覆盖每个小棋盘:对每个小棋盘,递归地应用棋盘覆盖算法。现在,每个小棋盘都有一个缺失的格子(原始的或人工指定的)。

递归基:当小棋盘的大小减小到 2×2 时,可以直接用一个“L”形骨牌覆盖剩下的三个格子。这是递归的基本情形。

合并解决方案:通过覆盖所有小棋盘,整个棋盘就被覆盖了。

算法复杂度

时间复杂度:每一次分割都将问题规模减半,但需要处理四个子问题,因此时间复杂度为 O(4^log n) = O(n^2)。
空间复杂度:由于算法的递归性质,空间复杂度也是 O(n^2)。

关键点

递归:算法的核心是递归地将大问题分解为小问题。
分治:通过分割棋盘并独立解决每个部分,实现了问题的分而治之。
覆盖技巧:在每个阶段为三个没有原始缺失格子的小棋盘人工添加一个缺失格子,这是整个算法能够工作的关键。

回溯法(Backtracking)

它通常用于解决那些问题,其中需要在一组可能的解中找到满足特定条件的一个或多个解,或者找到最优解。回溯法通过逐步构建候选解并检查其有效性来逐步搜索解空间,如果发现某个候选解无法满足问题的要求,就会回退到之前的状态,尝试其他可能的解。

回溯法的基本思想可以概括为以下步骤:

选择一个候选解的起始点。
逐步构建候选解,每一步都根据问题的要求选择一个可能的下一步。
检查当前候选解是否满足问题的要求,如果满足则继续构建下一步,如果不满足则回退到之前的状态。
重复步骤 3 直到找到一个满足要求的解或者遍历完所有可能的解空间。

回溯法通常用于解决诸如八皇后问题、旅行商问题、子集和问题等组合优化问题,以及迷宫问题、数独问题等搜索问题。它的关键是能够剪枝,即在搜索过程中通过某些条件判断可以提前终止不符合要求的搜索分支,以减少搜索的时间复杂度。
为了避免生成那些不可能产生最佳解的问题状态,减少无效搜索,要不断地利用限界函数(bounding function)来处死那些实际上不可能产生所需解的活结点,以减少问题的计算量。
具有限界函数的深度优先搜索法称为回溯法。

N后问题

初始化空棋盘(起始状态);
从第一行开始,直至第一行出现回溯
	  在当前行r中查找下一个可以放置皇后的位
	  如果找到了可以摆放的位置
		  放下一个皇后
		  如果已经是最后一行
				得到一个解
				撤掉该子,继续寻找下一个解
		  否则(未到最后一行)
				准备处理下一行
   否则(没有找到可以摆放的位置)
		   回溯到上一行,并撤掉该行的棋子

0-1背包问题

如果当前背包中的物品总重量是cw,前面k-1件物品都已经决定好是否放入包中,那么第k件物品是否放入包中取决于不等式cw + wk <= M 是否满足——这即是搜索的约束函数.

图的m着色问题

若判断图为m可着色的,可把着色问题安排到一棵图的顶点构成的树中,利用试探和回溯去求解。

初始化(起始状态);
从第一个顶点开始
	由当前顶点安排下一个顶点可设置的颜色
	如果找到了可以设置的颜色
		置顶点颜色
		如果已经是最后一个顶点
			得到一个解
			撤掉该子,继续寻找下一个解
		否则(未到最后)
			准备处理下一个顶点
	否则(没有找到可以设置的颜色)
		回溯到上一个顶点,并去除该顶点的颜色

贪心法

0/1 背包问题

但是对于部分背包问题有最优的贪心算法,就是以最大价值重量比优先为基础的选择准则。
这种贪心算法的原理如下(更多细节详读回溯法章节):
根据价值/重量比降序排列所有物件。
根据顺序依次将这些物件添加到背包中直到没有更多的物件或者下一个物件添加后会超出背包的承受范围。
如果背包还是没有超出承受重量,用未选择的部分物件填满它。

活动选择问题

使用“选择具有最早结束时间的活动”作为我们的贪心策略。

证明贪心活动选择的最优性

要证明贪心活动选择是最优的,我们需要证明使用贪心策略总是能得到活动选择问题的最优解。活动选择问题的目标是在给定的活动集合中,每个活动都有一个开始时间和结束时间,选出尽可能多的互不冲突的活动。贪心策略通常是选择结束时间最早的活动。

假设存在一个最优解,它不包括 a1 而是包括另一个活动 ak(其中 k > 1)。由于 ak 是最优解的一部分,那么在 ak 之后的所有活动都不能在 f[1] 之前开始,否则它们会与 ak 冲突。

现在,如果我们从最优解中移除 ak 并替换为 a1,我们会得到另一个解。由于 a1 的结束时间不晚于 ak,因此所有在原最优解中跟在 ak 后面的活动仍然是有效的,因此这个新解仍然是有效的。更重要的是,由于 a1 的结束时间不晚于 ak,我们没有减少任何可以选择的活动数量,因此这个新解至少与原来的最优解一样好。

因此,选择 a1 不会导致丢失最优解。由于 a1 是所有活动中结束时间最早的,选择 a1 是一个最佳的方案。

这里的关键证明涉及两个要点:

  1. 贪心选择属性:证明第一步的贪心选择(选择结束时间最早的活动)是安全的,即它不会排除解集中的任何活动。

  2. 最优子结构:证明问题的最优解包含了由贪心策略做出的选择,即在做出第一步选择之后,剩余子问题的最优解与整个问题的最优解一致。

活动选择问题是一个经典的贪心算法应用案例。在这个问题中,给定一系列活动,每个活动都有一个开始时间和结束时间,目标是选择最大数量的互不冲突的活动。

贪心算法解决活动选择问题的步骤如下:

排序:首先,根据活动的结束时间对所有活动进行升序排序。这是因为选择结束最早的活动可以留出更多时间进行其他活动。

选择第一个活动:选择结束时间最早的活动,然后排除所有与它冲突的活动(即所有开始时间小于或等于这个活动的结束时间的活动)。

重复选择:对剩余的活动重复上述步骤,直到没有更多的活动可以选择。

下面是使用贪心算法解决活动选择问题的伪代码:

function greedyActivitySelector(s, f):
	"""
	s: 活动的开始时间列表
	f: 活动的结束时间列表,已根据结束时间排序
	"""
	n = s.length
	A = [1]  # 选择第一个活动
	k = 1
	for m in range(2, n):
		if s[m] >= f[k]:  # 如果这个活动的开始时间不早于上一个选定活动的结束时间
			A.append(m)  # 选择这个活动
			k = m
	return A

在这个伪代码中,s 和 f 分别是开始时间和结束时间的列表,它们按照活动的结束时间排序。数组 A 存储被选中的活动的索引。算法从第一个活动开始,然后遍历剩下的活动,如果一个活动的开始时间大于或等于当前选中活动的结束时间,那么这个活动就会被选择。

这种贪心策略能保证找到最优解,因为每次选择结束最早的活动,都会为剩余的选择留下最大的时间窗口,从而使得可以选择的活动总数最大化。

贪心选择性质(Greedy Choice Property)

贪心选择性质指的是局部最优选择能够导致全局最优解。对于活动选择问题,贪心选择是选择结束时间最早的活动。我们需要证明,通过选择结束时间最早的活动,我们不会错过任何导致全局最优解的选择。

假设有一组活动按结束时间排序:a1, a2, ..., an,其中 ai 比 aj 更早结束(i < j)。如果选择了 ai,它将不与 ai+1, ai+2, ..., an 中结束更晚的活动冲突。因此,选择 ai 会留下尽可能多的剩余时间来安排其他活动,这是一个局部最优选择。由于结束时间最早的活动不会排除任何其他可能的选择,这个局部最优选择也是全局最优的。

最优子结构(Optimal Substructure)

最优子结构意味着问题的最优解包含其子问题的最优解。在活动选择问题中,一旦选择了结束时间最早的活动,我们面临的是一个更小的子问题:在剩余活动中选择最大的相容活动集。

设 Sij 是在活动 ai 结束和活动 aj 开始之间的活动集合。如果 ak 是 Sij 中结束最早的活动,那么 Sij 的最优解包含 ak 和 Sik 与 Skj 的最优解。这表明原问题的最优解由子问题的最优解构成。

最大流问题

给定一个具有源点s和汇点t的流网络G,找出从s到t的最大值流
割(( S, T ))的容量是指在流网络中,从一组节点 ( S ) 到另一组节点 ( T ) 的边的容量之和,其中 ( S ) 和 ( T ) 是网络的一种分割,使得源点 ( s ) 在集合 ( S ) 中,而汇点 ( t ) 在集合 ( T ) 中。这种分割实际上是将网络分成两部分,一部分包括源点 ( s ),另一部分包括汇点 ( t )。

具体来说,割 ( (S, T) ) 的容量计算如下:

  • 首先,将网络中的所有节点分为两个不相交的集合 ( S ) 和 ( T )。
  • 然后,考虑所有从 ( S ) 中的节点出发,到 ( T ) 中的节点的边。
  • 割的容量是这些边的容量之和。

例如,假设有一个边从集合 ( S ) 中的节点 ( u ) 指向集合 ( T ) 中的节点 ( v ),并且这条边的容量是 ( c(u, v) )。那么,这条边对割 ( (S, T) ) 的容量的贡献就是 ( c(u, v) )。将所有这样的边的容量加起来,就得到了整个割的容量。

在最大流最小割定理中,这个概念是非常重要的。该定理指出,在一个流网络中,从源点到汇点的最大流量等于网络中最小割的容量。这意味着,网络中流量的最大可能值由最容易“堵塞”的那个地方(即容量最小的割)决定。

Ford-Fulkerson方法

Ford-Fulkerson 方法是解决最大流问题的一种算法。它适用于包含有向边的网络,其中每条边都有一个非负的容量。这个方法的目的是找出从网络中的一个源点 ( s ) 到一个汇点 ( t ) 的最大可能流量。以下是其基本概念和步骤:

基本概念

  1. 残留网络 (Residual Network): 这是一个动态更新的网络,表示在当前流量配置下,网络中各边还能够承载多少额外流量。

  2. 增广路径 (Augmenting Path): 残留网络中从源点 ( s ) 到汇点 ( t ) 的一条路径,该路径上的每一条边都有正的剩余容量。

算法步骤

  1. 初始化: 流量设置为零。

  2. 寻找增广路径: 在残留网络中找到一条从 ( s ) 到 ( t ) 的增广路径。如果找不到这样的路径,算法终止。

  3. 增加流量:

    • 对找到的增广路径上的每一条边,计算剩余容量(即边的容量减去已有的流量)。
    • 找出这些剩余容量中的最小值,这是该路径上可以增加的最大流量。
    • 对路径上的每一条边增加这个最大流量。
  4. 更新残留网络: 根据增加的流量,更新残留网络中边的容量。

  5. 重复: 重复步骤 2 至 4,直到找不到增广路径为止。

注意事项

  • Ford-Fulkerson 方法的效率取决于增广路径的选择。错误的选择可能导致算法运行时间长。
  • 在特定情况下(如边容量为实数时),Ford-Fulkerson 方法可能不会终止。为了保证算法的多项式时间复杂度,可以使用其变体,如 Edmonds-Karp 算法,它在寻找增广路径时使用广度优先搜索(BFS),确保找到的是最短增广路径。

Ford-Fulkerson 方法在理论和实践中都非常重要,是理解和解决最大流问题的基础。

动态规划

动态规划(Dynamic Programming,简称DP)是一种解决复杂问题的算法设计和优化技术。它通常用于解决具有重叠子问题和最优子结构性质的问题,这些问题可以分解为较小的子问题,然后将这些子问题的解合并以获得原问题的解。

动态规划的核心思想是将问题分解成多个子问题,逐步解决这些子问题,并将它们的解存储起来,以避免重复计算。这种存储和重用已解决子问题的结果是动态规划的关键特点,它有助于降低问题的时间复杂度。

动态规划通常在以下情况下被应用:

  1. 最优化问题:当需要找到问题的最优解(如最大值或最小值)时,动态规划是一个常用的方法。

  2. 问题具有重叠子问题性质:如果问题可以被分解为相同或相似的子问题,那么动态规划通常是一个有效的解决方案,因为它可以避免重复计算子问题。

  3. 问题具有最优子结构性质:问题的全局最优解可以通过子问题的最优解来构建。

动态规划的一般步骤包括以下几个阶段:

  1. 定义子问题:将原问题分解为较小的子问题,通常通过递归的方式来定义这些子问题。

  2. 寻找递归关系:确定子问题之间的关系,即如何将子问题的解组合成原问题的解。

  3. 解决子问题:使用适当的方式来解决子问题。通常可以使用递归或迭代来解决。

  4. 存储子问题的解:为了避免重复计算,将已解决的子问题的解存储在数据结构中,如数组或字典。

  5. 构建原问题的解:根据子问题的解和递归关系,构建原问题的解。

动态规划可以用于解决各种问题,包括最短路径问题、背包问题、编辑距离问题、棋盘覆盖问题等。

0/1背包问题

动态规划是解决背包问题的一种有效方法。背包问题是一个经典的优化问题,通常描述为:给定一组物品,每个物品都有自己的重量和价值,确定在不超过背包最大重量限制的情况下,如何选择物品以使得背包中物品的总价值最大。

这里是动态规划求解背包问题的基本步骤:

  1. 定义状态:首先定义一个二维数组 dp[i][w],其中 i 表示考虑到第 i 个物品时,w 表示当前背包的重量。dp[i][w] 的值表示在这种情况下的最大价值。

  2. 状态转移方程:这是解决问题的核心。状态转移方程通常为:

    [ dp[i][w] = \max(dp[i-1][w], dp[i-1][w-weight[i]] + value[i]) ]

    这意味着对于每个物品,你可以选择不将其加入背包(保持价值不变),或者将其加入背包(价值增加,但需要考虑背包重量限制)。

  3. 初始化:通常将 dp[0][...]dp[...][0] 初始化为0,因为没有物品或背包容量为0时,能获得的最大价值为0。

  4. 填充表格:按照从小到大的顺序填充数组,最终 dp[n][W](其中 n 是物品数量,W 是背包容量)就是问题的答案。

  5. 追踪最优解(可选):如果需要知道是哪些物品被选中,可以通过追踪状态转移来找到这些物品。

让我们通过一个简单的例子来说明这个过程。假设有三个物品,其重量分别为 [2, 3, 4],价值分别为 [4, 5, 6],背包的最大承重为5。我们可以通过编写代码来实现这个动态规划过程。
当然,我可以提供一个用于解决背包问题的 C++ 示例代码。在这个例子中,我们将使用之前提到的物品和背包容量:物品的重量为 [2, 3, 4],价值为 [4, 5, 6],背包的最大承重为5。

以下是解决这个特定背包问题的 C++ 代码:

#include <iostream>
#include <vector>
using namespace std;

int knapsack(int W, vector<int>& wt, vector<int>& val) {
    int n = wt.size();
    vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0));

    for (int i = 1; i <= n; i++) {
        for (int w = 1; w <= W; w++) {
            if (wt[i - 1] <= w) {
                dp[i][w] = max(dp[i - 1][w], dp[i - 1][w - wt[i - 1]] + val[i - 1]);
            } else {
                dp[i][w] = dp[i - 1][w];
            }
        }
    }

    return dp[n][W];
}

int main() {
    vector<int> wt = {2, 3, 4};
    vector<int> val = {4, 5, 6};
    int W = 5;

    cout << "Maximum value in knapsack = " << knapsack(W, wt, val) << endl;
    return 0;
}

在这个代码中,knapsack 函数接受背包的最大重量 W,物品重量数组 wt 和物品价值数组 val 作为输入,并返回背包能够达到的最大价值。我们使用一个二维动态数组 dp 来存储每一步的结果。这个解法的时间复杂度和空间复杂度都是 O(nW),其中 n 是物品数量,W 是背包容量。

切杆问题

矩阵链乘法

最长公共子序列(LCS)

动态规划是解决最长公共子序列(LCS)问题的一个非常有效的方法。最长公共子序列指的是,对于两个序列,找到一个最长的子序列,它同时存在于这两个序列中。与子串不同,子序列不要求元素在原序列中连续。

以下是使用动态规划求解最长公共子序列问题的步骤:

  1. 定义状态:定义一个二维数组 dp[i][j],其中 dp[i][j] 表示第一个序列的前 i 个元素和第二个序列的前 j 个元素的最长公共子序列的长度。

  2. 状态转移方程

    • 如果两个序列的当前元素相同,即 X[i] == Y[j],那么这个元素一定在两个序列的最长公共子序列中:dp[i][j] = dp[i-1][j-1] + 1
    • 如果不相同,那么最长公共子序列要么在 X[i-1]Y[j] 中,要么在 X[i]Y[j-1] 中:dp[i][j] = max(dp[i-1][j], dp[i][j-1])
  3. 初始化:将 dp[0][j]dp[i][0] 初始化为0,因为一个空序列和任何序列的最长公共子序列长度都是0。

  4. 填充表格:按照顺序填充 dp 数组,最后 dp[m][n](其中 mn 分别是两个序列的长度)就是问题的答案。

  5. 追踪最优解(可选):如果需要,可以通过反向追踪 dp 数组来构造出最长公共子序列。

下面是用 C++ 实现的一个示例代码,该代码解决了两个给定字符串的最长公共子序列问题:

#include <iostream>
#include <vector>
#include <string>
using namespace std;

int lcs(string &X, string &Y) {
    int m = X.size();
    int n = Y.size();
    vector<vector<int>> dp(m + 1, vector<int>(n + 1));

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (X[i - 1] == Y[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];
}

int main() {
    string X = "AGGTAB";
    string Y = "GXTXAYB";
    cout << "Length of LCS is " << lcs(X, Y) << endl;
    return 0;
}

这个代码首先创建了一个二维数组 dp 来存储中间结果,然后根据两个输入字符串逐个字符比较来填充这个数组。最终,dp[m][n] 就包含了最长公共子序列的长度。

作业题目

最小化所有子序列中最大值的和

这个问题可以通过动态规划来解决,因为它具有优化子结构和子问题重叠的特点。动态规划的核心在于将大问题分解为小问题,并对小问题的解进行存储和复用,从而避免重复计算。

优化子结构

优化子结构指的是一个问题的最优解包含其子问题的最优解。在这个问题中,我们可以将整个序列分为若干子序列,其中每个子序列也需要满足相同的条件(即子序列的整数和不大于B)。这意味着,要最小化所有子序列中最大值的和,我们需要在每一步划分中也做到最优。

子问题重叠

子问题重叠是指在解决过程中,同一子问题会被多次遇到和解决。在这个问题中,考虑到每个子序列都可以有不同的起点和终点,很多子序列会在不同的划分中重复出现。因此,为避免重复计算,可以使用动态规划中的备忘录方法来存储已解决的子问题。

解题思路

  1. 定义状态:定义 dp[i] 为考虑到第 i 个元素时,所有子序列中最大值的和的最小值。

  2. 状态转移方程:要计算 dp[i],我们需要遍历所有可能的子序列结束位置 j(从1到i),并保证这个子序列的和不大于B。对于每个可能的 jdp[i] 是所有 dp[j-1] + max(a[j],...,a[i])(即前一个子序列的最优解加上当前子序列中的最大值)中的最小值。状态转移方程可以表示为:

    [ dp[i] = \min_{1 \leq j \leq i} { dp[j-1] + \max(a[j],...,a[i]) } ]
    image

    其中,要保证子序列 a[j],...,a[i] 的和不超过B。

  3. 初始化dp[0] 应初始化为0,表示没有元素时最大值的和为0。

  4. 填充表格:按照顺序从1到N计算 dp[i] 的值。

这种方法的时间复杂度可能相对较高,因为它涉及嵌套循环来计算每个 dp[i]。为了优化性能,可能需要额外的技巧或数据结构来降低复杂度,例如使用前缀和来快速计算子序列的和。

下面是一个简单的 C++ 实现示例:

#include <iostream>
#include <vector>
#include <climits>
using namespace std;

int minMaxSumSubsequence(vector<int>& nums, int B) {
    int N = nums.size();
    vector<int> dp(N + 1, INT_MAX);
    dp[0] = 0;

    for (int i = 1; i <= N; ++i) {
        int sum = 0, maxVal = 0;
        for (int j = i; j >= 1; --j) {
            sum += nums[j - 1];
            maxVal = max(maxVal, nums[j - 1]);
            if (sum > B) break;
            dp[i] = min(dp[i], dp[j - 1] + maxVal);
        }
    }

    return dp[N];
}

int main() {
    vector<int> nums = {2, 2, 2, 8, 1, 8, 2, 1};
    int B = 17;
    cout << "Minimum sum of maximum values of subsequence is " << minMaxSumSubsequence(nums, B) << endl;
    return 0;
}

这个代码实现了上述的动态规划方法,通过两层循环遍历所有可能的子序列,并计

算每一步的最优解。最终,dp[N] 存储了整个序列的最优解。

流水线调度问题

这个问题是一个经典的流水线调度问题,也被称为 Johnson's Rule(约翰逊规则)问题。它的目标是找出一种作业顺序,使得所有作业在两台机器上完成加工的总时间最短。约翰逊规则提供了一种高效的方法来找到这种最优序列。

约翰逊规则的基本步骤如下:

  1. 选择规则:在所有未安排的作业中,选择加工时间最短的作业。如果最短时间出现在机器 M1 上,则将该作业安排在当前序列的最前面;如果最短时间出现在机器 M2 上,则将该作业安排在当前序列的最后面。

  2. 分配作业:将选择的作业从候选列表中移除,并根据上述规则安排到序列中。

  3. 重复步骤:重复以上步骤,直到所有作业都被安排。

这种方法的关键在于,它能够有效减少等待时间和空闲时间,确保作业能够连续无间断地在两台机器上加工。

下面是一个简单的示例,说明如何应用约翰逊规则解决这个问题。

假设有三个作业,它们在 M1 和 M2 上的加工时间如下:

  • 作业1: a1 = 3, b1 = 2
  • 作业2: a2 = 2, b2 = 5
  • 作业3: a3 = 4, b3 = 3

根据约翰逊规则,我们首先找出所有作业中 M1 和 M2 加工时间最短的一个,这里是作业2的 M1 时间(2)。因此,我们首先安排作业2。然后,我们比较剩余的作业,发现作业1的 M2 时间是最短的,所以我们将作业1安排在最后。剩下作业3安排在作业2之后。

因此,作业的最优顺序是:作业2,作业3,作业1。

这个规则适用于大多数情况,但在某些特定情况下可能需要进行调整。例如,当存在多个作业具有相同的最短加工时间时,可能需要考虑其他因素(如作业的紧急程度)来确定顺序。尽管如此,约翰逊规则通常提供了一个非常有效的启发式方法来解决这类问题。

行列递增矩阵中搜索x

为了设计一个高效的算法来在这样的矩阵中搜索给定的数字 x,并证明其正确性,我们可以采用以下贪心策略:

算法步骤

  1. 开始位置:从矩阵的右上角开始,即位置 (1, n)(如果矩阵的索引从1开始)或 (0, n-1)(如果矩阵的索引从0开始)。

  2. 比较和移动

    • 如果当前元素等于 x,搜索成功。
    • 如果当前元素大于 x,向左移动(即减少列索引)。
    • 如果当前元素小于 x,向下移动(即增加行索引)。
  3. 重复:重复第2步,直到找到元素或者搜索范围超出矩阵边界。

算法正确性证明

  • 单调性:由于矩阵的每行和每列都是递增的,所以向左移动会遇到较小的元素,向下移动会遇到较大的元素。

  • 贪心选择性:在任意位置,我们可以根据当前元素与 x 的比较结果作出决策。如果当前元素大于 x,那么当前列中的所有元素都会大于 x,因此我们可以排除这一列,向左移动。如果当前元素小于 x,那么当前行中的所有元素都会小于 x,因此我们可以排除这一行,向下移动。

  • 优化复杂性:在最坏的情况下,算法会沿着矩阵的边界移动,即首先沿着最上面的一行向左移动,然后沿着最左边的一列向下移动,共进行最多 2n-1 次比较(n-1 次向左,n 次向下)。

实例演示

考虑一个 3x3 的矩阵,举例说明这个算法:

| 1  4  7 |
| 2  5  8 |
| 3  6  9 |
  • 假设 x=5,我们从 (1, 3) 或 (0, 2) 开始:

    • 比较 7 > 5,向左移动。
    • 比较 4 < 5,向下移动。
    • 比较 5 = 5,找到 x。
  • 假设 x=10,我们同样从右上角开始:

    • 比较 7 < 10,向下移动。
    • 比较 8 < 10,向下移动。
    • 比较 9 < 10,向下移动并超出矩阵边界,未找到 x。

因此,这个算法不仅能在 O(2n) 时间复杂度内确定 x 是否在矩阵中,其决策过程也保证了算法的正确性。

posted on 2023-12-31 16:59  FrostyForest  阅读(5)  评论(0编辑  收藏  举报