在Bash中从另一个较大的文件中查找文件行的最快方法

28

我有两个文件,file1.txtfile2.txtfile1.txt大约有14K行,file2.txt有20亿行。file1.txt每一行只有一个字段f1,而file2.txt有三个字段,分别是f1f3,由|分隔。

我想要找出所有file2.txtf2file1.txtf1匹配的行(如果不想花时间拆分file2.txt的值,则匹配任何位置的行)。

file1.txt(大约14K行,未排序):

foo1
foo2
...
bar1
bar2
...

file2.txt(约20亿行,未排序):

date1|foo1|number1
date2|foo2|number2
...
date1|bar1|number1
date2|bar2|number2
...

期望输出:

date1|foo1|number1
date2|foo2|number2
...
date1|bar1|number1
date2|bar2|number2
...

这是我尝试过的内容,但运行起来似乎需要几个小时:

fgrep -F -f file1.txt file2.txt > file.matched

我想知道是否有更好、更快的方式来使用常见的Unix命令或编写小脚本执行此操作。


3
请告诉我们以下解决方案的实际输入时间。 - Inian
3
数据是否以某种方式被排序?是否有基于C语言的解决方案可用? - Jose Ricardo Bustos M.
3
请查看 Fastest possible grep 的已接受解决方案。 - CAB
3
我有点困惑。你说你可以精确匹配第二个字段或在整行的任何位置进行匹配,但是第一个解决方案(仅匹配第二个字段)通常更具限制性。例如,给定单词“foo1”,第一个解决方案将不会与“foo1|date|number1”匹配,而第二个解决方案(在整行的任何位置进行匹配)将接受此作为匹配。那么你到底要使用哪种方法来解决你的问题? - Håkon Hægland
3
如果您能够上传 file1.txt 到例如pastebin.com并提供链接,则会很好。这样我们就可以在至少一些真实数据上尝试测试代码 :) - Håkon Hægland
显示剩余9条评论
16个回答

19

一个Perl的解决方案。[见下面Note]

对于第一个文件使用哈希。在逐行读取大文件时,通过正则表达式(捕获||之间第一个模式)或者 split (获取第二个单词) 提取字段,如果它是exists,则打印。它们可能在速度上略有不同(计时)。对于正则表达式中不需要进行defined检查,而对于 split,使用//(定义或),会短路。

use warnings;
use strict;

# If 'prog smallfile bigfile' is the preferred use
die "Usage: $0 smallfile bigfile\n"  if @ARGV != 2;
my ($smallfile, $bigfile) = @ARGV;

open my $fh, '<', $smallfile or die "Can't open $smallfile: $!";    
my %word = map { chomp; $_ => 1 } <$fh>;

open    $fh, '<', $bigfile or die "Can't open $bigfile: $!";       
while (<$fh>) 
{
    exists $word{ (/\|([^|]+)/)[0] } && print;  

    # Or
    #exists $word{ (split /\|/)[1] // '' } && print;
}
close $fh;

避免使用if分支和使用短路运算符会更快,但是差别非常小。在数十亿行的情况下,这些微调只会增加一点点时间。可能(或者可能不)逐行读取小文件比上面的列表上下文更快,但这应该是不明显的。 更新 写入STDOUT可以节省两个操作,而我反复计时发现与写入文件相比稍微快一点。这样的用法也与大多数UNIX工具保持一致,因此我改为写入STDOUT。接下来,没有必要进行exists测试,并且删除它可以节省一个操作。然而,我始终得到了稍微更好的运行时间,同时它也更好地传达了目的。总之,我还是保留了它。感谢ikegami的评论。 注意 经过我的基准测试,被注释掉的版本比其他版本快约50%。由于它们是不同的,一个查找第一个匹配项,另一个查找第二个字段,所以都提供了。我以这种方式保留它作为更通用的选择,因为问题本身就存在歧义。
一些比较(基准测试)[更新用于写入STDOUT,请参见上面的“更新”]HåkonHægland的答案中有一份详细分析,时间上运行大多数解决方案的一个运行。这里是另一种方法,对上述两种解决方案、OP自己的答案以及发布的fgrep进行基准测试,这是期望快速并用于问题和许多答案中的。
我以以下方式构建测试数据。为了使第二个字段匹配,在两个文件中使用随机单词生成大约如上所示长度的几行。然后,我用不会匹配的行填充此数据样本的“种子”,以模拟OP引用的大小和匹配之间的比率:小文件中有14K行,大文件中有1.3M行,产生126K个匹配项。然后,按照OP的方式重复编写这些样本,每次都List::Util::shuffle
下面比较的所有运行都针对上述文件大小产生106_120个匹配项(通过diff进行检查),因此匹配频率足够接近。它们通过使用my $res = timethese(60 ...)调用完整的程序进行基准测试。在v5.16上,cmpthese($res)的结果为:
        正则表达式 cfor split fgrep
正则表达式 1.05/s    --  -23%  -35%  -44%
cfor      1.36/s   30%    --  -16%  -28%
split     1.62/s   54%   19%    --  -14%
fgrep     1.89/s   80%   39%   17%    --

优化的C程序fgrep排名第一并不奇怪。"regex"落后于"split"可能是由于为小匹配启动引擎的开销,多次进行。这可能因Perl版本而异,考虑到不断发展的正则表达式引擎优化。我包括@codeforester("cfor")的答案,因为它被声称是最快的,并且其与非常相似的"split"之间的24%差距可能是由于分散的小低效率引起的(请参见下面这个答案的评论)。

尽管硬件和软件以及数据细节肯定会有变化,但这并没有太大的区别。我在不同的Perl和机器上运行了这个程序,显著的区别是,在某些情况下,有时fgrep确实快了一个数量级

OP对非常慢的fgrep的体验令人惊讶。考虑到他们引用的运行时间,比上面的时间慢一个数量级,我猜测是旧系统的问题。

尽管这完全基于I/O,但将其放在多个核心上可以获得并发性优势,并且我期望有很好的加速,最多几倍。


遗憾的是,该评论被删除了(?)。简而言之:不需要使用标量(成本),if分支,defined,printf而不是print(慢!)。这些对20亿行的运行时间很重要。


