我实现了(参见下面的代码)绝对最小化的通用后缀树构建算法。我写了一个单元测试,它似乎按预期工作(在正确的位置找到正确的子串)。但这棵树实在太大了。 问题:我是否在某个地方犯了错误,或者这个基本形式的后缀树只能用于非常短的文本?
统计
我想用它来搜索大量文本:多个15-20Mb文本文件,或者例如40&#39,000字符串〜每个字符60个字符。
当我构建40� 000字符串2.5Mb后缀树时(如果是公司名称,它是一个列表),树需要400Mb。我可以将其优化大约4倍,但即使这样,每个原始文本字符也要超过40个字节。这是正常的吗?文献中的估计对我来说这很高。
查看树的每个级别的平均分支因子,它们是:80 40 8 3 2 1 1 1 ...即,只有树的前3-5个级别实际上是分支的。我建立3-5个级别并保留长文本后缀节点要好得多。低于第5级的所有内容基本上都是链接的字符列表。这是预期的表现吗?从直觉上看,公司名称之间没有共享6个以上的字符子串。
在进一步使用15MB文本的实验中,在添加前10� 000后缀(长到短)后,我耗尽了2Gb的内存。几乎没有重复的子串,因此树不能重复使用。我完全可以看到后缀数组实际上是多么有用,它每个字符需要固定2-4个字节,并且在20Mb文本中每次搜索只需要24个字符串比较。我无法看到具有与文本中唯一子串一样多的顶点的后缀树如何适合内存。它是具有所有唯一字符的字符串的O(n ^ 2)个节点,但对于英文文本来说它似乎仍然非常超线性。后缀树如何为大文本工作?我读过的论文和问题似乎暗示它应该是可用的,因此我的问题在这里。
问题
我是否在某个地方犯了一个错误,使得树比它应该更大?
构建后缀树的方式是否与最终树形状无关?是否正确?
我的不是Ukkonen算法,只是强力将所有后缀添加到树中以简化代码和数据结构(无后缀链接字段)并稍后将perf与Ukkonen进行比较。建造方法应该只影响建筑的速度,不树的大小或形状。无论哪种方式,构建树都是递增的,因此没有比最终树更大的中间结果。
以下是代码:
#include <vector>
#include <assert.h>
class Node
{public:
char c; // 0 is terminator. terminators have no children
Node(char _c) { c = _c; }
};
class Terminator
{
public:
Terminator(int i, int p ) { id = i; pos = p; }
bool operator == (const Terminator &that)const { return id == that.id && pos == that.pos; }
int id; // id of the string
int pos; //position of this substring in the string
};
class Vertex : public Node
{
public:
static size_t s_nCount;
std::vector< Vertex* > children; // interior tree nodes; char != 0
std::vector< Terminator >terminators;
Vertex(char c) :Node(c) { s_nCount++; }
~Vertex() { for (Vertex*v : children) delete v; }
//void* operator new (size_t count) { return g_GstAlloc.Alloc(count); }
//void operator delete(void *p) { g_GstAlloc.Free(p); }
void getDepthCounts(std::vector<unsigned> &depth, size_t nLevel = 0)const
{
if (depth.size() <= nLevel)
depth.resize(nLevel + 1);
depth[nLevel]++;
for (Vertex*v : children)
v->getDepthCounts(depth,nLevel + 1);
}
Vertex *getOrCreateChildVertex(char c )
{
Vertex *out = getChild(c);
if (!out)
{
out = new Vertex(c);
children.push_back(out);
}
return out;
}
void getTerminators(std::vector<Terminator> &out, size_t limit )
{
if (out.size() >= limit)
return;
out.insert(out.end(), terminators.begin(), terminators.end());;
for (Vertex* c: children) {
c->getTerminators(out, limit);
if (out.size() >= limit)
break;
}
}
Vertex *getChild(char c)
{
for (Vertex *p : children)
if (p->c == c)
return p;
return nullptr;
}
size_t memSize()const
{
size_t out = sizeof(*this) + terminators.size() * sizeof(terminators[0]);
for (Vertex*v : children)
out += sizeof(v) + v->memSize();
return out;
}
};
class Root : public Vertex
{
public:
Root():Vertex(0) { }
void appendString(const char *str, int id )
{
for (volatile size_t len = strlen(str), suffix = len; suffix-- > 0;)
{
Vertex* parent = this;
for (size_t pos = suffix; pos < len; pos++)
{
parent = parent->getOrCreateChildVertex(str[pos]);
}
parent->terminators.push_back(Terminator(id, (int)suffix));
}
}
void findSubstr(std::vector<Terminator> &out, const char *substr, size_t limit )
{
Vertex *parent = this;
for (size_t len = strlen( substr ), i = 0; i < len; i++)
{
parent = parent->getChild(substr[i]);
if (!parent)
return;
}
parent->getTerminators(out, limit);
}
};
答案 0 :(得分:0)
Bo Persson评论提示,我重新阅读了维基百科article on Suffix Trees,最后点击了它。我可能仍然误解后缀树,但是这种特殊的内存爆炸是由于我在每个节点上用一个字符构建一个扩展树(让我们称之为字符树)。这是一种不正确的格式。
Ukkonen的算法以及所有其他后缀树构建算法,在O(n)时间内构建树,其中包含一个提示。字符串ABCD .... XYZ将具有带有O(n ^ 2)个节点的字符树,这显然不可能在O(n)时间内构建。
正确的后缀树必须包含具有唯一子串的节点(在wiki页面中,BANANA $是一个节点而不是6个节点加一个终结符)。它需要固定的内存(例如,第一个字符和长度的索引)。
Ukkonen的算法洞察力是优化AAAAAA ... AAAAA字符串的情况。这样的字符串具有O(n)后缀树节点(以及O(n)“字符树”节点),并且naiive构建需要O(n ^ 2)时间,因为您必须在每个步骤上遵循不断增长的节点串。但是,使用Ukkonen的后缀链接添加每个后缀需要进行O(1)操作(它只是在末尾添加一个节点,并且在跟随后缀链接一次之后,它会停止)。
对于ABCD ... XYZ字符串,正确的后缀树仍将具有O(n)个节点。例如。后缀FGH ... XYZ只有一个节点。
在我的代码中,该后缀将生成21个单独的节点。这就是创建O(n ^ 2)内存消耗的原因,基本上我对后缀树节点的误解是子字符串而不是一个字符。