正規表現による単語リストの圧縮 (3)
できたー!
migemo の RegexCompiler#optimize を利用させてもらいました。結果はこんな感じ。
a(bs|pd_set_socket_session_trace|r(ray|sort)|s(cii2ebcdic|ort)) acosh? add(cslashes|slashes) aggregat(e(|_(info|methods(|_by_(list|regexp))|properties(|_by_(list|regexp))))|ion_info)
戦略
末尾一致や中間一致は考えないことにして、先頭一致部分に注目する。たとえば "oooo" で始まる単語が3個あったとする。
ooooxxx ooooyyy oooozzz
共通部分である "oooo" を括り出せば 4バイト * 2行 = 8バイト減らせる。
ooooxxx yyy zzz
ただし、これを正規表現で表すには1行目に2バイト、2行目以降に各1バイトのメタ文字が必要。
oooo(xxx| yyy| zzz)
結局、文字列 x を括り出すことによって減らせるデータ量 r(x) は
r(x) = (x のバイト数 - 1) * (x の出現度数 - 1) - 2バイト
となる。あとは単語リストに出現するすべての部分文字列 x に対して r(x) を計算し、 r(x) の大きいものから順に x を括り出していけば、必ずしも最適ではないけれども結構いい線まで行くと思われる。
ちなみに、 PHP 5.2.0 における r(x) の上位は
r(x) | x |
---|---|
1111 | ncurses_ |
586 | mysqli_ |
366 | image |
331 | pdf_ |
282 | cpdf_ |
278 | fbsql_ |
274 | dbplus_ |
258 | imap_ |
238 | ibase_ |
233 | mysql_ |
でした。
実装
単語リストをファイルから読み込みつつ、部分文字列の出現頻度をカウントする。たとえば
abs acos acosh
というリストなら、部分文字列の出現頻度は
{"a"=>3, "ab"=>1, "abs"=>1, "ac"=>2, "aco"=>2, "acos"=>2, "acosh"=>1}
となる。この中で、減らせるデータ量 r(x) が最大になるのは x = "acos" である。 "acos" で始まる単語リスト ["acos", "acosh"] を RegexCompiler#optimize に食わせると "acos(h|)" が出てくる。最後に "(h|)" → "h?" みたいな局所最適化をして、ファイルに出力すればおしまい。
問題点
秀丸エディタの強調定義ファイルには次の制限がある。
- 1行の長さは(フラグ部分も含めて) 253 バイトまで
- 全体の容量は(内部形式に変換した状態で) 32KB まで
1行の長さ制限をクリアするために、正規表現が250バイトを超えそうになったところで分割する。そのため、たとえば同じ "printer_" で始まる2つの単語が
"printer_draw_elipse" → "printer_(d(raw_(elipse)))" "printer_select_pen" → "print(er_(s(e(lect_pen))))"
こんな風に泣き別れになることもある。ていうか、もはや元の単語がどこにあるのかわからない!
ソース
#!/usr/bin/ruby -I./migemo # # HilightCompressor # by IKKI 2007/03/21 # # 単語リストを正規表現で圧縮して強調定義ファイルを作る。 # migemo が必要。 → http://0xcc.net/migemo/ # [インストール] # hilight_compressor.rb # migemo/ # ├migemo.rb ← migemo.rb.in をリネーム # └migemo-regex.rb # [使い方] # 引数に単語リストファイルを与えて実行。 # $ ./hilight_compressor.rb filename.dic # 終わると filename.hilight ができる。 $KCODE="e" require 'migemo' require 'migemo-regex' include MigemoRegex hilight_prefix = '177,' # 出力行の先頭 hilight_suffix = '' # 出力行の末尾 if ARGV.length < 1 puts "usage: #{$0} inputfile" exit end outputfile = ARGV[0].sub(/[^.]*$/, "hilight") class RegexCompiler def my_optimize(level) @regex = optimize2(@regex) if level >= 2 @regex = optimize3(@regex) if level >= 3 end end class HilightCompressor def initialize(border = 1, limit = 250) @list = Array.new @freq = Hash.new(0) @border = border # 共通部分の最小文字数 @limit = limit # 強調定義の1行の長さの最大値 end attr_accessor :border attr_accessor :limit def load_stdin while line = gets line.chomp! push(line[/^\w+/]) end end def load_array arr.each do |item| push(item[/^\w+/]) end end def push(str) @list.push(str) str.length.downto(@border) do |len| @freq[str[0, len]] += 1 end self end def gather!(lead) # 文字列 lead で始まる要素を削除して配列で返す arr = Array.new bytes = 0 @list.delete_if do |str| if str[0, lead.length] == lead && bytes + str.length < @limit - arr.length str.length.downto(@border) do |len| @freq[str[0, len]] -= 1 @freq.delete(str[0, len]) if @freq[str[0, len]] <= 0 end arr.push(str) bytes += str.length true end end arr end def major_part # 最大共通部分を文字列で返す # 文字列 x を括ることによって減るバイト数は、正規表現のメタ文字の長さも考えると # (x のバイト数 - 1バイト) * (x を含む行数 - 1行) - 2バイト return nil if @freq.empty? x = @freq.max do |a, b| (a[0].length - 1) * (a[1] - 1) <=> (b[0].length - 1) * (b[1] - 1) end reduce = (x[0].length - 1) * (x[1] - 1) - 2 $stderr.puts "#{sprintf("%4i", reduce)} #{x[0]}" if reduce >= 0 x[0] else nil end end def regex_for(words) # 配列 words にヒットする正規表現を文字列で返す compiler = RegexCompiler.new words.each do |w| compiler.push(w) end compiler.uniq compiler.my_optimize(3) regex = compiler.regex renderer = RegexRendererFactory.new(regex, "egrep", "") string = renderer.render string.gsub(/\((.|\[.+?\])\|\)/, '\1?') # "(a|)" → "a?" end def compressed_list() # 最適化されたリストを配列で返す arr = Array.new while m = major_part # 大きく減らせるやつから順に括る g = gather!(m) r = regex_for(g) raise "regex length = #{r.length} bytes" if r.length > @limit arr.push(r) end (?! .. ?~).each do |c| # 余ったやつを頭文字ごとに括る g = gather!(c.chr) $stderr.puts "#{sprintf("%4i", g.length)} #{c.chr}" next if g.empty? r = regex_for(g) raise "regex length = #{r.length} bytes" if r.length > @limit arr.push(r) end arr.concat(@list) # さらに余ったやつをそのままくっつける @list.clear @freq.clear arr.sort end end hc = HilightCompressor.new hc.load_stdin max = 0 hfile = open(outputfile, "w") hc.compressed_list.each do |word| line = "#{hilight_prefix}#{word}#{hilight_suffix}\r\n" hfile.print(line) max = line.length if line.length > max end hfile.close $stderr.puts "output file = #{outputfile}" $stderr.puts "max length = #{max} bytes"