1
如果你将exists $word{ (/\|([^|]+)/)[0] }更改为$word{ (/\|([^|]+)/)[0] },就可以少一个操作符。没有理由不打印到STDOUT,这样可以再减少两个操作符。 - ikegami
1
@ikegami 感谢您的评论 - 它们涉及我不确定的事情。(1) 没有 exists,它会获取(并返回)该值。我认为 exists(以某种方式)避免了这种情况(虽然多了一个函数调用)?(2) 打印 'out' 并进行 shell 重定向没有开销吗?我过去比较(计时)过这些内容,从未发现直接写入文件更快,但我不理解它。 - zdim
1
@ikegami 不管价值如何,完整比较:1.61 /秒(使用“exists”写入文件),1.62 /秒(使用“exists”写入stdout,最佳),1.56 /秒(没有使用“exists”写入文件),1.57 /秒(没有使用“exists”写入stdout)。在这个系统上的一个基准测试中,似乎“exists”有所帮助(稍微一点),而直接写入比写入stdout要慢(我不理解这一点)。 - zdim
1
@zdim 我已经更新了我的测试套件,使用短路 if 并将输出打印到 STDOUT 而不是文件中,即我使用了这个:exists $word{ (/\|([^|]+)/)[0] } && print。但实际上在时间方面几乎没有任何区别。对于我的答案中的小数据集,我仍然得到了0.61秒的运行时间,对于大数据集,运行时间仍然约为6.55秒。请注意,脚本的输出仍然使用 shell 重定向保存到文件中(zdim.pl > out.txt)。 - Håkon Hægland
2
@HåkonHægland 好的,这很有道理--谢谢!正如文本中所解释的那样,它必须明显更好(但不是太多)。虽然基于正则表达式的方法落后于 split,我猜这是因为启动正则引擎对于所需的超级简单直接匹配来说是一个巨大的开销,而 split 所做的工作是直截了当和简单的(按空格分割并选择一个元素返回)。 - zdim
显示剩余4条评论

17
我已经尝试在这里提供一些方法的比较。
首先,我创建了一个Perl脚本来生成输入文件 file1.txtfile2.txt。为了比较一些解决方案,我确保 file1.txt 中的单词只能出现在 file2.txt 的第二个字段中。另外,为了能够使用由 @GeorgeVasiliou 提供的 join 解决方案,我对 file1.txtfile2.txt 进行了排序。目前,我基于仅有75个随机单词(从 https://www.randomlists.com/random-words 获取)生成了输入文件。这75个单词中只有5个用于 file1.txt,其余70个用于填充 file2.txt 中的字段。可能需要大幅增加单词数以获得真实结果(根据 OP 的说法,原始的 file1.txt 包含 14000 个单词)。在下面的测试中,我使用了一个包含1000000(1百万)行的 file2.txt。该脚本还生成了grep解决方案中 @BOC 所需的文件 regexp1.txtgen_input_files.pl:
#! /usr/bin/env perl
use feature qw(say);
use strict;
use warnings;

use Data::Printer;
use Getopt::Long;

GetOptions ("num_lines=i" => \my $nlines )
  or die("Error in command line arguments\n");

# Generated random words from site: https://www.randomlists.com/random-words
my $word_filename        = 'words.txt'; # 75 random words
my $num_match_words      = 5;
my $num_file2_lines      = $nlines || 1_000_000;
my $file2_words_per_line = 3;
my $file2_match_field_no = 2;
my $file1_filename       = 'file1.txt';
my $file2_filename       = 'file2.txt';
my $file1_regex_fn       = 'regexp1.txt';

say "generating $num_file2_lines lines..";
my ( $words1, $words2 ) = get_words( $word_filename, $num_match_words );

write_file1( $file1_filename, $words2 );
write_file2(
    $file2_filename, $words1, $words2, $num_file2_lines,
    $file2_words_per_line, $file2_match_field_no
);
write_BOC_regexp_file( $file1_regex_fn, $words2 );


sub write_BOC_regexp_file {
    my ( $fn, $words ) = @_;

    open( my $fh, '>', $fn ) or die "Could not open file '$fn': $!";
    print $fh '\\|' . (join "|", @$words) . '\\|';
    close $fh;
}

sub write_file2 {
    my ( $fn, $words1, $words2, $nlines, $words_per_line, $field_no ) = @_;

    my $nwords1 = scalar @$words1;
    my $nwords2 = scalar @$words2;
    my @lines;
    for (1..$nlines) {
        my @words_line;
        my $key;
        for (1..$words_per_line) {
            my $word;
            if ( $_ != $field_no ) {
                my $index = int (rand $nwords1);
                $word = @{ $words1 }[$index];
            }
            else {
                my $index = int (rand($nwords1 + $nwords2) );
                if ( $index < $nwords2 ) {
                    $word = @{ $words2 }[$index];
                }
                else {
                    $word =  @{ $words1 }[$index - $nwords2];
                }
                $key = $word;
            }
            push @words_line, $word;
        }
        push @lines, [$key, (join "|", @words_line)];
    }
    @lines = map { $_->[1] } sort { $a->[0] cmp $b->[0] } @lines; 
    open( my $fh, '>', $fn ) or die "Could not open file '$fn': $!";
    print $fh (join "\n", @lines);
    close $fh;
}

sub write_file1 {
    my ( $fn, $words ) = @_;

    open( my $fh, '>', $fn ) or die "Could not open file '$fn': $!";
    print $fh (join "\n", sort @$words);
    close $fh;
}

sub get_words {
    my ( $fn, $N ) = @_;

    open( my $fh, '<', $fn ) or die "Could not open file '$fn': $!";
    my @words = map {chomp $_; $_} <$fh>;
    close $fh;

    my @words1 = @words[$N..$#words];
    my @words2 = @words[0..($N - 1)];
    return ( \@words1, \@words2 );
}

接下来,我创建了一个子文件夹solutions并包含所有的测试用例:
$ tree solutions/
solutions/
├── BOC1
│   ├── out.txt
│   └── run.sh
├── BOC2
│   ├── out.txt
│   └── run.sh
├── codeforester
│   ├── out.txt
│   ├── run.pl
│   └── run.sh
[...]

这里的文件out.txt是每个解决方案grep的输出结果。脚本run.sh运行给定测试用例的解决方案。

不同解决方案的注释

  • BOC1 : First solution presented by @BOC

    grep -E -f regexp1.txt file2.txt
    
  • BOC2 : Second solution suggested by @BOC:

    LC_ALL=C grep -E -f regexp1.txt file2.txt
    
  • codeforester : Accepted Perl solution by @codeforester ( see source )

  • codeforester_orig : Original solution presented by @codeforested:

    fgrep -f file1.txt file2.txt
    
  • dawg : Python solution using dictionary and split line proposed by @dawg ( see source )

  • gregory1 : solution using Gnu Parallel suggested by @gregory

    parallel -k --pipepart -a file2.txt --block "$block_size" fgrep -F -f file1.txt
    

    See note below regarding how to choose $block_size.

  • hakon1 : Perl solution provided by @HåkonHægland (see source). This solution requires compilation of the c-extension the first time the code is run. It does not require recompilation when file1.txt or file2.txt changes. Note: The time used to compile the c-extension at the initial run is not included in the run times presented below.

  • ikegami : Solution using assembled regexp and using grep -P as given by @ikegami. Note: The assembled regexp was written to a separate file regexp_ikegami.txt, so the runtime of generating the regexp is not included in the comparison below. This is the code used:

    regexp=$(< "regexp_ikegami.txt")
    grep -P "$regexp" file2.txt
    
  • inian1 : First solution by @Inian using match()

    awk 'FNR==NR{
        hash[$1]; next
    }
    {
       for (i in hash) if (match($0,i)) {print; break}
    }' file1.txt FS='|' file2.txt
    
  • inian2 : Second solution by @Inian using index()

    awk 'FNR==NR{
        hash[$1]; next
    }
    {
       for (i in hash) if (index($0,i)) {print; break}
    }' file1.txt FS='|' file2.txt
    
  • inian3 : Third solution by @Inian checking only $2 field:

    awk 'FNR==NR{
        hash[$1]; next
    }
    $2 in hash' file1.txt FS='|' file2.txt
    
  • inian4 : 4th soultion by @Inian ( basically the same as codeforester_orig with LC_ALL ) :

    LC_ALL=C fgrep -f file1.txt file2.txt
    
  • inian5 : 5th solution by @Inian (same as inian1 but with LC_ALL ):

    LC_ALL=C awk 'FNR==NR{
        hash[$1]; next
    }
    {
       for (i in hash) if (match($0,i)) {print; break}
    }' file1.txt FS='|' file2.txt
    
  • inian6 : Same as inian3 but with LC_ALL=C. Thanks to @GeorgeVasiliou for suggestion.

  • jjoao : Compiled flex-generated C code as proposed by @JJoao (see source ). Note: Recompilation of the exectuable must be done each time file1.txt changes. The time used to compile the executable is not included in the run times presented below.

  • oliv : Python script provided by @oliv ( see source )

  • Vasiliou : Using join as suggested by @GeorgeVasiliou:

    join --nocheck-order -11 -22 -t'|' -o 2.1 2.2 2.3 file1.txt file2.txt
    
  • Vasiliou2 : Same as Vasiliou but with LC_ALL=C.

  • zdim : Using Perl script provided by @zdim ( see source ). Note: This uses the regexp search version ( instead of split line solution ).

  • zdim2 : Same as zdim except that it uses the split function instead of regexp search for the field in file2.txt.

