滴水穿石,非一日之功——理解二叉搜索树(JavaScript实现)

171 阅读6分钟

1、前言

二叉搜索树(或者叫二叉排序树),是一种特殊的二叉树,其中一个重要的性质是它的左子树的节点值均比根节点小,右子树的节点值均比根节点值大,并且其左右子树也是一颗二叉搜索树。正是因为这个性质,通常会采用二叉搜索树进行高效率的检索和删除操作。

2、二叉搜索树的实现

首先,先定义二叉树节点的数据结构,如下:

interface TreeNoode {
    // 树节点的值域
    val: number
    // 树节点的左子节点
    left: TreeNode | null
    // 树节点的右子节点
    right: TreeNode | null
}

根据二叉搜索性质,我们可以得出一个结论:

最大节点一定是在右子树上,并且一定没有右子节点

最小节点一定是在左子树上,并且这个节点一定没有左右儿子节点,即叶节点

二叉搜索树的主要操作有find,findMin,findMax,insert,delete。 我们先贴出代码,然后对代码的流程进行分析。

2.1、递归

为了方便大家理解,首先,我们先阐述一下递归作为前置知识。

递归,一般是函数自身调用自身。但是并不是只有函数自身调用自身才能形成递归,比如大家广为流传的Vue实现双向绑定的原理,就不是函数自身调用自身形成的递归,是几个函数形成的调用环。递归规模就是一个函数体运行所占的体积,而类似Vue双向绑定原理的那种递归的规模是几个函数体运行所占用的体积。

比如,用递归求斐波拉契数列,我们可以很容易写出如下代码:

/* 求斐波拉契数列的第N项的值 */
function F(n) {
    if (n === 1 || n === 2) {
        return 1;
    } else {
        return F(n - 1) + F(n - 2);
    }
}

比如我们要求F(4), 执行过程大致如下: Demo1.png demo2.png demo3.png demo4.png

以上便是整个F(4)求解过程的系统调用堆栈的顺序,聪明的你一下子就看出来了,这个F(2)明显是被计算了两次。因此在递归中,一个相当重要的优化手段就是注意对计算结果的缓存。另外关于递归一定要注意退出条件,否则会出现Maximum call stack size exceeded的错误。

递归只是作为本文的一个引子,更多关于JS执行上下文和执行栈的知识请各位读者参考掘金上的优秀文章,我就不在此处展开讲了。

小结:

优点: 简单,只要能写出递推关系和退出条件,就能实现所需的功能。

缺点: 占用大量的内存空间,如果不做一些优化的话,还会进行多次的重复运算。因此,在实际开发中,能不使用递归尽量还是不使用递归。

2.2、递归实现

为了保证对外暴露的API便于使用者操作,我们需要对函数进行封装,仅保留用户确实需要的参数,隐藏实现细节。

老规矩,先贴出代码,然后再进行解释。

class BST {

    /**
     * @type {TreeNode}
     */
    #tree = null

