在Bash中转置文件的有效方法

139
我有一个格式如下的巨大的制表符分隔文件:
X column1 column2 column3
row1 0 1 2
row2 3 4 5
row3 6 7 8
row4 9 10 11

我希望您能用bash命令高效地进行转换(我可以编写大约10行的Perl脚本来完成,但执行速度应该比原生的bash函数慢)。因此输出应该如下所示:
X row1 row2 row3 row4
column1 0 3 6 9
column2 1 4 7 10
column3 2 5 8 11

我想到了这样一个解决方案。
cols=`head -n 1 input | wc -w`
for (( i=1; i <= $cols; i++))
do cut -f $i input | tr $'\n' $'\t' | sed -e "s/\t$/\n/g" >> output
done

但是这种方法很慢,似乎不是最有效的解决方案。我在这篇文章中看到了一个针对vi编辑器的解决方案,但仍然很慢。有什么想法/建议/创意吗? :-)


13
你为什么认为会有一个比Perl脚本更快的bash脚本存在呢?这正是Perl擅长解决的问题类型。 - Mark Pim
2
@mark,如果这是纯Bash,那么它可能比将所有的cut/sed等工具链接在一起更快。但是,如果你将“Bash”定义为组合工具,则编写一个awk脚本与Perl相比在文本处理方面是可比的。 - ghostdog74
再加一个不理解为什么Perl在这里会很慢的人。是写代码慢吗?还是执行慢?我真的不喜欢Perl,但它确实擅长处理这种任务。 - Corey Porter
如果您的列/字段具有固定的大小/宽度,则可以使用Python文件查找来避免将文件读入内存。您的列/字段是否具有固定的大小/宽度? - tommy.carstensen
3
任何认为Shell脚本比awk或perl更快的人需要阅读http://unix.stackexchange.com/questions/169716/why-is-using-a-shell-loop-to-process-text-considered-bad-practice,以便了解为什么情况并非如此。请注意,Shell循环处理文本被视为不良实践。 - Ed Morton
33个回答

139
awk '
{ 
    for (i=1; i<=NF; i++)  {
        a[NR,i] = $i
    }
}
NF>p { p = NF }
END {    
    for(j=1; j<=p; j++) {
        str=a[1,j]
        for(i=2; i<=NR; i++){
            str=str" "a[i,j];
        }
        print str
    }
}' file

输出

$ more file
0 1 2
3 4 5
6 7 8
9 10 11

$ ./shell.sh
0 3 6 9
1 4 7 10
2 5 8 11

与Jonathan在一份10000行文件上的Perl解决方案相比,性能如何

$ head -5 file
1 0 1 2
2 3 4 5
3 6 7 8
4 9 10 11
1 0 1 2

$  wc -l < file
10000

$ time perl test.pl file >/dev/null

real    0m0.480s
user    0m0.442s
sys     0m0.026s

$ time awk -f test.awk file >/dev/null

real    0m0.382s
user    0m0.367s
sys     0m0.011s

$ time perl test.pl file >/dev/null

real    0m0.481s
user    0m0.431s
sys     0m0.022s

$ time awk -f test.awk file >/dev/null

real    0m0.390s
user    0m0.370s
sys     0m0.010s

这是Ed Morton编辑的版本(@ghostdog74如果您不同意可以删除)。

也许使用一些更明确的变量名称将有助于回答下面的一些问题,通常会澄清脚本正在做什么。它还使用制表符作为分隔符,这是OP最初要求的,因此它可以处理空字段,并且巧合地为这种特定情况美化输出。

$ cat tst.awk
BEGIN { FS=OFS="\t" }
{
    for (rowNr=1;rowNr<=NF;rowNr++) {
        cell[rowNr,NR] = $rowNr
    }
    maxRows = (NF > maxRows ? NF : maxRows)
    maxCols = NR
}
END {
    for (rowNr=1;rowNr<=maxRows;rowNr++) {
        for (colNr=1;colNr<=maxCols;colNr++) {
            printf "%s%s", cell[rowNr,colNr], (colNr < maxCols ? OFS : ORS)
        }
    }
}

$ awk -f tst.awk file
X       row1    row2    row3    row4
column1 0       3       6       9
column2 1       4       7       10
column3 2       5       8       11