注意事项

  1. I experimented a little bit with Gnu parallel (see gregory1 solution above) to determine the optimal block size for my CPU. I have 4 cores, and and currently it seems that the optimal choice is to devide the file (file2.txt) into 4 equal sized chunks, and run a single job on each of the 4 processors. More testing might be needed here. So for the first test case where file2.txt is 20M, I set $block_size to 5M ( see gregory1 solution above), whereas for the more realistic case presented below where file2.txt is 268M, a $block_size of 67M was used.

  2. The solutions BOC1, BOC2, codeforester_orig, inian1, inian4, inian5, and gregory1 all used loose matching. Meaning that the words from file1.txt did not have to match exactly in field #2 of file2.txt. A match anywhere on the line was accepted. Since this behavior made it more difficult to compare them with the other methods, some modified methods were also introduced. The first two methods called BOC1B and BOC2B used a modified regexp1.txt file. The lines in the original regexp1.txt where on the form \|foo1|foo2|...|fooN\| which would match the words at any field boundary. The modified file, regexp1b.txt, anchored the match to field #2 exclusively using the form ^[^|]*\|foo1|foo2|...|fooN\| instead.

    Then the rest of the modified methods codeforester_origB, inian1B, inian4B, inian5B, and gregory1B used a modified file1.txt. Instead of a literal word per line, the modified file file1b.txt used one regex per line on the form:

     ^[^|]*\|word1\|
     ^[^|]*\|word2\|
     ^[^|]*\|word3\|
     [...]
    

    and in addition, fgrep -f was replaced by grep -E -f for these methods.

运行测试

以下是用于运行所有测试的脚本。它使用Bash time 命令来记录每个脚本所花费的时间。请注意,time 命令返回三种不同的时间: real, user, 和 sys。起初我使用了user + sys,但在使用Gnu平行命令时发现这是不正确的,因此下面报告的时间现在是由time返回的real部分。有关time返回的不同时间的更多信息,请参见此问题

第一个测试使用包含5行的file1.txt和包含1000000行的file2.txt。以下是run_all.pl脚本的前52行,其余脚本可以在这里找到。

run_all.pl

#! /usr/bin/env perl

use feature qw(say);
use strict;
use warnings;

use Cwd;
use Getopt::Long;
use Data::Printer;
use FGB::Common;
use List::Util qw(max shuffle);
use Number::Bytes::Human qw(format_bytes);
use Sys::Info;

GetOptions (
    "verbose"       => \my $verbose,
    "check"         => \my $check,
    "single-case=s" => \my $case,
    "expected=i"    => \my $expected_no_lines,
) or die("Error in command line arguments\n");

my $test_dir    = 'solutions';
my $output_file = 'out.txt';
my $wc_expected = $expected_no_lines; # expected number of output lines

