宝,我去读书了。读的什么书,给你的情书《数据结构(c++)邓俊晖》-绪论篇

Posted 谁吃薄荷糖

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了宝,我去读书了。读的什么书,给你的情书《数据结构(c++)邓俊晖》-绪论篇相关的知识,希望对你有一定的参考价值。

数据结构(c++)邓俊晖

绪论

计算机与算法

算法

所谓算法,是指基于特定的计算模型,旨在解决某一信息处理问题而设计的一个指令序列。

算法要素:

  • 输入与输出

待计算问题的任一实例,都需要以某种方式交给对应的算法,对所求解问题特定实例的这种描述统称为输入(input);经过计算和处理之后得到的信息,即针对输入问题实例的答案,称作输出(output)。

  • 基本操作、确定性与可行性

所谓确定性和可行性是指,算法应可描述为若干语义明确的基本操作组成的指令序列,且每一基本操作在对应的计算模型中均可兑现。一个算法满足确定性和可行性,当且仅当它可以通过程序设计语言精确地描述。

  • 有穷性与正确性

任意算法都应在执行有限次基本操作之后终止并给出输出,此即所谓算法的有穷性(finiteness)。进一步地,算法不仅应该迟早会终止,而且所给的输出还应该能够符合由问题本身在事先确定的条件,此即所谓算法的正确性(correctness)。

证明算法的有穷性和正确性的一个重要技巧,就是从适当的角度审视整个计算过程,并找出其所具有的某种不变性和单调性。其中的单调性通常是指,问题的有效规模会随着算法的推进不断递减。不变性则不仅在算法初始状态下自然满足,而且应与最终的正确性相呼应-当问题的有效规模缩减到0时,不变性随即等价于正确性。

  • 退化与鲁棒性

同一问题往往不限于一种算法,而统一算法也常常会有多种实现方式,因此除了以上必须具备的基本属性,在应用环境中还需从实用的角度对不同算法及其不同版本做更为细致考量和取舍。这些细致的要求尽管应纳入软件工程的范畴,但也不失为成熟算法的重要标志。

比如其中之一就是,除一般性情况外,实用的算法还应能够处理各种极端的输入实例。以排序问题为例,极端情况下待排序序列的长度可能不是整数(参数n=0甚至n<0),或者反过来长度达到或者超过系统支持的最大值(n=INT_MAX),或者A[]中的元素不见得互异甚至全体相等,以上种种都属于所谓的退化(degeneracy)情况。算法所谓的鲁棒性(robustness),就是要求能够尽可能充分地应对此类情况。

  • 重用性

从实用角度评判不同算法及其不同实现方式时,可采用的另一标准是:算法的总体框架能否便捷地推广至其它场合。以冒泡排序为例,冒泡算法的正确性与所处理序列中元素的类型关系不大,无论是对于float、char或者其他类型,只要元素之间可以比较大小,算法的整体框架就依然可以沿用。算法模式可推广并适用于不同类型基本元素的这种特性,即是重用性的一种典型形式。

算法效率

  • 可计算性(computability)
  • 难解性(intractability)
  • 计算效率

首先需要确立一种尺度,用以从时间和空间等方面度量算法的计算成本,进而依此尺度对不同算法进行比较和评判。当然,更重要的是研究和归纳算法设计与实现过程中的一般性规律与技巧,以编写出效率更高、能够处理更大规模数据的程序。

  • 数据结构

数据结构这一学科以“数据”这一信息的表现形式为研究对象,旨在建立支持高效算法的数据信息处理策略、技巧和方法。要做到根据实际应用需求自如地设计、实现和选用适当的数据结构,必须对算法设计的技巧以及相应数据结构的特性了然于心。

复杂度度量

时间复杂度

为针对运行时间建立起一种可行、可信的评估标准,我们不得不首先考虑其中最为关键的因素-问题实例的规模。一般地,问题规模越接近,相应的计算成本也越接近;而随着问题规模的扩大,计算成本通常也呈上升趋势。

