challenge 続・ファイル内の重複行削除

ファイル内の重複行削除(後優先) 」の続編です。

1行あたり平均60文字のデータが書き込まれた、巨大なファイルがあるとします。どのくらい巨大かというと、積んでいるメモリの10倍程度の容量があります。このファイルから、同じ内容が書かれている行を取り除くプログラムを作ってください。ただし、同じ内容が書かれている行のうち、最後に出現したものを残すものとします。

このサイズのファイルを丸ごとメモリに読み込もうとしてしまうと、 スラッシング - Wikipedia が発生することが予想されます。そこで行単位で読み込もう、というのが前回のお題の趣旨でした。

しかし、与えられたファイルが運悪く「一致する部分のないファイル」である可能性を考えてみましょう。たとえ1行ずつ読んで処理をしたとしても、「重複するかどうかの判定」のために前の行をまるごとメモリに取っておいたのでは、最終的にファイルを丸ごとメモリに乗せることになってしまいます。

こういうデータが入力されうる状況の場合にどう書くか、というお題です。前回のお題の条件3「ファイル全体を一度にメモリに読み込んで処理しないこと」を「たとえすべての行が異なるようなデータであっても、メモリの消費量をファイルサイズのおよそ10%程度に抑えること」と読み替えてください。

追記:「メモリの10倍」はさすがに条件として厳しすぎました。「ファイルのサイズは4ギガバイト未満であり、メモリの消費量をファイルサイズの半分以下に抑えること」と読み替えてください。半分以下に抑えられているのならば題意は満たすものとします。もちろん、頑張ってもっと少ないメモリで動くようにするのもアリです。

Posted feedbacks - Nested

Flatten Hidden
よかった、ちゃんと投稿できたんだ

さすがに「メモリの10倍の量」は
条件として厳しすぎたかなと後悔しています。
まず4ギガを超えるファイルを扱えるのかとか…

というわけで、少し条件をゆるめたいと思います。
題意から離れてしまう突っ込みですが…
大きいファイルを扱うのも大変ですが小さいファイルを扱うのも大変そうです
おそらく数バイトのファイルで追加条件をクリアするのは不可能でしょう
なるほど。ファイルサイズが小さい場合には処理系が食うメモリだけで制限を超えてしまう場合があるわけですね…。題意は1行あたり何バイトあれば重複行の削除ができるかという点なのでそういう定数オーダーの項は無視してもいいことにしましょうか。
> 題意は1行あたり何バイトあれば重複行の削除ができるか
ということは、ハッシュに任意の行へのオフセット(ファイルサイズが4GB超なので仮に6バイト)
だけを置いて一行60バイトなので必要なサイズが行あたり6/60で
使用メモリがファイルサイズの10分の1になるというあたりが、想定していた回答なのかな。
そういうことは、動くコードで語ってくれと言うのがここの主旨なんじゃないですかね。
まぁ、僕が出題時に考えていたのはまさにその通りの方法です。
これ、ファイルを何回でもスキャンしていいとすれば、だいぶ楽になると思うんですがどうでしょう?
ファイルを何回もスキャンするのはスラッシングとは違う気がするんだけど、どうなんだろうなぁ。
もちろんそれは禁止されていません。
というより、「ファイルを読むのは1回だけ」とか
「別のファイルに書き出したりしてはいけない」とか
条件をつけてしまうと解けなくなるのではないかと…
この問題は「ファイルの書き換え」ではなく、「標準出力に表示」でもOKですか?
6メガ10万行のファイルで実行してみましたが
メモリ使用量が最大時25メガほど、
100メガほどのテンポラリファイルが作成され、
時間は24秒ほどでした。
あとこのコードだとMD5が衝突したときのことが考慮されてないと思います。
> 今や同じmd5値を持つデータが生成可能らしいですが
> 逆に言えば敢えてそういうデータを処理するのでもない限り
> 特に問題ない、というわけには行きませんか。
> もともとこの手のダイジェスト関数は一意とみなせる
> と言うところに価値があるわけですから。

