Lua 技術に関するメモ 9

分割して文字列を作成する

ロベルト・イェルサリムスキー

要約

Lua では、文字列表現の結果の「集積」(つまり、 s = s..x などのコードでループ処理)は非常にコストがかかる可能性があります。このメモでは、Lua で分割して文字列を作成する効率的な方法について説明します。

問題

例えば、ファイルを行ごとに読み取って文字列を段階的に作成しているとしましょう。一般的なコードは次のようになります。

-- WARNING: bad code ahead!!
local buff = ""
while 1 do
  local line = read()
  if line == nil then break end
  buff = buff..line.."\n"
end
一見問題ないように見えますが、このコードは Lua では大きなファイルの場合に大幅なパフォーマンス低下を招く可能性があります。例えば、350 K バイトのファイルを読み取ると、約 1 分かかります。

多くの場合、これは問題ではありません。小さな文字列では、上記のループで十分です。ファイル全体を読み取るには、"*all" オプションを使用して一度に読み取ることができます。しかし、このような簡単な解決策がない場合もあります。そのとき、唯一の解決策は問題に対するより効率的なアルゴリズムです。ここでは 1 つ(問題ではなくアルゴリズム)を示します。

解決策

アルゴリズムの核はスタックです。これにより、先に作成された大きな文字列が下部に保持され、小さな文字列は上部から入力されます。このスタックの主な不変性は、プログラマーによく知られている「ハノイの塔」に似ています。スタック内の文字列が短い文字列の上に配置されることはありません。新しい文字列が短い文字列の上にプッシュされるとき(かつそのときのみ)、アルゴリズムは両方を連結します。この連結により大きな文字列が作成され、これが以前の階層の隣接文字列よりも大きくなる可能性があります。そうなる場合は、これらも結合されます。連結は、ループがより大きな文字列またはスタックの下部に達するまでスタックを下っていきます。
function newBuffer ()
  return {n=0}     -- 'n' counts number of elements in the stack
end

function addString (stack, s)
  tinsert(stack, s)       -- push 's' into the top of the stack
  for i=stack.n-1, 1, -1 do
    if strlen(stack[i]) > strlen(stack[i+1]) then break end
    stack[i] = stack[i]..tremove(stack)
  end
end
バッファーの最終的なコンテンツを取得するには、下部のすべての文字列を連結するだけです。
function toString (stack)
  for i=stack.n-1, 1, -1 do
    stack[i] = stack[i]..tremove(stack)
  end
  return stack[1]
end

この新しいデータ構造を使用すると、プログラムを次のように書き直すことができます。

local s = newBuffer()
while 1 do
  local line = read()
  if line == nil then break end
  addString(s, line.."\n")  
end
s = toString(s)
この新しいプログラムは、350 K バイトのファイルを読み取る元の時間から 40 秒から 0.5 秒に短縮されました。(read"*all" の呼び出しの方がまだ高速で、0.02 秒でジョブが完了します。)

説明

ナイーブなアプローチの場合に何が起こるかを理解するために、読み取りの途中で buff がすでに 50 K バイトの文字列で、各行が 20 バイトであると仮定します。代入の後

    buff = buff..line.."\n"
buff は 50,020 バイトの新しい文字列になり、古い文字列はガベージになります。ループのサイクルが 2 回繰り返された後、buff は 50,040 バイトの文字列になり、2 つの古い文字列が合わせて合計 100 K バイト以上のガベージになります。そのため、Lua はガベージコレクターを実行するのに適切な時機であると正しく判断し、それらの 100 K バイトを解放します。問題は、これが 2 サイクルごとに発生するため、Lua はループの完了前に 2,000 回ガベージコレクターを実行することになります。これだけの作業があっても、メモリ使用量はファイルサイズの約 3 倍になります。さらに悪いことに、各連結では文字列のコンテンツ全体(50 K バイト以上をどんどん増やしていく)を新しい文字列にコピーする必要があります。

この問題は Lua に固有のものではありません。文字列が不変オブジェクトである真正的ガベージコレクションを使用した他の言語も、同様の動作を示します(最も有名な例は Java です)。

オリジナルのループでは、小さな文字列を一文字ずつアキュムレータに連結するという「線形」アプローチを採用しています。新しいアルゴリズムは、バイナリアプローチを採用することで、これを回避します。小さな文字列をまとめて結合し、しばらくすると、結合した大きな文字列をさらに大きな文字列に連結します。


最終更新: 2002年8月12日月曜日15:51:58(米国東部時間)