树结构在前端领域的应用

138 阅读5分钟

我们先看一张图,直观的感受一下树结构(了解的可以跳过这一part)

我们看最上面的一个节点,由它分出来几条边,每条边都有一个节点,然后继续向下延申,直到最后的子节点,直观印象上大概可以这么理解一棵树

规范一点的特征和定义应该是:

  • 树(tree)是n个节点(node)组成的有限集合
  • n=0,该集合为空,称之为空树(empty tree)
  • n>0,该集合有一个根节点(root),以及m个( >=0 )子树,即每个互不相交的有限集
  • 根节点与其子树的根节点用一条边(edge)相连
  • 节点的度表示某个节点的子树的个数
  • 度=0 的节点表示为叶子节点(leaf)
  • 子节点一般使用children表示
  • 父节点使用parent表示

了解以上这些,我们就可以着手构建一个树了

class treeNode {

  value: number
  children: treeNode[]

  parent?: treeNode | null

  constructor(value: number, children?: treeNode[], parent?: treeNode) {

    this.value = value

    this.children = children ?? []

    this.parent = parent ?? null

  }

  add(node: treeNode) {

    node.parent = this

    this.children.push(node)

  }

}





class tree {

  root: treeNode

  constructor(root: treeNode) {

    this.root = root

  }

}





const _tree = new tree(new treeNode(0))

const child_1 = new treeNode(10)

_tree.root.add(child_1)

_tree.root.add(new treeNode(11))





child_1.add(new treeNode(20))

child_1.add(new treeNode(21))

child_1.add(new treeNode(22))

TS Playground

我们在聊树结构的时候,经常会提到二叉树,然后不厌其烦的学习二叉树的相关算法和操作,为什么?

许多实际问题抽象出来的数据结构往往是二叉树形式,即使是一般的树也能简单地转换为二叉树,而且二叉树的存储结构及其算法都较为简单,因此二叉树显得特别重要。

二叉树特点是每个结点最多只能有两棵子树,且只有左右孩子节点

且使用二叉链表去存储数据,相对与子树的数组存储,可以节省很多内存空间

我们看一下一颗多叉树转为二叉树(一道典型的leetcode习题)的过程

二叉树

由👆我们知道了一颗二叉树的典型特征就是

  • 每个结点最多有两颗子树,所以二叉树中不存在度大于2的结点
  • 左子树和右子树是有顺序的,次序不能任意颠倒
  • 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树

👇是一颗满二叉树

根据定义我们先构建节点node对象,包含data, 还有left, right 两个子node对象

class treeNode<T> {

  private data: T

  private left?: treeNode<T>

  private right?: treeNode<T>

  constructor(data: T) {

    this.data = data

    this.left = undefined

    this.right = undefined

  }

  get Data() {

    return this.data

  }

  get Left() {

    return this.left

  }

  set Left(value) {

    this.left = value

  }

  get Right() {

    return this.right

  }

  set Right(value) {

    this.right = value

  }

  //判断是否叶子结点

  isLeafNode() {

    //没有左右孩子的结点即为叶子结点

    return !this.Left && !this.Right

  }

  toString() {

    return this.Data.toString()

  }

}

我们看一下将数组转换为一颗二叉树的例子

class BinaryTree<T extends TBaseType> {

  private _ListForCreateTree: Array<T>

  private head: treeNode<T>



  constructor(ListForCreateTree: Array<T>) {

    this._ListForCreateTree = ListForCreateTree //用于构造二叉树的列表

    this.head = new treeNode(ListForCreateTree[0]) //根节点

    this.createTree(this.head, 0)

  }



  get Head() {

    return this.head

  }

  // 构造一颗二叉树

  private createTree(parent: treeNode<T>, index: number) {

    let leftIndex = 2 * index + 1;

    if (leftIndex < this._ListForCreateTree.length) {

      // 添加左孩子

      parent.Left = new treeNode(this._ListForCreateTree[leftIndex])

      // 递归调用Add方法给左孩子添加孩子节点

      this.createTree(parent.Left, leftIndex)

    }

    let rightIndex = 2 * index + 2

    if (rightIndex < this._ListForCreateTree.length) {

      // 添加右孩子

      parent.Right = new treeNode(this._ListForCreateTree[rightIndex])

      // 递归调用Add方法给右孩子添加孩子节点

      this.createTree(parent.Right, rightIndex)

    }

  }

  

 }

树的遍历

树的遍历有两种:深度优先(DFS) 和广度优先(BFS)

👇方两张图直观理解一下:

在关于二叉树的遍历上,DFS可以分为先序、中序、后序三种遍历

  • 先序遍历(Preorder Traversal/VLR)

1)访问根节点;

2)访问当前节点的左子树;

3)若当前节点无左子树,则访问当前节点的右子树;

  • 中序遍历(Inorder Traversal/LVR)

  • 后序遍历(Postorder Traversal/LRV)

深度遍历的实现(ps:代码没有优化空间?):

  depthFirstOrder(node?: treeNode<T>) {

    node = node || this.Head;

    let list: string[] = [];

    console.log(node.toString());

    list.push(node.toString())

    if (node.Left) {

      list = list.concat(this.depthFirstOrder(node.Left));

    }

    if (node.Right) {

      list = list.concat(this.depthFirstOrder(node.Right));

    }

    console.log(list)

    return list;

  }

广度遍历:

  levelOrder() {

    const list = []

    const queue = []

    queue.push(this.Head) //利用队列先入先出的特性 把根结点压入队列

    while (queue.length) {

      console.log('queue', queue.toString());

      let node = queue.shift() as treeNode<T> //出队

      list.push(node.toString())

      console.log(node.toString(), list.toString())

      if (node.Left) {

        queue.push(node.Left)

      }

      if (node.Right) {

        queue.push(node.Right)

      }

    }

    return list

  }

TS Playground

以上聊了这么多 树结构和前端到底有什么关系?

DOM树

我们看一段vue2编译后的代码

 var s = function() {

            var t = this

              , e = t.$createElement

              , a = t._self._c || e;

            return a("div", {

                staticClass: "risk-index"

            }, [a("risk-drop", {

                staticClass: "risk-drop-rela",

                attrs: {

                    "risk-level": t.rowData.projectRiskLevel,

                    "read-only": ""

                }

            }), t._v(" "), a("el-tooltip", {

                attrs: {

                    effect: "dark",

                    content: t.rowData.projectName,

                    placement: "top"

                }

            }, [a("span", {

                staticClass: "el-col-box-span",

                on: {

                    click: t.jumpProjectHome

                }

            }, [t._v("\n      " + t._s(t.rowData.projectName) + "\n    ")])]), t._v(" "), a("risk-tag", {

                attrs: {

                    "label-info-list": t.rowData.projectLabels

                },

                on: {

                    changeTagDire: t.changeTagDire

                }

            })], 1)

        }

我们试着模拟一下简单的vdom构建

export type T_OBJ = {

  [key: string]: any

}

export type T_KEY = string | number

export type T_ArrayNode = Array<IVNode | string | null>

export interface IVNode {

  tagName: string

  children: T_ArrayNode

  key?: T_KEY

  text?: string

  count: number

  props: T_OBJ

  parentVNode?: IVNode

  render(): HTMLElement | Text

}

export class Element implements IVNode {

  tagName: string = 'div'

  props: T_OBJ = {}

  children: T_ArrayNode = []

  key?: T_KEY

  text?: string | undefined

  count: number = 0

  parentVNode?: IVNode

  constructor(_tagName: string, props?: T_OBJ, children?: T_ArrayNode) {

    this.tagName = _tagName

    this.children = children || []

    if (props?.text) {

      this.text = props.text

      delete props.text

    }

    this.props = props || {}

    this.key = props?.key ?? undefined

    let count = 0

    for (let i = 0; i < this.children.length; i++) {

      let c = this.children[i]

      if (c instanceof Element) {

        count += c.count

      } else if (typeof c === 'string') {

        this.children[i] = new Element('text', { text: c })

      }

      count++

    }

    this.count = count

  }

  render() {

    if (this.tagName === 'text') {

      return document.createTextNode(this.text!)

    }

    const el = document.createElement(this.tagName)

    const props = this.props

    for (let [key, value] of Object.entries(props)) {

      setAttr(el, key, value)

    }

    this.children.forEach(c => {

      let childEL = c instanceof Element ? c.render() : document.createComment('<!--null-->')

      el.appendChild(childEL)

    })

    return el

  }

}

 // export FUNCTION rather than CLASS , avoid instantiation in constructor with FUNCTION(which actually defined the CLASS) called

export default function (_tagName: string): IVNode

export default function (_tagName: string, children: T_ArrayNode): IVNode

export default function (_tagName: string, props: T_OBJ): IVNode

export default function (_tagName: string, props: T_OBJ, children: T_ArrayNode): IVNode

