时间复杂度和空间复杂度

时间复杂度和空间复杂度

  • 概述:本篇内容主要摘抄该链接中的书的内容,只对整个内容进行了大概的整理和一些总结

算法效率评估

设计算法时追求两个层面的目标:

  • 找到问题解法。算法需要能够在规定的输入范围下,可靠地求得问题的正确解。
  • 寻求最优解法。同一个问题可能存在多种解法,而我们希望算法效率尽可能的高。

算法效率则是主要评价维度,包括:

  • 时间效率,即算法的运行速度的快慢。
  • 空间效率,即算法占用的内存空间大小。

数据结构与算法追求“运行速度快、占用内存少”,而如何去评价算法效率则是非常重要的问题,因为只有知道如何评价算法,才能去做算法之间的对比分析,以及优化算法设计。

时间复杂度

在算法中时间复杂度指的是算法运行时间随着数据量变大时的增长趋势。

统计算法运行时间

运行时间能够直观且准确地体现出算法的效率水平,因此如果想要准确预估一段代码的运行时间,该如何做呢?

  • 首先需要 确定运行平台,包括硬件配置、编程语言、系统环境等,这些都会影响到代码的运行效率
  • 评估 各种计算操作的所需运行时间,例如加法操作 + 需要 1 ns ,乘法操作 * 需要 10 ns ,打印操作需要5 ns 等
  • 根据代码 统计所有计算操作的数量,并将所有操作的执行时间求和,即可得到运行时间

实际上,统计算法的运行时间既不合理也不现实。首先,我们不希望预估时间和运行平台绑定,毕竟算法需要跑在各式各样的平台之上。其次,我们很难获知每一种操作的运行时间,这为预估过程带来了极大的难度

统计时间增长趋势

在算法中,统计时间增长趋势通常指的是随着输入数据规模的增加,算法的执行时间是否呈现出逐渐增加的趋势。以下是统计时间增长趋势的步骤

  • 确定输入规模:首先需要确定该算法的输入数据规模,通常使用变量n表示。这个规模可以指输入数据* 的长度、大小或数量等,具体取决于算法的应用场景。
  • 时间趋势分析,根据算法实现的代码以及对其运行时间的观察和分析。
  • 绘制增长趋势图:将算法的输入规模n作为横轴,将算法的执行时间作为纵轴,绘制出算法的增长趋势图

直接统算算法运行时间和统计时间增长趋势之间比较

  • 统计时间增长趋势的方式可以有效评估算法效率,
  • 统计时间增长趋势的方式推算方法更加简便
  • 统计时间增长趋势的方式存在局限性。其会受到输入规模等因素的影响

时间复杂度的推算方法

统计操作数量

对着代码,从上到下一行一行地计数即可。然而,由于上述 𝑐 ⋅ 𝑓(𝑛) 中的常数项 𝑐 可以取任意大小,因此操作数量 𝑇 (𝑛) 中的各种系数、常数项都可以被忽略。根据此原则,可以总结出以下计数偷懒技巧:

  • 跳过数量与 𝑛 无关的操作。因为他们都是 𝑇 (𝑛) 中的常数项,对时间复杂度不产生影响。
  • 省略所有系数,例如,循环 2𝑛 次、5𝑛 + 1 次、⋯⋯,都可以化简记为 𝑛 次,因为 𝑛 前面的系数对时间复杂度也不产生影响。
  • 循环嵌套时使用乘法。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用上述 1. 和 2. 技巧。

判断渐近上界

时间复杂度由多项式 𝑇 (𝑛) 中最高阶的项来决定。这是因为在 𝑛 趋于无穷大时,最高阶的项将处于主导作用,其它项的影响都可以被忽略。

常见类型

设输入数据大小为 𝑛 ,常见的时间复杂度类型有(从低到高排列)
𝑂(1) < 𝑂(log 𝑛) < 𝑂(𝑛) < 𝑂(𝑛 log 𝑛) < 𝑂(𝑛2) < 𝑂(2𝑛) < 𝑂(𝑛!)
常数阶 < 对数阶 < 线性阶 < 线性对数阶 < 平方阶 < 指数阶 < 阶乘阶

  • 常数阶O(1)
    常数阶的操作数量与输入数据大小 𝑛 无关,即不随着 𝑛 的变化而变化。如下面代码所示,size的大小不会随n的变化而变化
