(一)算法基础-算法复杂度计算

目录:

  1. 什么是算法复杂度?
  2. T(n)代表什么?如何计算?
  3. O(f(n))代表什么?
  4. 常见的时间复杂度及推导举例
  5. 什么是最好、平均、最坏情况?
  6. 什么是空间复杂度?
  7. 常见排序算法的时间复杂度?
  8. 常见复杂度函数的对比?

1. 什么是算法复杂度

从概念上讲,算法的复杂度是指算法在编写成可执行程序后,其运行所需要的时间资源以及空间资源的大小。
通俗的讲,就是该算法所需要的成本。我们做一件事情,也会考虑到成本,就比如时间成本和金钱成本,达到相同结果的事情,我们自然会觉得花的时间更少,花的金钱更少的事情会更加高效。所以一个算法的优劣,便是从其时间复杂度以及空间复杂度来衡量的。

2. T(n)代表什么?如何计算?

那什么是时间复杂度呢?
在讲明白时间复杂度之前,我们首先需要知道T(n)是什么。一个算法运行所需要的时间理论上来讲是没有办法算出来的,因为它受到运行的平台以及硬件条件的影响。我们也不需要知道一个算法所需要的具体时间是多少,假设算法内每一个语句为一个时间单位,那么一个算法所需要的时间就与执行该算法的计算语句次数有关,如果计算语句次数越多,那么其耗时肯定也会越久,即算法消耗的时间与算法所需要的计算语句次数成正比。我们将这个算法所需要执行的计算语句次数称之为“时间频度”或“语句频度”,记做T(n).

如下代码,我们看一下如何计算时间频度

void calculate() {
    int arr[] = {1, 2, 3, 4, 5 ,6, 7, 8, 9};    // 1次
    int length = sizeof(arr) / sizeof(*arr);    // 1次
    int sum = 0;                                // 1次
    for (int i = 0; i < length; i++) {         
        sum += (*arr + i);                      // 1次 * 9
    }
    std::cout << "sum = " << sum << std::endl;  // 1次
}
复制代码

在上面这个函数中,我们可以看到,整个函数执行的语句频度为1+1+1+1x9+1 = 13次,计作:T(n) = 13。 当arr这个数组内的元素越多时,那么整个算法的时间频度就会变得更高。如果我们将这个数组的长度用n来表,那么随着n的递增变化,这个函数的时间频度也就递增,n在这里称为这个问题的规模。所以上面这个问题就理解为: 解决一个规模大小为n的问题,对应n+4次步骤,所需要的时间频度为T(n).那么,calculate函数内的时间频度便为:T(n) = n + 4. 而当规模变化时,整个算法的时间频度T(n)也就跟着变化了,当我们想要了解这个变化规律时,便引入了时间复杂度的概念.

3. O(f(n))代表什么?

如果存在一个正常数c,以及一个辅助函数f(n),能够使f(n) x c >= T(n),那么就计作T(n) = O(f(n)),这里的O(f(n))便称为算法的渐变时间复杂度(asymptotic time complexity),简称为时间复杂度。 通常在T(n)函数中如果随着n的不断递增变化,其中对整个问题复杂度的影响最大的一项,我们将其作为f(n),而会去掉影响稍若的系数或者常数。

4. 常见的时间复杂度及推导举例

例3.1(O(1))常数阶量级:

void function() {
   int sum = 0;                  // 1次
   for(int i = 0; i < 10; i++) {
       Sum += i;                 // 10次
   }
}
复制代码

注意:O(1)表示时间复杂度为一个常数,并不代表整个算法执行语句只有一句,只要时间复杂度不会随着n的变化而持续变化的算法我们便将其算法复杂度计作O(1),如上面的函数,T(n) = 10 + 1;

例3.2(O(n))线性阶量级:

void function() {
   int sum = 0;                  // 1次
   for(int i = 0; i < n; i++) {
       Sum += i;                 // n次
   }
}
复制代码

上面这个函数T(n) = n + 1;当n无限大时,我们会发现n会无限趋近于T(n),而常数1可以忽略不计.所以这里的 f(n) = n,这是一个线性增长的函数,计作O(n).

例3.3(O(n^2))平方阶量级:

void function() {
   int sum = 0;                  // 1次
   
   for(int i = 0; i < n; i++) {
       sum += i;                 // n次
       for(int j = 0; j < n; j++) {
          sum += j*I;           // n^2次
       }
   }
}
复制代码

上面这个函数T(n) = n^2 + n + 1;同样的道理,当n无限大时,我们会发现n^2会无限趋近于T(n),而一次项n以及常数1,会随着n的无限增大,其影响力变得越小,可以忽略不计.所以这里的 f(n) = n^2,这是一个二次函数,计作O(n^2).

例3.4(O(n^2))平方阶量级:

void function() {
   int sum = 0;                  // 1次
   
   for(int i = 0; i < n; i++) {
       sum += i;                 // n次
       for(int j = i; j < n; j++) {
          sum += j*i;           // n*(n-i)次
       }
   }
}
复制代码

