前端面试高频算法
1 排序算法;
1.1 如何分析一个排序算法
复杂度分析是整个算法学习的精髓。
- 时间复杂度: 一个算法执行所耗费的时间。
- 空间复杂度: 运行完一个程序所需内存的大小。
学习排序算法,我们除了学习它的算法原理、代码实现之外,更重要的是要学会如何评价、分析一个排序算法。
分析一个排序算法,要从 执行效率
、内存消耗
、稳定性
三方面入手。
1.1.1 执行效率
最好情况、最坏情况、平均情况时间复杂度
我们在分析排序算法的时间复杂度时,要分别给出最好情况、最坏情况、平均情况下的时间复杂度。
时间复杂度的系数、常数 、低阶
我们知道,时间复杂度 O(n)表示的是n很大的时候,所以它表示的时候会忽略系数、常数、低阶。
但是实际的软件开发中,如果我们排序的可能是 10 个、100 个、1000 个这样规模很小的数据,我们就要把系数、常数、低阶也考虑进来。比较次数和交换(或移动)次数
基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。
所以,如果我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。
3.1.2 内存消耗
也就是看空间复杂度。
还需要知道如下术语:
- 内排序:所有排序操作都在内存中完成;
- 外排序:由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行;
- 原地排序:原地排序算法,就是特指空间复杂度是 O(1) 的排序算法。
1.1.3 稳定性
- 稳定:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
比如: a 原本在 b 前面,而 a = b,排序之后,a 仍然在 b 的前面; - 不稳定:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序改变。
比如:a 原本在 b 的前面,而 a = b,排序之后, a 在 b 的后面;
1.2 冒泡排序(Bubble Sort)
思想
- 冒泡排序只会操作相邻的两个数据。
- 每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。
- 一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。
特点
- 优点:排序算法的基础,简单实用易于理解。
- 缺点:比较次数多,效率较低。
实现
(实现前先构思一遍场景)
// 冒泡排序
const bubbleSort=arr=>{
let i,j;
for(i=0;i<arr.length-1;i++){
let hasChange=false
for(j=0;j<arr.length-i-1;j++){
if(arr[j]>arr[j+1]){
let temp=arr[j+1]
arr[j+1]=arr[j]
arr[j]=temp
hasChange=true
}
}
console.log('每轮排序',arr)
if(!hasChange){
break
}
}
console.log('排序完成',arr)
}
//测试
let arr=[3,6,0,2,43,56,12,45,67]
console.log('初始值',arr)
bubbleSort(arr)
实现
- 冒泡排序是原地排序算法吗 ?
冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一个原地排序算法。 - 冒泡排序是稳定的排序算法吗 ?
在冒泡排序中,只有交换才可以改变两个元素的前后顺序。
为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序。
所以冒泡排序是稳定的排序算法。 - 冒泡排序的时间复杂度是多少 ?
最佳情况:T(n) = O(n),当数据已经是正序时。
最差情况:T(n) = O(n(2)),当数据是反序时。
平均情况:T(n) = O(n(2))。
1.3 插入排序(Insertion Sort)
思想
一般人打扑克牌,整理牌的时候,都是按牌的大小(从小到大或者从大到小)整理牌的,那每摸一张新牌,就扫描自己的牌,把新牌插入到相应的位置。
插入排序的工作原理:通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
步骤
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤 3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤 2 ~ 5。
实现
(实现前先构思一遍场景)
const insertionSort= arr=>{
let i,j;
for(i=1;i<arr.length;i++){
let insertIndex=i;
let insertItem=arr[i];
for(j=i-1;j>=0;j--){
if(insertItem<arr[j]){
insertIndex=j
arr[j+1]=arr[j]
}else{
//没有比前面的小,就结束了
break
}
}
arr[insertIndex]=insertItem
console.log('每轮排序',arr)
}
console.log('排序完成',arr)
}
//测试
let arr=[3,1,0,2,43,56,12,45,67]
console.log('初始值',arr)
insertionSort(arr)
分析
- 插入排序是原地排序算法吗
插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),所以,这是一个原地排序算法。 - 插入排序是稳定的排序算法吗 ?
在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。 - 插入排序的时间复杂度是多少 ?
最佳情况:T(n) = O(n),当数据已经是正序时。
最差情况:T(n) = O(n(2)),当数据是反序时。
平均情况:T(n) = O(n(2))。
优化插入排序 – 拆半插入
折半插入排序是直接插入排序的升级版,鉴于插入排序第一部分为已排好序的数组,我们不必按顺序依次寻找插入点,只需比较它们的中间值与待插入元素的大小即可。
拆半插入步骤
- 取 0 ~ i-1 的中间点 ( m = (i-1) >> 1 ),array[i] 与 array[m] 进行比较,若 array[i] < array[m],则说明待插入的元素 array[i] 应该处于数组的 0 ~ m 索引之间;反之,则说明它应该处于数组的 m ~ i-1 索引之间。
- 重复步骤 1,每次缩小一半的查找范围,直至找到插入的位置。
- 将数组中插入位置之后的元素全部后移一位。
- 在指定位置插入第 i 个元素。
注:x >> 1 是位运算中的右移运算,表示右移一位,等同于 x 除以 2 再取整,即 x >> 1 == Math.floor(x/2) ,可以自己实现
1.4 选择排序(Selection Sort)
思路
选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
步骤
- 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
- 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
- 重复第二步,直到所有元素均排序完毕。
实现
const selectionSort= arr=>{
let i,j;
for(i=0;i<arr.length-1;i++){
//假设当前位置是最小值
let minIndex=i;
let tempt=arr[i];
for(j=i+1;j<arr.length;j++){
if(arr[j]<tempt){
tempt=arr[j]
minIndex=j
}
}
//交换值
arr[minIndex]=arr[i];
arr[i]=tempt
console.log('每轮排序',arr)
}
console.log('排序完成',arr)
}
//测试
let arr=[3,1,0,2,43,56,12,45,67]
console.log('初始值',arr)
selectionSort(arr)
分析
- 选择排序是原地排序算法吗 ?
选择排序空间复杂度为 O(1),是一种原地排序算法。 - 选择排序是稳定的排序算法吗 ?
选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。所以,选择排序是一种不稳定的排序算法。 - 选择排序的时间复杂度是多少 ?
无论是正序还是逆序,选择排序都会遍历 n(2) / 2 次来排序,所以,最佳、最差和平均的复杂度是一样的。
最佳情况:T(n) = O(n(2))。
最差情况:T(n) = O(n(2))。
平均情况:T(n) = O(n(2))。
1.5 归并排序(Merge Sort)
思想
排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
归并排序采用的是分治思想
。
分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。
实现
const mergeSort= arr=>{
//向上取整和向下取整都可以
let middleIndex=Math.floor(arr.length/2)
let preArr=arr.slice(0,middleIndex)
let nextArr=arr.slice(middleIndex)
console.log('分割',preArr,nextArr)
let newArr=[]
if(preArr.length>2){//长度大于等于2的时候递归
preArr=mergeSort(preArr)
}else{
if(preArr.length==2&&preArr[0]>preArr[1]){
let temp=preArr[0]
preArr[0]=preArr[1]
preArr[1]=temp
}
console.log('前面的叔祖',preArr)
}
if(nextArr.length>2){//长度大于等于2的时候递归
nextArr=mergeSort(nextArr)
}else{
if(nextArr.length==2&&nextArr[0]>nextArr[1]){
let temp=nextArr[0]
nextArr[0]=nextArr[1]
nextArr[1]=temp
}
console.log('后面的叔祖',nextArr)
}
newArr=comparison(preArr,nextArr)
console.log('每次返回',newArr)
return newArr
}
//比较两个有序的数组
const comparison=(arr1,arr2)=>{
let newArr=[]
while(arr1.length!=0&&arr2.length!=0){
let aTempt=arr1[0]
let bTempt=arr2[0]
if(aTempt<=bTempt){
// 注意: 判断的条件是小于或等于,如果只是小于,那么排序将不稳定.
newArr.push(aTempt)
arr1.shift()
}else{
newArr.push(bTempt)
arr2.shift()
}
}
if(arr1.length==0){
newArr=newArr.concat(arr2)
}
if(arr2.length==0){
newArr=newArr.concat(arr1)
}
return newArr
}
//测试
let arr=[3,1,7,3,8,88,90,76,0,2,43,56,12,45,67]
console.log('初始值',arr)
mergeSort(arr)
更标准的写法
const mergeSort = arr => {
//采用自上而下的递归方法
const len = arr.length;
if (len < 2) {
return arr;
}
// length >> 1 和 Math.floor(len / 2) 等价
let middle = Math.floor(len / 2),
left = arr.slice(0, middle),
right = arr.slice(middle); // 拆分为两个子数组
return merge(mergeSort(left), mergeSort(right));
};
const merge = (left, right) => {
const result = [];
while (left.length && right.length) {
// 注意: 判断的条件是小于或等于,如果只是小于,那么排序将不稳定.
if (left[0] <= right[0]) {
result.push(left.shift());
} else {
result.push(right.shift());
}
}
while (left.length) result.push(left.shift());
while (right.length) result.push(right.shift());
return result;
};
- 归并排序是原地排序算法吗 ?
这是因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。
实际上,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。
所以,归并排序不是原地排序算法。 - 归并排序是稳定的排序算法吗 ?
merge 方法里面的 left[0] <= right[0] ,保证了值相同的元素,在合并前后的先后顺序不变。归并排序是稳定的排序方法。 - 归并排序的时间复杂度是多少 ?
从效率上看,归并排序可算是排序算法中的佼佼者。假设数组长度为 n,那么拆分数组共需 logn 步,又每步都是一个普通的合并子数组的过程,时间复杂度为 O(n),故其综合时间复杂度为 O(n log n)。
最佳情况:T(n) = O(n log n)。
最差情况:T(n) = O(n log n)。
平均情况:T(n) = O(n log n)。
1.6 快速排序(Quick Sort)
快速排序的特点就是快,而且效率高!它是处理大数据最快的排序算法之一。
思想
- 先找到一个基准点(一般指数组的中部),然后数组被该基准点分为两部分,依次与该基准点数据比较,如果比它小,放左边;反之,放右边。
- 左右分别用一个空数组去存储比较后的数据。
- 最后递归执行上述操作,直到数组长度 <= 1;
特点:快速,常用。
缺点:需要另外声明两个数组,浪费了内存空间资源。
实现
方法一:
const quickSort = arr=>{
if(arr.length<2){
return arr
}
let leftArr=[]
let rightArr=[]
let baseItem=arr[0]
for(let i=1;i<arr.length;i++){
let element=arr[i]
if(element<=baseItem){
leftArr.push(element)
}else{
rightArr.push(element)
}
}
let newLeftArr = quickSort(leftArr)
let newRightArr = quickSort(rightArr)
let newArr=[...newLeftArr,baseItem,...newRightArr]
console.log('每轮',newArr)
return newArr
}
//测试
let arr=[3,1,7,3,8,88,90,76,0,2,43,56,12,45,67]
console.log('初始值',arr)
console.log('排序后',quickSort(arr))
方法二:
const quickSort2 = (arr, left, right) => {
let len = arr.length,
partitionIndex;
left = typeof left != 'number' ? 0 : left;
right = typeof right != 'number' ? len - 1 : right;
if (left < right) {
partitionIndex = partition(arr, left, right);
quickSort2(arr, left, partitionIndex - 1);
quickSort2(arr, partitionIndex + 1, right);
}
return arr;
};
const partition = (arr, left, right) => {
//分区操作
let pivot = left, //设定基准值(pivot)
index = pivot + 1;
for (let i = index; i <= right; i++) {
if (arr[i] < arr[pivot]) {
swap(arr, i, index);
index++;
}
}
swap(arr, pivot, index - 1);
return index - 1;
};
const swap = (arr, i, j) => {
let temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
};
// 测试
const array = [3,1,7,3,8,88,90,76,0,2,43,56,12,45,67];
console.log('原始array:', array);
const newArr = quickSort2(array);
console.log('newArr:', newArr);
- 快速排序是原地排序算法吗 ?
用方法二进行分区时,不需要很多额外的内存空间,所以快排是原地排序算法。 - 快速排序是稳定的排序算法吗 ?
和选择排序相似,快速排序每次交换的元素都有可能不是相邻的,因此它有可能打破原来值为相同的元素之间的顺序。因此,快速排序并不稳定。 - 快速排序的时间复杂度是多少 ?
极端的例子:如果数组中的数据原来已经是有序的了,比如 1,3,5,6,8。如果我们每次选择最后一个元素作为 pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约 n 次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 n / 2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O(n(2))。
最佳情况:T(n) = O(n log n)。
最差情况:T(n) = O(n(2))。
平均情况:T(n) = O(n log n)。
问题回答
快排和归并用的都是分治思想,递推公式和递归代码也非常相似,那它们的区别在哪里呢 ?
可以发现:
- 归并排序的处理过程是
由下而上
的,先处理子问题,然后再合并。 - 而快排正好相反,它的处理过程是
由上而下
的,先分区,然后再处理子问题。 - 归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。
- 归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。
- 快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
1.7 希尔排序(Shell Sort)
思想
- 先将整个待排序的记录序列分割成为若干子序列。
- 分别进行直接插入排序。
- 待整个序列中的记录基本有序时,再对全体记录进行依次直接插入排序。
过程
举个易于理解的例子:[35, 33, 42, 10, 14, 19, 27, 44],我们采取间隔 4。创建一个位于 4 个位置间隔的所有值的虚拟子列表。下面这些值是 { 35, 14 },{ 33, 19 },{ 42, 27 } 和 { 10, 44 }。
我们比较每个子列表中的值,并在原始数组中交换它们(如果需要)。完成此步骤后,新数组应如下所示。
然后,我们采用 2 的间隔,这个间隙产生两个子列表:{ 14, 27, 35, 42 }, { 19, 10, 33, 44 }。
我们比较并交换原始数组中的值(如果需要)。完成此步骤后,数组变成:[14, 10, 27, 19, 35, 33, 42, 44],图如下所示,10 与 19 的位置互换一下。
最后,我们使用值间隔 1 对数组的其余部分进行排序,Shell sort 使用插入排序对数组进行排序。
实现
const shellSort = arr => {
let len = arr.length,
temp,
gap = 1;
console.time('希尔排序耗时');
while (gap < len / 3) {
//动态定义间隔序列
gap = gap * 3 + 1;
}
for (gap; gap > 0; gap = Math.floor(gap / 3)) {
for (let i = gap; i < len; i++) {
temp = arr[i];
let j = i - gap;
for (; j >= 0 && arr[j] > temp; j -= gap) {
arr[j + gap] = arr[j];
}
arr[j + gap] = temp;
console.log('arr :', arr);
}
}
console.timeEnd('希尔排序耗时');
return arr;
};
测试
// 测试
const array = [35, 33, 42, 10, 14, 19, 27, 44];
console.log('原始array:', array);
const newArr = shellSort(array);
console.log('newArr:', newArr);
// 原始 array: [35, 33, 42, 10, 14, 19, 27, 44]
// arr : [14, 33, 42, 10, 35, 19, 27, 44]
// arr : [14, 19, 42, 10, 35, 33, 27, 44]
// arr : [14, 19, 27, 10, 35, 33, 42, 44]
// arr : [14, 19, 27, 10, 35, 33, 42, 44]
// arr : [14, 19, 27, 10, 35, 33, 42, 44]
// arr : [14, 19, 27, 10, 35, 33, 42, 44]
// arr : [10, 14, 19, 27, 35, 33, 42, 44]
// arr : [10, 14, 19, 27, 35, 33, 42, 44]
// arr : [10, 14, 19, 27, 33, 35, 42, 44]
// arr : [10, 14, 19, 27, 33, 35, 42, 44]
// arr : [10, 14, 19, 27, 33, 35, 42, 44]
// 希尔排序耗时: 3.592041015625ms
// newArr: [10, 14, 19, 27, 33, 35, 42, 44]
分析
- 希尔排序是原地排序算法吗 ?
希尔排序过程中,只涉及相邻数据的交换操作,只需要常量级的临时空间,空间复杂度为 O(1) 。所以,希尔排序是原地排序算法。 - 希尔排序是稳定的排序算法吗 ?
我们知道,单次直接插入排序是稳定的,它不会改变相同元素之间的相对顺序,但在多次不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,可能导致相同元素相对顺序发生变化。
因此,希尔排序不稳定。 - 希尔排序的时间复杂度是多少 ?
最佳情况:T(n) = O(n log n)。
最差情况:T(n) = O(n log(2) n)。
平均情况:T(n) = O(n log(2) n)。
1.8 堆排序(Heap Sort)
堆的定义
堆其实是一种特殊的树。只要满足这两点,它就是一个堆。
- 堆是一个完全二叉树。
完全二叉树:除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。 - 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
也可以说:堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值。这两种表述是等价的。
对于每个节点的值都大于等于
子树中每个节点值的堆,我们叫作大顶堆
。
对于每个节点的值都小于等于
子树中每个节点值的堆,我们叫作小顶堆
。
其中图 1 和 图 2 是大顶堆,图 3 是小顶堆,图 4 不是堆。除此之外,从图中还可以看出来,对于同一组数据,我们可以构建多种不同形态的堆。
思想
- 将初始待排序关键字序列 (R1, R2 … Rn) 构建成大顶堆,此堆为初始的无序区;
- 将堆顶元素 R[1] 与最后一个元素 R[n] 交换,此时得到新的无序区 (R1, R2, … Rn-1) 和新的有序区 (Rn) ,且满足 R[1, 2 … n-1] <= R[n]。
- 由于交换后新的堆顶 R[1] 可能违反堆的性质,因此需要对当前无序区 (R1, R2 … Rn-1) 调整为新堆,然后再次将 R[1] 与无序区最后一个元素交换,得到新的无序区 (R1, R2 … Rn-2) 和新的有序区 (Rn-1, Rn)。不断重复此过程,直到有序区的元素个数为 n - 1,则整个排序过程完成。
实现
// 堆排序
const heapSort = array => {
console.time('堆排序耗时');
// 初始化大顶堆,从第一个非叶子结点开始
for (let i = Math.floor(array.length / 2 - 1); i >= 0; i--) {
heapify(array, i, array.length);
}
// 排序,每一次 for 循环找出一个当前最大值,数组长度减一
for (let i = Math.floor(array.length - 1); i > 0; i--) {
// 根节点与最后一个节点交换
swap(array, 0, i);
// 从根节点开始调整,并且最后一个结点已经为当前最大值,不需要再参与比较,所以第三个参数为 i,即比较到最后一个结点前一个即可
heapify(array, 0, i);
}
console.timeEnd('堆排序耗时');
return array;
};
// 交换两个节点
const swap = (array, i, j) => {
let temp = array[i];
array[i] = array[j];
array[j] = temp;
};
// 将 i 结点以下的堆整理为大顶堆,注意这一步实现的基础实际上是:
// 假设结点 i 以下的子堆已经是一个大顶堆,heapify 函数实现的
// 功能是实际上是:找到 结点 i 在包括结点 i 的堆中的正确位置。
// 后面将写一个 for 循环,从第一个非叶子结点开始,对每一个非叶子结点
// 都执行 heapify 操作,所以就满足了结点 i 以下的子堆已经是一大顶堆
const heapify = (array, i, length) => {
let temp = array[i]; // 当前父节点
// j < length 的目的是对结点 i 以下的结点全部做顺序调整
for (let j = 2 * i + 1; j < length; j = 2 * j + 1) {
temp = array[i]; // 将 array[i] 取出,整个过程相当于找到 array[i] 应处于的位置
if (j + 1 < length && array[j] < array[j + 1]) {
j++; // 找到两个孩子中较大的一个,再与父节点比较
}
if (temp < array[j]) {
swap(array, i, j); // 如果父节点小于子节点:交换;否则跳出
i = j; // 交换后,temp 的下标变为 j
} else {
break;
}
}
};
测试
const array = [4, 6, 8, 5, 9, 1, 2, 5, 3, 2];
console.log('原始array:', array);
const newArr = heapSort(array);
console.log('newArr:', newArr);
// 原始 array: [4, 6, 8, 5, 9, 1, 2, 5, 3, 2]
// 堆排序耗时: 0.15087890625ms
// newArr: [1, 2, 2, 3, 4, 5, 5, 6, 8, 9]
分析
- 堆排序是原地排序算法吗 ?
整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是
原地排序算法。 - 堆排序是稳定的排序算法吗 ?
因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。
所以,堆排序是不稳定
的排序算法。 - 堆排序的时间复杂度是多少 ?
堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。
最佳情况:T(n) = O(n log n)。
最差情况:T(n) = O(n log n)。
平均情况:T(n) = O(n log n)。
2 动态规划和贪心算法;
2.1 动态规划
递归是从顶部开始将问题分解
,通过解决掉所有分解出小问题的方式,来解决整个问题。
动态规划解决方案从底部开始解决问题
,将所有小问题解决掉,然后合并成一个整体解决方案,从而解决掉整个大问题。
使用递归去解决问题虽然简洁,但效率不高。
2.1.1 斐波拉契数列
斐波拉契数列定义为以下序列:
0,1,1,2,3,5,8,13,21,34,55,…
可以看到,当 n >= 2,a(n) = a(n - 1) + a(n - 2)。这个数列的历史非常悠久,它是被公元700年一位意大利数学家斐波拉契用来描述在理想状态下兔子的增长情况。
不难看出,这个数列可以用一个简单的递归函数表示。
function fibo (n) {
if (n <= 0) return 0;
if (n === 1) return 1;
return fibo(n - 1) + fibo(n - 2);
}
这种实现方式非常耗性能,在n的数量级到达千级别,程序就变得特别慢,甚至失去响应。如果使用动态规划从它能解决的最简单子问题着手的话,效果就很不一样了。
这里我们先用一个数组来存取每一次产生子问题的结果,方便后面求解使用。
function fibo (n) {
if (n <= 0) return 0;
if (n <= 1) return 1;
var arr = [0, 1];
for (var i = 2; i <= n; i++) {
arr[i] = arr[i - 1] + arr[i - 2];
}
return arr[n];
}
这里的数组可以去掉,换做局部变量来实现可以省下不少内存空间。
function fibo (n) {
if (n <= 0) return 0;
if (n <= 1) return 1;
var res, a = 0, b = 1;
for (var i = 2; i <= n; i++) {
res = a + b;
a = b;
b = res;
}
return res;
}
2.1.2 寻找最长公共子串
另一个适合使用动态规划去解决的问题是寻找两个字符串的最长公共子串。例如,在单词 raven 和 havoc中,最长的公共子串是“av”。
function maxSubString (str1, str2) {
if (!str1 || !str2) return '';
var len1 = str1.length,
len2 = str2.length;
var maxSubStr = '';
for (var i = 0; i < len1; i++) {
for (var j = 0; j < len2; j++) {
var tempStr = '',
k = 0;
while ((i + k < len1) && (j + k < len2) && (str1[i + k] === str2[j + k])) {
tempStr += str1[i + k];
k++;
}
if (tempStr.length > maxSubStr.length) {
maxSubStr = tempStr;
}
}
}
return maxSubStr;
}
2.1.3 背包问题
背包问题是算法研究中的一个经典问题。
你是一个保险箱大盗,打开了一个装满奇珍异宝的保险箱,但是你必须将这些宝贝放入你的一个小背包中。保险箱中的物品规格和价值不同。你希望自己的背包装进的宝贝总价值最大。
当然,暴力计算可以解决这个问题,但是动态规划会更为有效。使用动态规划来解决背包问题的关键思路是计算装入背包的每一个物品的最大价值,直到背包装满。
表1:0-1背包问题
物品 | A | B | C | D | E |
---|---|---|---|---|---|
价值 | 4 | 5 | 10 | 11 | 13 |
尺寸 | 3 | 4 | 7 | 8 | 9 |
首先,我们看看递归方式怎么去解决这个问题:
function knapsack (capacity, objectArr, order) {
if (order < 0 || capacity <= 0) {
return 0;
}
if (arr[order].size > capacity) {
return knapsack(capacity, objectArr, order - 1);
}
return Math.max(arr[order].value + knapsack(capacity - arr[order].size, objectArr, order - 1),
knapsack(capacity, objectArr, order - 1));
}
console.log(knapsack(16, [
{value: 4, size: 3},
{value: 5, size: 4},
{value: 10, size: 7},
{value: 11, size: 8},
{value: 13, size: 9}
], 4)); // 23
为了提高程序的运行效率,我们不妨将递归实现方式改成动态规划。
注意,理解0-1背包问题的突破口,就是要理解 “0-1” 这个含义,这里对于每一件物品,要么带走(1),要么留下(0)。
基本思路
0-1背包问题子结构:选择一个给定第 i 件物品,则需要比较选择第 i 件物品的形成的子问题的最优解与不选择第 i件物品的子问题的最优解。分成两个子问题,进行选择比较,选择最优的。
若将 f[i][w] 表示前 i 件物品恰放入一个容量为 w 的背包可以获得的最大价值。则其状态转移方程便是:
f[i][w] = max{ f[i-1][w], f[i-1][w-w[i]]+v[i] }
其中,w[i] 表示第 i 件物品的重量,v[i] 表示第 i 件物品的价值。
function knapsack (capacity, objectArr) {
var n = objectArr.length;
var f = [];
for (var i = 0; i <= n; i++) {
f[i] = [];
for (var w = 0; w <= capacity; w++) {
if (i === 0 || w === 0) {
f[i][w] = 0;
} else if (objectArr[i - 1].size <= w) {
var size = objectArr[i - 1].size,
value = objectArr[i - 1].value
f[i][w] = Math.max(f[i - 1][w - size] + value, f[i - 1][w]);
} else {
f[i][w] = f[i - 1][w];
}
}
}
return f[n][capacity];
}
2.2 贪心算法
前面研究的动态规划算法,它可以用于优化通过次优算法找到的解决方案——这些方案通常是基于递归方案实现的。对许多问题来说,采用动态规划
的方式去处理有点大材小用
,往往一个简单的算法就够了。
贪心算法
就是一种比较简单的算法。贪心算法总是会选择当下的最优解
,而不去考虑这一次的选择会不会对未来的选择造成影响。使用贪心算法通常表明,实现者希望做出的这一系列局部“最优”选择能够带来最终的整体“最优”选择。
2.2.1 背包问题
如果用到的物品是连续的,那么可以简单地通过物品的单价除以单位体积来确定物品的价值。在这种情况下的最优 是,先装价值最高的物品直到该物品装完或者将背包装满,接着装价值次高的物品,直到这种物品也装完或将背包装满,以此类推。我们把这种问题类型叫做 “部分背包问题”。
表2:部分背包问题
物品 | A | B | C | D | E |
---|---|---|---|---|---|
价值 | 50 | 140 | 60 | 60 | 80 |
尺寸 | 5 | 20 | 10 | 12 | 20 |
比率 | 10 | 7 | 6 | 5 | 4 |
我们不能通过贪心算法来解决离散物品问题的原因,是因为我们无法将“半台电视”放入背包。换句话说,贪心算法不能解决0-1背包问题,因为在0-1背包问题下,你必须放入整个物品或者不放入。
比如,贪心算法计算出单位面积价值最高的物品,依次放入后,背包容量假设剩余10,下一个要放入的是B,尺寸为20,便放不下了。如果不是离散(单个整体物品)的,就可以放入部分填满。
function knapsack (capacity, objectArr) {
// 首先按性价比排序, 高 -> 低
objectArr.sort(function (a, b) {
return parseFloat(b.value / b.size) - parseFloat(a.value / a.size);
});
// 记录物品个数
var n = objectArr.length;
// 记录已经选中尺寸,已选最大的最大价值
var selected = 0,
maxValue = 0;
for (var i = 0; i < n && selected < capacity; i++) {
var size = objectArr[i].size,
value = objectArr[i].value;
if (size <= capacity - selected) {
maxValue += value;
selected += size;
} else {
// 计算比例
maxValue += value * ((capacity - selected) / size);
selected = capacity;
}
}
return maxValue;
}
3 树;
3.1 树
不同于我们上面介绍的线性结构,树是一种非线性结构。
图:
它遵循:
- 仅有唯一一个根节点,没有节点则为空树
- 除根节点外,每个节点都有并仅有唯一一个父节点
- 节点间不能形成闭环
树有几个概念:
- 拥有相同父节点的节点,互称为兄弟节点
- 节点的深度 :从根节点到该节点所经历的边的个数
- 节点的高度 :节点到叶节点的最长路径
- 树的高度:根节点的高度
B、C、D就互称为兄弟节点,其中,节点B的高度为2,节点B的深度为 1,树的高度为3
3.1.1 高度
树的高度计算公式:
下图每个节点值都代表来当前节点的高度:
3.2 二叉树
二叉树,故名思义,最多仅有两个子节点的树(最多能分两个叉的树):
3.3 平衡二叉树
二叉树中,每一个节点的左右子树的高度相差不能大于 1,称为平衡二叉树。
另外还有满二叉树、完全二叉树等:
- 满二叉树:除了叶结点外每一个结点都有左右子叶且叶子结点都处在最底层的二叉树
- 完全二叉树:深度为h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,第h 层所有的结点都连续集中在最左边
3.4 在代码中如何去表示一棵二叉树
3.4.1 链式存储法
二叉树的存储很简单,在二叉树中,我们看到每个节点都包含三部分:
- 当前节点的 val
- 左子节点 left
- 右子节点 right
所以我们可以将每个节点定义为:
function Node(val) {
// 保存当前节点 key 值
this.val = val
// 指向左子节点
this.left = null
// 指向右子节点
this.right = null
}
一棵二叉树可以由根节点通过左右指针连接起来形成一个树。
function BinaryTree() {
let Node = function (val) {
this.val = val
this.left = null
this.right = null
}
let root = null
}
3.4.2 数组存储法(适用于完全二叉树)
下图就是一棵完全二叉树,
如果我们把根节点存放在位置 i=1
的位置,则它的左子节点位置为 2i = 2
,右子节点位置为 2i+1 = 3
。
如果我们选取 B 节点 i=2
,则它父节点为 i/2 = 1
,左子节点 2i=4
,右子节点 2i+1=5
。
以此类推,我们发现所有的节点都满足这三种关系:
- 位置为 i 的节点,它的父节点位置为 i/2
- 它的左子节点 2i
- 它的右子节点 2i+1
因此,如果我们把完全二叉树存储在数组里(从下标为 1 开始存储),我们完全可以通过下标找到任意节点的父子节点。从而完整的构建出这个完全二叉树。这就是数组存储法
。
数组存储法相对于链式存储法不需要为每个节点创建它的左右指针,更为节省内存。
3.5 二叉树的遍历
二叉树的遍历可分为:
- 前序遍历
- 中序遍历
- 后序遍历
所谓前、中、后,不过是根的顺序,即也可以称为先根遍历、中根遍历、后根遍历
3.5.1 前序遍历
对于二叉树中的任意一个节点,先打印该节点,然后是它的左子树,最后右子树
3.5.2 中序遍历
对于二叉树中的任意一个节点,先打印它的左子树,然后是该节点,最后右子树
3.5.3 后序遍历
对于二叉树中的任意一个节点,先打印它的左子树,然后是右子树,最后该节点
3.5.4 代码实现(前序遍历为例)
所以,遍历二叉树的过程也就是一个递归的过程,例如前序遍历,先遍历根节点,然后是根的左子树,最后右子树;遍历根节点的左子树的时候,又是先遍历左子树的根节点,然后左子树的左子树,左子树的右子树…….
所以,它的递归实现就是:
递归实现
// 前序遍历
var preorderTraversal = (root) => {
let result = []
var preOrderTraverseNode = (node) => {
if(node) {
// 先根节点
result.push(node.val)
// 然后遍历左子树
preOrderTraverseNode(node.left)
// 再遍历右子树
preOrderTraverseNode(node.right)
}
}
preOrderTraverseNode(root)
return result
};
我们既然可以使用递归实现,那么是否也可以使用迭代实现?
迭代实现
利用栈来记录遍历的过程,实际上,递归就使用了调用栈,所以这里我们可以使用栈来模拟递归的过程
- 首先根入栈
- 将根节点出栈,将根节点值放入结果数组中
- 然后遍历左子树、右子树,因为栈是先入后出,所以,我们先右子树入栈,然后左子树入栈
- 继续出栈(左子树被出栈)…….
依次循环出栈遍历入栈,直到栈为空,遍历完成
// 前序遍历
const preorderTraversal = (root) => {
const list = [];
const stack = [];
// 当根节点不为空的时候,将根节点入栈
if(root) stack.push(root)
while(stack.length > 0) {
const curNode = stack.pop()
// 第一步的时候,先访问的是根节点
list.push(curNode.val)
// 我们先打印左子树,然后右子树
// 所以先加入栈的是右子树,然后左子树
if(curNode.right !== null) {
stack.push(curNode.right)
}
if(curNode.left !== null) {
stack.push(curNode.left)
}
}
return list
}
复杂度分析
空间复杂度:O(n)
时间复杂度:O(n)
4 DFS与BFS
- DFS:深度优先遍历
- BFS:广度优先遍历
4.1 DFS(深度优先遍历)
js实现该算法代码(递归版本):
function deepFirstSearch(node,nodeList) {
if (node) {
nodeList.push(node);
var children = node.children;
for (var i = 0; i < children.length; i++)
//每次递归的时候将 需要遍历的节点 和 节点所存储的数组传下去
deepFirstSearch(children[i],nodeList);
}
return nodeList;
}
非递归版本:
function deepFirstSearch(node) {
var nodes = [];
if (node != null) {
var stack = [];
stack.push(node);
while (stack.length != 0) {
var item = stack.pop();
nodes.push(item);
var children = item.children;
for (var i = children.length - 1; i >= 0; i--)
stack.push(children[i]);
}
}
return nodes;
}
4.2 BFS(广度优先遍历)
js实现算法代码(递归版本):
function breadthFirstSearch(node) {
var nodes = [];
var i = 0;
if (!(node == null)) {
nodes.push(node);
breadthFirstSearch(node.nextElementSibling);
node = nodes[i++];
breadthFirstSearch(node.firstElementChild);
}
return nodes;
}
非递归版本:
function breadthFirstSearch(node) {
var nodes = [];
if (node != null) {
var queue = [];
queue.unshift(node);
while (queue.length != 0) {
var item = queue.shift();
nodes.push(item);
var children = item.children;
for (var i = 0; i < children.length; i++)
queue.push(children[i]);
}
}
return nodes;
}