写给前端开发的算法简介

352 阅读8分钟

「这是我参与2022首次更文挑战的第3天,活动详情查看:2022首次更文挑战」。

算法简介

引言

算法是什么?

算法是一组完成任务的指令

《图解算法》第一章中指出,任何代码片段都可视为算法,只是大家一般说的算法要么速度快,要么能解决实际问题。

这与我以前接受的理念是相似的,我以前的理念是:

程序 = 算法 + 数据结构

程序是用一定的数据结构加算法写出来的,只是中间会用到编程语言的一些表达式。

现在我又多了一层理解,就是算法和数据结构设计出来一定是为了解决实际问题的,下面我将介绍二分查找、选择排序、数组、链表和递归,带你进入算法的世界。

二分查找

为什么会有二分查找?

其实就像我们查字典,不会一页一页去翻一样。

因为按顺序一个一个去找实在太慢了。

所以二分查找就被设计出来了,为了解决查找太慢的问题。

代码实现如下:

// 对一个有序的数组使用二分查找,返回其index,数组的概念后面会介绍
const binarySearch = (list, item) => {
  let low = 0                             // 定义 low、high和mid来跟踪
  let high = list.length - 1
  let mid

  while (low <= high) {
    mid = Math.floor((low + high) / 2)    // (low + high) / 2 可能不是整数,要向下取整
    
    if (list[mid] === item) {             // 如果 mid 的值刚好是要找的,就返回mid
      return mid
    }
    if (list[mid] < item) {               // 如果 mid 的值小了,说明要找的数范围应在 mid 右边,就把 low 置为 mid + 1,继续二分查找
      low = mid + 1
    }
    if (list[mid] > item) {               // 如果 mid 的值大了,说明要找的数范围应在 mid 左边,就把 high 置为 mid - 1,继续二分查找
      high = mid - 1
    }
  }

  return null                             // 找不到这个值就返回 null
}

测试一下:

binarySearch([1, 2, 3, 4, 5], -2)     // null
binarySearch([1, 2, 3, 4, 5], 2)      // 1
binarySearch([1, 2, 3, 4, 5], 5)      // 4

运行时间

普通查找(线性时间):O(n)

二分查找(对数时间):O(logn)

在包含 100 个数字的列表中查找,二分查找最多只需 7 次。

在包含 40 亿个数字的列表中查找,二分查找最多只需 32 次。

可见,合适的算法能多么大的提高程序的运行效率。

大 O 表示法

上文我们提到了运行时间,用的大 O 表示法,大 O 表示法用来表示算法的速度快慢。

  • 算法的速度指的并非时间,而是操作数(n)的增速。
  • 谈论算法的速度时,我们说的是随着输入的增加,其运行时间将以什么样的速度增加。
  • 算法的运行时间用大 O 表示法表示。
  • 大 O 表示法表示的是最糟情况下的运行时间。

常见的大 O 运行时间

  • O(log n):对数时间,这样的算法包括二分查找。
  • O(n):线性时间,这样的算法包括简单查找。
  • O(n * log n):这样的算法包括快速排序。
  • O(n2):这样的算法包括选择排序。
  • O(n!):这样的算法包括旅行商问题的解决方案。

数组和链表

内存的工作原理

要介绍数组和链表,首先要知道内存的工作原理

假设你要去游泳,要把随身物品先寄存在储物柜里,游完泳后,再通过钥匙号牌开门。

这和计算机内存的工作原理相似,计算机就像很多储物柜的集合体,每个储物柜都有地址。

需要存储多项数据时,有两种基本方式—— 数组链表

数组和链表的区别

数组在内存中是一段连续的内存空间。每个元素的内存地址可以根据其索引距离数组头部的距离计算出来。因此对数组来说,每一个元素都可以通过数组的索引下标直接定位。

而链表中的结点,则允许散落在内存空间的各个角落里。通过指针(next)来记录后继结点的位置。

在链表中,每一个结点的结构都包括了两部分的内容:数据域和指针域。JS 中的链表,是以嵌套的对象的形式来实现的:

{
  // 数据域
  val: 1,
  // 指针域,指向下一个结点
  next: {
      val:2,
      next: ...
  }
} 

要想访问链表中的任何一个元素,我们都得从起点结点开始,逐个访问 next,一直访问到目标结点为止。为了确保起点结点是可抵达的,我们有时还会设定一个 head 指针来专门指向链表的开始位置。

画一张图来总结一下:

link.jpeg

链表结点的创建、插入和删除

创建

创建一个 1 -> 2 的链表

需要一个构造函数
function ListNode(val, next) {
  this.val = (val===undefined ? 0 : val)
  this.next = (next===undefined ? null : next)
}


const node = new ListNode(1) 
node.next = new ListNode(2)

这样就创建了一个数据域为1,指向数据域为2的链表。

插入

1 -> 2 变成 1 -> 3 -> 2

const node3 = new ListNode(3) 

node3.next = node1.next
node1.next = node3

删除

1 -> 2 -> 3 变成 1 -> 3

node1.next = node1.next.next

update.jpeg

数组的元素类型问题

本书指出:

在同一个数组中,所有元素的类型都必须相同(都为 int、double 等)。

但 JS 中不一定是。

JS比较特别。如果我们在一个数组中只定义了一种类型的元素,比如:

const arr = [1,2,3,4]

它是一个纯数字数组,那么对应的确实是连续内存。

但如果我们定义了不同类型的元素:

const arr = ['haha', 1, {a:1}]

