【LexoRank】一种解决用户拖拉拽的排序方式

336 阅读10分钟

前言

最近一次业务中,我们实现了对于分享人的拖拉拽排序,这样用户可以通过前端的拖拉拽,实现会议的自定义人员的分享顺序。

一开始我们的方案是,当排序时,前端将计算的位置计算出来,然后传给后端,后端赋予新的排序值,这样就形成了新的排序顺序。

计算方式是,将所要插入的前后两个排序值相加/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条记录进行重排。

  1. 首先按照需要排序记录的总数量,排序字符串总的范围(如果我们限制32位字符的话,那么范围最小就是"a", 最大就是"zzzzz..." 32个z),计算步长(也就是均匀分布情况下,每两条记录之间,按照字典序的最大的间隔)
  2. 然后按照我们范围的最大值,赋予这10条记录新的排序值。
  3. 将这10条记录的桶由0改为1。
  4. 接下来继续取10条记录,拿到步长,不过这个时候最大值将不是一开始范围的最大值,而是我们已经重拍后的那10条的最小值,作为新的范围的最大值。

这种排序可以带来的好处是,重排过程中,未重排的记录,和已重排的记录之间的顺序是并没有发生改变的,而且由于我们是渐进式的重排,所以在重排过程中,我们的排序依然是可用的。

但是注意,如果我们的桶只有0,1,2时。 当桶由2 转为 0时,需要将记录从前向后进行重排。

实现

梳理需求

那么我们可以按照上文的一些介绍来实现我们自己的排序算法,首先我们实现这个算法的整体需求。

  1. 初始化:计算每个值的初始化值,并且赋予记录的排序值字段。
  2. 排序:排序过程中计算中间值,赋予插入的记录的排序值。
  3. 重排:当不可重排时,渐进式重置初始值。

计算中间值的逻辑是:获取 left 和 right 之间的最短中间字符串

  1. 字符串的位数不能超过32位(我自己限制的,用于限制字符串在数据库的长度)
  2. 比较两个字符串,按照字典序(数据库的比较字符串的逻辑)
  3. 如果出现了第一个不相同的字符,则比较两个字符的大小
    1. 如果两个字符之间不存在中间值,即两者相邻,则向后继续判断
    2. 如果两个之间存在中间值,则直接返回前面比较过的字符 + 中间值
    3. 如果两者之间一直没有中间值,直到最短的比较完成,判断较小值的后面未比较的第一个字符,如果小于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次就需要重排。

扩大相同位置可插入次数的两种方式

  1. 扩大字符范围,我在代码中限制的范围是a-z,但是有的实现是将A-Z或者0-9都加入进去,这样可以扩大范围。
  2. 采用更长的长度,由于我限制了32位,当我们限制更大长度时,所可以插入的范围也更大。

总结

其实这种算法,我个人理解是非常类似于使用数值除2的方式求中间值的方法。但是这种方式的好处有

  1. 使用26位数值,相当于使用26进制,中间位数更多。
  2. 使用字符串时,中间值可以变得更短,更加节省空间。
  3. 使用桶排序,可以让我们不可再分时,渐进式重排(有点像redis的渐进式hash?)

参考