理论 | 带你认识算法

318 阅读13分钟

引言

为什么要学习 算法, 它到底能给我带来什么呢?

  1. 对于求职者而, 算法 能力能成为你拿到优质 offer 的敲门砖, 不管是大厂还是国外面试都要求有一定 算法 能力
  2. 算法 是解决某类问题的思路, 通过它可以很好培养我们的逻辑思维能力
  3. 也许在平常工作中你用不到你所学的 算法, 但是当遇到一些复杂问题, 它可以帮助你在复杂的情况中更好地分析问题、解决问题、从而寻找出最优的解决问题的思路
  4. 让我们自己研究 算法 很难, 但是目前很多 算法 都是几代人努力, 研究出来的, 我们可以直接研究学习, 站在巨人的肩膀上成就自我, 何乐而不为呢
  5. 算法 是一种解决问题的思路和方法, 也许有机会应用到生活和事业的其他方面呢
  6. 长期来看, 大脑思考能力是个人最重要的核心竞争力, 而算法是为数不多的能够有效训练大脑思考能力的途径之一

所以相信我, 学习 算法 应该是一件很酷的事情, 下面请开始进入正题...

一、什么是算法

首先一句话说明什么是算法, 算法是解决问题的思路或步骤!

比如 "如何将任意排列的数字, 按从小到大的顺序排列"、"寻求出发点到目的地的最短路径"等, 它们是一个个实际的问题, 那么基于问题有多种解题思路, 而这些思路, 其实就是所谓的算法。

1.1 算法和程序的区别

上文提到算法是解题思路, 我们可以通过人类所能够理解的方式进行描述, 可以是文字、可以是图片、可以是动画....

而程序则是计算机所能理解的代码段, 本质上它和算法是有所不同的:

  • 算法是解题思路, 是人类所能理解的想法、思路
  • 程序则是计算机能够理解的语法、步骤, 封装而成的代码块

同一个问题, 可以有多种解法, 也就是有多种算法。算法是具体的思路, 是可以被人类所理解的, 而同一个算法它可以使用不同的计算机语言去实现。当然相同的编程语言、相同算法, 编写出来的代码也是有可能不同的(因为用到的基础语法可能存在差异)但底层的思路是相同的!!

当然实际上我们在设计算法的过程中, 也是需要根据所选择的编程语言进行合理的设计!! 切勿天马行空, 最后发现设计出来的算法, 计算机编程语言并不能实现!!!

1.2 举个栗子: 排序

排序算法是将一组随机数字, 按照一定顺序(升序或降序)进行排列的算法。在计算机科学中, 排序算法有很多种, 常见的包括冒泡排序、选择排序、插入排序、快速排序、归并排序、堆排序等等。

下面我们以 选择算法 为例, 讲解下如果针对一组数字进行升序排序

image

在设计算法时, 假设整个程序是一个函数, 那么我们则需要考虑几个点:

  • 输入, 在这里输入就是一组数字, 这里可以是一个数字数组
  • 输出, 这里输出也是一组数字, 是一组排序完成后的数字, 这里同样可以是一个数字数组
  • 主体逻辑, 其实就是算法部分, 也就是排序的一个思路
const main = (nums: number[]): number[] => {
  // 算法逻辑
}

那么对于 选择排序 算法, 其思路是怎样的呢? 如下图所示:

  • 第一次先循环所有数字, 从列表中找到最小的一个数字, 然后和最左边的数字进行交换
  • 第二次循环从第二个位置开始, 从剩余的列表中找到最小的一个数字, 然后和循环开始位置(第二个位置)的数字进行交换
  • 第三次循环从第三个位置开始, 从剩余的列表中找到最小的一个数字, 然后和循环开始位置(第三个位置)的数字进行交换
  • 以此类推, 直到最后

ScreenFlow

这里我们没有限制数组长度, 输入数组长度为 n, 不管 n 多大, 思路是一样的, 我们的算法都应该可以解决问题

二、选择合适的算法

针对同一个问题, 解决的算法可以是多种多样的, 我们又可以如何一个合适的算法呢? 在算法的评判上, 可以考量的标准各有不同。我们通常会综合考虑算法的可理解性(方便编写程序)、算法运行所需占用的空间资源以及算法运行所需的时间。

当然, 一般情况下我们更重视算法的所运行的时间, 即从输入数据到输出结果整个过程所要花费的时长。

下面我们来看个例子, 来说明下不同算法之间的差距到底有多大!!

2.1 全排列算法

下面我们看一个低效率的排序算法, 来感受下其威力, 该方法被称为 全排列算法 你可以理解为暴力破解, 其主要思路就是:

  1. 根据输入数字, 随机打乱生成一个数列(不和前面生成的数列顺序重复)
  2. 如果上面一步生成的数列是按从小到大的顺序排列, 则将其输出, 否则继续上一步操作

该算法若在运气好的情况下, 很快就随机出正确的数列, 结果也就立马出来了。然而, 实际情况往往并不如我们所愿。最差的情况, 也就是直到最后才出现正确排列的情况下, 计算机就不得不列出所有可能的数列了。

假设这里 n50, 那么在最差情况下, 全排列次数则为:

50! = 50 * 49 * 48 ... * 13 * 12 * 11 * 10...  * 3 * 2 * 1 > 10^40

这里为方便计算, 我们姑且 50 个数字全排列次数按 10^40 来算, 那么假设 1 台高性能计算机 1 秒能检查 1万亿(=10^12)个数列, 那么检查 10^40 个数列将花费的时间为 10^40 ÷ 10^12 = 10^28 秒。1 年为 31536000 秒, 不到 10^8 秒, 因此, 10^28 秒 > 10^20 年

然而, 你要知道从大爆炸开始宇宙也就经历了约 137 亿年, 那么即便如此也少于 10^11 年。也就是说, 仅仅是对 50 个数字进行排序, 若使用全排列算法, 就算花费宇宙年龄的 10^9 倍时间也得不出答案。

2.2 选择排序

上文情况, 我们如果换成 选择排序 又会是啥情况呢?

选择排序 中假设输入为 n, 为了在第 1 轮找到最小的数字, 则需要从左往右确认数列中的数字, 如此只要查询 n 个数字即可。在接下来的第 2 轮中, 只需要从 n-1 个数字中寻找最小值, 所以需要查询 n-1 个数字, 在接下来的第 3 轮中, 需要从 n-2 个数字中寻找最小值, 所以需要查询 n-2 个数字, 以此类推, 我们需要查询的次数为:

n + (n - 1) + (n - 2) + (n - 3) + ... + 3 + 2 + 1 = n(n + 1)/2 <= n^2

如此的话, 同样当 n 等于 50 情况下, 选择排序 也只需要 n^2 = 2500 即可, 同样假设 1 秒能确认 1 万亿(=1012)个数字, 那么 2500 ÷ 10^12=0.0000000025 秒便能得出结果, 这不知比全排列算法的效率高了不知道多少倍!!

三、时间复杂度

算法运行时间和输入数据量呈正相关: 从上面内容不难得出结论, 不同的算法很大程度上会影响到算法运行的时长。 当然不难想象在使用相同算法情况下, 数据量的不同同样会影响算法的运行时间, 数据量越大运行时间自然也越久。

3.1 运行时间计算

实际情况, 给我们一个算法我们其实根本没法精确的计算出程序所需要运行的时长, 因为程序运行时间是会根据所运行的计算机环境所影响, 即便在同一机器上, 运行相同的程序, 最终的运行时间也是存在差异的。

所以这里我们其实可以用 步数 来描述一个算法所能运行的时间, 通过计算程序从开始到结束所需要执行多少步, 从而得出算法的一个运行时间。如此在输入相同情况下, 通过比较算法所执行的 步数, 我们就可以得出哪个算法时间效率最高。

下面我们还是拿 选择排序 为例, 我们先回顾下选择排序的思路:

  1. 从剩余的数列中, 寻找最小的数字(这里我们假设每次循环中, 确认最小值所需时间为 T1)
  2. 将最小值和剩余队列的最左边数字交换位置, 本轮循环结束, 回到上一步 👆🏻(这里我们假设, 交换数字所需时间为 T2)

而, 选择排序 算法运行时间计算结果则如下:

image

3.2 运行时间表示方法

虽然在上文我们已经得出运行时间的结果, 但是实际上还是可以进行一个简化的:

  1. 在公式中, T1T2 都是基本的单位, 和输入数据量 n 没有任何关系, 所以它们其实可以忽略
  2. 同时在整个公式中, 随着输入数据量的增加或减小, 对整个运行时间影响最大的是 n^2 所以我们只需要保留它即可

最后上面公式简化后结果如下:

image

上面公式结果中, 我们使用了 O 来进行表示, O 这个符号的意思是 忽略重要项以外的内容 读音同 OrderO(n^2) 的含义就是算法的运行时间最长也就是 n2 的常数倍, 准确的定义请参考相关专业书籍。重点在于, 通过这种表示方法, 我们可以直观地了解算法的时间和数据量 n 的关系。

同样的,

  • 假设某个算法运行时间计算为 5 * T1 * n^3 + 12 * T2 * n^2 + 3 * T3 * n 那么其时间复杂度可以表示为 O(n^3)
  • 假设某个算法运行时间计算为 3 * n * logn + 2 * T1 * n 那么其时间复杂度可以表示为 O(n * logn)

从时间复杂度上来看, 对于不同时间复杂度的算法, 我们很容易就可以判断出这几个算法哪个更为高效。比如, 当我们知道选择排序的时间复杂度为 O(n^2)、快速排序的时间复杂度为 O(nlogn) 时, 很快就能判断出快速排序的运算更为高速。二者的运行时间根据输入 n 产生的变化程度也一目了然。

四、空间复杂度

这里的空间复杂度其实和上面讲到的时间复杂度很是类似, 只是衡量的维度不同:

  • 时间复杂度描述的是, 数据量 n 和程序运行时间的一个关系(趋势)
  • 而空间复杂度则是描述, 数据量 n 和程序运行时所占用内存空间的一个关系(趋势)

而算法程序在运行期间, 涉及到的内存空间主要有: 输入空间、暂存空间、输出空间

image

空间复杂度推算方法和表示方式, 基本和时间复杂度一样, 但是空间复杂度这里则需要注意几个要点:

  1. 以最差情况为准: 假设输入数据量不同, 空间复杂度不同, 那么这里就要取最差的情况! 比如, 当输入数据量 n < 10 时, 空间复杂度为 O(1), 当输入数据量 n > 10 时, 空间复杂度为 O(n), 那么我们则需要取 O(n)
  2. 以程序运行的峰值内存为准: 在算法程序运行期间可能存在不同的情况, 我们需要以内存占用最高的为止。比如, 程序在运行的最初占用 O(n) 空间, 当运行到最后一行, 内存占用为 O(1), 那么我们则需要取 O(n)
  3. 在递归函数中, 需要注意统计栈帧空间(具体看下面例子)
  4. 在循环过程中, 定义的块级变量或调用的函数, 在下一轮循环后会被销毁, 因此这里是不会累积占用空间复杂度, 空间复杂度依然是 O(1)

下面是递归函数演示代码:

  • 函数 loop() 执行了 constFunc, 执行期间创建一个上下文, 执行完成则销毁, 所以这里空间复杂度为 O(1)
  • 递归函数 recur() 在运行过程中会同时存在 n 个未返回的 recur() 也就是同时存在 n 个上下文, 所以这里空间复杂度为 O(n)
function constFunc() {
  // 执行某些操作
  return 0;
}
/* 循环的空间复杂度为 O(1) */
function loop(n) {
  for (let i = 0; i < n; i++) {
    constFunc();
  }
}
/* 递归的空间复杂度为 O(n) */
function recur(n) {
  if (n === 1) return;
  return recur(n - 1);
}

五、常见复杂度类型

下图是几种常见的复杂度类型, 从低到高依次为: 常数阶: O(1)对数阶: O(logn)线性阶: O(n)平方阶: O(n^2)指数阶: O(2^n)

image

下表是相关复杂度类型的简介:

类型描述
常数阶: O(1)输入数据量 n 不影响需要衡量的指标
对数阶: O(logn)常见于分治算法
线性阶: O(n)输入数据量 n 和要衡量的指标成正相关, 常见于: 数组、链表、栈、队列等
平方阶: O(n^2)常见于矩阵和图, 输入数据量 n 和要衡量的指标成平方关系
指数阶: O(2^n)常见于二叉树

六、权衡时间与空间

每个人都追求完美, 故而在理想情况下, 我们肯定希望算法的时间复杂度和空间复杂度都能够达到最优。然而现实情况中, 两者很难兼得, 因为同时优化时间复杂度和空间复杂度通常非常困难。

大部分情况下降低时间复杂度通常需要以提升空间复杂度为代价, 反之亦然。我们将牺牲内存空间来提升算法运行速度的思路称为 以空间换时间; 反之, 则称为 以时间换空间

当然选择哪种思路取决于我们更看重哪个方面。在大多数情况下, 我们更在乎的是程序的运行时间, 因此 以空间换时间 通常是更常用的策略。当然, 在数据量很大的情况下, 控制空间复杂度也非常重要。

七、参考

大家好, 我是墨渊君, 如果您喜欢我的文章可以: