ubuntuusers.de

BASH: Text-Listen auswerten und in neue Liste zusammenführen

Status: Ungelöst | Ubuntu-Version: Kubuntu 20.04 (Focal Fossa)
Antworten |

michahe

Anmeldungsdatum:
12. Dezember 2013

Beiträge: 888

Hallo,

aus Maschinensteuerungen erhalte ich Datenlisten, A.txt und B.txt mit "Namen". Alle "Namen" in B.txt sind auch in A.txt vorhanden. Zu den "Namen" gibt es A.txt und B.txt ergänzende Daten, die mit einem oder mehreren BLANKS getrennt sind.

Nun möchte ich eine neue Liste C.txt erstellen lassen, in der

  • zu jedem Namen definiert wird, ob er nur in B oder auch in A vorkommt,

  • die ergänzenden Daten zu den Namen mit TAB (unten gekennzeichnet mit "| ") separiert geschrieben werden (d.h.: Blank erkennen und in TAB wandeln).

A.txt:

anton 17
beate 12
bettina 12
christian 3

B.txt:

anton 17 d4 uu
august 22 f5 88
beate 12 c55 xxx
bettina 12 q3 ww
christian 3 a1 tt
sabine 5 d6 ww

C.txt:

Quelle|Name     |K1 |K2 |K3
A     |antony   |17 |d4 |uu
B     |august   |22 |f5 |88
A     |beate    |12 |c55|xxx
A     |bettina  |12 |q3 |ww
A     |christian|3  |a1 |tt
B     |sabine   |5  |d6 |ww

Wie würdet Ihr das angehen?

Danke, Michael

seahawk1986

Anmeldungsdatum:
27. Oktober 2006

Beiträge: 11311

Wohnort: München

michahe schrieb:

Alle "Namen" in B.txt sind auch in A.txt vorhanden.

[...]

  • zu jedem Namen definiert wird, ob er nur in B oder auch in A vorkommt,

Das passt nicht zusammen - meinst du, dass alle Namen, die in A vorkommen auch in B sind und du wissen willst, ob die nur in B vorhanden sind?

Mit der Shell ginge das z.B. dann z.B. so:

 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
#!/usr/bin/bash
input_1="A.txt"
input_2="B.txt"
output="C.txt"

declare -A B_only
# get names only in the second input file
for e in $(cut -d ' ' -f 1 "$input_1" "$input_2"  | sort | uniq -u)
do
    B_only["$e"]=1
done

# get maximum number of fields from the second input file
n_fields=$(awk '{if (NF > max) {max = NF; line=$0}} END{print line}' "$input_2" | IFS=" " wc -w)

# print the header to the output file
echo -ne "Quelle\tName" > output
for n in seq(1,$(( n_fields - 1))):
    echo -ne "\tK${n}" >> output
echo >> output
    
# process the second input file and write the output
while read -ra line
do
    name="${line[0]}"
    [ "${B_only["$name"]:=0}" -eq 1 ] && origin="B" || origin="A"
    echo -ne "$origin$(printf "\t%s" "${line[@]}")\n"
done < "$input_2" >> output

Ich habe auch noch eine Lösung in Python geschrieben, die auch den Fall handeln kann, dass es Einträge in A gibt, die nicht in B sind:

 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
#!/usr/bin/env python3
import csv
import pathlib

file_a = pathlib.Path("A.txt")
file_b = pathlib.Path("B.txt")
file_c = pathlib.Path("C.txt")

collection = dict()
max_n = 0
for filename in (file_a, file_b):
    with open(filename) as f:
        f_origin = filename.stem
        for line in f:
            idx, *data = line.split()
            o, *_ = collection.get(idx, (None, None))
            origin = o if o else f_origin
            collection[idx] = (origin, *data)
            max_n = max(len(data), max_n)

with open(file_c, 'w', newline='') as csvfile:
    fieldnames = ('Quelle', 'Name', *[f"K{n}" for n in range(1, max_n + 1)])
    writer = csv.DictWriter(csvfile, fieldnames=fieldnames, delimiter='\t')
    writer.writeheader()
    for k, v in sorted(collection.items()):
        origin, *data = v
        write_data = {'Quelle': origin, 'Name': k}
        for n, e in enumerate(data, start=1):
            write_data[f"K{n}"] = e
        writer.writerow(write_data)

michahe

(Themenstarter)

Anmeldungsdatum:
12. Dezember 2013

Beiträge: 888

seahawk1986 schrieb:

michahe schrieb:

Alle "Namen" in B.txt sind auch in A.txt vorhanden. [...]

  • zu jedem Namen definiert wird, ob er nur in B oder auch in A vorkommt,

