在算法设计中,追求以下两个层面的目标:
- 找到问题的解法
- 找到最优/尽可能高效的解法
所谓的理论推算算法的效率,也就是复杂度分析,它是通过分析算法运行所需的时间和空间资源与输入数据大小之间的关系。它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势。
算法的时间复杂度
根据输入的数据大小(n),常见的时间复杂度有: 他们的数学曲线表达如下: 对于时间复杂度,实际上采用的是“最差时间复杂度”对应函数渐近上界,因此使用 𝑂 记号表示。 如果采用“最佳时间复杂度”,则是对应函数渐近下界,用 Ω 记号表示。
1. 常数阶𝑂(1)
操作数量与输入数据大小𝑛 无关,即不随着𝑛 的变化而变化。在以下函数中,尽管操作数量size 可能很大,但由于其与输入数据大小𝑛 无关,因此时间复杂度仍为𝑂(1)
int constant(int n) {
int count = 0;
int size = 100000;
for (int i = 0; i < size; i++)//操作数量与输入数据大小𝑛无关
count++;
return count;
}
2. 线性阶𝑂(𝑛)
线性阶的操作数量相对于输入数据大小𝑛 以线性级别增长。例如,遍历数组和遍历链表等操作的时间复杂度均为𝑂(𝑛) ,其中𝑛 为数组或链表的长度。 如下代码所示:
int Linear(int n) {
int count = 0;
for (int i = 0; i < n; i++)//操作数量与输入数据大小𝑛相关
count++;
return count;
}
3. 平方阶𝑂(𝑛2)
平方阶的操作数量相对于输入数据大小𝑛 以平方级别增长。 平方阶通常出现在嵌套循环中,外层循环和内层循环的时间复杂度都为𝑂(𝑛) ,因此总体的时间复杂度为𝑂(𝑛2) :
int quadratic(int n) {
int count = 0;
// 循环次数与数据大小n 成平方关系
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
count++;
}
}
return count;
}
以冒泡排序为例,外层循环执行𝑛 − 1 次,内层循环执行𝑛 − 1、𝑛 − 2、…、2、1 次,平均为𝑛/2 次,因 此时间复杂度为𝑂((𝑛 − 1)𝑛/2) = 𝑂(𝑛2) :
/* 平方阶(冒泡排序) */
int bubbleSort(vector<int> &nums) {
int count = 0; // 计数器
// 外循环:未排序区间为[0, i]
for (int i = nums.size() - 1; i > 0; i--) {
// 内循环:将未排序区间[0, i] 中的最大元素交换至该区间的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换nums[j] 与nums[j + 1]
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
count += 3; // 元素交换包含3 个单元操作
}
}
}
return count;//返回的为操作数
}
此处的的𝑂((𝑛 − 1)𝑛/2) =𝑂(𝑛2/2 − 𝑛/2)。但是为什么时间复杂度是 𝑂(𝑛2)而不是前者呢?
注意:时间复杂度由最高阶的项来决定。这是因为在𝑛趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。
如下图所示为不同操作数量对应的时间复杂度。
4. 指数阶𝑂(2𝑛)
典型例子就是生物学的“细胞分裂”
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++) {//base的size受到输入n的影响为2^n
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;
}
对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。
5. 对数阶𝑂(log𝑛)
与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为𝑛 ,由于每轮缩减到一半,因此循环次数是log2 𝑛 ,即2𝑛 的反函数。 如下代码所示:
int logarithmic(int n) {
int count = 0;
while (n > 1) {
n = n / 2;//每轮缩减一半
count++;
}
return count;
}
对数阶常出现于基于分治策略的算法中,体现了“一分为多”和“化繁为简”的算法思想。
与指数阶类似,对数阶也常出现于递归函数中:
int logRecur(int n) {
if (n <= 1)
return 0;
return logRecur(n / 2) + 1;//每轮递归缩减一半
}
6. 线性对数阶𝑂(𝑛log𝑛)
主流的排序算法如快速排序、归并排序、堆排序等的时间复杂度通常都是𝑂(𝑛log𝑛) 。 线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为𝑂(log 𝑛) 和𝑂(𝑛) 。相关代码如下:
int linearLogRecur(int n) {
if (n <= 1)
return 1;
int count = linearLogRecur(n / 2) + linearLogRecur(n / 2);//每轮递归缩减一半,对数𝑂(log 𝑛)
for (int i = 0; i < n; i++) {
count++; //而每次递归后,内嵌循环的时间复杂度为𝑂(𝑛)
}
return count;
}
7. 阶乘阶𝑂(𝑛!)
阶乘阶对应数学上的“全排列”问题。给定 𝑛 个互不重复的元素,求其所有可能的排列方案,方案数量为:
𝑛!=𝑛×(n-1)×(n-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;
}
算法的空间复杂度
空间复杂度(space complexity)用于衡量算法占用内存空间随着数据量变大时的增长趋势。
这个概念与时间复杂度非常类似,只需将“运行时间”替换为“占用内存空间”。
一般情况下,空间复杂度的统计范围是“暂存空间”加上“输出空间”。暂存空间可以进一步划分为三个部分:
- 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
- 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。
- 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。
常见的空间复杂度类型如下图所示:
1. 常数阶𝑂(1)
常数阶常见于数量与输入数据大小 𝑛 无关的常量、变量、对象。
注意:在循环中初始化变量或调用函数而占用的内存,在进入下一循环后就会被释放,因此不会累积占用空间,空间复杂度仍为 𝑂(1) :
int func() {
// 执行某些操作
return 0;
}
/* 常数阶 */
void constant(int n) {
// 常量、变量、对象占用 O(1) 空间
const int a = 0;
int b = 0;
vector<int> nums(10000);//虽然很大,但仍然是常数阶,与输入无关
ListNode node(0);
// 循环中的变量占用 O(1) 空间(每次调用进入下一次循环会释放)
for (int i = 0; i < n; i++) {
int c = 0;
}
// 循环中的函数占用 O(1) 空间(每次调用进入下一次循环会释放)
for (int i = 0; i < n; i++) {
func();
}
}
2. 线性阶𝑂(𝑛)
线性阶常见于元素数量与 𝑛 成正比的数组、链表、栈、队列等以及递归:
/* 线性阶 */
void linear(int n) {
// 长度为 n 的数组占用 O(n) 空间
vector<int> nums(n);
// 长度为 n 的列表占用 O(n) 空间
vector<ListNode> nodes;
for (int i = 0; i < n; i++) {
nodes.push_back(ListNode(i));
}
// 长度为 n 的哈希表占用 O(n) 空间
unordered_map<int, string> map;
for (int i = 0; i < n; i++) {
map[i] = to_string(i);
}
}
/* 递归的空间复杂度为 O(n) */
//对于递归函数,递归结束前是一直有占用空间的,这属于栈帧空间
//函数的递归深度为 𝑛 ,即同时存在 𝑛 个未返回的 recur() 函数,
void recur(int n) {
if (n == 1) return;
return recur(n - 1);
}
3. 平方阶𝑂(𝑛2)
平方阶常见于矩阵和图,元素数量与 𝑛 成平方关系:
/* 平方阶 */
void quadratic(int n) {
// 二维列表占用 O(n^2) 空间
vector<vector<int>> numMatrix;//矩阵式存储
for (int i = 0; i < n; i++) {
vector<int> tmp;
for (int j = 0; j < n; j++) {
tmp.push_back(0);
}
numMatrix.push_back(tmp);
}
}
下面代码递归深度为 𝑛 ,在每个递归函数中都初始化了一个数组,长度分别为 𝑛、𝑛 − 1、…、2、1 ,平均长度为 𝑛/2 ,因此总体占用𝑂(𝑛2)空间:
int quadraticRecur(int n) {//递归空间复杂度也为O(n)
if (n <= 0)
return 0;
vector<int> nums(n); //递归空间复杂度内的空间复杂度为O(n)
cout << " 递归 n = " << n << " 中的 nums 长度 = " << nums.size() << endl;
return quadraticRecur(n - 1);
}
4. 指数阶𝑂(2𝑛)
指数阶常见于二叉树。层数为 𝑛 的“满二叉树”的节点数量为 2𝑛 − 1 ,占用 𝑂(2𝑛) 空间
/* 指数阶(建立满二叉树) */
TreeNode *buildTree(int n) {
if (n == 0)
return nullptr;
TreeNode *root = new TreeNode(0);
root->left = buildTree(n - 1);
root->right = buildTree(n - 1);
return root;
}
5. 对数阶𝑂(log𝑛)
对数阶常见于分治算法。例如归并排序,输入长度为 𝑛 的数组,每轮递归将数组从中点处划分为两半,形成高度为 log 𝑛 的递归树,使用 𝑂(log 𝑛) 栈帧空间。
参考资料
- 算法时空复杂度分析实用指南
- 书籍《Hello 算法 C++语言版》