前言
最近一次业务中,我们实现了对于分享人的拖拉拽排序,这样用户可以通过前端的拖拉拽,实现会议的自定义人员的分享顺序。
一开始我们的方案是,当排序时,前端将计算的位置计算出来,然后传给后端,后端赋予新的排序值,这样就形成了新的排序顺序。
计算方式是,将所要插入的前后两个排序值相加/2,即可达到新的排序值。但是在测试过程中,我们发现在人员较多的情况下,不久就会因为排序值计算后的精度丢失,导致排序不再起作用。
在重新研究方案后,我发现了LexoRank算法。
还是老样子,先上理论,再实践,本篇文章将会介绍LexoRank相关原理,并且给出相关的代码实现。
LexoRank
LexoRank的由来
LexoRank 是一种 有序 ID/排序算法,最早由 Atlassian(做 Jira 的公司)提出,用来解决“在任意两个元素之间插入新元素”的排序问题。它主要用于像 Jira 这种“任务排序、拖拽调整顺序”的场景。
解决了什么问题?
当我们要解决“在任意两个元素之间插入新元素”这个问题时,我们经常会想到两种方案:
方案1:移位更新
当我们要插入一条数据时,将此新位置后面所有的数据向后移动。
这种方式对于数据量较少时可以接受,也就是将数据库向后更新n条数据。
但是对于数据量较大时,比如有一千条,或者一万条数据时,就可能会导致每次排序都要更新一万条数据,这会导致严重的性能问题。
方案2:使用数值类型,计算中间值
我们使用数值类型实现排序值,每次排序时,我们计算中间值(也就是前言中的除2操作)
假如我们要赋予初始值给三条记录 10, 20, 30。
我们要将排序值为30的值,放到10 和 20 之间,即可将排序值为30的记录的排序值改为 (10 + 20)/ 2 = 15,得到新的排序。
但是这样也会有一个问题,即当不断插入同一个位置时,很快精度就丢失了。
那能不能扩大可插入的数据量呢?也就是说使用浮点型数值来扩大精度。但是这种方式也会迟早导致精度丢失。
方案3:LexoRank算法
这种算法经过我的理解,是一个与方案2类似的算法,同样是计算中间值,但是与之不同的是,采用的是字符类型。
采用字符类型
使用字符代替数值类型有什么好处呢?
计算中间值的范围更大
假如说我们有一个中间值来排序,要在 a 和 c之间插入一个值,按照字典序(mysql类型的数据库,字符串排序采用的这种方式),我们可以插入一个新的排序值为 b。
但是假如我们要在排序值的 a 和 b之间插入会怎么样呢?
其实按照字典序,a 和 b之间可以有很多的排序值,比如 aa,ab,ac.... 甚至于 aajnhfskfh之类的。如果不限制位数的话,这中间可以有无数多个中间值。
采用最短字符串计算中间值
假设我们有两个排序值 aa 和 cc,那么他们之间的中间值是什么呢?
最简单的答案就是bb!当然你也可以采用上面的方式,通过延长前者的字符串,来获得中间值的方式,比如aaa,aab,aac,aad。
但是如果我们要节省空间的话,aa 和 cc中间最短的字符串就是b,也就是取最短的中间值。这种计算方式可以极大的帮我们省略一些空间。当两个很长的字符串进行比较时,我们只用取最短的长度的字符串即可,相比于取相同长度,或者更长长度字符串而言,这样会帮助我们再次多出很多的位数。
这种方式也类似于上面的方案2,只是计算逻辑不同。 相关的算法可以参考这篇讨论:stackoverflow.com/questions/3…
如果要插入两个相邻的字符串又该怎么办呢?
在上面的介绍中,我们忽略了一种情况,也就是如何在aa 和 aaa这两个字符串之间插入中间值。
按照字典序,aa和aaa中间将不会再有中间值,这也就导致我们无法再插入,这和数值类型的精度丢失其实是一个道理。
遇到这种方式,那么就只剩下了一个方法,也就是重排!,也就是,当我们发现无法再插入中间值时,将所有的排序按照我们的范围均匀的进行一次重新排序,让不同的记录之间可以有中间值。
但是如果按照这种方式,会导致当前所有的操作不可用,因为在重排过程中,所有的对于此列表的排序操作将不可用,直到重排完成。
那么有没有方式可以避免在重排过程中,依然可以使用排序相关操作呢?有的,兄弟,有的。
桶
在jira的官方实现中,引入了桶这一概念。
Lexorank 有一个称为桶的机制。一个桶由一个数字表示,该数字通过管道符与字母数字排名分隔。这在重新索引时使用,并允许我们在重新索引任务进行过程中从表中提供数据。你可以拥有任意数量的桶,但官方给出的标准是三个。
桶有什么作用呢?
假如一开始,我们将所有的排序值初始化为桶为0,那么值可以是 0|aaa, 0|bbb,0|ccc之类的值。
当我们发现不可再分时,我们将进行重排。如何进行重排呢?
我们可以从后向前,每次取固定的记录,比如10条记录进行重排。
- 首先按照需要排序记录的总数量,排序字符串总的范围(如果我们限制32位字符的话,那么范围最小就是"a", 最大就是"zzzzz..." 32个z),计算步长(也就是均匀分布情况下,每两条记录之间,按照字典序的最大的间隔)
- 然后按照我们范围的最大值,赋予这10条记录新的排序值。
- 将这10条记录的桶由0改为1。
- 接下来继续取10条记录,拿到步长,不过这个时候最大值将不是一开始范围的最大值,而是我们已经重拍后的那10条的最小值,作为新的范围的最大值。
这种排序可以带来的好处是,重排过程中,未重排的记录,和已重排的记录之间的顺序是并没有发生改变的,而且由于我们是渐进式的重排,所以在重排过程中,我们的排序依然是可用的。
但是注意,如果我们的桶只有0,1,2时。 当桶由2 转为 0时,需要将记录从前向后进行重排。
实现
梳理需求
那么我们可以按照上文的一些介绍来实现我们自己的排序算法,首先我们实现这个算法的整体需求。
- 初始化:计算每个值的初始化值,并且赋予记录的排序值字段。
- 排序:排序过程中计算中间值,赋予插入的记录的排序值。
- 重排:当不可重排时,渐进式重置初始值。
计算中间值的逻辑是:获取 left 和 right 之间的最短中间字符串
- 字符串的位数不能超过32位(我自己限制的,用于限制字符串在数据库的长度)
- 比较两个字符串,按照字典序(数据库的比较字符串的逻辑)
- 如果出现了第一个不相同的字符,则比较两个字符的大小
- 如果两个字符之间不存在中间值,即两者相邻,则向后继续判断
- 如果两个之间存在中间值,则直接返回前面比较过的字符 + 中间值
- 如果两者之间一直没有中间值,直到最短的比较完成,判断较小值的后面未比较的第一个字符,如果小于n,则新值为已经比较的前缀 加上 n,如果大于n,则新值为此字符和z的中间值。
代码实现
以下是我按照上述需求,实现的一个简单demo,这个代码并非我们业务中的实际代码,只是我对于此算法的一个尝试。
public class LexoRanksSortUtil {
private static final char[] ALPHABET = "abcdefghijklmnopqrstuvwxyz".toCharArray();
private static final int BASE = ALPHABET.length;
private static AtomicLong seq = new AtomicLong(0);
private static final int MAX_SUFFIX_LENGTH = 32;
/**
* 按照列表顺序初始化 rank(均匀分布,32 位长度)
*
*/
private static final Map<Character, Integer> charToIndex = new HashMap<>();
public static void init(){
for (int i = 0; i < ALPHABET.length; i++) {
charToIndex.put(ALPHABET[i], i);
}
}
public static List<String> initializeRanks(int size, String left, String right) {
List<String> ranks = new ArrayList<>();
// 处理边界值
if (left == null || left.isEmpty()) {
left = "a";
}
if (right == null || right.isEmpty()) {
char[] arr = new char[MAX_SUFFIX_LENGTH];
Arrays.fill(arr, 'z');
right = new String(arr);
}
// 将字符串转为 BigInteger
BigInteger leftValue = rankToInt(left);
BigInteger rightValue = rankToInt(right);
// 区间差值
BigInteger range = rightValue.subtract(leftValue);
if (range.compareTo(BigInteger.valueOf(size + 1)) <= 0) {
throw new IllegalArgumentException("区间太小,无法生成 " + size + " 个 rank");
}
// 均分步长
BigInteger step = range.divide(BigInteger.valueOf(size + 1));
int length = Math.max(left.length(), right.length());
for (int i = 0; i < size; i++) {
BigInteger value = leftValue.add(step.multiply(BigInteger.valueOf(i + 1)));
ranks.add(intToRank(value, length));
}
return ranks;
}
/**
* 将 rank 转换为 BigInteger
*/
private static BigInteger rankToInt(String rank) {
BigInteger value = BigInteger.ZERO;
for (int i = 0; i < rank.length(); i++) {
int digit = charToValue(rank.charAt(i));
value = value.multiply(BigInteger.valueOf(BASE)).add(BigInteger.valueOf(digit));
}
return value;
}
private static String intToRank(BigInteger value, int length) {
StringBuilder sb = new StringBuilder();
while (value.compareTo(BigInteger.ZERO) > 0) {
BigInteger[] divRem = value.divideAndRemainder(BigInteger.valueOf(BASE));
sb.append(valueToChar(divRem[1].intValue()));
value = divRem[0];
}
while (sb.length() < length) {
sb.append('a');
}
return sb.reverse().toString();
}
private static int charToValue(char c) {
return c - 'a';
}
private static char valueToChar(int value) {
return (char) ('a' + value);
}
/**
* 比较两个 rank,按照自定义字符表比较
*/
public static int compare(String a, String b) {
if(charToIndex.isEmpty()){
init();
}
int len = Math.min(a.length(), b.length());
for (int i = 0; i < len; i++) {
int ai = charToIndex.get(a.charAt(i));
int bi = charToIndex.get(b.charAt(i));
if (ai != bi) {
return ai - bi;
}
}
return a.length() - b.length();
}
/**
* 生成最短中间值
*/
public static String getMidRank(String left, String right) throws RuntimeException{
if (compare(left, right) >= 0) {
throw new IllegalArgumentException("排序取中间值失败:左值必须小于右值");
}
StringBuilder mid = new StringBuilder();
int pos = 0;
while (true) {
char lChar = pos < left.length() ? left.charAt(pos) : 'a';
char rChar = pos < right.length() ? right.charAt(pos) : 'z';
if (lChar != rChar) {
if (rChar - lChar > 1) {
// 可分中间值,直接取中间字符
mid.append((char) (lChar + (rChar - lChar) / 2));
break;
} else {
// 相邻字符,保留左边,继续下一位
mid.append(lChar);
pos++;
}
} else {
mid.append(lChar);
pos++;
}
if (mid.length() >= 32) {
throw new IllegalStateException("Rank exceeds maximum length of 32");
}
}
// 如果生成的 rank 与左边相同,则追加 'n'
if (compare(mid.toString(), left) <= 0) {
if (mid.length() >= 32) {
throw new IllegalStateException("Cannot append 'n', max length reached");
}
mid.append('n');
}
if(left.contentEquals(mid) || right.contentEquals(mid)){
throw new RuntimeException("已经不可再分");
}
return mid.toString();
}
测试
由于我限制了32位的字符串长度,当我将初始字符串的长度设置为4时。向相同的位置反复插入,直到不可再分时,可以达到150次+插入。而使用long类型,大约50~60次就需要重排。
扩大相同位置可插入次数的两种方式
- 扩大字符范围,我在代码中限制的范围是a-z,但是有的实现是将A-Z或者0-9都加入进去,这样可以扩大范围。
- 采用更长的长度,由于我限制了32位,当我们限制更大长度时,所可以插入的范围也更大。
总结
其实这种算法,我个人理解是非常类似于使用数值除2的方式求中间值的方法。但是这种方式的好处有
- 使用26位数值,相当于使用26进制,中间位数更多。
- 使用字符串时,中间值可以变得更短,更加节省空间。
- 使用桶排序,可以让我们不可再分时,渐进式重排(有点像redis的渐进式hash?)