Эффективный способ транспонирования файла в Bash


У меня есть огромный разделенный вкладками файл, отформатированный так

X column1 column2 column3
row1 0 1 2
row2 3 4 5
row3 6 7 8
row4 9 10 11

Я хотел бы транспонировать его эффективным способом, используя только команды bash (для этого я мог бы написать десять или около того строк 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 в этом post , но все равно слишком медленно. Какие-нибудь мысли/предложения/блестящие идеи? :- )
25 93

25 ответов:

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

Производительность по сравнению с решением Perl Джонатана на файле 10000 строк

$ 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

EDIT by Ed Morton (@ghostdog74 не стесняйтесь удалять, если вы не одобряете).

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

$ 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 конечно - там YMMV).

Вышеприведенные решения действительно считывают весь файл в память - если входные файлы слишком велики для этого, то вы можете сделать следующее:

$ 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 для ENDFILE и ARGIND, но любой awk может сделать то же самое с тестами на FNR==1 и END.

Другой вариант-использовать rs:

rs -c' ' -C' ' -T

-c изменяет разделитель входных столбцов, -C изменяет разделитель выходных столбцов и -T транспонирует строки и столбцы. Не используйте -t вместо -T, поскольку он использует автоматически вычисляемое число строк и столбцов, которое обычно не является правильным. rs, который назван в честь функции reshape в APL, поставляется с BSD и OS X, но он должен быть доступен из менеджеров пакетов на других платформах.

Секунда вариант-использовать Ruby:

ruby -e'puts readlines.map(&:split).transpose.map{|x|x*" "}'

Третий вариант-использовать jq:

jq -R .|jq -sr 'map(./" ")|transpose|map(join(" "))[]'

jq -R . выводит каждую входную строку в виде строкового литерала JSON, -s (--slurp) создает массив для входных строк после синтаксического анализа каждой строки как JSON, и -r (--raw-output) выводит содержимое строк вместо строковых литералов JSON. Оператор / перегружен для разделения строк.

Решение 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))

Этот код предполагает, что каждая строка содержит одинаковое количество столбцов (заполнение не выполняется).

Проектtranspose на sourceforge является программой coreutil-like C именно для этого.

gcc transpose.c -o transpose
./transpose -t input > output #works with stdin, too.

Чистый Баш, никакого дополнительного процесса. Хорошее упражнение:

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

Вот умеренно солидный 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 была незначительной (1 миллисекунда из 7 полных). С большим набором данных (матрица 100x100, записи по 6-8 символов каждая) perl немного превосходит awk - 0,026 с против 0,042 С. Ни то, ни другое, вероятно, не будет проблемой.


Репрезентативные тайминги для Perl 5.10.1 (32-бит) vs awk (версия 20040207 при задании '- V') vs gawk 3.1.7 (32-разрядный) на MacOS X 10.5.8 на файле, содержащем 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. Очевидно, что ваш пробег будет отличаться.

Посмотрите на GNU datamash, который можно использовать как datamash transpose. Будущая версия также будет поддерживать перекрестную табуляцию (сводные таблицы)

Если у вас есть sc установленный, вы можете сделать:

psc -r < inputfile | sc -W% - > outputfile

Предполагая, что все ваши строки имеют одинаковое количество полей, эта программа 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

Для этого есть специально построенная утилита,

GNU datamash utility

apt install datamash  

datamash transpose < yourfile

Взято с этого сайта, https://www.gnu.org/software/datamash/ и http://www.thelinuxrain.com/articles/transposing-rows-and-columns-3-methods

Единственное улучшение, которое я могу видеть в вашем собственном примере, - это использование awk, которое уменьшит количество запущенных процессов и количество данных, передаваемых между ними:

/bin/rm output 2> /dev/null

cols=`head -n 1 input | wc -w` 
for (( i=1; i <= $cols; i++))
do
  awk '{printf ("%s%s", tab, $'$i'); tab="\t"} END {print ""}' input
done >> output

Решение программистские Perl может быть такой. Это хорошо, потому что он не загружает весь файл в память, печатает промежуточные временные файлы, а затем использует все-замечательную вставку

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

my $counter;
open INPUT, "<$ARGV[0]" or die ("Unable to open input file!");
while (my $line = <INPUT>) {
    chomp $line;
    my @array = split ("\t",$line);
    open OUTPUT, ">temp$." or die ("unable to open output file!");
    print OUTPUT join ("\n",@array);
    close OUTPUT;
    $counter=$.;
}
close INPUT;

