電話帳作成問題の解答

前回に引き続き、こちらの記事を見てみて、面白そうな問題がいくつかあったので、プログラミング初心者ではないですが解きました。

blog.jnito.com

電話帳作成問題

カタカナ文字列の配列を渡すと、ア段の音別にグループ分けした配列を返すプログラムを作成する問題です。

# INPUT
['キシモト', 'イトウ', 'ババ', 'カネダ', 'ワダ', 'ハマダ']

# OUTPUT 
[ ['ア', ['イトウ']], ['カ', ['カネダ', 'キシモト']], ['ハ', ['ハマダ', 'ババ']], ['ワ', ['ワダ']] ]

詳しい仕様はこちらにあります。

https://github.com/JunichiIto/name-index

解答

class NameIndex
  NAME_INDEX_HASH = {
      '' => %w(ア イ ウ エ オ ヴ),
      '' => %w(カ キ ク ケ コ ガ ギ グ ゲ ゴ),
      '' => %w(サ シ ス セ ソ ザ ジ ズ ゼ ゾ),
      '' => %w(タ チ ツ テ ト ダ ヂ ヅ デ ド),
      '' => %w(ナ ニ ヌ ネ ノ),
      '' => %w(ハ ヒ フ ヘ ホ バ ビ ブ ベ ボ パ ピ プ ペ ポ),
      '' => %w(マ ミ ム メ モ),
      '' => %w(ヤ ユ ヨ),
      '' => %w(ラ リ ル レ ロ),
      '' => %w(ワ ヲ ン)
  }.freeze

  def self.create_index(names)
    names.sort.group_by {|name|
      NAME_INDEX_HASH.find(['Others']) {|_, value| value.include?(name[0])}.first
    }.to_a
  end
end

テストです。

describe NameIndex do
  describe '#create_index' do
    specify {expect(NameIndex.create_index([])).to eq []}
    specify {expect(NameIndex.create_index(['キシモト', 'イトウ', 'ババ', 'カネダ', 'ワダ', 'ハマダ'])).to eq [['', ['イトウ']], ['', ['カネダ', 'キシモト']], ['', ['ハマダ', 'ババ']], ['', ['ワダ']]]}
    specify {expect(NameIndex.create_index(['サトウ', 'スズキ', 'タカハシ', 'イケガミ', 'アラキ', 'デグチ', 'ヌマタ'])).to eq [['', ['アラキ', 'イケガミ']], ['', ['サトウ', 'スズキ']], ['', ['タカハシ', 'デグチ']], ['', ['ヌマタ']]]}
  end
end

元記事では、解答を見ないで解くこととなっていましたが、ある程度自分で考えた後に解答を見てから解きました。それでもオリジナルなコードになったと思います。

解説

問題を見たときに以下の4つを考えました。

  • 名前の先頭のカタカナを取り出す
  • カタカナが属する行のア段のカタカナを求める(ただし濁音、半濁音は清音にする)(例: キ → カ, バ →ハ)
  • ア段のカタカナでグループ化する
  • ソートする

これらをクリアにしていけば解けそうです。

  • 名前の先頭のカタカナを取り出すにはname[0]のように、[0]を使用します。
  • カタカナが属する行のア段のカタカナを求める を行う標準のメソッドは存在しないので、自身で実装する必要があります。
  • ア段のカタカナでグループ化する はArray.group_by( group_by (Enumerable) - Rubyリファレンス )を使用します。
  • ソートする はArray.sort( sort (Enumerable) - Rubyリファレンス )が使用できます。

まず先頭のカタカナを取り出すところまで実装しましょう。

class NameIndex
  def self.create_index(names)
    names.map { |name|
      name[0]
    }
  end
end

テストは一旦以下の2ケースのみ書いておくことにします。また、以下実行結果として書くのは2つ目のテストケースについてです。

describe NameIndex do
  describe '#create_index' do
    specify { expect(NameIndex.create_index([])).to eq [] }
    specify { expect(NameIndex.create_index(['キシモト', 'イトウ', 'ババ', 'カネダ', 'ワダ', 'ハマダ'])).to eq [ ['', ['イトウ']], ['', ['カネダ', 'キシモト']], ['', ['ハマダ', 'ババ']], ['', ['ワダ']] ] }
  end
end

実行結果です。先頭の文字列を取り出すことができました。

["", "", "", "", "", ""]

次にア段の音に変換していきます。範囲オブジェクトを使用する方法もありますが、 50音表の順番で並んでいるわけではないので、注意して使用する必要があります。( 片仮名 (Unicodeのブロック) - Wikipedia ) (カキクケコガギグゲゴと並ぶわけでなく、カガキギクグケゲコゴと並んでいます。)

今回はわかりやすさのため、名前の先頭に使用できるカタカナを全て書き出しました。(ヲ ンから始まる名前があるかどうか微妙ですが)

どのカタカナがどのア段のカタカナに対応するか求めます。まず全てのカタカナをキー、対応するア段のカタカナを値とする方法が思いつきます。

NAME_INDEX_HASH = {'' => '', '' => '', '' => '', ..., }

これは確実な方法ですが、コードが長くなるため今回は採用しませんでした。

代わりに、ア段のカタカナをキー、そのア段のカタカナが含むカタカナを値とするハッシュを定義しました。この場合だとハッシュの逆引きが必要なのですが、コードがわかりやすくなります。

  NAME_INDEX_HASH = {
      '' => %w(ア イ ウ エ オ ヴ),
      '' => %w(カ キ ク ケ コ ガ ギ グ ゲ ゴ),
      '' => %w(サ シ ス セ ソ ザ ジ ズ ゼ ゾ),
      '' => %w(タ チ ツ テ ト ダ ヂ ヅ デ ド),
      '' => %w(ナ ニ ヌ ネ ノ),
      '' => %w(ハ ヒ フ ヘ ホ バ ビ ブ ベ ボ パ ピ プ ペ ポ),
      '' => %w(マ ミ ム メ モ),
      '' => %w(ヤ ユ ヨ),
      '' => %w(ラ リ ル レ ロ),
      '' => %w(ワ ヲ ン)
  }.freeze

ス→サのような変換を行うには、"ス"が含まれる配列を見つけ、それのキーを取得することで実現できます。 Enumerable#find( find, detect (Enumerable) - Rubyリファレンス ) を使用すると、ブロックが最初に真になったときのキーと値を返します。名前の始めの文字が見つからなかったときの挙動は特に問題には示されていなかったので、"Other"というキーを返すようにしました。

class NameIndex
  NAME_INDEX_HASH = {
      '' => %w(ア イ ウ エ オ ヴ),
      '' => %w(カ キ ク ケ コ ガ ギ グ ゲ ゴ),
      '' => %w(サ シ ス セ ソ ザ ジ ズ ゼ ゾ),
      '' => %w(タ チ ツ テ ト ダ ヂ ヅ デ ド),
      '' => %w(ナ ニ ヌ ネ ノ),
      '' => %w(ハ ヒ フ ヘ ホ バ ビ ブ ベ ボ パ ピ プ ペ ポ),
      '' => %w(マ ミ ム メ モ),
      '' => %w(ヤ ユ ヨ),
      '' => %w(ラ リ ル レ ロ),
      '' => %w(ワ ヲ ン)
  }.freeze

  def self.create_index(names)
    names.map { |name|
      NAME_INDEX_HASH.find(['Others']) {|_, value| value.include?(name[0])}  # 変更
    }
  end
end

実行結果です。名前の最初のカタカナが含まれている配列と対応するキーが取り出せています。

[
  ["", ["", "", "", "", "", "", "", "", "", ""]],
  ["", ["", "", "", "", "", ""]],
  ["", ["", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]],
  ["", ["", "", "", "", "", "", "", "", "", ""]],
  ["", ["", "", ""]],
  ["", ["", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]]
]

必要なのはキーだけなんで、配列の最初の要素を取り出します。Array#first ( first (Array) - Rubyリファレンス )を使用します。

class NameIndex
  NAME_INDEX_HASH = {
      '' => %w(ア イ ウ エ オ ヴ),
      '' => %w(カ キ ク ケ コ ガ ギ グ ゲ ゴ),
      '' => %w(サ シ ス セ ソ ザ ジ ズ ゼ ゾ),
      '' => %w(タ チ ツ テ ト ダ ヂ ヅ デ ド),
      '' => %w(ナ ニ ヌ ネ ノ),
      '' => %w(ハ ヒ フ ヘ ホ バ ビ ブ ベ ボ パ ピ プ ペ ポ),
      '' => %w(マ ミ ム メ モ),
      '' => %w(ヤ ユ ヨ),
      '' => %w(ラ リ ル レ ロ),
      '' => %w(ワ ヲ ン)
  }.freeze

  def self.create_index(names)
    names.map { |name|
      NAME_INDEX_HASH.find(['Others']) {|_, value| value.include?(name[0])}.first  # 変更 
    }
  end
end

実行結果です。ア段のカタカナを取り出すことができました。

["", "", "", "", "", ""]

次に、ア段のカタカナごとに名前をグループ化します。Array#group_byを使うのでした。

class NameIndex
  # 中略

  def self.create_index(names)
    names.group_by { |name|  # 変更
      NAME_INDEX_HASH.find(['Others']) {|_, value| value.include?(name[0])}.first 
    }
  end
end

実行結果です。ア段ごとにグループ化されました。

{
  ""=>["キシモト", "カネダ"],
  ""=>["イトウ"],
  ""=>["ババ", "ハマダ"],
  ""=>["ワダ"]
}

個人的には、データ形式は上記のように{ア段のカタカナ => 含まれる名前の配列}がよいと思うのですが、問題ではア段のカタカナを0番目の要素、名前の配列を1番目の要素とする配列の配列を作成することになっているので変換します。この変換はHash#to_a( to_a (Hash) - Rubyリファレンス )で行うことができます。

class NameIndex
  # 中略

  def self.create_index(names)
    names.group_by { |name|
      NAME_INDEX_HASH.find(['Others']) {|_, value| value.include?(name[0])}.first
    }.to_a  # 変更
  end
end

実行結果です。配列に変更できました。

[
  ["", ["キシモト", "カネダ"]],
  ["", ["イトウ"]],
  ["", ["ババ", "ハマダ"]],
  ["", ["ワダ"]]
]

最後にソートをかけます。ア段のカタカナ、それに対応する名前の配列それぞれにソートをかける必要があります。今回は最後にソートかけるのではなく、最初にソートをかけるように変更します。それによって自然に最終的な配列もソートされます。

1つ気にしておくべきことは、Rubyの(というよりほぼ全てのプログラミング言語での)ソートは文字コード順に並ぶということです。例えば、['ゴトウ', 'コニシ', 'コバヤシ']は、国語辞典ではこの順に並びますが、Rubyでは['コニシ', 'コバヤシ', 'ゴトウ']の順に並びます。もし国語辞典と同じ順番で並べたい場合はArray#sortに比較を行うブロックを渡し独自のソートを行う必要があります。 今回は問題文中の例として示されているケース(['ハ', ['ハマダ', 'ババ']])から、文字コード順で並び替えることが類推できます(あと初心者向けと銘打っていることからも)。コードは以下になります。

class NameIndex
  # 中略

  def self.create_index(names)
    names.sort.group_by { |name|  # 変更
      NAME_INDEX_HASH.find(['Others']) {|_, value| value.include?(name[0])}.first 
    }.to_a
  end
end

実行結果です。期待通りの結果が得られました。

[
  ["", ["イトウ"]],
  ["", ["カネダ", "キシモト"]],
  ["", ["ハマダ", "ババ"]],
  ["", ["ワダ"]]
]

あとがき

こちらも意外と考えることの多い問題でした。自分が初心者の時に解けただろうか。全てのことを一度にやろうとするのではなく、問題を細かいステップに分けるのがカギなのですが、初心者の頃は全てのことを一度にやりがちでした。

名前の先頭のカタカナからア段のカタカナを求める部分がキモなのですが、その部分は今回はわりと読みやすいコードになったかとは思います。

テストが書きにくかったです。数ケースはコードが書いた人が書けばよいと思いますが、チームで働いてる場合はテストケースは他の人が追加で書いた方がよい気がします(特に今回の場合は)。考慮漏れが発生しやすいプログラミング問題だと感じました。

テストケースの名字は名字由来netを参考にしました。

出題者の伊藤淳一さんの書籍はこちら↓