算法课程杂记 1


算法课程杂记 1

线性时间解决MCS问题

课堂上的分治算法解决最大子序列问题的时间复杂度为 $O(n*logn)$,然而,这种问题更加优越、更加常见的解决方式是应用动态规划的思想,即 Kadane 算法。

MCS 问题简述

即最大子序列问题:在数组的一个维度寻找连续的的子序列,使该子序列的和最大。
这种问题经常被包装成一些现实的策略问题,例如:根据股票的涨跌求算哪个时间区间入股赚得最多(当然是事后判断,要是能预测那大家都退学炒股去了hhh

算法思想

一开始接触到的是分治算法解决的思想:

  • 显然,将一个数组从正中间分成两块,则所求序列有三种分布方式:在左块,在右块,横跨两块
  • 在左或右的情况递归处理即可,横跨两块的情况需要从正中的坐标出发,分别向左右进行移动,找到起点在正中的左右最大子序列(复杂度:$O(n)$),再将左右找到的合并即可。
  • 对这三种情况求算出的结果区最大值即可

显然,这种算法不可能是线性复杂度的

故采用动态规划的思想:

  • 要利用动态规划,一般情况下需要找到比当前问题规模小 “1” 的子问题
  • 故该问题转化为,求一系列以array[i], 0 <= i < len结尾的最大子序列,其中这一系列最大子序列中最大的为所求。

算法实现

这样,核心代码就非常简单了:

int MCSofArray(int *array, int len) &#123;
    int dp[len] = &#123;array[0]&#125;;
    int maxdp = dp[0];
    for(int i = 1; i < len; i++) &#123;
        dp[i] = max(dp[i-1] + array[i], array[i]);
        maxdp = max(maxdp, dp[i]);
    &#125;
    return maxdp;
&#125;

该算法的时间复杂度为 $O(n)$

分治和动归的界限

或许仍有些疑问,如果我拿到一个算法问题,如何确定是用分治还是用动归思想去解决呢?

我浅薄地认为:

  • 分治思想适用的问题都可以被分成两个(或者多个)子问题,这些子问题一般是该问题做除法得到的,这一点反映在时间复杂度的计算上
  • 动归思想适用的问题一般可以分为多个子问题,这些子问题是该问题做减法得到的
  • 显然,对于问题规模的下降速度,减法要比除法慢得多,所以“做减法”的问题如果不做转换一般不适用于分治的经典结构——递归

快速傅里叶变换

课上学习两个多项式相乘的算法时,我们很别扭地将两个多项式的乘法拆成了3个子问题,然而,最著名的解决该问题的方法则是快速傅里叶变换。

多项式的表示形式

不必多说,对于 n 次多项式,其标准型为:

$A(x) = a_0 + a_1 * x + a_2 * x^2 + \dots + a_n * x^n$

系数表示法写成:

$A = (a_0, a_1, a_2, \dots, a_n)$

点值表示法则有:

$A(x) = {(x_0, A(x_0)), (x_1, A(x_1)),\dots,(x_{n-1}, A(x_{n-1})),}$ , $x_k$ 互不相等

这个表示法是多项式乘法能被傅里叶变换优化的基础。

多项式相乘问题的 another approach

假设我们需要求的式子为:
$C(x) = A(x) * B(x)$

其中用点值表示法表示 C(x), A(x), B(x)
必然有:
$C(x) = {(x_0, A(x_0)*B(x_0)), (x_1, A(x_1)*B(x_1)),\dots,(x_{n-1}, A(x_{n-1})*B(x_{n-1})),}$ , $x_k$ 互不相等

至此,我们只需要根据 C(x) 的点表示式写出其一般多项式形式即可。

这个问题的关键有两点:

  • 如何得到一个多项式的点表示式
  • 如何由一个点表示式推出对应多项式

如何得到 A(x) 的点表示式

  • 需要取n个不重复的点,这n个点的取法是复数域上的单位根。复数域上的单位根:将单位圆分成n份,每一份对应的复数即为选择的点:
    $w_n^k = cos(2k\pi/n) + i * sin(2k\pi/n)$
  • 现在知道点表示法的横坐标了,需要带入 A(x) 求出对应的纵坐标的值,直接带入计算的话,求解这n个点的时间复杂度是$O(n^2)$的,因此不可取
  • 现在起要求n是2的整数次幂,如果不足的话,后面补零
  • 将 A(x) 的奇数项和偶数项分开,设:

$A_1(x) = a_0 + a_2*x + … + a_{n-2}*x^{n/2-1}$

$A_2(x) = a_1 + a_3*x + … + a_{n-1}*x^{n/2-1}$

$A(x) = A_1(x^2) + x*A_2(x^2)$

  • 当需要求解 $w_n^k$ 对应的纵坐标时,不妨同时关注$w_n^{k + n/2}$:

$A(w_n^k) = A_1(w_n^{2k}) + w_n^k*A_2(w_n^{2k})$

由变换可得(转化为三角形式会直观些):

$A(w_n^k) = A_1(w_{n/2}^{k}) + w_n^k*A_2(w_{n/2}^{k})$

同样的:

$A(w_n^{k+n/2}) = A_1(w_n^{2k+n}) + w_n^{k+n/2}*A_2(w_n^{2k+n})$

变换得:

$A(w_n^{k+n/2}) = A_1(w_{n/2}^{k}) - w_n^k*A_2(w_{n/2}^{k})$

  • 注意到$A(w_n^k)$ 和 $A(w_n^{k+n/2})$ 最后分解成的式子中有公共部分,则这二者可一并计算
  • 故对于计算单个点对应的纵坐标,采用分治算法,时间复杂度 $T(n) = 2*T(n/2)$,为$O(logn)$
  • 总时间复杂度为 $O(n*logn)$

关于如何分治:因为我们要求$A(w_n^k)$需要知道$A_1(w_{n/2}^{k})$ 和 $A_2(w_{n/2}^{k})$

可见,子问题的形式与原问题一样,但规模减半,运用分支的递归策略即可求解

如何由一个点表示式推出对应多项式
结论是:
将n个点的每个点$(w_n^k, A(w_n^k))$记作$(x_k, y_k)$,则多项式系数$a_k$为:
$a_k = 1/n * \sum(y_i * (w_n^{-k})^i), i \in [0, n-1]$
具体推导步骤见此

那对于$a_k$的计算,如何分治呢?
其实这里是一个小 trick,观察$a_k$可知,我们要计算的是一个以 $w_n^{-k}$ 为参数的多项式,这与刚刚求纵坐标的方式非常类似,做相同的处理即可,在编程上可以用同一个函数实现,总复杂度为 $O(n*logn)$

背包问题

背包问题主要有三类:

  • 0-1背包问题,每个物体数量为1
  • 完全背包问题,每个物体数量无限
  • 多重背包问题,每个物体数量有限,为 s[i]

其中,多重背包问题可通过将同类物体视为不同转变为0-1背包问题,这里不做赘述。

状态转移方程辨析

递推式中,i 表示检索第 i 个物体,j表示当前背包重量,v 为物体价值数组,dp 存储检索完第 i 个物体时背包重量为 j 时,对应的背包价值。

0-1背包问题:

dp[i][j] = max(dp[i - 1][j], v[i]+dp[i - 1][j - w[i]])

完全背包问题:

dp[i][j] = max(dp[i - 1][j], v[i] + dp[i][j - w[i]])

可以看到,由于完全背包情况下物体数量无限,故决定是否放入标号为 i 的物体前,可能已放入标号为 i 的物体。

多项式时间复杂度中的 x 的辨析

多项式时间算法:在输入规模为x的情况下,算法能够在$O(x^k)$ 时间(k为常数)内解决该问题。

以一个最简单的问题为例,寻找 $[0, N]$ 区间内所有的素数。
大家可能会想到素数筛等算法,或者干脆的直接查表,时间复杂度为O(n),但这个问题的输入只有一个数字 n,换算成输入规模为 $x = logn, n = 2^x$

显然,该算法的时间复杂度可以表示为 $O(2^x)$,不是多项式时间算法。

到这,你或许可以理解为什么人们对多项式时间算法如此执着追求,你可以通俗的理解,这种算法响应一个输入的相对时间代价较小;毕竟算法都是服务于人的,就像那个素数的例子,没有用户会手动把 0 到 N 全部输入进去哈哈哈~


文章作者: Argithun
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Argithun !
 上一篇
数据库系统——基础篇 数据库系统——基础篇
数据库课程基础篇总回顾,包括数据库结构、关系数据模型、SQL语言等
2023-10-16
下一篇 
北航计算机学院面向对象(2023 第四单元) 北航计算机学院面向对象(2023 第四单元)
本文将以笔者学习过程中的思考感悟为基础,对2023北航计算机学院面向对象课程第四单元(作业13~15)的架构搭建和程序设计思路做简明的描述;如有不同见解,欢迎学习交流。
2023-06-01