my $tests       = get_test_names( $test_dir, $case );

my $file2_size  = get_file2_size();
my $num_cpus    = Sys::Info->new()->device( CPU => () )->count;

chdir $test_dir;
my $cmd = 'run.sh';
my @times;
for my $case (@$tests) {
    my $savedir = getcwd();
    chdir $case;
    say "Running '$case'..";
    my $arg = get_cmd_args( $case, $file2_size, $num_cpus );
    my $output = `bash -c "{ time -p $cmd $arg; } 2>&1"`;
    my ($user, $sys, $real ) = get_run_times( $output );
    print_timings( $user, $sys, $real ) if $verbose;
    check_output_is_ok( $output_file, $wc_expected, $verbose, $check );
    print "\n" if $verbose;
    push @times, $real;
    #push @times, $user + $sys; # this is wrong when using Gnu parallel
    chdir $savedir;
}

say "Done.\n";

print_summary( $tests, \@times );

结果

这是运行测试的输出:

$  run_all.pl --verbose
Running 'inian3'..
..finished in 0.45 seconds ( user: 0.44, sys: 0.00 )
..no of output lines: 66711

Running 'inian2'..
..finished in 0.73 seconds ( user: 0.73, sys: 0.00 )
..no of output lines: 66711

Running 'Vasiliou'..
..finished in 0.09 seconds ( user: 0.08, sys: 0.00 )
..no of output lines: 66711

Running 'codeforester_orig'..
..finished in 0.05 seconds ( user: 0.05, sys: 0.00 )
..no of output lines: 66711

Running 'codeforester'..
..finished in 0.45 seconds ( user: 0.44, sys: 0.01 )
..no of output lines: 66711

[...]

概要

[@Vasiliou获得的结果显示在中间列。]

                               |Vasiliou
My Benchmark                   |Results  |   Details
-------------------------------|---------|----------------------
inian4             : 0.04s     |0.22s    | LC_ALL fgrep -f [loose] 
codeforester_orig  : 0.05s     |         | fgrep -f [loose]
Vasiliou2          : 0.06s     |0.16s    | [LC_ALL join [requires sorted files]]
BOC1               : 0.06s     |         | grep -E [loose] 
BOC2               : 0.07s     |15s      | LC_ALL grep -E [loose] 
BOC2B              : 0.07s     |         | LC_ALL grep -E [strict] 
inian4B            : 0.08s     |         | LC_ALL grep -E -f [strict] 
Vasiliou           : 0.08s     |0.23s    | [join [requires sorted files]] 
gregory1B          : 0.08s     |         | [parallel + grep -E -f [strict]] 
ikegami            : 0.1s      |         | grep -P 
gregory1           : 0.11s     |0.5s     | [parallel + fgrep -f [loose]] 
hakon1             : 0.14s     |         | [perl + c]
BOC1B              : 0.14s     |         | grep -E [strict] 
jjoao              : 0.21s     |         | [compiled flex generated c code] 
inian6             : 0.26s     |0.7s     | [LC_ALL awk + split+dict] 
codeforester_origB : 0.28s     |         | grep -E -f [strict] 
dawg               : 0.35s     |         | [python + split+dict] 
inian3             : 0.44s     |1.1s     | [awk + split+dict] 
zdim2              : 0.4s      |         | [perl + split+dict] 
codeforester       : 0.45s     |         | [perl + split+dict] 
oliv               : 0.5s      |         | [python + compiled regex + re.search()] 
zdim               : 0.61s     |         | [perl + regexp+dict] 
inian2             : 0.73s     |1.7s     | [awk + index($0,i)] 
inian5             : 18.12s    |         | [LC_ALL awk + match($0,i) [loose]] 
inian1             : 19.46s    |         | [awk + match($0,i) [loose]] 
inian5B            : 42.27s    |         | [LC_ALL awk + match($0,i) [strict]] 
inian1B            : 85.67s    |         | [awk + match($0,i) [strict]] 

Vasiliou Results : 2 X CPU Intel 2 Duo T6570 @ 2.10GHz - 2Gb RAM-Debian Testing 64bit- kernel 4.9.0.1 - no cpu freq scaling.

更现实的测试案例

接着我创建了一个更现实的案例,其中file1.txt有100个单词,而file2.txt则有1000万行(文件大小为268Mb)。我使用shuf -n1000 /usr/share/dict/american-english > words.txt/usr/share/dict/american-english的字典中提取了1000个随机单词,然后从中选出100个单词放入file1.txt中,并按照上述第一个测试案例所描述的方式构建了file2.txt。请注意,字典文件采用UTF-8编码,我从words.txt中删除了所有非ASCII字符。

然后我运行了测试,但排除了前一个案例中最慢的三种方法:inian1inian2inian5。以下是新的结果:

gregory1           : 0.86s     | [parallel + fgrep -f [loose]]
Vasiliou2          : 0.94s     | [LC_ALL join [requires sorted files]]
inian4B            : 1.12s     | LC_ALL grep -E -f [strict] 
BOC2B              : 1.13s     | LC_ALL grep -E [strict] 
BOC2               : 1.15s     | LC_ALL grep -E [loose] 
BOC1               : 1.18s     | grep -E [loose] 
ikegami            : 1.33s     | grep -P 
Vasiliou           : 1.37s     | [join [requires sorted files]]
hakon1             : 1.44s     | [perl + c]
inian4             : 2.18s     | LC_ALL fgrep -f [loose] 
codeforester_orig  : 2.2s      | fgrep -f [loose] 
inian6             : 2.82s     | [LC_ALL awk + split+dict] 
jjoao              : 3.09s     | [compiled flex generated c code] 
dawg               : 3.54s     | [python + split+dict] 
zdim2              : 4.21s     | [perl + split+dict]
codeforester       : 4.67s     | [perl + split+dict] 
inian3             : 5.52s     | [awk + split+dict] 
zdim               : 6.55s     | [perl + regexp+dict] 
gregory1B          : 45.36s    | [parallel + grep -E -f [strict]] 
oliv               : 60.35s    | [python + compiled regex + re.search()] 
BOC1B              : 74.71s    | grep -E [strict] 
codeforester_origB : 75.52s    | grep -E -f [strict] 

