JavaでListの重複を削除するには、順序を保つなら new ArrayList<>(new LinkedHashSet<>(list)) か list.stream().distinct().toList() を使います。 順序が不要なら HashSet、並べ替えも同時にしたいなら TreeSet が使えます。自作クラスのリストでは、equals と hashCode があるかどうかで結果が変わります。

この記事では、HashSet・LinkedHashSet・TreeSet・distinct を「順序が残るか/ソートされるか/null の可否」で比べる早見表を最初に示し、同じ入力で出力順がどう変わるかを実行結果で並べます。 さらに、equals と hashCode のない自作クラスで distinct が効かない様子の再現、特定の項目(メールアドレスなど)をキーにした重複排除の3通り、重複している値だけを取り出す方法、先勝ちと後勝ちの切り替えまで、1ファイルで動くコードと出力で説明します。

確認環境:Ubuntu 24.04、OpenJDK 21.0.10(java ファイル名.java で実行)。

重複を削除する方法の早見表

方法 順序が残るか ソートされるか null の可否 書き方
HashSet 残らない されない 可(1つにまとまる) new ArrayList<>(new HashSet<>(list))
LinkedHashSet 残る(最初に出てきた位置) されない 可 new ArrayList<>(new LinkedHashSet<>(list))
TreeSet 残らない される(昇順) 不可(NullPointerException)。Comparator で対応可 new ArrayList<>(new TreeSet<>(list))
distinct 残る(最初に出てきた位置) されない 可 list.stream().distinct().toList()
distinct と sorted 残らない される sorted が null で止まる list.stream().distinct().sorted().toList()
removeIf と Set 残る されない 可 list.removeIf(x -> !seen.add(x))(元のリストを書き換える)
やりたいこと 使うもの
特定の項目が同じものを1つにまとめる(先勝ち) toMap と LinkedHashMap、または distinctByKey
特定の項目が同じものを1つにまとめる(後勝ち) toMap のマージ関数を (a, b) -> b に
特定の項目で重複を除き、その項目順に並べる TreeSet と Comparator
重複している値だけを取り出す groupingBy と counting、または Set.add の戻り値
重複があるかどうかだけ知りたい new HashSet<>(list).size() != list.size()
大文字と小文字を区別せずに重複を除く new TreeSet<>(String.CASE_INSENSITIVE_ORDER)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java Listの重複を削除する方法 順序保持とキー指定の重複排除」

同じ入力で出力順を比べる:HashSet・LinkedHashSet・TreeSet・distinct

4つの方法に同じリストを渡して、出力の順序を比べます。

import java.util.*;
import java.util.stream.Collectors;

public class Four {
    public static void main(String[] args) {
        List<String> input = List.of("orange", "apple", "banana", "apple", "cherry", "orange", "apple");
        System.out.println("入力           : " + input);

        List<String> viaHashSet = new ArrayList<>(new HashSet<>(input));
        System.out.println("HashSet        : " + viaHashSet);

        List<String> viaLinked = new ArrayList<>(new LinkedHashSet<>(input));
        System.out.println("LinkedHashSet  : " + viaLinked);

        List<String> viaTree = new ArrayList<>(new TreeSet<>(input));
        System.out.println("TreeSet        : " + viaTree);

        List<String> viaDistinct = input.stream().distinct().toList();
        System.out.println("distinct       : " + viaDistinct);

        List<String> viaToSet = new ArrayList<>(input.stream().collect(Collectors.toSet()));
        System.out.println("toSet          : " + viaToSet);

        // 降順で重複を除く
        TreeSet<String> desc = new TreeSet<>(Comparator.reverseOrder());
        desc.addAll(input);
        System.out.println("TreeSet(降順)  : " + new ArrayList<>(desc));

        // 数値の例
        List<Integer> nums = List.of(30, 5, 100, 5, 17, 30, 2);
        System.out.println("数値 HashSet   : " + new ArrayList<>(new HashSet<>(nums)));
        System.out.println("数値 Linked    : " + new ArrayList<>(new LinkedHashSet<>(nums)));
        System.out.println("数値 TreeSet   : " + new ArrayList<>(new TreeSet<>(nums)));
        System.out.println("数値 distinct  : " + nums.stream().distinct().toList());
        System.out.println("distinct+sorted: " + nums.stream().distinct().sorted().toList());
    }
}

実行結果:

入力           : [orange, apple, banana, apple, cherry, orange, apple]
HashSet        : [orange, banana, apple, cherry]
LinkedHashSet  : [orange, apple, banana, cherry]
TreeSet        : [apple, banana, cherry, orange]
distinct       : [orange, apple, banana, cherry]
toSet          : [orange, banana, apple, cherry]
TreeSet(降順)  : [orange, cherry, banana, apple]
数値 HashSet   : [17, 2, 100, 5, 30]
数値 Linked    : [30, 5, 100, 17, 2]
数値 TreeSet   : [2, 5, 17, 30, 100]
数値 distinct  : [30, 5, 100, 17, 2]
distinct+sorted: [2, 5, 17, 30, 100]
方法 文字列の結果 読み取れること
入力 orange, apple, banana, apple, cherry, orange, apple apple が3回、orange が2回
HashSet orange, banana, apple, cherry 入力の順でもアルファベット順でもない
LinkedHashSet orange, apple, banana, cherry 最初に出てきた順
TreeSet apple, banana, cherry, orange アルファベットの昇順
distinct orange, apple, banana, cherry 最初に出てきた順(LinkedHashSet と同じ)
Collectors.toSet orange, banana, apple, cherry 今回は HashSet と同じ並び

順序を保ちたいなら LinkedHashSet か distinct、並べ替えたいなら TreeSet、順序が不要なら HashSet、と覚えておくと選びやすくなります。 HashSet の並びは要素のハッシュ値で決まるため、入力の順序とは関係がありません。数値の例でも [17, 2, 100, 5, 30] と、入力順(30, 5, 100, 17, 2)とも昇順とも違う並びになりました。「たまたま期待した順に見えた」ということもあるので、順序に意味があるときは LinkedHashSet か distinct を選びます。

distinct については、公式ドキュメントに「順序のあるストリームでは、重複のうち最初に出てきた要素が残る」と書かれています。List から作ったストリームは順序を持つので、distinct は先勝ちで、元の順序のままになります。

LinkedHashSetとdistinctのどちらを使うか

状況 向いているもの
重複を除くだけ どちらでもよい。1行で書ける new ArrayList<>(new LinkedHashSet<>(list))
filter や map など、ほかの処理と続けて書きたい stream().distinct()
結果を後から変更したい(add や remove) new ArrayList<>(...) で受ける。toList() の結果は変更できない
降順で重複を除きたい new TreeSet<>(Comparator.reverseOrder()) に addAll

nullが混ざるとき:TreeSetだけ止まる

import java.util.*;

public class NullEl {
    public static void main(String[] args) {
        List<String> input = Arrays.asList("b", null, "a", null, "b");
        System.out.println("入力         : " + input);
        System.out.println("HashSet      : " + new ArrayList<>(new HashSet<>(input)));
        System.out.println("LinkedHashSet: " + new ArrayList<>(new LinkedHashSet<>(input)));
        System.out.println("distinct     : " + input.stream().distinct().toList());
        try {
            new TreeSet<>(input);
        } catch (NullPointerException e) {
            System.out.println("TreeSet      : " + e);
        }
        // nullを先頭に置くComparatorを渡すとTreeSetでも扱える
        TreeSet<String> ts = new TreeSet<>(Comparator.nullsFirst(Comparator.naturalOrder()));
        ts.addAll(input);
        System.out.println("TreeSet+nullsFirst: " + new ArrayList<>(ts));
        // nullを除いてから重複を除く
        System.out.println("nullを除く   : "
            + input.stream().filter(Objects::nonNull).distinct().toList());
        try {
            Set.copyOf(input);
        } catch (NullPointerException e) {
            System.out.println("Set.copyOf   : " + e);
        }
    }
}

実行結果:

入力         : [b, null, a, null, b]
HashSet      : [null, a, b]
LinkedHashSet: [b, null, a]
distinct     : [b, null, a]
TreeSet      : java.lang.NullPointerException
TreeSet+nullsFirst: [null, a, b]
nullを除く   : [b, a]
Set.copyOf   : java.lang.NullPointerException: Cannot invoke "Object.hashCode()" because "pe" is null

HashSet、LinkedHashSet、distinct は null を1つの値として扱い、2つあった null が1つにまとまりました。TreeSet は要素どうしを比べて並べるため、null が入ると NullPointerException になります。 スタックトレースは次のとおりです。