    /**
     * 创建一个树节点
     * @param {number} val 
     * @returns {TreeNode}
     */
    #createTreeNode(val) {
        return {
            val,
            left: null,
            right: null,
        }
    }

    /**
     * 查找指定值是否在二叉搜索树中存在
     * @param {number} val 
     * @returns {TreeNode} 
     */
    find(val) {
       return this.#find(val, this.#tree)
    }

    #find(val, node) {
        if (!node) {
            return null
        }
        if (node.val === val) {
            return node
        } else if (node.val > val) {
            return this.#find(val, node.left)
        } else if (node.val < val) {
            return this.#find(val, node.right)
        }
    }

    insert(val) {
        this.tree = this.#insert(val, this.tree)
    }

    /**
     * 使用递归的方式向搜索树中插入一个节点
     * @param {number} val 
     * @param {Node} node 
     * @returns 
     */
    #insert(val, node) {
        if (!node) {
            node = this.#createTreeNode(val)
        } else {
            if (val < node.val) {
                node.left = this.#insert(val, node.left)
            } else if (val > node.val) {
                node.right = this.#insert(val, node.right)
            }
        }
        return node;
    }

    delete(val) {
        this.tree = this.#delete(val, this.tree)
    }

    /**
     * 使用递归的方式删除一个节点
     * @param {number} val 待删除的值
     * @param {TreeNode} node 从指定的节点开始删除
     * @returns 
     */
    #delete(val, node) {
        if (!node) {
            console.warn("无法找到需要删除的值")
            return null
        } else if (val > node.val) {
            node.right = this.#delete(val, node.right)
        } else if (val < node.val) {
            node.left = this.#delete(val, node.left)
        } else {
            if (node.left && node.right) {
                let rightMin = this.#findMinPosition(node.right)
                node.val = rightMin.val
                node.right = this.#delete(rightMin.val, node.right)
            } else if (node.left) {
                node = node.left
            } else if (node.right) {
                node = node.right
            } else {
                node = null
            }
        }
        return node
    }

}

有了之前的递归知识作为铺垫,我们来看一下如何使用递归实现二叉搜索树。

2.2.1 查找

根据二叉搜索树的定义,左儿子一定会比右儿子小。因此,如果我们当前起始节点不存在的话,说明对应的值在二叉树中不存在,如果当前节点存在,并且恰好和我们要找的节点值相等的话,那么就说明找到了,因此我们可以直接返回当前节点,如果待查找值比当前节点的值小,那么它只有可能在左子树上找的到,因次,我们沿着左子树递归,反之,我们则沿着右子树递归。

对于这个步骤,我就不再画图了,大家可以参考斐波拉契数列的堆栈调用顺序来理解。

2.2.2 插入

对于二叉搜索树节点的插入,我们需要明白一个点就是,我们的设计的函数必须要返回一个节点,因为只有返回节点了的话,系统才能帮我把节点接好。这就好比是一个绞肉机,你投肉进去,然后它给你加工成肉末返回给你。

假设我们需要向一颗空二叉搜索树插入 10 5 20三个节点,整个执行步骤,如下图所示: R1.png R2.png R3.png

2.2.3 删除

二叉搜索树的删除相比插入要复杂的多,我们需要考虑的情况比较多:

首先,递归的思路此处不再赘述了,如果传入的节点就不存在,说明要删除的值肯定在搜索树中根本不存在。 我们主要考虑能找到待删除节点的情况,如下:

1、没有左右儿子节点。

2、仅有左儿子节点。

3、仅有右儿子节点。

4、同时具有左右儿子节点。

对于case1,因为不需要进行任何操作。参考之前插入的时候画的图,传入的是有值的节点,返回空,我们给上层节点的左(或者 右)儿子接上这个空,则完成了删除。

对于case2,也不难,不就是要删除当前节点嘛,好呀,我直接把当前节点变成当前节点的左儿子给你返回,你上层递归调用的函数不就老老实实的给我接上了吗,嘿嘿(或者,把当前节点的左儿子的值拷贝给自己,然后把当前节点的左儿子指针置为空,然后返回这个节点,不过好像没有必要这样做呢)。

对于case3,同case2,只不过我们操作的是右儿子。

对于case4,也就是最关键的,这个问题需要利用转化的思想。首先,因为总是有右子树的任何节点比当前待删除节点大,左子树的任何节点比当前待删除节点的值小的性质。我们可以换个角度想,我从右子树里面先找一个最小值节点替换到当前的这个待删除节点上,如果不考虑之前找到的最小值节点,是否仍然满足二叉搜索树的性质呀,nice,那我再从这个位置开始把之前找到的最小值节点删了不就行了吗,哈哈哈。有的朋友会说了,待删除右子树的最小值节点可能同时有左右儿子节点嘛,不就又绕回来了吗。根据二叉搜索树的性质,当前子树的最小值肯定是不会再有左子节点的了。所以,我们再从待删除节点出发,递归的删除当前节点右子树的最小值即可(上述操作找左子树的最大值也可以)。

