likes
comments
collection
share

JS基础——常见的树的操作(持续更新)

作者站长头像
站长
· 阅读数 23

树的操作都离不开遍历,我们对于二叉树的遍历有前序遍历,中序遍历、后序遍历,层次遍历,我们常说的深度遍历和广度遍历其实就是前序遍历和层次遍历。 对二叉树更详细的介绍可以看一下大佬的文章,我这里做一些简要复习和我工作中常用到的一些关于树的操作,给自己留做工作记录也分享给大家,遇到一些比较特别的操作也会持续更新。

树结构

let data = [
    {
        id: 1,
        value: 1,
        parent: null,
        children: [
            {
                id: 4,
                value: 4,
                parent: 1,
                children: [
                    {
                        id: 6,
                        value: 6,
                        parent: 4,
                        children: [],
                    },
                ],
            },
            {
                id: 5,
                value: 5,
                parent: 1,
                children: [],
            },
        ],
    },
    {
        id: 2,
        value: 2,
        parent: null,
        children: [],
    },
    {
        id: 3,
        value: 3,
        parent: null,
        children: [],
    }
]

树转数组

树转数组的思想是,把树都遍历一遍,每当遇到一个节点,就把节点存储起来放到一个数组里,这个数组就是转化后的数组,常用到的遍历办法就是使用广度遍历和深度遍历。

深度优先遍历

Depth First Search(dfs):它会一直向下访问树的左子树,直到到达叶子节点,然后再回溯到上一层的右子树。

广度优先遍历

Breadth First Search(bfs):通过使用队列来实现,它会按照层级顺序逐层访问树的节点。

let treeToArray = (data) => {
    let result = []
    //深度遍历(递归):递归过程中,每次遇到一个节点有children,就进入递归
    let dfs = tree => {
        tree.forEach(item => {
            let { children, ...res } = item //可有可无,看你是否还需要chileren属性
            result.push(res)
            if (item.children && item.children.length > 0) {
                dfs(item.children)
            }
        });
    }
    //深度遍历(迭代):采用栈是思想,后进先出,每当有节点出栈后都需要把节点push到result
    let dfs1 = tree => {
        tree.forEach(node => {
            let stack = []
            stack.push(node)
            while (stack.length) { //循环结束条件:栈里一个节点也没有了
                let item = stack.pop()//把栈顶弹出去执行,然后把当前节点放到result中
                let { children, ...res } = item //可有可无,看你是否还需要chileren属性
                result.push(res)
                while (item.children.length > 0) {
                    let i = item.children.pop()
                    stack.push(i)
                }
            }
        })
    }
    //广度遍历:采用队列的思想,先进先出,每当有节点出队列后都需要把节点push到result
    let bfs = tree => {
        tree.forEach(node => {
            let queue = []
            queue.push(node)
            while (queue.length) {//迭代结束的条件:队列里一个节点都没了
                let item = queue.shift() //队列的第一个
                let { children, ...res } = item //可有可无,看你是否还需要chileren属性
                result.push(res)
                for (let i of item.children) {
                    queue.push(i)
                }
            }
        })
    }
    dfs(data) //[1,4,6,5,2,3]
    // dfs1(data) //[1,4,6,5,2,3]
    // bfs(data) //[1,4,5,6,2,3]
    return result
}
console.log('treeToArray', treeToArray(data))

深度、广度方法可以任选一个去做操作,但是也会有所差异:

相同点:

时间复杂度都是O(n)

不同点:

1、如果树的结构是高度不平衡的,即某一条路径上的节点数量远大于其他路径,那么深度优先遍历可能会更快,因为它会更快地到达叶子节点。 2、bfs的空间复杂度是O(w),dfs的空间复杂度是O(h),w是树的最大宽度,h是树的最大高度。 3、如果考虑性能问题,深度优先遍历建议使用迭代的方式实现,可以避免递归带来的性能消耗。

数组转树

这里其实是数组操作,但是写了树转数组就多写一个数组转树吧。 数组结构:

let arr = [
    {
        id: 1, value: 1, parent: null
    },
    {
        id: 2, value: 2, parent: null
    },
    {
        id: 3, value: 3, parent: null
    },
    {
        id: 4, value: 4, parent: 1
    },
    {
        id: 5, value: 5, parent: 1
    },
    {
        id: 6, value: 6, parent: 4
    },
]

实现代码:

let arrToTree = (arr, idProp = 'id', parentProp = 'parent') => {
    let tree = []
    let map = {}

    arr.forEach(item => {
        map[item[idProp]] = item
    })
    arr.forEach(item => {
        let parent = map[item[parentProp]]
        if (item[parentProp]) {
            (parent.children || (parent.children = [])).push(item)
        } else {
            tree.push(item)
        }
    })
    return tree
}

根据id查找对应的树节点

这里的办法跟上面是很类似的,核心思想还是利用遍历,边查询边对比,遇到查找目标就return。

//使用的是递归版深度遍历
//prop是可替换的,可能你要查找的属性是componyId,那这个prop就是componyId
let findNodeById = (tree, id, prop = "id") => {
    for (let item of tree) {
    //判断节点是否符合
        if (item[prop] === id) {
            return item
        }
        if (item.children && item.children.length > 0) {
        //递归调用时,如果符合条件的就退出递归
            const result = findNodeById(item.children, id, 'id')
            if (result) {
                return result
            }
        }
    }
}
console.log('result', findNodeById(data, 5, 'id'))

根据多个id查询对应的树节点

有时候我们可能要查询多个id,如果一个一个去查的话,会查询到很多重复的节点,为了避免性能浪费,可以传入一个数组,批量查询