import java.util.*;

public class NullTree {
    public static void main(String[] args) {
        List<String> input = Arrays.asList("b", null, "a");
        System.out.println(new TreeSet<>(input));
    }
}
Exception in thread "main" java.lang.NullPointerException
    at java.base/java.util.Objects.requireNonNull(Objects.java:233)
    at java.base/java.util.TreeMap.put(TreeMap.java:844)
    at java.base/java.util.TreeMap.put(TreeMap.java:569)
    at java.base/java.util.TreeSet.add(TreeSet.java:259)
    at java.base/java.util.AbstractCollection.addAll(AbstractCollection.java:338)
    at java.base/java.util.TreeSet.addAll(TreeSet.java:313)
    at java.base/java.util.TreeSet.<init>(TreeSet.java:164)
    at NullTree.main(NullTree.java:6)

TreeMap.put と Objects.requireNonNull が並んでいれば、「TreeSet に null が入った」と読み取れます(TreeSet は内部で TreeMap を使っています)。対応は2通りです。

対応 書き方 結果
null を除いてから重複を除く filter(Objects::nonNull).distinct() [b, a]
null を先頭に置く Comparator を渡す new TreeSet<>(Comparator.nullsFirst(Comparator.naturalOrder())) [null, a, b]

なお、Set.copyOf も null を受け付けず、NullPointerException になりました。

自作クラスはequalsとhashCodeがないとdistinctが効かない

ここからはオブジェクトのリストです。equals と hashCode を書いていないクラスで試します。

import java.util.*;

public class NoEq {
    // equals / hashCode を書いていないクラス
    static class User {
        final int id;
        final String name;
        User(int id, String name) { this.id = id; this.name = name; }
        @Override public String toString() { return "User(" + id + ", " + name + ")"; }
    }

    public static void main(String[] args) {
        List<User> users = List.of(
            new User(1, "sato"), new User(2, "suzuki"), new User(1, "sato"));

        System.out.println("distinct     : " + users.stream().distinct().toList());
        System.out.println("LinkedHashSet: " + new ArrayList<>(new LinkedHashSet<>(users)));
        System.out.println("件数         : " + users.stream().distinct().count());
        try {
            new TreeSet<>(users);
        } catch (ClassCastException e) {
            System.out.println("TreeSet      : " + e.getMessage());
        }
    }
}

実行結果:

distinct     : [User(1, sato), User(2, suzuki), User(1, sato)]
LinkedHashSet: [User(1, sato), User(2, suzuki), User(1, sato)]
件数         : 3
TreeSet      : class NoEq$User cannot be cast to class java.lang.Comparable (NoEq$User is in unnamed module of loader com.sun.tools.javac.launcher.Main$MemoryClassLoader @33afa13b; java.lang.Comparable is in module java.base of loader 'bootstrap')

id も name も同じ User(1, sato) が2つ残り、件数は3件のままです。distinct も LinkedHashSet も、要素の equals(と hashCode)で「同じかどうか」を判定します。equals を書いていないクラスは「同じインスタンスかどうか」だけを比べるので、new で別々に作ったものは、中身が同じでも別のものとして扱われます。 エラーにならず、そのまま重複が残る点に注意が必要です。

TreeSet は別の理由で止まりました。並べ替えのために Comparable(大小を比べる方法を持つクラス)が必要で、User はそれを実装していないため ClassCastException になります。

直し方:equalsとhashCodeを実装する、またはrecordにする

import java.util.*;

public class WithEq {
    static class User {
        final int id;
        final String name;
        User(int id, String name) { this.id = id; this.name = name; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof User other)) return false;
            return id == other.id && Objects.equals(name, other.name);
        }

        @Override
        public int hashCode() { return Objects.hash(id, name); }

        @Override
        public String toString() { return "User(" + id + ", " + name + ")"; }
    }

    record Member(int id, String name) {}

    public static void main(String[] args) {
        List<User> users = List.of(
            new User(1, "sato"), new User(2, "suzuki"), new User(1, "sato"));
        System.out.println("equals/hashCodeあり: " + users.stream().distinct().toList());

        List<Member> members = List.of(
            new Member(1, "sato"), new Member(2, "suzuki"), new Member(1, "sato"));
        System.out.println("record             : " + members.stream().distinct().toList());
        System.out.println("record+LinkedHashSet: " + new ArrayList<>(new LinkedHashSet<>(members)));
    }
}

