算法和算法评价¶
同一问题往往有多种算法. 要比较它们的好坏, 408 主要看两件事: 能不能正确把问题解完, 以及 时间和额外空间随规模怎么长. 后者就是复杂度分析.
算法¶
算法(algorithm)是对特定问题求解步骤的一种描述, 是指令的有限序列, 每条指令表示一个或多个操作. 「程序 = 数据结构 + 算法」指的就是先把数据组织好, 再给出在这些数据上的操作步骤.
算法与程序¶
算法是抽象的解题方法, 程序是算法在某种语言里的实现.
关键差别在有穷性: 算法必须在有限步后停下来; 程序可以写成死循环 (操作系统、服务进程就是故意不终止的). 同一算法也可以对应多种程序 (C / Python / 伪代码).
算法的五个基本特性¶
-
有穷性: 执行有穷步后结束, 且每一步都能在有穷时间内完成
-
确定性: 每条指令含义明确, 相同输入只能得到相同输出
-
可行性: 每步都能用已经实现的基本运算做有限次
-
输入: 零个或多个, 取自某个对象集合
-
输出: 一个或多个, 与输入有确定关系
Note
「零个输入」是合法的, 例如打印固定常数的算法. 「至少一个输出」是必须的, 否则看不出算法做了什么.
优秀算法的特征¶
特性只保证「这是算法」, 优秀算法还追求:
正确性可以用循环不变式或递归的 Base Case + 递推来证明; 效率则用下面的渐近分析来衡量.
复杂度分析的本质¶
事后在机器上跑一遍, 结果依赖 CPU、语言、编译器优化、缓存, 不同的机器/平台结果不尽相同.
复杂度分析做的是事前估计: 不关心某一条指令是 1 ns 还是 3 ns, 只关心当问题规模 \(n\) 变大时, 时间和空间涨得有多快.
因此可以把每条语句的耗时看成常数 (上界一定是常数; 受缓存影响未必每次都相同, 粗略估计够用), 把「计时」简化成「数基本操作执行了多少次」.
时间复杂度¶
设问题规模为 \(n\) (排序里是元素个数, 图里常指 \(|V|\) 或 \(|E|\)). 算法中某个基本操作的执行次数是 \(n\) 的函数 \(f(n)\), 则时间开销记为 \(T(n)\). 渐近时间复杂度取 \(f(n)\) 的增长量级, 408中通常写成
\(n\) 足够大以后, \(T(n)\) 的增长不超过 \(f(n)\) 的常数倍.
时间复杂度的计算步骤¶
-
找基本操作, 一般是最内层循环体里执行次数最多的那句 (比较、移动、赋值)
-
写出它关于 \(n\) 的频度 \(f(n)\)
-
只留最高次项, 丢掉系数和低次项, 得到 \(O(\cdot)\)
推导时常用两条规则:
-
加法: 依次执行 \(O(f)\) 和 \(O(g)\), 总复杂度 \(O(\max(f, g))\)
-
乘法: 嵌套时频度相乘, \(O(f) \cdot O(g) = O(fg)\)
顺序执行的代码只贡献常数, 可以忽略; 嵌套循环每深一层, 往往就多乘一个 \(n\).
演算示例
for (i = 1; i <= n; i++)
for (j = 1; j <= i; j++)
x++;
内层对每个 \(i\) 跑 \(i\) 次, 总次数 \(\sum_{i=1}^{n} i = n(n+1)/2 = O(n^2)\).
for (i = 1; i <= n; i *= 2)
x++;
\(i\) 每次翻倍, 循环约 \(\lfloor \log_2 n \rfloor + 1\) 次, \(O(\log n)\). 对数就是这样从「每次把规模砍半 / 翻倍」里冒出来的, 不是从加减乘里凭空出现的. 折半查找、归并排序的 \(\log n\) 同源.
最好、最坏、平均时间复杂度¶
同一算法, \(T(n)\) 还可能随输入内容变, 不只随 \(n\) 变. 以直接插入排序为例 (输入长度记为 \(n\)):
void insertion_sort(int a[], int n) {
int i, j, key;
for (j = 1; j < n; j++) { /* 外层 n-1 次 */
key = a[j]; /* c1 */
i = j - 1; /* c2 */
while (i >= 0 && a[i] > key) {
a[i + 1] = a[i]; /* c3 */
i--; /* c4 */
}
a[i + 1] = key; /* c5 */
}
}
外层固定 \(n-1\) 次. 设内层 while 平均每次跑 \(m\) 圈, 总时间大约是
\(m\) 不是常数, 也不由 \(n\) 单独决定, 而取决于数组长什么样:
-
最好 (已正序):
while一次都不进, \(m = 0\), 时间是 \(an+b\), 线性, \(\Theta(n)\) -
最坏 (逆序): 每插入一个
key都要挪开左侧全部元素, \(m\) 对 \(j=1,\ldots,n-1\) 分别取 \(1,2,\ldots,n-1\), 总和 \(n(n-1)/2\), 时间是 \(an^2+bn+c\), 二次, \(\Theta(n^2)\) -
平均 (随机): 左侧大约一半比
key大, \(m \approx j/2\), 总和仍是 \(\Theta(n^2)\)
比较次数、移动次数的精确求和见插入排序的算法分析.
为什么更关心最坏时间复杂度¶
-
最坏情况是执行时间的上界, 任意输入都不会更差, 方便比较
-
有些问题里最坏最常见, 例如查找失败要把表扫完 (顺序查找失败)
-
很多算法的平均和最坏属于同一量级, 插入排序就是: 平均、最坏都是 \(n\) 的二次函数
最好情况往往要输入满足很苛刻的条件 (已经排好序), 单独拿来当「这个算法很快」容易误导.
渐进记号¶
多项式里, \(n\) 的最高次决定量级: 比较 \(100n+1\) 和 \(n^2+1\), 当 \(n>100\) 后后者远大. 系数、低次项、常数在 \(n \to \infty\) 时都可以丢掉. 于是用集合来写「同一量级」.
西塔记号¶
\(\Theta(g(n))\) 表示和 \(g(n)\) 同一量级的一类函数. 若能找到正常数 \(c_1\)、\(c_2\)、\(n_0\), 使得 \(n \geqslant n_0\) 时
就说 \(f(n) = \Theta(g(n))\) (更严谨是 \(f \in \Theta(g)\)). \(\Theta\) 的含义接近「等于」: 上下都被 \(g\) 卡住, 是渐近紧确界.
\(\tfrac{1}{2}n^2 - 3n = \Theta(n^2)\)
要证存在 \(c_1,c_2,n_0\) 使 \(n \geqslant n_0\) 时 \(c_1 n^2 \leqslant \tfrac{1}{2}n^2 - 3n \leqslant c_2 n^2\). 两边除以 \(n^2\):
对任意 \(n>0\), 该式都 \(< 1/2\), 取 \(c_2 = 1/2\). \(n=7\) 时 \(\tfrac{1}{2}-\tfrac{3}{7}=\tfrac{1}{14}\), 之后都更大, 故可取 \(n_0=7\), \(c_1=1/14\).
任意 \(an^2+bn+c\) 在 \(n\) 足够大时都能这样夹进 \(c_1 n^2\) 与 \(c_2 n^2\) 之间: \(bn+c\) 相对 \(n^2\) 可忽略, \(a\) 靠选取 \(c_1,c_2\) 补偿.
渐进上界和渐进下界¶
-
大 \(O\) (渐进上界, 类似 \(\leqslant\)): 存在 \(c,n_0\), 使 \(n \geqslant n_0\) 时 \(0 \leqslant f(n) \leqslant c\,g(n)\)
-
\(\Omega\) (渐进下界, 类似 \(\geqslant\)): 存在 \(c,n_0\), 使 \(n \geqslant n_0\) 时 \(0 \leqslant c\,g(n) \leqslant f(n)\)
-
\(f = \Theta(g)\) 当且仅当 \(f = O(g)\) 且 \(f = \Omega(g)\).
408的写法
考研几乎只写 \(O\), 不强调 \(\Theta\). 看到「时间复杂度 \(O(n^2)\)」默认指最坏上界. 说「插入排序是 \(O(n^2)\)」正确; 说「插入排序是 \(\Theta(n^2)\)」则要加定语「最坏 / 平均」.
常见量级¶
从小到大:
| 量级 | 典型来源 |
|---|---|
| \(O(1)\) | 数组按下标访问, 栈的入栈 / 出栈 |
| \(O(\log n)\) | 折半查找 |
| \(O(n)\) | 遍历线性表, 插入排序最佳情况 |
| \(O(n\log n)\) | 堆排序、归并、快排平均情况 |
| \(O(n^2)\) | 插入 / 冒泡 / 简单选择的最坏情况 |
| \(O(2^n)\) | 朴素递归斐波那契 |
| \(O(n!)\) | 穷举全排列 |
算法分析里 \(\lg n\) 通常表示 \(\log_2 n\). \(\Theta(\lg n)\) 和 \(\Theta(\log_{10} n)\) 是一回事, 换底公式只差一个常数 \(\ln b\), 被 \(\Theta\) 吸收.
\(n\) 很小时, \(O(n^2)\) 的插入排序可以快过 \(O(n\log n)\) 的快排, 因为记号丢掉了常数. 复杂度比较的是 \(n\) 足够大 以后的趋势.
空间复杂度¶
空间复杂度 \(S(n)\) 描述算法耗费的存储随 \(n\) 的增长, 同样写成 \(S(n)=O(g(n))\).
通常只计辅助空间 (工作变量、辅助数组、递归调用栈), 不算存放输入本身的那份. 因此:
-
归并需要 \(\Theta(n)\) 的临时数组
-
递归快排平均栈深 \(O(\log n)\), 最坏 \(O(n)\)
若每层递归额外申请的空间不同, 要把各层加起来, 不能只看深度.
查找问题里还有一个专用指标: 平均查找长度 ASL, 本质仍是在数「关键字比较次数」的期望, 和 \(T(n)\) 是同一类想法.
排序算法的稳定性¶
评价排序算法时, 除了时间和空间, 还有一个指标称为稳定性(stability).
若两个记录 \(R_1, R_2\) 关键字相同, 排序前 \(R_1\) 在 \(R_2\) 前面, 排完后 \(R_1\) 仍在 \(R_2\) 前面, 就称该算法稳定; 否则不稳定.
关键字往往不是记录的全部. 例如先按成绩排, 同分的人应保持原来的报名顺序; 基数排序从低位排到高位, 每一趟也必须稳定, 高位才能叠在低位次序上面.
怎么判断¶
稳定性是实现的性质, 不是算法名字自带的. 判定口诀: 相等时不要让后来者插队.
-
要证不稳定, 举一个反例就够. 如希尔把 \(\{5_a, 5_b, 2\}\) 按增量 \(2\) 分组, \((5_a, 2)\) 排完变成 \(\{2, 5_b, 5_a\}\)
-
要证稳定, 看相等时的动作: 直接插入 只在
a[i] > key时后移; 归并 只在left[i] <= right[j]时取左. 改成>=/<, 同一套流程就会变不稳定
内部排序算法稳定性速记¶
快选希堆不稳定. 其余常见内部排序默认稳定.
| 稳定 | 不稳定 |
|---|---|
| 直接插入、折半插入 | 希尔 |
| 冒泡 | 快排 |
| 归并、基数、计数 | 简单选择、堆排 |
\(O(n\log n)\) 的比较排序里, 只有归并稳定.