TimSort源码详解
Python的排序算法由Peter Tim提出,因此称为TimSort。它最先被使用于Python语言,后被多种语言作为默认的排序算法。TimSort实际上可以看作是mergeSort+binarySort,它主要是针对归并排序做了一系列优化。如果想看Python的TimSort源码,在Cpython的Github仓库能找到,这里面还包含一个List对象的PyList_Sort函数。这篇文章为了方便借用JAVA对TimSort的实现源码来说明其原理。
一.binarySort函数
TimSort非常适合大量数据的排序,对于少量数据的排序,TimSort选择使用binarySort来实现,因此我想先介绍一下binarySort的过程。
我们知道插入排序的思路是通过交换元素位置的方式依次插入元素(如果不太了解插入排序可以先去熟悉一下),当要插入元素时,从已排序的部分的最后一位开始,依次比较其与待插入的元素的值,这样来找到待插入元素的位置。显然,在插入排序的过程中,始终是有一个在增长的有序部分和在缩短的无序部分。排序过程见下图(图源自RainySouL1994的博客):

但是插入排序有个很明显的问题,在找当前元素的位置时它是一步一步地在有序部分往前推进的,而有序列表的插入可以通过二分法来减少比较次数,这和二分查找的目的不同但是思路相同(可以自己尝试一下实现它),我们称其为二分插入,通过二分插入实现的排序就是二分排序(binarySort)。我们可以看一下它的Java源码:
//a是数组,lo是待排序部分(有序部分+无序部分)的最低位(包含),hi是最高位(不包含),start是无序部分的最低位,c是比较函数即排序的依据
private static <T> void binarySort(T[] a, int lo, int hi, int start, Comparator<? super T> c) {
assert lo <= start && start <= hi;
if (start == lo)
start++;
for ( ; start < hi; start++) {//接下来就是二分插入的过程
T pivot = a[start];
int left = lo;
int right = start;
assert left <= right;
while (left < right) {
int mid = (left + right) >>> 1;
if (c.compare(pivot, a[mid]) < 0)
right = mid;
else
left = mid + 1;
}
assert left == right;
int n = start - left;//n表示要移动的元素数量
//优化插入过程,当要移动的元素数量为1或2时,可以直接交换元素位置;
//否则将left后的元素往后挪一位再插入,方式是通过arraycopy函数复制
switch (n) {
case 2: a[left + 2] = a[left + 1];
case 1: a[left + 1] = a[left];
break;
default: System.arraycopy(a, left, a, left + 1, n);
}
a[left] = pivot;
}
}
二.run
这是TimSort中最重要的一个概念,实在找不到合适的翻译(无奈脸)。run实际上就是一个连续上升(包含相等)或者下降(不包含相等)的子串。比如对于数组[1,3,2,4,6,4,7,7,3,2],其中有四个run,第一个是[1,3],第二个是[2,4,6],第三个是[4,7,7],第四个是[3,2],在函数中对于单调递减的run会被反转成递增的序列。源码中通过countRunAndMakeAscending()函数来得到run:
private static <T> int countRunAndMakeAscending(T[] a, int lo, int hi, Comparator<? super T> c) {
assert lo < hi;
int runHi = lo + 1;
if (runHi == hi)
return 1;
//找到run的结束位置,如果是下降的序列将其反转
if (c.compare(a[runHi++], a[lo]) < 0) {
while (runHi < hi && c.compare(a[runHi], a[runHi - 1]) < 0)
runHi++;
reverseRange(a, lo, runHi);
} else {
while (runHi < hi && c.compare(a[runHi], a[runHi - 1]) >= 0)
runHi++;
}
return runHi - lo;//返回值为run的长度
}
三.TimSort排序过程
直接上源码分析,可以参考代码注释和下面的解释来阅读:
static <T> void sort(T[] a, int lo, int hi, Comparator<? super T> c, T[] work, int workBase, int workLen) {
assert c != null && a != null && lo >= 0 && lo <= hi && hi <= a.length;
int nRemaining = hi - lo;//待排序的数组长度
if (nRemaining < 2)
return; //长度为0或1的数组无需排序
// 如果数组长度小于32(即MIN_MERGE,TimSort的Python版本里这个值为64),直接用binarySort排序
if (nRemaining < MIN_MERGE) {
int initRunLen = countRunAndMakeAscending(a, lo, hi, c);//找到第一个run,返回其长度
binarySort(a, lo, hi, lo + initRunLen, c);//第一个run已排好序,因此binarySort的参数start=lo+initRunLen
return;
}
TimSort<T> ts = new TimSort<>(a, c, work, workBase, workLen);
int minRun = minRunLength(nRemaining);//最小run长度,见解释A
do {
// 找run
int runLen = countRunAndMakeAscending(a, lo, hi, c);
// 如果run长度小于minRun,将其扩展为min(nRemaining,minRun)
if (runLen < minRun) {
int force = nRemaining <= minRun ? nRemaining : minRun;
binarySort(a, lo, lo + force, lo + runLen, c);//扩展run到长度force
runLen = force;
}
ts.pushRun(lo, runLen);// 将run保存到栈中,见解释B
ts.mergeCollapse();// 根据规则合并相邻的run,见解释C
// 继续寻找run
lo += runLen;
nRemaining -= runLen;
} while (nRemaining != 0);
// Merge all remaining runs to complete sort
assert lo == hi;
ts.mergeForceCollapse();//最后收尾,将栈中所有run从栈顶开始依次邻近合并,得到一个run
assert ts.stackSize == 1;
}
解释A:在执行排序算法之前,会计算minRun的值,minRun会从[16,32]区间中选择一个数字,使得数组的长度除以minRun等于或者略小于2的幂次方。比如长度是65,那么minrun的值就是17;如果长度是174,minrun就是22。minRunLength()函数代码如下:
private static int minRunLength(int n) {
assert n >= 0;
int r = 0; // 如果n的低位有任何一位为1,r就会置1
while (n >= 32) {
r |= (n & 1);
n >>= 1;
}
return n + r;
}
解释B:存run是通过两个栈,分别保存run的起始位置和长度,可以看pushRun()函数代码:
private int stackSize = 0; // 栈中run的数量
private final int[] runBase;
private final int[] runLen; private void pushRun(int runBase, int runLen) {
this.runBase[stackSize] = runBase;
this.runLen[stackSize] = runLen;
stackSize++;
}
解释C:这里的合并规则如下:假设栈顶三个run依次为X,Y,Z,X为栈顶run,要求它们的长度满足X+Y<Z及X<Y两个条件。其实这就是TimSort算法的精髓所在了,它通过这样的方式尽力保证合并的平衡性,即让待合并的两个数组尽可能长度接近,从而提高合并的效率。通过这两个条件限制,保证了栈中的run从栈底到栈顶是从大到小排列的,并且合并的收敛速度与斐波那契数列一样。可以看mergeCollapse()函数代码:
private void mergeCollapse() {
while (stackSize > 1) {
int n = stackSize - 2;
if (n > 0 && runLen[n-1] <= runLen[n] + runLen[n+1]) {//条件一不满足的话,Y就会和X、Z中较小的run合并
if (runLen[n - 1] < runLen[n + 1])
n--;
mergeAt(n);
} else if (runLen[n] <= runLen[n + 1]) {//条件二不满足的话,Y就和X合并
mergeAt(n);
} else {
break; // Invariant is established
}
}
}
四.合并的方式
到这里我们就把整个流程讲完了,还有最后一个问题没有讲--如何合并run?合并两个run需要额外空间(可以不用,但是效率太低),额外空间大小我们可以设为较小的run的长度。假设我们有前后X、Y两个run需要合并,X较小,那么X可以放入临时内存中,然后从小到大合并;如果Y较小,那么把Y放入临时内存,然后从大到小排序。这个流程其实也比较简单(图源自佛西先森的博客):

