画一棵树(Reingold-Tilford algorithm)

2,241 阅读8分钟

工作中遇到了要可视化一个树结构数据的需求,自己瞎鼓捣一通倒是也勉强实现了这样的需求,但是并不美观,随后在网上冲浪的时候发现了一个叫Reingold-Tilford的树结构可视化算法,便研究了一下,随便记录一下自己的理解。

这个算法上网搜一下是可以搜到作者写的论文的,不过硬看纯英文论文对我来说还是有亿点难度的,所以研究它的论文还是算了

找资料的时候看到两篇很好的文章解释这个算法,对我的理解有很大启发

criheacy.com/blog/tree-v…

rachel53461.wordpress.com/2014/04/20/…

第一篇是中文文章,写的也挺清楚的,不过可能是我太菜了所以我感觉读起来还是有点抽象的,不太跟得上,不过它推荐了一个英文的博客,也就是上面的第二篇,写得更通俗易懂。

以下是我个人对这个算法的理解,参考了以上两篇文章,主要是第二篇

1. 设定

TR算法对最终画出来的树结构给了一些规范限制

  • 所有同层节点必须被放置在同一水平高度
  • 父节点需相对于它所有的子节点居中
  • 相邻的且都拥有子树的节点需在保证所有子树画出来不会重叠的前提下尽量紧挨

以及一些其他的规范,具体还有哪些我忘了,那篇中文的文章里有很详细的描述

2. 步骤

具体到代码实现的步骤,如下所示

  1. 后序遍历这棵树(确保先处理子节点再处理父节点,因为父节点有些东西是要根据子节点的情况来调整的)

  2. 给每个节点的x坐标赋值,如果它是一个最左节点,它的x值为0,否则为它的左兄弟节点的x的值加一(或者加预先设定的相邻节点间隔值)

  3. 对每一个父节点,我们需要将它置于相对于它所有子节点水平居中的位置(也就是最左子节点的X坐标与最右子节点X坐标的中间值,相加除二就行)

    如果父节点本身没有左兄弟节点,那就直接改父节点的X坐标。如果父节点是有左兄弟节点的,那就不是要修改父节点的x坐标以使它居中,而是要修改它的所有子节点的X坐标,让它们偏移到保证父节点是相对于它们水平居中的位置。

    具体做法是:如果这个父节点没有左兄弟节点,就将它的x值改成根据子节点的X坐标值算出来的居中的值。否则(它有左兄弟节点),那它需要增加一个“mod”属性,这个属性是一个数字,代表它的所有子节点应该往右偏移多少,才能保证它们是均匀分布在父节点两边(也就是保证父节点是相对于它们水平居中)

    根据第一步赋值X坐标,各个点的X坐标将会是这个样子

    pic1.png

    此时对于b来说,它的X坐标应该修改为0.5

    对于c来说,它的坐标不改变,而是计算它的子节点的X坐标应该怎么偏移,也就是f和g的,f的X坐标应该改成0.5,g的X坐标应该改成1.5,那么这个偏移量也就是0.5,这个0.5目前是要记录在c节点中的(也就是“mod”属性),在最后画之前要再遍历一遍整棵树,每个节点就能根据它的父节点的这个偏移量来调整它们各自的X坐标,得到最终正确的结果

    这个mod值的计算方法是这样:拿c做例子,先根据它的子节点坐标算出来它如果要居中的话,它的X坐标应该是0.5,那么它的mod值就是 c.x - 0.5

  4. 处理非叶子节点的互相之间子树重叠的问题,对于每个非叶子节点,检查它每一层子树的轮廓(contour,即这一层最左/小或者最右/大的X坐标)与旁边的子树的同层轮廓是否有重叠,根据重叠情况调整根节点的X坐标以及mod的值

    具体做法是:

    对于每一个非叶子节点(且它有左兄弟节点),从它的最左兄弟节点开始,逐个向右遍历,对于每一个左边的兄弟节点,判断它的右轮廓与当前节点的左轮廓有没有重叠,记下最大的重叠距离,然后当前节点就要往右偏移【最大重叠距离 + 预先设定的相邻节点间隔】距离(记得它的mod值也要加上这个距离)

    在处理这一步的时候记得要考虑上mod值,每个节点真正的X坐标是它的X坐标加上它父节点的mod值的和

  5. 第四步完成后,有可能会出现一种情况:

    两边两棵很宽的子树是撑开到合理的样子了,但是中间两个节点堆积在左侧,并不美观

    pic2.png

    如果将它们均匀分布在两棵宽子树之间,就会好看很多,也就是实现这样的效果

    pic3.png

    要实现这样其实也很简单,就是在第四步每次偏移完节点后,检查两个节点之间是否还有节点,有的话,就先算出两个节点之间的X坐标距离,然后除【中间夹着的节点个数 +1】,就得到要让中间的节点均匀分布,它们互相之间应该间隔的距离 这里好像不能算出来后就直接移动,不然会有不美观的情况

    我认为这里还需要加个判断,在试图移动中间的节点前,先判断下它跟夹着它的右边那个节点有没有重叠的部分,有的话就不移动它了,而是继续检查下一个

  6. 最后再对整棵树进行一次先序遍历,根据mod值算出每个节点的真正的X坐标

3. 实现

以下是参考资料后自己的代码实现,用的typescript,有很多自己的理解和个人代码风格,可能不一定完全健壮且正确,有不对的地方再改吧

2023.2.17更新,改了点代码

type Contour = { left: number; right: number }

// 树节点结构
type TreeNode = {
  name: string;
  x?: number;
  y?: number;
  mod?: number;
  leftSibling?: TreeNode;
  root?: TreeNode;
  index?: number;
  children?: Array<TreeNode>;
  contoursShift?: number;
  contours?: Array<Contour>;
}

然后是第一轮遍历

// 父节点与子节点间垂直间隔
const MIN_VERTICAL_GAP = 60

// 相邻节点间隔
const MIN_SIBLING_GAP = 60

function isEmpty(arr: Array<any>): boolean {
  return !Array.isArray(arr) || arr.length == 0
}

function setInitialX(node: TreeNode, depth: number = 0, index: number = 0) {
  node.x = 0
  node.y = depth * MIN_VERTICAL_GAP
  node.index = index
  node.mod = 0
  if (!isEmpty(node.children)) {
    let leftSibling: TreeNode
    depth++
    // 后序遍历,先遍历子节点
    for (let i = 0; i < node.children.length; i++) {
      const child = node.children[i];
      if (leftSibling)
        child.leftSibling = leftSibling
      child.root = node
      leftSibling = child
      setInitialX(child, depth, i)
    }
  }
  if (node.leftSibling)
    node.x = node.leftSibling.x + MIN_SIBLING_GAP
  if (!isEmpty(node.children)) {
    const midX = (node.children[0].x + node.children[node.children.length - 1].x) / 2
    if (node.leftSibling)
      node.mod = node.x - midX
    else
      node.x = midX
    const contours: Array<Contour> = [] // 以当前节点为根的子树的各层轮廓
    setContours(node, contours) // 填充轮廓
    node.contours = contours
    if (node.index > 0)
      checkForConflicts(node)
  }
}

function setContours(node: TreeNode, contours: Array<Contour>, modSum = 0, level = 0) {
  const actualX = node.x + modSum
  if (!contours[level])
    contours[level] = { left: actualX, right: actualX }
  else {
    contours[level].left = Math.min(contours[level].left, actualX)
    contours[level].right = Math.max(contours[level].right, actualX)
  }
  modSum += node.mod
  level++
  if (!isEmpty(node.children)) {
    for (const child of node.children) {
      setContours(child, contours, modSum, level)
    }
  }
}

检查重叠

function checkForConflicts(node: TreeNode) {
  node.contoursShift = 0 // 以当前节点为根的子树总共向右偏移的距离
  for (let i = 0; i < node.index; i++) {
    const left = node.root.children[i]
    if (isEmpty(left.children))
      continue
    const minContoursHeight = Math.min(left.contours.length, node.contours.length)
    let shiftVal = 0
    for (let i = 1; i < minContoursHeight; i++) {
      const rightContourOfLeftSibling = left.contours[i].right + (left.contoursShift ?? 0)
      const leftContour = node.contours[i].left + node.contoursShift
      const diff = leftContour - rightContourOfLeftSibling
      if (diff < MIN_SIBLING_GAP)
        shiftVal = Math.max(shiftVal, MIN_SIBLING_GAP - diff)
    }
    if (shiftVal > 0) {
      node.x += shiftVal
      node.mod += shiftVal
      node.contoursShift += shiftVal
      if (left.index + 1 < node.index)
        evenGapsBetween(left, node) // 使两个节点之间夹着的其他节点均匀分布
    }
  }
}

均匀分布中间夹着的节点

function evenGapsBetween(leftNode: TreeNode, rightNode: TreeNode): void {
  const root = rightNode.root
  const distance = rightNode.x - leftNode.x
  const gap = distance / (rightNode.index - leftNode.index)
  outer: for (let i = leftNode.index + 1; i < rightNode.index; i++) {
    const middleNode = root.children[i]
    const minContoursHeight = Math.min(middleNode.contours.length, rightNode.contours.length)
    for (let j = 1; j < minContoursHeight; j++) {
      const rightContour = middleNode.contours[j].right + middleNode.contoursShift
      const leftContour = rightNode.contours[j].left + rightNode.contoursShift
      const diff = leftContour - rightContour
      if (diff < MIN_SIBLING_GAP) 
        continue outer // 如果以中间的节点为根的子树与以当前节点为根的子树有重叠的话,就不移动它
    }
    const destinationX = leftNode.x + (i - leftNode.index) * gap
    const diff = destinationX - middleNode.x
    if (diff > 0) {
      middleNode.x += diff
      middleNode.mod += diff
      middleNode.contoursShift += diff
    }
  }
}

最后一次先序遍历计算每个节点的实际坐标

function calcFinalX(node: TreeNode, modSum: number = 0) {
  node.x += modSum
  modSum += node.mod
  if (!isEmpty(node.children)) {
    for (const child of node.children) {
      calcFinalX(child, modSum)
    }
  }
}

配合canvas可以画出树的结构

const tree: TreeNode = {
  name: 'o',
  children: [
    {
      name: 'a',
      children: [
        { 
          name: 'b'
        },{ 
          name: 'c',
          children: [
            { 
              name: 'g'
            },{ 
              name: 'h'
            },{ 
              name: 'i'
            }
          ]
        }
      ]
    },{
      name: 'c',
    },{
      name: 'd',
      children: [{
          name: 'e',
        },{
          name: 'f',
        }
      ]
    }
  ]
}

window.addEventListener('load', () => {
  /* 
    <canvas id="canvas" width="1000" height="1000"></canvas>
  */
  const canvas = document.getElementById("canvas") as HTMLCanvasElement
  const ctx = canvas.getContext("2d")
  ctx.textBaseline = 'middle'
  ctx.textAlign = 'center'
  ctx.fillStyle = "green"
  ctx.font = 'normal 24px serif'
  ctx.translate(200, 50)
  setInitialX(tree)
  calcFinalX(tree)
  function drawLines(node: TreeNode) {
    if (!isEmpty(node.children)) {
      for (const child of node.children) {
        ctx.moveTo(node.x, node.y)
        ctx.lineTo(child.x, child.y)
        drawLines(child)
      }
    }
  }
  drawLines(tree)
  ctx.stroke()
  function drawNodes(node: TreeNode) {
    ctx.fillRect(node.x - 20, node.y - 20, 40, 40)
    ctx.save()
    ctx.fillStyle = 'red'
    ctx.fillText(node.name, node.x, node.y)
    ctx.restore()
    if (!isEmpty(node.children)) {
      for (const child of node.children) {
        drawNodes(child)
      }
    }
  }
  drawNodes(tree)
})

pic4.png