在.NET中处理大型CSV文件的最高效方法

10

请原谅我的新手问题,我需要一些指导,但是我找不到其他回答这个问题的方法。我有一个相当大的csv文件(约300k行),我需要确定对于给定的输入,是否有任何一行csv以该输入开头。我已经按字母顺序排序了csv,但是我不知道:

1)如何处理csv中的行-我应该将其读入列表/集合中,还是使用OLEDB,或者嵌入式数据库或其他一些方式?

2)如何从按字母顺序排序的列表中高效地查找某些内容(利用排序加快速度,而不是搜索整个列表)


3
如果您不想编写CSV解析器,可以尝试使用FileHelpers。请告诉我们这是否是一个特定的解决方案,或者您需要一个通用的读取器。因为现在您的问题有些未明确说明。请注意,我保留了原文中的术语"CSV parser"和"under-specified"以保持翻译准确无误。 - Robert Harvey
为什么不直接使用.NET框架中的TextFieldParser类,而要使用FileHelpers? - Steven Doggart
@StevenDoggart:正确处理CSV比大多数人想象的要困难得多。但是感谢提供的链接,我会看一下的。 - Robert Harvey
@RobertHarvey 是的,TextFieldParser 可以正确处理 CSV,包括多行单元格、转义等。 - Steven Doggart
什么是处理行?你需要拆分列还是只是一次处理一行? - paparazzo
显示剩余6条评论
10个回答

10

您没有提供足够的具体信息,无法给出明确的答案,但是...


如果CSV文件经常更改,则使用OLEDB,并根据您的输入更改SQL查询。

string sql = @"SELECT * FROM [" + fileName + "] WHERE Column1 LIKE 'blah%'";
using(OleDbConnection connection = new OleDbConnection(
          @"Provider=Microsoft.Jet.OLEDB.4.0;Data Source=" + fileDirectoryPath + 
          ";Extended Properties=\"Text;HDR=" + hasHeaderRow + "\""))

如果CSV文件不经常更改且您需要频繁运行"查询",请将其加载到内存中并在每次搜索时快速搜索。

如果您希望按列进行精确匹配,请使用字典,其中键是要匹配的列,值是行数据。

Dictionary<long, string> Rows = new Dictionar<long, string>();
...
if(Rows.ContainsKey(search)) ...

如果您想要进行部分匹配(如StartsWith),则需要有一个包含可搜索数据的数组(即第一列)和另一个列表或数组包含行数据。然后使用C#内置的二进制搜索http://msdn.microsoft.com/en-us/library/2cy9f6wb.aspx

string[] SortedSearchables = new string[];
List<string> SortedRows = new List<string>();
...
string result = null;
int foundIdx = Array.BinarySearch<string>(SortedSearchables, searchTerm);
if(foundIdx < 0) {
    foundIdx = ~foundIdx;
    if(foundIdx < SortedRows.Count && SortedSearchables[foundIdx].StartsWith(searchTerm)) {
        result = SortedRows[foundIdx];
    }
} else {
    result = SortedRows[foundIdx];
}

注意:代码是在浏览器窗口中编写的,可能存在语法错误,因为它没有经过测试。


谢谢提供这些代码片段,真的很有帮助!我会尝试不同的方法,看看哪个最快。 - user1981003

5
如果您可以将数据缓存到内存中,并且只需要在一个主键列上搜索列表,我建议将数据作为Dictionary对象存储在内存中。 Dictionary类将数据存储为哈希表中的键值对。您可以使用主键列作为字典中的键,然后将其余列用作字典中的值。在哈希表中按键查找项目通常非常快速。
例如,您可以像这样将数据加载到字典中:
Dictionary<string, string[]> data = new Dictionary<string, string[]>();
using (TextFieldParser parser = new TextFieldParser("C:\test.csv"))
{
    parser.TextFieldType = FieldType.Delimited;
    parser.SetDelimiters(",");
    while (!parser.EndOfData)
    {
        try
        {
            string[] fields = parser.ReadFields();
            data[fields[0]] = fields;
        }
        catch (MalformedLineException ex)
        {
            // ...
        }
    }
}

然后,您可以像这样获取任何项的数据:

string fields[] = data["key I'm looking for"];

1
如果你拥有你正在寻找的确切密钥,那么这将起作用。 - Robert Harvey
@RobertHarvey 正确。我只是根据原始问题中给出的要求回答的:“我需要确定对于给定的输入,是否有任何一行csv以该输入开头”。如果这是所有需要完成的工作,那么在我看来,使用字典是一个很好的解决方案。 - Steven Doggart
谢谢,我没有使用字典对象的经验,但我会研究一下。 - user1981003
@ebyrob 这是真的。我假设“以...开始”指的是特定数量的列,很可能只是第一列。 - Steven Doggart
鉴于“仅有一列匹配”的要求,这可能是最简单/最佳的解决方案。(唯一的问题可能是是否有必要始终使用比完整文件大小或完整键大小更少的内存) - user645280
显示剩余2条评论