Das passt nicht zusammen - meinst du, dass alle Namen, die in A vorkommen auch in B sind und du wissen willst, ob die nur in B vorhanden sind?

Hmm. A.txt ist die lange Liste, B.txt die kurze. Ich möchte für alle Einträge der langen Liste das Kennzeichen A (aus der langen Liste) oder B (nur in der kurzen Liste) ermitteln.

seahawk1986

Anmeldungsdatum:
27. Oktober 2006

Beiträge: 11311

Wohnort: München

michahe schrieb:

Hmm. A.txt ist die lange Liste, B.txt die kurze. Ich möchte für alle Einträge der langen Liste das Kennzeichen A (aus der langen Liste) oder B (nur in der kurzen Liste) ermitteln.

Da passt dein Beispiel nicht so ganz dazu (woher der Wechsel von anton zu anthony kommt, ist mir da auch nicht klar) - kannst du da mal eine Problembeschreibung und Beispiele zeigen, die in sich stimmig sind?

shiro Team-Icon

Supporter

Anmeldungsdatum:
20. Juli 2020

Beiträge: 1466

michahe schrieb:

Nun möchte ich eine neue Liste C.txt erstellen lassen, in der

  • zu jedem Namen definiert wird, ob er nur in B oder auch in A vorkommt,

  • die ergänzenden Daten zu den Namen mit TAB (unten gekennzeichnet mit "| ") separiert geschrieben werden (d.h.: Blank erkennen und in TAB wandeln).

Das ist ein eine klassische "join" Aufgabe. Nicht nur SQL beherrscht das. Da die Dateien a.txt und b.txt ja nicht zwangsläufig sortiert sind, müssen sie allerdings zuvor sortiert werden. Weil du in der ersten Spalte das A oder B haben willst, muss man das Merkmal den Tabellen auch erst noch mit sed zufügen. Daher lautet die Lösung:

$ cat a.txt | sort | sed 's/^/A /' >$$;cat b.txt | sort | sed 's/^/B /' | join -j 2 -a 2  -o 1.1 2.1,2.2,2.3,2.4,2.5  $$ - | sed 's/^A B/A/;s/^ //;s/ /\t/g'
A	anton	17	d4	uu
B	august	22	f5	88
A	beate	12	c55	xxx
A	bettina	12	q3	ww
A	christian	3	a1	tt
B	sabine	5	d6	ww
$ 

Die angezeigten Spalten aus der ersten und 2. Datei kann in der Reihenfolge über das "-o" festgelegt werden. Der sed Befehl am Ende bereitet die erste Spalte auf und sorgt für den TAB als Trennzeichen.

rklm Team-Icon

Projektleitung

Anmeldungsdatum:
16. Oktober 2011

Beiträge: 13372

Das ist auch eine schöne Aufgabe für awk, aber bevor ich hier irgendwelche Vorschläge mache, kann ich nurseahawk1986 zustimmen, der schrieb:

Da passt dein Beispiel nicht so ganz dazu (woher der Wechsel von anton zu anthony kommt, ist mir da auch nicht klar) - kannst du da mal eine Problembeschreibung und Beispiele zeigen, die in sich stimmig sind?

Insbesondere müssen wir wissen:

  1. Ist A.txt oder B.txt die lange Liste, die die Basis für die Ausgabe werden soll?

  2. Die Werte aus welcher Datei soll man hinter der ersten Spalte ("B" oder "A") ausgeben?

  3. Wenn die kurze Liste die Werte enthält, die Du ausgeben willst: Was willst Du für diejenigen Zeilen ausgeben, die nicht in der kurzen Liste sind?

Also, wie üblich: erst mal Anforderungen klären, bevor man etwas umsetzen kann.

michahe

(Themenstarter)

Anmeldungsdatum:
12. Dezember 2013

Beiträge: 888

Entschuldigung, es hätte heißen müssen:

Alle "Namen" in A.txt (kurze Liste) sind auch in B.txt (lange Liste) vorhanden. [...]

  • zu jedem Namen definiert wird, ob er nur in B oder auch in A vorkommt,

Nochmal Entschuldigung, bei Anton / Antony hatte ich versucht, allen "Namen" die gleiche Länge zu verpassen; als dies misslang, habe ich vergessen das "y" zu löschen.

Mit der Shell ginge das z.B. dann z.B. so:

1

Diese Variante möchte ich versuchen:

  • Was bedeutet "declare -A B_only"? Hat das minus bei "-A" eine Bedeutung oder reine Namensgebung?

  • In Zeile 18