随着输入规模的扩大,算法执行时间将如何增长?执行时间的这一变化趋势可表示为输入规模的一个函数,称作该算法的时间复杂度(time complexity)。具体地,特定算法处理规模为n的问题所需要的时间记作T(n)。

根据规模并不能唯一确定具体的输入,规模相同的输入通常都有多个,而算法对其进行处理所需要时间也不尽相同。严格来说,以上定义的T(n)并不明确,为此需要再做一次简化,即从保守估计的角度出发,在规模为n的所有输入中选择执行时间最长者作为T(n),并以T(n)度量该算法的时间复杂度。

渐进复杂度

对于同一问题的两个算法A和B,通过比较其时间复杂度TA(n)和TB(n),即可评价二者对于同一输入规模n的计算效率高低。然而,籍此还不足以就其性能优劣做出总体性的评判,例如对于某些问题,一些算法更适合用于小规模输入,而另一些则相反。

幸运的是,在评价算法运行效率时,我们往往可以忽略其处理小规模问题时的能力差异,转而关注其在处理更大规模问题时的表现。其中的原因不难理解,小规模问题所需的处理时间本来就相对更少,故此时不同算法的实际效率差异并不明显;而在处理更大规模的问题时,效率的些许差异都将对实际执行效果产生巨大的影响。这种着眼长远、更为注重实践复杂度的总体变化趋势和增长速度的策略和方法,即所谓的渐进分析(asymptotic analysis)。

那么,针对足够大的输入规模n,算法执行时间T(n)的渐进增长速度,应如何度量和评价呢?

  • 大δ记号

同样地出于保守的估计,我们首先关注T(n)的渐进上界。为此可引入所谓“大δ记号”(big-δ notation)。具体地,若存在正的常数c和函数f(n),使得对任何n>>2都有T(n)≤c·f(n)则可认为在n足够大之后,f(n)给出了T(n)增长速度的一个渐进上界。此时,记之为:T(n) = δ(f(n))由这一定义,可导出大δ记号的一下性质:

(1)对于任一常数c>0,有δ(f(n))=δ(c·f(n))

(2)对于任意常数a>b>0,有δ(na + nb)=δ(na)

前一性质意味着,在大δ记号的意义下,函数各项正的常系数可以忽略并等同于1.后一性质则意味着,多项式中的低次项均可忽略,只需要保留最高此项。可以看出,大δ记号的这些性质的确体现了对函数总体渐进增长趋势的关注和刻画。

  • 环境差异

在实际环境中直接测得的执行时间T(n),虽不失为衡量算法性能的一个指标,但作为评判不同算法性能优劣的标准,其可信度值得推敲。事实上,即便是同一算法、同一输入,在不同的硬件平台上、不同的操作系统中甚至不同的时间,所需要的计算时间都不尽相同。因此,有必要按照超脱于具体硬件平台和软件环境的某一客观标准,来度量算法的时间复杂度,并进而评价不同算法的效率差异。

  • 基本操作

一种自然且可行的解决办法是,将时间复杂度理解为算法中各条指令的执行时间之和。在图灵机(Turing Machine,TM)和随机存储机(Random Access Machine,RAM)等计算模型中,指令语句均分解为若干次基本操作,比如算术运算、比较、分支、子程序调用与返回等;而在大多数实际的计算环境中,每一次这类基本操作都可在常数时间内完成。

如此,不妨将T(n)定义为算法所执行基本操作的总次数。也就是说,T(n)决定于组成算法的所有语句各自的执行次数,以及其中所含基本操作的数目。

  • 最坏、最好和平均情况

