Compose 系列【3】PrioritySet 最大堆

119 阅读6分钟
internal class PrioritySet(private val list: MutableList<Int> = mutableListOf())  

从 PrioritySet 的声明可以了解到的信息包括:

  • 这是一个内部类;
  • 包含一个可变列表参数,默认为空列表,列表保存 Int 类型的元素。

PrioritySet 类方法列表:

internal class PrioritySet(private val list: MutableList<Int> = mutableListOf()) {
  	// 添加数据
		fun add(value: Int)
  	// 判空
  	fun isEmpty(): Boolean
  	fun isNotEmpty(): Boolean
  	// 查看堆顶元素
  	fun peek(): Int
  	// 从堆中移除并返回最大值(不重复)
  	fun takeMax(): Int
  	// 验证堆(Heap)数据结构是否满足堆性质的函数
  	fun validateHeap()
}

向堆中添加元素

    // Add a value to the heap
    fun add(value: Int) {
        // Filter trivial duplicates
        if (list.isNotEmpty() && (list[0] == value || list[list.size - 1] == value)) return

        var index = list.size
        list.add(value)

        // Shift the value up the heap.
        while (index > 0) {
            val parent = ((index + 1) ushr 1) - 1
            val parentValue = list[parent]
            if (value > parentValue) {
                list[index] = parentValue
            } else break
            index = parent
        }
        list[index] = value
    }
  • 第一步先过滤重复项,若 list 不为空且插入值与 list 的第一个元素的值或最后一个元素值相等,则直接 return。否则继续执行下一步;
  • 第二步取列表的长度然后将其作为 index 添加到可变列表 list;
  • 当 index 大于 0 时,开启一个循环
    • 计算当前节点的父节点的索引,(index + 1) ushr 1 当前值 + 1 再右移(相当于除以 2),即取出 index 到 0 之间的中间值,为父节点的索引。
    • 然后通过父节点的索引取出父节点中的值。
    • 比较父节点的值和当前插入值,如果当前插入值大于父节点中的值,更新列表的最后一个元素为父节点的值,然后将插入值的索引 index 更新为父节点的索引值。
    • 否则跳出循环。
  • 结束循环后,将插入值更新到最最新的索引值 index 位置上。

这是一个通过列表维护一个二叉树的结构,维护一个最大堆的特性,操作如下:

  • 计算当前节点的父节点索引。
  • 比较当前值与父节点的值。
  • 如果当前值大于父节点的值,则将父节点的值下移到当前节点,并更新当前索引为父节点索引。
  • 如果当前值不大于父节点的值,则退出循环。

最大堆

最大堆是一个完全二叉树,即除了最后一层,其他层都是满节点的。并且最后一层是从左到右排序。

在最大堆中,每个节点的值都大于或等于其子节点的值。这意味着根节点的值最大。

每个节点的左子节点大于右子节点

常见的实现方法是使用数组来表示堆。对于索引为 i 的节点,其父节点和子节点的位置可以通过以下公式计算:

  • 父节点索引:P(i) = (i - 1) // 2// 运算为整数除法向下取整)
  • 左子节点索引:L(i) = 2 * i + 1
  • 右子节点索引:R(i) = 2 * i + 2

最大堆数组通常是层次遍历的顺序保存二叉树的值。

判空函数

判空方法是直接通过判断参数 list 是否为空:

    fun isEmpty() = list.isEmpty()
    fun isNotEmpty() = list.isNotEmpty()

查看堆顶

fun peek() = list.first()

最大堆的堆顶一般是第一个元素。

验证堆

    @Suppress("ExceptionMessage")
    fun validateHeap() {
        val size = list.size
        for (index in 0 until size / 2) {
            val left = (index + 1) * 2 - 1
            val right = (index + 1) * 2
            check(list[index] >= list[left])
            check(right >= size || list[index] >= list[right])
        }
    }

根据 list 的元素数量进行 0 到 size 的一半遍历,为什么是一半呢?因为在一个堆结构中,所有的叶子节点都位于数组的后半部分,而非叶子节点(即具有至少一个子节点的节点)位于数组的前半部分。

验证方法也是安装左右节点的索引公式,计算出左右子节点的索引,然后根据最大堆的两个特性进行检查:

  • 每个节点的值都大于或等于其子节点的值
  • 每个节点的左子节点大于右子节点

移除并返回最大值

从堆中取出最大值(移除并返回),同时处理堆中的重复值。

    fun takeMax(): Int {
        runtimeCheck(list.size > 0) { "Set is empty" }
        val value = list[0]
        while (list.isNotEmpty() && list[0] == value) {
            list[0] = list.last()
            list.removeAt(list.size - 1)
            var index = 0
            val size = list.size
            val max = list.size ushr 1
            while (index < max) {
                val indexValue = list[index]
                val left = (index + 1) * 2 - 1
                val leftValue = list[left]
                val right = (index + 1) * 2
                if (right < size) {
                    val rightValue = list[right]
                    if (rightValue > leftValue) {
                        if (rightValue > indexValue) {
                            list[index] = rightValue
                            list[right] = indexValue
                            index = right
                            continue
                        } else break
                    }
                }
                if (leftValue > indexValue) {
                    list[index] = leftValue
                    list[left] = indexValue
                    index = left
                } else break
            }
        }
        return value
    }

首先进行 list 检查:

internal inline fun runtimeCheck(value: Boolean, lazyMessage: () -> Any) {
    if (!value) {
        val message = lazyMessage()
        composeRuntimeError(message.toString())
    }
}

internal fun composeRuntimeError(message: String): Nothing {
    throw ComposeRuntimeError(
        "Compose Runtime internal error. Unexpected or incorrect use of the Compose " +
            "internal runtime API ($message). Please report to Google or use " +
            "https://goo.gle/compose-feedback"
    )
}

如果没有元素抛出 ComposeRuntimeError。

取出第一个元素,开启一个循环,当 list 不为空且 list 的第一个元素与预先取出的值相等时进行循环:

// in takeMax()
list[0] = list.last() // 将最后一个元素移动到堆顶
list.removeAt(list.size - 1) // 移除最后一个元素
var index = 0 // 内部声明一个 index 指针
val size = list.size // 取出新的 list 长度
val max = list.size ushr 1 // list 长度的一半,也就是之前用来验证堆性质时遍历的数量
// 开启一个循环,遍历所有非叶子结点
while (index < max) {
    val indexValue = list[index]  	// 取出 index 位置的值
    val left = (index + 1) * 2 - 1 	// index 索引节点的左子节点索引
    val leftValue = list[left] 			// index 索引节点的左子节点的值
    val right = (index + 1) * 2			// index 索引节点的右子节点索引
  	// 右子节点索引小于最大索引值,防止右子节点索引超出 list 最大长度
    if (right < size) { 						
        val rightValue = list[right] // index 索引节点的右子节点的值
      	// 如果右子节点的值大于左子节点的值
        if (rightValue > leftValue) {
          	// 右子节点大于父节点的值
            if (rightValue > indexValue) {
              	// 交换位置,右子节点变更为父节点
                list[index] = rightValue
              	// 交换位置,父节点的值移动到右节点
                list[right] = indexValue
                index = right // index 索引指向右子节点,继续下一轮节点交换
                continue
            } else break // 右子节点本身小于左子节点的值,满足最大堆特性,无需其他操作
        }
    }
  	// 如果左子节点的值大于父节点的值,两者交换位置,index 指向新的左子节点
    if (leftValue > indexValue) {
        list[index] = leftValue
        list[left] = indexValue
        index = left
    } else break
}
return value

通过阅读代码,这个函数的基本逻辑就是:取出最大堆中最大的值,重新对二叉树进行更新排序,确保满足最大堆的特性。

总结

PropertySet 实现了一个集合,允许将整数记录到集合中,并高效地从集合中提取最大的最大值。它使用堆结构来实现堆排序,确保即使值反复添加和移除,添加或移除值的操作也是 O(log N) 的时间复杂度。

它是 Compose 底层数据结构 SlotTable 中使用到的一环,本质上还是学习了解最大堆的实现逻辑。