Java: Text einlesen und Häufigkeit der Buchstaben analysieren?

3 Antworten

Ich denke, "Isendrak" und "regex9" haben dir schon sehr gute Hinweise gegeben, allerdings wollte ich nur mal ganz kurz erwähnen, dass es auch völlig andere Ansätze gibt, wie z. B. die neue Stream-API, welche es seit Java 8 gibt:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Stream;

// ...

public static void main(String[] args) throws Exception {
try (final Stream<String> s = Files.lines(Paths.get("/tmp/foo.txt"))) {
s.flatMap(l -> l.chars().mapToObj(i -> (char)Character.toLowerCase(i))).
filter(Character::isLetter).
collect(TreeMap<Character, Integer>::new, (m, c) -> m.merge(c, 1, Integer::sum), Map::putAll).
entrySet().stream().
sorted((a, b) -> a.getValue().equals(b.getValue()) ? a.getKey().compareTo(b.getKey()) : b.getValue() - a.getValue()).
forEach(System.out::println);
} catch (IOException e) {
System.err.println("OMG, WTF!?!");
}
}

Wenn du jetzt eine Datei mit beispielsweise diesem Inhalt hast:

Foo Bar baz,
qux quuux!

... dann bekommst du so eine Ausgabe:

u=4
a=2
b=2
o=2
q=2
x=2
f=1
r=1
z=1

Es stehen quasi die häufigsten Buchstaben oben und die selteneren unten, und bei gleicher Häufigkeit wird alphabetisch sortiert. Nicht vorhandene Buchstaben oder Sonderzeichen werden ignoriert. :)

Mir ist klar, dass du beim obigen Ansatz als Einsteiger noch größere Verständnisprobleme haben wirst, aber mach dir deshalb keinen Kopf! Lerne einfach erst mal schön die Grundlagen und irgendwann kommst du automatisch mit den moderneren Java-Features in Berührung. Jeder hat schließlich mal irgendwo angefangen, und wenn die Grundlagen sitzen, wirst du mit meinem obigen Snippet auch keine größeren Probleme mehr haben.

Noch eine persönliche Anmerkung: Ich weiß zwar, dass Streams als "schick", "elegant" und "hipp" gelten, aber m. M. n. ist das Ganze eine Legitimierung des "Train-Wreck" Antipatterns. ><

Bei Lehrbuch-Beispielen sind aneinandergehängte filter(), map(), compute(), und wie sie alle heißen zwar schön anzusehen, aber in der Praxis ist es NIE so einfach, und im Endeffekt ist der Code dadurch nicht sonderlich gut lesbar. Von der Laufzeit-Ineffizienz mal ganz zu schweigen.

Streams, v. a. mit Lambdas sind zwar ganz toll, aber auch nur, wenn man sie sparsam einsetzt. (Die Betonung liegt auf "sparsam"!)

Und noch eine Kleinigkeit: Im obigen Code-Schnipsel habe ich die Variablen mit s für "Stream", mit l für "Line", mit c für "Char" und mit i für "Integer" bezeichnet, aber auch nur deshalb, um den langen Stream-Schwanz nicht noch unnötig länger zu machen.

Alles in allem würde ich in freier Wildbahn nicht so programmieren, und das solltest du dir als Einsteiger auch möglichst nicht angewöhnen! Wenn du mit den Grundlagen von Java durch bist, lege erst mal eine Pause ein, und lies dir das Buch "Clean Code" durch:

https://www.amazon.de/Clean-Code-Refactoring-Patterns-Techniken/dp/3826655486/

Darin geht es nur um sauberes Programmieren, und das ist ein wichtigeres Thema, als du es dir momentan vermutlich vorstellen kannst. :)

Also dann ... viel Spaß noch beim Lernen, und lass dich von den vielen Infos nicht erschlagen! Eins nach dem anderen ... dann geht das schon. :)