/* 常数阶 */
function constant(n) {
    let count = 0;const size = 100000;
    for (let i = 0; i < size; i++) 
        count++;
    return count;
}
  • 线性阶 𝑂(𝑛)
    场景:遍历一维数组、遍历链表比较常见
    线性阶的操作数量相对输入数据大小成线性级别增长。线性阶常出现于单层循环。如下面的代码所示:
function quadratic(n) {
    let count = 0;// 循环次数与数组长度成平方关系
    for (let i = 0; i < n; i++) {
        for (let j = 0; j < n; j++) {
            count++;
        }
    }
    return count;
}
  • 平方阶 𝑂(𝑛2)
    场景:遍历二维数组,冒泡排序等
    平方阶的操作数量相对输入数据大小成平方级别增长。平方阶常出现于嵌套循环,外层循环和内层循环都为𝑂(𝑛) ,总体为 𝑂(𝑛2)
function quadratic(n) {
    let count = 0;// 循环次数与数组长度成平方关系
    for (let i = 0; i < n; i++) {
        for (let j = 0; j < n; j++) {
            count++;
        }
    }
    return count;
}
  • 指数阶 𝑂(2𝑛)
    场景:生物中的细胞分裂方式,指数阶常出现于递归函数
    指数阶增长得非常快,在实际应用中一般是不能被接受的。若一个问题使用「暴力枚举」求解的时间复杂度是𝑂(2𝑛) ,那么一般都需要使用「动态规划」或「贪心算法」等算法来求解。如下代码所示是模 拟细胞分裂的代码
function exponential(n) {
    let count = 0,base = 1;// cell 每轮一分为二,形成数列 1, 2, 4, 8, ..., 2^(n-1)
    for (let i = 0; i < n; i++) {
        for (let j = 0; j < base; j++) {
            count++;
        }
        base *= 2;
     }
     return count; // // count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1
}
  • 对数阶 𝑂(log 𝑛)
    场景:二分查找以及分治算法
    对数阶与指数阶正好相反,后者反映“每轮增加到两倍的情况”,而前者反映“每轮缩减到一半的情况”。对数阶仅次于常数阶,时间增长得很慢,是理想的时间复杂度。如下代码所示:
/* 对数阶(循环实现) */
function logarithmic(n) {
    let count = 0;
    while (n > 1) {
        n = n / 2;
        count++;
    }
    return count;
}
  • 线性对数阶 𝑂(𝑛 log 𝑛)
    场景:主流排序算法的时间复杂度都是 𝑂(𝑛 log 𝑛) ,例如快速排序、归并排序、堆排序等。
    线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 𝑂(log 𝑛) 和 𝑂(𝑛) ,如下面的代码所示:
/* 线性对数阶 */
function linearLogRecur(n) {
    if (n <= 1) return 1;
    let count = linearLogRecur(n / 2) + linearLogRecur(n / 2);
    for (let i = 0; i < n; i++) {
        count++;
    }
    return count;
}
  • 阶乘阶 𝑂(𝑛!)
    阶乘阶对应数学上的「全排列」。即给定 𝑛 个互不重复的元素,求其所有可能的排列方案,则方案数量为𝑛! = 𝑛 × (𝑛 − 1) × (𝑛 − 2) × ⋯ × 2 × 1阶乘常使用递归实现,如下所示:
/* 阶乘阶(递归实现) */
function factorialRecur(n) {
    if (n == 0) return 1;
    let count = 0;
    // 从 1 个分裂出 n 个
    for (let i = 0; i < n; i++) {
        count += factorialRecur(n - 1);
    }
    return count;
}
  • 最差、最佳、平均时间复杂度
    某些算法的时间复杂度不是恒定的,而是与输入数据的分布有关。举一个例子,输入一个长度为 𝑛 数组 nums ,其中 nums 由从 1 至 𝑛 的数字组成,但元素顺序是随机打乱的;算法的任务是返回元素 1 的索引。我们可以得出以下结论:
    • 当 nums = [?, ?, …, 1],即当末尾元素是 1 时,则需完整遍历数组,此时达到 最差时间复杂度 𝑂(𝑛);
    • 当 nums = [1, ?, ?, …] ,即当首个数字为 1 时,无论数组多长都不需要继续遍历,此时达到 最佳时间复杂度 Ω(1) ;

