Thoughts on a byte interview question about binary tree

topic

As we all know, at the end of one or two sides of a byte, an algorithm question will be randomly selected and the code will be written on the spot. The topics I picked are as follows:

The sum of all paths from the root node to the leaf node of the binary tree. Given a binary tree containing only numbers 0-9, each path from the root node to the leaf node can be represented by a number. For example, a path from the root node to the leaf node is 1→2→3, then this path is replaced by 123. Find the sum of the numbers represented by all paths from the root node to the leaf node.

For example: this binary tree has two paths, the path from the root node to the left leaf node is 12 instead, and the path from the root node to the right leaf node is replaced by 13. So the answer is 12+13=25.

 

Recursive solution

Seeing this topic, the first thing that comes to my mind is to find all the paths from the root node to the leaf nodes of this binary tree. To find all paths, the first thing that comes to mind is definitely recursion. Through the path obtained by the recursion of the left subtree, the path obtained by the recursion of the right subtree, and the root node, all the final paths are obtained.

The algorithm is as follows:

STEP1: If it is already a leaf node, construct a path list, the path has only one element, the value of the leaf node, and then return to [Exit Condition].

STEP2: Recursively find the list of all paths from the left subtree to the leaf node. For each path, add the root node to obtain a new result path, and join;

STEP3: Recursively find a list of all paths from the right subtree to the leaf node. For each path, add the root node to obtain a new result path, and join;

STEP4: Combine all the paths of the left and right subtrees into the final path list [solution of combined sub-problem].

There are two explanations:

  • Since the value of the node is only 0-9, the path can be directly represented by a string. If you use List[Integer], it is more flexible, but it will become a list of lists, which is a bit convoluted to handle.
  • When constructing the path, the append method of StringBuilder is used instead of the insert method, so the constructed path is in reverse order. The main consideration is that the insert method will cause the array to move frequently, which is inefficient. See the implementation of StringBuilder for details.

The recursive code is as follows:

public List<Path> findAllPaths(TreeNode root) {
        List<Path> le = new ArrayList<>();
        List<Path> ri = new ArrayList<>();
        if (root != null) {

            if (root.left == null && root.right == null) {
                List<Path> single = new ArrayList<>();
                single.add(new Path(root.val));
                return single;
            }

            if (root.left != null) {
                le = findAllPaths(root.left);
                for (Path p: le) {
                    p.append(root.val);
                }
            }
            if (root.right != null) {
                ri = findAllPaths(root.right);
                for (Path p: ri) {
                    p.append(root.val);
                }
            }
        }
        List<Path> paths = new ArrayList<>();
        paths.addAll(le);
        paths.addAll(ri);
        return paths;
    }


class Path {
    StringBuilder s = new StringBuilder();
    public Path() { }

    public Path(Integer i) {
        s.append(i);
    }

    public Path(List list) {
        list.forEach( e-> {
            s.append(e);
        });
    }

    public Path(String str) { this.s = new StringBuilder(str); }

    public Long getValue() {
        return Long.parseLong(s.reverse().toString());
    }

    public StringBuilder append(Integer i) {
        return s.append(i);
    }

    public String toString() {
        return s.reverse().toString();
    }
}


class TreeNode {

    int val;
    TreeNode left;
    TreeNode right;
    TreeNode(int x) { val = x; }

    public int height() {
        if (left == null && right == null) {
            return 1;
        }
        int leftHeight = 0;
        int rightHeight = 0;
        if (left != null) {
            leftHeight = left.height();
        }
        if (right != null) {
            rightHeight = right.height();
        }
        return 1 + max(leftHeight, rightHeight);
    }
}

 

key point

In fact, I didn't make it on the spot during the interview, but ten minutes after the interview, I wrote the code. Maybe I was a little nervous during the interview and got stuck in one place.

Similar to the problem of binary tree and dynamic programming, because there are multiple branches, in terms of thinking, it is not a linear thinking like dealing with arrays and linked lists, but a kind of nonlinear thinking. Therefore, do more similar topics, right The exercise of thinking is very beneficial, it can help people get rid of inherent linear thinking.

Generally speaking, algorithm problems can be divided into two steps: 1. Divide the sub-problems; 2. Combine the solutions of the sub-problems into the solutions of the original problem. It is relatively easy to divide the sub-problems, but if the division is unreasonable, it is difficult to figure out how to combine the solutions. At the beginning, I thought of combining the solution of the subtree with the root node, but I have been struggling with thinking about finding a single path instead of treating all paths as solutions to subproblems. In this way, I can hardly think of how to combine to get the final solution. But after the interview, all the path lists of the left subtree flashed through my mind, and I immediately understood how to combine them. Therefore, sometimes, taking "all" as the solution of the sub-problem, and then combining with the upper-level nodes, the solution of the original problem can be easily obtained. In addition, recursion should pay special attention to exit conditions.

It is recommended that you can do more problems with binary trees and dynamic programming, which can exercise the skills of dividing sub-problems and combining the solutions of sub-problems.
 

Non-recursive algorithm

Realizing the recursive solution is just the beginning. The recursive algorithm is very simple, but the execution efficiency is very low, and it is prone to stack overflow. If a binary tree is large enough, the recursive code cannot be executed. Therefore, a non-recursive implementation needs to be sought.

Non-recursive implementation often requires the help of stacks. We need to simulate how to use the stack to access the binary tree. As shown below:

You can find the rules first, often the rules are the code path.

  • Every time you reach a node, first push the node value into the stack;
  • When you reach the leaf node, it means you have reached the end of the path, and you can record this path.

The first non-recursive implementation is as follows. A stack is used to store the access nodes of the binary tree. If it is a leaf node, record the path, then pop the leaf node from the stack and continue to visit.

public List<Path> findAllPathsNonRecDeadLoop(TreeNode root) {

        List<Path> allPaths = new ArrayList<>();
        Stack<Integer> s = new DyStack<Integer>();

        TreeNode p = root;
        while(p != null) {
            s.push(p.val);
            if (p.left == null && p.right == null) {
                allPaths.add(new Path(s.unmodifiedList()));
                s.pop();
                if (s.isEmpty()) {
                    break;
                }
            }
            if (p.left != null) {
                p = p.left;
            }
            else if (p.right != null) {
                p = p.right;
            }
        }
        return allPaths;
    }

However, this code implementation will fall into an infinite loop. why? Because it will repeatedly enter the left subtree endlessly, and when backtracking, it can't find the parent node.

Backtracking

In order to solve the problem of infinite loops, we need to add some support: when entering a node, we must write down the parent node of the node and whether the parent node has visited the left and right subtrees. This information is represented by TraceNode. Since backtracking is always required, TraceNode must be placed on the stack and popped up when appropriate, just like saving the scene. When traversing, it is necessary to record the nodes that have been visited, not to visit repeatedly, and to avoid repeatedly pushing intermediate nodes on the stack.

Reorganize it. For the current node, there are four situations to consider:

  • The current node is a leaf node. Record path, pop treeData, pop traceNode, back to the parent node;
  • The current node is not a leaf node and has a left subtree, you need to record that the node pointer and the left subtree have been visited, and enter the left subtree;
  • The current node is not a leaf node and has a right subtree, you need to record that the node pointer and the right subtree have been visited, and enter the right subtree;
  • The current node is not a leaf node. It has left and right subtrees and both have been visited. Pop treeData, pop traceNode, and backtrack to the parent node.

The recursive implementation of the second edition is as follows:

public List<Path> findAllPathsNonRec(TreeNode root) {

        List<Path> allPaths = new ArrayList<>();
        Stack<Integer> treeData = new DyStack<>();
        Stack<TraceNode> trace = new DyStack<>();

        TreeNode p = root;
        TraceNode traceNode = TraceNode.getNoAccessedNode(p);
        while(p != null) {
            if (p.left == null && p.right == null) {
                // 叶子节点的情形,需要记录路径,并回溯到父节点
                treeData.push(p.val);
                allPaths.add(new ListPath(treeData.unmodifiedList()));
                treeData.pop();
                if (treeData.isEmpty()) {
                    break;
                }
                traceNode = trace.pop();
                p = traceNode.getParent();
                continue;
            }
            else if (traceNode.needAccessLeft()) {
                // 需要访问左子树的情形
                treeData.push(p.val);
                trace.push(TraceNode.getLeftAccessedNode(p));
                p = p.left;
            }
            else if (traceNode.needAccessRight()) {
                // 需要访问右子树的情形
                if (traceNode.hasNoLeft()) {
                    treeData.push(p.val);
                }
                if (!traceNode.hasAccessedLeft()) {
                    // 访问左节点时已经入栈过,这里不重复入栈
                    treeData.push(p.val);
                }
                trace.push(TraceNode.getRightAccessedNode(p));
                p = p.right;
                if (p.left != null) {
                    traceNode = TraceNode.getNoAccessedNode(p);
                }
                else if (p.right != null) {
                    traceNode = TraceNode.getLeftAccessedNode(p);
                }
            }
            else if (traceNode.hasAllAccessed()) {
                // 左右子树都已经访问了,需要回溯到父节点
                if (trace.isEmpty()) {
                    break;
                }
                treeData.pop();
                traceNode = trace.pop();
                p = traceNode.getParent();
            }
        }
        return allPaths;
    }

class TraceNode {

    private TreeNode parent;
    private int accessed;  // 0 均未访问 1 已访问左 2 已访问右

    public TraceNode(TreeNode parent, int accessed) {
        this.parent = parent;
        this.accessed = accessed;
    }

    public static TraceNode getNoAccessedNode(TreeNode parent) {
        return new TraceNode(parent, 0);
    }

    public static TraceNode getLeftAccessedNode(TreeNode parent) {
        return new TraceNode(parent, 1);
    }

    public static TraceNode getRightAccessedNode(TreeNode parent) {
        return new TraceNode(parent, 2);
    }

    public boolean needAccessLeft() {
        return parent.left != null && accessed == 0;
    }

    public boolean needAccessRight() {
        return parent.right != null && accessed < 2;
    }

    public boolean hasAccessedLeft() {
        return parent.left == null || (parent.left != null && accessed == 1);
    }

    public boolean hasNoLeft() {
        return parent.left == null;
    }

    public boolean hasAllAccessed() {
        if (parent.left != null && parent.right == null && accessed == 1) {
            return true;
        }
        if (parent.right != null && accessed == 2) {
            return true;
        }
        return false;
    }

    public TreeNode getParent() {
        return parent;
    }

    public int getAccessed() {
        return accessed;
    }
}

The judgment about whether the left and right subtrees have been visited is hidden in TraceNode, and the findAllPathsNonRec method does not perceive this. Later, if you feel that using int to represent the accessed space is not efficient, you can rebuild it internally, which has no effect on findAllPathsNonRec. This is the benefit of packaging.
 

test

Both recursive code and non-recursive code are prone to bugs and require careful testing. Test cases usually include at least:

  • C1: Single root node tree;
  • C2: Single root node + left node;
  • C3: Single root node + right node;
  • C4: Single root node + left and right nodes;
  • C5: Ordinary binary tree, left and right random;
  • Complex binary tree, very large.

How to construct a complex binary tree? The construction method can be used. Based on simple C2, C3, C4, connect the root node of one tree to the left or right leaf node of another tree. Complex structures are always composed of simple structures.

The test code is as follows. Use the TreeBuilder annotation to represent the constructed binary tree, so that the trees constructed by these methods can be obtained in batches for testing.

   public static void main(String[] args) {
        TreePathSum treePathSum = new TreePathSum();
        Method[] methods = treePathSum.getClass().getDeclaredMethods();
        for (Method m: methods) {
            if (m.isAnnotationPresent(TreeBuilder.class)) {
                try {
                    TreeNode t = (TreeNode) m.invoke(treePathSum, null);
                    System.out.println("height: " + t.height());
                    treePathSum.test2(t);
                } catch (Exception ex) {
                    System.err.println(ex.getMessage());
                }

            }
        }
    }

    public void test(TreeNode root) {

        System.out.println("Rec Implementation");

        List<Path> paths = findAllPaths(root);
        Long sum = paths.stream().collect(Collectors.summarizingLong(Path::getValue)).getSum();
        System.out.println(paths);
        System.out.println(sum);

        System.out.println("Non Rec Implementation");

        List<Path> paths2 = findAllPathsNonRec(root);
        Long sum2 = paths2.stream().collect(Collectors.summarizingLong(Path::getValue)).getSum();
        System.out.println(paths2);
        System.out.println(sum2);

        assert sum == sum2;
    }

    public void test2(TreeNode root) {
        System.out.println("Rec Implementation");
        List<Path> paths = findAllPaths(root);
        System.out.println(paths);

        System.out.println("Non Rec Implementation");
        List<Path> paths2 = findAllPathsNonRec(root);
        System.out.println(paths2);

        assert paths.size() == paths2.size();
        for (int i=0; i < paths.size(); i++) {
            assert paths.get(i).toString().equals(paths2.get(i).toString());
        }

    }

    @TreeBuilder
    public TreeNode buildTreeOnlyRoot() {
        TreeNode tree = new TreeNode(9);
        return tree;
    }

    @TreeBuilder
    public TreeNode buildTreeWithL() {
        return buildTreeWithL(5, 1);
    }

    public TreeNode buildTreeWithL(int rootVal, int leftVal) {
        TreeNode tree = new TreeNode(rootVal);
        TreeNode left = new TreeNode(leftVal);
        tree.left = left;
        return tree;
    }

    @TreeBuilder
    public TreeNode buildTreeWithR() {
        return buildTreeWithR(5,2);
    }

    public TreeNode buildTreeWithR(int rootVal, int rightVal) {
        TreeNode tree = new TreeNode(rootVal);
        TreeNode right = new TreeNode(rightVal);
        tree.right = right;
        return tree;
    }

    @TreeBuilder
    public TreeNode buildTreeWithLR() {
        return buildTreeWithLR(5,1,2);
    }

    public TreeNode buildTreeWithLR(int rootVal, int leftVal, int rightVal) {
        TreeNode tree = new TreeNode(rootVal);
        TreeNode left = new TreeNode(leftVal);
        TreeNode right = new TreeNode(rightVal);
        tree.right = right;
        tree.left = left;
        return tree;
    }

    Random rand = new Random(System.currentTimeMillis());

    @TreeBuilder
    public TreeNode buildTreeWithMore() {
        TreeNode tree = new TreeNode(5);
        TreeNode left = new TreeNode(1);
        TreeNode right = new TreeNode(2);
        TreeNode left2 = new TreeNode(3);
        TreeNode right2 = new TreeNode(4);
        tree.right = right;
        tree.left = left;
        left.left = left2;
        left.right = right2;
        return tree;
    }

    @TreeBuilder
    public TreeNode buildTreeWithMore2() {
        TreeNode tree = new TreeNode(5);
        TreeNode left = new TreeNode(1);
        TreeNode right = new TreeNode(2);
        TreeNode left2 = new TreeNode(3);
        TreeNode right2 = new TreeNode(4);
        tree.right = right;
        tree.left = left;
        right.left = left2;
        right.right = right2;
        return tree;
    }

    public TreeNode treeWithRandom() {
        int c = rand.nextInt(3);
        switch (c) {
            case 0: return buildTreeWithL(rand.nextInt(9), rand.nextInt(9));
            case 1: return buildTreeWithR(rand.nextInt(9), rand.nextInt(9));
            case 2: return buildTreeWithLR(rand.nextInt(9), rand.nextInt(9), rand.nextInt(9));
            default: return buildTreeOnlyRoot();
        }
    }

    public TreeNode linkRandom(TreeNode t1, TreeNode t2) {
        if (t2.left == null) {
            t2.left = t1;
        }
        else if (t2.right == null) {
            t2.right = t1;
        }
        else {
            int c = rand.nextInt(4);
            switch (c) {
                case 0: t2.left.left = t1;
                case 1: t2.left.right = t1;
                case 2: t2.right.left = t1;
                case 3: t2.right.right = t1;
                default: t2.left.left = t1;
            }
        }
        return t2;
    }

    @TreeBuilder
    public TreeNode buildTreeWithRandom() {
        TreeNode root = treeWithRandom();
        int i = 12;
        while (i > 0) {
            TreeNode t = treeWithRandom();
            root = linkRandom(root, t);
            i--;
        }
        return root;
    }

After testing, it is found that the second version of the non-recursive program still has BUG under certain circumstances. This shows that some basic situations are still not covered. Use the following test cases to debug and find that there is a problem:

@TreeBuilder
    public TreeNode buildTreeWithMore4() {
        TreeNode tree = new TreeNode(5);
        TreeNode left = new TreeNode(1);
        TreeNode right = new TreeNode(2);
        TreeNode left2 = new TreeNode(3);
        TreeNode right2 = new TreeNode(4);
        TreeNode right3 = new TreeNode(6);
        tree.right = right;
        tree.left = left;
        left.right = right3;
        right.right = left2;
        left2.right = right2;
        return tree;
    }

Thinking back and thinking

Where is the problem? When you first enter the right subtree without the left subtree, there will be problems. This shows that I haven't really figured out the entire retrospective process. Reorganize the backtracking process:

  • There is a pointer p used to point to the currently visited node;
  • There is a stack treeData used to store the value of the visited node;
  • There is a stack trace that stores the most recently accessed node information for backtracking;
  • There is a traceNode to indicate which direction to go.

The problem is that I didn't think about what traceNode really means. What should be stored in the parent and accessed of traceNode? In fact, traceNode and p are used together. p is the pointer of the node currently entered, and traceNode is used to indicate where to go after entering p. There should be two sources of traceNode:

  • When entering p for the first time, at this time, the left and right subtrees have not been visited, parent should be the same as p, and accessed is always initialized to 0;
  • When the left or right subtree of p is visited, when backtracking enters p, at this time parent should be the parent node of p, which is obtained from the trace.

The second version of the non-recursive program does not take into account the situation when p is first entered. As shown in the following code. When p enters the left subtree, the last parent node information needs to be pushed into the trace stack, and traceNode needs to be set to the situation when p was initially entered. Entering the right subtree is similar. This is exactly what the second edition of non-recursive programs did not think clearly.

trace.push(TraceNode.getLeftAccessedNode(p));
p = p.left;
traceNode = TraceNode.getNoAccessedNode(p);

We made some changes and got the third version of the non-recursive program. It is OK after testing.

public List<Path> findAllPathsNonRec(TreeNode root) {

        List<Path> allPaths = new ArrayList<>();
        Stack<Integer> treeData = new DyStack<>();
        Stack<TraceNode> trace = new DyStack<>();

        TreeNode p = root;
        TraceNode traceNode = TraceNode.getNoAccessedNode(p);
        while(p != null) {
            if (p.left == null && p.right == null) {
                // 叶子节点的情形,需要记录路径,并回溯到父节点
                treeData.push(p.val);
                allPaths.add(new ListPath(treeData.unmodifiedList()));
                treeData.pop();
                if (treeData.isEmpty()) {
                    break;
                }
                traceNode = trace.pop();
                p = traceNode.getParent();
                continue;
            }
            else if (traceNode.needAccessLeft()) {
                // 需要访问左子树的情形
                treeData.push(p.val);
                trace.push(TraceNode.getLeftAccessedNode(p));
                p = p.left;
                traceNode = TraceNode.getNoAccessedNode(p);
            }
            else if (traceNode.needAccessRight()) {
                // 需要访问右子树的情形
                if (traceNode.hasNoLeft()) {
                    treeData.push(p.val);
                }
                if (!traceNode.hasAccessedLeft()) {
                    // 访问左节点时已经入栈过,这里不重复入栈
                    treeData.push(p.val);
                }
                trace.push(TraceNode.getRightAccessedNode(p));
                p = p.right;
                traceNode = TraceNode.getNoAccessedNode(p);
            }
            else if (traceNode.hasAllAccessed()) {
                // 左右子树都已经访问了,需要回溯到父节点
                if (trace.isEmpty()) {
                    break;
                }
                treeData.pop();
                traceNode = trace.pop();
                p = traceNode.getParent();
            }
        }
        return allPaths;
    }

 

optimization

Scalability

Since the node value given in the title is 0-9, the string is used to represent the path in the previous trick. What if the node value is not 0-9? If you still want to use a string to represent, you need a separator. Now, we use a list to represent the path. The advantage of encapsulation is that you can replace the implementation, and change the client code as little as possible (here, findAllPaths and findAllPathsNonRec methods).

Here, the Path class is changed to an interface, the original Path class is changed to StringPath, and then StringPath is used to replace Path. Define the method used by StringPath as an interface method. Only the append and getValue methods are used. However, the constructor method parameters must also be compatible. In this way, as long as the original StringPath is changed to ListPath, the rest can be run through without any changes.

interface Path {
    void append(Integer i);
    Long getValue();
}

class StringPath implements Path { // code as before }

class ListPath implements Path {
    List<Integer> path = new ArrayList<>();

    public ListPath(int i) {
        this.path.add(i);
    }

    public ListPath(List list) {
        this.path.addAll(list);
    }

    @Override
    public void append(Integer i) {
        path.add(i);
    }

    @Override
    public Long getValue() {
        StringBuilder s = new StringBuilder();
        path.forEach( e-> {
            s.append(e);
        });
        return Long.parseLong(s.reverse().toString());
    }

    public String toString() {
        return StringUtils.join(path.toArray(), "");
    }
}

 

summary

It took a day to figure out how to play the binary tree backtracking. The spirit of a technical person is to get to the bottom of it, and to figure out one thing thoroughly!

In this article, we searched for interview questions through a binary tree path, discussed recursive and non-recursive solutions, discussed the problems encountered in the non-recursive process, and simulated the backtracking of the binary tree, which is very useful for understanding the access of the binary tree. The understanding of backtracking algorithms exercises nonlinear thinking. In addition, when a program has a BUG, ​​it is often caused by not thinking about it sufficiently. Persevering in thinking and clear definitions can take a step towards correctness.

If you don't look at the answer, you can figure out a problem by yourself and gain a lot!
 

For the complete source code of this article, see: In the ALLIN  project: zzz.study.datastructure.tree.TreePathSum

 

Author: @ Qin Talasite

Please indicate the source for reprinting: https://www.cnblogs.com/lovesqcc/p/13882386.html

Guess you like

Origin blog.csdn.net/m0_50180963/article/details/109315286