笔记系列之 常见的数据结构和算法

112 阅读11分钟

本文为原创文章,未获授权禁止转载,侵权必究!

本篇是 笔记 系列第 1 篇,关注专栏

VScode 调试

  1. js文件代码调试,设置断点,按 F5 进入 调试模式

image.png

  • 是一个 后进先出 的数据结构。
  • js中没有栈,可以通过 数组 来实现。
  • 栈常用操作:pushpopstack[stack.length - 1]
const stack = []
stack.push(1)
stack.push(1)
const item1 = stack.pop()
const item2 = stack.pop()

队列

  • 是一个 先进先出 的数据结构。
  • js中没有队列,可以通过 数组 来实现。
  • 队列常用操作:push、shift。
const queue = []
queue.push(1)
queue.push(1)
const item1 = queue.shift()
const item2 = queue.shift()

链表

  • 链表里的元素存储 不是连续的,之间通过 next 连接
  • avaScript 中没有链表,但可以用 Object 模拟链表。
  • 链表常用操作:修改 next、遍历链表。
  • 使用场景:
    • JS中的原型链也是一个链表。
    • 使用链表指针可以获取JSON的节点值。
  • 反转链表
    • 反转两个节点:将 n+1 的 next 指向 n
    • 反转多个节点:双指针遍历链表,重复上述操作
const a = { val: 1 }
const b = { val: 2 }
const c = { val: 3 }
a.next = b
b.next = c
c.next = null

// 遍历链表
let p = a
while (p) {
    console.log(p.val)
    p = p.next
}

// 插入
const d = { val: 4 }
b.next = d
d.next = c

// 删除 (修改 next)
b.next = c

// 手写 instanceOf
const instanceOf = (A, B) => {
    let p = A
    while (p) {
        if (p === B.prototype) {
            return true
        }
        p = p.__proto__
    }
    return false
}
instanceOf([], Array)
instanceOf({}, Object)

// 根据路径获取 值
const json = {
    a: { b: { c: 1 } },
    d: { e: 1 },
}
const path = ['a', 'b', 'c']
let p = json
path.forEach((key) => {
    p = p[key]
})

集合

  • 集合是一种 无需且唯一 的数据结构。
  • ES6中有集合,名为 Set
  • 集合常用操作:去重、判断某元素是否在集合中、求交集
// 去重
const arr = [1, 1, 2, 2]
const newArr = [...new Set(arr)]

// 判断元素是否在集合中
const set = new Set(arr)
const has = set.has(4)

// 求交集
const set2 = new Set([2,3])
const set3 = new Set([...set].filter(item => set2.has(item)))

// set 操作
let mySet = new Set()
mySet.add(1)
mySet.add(5)
mySet.add(5)
mySet.add('some text')
let o = { a: 1, b: 2 }
mySet.add(o)
mySet.add({ a: 1, b: 2 }) // 这里对象依旧可以添加,因为在内存中存放位置不同

const has1 = mySet.has(o)
mySet.delete(5)

// 数组转 set,set 转 数组
const arr = Array.from(mySet)
const mySet2 = new Set([1,2,3])
// 交集
const intersection = new Set([...mySet].filtet(item => mySet2.has(item)))
// 不同集
const difference = new Set([...mySet].filtet(item => !mySet2.has(item)))

字典

  • 与集合类似,字典也是一种 存储唯一值 的数据结构,但它是以键值对的形式来存储。
  • ES6中有字典,名为 Map
  • 字典的常用操作:字典的增删改查
const m = new Map();

//增
m.set('a', 'aa');
m.set('b', 'bb');

//删
m.delete('b');
m.clear() // 全部删除

// 改
m.set('a', 'aaa')

// 查
m.get('a')

  • 树是一种 分层数据 的抽象模型。
  • 树的常用操作,深度/广度优先遍历、先中后序遍历
  • 深度优先遍历
    • 尽可能深的搜索树的分支。
    • 类似看书,一页页看过去就是深度优先。
    • 算法步骤 dfs(深度缩写)
      • 访问根节点。
      • 对根节点的 children 挨个进行深度优先遍历。
  • 广度优先遍历
    • 先访问离根节点最近的节点。
    • 类似看书,先看目录,再深入了解每个小节。
    • 算法步骤 bfs(广度缩写)
      • 新建一个队列,把根节点入队。
      • 把对头出队并访问。
      • 把对头的 children 挨个入队。
      • 重复第二、三步,直到队列为空。