注意

基于 grep 的解决方案在查找整行匹配时可能会产生一些错误匹配:方法 codeforester_origBOC1BOC2gregory1inian4olivfile2.txt 中提取了 1,087,609 行,而其他方法从中正确提取了 997,993 行。

注意事项

  • 我在我的 Ubuntu 16.10 笔记本电脑上进行了测试(Intel Core i7-7500U CPU @ 2.70GHz)。

  • 整个基准研究可在此处找到。


3
测试不错。我很惊讶Join solution能够取得好的结果。同时请注意,在使用Join solution之前需要进行排序 - 请再次检查。此外,我认为你遗漏了codeforester所接受的解决方案(Perl代码),而且奇怪的是zdim Perl解决方案比grep慢得多,尽管OP声称相反(我们倾向于相信他 :))。 - George Vasiliou
5
确保您的输入包含您不想匹配的内容。例如,如果您只想在file2的第二个字段上执行完全字符串匹配,则应将foof.*o包含在file1中,以及将a|foobar|bfoo|a|b包含在file2中等等。编写工具以匹配您想要的内容总是很容易的,但是排除您不想要的内容则更加困难,因此真正测试解决方案的唯一方法是投入精力创建包括那些您可能想要但不应匹配的内容的测试用例(通常是部分、正则表达式或行中错误的部分)。 - Ed Morton
2
你基准测试的一半解决方案甚至都不能工作!以下无法处理 file1.txt foo1 file2.txt date1|foo12|number5 的解决方案有:BOC1、BOC2、codeforester_orig、gregory1、inian2、inian4、oliv。 - ikegami
3
这个话题汇集了很多信息,每次我在SE看到关于grep的问题时,我就会把人们引到这里......这些具有基准测试的解决方案应该成为一个“类grep”的维基! - George Vasiliou
2
parallel 的新版本会在您提供负数时计算块大小:--block-size -1 = 每个作业槽一个块,-2 = 每个作业槽两个块。 - Ole Tange
显示剩余18条评论

9

你尝试过使用 Awk 来加速吗:

awk 'FNR==NR{hash[$1]; next}{for (i in hash) if (match($0,i)) {print; break}}' file1.txt FS='|' file2.txt

(或者)根据Benjamin W.的评论建议,在Awk中使用index()函数

awk 'FNR==NR{hash[$1]; next}{for (i in hash) if (index($0,i)) {print; break}}' file1.txt FS='|' file2.txt

或者,像Ed Morton在评论中建议的那样,使用更直接的正则表达式匹配。

awk 'FNR==NR{hash[$1]; next}{for (i in hash) if ($0~i) {print; break}}' file1.txt FS='|' file2.txt

这就是你所需要的。我猜这种方法会更快,但对于包含数百万条目的文件并不确定。这里的问题在于匹配可能出现在任何一行中。如果它只出现在特定的列(例如,仅出现在$2中),那么可以采用更快的方法。

awk 'FNR==NR{hash[$1]; next}$2 in hash' file1.txt FS='|' file2.txt

您可以通过设置系统中的locale来加快速度。引用这个关于主题的Stéphane Chazelas的答案,您可以通过将LC_ALL=C传递给正在运行的本地命令来快速加速。

在任何基于GNU的系统上,默认值为locale

$ locale
LANG=en_US.UTF-8
LC_CTYPE="en_US.UTF-8"
LC_NUMERIC="en_US.UTF-8"
LC_TIME="en_US.UTF-8"
LC_COLLATE="en_US.UTF-8"
LC_MONETARY="en_US.UTF-8"
LC_MESSAGES="en_US.UTF-8"
LC_PAPER="en_US.UTF-8"
LC_NAME="en_US.UTF-8"
LC_ADDRESS="en_US.UTF-8"
LC_TELEPHONE="en_US.UTF-8"
LC_MEASUREMENT="en_US.UTF-8"
LC_IDENTIFICATION="en_US.UTF-8"
LC_ALL=

使用一个变量 LC_ALL,您可以一次性将所有的 LC_ 类型变量设置为指定的区域设置。

$ LC_ALL=C locale
LANG=en_US.UTF-8
LC_CTYPE="C"
LC_NUMERIC="C"
LC_TIME="C"
LC_COLLATE="C"
LC_MONETARY="C"
LC_MESSAGES="C"
LC_PAPER="C"
LC_NAME="C"
LC_ADDRESS="C"
LC_TELEPHONE="C"
LC_MEASUREMENT="C"
LC_IDENTIFICATION="C"       
LC_ALL=C

那么这对什么有影响呢?

简单来说,当使用 locale C 时,它会默认使用服务器基于 Unix/Linux 的语言 ASCII。基本上当你使用 grep 搜索时,默认的 locale 是国际化的并设置为 UTF-8,它可以表示 Unicode 字符集中的每个字符,以帮助显示世界上任何的书写系统,目前超过了 110,000 种独特字符,而 ASCII 中每个字符都是用单字节序列编码的,其字符集不超过 128 种独特字符。

因此,当在一个采用 UTF-8 字符集编码的文件中使用 grep 进行搜索时,需要将每个字符与数十万个独特字符之一进行匹配,但在 ASCII 中只需匹配 128 个字符,所以请使用 fgrep.

LC_ALL=C fgrep -F -f file1.txt file2.txt

同样的方法也可以应用于 Awk,因为它使用 match($0,i) 调用进行 regex 匹配,设置 C 语言环境可以加速字符串匹配。

LC_ALL=C awk 'FNR==NR{hash[$1]; next}{for (i in hash) if (match($0,i)) {print; break}}' file1.txt FS='|' file2.txt

