前端学习算法篇:DFS,BFS,UnionFind

129 阅读6分钟

前言

作为一个前端,面试遇到算法内容时,不禁要问前端也需要学习算法吗?

我也曾有过这种疑问,但这不也正反映了前端越来越重要了吗?

作为 Leetcode 的初学者,我认为算法对于编码思想还是有一定帮助的。

本文以 Leetcode 上的题目:“200. 岛屿数量” 为基础简单分析它的三种解法。

题目描述

给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。

岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。

此外,你可以假设该网格的四条边均被水包围。

示例 1:

输入: grid = [
  ["1","1","1","1","0"],
  ["1","1","0","1","0"],
  ["1","1","0","0","0"],
  ["0","0","0","0","0"]
]
输出: 1

示例 2:

输入: grid = [
  ["1","1","0","0","0"],
  ["1","1","0","0","0"],
  ["0","0","1","0","0"],
  ["0","0","0","1","1"]
]
输出: 3

 

提示:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 300
  • grid[i][j] 的值为 '0' 或 '1'

题目分析

岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。 这句话是重点,由水平方向和/或竖直方向上相邻的的陆地连接形成。

DFS-深度搜索算法

深度搜索算法 和 递归回溯 有点像。而递归回溯,我看了一些题解基本都只有递归,没有回溯。

/**
 * @param {character[][]} grid
 * @return {number}
 */
var numIslands = function(grid) {
    if (!Array.isArray(grid) || grid.length <= 0 || grid[0].length <= 0) return 0;
    const m = grid[0]?.length
    const n = grid.length
    let count = 0
    for(let x = 0; x < n; x++) {
        for(let y = 0; y < m; y++) {
            if(grid[x][y] === '1') {
                dfs(x, y)
                count++
            }
        }
    }

    function dfs(x, y) {
        if(x < n && y < m && x >=0 && y >=0 && grid[x][y] === '1') {
            grid[x][y] = '0'
            dfs(x + 1, y)
            dfs(x - 1, y)
            dfs(x, y + 1)
            dfs(x, y - 1)
        }
    }
    return count
};

DFS 题解完全是自己写出来的,通过之后内心还是有点小激动的。

思路:

    1. 遍历二维数组中的每一项,如果是1表示是一个陆地,计数加1。并找到相邻的陆地,因为它们属于同一块陆地,并将它们全部标记为0,表示已经统计过。后续的遍历过程,它们已经被标记为0了。

这又有点像并查集的思想,只不过并查集是将相邻的1打包到一个集合里,算一个。

BFS-广度搜索算法

广度搜索算法就是,一级一级往下搜索。

/**
 * @param {character[][]} grid
 * @return {number}
 */
var numIslands = function(grid) {
    if (!Array.isArray(grid) || grid.length <= 0 || grid[0].length <= 0) return 0;
    const m = grid[0]?.length
    const n = grid.length
    let count = 0
    let queue = []
    for(let x = 0; x < n; x++) {
        for(let y = 0; y < m; y++) {
            if(grid[x][y] === '1') {
                queue.push([x, y])
                bfs(queue)
                count++
            }
        }
    }
    function bfs(queue) {
        const direct = [[-1, 0], [1, 0], [0, -1], [0, 1]]
        while(queue.length > 0) {
            let [curX, curY] = queue.shift()
            for(let i = 0; i < direct.length; i++) {
                const [dx, dy] = direct[i]
                const x = curX + dx, y = curY + dy
                if(isValidIndex([x, y]) && grid[x][y] === '1') {
                    queue.push([x, y])
                    grid[x][y] = '0'
                }
            }
        }
    }

    function isValidIndex(arr) {
        let [x, y] = arr
        return x >=0 && y >=0 && x < n && y < m
    }
    return count
};

本题解是看了思路之后,自己写出来的,和官方题解一致。

这个题解就很有看点了:

  • 利用了队列 queue, 这是一种思想,将待处理的数据放在队列里,然后在while循环中去做处理。这样可以将代码解构,程序嵌套就不会很深了。
  • 四个方向查找时,定义了一个数组。通过循环数组去依次查找。我之前很笨,四个方向写了四个if语句,代码块的语句相似度很高,x+1, x-1, y+1, y-1 写的头都大了。

    也可以这样:const direct = [0, 1, 0, -1, 0]

