【问题标题】:Most memory efficient way to remove duplicate lines in a text file using C++使用 C++ 删除文本文件中重复行的最节省内存的方法
【发布时间】:2022-08-21 13:06:56
【问题描述】:

我了解如何使用 std::string 和 std::unordered_set 执行此操作,但是,集合的每一行和每个元素都会占用大量不必要的低效内存,导致 unordered_set 和文件中的一半行为 5 -10 倍于文件本身。

是否有可能(以及如何,如果是的话)以某种方式减少内存消耗,例如,您可以使用不超过 20 GB 的 RAM 从 10 GB 文件中删除重复项?在这种情况下,当然,必须以 O(n) 的速度执行此操作。

  • 重复项总是彼此相邻吗?
  • 不幸的是,不,重复项可以随机散布在文件周围。理论上,有一个排序选项,但这不适用于数百 GB 的文件
  • 内存映射文件(使用mmap() 或类似方法),并维护行的哈希值以及指向这些行的指针。在您为所有重复项编制索引之前,不要缩小文件。确定重复项后,进行压缩。
  • @JohnFilleau,请告诉我如何存储这些数据,以便通过哈希进行即时访问,并且该结构不会像 unordered_set 那样为每个元素占用太多空间?用于 5 到 50 个字符长的 ascii 字符串的最佳散列是什么?
  • @追赶 - 删除文本文件中重复行的最节省内存的方法-- 老实说 -- 使用awk 或实用程序来执行此操作。我记得有人在哪里接受 C++ 职位的面试,并被问到一个类似的问题(关于在文件中搜索文本)。答案是——使用 grep,而不是 C++。

标签: c++ memory-management hashtable unordered-set drop-duplicates


【解决方案1】:

您可以使用每行的哈希快速查找重复行,如其他答案所示。但是,如果您只存储散列,则假定没有散列冲突。如果您使用std::hash,那将是不正确的。如果您使用良好的加密散列,您可能会侥幸逃脱。

由于您的输入只有 10G,我建议采用不同的方法。好吧,除了琐碎。 10G 是您可能只需加载到内存中并将每行存储为现代系统上的字符串的东西。

但是让我们节省一些内存:

  • 首先,您应该对文件进行 mmap 映射,以便可以从 C++ 访问其所有数据,而无需同时将其全部加载到内存中。
  • 创建一个std::unordered_multimap<std::size_t, std::string_view> lines; 来跟踪输入中已经看到的行
  • 循环输入文件并为每一行文本创建一个string_view,计算哈希值并在lines中查找。如果散列存在,则将该行与具有相同散列的其他行进行比较。如果该行是唯一的,则将其添加到lines 并输出。

我认为这将使用每行(唯一)行 32 字节的内存。因此,对于短行,所需的内存可能比输入文件更多。另一方面,对于短线,独特的线可能要少得多。

PS:您可以通过仅存储每行的开头来节省内存。如果您估计(唯一)行的数量,您可以使用具有不同冲突策略(没有 bin)的哈希表将其降低到每行 8 个字节。