Schönen Tag noch! :)
TeeTier  01.10.2017, 08:03

PS: Ich habe mir jetzt erst genau deinen Code angesehen und bemerkt, dass du eine prozentuale Häufigkeit haben willst. Das ist mit dem obigen Stream-Ansatz auch leicht möglich und verlängert den ganzen Code-Schwanz nochmal um zwei bis drei Elemente, aber ich habe jetzt keine Zeit mehr für Beispielcode. Muss weg ... Sorry. ><

1
TeeTier  01.10.2017, 08:43

PPS: Hatte doch noch ein paar Minuten Zeit für die relative Version:

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.TreeMap;
import java.util.stream.Stream;

// ...

public static void main(String[] args) throws Exception {
try (final Stream<String> s = Files.lines(Paths.get("/tmp/foo.txt"))) {
final Map<Character, Integer> p = s.flatMap(l -> l.chars().mapToObj(i -> (char)Character.toLowerCase(i))).
filter(Character::isLetter).
collect(TreeMap<Character, Integer>::new, (m, c) -> m.merge(c, 1, Integer::sum), Map::putAll);

final Set<Entry<Character, Integer>> entries = p.entrySet();
final double sum = (double)entries.stream().mapToInt(e -> e.getValue()).sum();

entries.stream().
sorted((a, b) -> a.getValue().equals(b.getValue()) ? a.getKey().compareTo(b.getKey()) : b.getValue() - a.getValue()).
forEach(e -> System.out.printf("%c: %.02f%%\n", e.getKey(), e.getValue() / sum * 100.0));
} catch (IOException e) {
System.err.println("OMG, WTF!?!");
}
}

Ich habe ein EntrySet zur "Wiederverwertung" in eine Variable gepackt und die Summe aller Zeichvorkommen vorberechnet. (Die Variablen p und e stehen für "pairs" und "entry" ... Stichwort "Clean Code" ... hahaha)

Im unteren Teil wird sortiert, und bei der gleichen Eingabe-Datei wie oben wird folgendes formatiert ausgegeben:

u: 23.53%
a: 11.76%
b: 11.76%
o: 11.76%
q: 11.76%
x: 11.76%
f: 5.88%
r: 5.88%
z: 5.88%

Aber wie gesagt, so richtig schön finde ich die Streams wirklich nicht. ><

Merke dir für den Anfang lieber, was "regex9" und "Isendrak" geschrieben haben! :)

1

Zusätzlich zu den bereits von "regex9" erwähnten Sachen, gäbe es da noch ein paar:

  • Du verwendest ein Array für den Zähler, hast aber noch dazu für jeden Buchstaben ne eigene Variable, die du aber nie verwendest...
  • Wenn der Text zufälligerweise mehr als 180000 Zeichen hat, bekommst du ein falsches bzw. unvollständiges Ergebnis.
  • Du ignorierst sämtliche Großbuchstaben...
  • Das ganze Verfahren ist irgendwie n bissl konfus implementiert...

So dürfte es funktionieren:

import java.io.*;

