算法与数据结构——时间复杂度
时间复杂度
运行时间可以直观且准确地反映算法的效率。要准确预估一段代码的运行时间,应该进行如下操作。
- 确定运行平台,包括硬件配置、编程语言、系统环境等,这些因素都会影响代码的运行效率。
- 评估各种计算操作的运行时间,例如加法操作需要1ns,乘法操作需要10ns,打印操作需要5ns等。
- 统计代码中所有的计算操作,并将所有操作的执行时间求和,从而得到运行时间。
// 在某运行平台下 void algorithm(int n) { int a = 2; // 1 ns a = a + 1; // 1 ns a = a * 2; // 10 ns // 循环 n 次 for (int i = 0; i < n; i++) { // 1 ns ,每轮都要执行 i++ std::cout << 0 << std::endl; // 5 ns } }
根据以上方法,可以得到算法的运行时间为(6n+12)ns
但时间上,统计算法的运行时间既不合理也不现实。首先,我们不希望将预估时间和运行平台绑定,因为算法需要在各种不同的平台上运行。其次我们很难获知每种操作的运行时间,这给预估过程带来了极大的难度。
统计时间增长趋势#
时间复杂度分析统计的不是算法运行时间,而是算法运行时间随着数据量变大时的增长趋势。
“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为n,给定三个算法A、B、C:
// 算法 A 的时间复杂度:常数阶 void algorithm_A(int n) { cout << 0 << endl; } // 算法 B 的时间复杂度:线性阶 void algorithm_B(int n) { for (int i = 0; i < n; i++) { cout << 0 << endl; } } // 算法 C 的时间复杂度:常数阶 void algorithm_C(int n) { for (int i = 0; i < 1000000; i++) { cout << 0 << endl; } }
- 算法A只有一个打印操作,算法运行时间不随着n增大而增大。我们称此算法的时间复杂度为“常数阶”。
- 算法B中的打印操作需要循环n次,算法运行时间随着n增大而线性增加。此算法的时间复杂度被称为“线性阶”。
- 算法C中的打印操作需要循环1000000次,虽然运行时间很长,但是它与输入数据大小n无关。因此C的时间复杂度与A相同,均为“常数阶”。
函数渐近上界#
给定一个输入大小为n的函数:
void algorithm(int n) { int a = 2; // +1 a = a + 1; // +1 a = a * 2; // +1 // 循环 n 次 for (int i = 0; i < n; i++) { // +1 ,每轮都要执行 i++ std::cout << 0 << std::endl; // +1 } }
设算法的操作数量是一个关于输入数据大小n的函数,记为 𝑇(𝑛),则以上函数的操作数量为:
𝑇(𝑛) = 3 + 2𝑛
𝑇(𝑛) 是一次函数,说明其运行时间的增长趋势是线性的,因此它的时间复杂度是线性阶。
我们将线性阶的时间复杂度记为 𝑂(𝑛) ,这个数学符号称为大O记号,表示函数 𝑇(𝑛) 的渐近上界(asymptotic upper bound)。
时间复杂度分析本质上是计算“操作数量𝑇(𝑛)”的渐近上界,它具有明确的数学定义。
推算方法#
总体上分为两步:首先统计操作数量,然后判断渐近上界。
第一步:统计操作数量#
针对代码,逐行从上到下计算即可。操作数量𝑇(𝑛)中的各种系数、常数项都可以忽略。根据此原则,可总结出一下计数简化技巧。
- 忽略𝑇(𝑛)中的常数项。
- 省略所有系数。
- 嵌套循环时使用乘法。总操作数量等于外层循环和内层循环操作数量之积。
void algorithm(int n) { int a = 1; // +0(技巧 1) a = a + n; // +0(技巧 1) // +n(技巧 2) for (int i = 0; i < 5 * n + 1; i++) { cout << 0 << endl; } // +n*n(技巧 3) for (int i = 0; i < 2 * n; i++) { for (int j = 0; j < n + 1; j++) { cout << 0 << endl; } } }
以下公式展示了使用上述技巧前后的统计结果,两者推算出的时间复杂度都为 𝑂(𝑛2
) 。
𝑇(𝑛) = 2𝑛(𝑛 + 1) + (5𝑛 + 1) + 2 完整统计 (‑.‑|||)
= 2n2 + 7𝑛 + 3
𝑇(𝑛) = 𝑛2 + 𝑛 偷懒统计 (o.O)
第二步:判断渐近上界#
时间复杂度由𝑇(𝑛)中最高阶的项来决定。这是因为在n趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。
下表展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。
常见类型#
设输入数据大小为n,常见的时间复杂度类型如图所示:
常数阶 𝑂(1) #
常数阶的操作数量与输入数据大小n无关,即不随着n的变化而变化。
线性阶 𝑂(n) #
线性阶的操作数量相对于数据大小n以线性级别增长。通常出现在单层循环中。遍历数组和遍历链表等操作的时间复杂度均为 𝑂(𝑛) ,其中n为数组或链表的长度。
平方阶 𝑂(n2) #
平方阶通常出现在嵌套循环中,外层循环和内层循环的时间复杂度都为 𝑂(𝑛) ,因此总体的时间复杂度为 𝑂(𝑛2) 。
以冒泡排序为例,外层循环执行n-1次,内层循环执行n-1、n-2、n-3、...、2、1次,平均n/2次,因此时间复杂度为 𝑂((𝑛 − 1)𝑛/2) = 𝑂(𝑛2)
/*平方阶-冒泡排序*/ int bubbleSort(vector<int> &nums){ /*for (int i = 0; i < nums.size(); i++){ for (int j = i + 1; j < nums.size(); j++){ if (nums[i] > nums[j]){ int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; } } }*/ for (int i = 0; i < nums.size(); i++){ for (int j = 0; j < nums.size() - i - 1; j++){ if (nums[j] > nums[j + 1]){ int temp = nums[j]; nums[j] = nums[j + 1]; nums[j + 1] = temp; } } } }
指数阶 𝑂(2n) #
生物学的“细胞分裂”是指数阶增长的典型例子:初识状态为1个细胞,分裂一轮后变为4个,以此类推,n轮分裂后有2n个细胞。
在实际算法中,指数阶常出现于递归函数中。
/* 指数阶(循环实现) */ int exponential(int n) { int count = 0, base = 1; // 细胞每轮一分为二,形成数列 1, 2, 4, 8, ..., 2^(n-1) for (int i = 0; i < n; i++) { for (int j = 0; j < base; j++) { count++; } base *= 2; } // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1 return count; } /* 指数阶(递归实现) */ int expRecur(int n) { if (n == 1) return 1; return expRecur(n - 1) + expRecur(n - 1) + 1; }
指数阶增长非常迅速,在穷举(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。
对数阶 𝑂(log 𝑛) #
与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为n,由于每轮缩减到一半,因此循环次数是log2n
与指数阶类似,对数阶也常出现于递归函数中。以下代码形成了一棵高度为log2n的递归树:
/* 对数阶(递归实现) */ int logRecur(int n) { if (n <= 1) return 0; return logRecur(n / 2) + 1; }
对数阶常出现于基于分治策略的算法中,体现了“一分为多”和“化繁为简”的算法思想。它增长缓慢,是仅次于常数阶的理想的时间复杂度。
线性对数阶 𝑂(𝑛 log 𝑛) #
线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别是𝑂(log 𝑛) 和 𝑂(𝑛)
如图展示了线性对数阶的生成方式。二叉树的每一层的操作总数都为n,树共有 log2𝑛 + 1 层,因此时间复杂度为𝑂(𝑛 log 𝑛) 。
主流排序算法的时间复杂度通常为𝑂(𝑛 log 𝑛),例如快速排序、归并排序、堆排序等。
阶乘阶𝑂(𝑛!)#
阶乘对应数学上的“全排列”问题。给定n个互不重复的元素,求其所有可能的排列方案,方案数量为:
𝑛! = 𝑛 × (𝑛 − 1) × (𝑛 − 2) × ⋯ × 2 × 1
阶乘通常使用递归实现:
/* 阶乘阶(递归实现) */ int factorialRecur(int n) { if (n == 0) return 1; int count = 0; // 从 1 个分裂出 n 个 for (int i = 0; i < n; i++) { count += factorialRecur(n - 1); } return count; }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 记一次.NET内存居高不下排查解决与启示