资讯专栏INFORMATION COLUMN

用JavaScript来学习树「译」

Youngdze / 2688人阅读

摘要:树可谓是开发者最常碰到的数据结构之一了要知道整张网页就是一棵树啊所以我们就来学习树这一数据结构吧在这篇文章中我们将创建一棵树并且用两种不同的方法来遍历它深度优先遍历和宽度广度优先遍历方法使用借助栈这一数据结构来访问树的每个节点则借助了队

树可谓是web开发者最常碰到的数据结构之一了. 要知道, 整张网页就是一棵DOM树啊 (Document Object Model ). 所以我们就来学习树这一数据结构吧 !

在这篇文章中, 我们将创建一棵树并且用两种不同的方法来遍历它: Depth-First Search ( DFS, 深度优先遍历 ), 和 Breadth-First Search ( BFS, 宽度/广度优先遍历 ). DFS方法使用借助栈 ( stack ) 这一数据结构来访问树的每个节点, BFS则借助了队列 ( queue ).

在计算机科学里, 树是一种分层的数据结构, 用节点来描述数据. 每个节点都保存有自己的数据和指向其他节点的指针.

用我们熟悉的DOM来解释一下节点 ( node ) 和 指针 ( pointer ) . 在一张网页的DOM里, 标签被称为根节点/根元素 ( root node ), 那么, 代表html 的这个节点就有指向它的子节点们的指针. 具体到下面的代码:

var rootNode = document.getElementsByTagName("html")[0];
//  rootNode.childNodes可以粗暴地认为就是指针啦
var childNodes = rootNode.childNodes;
console.log(childNodes);

嗯, 所以一张网页就是节点有它的子节点们, 每个子节点又有可能有各自的子节点, 这样一直嵌套下去, 就构成了一棵DOM树.

对树的操作

因为每棵树都包含节点, 所以我们有理由抽象出两个构造函数: NodeTree. 下面列出的是他们的属性和方法. 扫一眼就好, 到具体实现可以再回来看有什么.

Node

data 属性用来保存节点自身的值, 简单起见, 先假设它保存的是一个基本类型的值, 如字符串one

parent 指向它的父节点

children 指向它的子节点们所组成的数组

Tree

_root 代表一棵树的根节点

traverseDF(callback) 用DFS遍历一棵树

traverseBF(callback) 用BFS遍历一棵树

contains(callback, traversal)DFSBFS 在树里遍历搜索一个节点

add(data, toData, traverse) 往树里添加一个节点

remove(child, parent) 从树里移除一个节点

具体的代码实现

来开始写代码吧 !

Node构造函数
function Node(data) {
    this.data = data;
    this.parent = null;
    this.children = [];
}
Tree构造函数
function Tree(data) {
    var node = new Node(data);
    this._root = node;
}

Tree 构造函数里只有两行代码, 第一行先是创建了一个节点, 第二行是把这个节点设为树的根节点.

虽然NodeTree 的代码只有那么几行, 但是这就足以让我们描述一棵树了. 不信 ? 用下面的代码创建一棵树看看:

var tree = new Tree ("CEO");  // 根节点就像是CEO老总
console.log(tree._root);  // Node {data: "CEO", parent: null, children: Array(0)}

幸好有parentchildren 这两个属性的存在, 我们可以children 给根节点_root 添加子节点, 也可以用parent 把其他节点的父节点设置成_root . 反正你开心就好.

Tree的方法

方法上面已经列举过啦.

1. traverseDF(callback) 深度优先遍历
Tree.prototype.traverseDF = function (callback) {
    (function recurse(currentNode) {
        // step2 遍历当前节点的子节点们
        for (var i = 0, length = currentNode.children.length; i < length; i++) {
            // step3, 递归调用遍历每个子节点的子节点们
            recurse(currentNode.children[i]);
        }

        // step4 可以在这里写你处理每一个节点的回调函数
        callback(currentNode);

        // step1, 把根节点传进来
    })(this._root);
};