2.3 插入和删除的非递归实现

对于二叉搜索树的非递归删除,就相对复杂多了,但是非递归操作性能肯定会比递归操作好。对于节点下滤的方式和递归类似,后文将不再赘述。还是老规矩,先贴代码, 然后进行思路阐述:

class BST {

    /**
     * @type {TreeNode}
     */
    #tree = null

    /**
     * 创建一个树节点
     * @param {number} val 
     * @returns {TreeNode}
     */
    #createTreeNode(val) {
        return {
            val,
            left: null,
            right: null,
        }
    }   
    
     /**
     * 查找二叉树中值为val的节点
     * @param {number} val 
     * @returns 
     */
    #findPosition(val) {
        let node = this.#tree
        let targetNode = null
        while (node) {
            if (node.val === val) {
                targetNode = node
                break
            } else if (val > node.val) {
                node = node.right
            } else if (val < node.val) {
                node = node.left
            }
        }
        return targetNode
    }
    
    /**
     * 从二叉搜素树中删除一个值
     * @param {number} val 
     */
    delete(val) {
        let node = this.#tree
        let parent = null
        while (node) {
            // 如果找到了当前节点 则中断循环
            if (node.val === val) {
                break
            }
            parent = node
            // 如果当前节点的值比val小,说明待删除的节点在右子树上
            if (node.val < val) {
                node = node.right
                // 如果当前节点的值比val大,说明待删除的节点在左子树上
            } else if (node.val > val) {
                node = node.left
            }
        }
        if (!node) {
            console.warn("要删除的节点不存在")
        } else if (node.left && node.right) {
            let leftMaxParentNode = node
            let leftMaxNode = node.left
            // 退出循环的时候,leftMaxNode是肯定没有右子树节点的了
            while (leftMaxNode.right) {
                leftMaxParentNode = leftMaxNode
                leftMaxNode = leftMaxNode.right
            }
            // 先把左子树的最大值拷贝到当前要删除的节点上去,然后从左子树删除左子树的最大值节点
            node.val = leftMaxNode.val
            // 前驱节点的右指针指向当前节点的左指针 虽然leftMaxNode.left可能不存在,但是还是要将其链接在前驱节点的右子节点上
            leftMaxParentNode.right = leftMaxNode.left
        } else if (node.left) {
            if (parent === null) {
                this.#tree = node.left
            } else {
                // 判断当前节点是来源于左子节点还是右子节点
                if (parent.val > node.val) {
                    parent.left = node.left
                } else {
                    parent.right = node.left
                }
            }
        } else if (node.right) {
            if (parent === null) {
                this.#tree = node.right
            } else {
                // 判断当前节点是来源于左子节点还是右子节点
                if (parent.val > node.val) {
                    parent.left = node.right
                } else {
                    parent.right = node.right
                }
                node = null
            }
        } else {
            if (parent === null) {
                this.#tree = null
            } else {
                // 判断当前节点是来源于左子节点还是右子节点
                if (parent.val > node.val) {
                    parent.left = null
                } else {
                    parent.right = null
                }
            }
            node = null
        }
    }
    
    
    /**
     * 向二叉搜索树中插入一个值
     * @param {number} val 
     */
    insert(val) {
        // 创建一个新的节点
        let newNode = this.#createTreeNode(val)
        // 如果当前树为空, 直接将节点插入根节点
        if (!this.#tree) {
            this.#tree = newNode
            return
        }
        let node = this.#tree
        while (true) {
            // 如果插入相同值,直接报错
            if (val === node.val) {
                console.warn("不允许插入相同值的节点")
                break
            }
            // 如果当前值比节点值大,node向右子树下滤
            else if (val > node.val) {
                // 如果当前节点的右儿子没了,说明这个就是一个合适的插入位置
                if (!node.right) {
                    node.right = newNode
                    break
                }
                // 沿着右子树下滤
                node = node.right
                // 如果当前值比节点值小,node向左子树下滤
            } else if (val < node.val) {
                // 如果当前节点的左儿子没了,说明这个就是一个合适的插入位置
                if (!node.left) {
                    node.left = newNode
                    break
                }
                // 沿着左子树下滤
                node = node.left
            }
        }
    }
    
    
    /**
     * 查找树中最大值
     */
    findMax() {
        let node = this.#findMaxPosition()
        return node ? node.val : null
    }

    /**
     * 查找树中最大值的位置
     * @param {TreeNode} startNode 从指定节点开始查询
     * @returns {TreeNode}
     */
    #findMaxPosition(startNode) {
        startNode = startNode || this.#tree
        if (!startNode) {
            console.warn("empty tree")
            return null
        }
        let node = startNode
        while (node.right) {
            node = node.right
        }
        return node
    }

    /**
     * 查找树中最小值
     * @returns {number}
     */
    findMin() {
        let node = this.#findMinPosition()
        return node ? node.val : null
    }

    /**
     * 查找树中最小值的节点
     * @param {TreeNode} startNode 从指定节点开始查询
     * @returns {TreeNode}
     */
    #findMinPosition(startNode) {
        startNode = startNode || this.#tree
        if (startNode) {
            console.warn("empty tree")
            return
        }
        let node = startNode
        while (node.left) {
            node = node.left
        }
        return node
    }
    
}

