https://labuladong.online/algo/data-structure-basic/binary-heap-implement/#代码实现

今天学二叉堆的时候,让gemini给了一份代码,发现它去实现了一个getPathToParent函数,可以很方便的找到插入节点的父节点。


// getPathToParent 根据节点索引n(1-based)的二进制表示,计算从根到其父节点的路径。
func getPathToParent(n int) string {
	if n <= 1 {
		return ""
	}
	// 获取n的二进制字符串,并去掉代表根的最高位'1'和代表节点本身的最低位。
	binaryStr := strconv.FormatInt(int64(n), 2)
	return binaryStr[1 : len(binaryStr)-1]
}

让我们一步步分解它的工作原理。

1. 问题:我们想做什么?

在一个完全二叉堆中,新元素总是被添加到树的最后一层的、从左到右的第一个可用位置。

想象一下我们已经有5个节点的堆:

      (1)
     /   \
    (2)   (3)
   / \
 (4) (5)

现在我们要插入第 6 个节点。它应该放在哪里?它应该成为节点 (3)左子节点

问题是,在我们的代码中,我们如何从根节点 (1) 出发,准确地找到节点 (3),从而可以执行 parent.Left = newNode 的操作?这就是 getPathToParent 的任务。

2. 核心思想:节点位置和二进制路径的映射

一个完全二叉树的结构和二进制数有着奇妙的对应关系。如果我们把节点的**位置(1-based 索引)**写成二进制,这个二进制数就隐藏了从根节点到达该节点的路径。

规则如下:

  • 把节点位置 n 写成二进制。
  • 忽略最高位的 1(因为它代表根节点本身)。
  • 剩下的二进制位就是路径:0 代表向走,1 代表向走。

让我们用上面的树来验证一下:

节点位置 (n) 二进制 忽略最高位’1’后的路径 实际路径 (从根节点出发)
1 1 (空) (就是根节点)
2 10 0 向左
3 11 1 向右
4 100 00 向左,再向左
5 101 01 向左,再向右
6 110 10 向右,再向左
7 111 11 向右,再向右

这个规律完美成立!

3. 从“节点路径”到“父节点路径”

我们已经知道如何找到去往目标节点 n 的路径了。但是,我们需要的是去往它父节点的路径。

这很简单:父节点的路径,就是目标节点路径去掉最后一步。

  • 要到达节点 6,路径是 向右,再向左 (二进制 10)。
  • 那么要到达它的父节点(节点 3),路径就是 向右 (二进制 1)。

4. 代码如何实现这个逻辑

现在我们再来看这几行代码,就清晰了:

// 例子: 我们要插入第 6 个节点, 所以 n = 6
func getPathToParent(n int) string {
    // n = 6
	if n <= 1 { // 如果是插入第一个节点,它没有父节点
		return ""
	}
    
	// 1. 将 n 转换为二进制字符串
	// strconv.FormatInt(6, 2)  -->  "110"
	binaryStr := strconv.FormatInt(int64(n), 2)

	// 2. 提取代表父节点路径的子字符串
	// binaryStr 是 "110", len(binaryStr) 是 3
	// 我们要的切片是 [1 : len-1],即 [1:2]
    // string[1:2] 提取的是索引为 1 的字符(不包括索引2)
	// "110"
	//  ^ (索引0, 最高位 '1', 忽略)
	//   ^ (索引1, 我们需要的部分)
	//    ^ (索引2, 最后一步, 丢弃)
	//
	// 所以 binaryStr[1:2] 的结果是 "1"
	return binaryStr[1 : len(binaryStr)-1]
}

总结一下 binaryStr[1 : len(binaryStr)-1] 这行代码的含义:

  • [1:] 的部分:跳过最高位的 1,因为路径是从根节点的子节点开始计算的。
  • [:len(binaryStr)-1] 的部分:忽略最后一个字符,因为我们想在到达目标节点的前一步停下来,也就是停在父节点上。

所以,当我们要插入第 6 个节点时,getPathToParent(6) 返回字符串 "1"。在 Insert 函数中,循环就会根据这个 "1" 从根节点向右走一步,准确地找到节点 (3) 作为新节点的父节点。