2

二叉搜索树的本质 - 林子er

 1 year ago
source link: https://www.cnblogs.com/linvanda/p/17283480.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

打算写写树形数据结构:二叉查找树、红黑树、跳表和 B 树。这些数据结构都是为了解决同一个基本问题:如何快速地对一个大集合执行增删改查。

本篇是第一篇,讲讲搜索树的基础:二叉搜索树。

如何在一千万个手机号中快速找到 13012345432 这个号(以及相关联信息,如号主姓名)?

最笨的方案

把一千万个手机号从头到尾遍历一遍,直到找到该手机号,返回对应的姓名。其时间复杂度是 O(n)————当然这肯定不是我们要的方案。

最秀的方案

用散列表,可以在 O(1) 的时间复杂度完成查找。

关于散列表的原理和代码参见 算法(TypeScript 版本)

散列表的问题

散列表的查询性能非常优秀,插入和删除的性能也不赖,但它有什么问题呢?

我们稍微变换一下问题:如何在一千万个手机号中快速找到在 1301111111 到 13022222222 之间所有的手机号?

和基本问题不同的是,这是个范围查询。

散列表的本质是通过对关键字(本例中是手机号)执行 hash 运算,将其转换为数组下标,进而可以快速访问。

此处讲的数组是 C 语言意义上的数组,不是 javascript、PHP 等脚本语言中的数组。C 语言的数组是一段连续的内存片段,对数组元素的访问是通过内存地址运算进行的,可在常数时间内访问数组中任意元素。

hash 运算的特点是随机性,这也带来了无序性,我们无法保证 hash(1301111111) < hash(1301111112)。

无序性使得我们无法在散列表上快速执行范围查找,必须一个一个比较,时间复杂度又降到 O(n)。

基于有序数组的二分搜索

如果这一千万的手机号是排好序的(升序),我们有没有更好的办法实现上面的范围查找呢?

对于排好序的序列,我们如果能快速找到下限(1301111111)和上限(13022222222)所在的位置,那么两者之间所有的手机号就都是符合条件的。

如何才能快速找到 1301111111 的位置呢?

想想我们是怎么玩猜数字游戏的?

第一次猜 100,发现大了,第二次我们便倾向于猜 50 附近的数————而不是猜 99,如图:

基于二分查找思想的猜数字游戏

这种思想叫二分法————这种方法可以将问题范围成倍地缩小,进而可以至多尝试 log2nlog2⁡n 次即可找出解。对于一千万个手机号来说,至多只需要比较 24 次即可找出 1301111111 的位置。相比于一千万次,简直是天壤之别。

代码如下:

interface Value {
    // 为方便起见,这里限定 key 是数值类型
    key: number;
    val: unknown;
}

/**
 * 二分搜索
 * @param arr - 待搜索数组,必须是按升序排好序的(根据 Value.key)
 * @param key - 搜索关键字
 * @reutrn 搜索到则返回对应的 Value,否则返回 null
 */
function binSearch(arr: Value[], key: number): Value | null {
    if (arr.length === 0) {
        return null
    }

    // 子数组左右游标
    let left = 0
    let right = arr.length - 1

    while (left <= right) {
        // 取中
        const mid = left + Math.floor((right - left) / 2)
        const val = arr[mid]

        if (key === val.key) {
            return val
        }

        // key 小于 val 则在左边找
        if (key < val.key) {
            right = mid - 1
        } else {
            left = mid + 1
        }
    }

    return null
}

所以,如果需要对这一千万个手机频繁地执行范围查找,二分搜索法是个不错的选择:先对一千万个手机号执行排序,然后对排好序的序列执行二分搜索。

二分搜索的问题

二分搜索能很快地执行精确查找和范围查找,但它仍然存在问题。

对一百个元素执行二分搜索,必须能够在常数时间内定位到第 50 个元素————只有数组(C 语言意义上的)这种数据结构才能做到。

也就是说,必须用数组来实现二分搜索。

