Rubyで文字列検索アルゴリズムを表現しよう!
文字列中のパターンを探し出すメソッドとして、RubyにはString#indexが用意されています。
text = "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur."
text.index("velit") # => 285
またRubyの標準ライブラリにはStringScannerクラスがあるので、これを使って以下のようにインデックスを求めることもできます。
require "strscan"
text = StringScanner.new(text)
text.skip_until(/.?(?=#{Regexp.escape("velit")})/) # => 285
ですから今更、文字列検索のアルゴリズムを実装する価値はありません。しかしアルゴリズムを理解してそれを実装する能力は、プログラムする上での基礎力として必須と言われているので1、ここで勉強しておきたいと思います。
以下では、文字列検索として代表的なアルゴリズムである、力まかせ検索、ボイヤー-ムーア(BM)検索、クヌース-モリス-プラット(KMP)検索、N-gramインデックス検索、ラビン-カープ(RK)検索の各検索アルゴリズムをRubyで実装します。なお、各アルゴリズムはWikipediaその他のネット上の情報を参考に、独自解釈して構成したものです。間違いがあるかも知れません。いやきっとあるでしょう。
力まかせ検索
力まかせ検索はテキストの先頭から、検索パターンを1文字ずつずらして照合を行う検索アルゴリズムです。具体的には以下の手順を行います。
- テキストの先頭文字から検索パターンの各文字を照合。
- 一致しない場合照合位置を1つずらす。
- 1-2を一致するまで繰り返す。
これをRubyのStringクラスに実装してみます。
class String
def power_search(pattern)
pos = 0
until pos > length-pattern.length
match = compare(pattern, pos)
return match if match
pos += 1
end
end
def compare(pattern, pos)
pattern.each_char.with_index do |chr, i|
return nil unless chr == self[pos+i]
end
pos
end
end
compareメソッドではパターンとテキストの対応箇所を、先頭から一文字ずつ比較してそのすべてが一致した場合に先頭のインデックスを返します。
StringScannerクラスを使ったcompareメソッドの例も示しておきます。
def compare_with_scanner(pattern, pos)
str = StringScanner.new(self)
str.pos = pos
pattern.each_char do |chr|
return nil unless str.scan /#{Regexp.escape(chr)}/
end
pos
end
StringScanner#scanメソッドはマッチする場合には、自動でポインタを進めるので繰り返しのための記述が簡潔になります。
ボイヤー-ムーア(BM)検索
BM検索は検索パターンとの照合をパターン末尾から行い、パターン内文字と不一致文字との照合を行うことによって、照合回数を減らす工夫をした検索アルゴリズムです。具体的には以下の手順を行います。
- テキストの先頭文字から検索パターンの各文字を後方から照合。
- 一致しない場合その不一致文字がパターン前方にあるか調べる。
- ある場合照合位置をその位置までずらす。
- ない場合照合位置を不一致文字の後ろにずらす。
- 1-4を一致するまで繰り返す。
BM検索では次の照合位置を、後方から調べた不一致位置までずらすことができるので、効率が大変いいです。
Rubyで実装してみます。
class String
def bm_search(pattern)
pos = 0
until pos > length-pattern.length
match, pos = bm_compare(pattern, pos)
return match if match
end
end
def bm_compare(pattern, pos)
(pattern.length-1).downto(0) do |i|
if self[pos+i] != pattern[i]
shift = pattern[0...i].bm_shift(self[pos+i]) || i + 1
return nil, pos + shift
end
end
pos
end
def bm_shift(chr)
length.times { |pos| return pos + 1 if self[-1-pos] == chr }
nil
end
end
bm_compareではFixnum#downtoメソッドでインデックスを降順にして、比較をパターン後方から行うようにしています。また力まかせ検索ではパターンのずらし量は1に固定されていましたが、ここではbm_shiftメソッドの結果でその量を決定しています。
クヌース-モリス-プラット(KMP)検索
KMP検索は検索パターン内に存在する文字パターンを利用して、照合回数を減らす工夫をした検索アルゴリズムです。具体的には以下の手順を行います.
- テキストの先頭文字から検索パターンの各文字を照合。
- 先頭文字で不一致が生じた場合、パターンを1つずらす。
- 2文字目以降で不一致が生じた場合、不一致箇所以前の部分においてその先頭文字列の繰り返し文字列の有無を調べる。
- 繰り返し文字列がなければ、不一致箇所にパターンをずらす。
- 繰り返し文字列がある場合は、その位置にパターンをずらすと共に照合開始位置を繰り返し文字列の次にセットする。
- 2-5を一致するまで繰り返す。
上記よりKMP検索では検索パターンが繰り返し文字列を含んでおらず、照合の不一致がパターンの後方で多く発生する場合に効果が大きくなることが分かります。ですから通常の文字列検索ではあまり効率がよくありません。
Rubyで実装してみます。
class String
def kmp_search(pattern)
pos, from = 0, 0
until pos > length-pattern.length
match, pos, from = kmp_compare(pattern, pos, from)
return match if match
end
end
def kmp_compare(pattern, pos, from)
(from..(pattern.length-1)).each do |i|
if self[pos+i] != pattern[i]
return nil, pos+1, 0 if i == 0
shift, from = pattern[0...i].count_sequence
if shift
return nil, pos+i-shift, from
else
return nil, pos+i, 0
end
end
end
pos
end
def count_sequence
shift = 1
until shift >= length
if self[0] == self[shift]
match = 1
until shift+match >= length
self[match] == self[shift+match] ? match += 1 : break
end
return length-shift, match
end
shift += 1
end
end
end
このコードではcount_sequenceメソッドにおいて繰り返し文字列の有無を調べています。そして繰り返し文字列の長さに応じて、次の照合の再開位置(from)を決定します。場合分けが多く若干コードが複雑になっていますが、その割に検索の効率が上がらないのが残念です。
N-gramインデックス検索
N-gramインデックス検索はテキスト内文字のインデックスを使って、照合回数を減らす工夫をした検索アルゴリズムです。具体的には以下の手順を行います。
- テキストのすべての文字の出現位置を記録した索引を作る
- 索引を参照して検索パターンの先頭文字と同じ文字の出現位置を調べる
- 照合位置を出現位置に移動して照合を行う
- 2-3を一致するまで繰り返す
N-gramインデックス検索では照合箇所をパターンの先頭文字の出現位置だけに限定できるので、照合回数を激減させることができます。一方で、インデックスの作成のためのコストが大きくかかる問題があります。
Rubyで実装してみましょう。
class String
def ngi_search(pattern, n=1)
indices = index_table(n)[pattern[0...n]]
until indices.empty?
match = ngi_compare(pattern, indices.shift)
return match if match
end
end
def index_table(n)
q = Hash.new{ |h, k| h[k] = [] }
self.split(//).each_cons(n).with_index { |chr, i| q[chr.join] << i }
q
end
def ngi_compare(pattern, pos)
pattern.length.times do |i|
return nil if self[pos+i] != pattern[i]
end
pos
end
end
インデックスの作成はindex_tableで行い、結果はHashに格納しています。デフォルトでインデックスは1文字ですが、複数文字(n)を指定することもできます。パターンのずらし回数はindicesの要素数で決まります。
ラビン-カープ検索(RK)検索
RK検索は検索パターンとの照合を個々の文字列で行うのではなく、それらのハッシュ値で行うことによって照合に掛かる時間を効率化する検索アルゴリズムです。パターンのずらし量は1文字ずつで力まかせ検索と同じです。具体的には以下の手順を行います。
- 検索パターンとその対応箇所のハッシュ値を求め照合する
- 一致しない場合テキストの次の照合箇所のハッシュ値を求める
- 照合位置を1つずらす
- 2-3を一致するまで繰り返す
なお次のハッシュ値の生成はその生成効率を上げるため、前のハッシュ値を利用したローリングハッシュ値の演算を用います。またハッシュ値が一致しても生文字列が一致しない場合があるのでその確認もします。
Rubyで実装してみます。
class String
def rk_search(pattern)
pos = 0
h_self = self[0...pattern.length].rhash
h_pattern = pattern.rhash
until pos > length-pattern.length
match, h_self = hash_compare(h_self, h_pattern, pattern.length, pos)
return match if match && self[pos...pos+pattern.length] == pattern
pos += 1
end
end
def rhash(base=101)
(0...length).inject(0) { |mem, i| mem + self[length-1-i].ord*base**(i) }
end
def hash_compare(h_self, h_pattern, len, pos)
h_self == h_pattern ? pos : [nil, next_hash(h_self, len, pos)]
end
def next_hash(h_self, len, pos, base=101)
return nil unless self[pos+len]
(h_self - self[pos].ord*base**(len-1))*base + self[pos+len].ord
end
end
ハッシュ値の計算はrhashメソッドで行うようにし、ここでは素数であるbaseを基数とした各文字の文字コードを足し合わせたものを用います。hash_compareメソッドでハッシュ値が一致しない場合に、next_hashメソッドで次のハッシュ値を求めています。ここでは前のハッシュ値h_selfから先頭文字のハッシュ値を引き、基数を一つずらしてから次の文字のハッシュ値を足しています。
テスト
これらのアルゴリズムをテストしてみます。各メソッドにクラス変数を挟んで照合回数を計算し、同時に各実行時間も求めます。
@@time = []
class TestSearchs < Test::Unit::TestCase
def setup
@text = "Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur ABC ABCDAB ABCDABCDABDE susususususumsu abcdefgabcdefghijabcdefghijk axacacdae sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.&" * 100
@words = ["Lorem", "sum", "fugiat", "ut aliquip", "&", "t, c", " m", "o ", "ABCDABD", "acac"]
@nowords = ["hello", "ipsumd", "Velit", "&.", "t D", "veniam,,", "abcdefghijkabcdefghijk"]
@st = Time.now
end
def teardown
@@time << Time.now - @st
end
def test_power_search
@words.each do |wd|
assert_equal(@text.index(wd), @text.power_search(wd))
end
@nowords.each do |wd|
assert_nil(@text.power_search(wd))
end
end
def test_bm_search
@words.each do |wd|
assert_equal(@text.index(wd), @text.bm_search(wd))
end
@nowords.each do |wd|
assert_nil(@text.bm_search(wd))
end
end
def test_kmp_search
@words.each do |wd|
assert_equal(@text.index(wd), @text.kmp_search(wd))
end
@nowords.each do |wd|
assert_nil(@text.kmp_search(wd))
end
end
def test_ngi_search
@words.each do |wd|
assert_equal(@text.index(wd), @text.ngi_search(wd))
end
@nowords.each do |wd|
assert_nil(@text.ngi_search(wd))
end
end
def test_rk_search
@words.each do |wd|
assert_equal(@text.index(wd), @text.rk_search(wd))
end
@nowords.each do |wd|
assert_nil(@text.rk_search(wd))
end
end
end
class String
def self.method_added(name)
class_variable_set("@@#{$`}", 0) if name =~ /_search$/
end
def self.counter
class_variables.sort.inject({}) { |h, cv| h[cv[/\w+/]] = class_variable_get(cv); h }
end
end
END{END{
res = String.counter
res.each do |k, v|
print "%s\t%8d times(%.4f) %.4f sec\n" % [k, v, v*1.0/res["power"], @@time.shift]
end
}}
結果は以下の通りです。
Loaded suite /Users/keyes/Dropbox/workspace/searchs
Started
.....
Finished in 16.116971 seconds.
5 tests, 85 assertions, 0 failures, 0 errors, 0 skips
bm 96236 times(0.2480) 1.3565 sec
kmp 382275 times(0.9850) 2.4872 sec
ngi 29977 times(0.0772) 5.3435 sec
power 388089 times(1.0000) 2.6198 sec
rk 370185 times(0.9539) 4.2976 sec
このテストではN-gramインデックス検索(ngi)の照合回数が際立って少ないことが分かります。しかしインデックス作成のために実行時間は最も遅いです。ハッシュ値で比較するラビン-カープ(RK)検索では思ったほど比較回数が少なくならず、その結果ハッシュ値生成のためのコストが響いています。
ボイヤー-ムーア(BM)検索が照合回数も少なく最速で、力まかせ(power)検索の倍の速度が出ています。一方で力まかせ検索の結果もそれほど悪くないという印象です。
参考:
- http://itpro.nikkeibp.co.jp/article/Watcher/20070924/282781/ ↩
blog comments powered by Disqus