第9講 Map

この講で利用するプログラム

第9講 Mapのサブセクション

Map(連想配列)

Map とは

住所録を作成することを考えます.iPhone や Android の連絡先アプリを想像してください.

住所録のイメージ

住所録では,上記のように住所や電話番号など複数の情報が名前に紐付いています. このように,人の名前をキーとし,その人に関する情報をバリューとして紐付ける方法として,Mapというデータ構造 が利用できます.Mapでは,キーとバリューのペアの集合を扱います.

Java 以外の他の言語では,Mapを連想配列やディクショナリなどと呼びます. いずれもある実体に別の実体を関連づけるためのデータ構造です.

別の考え方をすると,Listはインデックスという数値に実体が対応付いていました.Mapは 実体に紐付けるインデックスに相当する部分が,数値以外の自由な値が指定できるものとも考えられできます.

Map の種類

先ほども述べたようにMapはキーとバリューのペアの集合を扱うデータ構造です. その実現方法に,ハッシュ値を利用する方法,木構造を利用する方法などがあります. それぞれの実現方法で,Mapを実現する型が存在します.HashMapTreeMapの2つの型です. どちらを利用しても,利用方法や,処理結果に違いはありません. 以下の例では,HashMapを利用して説明していますが,HashMapTreeMapに置き換えても 同じ説明が成り立ちますので,適宜読み替えてください.

なお,HashMapを使うときには,import文が 必要です.import java.util.HashMap;クラス宣言の前に書きましょう.

Map の宣言方法

Map を使うには,Listと同じように,データ構造にどのような型の変数を格納するかを 宣言しなければいけません. 例えば,HashMapString型をキーに Integer型をバリューとする場合,次のような宣言が必要です.

HashMap<String, Integer> map =
    new HashMap<String, Integer>();

上記のように,HashMap<キーの型, バリューの型> という型として宣言しなければいけません. また,実体を作成するときも,new HashMap<キーの型, バリューの型>()のように作成しなければいけません. キーの型,バリューの型が異なれば,同じHashMap型であっても,異なる型であると判断されます.

// Complexをキーに,String型をバリューとした Map.
HashMap<Complex, String> map1 =
    new HashMap<Complex, String>();
// String型をキーに,Complex型をバリューとした Map.
HashMap<String, Complex> map2 =
    new HashMap<String, Complex>();

つまり,上記のように Complex型をキーに,String型を バリューとしたHashMap型変数map1と キーとバリューの型を逆にしたHashMap型変数map2は キーとバリューの型が異なるため,異なる型であると判断され,互いに代入できません. なお,Listと同じく,newの後ろの格納する型は省略可能です. 以下のコードは,上記のComplexStringをそれぞれキー,バリューに指定したコードと同じプログラムとしてコンパイルされます.

// Complexをキーに,String型をバリューとした Map.
HashMap<Complex, String> map1 = new HashMap<>();
// String型をキーに,Complex型をバリューとした Map.
HashMap<String, Complex> map2 = new HashMap<>();

なお,上記のように,独自に作成した型(第 5 講で作成したComplex)もMapに格納可能です.

Map の操作

Map に対する操作はListと同じく,CRUD (Create, Read, Update, Delete)が可能です. 以降の説明は,次に示すHashMap型の変数に対してメソッドを呼び出すものとして読み進めてください.

HashMap<String, Complex> map = new HashMap<>();
// HashMap<String, Complex> map =
//     new HashMap<String, Complex>();
// 上記のように書いても良い

Map にデータを追加する (put)

Complex c1 = // Complexの実体を作成する.
Complex c2 = // Complexの実体を作成する.
map.put("1+i", c1);
map.put("この場合文字列ならなんでも良い", c2);
map.put("バリューは同じものでも良い", c1);
map.put("1+i", c2); // "1+i"に紐付いているバリューを更新する

データを追加するには,上のサンプルプログラムのようにputメソッドを用います.put メソッドで追加するときのキーの型とバリューの型は,Mapの生成時に指定した型と合わせる必要があります. 理論上はデータ数に上限はなく,幾つでも追加できます.

また,異なるキーに同一のバリューを割り当てられます.一方で,同じキーに異なるバリューは割り当てられません. すでにキーとバリューのペアが割り当てられているところに,別のバリューを割り当て用とすると, 今までのバリューが上書きされます.

Map にあるデータを取得する (get)

// putで追加した c1 が返される.
Complex complex1 = map.get("バリューは同じものでも良い");
// putで追加した c2 が返される.
Complex complex2 = map.get("この場合文字列ならなんでも良い");
// 対応付けがないため,null が返される.
Complex complex3 = map.get("対応付けがない場合はnull");

上のサンプルプログラムのように,getメソッドを呼び出すことで,Map内の キーに対応付けられたバリューを取得できます. 返り値の型は,HashMap型の変数宣言時に指定した型(この例ではComplex型)でなければいけません.

キーに対応付けられたバリューが存在しない場合,nullが返されます. 上のサンプルプログラムでは,3 行目のキーはバリューとの対応付けがないため,nullが返されます. なお,返り値が nullの時にデフォルト値を返す getOrDefault メソッドも用意されています.

// c1 が返される.
Complex complex4 =
    map.getOrDefault("対応付けがない場合", c1);

// 上記のプログラムは以下の4行のプログラムと同等.
Complex complex5 = map.get("対応付けがない場合");
if(complex5 == null){
  // キーに対応する値がある場合,このif文は実行されない.
  complex5 = c1;
}

Map にあるデータを削除する (remove)

map.remove("1+i");
// 紐付けが解消されたので,nullが返される.
Complex removedValue1 = map.remove("1+i");

キーを指定して,バリューとの対応付けを削除します. 削除すると,対応付けられていたバリューが返り値として返されます. 上のサンプルプログラムの 1 行目のように,返り値を無視しても構いません.

指定したキーに対応付けられたバリューが存在しなかった場合,nullが返されます.

Map のサイズを取得する (size)

Integer size = map.size();

Map の現在のサイズを取得したい場合は,sizeメソッドを利用しましょう. キーとバリューのペアがいくつ格納されているかが得られます.

Map の要素の繰り返し

Java 言語では,Mapの繰り返しは,次の2種類が利用できます. 注意すべき点として,Mapには順番が存在しないため,従来の Integer型を用いる繰り返しは行えません. そのため,Iterator型を利用して繰り返しを行う必要があります. このとき,java.util.Iteratorだけでなく,java.util.Mapもインポートしておく必要があります (import java.util.Iterator;import java.util.Map;).Map.Entry型をHashMapの要素として扱うためです.

典型的な方法(Iterator)

一番典型的なIterator型を利用する方法です.Java らしい書き方です.

for(Iterator<Map.Entry<String, Complex>> iterator =
      map.entrySet().iterator();
      iterator.hasNext(); ){
  Map.Entry<String, Complex> entry = iterator.next();
  String key = entry.getKey();
  Complex value = entry.getValue();
  // ここに繰り返しの処理を書く.
}

ただし,mapのループの途中で mapの要素数を変化させると例外(ConcurrentModificationException)が発生する場合があります. ループを回している対象の Mapへの値の追加(get)・削除(remove)は行わないようにしましょう.

for(Iterator<Map.Entry<String, Complex>> iterator =
      map.entrySet().iterator();
      iterator.hasNext(); ){
  Map.Entry<String, Complex> entry = iterator.next();
  String key = entry.getKey();
  Complex value = entry.getValue();
  // mapを対象にループを回しているため,
  // ここで,mapの要素数を変化させる(get, removeなど)と例外が投げられる.
}

拡張 for 文を使った方法

拡張 for 文と呼ばれる書き方.実質的には,Iterator型を利用する方法と同じです. コンパイラがIterator型を利用する方法に変換してコンパイルしてくれます. 最近の Java の書き方はこの方法です.

for(Map.Entry<String, Complex> entry: map.entrySet()){
  String key = entry.getKey();
  Complex value = entry.getValue();
  // ここに繰り返しの処理を書く.
}

上記のように,Map.Entry型を順番に取り出して処理するようなプログラムに なっています.Map.Entry型はキーとバリューのペアを扱う型です. すなわち,Mapに格納されている各ペアを扱う型です.Map.Entry型の 利用には,import java.util.Map;というimport文が必要です.

サンプルプログラム

コマンドライン引数の文字列をコンマ(,)で区切り,前方の値をキーに, 後ろの値をバリューにしてMapに格納するプログラム. 最後に,順番に取り出し,出力しています.

import java.util.HashMap;
import java.util.Map;
public class HashMapExample {
  void run(String[] args) {
    HashMap<String, Integer> map = new HashMap<>();
    for(String arg: args) {
      this.putValueToMap(map, arg);
    }
    this.printMap(map);
  }
  void putValueToMap(HashMap<String, Integer> map, String string) {
    // , で文字列を区切っている.
    String[] items = string.split(",");
    Integer value = Integer.valueOf(items[1]);
    map.put(items[0], value);
  }
  void printMap(HashMap<String, Integer> map) {
    for(Map.Entry<String, Integer> entry: map.entrySet()) {
      System.out.printf("%s: %d%n",
          entry.getKey(), entry.getValue());
    }
  }
  // mainメソッドは省略.
}

出力例

$ java HashMapSample one,1 two,2 three,3 four,4
four: 4
one: 1
two: 2
three: 3

先にも述べたようにMapは順序を保持しないため,追加順と出力順が異なっている点に注意しましょう. 出力の順序には意味はありません.実際には,キーのハッシュ値の順番になっていますが, その値を意識する必要はありません. もし,追加順に出力したい場合は,HashMapの代わりにLinkedHashMapを利用してください. また,HashMapの代わりに TreeMapを用いればキーでソートされた結果が得られます. サンプルプログラムを変更して確認してみましょう.

文字列を分割する

文字列を特定の文字で分割するには,String型のsplitメソッドを利用します. split の引数には分割したい区切り文字を指定します. 返り値は引数で渡された文字で分割された結果が,String型の配列で返されます.

文字列分割のサンプルプログラム

String string = "abr,aca,dab,ra";
String[] array1 = string.split(",");
System.out.println(Arrays.toString(array1);
// => [ "abr", "aca", "dab", "ra" ]
String[] array2 = string.split("a");
System.out.println(Arrays.toString(array2);
// => ["", "br,", "c", ",d", "b,r"]

上記のように,"abr,aca,dab,ra""," で分割した場合は,長さ 4 の配列が返されます. この時のそれぞれの要素は上記のサンプルプログラムの通りです.

同様に,"abr,aca,dab,ra""a"で分割した場合は,長さ 5 の配列が返されます. コンマがややこしいですが,上記の実行結果をよく確認してください.

このように,指定された文字列で分割した結果を得るには split メソッドを利用します.

例題 1. お弁当の料金表

以下のプログラムの欠けている部分を補って,指定したお弁当の料金を出力するプログラムを作成してください.

import java.util.HashMap;
public class LunchBoxPrices{
  HashMap<String, Integer> prices;

  void run(String[] args){
    this.initialize();
    for(String arg: args){
      this.searchAndPrint(arg);
    }
  }
  void searchAndPrint(String lunchBoxName){
    Integer price = // お弁当の料金を prices から取得(get)する.

    if(price == null){ // お弁当が見つからなかった.
      // ここに見つからなかった旨を出力する処理を書く.
    }
    else{
      // お弁当の料金を出力する.
      System.out.printf("%s: %d円%n", lunchBoxName, price);
    }
  }
  void initialize(){ // お弁当の料金表を作成するメソッド.
    this.prices = new HashMap<>();
    prices.put("日の丸弁当", 200);
    // 他のお弁当の料金も追加する(10個程度).
  }
  // mainメソッドは省略.
}

お弁当の料金,名前は適当に決めてください.

出力例

$ java LunchBoxPrices のり弁当 上幕ノ内弁当  横綱弁当
のり弁当: 350円
上幕ノ内弁当: 800円
横綱弁当: 見つかりませんでした
import java.util.HashMap;

public class LunchBoxPrices{
    HashMap<String, Integer> prices;

    void run(String[] args){
        this.initialize();
        for(String arg: args){
            this.searchAndPrint(arg);
        }
    }
    void searchAndPrint(String lunchBoxName){
        // お弁当の料金を検索する.
        Integer price = this.prices.get(lunchBoxName);
        if(price == null){ // お弁当が見つからなかった.
            // ここに見つからなかった旨を出力する処理を書く.
            System.out.printf("%s: 見つかりませんでした%n", lunchBoxName);
        }
        else{
            // お弁当の料金を出力する.
            System.out.printf("%s: %d円%n", lunchBoxName, price);
        }
    }
    void initialize(){ // お弁当の料金表を作成するメソッド.
        this.prices = new HashMap<>();
        prices.put("日の丸弁当", 200);
        // 他のお弁当の料金も追加する(10個程度).
        prices.put("のり弁当", 350);
        prices.put("幕ノ内弁当", 400);
        prices.put("ステーキ弁当", 800);
        prices.put("ハンバーグ弁当", 550);
        prices.put("唐揚げ弁当", 450);
        prices.put("サービス弁当", 350);
        prices.put("おにぎり弁当", 250);
        prices.put("上幕ノ内弁当", 800);
        prices.put("サンドウィッチ弁当", 400);
    }
    public static void main(String[] args){
        LunchBoxPrices lbp = new LunchBoxPrices();
        lbp.run(args);
    }
}

練習問題

1. CSV データの格納

ここでは,郵便番号から住所の検索を行うプログラムを作成します.

  1. 郵便番号データを用意してください.
    • 郵便番号データダウンロードページから自分の出身地の郵便番号データをダウンロードしてください.
    • ダウンロードした zip ファイルを解凍してください.
    • 解凍してできたファイルを zipcode.csv にファイル名を変更してください.
    • ダウンロードしたファイルの文字コードは ShiftJIS になっています.utf-8 でなければ Java では文字化けを起こしますので,変換しておいてください.
  2. 郵便番号を読み込み,検索を行うプログラムを作成してください.
    • クラス名は ZipCodeとしてください.
    • コマンドライン引数で与えられた郵便番号に対応する住所を出力してください.

ヒント

ベースとなるプログラム

例題 1. お弁当の料金表が参考になるでしょう. 初期化処理(initializeメソッド)でzipcode.csvを読み込み,HashMap型の変数に 郵便番号と対応した住所を追加(put)しましょう.

その後,検索処理を行います.検索処理はsearchAndPrintメソッドとほぼ同じで良いでしょう. キーとバリューそれぞれの型は何が良いかを考えて作成しましょう.

ダブルクォートの削除

ダウンロードした csv ファイルは,各カラムがダブルクォートで囲まれています. これを削除するには,以下のメソッド(stripQuote)を使うと良いでしょう.sampleCodeメソッド はstripQuoteの使い方の例です.sampleCodeメソッドにあるように,splitで 得られた配列の各要素を順にstripQuoteに渡すことで, ダブルクォートを除いた部分を取り出しています.

    String stripQuote(String item){
        if(item.matches("\".*\"")){
            return item.substring(1, item.length() - 1);
        }
        return item;
    }
    void sampleCode(){
        String original = "\"クォートで囲まれた文字列\"";
        String value = this.stripQuote(original);
        // originalには「"クォートで囲まれた文字列"」が代入されており,
        // valueには「クォートで囲まれた文字列」が代入される.
    }

なお,matches メソッドは与えられた正規表現(Regular Expression)にマッチするかを判定するメソッドです.

出力例

$ java ZipCode 6038035 6038047 1000001
6038035: 京都市北区上賀茂朝露ケ原町
6038047: 京都市北区上賀茂本山
1000001: 見つかりませんでした

100-0001 は東京都の郵便番号ですので,京都府の郵便番号表では見つかりません. そのため,上記のように「見つかりませんでした」と出力されています.

2. 電話帳の作成

以下の仕様に従った名前と電話番号のペアを管理する電話帳を作成しましょう.

  • クラス名はPhoneBookとしてください.
  • 起動するとコマンド入力待ちとなります.
    • 以下のコマンドを入力することによって,データの更新が行われる.
    • add 名前 電話番号
      • 電話帳に名前と対応する電話番号を追加する.
    • list
      • 登録された名前と電話番号の一覧を表示する.
    • find 名前
      • 電話帳に名前が存在すれば名前と電話番号を表示する.
      • 電話帳に名前が存在しなければ何も表示しない.
    • remove 名前
      • 電話帳から名前のデータを削除する.
      • 電話帳に名前のデータが存在しなければ何も行わない.
    • exit
      • 電話帳を終了する.
  • 入力において,文法エラーは起きないものとします.
  • 標準入力から 1 行読み込むには,SimpleConsole.javaを利用してください.
    • SimpleConsole.javaをここからダウンロードし,プログラムと同じディレクトリに置いてください.
    • 入力した文字列をスペースで区切るには,String型のsplitメソッドを利用しましょう.
      • String[] items = line.split(" ");
    • SimpleConsole.javaは一切変更してはいけません.
      • 同様に,PhoneBook.javaには,SimpleConsole.java のコードを貼り付けてはいけません.

ヒント

SimpleConsole の使い方.

SimpleConsole console = new SimpleConsole();
String line = console.readLine();

利用する前に,new で実体を作成してください. その後,得られた実体(上記の例では console)に対して readLine メソッドを呼び出すと 1 行読み込むことができます.

出力例

> list
> add tamada 090-1111-1111
> find tamada
tamada 090-1111-1111
> find akiyama
> add akiyama 090-2222-2222
> list
tamada 090-1111-1111
akiyama 090-2222-2222
> remove akiyama
> find akiyama
> add tamada 090-1111-2222
> list
tamada 090-1111-2222
> exit

> で始まる行が入力を表しています. 最初は電話帳に何も入力されていないため,listコマンドを入力しても何も出力されません. データをaddコマンドで追加していくことで,結果が変わってきます.

まとめ

まとめ