public class Buchstabenstatistik{
public static void main(String[] args) throws IOException{
//Das Zählerarray für die Anzahl der Buchstaben
int[] counter = new int[26];
//Initialisieren mit 0
for(int i = 0; i < 26; ++i){
counter[i] = 0;
}
FileReader reader = new FileReader("d:\\time.txt");
//Ein Zwischenspeicher fürs Einlesen der Datei
char[] buffer = new char[1024];
//Eine Zählervariable für die Gesamtzahl der eingelesenen Zeichen
int n;
//Solange noch Daten gelesen werden können, weiterlesen
while((n = reader.read(buffer)) > 0){
//Durch den Zwischenspeicher iterieren
for(int i = 0; i < n; ++i){
//Kleinbuchstaben
if(buffer[i] >= 'a' && buffer[i] <= 'z'){
//Den Wert im Zählerarray erhöhen
++counter[buffer[i] - 'a'];
}
//Großbuchstaben
else if(buffer[i] >= 'A' && buffer[i] <= 'Z'){
//Den Wert im Zählerarray erhöhen
++counter[buffer[i] - 'A'];
}
}
}
reader.close();
//Variable für die Gesamtzahl der eingelesenen Buchstaben (nicht Textzeichen allgemein...)
int N = 0;
//Summieren der Zählerwerte
for(int i = 0; i < 26; ++i){
N += counter[i];
}
for(int i = 0; i < 26; ++i){
//Ausgabe der Ergebnisse:
//Formatierte Ausgabe
System.out.printf(
//Formatstring
"%c: %d (%3.2f%%)",
//Der aktuelle Buchstabe
(char)(i+'A'),
//Absolute Anzahl
counter[i],
//Prozentuale Anzahl
(counter[i] * 100.0) / N
);
//Neue Zeile
System.out.println();
}
}
}
llijnnasil 
Fragesteller
 30.09.2017, 21:50

Vielen, vielen Dank für die ganze Mühe! Das sieht sehr professionell aus. Leider kann ich nicht jede Zeile des Codes bzw. die Schreibweisen nachvollziehen :/ ich werd's mir nochmal genauer anschauen.

1

1)

Du benutzt den falschen Bezeichner in deinem Schleifenkopf:

for (int k1 = 0; k1 < buchstaben.length; k1++)

2)

Die errechneten prozentualen Werte sind wohl so klein, sodass nur 0 angezeigt wird.

3)

Lasse die Leerzeichen zwischen Inkrementaloperator und Bezeichner weg:

k1++ // statt k1 ++

4)

Verwende eindeutige, aussagekräftige Bezeichner.

5)

Ich schreibe noch ein Update als Kommentar.

llijnnasil 
Fragesteller
 30.09.2017, 21:06

Vielen Dank, dass du dir die Mühe gemacht hast, mir zu helfen :) 
Das mit dem falschen Bezeichner hab ich übersehen ^^ 
hab das jetzt gerade geändert und jetzt funktioniert das Zählen der Buchstaben.
Wie kann ich die Prozentzahlen genauer machen?

1
regex9  30.09.2017, 22:30
@llijnnasil

So, wie versprochen mein Update.

Zuallererst habe ich einmal alles etwas in einzelne Methoden ausgelagert, um den Code übersichtlicher zu machen. Wenn du mit Funktionen / Methoden noch nichts anfangen kannst: Dies sind im Grunde nur eigenständige Code-Blöcke, die, so wie die main-Methode für sich einzeln stehen und zur Ausführung aufgerufen werden können.

So sieht die main-Methode nun recht unspektakulär aus:

public static void main(String[] args) {

String letters = readFileInput(); int[] counter = countFrequency(letters);
printCounterResult(counter); printFrequencyInPercent(counter, letters.length()); }

Interessant dürfte vorerst nur sein, dass ich einen String verwende, kein char-Array wie du. Du kannst es aber auch mit char-Array lösen, es gibt viele Wege, dein Problem zu lösen.

Der Grund für den String wird in der ersten Methode deutlich:

private static String readFileInput(){ 

char[] input = new char[180000]; 

try { 
FileReader reader = new FileReader("time.txt"); 
reader.read(input); 
reader.close(); 
} 
catch (IOException ex){ 
ex.printStackTrace(); 
} 

String letters = new String(input); 
return letters.trim().toUpperCase(); 
}

Hier wird die Datei ausgelesen. Den möglichen Fehlerfall, dass die Datei nicht gelesen werden kann, fange ich mit einem try-catch-Block ab. Der erste Block versucht seinen Code auszuführen. Wenn er scheitert, springt er in den 2. Block und gibt den Fehlercode mitsamt Stacktrace aus.