上述解决方案适用于任何awk(当然,除了旧的、损坏的awk - 在那里可能会有所不同)。

然而,上述解决方案确实将整个文件读入内存 - 如果输入文件太大,则可以执行以下操作:

$ cat tst.awk
BEGIN { FS=OFS="\t" }
{ printf "%s%s", (FNR>1 ? OFS : ""), $ARGIND }
ENDFILE {
    print ""
    if (ARGIND < NF) {
        ARGV[ARGC] = FILENAME
        ARGC++
    }
}
$ awk -f tst.awk file
X       row1    row2    row3    row4
column1 0       3       6       9
column2 1       4       7       10
column3 2       5       8       11

这个程序几乎不占用内存,但是每读取一行的字段数量就需要读取一次输入文件,因此比将整个文件读入内存的版本要慢得多。它还假定每行的字段数相同,并使用GNU awk的ENDFILEARGIND,但任何awk都可以通过测试FNR==1END来实现相同的功能。


1
注意:awk 的最大字段大小为32767。 - zx8754
嗨,这行代码“NF>p { p = NF }”是什么意思?我不太明白...非常感谢。 - keypoint
很漂亮,但您可能需要指出像我这样不熟悉awk的人,test.awk中放置在引号之间的部分是什么! - daknowles
1
@zx8754,最大字段数仅适用于旧的、非POSIX awk。可能是那个名字极不幸的“nawk”。它不适用于gawk或其他现代awk。 - Ed Morton
我使用了你的第一条awk代码,但在转置后,第一列数据中的前导零消失了。有任何想法吗? - Sigur
显示剩余16条评论

77

csvtk

-t 使用制表符作为分隔符,-d 指定分隔符:

$ seq 4|paste - -|csvtk -t transpose
1   3
2   4
$ seq 4|paste -d, - -|csvtk -d, transpose
1,3
2,4

-l (--lazy-quotes) 允许在未被双引号包围的字段中使用双引号,但我认为没有办法禁用输出中的 CSV 格式转义双引号:

$ csvtk -t transpose<<<$'aa"bb\t2\n3\t4'
[ERRO] parse error on line 1, column 3: bare " in non-quoted-field
$ csvtk -lt transpose<<<$'aa"bb\t2\n3\t4'
"aa""bb"    3
2   4

另一个需要注意的是,-l 并不能阻止删除不需要在 CSV 中加引号的字段周围的双引号:
$ csvtk -lt transpose<<<$'"1"\t2\n3\t4'
1   3
2   4

awk

Gawk版本使用数组的数组:

tp(){ awk '{for(i=1;i<=NF;i++)a[i][NR]=$i}END{for(i in a)for(j in a[i])printf"%s"(j==NR?RS:FS),a[i][j]}' "${1+FS=$1}";}

普通的awk版本使用多维数组(在我的基准测试中,这个版本运行时间大约是两倍):

tp(){ awk '{for(i=1;i<=NF;i++)a[i,NR]=$i}END{for(i=1;i<=NF;i++)for(j=1;j<=NR;j++)printf"%s"(j==NR?RS:FS),a[i,j]}' "${1+FS=$1}";}

macOS自带的Brian Kerningham的nawk版本来自2007年,不支持数组的数组。

如果要使用空格作为字段分隔符而不会折叠多个空格序列,请使用FS='[ ]'

rs

rs是一种BSD实用程序,也随macOS一起提供,但应该可以从其他平台的包管理器中获得。它以APL中的reshape函数命名。

使用空格和制表符的序列作为列分隔符:

rs -T

使用制表符作为列分隔符:

rs -c -C -T

使用逗号作为列分隔符:

rs -c, -C, -T

-c 改变输入列分隔符,-C 更改输出列分隔符。单独使用 -c-C 将分隔符设置为制表符。 -T 转置行和列。

不要使用 -t 代替 -T,因为它选择输出列的数量,使得输出行填满显示器的宽度(默认情况下为80个字符,但可以使用 -w 进行更改)。

当使用 -C 指定输出列分隔符时,每行末尾都会添加一个额外的列分隔符字符,但您可以使用 sed 将其删除:

$ seq 4|paste -d, - -|rs -c, -C, -T
1,3,
2,4,
$ seq 4|paste -d, - -|rs -c, -C, -T|sed s/.\$//
1,3
2,4