実行結果:

equals/hashCodeあり: [User(1, sato), User(2, suzuki)]
record             : [Member[id=1, name=sato], Member[id=2, name=suzuki]]
record+LinkedHashSet: [Member[id=1, name=sato], Member[id=2, name=suzuki]]

equals と hashCode を書いたクラス、record のどちらも、重複が1つにまとまりました。Java 16 以降なら、データを入れるクラスは record にすると equals と hashCode が自動で用意されるので、書き忘れがなくなります。

equals だけ書いて hashCode を書かなかった場合も見ておきます。

import java.util.*;

public class EqOnly {
    // equals だけ書いて hashCode を書いていないクラス
    static class User {
        final int id;
        User(int id) { this.id = id; }
        @Override public boolean equals(Object o) { return o instanceof User u && id == u.id; }
        @Override public String toString() { return "User(" + id + ")"; }
    }

    public static void main(String[] args) {
        List<User> users = List.of(new User(1), new User(2), new User(1));
        System.out.println("equals(同じid): " + users.get(0).equals(users.get(2)));
        System.out.println("distinct      : " + users.stream().distinct().toList());
        System.out.println("HashSet件数   : " + new HashSet<>(users).size());
    }
}

実行結果:

equals(同じid): true
distinct      : [User(1), User(2), User(1)]
HashSet件数   : 3

equals は true を返しているのに、distinct でも HashSet でも重複が残りました。これらは先に hashCode で絞り込むためです。equals と hashCode は必ずセットで書きます。

クラスの状態 distinct/LinkedHashSet の結果
equals も hashCode もない 重複が残る(3件のまま)
equals だけある 重複が残る(3件のまま)
equals と hashCode がある 重複が除かれる(2件)
record 重複が除かれる(2件)

特定の項目をキーにして重複を除く3つの方法

「全項目が同じ」ではなく、「メールアドレスが同じ注文は1件にまとめたい」のように、特定の項目だけで重複を判定したい場面があります。equals を書き換えると他の処理にも影響するので、キーを指定する方法を使います。

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;

public class ByKey {
    record Order(int no, String email, String item) {}

    // 方法3: キーを取り出す関数を渡して使い回せるフィルター
    static <T> Predicate<T> distinctByKey(Function<? super T, ?> keyExtractor) {
        Set<Object> seen = ConcurrentHashMap.newKeySet();
        return t -> seen.add(keyExtractor.apply(t));
    }

    public static void main(String[] args) {
        List<Order> orders = List.of(
            new Order(1, "sato@example.com", "ノート"),
            new Order(2, "suzuki@example.com", "ペン"),
            new Order(3, "sato@example.com", "付箋"),
            new Order(4, "ito@example.com", "ノート"),
            new Order(5, "suzuki@example.com", "定規"));

        // そのままdistinctしても、全項目が同じものは無いので5件のまま
        System.out.println("distinctだけ: " + orders.stream().distinct().count() + "件");

        // 方法1: toMap + LinkedHashMap(先勝ち)
        List<Order> first = new ArrayList<>(orders.stream()
            .collect(Collectors.toMap(Order::email, Function.identity(),
                (a, b) -> a, LinkedHashMap::new))
            .values());
        print("toMap 先勝ち", first);

        // 方法1': マージ関数を (a, b) -> b にすると後勝ち
        List<Order> last = new ArrayList<>(orders.stream()
            .collect(Collectors.toMap(Order::email, Function.identity(),
                (a, b) -> b, LinkedHashMap::new))
            .values());
        print("toMap 後勝ち", last);

        // 方法2: TreeSet + Comparator(キーの昇順に並ぶ・先勝ち)
        List<Order> tree = orders.stream()
            .collect(Collectors.collectingAndThen(
                Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(Order::email))),
                ArrayList::new));
        print("TreeSet", tree);

        // 方法3: distinctByKey(元の順序・先勝ち)
        List<Order> byKey = orders.stream()
            .filter(distinctByKey(Order::email))
            .toList();
        print("distinctByKey", byKey);

        // 複数の項目の組み合わせをキーにする
        List<Order> more = List.of(
            new Order(1, "sato@example.com", "ノート"),
            new Order(2, "sato@example.com", "ノート"),
            new Order(3, "sato@example.com", "ペン"));
        List<Order> multi = more.stream()
            .filter(distinctByKey(o -> List.of(o.email(), o.item())))
            .toList();
        print("複数項目", multi);

        // 後勝ちで、最後に出てきた位置に並べたい場合
        Map<String, Order> map = new LinkedHashMap<>();
        for (Order o : orders) {
            map.remove(o.email());
            map.put(o.email(), o);
        }
        print("後勝ち(最後の位置)", new ArrayList<>(map.values()));
    }

    static void print(String label, List<Order> list) {
        System.out.println("--- " + label);
        list.forEach(o -> System.out.println("  " + o.no() + " " + o.email() + " " + o.item()));
    }
}