5
如果您只在程序运行时执行一次,这看起来相当快速。 (根据下面的评论更新为使用StreamReader而不是FileStream)
    static string FindRecordBinary(string search, string fileName)
    {
        using (StreamReader fs = new StreamReader(fileName))
        {
            long min = 0; // TODO: What about header row?
            long max = fs.BaseStream.Length;
            while (min <= max)
            {
                long mid = (min + max) / 2;
                fs.BaseStream.Position = mid;

                fs.DiscardBufferedData();
                if (mid != 0) fs.ReadLine();
                string line = fs.ReadLine();
                if (line == null) { min = mid+1; continue; }

                int compareResult;
                if (line.Length > search.Length)
                    compareResult = String.Compare(
                        line, 0, search, 0, search.Length, false );
                else
                    compareResult = String.Compare(line, search);

                if (0 == compareResult) return line;
                else if (compareResult > 0) max = mid-1;
                else min = mid+1;
            }
        }
        return null;
    }

这段程序针对600,000个记录、50MB大小的测试文件仅需0.007秒运行,而文件扫描则平均需要半秒以上,具体时间取决于记录所在位置。(相差100倍)
显然,如果您多次运行该程序,则缓存将加速处理速度。进行部分缓存的一种简单方法是保持StreamReader打开并重复使用它,只需每次重置最小值和最大值即可。这样可以避免始终在内存中存储50MB的数据。 编辑: 添加了knaki02提出的建议修复。

@knaki02 哎呀!你说得对。这段代码肯定还没准备好投入生产。如果你有一个修复方案,而且不会增加行数(或分号),那就请编辑它吧!(我想在第一个 fs.ReadLine() 周围加上 if(mid==0) 可能会有所帮助,但我需要测试一下……) - user645280
如果不将 while (min + 1 < max) 更改为 while (min + 1 <= max),它就会失败。 - knaki02
@knaki02 你是说 while( min < max ) 吗? 随意更改,如果你感觉完成了但不确定,我会进行测试。 - user645280
@ebyrob 好的,我已经更改了你代码中的收敛性,因为我无法通过其他方式使其通过我的单元测试... - knaki02
@knaki02 我没有看到其他的编辑,只有第一个。注意:你可以在顶部添加这些行(不太优雅但有效)string line = fs.ReadLine(); if (line.StartsWith(search)) return line; 循环后稍微快一点的方法可能是 if(min == 0) // check first line. - user645280
显示剩余7条评论

3
假设CSV数据已排序 - 如果您可以将整个数据加载到内存中(如果您只需要对每行执行.StartsWith()操作),则可以使用二进制搜索算法来实现极快的搜索。

也许可以尝试类似以下的代码(未经测试!):

var csv = File.ReadAllLines(@"c:\file.csv").ToList();
var exists = csv.BinarySearch("StringToFind", new StartsWithComparer());

...

public class StartsWithComparer: IComparer<string>
{
    public int Compare(string x, string y)
    {
        if(x.StartsWith(y))
            return 0;
        else
            return x.CompareTo(y);
    }
}

这将涉及将整个内容加载到列表中,对吧?我不确定除了List<T>.BinarySearch方法之外,在.NET中如何进行二分搜索。 - user1981003
如果你不将文件读入内存,你最好至少流式传输50%的行 - 这听起来可能会慢得多(但显然使用的内存要少得多)。 - Dave Bish
@ebyrob 谢谢,我会尝试弄清楚如何将其转换为C#,我不懂Java。 - user1981003
@DaveBish 是的,但你总是可以从我之前的链接向后(或者足够早地开始扫描前2行以获取完整的一行):file.seek(mid); file.readLine(); line = file.readLine(); Sarah真是个聪明的女孩。 - user645280
这假设文件中的行是分布式的,与字节大小相关(文件的中间字节接近于文件的中间行)。我认为这是一个相当大的假设。 - Dave Bish
显示剩余2条评论

2

我为工作快速写下了这篇文章,可能还有改进的空间...

定义列数:

private enum CsvCols
{
    PupilReference = 0,
    PupilName = 1,
    PupilSurname = 2,
    PupilHouse = 3,
    PupilYear = 4,
}

定义模型

public class ImportModel
{
    public string PupilReference { get; set; }
    public string PupilName { get; set; }
    public string PupilSurname { get; set; }
    public string PupilHouse { get; set; }
    public string PupilYear { get; set; }
}

导入并填充模型列表:

  var rows = File.ReadLines(csvfilePath).Select(p => p.Split(',')).Skip(1).ToArray();

    var pupils = rows.Select(x => new ImportModel
    {
        PupilReference = x[(int) CsvCols.PupilReference],
        PupilName = x[(int) CsvCols.PupilName],
        PupilSurname = x[(int) CsvCols.PupilSurname],
        PupilHouse = x[(int) CsvCols.PupilHouse],
        PupilYear = x[(int) CsvCols.PupilYear],

    }).ToList();

返回一个强类型对象列表。

Split函数有没有办法改进,以处理带引号的值? - n4rzul

1

