在向量中存储重复字符串时如何节省内存?

4
我可以为您翻译。以下是需要翻译的内容:

我正在使用C++和它的STL。 我有一个大的(100MB+)文本文件。这个文件只有很多“单词”(由空格分隔的字符串),如下:

sdfi sidf ifids sidf assd fdfd fdfd ddd ddd

我需要将每个“单词”放入一个向量中:
vector<string> allWordsInFile;

对于我从文件中读取的每个单词,我会执行以下操作:
allWordsInFile.push_back(word);

这个文件有很多重复的单词,我正在寻找节省内存的方法。每个单词需要在向量中正确的位置表示。如果我可以只有所有单词的列表在向量外部,然后只需在向量中放置引用,那就太好了,但是据我所知,在向量中无法放置引用。然后我想到只存储指向单词的指针,但每个单词的长度都非常短,我认为这不会产生太大的差异?(在我的系统上,每个指针占用4个字节,大多数字符串可能大小相同)。
有人能建议另一种方法来解决这个问题吗?

你预计文件中会有多少个唯一的单词? - v3.
这天,绝大多数现成PC的可用RAM容量约为100MB的5%至10%,这算得上是一个问题吗?为何需要节省内存呢? - Skizz
“只需在向量中放置一个引用,但这是不可能的[...]然后我考虑只存储单词的指针,但每个单词的长度都很短,我认为这不会有太大的区别。”而引用会更小,为什么?它们只有在代码需要将它们存储在某个地方时才占用零存储空间。我们可以通过创建一个唯一成员为引用的类,然后将其放入容器中来将引用“放入容器”中。由于大小和反汇编将显示,所有现有编译器都将引用实现为...指针,确切地说。很难想象另一种方法来做到这一点。 - underscore_d
10个回答

7

boost::flyweight 在这里看起来很有用。

实际上,教程示例 展示了如何使用 boost::flyweight<std::string> 来压缩数据库中的名称重复项。


我已经添加了一个更详细的答案,比较了字符串容器、轻量级对象以及将四个字符与char*联合的极端选项的效率。 - timday

3
如果您没有很多单词,可以将这些单词存储在外部数组中,并将其对应的索引存储在单词向量中。根据唯一单词的数量,每个单词可以使用1字节(最多256个单词)或2字节(最多65536个单词)来存储。
如果您需要速度,可以使用std :: map以log(n)时间查找字符串的索引(而不是迭代外部数组)。
例如,对于最大的65536个唯一单词。
vector<short> words
map<string,short> index
vector<string> uniqueWords
cindex = 0
for all words
    read the word
    if index[word] does not exist
        index[word] = cindex++
        uniqueWords.push_back(word)
    words.push_back(index[word]);

要检索原始单词,只需在uniqueWords中查找即可。


2
目标是使用更少的内存。由于每个索引存储一个short或int(如果需要超过65536个条目),我认为这个解决方案会使用更多的RAM。因为您的索引表的大小与整个单词列表的大小一样大。此外,您还必须在其上存储实际独特的单词。如果发布者在文件中有长字符串,那么这将非常好。但是由于他的平均4字节字符串,所以我认为这将消耗更多的内存。 - Brian R. Bondy
1
另一方面,std::string 存储字符串的大小和当前保留的额外存储空间,除了指针和实际字符串值。因此,使用 short 而不是 std::string 可能会将文档中每个单词的开销从大约 16 字节(或在 64 位系统上约为 32 字节)减少到仅 2 字节。 - Thorbjørn Lindeijer
我没有对这样的东西进行过基准测试,但是 unordered_map 是否更好呢?因为它不像 map 那样在每次插入时需要对其内容进行排序。此外,由于 [unordered_]map 已经处理了确保唯一性并允许通过 it->first 检索唯一键,所以 OP 可能已经能够将其作为最终结果重用,而不是维护一个基本上完全冗余的并行 vector - underscore_d

3
一种方法是只存储包含唯一字符串的向量。然后,“单词”列表只是一个整数向量,它是指向唯一字符串数组的索引。这样做可以节省内存,但读取文件的速度会变慢,因为您需要对每个新单词在唯一字符串数组中进行某种线性扫描。您可以使用映射作为唯一字符串数组的索引——如果在集合中找不到新单词,则知道将单词添加到唯一列表的末尾。想想看,您甚至不需要向量,因为映射已经具备了该功能:
typedef map<string, int> UniqueIndex;
UniqueIndex uniqueIndex;

