比较巨大的ASCII文件

6
我在一家公司工作,负责处理各种数据库的ETL工作。我被要求在客户机上为两个完整的历史数据集创建一个补丁,然后将其发送到我们的服务器。这个补丁需要是程序化的,以便可以从我们的软件中调用。
这些数据集是简单的文本文件。我们在客户端系统上运行提取软件来执行提取操作。提取文件的大小范围为3GB+。我已经使用Microsoft的FC.exe实现了一个解决方案,但它有限制。
我正在使用FC来生成比较文件,然后在我们这边使用perl解析它,以提取已删除/更新的记录和已添加的记录。
只要文本行不超过128个字符,FC就能完美地为我工作。当发生这种情况时,输出会放到比较文件的下一行,因此看起来像是一个已添加/已删除的记录。我知道我可能可以预处理文件,但这将增加大量时间,可能会失去目的。
我尝试使用diffutils,但它抱怨文件太大。
我还试着用一些c#代码来自己实现补丁过程。对于小文件,这个方法很好用,但处理大文件时效率非常低(在2.8 GB的提取上进行了测试)。
有没有好的命令行工具或c#库可以用来创建这个补丁文件?如果没有,我是否可以使用算法来自己实现呢?请记住,记录可能会被更新、添加和删除。
a
b
c
d
e
1
f
2
5

New.txt

a
3
b
c
4
d
e
1
f
g

预期输出将是:
3 added
4 added
2 removed
g added
5 removed

1
你可以尝试使用GNUWin32版本的diff.exe。虽然我从未在如此大的文件上使用过它,但它可能有效。至于C#解决方案,你看过这个吗:链接 - Brian.D.Myers
1
经过一些初步测试,似乎您想要的在C#中是相当可行的;如果您更好地解释了确切的条件,我可以尝试提供简化的代码来证明这一点。例如:在补丁过程中大致执行哪些操作(+最长时间预期)? - varocarbas
1
@rbedger:你说得对。我一开始想的是“按行号访问”,但这并不容易实现。你可以通过进行单次遍历来进行优化(差异按递增行号排序),但这仍然需要对数据进行额外的一次遍历 :/ - jods
1
你的输出让我感到困惑:你怎么知道在“g被添加”的行之前已经删除了2?难道2不可能就在New.txt中紧跟着g吗? - D.R.
1
文件中的行顺序是否很重要?可以调整提取过程以按特定顺序输出记录吗?并且,您如何区分新记录和修改后的记录?记录中是否有可以用作标识记录身份的关键部分? - Olaf
显示剩余10条评论
2个回答

1
这里有一个相当有效的解决方案 - 我认为它大致是O(n),但这取决于添加和删除的分布。内存消耗非常低,但也取决于连续添加和删除的数量。
限制:
1.该算法不会保留补丁行与原始文件中的顺序相同;如果这很重要,则可以使用Dictionary来跟踪添加和删除的行,其中键是该行,值是原始行号,而不是使用HashSet。 2.目标(“新”)文件必须与源(“旧”)文件相似。具体来说,所有未更改的行必须按相同顺序出现在源和目标中。如果不满足此条件,算法将表现不佳。 3.每行必须对于其附近的行是唯一的,“附近”的意思是在源和目标之间未更改的最近行之间。如果不满足此条件,算法将错过更改。 4.此实现不考虑修改后的行。我认为您可以通过将==比较替换为用于检测两行是否为“相同”行的任何操作,然后将它们写入到补丁中,如果它们是内容发生更改的“相同”行,则可以添加该功能。
该算法使用一对“添加”和“删除”缓冲区来跟踪在运行过程中可能添加和删除的行。当文件之间的行不匹配时,这些行会被暂时标记为“添加”或“删除”。当在其中一个文件中找到了一个被标记为暂时标记的行(如果在目标文件中找到了一个“删除”行,或者在源文件中找到了一个“添加”行),这是一个信号,表明所有在另一个缓冲区中的行都属于那里,因此将刷新另一个缓冲区到补丁文件中,然后读取器在找到匹配行的文件中向前移动一行。
例如:
 
源     目标    添加    删除
A-------A      _       _
B-------X      +X      +B
C-------B      刷新 X -B
D--\  \-C      _       _
E-\ \---E      +E      +D
F  \----F      -E      刷新 D
以下是代码:
public void Diff(
    string sourcePath,
    string targetPath,
    string patchPath,
    string addedSuffix,
    string removedSuffix)

{
    using(var sourceReader = new StreamReader(sourcePath))
    using(var targetReader = new StreamReader(targetPath))
    using(var patchWriter = new StreamWriter(patchPath, append:false))
    {   
        var sourceLine = sourceReader.ReadLine();
        var targetLine = targetReader.ReadLine();

        var added = new HashSet<string>();
        var removed = new HashSet<string>();

        do{
            if(sourceLine == targetLine)
            {   
                sourceLine = sourceReader.ReadLine();
                targetLine = targetReader.ReadLine();
            }
            else
            {
                if(removed.Contains(targetLine))
                {
                    // Found targetLine in tentatively removed lines, so it wasn't actually removed.
                    removed.Remove(targetLine);
                    // Since we found something we thought had been removed, we know that all tentatively added lines actually are new.
                    Flush(patchWriter, added, addedSuffix);             
                    added.Clear();

                    targetLine = targetReader.ReadLine();               
                } 
                else if(added.Contains(sourceLine))
                {
                    // Found sourceLine in tentatively added lines, so it wasn't actually added.
                    added.Remove(sourceLine);
                    // We found something we thought had been added, so all candidates for removal should actually be removed.
                    Flush(patchWriter,removed, removedSuffix);
                    removed.Clear();

                    sourceLine = sourceReader.ReadLine();               
                }
                else
                {
                    // Source and target don't match, so we assume that the source was removed and the target was added.
                    // If we're wrong, we'll clean it up when we come across the line later on.
                    removed.Add(sourceLine);
                    added.Add(targetLine);
                    sourceLine = sourceReader.ReadLine();               
                    targetLine = targetReader.ReadLine();               
                }       
            }   
        } while(sourceLine != null || targetLine != null); 

        Flush(patchWriter, added, addedSuffix);
        Flush(patchWriter, removed, removedSuffix);
    }
}