Tips

这里 BFS 和 DFS 算法主要思路是一致的,就是遍历二维数组,遇到1就计数加1。然后,将相邻的陆地也标记为0,避免重复统计。

不同的是,DFS是通过递归的方式去找相邻的陆地。我们来看下上述DFS算法中第一次调用dfs(x, y)发生了什么?dfs(x + 1, y) 表示先向右查找,如果是陆地,则标记为0,并以此为基点,继续向四个方向去找,直到某个基点四个方向都为 0 为止。然后再向左查找dfs(x - 1, y),以此类推。

而BFS第一次执行时,除了使用了队列外,其实并无太大的区别。这里用队列和栈结构都可以。

并查集

这个理解稍稍有点复杂,我也花了一下午才看明白:

/**
 * @param {character[][]} grid
 * @return {number}
 */
var numIslands = function(grid) {
    if (!Array.isArray(grid) || grid.length <= 0 || grid[0].length <= 0) return 0;
    const m = grid[0]?.length
    const n = grid.length
    let tmp = -1
    let direct = [[1, 0], [0, 1]]
    let uf = new UnionFind(m * n)
    for (let x = 0; x < n; x++) {
        for (let y = 0; y < m; y++) {
            if (grid[x][y] === '0') {
                uf.union(m * x + y, tmp)
            } else if (grid[x][y] === '1'){
                for (let i = 0; i < direct.length; i++) {
                    const [dx, dy] = direct[i]
                    const temX = x + dx, temY = y + dy
                    if (isValidIndex([temX, temY]) && grid[temX][temY] === '1') {
                        uf.union(m * x + y, m * temX + temY)
                    }
                }
            }
        }
    }

    function isValidIndex(arr) {
        let [x, y] = arr
        return x >=0 && y >=0 && x < n && y < m
    }

    return uf.getCount()

};

class UnionFind {
    constructor(n) {
        this.count = n
        this.parent = new Array(n)
        this.size = new Array(n)
        for(let i = 0; i < n; i++) {
            this.parent[i] = i
            this.size[i] = 1
        }
    }

    union(p, q) {
        const rootP = this.find(p)
        const rootQ = this.find(q)
        if(rootP === rootQ) return
        // 合并parent,size。把q接到p上
        if (this.size[rootP] > this.size[rootQ]) {
            this.parent[rootQ] = rootP;
            this.size[rootP] += this.size[rootQ];
        } else {
            this.parent[rootP] = rootQ;
            this.size[rootQ] += this.size[rootP];
        }

        // 每合并一次 count--
        this.count--
    }

     isConnected(p, q) { //判断p,q是否连通
        return this.find(p) === this.find(q)
    }

    find(x) { // 找到x节点的root,只有root节点的parent[x] == x
        while(this.parent[x] != x) {
            this.parent[x] = this.parent[this.parent[x]]
            x = this.parent[x]
        }
        return x
    }

    getCount() {
        return this.count
    }
}

并查集算法创建了一个类,并实现了合并、查找两个主要方法,通过 count 计算并查集的数量。

并查集思路:

  • 首先要理解二维数组的每一个元素均可通过唯一标识 m * x + y 来表示,m = grid[0].length
    for(let x = 0; x < 4; x++) {
        for(let y = 0; y < 3; y++) {
            console.log(3 * x + y) // 0 ~ 11
        }
    }
    
    有一点要特别注意: mn 的值别弄反了,我经常搞不清。
  • 初始时,二维数组每一个元素都代表一个集合,并设置它的父节点为自己,size为 1。
  • 遍历时,遇到1就向右,向下去找相邻的 1,然后合并到自己的父节点;遇到 0 也合并到 tmp。
    • 合并两个节点,就是合并唯一标识m * x + y,这比使用数组下标 xy 方便多了。
    • 合并节点就是找到它们的跟节点,然后将size小的加到size大的上去,然后将size小的跟节点也赋值为size大的根节点。
  • 然后获取最后的集合数量。

Tips

这里并查集写的有点复杂,其实与DFS、BFS类似。DFS/BFS是将相邻的陆地标记为0,并查集是将相邻的陆地合并。

总结

接触算法不久,如有不对的地方请大神指正。