【讨论】:

    【解决方案2】:

    当然,您可以在 O(n^2) 时间内完成此操作,只需使用保存两行所需的内存量、一个布尔标志和两个文件偏移量。

    基本算法将是:

    • 打开输入文件
    • 打开输出文件
    • 将标志设置为假
    • 将预读偏移设置为 0
    • 虽然输入更多:
      • 读取第一个输入行
      • 将当前输入文件偏移保存为读取后偏移
      • 寻找输入文件到偏移量 0
      • 当当前输入文件偏移量小于预读偏移量时:
        • 读取第二个输入行
        • 如果第一个输入行等于第二个输入行:
          • 将标志设置为真
          • 休息
      • 如果标志为假:
        • 将第一行输入写入输出文件
      • 将标志设置为假
      • 寻找输入文件到读后偏移
      • 将预读偏移设置为读后偏移

    当然,这是非常省时的,但它与内存效率差不多。

    可能的 C++ 实现:

    std::ifstream input(inputFilePath);
    std::ofstream output(outputFilePath);
    
    std::streamoff offset_before = 0;
    std::streamoff offset_after = 0;
    bool found_dupe = false;
    
    std::string line1;
    while (std::getline(input, line1)) {
        offset_after = input.tellg();
    
        input.seekg(0);
        std::string line2;
        while (input.tellg() < offset_before && std::getline(input, line2)) {
            if (line1 == line2) {
                found_dupe = true;
                break;
            }
        }
    
        if (!found_dupe) {
            output << line1 << '\n';
        }
    
        found_dupe = false;
        input.seekg(offset_after);
        offset_before = offset_after;
    }
    

    Live Demo

    【讨论】:

    • 提问者是否足够年轻,他可以活着看到这个算法在 10 GB 的数据集上完成? :)
    • 老实说,我什至不敢想象在几十 GB 的文件上以 O(N^2) 的速度执行算法需要多长时间……
    • 如果摩尔定律跟上,那么可能会有一台足够快的计算机让它在某个时候完成。我承认这个答案纯粹基于问题的标题有点开玩笑。
    【解决方案3】:

    我知道现在回答这个问题有点晚了,但只是为了好玩,我编写了一个我认为非常节省内存同时仍然具有合理性能的实现。

    特别是,此解决方案在O(N*log(N)) 时间运行,并且(在我的机器上)仅使用 360 KB(!)的堆内存,同时对包含 99,990,000 个随机排列的重复行的 100,000,000 行(5 GB)文本文件进行重复数据删除,并且6分32秒结束。

    当然,它确实有点作弊,因为它将临时索引文件写入磁盘(索引包含输入文件中每一行的哈希值,并且该行在输入文件中的位置)。每个文本行的索引文件需要 16 个字节,所以在我的测试中它达到了 ~1.4GB。

    为进行重复数据删除,程序mmap() 将索引文件放入 RAM,按哈希码对其内容进行排序,然后扫描索引并使任何具有相同哈希码且引用相同字符串的现在相邻条目无效输入文件。

    之后,它按字节偏移量对索引文件进行重新排序,然后再对索引进行一次迭代以生成去重输出文件。

    我的测试运行(在 2018 Intel Mac Mini 上)的输出如下:

    Jeremys-Mac-mini-2:~ jaf$ time ./a.out
    Step 1:  Read input file [big_input_file.txt] and write index file [index_file.tmp]
    Step 2:  mmap() index file [index_file.tmp] into RAM and sort its entries by hash-code
    Step 3:  Iterate through index file [index_file.tmp] and invalidate any duplicate entries
    Step 4:  Re-sort the index file [index_file.tmp] by byte-offset in preparation for generating output
    Step 5:  Write output file [big_output_file.txt]
    Step 6:  Delete index file and exit
    Final result:  Out of 100000001 lines read, 99990000 duplicate lines detected and removed.
    real        6m32.800s
    user        3m39.215s
    sys         2m41.448s
    

    源代码如下(我用g++ -std=c++20 ./dedup_file.cpp编译):

    #include <fcntl.h>
    #include <stdint.h>
    #include <unistd.h>
    #include <sys/mman.h>
    
    #include <array>
    #include <fstream>
    #include <iostream>
    #include <span>
    #include <string>
    
    using SizeTPair = std::array<size_t, 2>;
    
    static const SizeTPair INVALID_INDEX_ENTRY = {(std::size_t)-1, (std::size_t)-1};  // special value meaning "not a valid index entry"
    
    // Given a pointer into the contents of the input file, returns a string_view representing
    // the line of text starting there.  (This is necessary since we can't modify the input file
    // and the lines in the input file are not NUL-terminated)
    static std::string_view GetStringAtOffset(const char * inputFileMap, size_t offset)
    {
       if (offset == (size_t)-1) return "";
    
       const char * s  = &inputFileMap[offset];
       const char * nl = strchr(s, '\n');
       return nl ? std::string_view(s, nl-s) : std::string_view(s);
    }
    
    // Comparison functor to sort SizeTPairs by the text they point to
    // breaks ties by sorting by line-number (so that if a duplicate line is
    // detected in the text, it will always be the second instance of that line that
    // is excluded from our program's output, not the first instance)
    class SortIndicesByStringCompareFunctor
    {
    public:
       SortIndicesByStringCompareFunctor(const char * inputFileMap) : _inputFileMap(inputFileMap) {/* empty */}
    
       bool operator()(const SizeTPair & a, const SizeTPair & b) const
       {
          const std::string_view textA = GetStringAtOffset(_inputFileMap, a[0]);
          const std::string_view textB = GetStringAtOffset(_inputFileMap, b[0]);
          if (textA != textB) return (textA < textB);
          return (a[1] < b[1]); // sub-sort by line number
       }
    
    private:
       const char * _inputFileMap;
    };
    
    static void WriteEntryToIndexFile(std::ofstream & indexFile, const SizeTPair & entry, size_t & indexSizeItems)
    {
       indexFile.write(reinterpret_cast<const char *>(&entry), 2*sizeof(size_t));
       indexSizeItems++;
    }
    
    int main(int, char **)
    {
       const char * bigInputFileName  = "big_input_file.txt";
       const char * indexFileName     = "index_file.tmp";
       const char * bigOutputFileName = "big_output_file.txt";
    
       std::cout << "Step 1:  Read input file [" << bigInputFileName << "] and write index file [" << indexFileName << "]" << std::endl;
    
       // Step 1.  Read through the big input-text file, and generate a binary
       // index-file containing (for each line) that line's hash-code and also
       // its location in the input file
       size_t indexSizeItems = 0;
       size_t inputFileSizeBytes = 0;
       {
          std::ifstream inputFile;
          inputFile.open(bigInputFileName, std::ios_base::binary | std::ios_base::ate);  // binary only so we can get valid file-offsets out of tellg()
          inputFileSizeBytes = inputFile.tellg();  // get file size
          inputFile.seekg(0, std::ios_base::beg);  // then go back to the beginning of the file so we can read it
    
          std::ofstream indexFile;
          indexFile.open(indexFileName, std::ios_base::binary);
    
          std::string nextLine;
          while(inputFile.good())
          {
             const std::streampos curFileOffset = inputFile.tellg();  // do this before reading the line:  record our current read-offset into the file
             std::getline(inputFile, nextLine);
             WriteEntryToIndexFile(indexFile, {std::hash<std::string>()(nextLine), (std::size_t)curFileOffset}, indexSizeItems);
          }
    
          // Add a final dummy-entry to the end of the index, just to force the flushing of any
          // final text-line(s) in our for-loop in step (3)
          WriteEntryToIndexFile(indexFile, INVALID_INDEX_ENTRY, indexSizeItems);
       }
    
       std::cout << "Step 2:  mmap() index file [" << indexFileName << "] into RAM and sort its entries by hash-code" << std::endl;
    
       // Step 2.  mmap() the index-file we just generated, and sort its contents by hash-code (sub-sort by byte-offset)
       const int indexFD = open(indexFileName, O_RDWR, (mode_t)0666);
       if (indexFD < 0) {std::cerr << "Couldn't open() index file!" << std::endl; exit(10);}
    
       char * indexFileMap = (char *) mmap(0, indexSizeItems*(2*sizeof(size_t)), PROT_READ | PROT_WRITE, MAP_SHARED, indexFD, 0);
       if (indexFileMap == MAP_FAILED) {std::cerr << "mmap() of index file failed!" << std::endl; exit(10);}
    
       SizeTPair * index = reinterpret_cast<SizeTPair *>(indexFileMap);
       std::span<SizeTPair> indexSpan(index, index+indexSizeItems);
       std::sort(std::begin(indexSpan), std::end(indexSpan));
    
       std::cout << "Step 3:  Iterate through index file [" << indexFileName << "] and invalidate any duplicate entries" << std::endl;
    
       // Step 3.  Go through the index file and invalidate any duplicate
       // entries (i.e. any entries that have the same hash code and same
       // underlying string as a previous entry)
       const int inputFD = open(bigInputFileName, O_RDONLY, (mode_t)0666);
       if (inputFD < 0) {std::cerr << "Couldn't open() input file!" << std::endl; exit(10);}
    
       const char * inputFileMap = (const char *) mmap(0, inputFileSizeBytes, PROT_READ, MAP_SHARED, inputFD, 0);
       if (indexFileMap == MAP_FAILED) {std::cerr << "mmap() of index file failed!" << std::endl; exit(10);}
    
       size_t dupesRemoved = 0;
       ssize_t runStartIdx = -1;
       for (size_t i=0; i<indexSizeItems; i++)
       {
          SizeTPair & curEntry = index[i];
    
          // swap to put the line number in [0] and the hash in [1], since in the future
          // we will want to sort by line number and this will make it easier to do that.
          std::swap(curEntry[0], curEntry[1]);
    
          const size_t curByteOffset = curEntry[0];
          const size_t curHash       = curEntry[1];
    
          if (runStartIdx >= 0)
          {
             if (curHash != index[runStartIdx][1])
             {
                // A run of identical hashes started at (runStartIdx) and ended just before (i)
                if ((i-runStartIdx)>1)
                {
                   // Re-sort the index-entries-with-identical-hashes by the strings they represent
                   // so that we can find and remove any duplicate-strings easily.  (We have to do this
                   // because the same hash could, at least in principle, be associted with two different strings)
                   std::span<SizeTPair> duplicateHashesSpan(index+runStartIdx, index+i);
                   std::sort(std::begin(duplicateHashesSpan), std::end(duplicateHashesSpan), SortIndicesByStringCompareFunctor(inputFileMap));
                   std::string_view previousEntryTextLine;
                   for (size_t j=runStartIdx; j<i; j++)
                   {
                      const std::string_view curEntryTextLine = GetStringAtOffset(inputFileMap, index[j][0]);
                      if (curEntryTextLine == previousEntryTextLine)
                      {
                         index[j] = INVALID_INDEX_ENTRY;
                         dupesRemoved++;
                      }
                      previousEntryTextLine = curEntryTextLine;
                   }
                }
                runStartIdx = i;
             }
          }
          else runStartIdx = i;
       }
    
       std::cout << "Step 4:  Re-sort the index file [" << indexFileName << "] by byte-offset in preparation for generating output" << std::endl;
    
       // Step 4.  Re-sort the index file by byte-offset (note that each line's byte-offset is stored
       //          as the first entry in its SizeTPair now!)
       std::sort(std::begin(indexSpan), std::end(indexSpan));
    
       std::cout << "Step 5:  Write output file [" << bigOutputFileName << "]" << std::endl;
    
       // Step 5.  Read through the big text file one more time, and
       // write out only those lines that still exist in the index file
       std::ofstream outputFile;
       outputFile.open(bigOutputFileName);
       for (size_t i=0; i<indexSizeItems; i++)
       {
          const SizeTPair & curEntry = index[i];
          if (curEntry == INVALID_INDEX_ENTRY) break;  // these will all have been sorted to the end so we can stop now
                                          else outputFile << GetStringAtOffset(inputFileMap, curEntry[0]) << std::endl;
       }
       outputFile.close();
    
       // Step 6.  Clean up our mess and exit
       std::cout << "Step 6:  Delete index file and exit" << std::endl;
       close(inputFD);
       close(indexFD);
       remove(indexFileName);
    
       std::cout << "Final result:  Out of " << (indexSizeItems-1) << " lines read, " << dupesRemoved << " duplicate lines detected and removed. " << std::endl;
       return 0;
    }
    

    【讨论】:

      【解决方案4】:

      此代码逐行读取输入文件,仅将字符串的哈希值存储在内存中。如果之前没有看到该行,它将结果写入输出文件。如果之前看到过这条线,它不会做任何事情。

      它使用sparsepp 来减少内存占用。

      输入数据:

      • 12 GB 文件大小
      • ~197.000.000 不同的行
      • 行长 < 120 个字符

      建造:

      • C++20
      • 发布版本
      • 不在 Visual Studio 中运行(未附加调试器)

      加工:

      • AMD锐龙2700X
      • 32 GB 内存
      • 希捷固态硬盘
      • 190 秒
      • 954 MB 虚拟内存峰值

      这够好吗?我不能说,因为您的性能要求非常模糊,并且您没有提供适当的性能比较数据。这可能取决于您的机器、您的数据、您的 RAM 大小、您的硬盘速度……

      #include <chrono>
      #include <iostream>
      #include <fstream>
      #include <algorithm>
      #include <array>
      #include <cstring>
      #include <functional>
      #include <random>
      #include <string>
      #include "spp.h"
      int main()
      {
          std::chrono::steady_clock::time_point begin = std::chrono::steady_clock::now();
          std::ifstream input;
          std::ofstream output;
          input.open("10gb.txt");
          output.open("10gb_nodupes.txt");
          std::string inputline;
          spp::sparse_hash_map<size_t, size_t> hashes;
          while (std::getline(input, inputline))
          {
              std::size_t hash = std::hash<std::string>{}(inputline);
              if (!hashes.contains(hash))
              {
                  output << inputline << '\n';
                  hashes[hash]=0;
              }
          }
          input.close();
          output.close();
          std::chrono::steady_clock::time_point end = std::chrono::steady_clock::now();
          std::cout << "Time difference = " << std::chrono::duration_cast<std::chrono::seconds>(end - begin).count() << "[s]" << std::endl;
          std::cout << "Done";
      }
      

      【讨论】:

      • 看起来您可以改用 std::unordered_set 来节省内存。您只关心键,而不是 <key,value> 对。
      • 我不知道,程序在700mb file 上使用more than 3gb
      • @ThomasWeller 该文件由 python 程序 I sent you 生成。生成在 760 兆字节时手动停止
      • 程序是使用 MSVC 2022 编译器编译的。如果需要提供一些编译器选项,请告诉我哪些选项,因为在那里很难弄清楚。
      • 这种方法是不可靠的。它假设std::hash&lt;std::string&gt;{}(inputline); 总是为不同的inputline 值产生不同的值——这不是散列的工作方式。从统计上讲,您可能会因为 1) 更大的字大小(例如,具有 64 位 size_t 和哈希输出的 64 位应用程序,而不是 32 位应用程序/哈希值,有帮助),2)相对少量不同的inputline 值(根据经验,对于 64 位散列,保持在 2^32~=4b 键以下,对于 32 位散列,保持在 2^16=64k 键以下),以及 3) 真正强大的哈希函数。 Chase 使用 MSVC2022 => v. 弱哈希。
      猜你喜欢
      • 2011-01-28
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2011-09-17
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多