先记住这个答案
0 基数组里父节点索引为 (i-1)>>1,左孩子 2i+1,右孩子 2i+2;1 基数组(索引 1 起)父节点是 i>>1,左孩子 2i,右孩子 2i+1。检查孩子是否存在时,0 基须判断 2i+1 < n,1 基判断 2i <= n。两者只差一个偏移常数,但写错会越界或漏判。堆操作依赖这些公式从上往下或从下往上移动,基址变化不改变时间复杂度。
- 0基父节点公式是(i-1)/2,非i/2
- 1基数组通常索引0闲置以简化运算
- 边界检查必须与下标基址配套
公式推导与越界判断的精确差异
对于从 0 开始的数组,若根节点放在 a[0],第 i 个节点的左孩子应为 2i+1,右孩子 2i+2,因为从 a[0] 出发每往下层要跳过当前层全部节点。反过来,父节点是 i==0 时无父,否则 floor((i-1)/2),对应右移 (i-1)>>1。判断左孩子是否存在要用 2i+1 < n,右孩子则是 2i+2 < n。
换成从 1 开始的数组,通常令 a[1] 为根并让 a[0] 闲置或充作哨兵。此时左孩子为 2i,右孩子为 2i+1,父节点为 i>>1(因为整数除法自动向下取整)。左孩子存在条件简化为 2i <= n,右孩子则需 2i+1 <= n。可见 1 基省去 -1 和 +1 修正,边界检查也直接与数组长度 n 比较,代码更对称,但代价是浪费一个元素的空间。
function parent0(i) { return (i - 1) >> 1; }
function left0(i) { return 2 * i + 1; }
function right0(i) { return 2 * i + 2; }
function parent1(i) { return i >> 1; }
function left1(i) { return 2 * i; }
function right1(i) { return 2 * i + 1; }
// 给定数组长度 n 和孩子索引判断边界
function hasLeft0(i, n) { return left0(i) < n; }
function hasLeft1(i, n) { return left1(i) <= n && left1(i) >= 1; }
let n = 6; // 1基数组实际使用长度6,索引1~6
console.log(hasLeft0(2, 6)); // 左0:2*2+1=5 <6 -> true
console.log(hasLeft1(2, 6)); // 左1:4 <=6 -> true
console.log(hasLeft0(3, 6)); // 2*3+1=7 <6 false
console.log(hasLeft1(3, 6)); // 6 <=6 true查看输出与解释
true
true
false
true演示0基与1基在相同逻辑位置的孩子索引不同,边界判断结果因为基地址不同在同一数值下可能不同。
用数组画堆时位运算加速的场景
假设在内存受限的嵌入式环境中用 C 语言实现优先队列,堆容量固定为 1024,希望用位运算代替乘法除法。若采用 1 基下标,左孩子就是 i<<1,父节点就是 i>>1,代码无分支且快;但数组需声明为 int heap[1025],多一个元素。若用 0 基,则左孩子要 (i<<1)+1,父节点要 (i-1)>>1,多一次加减。测试中两种写法逻辑等价,但 1 基的移位更整洁且不易把 (i-1)>>1 错写成 i>>1。
实际选择取决于是否在意那一个元素的存储。当堆大小本身就是 2 的幂且需要频繁访问父节点时,1 基配合右移能减少一处减法,在紧凑循环中可能省下几个时钟周期。但多数高级语言里数组从 0 开始是自然习惯,强行加偏移反而会让代码读者困惑。工程中应固定一种约定并在封装函数中统一使用,避免混用导致越界。
下标公式容易失效的边界条件
最容易出错的是 0 基堆中把父节点写成 i/2。当 i 为奇数时,i/2 与 (i-1)/2 恰好相同;当 i 为偶数时,i/2 比 (i-1)/2 大 1,例如 i=2 实际父节点是 0,但 2/2=1,访问错位。另一个常见错误是用 2i < n 判断左孩子是否越界,但左孩子索引应为 2i+1;当 2i+1 == n 时左孩子不存在但条件仍成立,导致越界访问。
对于 1 基堆,若把 left1(i) <= n 误写成 left1(i) < n,当左孩子恰为最后一个元素时(2i == n)会被误判为不存在,导致 sift-down 提前终止。同样,右孩子存在条件是 2i+1 <= n,写成 < n 也会漏判最后两个元素之间的边界。因此建议在封装类中通过 hasLeft 和 hasRight 方法统一判断,并配合单元测试覆盖 n=1,2,3 的极端情况。
容易答错的地方
- 认为下标基址不影响复杂度或正确性
- 复杂度确实相同,但公式必须配套。把 0 基的父节点误作
i/2会破坏堆序;把 1 基的左孩子写成2i+1会跳过节点。正确性依赖准确的换算,尤其边界检查不能张冠李戴。 - 误以为 1 基数组必须浪费第一位
- 1 基只是一种逻辑视图,可以分配 n+1 长度并把 0 号元素设为哨兵或干脆不用,而用索引 1 到 n。有的语言支持自定义起始下标,但多数从 0 开始,此时只需在访问时减 1,不额外浪费。关键是保持实现的内部一致性,而不是纠结存储位置。
面试官还会怎么问?
堆的数组下标公式如何扩展到 d 叉堆?
0 基 d 叉堆中,第 i 个节点的第 k 个孩子索引为 d*i + k(k 从 1 到 d),父节点为 floor((i-1)/d)。1 基(根在 1)时,节点 i 的孩子索引为 d*i - d + 2 到 d*i + 1(第 k 个孩子索引为 d*(i-1)+k+1),父节点为 floor((i+d-2)/d)。当 d=2 时退化为 2i、2i+1 和 i/2,可验证。
数组下标从 0 和从 1 对堆的插入和删除代码具体影响哪几行?
插入时 sift-up 循环中,0 基比较当前节点与父节点 (i-1)/2,1 基为 i/2;删除堆顶后 sift-down 中,0 基找左右孩子 2i+2 与 2i+1,1 基为 2i+1 与 2i。边界条件也随基址改变,但交换与循环框架相同。
如果数组长度是 0(空堆),两种基址的边界检查会有何不同?
空堆无有效节点,任何访问都应避免。0 基中根索引 -1 非法,1 基中根索引 1 也非法但数组长度可为 0?若 1 基数组长度 n=0,无法有索引1。因此两者都必须先检查 n>0 再访问根。边界检查公式中 hasLeft0(0,0) 为假,hasLeft1(0,0) 因 0 不在合法范围内也为假,但直接调用可能出错。实际应增加空判断。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。