for n in seq(1,$(( n_fields - 1))):

kommt ein Syntax-Fehler und auch mit ShellCheck finde ich die Ursache nicht.

Danke, Michael

rklm Team-Icon

Projektleitung

Anmeldungsdatum:
16. Oktober 2011

Beiträge: 13372

michahe schrieb:

  • In Zeile 18

for n in seq(1,$(( n_fields - 1))):

kommt ein Syntax-Fehler und auch mit ShellCheck finde ich die Ursache nicht.

Das muss wohl eher heißen

1
2
3
4
5
for n in $(seq 1 $(( n_fields - 1))); do
    echo -ne "\tK${n}" >> output
done

echo >> output

seahawk1986

Anmeldungsdatum:
27. Oktober 2006

Beiträge: 11311

Wohnort: München

michahe schrieb:

  • Was bedeutet "declare -A B_only"? Hat das minus bei "-A" eine Bedeutung oder reine Namensgebung?

Das legt fest, dass die Variable B_only ein Assoziativer Array sein soll.

  • In Zeile 18

for n in seq(1,$(( n_fields - 1))):

kommt ein Syntax-Fehler und auch mit ShellCheck finde ich die Ursache nicht.

Ja, da hat das gedankliche Umschalten zwischen Python's range() Funktion und seq nicht ganz funktioniert... - die Lösung von rklm sollte passen.

shiro Team-Icon

Supporter

Anmeldungsdatum:
20. Juli 2020

Beiträge: 1466

seahawk1986 schrieb:

for n in seq(1,$(( n_fields - 1))):

Ok, wenn mit einer variablem Anzahl von Spalten in b.txt gearbeitet werden soll, wäre der join Befehl als 1-Zeiler wie folgt abzuändern:

$ a=a.txt;b=b.txt;srt() { sort $1 | sed "s/^/$2 /"; }; join -j2 -a2 -o 1.1,$(eval echo 2.{1..$(head -1 $b | srt - B | wc -w)} | tr " " ",") <(srt $a A) <(srt $b B) | sed "s/^A B / A /" | column -t; unset srt
A  anton      17  d4   uu
B  august     22  f5   88
A  beate      12  c55  xxx
A  bettina    12  q3   ww
A  christian  3   a1   tt
B  sabine     5   d6   ww

Bei größeren Tabellen ist join wesentlich schneller als awk, ganz zu schweigen von einer bash-Loop.

rklm Team-Icon

Projektleitung

Anmeldungsdatum:
16. Oktober 2011

Beiträge: 13372

shiro schrieb:

Bei größeren Tabellen ist join wesentlich schneller als awk,

Kannst Du das etwas mehr ausführen? Meines Wissens benutzt awk Hashtabellen für assoziative Arrays (siehe Ende dieser Sektion). Wie kann join da "wesentlich" schneller sein?

ganz zu schweigen von einer bash-Loop.

Klar, das ist ja O(N) und nicht O(1) wie bei einer Hashtabelle.

shiro Team-Icon

Supporter

Anmeldungsdatum:
20. Juli 2020

Beiträge: 1466

rklm schrieb:

Kannst Du das etwas mehr ausführen?

Ich habe mal folgendes Beispiel rechnen lassen:

  • Erzeugen der Daten (Datei mit $2 Zeilen, Länge des Namens über $1 festgelegt). Die Datei wird "namen.sh" genannt:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash
[ -z "$1" ] || NAMMinLen=$1
[ -z "$NAMMinLen" ] && NAMMinLen=8
Anz=${2:-1}

echo "" | gawk \
 -v NAMMinLen="$NAMMinLen" \
 -v Anz="$Anz" \
'BEGIN {
 seed=srand()
 lchr=split("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",achr,"")
 tail=split("17 d4 uu|22 f5 88|12 c55 xxx|12 q3 ww|3 a1 tt|5 d6 ww",atail,"|")
}{
 for(j=1;j<=Anz;j++){
  NAM=""
  for(i=1;i<=NAMMinLen;i++){ NAM=NAM achr[int(rand()*lchr)+1] }
  print NAM " " atail[j%tail+1]
 }
}'
  • In diesen Posts ist das obige Script leicht angepasst (damit es fehlerfrei läuft). Die Datei wird "join.sh" genannt:

 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
#!/usr/bin/bash
input_1="aaa.txt"
input_2="bbb.txt"
output="ccc.txt"

declare -A B_only
# get names only in the second input file
for e in $(cut -d ' ' -f 1 "$input_1" "$input_2"  | sort | uniq -u)
do
    B_only["$e"]=1
