前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
  • 前端算法面试

    • 数组基础
    • 栈、队列与链表
    • 树与二叉树
    • 二叉树递归遍历
    • 时间与空间复杂度
      • 时间复杂度
      • 空间复杂度
      • 小结
    • 数组高频题
    • 字符串高频题
    • 链表基础题
    • 链表双指针
    • 环形链表
    • 栈的经典应用
    • 队列与双端队列
    • DFS 与 BFS
    • 递归与回溯
    • 二叉树高频题
    • 二叉搜索树
    • 平衡二叉树
    • 堆与堆排序
    • 基础排序算法
    • 归并与快速排序
    • 动态规划入门
    • 动态规划进阶
完整面试题地址:
作者:程序员poetry
扫码关注作者公众号:「前端进阶之旅」 每天分享技术干货
前端进阶之旅公众号二维码

算法的衡量——轻松理解时间复杂度与空间复杂度|算法篇

结束了数据结构基本功的学习,接下来在真正开始撸真题之前,大家还需要具备评价算法的能力。

平时我们定义一个人是否“懂行”,一个重要的依据就是看这个人对某一个事物是否具备正确的评价能力。 举个例子,同样是买手机,外行进到手机店,他关注的可能是手机有没有跑马灯、有没有皮套护体、有没有“八心八箭”——这些东西,任何一部手机随便包装一下就都有了,根本没法反映出这台手机的本质问题。但如果是一个相对懂手机的人,他可能就会去关注这台手机的芯片、内存、屏幕材质及分辨率等等,从而对手机的整体性能和质量作出一个合理的判断,这样他买到好手机的概率就更大。

回到做算法题上,也是一样的道理。在面试时,自己给出的算法到底过不过得去,这一点在面试官给出评语之前,自己就应该有所感知。做到这一点,你才会掌握改进算法的主动权。

本节我们要学习的就是评价算法的两个重要依据——时间复杂度和空间复杂度。

很多同学算法入门直接就跪在复杂度理解这一环。时间复杂度、空间复杂度,直接读概念确实太无聊,我们本节从代码入手,大家的理解会更直观一点。

# 时间复杂度

30 秒速记

  • 大 O 描述输入规模增大时的渐进上界,不是精确耗时;分析前必须定义 n 代表什么
  • 顺序代码复杂度相加取主导项,独立嵌套循环通常相乘,相关循环要按实际总迭代次数求和
  • 每轮把问题缩小固定比例通常是 O(log n),递归还要结合子问题数量和每层工作量
  • 哈希 O(1) 常是期望/均摊结论,快排 O(n log n) 常是平均结论,必须带条件
  • 数据规模、常数、缓存局部性、I/O 和最坏延迟都会影响工程选型,量级分析后仍需基准测试

时间复杂度描述输入规模增大时,算法执行次数的增长趋势,而不是精确运行时间。 分析前要明确 n 的含义:顺序代码相加后取主导项,独立嵌套循环通常相乘,但相关循环要计算实际总次数。比如内层规模依次减半时,总工作量小于 2n,即使有两层循环仍是 O(n)。递归要同时看分支、规模缩减和每层工作量,工程选型还应结合常数、缓存、I/O 与基准测试。

看到两层循环不能机械报 O(n²)。下面内层总次数是 n + n/2 + n/4 + ... < 2n,因此整体仍为 O(n):

function halvingWork(items) {
  let operations = 0
  for (let size = items.length; size > 0; size = Math.floor(size / 2)) {
    for (let i = 0; i < size; i += 1) operations += 1
  }
  return operations
}

console.log(halvingWork(Array(8))) // 15
@前端进阶之旅: 代码已经复制到剪贴板

两个前后执行的线性循环是 O(n) + O(n) = O(n),而不是 O(n²)。矩阵应使用行数 r 与列数 c 表达 O(r × c),只有二者都等于 n 才写 O(n²)。递归则必须同时分析分支数、规模缩减和每层额外工作。

大家先来看这样一个问题:下面这段代码,一共会执行多少次?

function traverse(arr) {
    var len = arr.length
    for(var i=0;i<len;i++) {
        console.log(arr[i])
    }
}
@前端进阶之旅: 代码已经复制到剪贴板

首先,最没有悬念的是函数里的第一行代码,它只会被执行1次:

var len = arr.length
@前端进阶之旅: 代码已经复制到剪贴板

其次没有悬念的是循环体:

console.log(arr[i])
@前端进阶之旅: 代码已经复制到剪贴板

for循环跑了 n 次,因此这条语句就会被执行 n 次。

