【问题标题】:Efficiently sorting a String by the number of occurrences of each of its characters按每个字符的出现次数有效地对字符串进行排序
【发布时间】:2017-02-08 13:08:14
【问题描述】:

我正在尝试按每个字符出现的次数对字符串进行排序,最常见的出现在开头,最稀有的出现在结尾。排序后,我需要删除所有重复的字符。因为示例总是更清晰,所以程序应该执行以下操作:

String str = "aebbaaahhhhhhaabbbccdfffeegh";
String output = sortByCharacterOccurrencesAndTrim(str);

在这种情况下,'sortByCharacterOccurrencesAndTrim' 方法应该返回:

String output = "habefcdg"

在 2 个字符出现相同的情况下,它们在返回字符串中的顺序无关紧要。所以“habefcdg”也可以等于“habfecgd”,因为“f”和“e”都出现了3次,而“d”和“g”都出现了一次。

"habefcdg" would effectively be the same as "habfecgd"

注意:我想指出,在这种情况下,性能很重要,所以我希望尽可能采用最有效的方法。我这样说是因为字符串长度可以从 1 到最大长度(我认为与 Integer.MAX_VALUE 相同,但不确定),所以我想尽量减少任何潜在的瓶颈。

【问题讨论】:

  • 字符集是否有限?
  • 如果字符串太大,那么在性能方面使用地图将是一个不错的选择,因为地图可以在哈希码的帮助下轻松排序。
  • 请定义“不合理的时间量”。我可以在 ~215ms 中使用映射处理 100,000,000 个字符的字符串。使用数组作为suggested by Jim Mischel,需要~48ms。将 Streams 用作 suggested by Óscar López 需要 ~1250ms。在我看来,即使是相对较慢的 Stream 解决方案也不会花费“不合理的时间”。
  • 您的字符串是否可能包含亚洲语言?如果是这样,请注意您不能独立处理 Java 字符;您需要考虑代理对并允许超过 65k 的不同字符,这使得基于数组的解决方案不切实际。
  • @MichaelBorgwardt:即使考虑到非 BMP 代码点,Unicode 代码点的总数也只有 65k 的 17 倍——我想说数组解决方案仍然可行。

标签: java string performance sorting


【解决方案1】:

只是为了好玩(我并不是说这是最有效的解决方案):一些 Java 8 lambdas + 并行流怎么样?

public String sortByCharacterOccurrencesAndTrim(String str) {

    // build a frequency map, for each code point store its count    
    Map<Integer, Long> frequencies =
        str.codePoints()
           .parallel()
           .boxed()
           .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));

    // sort by descending frequency and collect code points into array
    int[] output =
        frequencies.entrySet()
                   .parallelStream()
                   .sorted(Map.Entry.<Integer, Long>comparingByValue().reversed())
                   .mapToInt(Map.Entry::getKey)
                   .toArray();

    // create output string from code point array
    return new String(output, 0, output.length);

}

如果您想要一个超高效的解决方案,您可以使用显式循环重写上述算法,但这是很多代码,对我来说已经晚了:)。然而,想法是一样的:构建一个 char 频率图,使用降序按频率排序,然后用 char 构建一个字符串。

【讨论】:

  • 使用str.codePoints()不是更一致吗?
  • @shmosel 会有什么优势吗?
  • 它允许 3 或 4 个字节的字符,而不是这里有必要。但是如果你使用的是码位构造器,你也可以使用对应的流方法。
  • @shmosel 很公平 :) 我用你的建议更新了我的答案,谢谢!
  • @Andreas 干得好!我冒昧地更新了使用并行流的答案,性能提升比我预期的要大。谢谢!
【解决方案2】:

注意:这不是答案,只是显示Jim MischelÓscar López 答案的性能测试代码(响应comment by OP 的并行流)。

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.function.Function;
import java.util.stream.Collectors;

public class Test {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        String s = buildString();
        System.out.println("buildString: " + (System.currentTimeMillis() - start) + "ms");

        start = System.currentTimeMillis();
        String result1 = testUsingArray(s);
        System.out.println("testUsingArray: " + (System.currentTimeMillis() - start) + "ms");

        start = System.currentTimeMillis();
        String result2 = testUsingMap(s);
        System.out.println("testUsingMap: " + (System.currentTimeMillis() - start) + "ms");