typedef vector<int> WordsInFile;
WordsInFile wordsInFile;

for (each word in the file)
{
  UniqueIndex::const_iterator it=uniqueIndex.find(word);
  int index; // where in the "uniqueIndex" we can find this word
  if (it==uniqueIndex.end())
  {
    // not found yet
    index=uniqueIndex.size();
    uniqueIndex[word]=index;
  }
  else
    index=it.second;
  wordsInFile.push_back(index);
}

如果需要存储每个索引,而每个索引占用4字节,那么如何节省内存呢?又因为每个字符串大约也要占用4字节的空间。所以您需要保留4字节乘以单词数量再加上唯一字符串的大小。 - Brian R. Bondy
如果唯一字符串的数量很少,您可以使用16位整数...不过我认为您的解决方案比我的更好 - 我投了您一票。 - 1800 INFORMATION
我没有对这样的东西进行过基准测试,但是 unordered_map 是否更好呢?因为它不需要像 map 一样在每次插入时对其内容进行排序。但无论如何,+1 表示注意到生成的 [unordered_] map 可能会使 vector 多余,因为它们最终完全重复了数据,并且提供了实际代码而不是其他类似答案的伪代码。 ;) - underscore_d

3
Amm,您实际想要做的是压缩。
霍夫曼编码可能可以很好地完成这项工作。您可以进行一次扫描以构建单词的频率表,然后应用霍夫曼算法将每个单词附加一个符号。然后,您组合位的行,表示具有适当顺序的单词。这些位的行可以视为您的“低内存向量”。
霍夫曼编码的特性使您可以在任何位置访问符号(没有符号是另一个符号的前缀),但问题在于访问时间将为O(n)。这里有一些优化可以减少访问时间,但仅通过恒定因子,没有任何东西可以阻止它仍然保留小内存使用。如果您想了解可以执行的优化,请给我留言。
缺点:
1.在构建编码单词之后,访问需要O(n)的时间,必须线性扫描符号直到到达适当位置。 2.实现此想法并不简单,并且会消耗大量时间。

编辑: 写帖子时我没考虑到的一件事是,你必须持有查找表。因此,这可能只适用于你有很多重复单词的情况。

霍夫曼编码: http://en.wikipedia.org/wiki/Huffman_coding


1
因此,只有在您具有大量重复单词时才可能起作用。如果没有,他就会迷失。没有办法压缩随机数据。 - Suma
我只是在提出一个想法,我并没有说他必须实现它,他会决定这是否适合他的数据。 - user88637
我并不是想批评你的想法。我认为你是对的。如果这些词是随机的,他迷失了方向不是因为你的想法不好,而是因为他在原则上迷失了方向,没有人能做什么。然而,更有可能的是,他的数据并不是随机的。 - Suma

2
由于您的字符串通常只有4个字节左右,所以仅创建另一个间接级别是没有帮助的,因为指针的大小为4个字节(在x86上),或者更糟糕的是在x64上为8个字节。基于int的索引的大小也将为4个字节。
分部加载:
您可以考虑通过部分加载文件来节省内存。根据要查找的单词位置,仅加载所需内容。
您可以扫描文件一次以构建索引。该索引将存储每10个单词(任意选择)的起始位置。
然后,如果您想访问第11个单词,则计算11除以10以获取组的起始位置在索引中的位置,并查找到找到的起始位置。然后计算11模10以找出从该索引读取多少个单词以获得所需单词。
该方法不会尝试消除存储重复字符串,但它限制了需要使用的RAM仅为索引的大小。您可以将上面的“每10个单词”调整为“每X个单词”,以减少内存消耗。因此,RAM中使用的大小仅为(num_words_in_file / X)* sizeof(int),这比在RAM中存储整个文件的大小要小得多,即使您仅存储每个唯一字符串一次。
无额外空间访问每个单词:
如果您使用某个字符填充每个单词,使得每个单词的大小相同,则在读入时忽略填充字符即可访问确切的单词。这样做无需通过构建索引进行额外的遍历,并且几乎不需要额外的空间。

2
您需要指定向量上需要快速执行的操作,否则无法设计出合适的解决方案。您需要大多数进行随机访问,还是顺序访问?对于随机访问,什么样的性能是可接受的?
举个例子:存储数据的一种方式是使用LZMA或其他良好的压缩库来简单地压缩它们。然后当您需要访问某个元素时,您解压缩,一旦不再需要解压缩数据,就会丢弃解压缩数据。这种存储将非常节省空间,顺序访问将相当快,但随机访问时间将非常糟糕。