rs -T 根据第一行的列数确定列数,因此当第一行以一个或多个空列结束时,它会产生错误的结果:

$ rs -c, -C, -T<<<$'1,\n3,4'
1,3,4,

R

t函数可以转置矩阵或数据框:

Rscript -e 'write.table(t(read.table("stdin",sep=",",quote="",comment.char="")),sep=",",quote=F,col.names=F,row.names=F)'

如果将Rscript -e替换为R -e,则会将正在运行的代码回显到STDOUT,并且如果R命令后跟着像head -n1这样的命令,在读取整个STDIN之前退出,则会导致错误ignoring SIGPIPE signal

如果输入不包含双引号或单引号,则可以删除quote="",如果输入不包含以井号字符开头的行,则可以删除comment.char=""

对于大型输入,data.table::fread通常比read.table更快,data.table::fwritewrite.table更快:

$ seq 1e6|awk 'ORS=NR%1e3?FS:RS'>a
$ time Rscript --no-init-file -e 'write.table(t(read.table("a")),quote=F,col.names=F,row.names=F)'>/dev/null
real  0m1.061s
user  0m0.983s
sys   0m0.074s
$ time Rscript --no-init-file -e 'write.table(t(data.table::fread("a")),quote=F,col.names=F,row.names=F)'>/dev/null

real  0m0.599s
user  0m0.535s
sys   0m0.048s
$ time Rscript --no-init-file -e 'data.table::fwrite(t(data.table::fread("a")),sep=" ",col.names=F)'>/dev/null
x being coerced from class: matrix to data.table

real  0m0.375s
user  0m0.296s
sys   0m0.073s

jq

tp(){ jq -R .|jq --arg x "${1-$'\t'}" -sr 'map(./$x)|transpose|map(join($x))[]';}

jq -R . 将每个输入行作为 JSON 字符串文字打印出来,-s--slurp)在解析每行 JSON 后为输入行创建一个数组,并且 -r--raw-output)输出字符串内容而不是 JSON 字符串文字。 / 运算符被重载以拆分字符串。

Ruby

ruby -e'STDIN.map{|x|x.chomp.split(",",-1)}.transpose.each{|x|puts x*","}'
< p > split 的参数 -1 禁用了在末尾丢弃空字段的功能:

$ ruby -e'p"a,,".split(",")'
["a"]
$ ruby -e'p"a,,".split(",",-1)'
["a", "", ""]

函数形式:

$ tp(){ ruby -e's=ARGV[0];STDIN.map{|x|x.chomp.split(s==" "?/ /:s,-1)}.transpose.each{|x|puts x*s}' -- "${1-$'\t'}";}
$ seq 4|paste -d, - -|tp ,
1,3
2,4

上面的函数使用s==" "?/ /:s,因为当split函数的参数是单个空格时,它会启用类似awk的特殊行为,其中字符串基于连续的空格和制表符进行拆分:
$ ruby -e'p" a  \tb ".split(" ",-1)'
["a", "b", ""]
$ ruby -e'p" a  \tb ".split(/ /,-1)'
["", "a", "", "\tb", ""]

C++

使用cpp -O3 -o tp tp.cpp进行编译:

#include<iostream>
#include<vector>
#include<sstream>

using namespace std;

int main(int argc,char**argv){
  vector<vector<string> >table;
  vector<string>row;
  string line,field;
  char sep=argc==1?'\t':argv[1][0];

  while(getline(cin,line)){
    row.clear();
    stringstream str(line);
    while(getline(str,field,sep))row.push_back(field);
    table.push_back(row);
  }

  int rows=table.size(),cols=table[0].size();
  for(int i=0;i<cols;i++){
    cout<<table[0][i];
    for(int j=1;j<rows;j++)cout<<sep<<table[j][i];
    cout<<endl;
  }
}

基准测试

csvtk 是最快的,jq 是最慢的:

$ seq 1e6|awk 'ORS=NR%1e3?"\t":"\n"'>test
$ TIMEFORMAT=%R
$ time ./tp<test>/dev/null # first run of C++ program is slower
0.895
$ time ./tp<test>/dev/null
0.520
$ time csvtk -t transpose<test>/dev/null
0.142
$ time rs -c -C -T<test|sed $'s/\t$//'>/dev/null
0.587
$ time gawk -F\\t '{for(i=1;i<=NF;i++)a[i][NR]=$i}END{for(i in a)for(j in a[i])printf"%s"(j==NR?RS:FS),a[i][j]}' test>/dev/null
1.119
$ time awk -F\\t '{for(i=1;i<=NF;i++)a[i,NR]=$i}END{for(i=1;i<=NF;i++)for(j=1;j<=NR;j++)printf"%s"(j==NR?RS:FS),a[i,j]}'<test>/dev/null
1.858
$ time jq -R . test|jq --arg x "${1-$'\t'}" -sr 'map(./$x)|transpose|map(join($x))[]'>/dev/null
3.604
$ time ruby -e'STDIN.map{|x|x.chomp.split("\t",-1)}.transpose.each{|x|puts x*"\t"}'<test>/dev/null
0.492

4
我不熟悉rs,谢谢你提供的信息!(链接是Debian的,上游似乎是https://www.mirbsd.org/MirOS/dist/mir/rs/) - tripleee
2
@lalebarde 至少在 OS X 自带的 rs 实现中,仅使用 -c 将输入列分隔符设置为制表符。 - nisetama
2
@lalebarde,尝试使用bash的ANSI-C引用来获取制表符:$'\t' - glenn jackman
3
这是一个极端情况,但对于像 TTC TTA TTC TTC TTT 这样有很多行的大型文件,运行 rs -c' ' -C' ' -T < rows.seq > cols.seq 会出现 rs: no memory: Cannot allocate memory。这是在运行32 GB RAM的FreeBSD 11.0-RELEASE系统。因此,我猜测 rs 把所有内容都放在RAM中,这对速度很有好处,但对于大数据来说不利。 - jrm
1
如果我执行 tp(){ awk '{for(i=1;i<=NF;i++)a[i][NR]=$i}END{for(i in a)for(j in a[i])printf"%s"(j==NR?RS:FS),a[i][j]}' "${1+FS=$1}";} input,我会得到 syntax error near unexpected token 的错误提示。我做错了什么? - mugdi
显示剩余7条评论

33
一个Python解决方案:
python -c "import sys; print('\n'.join(' '.join(c) for c in zip(*(l.split() for l in sys.stdin.readlines() if l.strip()))))" < input > output

以上内容基于以下内容:

import sys

for c in zip(*(l.split() for l in sys.stdin.readlines() if l.strip())):
    print(' '.join(c))

该代码假设每行具有相同数量的列(不执行填充)。


3
这里有一个小问题:将 l.split() 替换为 l.strip().split()(Python 2.7),否则输出的最后一行会出现问题。适用于任意列分隔符,如果您的分隔符存储在变量 sep 中,请使用 l.strip().split(sep)sep.join(c) - krlmlr

27
请看GNU datamash,它可以像datamash transpose一样使用。未来的版本还将支持交叉制表(数据透视表)。
下面是使用空格分隔列的方法:
datamash transpose -t ' ' < file > transposed_file

1
这个答案对于大文件非常有帮助。我面对了一个28GB的文件,datamash非常快速地处理了转置! - mugdi

23
在sourceforge上的transpose项目是一个类似于Coreutil的C程序,用于执行此操作。
gcc transpose.c -o transpose
./transpose -t input > output #works with stdin, too.

谢谢提供链接。但是,当处理大矩阵/文件时,它需要太多的内存。 - tommy.carstensen
它具有块大小和字段大小的参数:尝试调整“-b”和“-f”参数。 - flying sheep
默认块大小(--block或-b)为10kb,默认字段大小(--fieldmax或-f)为64,所以不可能是这个问题。我已经尝试过了。感谢您的建议。 - tommy.carstensen
1
能够很好地处理大小为2 GB的CSV文件。 - discipulus
2
对于一个大约为11k乘5k的矩阵文件,我发现transpose.c比ghostdog74的第一个awk解决方案快了约7倍,内存效率高了约5倍。此外,我发现ghostdog74的“几乎不使用内存”的awk代码无法正常工作。另外,请注意transpose.c程序中的--limit标志,默认情况下将输出限制为1k乘1k的维度。 - ncemami
如果您不喜欢SourceForge或者它无法访问,我已经在GitHub上创建了一个镜像 - jan-glx

17
纯Bash,无需额外进程。一个很好的练习:
declare -a array=( )                      # we build a 1-D-array

read -a line < "$1"                       # read the headline

COLS=${#line[@]}                          # save number of columns

index=0
while read -a line ; do
    for (( COUNTER=0; COUNTER<${#line[@]}; COUNTER++ )); do
        array[$index]=${line[$COUNTER]}
        ((index++))
    done
done < "$1"

for (( ROW = 0; ROW < COLS; ROW++ )); do
  for (( COUNTER = ROW; COUNTER < ${#array[@]}; COUNTER += COLS )); do
    printf "%s\t" ${array[$COUNTER]}
  done
  printf "\n" 
done

这对我的文件起作用,尽管有趣的是它打印出表格第一行的目录列表。我不太了解BASH,无法弄清原因。 - bugloaf
@bugloaf,你的表格角落里有一个星号。 - Hello71
2
@bugloaf:正确引用变量应该可以防止这种情况:printf "%s\t" "${array[$COUNTER]}" - Dennis Williamson

16

GNU datamash非常适合解决这个问题,只需要一行代码,而且可以处理任意大的文件大小!

datamash -W transpose infile > outfile

14

9

这是一个相当不错的Perl脚本,可以完成这个任务。它与@ghostdog74的awk解决方案有很多结构上的相似之处。

#!/bin/perl -w
#
# SO 1729824

use strict;

my(%data);          # main storage
my($maxcol) = 0;
my($rownum) = 0;
while (<>)
{
    my(@row) = split /\s+/;
    my($colnum) = 0;
    foreach my $val (@row)
    {
        $data{$rownum}{$colnum++} = $val;
    }
    $rownum++;
    $maxcol = $colnum if $colnum > $maxcol;
}

my $maxrow = $rownum;
for (my $col = 0; $col < $maxcol; $col++)
{
    for (my $row = 0; $row < $maxrow; $row++)
    {
        printf "%s%s", ($row == 0) ? "" : "\t",
                defined $data{$row}{$col} ? $data{$row}{$col} : "";
    }
    print "\n";
}

对于这个样本数据大小,perl和awk之间的性能差异可以忽略不计(7毫秒中只有1毫秒)。对于更大的数据集(100x100矩阵,每个条目6-8个字符),perl略微优于awk-0.026秒与0.042秒。两者都不太可能成为问题。


在MacOS X 10.5.8上,Perl 5.10.1(32位)与awk(给定“-V”时的20040207版本)vs gawk 3.1.7(32位)的代表性时间,针对包含10,000行,每行5列的文件:

Osiris JL: time gawk -f tr.awk xxx  > /dev/null

real    0m0.367s
user    0m0.279s
sys 0m0.085s
Osiris JL: time perl -f transpose.pl xxx > /dev/null

real    0m0.138s
user    0m0.128s
sys 0m0.008s
Osiris JL: time awk -f tr.awk xxx  > /dev/null

real    0m1.891s
user    0m0.924s
sys 0m0.961s
Osiris-2 JL: 

请注意,gawk在这台机器上比awk要快得多,但仍然比perl慢。 显然,您的表现可能会有所不同。

在我的系统上,gawk 的性能优于 perl。您可以在我编辑的帖子中查看我的结果。 - ghostdog74
4
结论得出:不同平台、不同软件版本会产生不同的结果。 - ghostdog74

7
假设您的所有行都具有相同数量的字段,这个awk程序可以解决问题:
{for (f=1;f<=NF;f++) col[f] = col[f]":"$f} END {for (f=1;f<=NF;f++) print col[f]}

当您遍历行时,对于每个字段f,请生成一个由冒号分隔的字符串col[f],其中包含该字段的元素。完成所有行后,请在单独的行中打印每个这样的字符串。然后,可以通过将输出通过管道传递tr ':' ' '来将“:”替换为所需的分隔符(例如,空格)。
例如:
$ echo "1 2 3\n4 5 6"
1 2 3
4 5 6

$ echo "1 2 3\n4 5 6" | awk '{for (f=1;f<=NF;f++) col[f] = col[f]":"$f} END {for (f=1;f<=NF;f++) print col[f]}' | tr ':' ' '
 1 4
 2 5
 3 6

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