javaのマップ

マップとは

javaのマップはユニークな(重複できない)キーに対して値を対応させるものです。キーと値は作成時にそれぞれクラスを指定します。

たとえば、

Map<String,String> map1 = new LinkedHashMap<>();
Map<String,Double> map2 = new LinkedHashMap<>();

MapとLinkedHashMapの関係はListとLinkedListの関係と同じです。

宣言は<>の中に入れるkeyとvalueのクラスをそれぞれ宣言します。後ろの<>はこのように省略できます。

intやdoubleなどの基本変数は、<Integer>, <Double> と相当するラッパークラスで宣言します。

自作のクラスや、配列やリストも使えます。整数と整数からなるリストを使う場合は次のようになります。

Map<Integer,List<Integer>> sgmss = new LinkedHashMap<>();

javascriptの連想配列では、キーは文字列のみ、値の方には様々な型の変数やオブジェクトを混ぜて格納できましたが、javaでは宣言時にクラスを限定します。もちろんObjectを指定して、混ぜてしまうことも不可能ではありませんが、普通はやりません。

エントリーの追加と更新・参照

キーと値の組をエントリーといいます。宣言の後、追加して格納します。

MapTest01.java

import java.util.Map;
import java.util.LinkedHashMap;

class MapTest01{
    public static void main( String[] args ) {
        Map<String,String> map = new LinkedHashMap<>();
        map.put("apple","りんご");
        map.put("orange","みかん");
        map.put("peach","もも");
        System.out.println(map.get("orange"));
        System.out.println(map.toString());
        map.put("orange","オレンジ");
        System.out.println(map.get("orange"));
        System.out.println(map.toString());
    }
}

実行結果

みかん
{apple=りんご, orange=みかん, peach=もも}
オレンジ
{apple=りんご, orange=オレンジ, peach=もも}

put()は要素の追加ですが、同一のkeyが既にあればvalueを上書きします。

参照はget(key)で行います。

拡張for文で繰り返し

for-each と言われながら、予約語としてはやっぱり forを使うのは 拡張for文です。

Mapの場合は、配列やリストではそのまま:の後ろに書きましたが、Mapでは、キーを使う方法と値を使う方法、両方をセットにしたentryを使う方法から選べます。

3ついっぺんに使ってみます。

MapTest02.java

import java.util.Map;
import java.util.LinkedHashMap;

class MapTest02{
    public static void main( String[] args ) {
        Map<String,String> map = new LinkedHashMap<>();
        map.put("apple","りんご");
        map.put("orange","みかん");
        map.put("peach","もも");
        System.out.println("要素数:"+map.size());
        System.out.println("(1) using keySet()");
        for (String key : map.keySet()) {
            System.out.println("Key:"+key+" Value:" + map.get(key));
        }
        System.out.println("(2) using values()");
        for (String value : map.values()) {
            System.out.println("Value: " + value);
        }
        System.out.println("(3) using entrySet()");
        for (Map.Entry<String,String> entry : map.entrySet()) {
            System.out.print("Key:"   + entry.getKey()  );
            System.out.println(" Value:" + entry.getValue());
    }
}

実行結果

要素数:3
(1) using keySet()
Key:apple Value:りんご
Key:orange Value:みかん
Key:peach Value:もも
(2) using values()
Value: りんご
Value: みかん
Value: もも
(3) using entrySet()
Key:apple Value:りんご
Key:orange Value:みかん
Key:peach Value:もも

:の前に書くインスタンスのクラスはMapの要素により決まります。

Map<K,V>の場合
:の前に書く変数のクラス メソッドの戻値のクラス メソッド
K Set<K> keySet()
V Collection<V> values()
Map.Entry<K,V> Set<Map.Entry<K,V>> entrySet()

forEachメソッドで繰り返し

Java8から使用できるメソッドです。

MapTest03.java

import java.util.Map;
import java.util.LinkedHashMap;

class MapTest03{
    public static void main( String[] args ) {
        Map<String,String> map = new LinkedHashMap<>();
        map.put("apple","りんご");
        map.put("orange","みかん");
        map.put("peach","もも");
        System.out.println("要素数:"+map.size());
        System.out.println("(4) using forEach()");
        map.forEach((k, v) ->
            System.out.println("Key:"+k+" Value:" + v));
    }
}

実行結果

要素数:3
(4) using forEach()
Key:apple Value:りんご
Key:orange Value:みかん
Key:peach Value:もも

連想配列に感動した昔

javascript版と同じ企画です。

連想配列に出会ったのは1993年のことでした。UNIX上のawkでした。

次のような名前と数値が組になったデータから、名前ごとの合計を出すというものです。

onion 50
carrot 226
carrot 315
eggplant 30
radish 75
eggplant 30
onion 120
carrot 417
eggplant 23
onion 80
radish 89
carrot 715
onion 95
radish 135
radish 98

こんな感じでできてしまいます。

$ awk '{sum[$1]+=$2} END {for(name in sum)print name,sum[name]}' onion.txt 
radish 397
eggplant 83
carrot 1673
onion 345

END以下は結果の表示のためのforで、計算の本体は連想配列 sum[$1] += $2 です。 $1がキー、$2が値でキーが同じ場所に値を加算するものです。

Map#getOrDefault()を使ってやってみる

これを java でやってみます。

awkではデータはファイルですが、javascriptでは別ファイルにするのは無理があるのでプログラム中に書き込みました。Javaではファイルから読む方法に戻します。Files.readAllLines()を使ってみます。

データファイルは、上記の onion 50\n carrot 226\n carrot 315\n... とスペース区切りのテキストファイル。onion.txt という名前でプログラムを実行するディレクトリに置きます。

MapTest04.java

import java.util.Map;
import java.util.LinkedHashMap;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
class MapTest04{
    public static void main( String[] args ) {
        Path path = Paths.get("onion.txt");
        List<String> list = null;
        try {
            list = Files.readAllLines(path);
        }
        catch (IOException e) {
            System.err.println( e);
        }
        Map<String,Integer> map = new LinkedHashMap<>();
        for (String line : list) {
             String[] tmp = line.split(" ");
             if (2>tmp.length) continue;
             String key = tmp[0];
             int val = map.getOrDefault(key, 0);
             val += Integer.parseInt(tmp[1]);
             map.put(key,val);
        }
        map.forEach((k, v) -> 
            System.out.println(k+": " + v));
    }
}

実行結果

onion: 345
carrot: 1673
eggplant: 83
radish: 397

処理がわかりやすいように冗長に分けて記述しています。

ポイントは、map.getOrDefault(key, 0);です。相当するキーがないとき、戻値はnullですが、これを第二引数の0にするメソッドです。

2>tmp.lengthはデータファイルに空行が入ってしまった時に面倒なので入れてあります。

Map#merge()を使ってやってみる

DebianのパッケージのJavaを使ってきた関係で、最近やっとversion1.8を使うようになりました。11が出たというのに遅いですが、そう急ぐ必要もありません。

今回.getOrDefault()の他にMapにいろいろなメソッドが追加されていることを知り、ちょっと調べてみました。今回の用途ではcompute()関係かなと思っていたのですが、merge()メソッドも使えます。名前から複数のMapの合成をイメージしてしまうのですが、これが邪魔をしていました。

MapTest05.java

import java.util.Map;
import java.util.LinkedHashMap;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
class MapTest05{
    public static void main( String[] args ) {
        Path path = Paths.get("onion.txt");
        List<String> list = null;
        try {
            list = Files.readAllLines(path);
        }
        catch (IOException e) {
            System.err.println( e);
        }
        Map<String,Integer> map = new LinkedHashMap<>();
        for (String line : list) {
             String[] tmp = line.split(" ");
             if (2>tmp.length) continue;
             String key = tmp[0];
             map.merge( key, Integer.parseInt(tmp[1]), (val, tmp1) -> val+tmp1 );
        }
        map.forEach((k, v) -> 
            System.out.println(k+": " + v));
    }
}

実行結果

onion: 345
carrot: 1673
eggplant: 83
radish: 397

getOrDefault()と比べて長さとしてはあまり違いはありませんが、ちょっとわかりづらい。

mergeは引数を3つとります。

第一引数:key
マージするキーです。mapにこれが存在するかをまず調べます。
存在すれば、第二引数が使われ、しなければ第三引数が使われます。3項演算子にも似ています
第二引数:Integer.parseInt(tmp[1])
keyが見つからない時に値として保存したいものです。今回入れた式はファイルから切り出した値を整数化したものです。
第三引数:(val, tmp1) -> val+tmp1
格納する値を計算する部分です。名前のない関数と捉えるとわかりやすいかもしれません。(val, tmp1)が関数の仮引数で、->の後ろが関数の内部です。{ }を書いて複数の文から構成し、return文で値を戻すことも可能です。仮引数のvalはkeyが存在した時に対応している値です。tmp1は第二引数の値です。Javaの仮引数には変数の型を書きますが、ここでは推論してくれますから不要です。
tmp1の場所に書かれる値の理解がポイントです。普通はkeyが見つからないという例外のために設定した値を、メイン部分で使うという発想をしないからです。

Map#compute()を使ってやってみる

compute()も使ってみます。引数は2つ。2つめの(k,v)は今度は第一引数のkeyと、それに対応する値のvです。keyがなければvはnullになります。->の後の関数はvがnullかどうかでどちらかの値を返す3項演算子になっています。

MapTest06.java

import java.util.Map;
import java.util.LinkedHashMap;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
class MapTest06{
    public static void main( String[] args ) {
        Path path = Paths.get("onion.txt");
        List<String> list = null;
        try {
            list = Files.readAllLines(path);
        }
        catch (IOException e) {
            System.err.println( e);
        }
        Map<String,Integer> map = new LinkedHashMap<>();
        for (String line : list) {
             String[] tmp = line.split(" ");
             if (2>tmp.length) continue;
             String key = tmp[0];
             int val = Integer.parseInt(tmp[1]);
             map.compute(key, (k,v) -> (v==null) ? val : v+val);
        }
        map.forEach((k, v) -> 
            System.out.println(k+": " + v));
    }
}

実行結果

onion: 345
carrot: 1673
eggplant: 83
radish: 397

getOrDefault(),merge(),compute()の比較

getOrDefault()を使った場合と、merge()、compute()を使った場合を並べてみました。

String key = tmp[0];
int value = Integer.parseInt(tmp[1]);
map.put(key, map.getOrDefault(key,0)+value);
map.merge(key, value, (v, tmp1) -> v+tmp1 );
map.compute(key, (k,v) -> (v==null) ? value : v+value );

今回の目的にはmerge()を使った方がわかりやすいとは言えない気がしますが、valueの値を一度書けばよいのはメリットかもしれません。

merge(),compute()は、C言語やJava7まででは見慣れないのですが、Java8の拡張の目玉であるラムダ式に絡んだものなので、慣れると応用範囲が広がるのかもしれません。

存在関連のメソッド

getOrDefault(),merge(),compute()などが出て、containsKey()を使う機会が出てきませんでした。その他も使用頻度は低そうです。

clear()
全部の要素を削除。戻値なし。
isEmpty()
要素を持たなければtrue。持てばfalse。
containsKey(Object key)
指定したキーの要素があればtrue。なければfalse。
containsValue(Object value)
指定した値の要素が一つでもあればtrue。なければfalse。合致する要素は複数あるかもしれない。

置換・削除関連のメソッド

Java8で随分と新しいメソッドが増えていて、追加と置換を分けて行うことができるようになっています。上記の「エントリーの追加と更新・参照」を書いた時はreplace()が追加されていることを知りませんでした。

Map<K key, V value>
v8で
追加
メソッド名(引数) 対象が存在しない時 対象が存在する時
動作 戻値 動作 戻値
- put(K key, V value) 追加 null 置換 以前の値
v8 putIfAbsent(K key, V value) 追加 null - 現在の値
- remove(Object key) - null 削除 以前の値
v8 remove(Object key, Object value) - false 削除 true
v8 replace(K key, V value) - null 置換 以前の値
v8 replace(K key, V oldValue, V newValue) - false 置換 true

Keyによる並べ替え

ここでは、数値の場合の大きさの順や文字列の文字コード順などデフォルトで設定されているような、単純なものについてやってみます。API仕様にはこの順序を示す natural ordering (自然順序付け) という言葉は出てきますが、きちんと説明している箇所を見つけられないでいます。

Mapインターフェースを実装するクラスは多数あるものの、よく使われるのは HashMap, LinkedHashMap, TreeMap の3つです。

HashMapは登録の順序が保存されないので、その分処理が早くなると期待できます。登録の順を保持したければLinkedHashMapを使います。keyによる並べ替えをしたければ TreeMap です。

これは、put時に二分木を使っておいて、拡張for文やforEach()メソッドで出す時に順番になるようにするものです。後からある種のメソッドで並び替えるというものではありません。

TreeMapを使うときには、SortedMap<K,V>インターフェースで宣言すると機能が増えますが、拡張for文やforEach()メソッドで使うだけならMapでかまいません。

次のプログラムはMapのインスタンスを、 HashMap, LinkedHashMap, TreeMap でそれぞれ生成するように書き換えて試すようになっています。現在の状態はTreeMapです。

データは文字列でASCIIの範囲ですが、ひとつだけ大文字で始まるものがあります。"自然順序付け"です。

MapTest07.java

import java.util.Map;
import java.util.LinkedHashMap;
import java.util.HashMap;
import java.util.TreeMap;
//import java.nio.charset.StandardCharsets;
class MapTest07{
    public static void main( String[] args ) {
        //Map<String,String> map = new LinkedHashMap<>();
        //Map<String,String> map = new HashMap<>();
        Map<String,String> map = new TreeMap<>();
        map.put("apple","りんご");
        map.put("orange","みかん");
        map.put("peach","もも");
        map.put("Peach","桃");
        map.put("blue", "青");
        map.put("yellow", "黄");
        map.put("green", "緑");
        map.put("red", "赤");
        map.put("white", "白");
        map.put("black", "黒");
 
        System.out.println("要素数:"+map.size());
        map.forEach((k, v) ->
            System.out.println("Key:"+k+" Value:" + v));
    }
}

実行結果

まず、LinkedHashMapです。プログラム中の登録順そのままです。

要素数:10
Key:apple Value:りんご
Key:orange Value:みかん
Key:peach Value:もも
Key:Peach Value:桃
Key:blue Value:青
Key:yellow Value:黄
Key:green Value:緑
Key:red Value:赤
Key:white Value:白
Key:black Value:黒

HashMapです。キーを探しやすいようにHashというものを作って格納しますが、コンピュータが探しやすいという都合で取り出す時の順番が変わってしまいます。

要素数:10
Key:orange Value:みかん
Key:red Value:赤
Key:apple Value:りんご
Key:green Value:緑
Key:blue Value:青
Key:white Value:白
Key:Peach Value:桃
Key:peach Value:もも
Key:yellow Value:黄
Key:black Value:黒

TreeMapです。そもそも登録の時に大きさの順で格納位置を探しながら格納します。HashでなくTreeという手法を使っているのです。知っている人は名前から性質を知ることができます。文字列の順番は文字コード順が一番簡単です。大文字が小さな文字コードに割り当てられているのでこのようになります。

要素数:10
Key:Peach Value:桃
Key:apple Value:りんご
Key:black Value:黒
Key:blue Value:青
Key:green Value:緑
Key:orange Value:みかん
Key:peach Value:もも
Key:red Value:赤
Key:white Value:白
Key:yellow Value:黄

大文字小文字の同一視と逆順

大文字小文字を同一視する方法もあります。

Map<String,String> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);

ただし、Peachとpeachを同一視するので、キーは先に登録される「peach」になり、値は後から登録される「桃」になって要素数は9になってしまいます。

同一視してもキーがユニークになる場合に使うのがいいでしょう。そうでない場合は、区別したままMapを作り、keySet()やentrySet()などでキーを取り出してから別な方法でソートする方法が使えると思います。

逆順にしたければ、

Map<String,String> map = new TreeMap<>(Collections.reverseOrder());
Map<String,String> map = new TreeMap<>(Comparator.reverseOrder()); //1.8なら

大文字小文字を同一視して、なおかつ逆順にしたければ、

Map<String,String> map = new TreeMap<>(String.CASE_INSENSITIVE_ORDER.reversed());

ソートはいろいろな方法があって、もっと複雑な指定もできます。

1.8になって楽になったようですので、古い方法を苦労して覚えなくてもいいでしょう。