traverseDF(callback) 有一个callback 参数, 是一个函数, 等到你需要调用的时候调用. 除此之外, 还有一个叫recurse 的递归函数. 说一下详细的步骤吧:

首先, 利用立即执行函数表达式把根节点传进recurse 函数, 此时, currentNode 就是根节点

进入for 循环后, 依次遍历当前节点的每一个子节点

for 循环体里, 递归地调用recurse 函数再遍历子节点的子节点

currentNode 不再有子节点了, 就会退出for 循环, 然后调用callback 回调函数后, 就一层层地返回了

开头我们说DFS 方法借助了栈来实现, 是的, 我们确实借用了栈, 就是recurse递归函数的函数调用栈. 任何函数的调用都会涉及到进栈和出栈.

递归是一个编程上很重要的思想, 要想讲清楚也不是一时半会的事. 在这里我们把重点放到树上, 对递归不太理解的童鞋们可以自行搜索一下, 但在这里建议大家把这个traverseDF 的代码敲一下, 相信你起码能理解其中的一些奥妙.

接下来的例子只用上面提及到的代码创建了一棵树, 并用traverseDF 遍历, 虽然不够优雅, 但好歹能正常工作. 在后面实现 add(value) 这个方法后, 我们的实现看起来就不会那么傻逼了

var tree = new Tree("one");
 
tree._root.children.push(new Node("two"));
tree._root.children[0].parent = tree;
 
tree._root.children.push(new Node("three"));
tree._root.children[1].parent = tree;
 
tree._root.children.push(new Node("four"));
tree._root.children[2].parent = tree;
 
tree._root.children[0].children.push(new Node("five"));
tree._root.children[0].children[0].parent = tree._root.children[0];
 
tree._root.children[0].children.push(new Node("six"));
tree._root.children[0].children[1].parent = tree._root.children[0];
 
tree._root.children[2].children.push(new Node("seven"));
tree._root.children[2].children[0].parent = tree._root.children[2];
 
/*
 
creates this tree
 
 one
 ├── two
 │   ├── five
 │   └── six
 ├── three
 └── four
     └── seven
 
*/

traverseDF(callback) 遍历:

tree.traverseDF(function(node) {
    console.log(node.data)
});
 
/*
 
logs the following strings to the console
 
"five"
"six"
"two"
"three"
"seven"
"four"
"one"
 
*/
2. traverseBF(callback)

接下来来看看宽度优先遍历BFS吧 !

DFS和BFS 的不同, 在于遍历顺序的不同. 为了体现这点, 我们再次使用之前DFS用过的那棵树, 这样就好比较异同了

/*
 
 tree
 
 one (depth: 0)
 ├── two (depth: 1)
 │   ├── five (depth: 2)
 │   └── six (depth: 2)
 ├── three (depth: 1)
 └── four (depth: 1)
     └── seven (depth: 2)
 
 */

先假装我们已经实现了traverseBF(callback) , 并且使用了和traverseDF(callback) 相同的回调函数, 看看输出的结果, 毕竟有时候从结果推过程很重要

tree.traverseBF(function(node) {
    console.log(node.data)
});
 
/*
 
logs the following strings to the console
 
"one"
"two"
"three"
"four"
"five"
"six"
"seven"
 
*/

哦吼, 就是先从depth = 0, depth = 1...这样按照每一层去遍历嘛. 既然我们已经有了个大致的概念, 那就又来愉快地敲代码吧:

Tree.prototype.traverseBF = function (callback) {
    var queue = [];

    queue.push(this._root);

    var currentNode = queue.shift();

    while (currentNode) {
        for (var i = 0, length = currentNode.children.length; i < length; i++) {
            queue.push(currentNode.children[i]);
        }

        callback(currentNode);
        currentNode = queue.shift();
    }
};

// 注: 此处原文的队列作者用了 `var queue = new Queue();`, 可能是他之前封装的构造函数
// 我们这里用数组来就好, push()表示进队列, shift()表示出队列

