算法的时间复杂度介绍
创始人
2025-05-30 19:06:20
0

本文主要算法时间复杂度的相关知识。

1 概述

算法(Algorithm)是指用来操作数据、解决程序问题的方法。

对于同一个问题,使用不同的算法,也许最终得到的结果是相同的,但在执行该算法所需的时间和(存储)资源可能会有很大的区别,那么我们应该如何去衡量不同算法之间的优劣呢?目前主要是从算法所占用的“时间”和“空间”两个维度去进行评价。

时间维度是指执行算法所消耗的时间,通常用“时间复杂度”来描述,空间维度是指执行算法需要占用的内存空间大小,通常用“空间复杂度”来描述。

2 大O表示法与常见的时间复杂度

大O表示法是一种算法复杂度的“相对”“表示”方式,在这个句子中:

相对(relative):我们只能比较相同的事物,不能把一个做算数乘法的算法和排序整数列表的算法进行比较,这是没有意义的;

表示(representation):大O表示法把算法间的比较简化为了一个单一的变量,这个变量的选择基于观察或假设。例如,排序算法之间的比较通常是基于比较操作(比较2个结点来决定这2个结点的相对顺序)的,这里面就假设了节点间的比较操作的计算开销很大。但是,如果比较操作的计算开销不大,而交换操作的计算开销很大,又会怎么样呢?这就改变了先前的比较方式。

常见的时间复杂度量级如下:

  • O(1):常数阶

  • O(n):线性阶

  • O(n^2):平方阶

  • O(logn):对数阶

  • O(nlogn):线性对数阶

  • O(2^n):2的n次方阶

  • O(n!):阶乘阶

说明:在表示算法复杂度时,对数logn的底数为2。

这几种时间复杂度的优劣性如下图所示:

下面分别介绍几种常见的时间复杂度。

2.1 O(1)

对于O(1)常数阶时间复杂度,仅需固定次数(如1次、5次等等)即可完成算法的操作,而不会受其他因素影响,即其他因素不会参与到算法的操作中。

下面给出一个交换参数a和b位置的示例代码:

void swapTwoInts(int &a, int &b) {int temp = a;a = b;b = temp;
}

在上面的示例代码中,仅需一次操作即可完成变量交换算法(操作),而在此过程中,其他因素不会影响到该算法的时间复杂度。上述代码的运行过程示意图如下:

2.2 O(n)

对于O(n)线性阶时间复杂度,需要执行n次操作(如变量交换、加法等算法运算等)方可完成算法的完整操作。

下面给出一个求累加和的示例代码:

int sum (int n) {int ret = 0;for (int i = 0; i <= n; i++) {ret += i;}return ret;
}

在上面的示例代码中,for循环中的代码(加法运算)会被执行n次,即需要n次操作方可完成累加算法(操作),因此该算法的时间复杂度为O(n)。上述代码的运行过程示意图如下:

注意,c*O(n)表示的时间复杂度与O(n)相同。例如2*O(n)或(1/2)*O(n)均表示时间复杂度为O(n)。

2.3 O(n^2)

对于O(n^2)平方阶时间复杂度,需要执行(n^2)次操作(如变量交换、加法等算法运算等)才能完成算法的完整操作。

下面给出一个选择排序(selection sort)的示例代码:

void selectionSort(int arr[],int n) {for (int i = 0; i < n; i++) {int minIndex = i;for (int j = i + 1; j < n; j++) {if (arr[j] < arr[minIndex]) {minIndex = j;}}swap(arr[i], arr[minIndex]);}
}

在上面的示例代码中,for循环中的代码中又嵌套了一层for循环(双重循环),即把O(n)的代码再嵌套循环一遍,因此该算法的时间复杂度就是O(n^2)了。上述代码的运行过程示意图如下:

这里推导一下该算法的执行过程:

  • 当”i = 0“时,第二重循环需要运行(n – 1)次;

  • 当”i = 1“时,第二重循环需要运行(n – 2)次;

依此类推,可以得到公式:

(n - 1) + (n - 2) + (n - 3) + ... + 0
= (0 + n - 1) * n / 2
= (n ^ 2 - n) / 2

因此,该算法的时间复杂度为O(n^2)。

