数据结构-时间与复杂度|时间/空间复杂度 + 两道力扣练习 + 二分查找复盘

数据结构入门笔记|从"能跑就行"到"会算复杂度":时间/空间复杂度 + 两道力扣练习 + 二分查找复盘

本文涉及的练习代码已经同步到 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 越来越大时,真正起决定作用的是增长最快的 ,所以最终只保留最高阶项:

时间复杂度:O(N²)

目前我把大 O 的化简先总结成三个容易记的规则:

  1. N 无关的固定次数看成 O(1)
  2. 多项式只保留增长最快的最高阶项;
  3. 忽略最高阶项前面的常数系数。

所以:

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,其中包含从 0n 的所有整数,但缺少了一个数字,需要在 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,和数组里的所有数字全部异或一遍以后,正常出现的数字都会出现两次并相互抵消,最后只剩下缺失的那个数字。

例如缺少 20 ^ 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 - 1mid 并不是可以随便组合的,它们都和区间的开闭状态对应。


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:数据结构练习一(复杂度模块)

相关推荐
Nil2081 小时前
leetcode 三数之和
数据结构·算法·leetcode
带鱼吃猫1 小时前
预处理器:宏、运算符与条件编译
c语言·开发语言
小小龙学IT1 小时前
C++ std::vector 底层实现深度解析:内存布局、扩容策略、移动语义与迭代器失效
开发语言·c++·算法
eBest数字化转型方案2 小时前
从工程视角拆解冰柜资产管理:拍照识别 pipeline、纯净度算法与 IoT 选型踩坑
人工智能·物联网·算法
Interview Aid1122 小时前
Roblox OA 面经|小游戏+编程双修,Coding 两题满分通过
算法
闲研随记2 小时前
【文献阅读 ICLR 2026】RL算法:Co-Rewarding
算法
(Charon)2 小时前
【C++】线程安全队列(二):生产者消费者模型与 condition_variable 阻塞等待
c语言·开发语言·c++
青山木2 小时前
Hot 100 --- 最小栈
java·数据结构·算法·leetcode
疯狂打码的少年2 小时前
【数据结构】栈的应用:表达式求值(后缀表达式)
java·数据结构·笔记·算法