- JAVA
Java Listの重複を削除する方法 順序保持とキー指定の重複排除
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期連続の黒字決算。