【问题标题】:`uniq` without sorting an immense text file?`uniq` 没有对一个巨大的文本文件进行排序?
【发布时间】:2015-09-03 13:13:02
【问题描述】:

我有一个非常大的文本文件(截至今天为 40 GB),我想过滤出唯一的行而不对文件进行排序

该文件具有 unix 行结尾,并且所有内容都匹配 [[:print:]]。我尝试了以下 awk 脚本来仅显示唯一行:

awk 'a[$0] {next} 1' stupid.txt > less_stupid.txt

当时的想法是,我会通过引用数组的元素来填充数组,使用文件的内容作为键,然后跳过数组中已经存在的行。但这失败有两个原因 - 首先,因为它莫名其妙地不起作用(即使在小测试文件上),其次因为我知道我的系统会在整个唯一行加载到内存之前耗尽内存通过 awk。

搜索后发现this answer推荐:

awk '!x[$0]++'

虽然这适用于小文件,但在读取我的整个文件之前它也会耗尽内存。

什么是更好的(即有效的)解决方案?我对任何事情都持开放态度,尽管我更偏爱我所知道的语言的解决方案(bash & awk,因此是标签)。在尝试将问题可视化时,我想出的最好方法是存储行校验和或 MD5 数组,而不是行本身,但这只会节省一点空间并存在校验和冲突的风险。

非常欢迎任何提示。告诉我这是不可能的也是受欢迎的,这样我就不再试图弄清楚了。 :-P

【问题讨论】:

  • 为什么要避免排序?你需要比排序更快的东西吗?排序后的文件会占用太多空间吗?还是有其他一些因素使排序不可行?
  • 如果您无法将完整的唯一行存储在内存中(并且这些行足够短,以至于即使每个行的合理总和(sha256 或其他)也不适合内存)那么我不确定这是否可行。
  • 您的原始脚本失败,因为您从未更改 a[$0],这与您找到的答案不同。
  • 您说唯一行的集合对于内存来说太大了。重复行的集合是否也太大了?也就是说,如果您排序并找到重复的行,该列表是否适合内存?
  • 引用它可能确实会导致它存在 - 作为一个空/零值,它比较 false,因此不会触发下一个子句,等等。替代方法将值从 0 增加到 1第一次,如果值为 0(未初始化)则打印。

标签: bash awk


【解决方案1】:

awk '!x[$0]++' 技巧是在不排序的情况下对文件或流进行重复数据删除的最优雅的解决方案之一。但是,它在内存方面效率低下并且不适合大文件,因为它将所有唯一行保存到内存中。

但是,更有效的实现方式是保存数组中行的恒定长度散列表示,而不是整行。您可以在一行中使用Perl 实现此目的,它与awk 脚本非常相似。

perl -ne 'use Digest::MD5 qw(md5_base64); print unless $seen{md5_base64($_)}++' huge.txt

这里我使用md5_base64而不是md5_hex,因为base64编码需要22个字节,而十六进制表示需要32个。

但是,由于 hashes 的 Perl 实现仍需要每个键大约 120 字节,因此您可能会很快耗尽您的大文件的内存。

这种情况下的解决方案是分块处理文件,手动拆分或使用带有 --pipe、--keep-order 和 --block 选项的 GNU Parallel(利用重复行是正如你所提到的,相距不远)。以下是使用parallel 的方法:

cat huge.txt | pv | 
parallel --pipe --keep-order --block 100M -j4 -q \
perl -ne 'use Digest::MD5 qw(md5_base64); print unless $seen{md5_base64($_)}++' > uniq.txt

--block 100M 选项告诉并行处理以 100MB 为单位的输入。 -j4 表示并行启动 4 个进程。这里的一个重要参数是--keep-order,因为您希望唯一的行输出保持相同的顺序。我在管道中包含了pv,以便在长时间运行的进程正在执行时获得一些不错的统计数据。

在我使用 1GB 随机数据文件执行的基准测试中,通过上述设置,我达到了 130MB/秒的吞吐量,这意味着您可以在 4 分钟内删除 40GB 文件的重复数据(如果您有足够快的硬盘能够以这种速度写)。

其他选项包括:

  • 使用高效的trie 结构来存储密钥并检查重复项。例如,一个非常有效的实现是marisa-trie 在 C++ 中使用wrappers in Python 编码。
  • 使用external merge sort 或distribution/bucket 排序您的大文件
  • 将您的文件存储在数据库中,并在包含您的行或最有效的 md5_sums 行的索引列上使用 SELECT DISTINCT。
  • 或使用bloom filters

这里是一个使用 Perl 的Bloom::Faster 模块的例子:

perl -e 'use Bloom::Faster; my $f = new Bloom::Faster({n => 100000000, e => 0.00001}); while(<>) { print unless $f->add($_); }' huge.txt > uniq.txt