循环体上面的几个部分我们拆开来看,首先是 i 的初始化语句:

var i = 0
@前端进阶之旅: 代码已经复制到剪贴板

初始化只有1次,因此它也只会被执行1次。

接着是 i < len 这个判断。这里有个规律大家可以记下:在所有的 for 循环里,判断语句都会比递增语句多执行一次。在这里,判断语句执行的次数就是 n+1。

再往下就是递增语句 i++ 了,它跟随整个循环体,毫无疑问会被执行 n 次。

假如把总的执行次数记为 T(n),下面咱们就可以来做个简单的加法:

T(n) = 1 + n + 1 + (n+1) + n = 3n + 3
@前端进阶之旅: 代码已经复制到剪贴板

接下来我们看看规模为 n*n 的二维数组的遍历,一共需要执行多少次代码:

function traverse(arr) {
    var outLen = arr.length

    for(var i=0;i<outLen;i++) {
        var inLen = arr[i].length

        for(var j=0;j<inLen;j++) { 
            console.log(arr[i][j])
        }
    }
}
@前端进阶之旅: 代码已经复制到剪贴板

首先仍然是没有悬念的第一行代码,它只会被执行一次:

var outLen = arr.length
@前端进阶之旅: 代码已经复制到剪贴板

接下来我们来看最内层的循环体:

console.log(arr[i][j])
@前端进阶之旅: 代码已经复制到剪贴板

因为咱们是两层循环,所以这货会被执行 n*n = n^2 次。

其它语句的计算思路和咱们第一个🌰区别不大,这里我就不重复讲了,直接给出大家答案:

继续来做个求总执行次数 T(n) 的加法看看:

T(n) = 1 + 1 + (n+1) + n + n + n + n*(n+1) + n*n + n*n = 3n^2 + 5n + 3
@前端进阶之旅: 代码已经复制到剪贴板

代码的执行次数,可以反映出代码的执行时间。但是如果每次我们都逐行去计算 T(n),事情会变得非常麻烦。算法的时间复杂度,它反映的不是算法的逻辑代码到底被执行了多少次,而是随着输入规模的增大,算法对应的执行总次数的一个变化趋势。要想反映趋势,那就简单多了,直接抓主要矛盾就行。我们可以尝试对 T(n) 做如下处理:

  • 若 T(n) 是常数,那么无脑简化为1
  • 若 T(n) 是多项式,比如 3n^2 + 5n + 3,我们只保留次数最高那一项,并且将其常数系数无脑改为1。

经过这么一波操作,T(n) 就被简化为了 O(n):

T(n) = 10  
O(n) = 1
T(n) = 3n^2 + 5n + 3
O(n) = n^2
@前端进阶之旅: 代码已经复制到剪贴板

到这里,我们思路仍然是 计算T(n) -> 推导O(n)。这么讲是为了方便大家理解 O(n) 的简化过程,实际操作中,O(n) 基本可以目测,比如咱们上面的两个遍历函数:

function traverse1(arr) {
    var len = arr.length
    for(var i=0;i<len;i++) {
        console.log(arr[i])
    }
}

function traverse2(arr) {
    var outLen = arr.length

    for(var i=0;i<outLen;i++) {
        var inLen = arr[i].length

        for(var j=0;j<inLen;j++) { 
            console.log(arr[i][j])
        }
    }
}
@前端进阶之旅: 代码已经复制到剪贴板

遍历 N 维数组,需要 N 层循环,我们只需要关心其最内层那个循环体被执行多少次就行了。

我们可以看出,规模为 n 的一维数组遍历时,最内层的循环会执行 n 次,其对应的时间复杂度是 O(n);规模为 nn 的二维数组遍历时,最内层的循环会执行 nn 次,其对应的时间复杂度是 O(n^2)。

以此类推,规模为 nm 的二维数组最内层循环会执行 nm 次,其对应的时间复杂度就是 O(nm);规模为 nn*n 的三维数组最内层循环会执行 n^3 次,因此其对应的时间复杂度就表示为 O(n^3)。

常见的时间复杂度表达,除了多项式以外,还有logn。我们一起来看另一个算法:

function fn(arr) {
    var len = arr.length  
    
    for(var i=1;i<len;i=i*2) {
        console.log(arr[i])
    }
}
@前端进阶之旅: 代码已经复制到剪贴板

这个算法读取一个一维数组作为入参,然后对其中的元素进行跳跃式的输出。这个跳跃的规则,就是数组下标从1开始,每次会乘以二。