done

# get maximum number of fields from the second input file
n_fields=$(awk '{if (NF > max) {max = NF; line=$0}} END{print line}' "$input_2" | IFS=" " wc -w)

# print the header to the output file
echo -ne "Quelle\tName" > output
for n in $(seq 1 $(( n_fields - 1))); do
    echo -ne "\tK${n}" >> $output
done
echo >> $output
    
# process the second input file and write the output
while read -ra line
do
    name="${line[0]}"
    [ "${B_only["$name"]:=0}" -eq 1 ] && origin="B" || origin="A"
    echo -ne "$origin$(printf "\t%s" "${line[@]}")\n"
done < "$input_2" >> $output
  • Nun die Aktionen und Messergebnisse (es werden 100.000 Datensätze für bbb.txt generiert und 100 für aaa.txt):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
$ . namen.sh 8 100000 >bbb.txt
$ cat bbb.txt | cut -d" " -f1 | uniq -u | wc -l
100000
$ head -100 bbb.txt | cut -d" " -f1,2 | sort >aaa.txt
$ cat bbb.txt | sort >$$
$ mv $$ bbb.txt
$ time . ./join.sh

real	3m13,977s
user	1m56,171s
sys	1m26,214s
$
$ time (a=aaa.txt;b=bbb.txt;srt() { sort $1 | sed "s/^/$2 /"; }; join -j2 -a2 -o 1.1,$(eval echo 2.{1..$(head -1 $b | srt - B | wc -w)} | tr " " ",") <(srt $a A) <(srt $b B) | sed "s/^A B / A /;s/^ //;s/ /\t/g" >jjj.txt)

real	0m0,161s
user	0m0,101s
sys	0m0,034s
$
$ echo "scale=2;(3*60+13.997)/0.161" | bc
1204.95
$ # Mit join ist die Aufgabe ca 1200 Mal schneller erledigt

Ein awk Beispiel habe ich bei diesen Posts nicht gefunden. Ich kann natürlich ein awk schreiben, möchte dies aber den Protagonisten überlassen. Beim Vergleich join Befehl zu bash-script erledigt der join Befehl die Aufgabe ca 1200 Mal schneller als das bash-script.

PS: ich habe ein awk script geschrieben, dass praktisch die gleiche Laufzeit hat wie der join Befehl (user 0m0,147s). Das bedeutet, dass

1
2
$ echo "scale=2;0.147/0.101" | bc
1.45

awk ca 45% länger braucht als der join Befehl. Na ja, eventuell schafft ihr es schneller.

Bearbeitet von rklm:

Farbe

Bearbeitet von rklm:

Und [mark] entfernt, was hier nicht mehr funktioniert

rklm Team-Icon

Projektleitung

Anmeldungsdatum:
16. Oktober 2011

Beiträge: 13372

Golfen wir ein wenig...

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ time (a=aaa.txt;b=bbb.txt;srt() { sort $1 | sed "s/^/$2 /"; }; join -j2 -a2 -o 1.1,$(eval echo 2.{1..$(head -1 $b | srt - B | wc -w)} | tr " " ",") <(srt $a A) <(srt $b B) | sed "s/^A B / A /;s/^ //;s/ /\t/g" >jjj.txt)

real	0m0,420s
user	0m0,225s
sys	0m0,008s
$ time ./a.awk aaa.txt bbb.txt >| awk.txt 

real	0m0,352s
user	0m0,324s
sys	0m0,025s

-16%.

a.awk sieht so aus

1
2
3
4
#!/usr/bin/awk -f

FNR==NR {++a[$1]}
FNR!=NR {printf "%s", a[$1]?"A":"B"; for (i=1;i<=NF;++i) printf "\t%s", $i; printf "\n"}

Sorry, das Syntaxhighlighting für awk kennt anscheinend den ternären Operator nicht.

shiro Team-Icon

Supporter

Anmeldungsdatum:
20. Juli 2020

Beiträge: 1466

rklm schrieb:

Golfen wir ein wenig...

Hallo rklm, die Einladung zum Golfen hat mich richtig erfreut! Ich habe dein awk script bei mir auch mal laufen lassen und folgendes Ergebnis erhalten:

$ time  ./a.awk aaa.txt bbb.txt >|awk2.txt

real	0m0,298s
user	0m0,273s
sys	0m0,025s

