第一章 算法简介
二分查找
二分查找是一种算法,其输入必须是有序的元素列表。
对于包含n
个元素的列表,使用二分查找最多需要$\log_2^n$
步
对数运算是幂运算的逆运算
log_2^n = a → 2^a=n
在使用大O表示法讨论运行时间时,$\log$
指的都是$\log_2$
(以2
为底)
二分法的JS实现:
// 二分法查找
const binarySearch = (list, target) => {
let min = 0;
let max = list.length - 1;
while (min <= max) {
const middle = Math.floor((min + max) / 2);
if (list[middle] > target) {
max = middle - 1;
} else if (list[middle] < target) {
min = middle + 1
} else {
return middle;
}
}
return null
};
console.log(binarySearch([1, 3, 5, 7, 9], 3));
对于Python,如果取非整数索引,会自动向下取整,但是JS中不会,因为JS中的数组本质上是一个对象:
let a = ['x', 'y'];
// 等同于
{
0: 'x',
1: 'y',
length: 2
}
所以如果为JS数组的非整数索引赋值,结果如下:
let a = [1, 2];
a[2.5] = 100;
console.log(a);
// [1, 2, 2.5: 100]
运行时间
线性时间:最多需要查找的次数与列表长度相同$O(n)$
对数时间:最多需要查找的次数要$\log_2^n$
, $O(\log n)$
$O(\log n)$
比$O(n)$
快,当需要搜索的元素越多,前者比后者快得就越多
大O表示法
仅仅知道算法需要多长时间能运行完还不够,还需要知道运行时间如何随列表增长而增加。大O表示法表示的是算法运行时间的增速。
大O表示法指出的是最糟糕的情况下的运行时间。
一些常见的大O运行时间有:
$O(\log n)$
,对数时间,例如二分查找$O(n)$
,线性时间,例如简单查找$O(n * \log n)$
,例如快速排序,一种比较快的排序算法$O(n^2)$
,例如选择排序,一种比较慢的排序算法$O(n!)$
,例如旅行商问题的解决方案,一种非常慢的算法
算法的速度指的并非时间,而是操作数的增速(随着输入的增加,运行时间将以什么样的速度增加)。
第二章 选择排序
内存的工作原理
需要将数据存储到内存是,你请求计算机提供存储空间,计算机给你一个存储地址。需要存储多项数据时,有两种基本方式:数组和链表
数组
数组所分配的内存空间都是紧紧相连的。如果为数组添加新元素时,当前数组占用的内存满了,则需要将数组元素转移到其他地方。可以预留内存,但是都会带来两个缺点:
- 浪费内存
- 预留的内存用完后,还需要转移元素
数组的优点:需要随机的读取元素时,数组效率很高。
链表
链表中的每个元素都存储了下一个元素的地址,从而使一系列随机的内存地址串在了一起。
链表的优势在与插入元素方面,而且如果需要同时读取所有元素时,链表的效率很高。
在中间插入元素
使用链表插入元素很简单,只需要修改它前面的额按个元素指向的地址。使用数组插入元素时,则必须将后面的所有元素都后移。
在中间插入元素,链表是更好的选择。
删除元素
需要删除元素,链表也是更好的选择。
数组和链表的比较
数组用的更多,因为它支持随机访问,所以数组的读取速度更快。而链表只能顺序访问。
数组和链表常见的操作的运行时间:
– | 数组 | 链表 |
---|---|---|
读取 | $O(1)$ |
$O(n)$ |
插入 | $O(n)$ |
$O(1)$ |
删除 | $O(n)$ |
$O(1)$ |
Facebook存储用户信息的方法
Facebook存储用户信息时用的是链表数组,数组包含26个元素,每个元素指向一个链表。
插入元素时,对数组的移动最多26次,然后再从它指向的链表中进行插入。
读取元素时,可以直接找到数组中的对应元素,然后在链表中查找。
那么查找元素时,会不会效率太低?
选择排序
选择排序的原理就是每一轮在n
个元素中进行查找,找出最小元素,放到结果当中。
每一轮的操作时间复杂度为$O(n)$
,这样的操作需要执行n
次,所以时间复杂度为$O(n^2)$
有一个问题,每一轮进行后,下一轮要检查的元素会逐渐减少,实际随后检查的元素格式是一个公差为1
的等差数列n-1
、n-2
…2
、1
,平均每次检查的元素是n/2
,因此运行时间为$O(n * n/2)$
,但是大O表示法会忽略诸如1/2
这样的常数,所以为$O(n^2)$
选择排序的JS实现:(从小到大排序)
function chooseSort (arr) {
for (let i = 0; i < arr.length; i++) {
let minIndex = i;
for (let j = i; j < arr.length; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j
}
}
[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
}
return arr;
}
选择排序的速度不是很快,快速排序的速度更快,时间复杂度为$O(n * \log n)$
第三章 递归
递归是一种很多算法都使用的一种编程方法。
递归只是让解决方案更加清晰,并没有性能上的优势。实际上在有些情况下使用循环的性能更好。
基线条件和递归条件
编写递归函数时,必须告诉它何时停止。每个递归函数都有两部分:基线条件(base case)和递归条件(recursive case)。递归条件指的是函数调用自己,而基线条件值得是函数不再调用自己,避免造成无限循环。
栈
栈是一种简单的数据结构,有两种操作:压入和弹出。
计算机内部使用被称为调用栈的栈。在调用过程中,调用另一个函数时,当前函数暂停并处于未完成状态。该函数的所有变量的值都保留在内存中。
在递归调用栈中,包含着未完成的函数调用,自己无需跟踪哪些函数还没有被执行,栈会完成这个步骤。
使用栈很方便,但是也有性能代价,存储详尽的信息可能占用大量的内存。每个函数调用都要占用一定的内存,如果栈很高,那么以为这计算机储存了大量函数调用的信息。
解决方法:
- 改用循环
- 使用尾递归(尾递归的实现需要确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数)
第四章 快速排序
分治法
分治法是解决问题的一种思路,一种递归式问题解决方法。
用分治法解决问题有包括两个步骤:
- 找出基线条件(即递归终止的条件),这种条件必须尽可能简单
- 不断将问题分解(或者说缩小规模),直到符合基线条件
一道题目:将一个长度为length
、宽度为width
的矩形均匀地分成方块,并确保分出的方块是最大的
分析这道题,首先要找出递归条件,最容易处理的情况是一条边的长度是另一条边的整数倍。这个时候就可以将矩形均匀地分成方块。
然后找出递归条件,每次递归都需要缩小问题规模。首先找出这块地可容纳的最大方块A
,剩余的土地为B
,根据欧几里得算法,适用于这小块地的最大方块,也是适用于整块地的最大的方块(就这个算法我就不知道,真做的时候就做出不来)。据此就可以不断的缩小规模,直到满足基线条件:
// 找到一个矩形可以均匀的划分的最大的小方块的尺寸
const getMaxSquare = (length, width) => {
if (width > length) {
[width, length] = [