Perl объединяет 2 CSV-файла построчно с первичным ключом - PullRequest
6 голосов
/ 27 июня 2010

Редактировать: решение добавлено.

Привет, в настоящее время у меня есть некоторый рабочий, хотя и медленный код.

Он объединяет 2 CSV файлов построчно, используя первичный ключ.Например, если в файле 1 есть строка:

"one,two,,four,42"

, а в файле 2 есть эта строка;

"one,,three,,42"

, где в 0 индексированное $ position = 4 имеет первичный ключ = 42;

затем подпрограмма: merge_file ($ file1, $ file2, $ outputfile, $ position);

выведет файл со строкой:

"one,two,three,four,42";

Каждый первичныйключ уникален в каждом файле, и ключ может существовать в одном файле, но не в другом (и наоборот)

В каждом файле содержится около 1 миллиона строк.

Пройдя через каждый файлВ строке в первом файле я использую хеш для хранения первичного ключа и сохраняю номер строки в качестве значения.Номер строки соответствует массиву [номер строки], в котором хранится каждая строка в первом файле.

Затем я просматриваю каждую строку во втором файле и проверяю, есть ли первичный ключ в хэше, и еслиэто, получить строку из file1array, а затем добавить нужные мне столбцы из первого массива во второй массив, а затем concat.к концу.Затем удалите хеш, а затем, в самом конце, сбросьте всю вещь в файл.(Я использую SSD, поэтому хочу свести к минимуму записи в файл.)

Вероятно, это лучше всего объяснить с помощью кода:

sub merge_file2{
 my ($file1,$file2,$out,$position) = ($_[0],$_[1],$_[2],$_[3]);
 print "merging: \n$file1 and \n$file2, to: \n$out\n";
 my $OUTSTRING = undef;

 my %line_for;
 my @file1array;
 open FILE1, "<$file1";
 print "$file1 opened\n";
 while (<FILE1>){
      chomp;
      $line_for{read_csv_string($_,$position)}=$.; #reads csv line at current position (of key)
      $file1array[$.] = $_; #store line in file1array.
 }
 close FILE1;
 print "$file2 opened - merging..\n";
 open FILE2, "<", $file2;
 my @from1to2 = qw( 2 4 8 17 18 19); #which columns from file 1 to be added into cols. of file 2.
 while (<FILE2>){
      print "$.\n" if ($.%1000) == 0;
      chomp;
      my @array1 = ();
      my @array2 = ();
      my @array2 = split /,/, $_; #split 2nd csv line by commas

      my @array1 = split /,/, $file1array[$line_for{$array2[$position]}];
      #                            ^         ^                  ^
      # prev line  lookup line in 1st file,lookup hash,     pos of key
      #my @output = &merge_string(\@array1,\@array2); #merge 2 csv strings (old fn.)

      foreach(@from1to2){
           $array2[$_] = $array1[$_];
      }
      my $outstring = join ",", @array2;
      $OUTSTRING.=$outstring."\n";
      delete $line_for{$array2[$position]};
 }
 close FILE2;
 print "adding rest of lines\n";
 foreach my $key (sort { $a <=> $b } keys %line_for){
      $OUTSTRING.= $file1array[$line_for{$key}]."\n";
 }

 print "writing file $out\n\n\n";
 write_line($out,$OUTSTRING);
}

Первое время в порядке, занимает менее 1 минутыОднако второй цикл while занимает около 1 часа, и мне интересно, правильно ли я выбрал подход.Я думаю, что это возможно для большого ускорения?:) Заранее спасибо.


Решение:

sub merge_file3{
my ($file1,$file2,$out,$position,$hsize) = ($_[0],$_[1],$_[2],$_[3],$_[4]);
print "merging: \n$file1 and \n$file2, to: \n$out\n";
my $OUTSTRING = undef;
my $header;

my (@file1,@file2);
open FILE1, "<$file1" or die;
while (<FILE1>){
    if ($.==1){
        $header = $_;
        next;
    }
    print "$.\n" if ($.%100000) == 0;
    chomp;
    push @file1, [split ',', $_];
}
close FILE1;

open FILE2, "<$file2" or die;
while (<FILE2>){
    next if $.==1;
    print "$.\n" if ($.%100000) == 0;
    chomp;
    push @file2, [split ',', $_];
}
close FILE2;

print "sorting files\n";
my @sortedf1 = sort {$a->[$position] <=> $b->[$position]} @file1;
my @sortedf2 = sort {$a->[$position] <=> $b->[$position]} @file2;   
print "sorted\n";
@file1 = undef;
@file2 = undef;
#foreach my $line (@file1){print "\t [ @$line ],\n";    }

my ($i,$j) = (0,0);
while ($i < $#sortedf1 and $j < $#sortedf2){
    my $key1 = $sortedf1[$i][$position];
    my $key2 = $sortedf2[$j][$position];
    if ($key1 eq $key2){
        foreach(0..$hsize){ #header size.
            $sortedf2[$j][$_] = $sortedf1[$i][$_] if $sortedf1[$i][$_] ne undef;
        }
        $i++;
        $j++;
    }
    elsif ( $key1 < $key2){
        push(@sortedf2,[@{$sortedf1[$i]}]);
        $i++;
    }
    elsif ( $key1 > $key2){ 
        $j++;
    }
}

#foreach my $line (@sortedf2){print "\t [ @$line ],\n"; }

print "outputting to file\n";
open OUT, ">$out";
print OUT $header;
foreach(@sortedf2){
    print OUT (join ",", @{$_})."\n";
}
close OUT;

}

Спасибо всем, решение выложено выше.Теперь на объединение всего 1 минуты!:)

Ответы [ 6 ]

4 голосов
/ 27 июня 2010

На ум приходят два метода.

  1. Считать данные из файлов CSV в две таблицы в СУБД (SQLite будет работать нормально), а затем использовать БД для выполненияприсоединиться и записать данные обратно в CSV.База данных будет использовать индексы для оптимизации объединения.

  2. Сначала отсортируйте каждый файл по первичному ключу (используя perl или unix sort), затем выполните линейное сканирование каждого файла параллельно(прочитайте запись из каждого файла; если ключи равны, выведите объединенную строку и продвиньте оба файла; если ключи не равны, продвиньте файл с меньшим ключом и повторите попытку).Этот шаг составляет O (n + m) времени вместо O (n * m) и O (1) памяти.

3 голосов
/ 27 июня 2010

Что убивает производительность, так это код, который объединяется миллионы раз.

$OUTSTRING.=$outstring."\n";

....

foreach my $key (sort { $a <=> $b } keys %line_for){
    $OUTSTRING.= $file1array[$line_for{$key}]."\n";
}

Если вы хотите записать в выходной файл только один раз, соберите результаты в массив, а затем напечатайте ихв самом конце, используя join.Или, что еще лучше, включите в результаты новые строки и напишите массив напрямую.

Чтобы увидеть, как конкатенация не масштабируется при обработке больших данных, поэкспериментируйте с этим демонстрационным сценарием.Когда вы запускаете его в режиме concat, все начинает значительно замедляться после пары сотен тысяч конкатенаций - я сдался и убил скрипт.Напротив, простая печать массива из миллиона строк заняла на моей машине менее минуты.

# Usage: perl demo.pl 50 999999 concat|join|direct
use strict;
use warnings;

my ($line_len, $n_lines, $method) = @ARGV;
my @data = map { '_' x $line_len . "\n" } 1 .. $n_lines;

open my $fh, '>', 'output.txt' or die $!;

if ($method eq 'concat'){         # Dog slow. Gets slower as @data gets big.
    my $outstring;
    for my $i (0 .. $#data){
        print STDERR $i, "\n" if $i % 1000 == 0;
        $outstring .= $data[$i];
    }
    print $fh $outstring;
}
elsif ($method eq 'join'){        # Fast
    print $fh join('', @data);
}
else {                            # Fast
    print $fh @data;
}
1 голос
/ 27 июня 2010

Если вы хотите слиться, вы должны действительно слить.Прежде всего вы должны отсортировать данные по ключу, а затем объединить!Вы побьете даже MySQL по производительности.У меня большой опыт работы с ним.

Вы можете написать что-нибудь в таком духе:

#!/usr/bin/env perl
use strict;
use warnings;

use Text::CSV_XS;
use autodie;

use constant KEYPOS => 4;

