原创

Java Set 去重怎么选:HashSet、LinkedHashSet 与 TreeSet

把标签列表转成 Set,重复项确实少了,页面上的顺序却也变了。另一个页面又发现 Redis 和 redis 只剩一项。两处代码都叫“去重”,实际执行的规则并不相同:一处需要保留首次出现顺序,一处按比较器把大小写不同的文本当成同一个元素。

选 Set 之前,先确定两个问题:怎样算重复,结果要什么顺序。HashSet 不保证遍历次序;LinkedHashSet 保留加入顺序;TreeSet 按比较规则排列,同时也用这个规则判断元素是否已经存在。TreeSet 并不是给 HashSet 免费加一个排序效果。

标签相同,三种输出要求

下面的输入经过 strip 清理首尾空格后为 [Java, Redis, Java, MySQL, redis]。本例默认按 String.equals 判断相同,大小写不同就是两个标签,所以共有四个唯一值。

如果用于筛选成员,HashSet 可以完成包含判断;如果用于回显用户第一次输入的顺序,选择 LinkedHashSet;如果明确要自然排序,可以创建 TreeSet。对这份数据,首次出现顺序为 [Java, Redis, MySQL, redis],自然排序则为 [Java, MySQL, Redis, redis]。不要用 HashSet 某次运行恰好显示的顺序作为页面合同。

再加一条业务要求:英文技术标签忽略大小写。可以明确规范成小写后去重,也可以使用忽略大小写的比较器,但后者还会保留第一次加入的原始文本。Redis 与 redis 的选择不能靠“哪个实现更高级”来决定。

完整运行一次,再选需要的结果

使用 JDK 17,保存为 TagSetDemo.java。程序还演练并集、交集、差集,所有集合运算都在副本上执行,保留左侧原数据。

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;

public class TagSetDemo {
    static List<String> validate(List<String> input) {
        Objects.requireNonNull(input, "tags are required");
        List<String> result = new ArrayList<>();
        for (String raw : input) {
            if (raw == null) throw new IllegalArgumentException("null tag");
            String value = raw.strip();
            if (value.isEmpty()) throw new IllegalArgumentException("blank tag");
            result.add(value);
        }
        return List.copyOf(result);
    }
    static LinkedHashSet<String> union(Set<String> left, Set<String> right) {
        LinkedHashSet<String> result = new LinkedHashSet<>(left);
        result.addAll(right);
        return result;
    }
    static LinkedHashSet<String> intersection(Set<String> left, Set<String> right) {
        LinkedHashSet<String> result = new LinkedHashSet<>(left);
        result.retainAll(right);
        return result;
    }
    static LinkedHashSet<String> difference(Set<String> left, Set<String> right) {
        LinkedHashSet<String> result = new LinkedHashSet<>(left);
        result.removeAll(right);
        return result;
    }
    static void equal(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) {
            throw new AssertionError("expected=" + expected + " actual=" + actual);
        }
    }
    static void fails(Class<? extends RuntimeException> type, Runnable action) {
        try { action.run(); }
        catch (RuntimeException error) {
            if (type.isInstance(error)) return;
            throw error;
        }
        throw new AssertionError("expected " + type.getSimpleName());
    }

    public static void main(String[] args) {
        List<String> tags = validate(List.of(" Java ", "Redis", "Java", "MySQL", "redis"));
        Set<String> hash = new HashSet<>(tags);
        LinkedHashSet<String> insertion = new LinkedHashSet<>(tags);
        TreeSet<String> sorted = new TreeSet<>(tags);
        equal(4, hash.size());
        equal(Set.of("Java", "Redis", "MySQL", "redis"), hash);
        equal(false, insertion.add("Java"));
        equal(List.of("Java", "Redis", "MySQL", "redis"), new ArrayList<>(insertion));
        equal(List.of("Java", "MySQL", "Redis", "redis"), new ArrayList<>(sorted));
        System.out.println("INPUT_ROWS=" + tags.size() + ",UNIQUE=" + hash.size());
        System.out.println("INSERTION=" + insertion);
        System.out.println("SORTED=" + sorted);

        TreeSet<String> ignoreCase = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
        ignoreCase.addAll(tags);
        equal(3, ignoreCase.size());
        equal(true, ignoreCase.contains("REDIS"));
        equal(false, ignoreCase.add("rEdIs"));
        equal(List.of("Java", "MySQL", "Redis"), new ArrayList<>(ignoreCase));
        System.out.println("IGNORE_CASE=" + ignoreCase);
        LinkedHashSet<String> canonical = new LinkedHashSet<>();
        for (String tag : tags) canonical.add(tag.toLowerCase(Locale.ROOT));
        equal(List.of("java", "redis", "mysql"), new ArrayList<>(canonical));
        System.out.println("CANONICAL=" + canonical);

        Set<String> left = new LinkedHashSet<>(List.of("Java", "Redis", "MySQL"));
        Set<String> right = new LinkedHashSet<>(List.of("Redis", "Go"));
        equal(List.of("Java", "Redis", "MySQL", "Go"), new ArrayList<>(union(left, right)));
        equal(List.of("Redis"), new ArrayList<>(intersection(left, right)));
        equal(List.of("Java", "MySQL"), new ArrayList<>(difference(left, right)));
        equal(List.of("Java", "Redis", "MySQL"), new ArrayList<>(left));
        System.out.println("UNION=" + union(left, right));
        System.out.println("INTERSECTION=" + intersection(left, right));
        System.out.println("DIFFERENCE=" + difference(left, right));
        System.out.println("INPUT_UNCHANGED=" + left);

        HashSet<String> nullable = new HashSet<>();
        equal(true, nullable.add(null));
        equal(false, nullable.add(null));
        equal(true, nullable.contains(null));
        fails(NullPointerException.class, () -> sorted.add(null));
        fails(IllegalArgumentException.class, () -> Set.of("Java", "Java"));
        fails(NullPointerException.class, () -> Set.of("Java", null));
        fails(IllegalArgumentException.class, () -> validate(Arrays.asList("Java", null)));
        fails(IllegalArgumentException.class, () -> validate(List.of("  ")));
        System.out.println("ALL_CHECKS_PASSED");
    }
}

