最廉价算法
Ⅰ 最短路径 | 深入浅出Dijkstra算法(一)
上次我们介绍了神奇的只有 五行的 Floyd-Warshall 最短路算法 ,它可以方便的求得 任意两点的最短路径, 这称为 “多源最短路”。
这次来介绍 指定一个点(源点)到其余各个顶点的最短路径, 也叫做 “单源最短路径”。 例如求下图中的 1 号顶点到 2、3、4、5、6 号顶点的最短路径。
与 Floyd-Warshall 算法一样,这里仍然 使用二维数组 e 来存储顶点之间边的关系, 初始值如下。
我们还需要用 一个一维数组 dis 来存储 1 号顶点到其余各个顶点的初始路程, 我们可以称 dis 数组为 “距离表”, 如下。
我们将此时 dis 数组中的值称为 最短路的“估计值”。
既然是 求 1 号顶点到其余各个顶点的最短路程, 那就 先找一个离 1 号顶点最近的顶点。
通过数组 dis 可知当前离 1 号顶点最近是 2 号顶点。 当选择了 2 号顶点后,dis[2]的值就已经从“估计值”变为了“确定值”, 即 1 号顶点到 2 号顶点的最短路程就是当前 dis[2]值。
为什么呢?你想啊, 目前离 1 号顶点最近的是 2 号顶点,并且这个图所有的边都是正数,那么肯定不可能通过第三个顶点中转,使得 1 号顶点到 2 号顶点的路程进一步缩短了。 因此 1 号顶点到其它顶点的路程肯定没有 1 号到 2 号顶点短,对吧 O(∩_∩)O~
既然选了 2 号顶点,接下来再来看 2 号顶点 有哪些 出边 呢。有 2->3 和 2->4 这两条边。
先讨论 通过 2->3 这条边能否让 1 号顶点到 3 号顶点的路程变短。 也就是说现在来比较 dis[3] 和 dis[2]+e[2][3] 的大小。其中 dis[3]表示 1 号顶点到 3 号顶点的路程,dis[2]+e[2][3]中 dis[2]表示 1 号顶点到 2 号顶点的路程,e[2][3]表示 2->3 这条边。所以 dis[2]+e[2][3]就表示从 1 号顶点先到 2 号顶点,再通过 2->3 这条边,到达 3 号顶点的路程。
我们发现 dis[3]=12,dis[2]+e[2][3]=1+9=10,dis[3]>dis[2]+e[2][3],因此 dis[3]要更新为 10。这个过程有个专业术语叫做 “松弛” 。即 1 号顶点到 3 号顶点的路程即 dis[3],通过 2->3 这条边 松弛成功。 这便是 Dijkstra 算法的主要思想: 通过 “边” 来松弛 1 号顶点到其余各个顶点的路程。
同理通过 2->4(e[2][4]),可以将 dis[4]的值从 ∞ 松弛为 4(dis[4]初始为 ∞,dis[2]+e[2][4]=1+3=4,dis[4]>dis[2]+e[2][4],因此 dis[4]要更新为 4)。
刚才我们对 2 号顶点所有的出边进行了松弛。松弛完毕之后 dis 数组为:
接下来,继续在剩下的 3、4、5 和 6 号顶点中,选出离 1 号顶点最近的顶点。通过上面更新过 dis 数组,当前离 1 号顶点最近是 4 号顶点。此时,dis[4]的值已经从“估计值”变为了“确定值”。下面继续对 4 号顶点的所有出边(4->3,4->5 和 4->6)用刚才的方法进行松弛。松弛完毕之后 dis 数组为:
继续在剩下的 3、5 和 6 号顶点中,选出离 1 号顶点最近的顶点,这次选择 3 号顶点。此时,dis[3]的值已经从“估计值”变为了“确定值”。对 3 号顶点的所有出边(3->5)进行松弛。松弛完毕之后 dis 数组为:
继续在剩下的 5 和 6 号顶点中,选出离 1 号顶点最近的顶点,这次选择 5 号顶点。此时,dis[5]的值已经从“估计值”变为了“确定值”。对5号顶点的所有出边(5->4)进行松弛。松弛完毕之后 dis 数组为:
最后对 6 号顶点的所有出边进行松弛。因为这个例子中 6 号顶点没有出边,因此不用处理。 到此,dis 数组中所有的值都已经从“估计值”变为了“确定值”。
最终 dis 数组如下,这便是 1 号顶点到其余各个顶点的最短路径。
OK,现在来总结一下刚才的算法。 Dijkstra算法的基本思想是:每次找到离源点(上面例子的源点就是 1 号顶点)最近的一个顶点,然后以该顶点为中心进行扩展,最终得到源点到其余所有点的最短路径。
基本步骤如下:
在 博客 中看到两个比较有趣的问题,也是在学习Dijkstra时,可能会有疑问的问题。
当我们看到上面这个图的时候,凭借多年对平面几何的学习,会发现在“三角形ABC”中,满足不了 构成三角形的条件(任意两边之和大于第三边)。 纳尼,那为什么图中能那样子画?
还是“三角形ABC”,以A为起点,B为终点,如果按照平面几何的知识, “两点之间线段最短”, 那么,A到B的最短距离就应该是6(线段AB),但是,实际上A到B的最短距离却是3+2=5。这又怎么解释?
其实,之所以会有上面的疑问,是因为 对边的权值和边的长度这两个概念的混淆, 。之所以这样画,也只是为了方便理解(每个人写草稿的方式不同,你完全可以用别的方式表示,只要便于你理解即可)。
PS:数组实现邻接表可能较难理解,可以看一下 这里
参考资料:
Dijkstra算法是一种基于贪心策略的算法。每次新扩展一个路程最短的点,更新与其相邻的点的路程。当所有边权都为正时,由于不会存在一个路程更短的没扩展过的点,所以这个点的路程永远不会再被改变,因而保证了算法的正确性。
根据这个原理, 用Dijkstra算法求最短路径的图不能有负权边, 因为扩展到负权边的时候会产生更短的路径,有可能破坏了已经更新的点路径不会发生改变的性质。
那么,有没有可以求带负权边的指定顶点到其余各个顶点的最短路径算法(即“单源最短路径”问题)呢?答案是有的, Bellman-Ford算法 就是一种。(我们已经知道了 Floyd-Warshall 可以解决“多源最短路”问题,也要求图的边权均为正)
通过 邻接矩阵 的Dijkstra时间复杂度是 。其中每次找到离 1 号顶点最近的顶点的时间复杂度是 O(N),这里我们可以用 优先队列(堆) 来优化,使得这一部分的时间复杂度降低到 。这个我们将在后面讨论。
Ⅱ 最短路径法与节约法的区别
最短路径法与节约法的区别:含义不同,计算不同。
一、含义不同:在这里启发式指的是一个在一个搜寻树的节点上定义的函数h(n),用于评估从此节点到目标节点最便宜的路径。启发式通常用于资讯充分的搜寻算法,例如最好优先贪婪算法与a*。
路径指的是实现查找的方法或算法,而树是把算法变化成可见的结构图。可以理解成树是文章的结构,路径是完成一篇作文的语句。
二、计算不同:最好优先贪婪算法会为启发式函数选择最低代价的节点;a*则会为g(n)+h(n)选择最低代价的节点,此g(n)是从起始节点到目前节点的路径的确实代价。
最短路径是一个路径,最小树是一个树(支撑树),虽然二者都是要求覆盖每一个节点,但是路径和树究竟不同,后者分叉前者不分叉。
SPFA算法
可以用于存在负数边权的图,这与dijkstra算法是不同的。与Dijkstra算法与Bellman-ford算法都不同,SPFA的算法时间效率是不稳定的,即它对于不同的图所需要的时间有很大的差别。
在最好情形下,每一个节点都只入队一次,则算法实际上变为广度优先遍历,其时间复杂度仅为O(E)。另一方面,存在这样的例子,使得每一个节点都被入队(V-1)次,此时算法退化为Bellman-ford算法,其时间复杂度为O(VE)。
Ⅲ 数据挖掘里面最简单的算法是什么
鄙人认为k-means算法不怎么难,不论是一维的还是二维的,用c或c++实现都不十分复杂,这方面的代码也很多。
算法描述:
K均值聚类算法:
给定类的个数K,将N个对象分到K个类中去,
使得类内对象之间的相似性最大,而类之间的相似性最小。
基本算法的步骤:
输入:k, data[n];
(1) 选择k个初始中心点,例如c[0]=data[0],…c[k-1]=data[k-1];
(2) 对于data[0]….data[n], 分别与c[0]…c[n-1]比较,假定与c[i]差值最少,就标记为i;
(3) 对于所有标记为i点,重新计算c[i]={ 所有标记为i的data[j]之和}/标记为i的个数;
(4) 重复(2)(3),直到所有c[i]值的变化小于给定阈值或者前后两次的中心不再发生变化。
Ⅳ 风靡全球的十大算法
作者 | George Dvorsky
编译 | 深度学习这件小事
1 排序算法
所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。排序算法,就是如何使得记录按照要求排列的方法。排序算法在很多领域得到相当地重视,尤其是在大量数据的处理方面。一个优秀的算法可以节省大量的资源。
稳定的
冒泡排序(bubble sort) — O(n^2) 鸡尾酒排序(Cocktail sort,双向的冒泡排序) — O(n^2) 插入排序(insertion sort)— O(n^2) 桶排序(bucket sort)— O(n); 需要 O(k) 额外空间 计数排序(counting sort) — O(n+k); 需要 O(n+k) 额外空间 合并排序(merge sort)— O(nlog n);需要 O(n) 额外空间 原地合并排序— O(n^2) 二叉排序树排序 (Binary tree sort) — O(nlog n)期望时间; O(n^2)最坏时间;需要 O(n) 额外空间 鸽巢排序(Pigeonhole sort)— O(n+k); 需要 O(k) 额外空间 基数排序(radix sort)— O(n·k); 需要 O(n) 额外空间 Gnome 排序— O(n^2) 图书馆排序— O(nlog n) withhigh probability,需要(1+ε)n额外空间不稳定的
选择排序(selection sort)— O(n^2) 希尔排序(shell sort)— O(nlog n) 如果使用最佳的现在版本 组合排序— O(nlog n) 堆排序(heapsort)— O(nlog n) 平滑排序— O(nlog n) 快速排序(quicksort)— O(nlog n) 期望时间,O(n^2) 最坏情况;对于大的、乱数列表一般相信是最快的已知排序 Introsort—O(nlog n) Patience sorting— O(nlog n+k) 最坏情况时间,需要额外的 O(n+ k) 空间,也需要找到最长的递增子串行(longest increasing subsequence)不实用的
Bogo排序— O(n× n!) 期望时间,无穷的最坏情况。 Stupid sort— O(n^3); 递归版本需要 O(n^2)额外存储器 珠排序(Bead sort) — O(n) or O(√n),但需要特别的硬件 Pancake sorting— O(n),但需要特别的硬件 stooge sort——O(n^2.7)很漂亮但是很耗时2 傅立叶变换与快速傅立叶变换
傅立叶是一位法国数学家和物理学家,原名是JeanBaptiste Joseph Fourier(1768-1830), Fourier于1807年在法国科学学会上发表了一篇论文,论文里描述运用正弦曲线来描述温度分布,论文里有个在当时具有争议性的决断:任何连续周期信号都可以由一组适当的正弦曲线组合而成。当时审查这个论文拉格朗日坚决反对此论文的发表,而后在近50年的时间里,拉格朗日坚持认为傅立叶的方法无法表示带有棱角的信号,如在方波中出现非连续变化斜率。直到拉格朗日死后15年这个论文才被发表出来。谁是对的呢?拉格朗日是对的:正弦曲线无法组合成一个带有棱角的信号。但是,我们可以用正弦曲线来非常逼近地表示它,逼近到两种表示方法不存在能量差别,基于此,傅立叶是对的。为什么我们要用正弦曲线来代替原来的曲线呢?如我们也还可以用方波或三角波来代替呀,分解信号的方法是无穷多的,但分解信号的目的是为了更加简单地处理原来的信号。用正余弦来表示原信号会更加简单,因为正余弦拥有原信号所不具有的性质:正弦曲线保真度。一个正余弦曲线信号输入后,输出的仍是正余弦曲线,只有幅度和相位可能发生变化,但是频率和波的形状仍是一样的。且只有正余弦曲线才拥有这样的性质,正因如此我们才不用方波或三角波来表示。
3 Dijkstra 算法
Dijkstra算法是典型的算法。Dijkstra算法是很有代表性的算法。Dijkstra一般的表述通常有两种方式,一种用永久和临时标号方式,一种是用OPEN, CLOSE表的方式,这里均采用永久和临时标号的方式。注意该算法要求图中不存在负权边。
4 RSA算法变换
RSA是目前最有影响力的公钥加密算法,它能够抵抗到目前为止已知的绝大多数密码攻击,已被ISO推荐为公钥数据加密标准。今天只有短的RSA钥匙才可能被强力方式解破。到2008年为止,世界上还没有任何可靠的攻击RSA算法的方式。只要其钥匙的长度足够长,用RSA加密的信息实际上是不能被解破的。但在分布式计算和量子计算机理论日趋成熟的今天,RSA加密安全性受到了挑战。
5 安全哈希算法
一种对输入信息(例如消息)进行摘要的算法。摘要过程能够完成下列特点:不同的输入信息绝对不会具有相同的指纹:相近输入信息经过摘要之后的输出信息具有较大的差异,同时计算上很难生产一个与给定输入具有相同指纹的输入。(即不可逆)。
6 整数因式分解
这是在计算机领域被大量使用的数学算法,没有这个算法,信息加密会更不安全。该算法定义了一系列步骤,得到将一合数分解为更小因子的质数分解式。这被认为是一种FNP问题,它是NP分类问题的延伸,极其难以解决。许多加密协议(如RSA算法)都基于这样一个原理:对大的合数作因式分解是非常困难的。如果一个算法能够快速地对任意整数进行因式分解,RSA的公钥加密体系就会失去其安全性。量子计算的诞生使我们能够更容易地解决这类问题,同时它也打开了一个全新的领域,使得我们能够利用量子世界中的特性来保证系统安全。
7 链接分析
链接分析,源于对Web结构中超链接的多维分析。当前其应用主要体现在网络信息检索、网络计量学、数据挖掘、Web结构建模等方山。作为Google的核心技术之一,链接分析算法应用已经显现出j惊人的商业价值。
8 比例积分微分算法
你是否曾经用过飞机、汽车、卫星服务或手机网络?你是否曾经在工厂工作或是看见过机器人?如果回答是肯定的,那么你应该已经见识过这个算法了。大体上,这个算法使用一种控制回路反馈机制,将期望输出信号和实际输出信号之间的错误最小化。无论何处,只要你需要进行信号处理,或者你需要一套电子系统,用来自动化控制机械、液压或热力系统,这个算法都会有用武之地。可以这样说,如果没有这个算法,现代文明将不复存在。
9 数据压缩算法
在现今的电子信息技术领域,正发生着一场有长远影响的数字化革命。由于数字化的多媒体信息尤其是数字视频、音频信号的数据量特别庞大,如果不对其进行有效的压缩就难以得到实际的应用。因此,数据压缩技术已成为当今数字通信、广播、存储和多媒体娱乐中的一项关键的共性技术。
10 随机数生成
在统计学的不同技术中需要使用随机数,比如在从统计总体中抽取有代表性的样本的时候,或者在将实验动物分配到不同的试验组的过程中,或者在进行蒙特卡罗模拟法计算的时候等等。
Ⅳ 打折打到最便宜的是几折 打1折 2折 怎么算
某宝上打0.1折都有,都是商家弄虚假的
打几折就是相当于乘零点几。比如九折就是乘0.9,六折就是乘以0.6,8.5折就是乘0.85
例如100元的衣服打9折就是90元,6折就是60元,8.5折就是85元
Ⅵ 图论中常见的最短路径算法有几种都是什么
主要是有三种、、
第一种是最直接的贪心dijkstra算法、、可以利用堆数据结构进行优化、、缺点就是不能求有负权的最短路与判断负环、、
第二种是bellman-ford算法、、根据松弛操作的性质是可以来判断负环的、、时间复杂度是O(nm)的、、
第三种是SPFA算法、、把他单独拿出来作为一种算法并不是非常好的、、他的实质应该是上面的bellman-ford算法的队列优化时间复杂度更低、O(KE)、K的值约等于2、、
Ⅶ 376-298最简便的算法怎么算
简便运算,就是利用运算定律或者是运算性质,巧用特殊数之间的特性进行巧算
利用运算定律。利用加法的交换律和结合律,乘法的交换律、结合律和分配律,可以使计算简便。
376-298
= 376-300+2
= 76+2
= 78
Ⅷ 为什么说Transformer的注意力机制是相对廉价的注意力机制相对更对于RNN系列及CNN系列算法有何优势
基于注意力机制的构造与基于RNN的不同,基于RNN的是在时间步上串联(在每个time step只能输入一个token),而基于注意力机制的是类似于桶状结构(一起将数据输入到模型中去)
Ⅸ 物流成本最低的算法有哪些
一些物流企业只注意到运输、存储、配送等单要素发生的成本,实际上要对各要素综合控制,才能实现物流总成本的最小化。
物流总成本是由运输、存储保管、流通加工、包装、装卸、配送等要素的成本构成。
当各成本同时为最小时总成本也最小。但由于“效益悖反”规律,各成本一般不可能同时为最小,必须对各个环节汇总进行协调和整合,加强物流各个环节的成本控制。
运输成本在物流总成本中占很大比例,从欧洲发达国家来看,一般也占到1/3以上,因此运输合理化也是降低物流成本的一个重要方法。
运输成本在很大程度取决于运输量(以及存储量)和运输方式,根据物流总成本最小化原则来确定相应的运输量(由存储量确定运输量),运输量既定时,运输方式成为影响运输成本的主要因素。
Ⅹ 冒泡排序法和快速排序比较的算法
打你屁股,这么简单的问题都不认真研究一下。
冒泡排序是最慢的排序,时间复杂度是 O(n^2)。
快速排序是最快的排序。关于快速排序,我推荐你看看《代码之美》第二章:我编写过的最漂亮的代码。作者所说的最漂亮,就是指效率最高的。
--------------------------------摘自《代码之美》---------------
当我撰写关于分治(divide-and-conquer)算法的论文时,我发现C.A.R. Hoare的Quicksort算法(“Quicksort”,Computer Journal 5)无疑是各种Quicksort算法的鼻祖。这是一种解决基本问题的漂亮算法,可以用优雅的代码实现。我很喜欢这个算法,但我总是无法弄明白算法中最内层的循环。我曾经花两天的时间来调试一个使用了这个循环的复杂程序,并且几年以来,当我需要完成类似的任务时,我会很小心地复制这段代码。虽然这段代码能够解决我所遇到的问题,但我却并没有真正地理解它。
我后来从Nico Lomuto那里学到了一种优雅的划分(partitioning)模式,并且最终编写出了我能够理解,甚至能够证明的Quicksort算法。William Strunk Jr.针对英语所提出的“良好的写作风格即为简练”这条经验同样适用于代码的编写,因此我遵循了他的建议,“省略不必要的字词”(来自《The Elements of Style》一书)。我最终将大约40行左右的代码缩减为十几行的代码。因此,如果要回答“你曾编写过的最漂亮代码是什么?”这个问题,那么我的答案就是:在我编写的《Programming Pearls, Second Edition》(Addison-Wesley)一书中给出的Quichsort算法。在示例2-1中给出了用C语言编写的Quicksort函数。我们在接下来的章节中将进一步地研究和改善这个函数。
【示例】 2-1 Quicksort函数
void quicksort(int l, int u)
{ int i, m;
if (l >= u) return; 10
swap(l, randint(l, u));
m = l;
for (i = l+1; i <= u; i++)
if (x[i] < x[l])
swap(++m, i);
swap(l, m);
quicksort(l, m-1);
quicksort(m+1, u);
}
如果函数的调用形式是quicksort(0, n-1),那么这段代码将对一个全局数组x[n]进行排序。函数的两个参数分别是将要进行排序的子数组的下标:l是较低的下标,而u是较高的下标。函数调用swap(i,j)将会交换x[i]与x[j]这两个元素。第一次交换操作将会按照均匀分布的方式在l和u之间随机地选择一个划分元素。
在《Programming Pearls》一书中包含了对Quicksort算法的详细推导以及正确性证明。在本章的剩余内容中,我将假设读者熟悉在《Programming Pearls》中所给出的Quicksort算法以及在大多数初级算法教科书中所给出的Quicksort算法。
如果你把问题改为“在你编写那些广为应用的代码中,哪一段代码是最漂亮的?”我的答案还是Quicksort算法。在我和M. D. McIlroy一起编写的一篇文章("Engineering a sort function," Software-Practice and Experience, Vol. 23, No. 11)中指出了在原来Unix qsort函数中的一个严重的性能问题。随后,我们开始用C语言编写一个新排序函数库,并且考虑了许多不同的算法,包括合并排序(Merge Sort)和堆排序(Heap Sort)等算法。在比较了Quicksort的几种实现方案后,我们着手创建自己的Quicksort算法。在这篇文章中描述了我们如何设计出一个比这个算法的其他实现要更为清晰,速度更快以及更为健壮的新函数——部分原因是由于这个函数的代码更为短小。Gordon Bell的名言被证明是正确的:“在计算机系统中,那些最廉价,速度最快以及最为可靠的组件是不存在的。”现在,这个函数已经被使用了10多年的时间,并且没有出现任何故障。
考虑到通过缩减代码量所得到的好处,我最后以第三种方式来问自己在本章之初提出的问题。“你没有编写过的最漂亮代码是什么?”。我如何使用非常少的代码来实现大量的功能?答案还是和Quicksort有关,特别是对这个算法的性能分析。我将在下一节给出详细介绍。
2.2 事倍功半
Quicksort是一种优雅的算法,这一点有助于对这个算法进行细致的分析。大约在1980年左右,我与Tony Hoare曾经讨论过Quicksort算法的历史。他告诉我,当他最初开发出Quicksort时,他认为这种算法太简单了,不值得发表,而且直到能够分析出这种算法的预期运行时间之后,他才写出了经典的“Quicksoft”论文。
我们很容易看出,在最坏的情况下,Quicksort可能需要n2的时间来对数组元素进行排序。而在最优的情况下,它将选择中值作为划分元素,因此只需nlgn次的比较就可以完成对数组的排序。那么,对于n个不同值的随机数组来说,这个算法平均将进行多少次比较?
Hoare对于这个问题的分析非常漂亮,但不幸的是,其中所使用的数学知识超出了大多数程序员的理解范围。当我为本科生讲授Quicksort算法时,许多学生即使在费了很大的努力之后,还是无法理解其中的证明过程,这令我非常沮丧。下面,我们将从Hoare的程序开
11
始讨论,并且最后将给出一个与他的证明很接近的分析。
我们的任务是对示例2-1中的Quicksort代码进行修改,以分析在对元素值均不相同的数组进行排序时平均需要进行多少次比较。我们还将努力通过最短的代码、最短运行时间以及最小存储空间来得到最深的理解。
为了确定平均比较的次数,我们首先对程序进行修改以统计次数。因此,在内部循环进行比较之前,我们将增加变量comps的值(参见示例2-2)。
【示例2-2】 修改Quicksort的内部循环以统计比较次数。
for (i = l+1; i <= u; i++) {
comps++;
if (x[i] < x[l])
swap(++m, i);
}
如果用一个值n来运行程序,我们将会看到在程序的运行过程中总共进行了多少次比较。如果重复用n来运行程序,并且用统计的方法来分析结果,我们将得到Quicksort在对n个元素进行排序时平均使用了1.4 nlgn次的比较。
在理解程序的行为上,这是一种不错的方法。通过十三行的代码和一些实验可以反应出许多问题。这里,我们引用作家Blaise Pascal和T. S. Eliot的话,“如果我有更多的时间,那么我给你写的信就会更短。”现在,我们有充足的时间,因此就让我们来对代码进行修改,并且努力编写出更短(同时更好)的程序。
我们要做的事情就是提高这个算法的速度,并且尽量增加统计的精确度以及对程序的理解。由于内部循环总是会执行u-l次比较,因此我们可以通过在循环外部增加一个简单的操作来统计比较次数,这就可以使程序运行得更快一些。在示例2-3的Quicksort算法中给出了这个修改。
【示例2-3】 Quicksort的内部循环,将递增操作移到循环的外部
comps += u-l;
for (i = l+1; i <= u; i++)
if (x[i] < x[l])
swap(++m, i);
这个程序会对一个数组进行排序,同时统计比较的次数。不过,如果我们的目标只是统计比较的次数,那么就不需要对数组进行实际地排序。在示例2-4中去掉了对元素进行排序的“实际操作”,而只是保留了程序中各种函数调用的“框架”。
【示例2-4】将Quicksort算法的框架缩减为只进行统计
void quickcount(int l, int u)
{ int m;
if (l >= u) return;
m = randint(l, u);
comps += u-l;
quickcount(l, m-1);
quickcount(m+1, u);
}
12
这个程序能够实现我们的需求,因为Quichsort在选择划分元素时采用的是“随机”方式,并且我们假设所有的元素都是不相等的。现在,这个新程序的运行时间与n成正比,并且相对于示例2-3需要的存储空间与n成正比来说,现在所需的存储空间缩减为递归堆栈的大小,即存储空间的平均大小与lgn成正比。
虽然在实际的程序中,数组的下标(l和u)是非常重要的,但在这个框架版本中并不重要。因此,我们可以用一个表示子数组大小的整数(n)来替代这两个下标(参见示例2-5)
【示例2-5】 在Quicksort代码框架中使用一个表示子数组大小的参数
void qc(int n)
{ int m;
if (n <= 1) return;
m = randint(1, n);
comps += n-1;
qc(m-1);
qc(n-m);
}
现在,我们可以很自然地把这个过程整理为一个统计比较次数的函数,这个函数将返回在随机Quicksort算法中的比较次数。在示例2-6中给出了这个函数。
【示例2-6】 将Quicksort框架实现为一个函数
int cc(int n)
{ int m;
if (n <= 1) return 0;
m = randint(1, n);
return n-1 + cc(m-1) + cc(n-m);
}
在示例2-4、示例2-5和示例2-6中解决的都是相同的基本问题,并且所需的都是相同的运行时间和存储空间。在后面的每个示例都对这些函数的形式进行了改进,从而比这些函数更为清晰和简洁。
在定义发明家的矛盾(inventor's paradox)(How To Solve It, Princeton University Press)时,George Póllya指出“计划越宏大,成功的可能性就越大。”现在,我们就来研究在分析Quicksort时的矛盾。到目前为止,我们遇到的问题是,“当Quicksort对大小为n的数组进行一次排序时,需要进行多少次比较?”我们现在将对这个问题进行扩展,“对于大小为n的随机数组来说,Quichsort算法平均需要进行多少次的比较?”我们通过对示例2-6进行扩展以引出示例2-7。
【示例2-7】 伪码:Quicksort的平均比较次数
float c(int n)
if (n <= 1) return 0
sum = 0
for (m = 1; m <= n; m++)
sum += n-1 + c(m-1) + c(n-m)
return sum/n
如果在输入的数组中最多只有一个元素,那么Quichsort将不会进行比较,如示例2-6
13
中所示。对于更大的n,这段代码将考虑每个划分值m(从第一个元素到最后一个,每个都是等可能的)并且确定在这个元素的位置上进行划分的运行开销。然后,这段代码将统计这些开销的总和(这样就递归地解决了一个大小为m-1的问题和一个大小为n-m的问题),然后将总和除以n得到平均值并返回这个结果。
如果我们能够计算这个数值,那么将使我们实验的功能更加强大。我们现在无需对一个n值运行多次来估计平均值,而只需一个简单的实验便可以得到真实的平均值。不幸的是,实现这个功能是要付出代价的:这个程序的运行时间正比于3n(如果是自行参考(self-referential)的,那么用本章中给出的技术来分析运行时间将是一个很有趣的练习)。
示例2-7中的代码需要一定的时间开销,因为它重复计算了中间结果。当在程序中出现这种情况时,我们通常会使用动态编程来存储中间结果,从而避免重复计算。因此,我们将定义一个表t[N+1],其中在t[n]中存储c[n],并且按照升序来计算它的值。我们将用N来表示n的最大值,也就是进行排序的数组的大小。在示例2-8中给出了修改后的代码。
【示例2-8】 在Quicksort中使用动态编程来计算
t[0] = 0
for (n = 1; n <= N; n++)
sum = 0
for (i = 1; i <= n; i++)
sum += n-1 + t[i-1] + t[n-i]
t[n] = sum/n
这个程序只对示例2-7进行了细微的修改,即用t[n]来替换c(n)。它的运行时间将正比于N2,并且所需的存储空间正比于N。这个程序的优点之一就是:在程序执行结束时,数组t中将包含数组中从元素0到元素N的真实平均值(而不是样本均值的估计)。我们可以对这些值进行分析,从而生成在Quichsort算法中统计比较次数的计算公式。
我们现在来对程序做进一步的简化。第一步就是把n-1移到循环的外面,如示例2-9所示。
【示例2-9】 在Quicksort中把代码移到循环外面来计算
t[0] = 0
for (n = 1; n <= N; n++)
sum = 0
for (i = 1; i <= n; i++)
sum += t[i-1] + t[n-i]
t[n] = n-1 + sum/n
现在将利用对称性来对循环做进一步的调整。例如,当n为4时,内部循环计算总和为:
t[0]+t[3] + t[1]+t[2] + t[2]+t[1] + t[3]+t[0]
在上面这些组对中,第一个元素增加而第二个元素减少。因此,我们可以把总和改写为:
2 * (t[0] + t[1] + t[2] + t[3])
我们可以利用这种对称性来得到示例2-10中的Quicksort。
【示例2-10】 在Quichsort中利用了对称性来计算
t[0] = 0
14
for (n = 1; n <= N; n++)
sum = 0
for (i = 0; i < n; i++)
sum += 2 * t[i]
t[n] = n-1 + sum/n
然而,在这段代码的运行时间中同样存在着浪费,因为它重复地计算了相同的总和。此时,我们不是把前面所有的元素加在一起,而是在循环外部初始化总和并且加上下一个元素,如示例2-11所示。
【示例2-11】 在Quicksort中删除了内部循环来计算
sum = 0; t[0] = 0
for (n = 1; n <= N; n++)
sum += 2*t[n-1]
t[n] = n-1 + sum/n
这个小程序确实很有用。程序的运行时间与N成正比,对于每个从1到N的整数,程序将生成一张Quicksort的估计运行时间表。
我们可以很容易地把示例2-11用表格来实现,其中的值可以立即用于进一步的分析。在2-1给出了最初的结果行。
表2-1 示例2-11中实现的表格输出
N Sum t[n]
0 0 0
1 0 0
2 0 1
3 2 2.667
4 7.333 4.833
5 17 7.4
6 31.8 10.3
7 52.4 13.486
8 79.371 16.921
这张表中的第一行数字是用代码中的三个常量来进行初始化的。下一行(输出的第三行)的数值是通过以下公式来计算的:
A3 = A2+1 B3 = B2 + 2*C2 C3 = A2-1 + B3/A3
把这些(相应的)公式记录下来就使得这张表格变得完整了。这张表格是“我曾经编写的最漂亮代码”的很好的证据,即使用少量的代码完成大量的工作。
但是,如果我们不需要所有的值,那么情况将会是什么样?如果我们更希望通过这种来方式分析一部分数值(例如,在20到232之间所有2的指数值)呢?虽然在示例2-11中构建了完整的表格t,但它只需要使用表格中的最新值。因此,我们可以用变量t的定长空间来替代table t[]的线性空间,如示例2-12所示。
【示例2-12】 Quicksoft 计算——最终版本
sum = 0; t = 0
15
for (n = 1; n <= N; n++)
sum += 2*t
t = n-1 + sum/n
然后,我们可以插入一行代码来测试n的适应性,并且在必要时输出这些结果。
这个程序是我们漫长学习旅途的终点。通过本章所采用的方式,我们可以证明Alan Perlis的经验是正确的:“简单性并不是在复杂性之前,而是在复杂性之后” ("Epigrams on Programming," Sigplan Notices, Vol. 17, Issue 9)。