実行結果:

distinctだけ: 5件
--- toMap 先勝ち
  1 sato@example.com ノート
  2 suzuki@example.com ペン
  4 ito@example.com ノート
--- toMap 後勝ち
  3 sato@example.com 付箋
  5 suzuki@example.com 定規
  4 ito@example.com ノート
--- TreeSet
  4 ito@example.com ノート
  1 sato@example.com ノート
  2 suzuki@example.com ペン
--- distinctByKey
  1 sato@example.com ノート
  2 suzuki@example.com ペン
  4 ito@example.com ノート
--- 複数項目
  1 sato@example.com ノート
  3 sato@example.com ペン
--- 後勝ち(最後の位置)
  3 sato@example.com 付箋
  4 ito@example.com ノート
  5 suzuki@example.com 定規

注文5件のうち、メールアドレスは3種類です。record なので distinct は「全項目が同じ」ものだけを除き、5件のままでした。3つの方法の結果を比べます。

方法 残った注文番号 並び 残るのは
toMap と LinkedHashMap、(a, b) -> a 1, 2, 4 最初に出てきた順 先勝ち
toMap と LinkedHashMap、(a, b) -> b 3, 5, 4 キーが最初に出てきた位置 後勝ち
TreeSet と Comparator 4, 1, 2 キー(メールアドレス)の昇順 先勝ち
distinctByKey 1, 2, 4 最初に出てきた順 先勝ち

方法1:toMapとLinkedHashMap(先勝ち・後勝ちを切り替えられる)

キーを項目、値を要素そのもの(Function.identity())にしてMapを作り、values() を取り出します。3つ目の引数(マージ関数)が、同じキーが出てきたときにどちらを残すかを決めます。

マージ関数 意味
(a, b) -> a 先勝ち(最初に出てきた要素を残す)
(a, b) -> b 後勝ち(最後に出てきた要素を残す)
(a, b) -> a.no() > b.no() ? a : b 条件で選ぶ(番号が大きいほうなど)

先勝ちと後勝ちを1か所の書き換えで切り替えられるのが、この方法の強みです。 4つ目の引数に LinkedHashMap::new を渡しているので、順序も保たれます。

後勝ちのとき、並びは「キーが最初に出てきた位置」になります。上の結果では、sato さんの注文は3番に置き換わりましたが、位置は先頭のままです(3, 5, 4)。「最後に出てきた位置」に並べたいときは、コードの最後にあるように、for文で remove してから put し直します。結果は 3, 4, 5 の順になりました。

方法2:TreeSetとComparator(キーの順に並べたいとき)

new TreeSet<>(Comparator.comparing(Order::email)) は、「メールアドレスが同じなら同じ要素」とみなす集合です。toCollection で集めたあと、collectingAndThen で ArrayList に入れ直しています。結果がキーの昇順に並ぶので、重複排除と並べ替えを一度に済ませたいときに向いています。 元の順序は残りません。同じキーでは、先に入った要素が残ります(先勝ち)。

方法3:distinctByKey(filterに渡して使い回す)

distinctByKey は標準ライブラリにはなく、自分で用意する小さなメソッドです。中に「すでに見たキー」を覚えておく Set を持ち、seen.add(キー) の戻り値(初めてなら true、すでにあれば false)をそのまま filter の条件にしています。

  • filter(distinctByKey(Order::email)) と1行で書け、ほかの filter や map と組み合わせやすい。
  • 複数の項目の組み合わせをキーにするときは、o -> List.of(o.email(), o.item()) のようにリストにまとめて渡します。 上の結果では、メールアドレスと商品の両方が同じ2番だけが除かれました。
  • 返ってきた Predicate は「見たキー」を覚えています。変数に入れて2回使うと2回目は何も通らなくなるため、使うたびに distinctByKey(...) を呼んで新しく作ります。
  • キーが null になる要素があると、この書き方では NullPointerException になります(ConcurrentHashMap から作った Set は null を受け付けないため)。null がありうるときは、先に filter で除くか、new HashSet<>() を使います。