以大δ记号形式表示的时间复杂度,实质上是对算法执行时间的一种保守估计-对于规模为n的 任意输入,算法的运行时间都不会超过δ(f(n))。比如“冒泡排序算法时间复杂度T(n)=δ(n2)”意味着,该算法处理任何序列所需要的时间绝不会超过δ(n2)。的确需要这么长计算时间的输入实例,称作最坏实例或最坏情况(worst case)。

需要强调的是,这种保守估计并不排斥更好情况甚至最好情况(best case)的存在和出现。比如,对于某些输入序列,冒泡排序算法的内循环的执行轮数可能少于n-1,甚至只需执行一轮。当然,有时也需要考察所谓的平均情况(average case),也就是按照某种约定的概率分布,将规模为n的所有输入对应的计算时间加权平均。

比较而言,“最坏情况复杂度”是人们最为关注且使用最多的,在一些特殊的场合甚至成为唯一的指标。比如控制核电站运转、管理神经外科手术室现场的系统而言,从最好或平均角度评判算法的响应速度都不具有任何意义,在最坏情况下的响应速度才是唯一的指标。

  • 大Ω记号

为了对算法的复杂度最好情况做出设计,需要借助另一个记号。如果存在正的常数c和函数g(n),使得对于任何n>>2都有T(n)≥c·g(n)就可以认为,在n足够大之后,g(n)给出了T(n)的一个渐进下界。此时,我们记之为:T(n)=Ω(g(n))

这里的Ω称作“大Ω记号”(big-omega notation)。与大δ记号恰好相反,大Ω记号是对算法执行效率的乐观估计-对于规模为n的任意输入,算法的运行时间都不低于Ω(g(n))。比如,即便在最好情况下,冒泡排序也至少需要T(n)=Ω(n)的计算时间。

  • 大Θ记号

借助大δ记号、大Ω记号,可以对算法的时间复杂度作出定量的界定,亦即,从渐进的趋势看,T(n)介于Ω(g(n))与δ(f(n))之间。若恰巧出现g(n)=f(n)的情况,则可以使用另一种记号来表示。

如果存在正的常数c1<c2和函数h(n),使得对于任何n>>2都有

c1·h(n)≤T(n)≤c2·h(n)

就可以认为n在足够大之后,h(n)给出了T(n)的一个确界。此时,我们记之为:T(n)=Θ(h(n))

这里的Θ称作“大Θ记号”(big-theta notation),它是对算法复杂度的准确估计-对于规模为n的任何输入,算法的运行时间T(n)都与Θ(h(n))同阶。

image-20210220151245657

以上就是这三种渐进复杂度记号之间的联系与区别,可由上图示意。

空间复杂度

除了执行时间的长短,算法所需存储空间的多少也是衡量其性能的一个重要方面,此即所谓的空间复杂度(space complexity)。实际上,以上针对时间复杂度所引入的几种渐进记号,也适用于对空间复杂度的度量,其原理及方法基本相同,不再赘述。

需要注意的是,为了更客观地评价算法性能的优劣,除非特别申明,空间复杂度通常并不计入原始输入本身所占用的空间-对于同一问题,这一指标对任何算法都是相同的。反之,其它(如转储、中转、索引、映射、缓冲等)各个方面所消耗的空间,则都应计入。

另外,很多时候我们都是更多地甚至仅仅关注算法是时间复杂度,而不必对空间复杂度做专门的考查。这种简便评测方式的依据,来自于一下事实:就渐进复杂的意义而言,在任一算法的任何一次运行过程中所消耗的存储空间,都不会多于其间所执行基本操作的累计次数。

实际上根据定义,每次基本操作所涉及的存储空间,都不会超过常数规模;纵然每次基本操作所占用或访问的存储空间都是新开辟的,整个算法所需要的空间总量,也不过与基本操作的次数同阶。从这个意义上说,时间复杂度本身就是空间复杂度的一个天然的上界。

当然,对空间复杂度的分析也有其自身的意义,尤其在对空间效率非常在乎的应用场合中,或当问题的输入规模极为庞大时,由时间复杂度所确立的平凡上界已经难以让人满意。这类情况下,人们将更为精细地考查不同算法的空间效率,并尽力在此方面不断优化。

