「这是我参与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 指针来专门指向链表的开始位置。
画一张图来总结一下:
链表结点的创建、插入和删除
创建
创建一个 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
数组的元素类型问题
本书指出:
在同一个数组中,所有元素的类型都必须相同(都为 int、double 等)。
但 JS 中不一定是。
JS比较特别。如果我们在一个数组中只定义了一种类型的元素,比如:
const arr = [1,2,3,4]
它是一个纯数字数组,那么对应的确实是连续内存。
但如果我们定义了不同类型的元素:
const arr = ['haha', 1, {a:1}]
它对应的就是一段非连续的内存。此时,JS 数组不再具有数组的特征,其底层使用哈希映射分配内存空间,是由对象链表来实现的。
谨记: JS 数组未必是真正的数组。
链表和数组的增删、访问操作
数组的访问
因为数组在内存中对应一串连续的空间地址,每个元素的内存地址可以根据其索引距离数组头部的距离计算出来,所以时间复杂度是 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 多的时候就要计算一会儿了,而用循环就瞬间计算出来了。
因为随着递归层级的加深,里面会有大量的重复计算。
基线条件和递归条件
编写递归函数时,必须要告诉它何时停止递归,不然可能会导致程序无限运行。
正因为如此,每个递归函数都有两部分:基线条件(终止条件)和递归条件。
递归条件指的是函数调用自己。
基线条件则是函数不再调用自己。
比如下面这个例子
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()
递归调用栈
递归函数也使用调用栈。
以前面提过的斐波那契数里为例,当 n = 2 时,递归函数执行情况如下展示:
在递归函数运行时,递归函数本身将被一次又一次地调用,不断地将先前的递归函数压入栈中,直到参数达到基线条件并得出返回值后被弹出栈,则先前的一系列的递归函数也将获得返回值并被弹出栈,运行其余被压入栈的递归函数。
使用栈虽然很方便,但是也要付出代价:存储详尽的信息可能占用大量的内存。每个函数调用都要占用一定的内存,如果栈很高,就意味着计算机存储了大量函数调用的信息。
所以用递归的时候要小心,很容易内存溢出。
小结
本文通过介绍二分查找、大 O 表示法、选择排序、数组、链表、递归和栈,简单介绍了算法入门的一些知识。
其实这也是我的算法学习笔记,一个工作几年的老前端,算法方面却连在校生都不如,很惭愧。
我会持续输出算法系列文章的~