[TOC]
算法是为求解一个问题需要遵循的、被清楚指定的简单指令的集合
第二章——算法分析
- 如何估计一个程序的运行时间
- 如何降低程序运行时间
- 一些经典案例
2.1 算法评价的量化理论
估计算法所需的资源消耗分析一般来说是理论问题——需要一套数学基础提供理论支撑
将核心操作的次数与输入规模关联
2.1.1 函数渐进增长
算法分析是衡量对象:某个算法的时间复杂度随输入规模增大的变化率
需要约定一个通用基准,才能通过对比,直观的判断变化率的大小
- 上界
- 下界
- 等价
四种渐进增长定义
函数渐进增长是要在函数间建立一种相对的级别——相对增长率
给定两个函数 $f(N)$ 和 $g(N)$ ,如果存在一个整数 $N_0$ 使得对于所有 $N >N_0$ , $f(N)$ 总是比 $g(N)$ 大,那么称 $f(N)$ 的渐进增长快于 $g(N)$
上界:如果存在正常数 $c$ 和 $N_0$ 使得 当 $N\ge N_0$ 时,$T(N)\le cf(N)$ ,则记为 $T(N)=O(f(N))$
- $T(N)$ 的增长率小于等于($\le$) $f(N)$ ——$f(N)$ 是 $T(N)$ 的一个上界
如果存在正常数 $c$ 和 $N_0$ 使得当 $N\ge N_0$ 时,$T(N)\ge cg(N)$ ,则记为 $T(N)=\Omega(g(N))$
- $T(N)$ 的增长率大于等于($\ge$) $g(N)$ 的增长率——$g(N)$ 是 $T(N)$ 的一个下界
如果存在正常数 $c_1$ 、$c_2$ 、$N_0$ 、$N_1$ ,使得当 $N\ge N_0$ 时,有 $T(N)\ge c_0h(N_0)$ 且当 $N\ge N_1$ 时,有 $T(N)\ge c_1h(N_1)$ ,则记为 $T(N)=\Theta(h(N))$
- $T(N)=\Theta(h(N))$ 当且仅当 $T(N)=O(h(N))$ 且 $T(N)=\Omega(h(N))$
- $T(N)$ 的增长率等于($=$) $h(N)$ 的增长率
如果 $T(n)=O(p(N))$ 且 $T(N)\neq \Theta(N)$ ,则 $T(N)=o(p(N))$
- $T(N)$ 的增长率大于 (>) $p(N)$ 的增长率
运算法则
如果 $T_1(N)=O(f(N))$ 且 $T_2(N)=\Omega(g(N))$ ,则
- 并列结构: $T_1(N)+T_2(N)=max(O(f(N)),O(g(N)))$
- 嵌套结构:$T_1(N)T_2(N)=O(f(N)g(N))$
如果 $T(N)$ 是一个 $k$ 次多项式,则 $T(N)=\Theta(N^k)$
对任意常数 $k$ ,$log^k(N)=O(N)$ ——对数增长很缓慢
几种典型的增长率排序
- $O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n)$
在渐进增长函数中省略所有的常数,系数
修改后的次数中,只保留高阶项
两个函数相对增长率判断
洛必达
通过 $\lim_{n\rightarrow\infty}\limits \frac{f(N)}{g(N)}$ 确定两个函数 $f(N)$ 和 $g(N)$ 的相对增长率
- $0$ :$f(N)=o(g(N))$
- $c\neq 0$ :$f(N)=\Theta(N)$
- $\infty$ :$g(N)=o(f(N))$
2.1.2 算法分析的计算机模型
有一个简单的指令系统:加法、乘法、比较和赋值
模型机做任何一个简单的工作,都花费一个时间单元
模型机有无限内存
2.1.3 要分析的目标
算法分析的最重要的资源一般来说是运行时间
事前分析:
- 算法采用的策略和方案
- 编译产生代码质量
- 问题的输入规模
- 机器执行指令的速度
编译器和计算机执行速度超过了理论分析范畴,所以在算法分析中,重点考虑 算法采用的策略 以及 输入规模
输入的大小是主要的考虑方面,$T_{avg}(N)$ 和 $T_{worst}(N)$ 分别表示算法花费的平均运行时间和最坏情况下运行时间
而算法的平均情况的界计算起来通常很困难,所以一般情况算法分析的是 最坏情况下的运行时间
所以实际上计算的是 大O运行时间 ,该分析结果为程序在一定的时间范围内能够终止运行提供了保障。程序可能提前结束,但绝不可能拖后
$\sum_{i=1}\limits^{N}i^3$
1 | # 累加 |
最坏情况
直接查找
1 | int search(int num){ |
最好情况:
- 查找的第一个数字就是期望数字—— $O(1)$
最坏情况:
- 查找的最后一个数字,才是期望数字——$O(n)$
平均情况:
- 任何数字查找的平均代价为 ——$O(n)$
2.1.4 一般法则
for循环
1 | for(int i=0;i < N;++i){} |
$T(N)=O(T(\{\})\times N)$
嵌套for循环
1 | for(int i=0;i < N1;++i){ |
$T(N)=O(T(\{\})\times N1\times N2\times…)$
顺序语句
将各个语句的运行时间求和——最大值就是整体的运行时间
1 | for(int i = 0;i < N;++i){ |
$T(N)=max\{O(N),O(N^2)\}=O(N^2)$
分支语句
1 | if(condition) S1; |
$T(N)=O(T\{condition\}+max\{T(S1),T(S2)\})$
二分
对数阶,由于输入规模n的增大,不管底数多少,增长率都相同,所以忽略底数
1 | int i=1,n=100; |
循环次数:$x = log_2n$
- 时间复杂度:$O(logn)$
函数调用
先分析被调用的函数,再分析回调函数
1 | void main(){ |
1 | void main(){ |
2.2 计算方法
2.2.1 循环主体中的变量参与循环条件的判断
找出主体语句中与 $T(n)$ 成正比的循环变量,将之代入条件运算
1 | int i = 1; |
- 执行次数 $t$ ,有 $2^t \leq n$ ,得 $T(n) = O(log_2n)$
1 | int y = 5; |
- 当 $(y+1)^2=\lfloor\sqrt{n}\rfloor$ 时,得到执行次数 $t = \lfloor\sqrt{n}\rfloor-6$ ,即 T(n) = O($\sqrt{n}$)
模拟法优化递归
斐波那契数列与阶乘的计算
- 输入 $N$ 作为计算的终止条件
- $fun(N)$ 的计算依赖于 $fun(N-1)…fun(N-k)$
- 不适用分治
斐波那契数列
1 | long int Fib(int N){ |
- 如果 $N=0$ 或 $N=1$ ,运行时间是常数值——$O(1)$
- 如果 $N\ge 2$ ,则 $T(N)=T(N-1)+T(N-2)+2$
$Fib(N)=Fib(N-1)+Fib(N-2)$ ,由归纳法可知 $T(N)\ge Fib(N)$
由归纳法可以证明:$Fib(N)<(\frac{5}{3})^N$ ,同样可得 $(\frac{3}{2})^N\le Fib(N),(N>4)$
可见,$(\frac{3}{2})^N\le Fib(N)\le T(N)$
故通过递归程序计算斐波那契数列,时间复杂度为 $O(2^N)$
之所以是指数时间,是因为存在大量重复计算——计算任何事物不要超过一次
优化斐波那契数列求解
1 | //记忆搜索 |
对于规模较小的输入可以得到输出,单对于大规模的输入,仍相当于指数阶
1 | long int Fib2(long int N){ |
- 时间复杂度为 $O(N)$
阶乘计算
$n!=1\times 2\times 3\times\cdots$
1 | //计算阶乘 ,递归定义 |
优化算法相当于模拟乘法的手算过程,用数组元素代表数位。将计算结果按位依次存储到数组中,然后逆序输出
例如:求 $5!$
1 | i = 2 |
1 | void print_fac(int n){ |
- 时间复杂度为 $O(N)$
通过运行结果,可以看到按定义(即递归),最多只能算到25
而改进之后的算法,可以看到算到70000都没有崩,对于一般的竞赛来说已经足够了
运算时间中的对数
- 如果一个算法用常数时间 $\left(O(1)\right)$ 将问题的规模削减为原来的一部分(一般为一半),那么该算法就是 $O(logN)$
- 如果用常数时间把问题规模削减为一个常数,那么这种算法就是 $O(N)$
折半查找
更定一个目标元素
X
和 有序 整数序列 $A_0$ 、$A_1$、…、$A_{N-1}$ ,求得使 $A_i=X$ 的下标 $i$ ;如果X
不存在与序列,则返回 $i=-1$
1 | int BinarySearch(const ElementType A[],ElementType X,int N){ |
在 while
循环体中,时间复杂度为 $O(1)$ ——$T(N)=O(1)$
循环次数最多为 $\lceil log(N-1)\rceil+2$
- 若 $high-low=128$ ,迭代过程中,$high-low$ 最大值是 64,32,16,8,4,2,1,0,-1
故二分查找时间复杂度为 $T(N)=O(T(\{\})\times (\lceil log(N-1)\rceil+2))=O(logN)$
适用于静态数据的查找——不允许数据的插入和删除
欧几里得算法——求最大公约数
定理:如果 $M\ge N\ge 1$ ,则 $M \mbox{ mod N} < \frac{M}{2}$
- 如果 $N\le\frac{M}{2}$ ,则由于余数小于 $N$ ,有 $M\%N < N\le \frac{M}{2}\Rightarrow M \mbox{ mod N} < \frac{M}{2}$
- 如果 $N>\frac{M}{2}$ ,则 $M\%N=M-N<\frac{M}{2}$
1 | unsigned int Gcd(unsigned int M,unsigned int N){ |
在循环体中,时间复杂度为 $T(\{\})=O(1)$
接下来重点是计算迭代次数
可以根据定理,粗略的认为:每次迭代可以在常数时间内使 $M$ 减少一半,故迭代次数为 $O(logN)$
令 $Rem_0=M,Rem_1=N$
迭代 $n$ 次后,$Rem_{n+1}=0$ ,$Rem_{n}\ge 0$ 即为最大公约数
观察斐波那契数列
可知:
不妨假设:$Fb_{k}\le Rem_{n-k}$ ,由归纳法
由于 $Rem_1=N\ge Fb_{n-1}=\frac{1}{\sqrt{5}}\left[\left(\frac{1+\sqrt{5}}{2}\right)^{n-1}-\left(\frac{1-\sqrt{5}}{2}\right)^{n-1}\right]\Rightarrow n=O(logN)$
- 故其时间复杂度为 $T(N)=O(T\{\}\times log(N))=O(logN)$
快速幂
计算 $x^n$
1 | long int Pow(long int x,unsigned int n){ |
- 时间复杂度为 $O(N)$
1 | long int Pow(long int X,unsigned int N){ |
- 若 $N$ 是偶数,则 $X^N=X^{\frac{N}{2}}\cdot X^{\frac{N}{2}}$
- 若 $N$ 是奇数,则 $X^N=X\cdot X^{\frac{N-1}{2}}\cdot X^{\frac{N-1}{2}}$
将问题对半分,每个子问题最多需要两次乘法
$T\{\}=2$ ,函数调用次数为 $logN$,故时间复杂度 $T\{N\}=O(T\{\}\times logN)=O(2logN)=O(logN)$
快速幂非递归形式
原理:
计算 $X$ 不大于 $N$ 的二次幂序列 $X,X^2,X^4,\cdots,X^{2^{\lfloor logN\rfloor}}$
时间复杂度为 $O(logN)$
将 $X$ 用二进制表示,将
1
位对应的二次幂相乘就是 $X$ 的 $N$ 次幂通过取余求1的个数,最多计算 ${\lfloor logN\rfloor}+1$ 次
通过 popcount,时间复杂度会更少
两个计算步骤是并列的,所以时间复杂度为 $O(logN)$
例:计算 $X^{62}$
计算并保存序列:$X,X^2,X^4,X^8,X^{16},X^{32}$
计算 $\lfloor log62\rfloor=5$ 次
$62$ 的二进制表示为
11 1110
通过 $\lfloor log62\rfloor=5$ 次计算得到1的个数
同时计算 $X^{62}=X^{32}\cdot X^{16}\cdot X^{8}\cdot X^{4}\cdot X^{2}$
进行5次乘法
1 | int pow(int X,int n){ |
1 | int pow(int X,int n){ |
快速幂时间复杂度为一个上界,实际执行次数不一定等于上界
例:分析计算 $X^{62}$ 需要的乘法次数
按照快速幂的思想:$2log(62)=10$
需要9次乘法
也可以通过
可见通过8次乘法也是能计算出 $X^{62}$
2.2.2 循环主体中的变量与循环条件无关
采用 归纳法 或 累计循环次数
多层循环从内到外分析,忽略单步语句、条件判断语句,只关注主体语句的执行次数
暴力法分析
1 | Sum = 0; |
- 时间复杂度为 $O(N)$
1 | Sum = 0; |
- 时间复杂度为 $O(N^2)$
1 | Sum = 0; |
- 时间复杂度为 $O(N^3)$
1 | Sum = 0; |
- 时间复杂度为 $O(N^2)$
1 | Sum = 0; |
- 时间复杂度为 $O(N^5)$
1 | Sum = 0; |
- 时间复杂度为 $O(N^4)$
分治法
策略
- ”分“:将大问题大致分为两大致相等的 幂次阶 子问题,用递归求解
- “治”:将两个子问题的解合并到一起并可能再做些少量的附加工作(幂次阶),得到整个问题的解
主方法 :大小为 $N$ 的原问题分成若干个大小为 $\frac{N}{b}$ 的子问题,其中 $a$ 个子问题需要求解,而 $cN^k$ 是合并各个子问题需要的工作量。此时问题可表示为:
则其时间复杂度可相应的表示为:
推导
已知
1. 等号右边连续代入递归关系
由 $k = logN$ 得
2. 叠缩求和
用 N 去除递归关系中的两边,不断替换
将等号左边的所有相相加等于右边所有项的和,结果为
3. 主方法
两个规模为 $\frac{N}{2}$ 的子问题,用 $N^1$ 的代价合并子问题
$k=1,a=2,b=2$ ,$2=2^1,a=b^k$
故由主定理,$T(N)=O(NlogN)$
2.2.3 常见算法时间复杂度
描述 | 增长的数量级 | 说明 | 举例 |
---|---|---|---|
常数级 | 1 | 普通语句 | 将两个数相加 |
对数级 | $logN$ | 二分策略 | 二分查找 |
线性级 | $N$ | 单层循环 | 找出最大元素 |
线性对数级 | $NlogN$ | 分治思想 | 归并排序 |
平方级 | $N^2$ | 双层循环 | 检查所有元素对 |
立方级 | $N^3$ | 三层循环 | 检查所有三元组 |
指数级 | $2^N$ | 穷举查找 | 检查所有子集 |
2.3 最大子序列和问题求解
2.3.1 遍历所有子串,对子串的子序列依次求和
1 | int MaxSubSequenceSum(const int A[],int N){ |
- 时间复杂度为 $O(N^3)$
- 时间复杂度为 $O(N^3)$
2.3.2 记录中间累加量
1 | int MaxSubSequenceSum(const int A[],int N){ |
- 时间复杂度为 $O(N^2)$
2.3.3 分治法
将序列分成大致相等的两部分。
最大子序列和可能在三处出现:
数据的左半部分;
- 递归求解
数据的右半部分; - 递归求解
中间部分 - 分别求出前、后部分的最大和,相加中间部分最大和
1 | int MaxSubSum(const int A[],int Left,int Right){ |
- 时间复杂度为 $O(NlogN)$
分治法 时间复杂度一般都是若干个代价为 $logN$ 的子问题与一个处理函数的代价和
2.3.4 最简
1 | int MaxSubSequenceSum(const int A[],int N){ |
- 时间复杂度为 $O(N)$
2.4 空间复杂度
算法所需的辅助空间数量级
- 运行过程中变量占用的空间 辅助数组
- 递归工作栈 递归深度
原地工作
算法所需的辅助空间为常量
2.5 习题
2.1
2.2
$T(N)=O(N)$ ,$O(N)$ 是上界,但不是上确界,即 $T(N)=aN+b=O(N^2)=O(N^3)$
即 $\lim_{n\rightarrow \infty}\limits \frac{f(N)}{T_1(N)}=\infty$ ,且 $\lim_{n\rightarrow \infty}\limits \frac{f(N)}{T_2(N)}=\infty$ ,无法判断 $T_1(N)$ 与 $T_2(N)$ 的关系
2.3
定理:$log^kN$ 的增长率远小于 $N^\epsilon$ 的增长率,$log^kN=o(N)$
实际上是比较 $logN$ 与 $N^{\frac{\epsilon}{\sqrt{logN}}}$
2.5
可以理解为 $f(N)$ 的增长率不大于 $g(N)$ 的增长率,$g(N)$ 的增长率也不大于 $f(N)$ 的增长率
即 $f(N)$ 的增长率与 $g(N)$ 的增长率相等即可 $f(N)=\Theta(g(N))$
2.6 在前面已经应用举例
2.5.7 随机数序列生成算法
alg1
1 | void randSeq(int A[],int N){ |
生成第 $i$ 个不重复随机数的概率为 $\frac{N-i}{N}$ ,所以理论上经过 $\frac{N}{N-i}$ 次生成得到不重复随机数的概率为 $1$ ,对于每个随机数需要经过 $i$ 次验证,所以总耗时
alg2
1 | void ranSeq(int A[],int N){ |
省去了对每个随机生成的数进行 $i$ 次验证,总的时间复杂度为
alg3
1 | void ranSeq(int A[],int N){ |
时间复杂度是线性的 $O(N)$
2.5.9 多项式计算
计算多项式 $F(x)=\sum_{i=0}^{N}\limits A_iX^i$ 需要多长时间
简单方法取幂:
1 | void poly(int A[],int X,int N){ |
快速幂
1 | void poly(int A[],int X,int N){ |
- 时间复杂度为 $O(NlogN)$
秦九韶算法(Horner算法)
1 | void poly(int A[],int X,int N){ |
- 时间复杂度为 $O(N)$
1 | oid poly(int A[],int X,int N){ |
- 时间复杂度为 $O(N)$
2.5.11 在有序数组中查找是否存在整数——二分查找
时间复杂度 $O(logN)$
2.5.13 判断一个正整数是否为素数
素数定义:在大于1的自然数中,除了1和他本身之外不再有其他因数的自然数
- 可见偶数一定不是素数——可被2整除
alg1
直接判断,对一个自然数 k
来说,需要判断 k-2
次,时间复杂度为 $O(n)$
1 | bool IsPrime(int n){ |
alg2
除了1之外,任何合数最小的因子就是2,相应的最大因子就是 $\lceil \frac{n}{2}\rceil$ ,所以只需要从2开始遍历到 $\lceil \frac{n}{2}\rceil$ 就可以了
1 | bool IsPrime(int n){ |
- 循环判断条件必须包含 $\frac{n}{2}$ :如 $n=4$ 时,必须通过 $\%2$ 判断为素数,若 $<2$ 则会出现错误的结果
- 可以是
i <= n/2
或i < n/2 + 1
- 可以是
时间复杂度仍然为$O(n)$
alg3
根据素数定义,如果一个数不是素数,即合数一定是两个数的乘积
假设 $n=i*j$ ,则一定有一个 $\le \sqrt{n}$ ,另一个 $\sqrt{n}$ ,因此只看较小那个数存不存在就可以判断n是否为素数
1 | bool IsPrime(int n){ |
时间复杂度降为 $O(\sqrt{N})$
- 当然,可以针对 alg1,alg2,alg3 做进一步改进,
i++
改为i+=2
,因为我们知道偶数一定不是素数
习题13是根据这种做法设问的
(b) $O(\sqrt{N})$
(c) 令B表示N的二进制中的位数,则B的值为多少
故 $N=O(2^B)$ ,$B=O(logN)$
(d)用B表示时间复杂度
$O(N^{\frac{1}{2}})=O(2^{B\frac{1}{2}})=O(2^{\frac{B}{2}})$
(e)比较确定20位二进制的数是否为素数与40位的运行时间
令 $T$ 表示确定20位二进制的数是否为素数的运行时间,有 $T=O(2^\frac{20}{2})$ ,有判断40位二进制数是否为二进制数的时间为 $T^2$
alg4
查表
表构造原理:如果一个数不能整除比它小的任何素数,那么这个数就是素数
等价于证明:“不能被素数整除则一定不能被合数整除”逆否“能被合数整除则一定能被素数整除”
合数要么是偶数,要么是奇合数
- 对于奇合数,只能被 $>2$ 的素数整除(若有偶因数,则成为偶数)
- 对于偶数,一定能分解为 $2\times p_1\times\cdots \times p_k$ ,p的取值为 $2,3,5,7,11,13,..$ 等一系列素数
可见,如果一个数是合数,则一定能被素数整除,故不能被比他小的任何素数整除,一定是素数
1 | //n:输入的要查找的数 |
alg4——普通筛法(埃拉托斯特尼筛Eratosthenes法)构造素数数组
只关注下标为 $2\sim n$ 的数组元素,如果该元素为1,则说明数组元素对应的下标为素数——桶思维
假设所有元素都是素数,初始化为1,遍历范围为 $2\sim \sqrt{n}$ ,将已知素数的整数倍下标赋值为0,
要得到自然数n以内的全部素数,必须把不大于n的所有素数的倍数剔除,剩下的就是素数
1 | //isprime:判断素数的数组 上限N |
- 埃拉斯托拉斯筛法,确定小于n的所有素数,时间复杂度为 $O(NloglogN)$
- 而判断n是否为素数,我们实际上之关心小于 $\sqrt{N}$ 的所有素数,所以可优化为 $O(N)$
alg5——找n以内的所有素数线性筛法——欧拉筛法
在提出非素数时,有些合数会重复,如:
2是素数,剔除”2 的倍数“,他们是:4,
6
, 8,10,12
, 14, 163是素数,剔除”3 的倍数”,他们时,
6
,9,12
,156,12是重复的。
合数一定有一个最小的质因子,所以在埃氏筛法的基础上,让每个合数只被它的最小质因子筛选一次,以达到不重复的目的
1 | void Eulerprime(int* isprime, int n,int *Prime,int &count) { |
- 欧拉筛获取n以内的所有素数,时间复杂度为 $O(N)$
- 若只关心n是否为素数,可以只获取 $\sqrt{N}$ 内的素数,时间复杂度减少为 $O(\sqrt{N})$
alg6——六素数法
任何一个自然数都可以被写成
中的一个。
可见,在6的倍数附近,可能会出现两个素数 $6k-1$ 和 $6k+1$ 。故判断一个数是否为素数,取余即可
例外:
2,3,5,7,11,13 ,17,19,23,29,31,37,41,43,47,53,59,61
- 可见,有两种例外,25,35对5取余为0,39对3取余为0,49对7取余为0
所以对素数判断的改进思路为:
在 alg1 的基础上,将步长改为6
还是根据没有素因数则为素数的定理,只不过不需要构造素数表
判断小于 $\sqrt{n}$ 范围内,能否被相邻两个可能的素数是否能整除n即可
1 | bool IsPrime(int n){ |
2.15 证明8次乘法可算出 $X^{62}$
2.16 非递归形式求快速幂
2.17 快速幂的乘法精确次数
见快速幂部分
2.18
d感觉是不能