当然,并非所有双重循环的时间复杂度都是O(n^2),如果内层循环次数为常数c,则时间复杂度为c*O(n)=O(n)。示例代码如下:

void printInformation(int n) {for (int i = 1; i <= n ; i++) {for (int j = 1; j <= 30; j++) {cout << "Hello World!" << endl;}}
}

2.4 O(logn)

对于O(logn)对数阶时间复杂度,需要执行(logn)次操作(如变量交换、加法等算法运算等)才能完成算法的完整操作。

下面给出一个二分查找(binary search)的示例代码:

int binarySearch(int arr[], int n, int target) {int left = 0, right = n - 1;while (left <= right) {int mid = left + (right - left) / 2;if (arr[mid] == target) {return mid;}if (arr[mid] > target) {right = mid - 1;}else {left = mid + 1;}}return -1;
}

在上面的示例代码中,通过while循环中的除2操作,将每次搜索范围缩小至前一次的一半,因此最坏的情况需要经过logn次操作跳出循环,因此该算法的时间复杂度为O(logn)。上述代码的运行过程示意图如下:

这里推导一下该算法的执行过程:

  • 在第1次查找完成后,剩余的搜索范围为n*(1/2);

  • 在第2次查找完成后,剩余的搜索范围为n*(1/2)*(1/2);

依此类推,可以得到公式:

n*((1/2)^k)=1
k=logn

说明:

  • n为数组长度;

  • k为最坏情况下查找到目标元素所需的次数(或者跳出循环的次数);

  • 等式“n*((1/2)^k)=1”中右侧的“1”表示剩余的待搜索范围。当剩余的搜索范围为“1”时,该次搜索操作完成后,无论是否找到了目标元素,此算法都将结束执行。

因此,该算法的时间复杂度为O(logn)。

2.5 O(nlogn)

将时间复杂度为O(logn)的代码循环n次,则该算法的时间复杂度为n*O(logn),即O(nlogn)。

示例代码如下:

void hello() {for (i = 1; i < n; i++) {int j = 1;while (j < n) {j = j * 2;}}
}

3 其他几种时间复杂度

本章介绍以下几种复杂度:

  • 递归算法(recursive algorithm)的时间复杂度;

  • 最好情况(best case)时间复杂度;

  • 最坏情况(worst case)时间复杂度;

  • 平均情况(average case)时间复杂度;

  • 均摊(amortized)时间复杂度。

3.1 递归算法时间复杂度

3.1.1 一次递归调用

如果递归函数中,只进行了一次递归调用,且递归深度为depth,同时递归函数中操作的时间复杂度为T,则该递归算法的总体时间复杂度为O(T * depth)。

下面给出一个使用递归函数的二分查找(binary search)的示例代码:

int binarySearch(int arr[], int left, int right, int target) {if (left > right) {return -1;}int mid = left + (right - left) / 2; if (arr[mid] == target) {return mid;  }else if (arr[mid] > target) {return binarySearch(arr, left, mid - 1, target);} else {return binarySearch(arr, mid + 1, right, target);}
}

在上述代码中,递归函数中每次只可进行一次递归调用(if-else判断条件),由于递归函数中的除2操作,使得递归深度为logn,同时递归函数中操作的时间复杂度为1(无循环等),因此该算法的时间复杂度为O(1 * logn) = O(logn)。

这里再给出一个使用递归函数计算累加和的代码示例:

int sum(int n) {if (n == 0) {return 0;}return n + sum(n - 1);
}

在上述代码中,递归深度随着n的增加而线性递增,即递归深度为n,同时递归函数中操作的时间复杂度为1(无循环等),因此该算法的时间复杂度为O(1 * n) = O(n)。该代码的运行过程示例图如下:

3.1.2 多次递归调用

递归算法中比较难计算的是多次递归调用的情况,在这种情况下,通常需要根据具体情况分析算法时间复杂度。

先看下面一段示例代码:

int f(int n) {if (n == 0) {return 1;}return f(n - 1) + f(n - 1);
}

在上述这段代码中,递归算法包含了2次递归调用,递归树中节点数就是代码操作的调用次数。假设n=3,则上述代码的运行过程示意图如下:

在上述示意图中,代码操作的调用次数计算公式如下:

1 + 2 + 4 + 8 = 15