这里的概念稍微有点多, 让我们先来梳理一下:

创建一个空数组, 表示队列queue

把根节点_root 压入队列

声明currentNode 变量, 并用根节点_root 初始化

currentNode 表示一个节点, 转换成布尔值不为false 时, 进入while 循环

for 循环来取得currentNode 的每一个子节点, 并把他们逐个压入queue

currentNode 调用回调函数 callback

queue 的队头出队列, 将其赋值给currentNode

就这样一直重复, 直到没有队列中没有节点赋值给currentNode , 程序结束

你可能会对上述步骤2, 3的对应两行代码有些疑惑:

queue.push(this._root);
var currentNode = queue.shift();
// 先进队列又出队列好像显得有些多次一举? 
// 实际上直接 var currentNode = this._root也是可以的
// 但在这里还是建议像这样写, 以保持和while循环体内代码格式的统一

到了这里, 是不是感觉到栈和队列的神奇之处? 后进先出 ( LIFO, Last In First Out) 和 先进先出 ( FIFO, First In First Out ) 就让能让我们的访问顺序截然不同

3. contains(callback, traversal)

下面我们来定义contains 方法:

Tree.prototype.contains = function (callback, traversal) {
    traversal.call(this, callback);
};

它是这样被调用的:

tree.contains(function (node) {
    if (node.data === "two") {
        console.log(node);
    }
}, tree.traverseBF);

可以看到, contains 方法实际上只是对树的遍历方法包裹多了一层而已:

traversal 让你决定定是遍历方法是DFS, 还是BFS

callback 让你指定的就是之前我们定义traverseDF(callback) 或者 traverseBF(callback) 里的callback 函数

函数体内 traversal.call(this, callback) , this 绑定到当前函数的执行环境对象, 在这里来说tree.contains()... 的话, tree 就是 this

这和你直接调用traverseDF(callback) 或者 traverseBF(callback) 并没有什么不同, 只是提供了一个更一致的对外接口

4. add(data, toData, traversal)

经过前面的步骤我们已经知道如何在一棵树搜索一个节点了, 那么我们就可以给某个特定的节点来添加子节点啦

Tree.prototype.add = function (data, toData, traversal) {
    var child = new Node(data),
        parent = null,
        callback = function (node) {
            if (node.data === toData) {
                parent = node;
            }
        };

    this.contains(callback, traversal);

    if (parent) {
        parent.children.push(child);
        child.parent = parent;
    } else {
        throw new Error("Cannot add node to a non-existent parent.");
    }
};

var tree = new Tree("CEO");

tree.add("VP of Happiness", "CEO", tree.traverseBF);
tree.add("VP of Finance", "CEO", tree.traverseBF);
tree.add("VP of Sadness", "CEO", tree.traverseBF);

tree.add("Director of Puppies", "VP of Finance", tree.traverseBF);
tree.add("Manager of Puppies", "VP of Finance", tree.traverseBF);

/*

 tree

 "CEO"
 ├── "VP of Happiness"
 ├── "VP of Finance"
 │   ├── "Director of Puppies"
 │   └── "Manager of Puppies"
 └── "VP of Sadness"

 */
 
 // 注: 原文此处的树图和代码有点不对应, 应该是作者画错了, 这里改了一下

感觉不用再啰嗦了, 就是遍历搜索节点, 找到的话new 一个Node设定好相互间的父子关系, 找不到这个特定的节点就抛出异常 : )

5. remove(data, fromData, traversal)

有了添加, 那就要有删除嘛:

Tree.prototype.remove = function (data, fromData, traversal) {
    var childToRemove = null,
        parent = null,
        index;

    var callback = function (node) {
        if (node.data === fromData) {
            parent = node;
        }
    };

    this.contains(callback, traversal);

    if (parent) {
        index = findIndex(parent.children, data);

        if (index === undefined) {
            throw new Error("Node to removes not exist.")
        } else {
            childToRemove = parent.children.splice(index, 1);
        }
    } else {
        throw new Error("Parent does not exist.");
    }
};

