2020前端面试必备算法

1,151 阅读15分钟

前言

程序员在职场上混,怎能没“两把刀”,算法就是我们的一把刀剑,虽然打磨锋利不见得用的着,但是能加深我们对底层的认识,我们也经常在项目中潜移默化的用着,这就像是“杀人于无形”

🙃好了,编不下去了~~~

为了加快程序的执行效率,即减少程序执行的时间复杂度空间复杂度,我们就需要对我们平时的代码进行优化,尽量达到最优的执行性能,这也就是我们今天要讲的算法的目的。

算法虽然是程序优化的一种,但也只有在运算数据量比较大或者比较频繁的程序中才能真正体现它的价值

开始之前先理解时间复杂度空间复杂度这两个概念

  • 时间复杂度:并不是程序执行所需要的时间,而是算法执行语句的次数
  • 空间复杂度:即程序运行时临时占用内存大小的量度

内排序和外排序的概念

  • 内排序:排序都在内存中进行
  • 外排序:数据量较大,因此把数据放在磁盘中,排序需要通过磁盘和内存数据传输才能进行

算法

排序

冒泡排序、选择排序、快速排序、插入排序、归并排序、计数排序、希尔排序

搜索

线性搜索、二分查找法(搜索有序数组)、二叉树搜索

广度优先搜索(BFS)、深度优先搜索(DFS)

排序

排序多用在对数组的处理,当数组的数据较大的时候,传统排序方式无法高效率的完成,需要通过更便捷的方式去快速处理

冒泡排序

解析:1. 比较前后两个数,大的换到后面,小的到前面(升序,降序则相反),需要个变量存储中间值。2.一次后应该最大的排到了最后,因此第二次可以在第二个循环减掉第一个循环的个数

function bubbleSort (arr = []) {
    let temp
    for (let i = 0, len = arr.length; i < len; i++) {
        // len减i是因为循环i之后在最后面的i个数是已经排好顺序的,所以可以减掉循环的次数,而减1是因为i是从0开始的
        for (let j = 0, len = arr.length-i-1; j < len; j++) {
            if (arr[j] > arr[j+1]) {
                temp = arr[j]
                arr[j] = arr[j+1]
                arr[j+1] = temp
            }
        }
    }
    return arr
}

效率: 时间复杂度 O(n²),空间复杂度O(1) 内排序

选择排序

解析:从数组里面选择最小(或最大)的数排在起始位置,接着在未排序的数组中寻找最小(或最大)的数依次排在起始位置的后面,重复此动作,直至排序完成

funtion selectSort (arr = []) {
    let temp, minIndex
    // len减1是因为最后剩下的那个数一定是最小(或最大)的,数组下标值已经被挤到最后去了,保持位置就行了,不用进入循环
    for (let i = 0, len = arr.length - 1; i < len; i++) {
        minIndex = i
        
        // 选择排序是从i的第一个值开始排,因此i前面的都是已经排好顺序的,下面直接拿i后面的数组出来排序就行了
        for (let j = i, len = arr.length; j < len; j++) {
            if (arr[j] < arr[minIndex]) {
                minIndex = j
            }
        }
        temp = arr[i]
        arr[i] = arr[minIndex]
        arr[minIndex] = temp
    }
    return arr
}

效率: 时间复杂度 O(n²) 空间复杂度 O(1) 内排序

快速排序

解析:快排是冒泡排序的优化,寻找数组中的中间值,然后将一组数据一分为二,小于中间值的放在左边(或者右边),大于中间值的放在右边(或者左边),循环递归这个操作直到操作的数组长度小于等于1(递归必须有个条件跳出循环,否则会造成内存堆栈溢出)

function quickSort (arr = []) {
    if (arr.length <= 1) {
        return arr
    }
    let left = [],
        right = []
        midIndex = Math.floor(arr.length/2)
        midVal = arr.splice(midIndex, 1)[0]
    for (let i = 0, len = arr.length; i < len; i++) {
        if (arr[i] < midVal) {
            left.push(arr[i])
        } else {
            right.push(arr[i])
        }
    }
    return quickSort(left).concat(midVal, quickSort(right))
}

效率: 时间复杂度O(n log n) 空间复杂度O(log n) 内排序

插入排序