2
关于语言环境的注释非常好。我不知道它是否有效(我想是),但仅仅考虑这个问题就值得了。 - linuxfan says Reinstate Monica
2
@codeforester:说实话,这个问题没有确定的答案。人们只能进行代码高尔夫比赛(使用最少资源的最佳答案),我已经尽力解决了它。作为 OP,您的负面评价并不是一个健康的方式,因为这里没有 100% 的解决方案。请感激那些花时间来回答您问题的人们。 - Inian
3
@Inian 的 awk 'FNR==NR{hash[$1]; next}$2 in hash' file1.txt FS='|' file2.txt 是进行 field2 完全字符串匹配的THE awk 解决方案。您所写的 match(i,$0) 应为 match($0,i),但这样做需要调用函数(并填充 RSTART 和 RLENGTH),而不是使用 $0~i 更好。 match()和index()版本在任何情况下都不会有用,因为 awk 正在将 file2 的每一行分割成字段,因此在整个行上进行正则表达式或字符串部分匹配时不能比 grepgrep -F 更快。 - Ed Morton
2
@EdMorton:一如既往地融入了您宝贵的评论。 - Inian
2
@codeforester:感谢您授予我赏金,希望我的答案对您有所帮助。 - Inian
显示剩余13条评论

6

假设:1. 您想在本地计算机上运行此搜索。2. 您拥有多个核心/处理器,以利用并行搜索的优势。

parallel --pipepart -a file2.txt --block 10M fgrep -F -f file1.txt

一些根据上下文需要进一步调整的细节: A. 使用 LANG=C 禁用 NLS(另一个答案中已经提到了这一点) B. 使用 -m 标志设置最大匹配次数。
注意:我猜测 file2 大约为 4GB,10M 的块大小可以接受,但您可能需要优化块大小以获得最快的运行速度。

如果文件较大:https://www.gnu.org/software/parallel/man.html#EXAMPLE:-Grepping-n-lines-for-m-regular-expressions - Ole Tange

6

一小段Perl代码解决了这个问题。 这是所采用的方法:

  • file1.txt的行存储在哈希中
  • 逐行读取file2.txt,解析并提取第二个字段
  • 检查提取的字段是否在哈希中;如果是,则打印该行。

以下是代码:

#!/usr/bin/perl -w

use strict;
if (scalar(@ARGV) != 2) {
  printf STDERR "Usage: fgrep.pl smallfile bigfile\n";
  exit(2);
}

my ($small_file, $big_file) = ($ARGV[0], $ARGV[1]);
my ($small_fp, $big_fp, %small_hash, $field);

open($small_fp, "<", $small_file) || die "Can't open $small_file: " . $!;
open($big_fp, "<", $big_file)     || die "Can't open $big_file: "   . $!;

# store contents of small file in a hash
while (<$small_fp>) {
  chomp;
  $small_hash{$_} = undef;
}
close($small_fp);

# loop through big file and find matches
while (<$big_fp>) {
  # no need for chomp
  $field = (split(/\|/, $_))[1];
  if (defined($field) && exists($small_hash{$field})) {
    printf("%s", $_);
  }
}

close($big_fp);
exit(0);

我在file1.txt中运行了上述脚本,其中包含14K行,在file2.txt中包含1.3M行。它在大约13秒钟内完成,并产生了126K个匹配项。以下是相同操作的time输出:


real    0m11.694s
user    0m11.507s
sys 0m0.174s

我运行了 @Inian 的 awk 代码:
awk 'FNR==NR{hash[$1]; next}{for (i in hash) if (match($0,i)) {print; break}}' file1.txt FS='|' file2.txt

相比Perl解决方案,它的速度慢得多,因为它针对file2.txt中的每一行循环了14K次 - 这真的很昂贵。在处理592K条file2.txt记录并生成40K匹配行后,它中止了。以下是花费的时间:

awk: illegal primary in regular expression 24/Nov/2016||592989 at 592989
 input record number 675280, file file2.txt
 source line number 1

real    55m5.539s
user    54m53.080s
sys 0m5.095s

使用@Inian的另一个awk解决方案,可以消除循环问题:
time awk -F '|' 'FNR==NR{hash[$1]; next}$2 in hash' file1.txt FS='|' file2.txt > awk1.out

real    0m39.966s
user    0m37.916s
sys 0m0.743s

time LC_ALL=C awk -F '|' 'FNR==NR{hash[$1]; next}$2 in hash' file1.txt FS='|' file2.txt > awk.out

real    0m41.057s
user    0m38.475s
sys 0m0.904s

awk 在这里表现非常出色,因为我们不需要编写一整个程序来完成它。

我也运行了 @oliv 的 Python 代码。它大约花费了 15 小时才完成任务,看起来产生了正确的结果。构建一个庞大的正则表达式并不像使用哈希查找那样高效。这里是 time 输出:

real    895m14.862s
user    806m59.219s
sys 1m12.147s

我尝试按照建议使用parallel,但是即使使用非常小的块大小,它仍然出现了fgrep: memory exhausted错误。


让我惊讶的是,fgrep完全不适合这种情况。22小时后我放弃了它,它只产生了大约100K个匹配项。我希望fgrep有一个选项可以强制保留-f file内容的哈希表,就像Perl代码一样。

我没有检查join方法,因为我不想增加文件排序的额外开销。此外,鉴于fgrep的性能较差,我不认为join比Perl代码更好。

感谢大家的关注和回复。


你的“fgrep: memory exhausted”让人感到惊讶;我知道你正在使用MBP,所以我假设你正在使用BSD grep版本。你可以尝试带有-F标志的gnu grep吗?(可以使用homebrew安装并使用gfgrep运行)。 - gregory
1
我相信当使用-f选项时,agrep会使用模式的哈希值。你可能想尝试一下这个功能;它也可以使用homebrew进行安装。 - gregory
1
  1. $fielddefined 应该是不必要的。如果确实需要,可以考虑关闭“未初始化”警告。 2) 如果您将 1 而不是 undef 分配为哈希值,则可以使用(略微)更快的 $small_hash{$field} 替换 exists($small_hash{$field})。 3) printf("%s", $_);print; 的较慢版本。
- ikegami
我意识到我的错误,没有测试@Inian优化的awk解决方案。刚刚更新了我的答案以包含它。 - codeforester

4
这个Perl脚本 (a) 生成一个正则表达式模式:
#!/usr/bin/perl

use strict;
use warnings;

use Regexp::Assemble qw( );

chomp( my @ids = <> );
my $ra = Regexp::Assemble->new();
$ra->add(quotemeta($_)) for @ids;
print("^[^|]*\\|(?:" . (re::regexp_pattern($ra->re()))[0] . ")\\|");