「函数渐近上界」使用大 𝑂 记号表示,代表「最差时间复杂度」。与之对应,「函数渐近下界」用 Ω 记号(OmegaNotation)来表示,代表「最佳时间复杂度」。

最差或最佳时间复杂度只出现在“特殊分布的数据”中,这些情况的出现概率往往很小,因此并不能最真实地反映算法运行效率。相对地,「平均时间复杂度」可以体现算法在随机输入数据下的运行效率,用 Θ 记号(Theta Notation)来表示。

对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此元素 1 出现在任意索引的概率都是相等的,那么算法的平均循环次数则是数组长度的一半 𝑛2 ,平均时间复杂度为 Θ( 𝑛2) = Θ(𝑛) 。
但在实际应用中,尤其是较为复杂的算法,计算平均时间复杂度比较困难,因为很难简便地分析出在数据分布下的整体数学期望。这种情况下,我们一般使用最差时间复杂度来作为算法效率的评判标准
实际中我们经常使用「大 𝑂 符号」来表示「平均复杂度」,这样严格意义上来说是不规范的。这可能是因为 𝑂 符号实在是太朗朗上口了。如果在本书和其他资料中看到类似 平均时间复杂度𝑂(𝑛) 的表述,请你直接理解为 Θ(𝑛) 即可。

空间复杂度

统计 算法使用内存空间随着数据量变大时的增长趋势。

算法相关空间

  • 输入空间,用于存储算法的输入数据;
  • 暂存空间,用于存储算法运行中的变量、对象、函数上下文等数据;
  • 输出空间,用于存储算法的输出数据;

推算方法

从统计“计算操作数量”变为统计“使用空间大小”。与时间复杂度不同的是,我们一般只关注「最差空间复杂度」。这是因为内存空间是一个硬性要求,我们必须保证在所有输入数据下都有足够的内存空间预留。最差空间复杂度中的“最差”有两层含义,分别为输入数据的最差分布、算法运行中的最差时间点。

  • 以最差输入数据为准。当 𝑛 < 10 时,空间复杂度为 𝑂(1) ;但是当 𝑛 > 10 时,初始化的数组 nums 使用 𝑂(𝑛) 空间;因此最差空间复杂度为 𝑂(𝑛) ;
  • 以算法运行过程中的峰值内存为准。程序在执行最后一行之前,使用 𝑂(1) 空间;当初始化数组 nums 时,程序使用 𝑂(𝑛) 空间;因此最差空间复杂度为 𝑂(𝑛) ;

一般时间复杂度案例,如下代码所示:

function algorithm(n) {
    const a = 0; // O(1)
    const b = new Array(10000); // O(1)
    if (n > 10) {
        const nums = new Array(n); // O(n)
    }
}

在递归函数中,需要注意统计栈帧空间。如下所示:

  • 如函数 loop(),在循环中调用了 𝑛 次 function() ,每轮中的function() 都返回并释放了栈帧空间,因此空间复杂度仍为 𝑂(1) 。
function constFunc() {
    // do something
    return 0;
}

// 调用上面函数,空间复杂度为O(n)
function loop(n) {
    for (let i = 0; i < n; i++) {
        constFunc();
    }
}
  • 递归函数中调用自身,递归函数 recur() 在运行中会同时存在 𝑛 个未返回的 recur() ,从而使用 𝑂(𝑛) 的栈帧空间。如下代码所示
function recur(n) {
    if (n === 1) return 1;
    return recur(n-1);
}

常见类型

设输入的数据为n,常见的空间复杂度类型有(从低到高排列)
𝑂(1) < 𝑂(log 𝑛) < 𝑂(𝑛) < 𝑂(𝑛2) < 𝑂(2𝑛)
常数阶 < 对数阶 < 线性阶 < 平方阶 < 指数阶

  • 常数阶 𝑂(1)
    常数阶常见于数量与输入数据大小 𝑛 无关的常量、变量、对象。
    注意:在循环中初始化变量或调用函数而占用的内存,在进入下一循环后就会被释放,即不会累积占用空间,空间复杂度仍为 𝑂(1) 。如下代码所示:
function constant(n) {
    // 常量、变量、对象占用 O(1) 空间
    const a = 0;
    const b = 0;
    const nums = new Array(10000);
    const node = new ListNode(0);
    // 循环中的变量占用 O(1) 空间
    for (let i = 0; i < n; i++) {
        const c = 0;
    }
    // 循环中的函数占用 O(1) 空间
    for (let i = 0; i < n; i++){
        constFunc();    
    }
}    
  • 线性阶 𝑂(𝑛)
    线性阶常见于元素数量与 𝑛 成正比的数组、链表、栈、队列等。线性阶递归函数,如下代码所示:
/* 线性阶 */
function linear(n) {
    // 长度为 n 的数组占用 O(n) 空间
    const nums = new Array(n);
    // 长度为 n 的列表占用 O(n) 空间
    const nodes = [];
    for (let i = 0; i < n; i++) {
        nodes.push(new ListNode(i));
    }
    // 长度为 n 的哈希表占用 O(n) 空间
    const map = new Map();
    for (let i = 0; i < n; i++) {
        map.set(i, i.toString());
    }
}

/* 线性阶(递归实现) */
function linearRecur(n) {
    console.log(`递归 n = ${n}`);
    if (n === 1) return;
    linearRecur(n - 1);
}
  • 平方阶 𝑂(𝑛2)
    平方阶常见于元素数量与 𝑛 成平方关系的矩阵、图。递归函数初始化一维数组,如下代码所示:
/* 平方阶 */
function quadratic(n) {
    // 矩阵占用 O(n^2) 空间
    const numMatrix = Array(n).fill(null).map(() => Array(n).fill(null));
    // 二维列表占用 O(n^2) 空间
    const numList = [];
    for (let i = 0; i < n; i++) {
        const tmp = [];
        for (let j = 0; j < n; j++){
            tmp.push(0);        
        }    
        numList.push(tmp);
    }
    return numList;
}

/* 平方阶(递归实现) */
function quadraticRecur(n) {
    if (n <= 0) return 0;
    const nums = new Array(n);
    console.log(`递归 n = ${n} 中的 nums 长度 = ${nums.length}`);
    return quadraticRecur(n - 1);
}
  • 指数阶 𝑂(2𝑛)
    指数阶常见于二叉树。高度为 𝑛 的「满二叉树」的结点数量为 2𝑛 − 1 ,使用 𝑂(2𝑛) 空间。如建二叉树过程。如下代码所示:
function buildTree(n) {
    if (n === 0) return null;
    const root = new TreeNode(0);
    root.left = buildTree(n - 1);
    root.right = buildTree(n - 1);
    return root;
}
  • 对数阶 𝑂(log 𝑛)
    对数阶常见于分治算法、数据类型转换等。
    例如「归并排序」,长度为 𝑛 的数组可以形成高度为 log 𝑛 的递归树,因此空间复杂度为 𝑂(log 𝑛) ,如下代码所示。
function mergeSort(arr) {
  if (arr.length <= 1) {
    return arr;
  }
  
  const middle = Math.floor(arr.length / 2);
  const leftArr = arr.slice(0, middle);
  const rightArr = arr.slice(middle);

  return merge(mergeSort(leftArr), mergeSort(rightArr));
}

function merge(leftArr, rightArr) {
  let resultArr = [];

  while (leftArr.length && rightArr.length) {
    if (leftArr[0] < rightArr[0]) {
      resultArr.push(leftArr.shift());
    } else {
      resultArr.push(rightArr.shift());
    }
  }

  return resultArr.concat(leftArr, rightArr);
}

时间复杂度和空间复杂度的权衡

实际情况下,希望算法的时间复杂度和空间复杂度都能够达到最优,而实际上,同时优化时间复杂度和空间复杂度是非常困难的。
降低时间复杂度,往往是以提升空间复杂度为代价的,反之亦然。我们把牺牲内存空间来提升算法运行速度的思路称为「以空间换时间」;反之,称之为「以时间换空间」。选择哪种思路取决于我们更看重哪个方面。大多数情况下,时间都是比空间更宝贵的,只要空间复杂度不要太离谱、能接受就行,因此以空间换时间最为常用。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值