        start = System.currentTimeMillis();
        String result3 = testUsingStream(s);
        System.out.println("testUsingStream: " + (System.currentTimeMillis() - start) + "ms");

        start = System.currentTimeMillis();
        String result4 = testUsingParallelStream(s);
        System.out.println("testUsingParallelStream: " + (System.currentTimeMillis() - start) + "ms");

        System.out.println(result1);
        System.out.println(result2);
        System.out.println(result3);
        System.out.println(result4);
    }
    private static String buildString() {
        Random rnd = new Random();
        char[] buf = new char[100_000_000];
        for (int i = 0; i < buf.length; i++)
            buf[i] = (char)(rnd.nextInt(127 - 33) + 33);
        return new String(buf);
    }
    private static String testUsingArray(String s) {
        int[] count = new int[65536];
        for (int i = 0; i < s.length(); i++)
            count[s.charAt(i)]++;
        List<CharCount> list = new ArrayList<>();
        for (int i = 0; i < 65536; i++)
            if (count[i] != 0)
                list.add(new CharCount((char)i, count[i]));
        Collections.sort(list);
        char[] buf = new char[list.size()];
        for (int i = 0; i < buf.length; i++)
            buf[i] = list.get(i).ch;
        return new String(buf);
    }
    private static String testUsingMap(String s) {
        Map<Character, CharCount> map = new HashMap<>();
        for (int i = 0; i < s.length(); i++)
            map.computeIfAbsent(s.charAt(i), CharCount::new).count++;
        List<CharCount> list = new ArrayList<>(map.values());
        Collections.sort(list);
        char[] buf = new char[list.size()];
        for (int i = 0; i < buf.length; i++)
            buf[i] = list.get(i).ch;
        return new String(buf);
    }
    private static String testUsingStream(String s) {
        int[] output = s.codePoints()
                        .boxed()
                        .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()))
                        .entrySet()
                        .stream()
                        .sorted(Map.Entry.<Integer, Long>comparingByValue().reversed())
                        .mapToInt(Map.Entry::getKey)
                        .toArray();
        return new String(output, 0, output.length);
    }
    private static String testUsingParallelStream(String s) {
        int[] output = s.codePoints()
                        .parallel()
                        .boxed()
                        .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()))
                        .entrySet()
                        .parallelStream()
                        .sorted(Map.Entry.<Integer, Long>comparingByValue().reversed())
                        .mapToInt(Map.Entry::getKey)
                        .toArray();
        return new String(output, 0, output.length);
    }
}
class CharCount implements Comparable<CharCount> {
    final char ch;
    int count;
    CharCount(char ch) {
        this.ch = ch;
    }
    CharCount(char ch, int count) {
        this.ch = ch;
        this.count = count;
    }
    @Override
    public int compareTo(CharCount that) {
        return Integer.compare(that.count, this.count); // descending
    }
}

样本输出

