直观的打印出LeetCode中的二叉树

前言

在LeetCode的刷题过程中,遇到二叉树的题目的时候,你一定会想,这二叉树的题目我在本地验证也太麻烦了吧。二叉树的初始化需要一个节点一个节点的赋值,而改变二叉树之后的二叉树的样子又不能直观的展现。你一定十分希望能够实现简单的生成一个二叉树,并且能够方便的看到自己的二叉树的具体内容。上述两点其实已经在文章《使用LeetCode方式初始化二叉树》,以及文章《重写LeetCode中的二叉树的toString方法》有了具体实现,那今天这篇文章,希望能让你能够更直观的看到二叉树具体是什么样子。如果想直接看代码的话可以直接拉到文章最后面复制代码哦

由易到难 - 先打印节点值为1~9的满二叉树

打印一个二叉树,想想就很复杂嘛!不过不用着急,复杂的代码也是一步步从简单代码改进而来的,我们先来试着打印一颗所有节点值为1,且层数为3的满二叉树。

代码

class TreeNode(var `val`: Int) {
    var left: TreeNode? = null
    var right: TreeNode? = null

    constructor(v0: Int, vararg v: Int?) : this(v0) {
       // 省略,见文章《使用LeetCode方式初始化二叉树》
    }

    private fun toArray(): List {
       // 省略,见文章《重写LeetCode中的二叉树的toString方法》
    }

    override fun toString() = toArray().toString()

    // 打印二叉树
    fun printNode() {
        val queue = LinkedList()
        queue.offer(this)
        while (queue.isNotEmpty()) {
            val size = queue.size
            for (i in 0 until size) {
                val temp = queue.pop()
                print("${temp?.`val` ?: "N"} ")
                if (temp != null) {
                    queue.offer(temp.left)
                    queue.offer(temp.right)
                }
            }
            println()
        }
    }
}

fun main() {
    TreeNode(1, 1, 1, 1, 1, 1, 1).printNode()
}

// 代码结果:
1 
1 1 
1 1 1 1 
N N N N N N N N

代码分析

可以看到,我们依然使用了层序遍历来打印二叉树,毕竟二叉树是自上而下的,并将二叉树的空节点打印成Nprint("${temp?.`val` ?: "N"} "),这里使用了kotlin特有的空判断处理符号?.?:,有不清楚这两个操作符的童鞋可以看这里,但是确实有一些问题,遍历结果中的节点并不像一个树,而是简单的排列起来而已,不过这个也很好解决,我们只需要在代码中加入层信息,然后按照层数打印出对应的空格数即可,而层数信息通过一个简单的二叉树遍历得到。

获取二叉树高度

class TreeNode(var `val`: Int) {
    var left: TreeNode? = null
    var right: TreeNode? = null

    // 省略无关代码......

    // 拿到二叉树的高度
    fun getHigh(): Int = 1 + (this.left?.getHigh() ?: 0).coerceAtLeast(this.right?.getHigh() ?: 0)
}

加入层级信息

现在我们可以把printNode()方法稍微改进一下

class TreeNode(var `val`: Int) {
    var left: TreeNode? = null
    var right: TreeNode? = null

    constructor(v0: Int, vararg v: Int?) : this(v0) {
       // ...
    }

    // 层序遍历,并保存在一个数组中,并删除尾部多余null
    private fun toArray(): List {
        // ...
    }

    override fun toString() = toArray().toString()

    fun getHigh(): Int = 1 + (this.left?.getHigh() ?: 0).coerceAtLeast(this.right?.getHigh() ?: 0)

    fun printNode() {
        val queue = LinkedList()
        val high = getHigh()
        var level = 0
        queue.offer(this)
        while (queue.isNotEmpty()) {
            level++
            val size = queue.size
            for (i in 0 until size) {
                val temp = queue.pop()
                val blank = " " * ((1 shl (high - level + 1)) - 1)
                if (i == 0) {
                    print("$blank${temp?.`val` ?: "N"}")
                } else {
                    print("${blank * 2} ${temp?.`val` ?: "N"}")
                }
                if (temp != null) {
                    queue.offer(temp.left)
                    queue.offer(temp.right)
                }
            }
            println()
        }
    }

    operator fun String.times(n: Int) = (1..n).joinToString("") { this }
}

代码分析