编译运行:

javac -encoding UTF-8 TagSetDemo.java
java TagSetDemo

预期输出:

INPUT_ROWS=5,UNIQUE=4
INSERTION=[Java, Redis, MySQL, redis]
SORTED=[Java, MySQL, Redis, redis]
IGNORE_CASE=[Java, MySQL, Redis]
CANONICAL=[java, redis, mysql]
UNION=[Java, Redis, MySQL, Go]
INTERSECTION=[Redis]
DIFFERENCE=[Java, MySQL]
INPUT_UNCHANGED=[Java, Redis, MySQL]
ALL_CHECKS_PASSED

程序没有打印 HashSet 的具体遍历结果,只检查成员与数量。对无顺序承诺的集合,验证“这些元素都在”比断言某个输出排列合理。

LinkedHashSet 保留首次出现,不会因重复加入换位置

输入中的第二个 Java 被忽略,add 返回 false,原来的 Java 仍在首位。如果希望把重复项移到最后,就需要明确执行删除再加入;仅仅再次 add 不会实现“最近使用顺序”。

LinkedHashSet 保留的是进入它的那份数据的迭代顺序。若先把输入转成 HashSet,再构造 LinkedHashSet,原列表的首次出现顺序已经没有保证,后一步无法恢复。要保留用户输入顺序,应直接从原列表创建它。

这里先 validate,再去重,所以 null 和纯空格标签会被拒绝,不会悄悄消失。HashSet 本身允许一个 null,并不意味着标签业务也应该允许;TreeSet 是否允许 null 则还与比较器有关,程序只对自然排序的这个实例核对拒绝行为,不能据此断言所有自定义 TreeSet 都一样。

排序比较为零,就已经是 TreeSet 里的同一个元素

自然顺序下,Redis 和 redis 不同;换成 CASE_INSENSITIVE_ORDER 后,两者比较结果为零,后加入的值不会另占一项。contains("REDIS") 也会返回 true,虽然原来存入的是 Redis。

String.equals 仍然区分大小写,所以这个比较器与 equals 的相等规则不一致。它可以表达本例的特定去重需求,但不要再把它当作普通的、按 String.equals 判断成员的 Set,随意与其他 Set 交换并指望所有相等比较都符合直觉。尤其不能只为好看而换比较器,却让业务中的两条不同记录合成一条。

如果大小写只是展示排序要求,而不能改变去重规则,可以先按 equals 去重,再把元素复制到 List,使用忽略大小写的 Comparator 排序。List 排序不会因为比较器返回零就删掉元素。反过来,若业务明确要求这些英文技术标签大小写不敏感并存成统一文本,程序里的 Locale.ROOT 小写规范更直接;不同语言文字的完整匹配规则需要另定,不能把这几组英文示例扩成任意 Unicode 的统一去重承诺。

retainAll 和 removeAll 会改掉接收者

交集操作不是返回一份新集合的纯计算。left.retainAll(right) 会把 left 中不在 right 的元素移除;直接用于后面还要展示的原数据,会导致后续列表突然少项。程序先复制 left,再对副本操作,并核对 INPUT_UNCHANGED 仍有三项。

同理,并集用 addAll,差集用 removeAll。本例左右两侧都采用普通 String 相等规则,而且副本使用 LinkedHashSet,所以交集和差集沿用左侧顺序;并集先保留左侧元素,再补右侧尚未出现的 Go。不要把一个按大小写敏感判断成员的集合与另一个忽略大小写的集合直接混算,否则“是否包含”可能取决于实际调用哪一侧的判断规则。

Set.of 适合声明固定集合,遇到重复元素会抛 IllegalArgumentException,也不接受 null。它与 HashSet.add 遇到重复项返回 false 的行为不同。导入一份允许重复的标签列表时,应由所选的可变集合执行去重,不能把所有输入直接塞进 Set.of,再把重复数据当成程序异常处理。

正文到此结束
评论插件初始化中...
Loading...