以下是如何使用它:

$ LC_ALL=C grep -P "$( a file1.txt )" file2.txt
date1|foo1|number1
date2|foo2|number2
date1|bar1|number1
date2|bar2|number2

请注意,该脚本使用了Regexp::Assemble,因此你可能需要安装它。
sudo su
cpan Regexp::Assemble

注意事项:

  • Unlike the solutions dubbed BOC1, BOC2, codeforester_orig, gregory1, inian2, inian4 and oliv, my solution correctly handles

    file1.txt
    foo1
    
    file2.txt
    date1|foo12|number5
    
  • Mine should be better than the similar solution by @BOC because the pattern is optimized to reduce backtracking. (Mine also works if there are more than three fields in file2.txt, whereas the linked solution can fail.)

  • I don't know how it compares to the split+dictionary solutions.


我理解 OP 的问题是:“file2.txt 的第二个字段应该等于 file1.txt 中的一个单词”。如果是这样,我们就有了匹配项。所以你是对的,很多解决方案可能会给出错误的结果。我在我的答案中也提到了这一点:对于我答案中的大型测试用例,许多方法确实给出了错误的匹配项。 - Håkon Hægland
也许我们应该将file1.txt更改为那些给出错误匹配的方法,例如^[^|]*\|foo1\|?然后,根据OP的假设规范,它们应该给出正确的结果(前提是将其命令更改为grep -E -f而不是使用fgrep)。这样,方法之间的比较就会变得不那么混乱 :) - Håkon Hægland
@Håkon Hægland,我很好奇为什么你在最近添加其他内容时没有将这个添加到你的基准测试中。是因为你没有R::A吗?我很好奇它与BOC的比较情况,即R::A的分组有多大帮助。 - ikegami

4
以下是使用 Perl 的解决方案,它使用 Inline::C 来加速在大文件中查找匹配字段的过程:
use strict;
use warnings;
use Inline C => './search.c';

my $smallfile = 'file1.txt';
my $bigfile   = 'file2.txt';

open my $fh, '<', $smallfile or die "Can't open $smallfile: $!";
my %word = map { chomp; $_ => 1 } <$fh>;
search( $bigfile, \%word );

搜索(search())子程序是使用纯C实现的,使用perlapi 在小文件字典 %words 中查找关键词:

search.c:

#include <stdio.h>
#include <sys/stat.h> 
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>


#define BLOCK_SIZE 8192       /* how much to read from file each time */
static char read_buf[BLOCK_SIZE + 1];

/*  reads a block from file, returns -1 on error, 0 on EOF, 
     else returns chars read, pointer to buf, and pointer to end of buf  */
size_t read_block( int fd, char **ret_buf, char **end_buf ) {
    int ret;
    char *buf = read_buf;
    size_t len = BLOCK_SIZE;
    while (len != 0 && (ret = read(fd, buf, len)) != 0) {
        if (ret == -1) {
            if (errno == EINTR)
                continue;
            perror( "read" );
            return ret;
        }
        len -= ret;
        buf += ret;
    }
    *end_buf = buf;
    *ret_buf = read_buf;
    return (size_t) (*end_buf - *ret_buf);
}

/* updates the line buffer with the char pointed to by cur,
   also updates cur
    */
int update_line_buffer( char **cur, char **line, size_t *llen, size_t max_line_len ) {
    if ( *llen > max_line_len ) {
        fprintf( stderr, "Too long line. Maximimum allowed line length is %ld\n",
                 max_line_len );
        return 0;
    }
    **line = **cur;
    (*line)++;
    (*llen)++;
    (*cur)++; 
    return 1;
}


/*    search for first pipe on a line (or next line if this is empty),
    assume line ptr points to beginning of line buffer.
  return 1 on success
  Return 0 if pipe could not be found for some reason, or if 
    line buffer length was exceeded  */
int search_field_start(
    int fd, char **cur, char **end_buf, char **line, size_t *llen, size_t max_line_len
) {
    char *line_start = *line;

    while (1) {
        if ( *cur >= *end_buf ) {
            size_t res = read_block( fd, cur, end_buf );        
            if (res <= 0) return 0;
        }
        if ( **cur == '|' ) break;
        /* Currently we just ignore malformed lines ( lines that do not have a pipe,
           and empty lines in the input */
        if ( **cur == '\n' ) {
            *line = line_start;
            *llen = 0;
            (*cur)++;
        }
        else {
            if (! update_line_buffer( cur, line, llen, max_line_len ) ) return 0;
        }
    }
    return 1;
}

/* assume cur points at starting pipe of field
  return -1 on read error, 
  return 0 if field len was too large for buffer or line buffer length exceed,
  else return 1
  and field, and  length of field
 */
int copy_field(
    int fd, char **cur, char **end_buf, char *field,
    size_t *flen, char **line, size_t *llen, size_t max_field_len, size_t max_line_len
) {
    *flen = 0;
    while( 1 ) {
        if (! update_line_buffer( cur, line, llen, max_line_len ) ) return 0;
        if ( *cur >= *end_buf ) {
            size_t res = read_block( fd, cur, end_buf );        
            if (res <= 0) return -1;
        }
        if ( **cur == '|' ) break;
        if ( *flen > max_field_len ) {
            printf( "Field width too large. Maximum allowed field width: %ld\n",
                    max_field_len );
            return 0;
        }
        *field++ = **cur;
        (*flen)++;
    }
    /* It is really not necessary to null-terminate the field 
       since we return length of field and also field could 
       contain internal null characters as well
    */
    //*field = '\0';
    return 1;
}

/* search to beginning of next line,
  return 0 on error,
  else return 1 */
int search_eol(
    int fd, char **cur, char **end_buf, char **line, size_t *llen, size_t max_line_len)
{
    while (1) {
        if ( *cur >= *end_buf ) {
            size_t res = read_block( fd, cur, end_buf );        
            if (res <= 0) return 0;
        }
        if ( !update_line_buffer( cur, line, llen, max_line_len ) ) return 0;
        if ( *(*cur-1) == '\n' ) {
            break;
        }
    }
    //**line = '\0'; // not necessary
    return 1;
}

