ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

时间复杂度与空间复杂度详解:数据结构入门必备

时间复杂度与空间复杂度详解:数据结构入门必备 数据结构是计算机专业的必修课也是自学编程路上绕不开的一座山。而这座山的第一道坡就是时间复杂度和空间复杂度。我第一次接触这两个概念时特别困惑数据结构不是讲链表、栈、树、图吗为什么要先学一套看起来像数学课的东西后来刷题多了才真正明白不懂复杂度你连“为什么数组比链表快”“为什么二分查找比顺序查找好”都解释不清更别提在面试里被问到“这个操作的复杂度是多少”时的窘迫了。这篇内容是数据结构系列的第一篇适合刚入门数据结构的学生、准备考研和期末复习的同学以及自学编程想补基础的朋友。我会从最朴素的角度出发把时间复杂度和空间复杂度的概念、分析方法和常见坑拆开讲透再配上几个可以直接跟着分析的代码例子。看完之后你至少能独立分析常见循环和递归代码的复杂度也能在实验报告里写出像样的分析结论。1. 复杂度的本质先算账再动手1.1 为什么数据结构的入门课是复杂度大多数数据结构的教材第一页讲抽象数据类型第二页开始讲算法效率很多人不理解为什么不能直接上链表、二叉树非要先研究一堆数学符号。我的理解是数据结构研究的是“数据怎么存”算法研究的是“数据怎么算”而复杂度就是衡量“存”和“算”到底划不划算的尺子。举个生活里的例子。收拾行李箱有人把所有东西塞得严严实实箱子确实装得下但取东西时要把整个箱子翻一遍有人准备了几个收纳袋分门别类放好取东西快但袋子本身就占空间。数据结构的世界里每天都在做这种选择数组和链表数组按下标访问快但插入慢链表插入快但访问慢哈希表查询几乎可以做到O(1)但需要额外的桶和哈希函数计算空间B树适合磁盘IO但节点维护成本高。如果你不理解背后的复杂度差异就只能靠背结论换个场景就不知道怎么选。复杂度分析的作用就是让我们在写代码之前先“心算”一遍当数据规模n很大的时候这个算法的耗时和内存消耗会怎么增长。它不关心你跑在什么机器上也不关心编译器优化了多少它只关心增长趋势。可以简单理解为复杂度是算法的一种“体能指标”告诉你它是能跑马拉松还是只能跑百米冲刺。注意复杂度分析的是“增长趋势”不是“具体执行时间”。同一个算法在不同机器上的绝对时间可能差很多倍但复杂度不会因此改变。所以面试官问“时间复杂度多少”时你回答“用了0.3秒”是没有任何意义的。1.2 大O表示法的直观理解大O表示法全称是大O渐进表示法Big O notation用来描述输入规模n趋于无穷大时算法运行时间的上界。中文资料里常说的“O(n)线性增长”“O(log n)对数增长”就是用这套符号来表述的。理解大O先记住三个忽略规则第一忽略常数项。3n和100n在大O眼里都是O(n)因为当n足够大时系数的影响可以忽略。第二忽略低阶项。n²n和n²大O都是O(n²)因为n相对于n²在无穷大面前影响很小。第三只保留最高阶项。这三个规则看着“粗暴”实际分析代码时却是最实用的。判断一段代码的复杂度不需要数每一行执行了多少次只需要找到执行次数最多的那段核心代码看它的执行次数随着n怎么变化即可。常见复杂度从低到高可以排成这样复杂度典型例子直观比喻O(1)数组按下标访问翻书直接翻到目标那一页O(log n)二分查找猜数字时每次排除一半O(n)遍历数组求和一页一页按顺序翻书O(n log n)归并排序把书先分堆再合并O(n²)双重循环两两比较每两个人都握手一次O(2ⁿ)朴素斐波那契递归每加一层工作量直接翻倍这个表我建议你有空就默写一遍。后面学排序算法、图算法、动态规划时所有结论都建立在这张表的基础上。2. 时间复杂度代码快不快先算再跑2.1 时间复杂度的定义与计算步骤时间复杂度的正式定义是算法中基本操作重复执行的次数是问题规模n的函数记作T(n)。再通过大O表示法描述T(n)的增长趋势就得到时间复杂度。很多人被定义吓到其实算起来就是三步第一步找出基本操作。基本操作通常指最内层循环体里的那条语句简单理解就是“整个算法里重复执行次数最多、最费时间的那句话”。第二步列出执行次数与n的关系。设总执行次数为T(n)根据循环边界和循环条件写出关于n的表达式。第三步用大O表示法简化。去掉常数、低阶项和系数只保留最高阶项得到最终的时间复杂度。我以前带过一个学弟每次分析复杂度都纠结“printf算不算基本操作”其实没必要。当你把某个操作认定为核心操作之后其他语句最多影响常数倍大O表示法下不会改变结果。另外不管你看的是C语言版、Java语言版还是Python版的数据结构教材复杂度分析的方法完全通用因为复杂度描述的是算法本身不描述具体语言的语法。2.2 常见复杂度级别从O(1)到O(n²)光背定义没用要配合实例去感受每个复杂度的“手感”。O(1)的典型是数组按下标访问int a[10]; int x a[3];不管数组有多长按下标取值就是一次寻址操作用时固定所以是O(1)。O(n)的典型是遍历数组累加int sum 0; for (int i 0; i n; i) { sum a[i]; }循环体执行n次T(n) n时间复杂度O(n)。O(n²)的典型是双重循环for (int i 0; i n; i) { for (int j 0; j n; j) { printf(%d , i * j); } }内层循环执行n次外层又控制内层执行n轮总共n²次复杂度O(n²)。这里要特别提醒一个细节双重循环不一定是O(n²)。比如下面这段for (int i 0; i n; i) { for (int j i; j n; j) { printf(%d , i * j); } }内层循环次数分别是n、n-1、n-2、...、1总和是n(n1)/2。去掉系数和低阶项之后依然是O(n²)。结论和前面一样但计算过程完全不同。如果你只会“看到双重循环就写O(n²)”遇到内层循环边界是i的情况虽然答案碰巧也是O(n²)但逻辑是错的考试换一个问法就翻车。O(log n)的典型是二分查找int left 0, right n - 1; while (left right) { int mid (left right) / 2; if (arr[mid] target) { return mid; } else if (arr[mid] target) { left mid 1; } else { right mid - 1; } }每执行一次循环搜索范围减半。n变成n/2再变成n/4直到范围消失循环次数大约是log₂n。复杂度就是O(log n)。这里的底数2通常被省略因为不同底数之间只是常数倍关系不影响大O结论。2.3 循环边界与变量增长高频易错点考试里最容易出错的不是标准双重循环而是循环变量的变化方式。第一种情况内层循环次数随外层变化for (int i 1; i n; i) { for (int j 1; j i; j) { // 基本操作 } }总执行次数12...n n(n1)/2时间复杂度O(n²)。第二种情况内层循环变量翻倍增长for (int i 0; i n; i) { for (int j 1; j n; j * 2) { // 基本操作 } }内层循环执行次数是log₂n外层是n总复杂度O(n log n)。第三种情况外层循环变量也翻倍for (int i 1; i n; i * 2) { // 基本操作 }循环的i取值为1、2、4、8、...终止条件是i n所以执行次数是log₂n复杂度O(log n)。我在实际辅导中遇到最多的问题就是有人分不清“j”和“j * 2”的区别。前者每次步长1执行n次后者每次翻倍执行log n次。这两种写法在代码里看着都很正常但运行时间的差距在天上地下。分析复杂度时一定要先看清楚循环变量的变化方式再决定用等差数列求和还是对数公式。3. 空间复杂度内存不是无限的3.1 空间复杂度的计算规则算额外空间空间复杂度描述的是算法在运行过程中临时占用存储空间的大小记作S(n)同样用大O表示法。这里有个关键点空间复杂度考察的是“额外空间”也就是除了输入数据本身占用的空间之外算法运行需要临时开辟的辅助空间。举一个容易混淆的例子。写一个函数返回数组最大值int findMax(int a[], int n) { int max a[0]; for (int i 1; i n; i) { if (a[i] max) { max a[i]; } } return max; }这里的数组a是外部传入的输入数据不计入算法的空间复杂度。算法本身只用了max和i两个临时变量所以空间复杂度S(n) O(1)。如果算法内部自己创建了一个大小为n的数组int* copyArray(int a[], int n) { int* b (int*)malloc(sizeof(int) * n); for (int i 0; i n; i) { b[i] a[i]; } return b; }这个malloc就是在运行过程中额外申请的堆内存大小随n线性增长所以空间复杂度S(n) O(n)。空间复杂度的分析对象就三类程序代码占用的空间通常固定不随n变化、输入数据占用的空间题目给定一般不纳入复杂度分析、以及辅助变量、动态分配的内存和递归调用栈这是分析重点。初学者最容易把输入数组的大小也算进去导致空间复杂度全部变成O(n)这就是对“辅助空间”理解不到位。3.2 递归的栈空间很多人漏掉的一块递归函数每次调用系统都会在调用栈中压入一个栈帧保存当前函数的局部变量、参数和返回地址。递归深度为多少栈空间就占用多少。这一点在分析空间复杂度时非常容易被忽略。看一个二分查找的递归实现int binarySearch(int arr[], int left, int right, int target) { if (left right) { return -1; } int mid (left right) / 2; if (arr[mid] target) { return mid; } else if (arr[mid] target) { return binarySearch(arr, mid 1, right, target); } else { return binarySearch(arr, left, mid - 1, target); } }每次递归把区间减半递归深度是log₂n所以空间复杂度S(n) O(log n)。代码没有额外分配数组但系统栈占用的空间就是递归深度决定的。再看朴素递归计算斐波那契数列int fib(int n) { if (n 1) { return n; } return fib(n - 1) fib(n - 2); }这个函数的时间复杂度是O(2ⁿ)很多人知道但问空间复杂度时很多人会答错。虽然每次递归内部只用了常数个变量但递归树的最大深度是n系统栈最多压入n层所以空间复杂度是O(n)而不是O(1)。对比一下迭代版斐波那契只需要维护两个变量prev和curr循环n次空间复杂度O(1)时间复杂度O(n)。同一个问题递归和迭代的空间差异如此明显这正好说明了算法设计对空间消耗的影响有多大。实操心得动态规划题目里有一类“记忆化搜索”本质上就是用额外的表格记录中间结果把时间从O(2ⁿ)降到O(n²)或者O(n)。代价就是额外的O(n)甚至O(n²)空间。你理解了递归栈空间之后再看这些优化方案思路会顺很多。3.3 时间与空间的权衡工程里的真实选择数据结构与算法里有一个很朴素也很重要的观点大多数优化都是拿空间换时间。哈希表是最典型的例子用额外的桶数组和哈希函数计算换来近乎O(1)的查找动态规划用一张辅助表存储中间状态避免重复计算空间占用量和状态数量成正比Redis里的跳表通过多维护几层索引指针加速查找本质上也在空间换时间。反过来的场景也存在。在内存受限的嵌入式设备、单片机上常常宁愿多算几遍也不肯开一个大数组在算法竞赛的极端情况下有时需要把O(n)的辅助空间优化到O(1)比如用快慢指针找链表中间节点。这些决策没有一个统一答案全靠结合硬件条件、数据规模、实时性要求来判断。我在实际做项目时有个体会学生阶段分析复杂度是为了应付考试工作阶段分析复杂度是为了避免线上事故。一次线上接口超时往往是某个调用链路上嵌套循环太多一次内存溢出往往是某个缓存没有控制大小。提前用复杂度思想审视代码很多问题在写的时候就能发现。4. 实操手把手分析一段代码的复杂度4.1 示例一矩阵乘法的完整分析矩阵乘法是数据结构实验报告里的常客。给定两个n×n矩阵A和B计算C A × B标准实现如下for (int i 0; i n; i) { for (int j 0; j n; j) { int sum 0; for (int k 0; k n; k) { sum A[i][k] * B[k][j]; } C[i][j] sum; } }分析时间复杂度最内层是k循环执行n次中间层j循环控制n轮每轮都要完整跑一遍内层最外层i循环又控制n轮。所以总执行次数是n × n × n n³时间复杂度O(n³)。分析空间复杂度除了输入的两个矩阵A、B和输出矩阵C之外算法只用了i、j、k、sum四个临时变量数量固定所以空间复杂度O(1)。你可能觉得这里“输出矩阵C”算不算额外空间我的判断标准是如果C是题目要求返回的结果它属于算法产出的必要存储不计入辅助空间如果算法只是为了中间计算临时申请一个n×n数组那就必须计入。这个标准在实验报告和考试题目里经常作为评分点建议记下来。4.2 示例二双端队列场景带来的复杂度直觉学数据结构时经常会遇到双端队列deque它和复杂度分析的关系也很能说明问题。双端队列可以在队首和队尾两端进行插入和删除操作。不同实现方式复杂度差别很大如果用数组实现队尾插入是O(1)队首插入需要移动元素是O(n)如果用双向链表实现两端的插入删除都是O(1)但按下标随机访问是O(n)。这个例子很好地说明了为什么分析复杂度要结合具体实现。同样是“双端队列”不同底层实现对外表现完全不同。你学数据结构时不能只记结论要能推导出结论为什么数组实现队首插入O(n)因为插入后所有后续元素都要后移一位。为什么链表实现随机访问O(n)因为链表没有连续存储必须从头遍历。这些推导过程比结论本身更有价值。4.3 面试与考试中的复杂度速算技巧不管是统考408还是学校自主命题复杂度分析都是必考点。它往往不单独出大题而是藏在算法设计题、选择题和填空题里。结合“数据结构期末复习”和“数据结构考研”的场景我总结几个非常实用的速算技巧。第一循环嵌套看层数。标准的n次循环嵌套k层复杂度通常是O(n^k)。如果内层循环边界随外层变化用求和公式算完再化简结果大概率不变过程一定要会写。第二递归看递归树。线性递归每次只调用一次自身的空间和时间分别看递归深度和递归次数树形递归一次调用多个自身的时间往往是指数级比如朴素斐波那契。看到递归先画递归树这是最快的判断方式。第三排序算法复杂度直接记结论。常见的快速排序平均O(n log n)、最坏O(n²)归并排序稳定O(n log n)但空间O(n)堆排序O(n log n)且空间O(1)。这些在高频考点里反复出现建议整理成表反复默写。第四题目给“最坏情况”和“平均情况”要分清。比如快速排序最坏情况出现在每次划分极度不平衡时但平均情况是O(n log n)。面试里如果只问了“快排复杂度”而不强调场景你最好把平均和最坏都说清楚再补一句“可以通过随机选基准点来避免最坏情况”这段回答立刻加分。注意考试和面试里的复杂度问题往往不是问你“是多少”而是问你“为什么是这么多”。所以在平时的练习里每道题都要写出推导过程不要只填一个答案。我自己帮人改实验报告时最反感的就是“根据资料可知复杂度为O(n²)”这种毫无推导的写法。5. 常见误区与避坑清单5.1 四个高频大坑第一个坑把最坏情况当成唯一答案。算法复杂度分析分为最好、平均、最坏三种情况但绝大多数教材和面试默认讨论最坏情况。如果你没有指明是哪种情况别人默认你在说最坏情况但如果你想展示自己对复杂度的理解可以主动把三种情况都列出来。第二个坑忽略递归的栈空间。很多人分析递归算法只算时间复杂度空间复杂度直接写O(1)。这是错的递归深度无论多少都占用调用栈空间。哪怕每次递归的局部变量只有几个深度n时栈空间就是O(n)。第三个坑把“输入规模n”搞错。有些代码的复杂度看起来是O(n²)但n的实际含义是二维矩阵的边长而不是元素总数。如果n是边长n²才是矩阵元素个数遍历矩阵的复杂度应该是O(n²)但按元素总数m来算就是O(m)。分析前先明确n到底代表什么。第四个坑忽略常数因子的实际影响。大O表示法会忽略常数但工程里常数因子真的很重要。一个O(n)但常数是100的算法在小数据规模下可能比一个O(n²)但常数是1的算法还慢。复杂度是理论工具不是唯一决策依据真实性能还要结合数据规模跑基准测试。5.2 避坑技巧用数据验证直觉我自己的经验是复杂度分析很容易出错所以写完之后会用实际数据验证。方法是选几个递增的n值比如n10、100、1000、10000记录程序运行时间或操作次数看增长倍数是否符合理论预期。如果理论是O(n)n扩大10倍耗时应该大约扩大10倍如果理论是O(n²)n扩大10倍耗时大约扩大100倍如果理论是O(log n)n扩大10倍耗时只增加一个很小的常数倍。这种验证方式我在课程实验和项目性能优化里都经常用它能快速帮你发现分析中的错误。实操心得想在本地体会复杂度差异最直接的办法是写一个遍历次数统计程序。定义一个全局计数器在每个基本操作前加一行count最后输出count的值和理论推导对比。我当年学复杂度就是这么干的比空想可靠得多。5.3 学习路径建议怎么把复杂度吃透如果你正在自学数据结构或者准备期末考试、考研我的建议是分三步走。第一步把复杂度基础概念吃透。能不看资料写出大O的三个忽略规则能背出常见复杂度从低到高的顺序能说出O(1)、O(log n)、O(n)、O(n log n)、O(n²)各自对应的典型算法。第二步刷题时每道题都做复杂度分析。不管题目来自力扣、王道还是教材习题每写完一个解法用30秒思考一下时间和空间复杂度写题解时把复杂度分析作为固定板块。坚持一个月分析速度会有质的提升。第三步总结高频算法的复杂度表。排序算法、查找算法、树的操作、图的基础遍历这些模块的复杂度结论整理成一张表考前突击非常高效。很多考研资料和“王道”系列辅导书里都有现成的表格但自己动手整理一遍记忆效果会好很多。我个人的看法是复杂度分析不只是考试工具。你去看开源代码、阅读框架源码时会发现大佬写的代码几乎处处体现复杂度意识能用O(log n)就不用O(n)能用O(1)空间就不用O(n)空间。这种意识不是天生自带就是靠日常刷题和阅读积累出来的。最后再分享一个小技巧学习复杂度时要多问“能不能更快”。看到一个O(n²)的解法问自己“能不能优化到O(n log n)”看到一个O(n)空间的算法问自己“能不能优化到O(1)”。带着这个问题去查资料、去讨论你对数据结构和算法的理解会比单纯背题深得多。这也是为什么我要把复杂度放在“数据结构01”这一篇的原因——它是后续所有数据结构和算法分析的起点。
返回列表