复杂度分析

常数δ(1)

  • 问题与算法

考察如下常规元素的选取问题,该问题一种解法如算法1.3所示。

ordinaryElement(S[], n) //从n≥3个互异整数中,除最大、最小者以外,任取一个“常规元素”
任取的三个元素x,y,z ∈S;//这三个元素亦比互异
通过比较,对它们做排序;//设经排序后,依次重命名为:a<b<c
输出b;
算法1.3 取非极端元素

该算法的正确性不言而喻,但它需要运行多少时间?与输入的规模n有何联系?

  • 复杂度

既然S是有限集,故其中的最大、最小元素各有且仅有一个。因此,无论S的规模有多大,在任意三个元素中至少都有一个是非极端元素。不妨取前三个x=S[0]、y=S[1]、z=S[2],这一步只需执行三次(从特定单元读取元素的)基本操作,耗费δ(3)时间。接下来,为确定这三个元素的大小次序,最多需要做三次比较,也需要δ(3)时间。最后,输出居中的非极端元素只需要δ(1)时间。因此综合起来,算法1.3的运行时间为:
T ( n ) = δ ( 3 ) + δ ( 3 ) + δ ( 1 ) = δ ( 7 ) = δ ( 1 ) T(n)=δ(3)+δ(3)+δ(1) = δ(7) = δ(1) T(n)=δ(3)+δ(3)+δ(1)=δ(7)=δ(1)
运行时间可表示和度量为T(n)=δ(1)的这一类算法,统称作“常数时间复杂度算法”(constant-time algorithm)。此类算法已经是最为理想的,因为不可能奢望“不劳而获”。

一般地,仅含一次或常数次基本操作的算法均属于此类。此类算法通常不含循环、分支、子程序调用等,但也不能仅凭语法结构的表面形式一概而论。

除了输入数组等参数之外,该算法仅需常数规模的辅助空间。此类仅需δ(1)辅助空间的算法,亦称作就地算法(in-place algorithm)。

对数δ(logn)

  • 问题与算法

考察如下问题:对于任意非负整数,统计其二进制展开中数位1的总数。

该问题的一个算法可实现如代码1.2所示。该算法使用一个计数器ones记录数位1的数目,其初始值为0.随后进入一个循环:通过二进制位的与(and)运算,检查n的二季之展开的最低位,若该位为1则累计至ones。由于每次循环都将n的二进制展开右移一位,故整体效果等同于逐个校验所有位数是否为1,该算法的正确性也不难由此得证。

表1.1 countOnes(441)的执行过程
十进制二进制数位1计数
4411101110010
220110111001
11011011101
551101111
27110112
1311013
61104
3114
115
006

以n=441(10)=11011001(2)为例,采用以上算法,变量n与计数器Ones在计算过程中的演变过程如表1.1所示。

int countOnes(unsigned int n){ //统计整数n的二进制展开中数位1的总数:0(logn)
    int ones = 0; //计数器复位
    while(0 <n){ //在缩减至0之前,若为1则计数
        ones += (1 & n); //检查最低位,若为1则计数
        n >>= 1; //右移一位
    }
    return ones; //返回计数
} //等效与glibc的内置函数int _builtin_popcount(unsigned int n)
代码1.2 整数二进制展开中数位1总数的统计
  • 复杂度

根据右移运算的性质,每右移一位,n都至少缩减一半。就是说,至多经过1+「log2n」次循环,n必然缩减至0,从而算法终止。实际上从另外一个角度看,1+「log2n」恰为n二进制展开的总位数,每次循环都将其右移一位,总的循环次数自然也应该是1+「log2n」。后一解释,也可以从表1.1中n的二进制展开一列清晰地看出。