1

如果您可以不使用向量,那么另一种可能性是使用一个单一的结构而非两个结构,这与上面某些解决方案类似。具体来说,您可以使用一个单词到整数列表的映射,其中每个整数表示该单词的位置,并且一个计数变量会在每次读取一个单词时递增:

   int count = 0;
   Map<String, List<Integer>> words = new HashMap<String, List<Integer>>();

然后它大概是这样的(Java 伪代码):

   for (word : yourFile) {
      List<Integer> positions = words.get(word);
      if (values == null) {
         positions = new ArrayList<Integer>();
      }
      positions.add(++count);
      words.put(word, positions);
   }

1
std::map<std::string, std::vector<int>> words;int position = 0; for (文件中的每个单词) { words[word].push_back(position); ++position; } - unwesen

1
在另一个答案中,我向您指出了boost::flyweight,现在我想更详细地研究字符串容器、Flyweight以及将四个字符与指针合并(代码中的“sillystring”类)的相对效率。
代码注释如下:
  • 适用于32位Debian / squeeze,使用g ++ 4.3.3和boost 1.38.0
  • 使用std::deque而不是std::vector,因为向量的大小加倍行为(参见deques' chunks)会给人们留下(可以争辩的)低效印象,如果测试用例刚刚翻倍。
  • “愚蠢的字符串”使用指针的LSB来区分指针用例和本地字符用例。除非您的malloc在奇数字节边界上分配(在这种情况下,代码将抛出异常)(在我的系统上肯定没有看到过这种情况; YMMV),否则它应该有效。在任何人感觉有必要指出之前,是的,我认为这是可怕的危险的hacky代码,不是轻易选择的选项。

结果因单词长度分布而异,但对于分布为“(2D6+1)/2”(峰值在4处,但长度从1到6不等)的情况,效率(定义为实际内存消耗和需要存储的实际字符数之间的比率)如下:

  • 12.4% deque<string>
  • 20.9% deque<flyweight<string> >
  • 43.7% deque<sillystring>

如果所有单词都是4个字符(在单词生成器中更改为const int length=4;),这是sillystring的理想情况,则您将获得:

  • 14.2% deque<string>
  • 77.8% deque<flyweight<string> >
  • 97.0% deque<sillystring>

因此,轻量级模式确实是一种快速改进,但您可以通过利用单词适合指针大小的能力并避免额外的堆开销来做得更好。

以下是代码:

// Compile with "g++ -O3 -o fly fly.cpp -lpthread"
// run "./fly 0 && ./fly 1 && ./fly 2" 

#include <boost/flyweight.hpp>
#include <boost/format.hpp>
#include <boost/random.hpp>
#include <cstring>
#include <deque>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>

#include <sys/types.h>
#include <unistd.h>

#define THROW(X,MSG) throw X(boost::str(boost::format("%1%: %2%") % __PRETTY_FUNCTION__ % MSG))

struct random_word_generator
{
  random_word_generator(uint seed)
    :_rng(seed),
     _length_dist(1,6),
     _letter_dist('a','z'),
     _random_length(_rng,_length_dist),
     _random_letter(_rng,_letter_dist)
  {}
  std::string operator()()
  {
    std::string r;
    const int length=(_random_length()+_random_length()+1)/2;
    for (int i=0;i<length;i++) r+=static_cast<char>(_random_letter());
    return r;
  }
private:
  boost::mt19937 _rng;
  boost::uniform_int<> _length_dist;
  boost::uniform_int<> _letter_dist;
  boost::variate_generator<boost::mt19937&,boost::uniform_int<> > 
    _random_length;
  boost::variate_generator<boost::mt19937&,boost::uniform_int<> > 
    _random_letter;
};

struct collector
{
  collector(){}
  virtual ~collector(){}

  virtual void insert(const std::string&)
    =0;
  virtual void dump(const std::string&) const
    =0;
};

struct string_collector
  : public std::deque<std::string>, 
    public collector
{
  void insert(const std::string& s)
  {
    push_back(s);
  }
  void dump(const std::string& f) const
  {
    std::ofstream out(f.c_str(),std::ios::out);
    for (std::deque<std::string>::const_iterator it=begin();it!=end();it++)
      out << *it << std::endl;
  }
};

struct flyweight_collector 
  : public std::deque<boost::flyweight<std::string> >, 
    public collector
{
  void insert(const std::string& s)
  {
    push_back(boost::flyweight<std::string>(s));
  }
  void dump(const std::string& f) const
  {
    std::ofstream out(f.c_str(),std::ios::out);
    for (std::deque<boost::flyweight<std::string> >::const_iterator it=begin();
         it!=end();
         it++
         )
      out << *it << std::endl;
  }
};

struct sillystring
{
  sillystring()
  {
    _rep.bits=0;
  }
  sillystring(const std::string& s)
  {
    _rep.bits=0;
    assign(s);
  }
  sillystring(const sillystring& s)
  {
    _rep.bits=0;
    assign(s.str());
  }
  ~sillystring()
  {
    if (is_ptr()) delete [] ptr();
  }
  sillystring& operator=(const sillystring& s)
  {
    assign(s.str());
  }
  void assign(const std::string& s)
  {
    if (is_ptr()) delete [] ptr();
    if (s.size()>4)
      {
        char*const p=new char[s.size()+1];
        if (reinterpret_cast<unsigned int>(p)&0x00000001)
          THROW(std::logic_error,"unexpected odd-byte address returned from new");
        _rep.ptr.value=(reinterpret_cast<unsigned int>(p)>>1);
        _rep.ptr.is_ptr=1;
        strcpy(ptr(),s.c_str());
      }
    else
      {
        _rep.txt.is_ptr=0;
        _rep.txt.c0=(s.size()>0 ? validate(s[0]) : 0);
        _rep.txt.c1=(s.size()>1 ? validate(s[1]) : 0);
        _rep.txt.c2=(s.size()>2 ? validate(s[2]) : 0);
        _rep.txt.c3=(s.size()>3 ? validate(s[3]) : 0);
      }
  }
  std::string str() const
  {
    if (is_ptr())
      {
        return std::string(ptr());
      }
    else
      {
        std::string r;
        if (_rep.txt.c0) r+=_rep.txt.c0;
        if (_rep.txt.c1) r+=_rep.txt.c1;
        if (_rep.txt.c2) r+=_rep.txt.c2;
        if (_rep.txt.c3) r+=_rep.txt.c3;
        return r;
      }
  }
private:
  bool is_ptr() const
  {
    return _rep.ptr.is_ptr;
  }
  char* ptr()
  {
    if (!is_ptr())
      THROW(std::logic_error,"unexpected attempt to use pointer");
    return reinterpret_cast<char*>(_rep.ptr.value<<1);
  }
  const char* ptr() const
  {
    if (!is_ptr()) 
      THROW(std::logic_error,"unexpected attempt to use pointer");
    return reinterpret_cast<const char*>(_rep.ptr.value<<1);
  }
  static char validate(char c)
  {
    if (c&0x80)
      THROW(std::range_error,"can only deal with 7-bit characters");
    return c;
  }
  union
  {
    struct
    {
      unsigned int is_ptr:1;
      unsigned int value:31;
    } ptr;
    struct
    {
      unsigned int is_ptr:1;
      unsigned int c0:7;
      unsigned int :1;
      unsigned int c1:7;
      unsigned int :1;
      unsigned int c2:7;      
      unsigned int :1;
      unsigned int c3:7;      
    } txt;
    unsigned int bits;
  } _rep;
};

struct sillystring_collector 
  : public std::deque<sillystring>, 
    public collector
{
  void insert(const std::string& s)
  {
    push_back(sillystring(s));
  }
  void dump(const std::string& f) const
  {
    std::ofstream out(f.c_str(),std::ios::out);
    for (std::deque<sillystring>::const_iterator it=begin();
         it!=end();
         it++
         )
      out << it->str() << std::endl;
  }
};

// getrusage is useless for this; Debian doesn't fill out memory related fields
// /proc/<PID>/statm is obscure/undocumented
size_t memsize()
{
  const pid_t pid=getpid();
  std::ostringstream cmd;
  cmd << "awk '($1==\"VmData:\"){print $2,$3;}' /proc/" << pid << "/status";
  FILE*const f=popen(cmd.str().c_str(),"r");
  if (!f)
    THROW(std::runtime_error,"popen failed");
  int amount;
  char units[4];
  if (fscanf(f,"%d %3s",&amount,&units[0])!=2)
    THROW(std::runtime_error,"fscanf failed");
  if (pclose(f)!=0)
    THROW(std::runtime_error,"pclose failed");
  if (units[0]!='k' || units[1]!='B')
    THROW(std::runtime_error,"unexpected input");
  return static_cast<size_t>(amount)*static_cast<size_t>(1<<10);
}

int main(int argc,char** argv)
{
  if (sizeof(void*)!=4)
    THROW(std::logic_error,"64-bit not supported");
  if (sizeof(sillystring)!=4) 
    THROW(std::logic_error,"Compiler didn't produce expected result");

  if (argc!=2)
    THROW(std::runtime_error,"Expected single command-line argument");

  random_word_generator w(23);

  std::auto_ptr<collector> c;
  switch (argv[1][0])
    {
    case '0':
      std::cout << "Testing container of strings\n";
      c=std::auto_ptr<collector>(new string_collector);
      break;
    case '1':
      std::cout << "Testing container of flyweights\n";
      c=std::auto_ptr<collector>(new flyweight_collector);
      break;
    case '2':
      std::cout << "Testing container of sillystrings\n";
      c=std::auto_ptr<collector>(new sillystring_collector);
      break;
    default:
      THROW(std::runtime_error,"Unexpected command-line argument");
    }

  const size_t mem0=memsize();

  size_t textsize=0;
  size_t filelength=0;
  while (filelength<(100<<20))
    {
      const std::string s=w();
      textsize+=s.size();
      filelength+=(s.size()+1);
      c->insert(s);
    }

  const size_t mem1=memsize();
  const ptrdiff_t memused=mem1-mem0;

  std::cout 
    << "Memory increased by " << memused/static_cast<float>(1<<20)
    << " megabytes for " << textsize/static_cast<float>(1<<20)
    << " megabytes text; efficiency " << (100.0*textsize)/memused << "%"
    << std::endl;

  // Enable to verify all containers stored the same thing:
  //c->dump(std::string(argv[1])+".txt");

  return 0;
}

0

我认为使用类似这样的东西可以节省内存:

struct WordInfo
{
    std::string m_word;
    std::vector<unsigned int> m_positions;
};

typedef std::vector<WordInfo> WordVector;

First find whether the word exists in WordVector

If no,
    create WordInfo object and push back into WordVector
else
    get the iterator for the existing WordInfo
    Update the m_positions with the position of the current string

1
或者你可以直接使用std::pair。 - aib

0

首先,我们应该知道字符串是什么:

如果“大多数字符串都是4个字母”,文件大小为100MB,则

a)可能有很多重复项,也许最好存储不在数组中的字符串(特别是如果您可以忽略大小写),但这不会给您它们在向量中的位置。