// 深度优先遍历
const json = {
    a: { b: { c: 1 } },
    d: [1, 2],
}

const dfs = (n, path) => {
    console.log(n, path)
    Object.keys(n).forEach((k) => {
        dfs(n[k], path.concat(k))
    })
}

dfs(json, [])

// 深度优先
const tree = {
    val: 'a',
    children: [
        {
            val: 'b',
            children: [
                {
                    val: 'd',
                    children: [],
                },
                {
                    val: 'e',
                    children: [],
                },
            ],
        },
        {
            val: 'c',
            children: [
                {
                    val: 'f',
                    children: [],
                },
                {
                    val: 'g',
                    children: [],
                },
            ],
        },
    ],
}

const dfs = (root) => {
    console.log(root.val)
    root.children.forEach(dfs)
}

dfs(tree)

/* 
* 广度优先
* 1. 新建一个队列,把根节点入队
* 2. 把对头出队并访问
* 3. 把对头的children挨个入队
* 4. 重复第二、三步,直到队列为空
*/
const bfs = (root) => {
    const q = [root] // 1 新队列
    while (q.length) {
        const n = q.shift() // 2 出队
        console.log(n.val) // 2 访问
        n.children.forEach((child) => {
            q.push(child) // 3 入队
        })
    }
}

bfs(tree)

二叉树

  • 树中每个节点 最多只能有两个子节点
  • 先序遍历
    • 访问根节点
    • 对根节点的左子树进行 先序遍历
    • 对根节点的右子树进行 先序遍历
  • 中序遍历
    • 对根节点的左子树进行 中序遍历
    • 访问根节点
    • 对根节点的右子树进行 中序遍历
  • 右序遍历
    • 对根节点的左子树进行 后序遍历
    • 对根节点的右子树进行 后序遍历
    • 访问根节点
const bt = {
    val: 1,
    left: {
        val: 2,
        left: { val: 4, left: null, right: null },
        right: {
            val: 5,
            left: null,
            right: null,
        },
    },
    right: {
        val: 3,
        left: {
            val: 6,
            left: null,
            right: null,
        },
        right: {
            val: 7,
            left: null,
            right: null,
        },
    },
}

// 前序
const preorder = (root) => {
    if (!root) return
    console.log(root.val)
    preorder(root.left)
    preorder(root.right)
}
//preorder(bt)

// 先序遍历 非递归版
const preorder1 = (root) => {
    if (!root) return
    const stack = [root]
    while (stack.length) {
        const n = stack.pop()
        console.log(n.val)
        if (n.right) stack.push(n.right)
        if (n.left) stack.push(n.left)
    }
}
//preorder1(bt)

// 中序
const inorder = (root) => {
    if (!root) return
    inorder(root.left)
    console.log(root.val)
    inorder(root.right)
}
//inorder(bt)

// 中序遍历 非递归版
const inorder1 = (root) => {
    if (!root) return
    const stack = []
    let p = root
    while (stack.length || p) {
        while (p) {
            stack.push(p)
            p = p.left
        }
        const n = stack.pop()
        console.log(n.val)
        p = n.right
    }
}
//inorder1(bt)

// 后序
const postorder = (root) => {
    if (!root) return
    postorder(root.left)
    postorder(root.right)
    console.log(root.val)
}
//postorder(bt)

