直观的打印出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<Int?> {
       // 省略,见文章《重写LeetCode中的二叉树的toString方法》
    }

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

    // 打印二叉树
    fun printNode() {
        val queue = LinkedList<TreeNode?>()
        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<Int?> {
        // ...
    }

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

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

    fun printNode() {
        val queue = LinkedList<TreeNode?>()
        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<TreeNode?>()
        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<TreeNode?>()
    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<TreeNode?>()
        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<Int?> {
        val arr = ArrayList<Int?>()
        val queue = LinkedList<TreeNode?>()
        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<TreeNode?>()
        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,我与你同行 ^-^ !

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350