ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

树的高度:从0还是从1?彻底讲透两种定义与工程避坑指南

树的高度:从0还是从1?彻底讲透两种定义与工程避坑指南

1. 一个看似无聊却能把人绕晕的问题

如果你写过树的遍历、算过平衡因子、调过AVL的旋转,或者只是单纯在刷题时被某个测试用例卡住过,那你大概率撞上过这个坑:树的高度到底从0开始算还是从1开始算。这个问题乍一看像是“茴字有几种写法”式的无聊争论,但我在实际项目里被它坑过不止一次——一次是手写线段树时把叶子节点高度定义成0,结果建树边界条件写错,调了两个小时;另一次是接手别人代码,两套模块对“高度”的理解不一致,一个按节点数算、一个按边数算,合并时直接错位,排查起来极其难受。

所以这篇东西不是来给教科书做注脚的,而是想把这件事彻底讲透:为什么会有两种定义、它们各自对应什么场景、混用会出什么事故、以及在实际写代码时怎么用一套约定把坑堵死。关键词就三个——树的高度、节点深度、边数与节点数的约定。不管你是刚学数据结构的新手,还是写了几年业务代码偶尔要手撸个树结构的老手,这篇都值得花十分钟看完,因为这类“约定型”问题一旦理解错,错的不只是一个数字,而是整段逻辑的边界。

先说结论免得你着急:两种定义都合法,学术界和工程界各有偏好,真正要命的不是选哪个,而是你有没有在同一个项目里保持一致。下面我把这件事拆开揉碎讲。

2. 两种定义各自的来路与合理性

2.1 按“边数”定义:空树高度为-1,叶子高度为0

这是很多算法教材(尤其是偏理论、偏图论视角的那一派)采用的约定。它的逻辑起点很自然:树是一种特殊的图,而图里谈“路径长度”谈的是边的条数。从根走到叶子,经过几条边,高度就是几。于是:

  • 空树(没有任何节点)高度记为-1;
  • 只有一个根节点的树,高度为0;
  • 根到最深叶子的边数,就是整棵树的高度。

这个定义的好处是数学上很干净。比如算完全二叉树的高度和节点数的关系时,高度为 h 的满二叉树节点数是 2^(h+1) - 1,指数直接对应高度,递推式写起来顺。再比如很多递归算法里,height(node) = max(height(left), height(right)) + 1,如果叶子返回0,空节点返回-1,这个 +1 恰好对应“往上走一条边”,语义严丝合缝。

但它的“反直觉”之处也很明显:空树高度是-1。这个负数第一次见的人基本都会愣一下,而且在一些不允许负值的场景(比如用无符号整数存高度、或者把高度直接当数组下标)里会直接炸掉。

2.2 按“节点数”定义:空树高度为0,叶子高度为1

另一派(偏工程、偏教学入门的一派)采用节点计数:从根到最深叶子经过的节点个数。于是:

  • 空树高度为0;
  • 只有一个根节点,高度为1;
  • 根到最深叶子的节点数,就是高度。

这个定义最大的优点是永远非负,而且和人的直觉一致——“这棵树有几层”数的是层数,一层就是一个节点。很多业务代码里说的“树的层级”“目录深度”,其实都是这个语义。比如一个文件目录,根目录算第1层,下面一级子目录算第2层,用户理解起来毫无障碍。

代价是公式里会多出一些偏移。同样是满二叉树,高度为 h(节点数口径)时节点数是 2^h - 1,比边数口径的指数少1。递归式也要相应调整:叶子返回1,空节点返回0,height = max(left, right) + 1依然成立,但基准情况变了。

2.3 一张表把两种口径对齐

光说容易混,直接上对照表最清楚:

场景边数口径(空树-1)节点数口径(空树0)
空树-10
单节点树01
根+一个孩子12
满二叉树节点数公式2^(h+1) - 12^h - 1
叶子节点的高度01
递归基准(空节点返回值)-10

这张表建议直接存下来。我见过太多人嘴上说“我知道有两种”,真到写代码时还是把基准情况写错,原因就是没把这张对照表在脑子里固化。

提示:判断一段代码用的是哪种口径,最快的办法是看它对空节点返回什么。返回-1就是边数口径,返回0就是节点数口径。这一招在阅读陌生代码时屡试不爽。

3. 为什么混用会出事故:三个真实踩坑场景

理论讲完,进入正题。这一节我想讲清楚“混用”到底怎么把人坑到怀疑人生。因为单纯知道两种定义不解决问题,真正的问题是同一个系统里两拨人各用各的。

3.1 场景一:平衡因子算反,旋转方向全错

AVL树里有个核心概念叫平衡因子,定义是“左子树高度减右子树高度”。问题来了:如果左子树是空树,它的高度是多少?按边数口径是-1,按节点数口径是0。假设你代码里一处按-1算、另一处按0算,平衡因子就会整体偏移,本来该左旋的判成右旋,树越转越歪。