但数组有个很大的缺陷:对插入和删除操作并不友好,它们都可能会造成数组元素迁移。

比如要往有序数组 arr = [1, 2, 3, 4, 5 ..., 100] 中插入元素 0 且继续保证数组元素的有序性,则必须先将既有的一百个元素都往右移一位,然后将 0 写到 arr[0] 位置。删除元素则会造成后续元素的左移。

倘若插入和删除操作非常频繁,这种迁移(复制)操作会带来很大的性能问题。

可见,对有序数组的查询可以在 O(log2nlog2⁡n) 执行,但其写操作却是 O(n) 复杂度的。

有没有什么数据结构能够让读写操作都能在 O(log2nlog2⁡n) 内完成呢?

二分搜索的启发

二分搜索的优势是能够在一次操作中将问题范围缩小到一半,进而能够在对数的时间复杂度上求得问题的解。不过其劣势是依赖于数组,因而其插入和删除性能低下。

那么,我们现在的目的便是解决二分搜索的写(插入和删除)性能。

要想提高写性能,我们的直觉是摆脱数组这种数据结构的桎梏————是否有别的数据结构代替数组?

一个很自然的想法是链表。链表的优势在于其元素节点之间是通过指针关联的,这使得插入和删除元素时只需要变更指针关系即可,无需实际迁移数据。

// 链表的节点定义
interface Node {
    data: Value;
    next: Node;
}

然而,链表的劣势是查询:它无法像数组那样通过下标访问(而只能从头节点一个一个遍历访问),进而也就无法实现二分法。

如对于数组 arr = [1, 2, 3, 4, 5],我们能直接通过 arr[2] 访问中间元素;但对于链表 link = 1 -> 2 -> 3 -> 4 -> 5,由于不是连续内存地址,无法通过下标访问,只能从头开始遍历。

那么,我们如何解决链表的查询问题呢?或者说如何用链表来模拟有序数组的二分法呢?

二分法有两个操作:

  1. 取中。快速定位到中间位置的元素(对于有序序列来说就是中位数)。
  2. 比较。根据第一步取得的元素,决定后续操作:如果相等则返回;如果比目标大,则取左半部子集继续操作;如果比目标小,则取右半部子集继续操作。

那么,如何在链表上实现上面两个操作?

我们先考虑操作二:比较。如果比较结果不相等,则会去左边或者右边继续查找。我们可以改造一下链表节点,用左右指针来表示“左边”和“右边”,左右指针分别指向左子链表和右子链表。改造后的节点定义如下:

// 改进的节点定义
interface Node {
    data: Value;
    // 左指针
    left: Node;
    // 右指针
    right: Node;
}

由于改造后的链表节点有 left 和 right 两个指针,相当于一个节点分了两个叉,故名为二叉。

再考虑操作一:取中。取中将原数组一分为三:当前元素(中间元素)、左子数组、右子数组。

我们可以将它映射到改造后的链表中的当前节点、左(left)子链表、右(right)子链表。查找时,如果当前节点的值小于目标值,则通过 right 指针进入到右子链表中继续查找,反之通过 left 指针进入左子链表查找。

数组转链表

继续分析之前,我们先从直观上考察一下改造后的链表。分叉后,整个结构不再像单条链子,更像一棵树,于是我们不再称之为“二叉链表”,而是称作“二叉树”。对应地,左右子链表也更名为“子树”。

对应数组看,很容易知道,节点左子树中的所有元素都小于等于节点元素,右子树中的所有元素都大于等于节点元素————这是二叉搜索树最重要(甚至是唯一重要)的性质。

至此,我们用链表的节点代替数组的元素,用节点的左右指针(或者说左右指针指向的两棵子树)代替左右子数组。

现在还剩下最后一个问题:如何将数组中的每个元素映射到这棵二叉搜索树(或者叫“改造后的链表”)中?

