文章目录
- 一、动态规划算法理论
- 二、典型例题
-
- [2.1 Weighted Interval Scheduling带权区间调度](#2.1 Weighted Interval Scheduling带权区间调度)
- [2.2 Knapsack Problem背包问题](#2.2 Knapsack Problem背包问题)
- [2.3 LCS & LIS](#2.3 LCS & LIS)
- [2.4 矩阵连乘](#2.4 矩阵连乘)
- [2.5 Sequence alignment & 编辑距离](#2.5 Sequence alignment & 编辑距离)
- [2.5 最大子数组和](#2.5 最大子数组和)
- [2.6 Dropping Eggs](#2.6 Dropping Eggs)
- [2.7 Coin Changing](#2.7 Coin Changing)
- [2.8 多阶段单目标最短路径问题](#2.8 多阶段单目标最短路径问题)
- [2.9 **Segmented Least Squares** 分段最小二乘法](#2.9 Segmented Least Squares 分段最小二乘法)
- 三、真题
- 三、真题
- 结束语
- 💂 个人主页 :风间琉璃
- 🤟 版权 : 本文由【风间琉璃】原创、在CSDN首发、需要转载请联系博主
- 💬 如果文章对你有
帮助
、欢迎关注
、点赞
、收藏(一键三连)
和订阅专栏
哦
前言
算法范式
- 贪心法 (Greed)
- 思想 :按照某种顺序处理输入数据,在每一步选择中,贪心地(即局部最优地)做出不可撤销的决策。
- 分析 :贪心算法适用于具有"最优子结构"的问题,即当前的局部最优选择不会影响全局最优解。常用于解决如最小生成树(Kruskal 算法)、活动选择问题等问题。
- 分治法 (Divide-and-Conquer)
- 思想 :将问题分解为一组相互独立的子问题,分别解决每个子问题,然后将子问题的解合并成原问题的解。
- 分析 :分治算法通常适用于递归式结构 的问题,子问题之间无相互依赖关系。经典案例包括归并排序、快速排序、二分搜索和矩阵乘法等。
- 动态规划 (Dynamic Programming)
- 思想 :将问题划分为一系列重叠的子问题,通过记忆化存储子问题的解(避免重复计算),逐步组合形成较大规模子问题的解,最终解决原问题。
- 分析 :动态规划适用于"最优子结构 "和"子问题重叠"的问题。典型应用包括最长公共子序列问题、背包问题、Floyd-Warshall 最短路径算法等。
贪心算法注重局部最优,而动态规划通过比较和记忆全局最优解,适合问题之间存在依赖性 的场景。分治法通常子问题是相互独立的,适合无重叠的递归问题。
参考:
一、动态规划算法理论
动态规划(Dynamic Programming, DP)是一种求解优化问题的算法,通过将原问题分解为若干个子问题,利用子问题的解来构建原问题的解。动态规划的核心思想是避免重复计算,利用重叠子问题 和最优子结构这两个性质来提高算法的效率。
动态规划的两种常见实现方式
- 自底向上(递推) :从最基本的子问题开始,通过递推计算出最终解。这通常使用一个数组或表格来存储中间结果,避免重复计算。
- 自顶向下(记忆化搜索) :使用递归的方 式解决问题,并在每次计算子问题时将结果存储起来,避免对已经解决的子问题进行重复计算。
动态规划问题分析是自顶而下 的思路,但是算法实现却是自底而上 的策略。动态规划与分治法类似,都是把大问题拆分成小问题,通过寻找大问题与小问题的递推关系,解决一个个小问题,最终达到解决原问题的效果。但不同的是,分治法在子问题和子子问题等上被重复计算了很多次,而动态规划则具有记忆性 ,通过填写表把所有已经解决的子问题答案纪录下来,在新问题里需要用到的子问题可以直接提取,避免了重复计算,从而节约了时间,所以在问题满足最优性原理之后,用动态规划解决问题的核心就在于填表,表填写完毕,最优解也就找到。
二、典型例题
动态规划常见问题类型
- 背包问题:如 0-1 背包问题、完全背包问题。
- 最短路径问题:如贝尔曼-福德算法。
- 最长子序列问题:如最长上升子序列(LIS)、最长公共子序列(LCS)。
- 划分问题:如最小划分问题、分割数组问题。
2.1 Weighted Interval Scheduling带权区间调度
加权间隔调度问题:作业 j 从 s j s_j sj开始,到 f j f_j fj结束,权重或价值为 v j v_j vj 。 如果两个作业不重叠,则它们兼容。
目标:查找相互兼容的作业的最大权重子集。
如果所有权重均为 1,则贪婪算法有效。 按完成时间的升序考虑作业。 如果作业与以前选择的作业兼容,则将其添加到子集。 如果允许任意权重,贪婪算法可能会失败。
下面考虑使用DP解决带权区间调度问题:
1.分析最优解的结构
-
将作业按照完成时间升序排列: f 1 ≤ f 2 ≤ ⋯ ≤ f n f_1 \leq f_2 \leq \dots \leq f_n f1≤f2≤⋯≤fn
-
定义两个参数:
- p ( j ) p(j) p(j) : p ( j ) = p(j)= p(j)= 与工作j相容的最大作业i,且i < j,即i是在j开始之前结束的区间。如下图所示,p(1) = 0, p(2) = 0, p(3) = 0, p(4)=1, p(5)=0, p(6)=2, p(7)=3, p(8)=5
- OPT(j): OPT(j)表示由作业1,2,...,j 组成的问题的最优解值。
OPT(j)有两种选择方案:
-
选择作业j
如果选择作业j,作业j包含在最优解当中,原问题退化 v j + O P T ( p ( j ) ) v_j + OPT(p(j)) vj+OPT(p(j)),向前递推一步,j之前能选择到最优解是OPT(p(j))。
即 O P T ( j ) = v j + O P T ( p ( j ) OPT(j) = v_j +OPT(p(j) OPT(j)=vj+OPT(p(j)
-
不选择作业j
如果不选择作业j,作业j不在最优解当中,原问题退化为OPT(j-1),即从作业1,2,3,...,j-1中找到最优解
即 O P T ( j ) = O P T ( j − 1 ) OPT(j) = OPT(j-1) OPT(j)=OPT(j−1)
2.建立递归关系
O P T ( j ) = { 0 i f j = 0 m a x { v j + O P T ( p ( j ) , O P T ( j − 1 ) } o t h e r w i s e OPT(j) = \left\{\begin{matrix} 0 & if \ j=0\\ max\{ v_j +OPT(p(j), OPT(j-1)\} & otherwise \end{matrix}\right. OPT(j)={0max{vj+OPT(p(j),OPT(j−1)}if j=0otherwise
当j=0时,结果为0,这是边界条件。
代码实现如下:
-
递归法
递归会使得空间复杂度变高,一般不建议使用。
- 自底向上法
练习:给定8个候选活动的开始时间、结束时间和权重,请根据递归式求解这个具体的活动安排问题,要求给出求解过程表格、最优解和最优值。
结果:按照状态方程对OPT(j)一步一步的计算,不要不按状态方程写,不然容易写乱,这题还好,数据不多。
2.2 Knapsack Problem背包问题
问题:有n个物品,其各自的重量和价值分别为 w i 、 v i w_i、v_i wi、vi,现给定容量为W的背包,如何让背包里装入的物品具有最大的价值总和?
令OPT(i,w)表示在前i个物品中,背包容量为w时能够获得的最大价值。即最终的目标是计算OPT(n,W),即在前 n 个物品中,背包容量为 W 时的最大价值。
对于物品i有以下两种选择:
- 不选择物品i
如果背包中不选择物品i,那么只需要考虑前 i−1个物品,在原来的重量限制w下的最大价值。即: OPT ( i , w ) = OPT ( i − 1 , w ) \text{OPT}(i, w) = \text{OPT}(i-1, w) OPT(i,w)=OPT(i−1,w)。
- 选择物品i
如果背包中选择了物品i,则需要加上物品i的价值,即 v i v_i vi。然后更新背包的剩余容量,新的容量变为 w − w i w - w_i w−wi(原来的容量减去物品i的重量)。继续计算剩下物品的最优解,即在前i-1个物品中,新的背包容量 w − w i w - w_i w−wi下的最大价值: OPT ( i , w ) = v i + OPT ( i − 1 , w − w i ) \text{OPT}(i, w) = v_i + \text{OPT}(i-1, w - w_i) OPT(i,w)=vi+OPT(i−1,w−wi)
状态转移方程如下:
算法实现:
在自底向上的动态规划中,将从最小子问题(即没有物品或容量为 0 时)开始逐步计算直到最终问题,创建一个大小为 ( n + 1 ) × ( W + 1 ) (n+1) \times (W+1) (n+1)×(W+1)的二维数组OPT,其中OPT(i,w)表示前 i 个物品,背包容量为 w 时的最大价值。
这里不仅要求会画状态转移表,还需要从表中恢复出选中的物品,实际上是通过跟踪物品的选择路径来完成的。这是因为在填充DP表时,在选择是否放入当前物品时记录了"选择"与"不选择"的决策。恢复选中的物品需要逆向推导这个决策过程。
从 OPT(n, W) 开始,检查当前物品是否被选中:
- 如果
OPT(i, w)
与OPT(i-1, w)
相等,表示物品 i 没有被选中。 - 如果
OPT(i, w)
不等于OPT(i-1, w)
,表示物品 i 被选中了。此时,将物品i加入选中的物品列表,并将背包容量更新为 w − w i w - w_i w−wi。
重复以上过程,直到回溯到i=0,即没有物品可选。从上图可知,选中的物品是第 3 个物品和第 4 个物品。
在构建01背包问题的最优解时,我们依据状态转移方程来决定物品的选取。特别地,当方程显示不选取某物品时,即opt(i, w) = opt(i-1, w),我们可以通过观察动态规划表格来推断出哪些物品最终被选中。例如,如果在表格的最后一列中出现了重复的最大价值,如两个连续的40,这意味着第5个物品没有被纳入背包。继续向上追溯,如果直到某一行的值不再重复,则表明该行的物品必须被选中。
在这个例子中,我们看到第4个物品的价值为22,因此我们可以推断出在选择第4个物品之前的最大价值为40减去22,即18。此时,18对应的列指示我们继续向上回溯。当物品数量减少到三个时,如果列中的数值不再重复,我们可以确定第3个物品必须被选中。因此,根据这一逻辑,最终选择的物品为3和4。
0-1 背包问题 的动态规划算法时间复杂度为 Θ ( n W ) \Theta(nW) Θ(nW),其中 n 是物品的数量,W 是背包的最大容量。但是由于该时间复杂度包含 W 的因素,0-1 背包问题的时间复杂度称为伪多项式时间,这是因为==它依赖于问题的数值大小(即 W 和物品的重量)而不仅仅是输入的位数。==因此该问题是一个NP问题。
伪多项式时间 是指算法的时间复杂度依赖于输入中数值的大小,而不是输入的位数。当一个算法的时间复杂度是输入值的大小的多项式时,这个算法被称为伪多项式时间算法。
多项式时间 是指算法的时间复杂度仅依赖于输入的位数(例如 n个物品的二进制表示长度),而不依赖于输入的数值大小。
如果 W 很大,动态规划表的大小和计算的次数就会非常多,导致算法的运行时间增加。虽然 W是输入的一部分,但它在输入中的表示是需要 O ( log W ) O(\log W) O(logW)位的。因此,算法的时间复杂度依赖于输入值的大小,而不是输入的位数。假设背包容量 W 为 1000,物品个数 n 为 10。动态规划表的大小是 n × W = 10 × 1000 = 10000 n \times W = 10 \times 1000 = 10000 n×W=10×1000=10000,这意味着算法的时间复杂度是 O(nW)=O(10000),这是一个多项式时间复杂度,但它的复杂度是依赖于 W 的数值的,而不是仅依赖于输入的位数。
如果 W 是一个非常大的数,比如 W = 1 0 100 W = 10^{100} W=10100,那么这个算法就需要非常长的时间来完成。因为此时 W 的位数是 log W = 100 \log W = 100 logW=100,而时间复杂度却是 O ( n × 1 0 100 ) O(n \times 10^{100}) O(n×10100),这是一个非常大的数字。
2.3 LCS & LIS
给定两个序列: X = ⟨ x 1 , x 2 , ... , x m ⟩ X = \langle x_1, x_2, \dots, x_m \rangle X=⟨x1,x2,...,xm⟩, Y = ⟨ y 1 , y 2 , ... , y n ⟩ Y = \langle y_1, y_2, \dots, y_n \rangle Y=⟨y1,y2,...,yn⟩,找到X和Y 的最长公共子序列(LCS,Longest Common Subsequence)。
子序列是序列中的一个子集 ,其中的元素保持原来的顺序。例如,对于序列 X = ⟨ A , B , C , B , D , A , B ⟩ X = \langle A, B, C, B, D, A, B \rangle X=⟨A,B,C,B,D,A,B⟩,可能的子序列包括: ⟨ A , B , D ⟩ \langle A, B, D \rangle ⟨A,B,D⟩、 ⟨ B , C , D , B ⟩ \langle B, C, D, B \rangle ⟨B,C,D,B⟩、 ⟨ A , B , C ⟩ \langle A, B, C \rangle ⟨A,B,C⟩。
如上图所示最长公共子序列有多个可能的解:⟨B,C,B,A⟩ 和 ⟨B,D,A,B⟩ 都是长度为 4 的最长公共子序列。⟨B,C,A⟩不是 X 和 Y 的最长公共子序列,因为它的长度小于 4。
暴力解法的思路是:对于 X的每一个子序列,检查它是否是 Y 的子序列。假设子序列的长度为 k,那么就需要扫描 Y 中的元素,找出这个子序列中的每一个元素是否在 YY中按顺序出现。由于X总共有 2 m 2^m 2m个子序列(其中m是X的长度),每一个子序列的检查时间复杂度是 O(n),其中n是 Y的长度。暴力算法的时间复杂度 O ( n ⋅ 2 m ) O(n \cdot 2^m) O(n⋅2m)是指数级别的,对于较长的序列,效率非常低。可以考虑使用动态规划来解决此问题。
令c[i,j]为序列 X 的前 i个元素和序列 Y的前 j个元素的最长公共子序列的长度,表示从 X 1 , X 2 , ... , X i X_1, X_2, \dots, X_i X1,X2,...,Xi和 Y 1 , Y 2 , ... , Y j Y_1, Y_2, \dots, Y_j Y1,Y2,...,Yj 中找到的最长公共子序列的长度。对于字符 x i x_i xi和 y i y_i yi有两种情况:
-
x i = y j x_i=y_j xi=yj:如果 x i = y j x_i = y_j xi=yj,那么可以将 x i = y j x_i = y_j xi=yj添加到 X i − 1 X_{i-1} Xi−1和 Y j − 1 Y_{j-1} Yj−1的最长公共子序列中。即当前的公共子序列是由前一个子序列 X i − 1 X_{i-1} Xi−1和 Y j − 1 Y_{j-1} Yj−1的公共子序列加上当前匹配的元素 x i = y j x_i = y_j xi=yj组成的。为了得到当前子问题的解,需要先解决 X i − 1 X_{i-1} Xi−1和 Y j − 1 Y_{j-1} Yj−1的子问题。
c [ i , j ] = c [ i − 1 , j − 1 ] + 1 c[i,j]=c[i−1,j−1]+1 c[i,j]=c[i−1,j−1]+1 -
x i ≠ y j x_i \neq y_j xi=yj:如果 x i ≠ y j x_i \neq y_j xi=yj,则需要分别解决两个子问题:
-
求解 X i − 1 X_{i-1} Xi−1和 Y j Y_j Yj的 LCS:即从 X i X_i Xi去掉最后一个元素,保持 Y j Y_j Yj不变,求解新的子问题。
-
求解 X i X_i Xi和 Y j − 1 Y_{j-1} Yj−1的 LCS:即从 Y j Y_j Yj去掉最后一个元素,保持 X i X_i Xi 不变,求解新的子问题。
c [ i , j ] = max { c [ i − 1 , j ] , c [ i , j − 1 ] } c[i, j] = \max \left\{ c[i - 1, j], c[i, j - 1] \right\} c[i,j]=max{c[i−1,j],c[i,j−1]}
-
这表示如果当前元素 x i x_i xi和 y j y_j yj不匹配,取前一个子问题的解中的最大值,即c[i-1, j]表示跳过 X i X_i Xi的当前元素;c[i,j−1]表示跳过 Y j Y_j Yj的当前元素。
状态转移方程:
在动态规划解决LCS问题时,除了用矩阵c[i, j]来存储子问题的解,还可以使用另一个矩阵b[i, j]来记录在每个子问题中做出的决策,从而重建 LCS。
从矩阵b[i, j]可知如何从子问题的解中获得最优值,也就是最优解的构建路径。矩阵 b[i,j] 的规则如下也分两种情况:
-
x i = y j x_i = y_j xi=yj:如果 x i x_i xi和 y j y_j yj相等,就将这个元素加入到 LCS 中。因此,矩阵 b[i,j]的值为 ↖ \nwarrow ↖,表示选择了匹配这两个元素。
b [ i , j ] = " ↖ " (表示选择 x i = y j ) b[i, j] = "\nwarrow " \quad \text{(表示选择 \( x_i = y_j \))} b[i,j]="↖"(表示选择 xi=yj) -
x i ≠ y j x_i \neq y_j xi=yj:如果 x i x_i xi和 y j y_j yj不相等,需要在两个选项中选择:要么忽略 x i x_i xi,即看 c[i−1,j]的值;要么忽略 y j y_j yj,即看c[i,j−1] 的值。根据这两个选项的比较:
- 如果 c [ i − 1 , j ] ≥ c [ i , j − 1 ] c[i-1, j] \geq c[i, j-1] c[i−1,j]≥c[i,j−1],表示选择了跳过 x i x_i xi,所以b[i, j] = "↑"(表示向上移动)。
- 如果 c [ i , j − 1 ] > c [ i − 1 , j ] c[i, j-1] > c[i-1, j] c[i,j−1]>c[i−1,j],表示选择了跳过 y j y_j yj,所以 b[i, j] = "←"(表示向左移动)。
b[i,j] 用来记录决策路径:x
- b[i,j]=" ↖ \nwarrow ↖ ":表示 x i = y j x_i = y_j xi=yj,当前元素匹配,加入 LCS。
- b[i,j]="↑":表示选择了跳过 x i x_i xi,向上移动。
- b[i,j]="←":表示选择了跳过 y j y_j yj,向左移动。
通过矩阵 b[i,j]的信息,可以从 c[m,n]反向回溯,重建出 LCS 的具体元素。
算法实现:
动态规划的时间复杂度为 O ( m n ) O(mn) O(mn),比暴力方法显著提高了效率。下图为状态转移过程:xi=yi箭头向左上角,不相等时比较当时格子的上和左边即可,那边大往那边画箭头。
从右下角长度最大值进行回溯,直到到底c[0,0]。可以看到最后有两个最大值4,说明存在多个LCS。绿色和蓝色分别代表不同的路径,粉色是两者相同的路径。对于蓝色路径,b[i,j]= " ↖ \nwarrow ↖",表示 x i = y j x_i = y_j xi=yj,即X[i] 和Y[j] 匹配,LCS 包含当前行的字符B,然后回溯到c[6,4]。同理继续回溯到c[5,3],b[5,3]="↑",表示从上方回溯,跳过X[5]=D,回溯到 c[4,3]。然后又跳过X[4]=B,回溯到c[3,3]。选择字符C,回溯到c[2,2]。b[2,2]="←",表示从左方回溯,跳过 Y[2]=D,回溯到 c[2,1],选择字符B,最后回溯到c[0,0]结束。
一种更简单的方式构造最优解:(推荐)
给定一个整数序列,找到其中的 最长递增子序列(LIS)。LIS 是指一个子序列,子序列中的元素严格递增,并且要求该子序列的长度最大。例如,对于序列 [10, 22, 9, 33, 21, 50, 41, 60, 80],最长递增子序列是 [10, 22, 33, 50, 60, 80],其长度为 6。
解法一:将该序列进行升序得到一个序列,然后将与原序列求最长公共子序列即可。
解法二:直接使用动态规划,动态规划的基本思想是使用一个dp 数组来存储到达每个元素时的最长递增子序列长度。
-
用dp[i]表示以第 i 个元素为结尾的最长递增子序列的长度。
-
对于每个元素 i,检查其之前的所有元素 j(0 ≤ j < i),如果 arr[j] < arr[i],说明可以将 arr[i]添加到以arr[j]结尾的递增子序列中,更新dp[i]为:
d p [ i ] = max ( d p [ i ] , d p [ j ] + 1 ) dp[i] = \max(dp[i], dp[j] + 1) dp[i]=max(dp[i],dp[j]+1)
初始条件是每个元素自身可以作为一个长度为 1 的递增子序列,所以 dp[i] = 1。
最长递增子序列的长度即为 dp 数组中的最大值,即: LIS长度 = max ( d p [ 0 ] , d p [ 1 ] , ... , d p [ n − 1 ] ) \text{LIS长度} = \max(dp[0], dp[1], \dots, dp[n-1]) LIS长度=max(dp[0],dp[1],...,dp[n−1])
2.4 矩阵连乘
矩阵链乘法问题如上图所示,给定一个矩阵的序列,找到一个最优的矩阵乘法顺序,以最小化总的乘法运算次数。
令C(i, j)代表计算矩阵链 M i × M i + 1 × ⋯ × M j M_i \times M_{i+1} \times \dots \times M_j Mi×Mi+1×⋯×Mj的最小计算成本(标量乘法次数)。目标变为计算C(1,r),即计算整个矩阵链从 M 1 M_1 M1到 M r M_r Mr的最小计算成本。
假设我们有两个矩阵 A 和 B,其维度分别为 p × q p \times q p×q 和 q × r q \times r q×r,那么矩阵乘法 A × B A \times B A×B需要进行 p × q × r p \times q \times r p×q×r次标量乘法。对于上面的矩阵链我们需要分割这个链,找到一个合适的划分点k,使得计算过程中的标量乘法次数最小。
举例如下所示:
递归公式
C ( i , j ) = m i n i ≤ k ≤ j − 1 C ( i , k ) + C ( k + 1 , j ) + D ( i − 1 ) × D ( k ) × D ( j ) C(i, j) = min_{i ≤ k ≤ j-1} {C(i, k) + C(k+1, j) + D(i-1) × D(k) × D(j)} C(i,j)=mini≤k≤j−1C(i,k)+C(k+1,j)+D(i−1)×D(k)×D(j)
它确保在每个分割点 k
处,累积的乘法次数是最小的。算法实现如下所示:
-
r
:矩阵链的长度(矩阵个数)。 -
D
:维度数组,定义了每个矩阵的行数和列数。D[i-1]
是矩阵Ai
的行数,D[i]
是矩阵Ai
的列数。 -
P
:用于存储最优括号化位置的数组(在代码中未直接展示存储部分,但通过C
的递归计算获得)。
(1) 初始化对角线
cpp
for i ← 1 to r do
C(i, i) = 0
初始化 C
矩阵的对角线元素。对于单个矩阵,乘法次数为 0,所以将所有 C(i, i)
设为 0。C(i, i)
表示只包含矩阵 A_i
的子链,当然不需要进行任何乘法。
(2) 迭代矩阵链的长度 d=
cpp
for d ← 1 to (r -- 1) do
d
代表子矩阵链的长度,取值从 1 到 r-1
。这意味着代码首先考虑长度为 2 的子矩阵链,然后是长度为 3 的,逐步递增直到计算整个矩阵链的最优乘法次序。
(3) 遍历子链的起点 i
,计算子链的终点 j
cpp
for i ← 1 to (r -- d) do
i
是当前子链的起始索引,从第 i
个矩阵开始。对于长度为 d
的子链,i
的最大值是 r - d
,确保计算时子链不超出矩阵链的范围。然后计算子链的终点 j
cpp
j ← i + d;
对于当前的子链,终点是 i + d
,即长度为 d
的子链的最后一个矩阵。
(4) 通过分割位置 k
递归计算最优乘法次数
cpp
C(i, j) = min_{i ≤ k ≤ j-1} {C(i, k) + C(k+1, j) + D(i-1) × D(k) × D(j)};
C(i, j)
代表从第i
个矩阵到第j
个矩阵的最小标量乘法次数。- 使用动态规划来计算,通过枚举
k
(i
到j-1
之间的分割点),将矩阵链A_i...A_j
分割为两个部分:A_i...A_k
和A_{k+1}...A_j
。最优解通过递归计算这两个部分的乘法次数之和,外加它们相乘所需的标量乘法次数。 D(i-1) × D(k) × D(j)
是矩阵链在当前分割点k
处的乘法代价。
时间复杂度为 O(r^3)
,因为动态规划的三个循环:一个循环控制子链长度 d
,一个循环控制子链起点 i
,最后一个循环控制分割点 k
。
d
的含义
d
代表矩阵链子问题的长度,即当前要考虑的矩阵子链包含多少个矩阵。具体来说,d = j - i
表示子矩阵链的长度,其中i
是子链的起始矩阵索引,j
是子链的结束矩阵索引。d
的值从 1 到r-1
逐渐增加,表示从最小的子链(两个矩阵)到完整矩阵链的不同情况。
k
的含义
k
是当前子矩阵链的分割点。我们将矩阵链分成两部分:从第i
个矩阵到第k
个矩阵,以及从第k+1
个矩阵到第j
个矩阵。k
的取值范围是从i
到j-1
,表示我们可以选择在子链的任何位置进行划分。通过对所有可能的k
进行计算,找到使标量乘法次数最小的分割点。
d
决定了当前考虑的子矩阵链的长度 ,即d = j - i
。一旦确定了d
,就可以从i
开始确定当前子链的起始和结束矩阵。
k
决定了当前子链在什么位置被划分 。对于一个确定的i
和j
(由d
确定),我们需要在i
到j-1
之间找到最佳的k
来划分矩阵链,使得乘法次数最少。因此,d
控制子链的长度 ,而**k
控制子链的划分位置**。
假设我们有 4 个矩阵链 A1, A2, A3, A4
,对应维度数组 D = [D0, D1, D2, D3, D4]
。动态规划计算步骤如下:
-
当
d = 1
时 ,我们考虑两两相邻的矩阵s链,即A1A2
、A2A3
、A3A4
。- 对于子链
A1A2
,k = 1
,在A1
和A2
之间划分。 - 对于子链
A2A3
,k = 2
,在A2
和A3
之间划分。 - 对于子链
A3A4
,k = 3
,在A3
和A4
之间划分。
- 对于子链
-
当
d = 2
时 ,我们考虑长度为 3 的矩阵链A1A2A3
、A2A3A4
。- 对于子链
A1A2A3
,k
可以是 1 或 2,分别对应两种划分方式:先计算A1A2
还是先计算A2A3
。 - 对于子链
A2A3A4
,k
可以是 2 或 3,分别对应先计算A2A3
还是先计算A3A4
。
- 对于子链
-
当
d = 3
时 ,我们考虑整个矩阵链A1A2A3A4
。- 对于链
A1A2A3A4
,k
可以是 1、2 或 3,分别对应先计算A1
和后面矩阵的乘积,或先计算A2A3
,或者从A3A4
开始。
- 对于链
在整个动态规划的过程中,d
控制子问题的规模,而 k
是在特定规模子问题内的决策变量。
python
def matrix_chain_order(p):
n = len(p) - 1
dp = [[0] * n for _ in range(n)]
# l 是链长度,逐步增加
for l in range(2, n + 1): # 从长度为2开始
for i in range(n - l + 1): # 起点
j = i + l - 1 # 终点
dp[i][j] = float('inf') # 初始化为正无穷
for k in range(i, j): # 划分点
q = dp[i][k] + dp[k+1][j] + p[i] * p[k+1] * p[j+1]
dp[i][j] = min(dp[i][j], q)
return dp[0][n-1] # 返回从 A1 到 An 的最优解
例题:构造最优解,即求出加括号顺序,使用另外一个s[i] [j]将对应m[i] [j]的断开位置k记录下来。
2.5 Sequence alignment & 编辑距离
编辑距离是衡量两个字符串之间相似度的一种方法,它定义为将一个字符串转化为另一个字符串所需的最少操作次数。每个操作可以是以下三种之一:
- 插入(Insertion):在字符串中插入一个字符。
- 删除(Deletion):删除字符串中的一个字符。
- 替换(Substitution):将一个字符替换为另一个字符
给定两个字符串 x = x 1 , x 2 , ... , x m x = x_1, x_2, \dots, x_m x=x1,x2,...,xm和 y = y 1 , y 2 , ... , y n y = y_1, y_2, \dots, y_n y=y1,y2,...,yn,找到它们的最小代价比对(alignment)。
比对 𝑀 是一组有序对 (𝑥𝑖,𝑦𝑗),其中每个字符最多出现在一个对中,且没有交叉。换句话说,序列中的每个字符要么和另一个序列中的字符对齐,要么未与任何字符对齐(即插入了空格)。
比对的性质:
- 有序对 :每个比对中的元素是一个字符对,形式是 ( x i , y j ) (x_i, y_j) (xi,yj),表示字符串 x 中的字符 x i x_i xi与字符串 y 中的字符 y j y_j yj 对齐。
- 无交叉 :比对中没有交叉的含义,即如果有两个比对对 ( x i , y j ) (x_i, y_j) (xi,yj) 和 ( x k , y l ) (x_k, y_l) (xk,yl),则必定满足 i<k 和 j<l即 x中的字符与 y中的字符按顺序对齐)。
根据这个定义,目标是找到一个最小代价的比对,即通过对齐两个字符串中的字符,最小化所有匹配和插入空格的代价。常见的代价包括:
- 不匹配的代价 :对于每一对比对 ( x i , y j ) (x_i, y_j) (xi,yj),如果 x i x_i xi 和 y j y_j yj不相同,就会产生一个不匹配代价,记作 α x i y j \alpha_{x_i y_j} αxiyj。
- 空格代价 :如果字符 x i x_i xi没有找到匹配的字符 y j y_j yj,或者字符 y j y_j yj 没有找到匹配的字符 x i x_i xi,就会在比对中插入一个空格,空格的代价为 δ \delta δ,分别对于未匹配的 x i x_i xi和 y j y_j yj进行求和。
比对M的代价是所有匹配、空格插入以及不匹配的代价之和。假设比对 M是由一组有序对 ( x i , y j ) (x_i, y_j) (xi,yj) 组成的,代价的计算方式如下:
通过动态规划来求解两个字符串的最小代价对齐,定义OPT(i, j)表示对齐前缀 x1, x2, ..., xi 和 y1, y2, ..., yj的最小代价。
OPT(i, j) 的计算基于以下几种情况:
-
匹配 xi 和 yj :如果 xi 和 yj匹配,则代价是0 ,不匹配为 α x i y j \alpha_{x_i y_j} αxiyj。然后其子问题是计算 OPT(i-1, j-1),即对齐 x1, x2, ..., xi-1 和 y1, y2, ..., yj-1。
O P T ( i , j ) = min ( α x i y j + O P T ( i − 1 , j − 1 ) ) OPT(i, j) = \min \left( \alpha_{x_i y_j} + OPT(i - 1, j - 1) \right) OPT(i,j)=min(αxiyj+OPT(i−1,j−1)) -
xi 不匹配,xi 留空 :如果 xi 留空(即插入一个 gap),需要支付一个 gap penalty,并且问题转化为计算 OPT(i-1, j),即对齐 x1, x2, ..., xi-1 和 y1, y2, ..., yj。
O P T ( i , j ) = min ( δ + O P T ( i − 1 , j ) ) OPT(i, j) = \min \left( \delta + OPT(i - 1, j) \right) OPT(i,j)=min(δ+OPT(i−1,j)) -
yj 不匹配,yj 留空 :如果yj 留空(即插入一个 gap),同样需要支付 gap penalty,并且问题转化为计算 OPT(i, j-1),即对齐 x1, x2, ..., xi和 y1, y2, ..., yj-1。
O P T ( i , j ) = min ( δ + O P T ( i , j − 1 ) ) OPT(i, j) = \min \left( \delta + OPT(i, j - 1) \right) OPT(i,j)=min(δ+OPT(i,j−1))
状态转移方程如上所示,OPT(i, 0)表示对齐 x1, x2, ..., xi 和空字符串 y,代价为 i * δ,即每个 xi都需要插入一个 gap。OPT(0, j)表示对齐空字符串 x和 y1, y2, ..., yj,代价为 j * δ,即每个 yj都需要插入一个 gap。
算法实现:
状态转移表:
编辑距离和Sequence alignment思路基本上是一模一样的。编辑距离,也称 Levenshtein 距离,指两个字符串之间互相转换的最少修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度。
输入两个字符串 s 和 t ,返回将 s 转换为 t 所需的最少编辑步数。你可以在一个字符串中进行三种编辑操作:插入一个字符 、删除一个字符 、将字符替换为任意一个字符。如下图将 kitten转换为 sitting 需要编辑 3 步,包括 2 次替换操作与 1 次添加操作;将 hello转换为 algo需要 3 步,包括 2 次替换操作和 1 次删除操作。
定义dp(i, j) 为将 x[1...i] 转换为 y[1...j] 的最小编辑距离,其中 x[1...i]表示字符串 x 的前 i 个字符,y[1...j] 表示字符串 y 的前 j个字符。
考虑子问题dp[i,j] ,其对应的两个字符串的尾部字符为 s[i−1] 和 t[j−1] ,可根据不同编辑操作下分为图所示的三种情况。
对于字符x[i]和y[j]有两种结果:
- 字符匹配条件:如果 x[i]和 y[j]相等(即当前字符匹配),那么就不需要进行任何操作。因此,dp(i, j)直接继承自 dp(i-1, j-1),表示将 x[0...i-1] 转换为 y[0...j-1] 的最小编辑距离。
- 字符不匹配 :如果 x[i]和 y[j]不相等则有三种操作选择。
- 删除操作:如果要==删除 x[i-1]==来匹配 y[j-1],则需要执行一个删除操作,代价为 1。删除一个字符后,问题转化为从 x[0...i-2] 转换为y[0...j-1] 的编辑距离,因此此时的状态转移为 dp(i-1, j) + 1。
- 插入操作:如果要插入y[j-1]来匹配 x[i-1],则需要执行一个插入操作,代价为 1。插入一个字符后,问题转化为从 x[0...i-1] 转换为 y[0...j-2]的编辑距离,因此此时的状态转移为 dp(i, j-1) + 1。
- 替换操作:如果 x[i-1]和 y[j-1]不相等,且需要通过替换操作使其匹配,那么执行一个替换操作,代价为 1。替换一个字符后,问题转化为从 x[0...i-2] 转换为 y[0...j-2]的编辑距离,因此此时的状态转移为 dp(i-1, j-1) + 1。
状态转移方程:
dp ( i , j ) = { i , if i ≠ 0 and j = 0 j , if i = 0 and j ≠ 0 dp [ i − 1 , j − 1 ] , if x [ i ] = y [ j ] min { dp [ i − 1 , j ] + 1 , (delete) dp [ i , j − 1 ] + 1 , (insert) dp [ i − 1 , j − 1 ] + 1 , (replace) , otherwise \begin{equation} \text{dp}(i, j) = \begin{cases} i, & \text{if } i \neq 0 \text{ and } j = 0 \\ j, & \text{if } i = 0 \text{ and } j \neq 0 \\ \text{dp}[i-1, j-1], & \text{if } x[i] = y[j] \\ \min \left\{ \begin{array}{l} \text{dp}[i-1, j] + 1, \quad \text{(delete)} \\ \text{dp}[i, j-1] + 1, \quad \text{(insert)} \\ \text{dp}[i-1, j-1] + 1, \quad \text{(replace)} \end{array} \right. , & \text{otherwise} \end{cases} \end{equation} dp(i,j)=⎩ ⎨ ⎧i,j,dp[i−1,j−1],min⎩ ⎨ ⎧dp[i−1,j]+1,(delete)dp[i,j−1]+1,(insert)dp[i−1,j−1]+1,(replace),if i=0 and j=0if i=0 and j=0if x[i]=y[j]otherwise
边界条件为dp[0][j] = j
,即将空字符串x转换为 y[0...j-1],需要执行 j 次插入操作;dp[i][0] = i
,即将字符串 x[0...i-1] 转换为空字符串y,需要执行 i 次删除操作。
以上是一个简单的状态转移过程,其算法实现和Sequence alignment一样,只需将所有的代价参数设置为1即可,如下所示:
2.5 最大子数组和
给定一个数组 nums,找到一个非空的连续子数组,使其元素之和最大,并返回该最大和。
暴力解法:遍历数组的所有可能子数组,计算每个子数组的和,并找到其中最大的和。枚举所有子数组的范围 O ( n 2 ) O(n^2) O(n2),计算每个子数组的和需要O(n),总时间复杂度: O ( n 3 ) O(n^3) O(n3)。
设OPT[i]表示以第i个元素结尾的连续子数组的最大和。对于数组元素 x i x_i xi,我们面临两个选择:
O P T [ i ] = { x i , if O P T [ i − 1 ] + x i ≤ x i ( 重新开始子数组 ) O P T [ i − 1 ] + x i , if O P T [ i − 1 ] + x i > x i ( 延续当前子数组 ) OPT[i] = \begin{cases} x_i, & \text{if } OPT[i-1] + x_i \leq x_i \quad (\text{重新开始子数组}) \\ OPT[i-1] + x_i, & \text{if } OPT[i-1] + x_i > x_i \quad (\text{延续当前子数组}) \end{cases} OPT[i]={xi,OPT[i−1]+xi,if OPT[i−1]+xi≤xi(重新开始子数组)if OPT[i−1]+xi>xi(延续当前子数组)
如果 O P T [ i − 1 ] + x i ≤ x i OPT[i-1] + x_i \leq x_i OPT[i−1]+xi≤xi,说明当前子数组 x i x_i xi对结果没有贡献,应该重新开始。否则,当前子数组继续延续。
所以状态转移方程为:
如果加入当前元素 x i x_i xi会让子数组和变大,则 OPT[i]是 O P T [ i − 1 ] + x i OPT[i-1] + x_i OPT[i−1]+xi。如果当前元素 x i x_i xi比 x i x_i xi加上之前的数组和大,则从当前元素重新开始子数组。最后最大字段和是OPT[i]的最大值。
给定一个 n × n n \times n n×n 的矩阵A,找到一个矩形区域,使得该区域的元素之和最大。
要解决最大子矩阵和问题,核心思想是将其转化为一维最大子数组和的问题,并利用动态规划求解转化后的问题。
首先,我们选择矩阵中的两列 j和 j′(其中 j<j′),这两列定义了子矩阵的左右边界然后对于选择的列对 j 和 j′,我们计算每一行的部分和,即该行在两列之间的元素之和:
KaTeX parse error: Expected 'EOF', got '_' at position 11: \text{row_̲sum}[i] = A[i][...
这样就将原本的二维问题转化为一维问题。每一行的部分和构成了一个一维数组,问题变成了在这个一维数组上找到最大子数组和。使用动态规划解这个一维数组的最大子数组和。通过遍历所有可能的列对,计算每对列之间的部分和数组,并应用动态规划 算法。这样可以保证找到最大子矩阵的和。
2.6 Dropping Eggs
给定 N 个鸡蛋和一栋 K 层楼的建筑,目标是找出最小的鸡蛋投掷实验次数,以确定在所有情况下,鸡蛋可以安全投掷的楼层。
具体来说,有 N 个鸡蛋和一栋 K 层楼的建筑。鸡蛋从某一楼层投掷时,如果鸡蛋破裂,表示这楼层的高度超过了鸡蛋的承受能力;如果鸡蛋没有破裂,表示可以在这楼层及以下楼层安全投掷鸡蛋。任务是找出最小的投掷次数,以确定在每种情况下,鸡蛋可以安全地投掷到哪些楼层。找到一个最优的实验策略,使用最少的投掷次数,在最坏情况下找到"临界楼层"------即鸡蛋开始破裂的最小楼层。每次实验后,可以根据鸡蛋是否破裂来做出不同的决策。
EggDrop(N, K) 代表使用 N 个鸡蛋 和 K 层楼 时,找到 最小的投掷次数,以确保可以确定哪个楼层是鸡蛋开始破裂的最低楼层(即临界楼层)。
-
当鸡蛋数量 = 1 且 楼层数量 = K 时,最坏的情况是逐层投掷鸡蛋,一直到第 K 层。此时投掷次数是 K: E g g D r o p ( 1 , K ) = K EggDrop(1, K)=K EggDrop(1,K)=K。
-
当 鸡蛋数量 = N且 楼层数量 = 1时,只需要进行 1 次实验来确认临界楼层。因为无论有多少个鸡蛋,1 层楼只需要投掷一次就能知道: E g g D r o p ( N , 1 ) = 1 EggDrop(N, 1)=1 EggDrop(N,1)=1
-
一般情况,假设从第 i层投掷鸡蛋,投掷的结果可以有两种可能:
- 鸡蛋破裂 :此时剩下的问题是有N-1 个鸡蛋和i-1层楼,变为用N−1个鸡蛋来测试 i−1层楼。
- 鸡蛋未破裂 :此时剩下的问题是有N个鸡蛋和K-i 层楼,变为用N个鸡蛋来测试 K−i层楼。
因此,递归关系可以表示为:
EggDrop(N, K) = 1 + min 1 ≤ i ≤ K max ( EggDrop(N-1, i-1) , EggDrop(N, K-i) ) \text{EggDrop(N, K)} = 1 + \min_{1 \leq i \leq K} \max(\text{EggDrop(N-1, i-1)}, \text{EggDrop(N, K-i)}) EggDrop(N, K)=1+1≤i≤Kminmax(EggDrop(N-1, i-1),EggDrop(N, K-i))1表示当前投掷的次数,接下来的递归计算考虑了两种情况的最坏结果(即最坏的路径,最差的情况需要更多的投掷次数)。
算法实现:
例题:
2.7 Coin Changing
给定 n种硬币面额 d 1 , d 2 , ... , d n {d_1, d_2, \dots, d_n} d1,d2,...,dn 和一个目标金额 V,求解找零所需的最少硬币数量,或者报告无法找零的情况。
这与前面贪心算法中的硬币找零所不同的仅仅是给定的面额具体数值是未知,从前面贪心算法可知,对于美国的硬币面额(如 1、5、10、25 美分等),贪心算法(每次选择最大面额的硬币)是最优的。
然而,对于任意的硬币面额集合,贪心算法未必是最优的。例如,硬币面额为{1, 10, 21, 34, 70, 100, 350, 1295, 1500}时,贪心算法并不一定得到最少的硬币数。比如140 = 70 + 70,而贪心算法是140 = 100 + 34 + 6 *1。
通过动态规划算法求解最少硬币数量,定义 OPT ( v ) \text{OPT}(v) OPT(v)为最少硬币数,使得金额为v。
多路选择问题 :为了计算 OPT(v),可以选择任意一种硬币 c i c_i ci(其中i表示硬币的种类)。然后,剩下的金额为 v − c i v - c_i v−ci,接着再计算最少硬币数,即 OPT ( v − c i ) \text{OPT}(v - c_i) OPT(v−ci)。因此状态转移方程如下:
动态规划算法的时间复杂度为 O ( n V ) O(nV) O(nV),其中 n 为硬币的种类数,V 为目标金额,同样的为伪多项式时间。
伪代码:
2.8 多阶段单目标最短路径问题
多阶段单目标最短路径问题,可以简要描述为:给定一个包含m阶段和 n 个节点的图,图中的边只在相邻阶段之间存在 。图共有 m 个阶段,每个阶段有 n 个节点。节点的编号从 (0, 1) 到 (m−1,1),其中 (0,1) 是源节点,(m−1,1)是目标节点。只有相邻阶段之间的节点之间存在边,即只有从(i,k) 到(i+1,j) 的边。目标是从源节点 (0,1)到目标节点 (m−1,1)找到最短路径。路径的长度由每条边的权重来决定。
为了通过动态规划解决这个问题,定义以下符号:
-
m(i, j):表示从源节点(0,1) 到达节点(i,j) 的最短路径长度,i可代表是第几个阶段。
-
c(i,k,j):表示从节点(i,k) 到节点(i+1,j) 的边的权重或距离。
对于每个阶段i和每个节点j,最短路径的长度可以通过从前一阶段的所有节点k转移到当前节点(i,j) 来计算:
例题:
状态转移表计算过程:
最短路径为上图中粉色的路径,长度为7。
对于每个阶段 i 和每个节点 j,需要计算前一个阶段i−1 中的所有节点 k到当前节点(i,j) 的最短路径。因此,对于每一个节点(i,j),需要检查所有 n 个前一个阶段的节点 (i−1,k)。递推方程需要对每个阶段 m 和每个节点 n 进行计算,且每个节点的计算需要遍历 n个前驱节点。因此,时间复杂度是: O ( m × n 2 ) O(m \times n^2) O(m×n2)。
2.9 Segmented Least Squares 分段最小二乘法
最小二乘法 是统计学和线性回归中的一个基础性问题。其目标是找到一条直线y = ax + b,使得给定的一组 n 个点 ( x 1 , y 1 ) , ( x 2 , y 2 ) , ... , ( x n , y n ) (x_1, y_1), (x_2, y_2), \dots, (x_n, y_n) (x1,y1),(x2,y2),...,(xn,yn)与该直线的拟合误差的平方和最小化。
分段最小二乘法是用于拟合多个线段的模型,其中给定一组数据点 ( x 1 , y 1 ) , ( x 2 , y 2 ) , ... , ( x n , y n ) (x_1, y_1), (x_2, y_2), \dots, (x_n, y_n) (x1,y1),(x2,y2),...,(xn,yn),且满足 x 1 < x 2 < . . . < x n x_1 < x_2 < ... < x_n x1<x2<...<xn,这些点大致落在几条直线段上。我们需要找到一系列的直线段,这些直线段尽量小化以下的目标函数:
f ( x ) = E + c L f(x)=E+cL f(x)=E+cL
其中:
- E :每个直线段的残差平方之和,即 S S E = ∑ i = 1 n ( y i − ( a x i + b ) ) 2 SSE = \sum_{i=1}^{n} (y_i - (a x_i + b))^2 SSE=∑i=1n(yi−(axi+b))2。即在每一段线段上,计算数据点与该线段之间的误差,并将这些误差平方和加起来。
- L:直线段的数量,也就是分段的数量。
- c:常数 c>0,它是对线段数量L的惩罚因子。通过调整c的值,可以控制模型复杂度和准确性之间的平衡。
令OPT(j)为对于前j个点 p 1 , p 2 , ... , p j p_1, p_2, \dots, p_j p1,p2,...,pj最小化目标函数的代价。即OPT(j) 表示从 p 1 p_1 p1到 p j p_j pj的最佳分段的最小代价。
e i j e_{ij} eij为从点 p i p_i pi 到点 p j p_j pj之间的一段线的残差平方和, 即 e i j e_{ij} eij表示在考虑点 p i , p i + 1 , ... , p j p_i, p_{i+1}, \dots, p_j pi,pi+1,...,pj时的误差。c为罚因子,用来惩罚增加的线段数量。
对于每个j,假设最后一段是从某点pi到pj的点构成的,这里的i就是分段点,相应的这段的误差是 e i j e_{ij} eij。为了计算OPT(j) ,需要考虑所有可能的分割点 i(即从点 pi 到 pj 作为最后一段的起始点),对于每个i,计算两部分的代价之和 :第一部分 的代价是前 i-1个点的最优代价,即 OPT[i−1]。第二部分 是当前分段的代价,从点pi 到点 pj 的平方误差 e i j e_{ij} eij。而且还有额外代价,每增加一段,还需要付出常数c的代价。
状态转移方程如下:
对于j = 0,OPT(0) = 0,表示没有点时,最小代价为 0。对于 j>0,这里在所有可能的i中寻找使得 e i j + c + O P T ( i − 1 ) e_{ij} + c + OPT(i-1) eij+c+OPT(i−1) 最小的值。也就是说,从i到j这一段的误差加上前面所有点的最优解,最终得到 OPT(j)。
M[j]表示 j个点的最优代价。对于每个点j,算法通过遍历所有可能的分段点i来计算最小代价,代价由当前分段的误差和之前最优解的代价组成。最后返回 M[n],即所有点的最优划分代价。该算法的时间复杂度为 O ( n 3 ) O(n^3) O(n3)。
三、真题
主要是如何从状态转移表得到选的活动子集的,比如最后三个都为50,说明后面两个活动都没有选择,而是选择第一次出现50的活动,即选择了15。
20241129130630677.png" alt="image-20241129130630677" style="zoom:80%;" />
对于j = 0,OPT(0) = 0,表示没有点时,最小代价为 0。对于 j>0,这里在所有可能的i中寻找使得 e i j + c + O P T ( i − 1 ) e_{ij} + c + OPT(i-1) eij+c+OPT(i−1) 最小的值。也就是说,从i到j这一段的误差加上前面所有点的最优解,最终得到 OPT(j)。
M[j]表示 j个点的最优代价。对于每个点j,算法通过遍历所有可能的分段点i来计算最小代价,代价由当前分段的误差和之前最优解的代价组成。最后返回 M[n],即所有点的最优划分代价。该算法的时间复杂度为 O ( n 3 ) O(n^3) O(n3)。
三、真题
主要是如何从状态转移表得到选的活动子集的,比如最后三个都为50,说明后面两个活动都没有选择,而是选择第一次出现50的活动,即选择了15。
结束语
感谢阅读吾之文章,今已至此次旅程之终站 🛬。
吾望斯文献能供尔以宝贵之信息与知识也 🎉。
学习者之途,若藏于天际之星辰🍥,吾等皆当努力熠熠生辉,持续前行。
然而,如若斯文献有益于尔,何不以三连为礼?点赞、留言、收藏 - 此等皆以证尔对作者之支持与鼓励也 💞。