ソートされた複数のCSVファイルを結合するスクリプト

問題

CSVファイルが複数存在する。各ファイルは異なる列データが保存されているが、どのファイルもCSV左端列でソートされている。これらを左端列でソートされた1つのCSVファイルにまとめたい。まとめるプログラムを作れ。

なお、CSVファイルは数万行を想定し、メモリ消費を考慮したプログラムを書くこと。

CSVデータ

data1.txt
#id,data11,data12
1,data111,data121
2,data112,data122
3,data113,data123
4,data114,data124
5,data115,data125
6,data116,data126
7,data117,data127
data2.txt
#id,data21,data22
0,data210,data220
1,data211,data221
4,data214,data224
5,data215,data225
6,data216,data226
7,data217,data227
8,data218,data228
9,data219,data229
data3.txt
#id,data31,data32
0,data310,data320
1,data311,data321
2,data312,data322
5,data315,data325
6,data316,data326
7,data317,data327
8,data318,data328
9,data319,data329
期待する出力例

※カンマだとわかりにくいので、タブ文字で出力してみた。

id	data11	data12	data21	data22	data31	data32
0			data210	data220	data310	data320
1	data111	data121	data211	data221	data311	data321
2	data112	data122			data312	data322
3	data113	data123				
4	data114	data124	data214	data224		
5	data115	data125	data215	data225	data315	data325
6	data116	data126	data216	data226	data316	data326
7	data117	data127	data217	data227	data317	data327
8			data218	data228	data318	data328
9			data219	data229	data319	data329

回答例

解説

まず、ヘッダを処理する。その後、データ部を処理する。

CSVファイルは歯かけでデータが入っているため、行を read しても、その行がすぐ出力できるとは限らない。出力する行の選択方法は、各CSVから1行読み込み、IDが最も小さいデータのみを探すことで実現した。その後、最小のIDのみを出力する。

この方法だと同一行に対する読込みが2回以上発生する。そこで、簡単な行バッファクラスBfhを実装してコード可読性を向上させている。

なお、各CSVファイルはソートされているので、行バッファの最大バッファ行数は1行で済む。

read-sorted-files.pl
#!perl

package Bfh;
use strict;
use warnings;

sub new {
    my ($class, %args) = @_;
    bless {
        fh => $args{fh},
        buff_line => undef,
    }
}

sub readline {
    my $self = shift;
    my $line;
    if ( $self->{buff_line} ) {
        $line = $self->{buff_line};
        $self->{buff_line} = '';
    } else {
        my $fh = $self->{fh};
        $line = <$fh>;
        chomp($line) if defined $line;
    }
    $line;
}

sub unreadline {
    my ($self, $line) = @_;
    $self->{buff_line} = $line;
    1;
}

package __main__;
use strict;
use warnings;
use Data::Dumper;

my @all_files = ('data1.txt', 'data2.txt', 'data3.txt');

my $separator = ",";

my @desc = map { {} } (1..@all_files);

sub cmp_id {
    my ($x, $y) = @_;
    $x <=> $y
}

# open
for (my $i = 0; $i < @all_files; $i++) {
    my $file = $all_files[$i];
    open(my $fh, '<', $file) or die $!;
    my $bfh = Bfh->new( fh => $fh );
    $desc[$i]{bfh} = $bfh;
}

# read header
for my $d (@desc) {
    my $header = $d->{bfh}->readline;
    my ($id, @cols) = split(/,/, $header);
    $d->{cols} = \@cols;
}

# output header
my @all_cols;
for my $d (@desc) {
    push @all_cols, @{ $d->{cols} };
}
print join($separator, 'id', @all_cols),"\n";

# output data
while (1) {
    # pass1: pick up min id
    my $min_id;
    my $is_bfh_open;
    for my $d (@desc) {
        my $line = $d->{bfh}->readline;
        next unless defined($line);
        $is_bfh_open = 1;
        my ($id) = split(/,/, $line);
        $min_id ||= $id;
        $min_id = $id if cmp_id($id, $min_id) < 0;
        $d->{bfh}->unreadline($line);
    }
    last unless $is_bfh_open;

    # pass2: pick up data
    my %record;
    for my $d (@desc) {
        my $line = $d->{bfh}->readline;
        next unless defined($line);
        my ($id, @data) = split(/,/, $line);
        if ($id != $min_id) {
            $d->{bfh}->unreadline($line);
            next;
        }
        $record{id} ||= $id;
        @record{ @{ $d->{cols} } } = @data;
    }

    # output data
    map { $record{$_} ||= '' } @all_cols; # warning eater
    print join($separator, $min_id, @record{ @all_cols }),"\n";
}

__END__