Danach wird der Inhalt des char-Arrays in einen String konvertiert. Die Methoden trim und toUppercase vereinfachen das kommende Prozedere einfach, aber man könnte sie natürlich auch selbst programmieren. Wenn der String getrimmt wird, bedeutet das, dass alle Leerzeichen vorn und hinten rausfliegen. So wird deine Prozentrechnung später genauer. Statt von 180 000 Zeichen auszugehen, rechne ich mit der tatsächlich eingegeben Menge an Zeichen. Zuletzt wird der String in Großbuchstaben umgewandelt (Kleinbuchstaben könntest du ebenso verwenden, müsstest bei der Berechnung aber einen Summand ändern - weiteres später). Ich habe diese Umwandlung vorgenommen, um bei der Berechnung alle Buchstaben einzubeziehen, ob A oder a, sollte meiner Meinung nach für den Zähler egal sein.

private static int[] countFrequency(String letters){ 

int[] counter = new int[26]; 

for (int i = 0; i < counter.length; ++i){ 
counter[i] = 0; 
} 

for (int i = 0; i < letters.length(); ++i){ 
for (int j = 0; j < counter.length; ++j){ 
int letter = j + 'A'; 

if (letters.charAt(i) == letter){ 
++counter[j]; 
break; 
} 
} 
} 

return counter; 
}

Die Zählerei läuft etwas anders, als bei dir. Ich vergleiche den Zahlwert und spare mir so die Initialisierung des ABC-Array. Laut ASCII-Tabelle hat das A den Zahlenwert 48, dort muss ich starten. Daher rechne ich auf den Integer, mit dem ich über das Zähler-Array laufe, immer den Zahlenwert von A auf (alternativ könntest du auch schreiben:

int letter = j + 48;

Ich wollte den Code aber so aussagekräftiger halten. Wäre der String nicht in Groß-, sondern in Kleinbuchstaben umgewandelt worden, müsstest du stattdessen bei 97 beginnen.

Mit charAt greife ich auf das aktuelle Zeichen des Strings zu. Der String selbst ist nichts anderes, als eine Liste an Zeichen.

Die wichtigste Änderung (aus Performance-Gründen) ist das vorzeitige Abbrechen der Schleife, wenn der entsprechende Buchstabe gefunden wurde. Ein A wird ein A bleiben, da lohnt es sich nicht, dennoch bis zum Z extra durchzuprüfen.

Zuletzt bleiben noch die Methoden für die Ausgabe:

private static void printCounterResult(int[] counter){ 

System.out.println("Anzahl der Buchstaben: "); 

for (int i = 0; i < counter.length; ++i) { 
System.out.printf("%c: %d%n", (char)(i + 'A'), counter[i]); 
} 
}

Zu formatierten Ausgaben lies einen dieser Artikel:

Bei der Berechnung der Prozentwerte musste ich etwas überlegen, aufgrund des Problems mit der Genauigkeit.

private static void printFrequencyInPercent(int[] counter, int numberOfLetters){ 

System.out.println("\nHäufigkeit der Buchstaben in Prozent:"); 

double percent; 

for (int i = 0; i < counter.length; ++i) { 
percent = counter[i] * 100 / numberOfLetters; 
System.out.printf("%c: %f%n", (char)(i + 'A'), percent); 
} 
}

Letzten Endes habe ich mich für die einfache Variante entschieden. Multipliziere erst mit 100 und dividiere danach. Das Ergebnis ist dasselbe.

Noch einige andere Anmerkungen:

  • Du liest nur 180 000 Zeichen ein, dies kann das Ergebnis verfälschen. Zudem gibt es bessere Möglichkeiten, zum Beispiel das Lesen einer vollständigen Zeile direkt in einen String.
  • Vermische nicht englischer mit deutscher Sprache bei Bezeichnern.
  • Deklarierte Variablen benötigen nur einen Startwert, wenn sie vor Erstgebrauch nicht definiert werden.
2