为了方便的打印出多个空格,在类中加入一个String的扩展方法
operator fun String.times(n: Int) = (1..n).joinToString("") { this }
如果你想打印50个空格,则只需要这么用:println(" " * 50),至于为啥能这么写,大家可以点这里作为参考,下面分析一下核心代码。

其实相比前面的逻辑只需要在合适的地方加入正确数量的空格,下图是我们希望打印出的二叉树的样子

目标图形

可以发现,每层最前面的空格数分别为0,1,3,7,而每层两个节点之间的空格数为1,3,7,15,这不就是2^n - 1吗,而当前的层数n可以用表达式high - level + 1来表示,所以很容易将空格的打印分为两种情况:

  • 首次打印每层的空格:空格数为2^n - 1,在代码中可写为(shl相当于左移,等价于2^n):
    val blank = " " * ((1 shl (high - level + 1)) - 1)
  • 否则,打印的是两个节点之间的空格,正好为首次空格的两倍再减一,于是空格数可以这么写:
    "${blank * 2} "
    这里使用了字符串模板,以及我们刚才定义的方法String.times(),既然定义了这么个方法,就得灵活使用不是吗^_^!

验证目前的代码

fun main() {
    TreeNode(1, 1, 1, 1, 1, 1, 1).printNode()
}

// 打印结果:
       1
   1       1
 1   1   1   1
N N N N N N N N

到目前为止,我们已经初步实现了二叉树的打印,这个二叉树的打印已经能基本满足可视化二叉树的需求,但是还有一些情况我们没有考虑到,我们仅能打印满二叉树,对于各处存在空的二叉树,还存在一些问题

  • 二叉树的值目前也仅能支持1个字符的值,如果值多了,也会让二叉树变得很难看
  • 无法正确显示空节点
fun main() {
    TreeNode(5, 1, 4, null, null, 3, 6).printNode()
    TreeNode(1, 2, 2, -30, 40, 540, 30).printNode()
}

// 打印结果
       5
   1       4
 N   N   3   6
N N N N
       1
   2       2
 -30   40   540   30
N N N N N N N N

下面让我们先处理字符串宽度的适配问题。

让打印出来的二叉树能适配节点字符的宽度

问题来了,二叉树的根节点值有大有小,有正有负,导致二叉树节点值的宽度有宽有窄,怎么才能完美适配而不影响二叉树的整体结构呢?当然非常简单,我们只需要遍历整个二叉树,得到所有节点中最宽的节点值,并以它的宽度为基准来画二叉树就可以了。为了方便拿到节点值的字符宽度,定义以下扩展方法:

private fun Int.charWidth(radix: Int = 10): Int = this.toString(radix).length

fun main() {
    val a = "12345"
    println(a.charWidth())
}

// 输出
5

这样就可以直接像Int的成员方法一样调用charWidth方法了,入参给了一个默认值为10,表示整形转成字符串的进制。
下面是获取二叉树节点最大宽度的代码:

class TreeNode(var `val`: Int) {
    var left: TreeNode? = null
    var right: TreeNode? = null

    // 省略部分代码
    // ......
    private fun Int.charWidth(radix: Int = 10): Int = this.toString(radix).length

    fun getValWidth(): Int =
        maxOf(this.`val`.charWidth(), this.left?.getValWidth() ?: 0, this.right?.getValWidth() ?: 0)
}

代码说明

想要拿到整个二叉树的最宽节点,考虑当前节点值与左右子节点的最大宽度值即可,将三个值取最大值,得到二叉树的最大字符宽度。

如何加入到打印?

我们可以先观察目前的打印结果和目标结果来确定代码改进方向,下图分别展示了当前代码结果和目标结果:
TreeNode(10000, 20000, 20000, 30000, 40000, 54000, 30000).printNode()

当前打印结果

目标结果

首行空格blank分别为 0 -> 3 -> 9 -> 21 -> 45
节点间空格分别为 1 - > 7 -> 19 -> 43
显然,字符的宽度记为w与首行空格以及节点间空格有如下关系(当前字符宽度为5),记当前层级为n
blank = (w + 1) / 2 * (2 ^ n - 1)
由于字符宽度为偶数时不好取中间值,可以将偶数宽度统一改为奇数:如宽度为4时取宽度为5。所以上面的公式还可以这么写:
blank = (1 + w / 2) * (2 ^ n - 1)
节点间空格的公式没有变化,依旧是 2 * blank + 1

公式加入到代码

class TreeNode(var `val`: Int) {
    var left: TreeNode? = null
    var right: TreeNode? = null