buildString: 974ms
testUsingArray: 48ms
testUsingMap: 216ms
testUsingStream: 1279ms
testUsingParallelStream: 442ms
UOMP<FV{KHt`(-q6;Gl'R9nxy+.Y[=2a7^45v?E@e,>|AD_\ILpJ}8sow"Z&bCmNW1$!Sd0c]~g3BjX#fz:Q*Tkui%/r)h
UOMP<FV{KHt`(-q6;Gl'R9nxy+.Y[=2a7^45v?E@e,>|AD_\ILpJ}8sow"Z&bCmNW1$!Sd0c]~g3BjX#fz:Q*Tkui%/r)h
UOMP<FV{KHt`(-q6;Gl'R9nxy+.Y[=2a7^45v?E@e,>|AD_\ILpJ}8sow"Z&bCmNW1$!Sd0c]~g3BjX#fz:Q*Tkui%/r)h
UOMP<FV{KHt`(-q6;Gl'R9nxy+.Y[=2a7^45v?E@e,>|AD_\ILpJ}8sow"Z&bCmNW1$!Sd0c]~g3BjX#fz:Q*Tkui%/r)h

【讨论】:

  • 另一个关于使用 TreeSet 而不是 ArrayList 的测试用例: private static String testUsingTreeSet(String s) { int[] count = new int[65536]; for (int i = 0; i set = new TreeSet(); for (int i = 0; i
  • 我希望TreeSet 解决方案比HashSet 解决方案要慢一些。 TreeSet 的解决方案是 O(n log k),其中 n 是字符串的长度,k 是唯一字符的数量。 HashSet 解决方案(和数组解决方案)为 O(n + k log k)(n 填充 HashSet,然后 k log k 对项目进行排序)。看到在这种情况下 n 如何比 k 大几个数量级(10^8 对 6x10^4),您可以将 HashSet 解决方案视为 O(n)。
  • @JimMischel 对 100000000 字符串的测试显示 TreeSetHashSet 的时间相同(ArrayList 插入也是 O(n))。由于我们正在计算最坏的情况,ArrayList 的 O(N + K + K logK) 将是 O(N + K + K logK) (N 用于遍历输入,K 用于填充 map/arrayList,K logK 用于排序)。对于TreeSet,它只是 O(N + K logK),我们根本没有改变这里的 N 部分,我们只是改变了我们对单个字符数进行排序的方式。实际上,我们也可以在 N 部分之后使用Arrays.sort 对数组进行排序,并且根本不使用额外的列表/映射。
  • @bashnesnos:我明白了。我误解了你提出的方法。
  • @bashnesnos 您的解决方案不起作用,除非您更改 compareTo() 实现以在 ch 上进行二次排序,而您没有建议这样做。 TreeSet 不允许重复,但 ArrayListsort() 都允许(保留原始顺序)。如果多个字符出现的频率相同,即具有相同的countTreeSet 将只保留具有该计数的最后一个字符。
【解决方案3】:

我对流和 lambdas 一无所知,但我会做这样的事情:

Map<Character, Integer> map;
for (int i=0;i<str.length,i++){
    char c = s.charAt(i);
    switch(c){
        case: map.containsKey(c):
            int temp = map.get(c)++;
            map.put(c, tmp);
            break;
        case: !map.containsKey(c):
            map.put(c, tmp);
            break;
    }
}        

计算出现次数。然后就是事后从高到低排序。

【讨论】:

  • 这还能编译吗?我认为你需要map.put(c, 1);
  • 此答案中的第 4、5、6、8、9 行(从零开始)有错误
【解决方案4】:

“一个地图和几个while循环”当然是最简单的方法,而且可能会非常快。这个想法是:

for each character
    increment its count in the map
Sort the map in descending order
Output the map keys in that order

但是 100,000,000 次地图查找可能会变得非常昂贵。您可以通过创建一个包含 65,536 个整数计数(如果是 ASCII 则为 128 个字符)的数组来潜在地加速它。那么:

for each character
    array[(int)ch] += 1

然后,您遍历该数组并创建具有非零计数的字符映射:

for i = 0 to 65535
    if array[i] > 0
        map.add((char)i, array[i])

然后按降序对地图进行排序,并按该顺序输出字符。

这可能会执行得更快一些,因为索引到数组 100,000,000 次可能比执行 100,000,000 次地图查找要快得多。

【讨论】:

  • HashMap 查找是 O(1),那么为什么说数组查找比地图查找“执行得快很多”呢?
  • @Andreas:查找是 O(1),是的。直接索引数组也是如此。但请记住 O(1) 没有考虑常数因素。对数组进行索引涉及间接内存引用。在哈希映射中查找涉及调用一个函数,该函数从键计算哈希值,然后索引到支持数组。哈希表查找与索引数组一样快,但需要执行更多代码。
  • 我测试了您的解决方案的性能。见my comment to question。相对而言,4 倍的性能差异可以被认为是“相当昂贵”,但对于 100,000,000 个字符的字符串,这两种解决方案仍然运行得相当快。
  • @Andreas:简单的地图解决方案似乎是可行的方法。我的阵列解决方案更快,但考虑到从磁盘或网络等读取字符串所需的时间,我认为 50 毫秒并不重要。代码中的额外复杂性只是没有似乎不值得。
  • @JimMischel 正如 Andreas 的基准所表明的那样,看起来数组是可行的方法。我会看看我是否可以启动并运行一些东西,然后带着结果和毫无疑问的更多问题回来。
猜你喜欢
  • 2016-08-16
  • 1970-01-01
  • 2021-04-16
  • 2016-05-25
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-01-18
  • 2018-08-29
相关资源
最近更新 更多