既然二分法是不断地取数组(包括左右子数组)中间位置的元素进行比较,那么我们将取出来的元素从上到下(从树根开始)依次挂到这棵树的节点上即可,如此当我们从树根开始遍历时,拿到的元素的顺序便和从数组中拿到的一致。

我们以数组 arr = [1, 2, 3, 4, 5, 6, 7] 为例,看看如何生成对应的二叉搜索树。

有序数组转成二叉搜索树
  1. 先取整个数组中间元素 4,作为二叉树的根节点;
  2. 取左子数组的中间元素 2,作为根节点的左子节点;
  3. 取右子数组的中间元素 6,作为根节点的右子节点;
  4. 依此递归处理,直到取出数组中所有的元素生成二叉树的节点,整棵二叉树生成完成;

我们将上面的过程转换成代码:

// 二叉搜索树
class BinSearchTree {
    // 树根节点
    root: Node;
}

/**
 * 基于已排好序(根据 key)的数组 arr 构建平衡的二叉搜索树
 */
function buildFromOrderdArray(arr: Value[]): BinSearchTree {
    const tree = new BinSearchTree()
    // 从树根开始构建
    tree.root = innerBuild(arr, 0, arr.length - 1)

    return tree
}

/**
 * 基于子数组 arr[start:end] 构建一棵以 node 为根节点的二叉子树,返回根节点 node
 */
function innerBuild(arr: Value[], start: number, end: number): Node {
    if (start > end) {
        // 空
        return null
    } else if (start == end) {
        // 只剩下一个元素了,则直接返回一个节点
        return { data: arr[start], left: null, right: null }
    }

    /**
     * 使用二分思想构建二叉树
     */
     
    // 中间元素
    const mid = start + Math.floor((end - start) / 2)
    // 当前节点
    const curr: Node = { data: arr[mid], left: null, right: null }
    
    /**
     * 递归生成左右子树
     */
    // 左子树
    curr.left = innerBuild(arr, start, mid - 1)
    // 右子树
    curr.right = innerBuild(arr, mid + 1, end)

    return curr
}

二叉搜索树的查找

二叉搜索树是基于二分搜索思想构建的,其搜索逻辑也和二分搜索相同,只不过将左右子数组替换成左右子树。

以搜索元素 13 为例:

二叉搜索树查找元素

上图中搜索步骤:

  1. 从根节点开始比较,15 大于 13,到本节点的左子树继续搜索;
  2. 节点 6 小于 13,到本节点的右子树继续搜索;
  3. 节点 7 小于 13,到本节点的右子树继续搜索;
  4. 节点 13 等于 13,找到目标节点,结束;

对比二分搜索可以发现,二叉搜索树中的 left 和 right 子树就是对应二分搜索中左右子数组,两者的搜索逻辑本质上是一致的。

代码如下:

class BinSearchTree {
    // 树根节点
    root: Node;
    
    /**
     * 在以 node 为根的子树中搜索关键字为 key 的节点并返回该节点
     * 如果没有找到则返回 null
     */
    search(key: unknown, node: Node = undefined): Node {
        // 默认取根
        node = node === undefined ? this.root : node
        
        // 遇到 null 节点,说明没搜到,返回 null
        if (!node) {
            return null
        }
        
        // 先判断当前节点
        if (node.data.key === key) {
            // 找到,即可返回
            return node
        }
        
        // 没有找到,则视情况继续搜索左右子树
        if (key < node.data.key) {
            // 目标值小于当前节点,到左子树中搜索
            return this.search(key, node.left)
        }
        
        // 目标值大于等于当前节点,到右子树中搜索
        return this.search(key, node.right)
    }
}

从图中可见,对于任何元素的搜索,搜索次数不可能大于从根到所有叶节点的最长路径中节点个数(上图中是 5)。如果用这条路径的边来表达的话,搜索次数不可能超过最长路径边数加 1。

这个最长路径的边数即是整棵树的高。