# paste files together
my $execute = "paste ";
foreach (1..$counter) {
    $execute.="temp$counter ";
}
$execute.="> $ARGV[1]";
system $execute;

Я использовал решение fgm (спасибо fgm!), но необходимо было исключить символы табуляции в конце каждой строки, поэтому модифицировали скрипт следующим образом:

#!/bin/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" ${array[$COUNTER]}
    if [ $COUNTER -lt $(( ${#array[@]} - $COLS )) ]
    then
        printf "\t"
    fi
  done
  printf "\n" 
done

Я просто искал похожий bash tranpose, но с поддержкой padding. Вот сценарий, который я написал на основе решения fgm, который, кажется, работает. Если это может помочь...

#!/bin/bash 
declare -a array=( )                      # we build a 1-D-array
declare -a ncols=( )                      # we build a 1-D-array containing number of elements of each row

SEPARATOR="\t";
PADDING="";
MAXROWS=0;
index=0
indexCol=0
while read -a line; do
    ncols[$indexCol]=${#line[@]};
((indexCol++))
if [ ${#line[@]} -gt ${MAXROWS} ]
    then
         MAXROWS=${#line[@]}
    fi    
    for (( COUNTER=0; COUNTER<${#line[@]}; COUNTER++ )); do
        array[$index]=${line[$COUNTER]}
        ((index++))

    done
done < "$1"

for (( ROW = 0; ROW < MAXROWS; ROW++ )); do
  COUNTER=$ROW;
  for (( indexCol=0; indexCol < ${#ncols[@]}; indexCol++ )); do
if [ $ROW -ge ${ncols[indexCol]} ]
    then
      printf $PADDING
    else
  printf "%s" ${array[$COUNTER]}
fi
if [ $((indexCol+1)) -lt ${#ncols[@]} ]
then
  printf $SEPARATOR
    fi
    COUNTER=$(( COUNTER + ncols[indexCol] ))
  done
  printf "\n" 
done

Я искал решение для транспонирования любого вида матрицы (nxn или mxn) с любыми данными (числами или данными) и получил следующее решение:

Row2Trans=number1
Col2Trans=number2

for ((i=1; $i <= Line2Trans; i++));do
    for ((j=1; $j <=Col2Trans ; j++));do
        awk -v var1="$i" -v var2="$j" 'BEGIN { FS = "," }  ; NR==var1 {print $((var2)) }' $ARCHIVO >> Column_$i
    done
done

paste -d',' `ls -mv Column_* | sed 's/,//g'` >> $ARCHIVO

Обычно я использую этот маленький фрагмент awk для этого требования:

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

Это просто загружает все данные в двумерный массив a[line,column] и затем печатает его обратно как a[column,line], так что он транспонирует заданный входной сигнал.

Это необходимо, чтобы отслеживать maxколичество столбцов в исходном файле, так что он используется в качестве количества строк для печати обратно.

Если вы хотите извлечь из файла только одну строку $N (через запятую) и превратить ее в столбец:

head -$N file | tail -1 | tr ',' '\n'

Не очень изящно, но эта "однострочная" команда быстро решает проблему:

cols=4; for((i=1;i<=$cols;i++)); do \
            awk '{print $'$i'}' input | tr '\n' ' '; echo; \
        done

Здесь cols-это количество столбцов, в которых можно заменить 4 на head -n 1 input | wc -w.

#!/bin/bash

aline="$(head -n 1 file.txt)"
set -- $aline
colNum=$#

#set -x
while read line; do
  set -- $line
  for i in $(seq $colNum); do
    eval col$i="\"\$col$i \$$i\""
  done
done < file.txt

for i in $(seq $colNum); do
  eval echo \${col$i}
done

Другая версия с set eval

Другое awk решение и ограниченный ввод с размером памяти, который у вас есть.

awk '{ for (i=1; i<=NF; i++) RtoC[i]= (RtoC[i]? RtoC[i] FS $i: $i) }
    END{ for (i in RtoC) print RtoC[i] }' infile

Это соединяет каждый такой же поданный номер positon в совместно и в END печатает результат который был бы первой строкой в первом столбце, второй строкой во втором столбце, etc. Выведет:

X row1 row2 row3 row4
column1 0 3 6 9
column2 1 4 7 10
column3 2 5 8 11

Вот решение Хаскелла. При компиляции с-O2 он работает немного быстрее, чем awk ghostdog, и немного медленнее, чем тонко обернутый C python Стефана на моей машине для повторяющихся входных строк "Hello world". К сожалению, поддержка GHC для передачи кода командной строки не существует, насколько я могу судить, поэтому вам придется записать его в файл самостоятельно. Он будет усекать строки до длины самой короткой строки.

transpose :: [[a]] -> [[a]]
transpose = foldr (zipWith (:)) (repeat [])

main :: IO ()
main = interact $ unlines . map unwords . transpose . map words . lines

Решение awk, которое хранит весь массив в памяти

    awk '$0!~/^$/{    i++;
                  split($0,arr,FS);
                  for (j in arr) {
                      out[i,j]=arr[j];
                      if (maxr<j){ maxr=j}     # max number of output rows.
                  }
            }
    END {
        maxc=i                 # max number of output columns.
        for     (j=1; j<=maxr; j++) {
            for (i=1; i<=maxc; i++) {
                printf( "%s:", out[i,j])
            }
            printf( "%s\n","" )
        }
    }' infile

Но мы можем "ходить" по файлу столько раз, сколько требуется выходных строк:

#!/bin/bash
maxf="$(awk '{if (mf<NF); mf=NF}; END{print mf}' infile)"
rowcount=maxf
for (( i=1; i<=rowcount; i++ )); do
    awk -v i="$i" -F " " '{printf("%s\t ", $i)}' infile
    echo
done

Который (для низкого количества выходных строк быстрее, чем предыдущий код).

Некоторые * Nix стандартные util однострочные, временные файлы не нужны. NB: ОП хотел эффективное исправление (т. е. быстрее), и верхние ответы обычно быстрее. Эти однострочные предназначены для тех, кто любит *Nix "программные средства", по каким-либо причинам. В редких случаях (например, при дефиците ввода-вывода и памяти) эти фрагменты могут быть на самом деле быстрее.

Вызовите входной файл foo .

  1. Если мы знаем, что foo имеет четыре столбцы:

    for f in 1 2 3 4 ; do cut -d ' ' -f $f foo | xargs echo ; done
    
  2. Если мы не знаем, сколько столбцов foo имеет:

    n=$(head -n 1 foo | wc -w)
    for f in $(seq 1 $n) ; do cut -d ' ' -f $f foo | xargs echo ; done
    

    xargs имеет ограничение по размеру и поэтому сделает неполной работу с длинным файлом. Какой предел размера зависит от системы, например:

    { timeout '.01' xargs --show-limits ; } 2>&1 | grep Max
    

    Максимальная длина команды, которую мы могли бы использовать: 2088944

  3. tr & echo:

    for f in 1 2 3 4 ; do cut -d ' ' -f $f foo | tr '\n\ ' ' ; echo ; done
    

    ...или если число столбцов неизвестно:

    n=$(head -n 1 foo | wc -w)
    for f in $(seq 1 $n); do cut -d ' ' -f $f foo | tr '\n\ ' ' ; echo ; done
    
  4. Используя set, которые любят xargs, имеет аналогичные ограничения на размер командной строки:

    for f in 1 2 3 4 ; do set - $(cut -d ' ' -f $f foo) ; echo $@ ; done
    

Вот Bash one-liner, который основан на простом преобразовании каждой строки в столбец и paste - объединении их вместе:

echo '' > tmp1;  \
cat m.txt | while read l ; \
            do    paste tmp1 <(echo $l | tr -s ' ' \\n) > tmp2; \
                  cp tmp2 tmp1; \
            done; \
cat tmp1

M.txt:

0 1 2
4 5 6
7 8 9
10 11 12
  1. Создает файл tmp1, чтобы он не был пустым.

  2. Считывает каждую строку и преобразует ее в столбец с помощью tr

  3. Вставляет новый столбец в файл tmp1

  4. Копирует результат обратно в tmp1.

PS: Я действительно хотел использовать io-дескрипторы, но не смог их получить. работа.

GNU datamash (https://www.gnu.org/software/datamash ) идеально подходит для этой задачи только с одной строкой кода и потенциально сколь угодно большим размером файла! datamash-W транспонировать input_file.тхт > input_file_transposed.txt