数据结构入门笔记|从"能跑就行"到"会算复杂度":时间/空间复杂度 + 两道力扣练习 + 二分查找复盘
本文涉及的练习代码已经同步到 Gitee: Code_2026:数据结构练习一(复杂度模块)
一、写在前面:代码能跑之后,还要考虑效率
前面学习顺序表、单链表和双向循环链表时,我关注得最多的还是"代码能不能正常运行":指针有没有连对、插入删除会不会崩、最后打印出来的结果是不是正确。进入复杂度这一部分之后,开始多考虑一个以前容易忽略的问题------同样能得到正确答案的两份代码,效率可能完全不是一个级别。
这次感受最明显的还是旋转数组。暴力方法本身没有逻辑错误,小数据运行也没有任何问题,但它需要重复移动大量数组元素,数据规模一大,执行次数就会迅速增加。这让我开始真正理解:逻辑正确只是算法成立的前提,而时间和空间消耗决定了这个解法到底好不好。
复杂度就是用来描述这种效率差异的工具。以后看到一个算法,除了"能不能做出来",还要逐渐养成继续问一句的习惯:
它的时间复杂度和空间复杂度是多少?有没有更优的做法?
二、时间复杂度:不是算几毫秒,而是看增长趋势
时间复杂度并不是拿秒表统计一段代码具体运行了多少毫秒,因为同样的代码换一台电脑、换一个编译器,实际运行时间都会发生变化。真正需要分析的是:
当问题规模
N不断增大时,代码中基本操作的执行次数会按照什么趋势增长?
通常使用大 O 渐进表示法表示,例如:
cpp
void Func1(int N) {
int count = 0;
// 双层循环:N*N 次
for (int i = 0; i < N; ++i)
for (int j = 0; j < N; ++j)
++count;
// 单层循环:2*N 次
for (int k = 0; k < 2 * N; ++k)
++count;
// 常数次:10次
int M = 10;
while (M--)
++count;
}
如果比较精确地统计执行次数,可以写成:F(N) = N² + 2N + 10;
但当 N 越来越大时,真正起决定作用的是增长最快的 N²,所以最终只保留最高阶项:
时间复杂度:O(N²)
目前我把大 O 的化简先总结成三个容易记的规则:
- 与
N无关的固定次数看成O(1); - 多项式只保留增长最快的最高阶项;
- 忽略最高阶项前面的常数系数。
所以:
O(2N) → O(N);
O(N² + N + 10) → O(N²);
O(1000) → O(1);
这里还有一个刚开始很容易看错的地方:代码里出现两个 for 循环,并不意味着一定是 O(N²) 。如果两个循环是前后执行的,就是:N + N = 2N,最终仍然是:O(N);
只有循环发生嵌套时,才更可能出现 N × N 这样的平方级增长。
2.1 最好、平均和最坏情况
同一种算法面对不同输入时,执行次数也可能不同。
例如在长度为 N 的数组中顺序查找某个元素,如果第一个位置就是目标,只需要比较一次;如果目标在最后,或者根本不存在,就可能需要把整个数组遍历完。
因此复杂度还可以分成:
- 最好情况:最少执行次数;
- 平均情况:各种输入情况下的期望;
- 最坏情况:最多执行次数。
目前做题和学习时,一般更关注最坏情况复杂度,因为它给出了算法性能的一个上界。
例如普通顺序查找,即使最好情况下可以一次找到,我们通常仍然把时间复杂度记为:O(N)
2.2 常见时间复杂度先建立直觉
| 复杂度 | 常见例子 | 直观理解 |
|---|---|---|
O(1) |
数组按下标访问 | 数据量变化,操作次数基本不变 |
O(logN) |
二分查找 | 每次排除大约一半 |
O(N) |
数组遍历、链表查找 | 数据翻倍,工作量大致翻倍 |
O(NlogN) |
归并排序、堆排序等 | 常见高效排序量级 |
O(N²) |
双层遍历、部分基础排序 | 数据翻倍,工作量约变四倍 |
O(2^N) |
部分暴力递归 | 数据稍大就快速增长 |
O(N!) |
暴力全排列 | 增长更快 |
现在没必要把所有复杂度死背下来,关键还是看到实际代码之后能够判断:循环跑多少次?有没有嵌套?每次规模缩小多少?
三、空间复杂度:重点看额外使用的空间
空间复杂度同样使用大 O 表示,不过它关注的不是算法一共占了多少内存,而主要看为了完成算法额外使用了多少辅助空间。
例如冒泡排序直接在原数组上交换,只额外使用几个临时变量,所以空间复杂度是:O(1);
如果为了处理一个长度为 N 的数组,又额外申请了一个同样长度的数组,那么辅助空间会随着 N 增长,空间复杂度就是:O(N);
递归算法还要考虑递归调用栈。比如经典的暴力递归斐波那契,虽然会产生大量重复计算,时间复杂度可以达到 O(2^N),但同一时刻函数调用栈最深大约只有 N 层,因此它的空间复杂度是:O(N);
所以时间复杂度和空间复杂度不能混在一起看:一个描述总共需要做多少工作,一个描述运行过程中最多需要多少额外空间。
四、LeetCode 练习一:面试题 17.04------消失的数字
题目给出一个数组 nums,其中包含从 0 到 n 的所有整数,但缺少了一个数字,需要在 O(N) 时间内找出缺失值。
这道题我先写了第一种比较直接的方法。
4.1 方法一:先累加完整范围,再减去已有数字
我的第一版代码:
cpp
int missingNumber (int* nums, int numsSize) {
int sum = 0;
for (int i = 0;i <= numsSize; i++) {
sum += i;
}
for (int j = 0; j < numsSize; j++) {
sum -= nums[j];
}
return sum;
}
思路很简单:数组长度是 numsSize,那么完整数字范围就是:0 ~ numsSize;
先把这个范围内所有数字全部加到 sum 中,再依次减掉数组里真正存在的元素,最后剩下的自然就是缺失的那个数字。
例如:[0,1,3],完整范围应该是:0 + 1 + 2 + 3,然后再减:0、1、3,最后只剩:2;
这段代码有两个前后执行的循环,第一个执行 N+1 次,第二个执行 N 次,总执行量仍然和 N 成正比,因此:
时间复杂度:O(N), 空间复杂度:O(1);
这里也刚好对应了前面提到的一点:两个并列的 O(N) 循环加起来仍然是 O(N),而不是 O(N²)。
4.2 方法二:异或------又遇到了"单身狗"的思路
第二种方法是:
cpp
int missingNumber (int* nums, int numsSize) {
int n = 0;
for (int i = 0;i <= numsSize; i++) {
n ^= i;
}
for (int j = 0; j < numsSize; j++) {
n ^= nums[j];
}
return n;
}
这个思路让我想到之前做过的"只出现一次的数字",也就是我当时习惯叫的"单身狗"问题。
它利用的是异或的几个性质:
a ^ a = 0,0 ^ a = a,并且异或满足交换律和结合律。
把完整的:0 ~ n,和数组里的所有数字全部异或一遍以后,正常出现的数字都会出现两次并相互抵消,最后只剩下缺失的那个数字。
例如缺少 2:0 ^ 1 ^ 2 ^ 3 ^ 0 ^ 1 ^ 3;相同数字两两消掉以后:= 2;
这也是一道比较典型的"一种思想多次使用"的题。以前做单身狗时,异或只是一个解题技巧;这次再遇到类似结构,就开始能够主动联想到之前的方法。
它同样需要遍历 N 量级的数据:时间复杂度:O(N), 空间复杂度:O(1)
除此之外,还可以先给数组排序,然后按照顺序逐个比较应该出现的数字和实际数字。但普通比较排序通常已经需要 O(NlogN) 的时间,因此对于这道明确要求 O(N) 的题来说,就不是更合适的选择了。
五、LeetCode 练习二:旋转数组------"能得到答案"和"解法够不够好"
旋转数组要求把数组中的元素向右轮转 k 个位置,一开始最直接的思路就是:
每次把最后一个元素取出来放到最前面,其余元素整体向后移动一格,一共重复 k 次。
代码:
cpp
void rotate(int* nums, int numsSize, int k) {
k = k % numsSize;
for (int i = 0; i < k; i++) {
int last = nums[numsSize - 1];
for (int j = numsSize - 1; j > 0; j--)
nums[j] = nums[j - 1];
nums[0] = last;
}
}
这段代码逻辑完全正确,但外层循环执行 K 次,每次内层都需要移动 N 量级的数据,因此:
时间复杂度:O(N*K), 空间复杂度:O(1);
当数据规模变大以后,这种重复移动就会产生明显的效率问题。
后来使用三次反转:
cpp
void Reverse(int* n, int left, int right) {
while (left < right) {
int tmp = n[left];
n[left] = n[right];
n[right] = tmp;
left++;
right--;
}
}
void rotate(int* nums, int numsSize, int k) {
if (numsSize <= 1) return;
k = k % numsSize;
if (k == 0) return;
Reverse(nums, 0, numsSize - k - 1);
Reverse(nums, numsSize - k, numsSize - 1);
Reverse(nums, 0, numsSize - 1);
}
例如:[1,2,3,4,5,6,7],k = 3;
第一步反转前四个:[4,3,2,1,5,6,7];
第二步反转后三个:[4,3,2,1,7,6,5];
第三步整体反转:[5,6,7,1,2,3,4];
虽然代码里调用了三次 Reverse,但总处理的数据量仍然只是 N 的常数倍,所以:
时间复杂度:O(N), 空间复杂度:O(1)
这道题也是我这次学习复杂度之后感受最明显的一题:暴力解法不是错,而是还可以继续优化。
以前看到输出正确可能就停下来了,现在会开始继续想:这份代码在大数据下还行不行?
六、课程补充:老师再次强调二分查找,我又重新写了一遍
二分查找之前已经接触过,所以这次不算是因为学习数据结构又重新学习了一遍。课程讲到复杂度之后,老师又专门把它拿出来提醒,尤其强调了一个非常容易写乱的问题:二分查找的循环条件和边界更新,必须和自己定义的搜索区间保持一致。
二分查找确实非常快,如果有大约十亿个已经排好序的数据,每次比较之后都可以排除一半,那么最多只需要大约 30 次比较,就可以把范围缩小到一个元素。
这也是:O(logN) 和普通 O(N) 顺序查找最直观的差别。
不过数组上的二分查找也有明显的使用前提:数据需要有序,并且适合随机访问。后面课程还会继续学习二叉搜索树、红黑树以及 B 树等结构,它们会从不同角度解决动态数据查找、插入和删除等问题。
因为老师这次又强调了边界问题,所以我也重新写了一遍,并且相比之前稍微正式一些,把它拆成:Binary Search.h,Binary Search.c,test.c三个文件。
6.1 Binary Search.h
cpp
#pragma once
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
typedef int Typedata;
Typedata BinarySearch(Typedata* arr,Typedata n,Typedata x);
6.2 Binary Search.c
cpp
#include"Binary Search.h"
//左闭右闭方法
Typedata BinarySearch(Typedata* arr, Typedata n,Typedata x)
{
int left = 0;
int right = n - 1;
while (left <= right)
{
int mid = left + ((right - left) / 2);
if (x < arr[mid])
{
right = mid - 1;
}
else if (x > arr[mid])
{
left = mid + 1;
}
else
{
return mid;//找到了,返回下标
}
}
return -1;//没找到返回-1;
}
//左闭右开方法
//Typedata BinarySearch(Typedata* arr, Typedata n, Typedata x)
//{
// int left = 0;
// int right = n;//右开区间
// while (left < right)
// {
// int mid = left + (right - left) / 2;
// if (x < arr[mid])
// {
// right = mid;
// }
// else if (x > arr[mid])
// {
// left = mid + 1;
// }
// else
// {
// return mid;//找到了,返回下标
// }
// }
// return -1;//没找到返回-1;
//}
这里实际使用的是左闭右闭 [left,right]。
既然 right 本身属于搜索范围,那么初始化就应该:right = n - 1;
当:left == right时,区间里仍然还有一个元素,所以循环条件必须是:left <= right;
如果已经判断:x < arr[mid],那么 mid 自己也已经不可能是答案,因此新的区间应该变成:[left, mid - 1];也就是:right = mid - 1。
第二种注释掉的写法则是左闭右开 [left,right)。
因为 right 本来就不包含在有效区间里,所以初始化成:right = n;
循环使用:left < right;
当需要排除 mid 右侧时,则直接:right = mid;
这次老师重新提醒之后,我感觉二分最核心的其实不是背哪一种代码,而是:先定义区间,再让整个代码从头到尾遵守同一套规则。
<=、<、mid - 1、mid 并不是可以随便组合的,它们都和区间的开闭状态对应。
6.3 test.c
cpp
#include"Binary Search.h"
int main()
{
Typedata arr[10] = { 0,1,3,5,9,11,16,19,30,66 };
Typedata num = (sizeof arr)/(sizeof arr[0]);
int n = 0;
//scanf("%d",&n);
Typedata m = BinarySearch(arr,num,n);
if (m == -1)
{
printf("No found!");
}
else
{
printf("Found it!\nsubscript is %d", m);
}
return 0;
}
相比一个文件里直接把函数和 main 全部写在一起,这次继续沿用了前面数据结构学习时的习惯,把接口声明、功能实现和测试代码分开。虽然二分查找本身代码并不长,但这样写也算是继续练习更规范的代码组织方式。
另外,中间位置继续写成:
cpp
int mid = left + ((right - left) / 2);
而不是:
cpp
(left + right) / 2
这样可以避免极端情况下 left + right 先发生整数溢出,也是老师讲二分时比较值得记住的细节。
二分查找最终:时间复杂度:O(logN), 空间复杂度:O(1)。
七、把这次内容放到一起看
这次真正练习的内容其实不算多,但刚好能够把几种复杂度对应到真实代码上:
| 内容 | 时间复杂度 | 空间复杂度 | 主要收获 |
|---|---|---|---|
| 消失的数字------累加减法 | O(N) |
O(1) |
两个并列循环仍然是 O(N) |
| 消失的数字------异或 | O(N) |
O(1) |
联系之前"单身狗"题的异或思想 |
| 排序后查缺失值 | 通常 O(NlogN) |
视排序而定 | 能做,但不满足本题 O(N) 的目标 |
| 旋转数组------暴力移动 | O(N*K) |
O(1) |
正确解法也可能效率不足 |
| 旋转数组------三次反转 | O(N) |
O(1) |
用算法减少重复操作 |
| 二分查找 | O(logN) |
O(1) |
区间定义和边界更新必须统一 |
以前看到这些 O(N)、O(logN) 更像是在记概念,现在开始逐渐能把它们和代码对应起来。
尤其是两个地方印象比较深:
一是两个并列循环不等于 O(N²);二是代码能得到正确答案,也不代表它已经是一个足够好的解法。
八、写在最后
从顺序表、单链表再到双向循环链表,前面一段时间我写代码时关注的重点一直是"结构有没有写对"。进入复杂度之后,开始多了一层新的判断:
写对之后,它的效率怎么样?
消失的数字让我重新用到了之前"单身狗"的异或思想,也第一次比较明确地把两个并列循环和 O(N) 联系起来;旋转数组则让我真正看到暴力解和优化解之间的效率差距。
二分查找算是这节课后额外的一次复习。不是第一次接触,但老师再次强调开闭区间之后,我重新写了一遍,也把代码拆成三个文件。相比第一次只是"会写二分",这次更关注为什么是 <=、为什么要 -1、什么时候又应该直接写 right = mid。
目前复杂度还只是刚刚入门,但我觉得自己的思考方式已经开始发生一点变化。
以前是:这个代码怎么写出来?
现在会在后面再补一句:写出来以后,它还能不能更好?
后面继续学习栈、队列、树以及更多算法时,也准备把复杂度分析逐渐变成每次写代码后的固定步骤,而不是只在专门学复杂度的时候才去计算。
完整练习代码已经上传至 Gitee: Code_2026:数据结构练习一(复杂度模块)