Appearance
算法和算法评价
2026 大纲 一、基本概念(二)算法的基本概念。
什么算是算法,什么算是好算法
算法是为解决某类问题而规定的有限长操作序列。这个定义里的每个限定词都对应一条特性,五条缺一不可:
| 特性 | 含义 | 反例 |
|---|---|---|
| 有穷性 | 有穷步内结束,每步也在有穷时间内完成 | while(1) x++;;操作系统主循环 |
| 确定性 | 每种情况的操作都有确切规定,无二义性 | "把较大的放前面"——相等时没规定 |
| 可行性 | 都能由已实现的基本运算执行有限次完成 | "取 |
| 输入 | 零个或多个 | 打印九九乘法表没有输入,允许 |
| 输出 | 一个或多个 | 无输出的算法没有意义 |
这里有个值得单独记一笔的边界:算法必须有穷,程序不必。操作系统的等待循环是个正经程序,却永远不停,所以它不是算法(殷人昆 p24 明确点出这一条)。另外算法可以用自然语言或流程图描述,程序则必须用程序设计语言写成、能被执行。
"是不是算法"之外还有"好不好",标准有四条:正确性、可读性、健壮性、高效性。高效性又含时间与空间两面,这两面常常此消彼长,一般以时间为主要指标——本篇剩下的内容都在讲怎么衡量这两面。
怎么衡量快慢:只数基本操作
最直接的办法是把程序跑一遍计时,但这叫事后统计,有两个绕不过去的毛病:必须先把算法实现出来,而且结果严重依赖机器和编译器,换台电脑就变。所以实际用的是事前分析估算——不看时间,只数执行次数。
也不必把每条语句都数。一条语句的重复执行次数叫语句频度,
三个记号的分工是这样的:
| 大 | 存在 |
| 同时是 | |
| 多项式规则 | |
| 加法与乘法规则 | 并列取 |
| 取最紧 | 上界不唯一,习惯取最紧的上界、最大的下界 |
常见量级从小到大排开,右边那列是为了让"差一个量级"有实感:
| 量级(递增) | 典型算法 | |
|---|---|---|
| 下标访问、散列查找(理想) | 瞬时 | |
| 折半查找、二叉排序树查找 | 瞬时 | |
| 顺序查找、遍历一遍表 | 1 ms | |
| 快排(平均)、归并、堆排序 | 20 ms | |
| 直接插入、起泡、简单选择排序 | 约 17 分钟 | |
| 三重循环(如 Floyd) | 约 32 年 | |
| 穷举子集、汉诺塔 | 无法完成 | |
| 穷举排列 | 无法完成 |
🔴 这张表只在
足够大时成立。渐近分析丢掉的常数在小规模下可能占主导: 时 反而比 快。快速排序在子表足够小时改用直接插入排序,依据就是这一条。
先看一眼
图里把"找基本操作 → 数频度 → 定量级"这条主线和递归、空间两个分支摊开了。看的时候留意一件事:非递归和递归是两套数法——前者数循环转了几圈,后者要先写出递归方程。下面就分这两条走。
数循环:看循环变量怎么长
非递归算法的量级,全看循环变量是怎么从起点走到终点的:每次加一个常数就是线性,每次乘一个常数就是对数,被平方约束就是平方根。把这条原则套开,常见的形态就七种:
| 形态 | 代码骨架 | 精确次数 | 量级 |
|---|---|---|---|
| 1 单层线性 | for(i=0;i<n;i++) | ||
| 2 嵌套独立 | 内层上界与外层无关 | ||
| 3 嵌套相关 | for(j=1;j<=i;j++) 上界是外层变量 | ||
| 4 倍增 | i=1; while(i<=n) i*=2; | ||
| 5 折半 | i=n; while(i>1) i/=2; | ||
| 6 平方比较 | for(i=0;i*i<=n;i++) | ||
| 7 两段并列 | 两段顺序执行 | 相加 | 取较大者 |
要想真的会用这张表,得能自己把次数数出来。先看频度是怎么算的:
c
int MatrixSum(int a[][N], int n) {
int sum = 0; /* 频度 1 */
for (int i = 0; i < n; i++) /* 条件判断 n+1 次(最后一次为假才跳出)*/
for (int j = 0; j < n; j++) /* 对每个 i 判断 n+1 次,共 n(n+1) 次 */
sum += a[i][j]; /* 频度 n²,这是基本操作 */
return sum; /* 频度 1 */
}和基本操作的频度
形态 4 和形态 5 量级相同,精确次数却差一次,这个差别值得单独看清楚。形态 4(i = 1; while (i <= n) i *= 2;)的循环体在 i = n; while (i > 1) i /= 2;)从
差别的来源是边界:形态 4 的循环体在
⚠️ 嵌套循环不是一律相乘。只有内层上界与外层无关时才是形态 2 的相乘;内层上界或步长跟着外层变(如
for (j = 1; j <= n; j += i)),次数由起点、终点、步长共同决定,得老老实实求和——上式的总次数是,不是 。
数递归:写方程再逐层展开
递归没有循环变量可数,得先把代价写成递归方程(递归项 + 非递归项 + 边界条件),再逐层展开:反复代入自身直到看出规律,用边界条件收尾。
| 递归式 | 解 | 典型来源 |
|---|---|---|
| 阶乘、单链表递归遍历 | ||
| 每层还要扫一遍整表 | ||
| 折半查找(只进一个子区间) | ||
| 汉诺塔,精确解 | ||
| 归并排序 |
这张表的每一行都是展开出来的,不必背。看两个最典型的展开过程。
规模每次减 1,以阶乘为例:
c
long long Fact(int n) {
if (n <= 1) return 1; /* 递归出口 */
return n * Fact(n - 1); /* 问题规模每次减 1 */
}Fact 每层只做常数次操作,对应
即
规模每次减半,以折半查找为例:
c
/* 在升序数组 a[low..high] 中折半查找 key,找到返回下标,找不到返回 -1 */
int BinSearch(int a[], int low, int high, int key) {
if (low > high) return -1; /* 出口:区间为空,查找失败 */
int mid = low + (high - low) / 2; /* 这样写而非 (low+high)/2,避免相加溢出 */
if (a[mid] == key) return mid;
else if (a[mid] > key)
return BinSearch(a, low, mid - 1, key); /* key 更小,只进入左半区间 */
else
return BinSearch(a, mid + 1, high, key); /* key 更大,只进入右半区间 */
}令
🔴 "减 1"和"减半"是两回事,前者线性、后者对数。递归本身不带来任何量级,量级来自规模缩小的方式。
汉诺塔是第三种形态:每层派生两个子问题、规模只减 1。把
令
另外两种解递归式的方法:递归树与主定理(想系统学就展开)
递归树:把每一层的非递归代价摊在树上逐层求和,适合
"每层
、共 层,故 "——这句话就是递归树法的完整推导, 归并排序、快速排序的复杂度都是这么来的,不必当结论背。
主定理:对
| 情形 | 条件 | 结论 |
|---|---|---|
| 1 | 存在 | |
| 2 | ||
| 3 | 存在 |
一句话用法:算出
| 递归式 | 结论 | 出现在 | ||
|---|---|---|---|---|
| 折半查找 | ||||
| 二叉树遍历、求结点数 | ||||
| 归并排序、快排平均情形 | ||||
| —— |
🔴 适用条件:主定理只适用于规模按倍数缩小的
二叉树遍历那一行要加一句限定:
描述的是平衡二叉树。一般的二叉树 应写成 (左子树 个结点、右子树 个结点), 不论 取什么,展开后每个结点恰好被访问一次,总代价都是 ——结论不变, 但推导过程不同。
最好、最坏与平均
同一个
| 情况 | 定义 | 说明 |
|---|---|---|
| 最好 | 计算量的最小值 | 易求,但出现概率通常极小 |
| 最坏 | 计算量的最大值 | 上界保证,默认讨论的就是它 |
| 平均 | 各输入等概率时计算量的加权平均值 | 最贴近实际,但常难以确定 |
🔴 三档比较的是同一个
下的不同输入,别和大 混作一谈。 描述的是函数关系(渐近上界),最好/最坏是在挑输入,两者正交——"最坏情况下的时间复杂度是 "和"平均情况下是 "可以同时成立。直接插入排序遇升序是最好 、遇逆序是最坏 ,变的是初始状态;顺序查找等概率下平均比较 次,与最坏同为 。
平均情况永远附带一个概率分布假设。题目不说明时默认等概率,一旦给了别的分布就必须按给定分布重算。比如已知待查元素有
取
由于平均情况往往难以确定,除非特别指明,讨论的时间复杂度一律指最坏情况。后面各章沿用这个约定,只在排序一章同时给出三档。
空间复杂度
| 含义 | 例子 | |
|---|---|---|
| 原地工作,只用常数个额外变量 | 起泡、直接插入、简单选择、堆排序 | |
| 递归深度为 | 折半查找(递归)、快排(平均) | |
| 与输入同量级的辅助空间 | 归并排序的辅助数组、快排最坏的递归栈 |
递归算法的空间是这里最容易出错的地方,关键是一句话:时间数的是递归树的结点总数,空间数的是递归树的高度。 递归是深度优先展开的,左分支出栈后右分支才入栈,所以栈上任何时刻只躺着"根到叶"的一条路径——每层只占常数空间时,
两段只差"进一个子区间"还是"进两个子区间"的代码,正好把这件事分开:
| 递归式 | 时间 | 同时在栈的层数 | 空间 | |
|---|---|---|---|---|
| 只递归一边(折半查找) | 根到叶一条路径 | |||
| 两边都递归(统计出现次数) | 仍是根到叶一条路径 |
按这条口径过一遍后面会遇到的典型情形:
| 算法 | 递归深度 | 其他辅助空间 | 空间复杂度 | 深度为什么是这个值 |
|---|---|---|---|---|
| 阶乘 / 单链表递归遍历 | 规模每次只减 1 | |||
| 折半查找(递归) | 规模每次减半 | |||
| 汉诺塔 | 时间虽是 | |||
| 二叉树先序遍历(递归) | 树高 | 最坏 | 深度就是树高;单支树时 | |
| 归并排序 | 辅助数组 | 两项取较大者 | ||
| 快速排序 | 平均 | 平均 | 划分越均匀树越矮;每次都切成 0 和 |
原地与非原地的代码对照,以及形参占不占空间(写大题时会用上)
时间同为
c
/* 原地逆置:S(n) = O(1),全程只用了 i、j、t 三个额外变量 */
void Reverse(int a[], int n) {
int i = 0, j = n - 1, t;
while (i < j) { /* i == j 时中间那个元素不用换,直接停 */
t = a[i]; a[i] = a[j]; a[j] = t;
i++; j--;
}
}
/* 借助辅助数组逆置:S(n) = O(n),多开了一个长度为 n 的数组 */
void ReverseCopy(int a[], int n) {
int *b = (int *)malloc(sizeof(int) * n);
for (int i = 0; i < n; i++) b[i] = a[n - 1 - i];
for (int i = 0; i < n; i++) a[i] = b[i];
free(b); /* 申请了就要释放 */
}形参占不占空间:C 语言里数组作形参会退化成指针,void f(int a[], int n) 与 void f(int *a, int n) 完全等价,传的是首地址、不产生数组拷贝,只占 MaxSize 数组成员的 SqList L 按值传给递归函数, 每层都复制一整份,空间会从 SqList *L 或 SqList &L 就不会。
考点速记
三条会被反复调用的结论:
- 分析的是基本操作的执行次数,不是运行时间——大
丢掉系数与低次项。 - 递归的时间与空间量级可以差很远——时间数递归树的结点总数,空间数递归树的高度。
- 主定理只管"规模按倍数缩小"的递归式,
一律逐层展开。
这一节在真题里被考过的形式(下方「真题练习」逐题对应):几乎是同一道题的变体——给一段程序片段,问它的时间复杂度。区别只在循环变量的走法,所以判断顺序固定:先看循环变量每次怎么变,再看有没有嵌套、内层依不依赖外层。
- 每次乘 2(或除以 2) →
级。 x = 2 * x从 2 撑到,只需约 次。 - 每次加 1,但被平方约束 →
级。 i*i < n等价于; while (n >= (x+1)*(x+1)) x++也是同一件事。 - 每次加的量本身在变 → 先求和再判。
sum += ++i直到sum >= n,累加的是,解出 ,所以是 级。 - 嵌套且内层与外层无关 → 两层相乘。外层
k *= 2走次、内层老老实实 次,合起来 。 - 嵌套且内层上界就是外层变量 → 不能相乘,要求和。外层
i *= 2、内层j < i,总次数是,结果是 而不是 。 - 递归 → 只看规模怎么缩。
fact(n-1)是减 1,深度, 。
易错:嵌套循环里只看了一层。 只盯外层
k *= 2就答、只盯内层 j<n就答,都是漏掉了另一层的乘法。看到嵌套,先确认内层跑几次、外层跑几次,再决定是相乘还是求和。
易错:"内层依赖外层"被当成了相乘。 外层
层、内层 时,每层的次数不一样,得按等比数列求和,答案是 ;直接乘成 就错了。同理,内层 j<i配上外层次时,总数是 。
易错:把"递归"和"对数"画等号。
对应的是规模每次减半(如折半查找),而 fact(n) = n * fact(n-1)每次只减 1,深度是,时间是 。
易错:开方当成了取对数。
i*i < n是,不是 ;只有乘除以常数才是对数级。
教材出处
- 严蔚敏《数据结构(C 语言版)》(第 2 版),p11:算法的定义("为了解决某类问题而规定的一个 有限长的操作序列")与五个特性的完整表述;评价算法优劣的四条标准(正确性、可读性、 健壮性、高效性)。
- 同书 p12:语句频度的定义("一条语句的重复执行次数");"算法分析并非精确统计实际执行时间, 而是针对语句的执行次数做出估计";基本语句的定义("重复执行次数和算法的执行时间成正比的 语句");矩阵乘法的逐句频度统计示例。
- 同书 p13:大
的定义(存在 与 使 时 ,描述增长率的 上限);定理 1.1( 次多项式的时间复杂度为 ,可忽略低次幂项与最高次幂的系数); 以及"递归算法的时间复杂度通常用递归方程表示,涉及递归方程求解"。 - 同书 p13~p14:常量阶、线性阶、立方阶、对数阶四个分析示例;常见时间复杂度按数量级递增的排列 (常量阶、对数阶、线性阶、线性对数阶、平方阶、立方阶、
次方阶、指数阶)。 - 同书 p15:最好、最坏、平均时间复杂度的定义——平均时间复杂度是"按照输入实例以等概率 出现时算法计算量的加权平均值";顺序查找在等概率假设下平均频度为
的推导; 以及"除特别指明外,均指最坏情况下的时间复杂度"这条默认约定。 - 同书 p15~p16:空间复杂度的定义——只分析算法实现时所需的辅助存储空间,输入数据所占 存储量取决于问题本身、与算法无关;以及时间与空间"可能此消彼长"的权衡。
- 同书 p65~p67:函数调用与返回时系统各做的三件事、递归工作栈与活动记录的进栈/退栈过程 (以
Fact的逐层活动记录图为例);汉诺塔问题的递归解法与"规模减 1"的分解方式。 - 殷人昆《数据结构——用面向对象方法与 C++ 描述》(第 2 版),p24:算法与程序的界线 ("程序可以不满足有穷性",并举操作系统的等待循环为例)。
- 同书 p33~p34:大
表示法的一般提法 ("当且仅当存在正整数 和 使 对所有 成立")与线性、平方、 指数、常数四个函数的验证例子;加法规则;量级序列 。 - 同书 p35:乘法规则及其常数特例("任何非 0 正常数都属于同一数量级,记为
"); 各函数随 增长的数值对照表;渐近空间复杂度的定义,明确指出计入的是 "排序算法中为移动数据所需的临时工作单元、递归算法中所需的递归工作栈"。 - 同书 p36~p37:
记号的定义(存在 与 使 时 ) 与"取最紧下界"的约定; 记号的定义(上下界同阶)及其对称性;顺序搜索在最好、最坏、 平均三档下分别为 、 、 的完整分析。
主定理不在上述两本教材的正文中,本篇把它作为求解
的工具给出, 并同时给出逐层展开法——后者才是两本教材共同采用的方法,也是减法型递归式唯一可用的方法。
相关知识
数据结构的基本概念|线性表基本概念| 栈和队列的基本概念|递归与栈| 树的基本概念|图的基本概念| 查找的基本概念|查找算法对比| 排序的基本概念|排序算法对比