let findNodeByIds = (tree, ids = [], result = [], prop = "id") => {
    let idsT = [...ids] //idsTemp: 临时存储ids,当查到一个就弹出一个,当idsT.length为0结束
    if (idsT.length) {
        for (let item of tree) {
            if (idsT.includes(item[prop])) {
                result.push(item)
                //删除数组中的特定元素的下标
                let index = idsT.indexOf(item[prop])
                idsT = idsT.slice(0, index).concat(idsT.slice(index + 1, idsT.length))
            }
            if (item.children && item.children.length) {
                findNodeByIds(item.children, idsT, result, 'id')
            }
        }
    }
    return result
}
console.log('findNodeByIds', findNodeByIds(data, [1, 2, 3, 4, 5, 6])) //1,4,6,5,2,3,

上面查询出来的结果是1,4,6,5,2,3(递归版深度遍历),也就是说没按照查询的顺序进行输出,这时候我们需要多做一步,根据输入的排序,输出输出的排序,有点拗口哈,但是你一看就能明白。

//nodes是findNodeByIds(data, [1, 2, 3, 4, 5, 6]))输出的结果,ids是[1, 2, 3, 4, 5, 6](输入的排序)
nodes.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id))

查找某个节点的路径

实现思想:遍历+回溯 由于路径的是纵深的,所以采用深度遍历的方式; 由于不知道目标节点是在哪棵树,所以需要有回溯的思想,每次遍历的时候,都记录节点,如果查到叶子节点都没找到目标节点,就清除当前添加的path,如果找到就需要把整个路径返回。

let findPathById = (tree, id, prop = "id", path = []) => {
    for (let node of tree) {
        path.push(node[prop])
        if (node[prop] === id) return path
        if (node.children && node.children.length) {
            let result = findPathById(node.children, id, prop, path)
            if (result) return result //找到匹配的节点了
        }
        //比如先找到[1,4,6],已经是叶子节点了,都没找到,6不是目标,pop出去
        //回溯上一层是4,也不是目标,pop出去,进入for的第二层,此时node是5
        //把节点5添加进path,匹配成功,return path
        path.pop() //找不到匹配的节点,将其从路径中删除
    }
    return null //遍历完整棵树的了,都找不到匹配的节点,返回null
}
let path = []
findPathById(data, 5, 'id', path)
console.log('path', path) //[1,5]

查找所有叶子节点

实现思想:遍历树,然后把children = [] 的节点添加到一个新数组里。

let findLeaves = (tree, list = []) => {
    tree.forEach(node => {
        if (node.children && node.children.length) {
            findLeaves(node.children, list)
        } else {
            console.log('node', node)
            list.push(node)
        }
    })
    return list
}
console.log('leaves', findLeaves(data)) //[6,5,2,3]

查找树的所有叶子节点路径

结合上面查找路径和查找叶子节点的方法,找到叶子节点时,把该叶子节点的路径(path)加入到结果集(list)中。

let findAllPath = (tree, path = [], list = []) => {
    tree.forEach(node => {
        path.push(node)

        if (node.children && node.children.length) {
            findAllPath(node.children, path, list)
        } else {
            list.push([...path])
        }
        path.pop()
    })
    return list
}
console.log('path--', findAllPath(data)) //[[1,4,6],[1,5],[2],[3]]

把一个节点移动到另一个节点

如果一个节点有children,会把整个节点包括children都带走,这种在树状列表拖拽的业务场景中经常使用。

做法一:

可以灵活运用数组转树和树转数组的操作,先数组化然后找到对应的要移动的节点,把节点的parent修改成目标节点的id。

function moveNodes(tree, nodeId1, nodeId2) {
    let arr = treeToArray(tree)
    let node1 = arr.find(item => item.id === nodeId1)
    node1.parent = nodeId2
    return arrToTree(arr)
}
console.log(moveNodes(data, 5, 6));

做法二:

通过findNodeByIds找到移动(node1)和目标(node2)两个节点,再找到移动的父节点(nod1parent),通过把node1 push到nod2的children上,然后把node1parent的children中对应的node1的位置splice掉,稍微有点麻烦,但是当有大量数据的时候,做法二的效率会比做法一要高。

let moveNodes = (tree, nodeId1, nodeId2) => {
    let node1 = null;
    let node2 = null;
    let node1Parent = null

    // 找到要移动的两个节点
    let arr = [nodeId1, nodeId2]
    let nodes = findNodeByIds(tree, arr).sort((a, b) => arr.indexOf(a.id) - arr.indexOf(b.id))
    node1 = nodes[0]
    node2 = nodes[1]

    // 移动节点的父节点,这里因为我的原数据树的第一层的parent是为null的,所以多做一步判断
    node1Parent = node1.parent == null ? null : findNodeByIds(tree, [node1.parent])[0]
    // 如果找到了两个节点,则进行移动操作
    if (node1 && node2) {
        // 从原来的父节点的children数组中移除node1
        if (node1Parent === null) {
            const index1 = tree.findIndex(node => node.id === nodeId1);
            tree.splice(index1, 1);
        } else {
            const index1 = node1Parent.children.findIndex(child => child.id === nodeId1);
            node1Parent.children.splice(index1, 1);
        }
        // 将node1添加到node2的children数组中
        node2.children.push(node1);

        // 更新node1的parent属性
        node1Parent = node2;
    }

    return tree;
}

执行

console.log(moveNodes(data, 2, 4));

这里贴个输出的截图吧。

JS基础——常见的树的操作(持续更新) 以上做法都请注意引用的问题,不然移动节点节点会导致源数据改变,可能会导致不可预测的问题出现!

最后

以上代码可能需要根据实际源数据情况做调整,仅供参考!

  • 2024/1/17 --更新-- 把一个节点移动到另一个节点
转载自:https://juejin.cn/post/7324250434910240818
评论
请登录