目的によります。そして、今回のお題に対して衝突を無視するのはどっちかというとまずいと思います。
false positiveがごく少数生じても良い場合と、ごくわずかでもその可能性があったら
(それ単独では)使えない場合とがあるわけですよ。

(まあ、お題の元ネタがヒストリファイルの整理ってことで、
打ったはずのコマンドが出現しない確率がわずかにあっても
それほど困らないだろう、という議論は出来ると思いますが。
一般的に「一意とみなせるから良いはずだ」という議論は乱暴だと思います)

> md5には原理的な脆弱性が見つかっていますので、
> 実用的なプログラムでは使わない方がいいでしょう、
> とだけは言い添えておきます。

目的によります。そして、今回のお題のような問題にmd5を使うのは、
衝突へのフォローさえ入れれば、実用的なプログラムに使っても*全く*問題ありません。
(もっとも、今回の問題の場合もっとナイーブなハッシュ関数で良いと思いますが)

「脆弱性」とか「一意とみなせる」とかいう話は、それにくっついてる文脈があるんですよ。
文脈を無視して言葉だけ取り出して議論するのは無意味です。
「衝突する可能性がある」という問題はmd5に起因するものではなく
一般的にハッシュ関数すべてにあるものなので
md5で意図的に衝突を起こさせる方法が見つかっているかどうかとは
まったく無関係じゃないですか?

今回の件に関してmd5に非はないですよね?

アルゴリズムがmd5であれSHA-1であれ、
今回のような使い方をすれば
「きわめて低い確率で期待と違う挙動をする」
とうバグを作り込むことになってしまいます。
「これはとても危険なコーディングスタイルだ」
というのがあなたにマイナス評価をつけている人たちの言いたいことなのでは。

にしおさんがフォローしてるけど。

>「このコードにおいて既知の手法を使った人為的な衝突以外の衝突を考慮する必要があるのか」

だから目的によります。 ちなみに、セキュリティ用途(一方向関数としてハッシュを使っている場合)でなければ、人為的な衝突か偶然な衝突かは関係ないことの方が 多いと思います。今回の問題は情報圧縮のためにハッシュを使ってるので、原因が何であれ衝突は衝突ですね。

今回のようなお題の場合、大抵のプログラマは経験的に衝突しちゃまずいと 考えると思うんですが、その判断が間違ってる可能性もあります。 ほんとうに衝突を考慮する必要があるのかどうか知りたければ、 「今回の問題の要件において、衝突が生じる可能性はxx%であり、コマンドのヒストリファイルという前提で一日yy個のコマンドを叩いているとすれば、衝突が起きるのはzz年に一度の確率である。そんなことを気にするよりもディスクのクラッシュを心配した方がよい。よって今回のお題で衝突を考慮する必要はない。」 というような定量的な議論をしてみると良いでしょう。 みんなそれが面倒だから安全側に倒れてるだけで、実は意外な結論になるかもしれません。 私も見てみたいです。そういう議論。

> これで何か問題ありますか?

これも目的によります:-) あなたが趣味で自分で使うコードを書いているだけで、 プログラミングの面白さについてもこれ以上踏み込む つもりがないのなら、何も問題ないと思います。

そうでないなら、一度「ハッシュ」というものについて 調べてみるといいですよ。 深追いするとすごく面白いです。

などとぐだぐだ書いてる時間があればやってみればいいのか。