对于一颗完美的平衡二叉树来说,这个高 h = log2nlog2⁡n,其中 n 是节点数量。因而说二叉搜索树的查询时间复杂度是 O(log2nlog2⁡n),和二分搜索是一致的。

注意上面说的是完美的平衡二叉树,但二叉搜索树并不是天生平衡的,所以才引出了各种平衡方案,诸如 2-3 树、红黑树、B 树等。

特殊查找:最小元素

由于二叉搜索树中的任意一个节点,其左边元素总小于该节点,所以要找最小元素,就是从根节点开始一直往左边找。

最小元素

代码如下:

class BinSearchTree {
    // 树根节点
    root: Node;
    
    /**
     * 查找以 node 为根的子树的最小节点并返回
     */
    min(node: Node = undefined): Node {
        // 默认取根节点
        node = node === undefined ? this.root : node
        
        if (node === null || !node.left) {
            // 如果是空子树,或者 node.left 是空节点,则返回
            return node
        }
        
        // 存在左子树,继续往左子树中找
        return this.min(node.left)
    }
}

相对应的是最大值,也就是递归地往右边找,此处略。

对于有序数组,很容易通过循环从头到尾按序遍历数组中元素,对应地,如何按序遍历二叉搜索树呢?

二叉搜索树是根据二分法递归生成的,所以同样可以用二分法来解决此问题。

对于一棵树来说,它分为三部分:树根节点、左子树、右子树,其中大小关系是:左子树 <= 树根节点 <= 右子树,所以我们以这个顺序遍历整棵树,便可以按序输出。

1997761-20230403160906615-1124980899.png

这种遍历方式,由于是在中间步骤操作树根节点,又称之为中序遍历

相应地,按“树根节点 -> 左子树 -> 右子树”的顺序遍历称之为先序遍历,按“左子树 -> 右子树 -> 树根节点”的顺序遍历称之为后序遍历

中序遍历代码:

class BinSearchTree {
    // 树根节点
    root: Node;
    
    /**
     * 中序遍历
     */
    inorder(): Node[] {
        const arr: Node[] = []
        this.innerInorder(this.root, arr)

        return arr
    }

    /**
     * 对 x 子树执行中序遍历,遍历的节点放入 arr 中
     */
    innerInorder(x: Node, arr: Node[]) {
        if (!x) {
            return
        }

        // 先遍历左子树
        this.innerInorder(x.left, arr)
        // 自身
        arr.push(x)
        // 右子树
        this.innerInorder(x.right, arr)
    }
}

如何在二叉搜索树上执行范围查询?

问题:按序返回二叉搜索树中所有大于等于 start 且小于等于 end 的节点集合(即返回所有节点 x,x 满足:start <= x <= end)。

上面的中序遍历其实就是一种特殊的范围查询:min <= x <= max。所以范围查询的思路和中序遍历一样,只不过在遍历时加上范围限制。

具体来说,什么时候需要去查左子树呢?当左子树有可能存在符合条件的元素时需要去查。如果当前节点 x 的值小于范围下限(start),而 x 的左子树的值都小于等于 x 的,说明此时其左子树中不可能存在符合条件的节点,无需查询;或者,如果 x 的值大于范围上限(end),而 x 的右子树的值都大于等于 x 的,说明此时其右子树中不可能存在符合条件的节点,也无需查询。其他情况则需要查询。

范围查询

代码如下:

class BinSearchTree {
    // 树根节点
    root: Node;
    
    /**
     * 按序返回所有大于等于 start 且小于等于 end 的节点集合
     */
    range(start: unknown, end: unknown): Node[] {
        const arr: Node[] = []
        this.innerRange(this.root, start, end, arr)
        return arr
    }
    