2.3.1 查找

对于查找此处不再赘述。

2.3.2 插入

对于插入,如果当前树是空的话直接插入即可。

如果有插入相同的值肯定不允许插入。

如果当前的节点值比待插入值小的话,说明我们应该往当前节点的右子树找位置插入才对。如果当前节点的右子树不存在的话,OK,恭喜你,这个位置就是你要插入的位置啦。我们的循环就没有必要再继续做下去了。否则,我们就接着从右子树下面找插入位置往下找呗。

如果当前的节点值比待插入值大的话,说明我们应该往当前节点的左子树找位置插入才对。如果当前节点的左子树不存在,OK,恭喜你,这个位置就是你要插入的位置啦,结束循环。否则,我们接着从左子树下面找合适的插入位置。

2.3.3 删除

使用非递归删除二叉搜素树的思路和递归一致,但是代码的实现复杂度一下子提高了很多。 在使用递归删除的过程中,因为我们借用了系统的调用栈,我们可以很好的处理删除之后的节点引用关系,而使用非递归操作,这个过程完全就交给我们自己了,正所谓划了多少水,迟早要还的。

首先,我们需要定义一个parent指针,因为一会儿需要用这个指针来连接子树。迭代步骤跟插入查找一致,此处不再赘述。退出第一个循环有2种情况: 树为空 或者 找到了待删除的节点。 如果是第一种情况,那好说,直接就啥事儿不用干,多好啊,哈哈哈。 主要就是看第二种情况,这种情况下仍然和之前递归删除的时候我们需要考虑的问题是一致的,仍然是4种case。

首先考虑最简单的case,当前待删除节点无左右儿子,此时parent指针指向的是父节点,但是有个问题需要注意哦,当前这个待删除节点是左儿子还是右儿子得根据父节点的值来确定哦。 NRD1.png

接着考虑待删除节点仅有右子节点的case,这个case和删除没有左右儿子节点的节点的case类似,只需要把当前待删除节点的右子树接上就可以了。同样,还是需要考虑是用父节点的左儿子指针接还是右儿子指针接。

待删除节点仅有左子节点的case和待删除节点仅有右子节点的case类似,此处也不再赘述。 NRD2.png