Na ja, damit ist das auf meinem Rechner doch einen Tick langsamer. Aber ich würde dennoch sagen, dass die Ergebnisse von awk-Script und dem join Befehl praktisch gleich schnell sind. Da ich normalerweise das Attribut eines awk-Fans habe, war es mir nicht so leicht, den join Befehl zu loben. Dennoch lernt man ja nie aus. Deinen "FNR!=NR" Trick um den Wechsel zwischen den beiden Dateien festzustellen (Chapeau!) hatte ich noch nie benutzt. Im Vergleich zu deinem eleganten 2-Zeiler ist mein awk ja richtig plump:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#!/bin/bash
a=aaa.txt
b=bbb.txt
sort $a >tmp.txt
sort $b | gawk -v a="tmp.txt" 'BEGIN {
 getline <a
 name=$1
}{
 if( $1 == name ) {
  print "A "$0
  getline <a
  name=$1
 } else {
  print "B "$0
 }
}' | tr " " "\t"

Mein obiges awk Script liefert auf meinem Rechner folgendes Ergebnis:

$ time . awk.sh >x.xxx

real	0m0,144s
user	0m0,132s
sys	0m0,041s

Kannst ja, mal schauen wie schnell das Script auf deinem Rechner ist (da gibt es offenbar Unterschiede). Ich habe einen Lenovo T420s von 2011. CPU, Memory und Disk verursachen die marginalen Unterschiede.

rklm Team-Icon

Projektleitung

Anmeldungsdatum:
16. Oktober 2011

Beiträge: 13372

shiro schrieb:

rklm schrieb:

Golfen wir ein wenig...

Hallo rklm, die Einladung zum Golfen hat mich richtig erfreut!

☺

Na ja, damit ist das auf meinem Rechner doch einen Tick langsamer. Aber ich würde dennoch sagen, dass die Ergebnisse von awk-Script und dem join Befehl praktisch gleich schnell sind.

Da kann ich mitgehen. Ich finde allerdings mein awk-Skript deutlich lesbarer (gut, man kann es noch auf mehrere Zeilen auffächern).

Da ich normalerweise das Attribut eines awk-Fans habe, war es mir nicht so leicht, den join Befehl zu loben. Dennoch lernt man ja nie aus. Deinen "FNR!=NR" Trick um den Wechsel zwischen den beiden Dateien festzustellen (Chapeau!) hatte ich noch nie benutzt.

Ja, den habe ich auch irgendwann mal aufgeschnappt - ich glaube, das war sogar hier im Forum. ☺

Im Vergleich zu deinem eleganten 2-Zeiler ist mein awk ja richtig plump:

Aber der Ansatz ist gut (s.u.)!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#!/bin/bash
a=aaa.txt
b=bbb.txt
sort $a >tmp.txt
sort $b | gawk -v a="tmp.txt" 'BEGIN {
 getline <a
 name=$1
}{
 if( $1 == name ) {
  print "A "$0
  getline <a
  name=$1
 } else {
  print "B "$0
 }
}' | tr " " "\t"

Ich bin mir nicht sicher, dass dieses Skript korrekt arbeitet. Der Grund ist, dass Du normalerweise mindestens eine Schleife brauchst, wenn Du so aus zwei sortierten Eingaben liest. Der Ansatz an sich ist gut, weil der besser arbeitet, falls beide Dateien richtig groß sind, weil man nicht alle Schlüssel einer Datei als Index in den Speicher laden muss. Ich muss da noch mal drüber schlafen, weil ich jetzt keine Zeit habe. Wollte im Wesentlichen das Messergebnis posten (s.u.).

Kannst ja, mal schauen wie schnell das Script auf deinem Rechner ist (da gibt es offenbar Unterschiede). Ich habe einen Lenovo T420s von 2011. CPU, Memory und Disk verursachen die marginalen Unterschiede.

 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
$ for i in {1..5}; do time ./a.awk aaa.txt bbb.txt >| awk.txt; done

real	0m0,365s
user	0m0,329s
sys	0m0,032s

real	0m0,366s
user	0m0,300s
sys	0m0,055s

real	0m0,363s
user	0m0,311s
sys	0m0,041s

real	0m0,363s
user	0m0,307s
sys	0m0,048s

real	0m0,365s
user	0m0,332s
sys	0m0,020s
$ for i in {1..5}; do time ./b.awk >|awk.txt; done

real	0m0,250s
user	0m0,253s
sys	0m0,026s

real	0m0,320s
user	0m0,327s
sys	0m0,016s

real	0m0,268s
user	0m0,244s
sys	0m0,035s

real	0m0,254s
user	0m0,255s
sys	0m0,021s

real	0m0,328s
user	0m0,328s
sys	0m0,021s

Deine Lösung im Schnitt 0,284s "real", meine 0,364s.

Antworten |