无论是该循环体之前、之内还是之后,均只涉及常数次(逻辑判断、位与运算、加法、右移等)基本操作。因此,countOnes()算法的执行时间主要由循环的次数决定,也就是:

δ(1+「log2n」) = δ(「log2n」)=δ(log2n)

由大δ记号定义,在用函数logrn界定渐进复杂度时,常数底r的具体取值无所谓,故通常不予专门标出而笼统地记作logn。例如,尽管此时底数为常数2,却可以直接记作δ(logn)。此类算法称作具有“对数时间复杂度”(logarithmic-time algorithm)。

  • 对数多项式复杂度

更一般地,凡运行时间可以表示和度量为T(n)=δ(logcn)形式的这一类算法(其中常数c>0),均统称作“对数多项式时间复杂度的算法”(polylogarithmic-time algorithm)。上述δ(logn)即c=1的特例。此类算法的效率虽不如常数复杂度算法理想,但从多项式的角度看仍能无限接近于后者,故也是极为高效的一类算法。

线性δ(n)

  • 问题与算法

考察如下问题:计算给定n个整数的总和。该问题可由代码1.3中的算法sumI()解决。

int sumI(int A[], int n){ //数组求和算法(迭代版)
    int sum = 0; //初始化累计器,O(1)
    for(int i = 0; i < n ; i++){ //对全部供O(n)个元素,逐一
        sum += A[i]; //累计,O(1)
    }
    return sum; //返回累计值,O(1)
} //O(1)+O(n)*O(1)+O(1) = O(n+2) = O(n)
代码1.3 数组元素求和算法sumI()
  • 复杂度

sumI()算法的正确性一目了然,它需要运行多少时间呢?

首先,对s的初始化需要δ(1)时间。算法的主体部分是一个循环,每一轮循环中只需要进行一次累加,这属于基本操作,可在δ(1)时间内完成。每经过一轮循环,都将一个元素累加至s,故总共需要做n轮循环,于是该算法的运行时间应为:
δ ( 1 ) + δ ( 1 ) ∗ n = δ ( n + 1 ) = δ ( n ) δ(1)+δ(1)*n = δ(n+1) = δ(n) δ(1)+δ(1)n=δ(n+1)=δ(n)
凡运行时间可以表示和度量为T(n) = δ(n)形式的这一类算法,均统称为“线性时间复杂度算法”(linear-time algorithm)。比如,算法1.2只需要略加修改,即可解决“n等分给定线段”问题,这个通用版本相对于输入n就是一个线性时间复杂度的算法。

也就是说,对于输入的每一个单元,此类算法平均消耗常数时间。就大多数问题而言,在对输入的每一单元至少访问一次之前,不可能得出解答。以数组求和为例,在尚未得知每一元素的具体数值之前,绝不可能确定其总和。故就此意义而言,此类算法的效率亦足以令人满意。

多项式δ(pllynomial(m))

若运行时间可以表示和度量为T(n)=δ(f(n))的形式,而且f(x)为多项式,则对应的算法称作“多项式时间复杂度算法”(polynomial-time algorithm)。比如根据1.2.2节的分析,1.1.3所实现起泡排序bublesort1A()算法的时间复杂度应为T(n)=δ(n2),故该算法即属于此类。当然,以上所介绍的线性时间复杂度算法,也属于多项式时间复杂度算法的特例,其中线性多项式f(n)=n的次数为1。

在算法复杂度理论中,多项式时间复杂度被视作一个具有特殊意义的复杂度级别。多项式级的运行时间成本,在实际应用中一般被认为是可接受的或可忍受的。某问题若存在一个复杂度在此范围以内的算法,则称该问题是可有效求解的或易解的(tractable)。

请注意,这里仅要求多项式的次数为一个正的常数,而并未对其最大取值范围设置任何具体上限,故实际上该复杂度级别涵盖了很大的一类算法。比如,从理论上讲,复杂度分别为δ(n2)和δ(n2012)算法都同属此类,尽管二者实际的计算效率有天壤之别。之所以如此,是因为相对于以下的指数级复杂度,二者之间不超过多项式规模的差异只是小巫见大巫。