function findIndex(arr, data) {
    var index;

    for (var i = 0; i < arr.length; i++) {
        if (arr[i].data === data) {
            index = i;
        }
    }

    return index;
}

tree.remove("Manager of Puppies", "VP of Finance", tree.traverseDF);
tree.remove("VP of Sadness", "CEO", tree.traverseDF);

/*

 tree

 "CEO"
 ├── "VP of Happiness"
 └── "VP of Finance"
    ├── "Director of Puppies"
    └── "Manager of Puppies"

 */

其实都是类似的套路, 另外, 数组的findIndex 方法已经存在于ES6的标准里, 我们大可以直接使用而不用再次定义一个类似的方法.

这篇文章重点是如何建立一棵树, 和遍历方法DFS, BFS 的思想, 至于那些增删改查, 只要懂得遍历, 那都好办, 具体情况具体分析

好啦, 到这里这些方法已经全部都实现了. 本文没有逐字翻译, 大部分是意译, 和原文是有些出入的, 此外代码也有一些边角的改动, 并没有一一指明.

原文链接: Data Structures With JavaScript: Tree

完整代码, 或者访问相应的JS Bin

更新(9/18): 如果你想看二叉树的实现




    
    Tree in JS




文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/91740.html

相关文章

  • JavaScript 究竟是如何工作的?(第一部分)

    摘要:文章的第二部分涵盖了内存管理的概念,不久后将发布。的标准化工作是由国际组织负责的,相关规范被称为或者。随着分析器和编译器不断地更改字节码,的执行性能逐渐提高。 原文地址:How Does JavaScript Really Work? (Part 1) 原文作者:Priyesh Patel 译者:Chor showImg(https://segmentfault.com/img...

    Youngdze 评论0 收藏0
  • JavaScript面试问题:事件委托和this

    摘要:主题来自于的典型面试问题列表。有多种方法来处理事件委托。这种方法的缺点是父容器的侦听器可能需要检查事件来选择正确的操作,而元素本身不会是一个监听器。 showImg(http://fw008950-flywheel.netdna-ssl.com/wp-content/uploads/2014/11/Get-Hired-Fast-How-to-Job-Search-Classifieds...

    浠ラ箍 评论0 收藏0
  • JavaScript数据结构(4):

    摘要:遍历树是访问树的每个节点的正式方式。想象一下,我们要将包含奇数数据的任何节点记录到控制台,并使用遍历树中的每个节点。第三个参数,是这个方法中用来遍历树的类型。与类似,移除将遍历树以查找包含第二个参数的节点,现在为。 翻译:疯狂的技术宅英文:https://code.tutsplus.com/art...说明:本文翻译自系列文章《Data Structures With JavaScri...

    Keagan 评论0 收藏0
  • 】关于机器学习的11个开源工具

    摘要:虽然广受欢迎,但是仍受到来自另外一个基于的机器学习库的竞争年出现的。还提供更传统的机器学习功能的库,包括神经网络和决策树系统。和的机器学习库。顾名思义,是用于神经网络机器学习的库,便于将浏览器用作数据工作台。 关于机器学习的11个开源工具 翻译:疯狂的技术宅英文标题:11 open source tools to make the most of machine learning英文连...

    岳光 评论0 收藏0
  • [] 究竟什么是DOM?!

    摘要:是我写的吗还是我偶尔打开控制台检查元素的时候点击的元素说实话,我花了好长时间才弄明白究竟是什么。什么简单来说,是在浏览器中的表示形式,允许您操纵页面。那么为什么它经常被称为树呢这是因为从一个父项开始,该父项扩展为子项。 原文自工程师Kara Luton博客,传送门 DOM,当我第一次在训练营学习编码时,就一直听到这个词,但是我从来不知道它到底是什么意思。是我写的HTML吗?还是我偶尔...

    winterdawn 评论0 收藏0

发表评论

0条评论

Youngdze

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<