关闭

初探算法复杂性度量

75人阅读 评论(0) 收藏 举报

初探算法复杂性度量

说到算法复杂性的度量,我们最熟悉的就是时间复杂度和空间复杂度,这两者均属于算法的事前估计。算法复杂性度量对一个算法的可操作性和效率性具有不可忽视的作用,可以说,一个算法的优异性与算法复杂性的优异性密切相关。


空间复杂度度量

空间复杂度(space complexity)是指问题的规模以某种单位从1增加到时,解决这个问题的算法在执行时所占用的存储空间也以某种单位由1增加到S(n),则称此算法的空间复杂度为S(n)。
   —— 殷人昆《数据结构(用面向对象方法与C++语言描述)》

我们先来谈谈空间复杂度,而以上是对空间复杂度的定义。要准确理解空间复杂度,我们从定义出发,先来理解问题规模和空进单位的概念。

问题规模

在一个问题的描述中,我们可以很容易地找到关于问题规模的描述。例如,在有n个学生资料中查找某一学生的资料,n就是问题的规模,又如对n阶线性方程进行求解,问题规模仍然是n。算法是针对实例而设定的,问题规模也就相应地代表了实例的特性。

空间单位

空间单位一般规定为一个工作单元所占用的存储空间大小,可以是一个简单变量,也可以是一个构造型变量。

然后我们再看看以下三组程序:

int abc(int a, int b, int c) {
    return a + b + c + a * b * c + a / b;
}
int sum(int a[], const int n) {
    int s = 0;
    for (int i = 0; i < n; i++) s += a[i];
    return s;
}
int resum(int a[], const int n) {
    if (n <= 0) return 0;
    else return resum(a, n - 1) + a[n - 1];
}

观察以上三组程序,我们来思考一下它们对存储空间的使用。

这些程序所需的存储空间包含两个部分:

1)固定部分 这部分空间大小与输入输出个数多少、数值大小无关。主要包括存放程序指令代码的空间、常数、简单变量、定长成分(如数组元素、结构成分、对象的数据成员等)变量所占的空间等。这部分属于静态空间,只需要做简单的统计即可估算。

2)可变部分 这部分空间主要包括其与问题规模有关的变量所占空间、递归工作栈所用空间,以及在算法运行规程中通过new和delete命令动态使用的空间。

假设空间大小仅与时间规模n有关,可以通过分析算法规格说明,找出所需空间大小与n的一个函数关系,从而得到所需空间大小。

对于第一个程序,问题规模由a,b,c各占有一个空间单位,这样该函数所需存储空间为一常数。

第二个程序的问题规模为n,在程序中用到了一个整数n存放累加项个数,还用到一个整数s作为存放累加值的存储空间;另外对于数组a[]来说,只耗费了一个空间单元存放它第一个元素a[0]的地址。因此,此函数所需的存储空间也为一常数。

第三个程序使用了递归算法,问题规模也是n。在实现递归的过程中用到了一个递归工作栈,每递归一层就要加一个工作记录到递归工作栈中,工作记录为形式参数(a[]的首地址a[o]和n)、函数的返回值以及返回地址,保留了4个存储单元。由于算法的递归深度是n + 1,故所需的栈空间是4(n + 1)。

最不好估算的是涉及动态存储分配时的存储空间需求。若使用了k次new命令,动态分配了k次空间单元。如果没有使用delete命令释放已分配的空间,那么占用的存储空间数等于分配的空间数;如果使用了m次delete命令,就不能简单地拿new分配的空间数减delete释放的空间数,必须具体分析。

因此,分析一个算法所占用的存储空间要从各方面综合考虑。如对于递归算法来说,一般比较简短,算法本身占用的存储空间较少,但运行时需要附加一个堆栈,占用较多的临时工作单元。当写成非递归算法时,一般比较长,算法本身占用的存储空间多,但运行时需要较少的存储单元。

若一个算法为递归算法,其空间复杂度为递归所使用的递归工作栈空间的大小,它等于一次调用所分配的临时存储空间的大小乘以被调用的次数(即为递归调用的次数加1,这个1表示开始进行的一次非递归调用)。

算法的空间复杂度一般也以数量级的形式给出。如当一个算法的空间复杂度为一个常量,即不随被处理数据量n的大小而改变时,可表示为O(1);当一个算法的空间复杂度与以2为底的n的对数成正比时,可表示为O(log2n);当一个算法的空间复杂度与n成线性比例关系时,可表示为O(n);若形参为数组,则只需要为它分配一个存储由实参传送来的一个地址指针的空间,即一个机器字长空间;若形参为引用方式,则也只需要为其分配存储一个地址的空间,用它来存储对应实参变量的地址,以便由系统自动引用实参变量。

时间复杂度度量

同样,我们先看看时间复杂度的定义。

时间复杂度(time complexity)是指当问题的规模以某种单位从1增加到n时,解决这个问题的算法在执行时所耗费的时间也以某种单位由1增加到T(n),则称此算法为T(n)。
—— 殷人昆《数据结构(用面向对象方法与C++语言描述)》

空间复杂度有空间单位来衡量,时间复杂度自然有时间单位来衡量。我们先来引入时间单位的概念。

时间单位

时间单位一般规定为一个程序步(program step),不同的语句有不同的程序步。

程序步