解析:将数组第0个元素作为有序数组的第一个值,然后下一次取数组第二个元素从有序数组的后面开始比较,如果没有小于有序数组的值,那么被比较过的有序数组的值就往后面移一个下标,否则(也就是找到了自己的位置了),就插入位置,完成一次插入。依次取剩余的数组的元素去比较即可。(有点类似于选择排序,不过选择排序是将比较值拿去跟未排序的数组比较)

function insertSort (arr = []) {
    for (let i=1, len=arr.length; i<len; i++) {
        // 升序
        if (arr[i] < arr[i-1]) {
            //存储将要比较的元素,因为这个元素的位置会被有序数组往后移动的过程中覆盖掉
            let temp = arr[i]
            // 存储有序数组最后一个值的下标
            let j = i - 1
            // 给有序数组大小扩大1个元素的占位
            arr[i] = arr[j]
            // 寻找新元素的位置,移动有序数组的位置
            while(j>=0 && arr[j] > temp) {
                arr[j+1] = arr[j]
                j--
            }
            // 这里+1是因为当不满足while条件的时候j已经被减掉1了,所以得+1抵消
            arr[j+1] = temp
        } 
    }
    return arr
}

**效率:**时间复杂度O(n²) 空间复杂度O(1) 内排序

搜索

二分查找法

解析:查询一个数在一个有序数组里的位置。首先我们需要现在一个数组排序成有序数组,然后取数组的中间值,将中间值和查询的数字比较,如果中间值大于查询的数组,那么我们取左边的数组(这个我们是按升序来说的,倒叙则相反)开始递归进行如上操作,这样每次就会减少数组一半的查询,如果数据量很大的情况下是非常能提升效率的。

function binarySearch (arr = [], key, start, end) {
    start = start|| 0
    end = end || arr.length-1
    let midIndex = Math.floor((start+end)/2)
    if (arr[midIndex] === key) {
        return midIndex
    }
    if (arr[midIndex] > key) {
        binarySearch(arr, key, 0, midIndex-1)
    }
    if (arr[midIndex] < key) {
        binarySearch(arr, key, midIndex+1, end)
    }
    return -1
}

效率: 时间复杂度 O(log₂ n)

线性搜索

解析:线性搜索就是普通的for循环查询,将一个数去比较数组里面的每个值,比较常规,这里就不细讲

二叉树

二叉查找树又叫二叉搜索树,是指一棵空树和一棵具有一下性质的树

  • 任意节点的左子树不空,则左子树节点上的所有值均小于根节点的值
  • 任意节点的右子树不空,则右子树节点上的所有值均大于根节点的值
  • 任意节点的左右子树也是二叉树
  • 没有键值相等的节点

二叉树的优势在于查找和插入的时间复杂度相对较低,为O(log n),二叉树是基础性数据结构,主要用于构建更为抽象的数据结构,如集合、关联数组等

二叉树跟其他的数据结构相比区别有它是通过按值保存元素,也是按值获取元素,而数组、向量、链表是顺序容器,通过位置访问数据,想要通过值来获取数据则必须通过遍历的方式

了解二叉树搜索,首先需要先了解几种遍历方式

  • 前序遍历:根节点->左节点->右节点,上面这棵树的前序遍历顺序为ABDEGHCF
  • 中序遍历:左节点->根节点->右节点,上面这棵树的中序遍历顺序为DBGEHACF
  • 后序遍历:左节点->右节点->根节点,上面这棵树的后序遍历顺序为DGHEBFCA