// 后续遍历 非递归版
const postorder1 = (root) => {
    if (!root) return
    const stack = [root]
    const outputStack = []
    while (stack.length) {
        const n = stack.pop()
        outputStack.push(n)
        if (n.left) stack.push(n.left)
        if (n.right) stack.push(n.right)
    }
    while (outputStack.length) {
        const n = outputStack.pop()
        console.log(n.val)
    }
}
postorder1(bt)

  • 图是 网络结构 的抽象模型,是一组由边连接的节点。
  • 图可以表示任何二元关系,比如道路、航班。
  • js中没有图,可以使用 ObjectArray 来实现。
  • 图的表示法:邻接矩阵、邻接表、关联矩阵。
  • 图的常用操作:深度优先遍历、广度优先遍历。
  • 图的深度优先遍历
    • 访问根节点。
    • 对根节点的 没访问过的相邻节点 挨个进行深度优先遍历(防止死循环)。
  • 图的广度优先遍历
    • 新建一个队列,把根节点入队。
    • 把队头出队并访问。
    • 把队头的 没访问过的相邻节点 入队。
    • 重复第二、三步,直到队列为空。
const graph = {
    0: [1, 2],
    1: [2],
    2: [0, 3],
    3: [3],
}

// 深度优先
const visited = new Set()
const dfs = (n) => {
    console.log(n)
    visited.add(n)
    graph[n].forEach((c) => {
        if (!visited.has(c)) {
            dfs(c)
        }
    })
}

dfs(2)

// 广度优先
const visited = new Set()
const q = [2]
visited.add(2)
while (q.length) {
    const n = q.shift()
    console.log(n)
    graph[n].forEach((c) => {
        if (!visited.has(c)) {
            q.push(c)
            visited.add(c)
        }
    })
}

  • 堆是一种特殊的 完全二叉树
  • 所有的节点都 大于等于(最大堆)或小于等于(最小堆) 它的子节点。
  • js中通常用数组表示堆
    • 左侧子节点的位置是 2 * index + 1
    • 左侧子节点的位置是 2 * index + 2
    • 父节点位置是 (index - 1) / 2
  • 堆的应用
    • 高效、快速地找出最大值和最小值,时间复杂度: O(1)。
    • 找出第 K 个最大(小)元素
      • 构建一个最小堆,并将元素一次插入堆中。
      • 当堆的容量超过 K,就删除堆顶。
      • 插入结束后,堆顶就是第 K 个最大元素。
  • 实现
    • 在类里,声明一个数组,用来装元素。
    • 主要方法:插入、删除堆顶、获取堆顶、获取堆大小
    • 插入
      • 将值插入堆的底部,即数组的尾部。
      • 然后上移:将这个值和它的父节点进行交换,直到父节点小于等于这个插入的值。
      • 大小为 k 的堆中插入元素的时间复杂度为 O(logk)。
    • 删除堆顶
      • 用数组尾部元素替换堆顶(直接删除堆顶会破坏堆结构)。
      • 然后下移:将新堆顶和它的子节点进行交换,直接子节点大于等于这个新堆顶。
      • 大小为 k 的堆中删除堆顶的时间复杂度为 O(logk)。
    • 获取堆顶和堆的大小
      • 获取堆顶:返回数组的头部。
      • 获取堆的大小:返回数组的长度。
class MinHeap {
    constructor() {
        this.heap = []
    }
    // 替换元素
    swap(i1, i2) {
        const temp = this.heap[i1]
        this.heap[i1] = this.heap[i2]
        this.heap[i2] = temp
    }
    // 获取父索引
    getParentIndex(i) {
        // >> 二进制 取商
        return (i - 1) >> 1 //Math.floor((i - 1) / 2)
    }
    // 获取左侧索引
    getLeftIndex(i) {
        return i * 2 + 1
    }
    // 获取右侧索引
    getRightIndex(i) {
        return i * 2 + 2
    }
    // 上移
    shiftUp(index) {
        if (index === 0) {
            return
        }
        const parentIndex = this.getParentIndex(index)
        if (this.heap[parentIndex] > this.heap[index]) {
            this.swap(parentIndex, index)
            this.shiftUp(parentIndex)
        }
    }
    // 下移
    shiftDown(index) {
        const leftIndex = this.getLeftIndex(index)
        const rightIndex = this.getRightIndex(index)
        if (this.heap[leftIndex] < this.heap[index]) {
            this.swap(leftIndex, index)
            this.shiftDown(leftIndex)
        }
        if (this.heap[rightIndex] < this.heap[index]) {
            this.swap(rightIndex, index)
            this.shiftDown(rightIndex)
        }
    }
    // 插入
    insert(value) {
        this.heap.push(value)
        this.shiftUp(this.heap.length - 1) // 上移算法
    }
    // 删除堆顶
    pop() {
        // 用数组尾部元素替换堆顶(直接删除堆顶会破坏堆结构)
        this.heap[0] = this.heap.pop()
        // 下移算法
        this.shiftDown(0) 
    }
    // 获取堆顶
    peek() {
        return this.heap[0]
    }
    // 获取堆的大小
    size() {
        return this.heap.length
    }
}