指数δ(2n)

  • 问题与算法

考查如下问题:在禁止超过1位的移位运算的前提下,对任意非负整数n,计算幂2n

_int64 power2BF_I(int n){ //幂函数2^n算法(蛮力迭代版), n >= 0
	_int64 pow = 1; // O(1):累积器初始化为2^0
    while (0 < n --) //O(n):迭代n轮,每轮都
        pow <<= 1; //O(1):将累计器翻倍

    return pow; //O(1):返回累积器
} //O(n)=O(2^r),r为输入指数n的比特位数
代码1.4 幂函数算法(蛮力迭代版)
  • 复杂度

如代码1.4所示的算法power2BF_I()由n轮迭代组成,各需做一次累乘和一次递减,均属于基本操作,故整个算法共需要δ(n)时间。若以输入指数n的二进制位数r=1+「log2n」作为输入规模,则运行时间为δ(2r)。稍后再1.4.3节我们将看到,该算法仍有巨大的改进空间。

一般地,凡运行时间可以表示和度量为T(n) = δ(an)形式的算法(a>1),均属于“指数时间复杂度算法”(exponential-time algorithm)。

  • 从多项式到指数

从常数、对数、线性、平方到多项式时间复杂度,算法效率的差异还在可接受的范围。然而在多项式与指数时间复杂度之间,却有一道巨大的鸿沟。当问题规模较大后,指数复杂度算法的实际效率将急剧下降,计算时间之长很快就会达到让人难以忍受的地步。因此通常认为,指数复杂度算法无法真正应用于实际问题中,它们不是有效算法,甚至不能称作算法。相应地,不存在多项式复杂度算法的问题,也称作难解的(intractable)问题。

需要注意的是,在问题规模不大的时候,指数复杂度反而可能在较长一段区间内均低于多项式复杂度。

复杂度层次

image-20210527141337653

图1.5 复杂度的典型层次:(1)~(7)δ(logn)、δ(√n)、δ(n)、δ(nlogn)、δ(n 2)、δ(n 3)、δ(2 n)、

利用大δ记号,不仅可以定量地把我算法复杂度的主要部分,而且可以定性地由低到高将复杂度划分为若干层次。

输入规模

对算法复杂度的界定,都是相对于问题的输入规模而言的。严格地讲,所谓待计算问题的 输入规模,应严格定义为“用以描述输入所需的空间规模”。

(img-OynPKFiz-1622097373627)]

图1.5 复杂度的典型层次:(1)~(7)δ(logn)、δ(√n)、δ(n)、δ(nlogn)、δ(n 2)、δ(n 3)、δ(2 n)、

利用大δ记号,不仅可以定量地把我算法复杂度的主要部分,而且可以定性地由低到高将复杂度划分为若干层次。

输入规模

对算法复杂度的界定,都是相对于问题的输入规模而言的。严格地讲,所谓待计算问题的 输入规模,应严格定义为“用以描述输入所需的空间规模”。

结束寄语

今天的博文介绍就到此结束啦!下篇博文与各位再见面~

写博不易,如蒙厚爱,赏个关注,一键三连~~点赞+评论+收藏🤞🤞🤞,感谢您的支持~~

在这里插入图片描述

以上是关于宝,我去读书了。读的什么书,给你的情书《数据结构(c++)邓俊晖》-绪论篇的主要内容,如果未能解决你的问题,请参考以下文章

DBA荐读 | 你读的书暴露了你的社会阶层

读的书记不住,那读书到底有什么用?

读的书记不住,那读书到底有什么用?

第六周读书笔记——《编程珠玑(第二版)》

第103期:NLP—为什么需要写情书?

读书笔记-《异类》