《目录》
- 快速排序或者说是分而治之的数学原理
- 大O记法
- 最好、最坏、平均、均摊时间复杂度
- 复杂度参考
- 空间测试
- 证明:基于比较的排序算法最大比较次数为
对于计算机算法来说,虽然衡量其好坏的标准非常多,比如
如此纷杂,我们需要一个统一的客观标准,这个标准就是算法的复杂程度。
1965年,计算机科学家 Juris Hartmanis and Richard Stearns 在 《论算法的计算复杂度》一文中提出这个概念。二人因此获得了图灵奖,但最早将计算机复杂度严格量化衡量但是计算机科学家、算法分析之父高德纳,著有《计算机程序设计艺术》。高德纳的思想也是一种极限思想,跳出框架看算法。
早期的算法,主要是在模仿。比如,小白会学习的冒泡和插入排序。冒泡是不是很像拍集体照的时候,摄影师会让人俩俩交换,找到一个合适的高矮顺序;插入和斗地主类似,抓到里按照顺序插入就排好序了。接着,我们采用以上高纳德的工程思想,去分析冒泡排序。
冒泡排序:暂定 50 人
- 第一轮 50 人中找出最高的要比较 49 次,
- 第二轮 49 人中找出最高的要比较 48 次,
- ... ...
- 第 n 轮 50 - (n+1) 人中找出最高的要比较 50 - n 次,大约要 1200 次,也是 一半左右。
- 如果我们把 50 换成 任意正整数 N(可能为∞),排序次数也为 [工程代换,考虑极限]
- 冒泡排序的时间复杂度是随数据量变化而变化的,是 的幂函数。
- 我可以指出,第二种插入排序和冒泡排序排序次数是一个量级,所以 ta 们是一样 ,冒泡排序哪怕优化为鸡尾酒排序,也还是一个量级。[构建理想环境]
没办法,早期的排序算法需要排序的次数 ,基本和冒泡、插入排序在一个量级。一时间,没人想得到好的算法,满足减少一个量级标准的算法。十年过去了,全世界所有的算法专家,终于发现冒泡等排序慢的原因,这种从生活经验出发的排序算法,做了很多无用功。要想改变执行速度的量级,只要让计算机少做事情。
冒泡排序,ta 每轮选一个数和其 ta 所有数比较,要让计算机少做事情,在这里就是减少比较次数。
最早对冒泡排序的改进是归并排序,思想是:如果把全班同学分成俩组,分别排序,那么从这俩组中可选出最高的,这样就减少一半的相互比较时间。
N个数排序过程如下:
step-0: N 个数一分为俩,变成俩组,接着对俩组排序
step-1: 排序才用冒泡还是插入好呢,既然,可以分成俩组,那么每个组又可以一分为二,变成四组,四组也可以一分为二,为八组。如此如此,辽东可破 !
step-2: 重复 step-1 的描述,分别排序再合并。俩组变成四组,减少 3/4 的比较次数。四组变成八组,减少 7/8 的比较次数。
计算机比较的次数节省的次数越来越多,分到最后每组只要俩人时,其实不用排序了,只比较一次大小即可。
这种方法是一个对数函数 ,当数趋向无穷时,对数函数反而增加的越来越缓慢。
具体请看量级比较表格
比较次数/数据量 N 1 100 1万 1百万 1亿 冒泡 1 1万 1亿 1万亿 1亿亿 归并 1 700 13万 2000万 23亿 当 数据量 N 为 1亿时,冒泡要比归并多 400万倍,当 N 继续增长,ta 们真就是一个地球,一个银河系了。
像 BAT 的用户数可远不止 1 亿,比如,王者荣耀的区域排名,这可以用冒泡排序不,显然不能。
归并是一种自顶向下细分,再自底向上合并[人工流水线模型]。这样的思想减少了许多比较,比如一个人的效率是这样计算。
效率 = 产出 / 所做的事情。
人的产出很难提高,好的作品需要不断研磨;但是所做的事情可以大幅度减少,我们只需要把一个方面的长处做到极致。也没必要为了补一个短板,去消耗长处研磨的时间。今天是一个人多粥少,人的技能普遍高出行业基本要求的时代,进入任何一个行业,都要把自己的长处发挥到极致。在农耕文明时代,没有力气的人最不济也能产生壮劳力 1/3 的生产力。在工业时代,靠手工一件件生产商品,可能效率能有机器的 1% 。但是到了智能时代,本事差一点,效果可能差几百万倍,几亿倍,极致和普通就差得更远了。
似曾相识,这就是勾股定理,非常巧妙的应用之一。
这个公式可用于任何平方项的公式,一方的 可以转为 。
因为冒泡排序的时间复杂度是 ,那可用上勾股定理的思想。
原来冒泡,排序 50 个数需要的时间现在可以排序 70 个数,为什么呢?
要做到很简单,只需要把数分成俩组即可。这就是为什么分开排序会更快!!
不过快也有限制的,数学证明基于比较的排序不会有比 O(N log n) 更快的排序算法了(算法导论有证明过程),另外基于比较的查找算法不会有比O(log n) 更快的查找算法,证明在文章末尾。
补充条件:排序的元素全部不同。
p.s. 数组计数 不算里面虽然TA的时间复杂度是O(m+n)哪个大就是哪个,而且TA的实现并不基于比较哦,比快排要快可是太浪费空间了。哪怕离散化一下,通用性也不好。工业界也多采用快排,快排的期望效率是归并的3倍左右,使用随机化技术可以让快排达到尽量平衡的划分具体可以参考《算法导论》。
既然说了快速排序,我还是把我对 Qsort 的理解也分享一下吧。
其实理解和掌握一个新知识,最好是从您已经熟悉的地方出发,找到他们之间的共同点。
假设您开了一家公司呢,公司人数为 n,公司规模大了这时您需要考虑怎么样使公司的运行效率会更高。
这时,我 --一个帮您拎包的助手 向您比比手。
我有办法,提高效率只需要让更少的人参与决策,公司的大事一定不和每个员工商量,行政需要层级,需要一个划定的枢纽值,就有了三六九等,每层都有自己层级的事情,这种思想叫 "分层"。
大到国家、中到美国的私营公司、小到 由十几亿晶体管组成的CPU 抑或是 一个班级会有 "班长、副班长、组长"等,都是采用的 "分层" 思想,效率极高。
重点就是一个划定的枢纽值,我们可以把其类比(另一种思想)到排序中。
类比思想最重要的是找到俩者的共同点,排序和一个班级 是有共同点的。
是枢纽,排序需要比较,普通的比较俩俩相比即可这如同小公司,因为员工不多效率也高;但用于大公司时也就是有成千上万个数需要排序时,俩俩比较十分低效。
这时,应类比国家治国。给排序一个枢纽值。
我们分析一下 Qsort 也就是快排的代码。
C 版 typedef int T; void quicksort( T arr[], int left, int right ) { int i, j, mid; if( left < right ){ mid = arr[(left+right)>>1]; // 取中间值做基准 i = left - 1; j = right + 1; while( 1 ){ while( arr[--j] > mid ); while( arr[++i] < mid ); if( i >= j ) break; SWAP(arr[i], arr[j]); } quicksort(arr, left, i-1); quicksort(arr, j+1, right); } } C++ 版 template
void quicksort(iterator L, iterator R) { if (L + 1 < R) { auto P = *L; iterator M = --partition(L + 1, R, [P](const auto& x) { return x < P; }); swap(*L, *M); quicksort(L, M); quicksort(M + 1, R); } } 无论是哪个版本,在代码 quicksort() 之前的代码都是通过枢纽划分数列,而 quicksort() 是对子数列排序。
快速排序就是反复执行的构成。代码细节,这里不会说因为这篇博客说的是渐进记号。快排不难推荐《啊哈!算法》自习。
确定性的快速排序选的枢纽值是固定的,一般为数列的第一个元素、中间的元素、最后一个元素。
C 标准库的快速排序采用的最后一个元素实现。
其实快速排序是一个随机算法,所以在随机快速排序中,我们使用随机数来选择下一个枢纽值即可。
上面说的除快速排序以外的文字,都只是为让您可以轻松理解 极限 这个概念,揭示 大O[简进记号] 才开头呢。
极限:跳出框架。
测试程序的具体运行时间:可采用 测试函数 执行时间来测试。
C 程序性能吞吐量计算 time.h
double start, over;
time(&start);
//******************************
//放要测定运行时间的函数 function()
//******************************
time(&over)
double run_time = over - start;
printf(" %lf\n ",run_time);
C++ 程序性能吞吐量计算
double start = clock( );
//******************************
//放要测定运行时间的函数 function()
//******************************
double end = clock( );
cout << ( end - start ) / (CLOCKS_PER_SEC ) << "秒" << endl;
// CLOCKS_PER_SEC * 60 就是以分钟为单位
执行出来,这是很精确的一个时间值,可为什么大部分人都采用的是 大O记法 呢 ???
因为这种计算方法有很大局限:
- 1. 不能跨平台,注定不是主流。
不同CPU的运算能力不一,比如1.6 GHz Intel Core i5,每秒运算次数最高至 1.6*10亿,【1.6GHz 是CPU的时钟频率以时钟转一圈的时间计数,如果只有 寄存器、控制器、运算模块,CPU是死的,加上时钟才让CPU颇具生命力。】可是,还有Intel Core i7、i9, 多核CPU,微机等等,甚至CPU架构完全不同。所以这样的算法并不主流,也不能事前规划,算法就很难优化。
- 2. 不能自动实现不同数据规模的整理分析。
运算结果都是一堆数字,我们要想分析得具体建模,画函数图像,写一个程序就分析一下,不是很麻烦吗?
我们观察的太局部,为什么呢!只想了一个程序的运行时间,算法的具体时间复杂度分析不出来,如果测试数据小了,冒泡和插入排序都比快速排序要快,于是,以后您都用前者处理以后工作中的量级数据!!!
智人,ta 的聪明是有道理的。因为比起我们只知道计算具体的时间,ta们已经到工程与科学的极限处看事情。如果您观察?,难道跟在 ta 走吗,要想学好肯定不能。分析事物,都应该跳出来看,要从 ta 的上面观察。
??
那么 1. 是不是不同数据量的程序都有明确的运行时间?
2. 如果有明确的运行时间可不可以直接分析?
3. 如果可以直接分析能不能简化到极致?
答案是有的,是函数? 因为函数,让对事物的研究,变的清晰,喜欢函数的人,一般都是聪明人。
大O记法,渐进时间复杂度(asymptotic time complexity),简称时间复杂度(算法运行时间)。
来源于高等数学[微积分]。是一种跨平台(不同电脑/CPU运算差异)的估算时间复杂度的方法,也是事前诸葛亮。
相信您会喜欢,哈哈。学好 大O(order of growth) ,算法就有您一席之地。
/* 假设 CPU 运行时间单位为 time. */
auto m = 9;
// Q0:CPU 执行 1 time,关键字【auto】C++11改变为,自动推导变量类型,与脚步语言一样。
for( auto i = 0; i < length; i ++ )
{
; // 空语句,什么都不做。
}
// Q1:CPU 执行length time
auto sum = 0; // 1 time
for( auto i = 0; i < length; i ++ ) // length time
{
sum += i; // length time
}
// Q2:CPU 执行 (2 * length)+ 1 time
auto sum = 0; // 1 time
for( auto i = 0; i < N; i ++, putchar(10) ) // N + 1 time, putchar(10)是换行
{
for( auto j = 0; j < i; j ++ ) // N / 2 time,等差数列
std::cout<
求解算法的运行步数是算法分析的前置准备。
算法也是一步一步执行,并没有半步的情况,因此求解算法的运行步数是按行计算。
为了能根据运行步数判断算法的速度,前提条件中必须给出运行每一步要花费的时间。
规定所有行算法执行步数的花费的时间一样。 如 a = 1,if(a = 1) , a = b 这些花费时间相同。
这个计算模型虽然极其简单,却十分实用。
有了上面的计算模型,我们开始研究第一个问题,对于不同规模的数据量可以看出只要研究 ta 的循环结构为主。比如Q1,运行length次,大O记法,一个循环,里面的因数默认是 n 即O(n),俩个循环默认 n,m 即O(n·m) ,大部分情况不会叠太多循环。因为我们学习 大O记法 ,便是为优化算法做准备的。
下面举例子; e.g. O(+n+1) 解决第二个问题,既然有复杂度,那么就可以分析。
智人不是喜欢研究函数图像,分析他们的规律嘛~,来来,找一个画函数图像软件画一下。
因为处理数据规模 x 肯定是大于 0 的 (量级),所以,只需要看 x,y 的 正半轴(第一象限 -> 右上部分)
考虑第三问,你不觉得这样的分析还是很麻烦吗,是的。那么,怎么简化呢?
一个科学家发现所有程序可以化为三种结构构成:顺序结构、分支结构和循环结构。而顺序结构和分支结构中的每段代码只运行一次;循环结构中的代码的运行时间要看循环的次数。所以,我们是不是只要研究循环结构就好了,因为顺序和分支只是一个常量,对算法本身影响微乎其微。 warning: 常量是一个确定的数,比如 0、1、100、1e+10、 1e+100,⚠️常量不计,稳。
P.S. 程序所有代码执行次数使用加法,加起来,如果程序循环嵌套,则嵌套内外代码执行次数乘积。
??
简化的过程总结为3步:
- step-0: 去掉运行时间中的所有加法常数。(例如 +n+1,直接变为 +n)
- step-1: 只保留最高项。(+n 变成 )
- step-2: 如果最高项存在但是系数不是1,去掉系数。(系数为 1)
所以,最终合并而成的代码的时间复杂度为O()。具体为什么这样可在下文找到。
O(1)常数阶 < O(logn)对数阶 < O(n)线性阶 < O()平方阶 < O()(立方阶)
)(k次方阶)< O() (指数阶)
相关论文:《Big Omicron and big Omega and big Theta》
你知道为什么 程序执行时间 T(n)可以等价于O(n) , T(n) = O(f(n)) ???
代码分析:
分析Q0 ~ Q3,逻辑推出:所有代码的执行时间 T(n) 与每行代码的执行次数 f(n) 成正比。
完成 Q2 的步骤是(2 * length)+ 1 time,简写为 2n + 1。
这种算法分析方法是很早之前所采用的,具体到某个算法最好、最坏、平均复杂度。
现在的算法分析,都是 最坏复杂度 + 渐进记号 来分析的。
俩者的区别,就在于前者追求每一步即 精细化,后者看中 数量级,只看步数最高的阶数。
2n + 1,用的就是 精细化 的数学方法来分析算法,现在我们跟着历史,把系数和常数项同等看待,分析未必要精细化。
把 Q2 的算法步数写成函数表达是 T( n ) = 2n + 1。
这个时候,分析从 精细化 的追求每一步改为 阶。
引入 至多 这个概念:
T(n) = 2n + 1
改为至多后,至多用高等数学中的 大 O 表示:
得出结论,O(n) = 2n + 1
推导过程:
∵ O(n) ≤ T(n)
∴ 4n + 2 ≤ O(n)
∵ 只考虑阶,同阶内系数和常数无用即省略,不要太精确,≤ 包含 =,因此也可以改为 = 。
∴ T(n) = O(n)
∴ 4n + 2 = O(n)
又因为高等数学的 大O 有其特殊意义。
数学分析:
,被看成同一数量级,因为注重阶。
阶,除了 n 还有 、、 ......
因此 T(n) 的 ( ) 里,实际是一个函数 f(n) ,T( f(n) )。
n 的数据规模是一个函数 f(n),这个函数没有边界(上界或下界),可用高等数学里的 大O 概念来限制。
如果来个数据规模在 大O 概念上相同
n 趋向无穷大时,ta 们的比值只差一个常数。如,,因为同 阶,都是 因此被看成同一数量级。
同理,如果俩个计算机算法在 大O 概念下相同,只相差一个常数,则认为 ta 们的复杂度相同。
因此,使用 大O 表示法时,去精细化,具体的步骤就是:
大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,只需要最大量级。
在研究算法复杂度时,仅用 O( f(n) ) 就好,O( f(n) ) 可以理解为 O(n) 的样子,但 f(n) 不一定只是 n 。
f(n) 可以为 n 的任意次方,。
而在算法分析中,如《算法导论》一书中,还有一些 大O 表示法的朋友。
就是常用,函数 T(n) 至多为 f(n) 阶
算法分析常用,函数 T(n) 恰好为 f(n) 阶
算法分析常用,函数 T(n) 至少为 f(n) 阶
这个记法表示 "函数的集合"。
表示满足 这一条件的函数 的集合。
以集合的形式表示, 与 { } 等价, 是 自然数集, 是存在, 是任意。
所以,以集合的形式 ,实际是 。
因为用了集合的包含关系,所以大 O 表示法就有层次。
O(1)常数阶 < O(logn)对数阶 < O(n)线性阶 < O()平方阶 < O()(立方阶)
)(k次方阶)< O() (指数阶)
我们可以继续学习 为了更全面,更准确的描述代码的时间复杂度,所以引入4个概念。
- 最好情况时间复杂度(best case time complexity)
- 最坏情况时间复杂度(worst case time complexity)
- 平均情况时间复杂度(average case time complexity)
- 均摊时间复杂度(amortized time complexity)
最好情况时间复杂度 即 最理想的情况,以数组查找举例,只需要一次就查找到目标数 => O(1)。
最坏情况时间复杂度 即 最糟糕的情况,以数组查找举例,需要遍历整个数组,也没有 => O(n)。
平均情况时间复杂度 即 最常见的情况,以数组查找举例,分析过程需要点前置数学【概率论】,
为方便理解,我们假设 目标数 出现在数组中的概率各为 1/2,而出现在数组 0 ~ (len-1) 下标的 n 个位置 概率各为 1/n。
根据概率乘法法则,要查找的数据出现在 0 ~ (n-1) 中的任意位置是 1/(2n)。
紧接着,我们要考虑每种情况发生的概率,计算过程如下 1.jpg:
这个值是概率论中叫加权平均值,也叫期望值。去掉加法常数 , 取最高项 ,去系数 => O(n)(平均复杂度)
以上,最好/最坏/平均复杂度,程序一般不区分;只有在同一程序不同情况有量级差距,我们才分开 3 种分析方法 分析。
均摊时间复杂度 :一种特殊的平均时间复杂度,采用 摊还分析法 分析时间复杂度。在代码执行的所有复杂度情况中绝大部分是低级别的复杂度,个别情况是高级别复杂度且发生具有时序关系时,可以将个别高级别复杂度均摊到低级别复杂度上。基本上均摊结果就等于低级别复杂度。
以数组查找并删除此目标数举例,假设数组长度为 n , 每个下标位找着的时间复杂度是 O(1),一共 n + 1 种情况。不要忘记,删除哦,数组位移会产生 O(n) 的时间复杂度。因为如果没找到的概率是 ,各个位置概率是
在不同量级或者想同量级比较,您可以把这个特殊的平均时间复杂度,均摊到其余操作上,理解这个,需要在实战中获得,我们先学会理论 ~
摊还分析法 可以暂时略过,因为完整版我会在算法司南里。
下面给出一些渐进复杂度数量级参考:
复杂度 不超的范围
O(logn) 2的64次方内都可以
O(n) [0,10e+7]
O(n logn) [10e+5, 5*10e+5]
O() [1000, 5000]
O() [200, 500]
O() [20, 24]
O(n!) 12
算法 | 复杂度 | 说明 |
---|---|---|
哈希查找 | O(1) | 常数复杂度 |
有序数组二分查找 | O log(N) | 对数复杂度 |
无序数组元素查找 | O(N) | 线性复杂度 |
图的遍历 | O(N) | 结点数N的线性复杂度 |
快速排序 | O(N logN) | 基于比较的排序算法最好复杂度 |
动态规划/最短路径/维比特 | 深度 d 的平方复杂度 长度 N 的线性复杂度 |
|
鲍姆 - 韦尔奇 | 同上 | |
贝叶斯网络训练 | NP-Complete 近似解 |
尚未找到多项式复杂度算法 |
如果一个算法的计算量不超过 N 的多项式函数,那么称这个算法是多项式函数复杂度;超过 N 的多项式函数,我们称为非多多项式问题,如 找到每一步围棋的最佳走法就是这样的问题。
一般的算法竞赛里面,会限制程序空间。一般为 64MB/128MB/256MB,如果您写的程序需要开百万甚至千万级数组,可以按照以下公式计算,看一下满不满足 ~
以 64MB 为例,测试 开 int 最多可以开多少
分母是:sizeof(int) = 4
分子是 MB 单位转为 Byte,因为一个 int 是 4B。
p.s. 注意大数组不要开在子函数内,栈空间可能不够
以上是树形图对 1、2、3 这 3 个数的全排列,共 3! = 3 * 2 *1 种排列。
从左至右看,第一段树的分杈有 3 个,第二段树的分杈有 2 个,第三段树的分杈有 1 个。
根据生活经验或乘法原理,排列 3 个数的方法数 = 3 个分杈 * 2 个分杈 * 1 个分杈,如果您愿意排列 4 个数,视觉效果会更好。
所以,得出结论。
n 个数的排列方法共 n! 种。
上图是《算法导论》中采用比较树比较的数列: < 1、2、3 > 的静态过程,理想环境:数列元素全部不相同。
树中结点有俩个元素,表示当前结点需要比较的俩个元素。
从树的根结点顺序向下,等价于比较排序算法中元素间的比较。
所以,比较树的结点必须包含数列所有的排列,否则无法正确排序数列。
满足以上条件,比较树的高度 h 等于比较算法的最大比较次数。
比较树只关注哪俩个元素并确定元素间的顺序,不关注元素如何在数列中移动。
在比较树中,所有的排列情况都已经记录在 "叶子结点中"(长方形的),不像基于比较的排序算法完全是程序跑,我们虽然知道数字在移动但看不清了所有过程。(调试,也不适合因为不直观)
诶,也不知道是哪位前辈发明了比较树,让动态的比较算法转化为静态的比较树。
证明过程,如下:
在比较树中,每个结点都有 2 个分杈,高度 h = 0,只有根结点,高度 h = 1, 至多 个结点,高度 h = 2,至多 个结点。
当高度 h 为 x 时,至多有 个结点。
而整个比较树包含数列的所有排序,
因为 至多 有水分,可能比较树尾部结点并没有抵达第 x 层。如上图的 <1,2,3> 位于第 2 层,而不是高度 h = 3 的第 3 层。
不等式俩边同时取底为 2 的对数,因为底大于 1 的对数函数 是单调递增函数,不等号方向不变。
,底数 = 真数,此时 log = 1。
对,我们知道基于比较排序算法最大比较次数的渐进意义上的评估为 。
写的正式一点是这样,。
采用 (至少) 而不是 (至多) 或 (恰好) 是因为对于最大比较次数至少是最有意义的,至多不明确,恰好不太可能。
思考一下,比较树的什么等于基于比较排序算法的最大比较次数的渐进意义上的评估 ??
是高度、高度 h ......
,证明这个等式即可。
不是对数形式,不好比较。
,证明这个等式即可。
对于 的评估,只要评估 即可,熟悉斯特林公式可以应用,斯特林公式可以精确的评估 的近似值。
就是要学,不然不会。
这评估 并不需要十分精确,所以可以不用斯特林公式。
比如,评估 的大小。
评估,一般会用比这个数更小的数来评估,取中间偏下的 3 ,确保万无一失。
,俩边同时取对数
, ,或者
写到这里,遇到最大的一个瓶颈。
回想起来,突然不知道要干嘛了。
这是一个魔术,让人感到不真实......
一般化,数学中很常用的一种思想方法。
一般化:从考虑一个对象或较少对象的集合过度到包含已给更大集合的一种思想方法。
为一般化做准备,将 3 变成 ,
将底数设为 2,当 时可以进行一般化。
底大于 1 的对数是单调递增函数,俩边同时取以 2 为底的对数没毛病。
神奇就神奇在这里,一般化啧啧,没谁了~
,同时开方
得到以下式子:
当 时, 是单调递增函数,所以
证毕。
p.s.
不是 n * log * n,而是 n * log(n),log(n) 是对数函数。
像对数函数是不需要注意底的,如是 还是 等等。
因为所有对数函数就算进行底的变换,其结果的差异也是常数倍!!
,原因如下:
按照对数的定义,如果 ,则 称为以 B 为底的 x 的对数,记作 ,把 ta 带入到 可得:。
推导过程:。
带入 x 即得:
因为
因为 是常数, 和 的差异也是常数倍的。
所以,使用渐进记号时阶为对数时,是不需要考虑对数的底的。
扩展书籍:《阶的估计》里面的技巧有点复杂。