const h = new MinHeap()
h.insert(3)
h.insert(2)
h.insert(1)
h.pop()

排序和搜索

  • 排序:把某个乱序的数组变成升序或者降序的数组
  • 搜索:找出数组中某个元素的下标
  • 冒泡排序 O(n ^ 2)
    • 比较所有相邻元素,如果第一个比第二个大,则交换它们。
    • 一轮下来,可以保证最后一个数是最大的。
    • 执行 n - 1 轮,就可以完成排序。
// 冒泡排序
Array.prototype.bubbleSort = function () {
    for (let i = 0; i < this.length - 1; i++) {
        // this.length - 1 - i 逐步减小区间
        for (let j = 0; j < this.length - 1 - i; j++) {
            if (this[j] > this[j + 1]) {
                const temp = this[j]
                this[j] = this[j + 1]
                this[j + 1] = temp
            }
        }
    }
}

const arr = [5, 4, 3, 2, 1]
arr.bubbleSort()
  • 选择排序 O(n ^ 2)
    • 找到数组中的最小值,选中它并将其放置在第一位。
    • 接着找到第二小的值,选中它并将其放置在第二位。
    • 以此类推,执行 n - 1 轮。
// 选择排序
Array.prototype.selectionSort = function () {
    for (let i = 0; i < this.length - 1; i++) {
        let indexMin = i
        for (let j = i; j < this.length; j++) {
            if (this[j] < this[indexMin]) {
                indexMin = j
            }
        }
        if (indexMin !== i) {
            const temp = this[i]
            this[i] = this[indexMin]
            this[indexMin] = temp
        }
    }
}

const arr = [5, 4, 3, 2, 1]
arr.selectionSort()
  • 插入排序 O(n ^ 2)
    • 从第二个数开始 往前比
    • 比它大就 往后排
    • 以此类推进行到最后一个数。
// 插入排序
Array.prototype.insertionSort = function () {
    for (let i = 1; i < this.length; i++) {
        const temp = this[i]
        let j = i
        while (j > 0) {
            if (this[j - 1] > temp) {
                this[j] = this[j - 1]
            } else {
                break
            }
            j--
        }
        this[j] = temp
    }
}

const arr = [5, 4, 3, 2, 1]
arr.insertionSort()
  • 归并排序 O(n * logN)
    • 分O(logN):把数组劈成两半,再递归地对子数组进行“分”操作,直到分成一个个单独的数
    • 合O(n):把两个数合并为有序数组,再对有序数组进行合并,直到全部子数组合并为一个完整数组
    • 合并两个有序数组
      • 新建一个空数组 res,用于存放最终排序后的数组。
      • 比较两个有序数组的头部,较小者出队并推入 res 中。
      • 如果两个数组还有只,就重复第二步。
// 归并排序
Array.prototype.mergeSort = function () {
    const rec = (arr) => {
        if (arr.length === 1) {
            return arr
        }
        const mid = Math.floor(arr.length / 2)
        const left = arr.slice(0, mid)
        const right = arr.slice(mid, arr.length)
        const orderLeft = rec(left)
        const orferRight = rec(right)
        const res = []
        while (orderLeft.length || orferRight.length) {
            if (orderLeft.length && orferRight.length) {
                res.push(
                    orderLeft[0] < orferRight[0] ? orderLeft.shift() : orferRight.shift()
                )
            } else if (orderLeft.length) {
                res.push(orderLeft.shift())
            } else if (orferRight.length) {
                res.push(orferRight.shift())
            }
        }
        return res
    }
    const res = rec(this)
    res.forEach((n, i) => {
        this[i] = n
    })
}

