C++抽象编程——算法分析(3)——深入了解Big-O

从代码中减少计算复杂度

下面的代码计算的是vector中的元素的平均值。

double average(vector<double> & vec) {
    int n = vec.size();
    double total = 0;
    for (int i = 0; i < n; i++) {
    total += vec[i];
}
    return total / n;
}

你如何确定其计算复杂度?当你调用此函数时,代码的某些部分只会执行一次,例如初始化total为0,并在返回语句中进行除法运算。这些计算需要一定的时间,但是不依赖于vector大小的意义上,时间是恒定的。其执行时间不依赖于问题大小的代码被称为以恒定时间运行(constant time),以大O表示法表示为O(1)。
有些人可能会对O(1)的心存疑惑。因为括号内的表达式不依赖于N.实际上,这种对N的不依赖是O(1)符号的整体。当你增加问题的大小时,执行运行时间为O(1)的代码所需的时间与1增加的方式完全相同, 换句话说,代码的运行时间根本不增加(只是确定,就像数学函数中的x= 0的一条线,它没有斜率)。
然而,average函数的其他部分只执行n次,对于for循环的每个循环都是执行 一次。这些部分包括在for循环语句中的表达式i ++还有下面的语句:

total += vec[i];

他们构成循环体。虽然这部分计算的任何一次执行都花费了固定的时间,但这些语句执行n次的事实意味着它们的总执行时间与vector的大小成正比。该部分平均函数的计算复杂度为O(N),通常称为线性时间(linear time)。
因此,这个函数的总运行时间是常数部分和算法的线性部分所需的时间的总和。然而,随着问题的大小的增加,常数变得越来越少。通过利用简化规则,允许忽略在N变大时变得无关紧要的部分,我们可以断言整个平均功能在O(N)时间内运行。

当然我们还可以通过查看代码的循环结构来预测此结果。在大多数情况下,单个表达式和语句(除非它们涉及必须单独考虑的函数调用,否则会在固定的时间里运行。) 在计算复杂性方面重要的是这些语句的执行频率。对于许多程序,可以通过找到最常执行的代码片段来确定计算复杂度,并确定其作为N的函数运行多少次。例如在average函数的情况下,循环体是执行n次 因为代码的任何部分都不会比这更频繁地执行,所以我们可以预测计算复杂度将为O(N)。
这个时候我们可以用同样的方式对选择排序函数进行分析。代码中最常执行的部分是语句中的比较

if (vec[i] < vec[rh]) rh = i;

该语句嵌套在两个for循环中,其限制取决于N的值。内部循环的运行次数是外部循环的N倍,这意味着内部循环体被执行次。 表示性能的选择排序的算法被称为以二次时间运行(quadratic time)。

最坏情况与平均情况复杂程度(Worst-case versus average-case complexity)

在某些情况下,算法的运行时间不仅取决于问题的大小,还取决于数据的特定特性。例如,思考下列的代码:

int linearSearch(int key, Vector<int> & vec) {
    int n = vec.size();
    for (int i = 0; i < n; i++) {
    if (key == vec[i]) return i;
    }
    return -1;
}

它返回出现key的vec中的第一个下标位置,如果key不显示在向量中的任何位置,则返回-1。因为实现中的for循环执行了n次,所以你可能第一时间想到的LinearSearch的性能,就是O(N)。但是另一方面,对linearSearch的一些调用可以非常快地执行。例如,假设正在搜索的key元素恰好位于向量中的第一个位置。在这种情况下,for循环的其实只运行一次。如果你足够幸运地搜索始终位于向量开始处的值,linearSearch将始终在确定的时间内完成。
当您分析程序的计算复杂性时,我们通常对最小可能的时间不感兴趣。一般来说,计算机科学家往往会关注以下两种复杂性分析:

  • 最坏情况下的复杂度。Worst-case complexity.)最常见的复杂性分析类型包括在最坏的情况下确定算法的性能。这样的分析通常是是很有用的,因为它允许你设置计算复杂度的上限。如果你分析最坏的情况,那么你可以保证算法的性能至少与你的分析表明一样好。虽然你可能有时会幸运,但此刻你可以相信,表现不会更糟。
  • 平均情况下的复杂度。Average-case complexity)从实际的角度来看,如果你在所有可能的输入数据集中平均计算其行为,那么考虑算法执行情况通常是有用的。特别是如果你没有理由假定你的问题的具体输入是非典型的输入,平均案例分析可提供实际绩效的最佳统计估计。然而,问题在于,平均案例分析通常要执行得更加困难,通常需要相当大的数学成果。

回到我们的linearSearch函数中,当key不在向量中时,当然会发生linearSearch函数的最坏情况。因为当key不存在时,该函数必须完成for循环的所有n个循环,这意味着其性能是O(N)。如果键已知在向量中,则for循环将平均执行大约一半,这意味着平均情况下的性能也是O(N)。(因为O(n/2)忽略常数也是O(n))。我们在以后的“Quicksort算法”中,算法的平均情况和最差情况下的性能有时会以定性的方式存在差异,这意味着在实践中,要综合考虑到这两个情况。

Big-O的公式化定义(A formal definition of big-O)

因为理解大O符号对现代计算机科学至关重要,因此提供更正式的定义以帮助我们了解大O的直观模型的原理以及为什么建议的大O公式的简化,这是很重要的。在数学中,大O表示法用于表达两个函数之间的关系,在这样的表达式中:

该表达式的形式意义在于,f(N)是具有以下特征的t(N)的近似值:存在常数N0和正常数C,使得对于N≤N0的每个值, 以下条件成立:

换句话说,只要N“足够大”,函数t(N)总是被函数ƒ(N)的常数倍数界定。
当用于表达计算复杂度时,函数t(N)表示算法的实际运行时间,这通常很难计算。函数ƒ(N)是一个简单得多的公式,尽管如此,对于运行时间如何作为N的函数而言,提供了合理的定性估计,因为在大O的数学定义中表达的条件确保实际的运行时间不能增长比ƒ(N)快。
要了解正式定义如何使用,让我们返回到选择排序示例。分析选择排序的循环结构表明,执行了最内循环中的操作,执行了

次,并且运行时间与这个公式成正比。当这种复杂性用大O表示法表示时,常数和低阶项被消除,只留下执行时间为O(N2)的断言,这实际上是断言:

为了表明这个表达式在大O的正式定义下确实是真的,所有你需要做的是找到常量C和N0,使得:

对于N≥N0的所有值。这个特殊的例子非常简单。将常数C和N0都设置为1. 毕竟,只要N不小于1,你知道N2≥N。因此,变成这样:

所以,对于任意的N >= 1,都满足要求。

更一般的证明

可以使用类似的参数来表示任何k阶多项式,可以用一般式子来表示:

它的big-O是
为了证明这个结论,我们的目标是寻找常数C和N0使得 对于任意的 N >= N0:

都成立。
如前面的例子所示,首先使N0为1.对于N≥1的所有值,N的每个连续阶数至少与其前身一样大,因此

这个原型反过来意味着:

其中围绕方程右侧的系数的垂直条表示绝对值。通过考虑N的k次方,我们可以简化这种不等式的右侧

如果我们把C定义成下面这个东西:

你就已经确定了

这个结论证明了这个多项式就是

如果这些繁重的数学式子吓到了你,不用担心,不懂无所谓,更重要的是明白Big-O的含义,而非它的公式化过程

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值