程序步是指在语法上或语义上有意义的一段指令序列,而且这段指令序列的执行时间与实例特性无关。

为了确定算法中每一个语句的程序步数,我将一一分析各种语句的程序步数。

1) 注释 程序步数为0,因为它是非执行语句。

2) 声明语句 程序步数为0。声明语句包括定义常数和变量的语句,用户自定义数据类型的语句,确定访问权限的语句,指明函数特征的语句。

3) 表达式 如果表达式中不包含函数调用,则程序步数为1.如果表达式中包含函数调用,还需要加入分配给函数调用的语句。

4) 赋值语句 <变量>=<表达式>的程序步数与表达式的程序步数相同。但如果赋值语句中的变量是数组或字符串(字符数组),则赋值语句程序步数等于变量的体积加上表达式的程序步数。

5) 循环语句
 1、while <表达式> do …….
 2、do …… while <表达式>
 控制部分一次执行的程序步数等于<表达式>的程序步数。
 3、for( <初始化语句>;<表达式1>;<表达式2>) ……
 <初始化语句>、<表达式1>、<表达式2>可能是实例特性 (例如n)的函数,控制部分第一次执行的程序步数等于<初始化语句>与<表达式>的程序步数之和,后续执行的程序步数等于<表达式1>与<表达式2>的程序步数之和。

6) switch 语句 其中,首部switch(<表达式>)的程序步数等于表达式具有的程序步数;执行一个条件的程序步数等于它自己的程序步数加上它前面所有条件计算的程序步数。

7) if_then 语句
 if (<表达式>) <语句1>;
 else <语句2>;
分别将<表达式>、<语句1>和<语句2>的程序步数分配给每一部分。需要注意的是如果else不出现,则这部分没有时间开销。

8) 函数执行语句/函数调用语句 函数调用语句的程序步数为0.其时间开销计入函数执行语句。函数执行语句的程序步数一般为1。但是,当函数执行语句中包含有传值参数且传值参数的体积与实例特性有关时,执行函数调用的程序步数等于这些值参的体积之和。如果函数是递归调用,那么我们还要考虑在函数中的局部变量。如果局部变量的体积依赖于实例特性,需要把这个体积加到程序步数中。

9) 动态存储管理语句 这类语句有new object、delete object 和sizeof (object)。每一个语句的程序步数都是1。new 和 delete
还分别隐式地调用了对象的构造函数和析构函数,这时可以用类似于分析函数调用语句的方式计算其程序步数。

10) 转移语句 这类语句包括continue、break 、goto、return 和 return<表达式>。它们的程序步数一般都为1。但是在return<表达式>情形,如果<表达式>的程序步数时实例特性的函数,则其程序步数为<表达式>的程序步数。

利用上述语句的程序步数可以确定一个程序的程序步数。通常有两种确定程序步数的方法。

第一种方法是在程序中插入一个计数变量count,它是一个初始值为0的全局变量。

以一组程序为例:

int sum(int a[], const int n) {
    int s = 0;
    count++;
    for (int i = 0; i < n; i++) {
         count += 2;
         s += a[i];
         count++;
     }
     count += 2;
     count++;
     return s;
}

假设count的初始值为0,则程序执行结束后,在count得到程序总程序步数是3 * n + 4。

如果程序采用递归呢?采用count同样可以得到相应的程序步数。

float rsum(float a[], const int n) {
     count++;
     if (n <= 0) {
         count++;
         return 0;
     } else {
         count += 2;
         return rsum(a, n + 1)+a[n - 1];
     }
}

若设count的初始值为0,且设Trsum(n)是程序执行结束后的count值,从上面程序可以看到,当n = 0时,Trsum(0) = 2。当n > 0时,进入rsum(a, n)执行后先在count中累加2,再加上递归调用rsum(a, n - 1)累加1,以及之后计算出的Trsum(n - 1)的值。这样我们可以得到一个计算递归程序rsum(a, n)的程序步数Trsum(n)的公式:
   当n = 0时,Trsum(n) = 2;
   当n > 0时,Trsum(n) = 3 + Trsum(n - 1)

然后通过重复代入Trsum递归计算Trsum:
Trsum(n) = 3 + Trsum(n - 1)
= 3 + 3 + Trsum(n - 2) = 3 * 2 + Trsum(n - 2)
= 3 * 3 + Trsum(n - 3)
= ……
= 3 * n + Trsum(0) = 3 * n + 2

这样一比较迭代求和与递归求和的程序步数,发现后者的程序步数要少一些,但是这并不能说明后者比前者运行时间短。事实上,后者涉及递归调用语句,其程序步数的时间开销要大得多。故后者实际运行时间比前者要多。

确定程序步数的第二种方法时建立一个表,列出程序内各个语句的程序步数。但本人就在这里就不详细说明了。

最后注意一点:

一个语句本身的程序步数可能不等于该语句一个执行所具有的程序步数!!!

你也许会奇怪为什么时间复杂度不是什么O(n)吗,为什么我还讲这么多程序步数?其实程序步数对我们阅读代码判断时间复杂度很有帮助,后续我将详细地介绍渐进的空间复杂度和时间复杂度,敬请期待!

0
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:126次
    • 积分:20
    • 等级:
    • 排名:千里之外
    • 原创:2篇
    • 转载:0篇
    • 译文:0篇
    • 评论:1条
    文章存档
    最新评论