开篇介绍:
hello 大家,我们又见面了,前面我所写的所有博客,其实本质上就是围绕着初学c语言,我们不难看出,C语言的内容繁多且复杂,数组指针地址等等各种各样的概念让我们应接不暇,但是令人无奈的是,这C语言只是我们的编程之路上的一个可以说是微不足道的开始,是的,虽然有些夸张,但是事实就是这样子的,它只是给我们起到了打开编程世界的大门,让我们对于计算机语言有了大致的了解,但是掌握它是远远达不到我们找工作,研究项目等等所需要的知识。
但是也请各位不要对此感到迷茫亦或沮丧,请相信自己,不妨想想自己初学c语言时,我们同样为未知充满了迷茫和好奇,在学习过程中,我们也同样遭遇了各种各样的难题,但是,我们不还是走到了这一步吗,走到了我们编程之路的下一章节,虽然是新的一章,但是依然和我们之前所学的c语言息息相关,所以大家大可不必担心,这新的路,或许崎岖了些,或许难度大了些,或许知识跨度猛了些,但这都不是问题,在我们的努力下,这些只会成为我们更进一步的奠基石,望,诸君共勉!!!
那么在接下来的篇章中,我将对数据结构展开解析,前面我们其实已经学了一些数据结构,比如顺序表,链表,双向链表等等,但是这些数据结构偏基础,我们还需要更深度的去对数据结构进行解析,例如大家经常听说的二叉树,红黑树等等~
只不过这些是后面的内容了,在这篇博客中,由于我们是刚开始,处于起步阶段,为了帮助大家过渡,我想先和大家介绍时间复杂度和空间复杂度这两个我们算法中经常存在的两个概念,同时,它们二者也是判定一段代码优不优秀的关键,我们在后面的练习和笔试面试等等,都会对这两个概念进行分析以及要求,所以,它们两个,我们必须要进行了解了解,避免哪一天两个横插一刀~
接下来,便让我们开始探索之旅吧各位,顺带一提,本篇博客中依旧有例题供大家练习,各位敬请期待~
ps:哇趣好热啊,笔者现在是在大太阳下写的,我滴妈🥵
时间复杂度:
说起这一个,大家应该既陌生又不陌生,毕竟在我们之前所刷的leetcode中的链表题,当我们提交后,页面都会显示时间复杂度为多少,比如O(N),O(log N),O(N平方)等等,那么这些是什么意思呢?
我们先看对于时间复杂度的定义:
定义:在计算机科学中,算法的时间复杂度是一个函数式T(N),它定量描述了该算法的运行时间。时间复杂度是衡量程序的时间效率,那么为什么不去计算程序的运行时间呢?
- 因为程序运行时间和编译环境和运行机器的配置都有关系,比如同一个算法程序,用一个老编译器进行编译和新编译器编译,在同样机器下运行时间不同。
- 同一个算法程序,用一个老低配置机器和新高配置机器,运行时间也不同。
- 并且时间只能程序写好后测试,不能写程序前通过理论思想计算评估。
那么算法的时间复杂度是一个函数式T(N)到底是什么呢?这个T(N)函数式计算了程序的执行次数。通过c语言编译链接章节学习,我们知道算法程序被编译后生成二进制指令,程序运行,就是cpu执行这些编译好的指令。那么我们通过程序代码或者理论思想计算出程序的执行次数的函数式T(N),假设每句指令执行时间基本一样(实际中有差别,但是微乎其微),那么执行次数和运行时间就是等比正相关,这样也脱离了具体的编译运行环境。执行次数就可以代表程序时间效率的优劣。比如解决一个问题的算法a程序T(N) = N,算法b程序T(N) = N^2,那么算法a的效率一定优于算法b。
在这里,我可以直接告诉大家,时间复杂度的本质是通过一个函数来进行分析,当然,此函数非彼函数,具体是数学相关的事,和我们关系不怎么大,我在这里就不详细介绍了诸位。
那么这些时间复杂度,究竟是个什么意思呢,看我三十年经验,三秒教给大家,其实,我们的O(N),O(log N),O(N平方)等等,它们代表的意思是,运行完你所写的代码需要运行几次,O(N)代表代码运行N次,O(log N)代表代码运行log2 N次,O(N平方)代表代码运行N平方次,由此递推,都是如此,括号内是多少,就代表运行多少次,当然,这里的N只是我们的一个代表值,并不就是N,它可以是M,也可以是S等等,只不过我们一般用N。
N可以是任何值,如果代码运行10次,那N就是10,如果代码运行10000次,那N就是10000,如果要是N平方 那N就是100,N是随意的,只不过我们统一用N, 至于是N,还是N平方等等,我们在后面会进行讲述,在这里,大家一定要记住,O(N),O(log N),O(N平方)等等,它们代表的意思是,运行完你所写的代码需要运行几次,大家谨记,这个概念能理解,我们就能讲时间复杂度一举拿下,下面,我们结合一些例子,加深大家对时间复杂度的理解。
例子一:
// 请计算一下Func1中++count语句总共执行了多少次?
int 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;
}
// while循环部分
int M = 10;
while (M--) {
++count;
}
return count;
}
大家可以看一下这一段代码,看看它的时间复杂度是多少呢?我们前面说过,时间复杂度的本质就是一段代码中一共需要运行几次。
我们再看这一段代码,首先针对第一个循环,可以看出,它是嵌套循环,一共两层,每一层都为N,所以针对这一个循环,大家很容易就能看出是运行次,但是这一段代码不止这么一个循环,还有两个,那两个也很简单,第二个是运行2N次,第三个是运行10次,那么总共就是:
Func1 执行的基本操作次数:
T (N) = N^2 + 2 ∗ N + 10
而针对不同的N值,是这样子:
• N = 10 T(N) = 130
• N = 100 T(N) = 10210
• N = 1000 T(N) = 1002010
那么问题来了,这一段代码的时间复杂度就是O(N^2 + 2 ∗ N + 10)吗?大家有没有觉得这一段怪长的,看起来就不简便,而且我们相信大家之前刷题也没见过这么长的时间复杂度吧。
那么,这一段代码的时间复杂度究竟是多少呢?答案是O(),为什么我们不要后面的呢?原因很简单,当N趋近于无限大时,那两个的占比就可以或是微乎其微,压根起不了一点风浪,
实际中我们计算时间复杂度时,计算的也不是程序的精确的执行次数,精确执行次数计算起来还是很麻烦的(不同的一句程序代码,编译出的指令条数都是不一样的),计算出精确的执行次数意义也不大,因为我们计算时间复杂度只是想比较算法程序的增长量级,也就是当N不断变大时T(N)的差别,上面我们已经看到了当N不断变大时常数和低阶项对结果的影响很小,所以我们只需要计算程序能代表增长量级的大概执行次数,复杂度的表示通常使用大O的渐进表示法。
所以,既为了简便,又为了概括性强,我们要懂得适当取舍,由此,就引出了我们的大O的渐进表示法:
大O的渐进表示法:
大O符号(Big O notation):是用于描述函数渐进行为的数学符号:
推导大O阶规则
1. 时间复杂度函数式T(N)中,只保留最高阶项,去掉那些低阶项,因为当N不断变大时,低阶项对结果影响越来越小,当N无穷大时,就可以忽略不计了。
2. 如果最高阶项存在且不是1,则去除这个项目的常数系数,因为当N不断变大,这个系数对结果影响越来越小,当N无穷大时,就可以忽略不计了。
3. T(N)中如果没有N相关的项目,只有常数项,用常数1取代所有加法常数。
再给大家详细解释一下:
一、大 O 符号的本质
大 O 符号(O(⋅))不是 “计算精确次数”,而是抓主要矛盾—— 当 N 足够大时,算法的耗时 / 空间主要由 “增长最快的部分” 决定,低阶项和常数的影响可忽略。
二、推导大 O 阶的 3 条规则(结合例子理解)
我们以一个具体的时间复杂度函数 T(N)=3N2+5N+100 为例,演示每条规则的应用:
规则 1:只保留最高阶项,去掉低阶项
- 原理:当 N 极大时,低阶项(如 5N、常数 100)的增长速度远慢于最高阶项(3N2),对整体趋势的影响可忽略。
- 操作:在 T(N)=3N2+5N+100 中,最高阶项是 3N2,因此去掉 5N 和 100,得到 3N2。
规则 2:去掉最高阶项的常数系数
- 原理:当 N 极大时,“系数” 对 “增长趋势” 的影响会被 N 的高次幂稀释。比如 3N2 和 N2,当 N→∞ 时,两者的增长趋势都是 “平方级”,系数 3 可忽略。
- 操作:对规则 1 得到的 3N2,去掉系数 3,得到 N2。
规则 3:无 N 相关项时,用常数 1 取代所有加法常数
- 原理:如果算法的耗时 / 空间与 N 无关(比如不管 N 多大,都只执行固定次数操作),则其复杂度为 “常数级”,统一用 O(1) 表示(而不是具体的常数,如 O(100))。
- 例子:若 T(N)=20(不管 N 是多少,都执行 20 次操作),则复杂度为 O(1)。
三、规则的综合应用(回到最初的例子)
回顾函数 Func1
的执行次数公式:T(N)=N2+2N+10,按照规则推导大 O 复杂度:
- 最高阶项是 N2,去掉低阶项 2N 和常数 10,得到 N2;
- 最高阶项 N2 的系数是 1,无需额外处理;
- 因此,
Func1
的时间复杂度为 O(N2)。
通过大 O 渐进表示法,我们能快速抓住算法 “增长最快的环节”,从而在不纠结细节的情况下,对比不同算法的效率差异(比如 O(N2) 算法的效率远低于 O(N) 算法,当 N 很大时)。
由此,大家便能大致推敲出时间复杂度要如何表示,那么接下来,我们再通过一些例子,再次加强大家的理解
例子二:
int Func2(int N) {
int count = 0;
// 循环1:执行2*N次
for (int k = 0; k < 2 * N; ++k) {
++count;
}
// 循环2:执行10次(常数次)
int M = 10;
while (M--) {
++count;
}
return count;
}
计算Func2的时间复杂度?
一、明确基本操作次数函数 T(N)
首先,通过代码分析得出Func2
中基本操作(++count
)的执行次数与输入规模 N 的关系:
T(N) = 2N + 10
- 其中
2N
来自for (k=0; k<2*N; ++k)
循环(执行 2N 次) - 其中
10
来自while (M--)
循环(固定执行 10 次,与 N 无关)
二、应用大 O 渐进表示法的推导规则
大 O 表示法的核心是忽略对增长趋势无影响的部分,只保留主导项。具体到 T(N) = 2N + 10
:
1. 规则 1:只保留最高阶项
在表达式 2N+10 中:
2N
是线性项(阶数为 1)10
是常数项(阶数为 0)
显然线性项 2N
的增长速度远快于常数项 10
(当 N 足够大时,2N
会远远大于 10)。因此,根据规则 1,先去掉低阶的常数项,得到:
简化后:2N
2. 规则 2:去掉最高阶项的系数
对于简化后的 2N
,系数 2
不影响增长趋势的本质 —— 无论是 2N
还是 N
,它们的增长趋势都是线性增长(随 N 增大而等比例增大)。
根据规则 2,去掉系数后得到:
最终简化:N
3. 规则 3 的补充说明(为何不影响此处结果)
规则 3 的完整描述是:“如果表达式中没有与 N 相关的项(即所有项都是常数),则时间复杂度为 O(1)”。
在Func2
中,由于存在与 N 相关的线性项 2N
,因此规则 3 不适用。但可以通过对比理解:
- 若
T(N) = 10
(无 N 相关项)→ 复杂度为 O(1) - 但
T(N) = 2N + 10
(有 N 相关项)→ 不适用规则 3
例子三:
int Func3(int N, int M) {
int count = 0;
// 第一个循环:执行M次
for (int k = 0; k < M; ++k) {
++count;
}
// 第二个循环:执行N次
for (int k = 0; k < N; ++k) {
++count;
}
return count;
}
计算Func3的时间复杂度?
1. 基本操作次数计算
Func3
包含两个独立循环:
- 第一个循环:执行次数由参数
M
决定,共执行M
次(++count
操作) - 第二个循环:执行次数由参数
N
决定,共执行N
次(++count
操作)
因此,基本操作总次数为:T(N,M)=M+N
2. 时间复杂度推导
根据大 O 渐进表示法规则:
- 当算法的输入规模包含两个独立参数(
N
和M
)时,需保留所有与输入规模相关的最高阶项 - 此处
M
和N
均为一阶项(线性项),且无法确定两者的大小关系(可能M>N
、N>M
或M=N
)
因此,Func3
的时间复杂度为:O(M+N)
关键说明
- 与
Func2
(仅单一输入规模N
)不同,Func3
有两个独立输入参数,两者对复杂度的影响需同时体现 - 若题目明确
M
和N
的关系(如M
远大于N
),可进一步简化(如O(M)
),但默认情况下需保留两者
例子四:
int Func4(int N) {
int count = 0;
// 循环执行固定的100次,与N无关
for (int k = 0; k < 100; ++k) {
++count;
}
return count;
}
计算Func4的时间复杂度?
1. 基本操作次数计算
Func4
中只有一个循环:
- 循环条件为
k < 100
,即固定执行 100 次 - 每次循环执行
++count
操作,总执行次数为 100 次 - 关键特点:执行次数与输入参数
N
完全无关,无论N
取何值(10、1000 甚至 100000),操作次数始终是 100 次
因此,基本操作总次数为:T(N)=100(常数,与N
无关)
2. 时间复杂度推导
根据大 O 渐进表示法规则:
- 当算法的执行次数是与输入规模
N
无关的常数时,时间复杂度统一表示为 O(1) - 此处 100 是固定常数,不随
N
变化,符合O(1)
的定义
因此,Func4
的时间复杂度为:O(1)(常数时间复杂度)
关键说明
O(1)
不表示 “只执行 1 次”,而是表示 “执行次数是固定常数,与输入规模无关”- 即使常数很大(如 10000 次),只要不随
N
变化,复杂度仍是O(1)(因为cpu频率特别快,一秒钟就几亿次处理,所以只要不是太大,系统默认啥也不是)
- 这是效率最高的时间复杂度类型,算法执行时间不会因输入规模增大而变长
经过上面这几个例子,相信大家对时间复杂度的理解一定蹭蹭蹭的往上涨。
我们接下来再看一个例子,这个例子会给我们揭示大O的另一个点
例子五:
const char* my_strchr(const char* str, int character) {
const char* p_begin = str; // 修正原代码中的变量名错误(s应为str)
while (*p_begin != character) {
if (*p_begin == '\0') // 遇到字符串结束符仍未找到
return NULL;
p_begin++; // 指针向后移动
}
return p_begin; // 返回找到的字符位置
}
计算strchr的时间复杂度?
对于这一个,我们就得好好思考一下,这个是不是有多种情况呢?大家可不要认为循环运行次数会是固定的一种情况,这是不一定的,比如上面这一段就是,下面我们来分析一下:
一、函数逻辑回顾
strchr
的作用是在字符串中查找第一个匹配的字符,核心逻辑是:
从字符串首字符开始,逐个遍历字符,直到找到目标字符或遇到字符串结束符 '\0'
。
二、三种场景的基本操作次数
时间复杂度的本质是基本操作(循环 / 判断)的执行次数与输入规模 N(字符串长度)的关系。
1. 最好情况:目标字符在字符串第一个位置
- 执行过程:第一次循环判断就找到目标字符,直接返回。
- 基本操作次数:T(N)=1(只需要 1 次判断)。
- 时间复杂度:由于操作次数是常数,与 N 无关,因此最好情况的时间复杂度为 O(1)。
2. 最坏情况:目标字符在字符串最后一个位置(或不存在)
- 执行过程:需要遍历整个字符串(共 N 个字符),直到最后一个字符才找到(或遍历完所有字符后返回
NULL
)。 - 基本操作次数:T(N)=N(循环执行 N 次)。
- 时间复杂度:操作次数与 N 成线性关系,因此最坏情况的时间复杂度为 O(N)。
3. 平均情况:目标字符在字符串中间位置
- 执行过程:假设目标字符 “等概率” 出现在字符串的任意位置,那么平均需要遍历 2N 个字符才能找到。
- 基本操作次数:T(N)=2N。
- 时间复杂度:根据大 O 表示法的 “忽略常数系数” 规则,2N 简化后仍为 O(N),因此平均情况的时间复杂度为 O(N)。
三、最终结论
- 最好情况:O(1)(最快 1 次就找到)。
- 最坏情况:O(N)(最多遍历整个字符串)。
- 平均情况:O(N)(平均遍历半程字符串)。
通过上面我们会发现,有些算法的时间复杂度存在最好、平均和最坏情况。
- 最坏情况:任意输入规模的最大运行次数(上界)
- 平均情况:任意输入规模的期望运行次数
- 最好情况:任意输入规模的最小运行次数(下界)
而大O的渐进表示法在实际中一般情况关注的是算法的上界,也就是最坏运行情况。
接下来我们不妨看看我们之前使用的冒泡排序的时间复杂度,它也是有三种情况哦:
示例六:冒泡排序:
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的时间复杂度?
一、函数逻辑回顾
冒泡排序的核心是相邻元素比较并交换,每一轮将最大的元素 “冒泡” 到数组尾部。优化版的冒泡排序会用 exchange
标记本轮是否发生交换,若未交换则提前终止(说明数组已有序)。
二、三种场景的基本操作次数
时间复杂度的本质是基本操作(比较、交换)的执行次数与输入规模 N(数组长度)的关系。
1. 最好情况:数组已经有序
- 执行过程:
第一轮循环中,所有相邻元素比较都不满足 “前大后小”,因此exchange
保持为0
,直接跳出外层循环,排序结束。 - 基本操作次数:
仅需 N−1 次比较(内循环执行 N−1 次,无交换),可近似为 T(N)≈N。 - 时间复杂度:
操作次数与 N 成线性关系,因此最好情况的时间复杂度为 O(N)。
2. 最坏情况:数组完全逆序(降序)
- 执行过程:
需要进行 N−1 轮 “冒泡”,每一轮内循环的比较次数依次为 N−1、N−2、...、1。 - 基本操作次数:
总比较次数为 (N−1)+(N−2)+⋯+1=2N(N−1),交换次数与比较次数相同(每次比较都需交换),总操作次数近似为 T(N)≈2N(N+1)(合并比较和交换的次数)。 - 时间复杂度:
操作次数与 N 成平方关系,因此最坏情况的时间复杂度为 O(N^2)。
3. 平均情况:数组无序程度中等
- 执行过程:
统计意义上,平均需要遍历约 2N 轮,每轮内循环平均执行 2N 次操作。 - 基本操作次数:
总操作次数近似为 T(N)≈4N2。 - 时间复杂度:
根据大 O 表示法 “忽略常数系数” 的规则,4N2 简化后仍为 O(N2),因此平均情况的时间复杂度为 O(N^2)。
三、最终结论
- 最好情况:O(N)(数组已完全有序时,效率最高)。
- 最坏情况:O(N^2)(数组完全逆序时,效率最低)。
- 平均情况:O(N^2)(大多数无序数组的平均效率)。
在算法分析中,我们通常关注 “最坏情况”(它决定了算法的 “性能底线”),因此冒泡排序的时间复杂度一般表述为 O(N^2)。
例子七:对数时间复杂度:
void func5(int n) {
int cnt = 1;
while (cnt < n) {
cnt *= 2;
}
}
那么这一段代码,就可以让大家大致知道对数时间复杂度,我们看分析,这边说一下,大家在判断时间复杂度的时候,不妨去代入一些例子进行尝试:
一、函数逻辑回顾
func5
的核心是一个 while
循环:
- 初始时
cnt = 1
; - 每次循环将
cnt
乘以 2(cnt *= 2
); - 当
cnt >= n
时,循环终止。
二、执行次数的推导(从例子到公式)
图中通过具体例子观察规律:
- 当
n = 2
时,循环执行 1 次(1 → 2
,此时2 >= 2
,循环终止); - 当
n = 4
时,循环执行 2 次(1 → 2 → 4
,此时4 >= 4
,循环终止); - 当
n = 16
时,循环执行 4 次(1 → 2 → 4 → 8 → 16
,此时16 >= 16
,循环终止)。
三、通用公式推导
假设循环执行次数为 x,则每轮循环后 cnt
的值为:
- 第 1 次循环后:
cnt = 2^1
; - 第 2 次循环后:
cnt = 2^2
; - ...
- 第 x 次循环后:
cnt = 2^x
。
循环终止的条件是 cnt >= n
,因此当 2^x >= n
时,循环停止。为了找到最小的执行次数(即最坏情况下的次数,因为要刚好满足 2^x >= n
),我们取等号 2^x = n
。
对等式 2^x = n
两边取以 2 为底的对数,可得:
x=log2 n
四、时间复杂度结论
时间复杂度描述的是基本操作次数与输入规模 n 的增长关系。这里的基本操作是 “cnt *= 2
及循环判断”,执行次数为 log2n。
根据大 O 表示法的规则:
- 对数的底数不影响复杂度的 “增长趋势”(例如 log2n 和 log10n 仅相差常数系数);
- 因此可以统一省略底数,表述为 O(log n)。
关键说明:
- O(logn) 是效率非常高的时间复杂度,因为对数函数的增长极其缓慢(比如 n=106 时,log2n 仅约 20)。
- 这种 “每次将规模折半 / 倍增” 的逻辑,是二分查找、快速幂等高效算法的核心思想。
说到二分查找,我这里就给大家再扩展一下二分查找吧:
二分查找:
二分查找是一种在有序数组中高效查找目标元素的算法。其核心思想是:
- 每次将查找范围缩小一半,通过比较中间元素与目标值的大小,确定目标值在左半部分还是右半部分,从而淘汰一半的元素。
- 重复这个过程,直到找到目标元素或确定目标元素不存在。
算法步骤(以升序数组为例)
- 确定查找范围的左右边界
left
(初始为数组起始索引)和right
(初始为数组末尾索引)。 - 计算中间位置
mid = (left + right) / 2
(整数除法)。 - 比较中间元素
arr[mid]
与目标值target
:- 如果
arr[mid] == target
,找到目标元素,返回mid
(或其他表示找到的标识)。 - 如果
arr[mid] > target
,说明目标值在左半部分,将right
更新为mid - 1
。 - 如果
arr[mid] < target
,说明目标值在右半部分,将left
更新为mid + 1
。
- 如果
- 重复步骤 2 - 3,直到
left > right
,此时说明数组中没有目标元素,返回未找到的标识(如-1
)。
#include <stdio.h>
// 二分查找函数,arr为有序数组,n为数组长度,target为目标值
int binarySearch(int arr[], int n, int target) {
int left = 0;
int right = n - 1;
while (left <= right) {
int mid = left + (right - left) / 2; // 防止left + right溢出
if (arr[mid] == target) {
return mid; // 找到目标值,返回索引
} else if (arr[mid] > target) {
right = mid - 1; // 目标值在左半部分
} else {
left = mid + 1; // 目标值在右半部分
}
}
return -1; // 未找到目标值
}
int main() {
int arr[] = {1, 3, 5, 7, 9, 11, 13};
int n = sizeof(arr) / sizeof(arr[0]);
int target = 7;
int result = binarySearch(arr, n, target);
if (result != -1) {
printf("目标值 %d 在数组中的索引为 %d\n", target, result);
} else {
printf("数组中未找到目标值 %d\n", target);
}
return 0;
}
我主要是想对这一段进行扩展以及补充:
while (left <= right)
- 比较中间元素
arr[mid]
与目标值target
:- 如果
arr[mid] == target
,找到目标元素,返回mid
(或其他表示找到的标识)。 - 如果
arr[mid] > target
,说明目标值在左半部分,将right
更新为mid - 1
。 - 如果
arr[mid] < target
,说明目标值在右半部分,将left
更新为mid + 1
。
- 如果
大家要知道,上面这个情况是左闭右闭的区间时,我们要这么写,可要是数组区间是左闭右开区间呢?我们还能这么写吗?
是不能的,我们不能再用
while (left <= right)
去作为循环终止条件,我们应该用while (left < right)作为循环终止条件
因为在左闭右开区间的定义里,right
所代表的位置是不属于查找范围的。所以循环条件要改为 while (left < right)
,因为当 left == right
时,区间 [left, right)
是没有元素的,不需要再进行查找了。
同时我们初始化right时,也不再是n-1,因为我们是右开区间,所以我们就得初始化为n,原因如下:
一、左闭右开区间的定义
左闭右开区间表示为 [left, right)
,其核心是:
- 区间包含
left
位置的元素(左闭); - 区间不包含
right
位置的元素(右开)。
二、数组的有效索引范围
假设数组长度为 n
,那么数组的有效索引是 0, 1, 2, ..., n-1
(共 n
个元素)。
三、结合区间与数组索引分析 right
的初始化
我们需要让整个数组都被包含在初始的查找区间内:
- 左边界
left
初始化为0
(包含第一个元素); - 右边界
right
若初始化为n
,则区间为[0, n)
。根据左闭右开的定义,这个区间包含的索引是0, 1, ..., n-1
,正好覆盖了数组的所有有效索引。
如果 right
初始化为 n-1
,则区间为 [0, n-1)
,根据右开的定义,这个区间不包含 n-1
索引,会导致数组的最后一个元素(索引 n-1
)永远不会被检查,(因为我们的循环终止条件是while (left < right))
总结
左闭右开区间下,right
初始化为 n
,是为了让初始区间 [0, n)
刚好覆盖数组的所有有效索引(0
到 n-1
),保证整个数组都能被二分查找遍历。
除此之外,我们还要注意
else if (arr[mid] > target)
{
right = mid - 1;
}
这一段代码,我们应该将其改为:
else if (arr[mid] > target)
{
right = mid;
}
原因如下:
一、左闭右开区间的定义
左闭右开区间表示为 [left, right)
,其含义是:
left
位置包含在区间内(左闭);right
位置不包含在区间内(右开)。
例如,区间 [2, 5)
包含的元素是 2, 3, 4
(不包含 5
)。
二、二分查找的 “排除” 逻辑
当 arr[mid] > target
时,说明:
- 目标值
target
一定在mid
左侧(因为数组有序,mid
右侧元素都比arr[mid]
大,更不可能等于target
); - 因此,
mid
及其右侧的所有元素都可以排除,不再参与后续查找。
三、结合区间定义分析 right
的更新
在左闭右开区间 [left, right)
中,right
本身是 “不包含” 的边界。
当需要排除 mid
及其右侧元素时:
- 新的右边界应设置为
mid
,因为区间[left, mid)
会排除mid
及右侧元素(右开区间不包含mid
)。 - 例如,原区间是
[left, 10)
,若mid=5
需被排除,则新区间变为[left, 5)
,自然不包含5
及右侧元素。
四、对比 “左闭右闭区间” 的差异
在左闭右闭区间([left, right]
)中:
right
是 “包含” 的边界,因此排除mid
时,需要将right
更新为mid - 1
(因为mid
本身也需被排除,而[left, mid - 1]
不包含mid
)。
总结
左闭右开区间的核心是 “右边界不包含”,因此排除 mid
时,直接将 right
设为 mid
即可(利用右开的特性排除 mid
);而左闭右闭区间需要显式用 mid - 1
来排除 mid
。
在这里我再强调并总结一下,在我们的二分查找中,比较之后,mid这个位置的数据是不用再进入下面的比较中的,那么在左闭右闭区间中,我们是需要显式用 mid - 1
来排除 mid,而在左闭右开区间中,由于我们while循环的终止条件已经帮我们排除了right这个位置的数据,所以我们在循环内部就不必再mid-1,直接mid就行。
下面通过代码示例来更清楚地展示两种情况
#include <stdio.h>
// 左闭右开区间的二分查找
int binarySearchOpen(int arr[], int n, int target) {
int left = 0;
int right = n; // 右开,所以right初始化为n
while (left < right) { // 左闭右开,left不能等于right
int mid = left + (right - left) / 2;
if (arr[mid] == target) {
return mid;
} else if (arr[mid] > target) {
right = mid; // 右开,所以更新为mid,因为mid位置已经被排除,右开区间不包含mid
} else {
left = mid + 1;
}
}
return -1; // 未找到
}
int main() {
int arr[] = {1, 3, 5, 7, 9};
int n = sizeof(arr) / sizeof(arr[0]);
int target = 5;
int result = binarySearchOpen(arr, n, target);
if (result != -1) {
printf("找到目标值,索引为:%d\n", result);
} else {
printf("未找到目标值\n");
}
return 0;
}
大家不妨想想如果是左开右开、左开右闭的情况,代码又是什么样子的呢?我在这里就不告诉大家啦。
接下来,我们可以看看二分查找的时间复杂度:
二分查找的时间复杂度为 O(logn),下面详细解释原因:
核心逻辑
二分查找每次都会将当前的查找范围缩小一半。例如,初始查找范围是 n 个元素,第一次查找后范围缩小到 2n,第二次缩小到 4n,第三次缩小到 8n,以此类推。
推导过程
假设经过 k 次查找后,查找范围缩小到只剩下 1 个元素(此时要么找到目标元素,要么确定目标元素不存在)。那么有:
- 2kn=1
- 对等式两边进行变形,求解 k:
- 2k=n
- 两边取以 2 为底的对数,可得:
- k=log2n
时间复杂度结论
这里的 k 就是二分查找最多需要进行的查找次数,而时间复杂度描述的是算法执行次数与输入规模 n 的增长关系。由于查找次数 k 与 log2n 成正比,所以二分查找的时间复杂度为 O(logn)(在大 O 表示法中,对数的底数不影响复杂度的量级,所以可以省略底数,直接写成 O(logn))。
这种时间复杂度意味着,当数据规模 n 很大时,二分查找的效率非常高。比如,当 n=106 时,log2106≈20,只需要大约 20 次查找就能完成,远快于线性查找(需要最多 106 次查找)。
最后,我们再来看看一个时间复杂度为O(2^N)的代码,这里说明一下,当时间复杂度为O(2^N)的时候,那么这段代码就会=和废铁一样没什么区别了,运行所需时间非常庞大,我这里只是给大家扩展一下:
时间复杂度为O(2^N)的代码:
其实大家对它应该是不陌生的,它就是我们的斐波那契数列递归函数:
// 递归计算斐波那契数列第n项
// 斐波那契定义:F(0)=0,F(1)=1,F(n)=F(n-1)+F(n-2)(n>1)
int Fibonacci(int n) {
// 递归终止条件
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
// 递归调用:第n项等于前两项之和
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
一、递归函数的执行流程
斐波那契数列的递归定义为:
F(n)=⎩⎨⎧01F(n−1)+F(n−2)if n=0if n=1if n>1
对应的递归函数每次计算 F(n) 时,都会拆解为两个子问题:计算 F(n−1) 和 F(n−2),直到触达基准情况(n=0 或 n=1)。
二、通过递归树分析执行次数
以 F(5) 为例,其递归调用关系可形成如下递归树:
F(5)
/ \
F(4) F(3)
/ \ / \
F(3) F(2) F(2) F(1)
/ \ / \ / \
F(2) F(1) F(1) F(0) F(1) F(0)
/ \
F(1) F(0)
- 树的深度:递归树的深度为 n(从根节点 F(n) 到叶节点 F(0) 或 F(1) 共 n 层)。
- 节点数量:每一层的节点数构成斐波那契数列本身。第 k 层(从 0 开始计数)的节点数为 F(k)+1(近似),总节点数约为 2F(n+1)−1。
三、时间复杂度的数学推导
时间复杂度的核心是基本操作的总执行次数,这里的基本操作是 “递归调用” 和 “加法运算”,每个节点对应一次基本操作。
1. 递推公式建立
设 T(n) 为计算 F(n) 的总操作次数,则:
- 基准情况:T(0)=1(直接返回 0),T(1)=1(直接返回 1)。
- 递归情况:T(n)=T(n−1)+T(n−2)+1(计算 F(n−1) 的次数 + 计算 F(n−2) 的次数 + 1 次加法操作)。
2. 递推公式求解
通过展开递推公式:
T(n)=T(n−1)+T(n−2)+1=[T(n−2)+T(n−3)+1]+[T(n−3)+T(n−4)+1]+1=…(持续展开,直到基准情况)
最终可证明 T(n) 与斐波那契数列本身的增长趋势一致,即:
T(n)=O(F(n))
3. 斐波那契数列的渐近增长
斐波那契数列的通项公式(比内公式)为:
F(n)=5ϕn−ψn
其中 ϕ=21+5≈1.618(黄金比例),ψ=21−5≈−0.618。由于 ∣ψn∣<1,当 n 足够大时可忽略,因此:
F(n)≈5ϕn
即 F(n) 是指数级增长的,底数为 ϕ≈1.618。
四、时间复杂度结论
由于 T(n) 与 F(n) 同阶,而 F(n) 是指数级增长(底数约 1.618),因此递归实现的斐波那契数列时间复杂度为:
O(2^n)
(注:严格来说是 O(ϕn),但 ϕ<2,为便于表述和强调指数级增长的低效性,通常简化为 O(2^n))。
那么我们如何改善它呢?其实也很简单,抓住斐波那契数列的定义并借助循环就行,具体如下:
// 迭代法计算斐波那契数列第n项
// 定义:F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2) (n≥2)
int fibonacci(int n) {
// 处理边界情况
if (n < 0) {
printf("错误:n不能为负数\n");
return -1; // 非法输入标识
}
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
// 初始化前两项
int prev_prev = 0; // F(n-2)
int prev = 1; // F(n-1)
int current; // 当前项F(n)
// 从第2项迭代计算到第n项
for (int i = 2; i <= n; i++) {
current = prev_prev + prev; // 当前项 = 前两项之和
prev_prev = prev; // 更新F(n-2)为上一轮的F(n-1)
prev = current; // 更新F(n-1)为当前项
}
return current;
}
一、核心逻辑回顾
迭代法通过循环累加计算斐波那契数列,核心逻辑是:
- 初始化前两项
prev_prev = F(0) = 0
,prev = F(1) = 1
; - 从第 2 项开始,通过循环计算每一项:
current = prev_prev + prev
; - 每次循环后更新
prev_prev
和prev
的值,为下一次计算做准备; - 循环执行到第
n
项时终止,返回结果。
二、基本操作次数统计
时间复杂度的核心是基本操作的总执行次数与输入规模 n
的关系。这里的基本操作包括:
- 循环内的加法运算(
current = prev_prev + prev
); - 循环内的赋值操作(
prev_prev = prev
和prev = current
); - 循环条件判断(
i <= n
)。
具体执行次数分析:
- 当
n = 0
或n = 1
时:直接返回结果,不进入循环,基本操作次数为 0; - 当
n >= 2
时:循环从i = 2
执行到i = n
,共执行n - 1
次循环(例如n = 5
时,循环执行 4 次:i=2,3,4,5
)。
每次循环包含:
- 1 次加法运算;
- 2 次赋值操作;
- 1 次循环条件判断(最后一次循环后还会多 1 次判断用于退出循环)。
总基本操作次数约为 3*(n-1) + 1
(常数项可忽略),即与 n
成线性关系。
三、时间复杂度结论
根据大 O 表示法的规则:
- 只关注与输入规模
n
相关的最高阶项,忽略常数系数和低阶项; - 此处总操作次数是
O(n)
级别的,与n
呈线性增长。
因此,迭代法实现的斐波那契数列时间复杂度为:O(n)。
到这里,我们对时间复杂度的了解就可以告一段落了。
空间复杂度:
其实空间复杂度在当今时代,并不是那么重要了,毕竟如今科技日新月异,早就有可以储存很多很多数据的机器、服务器等等,所以有些时候,我们会用空间复杂度去换时间复杂度,毕竟时间是最宝贵的,那么接下来我们就来了解了解空间复杂度吧:
空间复杂度也是一个数学表达式,是对一个算法在运行过程中因为算法的需要额外临时开辟的空间。
空间复杂度不是程序占用了多少bytes的空间,因为常规情况每个对象大小差异不会很大,所以空间复
杂度算的是变量的个数。
空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法。
注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时候显式申请的额外空间来确定
总结一下就是,空间复杂度就是你的代码在其原本应有的变量数量下多了几个变量,为几便为多少。
我们下面看两个例子,大家就能理解了:
例子一:冒泡排序:
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
:用于控制外层循环的范围,是一个整数变量;exchange
:用于标记本轮是否发生交换,是一个整数变量;i
:用于控制内层循环的计数,是一个整数变量;- 还有用于交换元素的临时变量(在
Swap
函数内部,通常也是几个固定变量)。
- 这些变量的数量是固定的,不会随着输入数组的大小 n 的变化而变化。
结论
无论输入数组的规模 n 有多大,冒泡排序额外使用的内存空间都是固定的常数级别的。根据大 O 表示法的规则,常数级别的空间复杂度记为 O(1)。
例子二:
// 递归计算阶乘
// 定义:0! = 1,n! = n × (n-1)! (n > 0)
long long Fac(size_t N) {
if (N == 0) {
return 1; // 递归终止条件:0的阶乘为1
}
return Fac(N - 1) * N; // 递归调用:n! = n × (n-1)!
}
计算阶乘递归Fac的空间复杂度?
一、空间复杂度的定义
空间复杂度是衡量算法在运行过程中临时占用存储空间大小的指标,主要关注:
递归调用栈的深度(递归函数特有的栈空间消耗);
- 额外使用的辅助变量、数据结构等占用的空间。
二、递归调用栈的分析
递归函数 Fac(N)
的执行过程是:
- 调用
Fac(N)
→ 调用Fac(N-1)
→ 调用Fac(N-2)
→ … → 调用Fac(0)
; - 当
Fac(0)
返回后,依次回溯计算Fac(1)
、Fac(2)
、…、Fac(N)
。
在这个过程中,每一层递归调用都会在栈上分配一个 “栈帧”,栈帧包含:
- 函数参数(如
N
的值); - 局部变量(本题中无额外局部变量);
- 返回地址(函数执行完后回到哪里继续执行)。
三、栈空间的最大占用
递归调用的深度决定了栈空间的最大占用:
- 从
Fac(N)
到Fac(0)
,共需要 N+1 层递归调用(例如N=3
时,调用链是Fac(3) → Fac(2) → Fac(1) → Fac(0)
,共 4 层); - 每一层栈帧的空间是常数级(因为参数和局部变量的数量固定,与 N 无关)。
四、辅助空间分析
函数 Fac
中没有使用额外的动态内存(如数组、链表、malloc
分配的空间),仅使用了:
- 函数参数
N
(每次调用的参数是常数级空间); - 临时的返回值(计算
Fac(N-1) * N
时的中间结果,也是常数级空间)。
五、空间复杂度结论
由于栈空间的最大占用与递归深度 N 成正比(N+1 层,每层是常数空间),且辅助空间是常数级,因此:
递归计算阶乘的空间复杂度为 O(N)。
关键说明
- 递归的空间复杂度不关注 “时间上的重复计算”,只关注 “空间的最大占用”;
- 若将递归改为迭代(用循环实现阶乘),空间复杂度可优化为 O(1)(仅需常数个变量)。
结语:
写到这里,关于时间复杂度和空间复杂度的探索就告一段落了。这两个看似抽象的概念,实则是衡量算法优劣的标尺,是我们从 “能写出代码” 到 “能写出好代码” 的必经之路。
回顾开篇时的感慨,C 语言只是编程之路的起点,而数据结构与算法才是真正考验我们思维深度的关卡。时间复杂度教会我们用 “增长的眼光” 看待问题 —— 当数据规模不断扩大时,O (n) 与 O (n²) 的差距会变成天堑,O (log n) 的优势会愈发耀眼。空间复杂度则提醒我们在资源有限的场景下权衡取舍 —— 用常数空间 O (1) 完成的算法,往往比动辄占用 O (n) 空间的实现更显功力。
或许此刻你会觉得,这些分析过程有些繁琐,甚至会疑惑 “实际写代码时真的需要如此较真吗?” 但请相信,当你面对百万级、千万级数据,当你在笔试中需要优化最后一个测试用例,当你在项目中为了性能瓶颈彻夜调试时,这些曾经刻意练习的分析能力,会成为你最坚实的底气。
就像我们从递归斐波那契的 O (2ⁿ) 优化到迭代的 O (n),从冒泡排序的 O (n²) 联想到更快的排序算法,每一次对复杂度的优化,都是对思维的锤炼。数据结构的世界里,没有一蹴而就的捷径,但每一步扎实的理解,都会让我们在面对更复杂的问题时更加从容。
接下来的篇章里,我们将带着这些分析工具,深入二叉树、红黑树等更复杂的数据结构。愿你我都能带着这份对效率的追求,在编程之路上继续深耕 —— 毕竟,写出能运行的代码只是开始,写出优雅而高效的代码,才是我们对技术最真诚的致敬。
最后,还是忍不住抱怨一句:太阳终于落山了,码字的快乐又多了几分~ 我们下一章节再见!