最后考虑同时有左右儿子的case,好想递归调用删除节点方法有没有?哈哈哈哈。既然我们使用非递归删除,那就完完全全的用非递归实现吧。我们先用一个变量先把当前这个节点的位置先记住,免得一会儿向下迭代的时候找不到了。还是和递归删除的思路一致,当前节点的左子树找一个最大值或者右子树里面找一个最小值。找到之后把这个值拷贝到之前我们事先记住的那个节点上去,然后删除这个节点,又变成了删除只有单子节点的case了。

这儿有一个需要注意的店是假设我们删除的时候以当前节点的左子树最大值替换待删除节点的值,一定不要忘了,左子树的最大值是有可能有左子树的,一定不要忘了把这个子树给拼接上,否则就会丢失这一大块存储区域

leftMaxParentNode.right = leftMaxNode.left // 拼接左子树,有可能没有左子树,但此步骤不能少。

NRD3.png

3、构建二叉搜索树

根据二叉搜索树的前序遍历还原二叉搜索树(节点值唯一):LeetCode第1008题

在前文我们已经阐述了二叉搜索树的性质,我们可以利用这个性质从先序序列来构建二叉搜索树。

首先,如果前序序列是空数组的话,那么说明当前树是空树,否则前序序列的第一个节点一定是二叉搜索树的根节点。

当我们拿到了根节点之后就可以根据二叉搜索树的性质确定一个边界,从第一个元素以后的位置上找出一个比根节点的值大的目标索引,如果能找得到的话,恭喜你,从这个索引开始到结束位置,都是二叉搜索树的右子树,那么从下标1到目标索引都是左子树。如果找不到的话,说明这个二叉搜索树没有右子树。从位置1开始到结束全部是左子树序列。

这是个递归的过程,我们递归这个操作即可还原这颗二叉搜索树。

算法实现如下:

/**
 * Definition for a binary tree node.
 * function TreeNode(val, left, right) {
 *     this.val = (val===undefined ? 0 : val)
 *     this.left = (left===undefined ? null : left)
 *     this.right = (right===undefined ? null : right)
 * }
 */
/**
 * @param {number[]} preorder
 * @return {TreeNode}
 */
var bstFromPreorder = function (preorder) {
  // 先序序列为空,说明是空树,直接可以返回
  if (!Array.isArray(preorder) || preorder.length <= 0) {
    return null;
  }
  // 我们可以很容易的确定先序序列的第一个元素就是树的根节点
  let rootVal = preorder[0];
  // 找到左右子树的分界点
  let boundaryIdx = preorder.findIndex((x) => x > rootVal);
  // 如果能够找到大于rootVal的值,说明有右子树;如果右子树存在的话,说明从1到boundaryIdx都属于左子树 否则,全部提取
  let leftTreeNodes = boundaryIdx < 0 ? preorder.slice(1) : preorder.slice(1, boundaryIdx);
  // 有右子树,从比rootVal大的位置开始,直接提取到数组的末尾,否则,说明不存在右子树
  let rightTreeNodes = boundaryIdx > 0 ? preorder.slice(boundaryIdx) : [];
  let bst = {
    left: bstFromPreorder(leftTreeNodes),
    val: rootVal,
    right: bstFromPreorder(rightTreeNodes),
  };
  return bst;
};

4、总结

二叉搜索树理想情况下查找删除的效率是非常高的,能达到O(logN),但是理想很丰满,现实很骨感。假设我们插入有序序列或者总是删除二叉搜索树的最大值或者最小值,最终我们的二叉搜索树竟然会成为一个链表,效率直接降到O(N),所以如何在插入或删除的时候总是使得我们的树左半部分和右半部分节点数差异不大,那么我们查找和删除的效率总能达到O(logN),这便是AVL树。AVL树在后面我们会详细讨论,正所谓好数据结构只学一个怎么够。

另外,二叉搜索树还有一个性质:二叉搜索树的中序遍历序列一定是一个严格递增的升序序列。,利用这条性质,可以解决很多问题。

由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,请联系作者本人,邮箱404189928@qq.com,你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。