如果您的文件在内存中(例如,因为您进行了排序),并且将其保留为字符串数组(行),则可以使用简单的二分搜索方法。您可以从CodeReview上的此问题的代码开始,只需将比较器更改为使用string而不是int,并仅检查每行的开头。

如果您必须每次重新读取文件,因为它可能已更改或由另一个程序保存/排序,则最简单的算法是最好的:

using (var stream = File.OpenText(path))
{
    // Replace this with you comparison, CSV splitting
    if (stream.ReadLine().StartsWith("..."))
    {
        // The file contains the line with required input
    }
}

当然,您可以每次将整个文件读入内存(以使用LINQ或List<T>.BinarySearch()),但这远非最佳选择(即使您只需要检查几行内容,也会读取所有内容),而且文件本身甚至可能太大。

如果您确实需要更多内容,并且由于排序而没有将文件保存在内存中(但您应该根据要求对实际性能进行分析),则必须实现更好的搜索算法,例如Boyer-Moore算法


谢谢,我已经将文件排序,所以将其加载到内存中会是一个额外的步骤。我会查看CodeReview链接。 - user1981003

1

OP表示只需要按行搜索。

问题是是否要将这些行保存在内存中。

如果一行大小为1k,则需要300mb的内存。
如果一行大小为1兆,则需要300gb的内存。

Stream.Readline将具有较低的内存占用率。
由于已经排序,因此一旦大于目标,可以停止搜索。

如果将其保存在内存中,则可以使用简单的

List<String> 

使用LINQ可以工作。
虽然LINQ不够智能化,无法充分利用排序,但对于30万条数据仍然非常快速。

而BinarySearch则可以充分利用排序的优势。


0

试试免费的CSV Reader。不需要一遍又一遍地重新发明轮子 ;)

1)如果您不需要存储结果,只需迭代CSV - 处理每行并忘记它。如果您需要一遍又一遍地处理所有行,请将它们存储在List或Dictionary中(当然要有一个好的键)

2)尝试使用通用扩展方法,如下所示

var list = new List<string>() { "a", "b", "c" };
string oneA = list.FirstOrDefault(entry => !string.IsNullOrEmpty(entry) && entry.ToLowerInvariant().StartsWidth("a"));
IEnumerable<string> allAs = list.Where(entry => !string.IsNullOrEmpty(entry) && entry.ToLowerInvariant().StartsWidth("a"));

0

以下是我的VB.net代码。它用于引号限定的CSV,如果是普通的CSV,请将Let n = P.Split(New Char() {""","""})更改为Let n = P.Split(New Char() {","})

Dim path as String = "C:\linqpad\Patient.txt"
Dim pat = System.IO.File.ReadAllLines(path)
Dim Patz = From P in pat _
    Let n = P.Split(New Char() {""","""}) _
    Order by n(5) _
    Select New With {
        .Doc =n(1), _
        .Loc = n(3), _
        .Chart = n(5), _
        .PatientID= n(31), _
        .Title = n(13), _
        .FirstName = n(9), _
        .MiddleName = n(11), _
        .LastName = n(7), 
        .StatusID = n(41) _
        }
Patz.dump

0

通常我会建议找到一个专门的CSV解析器(例如thisthis)。然而,我注意到你问题中的这一行:

我需要确定对于给定的输入,是否有任何一行csv以该输入开头。

这告诉我,在确定这个之前,计算机花费时间解析CSV数据是浪费时间的。你只需要编写代码来简单地匹配文本,就可以像任何其他东西一样通过字符串比较来完成。

此外,你提到数据已经排序。这应该能够极大地加快速度...但你需要知道,为了利用这一点,你需要编写自己的代码,在低级文件流上进行寻址调用。这将是你最好的表现结果,但也将需要最初的工作和维护。

我建议采用工程化方法,设定一个性能目标,构建相对简单的东西,并根据该目标测量结果。特别是,从我上面发布的第二个链接开始。那里的CSV阅读器每次只会将一条记录加载到内存中,因此它应该表现得相当不错,并且很容易入手。构建使用该阅读器的东西,并测量结果。如果达到了您的目标,请停止。

如果没有达到您的目标,请改编来自链接的代码,以便在读取每行之前进行字符串比较(在烦扰解析csv数据之前),并仅对匹配的行执行解析csv的工作。这应该会更好地表现,但只有在第一种选择未达到您的目标时才执行此操作。准备好后,再次测量性能。

最后,如果您仍然无法达到性能目标,我们就进入了编写低级代码的领域,使用seek调用在文件流上执行二进制搜索。这可能是您在性能方面能够做到的最好的,但编写起来会非常混乱和容易出错,因此只有在绝对无法通过前几步达到目标时才需要这样做。

记住,性能是一项功能,就像任何其他功能一样,您需要评估如何相对于真实设计目标构建该功能。 "尽可能快" 不是一个合理的设计目标。类似于“在0.25秒内响应用户搜索”这样的目标才是真正的设计目标,如果更简单但速度较慢的代码仍然可以满足该目标,那么您需要停止。


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