    /**
     * 在 x 子树中查找所有大于等于 start 且小于等于 end 的节点并放入 arr 中
     */
    innerRange(x: Node, start: unknown, end: unknown, arr: Node[]) {
        if (!x) {
            return
        }
        
        // 比较节点 x 和 start、end 之间的大小关系
        const greaterThanStart = x.data.key >= start
        const smallerThanEnd = x.data.key <= end

        // 如果当前节点大于等于 start,则需要搜索其左子树
        if (greaterThanStart) {
            this.innerRange(x.left, start, end, arr)
        }
        
        // 如果 x 在 start 和 end 之间,则符合条件,存入 arr
        if (greaterThanStart && smallerThanEnd) {
            arr.push(x)
        }
        
        // 如果当前节点小于等于 end,则需要搜索其右子树
        if (smallerThanEnd) {
            this.innerRange(x.right, start, end, arr)   
        }
    }
}

对于二叉树来说,新节点总是被插入到 null 节点(末端)处。

还是以上图为例,插入新节点 14:

插入元素

如图所示,插入操作分两步:

  1. 搜索。这一步和查找操作一样,相当于是搜索这个新节点 14,结果没搜到,遇到了 null 节点(Node(13).right);
  2. 插入。生成新节点 14 并插入到节点 13 的右侧(Node(13).right = Node(14));

很明显,插入操作的时间复杂度也是 O(log2nlog2⁡n),完美!

插入操作的代码如下:

class BinSearchTree {
    // 树根节点
    root: Node;
    
    /**
     * 将元素 data 插入到树中
     */
    insert(data: Value) {
        // 从根节点开始处理
        // 插入完成后,将新根赋值给 root
        this.root = this.innerInsert(data, this.root)
    }
    
    /**
     * 将元素 data 插入到以 node 为根的子树中
     * 返回插入元素后的子树的根节点
     */
    innerInsert(data: Value, node: Node): Node {
        if (node === null) {
            // 遇到了 null 节点,说明需要插入到该位置
            return { data: data, left: null, right: null }
        }
        
        // 比较 data 和 node 的值,视情况做处理
        if (data.key < node.key) {
            // 待插入的元素小于当前节点,需要插入到当前节点的左子树中
            node.left = this.innerInsert(data, node.left)
        } else {
            // 插入到右子树中
            node.right = this.innerInsert(data, node.right)
        }
        
        // 插入完成后,需返回当前节点
        return node
    }
}

删除操作需要分几种情况。

情况一:删除叶子节点,该节点的 left 和 right 都是 null。

这种情况很简单,直接删掉该元素即可。如删除节点 9:

删除情况一

情况二:待删除的节点只有一个子节点,用该子节点代替该节点即可。如删除节点 13:

删除情况二

以上两种情况都比较简单,第三种情况则稍微复杂。

情况三:待删除的节点有左右两个子节点。如图中节点 6。将 6 删除后,我们无法简单的用其 left 或 right 子节点替代它————因为这会造成两棵子树一系列的变动。

前面说过,二叉搜索树本质上是由有序数组演化而来,那么我们不妨以数组的角度看是否有所启示。

上图用数组表示:arr = [2, 3, 4, 6, 7, 9, 13, 15, 18, 17, 20]。该数组中删掉元素 6 后,如何才能让数组中其他元素调整次数最少(这里不考虑迁移,因为二叉树不存在迁移开销)?

自然是直接用 6 的前一个(4)或者后一个(7)元素替代 6 的位置。其中 4 恰恰是 6 左边子数组中的最大值,而 7 恰恰是其右边子数组中的最小值。

我们不妨用右边子数组中最小值(7)来替代 6————映射到二叉搜索树中便是节点 6 的右子树的最小节点。

前面已经讨论过二叉搜索树中最小值的求解逻辑:顺着节点左子树(left)一直递归查找,直到 node.left 等于 null,该 node 便是最小值————也就是说,一棵树的最小节点不可能有左子节点,即最小节点最多有一个子节点,这便是情况一或者情况二。

那么能否用右子树中最小节点(7)替代当前节点(6)呢?无论从有序数组还是从二叉搜索树本身角度看,都很容易证明是可以的(替换后仍然符合二叉搜索树的性质)。