md5のダイジェスト結果は128bit。4Gのヒストリファイルに一行64byteとして2^26行。全てがユニーク行だったとして、衝突が生じる確率は 1 - ((2^128 * (2^128-1) * (2^128-2) * ... * (2^128-2^26+1))/((2^128)^(2^26))、でいいのかな。

これを 1 - ((2^128-2^25)/(2^128))^(2^26) で近似するのってまずいかな? あんまり深く考えてないけど。もし良ければさらにx<<1のとき(1-x)^n ≒ 1-nx だから 1 - (1 - 2^(-103))^(2^26) → 1 - (1 - 2^(-103)*(2^26)) → 2^-77 ≒ 7e-24。たぶんオーダーはそう外してないと思うけど…

一日にどのくらいコマンドを打つかなあ。だいたい1000くらい=2^10として2^26行のヒストリをためるのに2^16日ってことは180年くらいか。結構かかるな。仮にその5倍コマンドをタイプするスーパーハッカーだったとしても、キャリア36年の間に7e-24の確率で打ったはずのコマンドが出てこないという事例に合うことになると。7e24というとコップ一杯の水の中の水分子の数くらい?

コマンドヒストリに使うという前提をおけば、md5で衝突の心配をする必要は全く無いと言えるでしょうね。というわけで匿名さん、安心してお使いください。

冷静なつもりでしたがやはり興奮していたようです。
一晩寝て落ち着きを取り戻しました。

結局のところ以前の私の発言は、素人的な見積りの大雑把さに
過ぎなかったことを認識しました。

コメントいただいた方々に余計な手間をかけさせたことをお詫びするとともに
有用な示唆を与えてくださったことに感謝いたします。
古い話への補足ですが、「MD5を使ってはいけない具体例」としておもしろい記事を見かけたので紹介しておきます。

この記事は、「来年の米大統領選の結果を正しく予測したPDFドキュメントを作成した。」と主張し、正当性を証明するためドキュメントのMD5ハッシュを公開しています。

もちろん種明かしもその下に書いてあって、全く同じMD5ハッシュ値を持ち、見かけ上候補者名だけが異なる12のpdfファイルを作ったということです。PlayStation 3とクアッドコアPCで2日でできたそうで。

MD5 collisionを起こすpdf文書のペアを構成することなどは数年前から示されていますが、ここではより進歩した方法を使ったってことらしいです。
と言うわけで、改訂版を投稿させていただきます。

使用メモリ量は非常に少なく、psコマンドでは9.2MBと出ます。

16MBのテストデータが問題なく完走したので、思い切って512MBのデータを
処理してみましたが、数時間たった今現在も終わる気配がありません。

残念ながらこのコードでは、大きなデータを許容できる時間内に処理することは
無理なようです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import os, sys, bsddb

db = bsddb.hashopen('tmp.dat')
fp = file(sys.argv[1], 'r')
wfp = file(sys.argv[2], 'w')
i = 0
for s in fp:
  db[s] = str(i)
  i += 1

fp.seek(0)
i = 0
for s in fp:
  if db[s] == str(i):
    wfp.write(s)
  i += 1
fp.close()
wfp.close()
db.close()

os.remove('tmp.dat')
この問題では、「動くはず」と「動いた」は全然違う危険があります。
具体的な検証用のファイル(あるいはその作り方)を示して、
「実際に動作したものを投稿する」というルールが必要なのではないでしょうか。
「動くつもりで投稿したけど動かない」という事態は今までにも何度も起きましたが、「動かないコードを投稿してそれが発覚するとマイナス評価を受ける」「動かないことを指摘した人はプラス評価を受ける」という形でうまく回っているように思えます。

ルールを増やして縛るよりも、行動の結果に対する評価でフィードバックする方がいいのではないかと思っています。
参加者の総意で評価が形成されていく
というのはいいアイディアですね。

でも、今回は、4G程度が目標だったはずが、
参加者の総意で60M程度に落ち着きそうで、
ちょっと残念な気がします。

まあ、これからに期待ですね。
4Gでも問題なく動くコードを投稿いただければ
少なくとも僕はプラス評価をつけますよ。
個人的思うのは。
別に性能要件があるわけではないので、
結果を見て4G程度でも耐えられるという根拠になれば
十分ではないかなーなんて思います。

そういった点で言うとコメントで
どのくらいのペースで使用メモリが増えていくのとか
あると良かったのかもしれませんね。
# めんどくさいからきっと僕もしないのだろうけど(笑
バッファは1行です。
頭から1行ずつ取り出して、
それ以降の行と比べ、同じ行が無ければ出力します。
注目する行の移動には、seekを使ってます。
計算量の事は聞かないで下さい。
; #3437の事は無かったことにして下さい。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
(define (main args)
  (call-with-input-file (cadr args)
    (lambda (port)
      (let loop ((next 0))
        (port-seek port next)
        (let ((buf (read-line port))
              (next (port-tell port)))
          (unless (eof-object? buf)
            (let loop1 ()
              (let1 bufn (read-line port)
                (cond ((eof-object? bufn) (print buf))
                      ((not (string=? bufn buf)) (loop1)))))
            (loop next)))))))
60文字×10万行6Mバイトのファイルは149分
〃×1万行 610Kのファイルは、1.5分。
でした。

n行として、計算オーダーはn!なので、
10万!/1万!は100で丁度100倍ぐらいで、
つまらないぐらいの計算通りです。
ま、実用的ではないですな。
このエントリ間違いです。
消して下さい。すみません。orz
60文字×10万行6Mバイトのファイルは149分
〃×1万行 610Kのファイルは、1.5分。
でした。

n行として、計算オーダーはSnなので、
S10万/S1万は100で丁度100倍ぐらいで、
つまらないぐらいの計算通りです。
ま、実用的ではないですな。
全行一回なめて行頭n文字が同じだった行の位置を記録します。
んで巻き戻して読んでいって、さっき記録した疑わしい行がある場合、実際見に行って比較、表示か非表示を決めます。

60メガ100万行のファイルでメモリ30メガ、44秒くらいでした。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def fetch(fin, head)
  cur = fin.pos
  fin.pos = head
  s = fin.gets
  fin.pos = cur
  s
end

def line_with_headpos(fin)
  head = fin.pos
  while line = fin.gets
    yield(line, head)
    head = fin.pos
  end
end

def zoku(fn, n=5)
  open(fn){|fin|
    h = {}

    line_with_headpos(fin){|line, head|
      (h[line[0,n]] ||= []) << head
    }

    fin.rewind

    line_with_headpos(fin){|line, head|
      flag = h[line[0,n]].any?{|ps|
        head < ps && line == fetch(fin, ps)
      }
      print line unless flag
    }
  }
end

zoku(ARGV[0])
構想は同じですが、なんかすっきりしてないなぁ。
line_with_headposを拝借させていただきました。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
def line_with_headpos(fin)
  head = fin.pos
  while line = fin.gets
    yield(line, head)
    head = fin.pos
  end
end

def deldup(file)
  require 'digest/md5'
  
  buf = Hash.new
  open(file, 'r') {|io|
    
    line_with_headpos(io){|line, head|
      (buf[Digest::MD5.digest(line)] ||= []) << head
    }
    
    buf.each_value {|val|
      0.upto(val.size-2) {|i|
        io.pos = i
        idat = io.gets
        i.upto(val.size-1) {|j|
          io.pos = j
          if idat == io.gets
            val[i] = -1
            break
          end
        }
      }
    }
    
    buf.values.flatten.select {|i|
      i != -1
    }.sort.each {|i|
      io.pos = i
      print io.gets
    }
  }
end

deldup(ARGV[0])
自分のRubyのもそうですが一行のみファイルには負ける、
ということでバッファ一文字で。
がんがんseekします。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <stdio.h>
#include <fcntl.h>
#include <sys/types.h>
#include <unistd.h>

char same(int fin, off_t a, off_t b){
    char x[1],y[1];
    char ret;

    while (1){
        lseek(fin, a++, SEEK_SET);
        ret = read(fin, x, 1);

        lseek(fin, b++, SEEK_SET);
        ret += read(fin, y, 1);

        if (ret == 1 || x[0] != y[0]){
            return 0;
        }
        if (ret == 0 || x[0] == '\n'){
            return 1;
        }
    }
}

off_t next_line(int fin, off_t a){
    char x[1];
    char ret;

    lseek(fin, a, SEEK_SET);
    do{
        ret = read(fin, x, 1);
    }while(x[0] != '\n' && ret > 0);

    return ret == 0 ? 0 : lseek(fin, 0, SEEK_CUR);
}

void print(int fin, off_t a){
    char x[1];
    char ret;

    lseek(fin, a, SEEK_SET);
    do{
        ret = read(fin, x, 1);
        if (ret == 0)
            break;

        printf("%c", x[0]);
    }while(x[0] != '\n');
}

void zoku(int fin){
    off_t a=0,b=0;

    do{
        char flag = 1;

        lseek(fin, a, SEEK_SET);
        b = a;
        while ((b = next_line(fin, b))){
            if (same(fin, a, b)){
                flag = 0;
                break;
            }
        }
        if (flag)
            print(fin ,a);

    }while((a = next_line(fin, a)));
}

int main(int argc, char** argv){
    int fin;

    if (argc <= 1)
        return 1;

    fin = open(argv[1], O_RDONLY);

    if (fin < 0)
        return 1;

    zoku(fin);

    return 0;
}
一文字バッファに萌えました。
使い方:sh uniq.sh input.txt

http://unicode.org/にあるUnihan.txtを37個連結した、
約4000万行(約1GB)のファイルの処理に、
Core2 6700, メモリ2GB、MySQL 5.0.45(設定ファイルはmy-huge.cnf)で、
約23分かかりました(消費メモリは約400MB)

uniq.shは下に
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
mysql -uroot -N -r test << EOF

DROP TABLE IF EXISTS tmp;
CREATE TABLE tmp (
  id INT PRIMARY KEY AUTO_INCREMENT,
  line LONGTEXT NOT NULL,
  INDEX (line(10))
) CHARACTER SET UTF8;

LOAD DATA LOCAL INFILE "$1" INTO TABLE tmp FIELDS TERMINATED BY '\n' (line);

SELECT line FROM tmp GROUP BY line ORDER BY max(id);

EOF
外部コマンドsortを使ってます。
・対象ファイルの各行のハッシュ値をテンポラリファイルに出力
・テンポラリファイルを順ソート後、読み込んで重複行数をカウント
・対象ファイルを再読込、重複行に出会うたびに重複行数カウントをデクリメント
100万行、60MBの数字のみファイルで、実行時間は50秒、メモリ35MB使用です。
マシンはCelleon2.8Ghz、メモリ1GB、Ubuntu7.04。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
require 'tempfile'

def make_big_seq_file(cntmax,lthmin,eps)
  cntmax.times {
    s = ""
    (lthmin+rand(eps)).times {
      s << rand(10).to_s
    }
    puts s
  }
end

#make_big_seq_file(10**6,50,20)

def double_line_reject(f)
  h = Hash.new(0)

  temp = Tempfile::new("foobar")
  open(f) {|fin|
    while line = fin.gets
      temp.puts(line.hash)
    end
    temp.close

    f = open("|sort #{temp.path}")
    iold = ""
    f.each {|i|
      i = i.chomp.to_i
      h[i] += 1 if iold == i
      iold = i
    }
    fin.rewind
    while line = fin.gets
      puts line if h[line.hash] < 1
      h[line.hash] -= 1
    end
  }
end

double_line_reject(ARGV[0])
ハッシュテーブルを作成して。
60MB 100万行で メモリ30MB、40秒くらいでした。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <string>
#include <iostream>
#include <fstream>
#include <list>
#include <vector>
#include <numeric>
#include <algorithm>
#include <functional>
using namespace std;

template <int SIZE>
struct StringHashFunc : public binary_function<int, unsigned char, int> {
    int operator()(int h, unsigned char c) const {
        return (h * 137 + c) % SIZE;
    }
};
template <class T, int SIZE>
class StringHashTable {
    vector<list<T> > table;
public:
    StringHashTable<T, SIZE>() : table(SIZE) {}
    list<T> &operator[](const string &s) {
        return table[accumulate(s.begin(), s.end(), 0, StringHashFunc<SIZE>())];
    }
};