我当初接手一个手写平衡树的模块时就遇到过:建树部分用的是节点数口径(空返回0),而旋转判断部分是从另一份参考代码抄来的边数口径(空返回-1)。单节点插入时看不出问题,一旦插入序列让某棵子树变空,平衡因子瞬间差1,触发错误的旋转。表现是树结构看起来“怪怪的”,但遍历结果又是对的(因为中序遍历不受影响),所以测试没报错,直到做范围查询时性能崩了才发现。

排查这种问题的思路很明确:把所有涉及高度的函数列出来,逐个确认空节点返回值。我当时就是写了个小脚本,对每个高度相关函数喂一个空树,打印返回值,一眼就看出哪个是-1哪个是0。

3.2 场景二:数组下标越界,负高度直接崩

边数口径下空树高度是-1,这个负数如果被拿去当数组下标或者参与无符号运算,后果很直接。比如你用一个数组level[height]存每层节点,空树时level[-1]就是未定义行为;再比如高度用size_t(无符号)存,-1 会变成一个巨大的正数,循环直接跑飞。

这类问题在C/C++里尤其阴险,因为编译不报错,运行时可能只是偶尔崩,或者更糟——不崩但结果错。我见过一个用高度做哈希桶索引的实现,空树时索引变成天文数字,程序没崩但内存被吃光。

规避办法很简单:如果项目里存在空树的可能性,优先选节点数口径,从源头上消灭负数。如果因为历史原因必须用边数口径,那就在所有对外接口处做一次转换,内部随便用,出口统一加1变成非负。

3.3 场景三:跨模块对接,两边对“深度”理解不一致

最麻烦的是跨团队或跨模块对接。A模块导出树结构,字段叫height,按边数口径算;B模块消费这个结构,默认height是节点数口径。两边单测都过,联调就错位一层。

这种问题的隐蔽性在于:它不会报错,只会让结果差1。差1在很多时候看不出来,但在边界判断上就是致命的。比如“高度超过阈值就触发重平衡”,差1可能导致该触发时不触发,树慢慢退化成链表。

我的经验是:凡是跨模块传递的树结构,高度字段必须带单位说明。要么在字段名里写清楚(height_edges/height_nodes),要么在接口文档里用一句话钉死。别嫌啰嗦,这一句注释能省掉后面几小时的联调。

4. 递归实现里那个 +1 到底加的是什么

这一节稍微往深里挖一点,因为很多人对“+1”的理解是模糊的,而模糊正是出错的根源。

4.1 递归式的两种写法及其语义

先看边数口径的标准写法:

def height_edges(node): if node is None: return -1 return max(height_edges(node.left), height_edges(node.right)) + 1

这里的+1加的是一条边。空节点返回-1,叶子节点max(-1,-1)+1 = 0,符合“叶子高度为0”。每往上一层加1,加的是从当前节点到孩子的那条边。

再看节点数口径:

def height_nodes(node): if node is None: return 0 return max(height_nodes(node.left), height_nodes(node.right)) + 1

这里的+1加的是一个节点。空节点返回0,叶子节点max(0,0)+1 = 1,符合“叶子高度为1”。每往上一层加1,加的是当前节点自身。

看出来了吗?两个版本的代码结构一模一样,唯一的区别是基准返回值。这也解释了为什么混用如此容易——你复制粘贴改代码时,只要漏改基准那一行,整个口径就悄悄变了,而函数名、调用方式、甚至大部分测试用例都不会提醒你。

4.2 用“层数”和“边数”的物理意义来记

我自己的记忆方法是把它和现实场景绑定:

  • 边数口径 = 走楼梯。从一楼走到三楼,走了两段楼梯,所以“高度”是2。空房子没人,高度-1(这个稍微牵强,但记住它是“比0还少一”就行)。
  • 节点数口径 = 数楼层。一栋楼有几层,数的是楼层数,一楼就是1层。空地没有楼,高度0。

这个类比不严谨,但足够帮你在写代码的瞬间反应过来该返回什么。尤其是“走楼梯”这个意象,能让你在写+1时清楚意识到加的是边。

4.3 迭代写法里的坑

递归好办,迭代(比如层序遍历求高度)更容易出错。层序遍历求高度,本质是数“层数”,天然对应节点数口径:

def height_iter(root): if root is None: return 0 depth = 0 queue = [root] while queue: depth += 1 for _ in range(len(queue)): node = queue.pop(0) if node.left: queue.append(node.left) if node.right: queue.append(node.right) return depth

这段代码返回的是节点数口径。如果你项目里用的是边数口径,就得在最后return depth - 1。我见过有人忘了减这个1,导致同一棵树递归算出来是3、迭代算出来是4,然后怀疑人生。

注意:层序遍历天然数的是“层”,也就是节点数口径。如果你的项目约定是边数口径,所有基于层序的高度计算都要记得减1,这是最容易漏的转换点。

5. 工程实践里怎么把口径钉死

讲完原理和坑,落到实操。这一节是我这些年总结的一套“防混用”做法,直接可以抄。

