Bitap Algorithmを理解する (using asearch)
deno-asearchをCode Readingして、Bitap algorithmをちゃんと理解してみる試み
自分で作ったコードをcode readingするってどゆこと?と思うかもしれないが、takker.iconはnode-asearchのコードを移植しただけでアルゴリズムの仕組みは全く理解していないので、code readingする必要がある
用語を決めておきたい
「ビットパターン」だの「変数」だの「状態」だのを適当に使ったため、どれがどれを示しているのかよくわからなくなってきた
状態をビット値で表したものを「レジスタ」とも呼ぶ。https://tech.pjin.jp/blog/2020/11/13/stringsearch-3#StringSearch_03
なるほどtakker.icon
使用するcode
各種初期値の定義
32bit目だけを立てたビットパターン
code:mod.ts
const INITPAT = 0x80000000;
code:mod.ts
const MAXCHAR = 0x10000;
const INITSTATE = INITPAT, 0, 0, 0;
大文字小文字変換
NativeのString.prototype.toLowerCase()を代わりに使ってもいい気がするのだが……
文字ではなく文字コードの変換だから、String.prototype.toLowerCase()は使えないのか
code:mod.ts
const isupper = (c: number) => (c >= 0x41) && (c <= 0x5a);
const islower = (c: number) => (c >= 0x61) && (c <= 0x7a);
const tolower = (c: number) => isupper(c) ? (c + 0x20) : c;
const toupper = (c: number) => islower(c) ? (c - 0x20) : c;
各種初期値の定義その2
wildCardに指定した文字をwild card*として解釈する
これを検索文字列の前後に挿入すると、部分一致文字列検索を実現できる
code:mod.ts
const wildCard = 0x20;
code:mod.ts
export function Asearch(source: string) {
前処理
遷移可能な場所を記録する
表現方法
それぞれの文字に対応するビットパターン
bitが立っている場所でそれらの文字が来ることを表す
例:検索文字列の2,5,8文字目にaが来る場合、対応する整数は2進数で01001001になる
ここで使用するビットパターンの最大ビット長で、検索可能な最大文字数が決まる
JavaScriptのビット演算は32bitに切り詰められるので、JSでの実装だと32文字までが限界
全ての文字に対応するために、任意の文字コードからビットパターンに写す写像を作成する必要がある
本実装では予め全ての文字コードごとにビットパターンを用意している
node-asearchもほぼ同じ方法を使っている
これは検索に使わない文字のビットパターンも用意しなければならないので、かなりメモリ効率が悪いと思われる
後ほどHash mapMapを使った方法に切り替えるつもり
もちろんベンチマークをとって実際に速くなったかどうかを調べる
疎行列のデータ表現方法に、この手のメモリを節約する方法がないかな?takker.icon
code:mod.ts
const shiftpat = new Float64Array(MAXCHAR);
let epsilon = 0;
code:mod.ts
let mask = INITPAT;
for (const i of unpack(source)) {
if (i === wildCard) {
ワイルドカードの場合は別途用意したビットパターンepsilonに記録する
またwild cardの場合はmaskのビットの場所を進めない
これはなぜだろう?takker.icon
code:mod.ts
epsilon |= mask;
} else {
文字の場所をビットパターンに記録する
大文字と小文字のビットパターンを両方立てることでcase insensitiveを実現している
ここをいじれば、いろんな同一視方法を組み込める
e.g.
ひらがなカタカナ無視
全角半角無視
見た目が似ている文字の違いを無視
𝟙と1とか
|= maskで、mask内で立っている全てのbitを転写できる
今回の場合は1本しか立っていないが
code:mod.ts
shiftpati |= mask;
shiftpattoupper(i) |= mask;
shiftpattolower(i) |= mask;
mask >>>= 1;
}
}
acceptpatが状態遷移機械の受理状態を表す
sourceからwild cardを除いた文字列の長さと同じ位置にビットが立っている
code:mod.ts
const acceptpat = mask;
被検索文字列strを状態遷移機械に入力してLevenshtein距離を計算する
4つの変数i0, i1, i2, i3を使っているので、Levenshtein距離4まで計測できる
それ以上は全ての変数が未受理になるので計測できない
変数の数を増やせば、より長いLevenshtein距離まで対応できる
もちろんその分計算量が増える
code:mod.ts
function getState(str = "") {
NFAの初期化
code:initial_state
i3 0000 0000 0000 0000 0000 0000 0000 0000
i2 0000 0000 0000 0000 0000 0000 0000 0000
i1 0000 0000 0000 0000 0000 0000 0000 0000
i0 1000 0000 0000 0000 0000 0000 0000 0000
code:mod.ts
let i0, i1, i2, i3 = INITSTATE;
被検索文字列の全ての文字に対して機械を動かしている
全部なめる必要なくない?takker.icon
変数のどれかが受理状態になった時点で遷移を打ち切っていいのでは
ビット演算の意味を考える
i0 = (i0 & epsilon) | ((i0 & mask) >>> 1)
前のi0から新しいi0を作っている
新しいi0に以下の場所を書き込んでいる
前のi0のうちwildcardの場所と一致する場所((i0 & epsilon))
前のi0のうち文字コードcに該当する文字の検索文字列中の出現箇所と一致する場所((i0 & mask))を右に1つずらしたもの((i0 & mask) >>> 1)
1つ下の状態を上の状態に転写している
うーん、適当な文字で1stepごとに具体化して調べないと、何をやっているのかわからなそうだなtakker.icon
適当な短い文字で試してみるか
wild cardありとwild cardなしとで試したい
ちょっとわかった
例:
sourceの1文字目がaだった時
strの1文字目がaのとき
mask:0b1...
i0:0b1...
初回のループなのでi0の先頭ビットも1
よって(i0 & mask) >>> 1は0b01...となる
これが文字を受理して状態遷移したということ
strの1文字目がaでないとき
(i0 & mask) >>> 1は0b00...となる
maskが0b0xxxxなのでi0の先頭ビットが削られる
この時削られたi0のビット情報自体はi1に転記されているので消えはしない
sourceの1文字目が (wild card)だった時
どんな文字が来ても(i0 & epsilon)によってi0の先頭ビットは常に1に保たれる
部分一致検索できる原理はこれだなtakker.icon
code:mod.ts
for (const c of unpack(str)) {
mask = shiftpatc;
i3 = (i3 & epsilon) | ((i3 & mask) >>> 1) | (i2 >>> 1) | i2;
i2 = (i2 & epsilon) | ((i2 & mask) >>> 1) | (i1 >>> 1) | i1;
i1 = (i1 & epsilon) | ((i1 & mask) >>> 1) | (i0 >>> 1) | i0;
i0 = (i0 & epsilon) | ((i0 & mask) >>> 1);
i1 |= (i0 >>> 1);
i2 |= (i1 >>> 1);
i3 |= (i2 >>> 1);
}
return i0, i1, i2, i3;
}
一致判定を行う函数
strとsourceが与えられたLevenshtein距離以下でマッチしたかどうかを判定する函数
node-asearchの判定函数と同じ
code:mod.ts
function test(str: string, distance = 0) {
const state = getState(str);
distance = Math.min(INITSTATE.length - 1, distance);
return (statedistance & acceptpat) !== 0;
}
求まったLevenshtein距離を返す函数
これはdeno-asearchオリジナル
検索文字列とどのくらい一致したかがわかるので、検索結果の並び替えに使える
code:mod.ts
function match(str: string) {
const state = getState(str);
if ((stateINITSTATE.length - 1 & acceptpat) === 0) {
return { found: false };
}
const distance = state.findIndex((i) => (i & acceptpat) !== 0);
return { found: true, distance };
}
code:mod.ts
return {
test,
match,
source,
};
}
書記素クラスタごとに文字を分割し、文字コードの一部を取得する
配列で返すと余計な配列生成のコストがかかってしまうので、Generator (JavaScript)で返している
0xFFFFまでの文字ならString.prototype.charCodeAt()で文字コードを取得できるが、絵文字など0x10000以降の文字の文字コードは最初の部分しか取得できない
ここをちゃんとやるには、String.prototype.codePointAt()を使う必要がある
しかしそれをやるにはMAXCHARを0x10000より大きくする必要があるが、容易にはできない
shiftpatのメモリ消費量が大変なことになる
code:mod.ts
function* unpack(str: string) {
for (const char of str) {
yield char.charCodeAt(0);
}
}
#2021-10-16 19:17:15