b)也许有一种方法可以从ASCII 8位(假设它确实是ASCII)(8X4=32)挤压到可能的20位(5x4),每个字母使用5位,并且通过一些花哨的位操作可以减小向量的大小。请运行数据样本并查看文件中确实有多少不同的字母,也许某些字母组非常丰富,以至于将它们分配一个特殊值(在8位序列的32个选项中)是有意义的。实际上,如果我正确的话,如果大多数单词可以转换为20位表示,则只需要一个包含3MB的数组即可存储所有单词及其单词计数-并单独处理> 4个字符(假设3个字节足够用于单词计数,应该足够了,也许2个字节就足够了:可以使其动态,总共使用2MB)

c) 另一个不错的选择是,我想其他人在上面已经提到过,只需将字符串与字母连接起来并在其上运行压缩器,坏处是它可能需要压缩//解压缩的临时内存和cpu负载。除此之外应该很好用。
d) 如果你真的想最小化使用的RAM,也许你想要使用你的磁盘:对单词进行排序(如果内存不足可以使用磁盘),并创建一个临时文件,其中单词一个接一个,这将适用于顺序访问。然后,您可以创建一次性树状表示单词,叶子包含文件中单词的相对地址以供随机访问,并将其“序列化”到磁盘上。最终,由于大多数单词长度为4个字符,使用5个跳跃即可获取文件中任何字母的位置,而不使用任何RAM,可以说。您还可以将树的前2或3层缓存在RAM中,这将在RAM方面轻便,以减少对于4个字符单词的2或3跳跃。然后,您可以使用一些小的RAM缓存最常用的单词,并做出各种美妙的事情以加速访问。 p>现在已经很晚了,希望我讲得通...

pd. 谢谢大家的评论


1
我一开始也描述了一个 trie,但后来将其删除了,因为他需要保留单词数量,而要做到这一点,你需要存储相同数量的内存(因为每个字符串大约是4个字节)。 - Brian R. Bondy
除了使用Tries之外,有向无环字图可能更加适合,但它同样不能保持顺序。 - Suma

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接