I know it's a bit late for an answer to this question, but just for fun I wrote an implementation that I think is quite memory-efficient while still being reasonably performant.
In particular, this solution runs in O(N*log(N))
time and uses (on my machine) just 360 kilobytes(!) of heap memory while de-duping a 100,000,000 line (5 gigabyte) text file that contains 99,990,000 randomly-arranged duplicate lines, and finishes in 6 minutes, 32 seconds.
Of course it does cheat a bit, in that it writes out a temporary index file to disk (the index contains a hash value for each line in the input file, as well as that line's position within the input file). The index file needs 16 bytes for each text line, so it came out to ~1.4GB in my test.
To do the de-duplication, the program mmap()
's the index file into RAM, sorts its contents by hash code, then scans the index and invalidates any now-adjacent entries with the same hash code that refer to the same string in the input file.
After that it, re-sorts the index file by byte-offset, and then iterates through the index one more time to generate the de-duped output file.
Output from my test run (on a 2018 Intel Mac Mini) is as follows:
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
Source code is below (I compiled it with g++ -std=c++20 -O3 ./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;
}