重複している値だけを取り出す

「重複を除く」の逆で、「どの値が重複しているか」を知りたい場面もあります。データの点検や、入力チェックでよく使います。

import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;

public class Dups {
    public static void main(String[] args) {
        List<String> input = List.of("orange", "apple", "banana", "apple", "cherry", "orange", "apple");

        // 方法1: groupingBy + counting で件数を数え、2件以上のものを取り出す
        Map<String, Long> counts = input.stream()
            .collect(Collectors.groupingBy(Function.identity(),
                LinkedHashMap::new, Collectors.counting()));
        System.out.println("件数           : " + counts);
        List<String> dup1 = counts.entrySet().stream()
            .filter(e -> e.getValue() > 1)
            .map(Map.Entry::getKey)
            .toList();
        System.out.println("重複している値 : " + dup1);

        // 方法2: Set.add の戻り値(すでにあれば false)を使う
        Set<String> seen = new HashSet<>();
        Set<String> dup2 = new LinkedHashSet<>();
        for (String s : input) {
            if (!seen.add(s)) {
                dup2.add(s);
            }
        }
        System.out.println("Set.addで抽出  : " + dup2);

        // 方法2': Streamで書く
        Set<String> seen2 = new HashSet<>();
        List<String> dup3 = input.stream()
            .filter(s -> !seen2.add(s))
            .distinct()
            .toList();
        System.out.println("Streamで抽出   : " + dup3);

        // 1回しか出てこない値だけ
        List<String> unique = counts.entrySet().stream()
            .filter(e -> e.getValue() == 1).map(Map.Entry::getKey).toList();
        System.out.println("1回だけの値    : " + unique);

        // 重複があるかどうかだけ知りたい
        System.out.println("重複がある?   : " + (new HashSet<>(input).size() != input.size()));

        // Collections.frequency で1つの値の件数
        System.out.println("appleの件数    : " + Collections.frequency(input, "apple"));

        // Set.add の戻り値そのもの
        Set<String> s = new HashSet<>();
        System.out.println("add(\"a\") 1回目 : " + s.add("a"));
        System.out.println("add(\"a\") 2回目 : " + s.add("a"));
    }
}

実行結果:

件数           : {orange=2, apple=3, banana=1, cherry=1}
重複している値 : [orange, apple]
Set.addで抽出  : [apple, orange]
Streamで抽出   : [apple, orange]
1回だけの値    : [banana, cherry]
重複がある?   : true
appleの件数    : 3
add("a") 1回目 : true
add("a") 2回目 : false

方法1:groupingByとcountingで件数を数える

groupingBy(Function.identity(), LinkedHashMap::new, Collectors.counting()) で、値ごとの件数のMapができます。そこから件数が2以上のものだけを取り出します。件数そのもの(apple が3回)もわかるので、点検の結果を表示したいときに向いています。 LinkedHashMap を指定しているため、結果は最初に出てきた順(orange, apple)です。同じMapから「1回だけ出てくる値」も取り出せます。

方法2:Set.addの戻り値を使う

Set の add は、追加できたら true、すでに入っていたら false を返します。実行結果の最後の2行がその確認です。この戻り値を使うと、「add が false だった値」がそのまま重複している値になります。

方法 結果の順序 件数もわかるか 向いている場面
groupingBy と counting 最初に出てきた順(orange, apple) わかる 件数つきで一覧にしたい
Set.add の戻り値 2回目が出てきた順(apple, orange) わからない 重複している値だけがほしい・for文で書きたい

2つの方法で順序が違うのは、groupingBy が「最初に出てきた位置」、Set.add が「2回目に出てきた位置」で並ぶためです。入力では apple の2回目(4番目)が orange の2回目(6番目)より先なので、Set.add のほうは apple が先になります。

重複があるかどうかだけ知りたいときは、new HashSet<>(list).size() != list.size() が最短です。1つの値の件数だけなら Collections.frequency(list, 値) が使えます。

元のリストを書き換える・大文字小文字を無視する・2つのリストをまとめる

import java.util.*;

