目录
一、前言
1. 什么是数据结构
2. 什么是算法
二、时 / 空间复杂度
1. 算法效率
2. 时间复杂度
2.1 时间复杂度的概念
2.2 大 O 的渐进表示法
2.3 常见的计算时间复杂度的例子
2.3.1 实例 1
2.3.2 实例 2
2.3.3 实例 3
2.3.4 实例 4
2.3.5 实例 5 :冒泡排序
2.3.6 实例 6 :二分查找
2.3.7 实例 7 :阶乘递归
2.3.8 实例 8 :斐波那契递归
3. 空间复杂度
3.1 空间复杂度的概念
3.2 显式申请的额外空间
3.3 常见的计算空间复杂度的例子
3.3.1 实例 1 :冒泡排序
3.3.2 实例 2 :返回斐波那契数列的前n项
3.3.3 实例 3 :阶乘递归
4.
4.1 常见复杂度总结
4.2 判断复杂度的方法
4.2.1 判断时间复杂度的方法
4.2.2 判断空间复杂度的方法
5. 练习
5.1 消失的数字
5.2 轮转数组
💬 :如果你在阅读过程中有任何疑问或想要进一步探讨的内容,欢迎在评论区畅所欲言!我们一起学习、共同成长~!
👍 :如果你觉得这篇文章还不错,不妨顺手点个赞、加入收藏,并分享给更多的朋友噢~!
一、前言
1. 什么是数据结构
数据结构(Data Structure)是计算机存储、组织数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合。
2. 什么是算法
算法(Algorithm)是明确的计算流程,它接收一个或一组输入值,经过一系列计算步骤后,输出一个或一组结果,其作用就是把输入数据转变为输出结果。
二、时 / 空间复杂度
1. 算法效率
算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源 。
因此衡量一个算法的好坏,一般从时间和空间两个维度来衡量,即时间复杂度和空间复杂度。
- 时间复杂度主要衡量一个算法的运行快慢。
- 空间复杂度主要衡量一个算法运行所需要的额外空间。
计算机发展早期,由于存储容量很小,所以很在乎空间复杂度。但随着计算机行业迅速发展,如今计算机的存储容量已达到很高程度,所以我们现在不需特别关注一个算法的空间复杂度。
2. 时间复杂度
2.1 时间复杂度的概念
时间复杂度是定量描述算法运行时间随问题规模 N 增长的变化趋势的函数。
理论上,算法执行耗时无法直接算出,需运行程序才知晓,但没必要对每个算法都上机测试,于是有了时间复杂度分析。
算法耗时与语句执行次数成正比,基本操作的执行次数就是算法的时间复杂度。即,算出某条基本语句和问题规模 N 的数学表达式,就得到了该算法的时间复杂度。
// 计算一下Func1中++count语句共执行了多少次?
void Func1(int N)
{int count = 0;for (int i = 0; i < N ; ++ i){for (int j = 0; j < N ; ++ j){++count;}}for (int k = 0; k < 2 * N ; ++ k){++count;}int M = 10;while (M--){++count;}printf("%d\n", count);
}
这里 Func1 执行的基本操作次数 :
- 当 N = 10 时,F (N) = 130
- 当 N = 100 时,F (N) = 10210
- 当 N = 1000 时,F (N) = 1002010
实际中计算时间复杂度时,不一定要计算精确的执行次数,只需获知大概执行次数,此时使用大 O 的渐进表示法。
2.2 大 O 的渐进表示法
大 O 符号(Big O notation):用于描述函数渐进行为的数学符号。
推导大 O 阶:
- 用常数 1 取代运行时间中的所有加法常数。
- 在修改后的运行次数函数中,只保留最高阶项
。
- 若最高阶项存在且不是 1,则去掉该项系数 y 。得到的结果就是大 O 阶
。
对于前面的 ,
- 步骤 1:用常数 1 取代加法常数 10,得
。
- 步骤 2:只保留最高阶项
。
- 步骤 3:最高阶项
的系数为 1,无需去除,得到大 O 阶
。
因此使用大 O 的渐进表示法后,前面 Func1 的时间复杂度为 。
另外,有些算法的时间复杂度存在最好、平均和最坏运行情况:
- 最坏情况:任意输入规模的最大运行次数 (上界)
- 平均情况:任意输入规模的期望运行次数
- 最好情况:任意输入规模的最小运行次数 (下界)
例如:在一个长度为 N 的数组中搜索一个数据 x
- 最好情况:1 次找到
- 最坏情况:N 次找到
- 平均情况:N/2 次找到
实际中一般关注算法的最坏运行情况,所以数组中搜索数据的时间复杂度为 O (N) 。
2.3 常见的计算时间复杂度的例子
2.3.1 实例 1
void Func2(int N)
{int count = 0;for (int k = 0; k < 2 * N ; ++ k){++count;}int M = 10;while (M--){++count;}printf("%d\n", count);
}
基本操作执行次数 ,所以 Func2 时间复杂度为 O(N) 。
2.3.2 实例 2
void Func3(int N, int M)
{int count = 0;for (int k = 0; k < M; ++ k){++count;}for (int k = 0; k < N ; ++ k){++count;}printf("%d\n", count);
}
基本操作执行次数 ,有两个未知数 M 和 N,所以 Func3 时间复杂度为 O(N + M) 。
2.3.3 实例 3
void Func4(int N)
{int count = 0;for (int k = 0; k < 100; ++ k){++count;}printf("%d\n", count);
}
基本操作执行次数 100,所以 Func4 时间复杂度为 O(1) 。
2.3.4 实例 4
// 计算strchr的时间复杂度?
const char *strchr ( const char *str, int character );
基本操作执行最好 1 次,最坏 N 次,时间复杂度一般看最坏,所以时间复杂度为 O(N) 。
2.3.5 实例 5 :冒泡排序
// 冒泡排序
void BubbleSort(int* a, int n)
{assert(a);for (size_t end = n; end > 0; --end){int exchange = 0;for (size_t i = 1; i < end; ++i){if (a[i-1] > a[i]){Swap(&a[i-1], &a[i]);exchange = 1;}}if (exchange == 0)break;}
}
基本操作执行最好 次(数组已有序,只需进行一轮遍历),最坏执行了
次(
),时间复杂度一般看最坏,所以时间复杂度为
。
2.3.6 实例 6 :二分查找
// 二分查找
int BinarySearch(int* a, int n, int x)
{assert(a);int begin = 0;int end = n-1;// [begin, end]:begin和end是左闭右闭区间,因此有=号while (begin <= end){int mid = begin + ((end-begin)>>1);if (a[mid] < x)begin = mid+1;else if (a[mid] > x)end = mid-1;elsereturn mid;}return -1;
}
基本操作执行最好 1 次(第一次比较就找到目标值),最坏 次(第 k 次比较后,搜索区间长度变为
,
得
。又因为大 O 表示法中,通常忽略对数的底数 ),所以时间复杂度为
。有些地方写成
。
2.3.7 实例 7 :阶乘递归
// 阶乘递归
long long Fac(size_t N)
{if(0 == N)return 1;return Fac(N-1)*N;
}
基本操作执行了 N 次,所以时间复杂度为 O(N) 。
2.3.8 实例 8 :斐波那契递归
// 斐波那契递归
long long Fib(size_t N)
{if(N < 3)return 1;return Fib(N-1) + Fib(N-2);
}
以 Fib(5) 为例,构建它的递归调用树:
- 当 N < 3 时,F(N) = 1 ;
- 当 N >= 3 时,F(N) = F(N - 1) + F(N - 2) +1 (
+1
是当前Fib(N)
的这次调用)。此递推关系的解的增长趋势与同阶。
又因为大 O 表示法忽略常数和低阶项,所以该算法的时间复杂度为 。
3. 空间复杂度
3.1 空间复杂度的概念
空间复杂度是用于衡量算法运行时临时占用存储空间大小的数学表达式。
其计算规则与时间复杂度类似,采用大O渐进表示法。
需注意,函数运行所需的栈空间(如参数、局部变量、寄存器信息等占用空间)在编译时已确定,故空间复杂度主要看函数运行时显式申请的额外空间。
3.2 显式申请的额外空间
-
动态内存分配函数申请的空间。
-
部分库函数内部会申请额外空间,C 语言标准库中常见的如 realloc 、strdup 等。
-
创建某些数据结构时需专门写代码向系统申请内存,那么申请到的这部分内存空间就属于额外空间。如,创建链表节点时得用
malloc
或new
这些操作去申请内存。
3.3 常见的计算空间复杂度的例子
3.3.1 实例 1 :冒泡排序
// 计算BubbleSort的空间复杂度
void BubbleSort(int* a, int n)
{assert(a);for (size_t end = n; end > 0; --end){int exchange = 0;for (size_t i = 1; i < end; ++i){if (a[i-1] > a[i]){Swap(&a[i-1], &a[i]);exchange = 1;}}if (exchange == 0)break;}
}
BubbleSort
函数未通过动态内存分配函数、特定库函数或创建复杂数据结构来显式申请额外空间,仅使用了几个固定的局部变量,所以它使用了常数个额外空间,其空间复杂度为 O(1)。
3.3.2 实例 2 :返回斐波那契数列的前n项
// 计算Fibonacci的空间复杂度
// 返回斐波那契数列的前n项
long long* Fibonacci(size_t n)
{if(n==0)return NULL;long long * fibArray = (long long *)malloc((n+1) * sizeof(long long));fibArray[0] = 0;fibArray[1] = 1;for (int i = 2; i <= n ; ++i){fibArray[i] = fibArray[i - 1] + fibArray [i - 2];}return fibArray;
}
动态开辟了 N+1 个空间,空间复杂度为 O(N) 。
3.3.3 实例 3 :阶乘递归
// 计算阶乘递归Fac的空间复杂度
long long Fac(size_t N)
{if(N == 0)return 1;return Fac(N-1)*N;
}
Fac
函数虽然未显式地使用动态内存分配函数来申请额外空间,但递归调用会隐式地占用与递归深度成正比的栈空间,因此在计算空间复杂度时需要考虑这部分额外空间。
这里递归调用了 N+1 次,所以空间复杂度为 O(N) 。
4.
4.1 常见复杂度总结
基本操作执行次数/额外空间量 | 复杂度 | |
---|---|---|
常数 | 常数阶 | |
线性阶 | ||
平方阶 | ||
对数阶 | ||
NlogN阶 | ||
立方阶 | ||
指数阶 |
4.2 判断复杂度的方法
4.2.1 判断时间复杂度的方法
- 确定基本操作:如简单赋值、算术和比较运算等,其时间复杂度为 O(1)。
- 分析循环:
- 单层循环:循环体为基本操作且次数与输入规模
n
相关,复杂度为 O(n)。 - 嵌套循环:多层嵌套时,复杂度是各层循环次数乘积,如双重嵌套为
,三层为
。
- 次数不固定循环:像二分查找,复杂度为 O(logn)。
- 单层循环:循环体为基本操作且次数与输入规模
- 分析递归函数:
- 确定深度:如阶乘递归调用
N
次,复杂度为 O(N)。 - 考虑操作数:每次递归操作数固定,复杂度是深度与单次操作数乘积;若操作数与规模有关,如斐波那契递归,复杂度为
。
- 确定深度:如阶乘递归调用
- 处理条件语句:
if-else
本身 O(1),若分支操作复杂度不同,取最坏情况,如一支 O(1) 一支 O(n),整体为 O(n)。 - 确定整体复杂度:取复杂度最高部分作为整体复杂度。
4.2.2 判断空间复杂度的方法
- 找基本变量:如简单数据类型(
int
、float
、char
等)变量占用固定空间,复杂度为 O(1)。 - 数据结构:
- 一维数组:数组的空间复杂度取决于数组的大小。若一维数组大小为
n
,复杂度 O(n)。 - 二维数组:
int arr[m][n]
,复杂度 O(mn)。 - 其他:链表、树、图等,依节点数和单节点空间定,如链表节点数为
n
时复杂度 O(n)。
- 一维数组:数组的空间复杂度取决于数组的大小。若一维数组大小为
- 递归函数:
- 调用栈:递归深度为
n
且每次递归占固定空间,复杂度 O(n)。 - 数据结构:若递归函数中创建了数据结构,需结合结构空间和递归深度考量。
- 调用栈:递归深度为
- 函数调用:
- 参数:函数参数若为大型数据结构或数组等,需考虑其占用空间对整体空间复杂度的影响。若传递的是指针,则通常只占固定空间,空间复杂度为 O(1)。
- 返回值:若函数返回大型数据结构,返回值占用的空间也应计入空间复杂度。
- 整体复杂度:取代码各部分中复杂度最高的作为整体复杂度。
5. 练习
5.1 消失的数字
一个数组包含从0到n的所有整数,但其中缺了一个。请编写代码在O(n)时间内找出缺失的整数。
分析:“O(n)时间”意味着不能使用嵌套循环等。
示例:求和法
#include <stdio.h>int missingNumber(int* nums, int numsSize)
{int i = 0;int sum1 = 0;int sum2 = 0;for (i = 0; i < numsSize; i++){sum1 += nums[i];}sum2 = (numsSize * (numsSize + 1)) / 2;return sum2 - sum1;
}int main()
{int nums[] = { 9,6,4,2,3,5,7,0,1 };int size = sizeof(nums) / sizeof(nums[0]);int missnum = missingNumber(nums, size);printf("missnum=%d\n", missnum);return 0;
}
5.2 轮转数组
给定一个整数数组,将数组中的元素向右轮转 k
个位置。
示例:环状替换
#include <stdio.h>void swap(int *a, int *b)
{int temp = *a;*a = *b;*b = temp;
}// 向右轮转数组函数
void rotate(int* nums, int numsSize, int k)
{k = k % numsSize;// 处理 k > 数组长度的情况。避免不必要的重复轮转int count = 0;for (int start = 0; count < numsSize; start++) {int current = start;// current 用于标记当前正在处理的元素的索引int prev = nums[start];do {int next = (current + k) % numsSize;// 计算当前元素要移动到的新位置的索引// 取模确保索引在数组范围内int temp = nums[next];nums[next] = prev;prev = temp; // 更新 prev 为新位置的原来值current = next; // 更新 current 为新位置的索引count++;} while (start != current); // 回到起始位置时,说明当前的环状替换完成,退出内层循环}
}int main()
{int nums[] = {1, 2, 3, 4, 5, 6, 7};int numsSize = sizeof(nums) / sizeof(nums[0]);int k = 3;rotate(nums, numsSize, k);for (int i = 0; i < numsSize; i++) {printf("%d ", nums[i]);}printf("\n");return 0;
}
- 索引 :0→3→6→2→5→1→4→0
- 元素 :1→4→7→3→6→2→5→1
- 时间复杂度:O(n)( n 是数组长度)
- 空间复杂度:O(1)