因而,情况三可以作如下处理:

  1. 将右子树中最小节点的 data 赋值给当前节点;
  2. 删除右子树中最小节点;
删除情况三

代码如下:

class BinSearchTree {
    // 树根节点
    root: Node;
    
    // ...
    
    /**
     * 删除 key 对应的节点
     */
    delete(key: unknown) {
        const node = this.search(key, this.root)
        if (!node) {
            // key 不存在
            return
        }
        
        this.root = this.innerDelete(this.root, node)
    }
    
     /**
     * 删除子树 current 中 del 节点,并返回操作完成后的子树根节点
     */
    innerDelete(current: Node, del: Node): Node {
        /**
         * 当前节点即为待删除节点
         */
        if (current === del) {
            // 情况一:当前节点没有任何子节点,直接删除
            if (!current.left && !current.right) {
                return null
            }
            
            // 情况二:只有一个子节点
            if (current.left && !current.right) {
                // 只有左子节点,用左子节点替换当前节点
                return current.left
            }
            
            if (current.right && !current.left) {
                // 只有右子节点,用右子节点替换当前节点
                return current.right
            }
            
            // 情况三:有两个子节点
            // 取右子树的最小节点
            const minNode = this.min(current.right)
            // 用最小节点的值替换当前节点的
            current.data = minNode.data
            // 删除右子树中的最小节点
            current.right = this.innerDelete(current.right, minNode)

            return current
        }
        
        /**
         * 当前节点不是待删除节点,视情况递归从左或右子树中删除
         */
        if (del.data.key < current.data.key) {
            // 待删除节点小于当前节点,从左子树删除
            current.left = this.innerDelete(current.left, del)
        } else {
            // 待删除节点大于当前节点,继续从右子树删除
            current.right = this.innerDelete(current.right, del)
        }
        
        return current
    }
}

很容易证明,删除操作的时间复杂度也是 O(log2nlog2⁡n)。

二叉搜索树的问题

由上面的插入操作可知,二叉搜索树新增节点时总是往末端增加新节点,这种操作方式有个问题:当我们一直朝同一个方向(一直向左或者一直向右)插入时,便很容易出现倾斜。

比如我们向一棵空树中依次插入 1, 2, 3, 4, 5:

const tree = new BinSearchTree()
tree.insert({ key: 1, val: 1 })
tree.insert({ key: 2, val: 2 })
tree.insert({ key: 3, val: 3 })
tree.insert({ key: 4, val: 4 })
tree.insert({ key: 5, val: 5 })

得到的二叉树如下:

倾斜二叉树

二叉树退化成了普通链表,所有的操作退化为 O(n) 复杂度!

所以在插入和删除二叉树节点时,需要执行额外的操作来维护树的平衡性,后面将要介绍的红黑树和 B 树就是两种非常著名的解决方案。

  1. 散列表能很好地解决精确查询(O(1) 复杂度),但无法解决范围查询(必须 O(n) 复杂度);
  2. 基于有序数组的二分搜索能很好地解决精确查询和范围查询(O(log2nlog2⁡n) 复杂度),但无法解决插入和删除(必须 O(n) 复杂度);
  3. 基于二分搜索思想改进的链表(二叉搜索树)能很好地解决查询(包括范围查询)、插入和删除,所有的操作都是 O(log2nlog2⁡n) 的时间复杂度;
  4. 二叉搜索树中以任意节点作为根的子树仍然是一棵二叉搜索树,这个特点很重要,它是递归操作的关键;
  5. 二叉搜索树存在节点倾斜问题,会降低操作性能,极端情况下会退化成普通链表,所有的操作都退化到 O(n) 复杂度;
  6. 为解决二叉搜索树的倾斜问题,实际应用中需引入相关平衡方案,本系列的后序文章将介绍三种常见的方案:红黑树、跳表和 B 树;

本文中的示例代码采用 TypeScript 语言实现,且用递归法写的,完整代码参见二叉搜索树(递归法)。非递归法代码参见二叉搜索树(循环法)


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK