Python 版 TimSort 算法


根据 JDK1.8 的 Collections.sort() 源码翻译的 Python 版 TimSort 代码。

1 总结

  • 小数组无需归并,使用二分插入排序返回即可。大数组需要归并,先计算最小 run 长度,长度不足此值的 run 需要用二分插入排序补足。
  • 整个流程是从左到右遍历数组一次,每识别出一个 run 就入栈,并检查栈顶的三个 run,如果符合归并条件则归并其中两个相邻的 run。遍历结束后,把堆栈中剩余的 run 都归并成一个,完成排序。
  • 具体来说,每次归并开始前,先通过 gallop 查找法分别缩小两个 run 的待归并区间。
  • 为了节省空间,把较短的 run 拷贝到临时数组中,arr 上空出来的区域用于保存归并值。
  • 为了减少比较次数,如果左 run 更短则从左侧开始检查,选择小值保存到 arr 上;如果右 run 更短则从右侧开始检查,选择大值保存到 arr 上。
  • 归并时,先使用【普通归并模式】,一次一对地检查,如果其中一个 run 的连续胜出次数达到触发阈值,则切换到【Gallop 模式】,即通过 gallop 查找法,一次性找出所有能连续胜出的值。如果达不到停留阈值,则再切换回【普通归并模式】。
  • 在归并过程中,根据【Gallop 模式】的停留轮次、退出次数,自适应地调整触发阈值。

run :数组中本身存在的有序区间,如果是降序则会就地转成升序。

gallop 查找法:先通过指数查找法(1、3、7、15、31、63 … 2^n-1),快速找到正确位置的大致范围,然后在这个这个范围中用二分查找法锁定最终的正确位置。

2 源码分析

2.1 init

__init__ 方法初始化了各项阈值、堆栈。

MIN_MERGE

  • 使用归并的最小数组长度,必须是 2 的幂次方。
  • 如果数组长度小于此值,将直接使用二分插入排序,无需归并;长度大于此值才会分割成多个 run 进行归并。
  • Tim Peter’s 在 CPython 实现中使用的是 64,JAVA 出于自身性能的考虑,使用的是 32。

MIN_GALLOP

  • GALLOP 停留阈值,控制能否在【GALLOP 模式】中继续停留。
  • 当进入了【GALLOP 模式】后,只要此轮某个 run 的续胜出次数还能超过此阈值,下轮就继续停留在【GALLOP 模式】;
  • 反之,如果此轮两个 run 的续胜出次数都低于此阈值,则退出【GALLOP 模式】。

min_gallop

  • GALLOP 触发阈值,控制能否进入【GALLOP 模式】,初始值等于 MIN_GALLOP
  • 在【普通归并模式】中,如果某个 run 的续胜出次数超过了 min_gallop,则退出【普通归并模式】,进入【GALLOP 模式】。
  • mergeLo 和 mergeHi 这两个方法会调整 min_gallop 的值,具体来说:
  • 如果能够在【GALLOP 模式】中多停留一轮,min_gallop 就会减 1,则下次进入【GALLOP 模式】就会更容易些。
  • 当退出【GALLOP 模式】时,说明即数据的连续性变弱了,则予以一定的惩罚,即 min_gallop 加 2,则下次进入【GALLOP 模式】就会更难些。

堆栈
保存待归并的 run 的堆栈,是一个虚拟的堆栈,通过 3 个变量来定义:

  • stack_size:堆栈中待归并 run 的个数。
  • run_base:是一个 list,其元素是每个 run 在 arr 中的起始下标。
  • run_len:是一个 list,其元素是每个 run 的长度。

对于第 i 个 run:它在 arr 中的下标从 run_base[i] 开始,共包含 run_len[i] 个元素,并且总是满足:run_base[i] + run_len[i] == run_base[i + 1]

class TimSort:

    # 触发归并的最小长度,必须是 2 的倍数。
    MIN_MERGE = 32
	# GALLOP 停留阈值
    MIN_GALLOP = 7
	# GALLOP 触发阈值
    min_gallop = MIN_GALLOP

    def __init__(self):
        """
        初始化保存待归并 run 的堆栈
        """
        self.stack_size = 0
        self.run_base = []
        self.run_len = []

2.2 sort

sort 方法是入口,控制了整个排序流程。

小数组无需归并

  • 如果 arr 长度小于 MIN_MERGE,使用二分插入排序返回即可。

大数组需要归并

  • 先计算最小 run 长度 min_run,然后开始循环,每轮识别出一个 run 入栈,并检查堆栈中的 run,如果符合归并条件则归并。
  • 识别 run 时是先寻找 arr 本身的有序部分作为初始 run,如果其长度达不到 min_run,再使用二分插入排序补足。
  • 循环结束后,把堆栈中剩余的 run 都归并成一个,完成排序。
    def sort(self, arr, lo, hi):
        """
        排序方法
        :param arr: 待排序数组
        :param lo: 待排序区间的第一个元素下标, 包含该元素
        :param hi: 待排序区间的最后一个元素下标, 不包含该元素
        :return: 无
        """
        assert (arr is not None) and (lo >= 0) and (lo <= hi) and (hi <= len(arr))
        # n_remaining 是待排序区间的元素个数
        n_remaining = hi - lo
        # 当 arr 中只有一个或零个元素需要排序时直接返回
        if n_remaining < 2:
            return arr

        # 如果 arr 长度小于 MIN_MERGE(32),使用二分插入排序即可,无需归并
        if n_remaining < self.MIN_MERGE:
            init_run_len = self.countRunAndMakeAscending(arr, lo, hi)
            self.binarySort(arr, lo, hi, lo + init_run_len)
            return arr

        # 根据待排序元素个数计算 min_run
        min_run = self.minRunLength(n_remaining)

        # 从左到右遍历 arr,每轮构造一个 run 并入栈,同时检查堆栈中的 run,如果符合归并条件则归并
        while n_remaining:
            # 找到 arr 本身已有升序部分视为初始 run
            run_len = self.countRunAndMakeAscending(arr, lo, lo + n_remaining)
            # 当初始 run 长度不足 min_run 时,使用二分插排法把它的长度扩充至 min(min_run, n_remaining)
            if run_len < min_run:
                force = n_remaining if n_remaining <= min_run else min_run
                self.binarySort(arr, lo=lo, hi=lo+force, start=lo+run_len)
                run_len = force

            # 把 run 的起始位置和长度这两个变量入栈
            self.pushRun(lo, run_len)
            # 检查堆栈中的 run,如果符合归并条件则归并
            self.mergeCollapse(arr)

            # 更新下个 run 的起始位置、剩余待排序元素个数
            lo += run_len
            n_remaining -= run_len

        # 循环结束时两指针必然相遇
        assert lo == hi
        # 对堆栈中剩余的 run 做归并,完成排序
        self.mergeForceCollapse(arr)
        # 排序完成时堆栈中必然只剩一个 run
        assert self.stack_size == 1

        return arr

2.3 binarySort

binarySort 方法是二分插入排序算法的实现,并进行了优化:

  • 通过 start 参数能跳过已知有序区,直接从无序区开始排序。
    def binarySort(self, arr, lo, hi, start):
        """
        对数组的指定区间进行二分插入排序,通过 start 参数能跳过已知有序区,直接从无序区开始排序。
        :param arr: 待排序数组
        :param lo: 待排序区间的第一个元素下标, 包含该元素
        :param hi: 待排序区间的最后一个元素下标, 不包含该元素
        :param start: 已知无序区的第一个元素下标
        :return: 无
        """
        assert (lo <= start) and (start <= hi)
        if lo == start:
            start += 1

        while start < hi:
            pivot = arr[start]

            left = lo
            right = start
            assert left <= right

            # 二分法查找待插入元素的正确位置
            while left < right:
                mid = (left + right) >> 1
                if pivot < arr[mid]:
                    right = mid
                else:
                    left = mid + 1

            # 两指针相遇的位置就是待插入元素的正确位置
            assert left == right

            # n 是要移动的元素个数
            n = start - left
            arr[left + 1:left + 1 + n] = arr[left:left + n]
            arr[left] = pivot

            # 继续对下一个元素排序
            start += 1

2.4 countRunAndMakeAscending

countRunAndMakeAscending 负责识别 run:

  • 从数组的指定位置开始寻找有序部分作为初始 run,如果是降序则就地转成升序,返回有序部分的长度。
  • 为了确保排序的稳定性,在寻找升序部分时使用 <=(不严格递增),而寻找降序部分时使用 >(严格递减),从而把降序部分转成升序后仍然是稳定的。
    def countRunAndMakeAscending(self, arr, lo, hi):
        """
        从数组的指定位置开始寻找有序部分作为初始 run,如果是降序则就地转成升序,返回有序部分的长度。
        :param arr: 待排序数组
        :param lo: 待搜索区间的起始元素下标, 包含该元素
        :param hi: 待搜索区间的结束元素下标, 不包含该元素
        :return: 有序部分的长度
        """
        assert lo < hi

        # 从待搜索区间的第二个元素开始检查
        run_hi = lo + 1
        # 当待搜索区间只有一个元素时,直接返回 1
        if run_hi == hi:
            return 1

        # 降序(严格递减)
        if arr[run_hi] < arr[lo]:
            run_hi += 1
            # 循环结束时,run_hi 指针指向无序区的开始下标
            while run_hi < hi and arr[run_hi] < arr[run_hi - 1]:
                run_hi += 1
            # 把降序部分就地转成升序
            arr[lo:run_hi] = reversed(arr[lo:run_hi])
        # 升序(不严格递增)
        else:
            # 循环结束时,run_hi 指针指向无序区的开始下标
            while run_hi < hi and arr[run_hi] >= arr[run_hi - 1]:
                run_hi += 1

        # 返回有序部分的长度
        return run_hi - lo

2.5 minRunLength

minRunLength 负责根据待排序元素个数计算最小 run 长度:

  • n < MIN_MERGE(32)时,直接返回 n。小数组直接使用二分插排即可,无需归并。
  • 当 n 刚好是 2 的幂次方时,则返回的 min_run 都是 16,此时 n / min_run 等于 2 的幂次方,是完美的归并。
  • 其他情况下,返回的 min_run 的取值范围是 [16, 32],此时 n / min_run 略小于 2 的幂次方,是近似完美的归并(没有特别短的 run)。例如 n = 63 为奇数即 r = 1,且 63 >> 1 = 31,所以 min_run 为 31 + 1 = 32。此时 63 / 32 = 1.96875 略小于 2,即会有两个 run,一个长度为 32,另一个长度为 31。反之,当 n / min_run 大于 2 的幂次方时,例如 63 / 31 = 2.0323 时,会有三个 run,两个长度为 31,第三个长度为 1,存在特别短的 run 时归并效果会大打折扣。

可以看出,核心是要控制 run 的个数刚好是 2 的幂次方(2、4、8、16 …)。为了满足这一点,最后一个 run 的长度可以略小于 min_run,但不能特别短。

    def minRunLength(self, n):
        """
        根据待排序元素个数计算 min_run(最优的 run 长度)。
        :param n: 待排序元素个数
        :return: min_run(最优的 run 长度)
        """
        assert n >= 0
        r = 0
        while n >= self.MIN_MERGE:
            # 【n 与 1】的结果只能为 0 或 1,用于判断奇偶
            # 【r 或 奇偶】的结果只能为 0 或 1,用于保持奇性,一旦某轮循环中出现了奇数,r 就一直为 1
            r |= n & 1
            # 【n 右移 1位】相当于除 2 并向下取整,即 n // 2
            n >>= 1
        return n + r

2.6 pushRun

pushRun 负责把 run 入栈:

  • 把 run 的起始位置和长度这两个变量放入堆栈。
    def pushRun(self, run_base, run_len):
        """
        把 run 的起始位置和长度这两个变量放入堆栈。
        :param run_base: run 的起始位置
        :param run_len: run 的长度
        :return:
        """
        # 避免下标超界
        if len(self.run_base) <= self.stack_size:
            self.run_base.append(run_base)
            self.run_len.append(run_len)
        else:
            self.run_base[self.stack_size] = run_base
            self.run_len[self.stack_size] = run_len
        # 堆栈中的 run 个数加 1
        self.stack_size += 1

2.7 mergeForceCollapse

mergeForceCollapse 负责强制合并堆栈中的所有 run:

  • 归并堆栈中的所有 run,直到只剩一个,这个方法只会在最后调用一次来完成排序。
  • 如果堆栈中有三个及以上的 run,则把倒二与较短那个的 run 合并,即 min(倒一, 倒三)。
    def mergeForceCollapse(self, arr):
        """
        归并堆栈中的所有 run,直到只剩一个,这个方法只会调用一次来完成排序。
        """
        while self.stack_size > 1:
            # n 是倒数第二个 run 在堆栈中的下标,n-1 是倒数第三个 run 的堆栈下标,n+1 是倒数第一个 run 的堆栈下标
            n = self.stack_size - 2
            # 如果堆栈中有三个及以上的 run,则把倒二与较短那个的 run 合并,具体来说:
            # 如果倒三长度 < 倒一长度:则 n 变成倒三,归并倒三和倒二这两个 run;
            # 否则:n 仍然是倒二,归并倒二和倒一这两个 run。
            if n > 0 and self.run_len[n - 1] < self.run_len[n + 1]:
                n -= 1
            self.mergeAt(arr, n)

2.7 mergeCollapse

mergeCollapse 负责检查堆栈中的 run,如果符合以下条件则归并:

  • 如果堆栈中有三个及以上的 run,则归并条件为:倒三长度 <= 倒二长度 + 倒一长度
  • 如果堆栈中只有两个 run,则归并条件为:倒二长度 <= 倒一长度

这样设计是为了平衡合并时 run 的长度,同时保持堆栈中 run 的数量尽量少。

举例

对于随机数据(n = 129),很可能所有 run 都是通过调用二分插排算法构造出来的,前 7 个run 的长度都是 min_run(17),最后一个 run 的长度为 10,其归并过程如下:

  • 推入第 1 个 run 时:堆栈中只有 1 个 run(run_len = [17]),未进入循环,返回。
  • 推入第 2 个 run 时:堆栈中有 2 个 run(run_len = [17, 17]),符合归并条件 2,合并后堆栈中只有 1 个 run(run_len = [34]),循环结束,返回。
  • 推入第 3 个 run 时:堆栈中有 2 个 run(run_len = [34, 17]),不符合任何归并条件,打破循环,返回。
  • 推入第 4 个 run 时:堆栈中有 3 个 run(run_len = [34, 17, 17]),符合归并条件 1,合并后堆栈中还有 2 个 run(run_len = [34, 34]),仍然符合归并条件 2,合并后堆栈中只有 1 个 run(run_len = [68]),循环结束,返回。
  • 推入第 5 个 run 时:堆栈中有 2 个 run(run_len = [68, 17]),不符合任何归并条件,打破循环,返回。
  • 推入第 6 个 run 时:堆栈中有 3 个 run(run_len = [68, 17, 17]),符合归并条件 2,合并后堆栈中还有 2 个 run(run_len = [68, 34]),不符合任何归并条件,打破循环,返回。
  • 推入第 7 个 run 时:堆栈中有 3 个 run(run_len = [68, 34, 17]),不符合任何归并条件,打破循环,返回。
  • 推入第 8 个 run 时:堆栈中有 4 个 run(run_len = [68, 34, 17, 10]),不符合任何归并条件,打破循环,返回。

最后,主流程 sort 方法会调用 mergeForceCollapse 依次强制合并堆栈中的所有 run。

    def mergeCollapse(self, arr):
        """
        检查堆栈中的 run,如果符合以下条件则归并:
        1)如果堆栈中有三个及以上的 run:倒三长度 <= 倒二长度 + 倒一长度
        2)如果堆栈中只有两个 run:倒二长度 <= 倒一长度
        """
        # 当栈中存在 2 个或以上的 run 时才需检查是否归并
        while self.stack_size > 1:
            # n 是倒数第二个 run 在堆栈中的下标,n-1 是倒数第三个 run 的堆栈下标,n+1 是倒数第一个 run 的堆栈下标
            n = self.stack_size - 2
            # 如果堆栈中有三个及以上的 run,并且倒数的三个 run 符合归并条件:倒三长度 <= 倒二长度 + 倒一长度,则把倒二与较短那个的 run 归并
            if n > 0 and self.run_len[n-1] <= self.run_len[n] + self.run_len[n+1]:
                if self.run_len[n-1] < self.run_len[n+1]:
                    n -= 1
                self.mergeAt(arr, n)
            # 如果堆栈中只有两个 run,并且符合归并条件:倒二长度 <= 倒一长度,则归并它们
            elif self.run_len[n] <= self.run_len[n+1]:
                self.mergeAt(arr, n)
            # 循环归并,直到当堆栈中的 run 都不符合归并条件
            else:
                break

2.8 mergeAt

mergeAt 负责对堆栈中的两个特定的 run 做归并:

  • 要么归并【倒二、倒一】这两个 run,要么归并【倒三、倒二】这两个 run,不能按【倒一、倒三】归并是为了确保排序的稳定性。
  • 分别缩小两个 run 的待归并区间:左 run 缩左边去掉小等值(即 <= 右 run 最小值的元素),右 run 缩右边去掉大等值(即 >= 左 run 最大值的元素)。
  • 归并这两个已缩小的 run:如果左 run 更短则从左侧的最小值开始检查,选择小值保存到 arr 上;如果右 run 更短则从右侧的最大值开始检查,选择大值保存到 arr 上。
    def mergeAt(self, arr, i):
        """
        归并堆栈中的第 i 个和第 i+1 个 run,i 必须是 run 堆栈中的倒数第二或倒数第三个下标。
        :param i: 两个待归并的 run 中的第一个的堆栈下标
        :return: 无
        """

        # 校验堆栈中至少有 2 个 run
        assert self.stack_size >= 2
        # 校验 i 必须是堆栈中的倒数第二或倒数第三个下标
        assert i >= 0
        assert i == self.stack_size - 2 or i == self.stack_size - 3

        base1 = self.run_base[i]  # (左)run1 的开始位置
        len1 = self.run_len[i]	 # (左)run1 的长度
        base2 = self.run_base[i + 1]	 # (右)run2 的开始位置
        len2 = self.run_len[i + 1]	 # (右)run2 的长度
        assert len1 > 0 and len2 > 0   # 校验两个 run 的长度必须大于0
        assert base1 + len1 == base2   # 校验两个 run 必须是相邻的

        # 更新合并后 run1 的长度(run2 被合并到 run1 中)
        self.run_len[i] = len1 + len2
        # 如果要合并的是倒三和倒二,则合并后倒一在堆栈中的下标需要往前挪一位
        if i == self.stack_size - 3:
            self.run_base[i + 1] = self.run_base[i + 2]
            self.run_len[i + 1] = self.run_len[i + 2]
        # 由于 run2 会被合并,堆栈大小需要缩减 1
        self.stack_size -= 1

        """分别缩小两个 run 的待归并区间"""
        # 找出 run2 的最小值(首个元素)应该插入到 run1 中的什么位置。
        # k 是此正确位置相对 base1 的偏移量,即 run1 的前 k 个元素都 <= run2 的最小值(首个元素),无需归并。
        k = self.gallopRight(key=arr[base2], arr=arr, base=base1, len_=len1, hint=0)
        assert k >= 0
        # 把 run1 的起点后移,跳过这 k 个元素。
        base1 += k
        len1 -= k
        # len1 是 run1 中需要归并的元素个数,如果 run1 直接缩减为空,则归并结束。
        if len1 == 0:
            return

        # 找出 run1 的最大值(末尾元素)应该插入到 run2 中的什么位置,在此位置右侧的 run2 元素都 >= run1 的最大值(末尾元素),无需归并。
        # len2 是此正确位置相对 base2 的偏移量,即 run2 中需要归并的元素个数。
        len2 = self.gallopLeft(key=arr[base1+len1-1], arr=arr, base=base2, len_=len2, hint=len2-1)
        assert len2 >= 0
        # len2 是 run2 中需要归并的元素个数,如果 run2 直接缩减为空,则归并结束。
        if len2 == 0:
            return

        """归并这两个已缩小的 run"""
        # 如果 run1 比 run2 短,则把 run1 拷贝到临时数组中,从左侧的最小值开始检查,选择小值保存到 arr 上。
        if len1 <= len2:
            self.mergeLo(arr, base1, len1, base2, len2)
        # 如果 run2 比 run1 短,则把 run2 拷贝到临时数组中,从右侧的最大值开始检查,选择大值保存到 arr 上。
        else:
            self.mergeHi(arr, base1, len1, base2, len2)

2.9 gallopLeft

gallopLeft 负责在数组的待搜索区间中,查找指定值(key)应当插入的正确位置;如果搜索区间中存在与 key 相同的值(一个或者多个),则搜索到的是这些相等值中最左边的位置。

用途

  • mergeAt 中被调用,缩小 run 的待归并区间。
  • mergeLomergeHi 中被调用,是【Gallop 模式】加快合并速度的主逻辑,一次性找到可以连续合并的所有元素。

实现逻辑

  • 先通过指数查找法快速找到 key 应插入位置的大致范围,即一对偏移量指针(last_ofs, ofs),其遍历范围:
指针
lastOfs013715312^(k-1)-1
ofs1371531632^(k)-1
  • 然后在这个这个范围中用二分查找法锁定最终的精确位置。

hint参数

1)当 hint = 0 时,从搜索区间的最左侧开始寻找:

  • 【if分支】如果 key > 左侧第一个值(区间最小值),随着 ofs 增加,while 循环会继续向右找,最终确定一对指针 (last_ofs, ofs),它们满足 a[base+hint+lastOfs] < key <= a[base+hint+ofs],最后转换成相对 base 的偏移。
  • 【else分支】如果 key <= 左侧第一个值(区间最小值),ofs = max_ofs = 1,不会进入 while 循环,(last_ofs, ofs)转换成相对 base 的偏移就是(-1, 0)

2)当 hint = len_-1 时,从搜索区间的最右侧开始寻找:

  • 【if分支】如果 key > 右侧第一个值(区间最大值),ofs = max_ofs = 1,不会进入 while 循环,(last_ofs, ofs)转换成相对 base 的偏移就是(len_-1, len_)
  • 【else分支】如果 key <= 右侧第一个值(区间最大值),随着 ofs 增加,while 循环会继续向左找,确定一对指针 (last_ofs, ofs),它们满足 a[base+hint-ofs] < key <= a[base+hint-lastOfs],最后转换成相对 base 的偏移。
    def gallopLeft(self, key, arr, base, len_, hint):
        """
		在数组的待搜索区间中,查找指定值(key)应当插入的正确位置;如果搜索区间中存在与 key 相同的值(一个或者多个),则搜索到的是这些相等值中最左边的位置。
        :param key: 寻找其插入位置的值
        :param arr: 被搜索的数组
        :param base: 搜索区间的左边界下标
        :param len_: 搜索区间的长度,必须满足 len_ > 0
        :param hint: 开始搜索位置相对 base 的偏移,必须满足 0 <= hint < n,此位置越接近正确位置,查找就越快
        :return: 返回的是 key 应插入的正确位置相对于 base 的偏移量(ofs),它满足 a[base+ofs-1] < key <= a[base+ofs]。
        """
        assert (len_ > 0) and (hint >= 0) and (hint < len_)

		# (last_ofs, ofs)的遍历范围
		# lastOfs: 0   1   3   7  15  31 2^(n-1)-1  < ofs
		# ofs:     1   3   7  15  31  63 2^n-1 ... maxOfs
        last_ofs = 0
        ofs = 1
        if key > arr[base + hint]:
            # 确定一对指针 (last_ofs, ofs),它们满足 a[base+hint+lastOfs] < key <= a[base+hint+ofs]
            max_ofs = len_ - hint
            while ofs < max_ofs and key > arr[base + hint + ofs]:
                last_ofs = ofs
                ofs = (ofs << 1) + 1
                if ofs <= 0:    
                    ofs = max_ofs

            if ofs > max_ofs:
                ofs = max_ofs

            # 因为目前的 (last_ofs, ofs) 是相对 hint 的偏移,需要转换成相对 base 的偏移
            last_ofs += hint
            ofs += hint
        else:
            # 确定一对指针 (last_ofs, ofs),它们满足 a[base+hint-ofs] < key <= a[base+hint-lastOfs]
            max_ofs = hint + 1
            while ofs < max_ofs and key <= arr[base + hint - ofs]:
                last_ofs = ofs
                # ofs 左移 1 位相当于 ofs * 2
                ofs = (ofs << 1) + 1
                if ofs <= 0:    
                    ofs = max_ofs

            if ofs > max_ofs:
                ofs = max_ofs

            # 因为目前的 (last_ofs, ofs) 是相对 hint 的偏移,需要转换成相对 base 的偏移
            tmp = last_ofs
            last_ofs = hint - ofs
            ofs = hint - tmp

        # (last_ofs, ofs) 转换成相对 base 的偏移后,last_ofs 最小可能等于 -1,ofs 最大可能等于 len_
        assert (-1 <= last_ofs) and (last_ofs < ofs) and (ofs <= len_)

        # 此时确定了 key 应插入位置的大致范围:a[base+lastOfs] < key <= a[base+ofs]。
        # key 应当插入到 lastOfs 的右边,但又不超过 ofs。
        # 最后在这个范围内做一次二分查找。
        last_ofs += 1
        while last_ofs < ofs:
            m = last_ofs + ((ofs - last_ofs) >> 1)

            if key > arr[base + m]:
                last_ofs = m + 1
            else:
                ofs = m

        # (last_ofs, ofs) 这两个指针相遇时,满足 a[base+ofs-1] < key <= a[base+ofs]
        # ofs 就是 key 应插入的正确位置相对于 base 的偏移量
        assert last_ofs == ofs
        return ofs

2.10 gallopRight

gallopRightgallopLeft 的逻辑一样,区别在于,如果存在与 key 等值的元素:

  • gallopLeft 返回是这些相等值中最左边的位置。
  • gallopRight 返回是这些相等值中最右边的位置。

这样设计是为了保证排序的稳定性。

    def gallopRight(self, key, arr, base, len_, hint):
        """
		在数组的待搜索区间中,查找指定值(key)应当插入的正确位置;如果搜索区间中存在与 key 相同的值(一个或者多个),则搜索到的是这些相等值中最右边的位置。
        :param key: 寻找其插入位置的值
        :param arr: 被搜索的数组
        :param base: 搜索区间的左边界下标
        :param len_: 搜索区间的长度,必须满足 len_ > 0
        :param hint: 第一搜索位置相对 base 的偏移,必须满足 0 <= hint < n,此位置越接近正确位置,查找就越快
        :return: 返回的是 key 应插入的正确位置相对于 base 的偏移量(ofs),它满足 a[base + ofs - 1] <= key < a[base + ofs]。
        """
        assert (len_ > 0) and (hint >= 0) and (hint < len_)

        last_ofs = 0
        ofs = 1
        if key < arr[base + hint]:
            # 确定一对指针 (last_ofs, ofs),它们满足 a[base+hint-ofs] <= key < a[base+hint-lastOfs]
            max_ofs = hint + 1
            while ofs < max_ofs and key < arr[base + hint - ofs]:
                last_ofs = ofs
                # ofs 左移 1 位相当于 ofs * 2
                ofs = (ofs << 1) + 1
                if ofs <= 0:    
                    ofs = max_ofs

            if ofs > max_ofs:
                ofs = max_ofs

            # 因为目前的 (last_ofs, ofs) 是相对 hint 的偏移,需要转换成相对 base 的偏移
            tmp = last_ofs
            last_ofs = hint - ofs
            ofs = hint - tmp
        else:
            # 确定一对指针 (last_ofs, ofs),它们满足 a[base+hint+lastOfs] <= key < a[base+hint+ofs]
            max_ofs = len_ - hint
            while ofs < max_ofs and key >= arr[base + hint + ofs]:
                last_ofs = ofs
                ofs = (ofs << 1) + 1
                if ofs <= 0:    
                    ofs = max_ofs

            if ofs > max_ofs:
                ofs = max_ofs

            # 因为目前的 (last_ofs, ofs) 是相对 hint 的偏移,需要转换成相对 base 的偏移
            last_ofs += hint
            ofs += hint

        # (last_ofs, ofs) 转换成相对 base 的偏移后,last_ofs 最小可能等于 -1,ofs 最大可能等于 len_
        assert (-1 <= last_ofs) and (last_ofs < ofs) and (ofs <= len_)

        # 此时确定了 key 应插入位置的大致范围:a[base+lastOfs] <= key < a[base+ofs]。
        # key 应当插入到 lastOfs 的右边,又不超过 ofs。
        # 最后在这个范围内做一次二分查找。
        last_ofs += 1
        while last_ofs < ofs:
            m = last_ofs + ((ofs - last_ofs) >> 1)

            if key > arr[base + m]:
                last_ofs = m + 1
            else:
                ofs = m

        # (last_ofs, ofs) 这两个指针相遇时,满足 a[base + ofs - 1] <= key < a[base + ofs]
        # ofs 就是 key 应插入的正确位置相对于 base 的偏移量
        assert last_ofs == ofs
        return ofs

2.11 mergeLo

mergeLo 负责按小值合并两个相邻的 run,保持排序的稳定性。调用本方法之前,必须保证:

  • run1 的首个元素大于 run2 的首个元素(全局最小值)。
  • run1 的末尾元素(全局最大值)大于 run2 的所有元素。

实现逻辑

  • 把 run1 元素都复制到临时数组中,把全局最小值(run2 的首个元素)直接保存到 arr 上。
  • 如果此时符合收尾条件(run2 已空,或 run1 只剩1个全局最大值),则直接做收尾处理。
  • 外循环负责初始化两个变量记录两个 run 的连续胜出次数,先进入【普通归并模式】的内循环,如果某个 run 连续胜出次数达到触发阈值,则打破此内循环,进入【Gallop 模式】的内循环。
  • 在【Gallop 模式】中,当两个 run 的连续胜出次数都达不到停留阈值时,退出【Gallop 模式】的内循环,由于外循环的存在,将再次回到【普通归并模式】。
  • 在循环过程中对触发阈值进行动态调整:在【Gallop 模式】中每停留一轮,触发阈值就减 1 予以奖励;每退出一次【Gallop 模式】,触发阈值就加 2 予以惩罚。
  • 归并完成时,把这个调整过的触发阈值记录下来,作为下次归并其他 run 时的初始阈值。
  • 在两个内循环中,如果发现符合收尾条件,则直接打破外循环,进行最后的收尾处理,归并完成。
    def mergeLo(self, arr, base1, len1, base2, len2):
        """
		按小值合并两个相邻的 run,保持排序的稳定性。调用本方法之前,必须保证:
		- run1 的首个元素大于 run2 的首个元素(全局最小值)。
		- run1 的末尾元素(全局最大值)大于 run2 的所有元素。
        :param arr: 待排序数组
        :param base1: run1 的开始下标
        :param len1: run1 的长度(必须大于0)
        :param base2: run2 的开始下标(必须满足 base2 == base1 + len1)
        :param len2: run2 的长度(必须大于0)
        :return:
        """
        assert len1 > 0 and len2 > 0 and base1 + len1 == base2

        tmp = []
        cursor1 = 0     # run1(临时数组)的下标指针,初始值为 0
        cursor2 = base2     # run2 的下标指针
        dest = base1    # 把归并结果保存到 arr 上的指针

        # 把 run1 元素都复制到临时数组中
        tmp[cursor1:cursor1+len1] = arr[base1:base1+len1]

        # 把全局最小值(run2 的首个元素)直接保存到 arr 上。
        # 由于调用本方法之前已通过 gallopRight 保证 run1 的最小值(首个元素)大于 run2 的最小值(首个元素),则 run2 的首个元素一定是全局最小值,可以直接保存到 arr 上。
        arr[dest] = arr[cursor2]
        # 更新指针和 run2 的长度
        dest += 1
        cursor2 += 1
        len2 -= 1
        # 如果 run2 已归并完,则可以直接进行收尾处理:把 run1(临时数组)直接保存到 arr 上,归并完成。
        if len2 == 0:
            arr[dest:dest+len1] = tmp[cursor1:cursor1+len1]
            return

        # 由于调用本方法之前已通过 gallopLeft 保证 run1 的最大值(末尾元素)大于 run2 的所有元素,则 run1 的末尾元素一定是全局最大值。
        # 当 run1 只有 1 个元素时(全局最大值),则可以直接进行收尾处理:把 run2 的剩余元素直接保存到 arr 上,最后把 run1 仅剩的最大值也保存上,归并完成。
        if len1 == 1:
            arr[dest:dest+len2] = arr[cursor2:cursor2+len2]
            arr[dest+len2] = tmp[cursor1]
            return

        # 使用变量 min_gallop 记录在【Gallop 模式】中的阈值变化
        min_gallop = self.min_gallop

        # 打破外层大循环的标志
        break_outer = False
        # 开始归并操作
        while True:
            # 使用两个变量分别记录 run1 和 run2 的连续胜出次数。
            # mergeLo 是选择小值保存到 arr 中,则连续胜出表示:一个 run 中的元素连续比另外一个 run 中元素小。
            count1 = 0
            count2 = 0

            # 【普通归并模式】当两个 run 的连续胜出次数都较低时,说明数据随机性较强,适合使用普通归并逻辑逐一合并;
            # 当某个 run 的连续胜出次数达到阈值 min_gallop 时,说明它很可能会继续胜出,则退出【普通归并模式】,进入【Gallop 模式】,加快合并速度。
            while True:
                assert len1 > 1 and len2 > 0
                # run2 胜出,count2 += 1,把 count1 清零
                if arr[cursor2] < tmp[cursor1]:
                    arr[dest] = arr[cursor2]
                    dest += 1
                    cursor2 += 1
                    count2 += 1
                    count1 = 0
                    len2 -= 1
                    # 当 run2 已归并完时,直接做收尾处理即可 
                    if len2 == 0:
                        break_outer = True
                        break
                # run1 胜出,count1 += 1,把 count2 清零
                else:
                    arr[dest] = tmp[cursor1]
                    dest += 1
                    cursor1 += 1
                    count1 += 1
                    count2 = 0
                    len1 -= 1
                    # 当 run1 只剩 1 个元素时(全局最大值),直接做收尾处理即可 
                    if len1 == 1:
                        break_outer = True
                        break

                # 退出【普通归并模式】,进入【Gallop 模式】的条件。
                # 注意:使用 while True 加 break,而不是直接 While 循环条件,是为了模仿 Java 的 do...while 语法,确保循环至少会执行一次
                if (count1 | count2) >= min_gallop:
                    break

            if break_outer:
                break

            """            
            如果外循环能执行到这里还没被打破,说明某个 run 的已经连续胜出多次(达到阈值 min_gallop),可以合理推测它会继续胜出。
            如果推测正确,进入【GALLOP 模式】能够直接找到这种连续性的最长范围,一次性把这些连续的小值都合并过去,提高合并速度。
            直到数据连续性不够强时,退出【GALLOP 模式】,由于外循环的控制,将再次进入【普通归并模式】。
            """

            # 【GALLOP 模式】
            while True:
                assert len1 > 1 and len2 > 0
                # 找出 run2 的最小值(首个元素)应该插入到 run1(临时数组)中的什么位置,此位置左侧的 run1 元素都 <= run2,可以直接保存到 arr 上。
                # count1 是此正确位置相对 cursor1 的偏移量,即 run1 连续胜出的次数。
                count1 = self.gallopRight(key=arr[cursor2], arr=tmp, base=cursor1, len_=len1, hint=0)
                if count1 != 0:
                    # 把这 count1 个小值直接保存到 arr 上,并更新 run1 指针和长度
                    arr[dest:dest+count1] = tmp[cursor1:cursor1+count1]
                    dest += count1
                    cursor1 += count1
                    len1 -= count1
                    # 当 run1 只剩 1 个待归并元素时(全局最大值),直接做收尾处理即可 
                    if len1 <= 1:
                        break_outer = True
                        break

                # 此时 run2 的首个元素就是全局最小值,保存到 arr 上,并更新 run2 指针和长度
                arr[dest] = arr[cursor2]
                dest += 1
                cursor2 += 1
                len2 -= 1
                # 当 run2 已归并完时,直接做收尾处理即可 
                if len2 == 0:
                    break_outer = True
                    break

                # 找出 run1(临时数组)的最小值(首个元素)应该插入到 run2 的什么位置,此位置左侧的 run2 元素都 < run1,可以直接保存到 arr 上。
                # count2 是此正确位置相对 cursor2 的偏移量,即 run2 连续胜出的次数。
                count2 = self.gallopLeft(key=tmp[cursor1], arr=arr, base=cursor2, len_=len2, hint=0)
                if count2 != 0:
                    # 把这 count2 个小值直接保存到 arr 上,并更新 run2 指针和长度
                    arr[dest:dest+count2] = arr[cursor2:cursor2+count2]
                    dest += count2
                    cursor2 += count2
                    len2 -= count2
                    # 当 run2 已归并完时,直接做收尾处理即可 
                    if len2 == 0:
                        break_outer = True
                        break

                # 此时 run1(临时数组)的首个元素就是全局最小值,保存到 arr 上,并更新 run1(临时数组)的指针和长度
                arr[dest] = tmp[cursor1]
                dest += 1
                cursor1 += 1
                len1 -= 1
                # 当 run1 只剩 1 个待归并元素时(全局最大值),直接做收尾处理即可
                if len1 == 1:
                    break_outer = True
                    break

                # 每在【GALLOP 模式】中停留一轮,就把触发阈值减 1,使得下次触发更容易。
                min_gallop -= 1

                # 【GALLOP 模式】的停留条件:此轮某个 run 的续胜出次数还能超过 MIN_GALLOP,就继续停留在【GALLOP 模式】
                # 注意:使用 while True 加 break,而不是直接 While 循环条件,是为了模仿 Java 的 do...while 语法,确保循环至少会执行一次
                if not (count1 >= self.MIN_GALLOP | count2 >= self.MIN_GALLOP):
                    break

            if break_outer:
                break

            if min_gallop < 0:
                min_gallop = 0

            # 当代码走到这里时,说明不再满足停留条件,退出了【GALLOP 模式】,即数据的连续性不强了,应予以一定的惩罚,即增加触发阈值。
            min_gallop += 2

        # 此时外循环已结束,把本次归并的触发阈值更新到 self.min_gallop,作为下次归并其他 run 的初始阈值。
        # 其隐含的推测是如果先归并的 run 的连续性很强,则后归并的 run 很可能也连续性很强,应该让它们更容易进入【GALLOP 模式】。
        self.min_gallop = 1 if min_gallop < 1 else min_gallop

        """收尾处理"""
        # 如果外循环结束是因为 run1 只剩 1 个待归并元素时(全局最大值),则做收尾处理
        if len1 == 1:
            assert len2 > 0
            arr[dest:dest+len2] = arr[cursor2:cursor2+len2]
            arr[dest + len2] = tmp[cursor1]
        # 因为 run1 中有全局最大值,所以不可能存在 run1 空了的情况,如果出现了说明有问题
        elif len1 == 0:
            raise Exception("IllegalArgument")
        # 另一种外循环结束原因只可能是 run2 已归并完,则做收尾处理
        else:
            assert len2 == 0
            assert len1 > 1
            arr[dest:dest+len1] = tmp[cursor1:cursor1+len1]

2.12 mergeHi

mergeLomergeLo 的逻辑相似,区别在于:

  • 为了节省空间、时间,mergeLolen1 <= len2 的时候调用,把较短的 run1 复制到临时数组中,mergeHilen1 >= len2 的时候调用,把较短的 run2 复制到临时数组中,len1==len2 的时候随便调用哪个都可以。
  • 为了减少比较次数,mergeLo 从左侧的最小值开始检查,选择小值保存到 arr 上。mergeHi 从右侧的最大值开始检查,选择大值保存到 arr 上。
  • mergeLo 的收尾条件是:run2 已空,或 run1 只剩 1 个全局最大值。mergeHi 的收尾条件是:run1 已空,或 run2 只剩 1 个全局最小值。
    def mergeHi(self, arr, base1, len1, base2, len2):
        """
		按大值合并两个相邻的 run,保持排序的稳定性。调用本方法之前,必须保证:
		- run1 的首个元素大于 run2 的首个元素(全局最小值)。
		- run1 的末尾元素(全局最大值)大于 run2 的所有元素。
        :param arr: 待排序数组
        :param base1: run1 待归并区间的开始下标
        :param len1: run1 待归并区间的长度(必须大于0)
        :param base2: run2 待归并区间的开始下标(必须满足 base2 == base1 + len1)
        :param len2: run2 待归并区间的长度(必须大于0)
        :return:
        """
        assert len1 > 0 and len2 > 0 and base1 + len1 == base2

        tmp = []
        tmp_base = 0
        # 把 run2 的元素都复制到临时数组中
        tmp[tmp_base:tmp_base+len2] = arr[base2:base2+len2]

        # 按最大值从后往前归并,指针都初始化在最后位置
        cursor1 = base1 + len1 - 1     # run1 的下标指针
        cursor2 = tmp_base + len2 - 1     # run2(临时数组)的下标指针
        dest = base2 + len2 - 1    # 把归并结果保存到 arr 上的指针

        # 由于调用本方法之前已通过 gallopLeft 保证 run1 的最大值(末尾元素)大于 run2 的所有元素,则 run1 的末尾元素一定是全局最大值,可以直接保存到 arr 的最后。
        arr[dest] = arr[cursor1]
        # 更新指针和 run1 的长度
        dest -= 1
        cursor1 -= 1
        len1 -= 1
        # 如果 run1 已归并完,则可以直接进行收尾处理:把 run2(临时数组)直接保存到 arr 上,归并完成
        if len1 == 0:
            arr[dest+1-len2:dest+1] = tmp[tmp_base:tmp_base+len2]
            return

        # 由于调用本方法之前已通过 gallopRight 保证 run1 的最小值(首个元素)大于 run2 的最小值(首个元素),则 run2 的首个元素一定是全局最小值
        # 当 run2 只有 1 个元素时(全局最小值),则可以直接进行收尾处理:把 run1 的剩余元素直接保存到 arr 上,最后把 run2 的这个全局最小值也保存上,归并完成
        if len2 == 1:
            dest -= 1
            cursor1 -= len1
            arr[dest+1:dest+1+len1] = arr[cursor1+1:cursor1+1+len1]
            arr[dest] = tmp[cursor2]
            return

        # 使用变量 min_gallop 记录在【Gallop 模式】中的阈值变化
        min_gallop = self.min_gallop

        # 打破外层大循环的标志
        break_outer = False
        # 开始归并操作
        while True:
            # 使用两个变量分别记录 run1 和 run2 的连续胜出次数。
            # mergeHi 是选择大值保存到 arr 中,则连续胜出表示:一个 run 中的元素连续比另外一个 run 中元素大。
            count1 = 0
            count2 = 0

            # 【普通归并模式】当两个 run 的连续胜出次数都较低时,说明数据随机性较强,适合使用普通归并逻辑逐一合并;
            # 当某个 run 的连续胜出次数达到阈值 min_gallop 时,说明它很可能会继续胜出,则退出【普通归并模式】,进入【Gallop 模式】,加快合并速度。
            while True:
                assert len1 > 0 and len2 > 1

                if tmp[cursor2] < arr[cursor1]:
                    # run1 胜出,count1 += 1,把 count2 清零
                    arr[dest] = arr[cursor1]
                    dest -= 1
                    cursor1 -= 1
                    count1 += 1
                    count2 = 0
                    len1 -= 1
                    # 当 run1 已归并完时,直接做收尾处理即可
                    if len1 == 0:
                        break_outer = True
                        break
                else:
                    # run2 胜出,count2 += 1,把 count1 清零
                    arr[dest] = tmp[cursor2]
                    dest -= 1
                    cursor2 -= 1
                    count2 += 1
                    count1 = 0
                    len2 -= 1
                    # 当 run2 只剩 1 个元素时(全局最小值),直接做收尾处理即可
                    if len2 == 1:
                        break_outer = True
                        break

                # 退出【普通归并模式】,进入【Gallop 模式】的条件。
                # 注意:使用 while True 加 break,而不是直接 While 循环条件,是为了模仿 Java 的 do...while 语法,确保循环至少会执行一次
                if (count1 | count2) >= min_gallop:
                    break

            if break_outer:
                break

            """            
            如果外循环能执行到这里还没被打破,说明某个 run 的已经连续胜出多次(达到阈值 min_gallop),可以合理推测它会继续胜出。
            如果推测正确,进入【GALLOP 模式】能够直接找到这种连续性的最长范围,一次性把这些连续的大值都合并过去,提高合并效率。
            直到数据连续性不够强时,退出【GALLOP 模式】,由于外循环的控制,将再次进入【普通归并模式】。
            """

            # 【GALLOP 模式】
            while True:
                assert len1 > 0 and len2 > 1
                # 找出 run2(临时数组)的最大值(末尾元素)应该插入到 run1 中的什么位置,此位置右侧的 run1 元素都 > run2,可以直接保存到 arr 上。
                # count1 是此正确位置相对 cursor1 的偏移量,即 run1 连续胜出的次数。
                count1 = len1 - self.gallopRight(key=tmp[cursor2], arr=arr, base=base1, len_=len1, hint=len1-1)
                if count1 != 0:
                    # 把这 count1 个大值直接保存到 arr 上,并更新 run1 指针和长度
                    dest -= count1
                    cursor1 -= count1
                    len1 -= count1
                    arr[dest+1:dest+1+count1] = arr[cursor1+1:cursor1+1+count1]
                    # 当 run1 已归并完时,直接做收尾处理即可 
                    if len1 == 0:
                        break_outer = True
                        break

                # 此时 run2 的末尾元素就是全局最大值,保存到 arr 上,并更新 run2 指针和长度
                arr[dest] = tmp[cursor2]
                dest -= 1
                cursor2 -= 1
                len2 -= 1
                # 当 run2 只剩 1 个元素时(全局最小值),直接做收尾处理即可
                if len2 == 1:
                    break_outer = True
                    break

                # 找出 run1 的最大值(末尾元素)应该插入到 run2(临时数组)中的什么位置,此位置右侧的 run2 元素都 >= run1,可以直接保存到 arr 上。
                # count2 是此正确位置相对 cursor2 的偏移量,即 run2 连续胜出的次数。
                count2 = len2 - self.gallopLeft(key=arr[cursor1], arr=tmp, base=tmp_base, len_=len2, hint=len2-1)
                if count2 != 0:
                    # 把这 count2 个大值直接保存到 arr 上,并更新 run2 指针和长度
                    dest -= count2
                    cursor2 -= count2
                    len2 -= count2
                    arr[dest+1:dest+1+count2] = tmp[cursor2+1:cursor2+1+count2]
                    # 当 run2 只剩1个元素时(全局最小值),直接做收尾处理即可
                    if len2 <= 1:
                        break_outer = True
                        break

                # 此时 run1 的末尾元素就是全局最大值,保存到 arr 上,并更新 run1 指针和长度
                arr[dest] = arr[cursor1]
                dest -= 1
                cursor1 -= 1
                len1 -= 1
                # 当 run1 已归并完时,直接做收尾处理即可
                if len1 == 0:
                    break_outer = True
                    break

                # 每在【GALLOP 模式】中停留一轮,就把触发阈值减 1,使得下次触发更容易。
                min_gallop -= 1

                # 【GALLOP 模式】的停留条件:此轮某个 run 的续胜出次数还能超过 MIN_GALLOP,就继续停留在【GALLOP 模式】
                # 注意:使用 while True 加 break,而不是直接 While 循环条件,是为了模仿 Java 的 do...while 语法,确保循环至少会执行一次
                if not (count1 >= self.MIN_GALLOP | count2 >= self.MIN_GALLOP):
                    break

            if break_outer:
                break

            if min_gallop < 0:
                min_gallop = 0

            # 当代码走到这里时,【GALLOP 模式】的循环正常结束(即不满足循环条件 count1 >= self.MIN_GALLOP | count2 >= self.MIN_GALLOP),
            # 说明此时数据的连续性不强,应当增加阈值,使进入【GALLOP 模式】的难度增加,避免浪费资源,相当于一种扣分惩罚。
            # 通过对 min_gallop 的增减,形成了一种积分奖罚制度(连续性强则积分低),动态调整从【普通归并模式】切换成【GALLOP 模式】的阈值。
            min_gallop += 2

        # 此时外循环已结束,把本次归并的触发阈值更新到 self.min_gallop,作为下次归并其他 run 的初始阈值。
        # 其隐含的推测是如果先归并的 run 的连续性很强,则后归并的 run 很可能也连续性很强,应该让它们更容易进入【GALLOP 模式】。
        self.min_gallop = 1 if min_gallop < 1 else min_gallop

        """收尾处理"""
        # 如果外循环结束是因为 run2 只剩 1 个元素(全局最小值),则做收尾处理
        if len2 == 1:
            assert len1 > 0
            dest -= len1
            cursor1 -= len1
            arr[dest+1:dest+1+len1] = arr[cursor1+1:cursor1+1+len1]
            arr[dest] = tmp[cursor2]
        # 因为 run2 中有全局最小值,所以不可能存在 run2 空了的情况,如果出现了说明有问题
        elif len2 == 0:
            raise Exception("IllegalArgumentException")
        # 另一种外循环结束原因只可能是 run1 已归并完,直接做收尾处理即可 
        else:
            assert len1 == 0
            assert len2 > 0
            arr[dest+1-len2:dest+1] = tmp[tmp_base:tmp_base+len2]

3 参考文档

  1. CPython源码阅读——Timsort
  2. 【jdk8源码】TimSort算法——从头看到脚
  3. Timsort 介绍(listsort.txt 翻译)
  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值