从中可以寻找出如下规律:

2 ^ 0 + 2 ^ 1 + 2 ^ 2 + ... + 2 ^ n
= 2 ^ (n + 1) – 1
= 2 ^ n + 1

因此,该算法的时间复杂度为O(2^n)。

再看一个归并排序的代码示例:

void mergeSort(int sourceArr[], int tempArr[], int startIndex, int endIndex)
{if(startIndex < endIndex){int midIndex = startIndex + (endIndex-startIndex) / 2;mergeSort(sourceArr, tempArr, startIndex, midIndex);mergeSort(sourceArr, tempArr, midIndex+1, endIndex);merge(sourceArr, tempArr, startIndex, midIndex, endIndex);}
}

在上述这段代码中,递归算法包含了2次递归调用。假设n=8,则上述代码的运行过程示意图如下:

通过观察上面的归并排序的递归树可知:

  • 归并排序的递归树深度为logn;

  • 归并排序的递归树中每个节点处理的数据规模是逐渐缩小的,但每一层的时间复杂度保持不变,为O(n);

因此,归并排序的算法时间复杂度为O(nlogn)。

说明:归并排序算法时间复杂度的计算方式与3.1.1节“一次递归调用”中的计算方式类似。这里要强调的是,涉及到对此递归调用的时间复杂度计算时,需根据实际情况进行分析。

3.2 最好/最坏情况时间复杂度

最好或最坏情况下的时间复杂度指的是特殊情况下的时间复杂度。

现有如下示例代码(作用是查找某数值在数组中首次出现时的位置):

int find(int array[], int n, int x) {for (int i = 0; i < n; i++) {if (array[i] == x) {return i;break;}}return -1;
}

上述代码的运行过程示意图如下:

通过上图可知,当数组中第一个元素就是要查找的数值时,时间复杂度就是O(1);而当最后一个元素才是要找的数值时,时间复杂度则是O(n)。

简单总结一下,最好情况时间复杂度就是在最理想情况下执行代码的时间复杂度,它所消耗的时间是最短的;最坏情况时间复杂度就是在最糟糕情况下执行代码的时间复杂度,它所消耗的时间是最长的。

3.3 平均情况时间复杂度

最好、最坏时间复杂度反应的是极端条件下的时间复杂度,通常发生的概率不大,不能代表平均水平。因此为了更好的表示一般情况下的算法时间复杂度,就需要引入平均情况时间复杂度。

平均情况时间复杂度可用代码在所有可能情况下执行次数的加权平均值表示。

这里仍以3.2节中的find函数为例,从概率的角度看, 数值x在数组中每一个位置出现的可能性是相同的,都是1/n,同时对应位置的权重为n的实际值(如1、2、3...),因此,该算法的平均情况时间复杂度就可以用如下的方式计算:

(1/n)*1 + (1/n)*2 + (1/n)*3 + ... + (1/n)*n
= (n+1)/2

因此,find函数的平均时间复杂度为O(n)。

3.4 均摊时间复杂度

这里通过一个动态数组的push_back操作来理解均摊时间复杂度。示例代码如下:

template 
class MyVector {
private:T* data;// 存储数组中的元素个数int size;// 存储数组中可以容纳的最大的元素个数int capacity;// 复杂度为O(n)void resize(int newCapacity) {T *newData = new T[newCapacity];for (int i = 0; i < size; i++) {newData[i] = data[i];}data = newData;capacity = newCapacity;}
public:MyVector() {data = new T[100];size = 0;capacity = 100;}// 平均复杂度为 O(1)void push_back(T e) {if (size == capacity) {resize(2 * capacity);}data[size++] = e;}// 平均复杂度为 O(1)T pop_back() {size --;return data[size];}
};

上述代码的运行过程示意图如下:

在上述代码中,push_back实现的功能是向数组的末尾增加一个元素。如果数组没有填满,则直接向其后面插入元素;如果数组已经填满了(即size == capacity),则将数组扩容一倍,然后再执行插入元素操作。

假设数组长度为n,则前n次调用push_back函数的时间复杂度都是O(1)级别,而在第(n+1)次则需要先进行n次元素转移操作,然后再进行1次元素插入操作,因此,第(n+1)操作的时间复杂度为O(n)。

所以,对于容量为n的动态数组,前面添加元素需要消耗了(1*n)的时间,同时扩容操作消耗n时间,总共消耗(2*n)的时间,因此,完整操作的均摊时间复杂度为:O(2n/n) = O(2),也就是O(1)常数级别了。

通过以上内容,可以知道:一个相对比较耗时的操作(如本例中的扩容操作),如果能保证它不会每次都被触发,那么这个相对比较耗时的操作,它需要的时间是可以分摊到其它的操作中的。

相关内容

热门资讯

每周股票复盘:传音控股(688... 截至2025年7月25日收盘,传音控股(688036)报收于76.2元,较上周的74.69元上涨2....
上海第六批土拍收官:全国单价地... 观点网7月25日,为期两日的上海六批次8宗地土拍落下帷幕,热度再创新高。 第二日出让的3宗地块分布于...
“国补”来了!第三批690亿元... 关注我们哦! 国家发展改革委下达今年第三批690亿元超长期特别国债支持消费品以旧换新资金 2025年...
和讯投顾黄杰:股市最近应该买阴... 今天怎么操作?和讯投顾黄杰分析,今天的策略是尾盘低吸科技低吸小票,或者明天低吸科技低吸小票,这是我的...
市场监管总局:已暂停充电宝及电... 7月25日,市场监管总局消息,从2024年开始将充电宝及其关键部件锂电池纳入CCC认证管理,近日正组...
门店“转卖”会员,把消费者当什... 预付式消费以其便捷与优惠在健身、教培、美容等行业广泛应用。针对预付式消费门店完全“跑路”的情况,相关...
财政部:上半年共发行超长期特别... 上证报中国证券网讯(记者 李苑)财政部国库支付中心副主任唐龙生25日在财政部新闻发布会上表示,上半年...
调查:A股、美股、黄金即将发生... 来源:华尔街情报圈 一系列即将发生的事件可能会扰乱日趋平静的市况,下周市场将有很多事情需要消化。 ...
运行总体平稳 支出力度加大 新华社北京7月25日电(申铖 欧阳剑环)财政部25日发布的上半年财政收支半年报显示,今年以来,财政运...
情暖老党员!日照银行东港中心支... 大众网记者 陈璐 日照报道 为传承党的优良传统,践行社会责任,近日,日照银行东港中心支行党委组织党员...
石头扫地机二次上市虽不性感,但... 来源:晚点LatePost 虽然扫地机已与机器人概念脱钩,但国内品牌商正与持续增长的确定性挂钩...
交易限额!两大交易所出手,焦煤... 当下最火爆的两个期货品种——焦煤、碳酸锂,7月25日都迎来了交易限额要求。 7月25日,根据交易所通...
晶方科技涨0.90%,成交额8... 来源:新浪证券-红岸工作室 7月25日,晶方科技涨0.90%,成交额8.73亿元,换手率4.65%,...
新央企中国雅江集团,董事长、总... 中国三峡集团网站消息,7月19日,中国三峡集团董事长、党组书记刘伟平在西藏林芝与 中国雅江集团董事长...
人身险预定利率研究值再下调 保... 7月25日,中国保险行业协会公布最新普通型人身保险产品预定利率研究值,1.99%的数值较上一期下调了...
近半数投顾机构被罚,超六成涉虚... 文/王占全 导语 2025年证券投顾行业再掀监管风暴!黑龙江证监局日前对容维公司开出年内第二张罚单,...
新一轮Meme股热潮迎微妙转折... 高盛集团交易部门周五表示,新一轮Meme股热潮推动一批小型公司股价大涨后,其客户对押注无盈利科技股下...
增持未在规定时间内停止交易!荣... 浙江省证监局近日发布关于对浙江荣盛控股集团有限公司采取出具警示函措施的决定。 经查,荣盛控股集团于...
72岁“稀土大王”蒋泉龙被踢出... 红星资本局7月25日消息,近期,A股稀土板块行情持续火热,热度也蔓延到了港股。不过,港股上市公司、家...
上半年30个省份“半年报”出炉... 贝壳财经原创出品 记者 张晓翀 任婉晴 任娇 董怡楠 编辑 陈莉 截至7月25日发稿时,全国30个...