您可以从cran 安装Bloom::Fastersudo cran 然后install "Bloom::Faster"

解释:

  • 您必须指定概率错误率e 和可用桶的数量n。每个存储桶所需的内存约为 2.5 字节。如果您的文件有 1 亿个唯一行,那么您将需要 1 亿个桶和大约 260MB 的内存。
  • $f-&gt;add($_) 函数将行的哈希添加到过滤器中,如果键(即此处的行)重复,则返回 true
  • 您可以估算文件中唯一行的数量,用dd if=huge.txt bs=400M count=1 | awk '!a[$0]++' | wc -l (400MB) 解析文件的一小部分,然后将该数字乘以 100 (40GB)。然后将n 选项设置得稍高一些,以确保安全。

在我的基准测试中,这种方法达到了 6MB/s 的处理速度。您可以将此方法与上面的 GNU parallel 建议结合使用,以利用多个内核并实现更高的吞吐量。

【讨论】:

  • 这很棒。我刚刚在不到 10 分钟的时间内整理了 50 多个文件,超过 2500 万行!
  • @user2117258。很高兴它有帮助。您使用了建议中的哪种方法?请注意,“bloom filter”方法可能会错误地删除一小部分(等于错误率e)的非重复行。 parallel + perl 方法将只删除重复的行,但只删除相同“块”中的那些(由parallel 中的--block 100M 选项定义)。如果重复的行足够接近,这将起作用。您可以多次重新运行该过程,最好使用不同的--block 大小,以检测新块中先前遗漏的重复行。
【解决方案2】:

我手边没有你的数据(或任何类似的数据),所以我无法对此进行测试,但这里有一个概念证明供你参考:

$ t='one\ntwo\nthree\none\nfour\nfive\n'
$ printf "$t" | nl -w14 -nrz -s, | sort -t, -k2 -u | sort -n | cut -d, -f2-
one
two
three
four
five

我们的原始数据包括一个重复的行。管道功能如下:

  • nl 添加行号。这是一个标准的、低影响的 unix 工具。
  • sort 第一次在 SECOND 字段上进行 'round 排序 - nl 之前的行首。根据您的数据需要进行调整。
  • sort 第二次按nl 命令定义的顺序放回东西。
  • cut 只是去掉了行号。有多种方法可以做到这一点,但其中一些取决于您的操作系统。这个是便携式的,适用于我的示例。

现在...对于非常大的文件,sort 命令将需要一些额外的选项。特别是--buffer-size--temporary-directory。阅读 man sort 了解详情。

我不能说我期望这会,我怀疑你会使用大量的磁盘 IO,但我不明白为什么它至少不会工作。

【讨论】:

  • 不知道nl的便携性如何;您可以使用awk 轻松编号。就个人而言,我会使用固定长度的零填充数字格式(nl -w12 -nrz);这将使您更准确地了解数据的开始位置,并允许您使用cut 删除数字。但肯定是 +1
  • @rici,所有的要点,谢谢。以我的经验,nl 已经出现在从 SunOS 和 HP/UX 到 Linuces 和 *BSD 的所有领域,尽管 MINIX 2.0 中缺少它。关于数字格式的好主意 - 我希望这也会加快 sort
  • 看起来不错,只要保证sort 保持重复行的最低行数。我不确定是否可以保证这样做。每组重复项中肯定只有一行,但我不确定它是否保证是每组重复项中的最低数字。
  • 可能需要在第一个字段上进行额外的子排序来处理@JonathanLeffler 指出的问题。 sort -t, -k2 -k1,1 -u 或类似的东西。 (尽管这可能是默认操作。)
【解决方案3】:

假设您首先可以对文件进行排序(即您可以让sort file 工作)然后我认为这样的事情可能会起作用(取决于大型 awk 脚本文件是否在内存使用/等方面比大型 awk 数组更好。

sort file | uniq -dc | awk '{gsub("\"", "\\\"", $0); print "$0==\""substr($0, index($0, $1) + 2)"\"{x["NR"]++; if (x["NR"]>1){next}}"} END{print 7}' > dedupe.awk
awk -f dedupe.awk file

在测试输入文件中,例如:

line 1
line 2
line 3
line 2
line 2
line 3
line 4
line 5
line 6

创建一个 awk 脚本:

$0=="line 2"{x[1]++; if (x[1]>1){next}}
$0=="line 3"{x[2]++; if (x[2]>1){next}}
7

并以awk -f dedupe.awk file 输出运行:

line 1
line 2
line 3
line 4
line 5
line 6

如果 awk 脚本本身的大小是一个问题(可能不太可能),您可以通过使用另一个标记值来减少它,例如:

sort file | uniq -dc | awk 'BEGIN{print "{f=1}"} {gsub("\"", "\\\"", $0); print "$0==\""substr($0, index($0, $1) + 2)"\"{x["NR"]++;f=(x["NR"]<=1)}"} END{print "f"}'

从每行中删去七个字符(如果您也从原版中删除空格,则为六个)并生成:

{f=1}
$0=="line 2"{x[1]++;f=(x[1]<=1)}
$0=="line 3"{x[2]++;f=(x[2]<=1)}
f

此解决方案可能会运行较慢,因为它不会在找到匹配项时使脚本短路。

如果 awk 脚本的运行时间太长,甚至可以通过根据匹配计数对重复行进行排序来缩短时间(但这是否重要取决于数据)。

【讨论】:

  • 哇。你和 ghoti 都提出了听起来合理的解决方案来解决其他人告诉我的事情是不可能的。谢谢,我好好测试看看效果如何。
  • 我总是对编写代码的代码感到紧张,但是.. 这很优雅。做得很好。 +1。
  • @ghoti 是的,通常也不是我的首选解决方案(尽管它肯定有它的位置),但这个案例非常适合它。
  • @EtanReisner:这是一个非常漂亮的解决方案,但生成的脚本可能接近文件的大小,不是吗?您需要大量内存来处理 40GB 的 awk 脚本。此外,通过所有行的线性搜索使得这个算法 O(n^2),对吧?虽然我们不知道线路有多长,但我怀疑n 相当大。但我喜欢元编程,所以请点赞。
  • @rici 这就是为什么我询问已知重复列表是否小于已知唯一列表的部分原因(我假设它有一个公平的余量,所以这应该比我的原始输入小很多希望)。是的,这将表现得相当糟糕(因此我对频率排序的评论试图对此有所帮助)。一个更好的解决方案是获取列表并按前缀排序并进行更高级的基于树的匹配(本质上是 trie 和bloom 过滤器的想法)。此外,我在 awk 可能“优化”脚本而不是内部数组的理论下进行操作。
【解决方案4】:

我会这样做:

#! /bin/sh
usage ()
{
    echo "Usage:  ${0##*/} <file> [<lines>]" >&2
    exit 1
}


if [ $# -lt 1 -o $# -gt 2 -o ! -f "$1" ]; then usage; fi
if [ "$2" ]; then
    expr "$2" : '[1-9][0-9]*$' >/dev/null || usage
fi

LC_ALL=C
export LC_ALL

split -l ${2:-10000} -d -a 6 "$1"

for x in x*; do
    awk '!x[$0]++' "$x" >"y${x}" && rm -f "$x"
done

cat $(sort -n yx*) | sort | uniq -d | \
    while IFS= read -r line; do
        fgrep -x -n "$line" /dev/null yx* | sort -n | sed 1d | \
            while IFS=: read -r file nr rest; do
                sed -i -d ${nr}d "$file"
            done
    done

cat $(sort -n yx*) >uniq_"$1" && rm -f yx*

(概念证明;在用于生产之前需要更多的打磨)。

这里发生了什么:

  • split 将文件分割成 10000 行的块(可配置);这些块被命名为x000000x000001、...
  • awk 从每个块中删除重复项,而不会弄乱行顺序;生成的文件是yx000000yx000001、...(因为awk 不能便携地就地进行更改)
  • cat $(sort -n yx*) | sort | uniq -d 重新组装块并找到重复的列表;由于块的构造方式,每个重复的行在每个块中最多出现一次
  • fgrep -x -n "$line" /dev/null yx* 查找每个重复行所在的位置;结果是行列表yx000005:23:some text
  • sort -n | sed 1d 从上面的列表中删除第一个块(这是第一次出现的行,应该不理会它)
  • IFS=: read -r file nr restyx000005:23:some text 拆分为 file=yx000005nr=23 和其余部分
  • sed -i -e ${nr}d "$file" 从块 $file 中删除行 $nr
  • cat $(sort -n yx*) 重新组装块;需要对它们进行排序,以确保它们按正确的顺序排列。

这可能不是很快,但我会说它应该可以工作。将每个块中的行数从 10000 增加可以加快速度,但代价是使用更多内存。操作是O(N^2)跨chunk的重复行数;幸运的是,这不会太大。

以上假设 GNU sed(对于 -i)。它还假设当前目录中没有名为 x*yx* 的文件(这是可以使用一些清理的部分,可能通过将垃圾移动到由 mktemp -d 创建的目录中)。

编辑:第二版,来自@EtanReisner 的反馈:

#! /bin/sh
usage ()
{
    echo "Usage:  ${0##*/} <file> [<lines>]" >&2
    exit 1
}


if [ $# -lt 1 -o $# -gt 2 -o ! -f "$1" ]; then usage; fi
if [ "$2" ]; then
    expr "$2" : '[1-9][0-9]*$' >/dev/null || usage
fi

tdir=$(mktemp -d -p "${TEMP:-.}" "${0##*/}_$$_XXXXXXXX") || exit 1
dupes=$(mktemp -p "${TEMP:-.}" "${0##*/}_$$_XXXXXXXX") || exit 1

trap 'rm -rf "$tdir" "$dupes"' EXIT HUP INT QUIT TERM

LC_ALL=C
export LC_ALL

split -l ${2:-10000} -d -a 6 "$1" "${tdir}/x"

ls -1 "$tdir" | while IFS= read -r x; do
    awk '!x[$0]++' "${tdir}/${x}" >"${tdir}/y${x}" && \
    rm -f "${tdir}/$x" || exit 1
done

find "$tdir" -type f -name 'yx*' | \
    xargs -n 1 cat | \
    sort | \
    uniq -d >"$dupes" || exit 1

find "$tdir" -type f -name 'yx*' -exec fgrep -x -n -f "$dupes" /dev/null {} + | \
    sed 's!.*/!!' | \
    sort -t: -n -k 1.3,1 -k 2,2 | \
    perl '
        while(<STDIN>) {
            chomp;
            m/^(yx\d+):(\d+):(.*)$/o;
            if ($dupes{$3}++)
                { push @{$del{$1}}, int($2) }
            else
                { $del{$1} = [] }
        }
        undef %dupes;

        chdir $ARGV[0];

        for $fn (sort <"yx*">) {
            open $fh, "<", $fn
                or die qq(open $fn: $!);
            $line = $idx = 0;
            while(<$fh>) {
                $line++;
                if ($idx < @{$del{$fn}} and $line == $del{$fn}->[$idx])
                    { $idx++ }
                else
                    { print }
            }
            close $fh
                or die qq(close $fn: $!);
            unlink $fn
                or die qq(remove $fn: $!);
        }
    ' "$tdir" >uniq_"$1" || exit 1

【讨论】:

  • 这可能会奏效(我还没有完全考虑清楚),但这是一种非常昂贵的方法。使用多倍的磁盘空间并需要多倍的文件数据才能工作(以及对sed/等的许多调用)。
  • @EtanReisner 如果你仔细看,除了最终结果之外,磁盘上使用的空间只有输入文件(初始文件和拆分块)大小的 2 倍,再加上一个块的大小(每块 10k 行,可能小于 1MB)。 sort 可能需要更多,但那是 sort,不是我的脚本。而且由于每个块只能有一个重复的行,sed 不会运行 那个 多次。这就是首先对块进行重复数据删除的全部意义所在。真的,试着去理解应该发生的事情,它并没有那么糟糕。
  • 磁盘总共只有两次,但每个块在处理循环期间都会被复制。是的,对于这种大小的拆分,每个块可能很小(但这本身可能是一个问题)你的 glob 可能会破坏你的行长度限制,目录遍历可能会大大减慢,因为 fs 可能无法处理目录这么多文件,您可能会因无序读取/写入而炸毁磁盘缓存。您正在向 y* 文件写入唯一行。我们知道唯一行的总数大于内存容量,因此每个文件都有这 10k 行中的大部分。
  • 我承认我还没有完全考虑到这一点,所以它可能没有我想的那么糟糕,但它似乎仍然比其他一些解决方案更糟糕。但确实有用磁盘换内存的好处,这可能意味着这种方法是唯一可能的方法(取决于数据大小、磁盘空间和可用内存)。
  • @EtanReisner x 文件在创建 yx 文件后立即被删除。它们不是重复的。溢出命令行缓冲区是一个细节,如果需要,可以通过xargs 和朋友解决(您是否阅读了上面的“概念证明”警告?)。
【解决方案5】:

如果有很多重复,一种可能性是使用split(1) 将文件拆分为可管理的部分,并使用诸如 sort/uniq 之类的常规方法来汇总唯一行。这将比实际作品本身短。在此之后,您可以比较各个部分以得出实际摘要。

【讨论】:

  • 如果他不能在内存中保存整组独特的行,我不确定这会有什么帮助。在某些时候,他需要能够在所有文件之间进行重复数据删除而不对它们进行排序。我想可能有一个聪明的 N 路重复数据删除算法,它不需要一次内存中的所有唯一行,但我不知道是否有。
  • @EtanReisner 这不是一种无用的方法,可以在几乎合理的时间内完成跨文件删除重复项(请参阅我的回答)。
【解决方案6】:

也许不是您一直在寻找的答案,但这里是:使用布隆过滤器。 https://en.wikipedia.org/wiki/Bloom_filter这类问题是它们存在的主要原因之一。

【讨论】:

    猜你喜欢
    • 2021-02-15
    • 1970-01-01
    • 2011-11-28
    • 1970-01-01
    • 2019-09-06
    • 1970-01-01
    • 2013-05-15
    • 2017-10-22
    • 2011-10-15
    相关资源
    最近更新 更多