【数据结构入门】数据结构基本概念 + 时间复杂度/空间复杂度保姆级详解
本文是我学习《数据结构》第一节课(数据结构概念和复杂度计算)的完整笔记整理。
面向零基础同学,把课件里每一个概念都拆开讲透,配了 C 语言代码、逐行执行次数推导、以及 408 真题解析。
建议:边看边在纸上画图、自己动手算一遍复杂度,这一章吃透,后面顺序表、链表、栈队列、二叉树、排序都会轻松很多。
目录
- 一、写在前面:数据结构到底学什么
- 二、数据结构的基本概念和术语
- 三、数据结构三要素
- 四、数据类型与抽象数据类型(ADT)
- 五、算法的基本概念
- [六、算法的五个特性 + 四条设计要求](#六、算法的五个特性 + 四条设计要求)
- 七、如何分析算法的时间效率
- [八、时间复杂度(大 O 渐进表示法)](#八、时间复杂度(大 O 渐进表示法))
- 九、时间复杂度经典样例逐个分析
- 十、空间复杂度
- 十一、空间复杂度经典样例分析
- [十二、408 真题实战(含解析)](#十二、408 真题实战(含解析))
- [十三、全章易错点总结 & 背诵清单](#十三、全章易错点总结 & 背诵清单)
一、写在前面:数据结构到底学什么
数据结构课程一般分成两块:
| 阶段 | 主要内容 | 语言 |
|---|---|---|
| 初阶 | 顺序表、链表、栈和队列、二叉树、常见排序算法 | C 语言 |
| 高阶 | 搜索树、AVL 树、红黑树、图、B 树等 | C++ / STL 为主 |
用 C 语言实现初阶部分的好处:在掌握数据结构的同时,把刚学完的 C 语法(指针、结构体、动态内存)彻底练熟,为后面 C++ STL 打基础。
考试 / 校招的重难点不一样(很重要)
- 深度:校招 > 408 考研 > 自主命题 > 学校期末考试
- 广度:408 考研 ≈ 自主命题 ≈ 学校期末 > 校招
也就是说:
- 学校期末、考研喜欢考教材上的概念、定义、名词解释;
- 校招更喜欢考实践中用得多的东西(链表、栈队列、二叉树、排序、哈希等),图论、B 树在校招里重要性相对低。
所以看到本文后面一堆"概念题",别急着跳过------这些正是学校考试和考研最爱考的地方,而校招面试官通常不会问。
二、数据结构的基本概念和术语
这一节是纯概念,但考试必考,请一定分清下面 4 个词。
1. 数据(Data)
数据是对客观事物的符号表示,是所有能输入到计算机中并被程序处理的符号的总称。
它是信息的载体,可以是数字、字符、字符串、图像、声音等。
例子:101、"张三"、95、"男" ------ 这些独立的数字和字符串都是数据。
📌 注意:数据 ≠ 数字。图像、音频、视频在计算机里也是数据。
2. 数据元素(Data Element)
数据元素是数据的基本单位,在计算机程序中通常作为一个整体进行考虑和处理。
- 一个数据元素可以由若干个数据项构成;
- 数据项是数据不可分割的最小单位。
例子:一条学生记录
text
{ 学号:101,姓名:"张三",成绩:95,性别:"男" }
- 这整条记录 是一个数据元素(计算机把它当成一个整体来处理);
- 其中的
学号:101是一个数据项(不能再拆了)。
📌 记忆口诀:元素是整体,数据项是最小单位。
3. 数据对象(Data Object)
数据对象是具有相同性质的数据元素的集合,是数据的子集。
例子:全班学生的花名册就是一个数据对象,它里面包含 学生A数据元素、学生B数据元素、学生C数据元素......
4. 三者的包含关系
text
数据对象(集合) ⊇ 数据元素(个体) ⊇ 数据项(属性)
它们都是数据的表现形式。
5. 用 C 语言代码理解(重点!)
课件里用一段代码把上面三个概念全部串起来了:
c
typedef struct
{
int id; // 学号
char name[20]; // 姓名
char gender[3]; // 性别
double score; // 绩点
} Student;
int main()
{
// 结构体变量 arr[i] 中每个成员就是一个数据项
// 结构体变量 arr[i] 就是一个数据元素
// 结构体数组 arr 就是一个数据对象
Student s[] = { {10001, "张三", "男", 3.0},
{10002, "李四", "女", 3.5},
{10003, "王五", "男", 2.0} };
}
对应关系:
| 概念 | 代码中的对应物 |
|---|---|
| 数据项 | s[i].id、s[i].name 等每一个结构体成员 |
| 数据元素 | s[i] 一个结构体变量(整体) |
| 数据对象 | s[] 整个结构体数组(一批同性质元素的集合) |
💡 初学者建议这样理解:"数据项 → 数据元素 → 数据对象" 就是 "字段 → 一行记录 → 一张表" 的关系,跟数据库里的概念是打通的。
三、数据结构三要素
什么是数据结构?
数据元素之间不是孤立存在的,它们之间存在某种关系,数据元素互相之间的关系称为结构 。
或者说:数据结构是带结构的数据元素的集合。
例:同样是 3 个学生,"谁排在谁后面"就是一种结构;"谁是班长、谁是谁的组员"又是另一种结构。
数据结构包含三个方面(三要素):
- 逻辑结构 ------ 数据元素之间的逻辑关系
- 物理结构(存储结构) ------ 数据及其关系在计算机内存中的表示(映像)
- 数据的运算和实现 ------ 对数据元素可以施加的操作,以及这些操作在相应存储结构上的实现
3.1 逻辑结构
逻辑结构:描述数据元素之间的逻辑关系。
分类方式有两种(考试喜欢考第一种,也考第二种):
第一种分法(四分法): 线性结构、树形结构、图形结构(网状结构)、集合
第二种分法(两分法): 线性结构、非线性结构(树形 + 图形 + 集合)
a. 线性结构
数据元素之间存在一对一的关系:
- 除了第一个元素 ,所有元素都有唯一前驱;
- 除了最后一个元素 ,所有元素都有唯一后继。
典型:顺序表、链表、栈、队列、字符串。
text
a1 → a2 → a3 → a4 每个元素:一个前驱、一个后继
b. 树形结构
数据元素之间存在一对多的关系。
典型:二叉树、B 树、文件目录系统。
text
A
/ | \
B C D A 有多个"孩子",但每个孩子只有一个"父亲"
c. 图形结构(网状结构)
数据元素之间存在多对多的关系。
典型:地图导航、社交关系网。
text
A ------ B
| \/ |
C ------ D 任意两点之间都可能有边
d. 集合
数据元素同属于一个集合 ,除此之外别无其他关系。
text
{ 1, 2, 3, 4 } 元素之间没有任何"顺序/层次"关系
⚠️ 易错:非线性结构(树、图)中,一个元素可能有多个直接前驱和直接后继;而线性结构最多一前一后。
3.2 物理结构(存储结构)
物理结构:数据元素及其关系在计算机存储结构中的表示(映像)。
主要有四种:
a. 顺序存储
把逻辑上相邻的数据元素存放在一段连续的物理存储单元 中,数据元素之间的逻辑关系由物理存储关系(相邻)来体现。
- 优点:可随机访问(下标直接算出地址)、存储密度高
- 缺点:插入删除要大量移动元素、需要预先分配连续空间
c
int a[5] = {10, 20, 30, 40, 50}; // 逻辑相邻 = 物理相邻
b. 链式存储
逻辑上相邻的数据元素存储在任意的一组物理存储单元 中,元素之间的逻辑关系用指针来表示。
- 优点:插入删除方便、不需要连续空间
- 缺点:不能随机访问、需要额外空间存指针
c
typedef struct Node {
int data;
struct Node *next; // 逻辑关系靠这个指针体现
} Node;
c. 索引存储
存储元素信息的同时,还建立索引表 ,索引表中的每一项称为索引项,索引项的一般形式为:
text
(关键字,地址)
就像书的目录:想找某章,先查目录得到页码,再翻到那一页。
d. 散列存储(哈希存储)
根据数据元素中的关键字计算出该数据元素的存储地址。
text
addr = Hash(key)
优点:查找极快(理想情况 O(1));缺点:可能有哈希冲突,需要额外处理。
📌 重点结论:
- 逻辑结构是面向问题的(跟怎么存无关,是抽象的关系);
- 存储结构是面向计算机的(是逻辑结构在内存里的实现);
- 同一个逻辑结构可以有多种存储结构(比如"线性表"既能顺序存储,也能链式存储);
- 存储结构不同,运算的实现方式就不同,效率也不同。
3.3 数据的运算和实现
数据的运算和实现:对数据元素可以施加的操作,以及这些操作在相应的存储结构上的实现。
例如"线性表"这个逻辑结构上定义的运算有:初始化、求长度、按位查找、插入、删除......
- 用顺序存储 实现 → 插入要搬移元素,
O(N); - 用链式存储 实现 → 插入只改指针,
O(1)(找位置另算)。
同一个逻辑结构 + 不同的存储结构 = 不同的算法效率,这正是数据结构这门课的核心思想。
四、数据类型与抽象数据类型(ADT)
4.1 数据类型
数据类型是一个值的集合 + 定义在这个值集合上的一组操作的总称。
一般分为两类:
- 原子类型 :值不可以再分解 。如 C 语言的
int、double、char等基本数据类型。 - 结构类型 :值是由若干成分按某种结构组合 而成,可以分解。如 C 语言中定义的一个结构体类型。
4.2 抽象数据类型(ADT)
抽象数据类型(Abstract Data Type, ADT)是一种数学模型 + 在该模型上定义的一组操作的集合。
它定义了数据对象、数据关系以及对数据的操作 ,但隐藏了具体实现细节。
三个关键词:
- 抽象 ------ 只关心"能做什么",不关心"怎么做";
- 数据对象 + 数据关系 + 基本操作 三部分;
- 实现细节被隐藏(信息隐藏 / 封装)。
用栈来举例(课件原文):
text
// ADT = 数据对象(D) + 数据关系(S) + 基本操作(P)
ADT Stack {
数据对象:D = { aᵢ | aᵢ ∈ ElemType, i = 1,2,...,n, n ≥ 0 }
数据关系:S = { <aᵢ₋₁, aᵢ> | aᵢ₋₁, aᵢ ∈ D, i = 2,...,n }
约定 aₙ 为栈顶,a₁ 为栈底
基本操作:
InitStack(&S) // 初始化栈
操作结果:构造一个空栈 S
DestroyStack(&S) // 销毁栈
初始条件:栈 S 已存在
操作结果:销毁栈 S
StackEmpty(S) // 判断栈空
初始条件:栈 S 已存在
操作结果:若栈空则返回 TRUE,否则 FALSE
Push(&S, e) // 入栈
初始条件:栈 S 已存在
操作结果:插入元素 e 为新的栈顶元素
Pop(&S, &e) // 出栈
初始条件:栈 S 已存在且非空
操作结果:删除 S 的栈顶元素,并用 e 返回其值
GetTop(S, &e) // 获取栈顶
初始条件:栈 S 已存在且非空
操作结果:用 e 返回 S 的栈顶元素(不删除)
}
📌 ADT 的写法要点(考试常考格式) :每个操作都要写清「初始条件 」和「操作结果」。
💡 用生活类比:ADT 就像"遥控器的说明书"------告诉你按哪个键能换台,但不说红外信号怎么编码。这样你换一台电视,按键含义不变。
五、算法的基本概念
5.1 什么是算法
算法(Algorithm)是解决特定问题的求解步骤的一种描述,是一系列解决问题指令的有限序列。
它描述了如何通过一系列操作将输入转换为期望的输出。
关键词:
- 求解步骤的描述 ------ 是"方法",不是"程序代码本身";
- 有限序列 ------ 指令条数有限(下面会讲"有穷性")。
精心设计的算法可以带来更高的运行效率;精心选择的数据结构也可以带来更高的运行或存储效率。
5.2 算法 + 数据结构 = 程序
这是瑞士计算机科学家、1984 年图灵奖得主、Pascal 之父 ------ 尼克劳斯·沃斯(Niklaus Wirth) 提出的著名公式:
text
算法 + 数据结构 = 程序
Algorithms + Data Structures = Programs
含义:程序的好坏快慢,直接由它所采用的数据结构和算法决定。
数据结构和算法是紧密耦合、相辅相成的:
- 数据结构通过算法来实现操作;
- 算法根据数据结构来设计实现细节。
同一个问题,采用不同的数据结构,算法可能完全不同,效率差别可能非常大。
举例:要排序一组数据
- 数据存在数组里 → 可以用快排(下标随机访问很快);
- 数据存在链表里 → 快排不好用(不能随机访问),更适合归并排序。
📌 考试常考:这句话是谁说的、公式内容、含义。
六、算法的五个特性 + 四条设计要求
6.1 算法的五个特性(必背)
| 特性 | 含义 |
|---|---|
| 有穷性 | 算法必须在有限步骤内结束,不能无限循环或永远不终止 |
| 确定性 | 算法的每一步骤必须有确切、无歧义 的定义;对于相同的输入必须得到相同的输出 |
| 可行性 | 算法是可执行 的,算法中描述的操作都是通过已经实现的基本运算执行有限次来实现 |
| 输入 | 算法有零个或多个输入,这些输入取自特定的对象集合 |
| 输出 | 算法有一个或多个输出,输出是算法处理输入后得到的结果,与输入有特定关系 |
⚠️ 两个最容易记错的点:
- 输入可以是 0 个 (例如打印 "Hello World" 的算法),但输出必须至少 1 个(否则这个算法毫无意义)。
- "有穷性" ≠ "可行性":有穷性说的是"会结束",可行性说的是"每一步都能真正做出来"。
- "确定性" ≠ "可行性":确定性说的是"没有二义性"。
6.2 算法设计的四条要求(必背)
| 要求 | 含义 |
|---|---|
| 正确性 | 算法能正确地解决求解的问题 |
| 可读性 | 算法的思路便于理解,可读性强 |
| 健壮性 | 当输入数据非法时,算法也能适当地做出处理,而不会产生莫名其妙的结果 |
| 高效率与低存储需求 | 高效率 = 执行时间尽可能低;低存储 = 执行过程中使用更少的存储空间 |
📌 记忆:正、可、健、高(正确、可读、健壮、高效低存储)。
七、如何分析算法的时间效率
同一个问题可以有许多不同的算法,如何评价哪个更好?
顺序是:先满足正确性 → 其次可读性、健壮性 → 在这三点都满足的情况下,主要评估算法效率的优劣。
算法效率分为:时间效率 和 空间效率。
- 时间效率 :算法运行时所耗费的时间;
- 空间效率 :算法运行过程中额外耗费的存储空间。
7.1 两种分析方法:事后统计 vs 事前估算
| 方法 | 做法 | 缺点 |
|---|---|---|
| 事后统计 | 实现算法后运行、统计实际耗时 | 必须先实现算法;严重依赖环境 (编程语言、CPU、编译器),容易掩盖算法本身的优劣 |
| 事前估算 | 在写代码之前,分析语句执行次数 | 需要抽象、忽略环境因素(课程采用) |
📌 为什么不用事后统计?因为同一段代码在 i9 上跑和在旧笔记本上跑,结果完全不一样,这样就没法比较算法本身的好坏了。
7.2 事前估算公式
算法的运行时间:
text
M K
T(N) = ∑ ∑ Cᵢ * Nⱼ
i=1 j=1
其中:
- Cᵢ 是第 i 条代码语句消耗的时间;
- Nᵢ 是第 i 条语句执行的次数。
7.3 例子:Summation 累加算法
c
// 这里我们给出了一个简单的 summation 累加算法,根据上面的事前估算公式可以得到
// 算法的运行时间 f(N) = C1*1 + C2*1 + C3*(N+1) + C4*N + C5*N + C6*1
int Summation(int N) { // 消耗时间 执行次数
int ret = 0; // C1 1
int i = 1; // C2 1
while (i <= N) { // C3 N+1
ret += i; // C4 N
i++; // C5 N
}
return ret; // C6 1
}
为什么 while (i <= N) 是 N+1 次?
因为循环条件在"循环体执行 N 次"之外,还要在最后一次判断为假时再执行一次。这是初学者最容易算错的地方!
7.4 关键简化:假设 Cᵢ = 1
上面公式里每条语句消耗的时间 Cᵢ 本质跟算法无关 ,它跟实现的编程语言、编译器、CPU 等环境因素相关。
既然是事前估算,我们就简单地假设每条语句消耗的时间都是一样的,即 Cᵢ = 1,于是:
事前估算简化为:所有代码语句的执行次数之和。
这样就能脱离软硬件环境来分析算法的时间效率了。
那么 Summation 函数的时间效率为:
text
f(N) = 1 + 1 + (N+1) + N + N + 1 = 3N + 4
八、时间复杂度(大 O 渐进表示法)
8.1 为什么要"渐进"?(一个直观例子)
假设:算法 a 的执行次数函数 fₐ(N) = 100 * N ,算法 b 的执行次数函数 f_b(N) = N²(课件原文此处为 N²,请注意)
- 当 N = 50 时:算法 a 执行 5000 次,算法 b 执行 2500 次,算法 b 更快;
- 但当 N = 1000 时:算法 a 执行 100000 次,算法 b 执行 1000000 次,算法 a 更快。
结论:当 N 比较小的时候或许算法 b 更快,但是随着 N 不断增大,算法 a 总是更快的。
所以事前估算应该关注算法执行次数随着规模变化的增长速度,关注算法执行次数的量级。
再看一个更麻烦的例子:
- fₐ(N) = 10 * N³ + 5 * N² + N + 100
- f_b(N) = 100 * N² + 10000
直接比较谁更好很难下手 ,所以我们需要一个统一的方法来确定它们的量级 ------这就是时间复杂度。
8.2 大 O 的定义
若
text
T(N)
lim -------- = C (C 是一个非零有限常数)
N→∞ f(N)
那么我们就称 T(N) 和 f(N) 是同阶函数 ,也就是它们的增长速度相同,是同一个数量级,记作:
text
T(N) = O(f(N))
这里的 O 是"上界"的意思:T(N) 的增长不超过 f(N) 的某个常数倍。
我们称 O(f(N)) 为算法的渐进时间复杂度,简称时间复杂度。
"渐进"一词来自数学中的"渐近线"概念,指的是当变量趋于无穷大时,函数的增长行为趋势 。
也就是说:时间复杂度描述的是一个算法运行时间随输入数据规模增长而增长的趋势 ,也可以理解为------当输入的数据量变大时,算法需要执行的基本操作次数会如何变化。
8.3 通俗求 T(N) 的四步法
对 f(N) 函数:
- 仅保留最高阶项 ------ 因为当 N 无限大,最高阶项对函数增长起决定性作用;
- 去除最高阶项的系数 ------ 因为当 N 无限大,系数对结果的量级没有影响;
- 去除常数项 ------ 因为当 N 无限大,常数项对量级也没有影响;
- 若没有跟 N 相关的项,只有常数项,则时间复杂度为 O(1)。
那么上面的:
- 算法 a:Tₐ(N) = O(N)
- 算法 b:T_b(N) = O(N²)
这样对比时间复杂度,就可以快速明确地看出算法 b 效率更高。
8.4 更快的算法:直接算复杂度(不用先求 f(N))
计算时间复杂度本质是估算算法属于哪个数量级。实际中不需要先求出执行次数 f(N) 再求 T(N),太麻烦了。可以直接计算,规则如下:
a. 只关注循环语句。 因为普通语句的执行次数都算常数项,可以忽略。
b. 循环语句中只计算内部的一个基本语句执行次数即可。 其他语法叠加计算出的也是这个项的系数,而系数可以忽略。
例如:
c
for (int i = 0; i < n; ++i) { ++x; }
我们直接计算 ++x 的执行次数为 N 即可。i < n 和 ++i 也一起计算的话,影响的也是系数,系数最后还是要被忽略掉。
c. 多个循环语句,一般优先看嵌套层数最多的循环,找出最高阶项。
通常:
- 一层循环 → N
- 两层循环 → N²
- 三层循环 → N³
但实际中需要都算一下,因为:
- 有时一层循环不一定是 N,可能是 log₂N;
- 两层嵌套循环也可能不是 N²,而是 N*log₂N 等等。
所以还是要计算具体的算法逻辑。
8.5 常见时间复杂度量级(从小到大,必背)
text
O(1) < O(log N) < O(N) < O(N*log N) < O(N²) < O(N³) < O(2^N) < O(N!)
| 量级 | 名称 | 典型算法 |
|---|---|---|
| O(1) | 常数阶 | 数组随机访问、哈希查找(理想) |
| O(log N) | 对数阶 | 二分查找、倍增 |
| O(N) | 线性阶 | 一次遍历、顺序查找 |
| O(N log N) | 线性对数阶 | 快排、归并、堆排序 |
| O(N²) | 平方阶 | 冒泡、插入、选择排序 |
| O(N³) | 立方阶 | 矩阵乘法 |
| O(2^N) | 指数阶 | 子集枚举、朴素递归斐波那契 |
| O(N!) | 阶乘阶 | 全排列、旅行商暴力解 |
💡 记忆技巧:"常对线,线对平方立,指阶爆" ------ 常数、对数、线性、线性对数、平方、立方、指数、阶乘。
8.6 对数的一个重要性质
因为对数的换底公式:
text
log_b N
f(N) = log_a N = ------- 其中 log_b a 是一个常数
log_b a
所以在时间复杂度的渐进表示法中,所有的对数都是同一量级的:
text
T(N) = O(f(N)) = O(log_a N) = O(log_b N)
因此一般对数级的算法我们都用 O(log N) 表示 ,有些书上也用 O(lg N) 表示。
⚠️ 注意:这个结论只对"底数是常数"成立 。
O(log N)和O(N)当然不同量级。
九、时间复杂度经典样例逐个分析
样例 1:O(N) 级
c
int Summation(int N) {
int ret = 0;
int i = 1;
while (i <= N) {
ret += i;
i++;
}
return ret;
}
分析:只有一层循环,循环体执行 N 次。
结论:T(N) = O(N)
样例 2:O(N²) 级 ------ 冒泡排序
冒泡排序的数据比较和交换次数是一个 1 至 N 的等差数列 ,
f(N) = N*(N+1)/2,中我们只保留最高阶项,所以 T(N) = O(N²)。
c
// 计算 BubbleSort 的时间复杂度?
void BubbleSort(int *a, int n) {
assert(a);
for (size_t end = n; end > 0; --end) {
for (size_t i = 1; i < end; ++i) {
if (a[i-1] > a[i]) {
Swap(&a[i-1], &a[i]);
}
}
}
}
逐行算执行次数:
| 语句 | 执行次数 |
|---|---|
外层 for |
N 次(end 从 N 减到 1) |
内层 for |
第 1 轮 N-1 次,第 2 轮 N-2 次......最后一轮 1 次 |
if 判断 |
1 + 2 + ... + (N-1) = N(N-1)/2 |
Swap |
最坏情况下同样 N(N-1)/2 |
总数 = N(N-1)/2 = (N² - N)/2。保留最高阶项并去掉系数:
结论:T(N) = O(N²)
样例 3:O(N³) 级 ------ 矩阵乘法
MatrixMultiply 是一个矩阵相乘并打印结果的算法。
O(f(N)) 中我们只保留最高阶项,那么矩阵相乘部分的执行次数阶数明显高于打印结果部分 ,所以我们只关注矩阵相乘部分。
T(N) = O(N³)
c
#define N 3
void MatrixMultiply(int A[][N], int B[][N], int C[][N]) {
// 矩阵相乘
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
C[i][j] = 0;
for (int k = 0; k < N; k++) {
C[i][j] += A[i][k] * B[k][j];
}
}
}
// 打印结果
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
printf("%d", C[i][j]);
}
printf("\n");
}
}
分析:
- 矩阵相乘:三层嵌套循环 → N × N × N = N³
- 打印结果:两层嵌套循环 → N²
- N³ 的阶数明显高于 N²,只保留最高阶项 → T(N) = O(N³)
💡 这里体现了规则 c:优先看嵌套层数最多的循环 。
📌 小提示:课件里
#define N 3是把矩阵阶数写成了编译期常量方便演示,做复杂度分析时把它当成问题规模 n 即可,于是就是标准的 O(n³)。
样例 4:O(log N) 级 ------ Count
Count 算法是一个循环打印程序,不同于普通循环
++i,它每循环一次 *i = 2 。假设循环了 x 次,循环结束的条件是 2^x >= N ,那么 x >= log₂N ,
T(N) = O(log₂N)
c
#include <stdio.h>
void Count(int n) {
for (int i = 1; i < n; i *= 2) {
printf("%d\n", i);
}
}
int main() {
Count(100);
return 0;
}
为什么是 log N?
i 的变化是:1 → 2 → 4 → 8 → ... → 2^x。
乘 2 增长意味着指数增长 ,要达到 N 需要 x = log₂N 次。
📌 记住这个套路:循环变量"乘几/除几"→ 对数级;"加几/减几"→ 线性级。
样例 5:O(1) 级 ------ Print100
Print100 算法是一个循环打印程序,它的循环最多循环 100 次 ,打印前 100 个数。
T(N) = O(1)
c
void Print100(int *a, int n) {
for (int i = 0; i < n && i < 100; i++) {
printf("%d", a[i]);
}
printf("\n");
}
为什么循环了还是 O(1)?
因为循环次数被常数 100 卡住了上界 ,与 N 无关 → 执行次数是常数 → O(1)。
⚠️ 易错:有循环 ≠ 有 N 的复杂度。关键是看执行次数跟 N 有没有关系。
样例 6:O(M+N) 级 ------ PrintMN
PrintMN 算法是一个循环打印程序,第一个循环打印 M 次,第二个循环打印 N 次,
执行次数 f(M,N) = M + N 。这里 M 和 N 都是同一次幂的项(课件原文写作"均是最低阶项",指两者谁也无法被忽略 ),它们中大的那一个对函数增长起决定性作用,但无法确定谁大,故时间复杂度为:
text
T(N) = O(M + N) 或 T(N) = O(max(M, N))
c
void PrintMN(int m, int n) {
for (int i = 0; i < m; ++i) {
printf("hello\n");
}
for (int i = 0; i < n; ++i) {
printf("hello\n");
}
}
💡 这类"两个规模变量"的题目在 408 和面试里很常见:
- 两个循环并列 (不是嵌套)→ O(M + N)
- 两个循环嵌套 → O(M × N)
样例 7:存在最好 / 最坏 / 平均三种情况
有的情况下算法的执行次数跟问题的输入有关 ,可以分为最好、最坏、平均三种情况去讨论。
一般情况下我们比较算法的时间复杂度都是讨论最坏情况 ,这样可以保证算法最差也不会超过这个量级,这是一种底线思维 ,是一种保守的估算。
三个概念:
| 名称 | 定义 |
|---|---|
| 最坏时间复杂度 | 最坏情况下的时间复杂度 |
| 最好时间复杂度 | 最好情况下的时间复杂度 |
| 平均时间复杂度 | 所有输入实例等概率 出现的情况下,算法的期望运行时间 |
看 Find 算法(顺序查找):
c
int Find(int *a, int n, int x) {
for (int i = 1; i < n; i++) {
if (a[i] == x)
return i;
}
return -1;
}
它在数组 a 中顺序查找一个值为 x 的元素的下标:
- 最好情况 :要查找的值 x 在第一个位置 → T(N) = O(1)
- 最坏情况 :x 在最后一个位置 (或不存在)→ T(N) = O(N)
- 平均情况:假设 x 在数组中任意一个位置的概率均是 1/N,把每个位置值都查找一遍的平均次数(期望):
text
1 1 (1 + n) * n 1 1 + n
平均查找次数 f(n) = --- (1+2+...+n) * --- = ---------- * --- = -------
n n 2 n 2
则 T(N) = O(N)
📌 关键结论:平均情况也是 O(N),因为 (1+n)/2 是 N 的线性函数(系数 1/2 被忽略)。
样例 8:递归算法的时间复杂度
递归算法的时间复杂度公式:
text
M
T(N) = O( ∑ fᵢ(N) )
i=1
其中:
- M 为递归的次数(注意:不是递归深度,而是"总共发生了多少次递归调用")
- fᵢ(N) 为每次递归的执行次数
通俗点说:递归算法的时间复杂度 = M 次递归的执行次数累加和。
例:Fac 阶乘
c
long long Fac(size_t N) {
if (0 == N)
return 1;
return Fac(N-1) * N;
}
分析 :Fac 算法每次递归的执行次数是 O(1) ,递归 N+1 次,所以:
结论:T(N) = O(N)
💡 递归复杂度速算:
- 每次递归只做常数事 + 递归 N 次 → O(N)
- 每次递归把问题砍一半 + 递归 log N 次 → O(log N)
- 每次递归裂成 2 个分支、深度 N → O(2^N)(如朴素斐波那契)
十、空间复杂度
10.1 从空间估算公式说起
和时间复杂度类似,我们使用的方法也是事前估算。
Func 函数占用的空间大小为:
text
f(N) = C1 * 1 + C2 * 2 + C3 * N
每个变量消耗的空间 Cᵢ 本质跟算法无关 ,它跟实现的编程语言、编译器等环境因素相关。既然是事前估算,我们可以简单地假设每个变量的大小都是一样的 Cᵢ = 1,则:
text
f(N) = 1 + 2 + N
c
// 算法的运行空间 F(N) = C1*1 + C2*2 + C3*N
double Func(int N) { // 空间大小(字节) 变量个数
double ret = 0; // C1 1
int i = 1; // C2 1
char tmp[N]; // C3 N
while (i <= N) {
ret += i;
i++;
}
return ret;
}
10.2 空间复杂度定义
跟时间复杂度分析类似,我们关注的是随着问题规模 N 趋于很大时,空间占用的增长量级,所以也使用渐进表示法,找到跟变量个数相关的同阶函数:
text
S(N) = O(f(N))
方法类似:保留最高阶项,忽略常数和系数。
则 Func 函数的空间复杂度为:
text
S(N) = O(f(N)) = O(N)
10.3 一个非常重要的注意点
空间复杂度是算法因设计思路所需的额外开辟的空间。
我们事前估算的是空间随问题规模(通常用 N 表示)增长的变化趋势 。
算法输入数据本身占用的空间不计入空间复杂度分析 ,因为那是算法的输入数据占据的空间,我们关心的是算法为了实现其功能而额外开辟的空间。
⚠️ 这一句是考试和面试的超高频考点:
- 传入一个长度为 N 的数组
int a[N],它不算空间复杂度; - 但如果你在函数里又开了
int tmp[N],它算,空间复杂度 O(N)。
10.4 常见空间复杂度量级
text
O(1) < O(log N) < O(N) < O(N²)
- 若算法在执行过程中额外需要常数级别的额外空间 O(1) ,一般称这样的算法为 原地算法(in-place algorithm)。
💡 常见"原地算法":冒泡排序、选择排序、插入排序、堆排序、数组逆置、三路逆置轮转数组。
十一、空间复杂度经典样例分析
样例 1:O(1) 级
Summation 算法申请了常数个变量 ,所以空间复杂度 S(N) = O(1)。
c
int Summation(int N) {
int ret = 0;
int i = 1;
while (i <= N) {
ret += i;
i++;
}
return ret;
}
样例 2:旋转数组 ------ 时间 O(N²)、空间 O(1)
题目 :给定一个整数数组 nums,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。(189. 轮转数组 - 力扣 LeetCode)
思路:每次将数组中数据右轮转一位,轮转 k 次。
- 时间复杂度:T(N) = O(N²)
- 空间复杂度:S(N) = O(1)
⚠️ 需要注意的是 leetcode 这道题目有时间要求 ,这个算法思路无法达到要求,所以无法 AC 。
(AC 是 Accepted 的意思,在 OJ(Online Judge)中所有测试用例通过叫 AC)
c
void rotate(int* nums, int numsSize, int k) {
// 轮转 N 次相当于回到原来的顺序,所以 k 大于 N 时,只需要轮转余数次
k %= numsSize;
// 轮转 k 次
while (k--) {
// 向右轮转一个位置
int tmp = nums[numsSize - 1];
for (int i = numsSize - 1; i > 0; i--)
{
nums[i] = nums[i - 1];
}
nums[0] = tmp;
}
}
复杂度推导:
- 外层
while最多执行 k 次(k < N),内层for每次搬 N-1 个元素 → N × N = O(N²) - 只用了
tmp一个变量 → O(1)
样例 3:旋转数组 ------ 时间 O(N)、空间 O(K)(空间换时间)
思路 :空间换时间 。开辟一个额外的跟 nums 一样大的临时数组 tmp,将 nums 的后 k 个数据 挪到 tmp 的前 k 个位置 ,再将 nums 前 n-k 个 挪到 tmp 的后 n-k 个位置,最后将 tmp 数组中的值拷贝回 nums 数组。
- 时间复杂度:T(N) = O(N)
- 空间复杂度:S(N) = O(K)(注:按课件思路开辟与 N 同规模的临时数组,最坏情况下为 O(N))
c
void rotate(int* nums, int numsSize, int k) {
k %= numsSize;
if (k == 0)
return;
int tmp[k];
// 拷贝 nums 中的后 k 个到 tmp 的前 k 个位置
int j = 0;
for (int i = numsSize - k; i < numsSize; ++i) {
tmp[j++] = nums[i];
}
// 从后往前拷贝 nums 前 n-k 个到后 n-k 个位置
for (int i = numsSize - k - 1; i >= 0; --i) {
nums[i + k] = nums[i];
}
// 将 tmp 数组中的值拷贝回到 nums 数组
for (int i = 0; i < k; ++i) {
nums[i] = tmp[i];
}
}
📌 空间换时间是算法优化里最重要的思想之一。当时间超限(TLE)时,第一反应就是:"我能不能多开点空间,把时间降下来?"
样例 4:旋转数组 ------ 时间 O(N)、空间 O(1)(最优解,三次逆置)
思路 :有没有既省空间又省时间 的思路呢?
将 nums 中所有数据逆置一遍,再分别前 k 个逆置和后 n-k 个逆置。
- 时间复杂度:T(N) = O(N)
- 空间复杂度:S(N) = O(1)
c
void reverse(int* a, int left, int right) {
while (left < right) {
int tmp = a[left];
a[left] = a[right];
a[right] = tmp;
++left;
--right;
}
}
void rotate(int* nums, int numsSize, int k) {
k %= numsSize;
// 整体逆置
reverse(nums, 0, numsSize - 1);
// 前 k 个逆置
reverse(nums, 0, k - 1);
// 后 n-k 个逆置
reverse(nums, k, numsSize - 1);
}
举个具体例子(nums = 1,2,3,4,5,6,7,k = 3):
text
原始: 1 2 3 4 | 5 6 7
整体逆置: 7 6 5 4 | 3 2 1
前 k 个逆置:5 6 7 4 | 3 2 1
后 n-k 逆置:5 6 7 | 1 2 3 4 ← 答案!
💡 这个"三次逆置"技巧非常经典,面试里能手推出这一步,加分很多。
样例 5:递归算法的空间复杂度
递归算法的空间复杂度公式:
text
M
S(N) = O( ∑ fᵢ(N) )
i=1
其中:
- M 为递归的深度 (注意:空间看的是"深度",时间看的是"次数")
- fᵢ(N) 为每次递归算法的额外空间消耗
例:Fac 阶乘用递归实现,递归深度为 O(n) ,每次递归额外空间消耗为常数个,则:
结论:S(N) = O(N)
c
// 计算递归求阶乘 Fac 算法的空间复杂度?
long long Fac(size_t N) {
if (N <= 0)
return 1;
return Fac(N - 1) * N;
}
⚠️ 时间复杂度和空间复杂度的递归公式对比(超高频考点):
求和对象 含义 时间复杂度 递归的次数 M 总共调用了多少次 空间复杂度 递归的深度 M 递归栈最深有多少层 例如斐波那契朴素递归:时间 O(2^N) (调用次数爆炸),但空间只有 O(N)(深度为 N)。
十二、408 真题实战(含解析)
下面 6 题全部来自课件,都是历年 408 选择题第 01 题的原题或变形,强烈建议先自己做一遍再看答案。
第 1 题(2011 年 408 选择题 01)
设 n 是描述问题规模的非负整数,下面程序片段的时间复杂度是 ______。
c
x = 2;
while (x < n / 2)
x = 2 * x;
A. O(log₂n) B. O(n) C. O(n * log₂n) D. O(n²)
点击查看答案与解析
正确答案:A
假设循环执行了 t 次,变化条件为 x = 2 * x,则循环结束时 x = 2^(t+1) >= n/2,则
text
t + 1 >= log₂(n/2) = log₂n - 1
t >= log₂ n - 2
故最终的时间复杂度为 O(log₂n)。
第 2 题(2012 年 408 选择题 01)
求整数 n(n ≥ 0)阶乘的算法如下,其时间复杂度是 ______。
c
int fact(int n)
{
if (n <= 1)
return 1;
return n * fact(n - 1);
}
A. O(log₂n) B. O(n) C. O(n * log₂n) D. O(n²)
点击查看答案与解析
正确答案:B
本题前面例题已经讲过了,fact 递归了 n 次 ,每次递归执行次数为 O(1) ,所以时间复杂度为 O(n)。
第 3 题(2014 年 408 选择题 01)
下列程序段的时间复杂度是 ______。
c
count = 0;
for (k = 1; k <= n; k *= 2)
for (j = 1; j <= n; j++)
count++;
A. O(log₂n) B. O(n) C. O(n * log₂n) D. O(n²)
点击查看答案与解析
正确答案:C
假设第一层循环执行 x 次,则循环结束时 k = 2^x > n ,则 x > log₂n ;因为循环次数 x 是整数,则 x = log₂n + 1 。
第二层循环每次都执行 N 次,则整体时间复杂度为:
text
O(n * log₂n)
第 4 题(2017 年 408 选择题 01)
下列函数的时间复杂度是 ______。
c
int func(int n) {
int i = 0, sum = 0;
while (sum < n)
sum += ++i;
return i;
}
A. O(log₂n) B. O(n^(1/2)) C. O(n) D. O(n log₂n)
点击查看答案与解析
正确答案:B
本题要计算循环执行次数,相比前面的题要绕一些。假设循环执行 x 次,本题中每次循环 sum += ++i 的结果:
text
i = 0 时,sum += 1;
i = 1 时,sum += 2;
i = 2 时,sum += 3;
...
i = x-1 时,sum += x
循环结束时:
text
(1 + x) * x
sum = 1+2+3+...+x = ----------- >= n
2
解得 x ≈ n^(1/2) ,则整体时间复杂度为 O(n^(1/2))。
第 5 题(2019 年 408 选择题 01)
设 n 是描述问题规模的非负整数,下列程序段的时间复杂度是 ______。
c
x = 0;
while (n >= (x + 1) * (x + 1))
x = x + 1;
A. O(log₂n) B. O(n^(1/2)) C. O(n) D. O(n²)
点击查看答案与解析
正确答案:B
本题是 2017 年题目的变形的"故技重施":假设循环执行 t 次,每次循环 x 递增 +1。
循环结束时,则 n < (x + 1)² ,x 从 0 开始,则 x = t ,则 n < (t + 1)² ,即 t ≈ n^(1/2)。
则整体时间复杂度为 O(n^(1/2))。
补充:其他概念题(学校期末 / 自命题高频)
以下选自课件"经典题目"部分(校招不考,但考研/期末常考):
-
在数据结构中,从逻辑上可以把数据结构分成( )。(2018 年东北林业大学 自命题)
A. 动态结构和静态结构 B. 紧凑结构和非紧凑结构 C. 线性结构和非线性结构 D. 内部结构和外部结构
-
组成数据的基本单位是( )。(2013 年东北石油大学 自命题)
A. 数据项 B. 数据类型 C. 数据元素 D. 数据变量
-
运算实现是针对( )指出运算的具体操作步骤。(2017 年南京邮电大学 自命题)
A. 逻辑结构 B. 存储结构 C. 顺序结构 D. 链接存储
-
数据在计算机中存储器内表示时,物理地址和逻辑地址都连续,称之为( )。(2002 年哈尔滨工程大学 自命题)
A. 逻辑结构 B. 顺序存储结构 C. 链式存储结构 D. 以上都不对
-
数据结构是一门研究非数值计算的程序设计问题中计算机的操作对象,以及它们之间的( )和运算的学科。(2015 年青岛大学 自命题)
A. 逻辑存储 B. 关系 C. 算法 D. 数据映像
-
非线性结构是数据元素之间存在一种( )。(2015 年东北林业大学 自命题)
A. 一对多关系 B. 多对多关系 C. 多对一关系 D. 一对一关系
答案:1. C 2. C 3. B 4. B 5. B 6. A
算法概念题:
-
算法是指( )。(2015 年哈尔滨工程大学 自命题)
A. 可执行程序 B. 问题求解的计算方法 C. 系统软件 D. 解决问题的有限运算序列
-
以下说法错误的是( )。(2021 年南京邮电大学 自命题)
A. 算法的健壮性指输入非法数据,会适当做出反应和处理,不会出现奇怪的输出
B. 算法的输入可以是零个
C. 算法的可行性是指无二义义
D. 都不正确
-
对一个好的算法应该有( )、可读性、健壮性、效率及存储量的要求。(2020 年西安理工大学 自命题)
A. 正确性 B. 有穷性 C. 可行性 D. 确定性
答案:1. D 2. C 3. A
📌 第 2 题 C 选项的坑:"无二义性"是确定性,不是可行性。可行性说的是"每一步都能通过已实现的基本运算执行有限次来实现"。
十三、全章易错点总结 & 背诵清单
✅ 必须背下来的清单
- 四个术语 :数据 / 数据元素 / 数据项 / 数据对象,以及包含关系
数据对象(集合)⊇ 数据元素(个体)⊇ 数据项(属性) - 数据结构三要素:逻辑结构、物理结构(存储结构)、数据的运算和实现
- 逻辑结构四分 :线性、树形、图形(网状)、集合
逻辑结构二分:线性、非线性 - 物理结构四种:顺序存储、链式存储、索引存储、散列存储
- 四种逻辑关系的对应:线性=一对一,树=一对多,图=多对多,集合=无关系
- 数据类型:原子类型(不可再分)、结构类型(可分解)
- ADT:数学模型 + 一组操作,隐藏实现细节
- 算法五特性 :有穷性、确定性、可行性、输入、输出(输入可 0 个,输出至少 1 个)
- 算法四要求:正确性、可读性、健壮性、高效率与低存储
- 公式:算法 + 数据结构 = 程序(Niklaus Wirth,1984 图灵奖)
- 复杂度量级顺序 :
O(1) < O(log N) < O(N) < O(N log N) < O(N²) < O(N³) < O(2^N) < O(N!) - 空间复杂度量级顺序 :
O(1) < O(log N) < O(N) < O(N²) - 空间复杂度只算额外开辟的空间,输入数据本身不算
- 递归 :时间看递归次数 ,空间看递归深度
⚠️ 十个高频误区
| 误区 | 正确理解 |
|---|---|
| 数据就是数字 | 图像、声音、字符串都是数据 |
| 数据元素 = 数据项 | 元素是整体,数据项是最小单位 |
| 逻辑结构和存储结构是一回事 | 逻辑结构面向问题,存储结构面向计算机;同一逻辑结构可有多种存储结构 |
| 有循环就是 O(N) | 如 i *= 2 → O(log N);循环上限是常数 100 → O(1) |
| O(2N) 比 O(N) 慢 | 系数可忽略,它们是同一量级 |
| 对数底数不同就不同量级 | 换底公式 → 所有常数底对数同量级,统一写 O(log N) |
while (i <= N) 只执行 N 次 |
循环条件判断执行 N+1 次 |
| 复杂度只讨论最好情况 | 一般讨论最坏情况(保守估算、底线思维) |
| 函数里开了数组不算空间 | 只要是额外开辟的就算,O(N) 的临时数组就是 O(N) |
| 递归空间复杂度按调用次数算 | 空间按深度算(斐波那契朴素递归时间 O(2^N)、空间 O(N)) |
结语
这一章是整门数据结构课的"地基":
- 概念 部分(数据、数据元素、逻辑结构、存储结构、ADT)是期末/考研简答与选择题的常客;
- 复杂度 部分是贯穿全课程的分析工具------后面学顺序表、链表、二叉树、排序,每一个操作都要问一句"时间复杂度是多少?空间复杂度是多少?"
建议动手练习:
- 把本文所有代码自己在编译器里敲一遍并运行;
- 把 6 道 408 真题先遮住答案做一遍;
- 把"背诵清单 + 十个误区"抄到笔记本上,考前 10 分钟看一遍。
如果这篇对你有帮助,欢迎点赞 + 收藏,后续我会继续更新顺序表、链表、栈和队列、二叉树、排序的笔记,一起把数据结构啃下来! 💪
本文内容整理自《数据结构》课程第一讲课件《数据结构概念和复杂度计算》,代码示例与例题均来自课件原文,解析部分为个人理解与补充。