词典分词的初级算法II

138 阅读8分钟

《入门》这本书的一个很大的问题就是过早优化!在读者还没形成整体印象的时候,过早地谈论性能,速度,优化,结果让入门的人不知所云,要求会数学的人还得了解编程概念,而让会编程的人了解数学原理,然而即会数学也会编程的人根本不需要再看这样一本入门书。了解知识体系与把知识体系让不同知识层次人了解是一门重要的技能。

首字散列其余二分字典树

首先这个字典的树的名字就是容易让人困惑的,这种命名想表达的其实是这种字典树针对一个待查找的字符串,将它的首字进行散列其余字符进行二分查找。也就是说针对的是查找过程,而查找的方式决定了存储的方式。理解描述所表达的含义之后,实现是相当简单的,只是用数组代替根节点而已,这个时候充分体现了组合模式的好处了:

class ArrayTrieTree {
    private val children = Array<TrieNode?>(Char.MAX_VALUE.toInt() + 1) {
        null
    }

    operator fun contains(key: String): Boolean = get(key) != null

    operator fun get(key: String): String? {
        if (key.isEmpty()) return null
        var node = children[key[0].toInt()] ?: return null
        for (c in key.substring(1)) {
            node = node[c] ?: return null
        }
        return node.value
    }

    operator fun set(key: String, value: String?) {
        if (key.isEmpty()) return
        val first = key[0]
        val pos = first.toInt()
        var node = children[pos] ?: TrieNode(null, first).apply {
            children[pos] = this
        }
        val k = key.substring(1)
        k.forEachIndexed { i, c ->
            node = if (i == k.length - 1) {
                node.add(c, value, true)
            } else {
                node.add(c, null, false)
            }
        }
    }
}

使用稀疏数组

书中说道“普通节点……先维护数组的有序性,然后进行二分查找”。也就是说普通节点TrieNode也是持有数组来保存子节点,但为了能更快的查找,采取了这种方式。这是妥妥的稀疏数组要做的事情呀,对于安卓开发来说是一种非常熟悉的数据结构了。如果数组保存的值类型为Int还可以选取专门的SparseIntArray。普通的java项目里没有SparseArray,不过问题根本不大,直接从安卓系统项目里拷贝一份源码即可。所以普通节点更改如下:

 class TrieNode(val value: String?, val edge: Char = Char.MIN_VALUE) {
-    private val children = mutableMapOf<Char, TrieNode>()
+    private val children = SparseArray<TrieNode>()

还有一些其他细微改动,主要就是原来的children[char]改成children[char.toInt()]

双数组字典树

原理理解

双数组字典树首先在理解上就比较费力,这本书几乎没有任何帮助,它把原理和实现混在一块说,结果原理即没讲清,实现也很含糊,还不如根本不提。Trie树上每个节点都表示一个状态,从一个节点到另一个节点,就是状态s想转到状态t,必须接收一个字符,或者说必须有一个触发事件。base[s] + c = t中的+即不是数学意义上的,也不是编程意义上的,它表示的只是接收一个字符的事件。并不是base数组中下标为s处的取值加上c值等于t值。

譬如“中华”这个字符串,当接收”中“字符时,c表示的是”中“字对应的散列(也就是字符的编码值),而s表示上一个字符(节点)的位置,因为”中“字是首字,所以上一字符即是根字符,也就是数组中位置为0的那个字符。base[s]的含义则表示当前字符c在数组位置上需要偏移的值,只不过这个偏移值的保存是由当前字符c的上一字符决定的。所以要决定当前字符保存的位置时,需要先给定上一节点在数组中的位置,这就是s的含义;于是“中”应当保存的位置=base[0]+code('中')=1+20013=20014,这时候的+回到数学和编程意义上的含义;确定了“中”字的位置之后,需要再设置20014位置下base和check保存的值。如前所述,base[20014]决定了下一字符的位置偏移,当前其实是无法确定的,直接设置前一字符的base值,即base[20014]=base[0]check[20014]表示的是位置20014字符的前一字符在数组中的位置,于是check[20014]=0

接着接收“华”字符,c变成了“华”的编码值,s则变成了“中”字在数组中的位置,也就是20014;决定“华”字存储的位置需要知道base[20014],前一步操作已经保存为1,于是“华”字的保存位置=base[20014]+code('华')=1+21326=21327;接下来需要确定base[21327]check[21327],因为“华”已经是当前字串的最后一个,所以不会再有“下一字符”,于是base[21327]=0,而check[21327]=20014。这样就完成了”中华“字符串的保存。

base数组看来总是1,岂不是没用?实际上base值不一定是1,这是因为我们目前还没有遇到冲突。比如接上面的例子,需要再次保存“华人”这个字符串。首先“华”作为新字符串的第一个字符,它的前一字符就是根字符,所以'华'的位置=base[0]+code('华')=1+21326=21327,然而这时的base[21327]check[21327]已经有值了,是我们保存“中华”时设置的,一旦原来保存的数据被新值覆盖,立马就会出现错误:老“华”字的前一字符是“中”,新“华”字的前一字符是根字符,而且老“华”字是字符串的最后一个,而新“华”字可是字符串的开头;显然,作为不同字符串的不同位置的同一个字,应当保存在数组中的不同位置。冲突的解决类似线性散列的冲突解决,这时就需要借助check作判断,base会设置成不同的值。

理解上可以看看这篇帖子,而实现上可以再看看这篇帖子。也可以看看这篇

初始构建

这里直接引用现成的说法,来说明为什么需要先罗列所有词语的首字进行预先保存,参照“中华”和“华人”保存的例子:

构造字典时,需要加入新词,若新词的首字未出现,写入时有冲突的情况下,导致根节点的转移基数改变,会导致重构整个树的情况(否则不能进行正确的状态转移),所以构建树时建议先构建每个词的首字,再构建各个词的子节点,这样产生冲突的情况下,可以将冲突局限在单个父节点和子节点之间,不至于大范围的节点重构

huoji555那篇帖子的构建函数写的实在太难受,其实就是分两次循环分别构建首字和其余字符串而已,重新实现如下:

fun build(words: List<String>) {
    words.forEachIndexed { i, s ->
        val c = s[0]
        add(0, c, s.length == 1, i)
    }
    words.forEachIndexed { i, s ->
        var state = 0
        var j = 1
        while (j < s.length) {
            state = transfer(state, s[j - 1])
            add(state, s[j], s.length == j + 1, i)
            j++
        }
    }
}

也就是说,与数组字典树可以分多次插入单词不同,双数组字典树需要预先把所有待插入的单词一次性全部保存。

不同的实现点

其余的思路基本参照了huoji555的实现,不过我这里有2个比较大的不同之处。

1. 直接保存位置而不是结构体

huoji555的base数组元素类型是一个自定义结构体,依照对原理的理解和网上其它实现的参照,base数组直接保存整型数值即可。它的含义就表示为下一字符编码的偏移值

private val _offset = IntArray(ARRAY_CAPACITY).apply {
    this[0] = 1
}

2. 节省保存字符的内存

huoji555的实现中用结构体单独保存了char值,但是这部分内存其实也可以省下来,因为字符的最终位置始终包含了字符的编码值,因此针对某一位置pos,它所对应的字符编码值就是pos-offset,而偏移值offset可由前一字符的check值得到:

val parent = check[pos]
val offset = base[parent]
val c = (pos - offset).toChar()

需要完善的点

扩容策略 当前的实现假定了数组长度为65536,实际使用肯定是不够的,因为同一个字因为在不同词语及其不同位置会多次出现,需要保存在不同的位置。这时候base和check可以用ArrayList,先保证正确性,避免过多的考虑影响实现。

空闲位置利用 因为当前这种实现的base里保存的值是正向增长的,这样很容易造成一种情况:数组的低索引部分可能会出现大量空闲,这个时候可以利用环形数组的形式,offset+code可以超过数组大小,但这种实现和扩容是冲突的,实用性其实比较小。

完整代码

命名上用_offset代替了base,用_parent代替了check,标识节点是叶子节点用了位向量BitSet,这样也比较节省内存空间。

positionOf方法就是huoji555实现中的transferadd就是insertfun char(pos: Int): Char?方法就是为了获得当前位置对应的字符;parentOf就是为了获取check值;wordByLeaf表示从某一叶子节点开始得到的完整单词。

class DoubleArrayTrieTree {
    companion object {
        private const val ARRAY_CAPACITY = Char.MAX_VALUE.toInt() + 1
    }

    private val _offset = IntArray(ARRAY_CAPACITY).apply {
        this[0] = 1
    }
    private val _parent = IntArray(ARRAY_CAPACITY)
    private val leaves = BitSet(ARRAY_CAPACITY)

    fun build(words: List<String>) {
        words.forEachIndexed { i, s ->
            val c = s[0]
            add(0, c, s.length == 1, i)
        }
        words.forEachIndexed { i, s ->
            var state = 0
            var j = 1
            while (j < s.length) {
                state = positionOf(state, s[j - 1])
                add(state, s[j], s.length == j + 1, i)
                j++
            }
        }
    }

    private fun positionOf(parent: Int, c: Char): Int = _offset[parent] + c.toInt()

    private fun add(parent: Int, c: Char, isLeaf: Boolean, idx: Int): Int {
        var pos = positionOf(parent, c)
        println("Add-0 '$c(${c.toInt()})' from [$parent] -> [$pos], leaf=$isLeaf, " +
                "idx=$idx, check[$pos]=${_parent[pos]}, base[$pos]=${_offset[pos]}")
        if (_parent[pos] != parent) {
            while (_offset[pos] != 0) {
                pos++
            }
            _offset[parent] = pos - c.toInt()
            println("Add-1: new pos = $pos, base[$parent]=${_offset[parent]}")
        }

        _parent[pos] = parent
        val parentOffset = _offset[parent]
        println("Add-2: pos=[$pos], parent=[$parent], check[pos]=$parent, parent: base[$parent]=$parentOffset, this: base[$pos]=${_offset[pos]}")
        if (isLeaf) {
            _offset[pos] = parentOffset
            leaves[pos] = true
        } else if (_offset[pos] == 0) {
            _offset[pos] = parentOffset
        }
        return pos
    }

    operator fun get(pos: Int): Int = _offset[pos]

    fun char(pos: Int): Char? {
        if (pos == 0) return null
        val offset = _offset[parentOf(pos)]
        return (pos - offset).toChar()
    }

    fun parentOf(pos: Int): Int = _parent[pos]

    fun wordByLeaf(index: Int): String? {
        if (!leaves[index]) return null
        var pos = index
        val sb = StringBuilder()
        while (pos != 0) {
            val parent = parentOf(pos)
            val offset = _offset[parent]
            val c = pos - offset
            sb.append(c.toChar())
            pos = parent
        }
        return sb.reverse().toString()
    }
}

测试验证

    @Test
    fun testDoubleArrayTrieTree() {
        val trie = DoubleArrayTrieTree()
        trie.build(listOf(
            "清华",
            "清华大学",
            "清新",
            "中华",
            "中华人民",
            "华人",
        ))
        testExpect(3, trie[20014])
        testExpect(1, trie[20155])
        testExpect(3, trie[20157])
        testExpect(1, trie[21327])
        testExpect(2, trie[21328])
        testExpect(3, trie[21329])
        testExpect(2, trie[22825])
        testExpect(2, trie[23400])
        testExpect(2, trie[26034])
        testExpect(3, trie[27668])
        testExpect(2, trie[28166])

        testExpect('中', trie.char(20014))
        testExpect('人', trie.char(20155))
        testExpect('人', trie.char(20157))
        testExpect('华', trie.char(21327))
        testExpect('华', trie.char(21328))
        testExpect('华', trie.char(21329))
        testExpect('大', trie.char(22825))
        testExpect('学', trie.char(23400))
        testExpect('新', trie.char(26034))
        testExpect('民', trie.char(27668))
        testExpect('清', trie.char(28166))

        testExpect(0, trie.parentOf(20014))
        testExpect(21327, trie.parentOf(20155))
        testExpect(21329, trie.parentOf(20157))
        testExpect(0, trie.parentOf(21327))
        testExpect(28166, trie.parentOf(21328))
        testExpect(20014, trie.parentOf(21329))
        testExpect(21328, trie.parentOf(22825))
        testExpect(22825, trie.parentOf(23400))
        testExpect(28166, trie.parentOf(26034))
        testExpect(20157, trie.parentOf(27668))
        testExpect(0, trie.parentOf(28166))

        testExpect(0, trie.parentOf(21327))

        testExpect("华人", trie.wordByLeaf(20155))
        testExpect("清华", trie.wordByLeaf(21328))
        testExpect("中华", trie.wordByLeaf(21329))
        testExpect("清华大学", trie.wordByLeaf(23400))
        testExpect("清新", trie.wordByLeaf(26034))
        testExpect("中华人民", trie.wordByLeaf(27668))
    }