1
头图
ps. This article is a translation of the article https://llimllib.github.io/pymag-trees/ , the original text is in python language, the translator uses JavaScript instead, and adds the translator’s details at the end of the article For those who need to draw tree structures such as mind maps, don't miss it.

When I needed to draw some trees for my project, I thought there must be a classic and simple algorithm, but eventually I found something interesting: the layout of the tree is not just an NP-complete problem, in the drawing of the tree The algorithm has a long and interesting history behind it. Next, I will introduce the tree drawing algorithms that have appeared in history one by one, try each of them, and finally implement a tree drawing algorithm with a complete complexity of O(n) .

what is the problem?

img

Give us a tree T , all we have to do is try to draw it so that others can understand it at a glance, every algorithm in this article will give the tree node a (x,y) coordinate, so after the algorithm runs it can Draw it on the screen, or print it out.

To store the results of the tree drawing algorithm, we create a DrawTree data structure to mirror the tree we draw. The only thing we have to assume is that each tree node can iterate over its children. The basic implementation of DrawTree is as follows:

// 代码1
class DrawTree {
    constructor(tree, depth = 0) {
        this.x = -1
        this.y = depth
        this.tree = tree
        this.children = tree.children.map((child) => {
            return new DrawTree(child, depth + 1)
        })
    }
}

As our method becomes more and more complex, the complexity of DrawTree will also increase. Now, it just assigns the x coordinate of each node as -1 , and the y coordinate as its depth in the tree, and stores A reference to the tree node. Then, it recursively creates a DrawTree for each node, thus building a list of that node's children. In this way, we build a DrawTree to represent the tree that will be drawn, and add specific drawing information to each node.

As we implement better algorithms in this article, we'll use principles drawn from everyone's experience to help us build the next better algorithm, although generating a "pretty" treemap is a matter of taste, these Principles will still help us optimize program output.

The story begins with Knuth

What we're going to draw is a special type with the root node at the top, its children below it, and so on, this kind of graph, and the solution to this kind of problem, thanks in large part to Donald Knuth , we'll start with Here he derives the first two principles:

Principle 1: Tree edges should not cross

Principle 2: Nodes of the same depth should be drawn on the same horizontal line, which makes the tree structure clearer

The algorithm of Knuth is simple and fast, but it is only suitable for binary trees, and it will generate some quite deformed graphs. It is a simple in-order traversal of the tree. Set a global count variable to be used as the coordinate of x . The counter will follow The node is incremented, the code is as follows:

// 代码2
let i = 0
const knuth_layout = (tree, depth) => {
    if (tree.left_child) {
        knuth_layout(tree.left_child, depth + 1)
    }
    tree.x = i
    tree.y = depth
    i += 1
    if (tree.right_child) {
        knuth_layout(tree.right_child, depth + 1)
    }
}

img

As you can see in the image above, the tree generated by this algorithm satisfies the principle 1, but it's not very aesthetic, you can see that the graph of Knuth horizontally quickly because it doesn't reuse x coordinates, even though this makes the tree significantly narrower, To avoid wasting space like this, we can derive a third principle:

Principle 3: Trees should be drawn as compactly as possible

a brief review

Before we move on to more advanced algorithms, let's stop and understand some terminology, first, when describing the relationship between our data nodes, we will use the family tree analogy, a node can have children below it, and the left Or there can be sibling nodes on the right, and a parent node on it.

We've discussed in-order traversals of trees, and next we'll see preorder and postorder traversals, three terms you may have seen in a "Data Structures" class a long time ago, but unless you've been You're dealing with trees, otherwise you might have gotten a little fuzzy on them already.

The traversal method only determines the timing of processing on a given node. In-order traversal, which is the Knuth algorithm above, only accepts a binary tree, which means that we will process the left child first, then the current node, and then the right Child nodes, preorder traversal, means that we process the current node first, and then process all its children, and postorder traversal is just the opposite.

Finally, you may already know the concept of the uppercase O symbol, which is used to represent the time complexity of an algorithm. In this article, we will mention it from time to time as a simple tool to judge the running time of an algorithm. Can it be accepted. If an algorithm frequently traverses all children of one of its nodes in its main loop, we call its time complexity O(n^2) , otherwise O(n) , if you want more details, the paper cited at the end of this article contains a lot of information about the time complexity of these algorithms.

bottom up

The two men Charles Wetherell and Alfred Shannon appeared in 1979 , 8 years after Knuth proposed the tree layout algorithm, they introduced some innovative techniques, first, they showed how to generate as compact as possible that satisfies the previous three principles The tree of , by post-order traversal, only needs to maintain the position of the next node of the same depth:

// 代码3
const nexts = []
const minimum_ws = (tree, depth = 0) => {
    if (nexts[depth] === undefined) {
        nexts[depth] = 0
    }
    tree.x = nexts[depth]
    tree.y = depth
    nexts[depth] += 1
    tree.children.forEach((child) => {
        minimum_ws(child, depth + 1)
    })
}

img

Although the tree generated by this algorithm satisfies all our principles, you might also agree that it is ugly to actually draw it, and even in a simple tree like the one above, it is difficult to quickly determine the relationship between tree nodes , and the whole tree seems to be crowded together. It's time to introduce the next principle, which will help optimize Knuth trees and minimum-width trees:

Principle 4: The parent node should be in the middle of the child node

So far, we have been able to use a very simple algorithm to draw the tree because we don't really consider each node itself, we rely on a global counter to prevent nodes from overlapping, in order to satisfy the principle that the parent node is in the middle of the child node, we need to consider Each node's own context state, then some new strategy is needed.

The first strategy introduced by Wetherell and Shannon is to build the tree from the bottom through a postorder traversal of the tree, not top to bottom like code 2, or through the middle like code 3, as long as you start with this Looking at the tree this way, then centering the parent node is a simple operation, just split the x coordinates of its child nodes in half.

img

But we have to remember that when building the right side of the tree, pay attention to the left side of the tree, as shown in the image above, the right side of the tree is pushed to the right to accommodate the left side, to achieve this separation, Wetherell and Shannon at The base of code 2 maintains the next available point via an array, but only uses the next available position if centering the parent tree would cause the right side of the tree to overlap the left side.

Mods and Rockers

Before we look at more code, let's take a closer look at the result of building the tree from the bottom up, if the node is a leaf, we'll give it the next available x coordinate, and if it's a branch, center it on Above its children, however, if centering the branch would cause it to collide with another part of the tree, we need to move it correctly enough to avoid the collision.

When we move the branch correctly, we need to move all of its children, otherwise we will lose the central parent we've been struggling to maintain. It's easy to write a function that moves the branch and its subtrees correctly:

// 代码4
const move_right = (branch, n) => {
    branch.x += n
    branch.children.forEach((child) => {
        move_right(child, n)
    })
}

The above function works, but there is a problem, if we use this function to move a subtree to the right, we will do recursion (move tree) in recursion (place tree node), which means our algorithm is very inefficient , the time complexity is O(n²) .

In order to solve this problem, we will add a mod attribute to each node, when we reach a branch we need to move n spaces correctly, we will add x coordinates to n and assign it to the mod attribute, and continue happily Execute the layout algorithm, since we're moving from the bottom up, we don't need to worry about collisions at the bottom of the tree (which we've proven they don't), we'll wait a bit to move them correctly.

Once the first tree traversal has been performed, we start the second traversal process, moving the branch that needs to be moved correctly, since we've only traversed each node once, and doing just arithmetic, we can be sure of it The time complexity is the same as the first time, which is O(n) , so the two combined are still O(n) .

The following code demonstrates centering the parent node and using the mod attribute to improve the efficiency of the code:

// 代码5
class DrawTree {
    constructor(tree, depth = 0) {
        this.x = -1;
        this.y = depth;
        this.tree = tree;
        this.children = tree.children.map((child) => {
            return new DrawTree(child, depth + 1);
        });
        this.mod = 0;
    }
}
const setup = (tree, depth = 0, nexts = {}, offset = {}) => {
    tree.children.forEach((child) => {
        setup(child, depth + 1, nexts, offset);
    });
    tree.y = depth;
    let place;
    let childrenLength = tree.children.length
    if (childrenLength <= 0) {
        place = nexts[depth] || 0;
        tree.x = place;
    } else if (childrenLength === 1) {
        place = tree.children[0].x - 1;
    } else {
        let s = tree.children[0].x + tree.children[1].x;
        place = s / 2;
    }
    offset[depth] = Math.max(offset[depth] || 0, (nexts[depth] || 0) - place);
    if (childrenLength > 0) {
        tree.x = place + offset[depth];
    }
    if (nexts[depth] === undefined) {
        nexts[depth] = 0;
    }
    nexts[depth] += 2;
    tree.mod = offset[depth];
};
const addmods = (tree, modsum = 0) => {
    tree.x = tree.x + modsum;
    modsum += tree.mod;
    tree.children.forEach((child) => {
        addmods(child, modsum);
    });
};
const layout = (tree) => {
    setup(tree);
    addmods(tree);
    return tree;
};

Tree as Block

While it does produce good results in many cases, code 5 also produces some weird trees like the one above (sorry, the graph has been lost in the years), another understanding of the Wetherell-Shannon algorithm The difficulty is that the same tree structure, when placed in different parts of the tree, may draw different structures. To solve this problem, we will borrow a principle from the papers of Edward Reingold and John Tilford :

Principle 5: No matter where the same subtree is in the tree, the result of drawing should be the same

Although this will expand our draw width, this principle will help them convey more information. It also helps to simplify bottom-up traversal, e.g. once we have calculated the x coordinates of a subtree, we just need to move it left or right as a unit.

The following is the general process of the algorithm of code 6:

  • postorder traversal of the tree
  • If a node is a leaf node, then give it a x coordinate with the value 0
  • Otherwise, move its right subtree as close to the left subtree as possible without conflicting

    • Using the same mod method as before, move the tree in O(n) time
  • Place a node in the middle of its children
  • Traverse the tree one more time and add the accumulated mode value to the x coordinate

This algorithm is simple, but to execute it, we need to introduce some complexity.

contour

img

The outline of a tree refers to a list of the largest or smallest coordinates on one side of the tree. As shown above, there are two trees that overlap each other. If we go along the left side of the left tree and take the smallest x coordinate for each layer, we can get To get [1, 1, 0] , we call it the left contour of the tree. If we go along the right side and take the x coordinate of the far right of each layer, we can get [1, 1, 2] , which is the right contour of the tree.

In order to find the left contour of the right tree, we also take the x coordinates of the leftmost node of each layer, and we can get [1, 0, 1] . At this time, we can see that the contour has an interesting feature, that is, not all nodes are connected by a parent-child relationship. 0 on the second layer is not the parent node of 1 on the third layer.

If I were to connect the two trees according to code 6, we can find the right contour of the left tree, and the left contour of the right tree, then we can easily find what we need to push the right tree to the right so that it doesn't The minimum value that overlaps the tree on the left, the following code is a simple implementation:

// 代码7
const lt = (a, b) => {
    return a < b
}
const gt = (a, b) => {
    return a > b
}
// [a, b, c],[d, e, f] => [[a, d], [b, e], [c, f]]
const zip = (a, b) => {
    let len = Math.min(a.length, b.length)
    let arr = []
    for(let i = 0; i < len; i++) {
    arr.push([a[i], b[i]])
    }
    return arr
}
const push_right = (left, right) => {
    // 左边树的右轮廓
    let wl = contour(left, lt)
    // 右边树的左轮廓
    let wr = contour(right, gt)
    let res = zip(wl, wr)
    let arr = res.map((item) => {
        return item[0] - item[1]
    })
    return Math.max(...arr) + 1
}
// 获取一棵树的轮廓
const contour = (tree, comp, level = 0, cont = null) => {
    // 根节点只有一个,所以直接添加
    if (cont === null) {
        cont = [tree.x]
    } else if (cont.length < level + 1) {// 该层级尚未添加,直接添加
        cont.push(tree.x)
    } else if (comp(cont[level], tree.x)) {// 该层级已经有值,所以进行比较
        cont[level] = tree.x
    }
    tree.children.forEach((child) => {
        contour(child, comp, level + 1, cont)
    })
    return cont
}

If we run the push_right method with the two trees above, we can get the right contour of the tree on the left [1, 1, 2] and the left contour of the tree on the right [1, 0, 1] , then compare these lists to find the maximum space between them, and add a space for padding. For the two trees in the image above, pushing the right tree two spaces to the right will prevent it from overlapping the left tree.

new thread

Using code 7, we can correctly find the value of how far we need to push the right tree, but in order to do this, we need to scan each node of the two subtrees to get the contour we need, because of the time complexity it takes Most likely O(n^2) , Reingold and Tilford introduced a confusing concept for this, called threads, which have a completely different meaning than threads used for parallel execution.

img

Threading is a method that reduces the time required to scan a subtree contour by creating links between nodes on the contour (if one of the nodes is not already a child of the other), as shown above, the dotted line represents a Threads, while solid lines indicate parent-child relationships.

We can also take advantage of the fact that if one tree is deeper than the other, we just need to go down to the shorter tree. Anything deeper does not require the two trees to be separated since there can be no conflict between them.

Using threads and traversing to a shorter tree, we can get the outline of a tree and set up threads in linear time complexity using the code below.

// 代码8
const nextright = (tree) => {
    if (tree.thread) {
        return tree.thread
    } else if (tree.children.length > 0) {
        return tree.children[tree.children.length - 1]
    } else {
        return null
    }
}
const nextleft = (tree) => {
    if (tree.thread) {
        return tree.thread
    } else if (tree.children.length > 0) {
        return tree.children[0]
    } else {
        return null
    }
}
const contour = (left, right, max_offset = 0, left_outer = null, right_outer = null) => {
    if (left_outer === null) {
        left_outer = left
    }
    if (right_outer === null) {
        right_outer = right
    }
    if (left.x - right.x > max_offset) {
        max_offset = left.x - right.x
    }
    let lo = nextleft(left)
    let li = nextright(left)
    let ri = nextleft(right)
    let ro = nextright(right)
    if (li && ri) {
        return contour(li, ri, max_offset, lo, ro)
    }
    return max_offset
}

It is obvious to see that this process only visits two nodes at each level in the subtree being scanned.

combine them

Code 8 computes the contours concisely and quickly, but it doesn't work with the mod way we discussed earlier, because a node's actual x coordinate is the node's x value plus the 0623c25116e8bc value on the path from it to the root node The sum of all mod values. To solve this problem, we need to add some complexity to the contour algorithm.

The first thing we need to do is to maintain two additional variables, the sum of the mod mod on the right subtree, which are used to calculate the actual position of each node on the contour. is required so we can check if it collides with a node on the other side:

// 代码9
const contour = (left, right, max_offset = null, loffset = 0, roffset = 0, left_outer = null, right_outer = null) => {
    let delta = left.x + loffset - (right.x + roffset)
    if (max_offset === null  || delta > max_offset) {
        max_offset = delta
    }
    if (left_outer === null) {
        left_outer = left
    }
    if (right_outer === null) {
        right_outer = right
    }
    let lo = nextleft(left_outer)
    let li = nextright(left)
    let ri = nextleft(right)
    let ro = nextright(right_outer)
    if (li && ri) {
        loffset += left.mod
        roffset += right.mod
        return contour(li, ri, max_offset, loffset, roffset, lo, ro)
    }
    return [li, ri, max_offset, loffset, roffset, left_outer, right_outer]
}

The other thing we want to do is return the current state of the function on exit so we can set the appropriate offset on the thread node. With this information, we can look at this function, which uses code 8 to get the two trees as close together as possible:,

// 代码10
const fix_subtrees = (left, right) => {
    let [li, ri, diff, loffset, roffset, lo, ro]  = contour(left, right)
    diff += 1
    diff += (right.x + diff + left.x) % 2
    right.mod = diff
    right.x += diff
    if (right.children.length > 0) {
        roffset += diff
    }
    if (ri && !li) {
        lo.thread = ri
        lo.mod = roffset - loffset
    } else if (li && !ri) {
        ro.thread = li
        ro.mod = loffset - roffset
    }
    return (left.x + right.x) / 2
}

After we run the contouring process, we add 1 to the maximum difference between the left and right trees, so they don't collide, and if the middle point is odd, then add 1, which makes it easier for us to test - All nodes have integer x coordinates without loss of precision.

Then we move the right tree by the corresponding distance to the right, remember that the reason we add diff to the x coordinate and also save diff to the mod attribute is that the mod value is only used for nodes below the current node. If the right subtree has more than one child, we add the difference to roffset , because all the children of the right node have to move that far to the right.

If the left side of the tree is deeper than the right, or vice versa, we need to set up a thread. We just check if the node pointer on one side is advancing further than the node pointer on the other side, and if so, set the thread from the outside of the shallow tree to the inside of the deep tree.

In order to properly handle the mod value we mentioned earlier, we need to set a special mod value on the thread node, and since we've updated our right offset value to reflect the movement of the right tree, all we need to do is set the The thread node's mod value is set to the difference between the offset of the deeper tree and itself.

Now that we have the proper code to find the outline of the tree and place the two trees as close together as possible, we can easily implement the algorithm described above. I'll render the rest of the code uncommented:

// 代码11
const layout = (tree) => {
    return addmods(setup(tree))
}
const addmods = (tree, mod = 0) => {
    tree.x += mod
    tree.children.forEach((child) => {
        addmods(child, mod + tree.mod)
    })
    return tree
}
const setup = (tree, depth = 0) => {
    if (tree.children.length === 0) {
        tree.x = 0
        return tree
    } else if (tree.children.length === 1) {
        tree.x = setup(tree.children[0], depth + 1).x
        return tree
    }
    left = setup(tree.children[0], depth + 1)
    right = setup(tree.children[1], depth + 1)
    tree.x = fix_subtrees(left, right)
    return tree
}

Extend to N-ary tree

Now that we finally have an algorithm for drawing a binary tree that satisfies all our principles, looks good in most cases, and has linear time complexity, it is natural to think of how to extend it to support arbitrarily many children tree of nodes. If you've been seeing this, you're probably wondering if we just need to apply the wonderful algorithm we just defined to all the children of the node.

Extend the previous algorithm to work on polytrees:

  • postorder traversal of the tree
  • If the node is a leaf node, then give it a x coordinate with a value of 0
  • Otherwise, iterate over its children, placing their children as close as possible to their left siblings
  • Place the parent node in the middle of its leftmost and rightmost children

img

This algorithm works and is fast, but there is a problem, it fills all the subtrees of the node to the left as far as possible, if the rightmost node collides with the leftmost node, then the middle tree will be filled to the right. Let's solve this problem by adopting the last principle of drawing trees:

Principle 6: Children of the same parent node should be evenly spaced

To draw an N-ary tree symmetrically and quickly, we need to use all the tricks we've learned so far, plus some new ones, thanks to a recent paper by Christoph Buchheim et al. We already have All the knowledge reserves to do this and still be able to do it in linear time.

Modifying the above algorithm so that it satisfies principle 6, we need a way to separate the trees between two large conflicting trees, the simplest way is to divide the available space by the tree whenever two trees conflict , then move each tree so that it is separated from its neighbors by that distance. For example, in the picture above, there is a distance n between the big trees on the right and the left, and there are 3 trees between them. If we put the distance between the first tree and the leftmost distance n/3 , the next one and this An interval n/3 , and so on, yields a tree that satisfies Principle 6.

Every simple algorithm we've seen in this article so far, we've found its shortcomings, and this one is no exception, if we had to move all the trees between every two conflicting trees, we An operation of O(n^2) complexity will be introduced into the algorithm.

The solution to this problem is similar to the shift problem we solved earlier. We introduce mod , instead of moving each subtree in the middle every time there is a conflict, we save the value of the tree we need to move in the middle, and after placing the Apply after all nodes.

In order to correctly find the distance we need to move the middle node, we need to be able to find the number of trees between two conflicting nodes, when we only have two trees, it is clear that all conflicts occur in the left and right trees In between, when there are arbitrary trees, it becomes a challenge to figure out which tree caused the conflict.

To address this challenge, we'll introduce a default ancestor variable and add another member to the tree's data structure, let's call it ancestor , ancestor either points to itself or the root of the tree it belongs to, when we need to find a When the node belongs to which tree, use it if this property is set, otherwise use default_ancestor .

When we place the first subtree of a node, we point default_ancestor to this subtree, assuming that if there is a conflict in the next tree, it must happen with the first tree, when we place the second tree After that, we distinguish two cases, if the second subtree is not as deep as the first one, we traverse its right contour and set the ancestor attribute to the root of the second tree, otherwise, the second tree is bigger than the first tree deep, which means anything that conflicts with the next tree will be placed in the second tree, so we just set default_ancestor to point to it.

Without further ado, let's take a look at a tree drawing algorithm proposed by Buchheim with a time complexity of O(n) :

Please see the next section :)

Summarize

In this article, I omitted some things because I felt it was more important for the final algorithm to try and present a logical progression, rather than reloading the article with pure code. If you want to see more details, or want to know the data structure of the trees used in the various code listings, you can go to https://github.com/llimllib/pymag-trees/ This repository downloads the code for each algorithm Source code, some basic tests, and the code used to generate the tree images for this article.

quote

1 K. Marriott, NP-Completeness of Minimal Width Unordered Tree Layout, Journal of Graph Algorithms and Applications, vol. 8, no. 3, pp. 295-312 (2004). http://www.emis.de/journals/JGAA/accepted/2004/MarriottStuckey2004.8.3.pdf

2 D. E. Knuth, Optimum binary search trees, Acta Informatica 1 (1971)

3 C. Wetherell, A. Shannon, Tidy Drawings of Trees, IEEE Transactions on Software Engineering. Volume 5, Issue 5

4 E. M. Reingold, J. S Tilford, Tidier Drawings of Trees, IEEE Transactions on Software Engineering. Volume 7, Issue 2

5 C. Buchheim, M. J Unger, and S. Leipert. Improving Walker's algorithm to run in linear time. In Proc. Graph Drawing (GD), 2002. http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.16.8757

Translator's personal performance

Detailed breakdown of the algorithm

Although the author has spent so much time to the final algorithm, but if it is released directly, there is a high probability that it is still incomprehensible, so the translator will try to break it .

The node classes are as follows, be sure to take a closer look at right() and left() methods:

// 树节点类
class DrawTree {
    constructor(tree, parent = null, depth = 0, number = 1) {
        // 节点名称
        this.name = tree.name;
        // 坐标
        this.x = -1;
        this.y = depth;
        // 子节点
        this.children = tree.children.map((child, index) => {
            return new DrawTree(child, this, depth + 1, index + 1);
        });
        // 父节点
        this.parent = parent;
        // 线程节点,也就是指向下一个轮廓节点
        this.thread = null;
        // 根据左兄弟定位的x与根据子节点中间定位的x之差
        this.mod = 0;
        // 要么指向自身,要么指向所属树的根
        this.ancestor = this;
        // 记录分摊偏移量
        this.change = this.shift = 0;
        // 最左侧的兄弟节点
        this._lmost_sibling = null;
        // 这是它在兄弟节点中的位置索引 1...n
        this.number = number;
    }

    // 关联了线程则返回线程节点,否则返回最右侧的子节点,也就是树的右轮廓的下一个节点
    right() {
        return (
            this.thread ||
            (this.children.length > 0
             ? this.children[this.children.length - 1]
             : null)
        );
    }

    // 关联了线程则返回线程节点,否则返回最左侧的子节点,也就是树的左轮廓的下一个节点
    left() {
        return (
            this.thread || (this.children.length > 0 ? this.children[0] : null)
        );
    }

    // 获取前一个兄弟节点
    left_brother() {
        let n = null;
        if (this.parent) {
            for (let i = 0; i < this.parent.children.length; i++) {
                let node = this.parent.children[i];
                if (node === this) {
                    return n;
                } else {
                    n = node;
                }
            }
        }
        return n;
    }

    // 获取同一层级第一个兄弟节点,如果第一个是自身,那么返回null
    get_lmost_sibling() {
        if (
            !this._lmost_sibling &&
            this.parent &&
            this !== this.parent.children[0]
        ) {
            this._lmost_sibling = this.parent.children[0];
        }
        return this._lmost_sibling;
    }

    // 同一层级第一个兄弟节点
    get leftmost_sibling() {
        return this.get_lmost_sibling();
    }
}

Entering the first recursion, the processing is as follows:

1. The current node is a leaf node and has no left sibling, and x is set to 0

2. The current node is a leaf node and has a left brother, and x is the x of the left brother plus the spacing, that is, positioning according to the left brother

3. The current node is not a leaf node and has no left sibling, x is the x of the first child node plus the x of the last child node divided by 2, that is, positioning according to the child node

4. The current node is not a leaf node and has a left brother, x is the x of the left brother plus the distance, and mod is set to the difference between the positioning of x relative to the child node

// 第一次递归
const firstwalk = (v, distance = 1) => {
    if (v.children.length === 0) {
        // 当前节点是叶子节点且存在左兄弟节点,则其x坐标等于其左兄弟的x坐标加上间距distance
        if (v.leftmost_sibling) {
            v.x = v.left_brother().x + distance;
        } else {
            // 当前节点是叶节点无左兄弟,那么x坐标为0
            v.x = 0;
        }
    } else {
        // 后序遍历,先递归子节点
        v.children.forEach((child) => {
            firstwalk(child);
        });
        // 子节点的中点
        let midpoint =
            (v.children[0].x + v.children[v.children.length - 1].x) / 2;
        // 左兄弟
        let w = v.left_brother();
        if (w) {
            // 有左兄弟节点,x坐标设为其左兄弟的x坐标加上间距distance
            v.x = w.x + distance;
            // 同时记录下偏移量(x坐标与子节点的中点之差)
            v.mod = v.x - midpoint;
        } else {
            // 没有左兄弟节点,x坐标直接是子节点的中点
            v.x = midpoint;
        }
    }
    return v;
};

The second recursion adds the mod value to x , so that the parent node is still centered on the child node:

// 第二次遍历
const second_walk = (v, m = 0, depth = 0) => {
    // 初始x值加上所有祖宗节点的mod值(不包括自身的mod)
    v.x += m;
    v.y = depth;
    v.children.forEach((child) => {
        second_walk(child, m + v.mod, depth + 1);
    });
};

The whole process is two recursion:

const buchheim = (tree) => {
    let dt = firstwalk(tree);
    second_walk(dt);
    return dt;
};

Node position after first recursion:

image-20220318102931215.png

Node position after second recursion:

image-20220318102949466.png

The subtrees with obvious conflicts can be seen to be G and P subtrees. The subtree P needs to move a certain distance to the right. How to calculate this distance?

1. Enter the second layer of subtree G and P , find the rightmost child node of subtree G in this layer, which is F , and find the leftmost child node of subtree P in this layer, which is I , Compare their x coordinates, the original x value plus the sum of the mod values of their ancestor nodes, and find that there is no intersection, so enter the next layer.

2. Enter the third layer, and also find the rightmost child node of subtree G in this layer, which is E , and the leftmost child node of subtree P in this layer, which is J . Compare their x and find that There is an intersection, this difference plus the node interval distance is the distance that the subtree P needs to move to the right

3. Repeat the above until the bottom layer.

So how to find the leftmost or rightmost node of each layer as quickly as possible? Of course, direct recursion is possible, but the time complexity is nonlinear, so the concept of thread mentioned above is introduced.

The G node in the above figure is used as an example to introduce the connection process of the thread. After returning from its child node C , because C has no left brother, it will not be processed. Enter the F node recursively, and then process the F node after returning from the F node, which exists. Left brother C , since every tree has inside and outside, we set four pointers:

image-20220318104203798.png

vInnerLeft is the left sibling node of the current node, and vOuterLeft is the leftmost sibling node of the current node. Of course, for the F node, these two pointers both point to the C node, and both vInnerRight and vOuterRight initially point to the current node.

Next we set the thread from the outside of the shallow tree to the inside

1. Because C and F nodes have child nodes, this layer still cannot determine which tree is deep and which is shallow, so it moves down one layer and updates four pointers at the same time. Here, the left() or right() method:

image-20220318105921843.png

There are four pointers here, how to judge whether there is a next layer, because we want to check the node conflict is based on the comparison of the inner nodes of the two trees, so here we only need to check the two inner node pointers to determine whether there is a next layer One layer, we only need to go to the shallower tree to stop, and the deeper nodes of the other tree will not conflict, so we can judge whether both vInnerLeft.right() and vInnerRight.left() exist.

2. After moving down one layer, it is found that the leaf node of F has been reached. Then we will judge and repeat our principle:

Set threads from the outside of the shallow tree to the inside of the deep tree

The shallow tree is the F subtree, and the deep tree is the C subtree, then set it from the outside of F to the inside of C , that is, connect the E node and the A node through a thread.

The specific judgment rules are:

2.1. If vInnerLeft.right() node (that is, the next node of the right contour of the tree where the B node is located, it exists here, it is the A node) exists, and vOuterRight.right() node (that is, the next node of the right contour of the tree where the E node is located) , does not exist here) does not exist, then set the thread thread attribute on the vOuterRight node to point to the vInnerLeft.right() node, which just meets this condition, so E.thread points to the A node.

2.2. Otherwise, if vOuterLeft.left() node (that is, the next node of the left contour of the tree where the B node is located, which exists here, is the A node) does not exist, and vInnerRight.left() node (that is, the next node of the left contour of the tree where the D node is located) , which does not exist here) exists, then set the thread thread attribute on the vOuterLeft node to point to the vInnerRight.left() node, obviously the conditions are not met here.

For all other nodes, use this method to judge, and finally the thread node connection on this tree is:

image-20220318112225285.png

Because we are traversing the tree in post-order, the lower-level node threads are connected earlier. For example, when processing the O node, the I and J nodes will be connected, then when the P node is processed later, although it also goes to the I node, However, because the I node has thread nodes, it is not a "leaf node" to a certain extent, so I will no longer be connected to other nodes.

// 第一次递归
const firstwalk = (v, distance = 1) => {
    if (v.children.length === 0) {
        // ...
    } else {
        v.children.forEach((child) => {
            firstwalk(child);
            apportion(child);// ++
        });
        // ...
    }
    // ...
}

const apportion = (v) => {
    let leftBrother = v.left_brother();
    // 存在左兄弟才处理
    if (leftBrother) {
        // 四个节点指针
        let vInnerRight = v;// 右子树左轮廓
        let vOuterRight = v;// 右子树右轮廓
        let vInnerLeft = leftBrother;// 当前节点的左兄弟节点,左子树右轮廓
        let vOuterLeft = v.leftmost_sibling;// 当前节点的最左侧的兄弟节点,左子树左轮廓

        // 一直遍历到叶子节点
        while(vInnerLeft.right() && vInnerRight.left()) {
            // 更新指针
            vInnerLeft = vInnerLeft.right()
            vInnerRight = vInnerRight.left()
            vOuterLeft = vOuterLeft.left()
            vOuterRight = vOuterRight.right()
        }

        // 将线程从浅的树的外侧设置到深的树的内侧
        if (vInnerLeft.right() && !vOuterRight.right()) {
            vOuterRight.thread = vInnerLeft.right();
        } else {
            if (vInnerRight.left() && !vOuterLeft.left()) {
                vOuterLeft.thread = vInnerRight.left();
            }
        }
    }
};

After the thread nodes are connected, then you can judge whether the two trees intersect according to the outline. Also, because we are traversing in post-order, when judging whether there is a conflict in a subtree, the node thread below it must have been connected, and you can directly use.

The logic of judging according to the outline is also placed in the apportion method:

// 第一次递归
const firstwalk = (v, distance = 1) => {
    if (v.children.length === 0) {
        // ...
    } else {
        v.children.forEach((child) => {
            firstwalk(child);
            apportion(child, distance);// distance++
        });
        // ...
    }
    // ...
}

const apportion = (v, distance) => {
    let leftBrother = v.left_brother();
    if (leftBrother) {
        // ...

        // 从当前节点依次往下走,判断是否和左侧的子树发生冲突
        while(vInnerLeft.right() && vInnerRight.left()) {
            // ...

            // 左侧节点减右侧节点
            let shift = vInnerLeft.x + distance - vInnerRight.x 
            if (shift > 0) {
                // 大于0说明存在交叉,那么右侧的树要向右移动
                move_subtree(v, shift)
            }
        }

        // ...
    }
}

// 移动子树
const move_subtree = (v, shift) => {
    v.x += shift// 自身移动
    v.mod += shift// 后代节点移动
}

Taking node P as an example, the process is as follows:

image-20220318154717319.png

vInnerLeft.right() exists ( H.right()=F ), vInnerRight.left() exists ( P.left()=I ), so move down one level:

image-20220318154901212.png

Comparing the difference between the x coordinates of the F and I nodes, it can be found that they do not conflict, so continue to the next layer:

image-20220318155104532.png

This comparison will find that E and J nodes conflict, so the subtree P needs to move a certain distance to the right as a whole.

Of course, the above code is problematic, because the real final x coordinate of a node is to add the mod value of all its ancestors, so we add four variables to accumulate mod value:

const apportion = (v, distance) => {
    if (leftBrother) {
        // 四个节点指针
        // ...

        // 累加mod值,它们的父节点是同一个,所以往上它们要加的mod值也是一样的,那么在后面shift值计算时vInnerLeft.x + 父节点.mod - (vInnerRight.x + 父节点.mod),父节点.mod可以直接消掉,所以不加上面的祖先节点的mod也没关系
        let sInnerRight = vInnerRight.mod;
        let sOuterRight = vOuterRight.mod;
        let sInnerLeft = vInnerLeft.mod;
        let sOuterLeft = vOuterLeft.mod;

        // 从当前节点依次往下走,判断是否和左侧的子树发生冲突
        while (vInnerLeft.right() && vInnerRight.left()) {
            // ...

            // 左侧节点减右侧节点,需要累加上mod值
            let shift = vInnerLeft.x + sInnerLeft + distance - (vInnerRight.x + sInnerRight);
            if (shift > 0) {
                // ...
                // v.mod,也就是节点P.mod增加了shift,sInnerRight、sOuterRight当然也要同步增加
                sInnerRight += shift;
              sOuterRight += shift;
            }

            // 累加当前层节点mod
            sInnerRight += vInnerRight.mod;
            sOuterRight += vOuterRight.mod;
            sInnerLeft += vInnerLeft.mod;
            sOuterLeft += vOuterLeft.mod;
        }
        // ...
    }
};

The effect is as follows:

image-20220318155814623.png

But this is still problematic, why? For example, for node E , it adds the mod value of nodes F and H , but the problem is that the H node is not the ancestor of the E node, they just pass through a thread virtual line The connection is only generated. The actual value to be added should be the mod value of the nodes F and G . What should we do with this mod

image.png

Then for node A , the real value of mod to be accumulated should be:

B.mod + C.mod + G.mod = 1 + 2 + 3 = 6

But because of the thread connection, the actual accumulated value of mod becomes:

E.mod + F.mod + H.mod = 0 + 4 + 0 = 4

2 , it would be beautiful to set a special mod value on the thread nodes E and H to make up for the difference. Anyway, because there are no child nodes under them, no matter what you set for them None of the mod values have any effect. So how is this special mod value calculated? It's very simple. For example, when the F node is processed for the first time, it has the left node C , so the thread connection judgment of the nodes below them is carried out. Because they all have children, they move down one layer. At this time, the F subtree is over. , C subtree is not, at this time satisfies vInnerLeft.right() && !vOuterRight.right() conditions will E connected to A , for C and F , they are the same ancestor node, the ancestor node mod values do not control, then for A node Say, the value of mod that it really wants to accumulate is mod , and the value of B.mod + C.mod that it will add according to the thread B.mod + C.mod - F.mod sInnerLeft - sOuterRight E.mod + F.mod E.mod :

// 将线程从浅的树的外侧设置到深的树的内侧
if (vInnerLeft.right() && !vOuterRight.right()) {
    vOuterRight.thread = vInnerLeft.right();
    // 修正因为线程影响导致mod累加出错的问题,深的树减浅的树
    vOuterRight.mod += sInnerLeft - sOuterRight// ++
} else {
    if (vInnerRight.left() && !vOuterLeft.left()) {
        vOuterLeft.thread = vInnerRight.left();
        vOuterLeft.mod += sInnerRight - sOuterLeft// ++
    }
}

At this point the effect is as follows:

image.png

There is no conflict here, but the position of H should be centered. Obviously, it needs to move to the right. How much? The subtree P moves shift distance to the right, so this distance needs to be divided into three equal parts: G , H , P On the distance between nodes, assuming that the number of subtrees between two conflicting subtrees is n , then it is shift / (n + 1) , and the subtree H can be moved to the right by this distance.

In other words, we need to find out which two subtrees are in conflict before we can correct the tree between them. As can be seen in the above figure, the conflicting nodes are E and J . For the J node, we must know that it belongs to the current top-level subtree. The tree is P , then as long as we can find the tree to which the E node belongs, we can see at a glance that it is the G node, but the code has no eyes, so we can directly find it through upward recursion, but we can't do it for linear time complexity. .

We set a ancestor attribute for each node, which initially points to itself. For the E node, although it belongs to the vOuterRight node in the conflict judgment, it belongs to the vInnerLeft node in the tree to which it belongs, so in the thread connection stage, we By the way, you can set the ancestor of the vOuterRight node of each layer to point to the current top-level node v , but sometimes this point may not meet P ancestor N For the E node, its ancestor points to its parent node F , and what we need is G , so we set another variable default_ancestor , when a node's ancestor points to not meet our requirements, use default_ancestor node, default_ancestor initially points to the first child node of a node, and then updates the pointer when returning from each child node. If the previous child node is not as deep as the latter child node, then default_ancestor is updated to point to the latter child node, because If there is a subtree on the right that conflicts with the left, it must be the one with the deeper one.

const firstwalk = (v, distance = 1) => {
    if (v.children.length === 0) {
        // ...
    } else {
        let default_ancestor = v.children[0]// ++初始指向第一个子节点
        v.children.forEach((child) => {
            firstwalk(child);
            default_ancestor = apportion(child, distance, default_ancestor);// 递归完每一个子节点都更新default_ancestor
        });
    }
}

const apportion = (v, distance, default_ancestor) => {
    let leftBrother = v.left_brother();
    if (leftBrother) {
      // ...
      while (vInnerLeft.right() && vInnerRight.left()) {
          // ...
          // 节点v下面的每一层右轮廓节点都关联v
          vOuterRight.ancestor = v;// ++
          // ...
      }
        // ...
      if (vInnerLeft.right() && !vOuterRight.right()) {
        // ...
      } else {
        // ...
        default_ancestor = v// ++,前面的节点没有当前节点深,那么default_ancestor指向当前节点
      }
    }
    return default_ancestor;// ++
}

Then we can find the root node to which the conflicting node in the left tree belongs:

const apportion = (v, distance, default_ancestor) => {
    let leftBrother = v.left_brother();
    if (leftBrother) {
      // ...
      while (vInnerLeft.right() && vInnerRight.left()) {
          // ...
          let shift = vInnerLeft.x + sInnerLeft + distance - (vInnerRight.x + sInnerRight);
          if (shift > 0) {
              // 找出vInnerLeft节点所属的根节点
              let _ancestor = ancestor(vInnerLeft, v, default_ancestor)// ++
              move_subtree(v, shift);
              // ...
            }
            // ...
      }
      // ...
    }
    return default_ancestor;// ++
}

// 找出节点所属的根节点
const ancestor = (vInnerLeft, v, default_ancestor) => {
    // 如果vInnerLeft节点的ancestor指向的节点是v节点的兄弟,那么符合要求
    if (v.parent.children.includes(vInnerLeft.ancestor)) {
        return vInnerLeft.ancestor;
    } else {
        // 否则使用default_ancestor指向的节点
        return default_ancestor
    }
}

After finding out which two trees are in conflict, we can find the subtree between the two trees, and then assign shift to them, but we still can't traverse them directly to make corrections, yes, or to maintain Linear time complexity, so you can only save the amortized data to the root nodes of the two conflicting trees first, and then wait for all their sibling nodes to complete recursively and then set it once.

const firstwalk = (v, distance = 1) => {
    if (v.children.length === 0) {
        // ...
    } else {
        let default_ancestor = v.children[0]
        v.children.forEach((child) => {
            firstwalk(child);
            default_ancestor = apportion(child, distance, default_ancestor);
        });
        // 将shift分摊添加到中间节点的x及mod值上
        execute_shifts(v)// ++
        // ...
    }
}

const apportion = (v, distance, default_ancestor) => {
    let leftBrother = v.left_brother();
    if (leftBrother) {
      // ...
      while (vInnerLeft.right() && vInnerRight.left()) {
          // ...
          if (shift > 0) {
              let _ancestor = ancestor(vInnerLeft, v, default_ancestor)
              move_subtree(_ancestor, v, shift);// ++
              // ...
            }
            // ...
      }
      // ...
    }
    return default_ancestor;// ++
}

const execute_shifts = (v) => {
    let change = 0
    let shift = 0
    // 从后往前遍历子节点
    for(let i = v.children.length - 1; i >= 0; i--) {
      let node = v.children[i]
      node.x += shift
      node.mod += shift

      change += node.change// change一般为负值
      shift += node.shift + change// 越往左,节点添加的shift值越小
    }
}

const move_subtree = (leftV, v, shift) => {
    let subTrees = v.number - leftV.number// 索引相减,得到节点之间被分隔的数量
    let average = shift / subTrees// 平分偏移量
    v.shift += shift// 完整的shift值添加到v节点的shift属性上
    v.change -= average// v左边的节点从右往左要添加的偏移量是递减的,所以是加上负的average
    leftV.change += average// v.change减了average,为了不影响leftV左侧的节点,这里需要恢复

    // ...
};

What this process, the next assumption FIG Example view P node final calculated shift = 3 , then P.number - G.number = 4 - 1 = 3 , assessed value of the intermediate node 3 / 3 = 1 , node G to P node between the distance to be equal, then, H need to move to the right 1 , H2 to move 1 + 1 , so that their coordinates are 1,3,5,7 , arithmetic sequence, the spacing is equal, if there are more nodes, and so on, because the node to the right moves its own 1 , and is also replaced by the previous n nodes Pushing n * 1 to the right, we save these two values on nodes G and P :

image.png

Then execute the execute_shifts method to traverse the child nodes of 0623c25116f8eb from back to Q :

1. change=0 , shift=0 , first update last node P2 : P2.x and P2.mod plus shift , namely plus 0 , update change : change + P2.change = 0 + 0 = 0 , update shift : shift + P2.shift + change = 0 + 0 + 0 = 0

2. Update P nodes: P.x and P.mod plus shift , that is, add 0 , update change : change + P.change = 0 + (-1) = -1 , shift + P.shift + change = 0 + 3 + (-1) = 2 shift

3. Update H2 nodes: H2.x and H2.mod plus shift , that is, add 2 , update change : change + H2.change = -1 + 0 = -1 , shift + H2.shift + change = 2 + 0 + (-1) = 1 shift

4. Update H node: H.x and H.mod plus shift , that is, add 1 , update change : change + H.change = -1 + 0 = -1 , shift + H.shift + change = 1 + 0 + (-1) = 0 shift

5. Update G nodes: G.x and G.mod plus shift , that is, add 0 , update change : change + G.change = -1 + 1 = 0 , update shift : shift + G.shift + change = 0 + 0 + 0 = 0

6. Update G0 nodes: G0.x and G0.mod plus shift , that is, add 0 , update change : change + G0.change = 0 + 0 = 0 , update shift : shift + G0.shift + change = 0 + 0 + 0 = 0

The above is the translator's understanding of the aftermath, and the final effect:

image.png

x and y :

image.png

Implement a mind map

The above algorithm cannot be directly applied to mind maps, because the size of each node of the tree considered above is the same, and the width and height of each node of the mind map may be different, which needs to be based on the above algorithm. Make some modifications on the above, because this article is already very long, so I won't go into details. The online example https://wanglin2.github.io/tree_layout_demo/ , the complete code is in https://github.com/wanglin2/ tree_layout .

image.png

Reference link

1. native javascript implements tree layout algorithm

2. tree interface drawing algorithm (2) Simple and clear First-Second

3. Tree Interface Drawing Algorithm (3) Grinding

4. Tree Interface Drawing Algorithm (Summary)

5.A Node-positioning Algorithm for General Trees


街角小林
883 声望771 粉丝