public void Flush(StreamWriter writer, IEnumerable<string> lines, string suffix)
{
    foreach (var line in lines.Where (l => l != null))
    {
        writer.WriteLine("{0} {1}", line.Trim(), suffix);
    }
}

这是我用来生成测试文件的一些代码:

var path = /* path */;
var sourcePath = Path.Combine(path, "source.txt");
var targetPath = Path.Combine(path, "target.txt");
var expectedPath = Path.Combine(path, "expected.txt");
var rnd = new Random(10);

using(var sourceWriter = new StreamWriter(sourcePath))
using(var targetWriter = new StreamWriter(targetPath))
using(var expectedWriter = new StreamWriter(expectedPath))
{
    var limit = 10.0 * 100000;
    for (int i = 0; i < limit; i++)
    {
        if(i % 10000 == 0) Console.Write("{0:P0} ...", i / limit);
        var guid = Guid.NewGuid().ToString();
        var r = rnd.Next(0,10);
        var removed = 3;
        var added = 6;
        if(r >= 0 && r < removed)
        {
            sourceWriter.WriteLine(guid);
            expectedWriter.WriteLine(guid + " 0");
        }
        else if(r >= removed && r < added)
        {
            targetWriter.WriteLine(guid);
            expectedWriter.WriteLine(guid + " 1");
        }
        else if(r >= added)
        {   
            sourceWriter.WriteLine(guid);
            targetWriter.WriteLine(guid);           
        }
    }
}

看到任何错误或问题了吗?这是你要找的内容吗?


这就是我所问的修改记录,因为真正的问题是检测它们(新增和删除很容易)。只是一句评论,使用哈希(或涉及哈希表/字典等技术)是不好的,因为在包含大量行的大文件中会出现冲突。 - lontivero
lontivero,哈希集合永远不会比连续添加/删除行的最长字符串更大,因此除非有巨大的变化块(即使如此,System.String可能具有相当平衡的哈希算法),否则这不应该是一个问题。我已经更新了我的答案以注意到更新检测要求,在发布答案之前我没有看到它。 - Steve Ruble
我写了一个非常相似的算法,但是使用了带有字符串哈希/字符串对的字典,当测试4GB文件时总是失败(作为证明,我在我的西班牙博客上发布了它:http://geeks.ms/blogs/lontivero/archive/2013/07/23/string-gethashcode-colisiones.aspx),这就是为什么我说即使System.String.GetHashCode非常平衡,它也是一个Int32值(最多2^32个哈希),你有很高的概率会发生冲突。这个评论只是为了分享我的警告,仅此而已。 - lontivero
@lotivero,MSDN 关于 GetHashCode 的指导特别警告不要将从 GetHashCode 返回的值用作键集合中的键。GetHashCode 仅供哈希使用的类型(如 HashSet 和 Dictionary)内部使用,而非用于键生成。http://msdn.microsoft.com/en-us/library/system.object.gethashcode.aspx - Steve Ruble
这非常适合我的需求。感谢您的帮助,也感谢您提供格式良好的源代码! - rbedger

0

嗯,你正在比较两个文本文件,每个文件都有条目,这些条目不一定按任何顺序排列,我期望一个条目具有特定的格式。如果我理解正确,你真正拥有的是这样的东西: *表示条目的开始 @表示条目的结束 所以 OLD.TXT *a@*b@*c@等等... 一个简单的“算法”可以是: 1)复制NEW,称其为ADDED 2)从OLD获取条目 3.0)扫描ADDED以查找该条目。如果存在,则将条目保存在名为STILLEXISTS的文件中,并从ADDED文件中删除该条目 3.1)如果条目不在ADDED中,则保存在名为DELETED的文件中,并获取下一个条目从OLD中 4)当此过程结束时,您将拥有3个文件,每个文件都有添加、删除和奖励“仍然存在”的文件,所有这些都在一次通过中;) 希望我理解得对,这可以帮助你。


使用您提出的方法,由于您不知道任何一侧的更改量,因此您最终将不断扫描文件。这可能有效,但效率非常低下。 - rbedger
是的,这不是很优雅,但它会起作用 :) 此外,第二个文件每次处理都会变小,使得解析每个新条目的速度线性更快。如果您有太多这些巨大的文件,并且 Steve Ruble 方法不适用于您的数据,您可以有两个进程同时工作,一个从开始,另一个从结尾,节省时间?此外,如果您预计数据变化将少于未更改的内容,您可以交换扫描操作以仅保存新记录。那也会加快进程。程序员肯定能够改进基本思想 :) - user2605249

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