// 每个节点都有data、left、right的属性,所以先创建一个节点类工厂
class Node {
    constructor(data) {
        this.data = data
        this.left = null
        this.right = null
    }
}
// 创建二叉树BST
class BinarySearchTree  {
    constructor() {
        this.root = null
    }
    // 插入节点
    insertNode (data) {
       let newNode = new Node(data)
       const _insertNode = (node, newNode) => {
           if (newNode.data < node.data) {
               if (!node.left) {
                   node.left = newNode
               } else {
                   _insertNode(node.left, newNode)
               }
           } else {
               if (!node.right) {
                   node.right = newNode
               } else {
                   _insertNode(node.right, newNode)
               }
           }
       }
       // 没有根节点直接插入到根节点
       if (!this.root) {
           this.root = newNode
       } else {
            _insertNode(this.root, newNode)   
       }
    }
    // 前序遍历
    preOrder (data) {
        let arr = []
        const _preOrder = (node) => {
            if (node) {
                // 符合前序遍历的特点:根->左->右
                arr.push(node.data)
                _preOrder(node.left)
                _preOrder(node.right)
            }
        }
        _preOrder(this.root)
        return arr
    }
    // 中序遍历
    midOrder () {
        let arr = []
        const _midOrder = (node) => {
            if (node) {
                // 符合中序遍历的特点:左->根->右
                _midOrder(node.left)
                arr.push(node.data)
                _midOrder(node.right)
            }
        }
        _midOrder(tiis.root)
        return arr
    }
    // 后续遍历
    behindOrder () {
        let arr = []
        const _behindOrder = (node) => {
            if (node) {
                // 符合后序遍历的特点:左->右->根
                _behindOrder(node.left)
                _behindOrder(node.right)
                arr.push(node.data)
            }
        }
        _behindOrder(this.root)
        return arr
    }
    // 获取最小值,可以指定某个节点下的最小值
    getMin (node) {
        const _getMin = (node) => {
            return node?(node.left?_getMin(node.left):node):null 
        }
        return _getMin(node || this.root)
    }
    // 获取最大值,可以指定某个节点下的最大值
    getMax (node) {
        const _getMax = (node) => {
            return node?(node.right?_getMax(node.right):node):null
        }
        return _getMax(node || this.root)
    }
    // 查找元素所在的节点
    findNode (data) {
        const _findNode = (node, data) => {
            if (!node) return -1
            if (node.data === data) return node
            // 根据data和当前节点data比较,小的在左,大的在右
            let n = data > node.data ? node.right : node.left
            return _findNode(n, data)
        }
        return _findNode(this.root, data)
    }
    /** 
    * 移除节点
    * 
    */
    removeNode (data) {
        const _removeNode = (node, data) => {
            if (!node) return -1
            if(node.data === data) {
                if (!node.left && !node.right) return null
                if (!node.left) return node.right
                if (!node.right) return node.left
                // 左右子树都存在的情况下,用右子树最小节点替换掉要删除的节点,如果找左子数的最小节点替换,那么意味着它的左边会有大于它本身的节点,明显是不合理的🙃
                let _node = this.getMin(node.right)
                node.data = _node.data
                node.right = _removeNode(node.right, data)
                return node
            } else if(node.data < data) {
                node.left = _removeNode(node.left, data)
                return node
            } else {
                node.right = _removeNode(node.right, data)
                return node
            }
        }
        return _removeNode(this.root, data)
    }
}
// 测试
var tree = new BinarySearchTree()
tree.insertNode(11)
tree.insertNode(7)
tree.insertNode(5)
tree.insertNode(3)
tree.insertNode(9)
tree.insertNode(8)
tree.insertNode(10)
tree.insertNode(13)
tree.insertNode(12)
tree.insertNode(14)
tree.insertNode(20)
tree.insertNode(18)
tree.insertNode(25)
console.log(tree)
console.log(tree.root)
//中序遍历BST
console.log(tree.midOrder())
//前序遍历BST
console.log(tree.preOrder())
//后序遍历BST
console.log(tree.behindOrder())
//搜索最小值
console.log(tree.getMin())
//搜索最大值
console.log(tree.getMax())
//查找特定值
console.log(tree.findNode(2))
console.log(tree.findNode(3))
console.log(tree.findNode(20))
//删除节点,返回新的二叉树,不改变原来的二叉树
console.log(tree.removeNode(11))
a=tree.removeNode(11)
console.log(a.root)
console.log(tree)

查找第K大的值,思路是,利用中序遍历是从小到大的序列,则可以通过叠加计算到K次的时候返回节点的值

在BinarySearchTree类里面添加以下方法
whichNum (k) {
    let count = 0, kNum
    const _midOrder = (node) => {
        if (node) {
            _midOrder(node.left)
            if (++count === k) {
                kNum = node.data
                return
            }
            _midOrder(node.right)
        }
    }
    _midOrder(this.root)
    return kNum
}