export default function (_tagName: string, props?: T_OBJ, children?: T_ArrayNode): IVNode {

  if (arguments.length === 2) {

    const tmp = arguments[1]

    if (Array.isArray(tmp)) {

      return new Element(_tagName, {}, tmp)

    } else {

      return new Element(_tagName, tmp, [])

    }

  } else if (arguments.length === 1) {

    return new Element(_tagName, {}, [])

  }

  return new Element(_tagName, props, children)

}

测试代码

import el from '../src/lib/element'


it('test create-element', () => {

  const tree = el('div', { id: 'container' }, [

    el('h1', { style: 'color: blue' }, ['simple virtal dom']),

    el('p', ['Hello, virtual-dom']),

    el('ul', [el('li')]),

  ])

  // 2. generate a real dom from virtual dom. `root` is a `div` element

  var root = tree.render() as HTMLElement

  console.log(root.outerHTML)

  expect(true).toBe(true)

})

那么,树结构在日常开发中如何应用呢?

我们看下面的例子

树结构在实际开发中的应用

菜单栏

菜单栏内菜单切换时,选中的菜单要高亮,已高亮的菜单要清除样式

假设菜单的数据结构是这样的:

interface INavItem {

  ID: number

  title: string

  href: string

  isSel?: boolean

  children?: INavItem[]

}

type INavList = INavItem []

首先我们要构建节点对象,最👆面我们知道,为查找方便,可以无脑添加parent属性

interface INavItem {

  ID: number

  title: string

  href: string

  isSel?: boolean

  children?: INavItem[]

  parent: INavItem

}

然后我们构建一颗树,树里面当然存在根对象

class sidebars {

  private list: INavItem[]

  private Root: INavItem

  /**

   * @description 树的构建

   */

  constructor(list: INavItem[]) {

    this.list = list

    this.Root = { children: list } as INavItem

    this.createTree()

  }

  private createTree() {

    let i = 0

    const func = (node: INavItem) => {

      node.ID = i++; //唯一标识

      const children = node.children

      if (children) {

        children.forEach(brother => {

          brother.parent = node //添加指向双亲的指针域

          func(brother)

        })

      }

    }

    func(this.Root)

  }

}

我们使用深度搜索DFS获取选中对象

  /**

   * @description 查询isSel=true的唯一选项

   */

  getSelectedNode() {

    let tmp: INavItem | undefined

    const func = (node: INavItem) => {

      console.log(node.title);

      if (node.isSel) tmp = node

      const children = node.children

      if (children) {

        for (let i = 0, len = children.length; i < len && !tmp; i++) {

          func(children[i])

        }

      }

    }





    func(this.Root)





    return tmp

  }

TS Playground

多层级的checkbox

  • (不)勾选某一层级对象,如果其存在子对象数组,子对象数组跟随变化
  • (不)勾选某一层级对象,根据其兄弟对象状态,更新父对象状态,并递归向上更新其祖先对象状态

一般接口返回的数据格式是这样的

const list = [{

    label:  '',

    checked: false,

    children: [{

        label: '',

        checked: false,

        children:[]

    }]

  }]

同样的道理,我们要构建node, 生成一棵树

interface ICheckboxItem {

  label: string;

  checked: boolean;

  children?: ICheckboxItem[];

  level: number; // 结点的深度

  parent: ICheckboxItem;

}

/** 构建一棵树 */

// 定义根节点

const root = ({children: fetchList} as unknown) as ICheckboxItem

// 深度递归添加parent和level

const createTree = () => {

  let l = 0; // 游标

  const func = (arr: ICheckboxItem[], parent: ICheckboxItem) => {

    l++;

    arr.forEach((x) => {

      x.parent = parent;

      x.level = l;

      if (x.children) {

        func(x.children, x);

      }

    });

    l--;

  };

  func(root.value.children!, root.value);

};

createTree();

节点上下游对象状态都要跟随变化,我们可以着手构建两个DFS方法,先向下更新状态,再向上更新状态

const handleChange = (checkboxItem: ICheckboxItem) => {

  checkboxItem.checked = !checkboxItem.checked;

  const checked = checkboxItem.checked;

  // 向下查询

  const func = (o: ICheckboxItem) => {

    o.children?.forEach((x) => func(x));

    o.checked = checked;

  };

  // 向上回溯

  const func2 = (o: ICheckboxItem) => {

    if (o.parent) {

      o.parent.checked = !o.parent.children!.some((x) => !x.checked);

      func2(o.parent);

    }

  };

  func(checkboxItem);

  func2(checkboxItem);

};

vue3-tsx-demo (forked) - CodeSandbox