如何计算这个函数的时间复杂度呢?在有循环的地方,我们关心的永远是最内层的循环体。这个算法中,我们关心的就是 console.log(arr[i]) 到底被执行了几次,换句话说,也就是要知道 i<n( len === n) 这个条件是在 i 递增多少次后才不成立的。

假设 i 在以 i=i*2的规则递增了 x 次之后,i<n 开始不成立(反过来说也就是 i>=n 成立)。那么此时我们要计算的其实就是这样一个数学方程:

2^x >= n
@前端进阶之旅: 代码已经复制到剪贴板

x解出来,就是要大于等于以 2 为底数的 n 的对数:

也就是说,只有当 x 小于 log2n 的时候,循环才是成立的、循环体才能执行。注意涉及到对数的时间复杂度,底数和系数都是要被简化掉的。那么这里的 O(n) 就可以表示为:

O(n) = logn
@前端进阶之旅: 代码已经复制到剪贴板

没错,这时的主要矛盾,就变成了一个对数表达式。

关于常见的时间复杂度,我们会在后面讲到具体知识点(尤其是排序算法)时,结合实例来给大家做分析。这里大家首先要认识一下常见时间复杂度有哪些,并且对这些常见时间复杂度之间的大小关系做个把握。

常见的时间复杂度按照从小到大的顺序排列,有以下几种:

面试官追问

追问 1代码评审里有人看到 halvingWork 有两层循环,直接标成 O(n²) 并要求重写,你会怎样当场验证他的判断?
参考回答

不能按循环层数直接相乘,应先计算内层循环的总执行次数。各轮规模形成 n + n/2 + n/4 + ...,总和小于 2n,所以整体是 O(n);若循环边界改为每轮都执行 n 次,结论才会变成 O(n log n)。

追问 2数据看板先后用两个循环处理同一批 n 条记录,负责人担心复杂度从 O(n) 升到 O(n²),你会如何解释并决定是否合并循环?
参考回答

两个前后执行的线性循环总工作量是 n + n,忽略常数因子后仍为 O(n)。是否合并应看中间结果、缓存局部性和可读性,而不是为了改变渐进复杂度;若第二个循环实际遍历另一批 m 条数据,则应保留为 O(n + m)。

追问 3表格组件遍历一个 r 行、c 列的矩阵,产品经理坚持需求文档统一写成 O(n²),你为什么会要求改成 O(r × c)?
参考回答

行数与列数是两个可独立变化的输入规模,遍历次数应表达为 r × c。只有明确约束 r = c = n 时才能简写成 O(n²);若线上表格是少量行配大量列,错误记号会掩盖真正随哪一维增长的成本。

追问 4分页接口把循环步长从 i += 1 改成 i *= 2 后,监控显示大数据下增长明显放缓,你会怎样推导它的时间复杂度?
参考回答

循环执行 x 次后索引约为 2^x,终止条件对应 2^x >= n,因此迭代次数是 O(log n)。大 O 中对数底数和常数系数会被忽略;若循环体每轮还处理与当前索引等量的数据,就不能只依据步长判为对数复杂度。

追问 5一个递归搜索每层分成两个调用、输入规模各自减半,同时每层还线性扫描当前数据,候选人只凭“规模减半”报 O(log n),你会追问他补充什么分析?
参考回答

要同时核对递归分支数、子问题规模和每层额外工作,不能只看递归深度。两个规模减半的分支会形成多层调用树,而每层线性工作的总量还需按整层汇总;缺少重复子问题和终止条件信息时,不应武断给出单一复杂度。

# 空间复杂度

← 二叉树递归遍历数组高频题 →

fe
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片NEW
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 业务场景题真实业务问题与追问
  • 查漏补缺常见问题解析
  • AI 模拟面试NEW模拟真实面试 + 报告
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
    • AI 定制路线NEW按你的简历现排
    • AI 知识地图NEW串起全站知识点
  • 动态
    • AI 热点NEWAI 每日动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
AI 助手NEW
旧版
  • 前端算法面试

    • 数组基础
    • 栈、队列与链表
    • 树与二叉树
    • 二叉树递归遍历
    • 时间与空间复杂度
      • 时间复杂度
      • 空间复杂度
      • 小结
    • 数组高频题
    • 字符串高频题
    • 链表基础题
    • 链表双指针
    • 环形链表
    • 栈的经典应用
    • 队列与双端队列
    • DFS 与 BFS
    • 递归与回溯
    • 二叉树高频题
    • 二叉搜索树
    • 平衡二叉树
    • 堆与堆排序
    • 基础排序算法
    • 归并与快速排序
    • 动态规划入门
    • 动态规划进阶