当i = 0时,会执行1 + n + 1 次
当i = 1时,会执行1 + (n - 1) + 1 次
当i = 2时,会执行1 + (n - 2) + 1 次
……
当i = n时,会执行1 + (n - n) + 1次
所以
T(n) = n + ((n+1)xn)/2 + 1 = 1/2(n^2) + (3/2)xn + 1
上面这个函数T(n) = 1/2(n^2) + (3/2)xn + 1;同样的道理,当n无限大时,我们会发现n^2会无限趋近于T(n),而二次项系数1/2与整个一次项(3/2)*n以及常数1,会随着n的无限增大,其影响力变得越小,可以忽略不计.所以这里的 f(n) = n^2,这是一个二次函数,计作O(n^2).

例3.5(O(n^k))k次方阶量级:

void function() {
   int sum = 0;                  // 1次
   for(int i = 0; i < n; i++) {
       for (int j = 0; j < n; j++) {
           for (int k = 0; k < n; k++) {
               sum += i * j * k;        // n^3
           }
       }
   }
}
复制代码

例3.6(O(2^n))指数阶量级:

long function() {
   if (n < 2) {
       return 1;
   }
   return function(n-1) + function(n-2)
}
复制代码

n = 0, T(n) = 1; n = 1, T(n) = 1; n = 2, T(n) = 2; n = 3, T(n) = 3; n = 4, T(n) = 5; n = 5, T(n) = 8; .... n = n, T(n) = T(n-1) + T(n-2); 很明显这是一个斐波那契数列,其通过归纳法可以证明,n>4时,T(n) >= (3/2)^n,当我们可以讲其成2^n,即计作T(n) = O(n^2).

例3.7(O(logn))或者(O(nlogn))对数阶量级:

void function() {
   int sum = 1;                  
   while (sum < n) {
       sum = sum * 2;
   }
}
复制代码

n = 1, T(n) = 0; (约等于log2(1)) n = 2, T(n) = 1; (等于log2(2) n = 3, T(n) = 2; (log2(3) 约等于log2(4)) n = 4, T(n) = 2;(等于log2(4)) n = 5, T(n) = 2; n = 6, T(n) = 2; n = 7, T(n) = 2; n = 8, T(n) = 3;(等于log2(8)) ..... n = n, T(n) = O(logn) 如果讲循环中的sum = sum * 2还做sum = sum * 3,那么结果约等于log3(n),但是通过换底公式,再去掉系数后,T(n) = O(logn).
O(nlogn),就是把上面的代码在循环执行n遍了。归并排序、快速排序的时间复杂度就是O(nlogn)

5. 什么是最好、平均、最坏情况?

通常一个算法的复杂度不仅与其规模n的大小有关系,还会与数据源本身的初始状态有关。例如在下列的冒泡排序(从小到大)算法中:

void bubbleSort(int arr[], int n) {
    if (n < 2) {
        return;
    }
    bool isAllInOrder = false;
    for (int i = 0; i < n-1 && !isAllInOrder; i++) {
        isAllInOrder = true;
        for (int j = 0; j < n - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                int temp = arr[j];
                arr[j] = arr[j+1];
                arr[j+1] = temp;
                isAllInOrder = false;
            }
        }
    }
}
复制代码

核心计算只有在数组中相邻两个元素,前者大于后者的情况下才会去做交换。因此,如果数据源本身就是一个从小到大的有序的数组后,那么该算法的时间复杂度将会是n,这就是我们所说的最好情况。但是,相反,如果数据源初始化时是一个从大到小排列的数组,那么这个冒泡算法的核心计算将会进行(n-1)x(n-2)/2次,这就是该算法的最坏情况。我们在研究算法的时间复杂度时,都是以最坏的情况来做比较的。至于平均情况,实践中可以采将事前预估与事后统计相结合起来使用。比如如果我们在计算一个10x10的矩阵相乘,运行时间为10ms,那么运行一个31x31的矩阵相乘可以预估为(31/10)^2.

冒泡排序的效率其实不是很高,快速排序是对冒泡排序的改进,下面我们就再以快速排序(QuickSort)来举例,通过计算来推导快速排序的最坏、最好和平均情况的时间复杂度。
快速排序算法的核心思想就是选择数组中任意一元素(通常会选择第一个元素)作为枢轴(pivot),将小于枢轴元素的数组元素放到左边,大于枢轴元素的数组元素放到右边,这样就将原数组分成了以枢轴元素为中心的两个数组,然后分别再对这两个数组进行同样的分割操作,直到分割到最后只剩两个元素的数组。
在分割数组中(假定是从小到大排序),我们使用low与high分别指向将要排序数组的第一个和最后一个元素。从high标记位置向前查询,直到找到一个比pivot元素小的元素,将它与low位置对应的元素交换。然后再从low位置向后查询,直到找到一个比pivot元素大的元素,将它与high位置进行交换。继续这样两端交换查询,对比,直到low==high,即low与high指向同一个元素,最后将pivot元素存放到low(或high)位置上,这样就完成了一轮数组分割。
下面我们使用图来说明一下数组分割的具体操作,如图:

这个长度为8的元素经过三次排序后,便称为了一个有序序列。下面是具体的程序:

void quickSort(int arr[], int start, int end) {
    if (start >= end) {
        return;
    }
    int pivot = arr[start];
    int low = start;
    int high = end;
    while (low < high) {
        while (high > low && arr[high] >= pivot) {
            high --;
        }
        arr[low] = arr[high];
        
        while (low < high && arr[low] < pivot) {
            low ++;
        }
        arr[high] = arr[low];
    }
    arr[low] = pivot;
    
    
    quickSort(arr, start, low - 1);
    quickSort(arr, low + 1, end);
}
int arr[] = {23, 3, 12, 4, 7 , 8 ,23, 10,1, 11, 12, 2, 3};
quickSort(arr, 0, sizeof(arr) / sizeof(*arr) - 1);
复制代码

那么,有了算法,我们再来推导一下该算法的最坏情况。很明显如果每进行一次分割,low或high其中一个位置一直不变的情况下(即原数组是一个有序序列),那么就会对比n-i次(i属于[1,n-1]),直到排序完,整个时间频度为
(n-1) + (n-2) + (n - 3) .... + 2 + 1 = (n x (n - 1)) / 2 = (n^2 - n) / 2
当n趋近于无穷大时,n^2将会是主要影响因素,所以在最坏的情况下,快速排序的时间复杂度是O(n^2).
快速排序的过程可以看作是一棵二叉树建立的过程,将原数据进行不停地分裂,每次分裂成两个节点,直到最后分裂出的叶子节点里只包含两个元素为止。每一次的分裂,需要的时间复杂度为节点包含的元素个数减1。其实这样我们就知道,整个二叉树的效率与这颗二叉树的深度有关,深度越深,其效率就越低,相反,如果二叉树的深度越浅,其效率就会越高。如果要保证其深度最浅,那么只需要每次分裂时,其直接孩子节点的元素包含个数之差小于或等于1即可,如图所示,如果我们要将[1,16]这16个数据进行分裂,那最好的情况就是每次都是平均分割。

下面,我们来通过公式来计算最好情况的时间复杂度。
如果每次都是平均分割,那么T(n) = (n-1) + T(n/2) + T(n/2)
T(n) <= n + 2T(n/2)
T(n) <= n + 2(n/2 + 2T(n/4)) = 2n + 4T(n/4)
T(n) <= 2n + 4(n/4 + 2T(n/8)) = 3n + 8T(n/8)
T(n) <= 3n + 8(n/8 + 2T(n/16)) = 4n + 16T(n/16)
......
T(n) <= log(2,n)n + 2^log(2,n)T(2) ,其中T(2) = 1

T(n) <= log(2,n)n + 2^log(2,n) = nlgn + n
忽略掉最后的n,即快速排序最好的时间复杂度为O(nlgn).
当然,这只是最好的情况,那么平均情况该如何呢,又怎么来计算呢?
第一次分割,记分割的为止为k,分割所需要的时间计作cn,c为常数(最坏的情况下c = (n-1)/n),那么
T(n) = c*n + T(k-1) + T(n-k),其中k∊[1,n]
k在[1,n]任意一位置的概率为1/n,那快速排序的平均时间复杂度可以使用数学期望公式:

通过数学归纳法可以进一步证明其平均复杂 <= c*(n+1)ln(n=1),其中n>=2. 所以快速排序的平均时间复杂度也为nlgn.

6. 什么是空间复杂度?

与时间复杂度类似,我们将空间复杂度(Space Complexity)计作:
S(n) = O(f(n))
一个算法除了需要指令(即程序)、输入数据、常量、变量数据外,还需要对数据进行操作的工作单元以及为辅助计算所需要的信息的辅助空间。若输入数据所占的空间只与问题本身有关系,而和算法无关,则只需要分析除输入和程序之外的额外空间。若额外空间相对与输入来说是一个常数,那么我们就称此算法为
原地工作

如今科技飞速发展,存储空间已经不是什么问题,对于一般的算法,现在也很少会对减少一些存储空间而对算法大动干戈。

7. 常见排序算法的时间复杂度?

下面这张图是在网上搜索到的一个用用排序时间复杂度的比较结论图,可以先大做个了解。后面的博客中,我将对每一个排序算法进行单独的分析。

8. 常见复杂度函数的对比?

f(n)Oname
cO(1)常数函数
2n+cO(n)线性函数
3n^2 + n + cO(n^2)平方阶函数
n^k + n + cO(n^k)指数阶函数
clog2n + cO(logn)对数函数
nlog2n + cO(nlogn)对数函数

总结

通常为了去找到解决同一问题的更优算法,我们采用算法复杂度来作为衡量其优劣的方法。我们在写完一段程序后,用这样的思维去多思考一下是否还有更优解,在常年累月不断锻炼自己的思维的同时,也是对我们自身能力的极大提升。

本文为原创内容,供学习参考以及总结归纳使用,若文章中有引用到涉及版权相关的图片,请告知!
勘误,请留言!

转载于:https://juejin.im/post/5bef774f518825741f6258bf

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值