2023 前端算法不完全指南

1,195 阅读18分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第15天,点击查看活动详情

在这样的铜三铁四一个存量市场,前端竞争也在无形中“卷”了起来

缺乏算法思维,又如何在大厂、中厂脱影而出呢??

算法感兴趣的倔友可以看下面写的专栏

# JavaScript版数据结构与算法

目录

一、旋转数组

题目

定义一个函数,实现数组的旋转。如输入 [1, 2, 3, 4, 5, 6, 7]key = 3
输出 [5, 6, 7, 1, 2, 3, 4]

考虑时间复杂度和性能

实现思路

  • k 后面的所有元素拿出来作为 part1
  • k 前面的所有元素拿出来作为 part2
  • 返回 part1.concat(part2)

写代码

/**
 * 旋转数组 k 步 - 使用 concat
 * @param arr arr
 * @param k k
 */
 export function rotate(arr: number[], k: number): number[] {
    const length = arr.length
    if (!k || length === 0) return arr
    const step = Math.abs(k % length) // abs 取绝对值

    // O(1)
    const part1 = arr.slice(-step) // O(1)
    const part2 = arr.slice(0, length - step)
    const part3 = part1.concat(part2)
    return part3
}

// // 功能测试
// const arr = [1, 2, 3, 4, 5, 6, 7]
// const arr1 = rotate(arr, 3)
// console.info(arr1)

划重点

  • 考虑参数非法情况,代码鲁棒性
  • 算法复杂度
    • 要看到全部的时间复杂度(包括 API)
    • 重时间,轻空间
  • 数组是有序结构,shift unshift 等要慎用
  • 单元测试

扩展 - 不要过度优化

其实还有一种思路,时间复杂度 O(n) ,空间复杂度 O(1) ,思路:

  • k 前面的元素移动到 i + (length - k) 的位置
  • k 后面的元素移动到 i - k 的位置

但不推荐这样的做法

  • 前端重时间、轻空间,优先考虑时间复杂度,而非空间复杂度
  • 代码是否易读,是否易沟通 —— 这个比性能更重要!人力成本永远是最贵的!!

二、括号匹配

题目