public class InPlace {
    public static void main(String[] args) {
        // 元のリストそのものから重複を取り除く(先勝ち・順序はそのまま)
        List<String> list = new ArrayList<>(List.of("b", "a", "b", "c", "a"));
        Set<String> seen = new HashSet<>();
        list.removeIf(s -> !seen.add(s));
        System.out.println("removeIf        : " + list);

        // 大文字と小文字を区別せずに重複を除く
        List<String> mixed = List.of("Java", "java", "SQL", "JAVA", "sql");
        TreeSet<String> ci = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
        ci.addAll(mixed);
        System.out.println("大小文字を無視  : " + new ArrayList<>(ci));

        // 順序を保ったまま、大文字と小文字を区別せずに重複を除く
        Set<String> lower = new HashSet<>();
        System.out.println("順序を保つ版    : "
            + mixed.stream().filter(x -> lower.add(x.toLowerCase())).toList());

        // 変更できないリストにremoveIfを呼ぶと止まる
        try {
            List<String> fixed = List.of("a", "a");
            Set<String> seen3 = new HashSet<>();
            fixed.removeIf(x -> !seen3.add(x));
        } catch (UnsupportedOperationException e) {
            System.out.println("List.ofにremoveIf: " + e);
        }

        // 2つのリストをまとめて重複を除く
        List<String> x = List.of("a", "b"), y = List.of("b", "c");
        Set<String> merged = new LinkedHashSet<>(x);
        merged.addAll(y);
        System.out.println("2つのリスト     : " + new ArrayList<>(merged));
    }
}

実行結果:

removeIf        : [b, a, c]
大小文字を無視  : [Java, SQL]
順序を保つ版    : [Java, SQL]
List.ofにremoveIf: java.lang.UnsupportedOperationException
2つのリスト     : [a, b, c]
  • 新しいリストを作らず、元のリストから重複を取り除きたいときは removeIf と Set を組み合わせます。 !seen.add(s) は「すでに見た値なら true」なので、2回目以降だけが取り除かれます(先勝ち・順序はそのまま)。
  • removeIf は変更できるリストで使います。List.of で作ったリストに呼ぶと UnsupportedOperationException になりました。その場合は new ArrayList<>(list) で受けてから使うか、distinct で新しいリストを作ります。
  • 大文字と小文字を区別せずに重複を除くには、TreeSet に String.CASE_INSENSITIVE_ORDER を渡します。Java、java、JAVA が1つにまとまり、最初に入った「Java」が残りました。元の順序も保ちたいときは、小文字にそろえたキーを Set.add に渡す書き方(順序を保つ版)が使えます。
  • 2つのリストをまとめて重複を除くには、LinkedHashSet に1つ目を入れ、addAll で2つ目を足します。

10万件での時間の比較

10万件(値は0〜49,999の乱数、重複を除くと43,199件)のリストで、各方法の時間を測りました。最後の「contains と add」は、結果のリストに contains で確かめてから add する、Set を使わない書き方です。

import java.util.*;

public class Bench {
    public static void main(String[] args) {
        int n = 100_000;
        Random r = new Random(42);
        List<Integer> input = new ArrayList<>(n);
        for (int i = 0; i < n; i++) input.add(r.nextInt(50_000));

        for (int round = 1; round <= 3; round++) {
            long t0 = System.nanoTime();
            int a = new ArrayList<>(new HashSet<>(input)).size();
            long t1 = System.nanoTime();
            int b = new ArrayList<>(new LinkedHashSet<>(input)).size();
            long t2 = System.nanoTime();
            int c = new ArrayList<>(new TreeSet<>(input)).size();
            long t3 = System.nanoTime();
            int d = input.stream().distinct().toList().size();
            long t4 = System.nanoTime();
            List<Integer> out = new ArrayList<>();
            for (Integer v : input) if (!out.contains(v)) out.add(v);
            long t5 = System.nanoTime();
            System.out.printf("%d回目 HashSet %.1f ms / LinkedHashSet %.1f ms / TreeSet %.1f ms / distinct %.1f ms / contains+add %.0f ms (件数 %d,%d,%d,%d,%d)%n",
                round, (t1-t0)/1e6, (t2-t1)/1e6, (t3-t2)/1e6, (t4-t3)/1e6, (t5-t4)/1e6, a, b, c, d, out.size());
        }
    }
}

実行結果(2コアの仮想マシン、同じ処理を3回くり返し):

1回目 HashSet 162.3 ms / LinkedHashSet 79.1 ms / TreeSet 280.2 ms / distinct 16.0 ms / contains+add 7260 ms (件数 43199,43199,43199,43199,43199)
2回目 HashSet 27.2 ms / LinkedHashSet 122.7 ms / TreeSet 205.7 ms / distinct 13.1 ms / contains+add 8798 ms (件数 43199,43199,43199,43199,43199)
3回目 HashSet 7.2 ms / LinkedHashSet 97.2 ms / TreeSet 552.7 ms / distinct 42.8 ms / contains+add 6338 ms (件数 43199,43199,43199,43199,43199)
方法 今回の計測
HashSet 7.2〜162.3 ms
LinkedHashSet 79.1〜122.7 ms
TreeSet 205.7〜552.7 ms
distinct 13.1〜42.8 ms
List の contains と add 6,338〜8,798 ms

数値は実行するマシンや回によって変わります。そのうえで読み取れるのは、Set や distinct を使う方法はどれも1秒未満で、List の contains で確かめながら追加する書き方だけが6秒以上かかったという点です。contains は結果のリストを先頭から順に比べるため、件数が増えるほど1回あたりの時間が延びていきます。件数が少ないうちは差が見えにくいので、最初から Set か distinct で書いておくと、データが増えても安心です。

チェックリスト:重複が消えないときに確かめること

確かめること 見るポイント
要素のクラスに equals と hashCode の両方があるか 片方だけでは distinct も HashSet も効かない
順序が変わって困っていないか HashSet ではなく LinkedHashSet か distinct
「同じ」とみなす基準は全項目か、特定の項目か 特定の項目なら toMap、TreeSet と Comparator、distinctByKey
残したいのは先に出てきたほうか、後か 後勝ちは toMap のマージ関数 (a, b) -> b
null が混ざっていないか TreeSet は null で止まる
大文字と小文字、前後の空白の違いはないか そろえてから比べる
元のリストは変更できるか List.of のリストは removeIf が使えない

よくある質問

Q. 順序を保ったまま重複を削除する、いちばん短い書き方は? A. new ArrayList<>(new LinkedHashSet<>(list)) か list.stream().distinct().toList() です。どちらも最初に出てきた位置が残ります。結果を後から変更したいときは前者、ほかのStream処理と続けたいときは後者が向いています。

Q. distinct で特定の項目だけを基準にできますか? A. distinct には引数がなく、基準は要素の equals と hashCode に固定されています。特定の項目を基準にしたいときは、この記事の3つの方法(toMap と LinkedHashMap、TreeSet と Comparator、distinctByKey)のいずれかを使います。

Q. 重複したうちの「最後のもの」を残すには? A. toMap のマージ関数を (a, b) -> b にします。今回の例では、sato さんの注文は1番ではなく3番が残りました。並びも最後に出てきた位置にしたいときは、LinkedHashMap に対して remove してから put し直します。

Q. HashSet で並べ替えたように見えることがあるのはなぜですか? A. HashSet の並びは要素のハッシュ値で決まります。値によっては昇順に見えることもありますが、今回の数値の例(17, 2, 100, 5, 30)のように、そうならない場合もあります。並べ替えが必要なら TreeSet か sorted を使います。

まとめ

  • 順序を保って重複を削除するなら LinkedHashSet か distinct。どちらも最初に出てきた位置が残る。
  • HashSet は順序が残らず、TreeSet は昇順に並ぶ。TreeSet は null が入ると NullPointerException になる。
  • 自作クラスは equals と hashCode の両方がないと distinct が効かない。record にすると自動で用意される。
  • 特定の項目での重複排除は、toMap と LinkedHashMap、TreeSet と Comparator、distinctByKey の3通り。
  • 先勝ちと後勝ちは toMap のマージ関数で切り替える((a, b) -> a と (a, b) -> b)。
  • 重複している値の抽出は groupingBy と counting、または Set.add の戻り値。
  • 件数が多いときは、List の contains で確かめながら追加する書き方より、Set か distinct が短い時間で済む。

参考・出典

  • Java SE 21 API ドキュメント Stream(distinct、toList): https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html
  • Java SE 21 API ドキュメント Collectors(toMap、groupingBy): https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Collectors.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java Listの重複を削除する方法 順序保持とキー指定の重複排除」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?