#define MAX_FIELD_LEN 80  /* max number of characters allowed in a field  */
#define MAX_LINE_LEN 80   /* max number of characters allowed on a line */

/* 
   Get next field ( i.e. field #2 on a line). Fields are
   separated by pipes '|' in the input file.
   Also get the line of the field.
   Return 0 on error,
   on success: Move internal pointer to beginning of next line
     return 1 and the field.
 */
size_t get_field_and_line_fast(
    int fd, char *field, size_t *flen, char *line, size_t *llen
) {
    static char *cur = NULL;
    static char *end_buf = NULL;

    size_t res;
    if (cur == NULL) {
        res = read_block( fd, &cur, &end_buf );        
        if ( res <= 0 ) return 0;
    }
    *llen = 0;
    if ( !search_field_start( fd, &cur, &end_buf, &line, llen, MAX_LINE_LEN )) return 0;
    if ( (res = copy_field(
        fd, &cur, &end_buf, field, flen, &line, llen, MAX_FIELD_LEN, MAX_LINE_LEN
    ) ) <= 0)
        return 0;
    if ( !search_eol( fd, &cur, &end_buf, &line, llen, MAX_LINE_LEN ) ) return 0;
    return 1;
}

void search( char *filename, SV *href) 
{
    if( !SvROK( href ) || ( SvTYPE( SvRV( href ) ) != SVt_PVHV ) ) {
        croak( "Not a hash reference" );
    }

    int fd = open (filename, O_RDONLY);
    if (fd == -1) {
        croak( "Could not open file '%s'", filename );
    }
    char field[MAX_FIELD_LEN+1];
    char line[MAX_LINE_LEN+1];
    size_t flen, llen;
    HV *hash = (HV *)SvRV( href );
    while ( get_field_and_line_fast( fd, field, &flen, line, &llen ) ) {
        if( hv_exists( hash, field, flen ) )
            fwrite( line, sizeof(char), llen, stdout);
    }
    if (close(fd) == -1)
        croak( "Close failed" );

}

测试表明,它的速度比这里提供的最快纯Perl方案(请参见我的其他答案中的zdim2方法)快大约3倍。


3

这里有一个使用集合的Python解决方案——在概念上与Perl键值哈希或awk数组大致相当。

#!/usr/bin/python

import sys 

with open(sys.argv[1]) as f:
    tgt={e.rstrip() for e in f}

with open(sys.argv[2]) as f:
    for line in f:
        cells=line.split("|")
        if cells[1] in tgt:
            print line.rstrip()

当我在大小类似的文件上运行这个程序时,它大约需要8秒钟的时间。与以下速度相同:
$ awk 'FNR==NR{arr[$1]; next} $2 in arr{print $0}' FS="|" /tmp/f1 /tmp/f2 

这里的Python和awk解决方案都只是完全字符串匹配,而不是部分正则表达式样式匹配。

由于awk解决方案快速且符合POSIX标准,因此这是更好的答案。


2

你可以尝试使用join吗?但是必须先对文件进行排序...

$ cat d.txt
bar1
bar2
foo1
foo2

$ cat e.txt
date1|bar1|number1
date2|bar2|number2
date3|bar3|number3
date1|foo1|number1
date2|foo2|number2
date3|foo3|number3

$ join --nocheck-order -11 -22 -t'|' -o 2.1 2.2 2.3 d.txt e.txt
date1|bar1|number1
date2|bar2|number2
date1|foo1|number1
date2|foo2|number2

小更新:
在join之前使用LC_ALL=C,可以加快速度,如Håkon Hægland的基准测试所示。

PS1:我怀疑join是否比grep -f更快...


@codeforester 显然,这个解决方案只适用于您之前的问题版本:在文件1中匹配模式以匹配文件2的字段2。即使我们仅比较第二个字段,看到时间结果也很有趣。 - George Vasiliou

2
尽管这个帖子已经结束了,但是在这篇文章中收集了两个文件之间所有类似于grep的方法,为什么不加入这个awk替代方案呢?它类似于(甚至更好)赢得奖励的Inian的awk解决方案。
awk 'NR==FNR{a[$0]=1;next}a[$2]' patterns.txt FS="|" datafile.txt >matches.txt # For matches restricted on Field2 of datafile

这相当于印度 awk 中的 $2 in hash 解决方案,但由于我们不要求 awk 检查整个哈希数组是否包含文件2的 $2,所以它甚至可能更快 - 我们只是检查 a[$2] 是否有值。

在读取第一个模式文件时,除了创建哈希数组外,我们还分配了一个值。

如果在模式文件中之前已经找到了 datafile 的 $2,那么 a[$2] 将具有一个值,因此将被打印,因为它不为空。

如果 a[$2] 返回无值(null),则这被翻译为 false => 不打印。

扩展以匹配 datafile 的任意三个字段:

awk 'NR==FNR{a[$0]=1;next}(a[$1] || a[$2] || a[$3])' patterns.txt FS="|" datafile.txt >matches.txt. #Printed if any of the three fields of datafile match pattern.

在这两种情况下,在awk前应用LC_ALL=C似乎可以加快速度。
PS1:当然,这个解决方案也有所有awk解决方案的缺点。它不是一个模式匹配。它是两个文件之间的直接/固定匹配,就像这里的大多数解决方案一样。
PS2:在我的机器上使用Håkon Hægland的小基准文件进行基准测试,与awk 'FNR==NR{hash[$1]; next}$2 in hash' file1.txt FS='|' file2.txt相比,我得到了约20%的更好性能。

@HåkonHægland - 你介意也用你的小文件和大文件来测试一下这个解决方案吗? - George Vasiliou
嗨,George!感谢您的输入。我的第一印象是$2 in a应该等同于a[$2]。但也许我错了,我不知道awk内部工作的所有细节。但我相信两者都需要哈希查找..因此速度相同。 - Håkon Hægland
我现在进行了测试,速度似乎与我的比较答案中的“inian3”相同。这可能表明这些方法确实是等效的。你认为呢? - Håkon Hægland
@HåkonHægland 你好!好的,知道了。所以我们不能声称这种方法更快。在我的旧机器上,似乎比inian3给出了更好的结果。我会重新检查一下。你也试过使用C localle吗? - George Vasiliou

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