目录
一. 数据结构和算法
1.1 数据结构
数据结构是计算机存储 组织数据的方式 指相互之间存在一种或多种特定关系的数据元素的集合 没有一种单一的数据结构对所有用途都有用 使用我们要学习各式各样的数据结构 如 线性表 树 图 哈希表等
按逻辑结构可分为:
线性结构:数据元素之间为一对一关系
- 顺序存储:数组(如
int arr[10]
)、字符串(char str[]
)- 链式存储:单链表、双链表、循环链表、栈(先进后出)、队列(先进先出)
非线性结构:数据元素之间为一对多或多对多关系
- 树结构:二叉树(如二叉搜索树、平衡二叉树)、堆(大根堆 / 小根堆)、红黑树、B 树
- 图结构:有向图、无向图、加权图(用于路径规划等场景)
- 集合与映射:哈希表(通过哈希函数快速定位数据)、有序集合(如基于红黑树的 TreeSet)
1.2 算法
算法: 就是定义良好的计算过程 它取一个或一组的值为输入 并产生一个或一组值作为输出 简单来说 算法就是一系列的计算步骤 用来将输入数据转化成输出结果
核心分类
基础算法:
- 排序算法:冒泡排序、插入排序、快速排序、归并排序、堆排序等。
- 查找算法:顺序查找、二分查找、哈希查找、树查找(如二叉搜索树查找)。
- 字符串算法:模式匹配(如 KMP 算法)、字符串拼接与分割。
高级算法思想:
- 贪心算法:每次选择局部最优解(如哈夫曼编码、活动安排问题)。
- 动态规划:通过存储中间结果避免重复计算(如斐波那契数列优化、最长公共子序列)。
- 分治算法:将问题分解为子问题,递归求解后合并(如快速排序、归并排序)。
- 回溯算法:尝试所有可能路径,不满足条件时回退(如八皇后问题、迷宫求解)。
- 图算法:深度优先搜索(DFS)、广度优先搜索(BFS)、最短路径算法(Dijkstra、Floyd)。
1.3 数据结构与算法的关系
- 数据结构是算法的载体:算法依赖数据结构提供的存储和操作接口(如用链表实现队列的入队 / 出队操作)。
- 算法是数据结构的灵魂:同一数据结构可通过不同算法实现不同功能(如数组可用于冒泡排序或二分查找)。
二. 算法效率
2.1 轮转数组练习
前面我们提到过一个题可以用不同的算法来解决,其中肯定有较优和较差的解法,那我们该如何区衡量一个算法的变化呢?
我们来通过一个题直观的了解一下--旋转数组:189. 轮转数组 - 力扣(LeetCode)
给定一个整数数组nums 将数组中的元素向右轮转k个位置 其中k是非负数
示例:
输入: nums=[1,2,3,4,5,6,7] k=3
输出: [5,6,7,1,2,3,4]
解法一
思路:循环k次将数组所有元素向后移动一位
void rotate(int* nums, int numsSize, int k) { while (k--) { // 向右轮转一次 // 保存数组最后一个位置的数据 int temp = nums[numsSize - 1]; for (int i = numsSize - 1; i > 0; i--) // 这里其实比原来少一个数据 { nums[i] =nums[i-1]; // 把最后一个元素存放好后,剩余的元素都向后移动一次。 } nums[0] = temp; // 最后再把保存的数据存放在nums[0]中 } }
缺点: 时间效率低 无法通过
解法二
思路:使用额外的数组
我们可以使用额外的数组来将每个元素放至正确的位置。用 n 表示数组的长度,我们遍历原数组,将原数组下标为 i 的元素放至新数组下标为 (i+k)modn 的位置,最后将新数组拷贝至原数组即可。
void rotate(int* nums, int numsSize, int k) { int newArr[numsSize]; for (int i = 0; i < numsSize; ++i) { newArr[(i + k) % numsSize] = nums[i]; } for (int i = 0; i < numsSize; ++i) { nums[i] = newArr[i]; } }
三. 复杂度的概念以及推理
概念:
算法在编写成可执行程序后 运行是需要耗费的时间资源和空间(内存)资源 因此衡量一个算法的好坏 一般是从时间和空间两个维度来衡量即时间复杂度和空间复杂度
算法的时间复杂度是一个函数式T(N)
那么算法的时间复杂度是⼀个函数式T(N)到底是什么呢?这个T(N)函数式计算了程序的执行次数。通过对c语言编译链接的学习,我们知道算法程序被编译后生成二进制指令,程序运行,就是cpu执行这些编译好的指令。那么我们通过程序代码或者理论思想计算出程序的执行次数的函数式T(N),假设每句指令执行时间基本⼀样(实际中有差别,但是微乎其微),那么执行次数和运行时间就是等比正相关,这样也脱离了具体的编译运行环境。执行次数就可以代表程序时间效率的优劣。比如解决⼀个问题的算法a程序T(N) = N,算法b程序T(N) = N^2,那么算法a的效率⼀定优于算法b。
我们为什么不直接计算程序的运行时间呢?
因为程序运行时间和编译环境和运行机器的配置都有关系,比如同一个算法程序,用一个老编译器进行编译和新编译器编译,在同样机器下运行时间不同。
同一个算法程序,用⼀个老低配置机器和新高配置机器,运行时间也不同。
并且时间只能程序写好后测试,不能写程序前通过理论思想计算评估。
举例:
//计算FUNC1的执行次数
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;
}
}
Func1执行的基本操作次数 :
T(N)=N^2+2*N+10
- N=10 T(N)=130
- N=100 T(N)=10210
- N=1000 T(N)=1002010
通过对N取值分析,对结果影响最大的一项是N^2
在实际计算时间复杂度时,计算的也不是程序的精确执行次数,因为精确执行次数计算起来很麻烦而且意义不大,因为我们计算时间复杂度只是想比较算法程序的增长量级,即N不断变大时T(N)的差别,上面我们已经发现了当N不断变大时常数和低阶项对结果的影响很小,所以我们只需要计算程序能代表增长量级的大概执行次数,复杂度的表示通常使用大O的渐进表示法。
四. 时间复杂度(Time Complexity)
描述算法执行所需的时间随随输入规模增长的关系。
- 关注重点:算法中基本操作的执行次数(如赋值、比较、运算等),而非实际运行时间(受硬件、编程语言影响)。
- 表示方法:使用大 O 符号(O-notation),忽略常数项、低阶项和系数,只保留最高阶项(反映增长趋势)。
4.1 大O渐进表示法
大 O 渐进表示法是算法复杂度分析的核心工具,用于描述算法在输入规模(通常用n
表示)增长时,其时间消耗(时间复杂度)或空间占用(空间复杂度)的增长趋势,而非具体的执行次数或内存大小。它的核心价值是帮助开发者快速判断算法的效率,尤其是在处理大规模数据时的性能上限。
推导大O阶规则:
1. 忽略常数项
常数项(与
n
无关的固定开销,如初始化变量、单次判断)不影响 “增长趋势”,直接舍弃。
例:
f(n) = 2n + 3
→ 忽略常数3
,简化为2n
;例:
f(n) = 5
(无论n
多大,执行次数固定)→ 简化为O(1)
(读作 “大 O 一”,表示常数复杂度)。2. 忽略低阶项
当
n
足够大时,低次项(如n
、logn
)的增长速度远慢于高次项(如n²
、n³
),低阶项可直接舍弃。
例:
f(n) = n² + 5n + 10
→ 低阶项5n
和常数10
可忽略,简化为O(n²)
;例:
f(n) = 3n³ + 2n² + logn
→ 低阶项2n²
和logn
可忽略,简化为O(n³)
。3. 忽略系数
系数(如
2n
中的2
、5n²
中的5
)仅影响 “增长幅度”,不改变 “增长趋势”,可直接舍弃。
例:
f(n) = 100n
→ 忽略系数100
,简化为O(n)
;例:
f(n) = 7n²
→ 忽略系数7
,简化为O(n²)
。
4.2 时间复杂度计算示例
示例一:
// 计算Func2的时间复杂度?--O(N)
void Func2(int N)
{
int count = 0;
for (int k = 0; k < 2 * N; ++k)//循环2*N
{
++count;
}
int M = 10;
while (M--)//循环M
{
++count;
}
printf("%d\n", count);
}
基本操作次数是 2*N+M且M=10 忽略常数项和系数可以得出时间复杂度=O(N)
示例二:
// 计算Func3的时间复杂度?--O(M+N)
void Func3(int N, int M)
{
int count = 0;
for (int k = 0; k < M; ++k)//循环M次
{
++count;
}
for (int k = 0; k < N; ++k)//循环N次
{
++count;
}
printf("%d\n", count);
}
基本执行次数M+N 时间复杂度: O(M+N)
根据M与N的大小关系可以得出
当M>N 时间复杂度: O(M)
当M==N 时间复杂度: O(M)或者O(N)
当M<N 时间复杂度: O(N)
示例三:
// 计算Func4的时间复杂度?--O(1)
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++k)//循环100次
{
++count;
}
printf("%d\n", count);
}
基本执行次数: 100 时间复杂度:O(1)
注:时间复杂度是时间随着变量改变的函数式 当基本执行次数是常数时 与该常数的大小无关
示例四:
// 计算strchr的时间复杂度?--O(N)
const char* strchr(const char
* str, int character)
{
const char* p_begin = s;
while (*p_begin != character)
{
if (*p_begin == '\0')
return NULL;
p_begin++;
}
return p_begin;
}
该题目的时间复杂度与查找的字符出现的位置有关
当在第一个时 时间复杂度: O(1)--------最好情况
当在中间时 时间复杂度: O(N/2)-----中间情况 根据规则可以简化为O(N)
当在最后时 时间复杂度: O(N)-------最坏情况
因此
💡总结:
通过上面我们会发现,有些算法的时间复杂度存在最好、平均和最坏情况。
最坏情况:任意输⼊规模的最大运行次数(上界)
平均情况:任意输⼊规模的期望运行次数
最好情况:任意输⼊规模的最小运行次数(下界)
大O的渐进表示法在实际中⼀般情况关注的是算法的上界,也就是最坏运行情况。
示例五:
// 计算BubbleSort的时间复杂度?--O(N^2)
void BubbleSort(int* a, int n)
{
assert(a);
for (int end = n; end > 0; --end)//外层循环n次
{
int exchange = 0;
for (int i = 1; i < end; ++i)//内层循环受end影响 需要计算
{
if (a[i - 1] > a[i])//第一次n-1 第二次n-2 直到0 累加得到(n-1)/2
{
Swap(&a[i - 1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
基本执行次数:n*(n-1)/2 即时间复杂度: O(N^2)
示例六:
//计算func5的时间复杂度--O(logn)
void func5(int n)
{
int cnt = 1;
while (cnt < n)
{
cnt *= 2;
}
}
通过带入n=2 4 8 程序分别执行了 1 2 3次 不难发现n与执行次数x的关系为
n=x^2 即x=log2 (n)
根据换底公式得到时间复杂度:O(logN)
示例七:
// 计算阶乘递归Fac的时间复杂度?--O(N)
long long Fac(size_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
时间复杂度分析:
递归调用次数:
当输入为N
时,函数会依次调用Fac(N)
、Fac(N-1)
、...
、Fac(0)
,共产生N+1
次调用(从N
到0
,包含两端)。每次调用的操作次数:
除基础情况(N=0
)外,每次调用仅执行:
1 次条件判断(
if (0 == N)
)1 次乘法运算(
Fac(N-1) * N
)
这些都是常数时间操作(O(1)
)。总时间复杂度:
总操作次数 = 调用次数 × 每次调用的操作数 =(N+1) × O(1)
,根据大 O 的计算法则,忽略常数项和系数后,最终时间复杂度为O(N)
。
五. 空间复杂度(Space Complexity)
描述算法执行所需的额外存储空间随输入规模增长的关系。
- 关注重点:算法在运行过程中临时占用的内存(如变量、数组、递归栈等),不包含输入数据本身的存储空间。
- 表示方法:同样使用大 O 符号。
5.1 空间复杂度计算示例
示例一
// 计算BubbleSort的空间复杂度?--O(1)
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;
}
}
注: 与申请的类型关联不大 因此可以看出有end i exchance三个新开辟的空间
因此空间复杂度为O(1)
示例二:
// 计算阶乘递归Fac的空间复杂度?--O(N)
long long Fac(size_t N)
{
if (N == 0)
return 1;
return Fac(N - 1) * N;
}
空间复杂度分析:
递归调用栈的深度:
当输入为N
时,函数会依次递归调用Fac(N)
、Fac(N-1)
、...
、Fac(0)
。
这些调用会在内存的栈区中依次创建函数栈帧(存储参数、返回地址等信息),直到触发终止条件N=0
。
因此,栈的最大深度为N+1
(从N
到0
共N+1
层调用)。每层栈帧的空间开销:
每层递归调用中,函数仅需要存储参数N
和返回地址等少量数据,这些都是常数级空间开销(O(1)
)。总空间复杂度:
总空间开销 = 递归深度 × 每层栈帧空间 =(N+1) × O(1)
。
根据大 O 的计算法则,忽略常数项后,最终空间复杂度为O(N)
。
六. 常见复杂度
6.1 按增长速度排序(从优到劣)
复杂度的核心差异是输入规模n
增大时,复杂度函数的增长快慢。以下是算法设计中最常见的复杂度,按 “效率从高到低”(即增长速度从慢到快)排序:
O(1)
< O(logn)
< O(n)
< O(nlogn)
< O(n²)
< O(n³)
< O(2ⁿ)
< O(n!)
6.2 各复杂度核心特征对比
通过 “数学含义”“增长趋势”“典型场景”“适用数据规模” 四个维度,可清晰区分不同复杂度的本质差异:
复杂度符号 | 数学含义(简化) | 增长趋势(n 增大时) | 典型算法 / 场景 | 适用n 规模(参考) |
---|---|---|---|---|
O(1) | 常数(与n 无关) | 无增长,固定开销 | 数组随机访问、简单算术运算、哈希表查找 | 任意(n=10⁹ 也无压力) |
O(logn) | 对数(如log₂n ) | 极缓慢增长,n 翻倍时仅 + 1 | 二分查找、平衡二叉树(AVL/RB 树)操作 | 极大(n=10⁹ 仅需 30 步) |
O(n) | 线性(与n 成正比) | 线性增长,n 翻倍时翻倍 | 数组 / 链表遍历、单源最短路径(BFS) | 大(n=10⁶ 无压力) |
O(nlogn) | 线性 × 对数 | 温和增长,n 翻倍时≈2 倍 | 快速排序、归并排序、堆排序 | 较大(n=10⁶ 仅需百万级步) |
O(n²) | 平方(与n² 成正比) | 快速增长,n 翻倍时 ×4 | 冒泡排序、插入排序、双层嵌套循环 | 小(n≤10⁴ ,否则超时) |
O(n³) | 立方(与n³ 成正比) | 极快增长,n 翻倍时 ×8 | 三层嵌套循环、矩阵乘法(朴素实现) | 极小(n≤100 ) |
O(2ⁿ) | 指数(2 的n 次方) | 爆炸式增长,n +1 时 ×2 | 未优化的递归斐波那契、子集枚举 | 极极小(n≤20 ) |
O(n!) | 阶乘(n×(n-1)×…×1 ) | 超爆炸式增长 | 全排列暴力枚举、旅行商问题(暴力) | 极 |
6.3 直观数值对比:n
增大时的复杂度规模
通过具体n
值对应的 “复杂度规模”(可理解为 “大致执行次数”),能更深刻感受增长差异(假设每次操作耗时 1 纳秒,辅助理解时间成本):
输入规模n | O(1) | O(logn) (2 为底) | O(n) | O(nlogn) | O(n²) | O(n³) | O(2ⁿ) | O(n!) |
---|---|---|---|---|---|---|---|---|
10 | 1 | 4 | 10 | 40 | 100 | 1000 | 1024 | 3.6×10⁶(362 万) |
20 | 1 | 5 | 20 | 100 | 400 | 8000 | 1×10⁶(104 万) | 2.4×10¹⁸(超万亿) |
30 | 1 | 5 | 30 | 150 | 900 | 2.7×10⁴ | 1×10⁹(10 亿) | 2.7×10³²(无法计算) |
100 | 1 | 7 | 100 | 700 | 1×10⁴ | 1×10⁶ | 1×10³⁰(天文数字) | 远超 |
6.4 结论
高效复杂度(
O(1)
/O(logn)
/O(n)
/O(nlogn)
):
即使n=10⁶
,O(nlogn)
的规模仅约10⁶×20=2×10⁷
(2000 万次操作),耗时仅 0.02 秒(1 纳秒 / 次),可轻松应对大规模数据。低效复杂度(
O(n²)
/O(n³)
):
n=10⁴
时,O(n²)
的规模为1×10⁸
(1 亿次操作),耗时 0.1 秒;n=10⁵
时,规模达1×10¹⁰
(100 亿次操作),耗时 10 秒,已超出用户可接受范围(通常算法耗时需≤1 秒)。极劣复杂度(
O(2ⁿ)
/O(n!)
):
n=30
时,O(2ⁿ)
的规模达1×10⁹
(10 亿次操作),耗时 1 秒;n=40
时,规模达1×10¹²
(1 万亿次操作),耗时 1157 天(约 3 年),完全无法实用。