并且,由于两个run都是已经排好序的序列,我们可以在run合并之前计算A中最后一个元素在B中的位置i,那么B中i之后的元素都不需要参与合并;同理,我们也可以计算B中第一个元素在A中位置j,A中j之前的元素都不需要参与合并。
在归并排序算法中合并两个数组就是一一比较每个元素,把较小的放到相应的位置,然后比较下一个,这样有一个缺点就是如果A中如果有大量的元素A[i...j]是小于B中某一个元素B[k]的,程序仍然会持续的比较A[i...j]中的每一个元素和B[k],增加合并过程中的时间消耗。
为了优化合并的过程,TimSort设定了一个阈值MIN_GALLOP,如果A中连续MIN_GALLOP个元素比B中某一个元素要小,则通过二分搜索找到A[0]在B中的位置i0,把B中i0之前的元素直接放入合并的空间中,然后再在A中找到B[i0]所在的位置j0,把A中j0之前的元素直接放入合并空间中,如此循环直至在A和B中每次找到的新的位置和原位置的差值是小于MIN_GALLOP的,这才停止然后继续进行一对一的比较。
五.总结
总结一下上面的排序的过程:
- 如果长度小于32直接进行二分插入排序
- 遍历数组组成一个
run - 得到一个
run之后会把他放入栈中 - 如果栈顶部几个的
run符合合并条件,就会合并相邻的两个run - 合并会使用尽量小的内存空间和GALLOP模式来加速合并
参考资料:1.世界上最快的排序算法——Timsort
2.JDK8官方源码
TimSort源码详解的更多相关文章
- Spark Streaming揭秘 Day25 StreamingContext和JobScheduler启动源码详解
Spark Streaming揭秘 Day25 StreamingContext和JobScheduler启动源码详解 今天主要理一下StreamingContext的启动过程,其中最为重要的就是Jo ...
- spring事务详解(三)源码详解
系列目录 spring事务详解(一)初探事务 spring事务详解(二)简单样例 spring事务详解(三)源码详解 spring事务详解(四)测试验证 spring事务详解(五)总结提高 一.引子 ...
- 条件随机场之CRF++源码详解-预测
这篇文章主要讲解CRF++实现预测的过程,预测的算法以及代码实现相对来说比较简单,所以这篇文章理解起来也会比上一篇条件随机场训练的内容要容易. 预测 上一篇条件随机场训练的源码详解中,有一个地方并没有 ...
- [转]Linux内核源码详解--iostat
Linux内核源码详解——命令篇之iostat 转自:http://www.cnblogs.com/york-hust/p/4846497.html 本文主要分析了Linux的iostat命令的源码, ...
- saltstack源码详解一
目录 初识源码流程 入口 1.grains.items 2.pillar.items 2/3: 是否可以用python脚本实现 总结pillar源码分析: @(python之路)[saltstack源 ...
- Shiro 登录认证源码详解
Shiro 登录认证源码详解 Apache Shiro 是一个强大且灵活的 Java 开源安全框架,拥有登录认证.授权管理.企业级会话管理和加密等功能,相比 Spring Security 来说要更加 ...
- udhcp源码详解(五) 之DHCP包--options字段
中间有很长一段时间没有更新udhcp源码详解的博客,主要是源码里的函数太多,不知道要不要一个一个讲下去,要知道讲DHCP的实现理论的话一篇博文也就可以大致的讲完,但实现的源码却要关心很多的问题,比如说 ...
- Activiti架构分析及源码详解
目录 Activiti架构分析及源码详解 引言 一.Activiti设计解析-架构&领域模型 1.1 架构 1.2 领域模型 二.Activiti设计解析-PVM执行树 2.1 核心理念 2. ...
- 源码详解系列(六) ------ 全面讲解druid的使用和源码
简介 druid是用于创建和管理连接,利用"池"的方式复用连接减少资源开销,和其他数据源一样,也具有连接数控制.连接可靠性测试.连接泄露控制.缓存语句等功能,另外,druid还扩展 ...
随机推荐
- 痞子衡嵌入式:JLink Script文件基础及其在IAR下调用方法
大家好,我是痞子衡,是正经搞技术的痞子.今天痞子衡给大家分享的是JLink Script文件基础及其在IAR下调用方法. JLink可以说是MCU开发者最熟悉的调试工具了,相比于其他调试器(比如DAP ...
- Python_faker (伪装者)创建假数据
faker (伪装者)创建假数据 工作中,有时候我们需要伪造一些假数据,如何使用 Python 伪造这些看起来一点也不假的假数据呢? Python 有一个包叫 Faker,使用它可以轻易地伪造姓名.地 ...
- 限流10万QPS、跨域、过滤器、令牌桶算法-网关Gateway内容都在这儿
一.微服务网关Spring Cloud Gateway 1.1 导引 文中内容包含:微服务网关限流10万QPS.跨域.过滤器.令牌桶算法. 在构建微服务系统中,必不可少的技术就是网关了,从早期的Zuu ...
- 使用进程池模拟多进程爬取url获取数据,使用进程绑定的回调函数去处理数据
1 # 使用requests请求网页,爬取网页的内容 2 3 # 模拟使用进程池模拟多进程爬取网页获取数据,使用进程绑定的回调函数去处理数据 4 5 import requests 6 from mu ...
- 04、MyBatis DynamicSQL(Mybatis动态SQL)
1.动态SQL简介 动态 SQL是MyBatis强大特性之一. 动态 SQL 元素和使用 JSTL 或其他类似基于 XML 的文本处理器相似. MyBatis 采用功能强大的基于 OGNL 的表达式来 ...
- Java web项目 Jxl 读取excel 并保存到数据库,(从eclipse上移动到tomact服务器上,之路径更改,)
最开始在eclipse中测试的时候,并没有上传到服务器上,后来发现,想要读取数据必须上传服务器然后把文件删除就可以了,服务器不可以直接读取外地的文件.用到jxl 1.上传到服务器 前端 <for ...
- 有什么OCR文字识别软件好用?
OCR文字识别是指:对文本资料进行扫描,然后对图像文件进行分析处理,最后获取文字以及版面信息的过程.对于许多学生党而言,一款好用的文字识别软件,能节省很多抄笔记的时间,而对于许多处理文字内容的白领而言 ...
- Python爬虫实现翻译功能
前言 学了这么久的python理论知识,需要开始实战来练手巩固了. 准备 首先安装爬虫urllib库 pip install urllib 获取有道翻译的链接url 需要发送的参数在form data ...
- 【P4178】Tree——点分治
(题面来自luogu) 题目描述 给你一棵TREE,以及这棵树上边的距离.问有多少对点它们两者间的距离小于等于K 输入格式 N(n<=40000) 接下来n-1行边描述管道,按照题目中写的输入 ...
- MySQL的中的全局锁、表级锁、行锁
MySQL的中的全局锁.表级锁.行锁 学习极客时间-林晓彬老师-MySQL实战45讲 学习整理 全局锁 对整个数据库实例加锁.通过使用Flush tables with read lock (FTWR ...