它对应的就是一段非连续的内存。此时,JS 数组不再具有数组的特征,其底层使用哈希映射分配内存空间,是由对象链表来实现的。

谨记: JS 数组未必是真正的数组。

知乎:探究JS V8引擎下的“数组”底层实现

链表和数组的增删、访问操作

数组的访问

因为数组在内存中对应一串连续的空间地址,每个元素的内存地址可以根据其索引距离数组头部的距离计算出来,所以时间复杂度是 O(1)。

数组的增删

因为数组在内存中对应一串连续的空间地址,所以增加元素,后面的所有元素都要后移一位; 同理,删除元素,后面所有的元素都要前移一位,所以时间复杂度是 O(n)。

链表的访问

因为链表只能通过 next 一个一个去找,所以访问的时间复杂度是 O(n)

链表的增删

不管链表里面的结点个数 n 有多大,只要我们明确了要插入/删除的目标位置,那么我们需要做的都仅仅是改变目标结点及其前驱/后继结点的指针指向,所以增删的时间复杂度是 O(1)

小结一下:

操作数组链表
访问O(1)O(n)
插入O(n)O(1)
删除O(n)O(1)

选择排序

现在我们有数组来存放元素了,下面就来讨论怎么给元素排序吧。

我们介绍一种最笨拙的排序算法,选择排序。

顾名思义,每次从列表中选出最大(小)的,排到新的列表里,选完了之后,新列表就有序了

优化一下,使用元素交换,就在原列表里排序,不使用新的列表,代码实现如下:

const findSort = (list) => {
  let minIndex                            // 定义 minIndex,缓存本次找到的最小值的索引
  let temp                                // 定义临时变量,用于数组之间交换元素
  const len = list.length                 // 缓存数组长度
  for (let i = 0; i < len; i++) {
    minIndex = i                          // 初始化 minIndex 为当前区间第一个元素
    for (let j = i; j < len; j++) {
      if (list[j] < list[minIndex]) {     // 如果找到比当前缓存的最小值还小的值,就更新缓存的最小值的index
        minIndex = j
      }
    }
    if (minIndex !== i) {                 // 如果最小值的 index 不是最开始初始化的第一个元素,就和第一个元素交换位置
      temp = list[i]
      list[i] = list[minIndex]
      list[minIndex] = temp
    }
  }
  return list                             // 返回排序好的数组
}

时间复杂度:O(n^2)。

其实排序的方法有很多,比如 js 中调用数组的 sort 方法就排序了,其原理肯定不是选择排序,因为选择排序太慢了。

后面的文章会介绍别的排序算法的~

递归

为什么会有递归?

因为计算机和人的思维方式不一样,很多东西人一眼就能看出来,计算机却不能,计算机就是擅长处理单个任务,且运行速度极快。

所以很多问题可以被拆分成单个重复的任务,扔给计算机去处理。

重复做单个同样的任务,叫做循环

程序调用自身的编程技巧,叫做递归( recursion)。

循环和递归

很多问题,用循环和用递归都可以解决,一般使用循环性能更好,使用递归程序更容易理解。

比如斐波那契数列:

递归实现
const fib = (n) => {
  if (n === 0) {
    return 0
  }
  if (n === 1) {
    return 1
  }
  return fib(n - 1) + fib(n - 2)
}
循环实现
const fib = (n) => {
  const arr = [0, 1]
  for (let i = 2; i <= n; i++) {
    arr[i] = arr[i - 1] + arr[i - 2]
  }
  return arr[n]
}

递归的性能比循环差很多, n 大概等于 40 多的时候就要计算一会儿了,而用循环就瞬间计算出来了。

image.png

image.png

因为随着递归层级的加深,里面会有大量的重复计算。

基线条件和递归条件

编写递归函数时,必须要告诉它何时停止递归,不然可能会导致程序无限运行。

正因为如此,每个递归函数都有两部分:基线条件(终止条件)和递归条件。

递归条件指的是函数调用自己。

基线条件则是函数不再调用自己。

比如下面这个例子

const countDown = (i) => {
  console.log('i :>> ', i)
  if (i <= 1) {                 // 基线条件 i <= 1
    return false
  } else {
    countDown(i - 1)            // 递归条件 i > 1
  }
}

栈和调用栈

栈是一种简单的数据结构,只有压入和弹出两种操作,数据先入后出

调用栈

同步代码,先入栈,执行之后,再出栈。

const second = () => {
  console.log('Hello there!')
}
const first = () => {
  console.log('Hi there!')
  second()
  console.log('The End')
}
first()

image.png

递归调用栈

递归函数也使用调用栈。

以前面提过的斐波那契数里为例,当 n = 2 时,递归函数执行情况如下展示:

递归调用栈.jpeg

在递归函数运行时,递归函数本身将被一次又一次地调用,不断地将先前的递归函数压入栈中,直到参数达到基线条件并得出返回值后被弹出栈,则先前的一系列的递归函数也将获得返回值并被弹出栈,运行其余被压入栈的递归函数。

使用栈虽然很方便,但是也要付出代价:存储详尽的信息可能占用大量的内存。每个函数调用都要占用一定的内存,如果栈很高,就意味着计算机存储了大量函数调用的信息。

所以用递归的时候要小心,很容易内存溢出。

小结

本文通过介绍二分查找、大 O 表示法、选择排序、数组、链表、递归和栈,简单介绍了算法入门的一些知识。

其实这也是我的算法学习笔记,一个工作几年的老前端,算法方面却连在校生都不如,很惭愧。

我会持续输出算法系列文章的~