    private fun String.paddingTo(w: Int, c: Char = ' '): String {
        val target = if (w % 2 == 0) w + 1 else w
        if (this.length >= target) return this
        val sb = StringBuilder(this)
        while (sb.length < target) {
            sb.insert(0, c).append(c)
        }
        return sb.substring(0, target)
    }

    fun printNode() {
        val queue = LinkedList()
        val high = getHigh()
        val width = getValWidth()
        var level = 0
        queue.offer(this)
        while (queue.isNotEmpty()) {
            level++
            val size = queue.size
            for (i in 0 until size) {
                val temp = queue.pop()
                val blank = " " * (((1 + width / 2)) * ((1 shl (high - level + 1)) - 1))
                val tempVal = "${temp?.`val` ?: "N"}".paddingTo(width)
                if (i == 0) {
                    print("$blank$tempVal")
                } else {
                    print("${blank * 2} $tempVal")
                }
                if (temp != null) {
                    queue.offer(temp.left)
                    queue.offer(temp.right)
                }
            }
            println()
        }
    }
    // 省略部分代码
    // ......
}

fun main() {
    TreeNode(10000, 20000, 20000, 30000, 40000, 54000, 30000).printNode()
}

// 打印结果
                     10000
         20000                   20000
   30000       40000       54000       30000
  N     N     N     N     N     N     N     N  

代码分析

为了保证所有节点的宽度都能扩充到相同的宽度,还加入了一个新的方法paddingTo(),同样以String的扩展方法的形式给出,并以默认参数的形式给出填充字符。

正确处理代码中的空节点

目前代码中还存在三个较为明显的问题

  • 节点中的空节点没有考虑到,导致如果上层有空节点且下层节点有数据,会导致空节点也打印出了左右子树,这明显是有问题的
  • 空节点的占位符有点丑,我们需要把它去掉或者支持自定义
  • 最后一排空节点是多余的,我们需要去掉它,并同时缩小整棵树

运行下面代码我们可以发现打印结果有明显问题

fun main() {
    TreeNode(5, 4, 8, 11, null, 13, 4, 7, 2, null, null, null, 1).printNode()
}

// 打印结果
                               5 
               4                               8 
       11              N               13              4 
   7       2       N       N       N       1 
 N   N   N   N   N   N 
// 目标结果
                               5 
               4                               8 
       11              N               13              4 
   7       2       N       N       N       N       N       1 

按照我们所想的,节点13下面应该对应两个空节点,且节点13旁边的节点4对应空节点以及节点1

分析原因

fun printNode() {
    val queue = LinkedList()
    val high = getHigh()
    val width = getValWidth()
    var level = 0
    queue.offer(this)
    while (queue.isNotEmpty()) {
        level++
        val size = queue.size
        for (i in 0 until size) {
            val temp = queue.pop()
            val blank = " " * (((1 + width / 2)) * ((1 shl (high - level + 1)) - 1))
            val tempVal = "${temp?.`val` ?: " "}".paddingTo(width)
            if (i == 0) {
                print("$blank$tempVal")
            } else {
                print("${blank * 2} $tempVal")
            }
            if (temp != null) {
                queue.offer(temp.left)
                queue.offer(temp.right)
            }
        }
        println()
    }
}

在代码第19行 if (temp != null)我们做了这样的判断,导致每层二叉树并不是满二叉树,从而导致了二叉树的打印错位问题,现将这个条件移除,将空节点也加入队列,那么判断进入循环条件也需要一定的改变:
while (queue.isNotEmpty())改为while (queue.any { it != null })
即只要队列中存在不为空的节点,则将节点打出来。

二叉树缩小

在之前的分析中,二叉树的最底层全为空的层已经被我们丢掉了,并且我们知道二叉树的大小与层高密切相关,我们只需要将层高减一即可完成二叉树的缩小了,具体修改如下。

// val blank = " " * (((1 + width / 2)) * ((1 shl (high - level + 1)) - 1))
val blank = " " * (((1 + width / 2)) * ((1 shl (high - level)) - 1))

自定义空节点占位符

使用默认参数的形式将占位符传入方法,替换掉源代码中的“N”即可。

最终代码

import java.lang.StringBuilder
import java.util.*

class TreeNode(var `val`: Int) {
    var left: TreeNode? = null
    var right: TreeNode? = null

    constructor(v0: Int, vararg v: Int?) : this(v0) {
        val queue = LinkedList()
        var index = -1
        queue.offer(this)
        while (queue.isNotEmpty()) {
            val size = queue.size
            for (i in 0 until size) {
                val temp = queue.pop()
                if (temp != null) {
                    index++
                    if (index < v.size && v[index] != null) {
                        temp.left = TreeNode(v[index]!!)
                    }
                    index++
                    if (index < v.size && v[index] != null) {
                        temp.right = TreeNode(v[index]!!)
                    }
                    queue.offer(temp.left)
                    queue.offer(temp.right)
                }
            }
        }
    }

    // 层序遍历,并保存在一个数组中,并删除尾部多余null
    private fun toArray(): List {
        val arr = ArrayList()
        val queue = LinkedList()
        queue.offer(this)
        while (queue.isNotEmpty()) {
            val size = queue.size
            for (i in 0 until size) {
                val temp = queue.pop()
                arr.add(temp?.`val`)
                if (temp != null) {
                    queue.offer(temp.left)
                    queue.offer(temp.right)
                }
            }
        }
        while (arr.last() == null) {
            arr.removeAt(arr.lastIndex)
        }
        return arr
    }

    override fun toString() = toArray().toString()

    private fun getHigh(): Int =
        1 + (this.left?.getHigh() ?: 0).coerceAtLeast(this.right?.getHigh() ?: 0)

    private fun Int.charWidth(radix: Int = 10): Int = this.toString(radix).length

    private fun getValWidth(): Int =
        maxOf(this.`val`.charWidth(), this.left?.getValWidth() ?: 0, this.right?.getValWidth() ?: 0)

    private fun String.paddingTo(w: Int, c: Char = ' '): String {
        val target = if (w % 2 == 0) w + 1 else w
        if (this.length >= target) return this
        val sb = StringBuilder(this)
        while (sb.length < target) {
            sb.insert(0, c).append(c)
        }
        return sb.substring(0, target)
    }

    fun printNode(char: Char = ' ') {
        val queue = LinkedList()
        val high = getHigh()
        val width = getValWidth()
        var level = 0
        queue.offer(this)
        while (queue.any { it != null }) {
            level++
            val size = queue.size
            for (i in 0 until size) {
                val temp = queue.pop()
                val blank = " " * (((1 + width / 2)) * ((1 shl (high - level + 1)) - 1))
                val tempVal = "${temp?.`val` ?: char}".paddingTo(width)
                if (i == 0) {
                    print("$blank$tempVal")
                } else {
                    print("${blank * 2} $tempVal")
                }
                queue.offer(temp?.left)
                queue.offer(temp?.right)
            }
            println()
        }
    }

    operator fun String.times(n: Int) = (1..n).joinToString("") { this }
}

fun main() {
    println("*".repeat(50))
    TreeNode(5, 1, 4, null, null, 3, 6).printNode()
    println("*".repeat(50))
    TreeNode(1000, 2000, 2000, 3000, 4000, 5400, 3000).printNode()
    println("*".repeat(50))
    TreeNode(10000, 2, 20000, 30000, 40000, 54000, 30000).printNode()
    println("*".repeat(50))
    TreeNode(10000, 20000, 20000, 30000, 40000, 54000, 30000).printNode()
    println("*".repeat(50))
    TreeNode(100, 200, 200, 300, 400, 500, 300).printNode()
    println("*".repeat(50))
    TreeNode(1, 3, null, null, 2).printNode()
    println("*".repeat(50))
    TreeNode(3, 1, 4, null, null, 2).printNode()
    println("*".repeat(50))
    TreeNode(1, 2, 2, 3, 4, 4, 3).printNode()
    println("*".repeat(50))
}

// 打印结果
**************************************************
       5
   1       4
         3   6
**************************************************
                      1000
          2000                    2000
    3000        4000        5400        3000
**************************************************
                     10000
           2                     20000
   30000       40000       54000       30000
**************************************************
                     10000
         20000                   20000
   30000       40000       54000       30000
**************************************************
              100
      200             200
  300     400     500     300
**************************************************
       1
   3        
     2        
**************************************************
       3
   1       4
         2    
**************************************************
       1
   2       2
 3   4   4   3
**************************************************

Process finished with exit code 0

如果你使用的是gradle构建工程,可以直接引入我的LeetCode项目代码
希望这篇文章对你有帮助!使用kotlin完成LeetCode,我与你同行 ^-^ !

你可能感兴趣的:(直观的打印出LeetCode中的二叉树)