5.1 命名即文档:用后缀区分口径

最省事的办法是从命名上就区分开。我现在的习惯是:

  • height_edges(node):边数口径,空树-1;
  • height_nodes(node):节点数口径,空树0;
  • 对外接口如果只暴露一个,就叫height,但在文档里用一句话钉死口径,并且全项目只允许这一个函数叫height。

命名带后缀的好处是,任何人看到调用点就知道口径,不需要跳转看实现。代价是名字长一点,但比起联调时差1的痛苦,这点长度完全值得。

5.2 用断言把基准情况锁死

光靠命名还不够,因为总有人手滑。我的做法是在模块初始化或单测里加一条断言,把基准情况锁死:

assert height_edges(None) == -1, "边数口径基准错误" assert height_nodes(None) == 0, "节点数口径基准错误"

这两行看着傻,但它能在口径被意外改动时第一时间报警。我甚至见过有团队把这类断言放进CI,每次提交都跑,效果很好。

5.3 跨模块传递时加单位字段

如果树结构要序列化传给别的模块,别只传一个裸数字。要么字段名带单位,要么加一个height_unit字段说明是"edges"还是"nodes"。多一个字段的成本,远低于联调时排查差1的成本。

5.4 一张决策清单

到底该选哪种口径?我给个简单的决策清单:

你的场景推荐口径理由
纯算法题、图论相关边数(空-1)与路径长度语义一致,公式干净
业务代码、目录层级、UI层级节点数(空0)非负、符合直觉、避免下标越界
需要和外部系统对接看对方口径,出口统一转换一致性优先于偏好
存在空树且用无符号类型节点数(空0)消灭负数
教学/文档示例明确标注,二选一别让读者猜

这张表不是铁律,但它覆盖了大多数实际决策场景。核心思想就一句:先看约束(有没有空树、有没有无符号、要不要对接),再看偏好。

6. 几个容易混淆的相邻概念

高度这个问题之所以绕,很大程度是因为它和“深度”“层数”“节点数”这些词纠缠在一起。这一节把它们理清楚,避免你在读别人代码时把概念对错号。

6.1 高度与深度:方向相反的一对

深度(depth)是从根往下数,高度(height)是从叶子往上数。对单个节点来说,它的深度是“根到它的距离”,它的高度是“它到最深叶子的距离”。整棵树的高度等于根的高度,整棵树的深度等于最深叶子的深度,两者数值上相等(同一口径下),但语义方向相反。

这个区分在算平衡因子时特别重要:平衡因子用的是子树高度(往上数),不是深度。搞反了方向,逻辑就全错。

6.2 层数(level)通常从1开始

很多教材讲层序遍历时说“第1层是根”,这里的层数天然是节点数口径。所以如果你看到代码里level从1开始,基本可以判断它走的是节点数口径;如果从0开始,那多半是边数口径或者纯粹的下标习惯。这个观察能帮你在读陌生代码时快速定位口径。

6.3 节点数与边数的恒等关系

对任意非空树,节点数 = 边数 + 1。这个恒等式是两种口径之间转换的数学基础。边数口径高度 h 对应节点数口径高度 h+1,本质就是这个恒等式在“最长路径”上的体现。理解了这一点,你就不会再觉得两种口径是“两套东西”,它们只是同一个量的两种计量单位,像摄氏度和华氏度。

7. 我在实际项目里固化下来的一套约定

最后分享一套我自己用了几年、没再出过事的约定,你可以直接拿去用,也可以按自己团队情况微调。

第一条:全项目统一用节点数口径(空树0)作为默认。理由是业务代码里空树太常见,负数带来的麻烦远大于公式上那点偏移。算法题里如果题目明确按边数,那就在解题函数内部临时转换,不污染项目约定。

第二条:所有高度函数必须带口径后缀,height_nodes和height_edges二选一,禁止裸height出现在核心逻辑里。对外接口如果必须叫height,文档里用加粗写死口径。

第三条:基准情况必须有断言,空树返回值在单测里锁死,CI 里跑。

第四条:跨模块传递带单位,序列化结构里加height_unit字段,或者字段名带后缀。

第五条:层序遍历求高度时,转换点单独注释。因为层序天然是节点数口径,如果项目用边数口径,那个-1的转换必须写注释说明,否则下一个人一定会漏。

这套约定听起来繁琐,但实际落地成本很低——无非是多写几个字符、多写两行断言。而它挡掉的是那种“差1、不报错、联调两小时”的隐性事故。我踩过的坑告诉我,约定型问题的成本不在理解,而在遗忘。你今天想明白了,三个月后接手新项目,照样可能手滑。所以把约定固化到命名、断言、文档里,比记在脑子里靠谱得多。

顺带说一句,这套思路其实可以推广到所有“约定型”问题——索引从0还是1、区间是左闭右开还是双闭、角度用弧度还是度。凡是存在两种合法约定且混用会出错的地方,都值得用命名加断言的方式钉死。树的高度只是其中最典型、也最容易被轻视的一个罢了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进