注: removeNode这个删除节点的方法相对比较难理解,存在3种情况,节点只含有左子树、节点只含有右子树、节点左右子树都包含,当左右子树都包含的时候我们一般删除的策略是寻找右子树最小节点的数据替换要删除的节点的数据,也即调用我们创建的getMin方法

二叉树的实际应用性能对比:

有序数组:查找一个元素的复杂度为O(1),但插入一个元素复杂度为O(N) 有序链表:查找一个数的复杂度为O(N),插入一个数的复杂度为O(1) 二叉树:查找和插入的复杂度都为O(log n),是以上两种方式的折中选择方案

二叉树和二分查找法对比

二叉树的缺点是,不能随机访问

BFS和DFS的区别无非就是时间换空间或者空间换时间

深度优先算法不需要记住所有的节点,所以占用的空间较小,而广度优先因为要记住遍历过的所有节点所以占用的空间较大

深度优先算法因为没有路了要走回去,即回溯,所以走的时间会长一点,即占用的时间较长

深度优先算法BFS采用的是堆栈的形式,即先进后出

而广度优先算法则采用的是队列的形式,即先进先出

深度优先

解析:其实就是自上而下的遍历

let arr = [
    {
        name: 'a',
        children: [
            {name: 'b', children: [{name: 'c'}]},
            {name: 'd', children: [{name: 'e'}]},
            {name: 'f', children: [{name: 'g'}]}
        ]
    },
    {
        name: 'a1',
        children: [
            {name: 'b1', children: [{name: 'c1'}]},
            {name: 'd1', children: [{name: 'e1'}]},
            {name: 'f1', children: [{name: 'g1'}]}
        ]
    }
]
/**
* arr 遍历的数组
* store是准备最后返回出去的
*/
function depth (arr, store = []) {
    arr.forEach(item => {
        store.push(item.name)
        item.children && depth(item.children, store)
    })
    return store
}
depth(arr)
// 输出["a", "b", "c", "d", "e", "f", "g", "a1", "b1", "c1", "d1", "e1", "f1", "g1"]

广度优先

解析:逐层遍历

function bread (arr=[]) {
    let result = []
    let queue = arr
    while(queue.length>0) {
        // [...queue]这样写的原因是避免下面的shift和push对当前遍历的数组的影响
        [...queue].forEach(child => {
            queue.shift()
            result.push(child.name)
            child.children && (queue.push(...child.children))
        })
    }
    return result.join(',')
}
// 输出"a,a1,b,d,f,b1,d1,f1,c,e,g,c1,e1,g1"

其他算法

杨辉三角形

解析: 函数接收三角形排列的行数,每层的第一个和最后一个都是1,中间的每个数都是对应上一层中的同个位置的数值和上一层中同个位置的上一个位置的数字之和。下面我们用两种方式来实现

二维数组

function triangle (n) {
    let arr = []
    for (let i=0; i<n; i++) {
        // 把行和列看成二位数组来处理
        arr[i] = []
        // 因为每行的列数等于行数,所以写成j<=i
        for(let j=0; j<=i; j++) {
            // 每行的第一个数和最后一个数是1
            if (j === 0 || j === i) arr[i][j] = 1
            else arr[i][j] = arr[i-1][j-1] + arr[i-1][j]
        }
        console.log(arr[i].join(','))
    }
}

递归

function calcute (i, j) {
    if (j === 0) return 1
    if (j === i) return 1
    else return calcute(i-1, j-1) + calcute(i-1, j)
}
function triangle (n) {
    for (let i=0; i<n; i++) {
        let arr = []
        for (let j=0; j<=i; j++) {
            arr.push(calcute(i, j))
        } 
        console.log(arr.join(','))
    }
}

两者之间的比较:

当数据量小的时候,其实差别不大,但是当n很大的时候,递归这种方式因为每次都得重新去递归计算到三角形最上面的1的值,所以非常消耗计算机的计算,我单单只试了一下n=1000,递归这种方式打印过程直接卡住了。而二维数组这种方式不用回溯之前的值所以效率相对来说高点,同样n=1000,二维数组算法消耗时间是time=18.423828125ms。

所以给我们提个醒,并非所有场景下都适用递归,虽然递归会给我们减少代码量,但是我们也要慎重用之

回文判断