const arr = [5, 4, 3, 2, 1]
arr.mergeSort()
  • 快速排序 O(n * logN)(性能比前几个都好)
    • 分区O(n):从数组中任意选择一个“基准”,所有 比基准小 的元素放在基准 前面比基准大 的元素放在基准 后面
    • 递归O(logN):递归地对基准前后的子数组进行分区。
// 快速排序
Array.prototype.quickSort = function () {
    const rec = (arr) => {
        if (arr.length <= 1) {
            return arr
        }
        const left = []
        const right = []
        const mid = arr[0]
        for (let i = 1; i < arr.length; i++) {
            if (arr[i] < mid) {
                left.push(arr[i])
            } else {
                right.push(arr[i])
            }
        }
        return [...rec(left), mid, ...rec(right)]
    }
    const res = rec(this)
    res.forEach((n, i) => {
        this[i] = n
    })
}

const arr = [5, 4, 3, 2, 1]
arr.quickSort()
  • 顺序搜索 O(n) 效率低
    • 遍历数组
    • 找到跟目标相等的元素,就 返回它的下标
    • 遍历结束后,如果没有搜索到目标值,就 返回 -1
// 顺序搜索
Array.prototype.sequentialSearch = function (item) {
    for (let i = 0; i < this.length; i++) {
        if (this[i] === item) {
            return i
        }
    }
    return -1
}

const res = [1, 2, 3, 4, 5].sequentialSearch(3)
  • 二分搜索 O(logN)
    • 从数组的中间元素开始,如果中间元素正好是目标值,则搜索结束
    • 如果目标值大于或小于中间元素,则在大于或小于中间元素的那一半数组中搜索
// 二分搜索
Array.prototype.binarySearch = function (item) {
    let low = 0
    let high = this.length - 1
    while (low <= high) {
        const mid = Math.floor((low + high) / 2)
        const element = this[mid]
        if (element < item) {
            low = mid + 1
        } else if (element > item) {
            high = mid - 1
        } else {
            return mid
        }
    }
    return -1
}

const res = [1, 2, 3, 4, 5].binarySearch(3)

分而治之

  • 算法设计 中的一种方法,或者说是一种思想。
  • 它将一个问题 成多个和原问题相似的小问题,递归解决 小问题,再将结果 并以解决原来的问题。
  • 分、递归、合 是分而治之三步骤。
  • 场景一:归并排序
    • :把数组从中间一分为二。
    • :递归地对两个子数组进行归并排序。
    • :合并有序子数组。
  • 场景二:快速排序
    • :选基准,按基准把数组分成两个子数组。
    • :递归地对两个子数组进行快速排序。
    • :对两个子数组进行合并。

动态规划

  • 算法设计 中的一种方法。
  • 它将一个问题分解为 相互重叠 的子问题,通过反复求解子问题,来解决原来的问题。
  • 场景:斐波那契数列
    • 前两个之和等于后一个,依此类推(0,1,1,2,3,5,8,13,21,34)。
    • 定义子问题:F(n) = F(n - 1) + F(n - 2)。
    • 反复执行:从 2 循环到 n , 执行上述公式。

贪心算法

  • 算法设计 中的一种方法。
  • 期盼通过每个阶段的 局部最优 选择,从而达到全局的最优。
  • 结果并 不一定是最优。

回溯算法

  • 算法设计 中的一种方法。
  • 回溯算法是一种 渐进式 寻找并构建问题解决方式的策略。
  • 回溯算法会先从一个可能得动作开始解决问题,如果不行,就回溯并选择另一个动作,直到将问题解决。
  • 什么问题适合用回溯算法解决?
    • 有很多路。
    • 这些路里,有思路,也有出路。
    • 通常需要递归来模拟所有的路。
  • 场景:全排列
    • 用递归模拟出所有情况。
    • 遇到包含重复元素的情况,就回溯。
    • 收集所有到底递归终点的情况,并返回。

练习

learn-algorithm

笔记 系列

  1. 笔记系列之 常见的数据结构和算法