一个字符串内部可能包含 { } ( ) [ ] 三种括号,判断该字符串是否是括号匹配的。
(a{b}c) 就是匹配的, {a(b{a(b}c) 就是不匹配的。

栈 Stack

栈,先进后出,基本的 API

  • push
  • pop
  • length

和栈相关的数据结构(后面讲)

  • 队列,先进先出
  • 堆,如常说的“堆栈模型”

逻辑结构和物理结构

栈和数组有什么区别?—— 没有可比性,两者不一个级别。就像:房子和石头有什么区别?

栈是一种逻辑结构,一种理论模型,它可以脱离编程语言单独讲。
数组是一种物理结构,代码的实现,不同的语言,数组语法是不一样的。

栈可以用数组来表达,也可以用链表来表达,也可以自定义 class MyStack {...} 自己实现…
在 JS 中,栈一般情况下用数组实现。

思路

  • 遇到左括号 { ( [ 则压栈
  • 遇到右括号 } ) ] 则判断栈顶,相同的则出栈
  • 最后判断栈 length 是否为 0

答案

/**
 * 判断左右括号是否匹配
 * @param left 左括号
 * @param right 右括号
 */
function isMatch(left: string, right: string): boolean {
    if (left === '{' && right === '}') return true
    if (left === '[' && right === ']') return true
    if (left === '(' && right === ')') return true
    return false
}

/**
 * 判断是否括号匹配
 * @param str str
 */
export function matchBracket(str: string): boolean {
    const length = str.length
    if (length === 0) return true

    const stack = []

    const leftSymbols = '{[('
    const rightSymbols = '}])'

    for (let i = 0; i < length; i++) {
        const s = str[i]

        if (leftSymbols.includes(s)) {
            // 左括号,压栈
            stack.push(s)
        } else if (rightSymbols.includes(s)) {
            // 右括号,判断栈顶(是否出栈)
            const top = stack[stack.length - 1]
            if (isMatch(top, s)) {
                stack.pop()
            } else {
                return false
            }
        }
    }

    return stack.length === 0
}

// // 功能测试
// const str = '{a(b[c]d)e}f'
// console.info(123123, matchBracket(str))

划重点

  • 逻辑结构和物理结构

三、用两个栈实现一个队列

题目

请用两个栈,来实现队列的功能,实现功能 add delete length

队列 Queue

栈,先进后出

队列,先进先出,API 包括

  • add
  • delete
  • length

常见的“消息队列”就是队列的一种应用场景

  • A 系统向 B 系统持续发送海量的消息
  • A 系统先把一条一条消息放在一个 queue
  • B 系统再从 queue 中逐条消费(按顺序,先进先出)

逻辑结构和物理结构

队列和栈一样,是一种逻辑结构。它可以用数组、链表等实现。
思考:用数组实现队列,性能会怎样 —— add 怎样?delete 怎样?

复杂场景下(如海量数据,内存不够用)需要单独设计。

题目分析

  • 队列 add
    • 往 stack1 push 元素
  • 队列 delete
    • 将 stack1 所有元素 pop 出来,push 到 stack2
    • 将 stack2 执行一次 pop
    • 再将 stack2 所有元素 pop 出来,push 进 stack1

答案

/**
* @description 两个栈 - 一个队列
*/

export class MyQueue {
   private stack1: number[] = []
   private stack2: number[] = []

   /**
    * 入队
    * @param n n
    */
   add(n: number) {
       this.stack1.push(n)
   }

   /**
    * 出队
    */
   delete(): number | null {
       let res

       const stack1 = this.stack1
       const stack2 = this.stack2

       // 将 stack1 所有元素移动到 stack2 中
       while(stack1.length) {
           const n = stack1.pop()
           if (n != null) {
               stack2.push(n)
           }
       }

       // stack2 pop
       res = stack2.pop()

       // 将 stack2 所有元素“还给”stack1
       while(stack2.length) {
           const n = stack2.pop()
           if (n != null) {
               stack1.push(n)
           }
       }

       return res || null
   }

   get length(): number {
       return this.stack1.length
   }
}

// // 功能测试
// const q = new MyQueue()
// q.add(100)
// q.add(200)
// q.add(300)
// console.info(q.length)
// console.info(q.delete())
// console.info(q.length)
// console.info(q.delete())
// console.info(q.length)

划重点

  • 队列
  • 画图,帮助梳理解题思路

四、反转单向链表

题目

定义一个函数,输入一个单向链表的头节点,反转该链表,并输出反转之后的头节点

链表

链表是一种物理结构(非逻辑结构),是数组的补充。
数组需要一段连续的内存空间,而链表不需要。

数据结构

  • 单向链表 { value, next }
  • 双向链表 { value, prev, next }

两者对比

  • 链表:查询慢,新增和删除较快
  • 数组:查询快,新增和删除较慢

应用场景

React Fiber 就把 vdom 树转换为一个链表,这样才有可能随时中断、再继续进行。
如果 vdom 是树,那只能递归一次性执行完成,中间无法断开。

分析

反转链表,画图很好理解。没有捷径,遍历一边,重新设置 next 指向即可。
但实际写代码,却并不简单,很容易造成 nextNode 丢失。

因此,遍历过程中,至少要存储 3 个指针 prevNode curNode nextNode

时间复杂度 O(n)

答案

/**
 * @description 反转单向链表
 */

export interface ILinkListNode {
    value: number
    next?: ILinkListNode
}

/**
 * 反转单向链表,并返回反转之后的 head node
 * @param listNode list head node
 */
export function reverseLinkList(listNode: ILinkListNode): ILinkListNode {
    // 定义三个指针
    let prevNode: ILinkListNode | undefined = undefined
    let curNode: ILinkListNode | undefined = undefined
    let nextNode: ILinkListNode | undefined = listNode

    // 以 nextNode 为主,遍历链表
    while(nextNode) {
        // 第一个元素,删掉 next ,防止循环引用
        if (curNode && !prevNode) {
            delete curNode.next
        }

        // 反转指针
        if (curNode && prevNode) {
            curNode.next = prevNode
        }

        // 整体向后移动指针
        prevNode = curNode
        curNode = nextNode
        nextNode = nextNode?.next
    }

    // 最后一个的补充:当 nextNode 空时,此时 curNode 尚未设置 next
    curNode!.next = prevNode

    return curNode!
}

/**
 * 根据数组创建单向链表
 * @param arr number arr
 */
export function createLinkList(arr: number[]): ILinkListNode {
    const length = arr.length
    if (length === 0) throw new Error('arr is empty')

    let curNode: ILinkListNode = {
        value: arr[length - 1]
    }
    if (length === 1) return curNode

    for (let i = length - 2; i >= 0; i--) {
        curNode = {
            value: arr[i],
            next: curNode
        }
    }

    return curNode
}

const arr = [100, 200, 300, 400, 500]
const list = createLinkList(arr)
console.info('list:', list)

const list1 = reverseLinkList(list)
console.info('list1:', list1)

划重点

  • 链表
  • 链表和数组的不同
    • 内存占用
    • 查询、新增、删除的效率
  • 如何保证 nextNode 不丢失

扩展

思考:用数组和链表实现队列,哪个性能更好?

五、二分查找

题目

用 Javascript 实现二分查找(针对有序数组),说明它的时间复杂度

一个故事

N 年前,百度,一个复杂的后台系统出现了问题,因为太大找不到问题所在。 一个工程师,使用二分法,很快找到了问题原因。

无论多么大的数据量,一旦有了二分,便可快速搞定。
二分法,是算法的一个重要思维。

但二分法有一个条件:需要有序数据。

分析

二分查找是一种固定的算法,没什么可分析的。

两种实现思路

  • 递归 - 代码逻辑更加简洁
  • 循环 - 性能更好(就调用一次函数,而递归需要调用很多次函数,创建函数作用域会消耗时间)

时间复杂度 O(logn)

答案

/**
 * @description 二分查找
 */

/**
 * 二分查找(循环)
 * @param arr arr
 * @param target target
 */
export function binarySearch1(arr: number[], target: number): number {
    const length = arr.length
    if (length === 0) return -1

    let startIndex = 0 // 开始位置
    let endIndex = length - 1 // 结束位置

    while (startIndex <= endIndex) {
        const midIndex = Math.floor((startIndex + endIndex) / 2)
        const midValue = arr[midIndex]
        if (target < midValue) {
            // 目标值较小,则继续在左侧查找
            endIndex = midIndex - 1
        } else if (target > midValue) {
            // 目标值较大,则继续在右侧查找
            startIndex = midIndex + 1
        } else {
            // 相等,返回
            return midIndex
        }
    }

    return -1
}

/**
 * 二分查找(递归)
 * @param arr arr
 * @param target target
 * @param startIndex start index
 * @param endIndex end index
 */
export function binarySearch2(arr: number[], target: number, startIndex?: number, endIndex?: number): number {
    const length = arr.length
    if (length === 0) return -1

    // 开始和结束的范围
    if (startIndex == null) startIndex = 0
    if (endIndex == null) endIndex = length - 1

    // 如果 start 和 end 相遇,则结束
    if (startIndex > endIndex) return -1

    // 中间位置
    const midIndex = Math.floor((startIndex + endIndex) / 2)
    const midValue = arr[midIndex]

    if (target < midValue) {
        // 目标值较小,则继续在左侧查找
        return binarySearch2(arr, target, startIndex, midIndex - 1)
    } else if (target > midValue) {
        // 目标值较大,则继续在右侧查找
        return binarySearch2(arr, target, midIndex + 1, endIndex)
    } else {
        // 相等,返回
        return midIndex
    }
}

// // // 功能测试
// const arr = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]
// const target = 40
// // console.info(binarySearch2(arr, target))

// // 性能测试
// console.time('binarySearch1')
// for (let i = 0; i < 100 * 10000; i++) {
//     binarySearch1(arr, target)
// }
// console.timeEnd('binarySearch1') // 17ms
// console.time('binarySearch2')
// for (let i = 0; i < 100 * 10000; i++) {
//     binarySearch2(arr, target)
// }
// console.timeEnd('binarySearch2') // 34ms

划重点

  • 有序,就一定要想到二分
  • 二分的时间复杂度必定包含 O(logn)

六、两数之和

题目

输入一个递增的数字数组,和一个数字 n 。求和等于 n 的两个数字。
例如输入 [1, 2, 4, 7, 11, 15]15 ,返回两个数 [4, 11]

分析

注意题目的要点

  • 递增,从小打大排序
  • 只需要两个数字,而不是多个

常规思路

嵌套循环,找个一个数,然后再遍历剩余的数,求和,判断。

时间复杂度 O(n^2) ,基本不可用。

利用递增的特性

数组是递增的

  • 随便找两个数
  • 如果和大于 n ,则需要向前寻找
  • 如果和小于 n ,则需要向后寻找 —— 二分法

双指针(指针就是索引,如数组的 index)

  • i 指向头,j 指向尾, 求 i + j 的和
  • 和如果大于 n ,则说明需要减少,则 j 向前移动(递增特性)
  • 和如果小于 n ,则说明需要增加,则 i 向后移动(递增特性)

时间复杂度降低到 O(n)

答案

/**
 * @description 两数之和
 */

/**
 * 寻找和为 n 的两个数(嵌套循环)
 * @param arr arr
 * @param n n
 */
export function findTowNumbers1(arr: number[], n: number): number[] {
    const res: number[] = []

    const length = arr.length
    if (length === 0) return res

    // O(n^2)
    for (let i = 0; i < length - 1; i++) {
        const n1 = arr[i]
        let flag = false // 是否得到了结果

        for (let j = i + 1; j < length; j++) {
            const n2 = arr[j]

            if (n1 + n2 === n) {
                res.push(n1)
                res.push(n2)
                flag = true
                break
            }
        }

        if (flag) break
    }

    return res
}

/**
 * 查找和为 n 的两个数(双指针)
 * @param arr arr
 * @param n n
 */
export function findTowNumbers2(arr: number[], n: number): number[] {
    const res: number[] = []

    const length = arr.length
    if (length === 0) return res

    let i = 0 // 头
    let j = length - 1 // 尾

    // O(n)
    while (i < j) {
        const n1 = arr[i]
        const n2 = arr[j]
        const sum = n1 + n2

        if (sum > n) {
            // sum 大于 n ,则 j 要向前移动
            j--
        } else if (sum < n) {
            // sum 小于 n ,则 i 要向后移动
            i++
        } else {
            // 相等
            res.push(n1)
            res.push(n2)
            break
        }
    }

    return res
}

// // 功能测试
const arr = [1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2,1, 2, 4, 7, 11, 15]
// console.info(findTowNumbers2(arr, 15))

console.time('findTowNumbers1')
for (let i = 0; i < 100 * 10000; i++) {
    findTowNumbers1(arr, 15)
}
console.timeEnd('findTowNumbers1') // 730ms

console.time('findTowNumbers2')
for (let i = 0; i < 100 * 10000; i++) {
    findTowNumbers2(arr, 15)
}
console.timeEnd('findTowNumbers2') // 102

划重点

  • 有序数据,要善用二分法
  • 优化嵌套循环,可以考虑“双指针”

七、求二叉搜索树的第 K 小的值

题目

一个二叉搜索树,求其中的第 K 小的节点的值。

image.png

二叉树

树,大家应该都知道,如前端常见的 DOM 树、vdom 结构。

二叉树,顾名思义,就是每个节点最多能有两个子节点。

interface ITreeNode {
    value: number // 或其他类型
    left?: ITreeNode
    right?: ITreeNode
}

二叉树的遍历

  • 前序遍历:root -> left -> right
  • 中序遍历:left -> root -> right
  • 后序遍历:left -> right -> root

二叉搜索树 BST

  • 左节点(包括其后代) <= 根节点
  • 右节点(包括其后代) >= 根节点

思考:BST 存在的意义是什么?—— 后面解释

分析题目

根据 BST 的特点,中序遍历的结果,正好是按照从小到大排序的结果。
所以,中序遍历,求数组的 arr[k] 即可。

答案

/**
 * @description 二叉搜索树
 */

export interface ITreeNode {
    value: number
    left: ITreeNode | null
    right: ITreeNode | null
}

const arr: number[] = []

/**
 * 二叉树前序遍历
 * @param node tree node
 */
function preOrderTraverse(node: ITreeNode | null) {
    if (node == null) return
    // console.log(node.value)
    arr.push(node.value)
    preOrderTraverse(node.left)
    preOrderTraverse(node.right)
}

/**
 * 二叉树中序遍历
 * @param node tree node
 */
function inOrderTraverse(node: ITreeNode | null) {
    if (node == null) return
    inOrderTraverse(node.left)
    // console.log(node.value)
    arr.push(node.value)
    inOrderTraverse(node.right)
}

/**
 * 二叉树后序遍历
 * @param node tree node
 */
function postOrderTraverse(node: ITreeNode | null) {
    if (node == null) return
    postOrderTraverse(node.left)
    postOrderTraverse(node.right)
    // console.log(node.value)
    arr.push(node.value)
}

/**
 * 寻找 BST 里的第 K 小值
 * @param node tree node
 * @param k 第几个值
 */
export function getKthValue(node: ITreeNode, k: number): number | null {
    inOrderTraverse(node)
    return arr[k - 1] || null
}

const bst: ITreeNode = {
    value: 5,
    left: {
        value: 3,
        left: {
            value: 2,
            left: null,
            right: null
        },
        right: {
            value: 4,
            left: null,
            right: null,
        }
    },
    right: {
        value: 7,
        left: {
            value: 6,
            left: null,
            right: null
        },
        right: {
            value: 8,
            left: null,
            right: null
        }
    }
}

// preOrderTraverse(bst)
// inOrderTraverse(bst)
// postOrderTraverse(bst)

console.log(getKthValue(bst, 3))

划重点

  • 二叉搜索树的特点
  • 前序、中序、后序遍历

八、为何二叉树重要

题目

为何二叉树那么重要,而不是三叉树、四叉树呢?

分析

树是常见的数据结构,如 DOM 树,是一种多叉树。
其中二叉树是一个特别的存在,很重要,很常考。

【注意】本文涉及很多数据结构的知识,所以要“不求甚解” —— 掌握要点和结果,不求细节和过程

如何让性能整体最优?

有序结构

  • 数组:查找易,增删难
  • 链表:增删易,查找难

将两者优点结合起来 —— 二叉搜索树 BST :查找易,增删易 —— 可使用二分算法

二叉搜索树 BST

  • 左节点(包括其后代) <= 根节点
  • 右节点(包括其后代) >= 根节点

image.png

高级二叉树

二叉搜索树 BST ,如果左右不平衡,也无法做到最优。
极端情况下,它就成了链表 —— 这不是我们想要的。

平衡二叉搜索树 BBST :要求树左右尽量平衡

  • 树高度 h 约等于 logn
  • 查找、增删,时间复杂度都等于 O(logn)

红黑树:一种自动平衡的二叉树

  • 节点分 红/黑 两种颜色,通过颜色转换来维持树的平衡
  • 相比于普通平衡二叉树,它维持平衡的效率更高

image.png

B 树:物理上是多叉树,但逻辑上是一个 BST 。用于高效 I/O ,如关系型数据库就用 B 树来组织数据结构。

image.png

JS 执行时代码中的变量

  • 值类型 - 存储到栈
  • 引用类型 - 存储到堆

image.png

堆的特点:

  • 节点的值,总是不大于(或不小于)其父节点的值
  • 完全二叉树

堆,虽然逻辑上是二叉树,但实际上它使用数组来存储的。

// 上图是一个堆(从小到大),可以用数组表示
const heap = [-1, 10, 14, 25, 33, 81, 82, 99] // 忽略 0 节点

// 节点关系
const parentIndex = Math.floor(i / 2)
const leftIndex = 2 * i
const rightIndex = 2 * i + 1

堆的排序规则,没有 BST 那么严格,这就造成了

  • 查询比 BST 慢
  • 增删比 BST 快,维持平衡也更快
  • 但整体复杂度都是 O(logn) 级别,即树的高度

但结合堆的应用场景

  • 一般使用内存地址(栈中保存了)来查询,不会直接从根节点搜索
  • 堆的物理结构是数组,所以查询复杂度就是 O(1)

总结

  • 物理结构是数组(空间更小),逻辑结构是二叉树(操作更快)
  • 适用于“堆栈”结构

答案

  • 二叉树,可以充分利用二分法
  • 二叉树可以同时规避数字和链表的缺点
  • 引申到 BST BBST 等其他扩展结构

划重点

  • 二分法的神奇力量
  • 各个高级数据结构的存在价值、设计初衷
  • 数据结构是基本功能

九、斐波那契数列

题目

用 Javascript 计算第 n 个斐波那契数列的值,注意时间复杂度。

分析

斐波那契数列很好理解

  • f(0) = 0
  • f(1) = 1
  • f(n) = f(n - 1) + f(n - 2) 前两个值的和

递归计算

但这种方式会导致很多重复计算。
时间复杂度是 O(2^n) ,爆炸式增长,不可用。(可以试试 n: 100 ,程序会卡死)

image.png

优化

不用递归,用循环,记录中间结果。时间复杂度降低到 O(n)

动态规划

即,把一个大问题,拆解为不同的小问题,递归向下。

【注意】一般使用动态规划的思路(递归)分析问题,再转换为循环来解决问题。

三大算法思维

  • 贪心(递归)
  • 二分
  • 动态规划

答案

/**
 * @description 斐波那契数列
 */

// /**
//  * 斐波那契数列(递归)
//  * @param n n
//  */
// function fibonacci(n: number): number {
//     if (n <= 0) return 0
//     if (n === 1) return 1

//     return fibonacci(n - 1) + fibonacci(n - 2)
// }

/**
 * 斐波那契数列(循环)
 * @param n n
 */
export function fibonacci(n: number): number {
    if (n <= 0) return 0
    if (n === 1) return 1

    let n1 = 1 // 记录 n-1 的结果
    let n2 = 0 // 记录 n-2 的结果
    let res = 0

    for (let i = 2; i <= n; i++) {
        res = n1 + n2

        // 记录中间结果
        n2 = n1
        n1 = res
    }

    return res
}

// 功能测试
// console.log(fibonacci(10))

划重点

  • 动态规划的思路
  • 识别出时间复杂度

扩展

青蛙跳台阶:一只青蛙,一次可以跳 1 个台阶,也可以跳 2 个台阶,问该青蛙跳上 n 级台阶,总共有多少种方式?

分析

  • f(1) = 1 跳 1 级台阶,只有一种方式
  • f(2) = 2 跳 2 级台阶,有两种方式
  • f(n) = f(n - 1) + fn(n - 2) 跳 n 级,可拆分为两个问题
    • 第一次跳,要么 1 级,要么 2 级,只有这两种
    • 第一次跳 1 级,剩下有 f(n - 1) 种方式
    • 第一次跳 2 级,剩下有 f(n - 2) 种方式

看公式,和斐波那契数列一样。

十、移动 0

题目

定义一个函数,将数组种所有的 0 都移动到末尾,例如输入 [1, 0, 3, 0, 11, 0] 输出 [1, 3, 11, 0, 0, 0]。要求:

  • 只移动 0 ,其他数字顺序不变
  • 考虑时间复杂度
  • 必须在原数组就行操作

如果不限制“必须在原数组修改”

  • 定义 part1 part2 两个空数组
  • 遍历数组,非 0 push 到 part10 push 到 part2
  • 返回 part1.concat(part2)

时间复杂度 O(n) 空间复杂度 O(n)

所以,遇到类似问题,要提前问面试官:是否能在原数组基础上修改?

传统方式

思路

  • 遍历数组
  • 遇到 0 则 push 到数组末尾
  • 然后用 splice 截取掉当前元素

分析性能

  • 空间复杂度没有问题 O(1)
  • 时间复杂度
    • 看似是 O(n)
    • 但实际上 spliceunshift 一样,修改数组结构,时间复杂度是 O(n)
    • 总体看来时间复杂度是 O(n^2),不可用

【注意】网上有很多人对这种方式点赞,切不可随意从众,要对思考!

双指针

思路

  • 指针1 指向第一个 0 ,指针2 指向第一个非 0
  • 把指针1 和 指针2 进行交换
  • 指针向后移

性能分析

  • 时间复杂度 O(n)
  • 空间复杂度 O(1)

性能测试,实际对比差距非常大。

答案

使用双指针,保证时间复杂度。

/**
 * @description 移动 0 到数组末尾
 */

/**
 * 移动 0 到数组的末尾(嵌套循环)
 * @param arr number arr
 */
export function moveZero1(arr: number[]): void {
    const length = arr.length
    if (length === 0) return

    let zeroLength = 0

    // O(n^2)
    for (let i = 0; i < length - zeroLength; i++) {
        if (arr[i] === 0) {
            arr.push(0)
            arr.splice(i, 1) // 本身就有 O(n)
            i-- // 数组截取了一个元素,i 要递减,否则连续 0 就会有错误
            zeroLength++ // 累加 0 的长度
        }
    }
}

/**
 * 移动 0 到数组末尾(双指针)
 * @param arr number arr
 */
export function moveZero2(arr: number[]): void {
    const length = arr.length
    if (length === 0) return

    let i
    let j = -1 // 指向第一个 0

    for (i = 0; i < length; i++) {
        if (arr[i] === 0) {
            // 第一个 0
            if (j < 0) {
                j = i
            }
        }

        if (arr[i] !== 0 && j >= 0) {
            // 交换
            const n = arr[i]
            arr[i] = arr[j]
            arr[j] = n

            j++
        }
    }
}

// // 功能测试
// const arr = [1, 0, 3, 4, 0, 0, 11, 0]
// moveZero2(arr)
// console.log(arr)

// const arr1 = []
// for (let i = 0; i < 20 * 10000; i++) {
//     if (i % 10 === 0) {
//         arr1.push(0)
//     } else {
//         arr1.push(i)
//     }
// }
// console.time('moveZero1')
// moveZero1(arr1)
// console.timeEnd('moveZero1') // 262ms

// const arr2 = []
// for (let i = 0; i < 20 * 10000; i++) {
//     if (i % 10 === 0) {
//         arr2.push(0)
//     } else {
//         arr2.push(i)
//     }
// }
// console.time('moveZero2')
// moveZero2(arr2)
// console.timeEnd('moveZero2') // 3ms

划重点

  • 咨询面试官,确认是否必须要修改原数据?
  • 数组是有序结构,不能随意 splice unshift
  • 双指针的思路

十一、连续最多的字符

题目

给一个字符串,找出连续最多的字符,以及次数。
例如字符串 'aabbcccddeeee11223' 连续最多的是 e ,4 次。

传统方式

嵌套循环,找出每个字符的连续次数,并记录比较。

时间复杂度看似是 O(n^2),因为是嵌套循环。 但实际上它的时间复杂度是 O(n),因为循环中有跳转

双指针

只有一次循环,时间复杂度是 O(n)

性能测试,发现两者时间消耗一样,循环次数也一样

其他方式

这个题目网上还有其他的答案

  • 正则表达式 —— 正则表达式的效率非常低,可进行性能测试(代码 x-reg.ts
  • 使用数组累计各个字符串的长度,然后求出最大值 —— 增加空间复杂度,面试官不会喜欢

【注意】算法尽量用基础代码实现,尽量不要用现成的 API 或语法糖 —— 方便,但你不好直观判断时间复杂度

答案

上述两种方式(嵌套循环和双指针)都可以

/**
 * @description 连续字符
 */

interface IRes {
    char: string
    length: number
}

/**
 * 求连续最多的字符和次数(嵌套循环)
 * @param str str
 */
export function findContinuousChar1(str: string): IRes {
    const res: IRes = {
        char: '',
        length: 0
    }

    const length = str.length
    if (length === 0) return res

    let tempLength = 0 // 临时记录当前连续字符的长度

    // O(n)
    for (let i = 0; i < length; i++) {
        tempLength = 0 // 重置

        for (let j = i; j < length; j++) {
            if (str[i] === str[j]) {
                tempLength++
            }

            if (str[i] !== str[j] || j === length - 1) {
                // 不相等,或者已经到了最后一个元素。要去判断最大值
                if (tempLength > res.length) {
                    res.char = str[i]
                    res.length = tempLength
                }

                if (i < length - 1) {
                    i = j - 1 // 跳步
                }

                break
            }
        }
    }

    return res
}

/**
 * 求连续最多的字符和次数(双指针)
 * @param str str
 */
export function findContinuousChar2(str: string): IRes {
    const res: IRes = {
        char: '',
        length: 0
    }

    const length = str.length
    if (length === 0) return res

    let tempLength = 0 // 临时记录当前连续字符的长度
    let i = 0
    let j = 0

    // O(n)
    for (; i < length; i++) {
        if (str[i] === str[j]) {
            tempLength++
        }

        if (str[i] !== str[j] || i === length - 1) {
            // 不相等,或者 i 到了字符串的末尾
            if (tempLength > res.length) {
                res.char = str[j]
                res.length = tempLength
            }
            tempLength = 0 // reset

            if (i < length - 1) {
                j = i // 让 j “追上” i
                i-- // 细节
            }
        }
    }

    return res
 }

// // 功能测试
// const str = 'aabbcccddeeee11223'
// console.info(findContinuousChar2(str))

// let str = ''
// for (let i = 0; i < 100 * 10000; i++) {
//     str += i.toString()
// }

// console.time('findContinuousChar1')
// findContinuousChar1(str)
// console.timeEnd('findContinuousChar1') // 219ms

// console.time('findContinuousChar2')
// findContinuousChar2(str)
// console.timeEnd('findContinuousChar2') // 228ms

划重点

  • 注意实际的时间复杂度,不要被代码所迷惑
  • 双指针的思路(常用于解决嵌套循环)

十二、快速排序

题目

用 Javascript 实现快速排序,并说明时间复杂度。

思路

快速排序是基础算法之一,算法思路是固定的

  • 找到中间位置 midValue
  • 遍历数组,小于 midValue 放在 left ,大于 midValue 放在 right
  • 继续递归,concat 拼接

splice 和 slice

代码实现时,获取 midValue 可以通过 spliceslice 两种方式

理论分析,slice 要优于 splice ,因为 splice 会修改原数组。
但实际性能测试发现两者接近。

但是,即便如此还是倾向于选择 slice —— 因为它不会改动原数组,类似于函数式编程

性能分析

快速排序 时间复杂度 O(n*logn) —— 有遍历,有二分

普通的排序算法(如冒泡排序)时间复杂度时 O(n^2)

答案

使用 slice 方案

/**
* @description 快速排序
*/

/**
* 快速排序(使用 splice)
* @param arr number arr
*/
export function quickSort1(arr: number[]): number[] {
   const length = arr.length
   if (length === 0) return arr

   const midIndex = Math.floor(length / 2)
   const midValue = arr.splice(midIndex, 1)[0]

   const left: number[] = []
   const right: number[] = []

   // 注意:这里不用直接用 length ,而是用 arr.length 。因为 arr 已经被 splice 给修改了
   for (let i = 0; i < arr.length; i++) {
       const n = arr[i]
       if (n < midValue) {
           // 小于 midValue ,则放在 left
           left.push(n)
       } else {
           // 大于 midValue ,则放在 right
           right.push(n)
       }
   }

   return quickSort1(left).concat(
       [midValue],
       quickSort1(right)
   )
}

/**
* 快速排序(使用 slice)
* @param arr number arr
*/
export function quickSort2(arr: number[]): number[] {
   const length = arr.length
   if (length === 0) return arr

   const midIndex = Math.floor(length / 2)
   const midValue = arr.slice(midIndex, midIndex + 1)[0]

   const left: number[] = []
   const right: number[] = []

   for (let i = 0; i < length; i++) {
       if (i !== midIndex) {
           const n = arr[i]
           if (n < midValue) {
               // 小于 midValue ,则放在 left
               left.push(n)
           } else {
               // 大于 midValue ,则放在 right
               right.push(n)
           }
       }
   }

   return quickSort2(left).concat(
       [midValue],
       quickSort2(right)
   )
}

// // 功能测试
// const arr1 = [1, 6, 2, 7, 3, 8, 4, 9, 5]
// console.info(quickSort2(arr1))

// // 性能测试
// const arr1 = []
// for (let i = 0; i < 10 * 10000; i++) {
//     arr1.push(Math.floor(Math.random() * 1000))
// }
// console.time('quickSort1')
// quickSort1(arr1)
// console.timeEnd('quickSort1') // 74ms

// const arr2 = []
// for (let i = 0; i < 10 * 10000; i++) {
//     arr2.push(Math.floor(Math.random() * 1000))
// }
// console.time('quickSort2')
// quickSort2(arr2)
// console.timeEnd('quickSort2') // 82ms

// // 单独比较 splice 和 slice
// const arr1 = []
// for (let i = 0; i < 10 * 10000; i++) {
//     arr1.push(Math.floor(Math.random() * 1000))
// }
// console.time('splice')
// arr1.splice(5 * 10000, 1)
// console.timeEnd('splice')
// const arr2 = []
// for (let i = 0; i < 10 * 10000; i++) {
//     arr2.push(Math.floor(Math.random() * 1000))
// }
// console.time('slice')
// arr2.slice(5 * 10000, 5 * 10000 + 1)
// console.timeEnd('slice')

划重点

  • 排序算法(基本功)
  • 二分法的时间复杂度
  • 注意数组的操作( splice vs slice

十三、1-10000 之间的对称数(回文)

题目

打印 1-10000 之间的对称数

使用数组反转

  • 数字转换为字符串
  • 字符串转换为数组 reverse ,再 join 生成字符串
  • 比较前后的字符串

使用字符串头尾比较

  • 数字转换为字符串
  • 字符串头尾比较

还可以使用(但栈会用到数组,性能不如直接操作字符串)

  • 数字转换为字符串,求长度
  • 如果长度是偶数,则用栈比较
  • 如果长度是奇数,则忽略中间的数字,其他的用栈比较

生成反转数

  • 通过 %Math.floor 将数字生成一个反转数
  • 比较前后的数字

性能分析

时间复杂度看似相当,都是 O(n)

但 方案1 涉及到了数组的转换和操作,就需要耗费大量的时间

  • 数组 reverse 需要时间
  • 数组和字符串的转换需要时间

方案 2 3 比较,数字操作最快。电脑的原型就是计算器,所以处理数字是最快的。

答案

第三种方案,参考 palindrome-number.ts

/**
* @description 对称数
*/

/**
* 查询 1-max 的所有对称数(数组反转)
* @param max 最大值
*/
export function findPalindromeNumbers1(max: number): number[] {
   const res: number[] = []
   if (max <= 0) return res

   for (let i = 1; i <= max; i++) {
       // 转换为字符串,转换为数组,再反转,比较
       const s = i.toString()
       if (s === s.split('').reverse().join('')) {
           res.push(i)
       }
   }

   return res
}

/**
* 查询 1-max 的所有对称数(字符串前后比较)
* @param max 最大值
*/
export function findPalindromeNumbers2(max: number): number[] {
   const res: number[] = []
   if (max <= 0) return res

   for (let i = 1; i <= max; i++) {
       const s = i.toString()
       const length = s.length

       // 字符串头尾比较
       let flag = true
       let startIndex = 0 // 字符串开始
       let endIndex = length - 1 // 字符串结束
       while (startIndex < endIndex) {
           if (s[startIndex] !== s[endIndex]) {
               flag = false
               break
           } else {
               // 继续比较
               startIndex++
               endIndex--
           }
       }

       if (flag) res.push(i)
   }

   return res
}

/**
* 查询 1-max 的所有对称数(翻转数字)
* @param max 最大值
*/
export function findPalindromeNumbers3(max: number): number[] {
   const res: number[] = []
   if (max <= 0) return res

   for (let i = 1; i <= max; i++) {
       let n = i
       let rev = 0 // 存储翻转数

       // 生成翻转数
       while (n > 0) {
           rev = rev * 10 + n % 10
           n = Math.floor(n / 10)
       }

       if (i === rev) res.push(i)
   }

   return res
}


// 功能测试
// console.info(findPalindromeNumbers3(200))

// 性能测试
console.time('findPalindromeNumbers1')
findPalindromeNumbers1(100 * 10000)
console.timeEnd('findPalindromeNumbers1') // 408ms

console.time('findPalindromeNumbers2')
findPalindromeNumbers2(100 * 10000)
console.timeEnd('findPalindromeNumbers2') // 53ms

console.time('findPalindromeNumbers3')
findPalindromeNumbers3(100 * 10000)
console.timeEnd('findPalindromeNumbers3') // 42ms

划重点

  • 尽量不要使用内置 API ,不好判断时间复杂度
  • 尽量不要转换数据格式,尤其注意数组(有序结构,不能乱来~)
  • 数字操作最快

十四、搜索单词

字符串前缀匹配

题目

请描述算法思路,不要求写出代码。

  • 先给一个英文单词库(数组),里面有几十万个英文单词
  • 再给一个输入框,输入字母,搜索单词
  • 输入英文字母,要实时给出搜索结果,按前缀匹配

要求

  • 尽量快
  • 不要使用防抖(输入过程中就及时识别)

常规思路

keyup 之后,拿当前的单词,遍历词库数组,通过 indexOf 来前缀匹配。

性能分析

  • 算法思路的时间复杂度是 O(n)
  • 外加 indexOf 也需要时间复杂度。实际的复杂度要超过 O(n)

优化数据结构

英文字母一共 26 个,可按照第一个字母分组,分为 26 组。这样搜索次数就减少很多。

可按照第一个字母分组,那也可以按照第二个、第三个字母分组。
即,在程序初始化时,把数组变成一个树,然后按照字母顺序在树中查找。

const arr = [
    'abs',
    'arab',
    'array',
    'arrow',
    'boot',
    'boss',
    // 更多...
]

const obj = {
    a: {
        b: {
            s: {}
        },
        r: {
            a: {
                b: {}
            },
            r: {
                a: {
                    y: {}
                },
                o: {
                    w: {}
                }
            }
        }
    },
    b: {
        o: {
            o: {
                t: {}
            },
            s: {
                s: {}
            }
        }
    },
    // 更多...
}

这样时间复杂度就大幅度减少,从 O(n) 降低到 O(m)m 是单词的最大长度)

划重点

  • 对于已经明确的范围的数据,可以考虑使用哈希表
  • 以空间换时间

十五、数字千分位

题目

将数字按照千分位生成字符串,即每三位加一个逗号。不考虑小数。
如输入数字 78100200300 返回字符串 '78,100,200,300'

分析

  • 使用数组
  • 使用正则表达式
  • 使用字符串拆分

性能分析

  • 数组转换,影响性能
  • 正则表达式,性能较差
  • 操作字符串,性能较好

答案

方案二,参考 thousands-format.ts

/**
 * @description 千分位格式化
 */

/**
 * 千分位格式化(使用数组)
 * @param n number
 */
export function format1(n: number): string {
    n = Math.floor(n) // 只考虑整数

    const s = n.toString()
    const arr = s.split('').reverse()
    return arr.reduce((prev, val, index) => {
        if (index % 3 === 0) {
            if (prev) {
                return val + ',' + prev
            } else {
                return val
            }
        } else {
            return val + prev
        }
    }, '')
}

/**
 * 数字千分位格式化(字符串分析)
 * @param n number
 */
export function format2(n: number): string {
    n = Math.floor(n) // 只考虑整数

    let res = ''
    const s = n.toString()
    const length = s.length

    for (let i = length - 1; i >= 0; i--) {
        const j = length - i
        if (j % 3 === 0) {
            if (i === 0) {
                res = s[i] + res
            } else {
                res = ',' + s[i] + res
            }
        } else {
            res = s[i] + res
        }
    }

    return res
}

// // 功能测试
// const n = 10201004050
// console.info('format1', format1(n))
// console.info('format2', format2(n))

划重点

  • 从尾向头计算,和日常遍历的顺序相反
  • 慎用数组操作
  • 慎用正则表达式

十六、切换字母大小写

题目

切换字母大小写,输入 'aBc' 输出 'AbC'

分析

需要判断字母是大写还是小写

  • 正则表达式
  • charCodeAt 获取 ASCII 码(ASCII 码表,可以网上搜索)

性能分析

  • 正则表达式性能较差
  • ASCII 码性能较好

答案

使用 charCodeAt ,参考代码 switch-case.ts

/**
 * @description 切换字母大小写
 * @author 双越老师
 */

/**
 * 切换字母大小写(正则表达式)
 * @param s str
 */
export function switchLetterCase1(s: string): string {
    let res = ''

    const length = s.length
    if (length === 0) return res

    const reg1 = /[a-z]/
    const reg2 = /[A-Z]/

    for (let i = 0; i < length; i++) {
        const c = s[i]
        if (reg1.test(c)) {
            res += c.toUpperCase()
        } else if (reg2.test(c)) {
            res += c.toLowerCase()
        } else {
            res += c
        }
    }

    return res
}

/**
 * 切换字母大小写(ASCII 编码)
 * @param s str
 */
export function switchLetterCase2(s: string): string {
    let res = ''

    const length = s.length
    if (length === 0) return res

    for (let i = 0; i < length; i++) {
        const c = s[i]
        const code = c.charCodeAt(0)

        if (code >= 65 && code <= 90) {
            res += c.toLowerCase()
        } else if (code >= 97 && code <= 122) {
            res += c.toUpperCase()
        } else {
            res += c
        }
    }

    return res
}

// // 功能测试
// const str = '100aBcD$#xYz'
// console.info(switchLetterCase2(str))

// // 性能测试
// const str = '100aBcD$#xYz100aBcD$#xYz100aBcD$#xYz100aBcD$#xYz100aBcD$#xYz100aBcD$#xYz'
// console.time('switchLetterCase1')
// for (let i = 0; i < 10 * 10000; i++) {
//     switchLetterCase1(str)
// }
// console.timeEnd('switchLetterCase1') // 436ms

// console.time('switchLetterCase2')
// for (let i = 0; i < 10 * 10000; i++) {
//     switchLetterCase2(str)
// }
// console.timeEnd('switchLetterCase2') // 210ms

划重点

  • 慎用正则表达式
  • ASCII 码

十七、小数相加

题目

为何 0.1 + 0.2 !== 0.3

答案

计算机用二进制存储数据。

整数用二进制没有误差,如 9 表示为 1001
而有的小数无法用二进制表示,如 0.2 用二进制表示就是 1.10011001100...

所以,累加小数时会出现误差。
这不仅仅是 JS ,所有的计算机语言都这样。

扩展

可以使用第三方库 www.npmjs.com/package/mat…

总结

内容总结

包含了数组、栈、队列、链表、二叉树这些常见的数据结构。 常用的算法思维如贪婪、二分、动态规划,以及如何计算时间复杂度。

划重点

  • 有序数据考虑用二分
  • 双指针可以解决嵌套循环

注意事项

  • 注意区分逻辑结构和物理结构,否则思维会很混乱
  • 要有“算法敏感度”,条件反射般的根据数据结构分析时间复杂度