解析:回文字符串就是顺着拼写和倒着拼写是相等的,比如mom(倒着也是mom)、redivider等,思路就是先将字符串转换成数组,然后通过reverse倒排数组,再通过join还原成字符串与之比较

function huiwen (word) {
    if(!word) return -1
    return word === word.split('').reverse().join('')
}

数组去重

解析:将数组中一样的数字或者字符只保留一个,去掉多余的,通过ES6新增的数据结构Set可以便捷的解决此问题

function unique (arr = []) {
    let temp = new Set(arr)
    // [...temp]是为了将set重新转成array
    return [...temp]
}

Set是ES6新增的数据结构,一大特性就是所有元素必须是唯一,不能重复,注意的是set会用===去比较值,所以"2"和2是可以共存的,因为类型不一样

数组去重也可以通过for循环解决

function unique (arr = []) {
    let _arr = [], obj = {}
    for(let i=0, len=arr.length; i<len; i++) {
        if(!obj[arr[i]]) {
            _arr.push(arr[i])
            obj[arr[i]] = true
        }
    }
    return _arr
}

找数组重复次数最多的元素

解析:数组中同个元素重复次数最多的元素

function maxNum (str) {
    if (str.length === 1) return str
    let obj = {}
    for(let i=0, len=str.length; i<len; i++) {
        if (!obj[str.charAt(i)]) {
            obj[str.charAt(i)] = 1
        } else {
            obj[str.charAt(i)] += 1
        }
    }
    // 因为只要在obj里面存在的属性的值都是>=1的值,所以可以设置开始比较的值为1
    let maxStr, maxValue = 1
    for(let key in obj) {
        if (obj[key] >= maxValue) {
            maxValue = obj[key]
            maxStr = key
        }
    }
    return maxStr
}

交换两个数

解析:交换两个数的值通过临时变量很容易,但是我们在这里不通过临时变量去更换两个数值,思路主要就是a=b,也相当于a=a+b-a,通过利用+-去计算

function swap (a, b) {
    b=b-a
    // 相当于a=a+(b-a)
    a=a+b
    // 相当于b=(a+b)-b
    b=a-b
}

斐波那契数列

解析:这种只要找出规律基本就没啥问题,第n个数等于第n-1个数和第n-2个数相加之和,即fn = F(n-1)+F(n+2),比如0、1、1、2、3、5、8、13、21、34、……就是一个斐波那契数列

function fbnq (n) {
	let arr = []
	for(let i=0; i<n; i++) {
		if (i<=1) arr.push(i)
		else arr.push(arr[i-1]+arr[i-2])
	}
	return arr
}

阶乘

解析:比如!5 = 54321

function jc (num) {
    if (typeof num !== 'number') return
    if (num<1) return 1
    return num*jc(--num)
}

数组扁平化

解析:将多维数组转变为一维数组,比如[1, [2], [3, [[4]]]]->[1,2,3,4]

function flatten (arr = []) {
    let temp = []
    for(let i=0, len=arr.length; i<len; i++) {
        if (Array.isArray(arr[i])) {
            temp = temp.concat(flatten(arr[i]))
        } else {
            temp.push(arr[i])
        }
    }
    return temp
}

或者通过ES6新增的some来实现,some只要遍历到不满足条件的就会停止后面的遍历,有助于提高效率

function flatten (arr=[]) {
    while(arr.some(item => Array.isArray(item))) {
        arr = [].concat(...arr)
    }
    return arr   
}

也可以利用ES6新增的flat拉平数组,默认只拉平一层,如果要拉两层,则flat(2),依次类推,如果不管多少层都拉平,可以通过关键字Infinity, flat(Infinity)

[1,[2],3].flat()  ->  output [1,2,3]
[1,[[2]],3].flat() ->  output [1,[2],3]
[1,[[2]],3].flat(2) -> output [1,2,3]
[1,[[2]],3].flat(Infinity) -> output [1,2,3]

前面讲的都是一些更接近算法的东西,后面的则偏重于实际应用中的编码技巧集锦

👌好了,到此就告一段落了,能看到这里的地方,多多少少相信你也对算法和递归有些许的了解了,有错的请指正或评论区留言

祝各位君在2020年能找到一份满意的offer👨‍🎓

感兴趣的可以留言加关注🤣,也可以顺手点个赞再走

参考资料