die "Insufficient number of parameters" if @ARGV < 2;
my $csv = Text::CSV_XS->new( { eol => $/ } );
my $sortpos = KEYPOS + 1;
open my $file1, "sort -n -k$sortpos -t, $ARGV[0] |";
open my $file2, "sort -n -k$sortpos -t, $ARGV[1] |";
my $row1 = $csv->getline($file1);
my $row2 = $csv->getline($file2);
while ( $row1 and $row2 ) {
    my $row;
    if ( $row1->[KEYPOS] == $row2->[KEYPOS] ) {    # merge rows
        $row  = [ map { $row1->[$_] || $row2->[$_] } 0 .. $#$row1 ];
        $row1 = $csv->getline($file1);
        $row2 = $csv->getline($file2);
    }
    elsif ( $row1->[KEYPOS] < $row2->[KEYPOS] ) {
        $row  = $row1;
        $row1 = $csv->getline($file1);
    }
    else {
        $row  = $row2;
        $row2 = $csv->getline($file2);
    }
    $csv->print( *STDOUT, $row );
}

# flush possible tail
while ( $row1 ) {
    $csv->print( *STDOUT, $row1 );
    $row1 = $csv->getline($file1);
}
while ( $row2 ) {
    $csv->print( *STDOUT, $row2 );
    $row2 = $csv->getline($file1);
}
close $file1;
close $file2;

Перенаправить вывод в файл и измерить.

Если вам нравится больше здравомыслиявокруг аргументов сортировки вы можете заменить часть открытия файла на

(open my $file1, '-|') || exec('sort',  '-n',  "-k$sortpos",  '-t,',  $ARGV[0]);
(open my $file2, '-|') || exec('sort',  '-n',  "-k$sortpos",  '-t,',  $ARGV[1]);
1 голос
/ 27 июня 2010

Я не вижу ничего, что кажется мне слишком медленным, но я бы внес эти изменения:

  • Во-первых, я бы исключил переменную @file1array.Вам это не нужно;просто сохраните саму строку в хэше:

    while (<FILE1>){
         chomp;
         $line_for{read_csv_string($_,$position)}=$_;
    }
    
  • Во-вторых, хотя это не должно иметь большого значения для perl, я бы не добавил к $OUTSTRING всевремя.Вместо этого сохраняйте массив выходных строк и push каждый раз.Если по какой-то причине вам все еще нужно вызвать write_line с массивной строкой, вы всегда можете использовать join('', @OUTLINES) в конце.

  • Если write_line не использует syswriteили что-то в этом роде низкого уровня, но вместо этого используются print или другие вызовы на основе stdio, тогда вы не сохраняете записи на диск, создавая выходной файл в памяти.Следовательно, вы могли бы вообще не создавать свой вывод в памяти, а вместо этого просто записывать его по мере его создания.Конечно, если вы используете syswrite, забудьте об этом.

  • Поскольку очевидно, что ничего не происходит медленно, попробуйте добавить в свой код Devel :: SmallProf .Я обнаружил, что это лучший Perl-профилировщик для производства "О! Это медленная линия!"идеи.

0 голосов
/ 27 июня 2010

Я бы хранил каждую запись в хэше, ключи которого являются первичными ключами. Значение данного первичного ключа является ссылкой на массив значений CSV, где undef представляет неизвестное значение.

use 5.10.0;  # for // ("defined-or")
use Carp;
use Text::CSV;

sub merge_csv {
  my($path,$record) = @_;

  open my $fh, "<", $path or croak "$0: open $path: $!";

  my $csv = Text::CSV->new;
  local $_;
  while (<$fh>) {
    if ($csv->parse($_)) {
      my @f = map length($_) ? $_ : undef, $csv->fields;
      next unless @f >= 1;

      my $primary = pop @f;
      if ($record->{$primary}) {
        $record->{$primary}[$_] //= $f[$_]
          for 0 .. $#{ $record->{$primary} };
      }
      else {
        $record->{$primary} = \@f;
      }
    }
    else {
      warn "$0: $path:$.: parse failed; skipping...\n";
      next;
    }
  }
}

Ваша основная программа будет напоминать

my %rec;
merge_csv $_, \%rec for qw/ file1 file2 /;

Модуль Data::Dumper показывает, что результирующий хэш с простыми входными данными из вашего вопроса равен

$VAR1 = {
  '42' => [
    'one',
    'two',
    'three',
    'four'
  ]
};
0 голосов
/ 27 июня 2010

Предполагая, что около 20 байтовых строк в каждом файле будет иметь размер около 20 МБ, это не слишком много Поскольку вы используете хеш, сложность времени не выглядит проблемой.

Во втором цикле вы печатаете на консоль для каждой строки, этот бит медленный. Попробуйте удалить это должно очень помочь. Вы также можете избежать удаления во втором цикле.

Чтение нескольких строк одновременно также должно помочь. Но, думаю, не так уж много, за кулисами всегда будет впереди чтение.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...