ProjektMatcher verbessern?

Elija7906

Mitglied
Man hat einen Projektnamen String = "k1000000000-1212-21241-model-KI";
Und eine Liste aus Wörtern woraus man das beste Raussuchen soll was zu dem Projektnamen passt.

Code:
@Test
    void test144_k80100() {
        String projektname = "k80100_5144_000-basismodul-model";
        assertEquals("144_k80100", workingSets1.getBestermatch(projektname));
    }

Dazu folgender Algorithmus:
Java:
package Phillip;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

public class ProjektnamenSortierer {

    private final String projektname;
    private final ArrayList<String> allesuchbegriffe;
    private final String bestermatch;

    public ProjektnamenSortierer(String projektname, ArrayList<String> allesuchbegriffe) {

        this.projektname = projektname;
        this.allesuchbegriffe = new ArrayList<>(allesuchbegriffe);
        this.bestermatch = findBestMatch();
    }

    private String normalisieren(String text) {
        return text.toLowerCase(Locale.ROOT);
    }

    // Zerlegt den Projektnamen in Tokens(Teile)...

    private Set<String> zerlegeProjektname(String name) {

        Set<String> tokens = new HashSet<>();

        String normalisiert = normalisieren(name);

        StringBuilder version = new StringBuilder();

        for (char c : normalisiert.toCharArray()) {

            if (Character.isDigit(c)) {

                version.append(c);

            } else if (c == '.' || c == '-') {

                // Trenner innerhalb einer Version ignorieren

            } else {

                if (version.length() > 0) {

                    if (version.length() >= 3) {
                        tokens.add(version.toString());
                    }

                    version.setLength(0);
                }
            }
        }

        if (version.length() >= 3) {
            tokens.add(version.toString());
        }

        String[] teile = normalisiert.split("[\\-_.\\s]+");

        for (String teil : teile) {

            if (!teil.isBlank()) {
                tokens.add(teil);
            }
        }

        return tokens;
    }

    // Zerlegt Suchbegriffe

    private List<String> zerlegeSuchbegriff(String begriff) {

        List<String> teile = new ArrayList<>();

        for (String teil : begriff.split("_")) {

            teil = teil.trim();

            if (!teil.isEmpty()) {
                teile.add(teil);
            }
        }

        return teile;
    }

    private boolean istTechnischeKennung(String text) {

        for (char c : text.toCharArray()) {

            if (Character.isDigit(c)) {
                return true;
            }
        }

        return false;
    }

    private String entferneVersionsTrenner(String text) {
        return text.replace(".", "").replace("-", "");
    }

    // Prüft ob ein Teil im Projekt gefunden wird

    private boolean passt(String suchteil, String projektLower, Set<String> projektTokens) {

        String teil = normalisieren(suchteil);

        if (projektTokens.contains(teil)) {
            return true;
        }

        if (projektLower.contains(teil)) {
            return true;
        }

        String bereinigt = entferneVersionsTrenner(teil);

        if (bereinigt.isEmpty())
            return false;

        if (!bereinigt.equals(teil) && projektLower.contains(bereinigt)) {
            return true;
        }

        boolean nurZiffern = true;

        for (char c : bereinigt.toCharArray()) {

            if (!Character.isDigit(c)) {
                nurZiffern = false;
                break;
            }
        }

        if (nurZiffern) {

            for (String token : projektTokens) {

                if (token.equals(bereinigt)) {
                    return true;
                }

                // 5142 -> 142
                // 5144 -> 144

                if (token.endsWith(bereinigt)) {
                    return true;
                }
            }
        }

        return false;
    }

    // TODO Ist das schon gerhard coded? -> Phillip fragen!

    // Marker-Teile - die nicht im Projektnamen vorkommen müssen
    private static final Set<String> OPTIONALE_MARKER = Set.of("kv");

    private boolean istOptionalerMarker(String teil) {
        return OPTIONALE_MARKER.contains(normalisieren(teil));
    }

    // Prüft ob alle Bestandteile gefunden wurden

    private boolean istVolltreffer(String begriff, String projektLower, Set<String> projektTokens) {

        List<String> teile = zerlegeSuchbegriff(begriff);

        if (teile.isEmpty()) {
            return false;
        }

        boolean mindestensEinEchterTreffer = false;

        for (String teil : teile) {

            if (istOptionalerMarker(teil)) {
                continue; // muss nicht im Projekt vorkommen
            }

            if (!passt(teil, projektLower, projektTokens)) {
                return false;
            }

            mindestensEinEchterTreffer = true;
        }

        return mindestensEinEchterTreffer;
    }

    // Bewertet Volltreffer

    private int berechneVolltrefferScore(String begriff) {

        List<String> teile = zerlegeSuchbegriff(begriff);

        int technischeTeile = 0;
        int laenge = 0;

        for (String teil : teile) {

            laenge += teil.length();

            if (istTechnischeKennung(teil)) {
                technischeTeile++;
            }
        }

        return technischeTeile * 10000 + teile.size() * 1000 + laenge;
    }

    // Bewertet Teiltreffer

    private int berechneTeiltrefferScore(String begriff, String projektLower, Set<String> projektTokens) {

        List<String> teile = zerlegeSuchbegriff(begriff);

        int treffer = 0;
        int technischeTreffer = 0;

        for (String teil : teile) {

            if (passt(teil, projektLower, projektTokens)) {

                treffer++;

                if (istTechnischeKennung(teil)) {
                    technischeTreffer++;
                }
            }
        }

        if (treffer == 0) {
            return -1;
        }
        return treffer * 100000 + technischeTreffer * 10000 + teile.size() * 1000;
    }

    // Sucht den besten Treffer.

    private String findBestMatch() {

        String projektLower = normalisieren(projektname);

        Set<String> projektTokens = zerlegeProjektname(projektname);

        String besterTreffer = "0";
        int besterScore = -1;

        // Phase 1: Nur Volltreffer

        for (String begriff : allesuchbegriffe) {

            if (begriff == null || begriff.isBlank()) {
                continue;
            }

            if (istVolltreffer(begriff, projektLower, projektTokens)) {

                int score = berechneVolltrefferScore(begriff);

                if (score > besterScore) {

                    besterScore = score;
                    besterTreffer = begriff;
                }
            }
        }

        if (besterScore >= 0) {
            return besterTreffer;
        }

        // Phase 2: Teiltreffer

        for (String begriff : allesuchbegriffe) {

            if (begriff == null || begriff.isBlank()) {
                continue;
            }

            int score = berechneTeiltrefferScore(begriff, projektLower, projektTokens);

            if (score > besterScore) {

                besterScore = score;
                besterTreffer = begriff;
            }
        }

        return besterTreffer;
    }

    public String getBestermatch() {
        return bestermatch;
    }
    
}

Coverten damit der Test geht:

Java:
package Phillip;

import java.util.ArrayList;
import java.util.Arrays;

public class BestMatchFinder {

    private final ArrayList<String> suchbegriffe;

    public BestMatchFinder(String... suchbegriffe) {
        this.suchbegriffe = new ArrayList<>(Arrays.asList(suchbegriffe));
    }

    public String getBestermatch(String projektname) {

        ProjektnamenSortierer sortierer = new ProjektnamenSortierer(projektname, suchbegriffe);

        return sortierer.getBestermatch();


Und ich würde gerne wissen ob das besser geht/ob ich große fehler mache?
Wichtig! Keine Libraries oder so in der Art.

Vielen Dank im voraus fürs Helfen und Testen 🙂

LG Elija
 
Und ich würde gerne wissen ob das besser geht/ob ich große fehler mache?
Wichtig! Keine Libraries oder so in der Art.
Dazu solltest Du zuallererst beschreiben was du machen möchtest. Deine Formulierung ..
Man hat einen Projektnamen String = "k1000000000-1212-21241-model-KI";
Und eine Liste aus Wörtern woraus man das beste Raussuchen soll was zu dem Projektnamen passt.
.. ist da etwas schwammig.
Es gilt also, einen Projektnamen gegeben als String zu untersuchen.
Man greift dabei auf eine Liste von Wörten zurück die dabei benutzt werden.
Was aber bedeutet das Beste das zu den Namen passen soll?
Müssen die Wörter vollständig oder nur Teile davon gefunden werden?
Was passiert wenn mehrere Wörter gefunden werde?
Gibt es ein vorgegebenes Format für den Programmnamen ?
Falls ja wie sieht das aus?
Was ist das gewünschte Ergebnis?
....

Bevor man auch nur eine Zeile Code schreibt, sollte man genau wissen was das Ziel ist. Anschließend sollte man diese Anforderungen genau formulieren.
Ein Blick in deinen Code führt dann zu weiteren Verwirrungen.
Da kommt eine Klasse ProjektnamenSortierer vor. Das suggeriert, dass eine weitere Anforderung das Sortieren von vielen Projektnamen ist. In der Klasse selbst wird aber nicht sortiert. Sie zerlegt den Namen und arbeitet mit der oben beschriebenen Liste von Wörtern was eher einem Parsing entspricht.
Möchtest du auch sortieren ?
Falls ja, dann ein Tipp von mir. Teile und herrsch. Das bedeutet nachdem du das gwünschte Ergbnis in Worten beschrieben hast zerlege das Problem in kleinere Problem und löse diese nacheinander.
z.B. Das parsen eines Programmnamen. Er wird in die gewünschten Token zerlegt. Also brauchst du zuerst Token. Vielleicht werden da verschieden Typen benötigt.
NAME, DATUM, VERSION, OPTIONAL usw.
1) Mache eine Klasse Token die verschiedene TYPEN unterstützt. Eventuell mit einer Gewichtung der Token. Das hilft dann später beim Sortieren.
2) Mache eine Klasse ProjektName die die jeweiligen Token beinhaltet.
3) Mache eine Klasse NamenParser die einen gültigen String in TOKEN zerlegt und einen ProjektName liefert oder bei ungeignetem Format einen Fehler wirft.
4) Gib der Klasse ProjektName einen Konstruktor mit einem String als Parameter. Dort benutzt du die Klasse NamenParser.
5) Sortiern .......

Aber zuerst beschreibe dein Projekt mit konkreten Worten.

Vielen Dank im voraus fürs Helfen und Testen 🙂
Das Testen setzt eine Beschreibung des Problems voraus.
workingSets1.getBestermatch(projektname)
Du hast dein workingSet nicht gepostet. Man kann nicht testen was man nicht hat.😉
 
Naja, zusätzlich ist mir sofort ein Klassiker aufgefallen. Die Variable "besterTreffer" wird mit dem String "0" initialisiert.

Wenn keiner der Suchbegriffe trifft (oder auch leere Liste), wird am Ende "0" zurückgegeben. Das kann beim Aufrufer zu Verwirrung führen, da man vermuten könnte, dass "0" ein tatsächlicher Suchbegriff aus der Liste war. Es wäre besser null oder einen leeren String für die Initialisierung zu verwenden.

Bei "private boolean passt" weiß ich nicht ob es Absicht ist - aber:

Java:
                // 5142 -> 142
                // 5144 -> 144

                if (token.endsWith(bereinigt)) {
                    return true;
                }
Der Kommentar sagt nicht die ganze Wahrheit. Es sind weitere Möglichkeiten gültig:

Java:
// 5142 -> 142
// 5142 ->  42
// 5142 ->   2
Bei kurzen Ziffernfolgen kann es zu "false positives" kommen.
 
Zuletzt bearbeitet:
Ich finde die Umsetzung recht stark - wie du das Tokenizing und die Suchlogik aufgebaut hast – das greift gut ineinander! Mir ist aber aufgefallen, dass aktuell die gesamte Logik direkt im Konstruktor durchläuft.
Das ist ein Anti-Pattern – wird als „Heavy Constructor“ oder „Computation in Constructor“ bezeichnet - die Namen musste ich auch erst einmal raussuchen.
Der Ctor sollte nur der Initialisierung dienen und die Logik sollte separat aufgerufen werden.

Einer der Gründe ist schlechte Testbarkeit: Wenn du diese Klasse in einem Unittest testen möchtest, kannst du das Objekt nicht einfach mit Dummy-Werten erzeugen, ohne dass sofort die gesamte Matching-Logik durchläuft. Kommt es dabei zu einer Exception kommt es gar nicht zu dem eigentlichen Test. Das ist verwirrend. Außerdem sind alle wichtigen Methoden private, was Unit-Tests für einzelne Teilbereiche (wie zerlegeProjektname oder passt) unmöglich macht.
 
Java:
// Trenner innerhalb einer Version ignorieren
Ich weiß nicht, ob ich das gut finde. Entfernt man die Trenner, dann sind plötzlich folgende Versionen gleich:
1.1.10 -> 1110
1.11.0 -> 1110
 
Hm, es sieht so aus, als würdest du hier die Levenshtein-Distanz (auch Edit-Distance genannt) über alle unique Substrings suchen...

Java:
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

public class EditDistance {
  private record Match(String value, double score) {}

  public static String test(String a, String... bs) {
    List<Match> matches = new ArrayList<>();
    for (String b : bs) {
      matches.add(new Match(b, bestMatch(a, b)));
    }
    if (matches.isEmpty()) {
      throw new IllegalArgumentException("At least one comparison string is required");
    }

    matches.sort(Comparator.comparingDouble(Match::score).reversed());
    for (Match match : matches) {
      System.out.println(match.value() + ": " + match.score());
    }

    return matches.get(0).value();
  }

  private static double bestMatch(String s1, String s2) {
    return uniqueSubstrings(s2).stream()
        .mapToDouble(s3 -> editDistance(s1, s3))
        .max()
        .orElseThrow();
  }

  private static List<String> uniqueSubstrings(String s) {
    Set<String> uniqueSubstrings = new LinkedHashSet<>();
    for (int start = 0; start < s.length(); start++) {
      for (int end = start + 1; end <= s.length(); end++) {
        uniqueSubstrings.add(s.substring(start, end));
      }
    }
    return new ArrayList<>(uniqueSubstrings);
  }

  private static double editDistance(String s1, String s2) {
    if (s1.equals(s2)) {
      return 1.0;
    }

    if (s1.isEmpty() || s2.isEmpty()) {
      return 0.0;
    }

    int[] previous = new int[s2.length() + 1];
    int[] current = new int[s2.length() + 1];
    for (int j = 0; j <= s2.length(); j++) {
      previous[j] = j;
    }

    for (int i = 1; i <= s1.length(); i++) {
      current[0] = i;
      for (int j = 1; j <= s2.length(); j++) {
        int substitutionCost = s1.charAt(i - 1) == s2.charAt(j - 1) ? 0 : 1;
        current[j] =
            Math.min(
                Math.min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + substitutionCost);
      }

      int[] temporary = previous;
      previous = current;
      current = temporary;
    }

    int editDistance = previous[s2.length()];
    return 1.0 - (double) editDistance / Math.max(s1.length(), s2.length());
  }

  public static void main(String[] args) {
    test("k80100_5144_000-basismodul-model", "144_k80100", "k80100", "144", "k80100_144", "00basis");
  }
}

Ergebnis ist hier, k80100_144 passt am besten zur Suchbasis:

k80100_144: 0.3125
00basis: 0.21875
144_k80100: 0.1875
k80100: 0.1875
144: 0.09375
 
Hallo zusammen,


danke für euer Feedback! Ich habe den Code überarbeitet und gehe kurz durch, was ich übernommen habe und was noch offen ist.


Zur Problembeschreibung (@Blender3D):
Ich bekomme einen Projektnamen, z. B. k80100_5144_000-basismodul-model, und eine Liste von Suchbegriffen, z. B. 144_k80100, k80100, 144. Gesucht ist der Suchbegriff, der am besten zum Projektnamen passt. Die Teile eines Suchbegriffs müssen nicht komplett im Projektnamen stehen, aber je mehr Teile passen, desto besser. Teile mit Ziffern (Kennungen) zählen mehr als reine Wörter. Mein Testfall erwartet hier 144_k80100.


Was ich umgesetzt habe:


  • Scoring (@Blender3D, Gewichtung): Statt zwei Phasen (Volltreffer / Teiltreffer) gibt es jetzt ein einheitliches Scoring. Jeder gefundene Teil zählt mit Länge × Gewicht (Kennung 5, Wort 1). Jeder fehlende Teil gibt einen Abzug in Höhe seiner Länge. Die Gewichte stehen als Konstanten oben in der Klasse.
  • Levenshtein (@Escobar): Danke für den Hinweis! Ich nutze die Distanz jetzt für ähnliche Kennungen. Ab 5 Zeichen darf eine Kennung um 1 Zeichen abweichen (k0010 ~ k80010). Dein Ansatz über alle Substrings wäre mir zu teuer, weil es bei langen Namen schnell quadratisch bis kubisch wird. Für meinen Fall reichte die begrenzte Variante.
  • Suchbegriffe zerlegen: Das passiert jetzt per Regex und nicht mehr nur an _. Punkte zwischen Ziffern (1.4.2) bleiben erhalten.
  • Hardcodierter Marker "kv": Ist entfernt.
  • Javadoc: Ergänzt.

Was noch offen ist (@Oneixee5):


  • "0" als Rückgabewert bei keinem Treffer: Das stimmt, ich stelle auf null bzw. Optional<String> um.
  • Heavy Constructor: Die Logik soll aus dem Konstruktor raus. Ich plane, findBestMatch() öffentlich zu machen und den Konstruktor nur noch Werte zuweisen zu lassen.
  • Private Methoden: Ich mache die Kernmethoden package-private, damit ich passt() und zerlegeProjektname() einzeln testen kann.
  • endsWith-False-Positives (5142 → 42, 2): Ich überlege, eine Mindestlänge oder eine feste Regel (z. B. nur die letzten 3 Ziffern) einzuführen.
  • Versionstrenner (1.1.10 = 1.11.0 = 1110): Das ist ein echtes Problem. Ich muss noch klären, ob Versionen überhaupt in den Projektnamen vorkommen.

Falls jemand weitere Schwachstellen sieht, gerne her damit.

LG Elija


Code:
Java:
package bestMatch.workingSet;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;

public class ProjektnamenSortierer{
    // Ab dieser Länge darf eine Kennung um 1 Zeichen abweichen (k0010 ~ k80010)
    private static final int MIN_LAENGE = 5;

    private static final int GEWICHT_TECHNISCH = 5;
    private static final int GEWICHT_WORT = 1; // Wort bewertung für Scoring
    private static final int STRAFE_FEHLEND = 1; // Scoring strafe...
    private static final int KEIN_TREFFER = Integer.MIN_VALUE; // Schlechteste Mögliche auswärtung ->Min_Vlue

    // Trennt an allem außer Buchstaben/Ziffern. Punkte zwischen Ziffern bleiben
    // (1.4.2)
    private static final Pattern SUCHBEGRIFF_TRENNER = Pattern.compile("[^\\p{L}\\p{N}.]+|(?<!\\d)\\.|\\.(?!\\d)");

    private final String projektname;
    private final ArrayList<String> allesuchbegriffe;
    private final String bestermatch;

    /**
     * Erstellt einen ProjektnamenSortierer und sucht den besten Treffer.
     *
     * @param projektname
     * @param allesuchbegriffe
     */
    public ProjektnamenSortierer(String projektname, ArrayList<String> allesuchbegriffe) {

        this.projektname = projektname;
        this.allesuchbegriffe = new ArrayList<>(allesuchbegriffe);
        this.bestermatch = findBestMatch();
    }

    /**
     * Normalisiert einen Text, indem er in Kleinbuchstaben umgewandelt wird.
     *
     * @param text
     * @return
     */
    private String normalisieren(String text) {
        return text.toLowerCase(Locale.ROOT);
    }

    /**
     * Prüft, ob zwei Texte höchstens ein Zeichen Unterschied haben.
     *
     * @param a
     * @param b
     * @return
     */
    private boolean aehnlich(String a, String b) {

        if (Math.abs(a.length() - b.length()) > 1) {
            return false;
        }

        return levenshtein_recursive(a, b) <= 1;
    }

    /**
     * Berechnet die Levenshtein-Distanz zwischen zwei Texten.
     *
     * @param a
     * @param b
     * @return
     */
    private int levenshtein_recursive(String a, String b) {

        int[] vorher = new int[b.length() + 1];
        int[] aktuell = new int[b.length() + 1];

        for (int j = 0; j <= b.length(); j++) {
            vorher[j] = j;
        }

        for (int i = 1; i <= a.length(); i++) {

            aktuell[0] = i;

            for (int j = 1; j <= b.length(); j++) {
                int kosten = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;

                aktuell[j] = Math.min(Math.min(aktuell[j - 1] + 1, vorher[j] + 1), vorher[j - 1] + kosten);
            }

            int[] tmp = vorher;
            vorher = aktuell;
            aktuell = tmp;
        }

        return vorher[b.length()];
    }

    /**
     * Zerlegt den Projektnamen in Tokens(Teile)
     *
     * @param name
     * @return
     */
    private Set<String> zerlegeProjektname(String name) {

        Set<String> tokens = new HashSet<>();

        String normalisiert = normalisieren(name);

        StringBuilder version = new StringBuilder();

        for (char c : normalisiert.toCharArray()) {

            if (Character.isDigit(c)) {

                version.append(c);

            } else if (c == '.' || c == '-') {

                // Trenner innerhalb einer Version ignorieren

            } else {

                if (version.length() > 0) {

                    if (version.length() >= 3) {
                        tokens.add(version.toString());
                    }

                    version.setLength(0);
                }
            }
        }

        if (version.length() >= 3) {
            tokens.add(version.toString());
        }

        String[] teile = normalisiert.split("[\\-_.\\s]+");

        for (String teil : teile) {

            if (!teil.isBlank()) {
                tokens.add(teil);
            }
        }

        return tokens;
    }

    /**
     * Zerlegt Suchbegriffe
     *
     * @param begriff
     * @return
     */
    private List<String> zerlegeSuchbegriff(String begriff) {

        List<String> teile = new ArrayList<>();

        for (String teil : SUCHBEGRIFF_TRENNER.split(begriff)) {
            if (!teil.isBlank()) {
                teile.add(teil);
            }
        }
        return teile;
    }

    /**
     * Prüft, ob ein Text mindestens eine Ziffer enthält.
     *
     * @param text
     * @return
     */
    private boolean istTechnischeKennung(String text) {

        for (char c : text.toCharArray()) {

            if (Character.isDigit(c)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Entfernt VersionsTrenner("." -> "" / "-" -> "")
     *
     * @param text
     * @return
     */
    private String entferneVersionsTrenner(String text) {
        return text.replace(".", "").replace("-", "");
    }

    /**
     * Prüft ob ein Teil im Projekt gefunden wird
     *
     * @param suchteil
     * @param projektLower
     * @param projektTokens
     * @return
     */
    private boolean passt(String suchteil, String projektLower, Set<String> projektTokens) {

        String teil = normalisieren(suchteil);

        if (projektTokens.contains(teil)) {
            return true;
        }

        if (projektLower.contains(teil)) {
            return true;
        }

        String bereinigt = entferneVersionsTrenner(teil);

        if (bereinigt.isEmpty()) {
            return false;
        }

        if (!bereinigt.equals(teil) && projektLower.contains(bereinigt)) {
            return true;
        }

        boolean nurZiffern = true;

        for (char c : bereinigt.toCharArray()) {

            if (!Character.isDigit(c)) {
                nurZiffern = false;
                break;
            }
        }

        if (nurZiffern) {

            for (String token : projektTokens) {

                if (token.equals(bereinigt)) {
                    return true;
                }

                // 5142 -> 142
                // 5144 -> 144
                // Hier werden die Wichtigen Zahlen rausgefilter

                if (token.endsWith(bereinigt)) {
                    return true;
                }
            }
        }
        // Ähnliche Kennung: höchstens 1 Zeichen Abweichung (k0010 ~ k80010)
        if (istTechnischeKennung(teil) && teil.length() >= MIN_LAENGE) {

            for (String token : projektTokens) {

                if (token.length() >= MIN_LAENGE && aehnlich(teil, token)) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Berechnet den Score eines Suchbegriffs anhand der gefundenen und nicht
     * gefundenen Teile. // Neues Scoring system welches jeden teil nach länge
     * bewertet zB: gefunden + // Ziffer: Länge × 5gefunden // nur Buchstaben: Länge
     * × 1 // nicht gefunden: − Länge // kein Teil gefunden: kein Kandidat
     *
     * @param begriff
     * @param projektLower
     * @param projektTokens
     * @return
     */
    private int berechneScore(String begriff, String projektLower, Set<String> projektTokens) {

        int score = 0;
        int treffer = 0;

        for (String teil : zerlegeSuchbegriff(begriff)) {

            int laenge = teil.length();

            if (passt(teil, projektLower, projektTokens)) {
                treffer++;
                score += laenge * (istTechnischeKennung(teil) ? GEWICHT_TECHNISCH : GEWICHT_WORT);
            } else {
                score -= laenge * STRAFE_FEHLEND;
            }
        }

        return treffer == 0 ? KEIN_TREFFER : score;
    }

    /**
     * Sucht den besten Treffer aus allen Suchbegriffen.
     *
     * @return
     */
    private String findBestMatch() {

        String projektLower = normalisieren(projektname);
        Set<String> projektTokens = zerlegeProjektname(projektname);

        String besterTreffer = "0";
        int besterScore = KEIN_TREFFER;

        for (String begriff : allesuchbegriffe) {

            if (begriff == null || begriff.isBlank()) {
                continue;
            }

            int score = berechneScore(begriff, projektLower, projektTokens);

            if (score > besterScore) {
                besterScore = score;
                besterTreffer = begriff;
            }
        }

        return besterTreffer;
    }

    /**
     * Gibt den besten gefundenen Treffer zurück.
     *
     * @return
     */
    public String getBestermatch() {
        return bestermatch;
    }

}
 
Dein Ansatz über alle Substrings wäre mir zu teuer, weil es bei langen Namen schnell quadratisch bis kubisch wird. Für meinen Fall reichte die begrenzte Variante.
Könnte auch sein, dass über alle Substrings zu gehen (uniqueSubstrings()), gar nicht nötig ist, wenn der Suchbegriff vorher per RegEx zerteilt wird.

Btw. editDistance() nicht-rekursiv ist etwas schneller als rekursiv.
 
Hey, noch kurz ich habe den jetzigen code so genutzt. Ich habe nur noch eine kleine Änderung gemacht bevor ich den code Commitet/Pushed habe. Das ist in Zeile 310 wo ich 0 zu null geändert habe.

Danke an all eure Tipps und vor allem ein großen Dank an @Blender3D das du mir die Augengeöffnet hast und ich den code in Vielen Ecken verbessern/umschreiben konnte.

Code:
String besterTreffer = null;

DAVOR

String besterTreffer = 0

([ Ich war außerdem die ganze Nacht am coden und brauch jetzt erstmal wieder Schlaf 🙂 ]

Lg Elija

​

 
private int levenshtein_recursive(String a, String b) {
Eine kleine Anmerkung zu deinem levenshtein Algorithmus.

Das ist ein Paradebeispiel für eine Hilfsfunktion. Warum? Der Algorithmus greift nicht auf die Klasse die ihn benutzt zu. Das bedeutet man kann sich da z.B, Eine Utility Klasse machen und diese Funktion statisch zur Verfügung stellen.
Die Vorteile:
1) Die Klasse, die den Algorithmus benötigt wird kleiner und lesbarer.
2) Wenn der Algorithmus anderorts benötigt wird lässt er sich leicht wiederverwenden.

Außerdem deutest du an, dass der Algorithmus rekursiv ist. Das stimmt aber nicht. Die Funktion ruft sich selbst nicht auf.
Ein weiterer Punkt. Ein 2D Array erspart dir das ständige Tauschen der Referenzen auf deine 2
1D Arrays.

Java:
public class Util {
    /**
     * Calculates Levenshtein distance between two words.
     *
     * @param a String
     * @param b String
     * @return int
     */
    public static int levenshtein(String a, String b) {   
        int aLen = a.length();
        int bLen = b.length();
        int[][] dist = new int[aLen + 1][bLen + 1];
        for (int i = 1; i <= aLen; i++) {
            dist[i][0] = i;
            for (int j = 1; j <= bLen; j++) {
                dist[0][j] = j;
                int cost = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
                dist[i][j] = Math.min(Math.min(dist[i][j - 1] + 1, dist[i - 1][j] + 1), dist[i - 1][j - 1] + cost);
            }
        }
        return dist[aLen][bLen];
    }   
}
 
Code Smell: "Message Chain / Unnötige Weiterleitung" - Wenn eine Methode nur eine andere Methode aufruft, ohne einen Mehrwert zu bieten, nennt man das Inline Method oder unnötige Indirektion:

Java:
    /**
     * Normalisiert einen Text, indem er in Kleinbuchstaben umgewandelt wird.
     *
     * @param text
     * @return
     */
    private String normalisieren(String text) {
        return text.toLowerCase(Locale.ROOT);
    }
 
unnötige Indirektion
Nicht ganz, du hast den Locale.ROOT-Parameter übersehen.

Eine kleine Anmerkung zu deinem levenshtein Algorithmus.
Der Typ hieß übersetzt Vladimir Levenshtein (eigentlich Wladimir Lewenstein)... und den snake_case sollte man in Java vermeiden. Und wenn du eine Utils-Klasse einführst (Plural...), dann dürfen die Checks nicht fehlen, ob die Strings wirklich nicht leer sind.

Wenn das Trennzeichen (separator char) vorher nicht bekannt ist, würden die alle Vereinigungen aller unique Subtrings Sinn machen... das sind aber viele.

Beispiel:

aa_b -> a, aa, aab, ab -> a+aa, a+aab, a+ab, aab+a, ab+a, aa+aab, aab+aa, ab+aa, aab+ab, ab+aaab.

-> aaa, aaab, aab, aaba, aba, aaaab, aabaa, abaa, aabab, abaaab

-> aaa, aaaab, aaab, aab, aaba, aabaa, aabab, aba, abaa, abaaab (sortiert)

(Das + hier nur, damit man noch etwas erkennt...)
 
Nicht ganz, du hast den Locale.ROOT-Parameter übersehen.
Jaein, ich bin mal nicht von türkischen Projektnamen ausgegangen. Locale.ROOT sollte hier also gar keine Rolle spielen.
Der Punkt sollte auch nur zum Nachdenken anregen. Die Aufgabenstellung ist sehr allgemein und naiv gehalten - sorry eine bessere Formulierung fällt mir nicht ein - nicht beleidigend gemeint. Geht man davon aus, dass es bei der Aufgabenstellung um ein einmaliges Projekt geht, dann müsste man nicht nachfragen ob der Code OK ist. Es würde ein einmaliger Test des Resultats ausreichen. Nimmt man an, dass es sich um etwas sehr langlebiges handelt, würden mir noch sehr viele notwendige Verbesserungen einfallen. Sollte es sich um ein Projekt handeln, welches von anderen nachgenutzt werden soll, dann ist die Qualität ungenügend.
Andererseits sollte auch nicht rüberkommen, dass ich das Gezeigte für Mist halten könnte.
Solange also nicht ganz klar ist was die eigentliche Anforderung ist, kann man nur einzelne Denkanstöße geben - da ja dazu aufgefordert wurde. Die eigentliche Beurteilung kann man einfach nicht vornehmen.

Aber schauen wir uns das mal: findBestMatch() ist der Einstieg und erzeugt bereits projektLower. Alle anderen Stellen welche projektname oder Teile davon verwenden sind private. Also wenn ich keine Stelle übersehen habe, welche irgendwelche Teile in Großbuchstaben umwandelt, dann sind die anderen Aufrufe von normalisieren unnötig.
Java:
        String projektLower = normalisieren(projektname);
        Set<String> projektTokens = zerlegeProjektname(projektname);

vs:
        String projektLower = normalisieren(projektname);
        Set<String> projektTokens = zerlegeProjektname(projektLower);
Somit bleibt ein einziger Aufruf übrig und dieser muss nicht unbedingt in eine Methode gekapselt werden. Ich sehe hier keinen Mehrwert und die JVM wird den Bytecode vermutlich automatisch 'inline' machen.
Der Sachverhalt wäre anders, wäre die Methode nicht private und jemand würde von dieser Klasse eine eigene Implementierung ableiten (wollen). Einen Bibliotheks- oder Frameworkkarakter kann ich hier aber nicht erkennen.
 
Zuletzt bearbeitet:
Locale.ROOT sollte hier also gar keine Rolle spielen.
Nope, ein Anti-pattern nach SpotBugs. Dort geht's darum, welche Sprache das ausführende System hat...

... Die Beschreibung ist zwar noch seh dürftig, aber ich denke, das dürfte gemeint sein:

Java:
import java.util.*;
import java.util.regex.Pattern;

public class EditDistance {
  private record Match(String value, double score) {}

  public static String test(String a, String... bs) {
    List<Match> matches = new ArrayList<>();
    Arrays.stream(bs).forEach(b -> matches.add(new Match(b, bestMatch(a, b, '_'))));
    if (matches.isEmpty()) {
      throw new IllegalArgumentException("At least one comparison string is required");
    }
    matches.sort(Comparator.comparingDouble(Match::score).reversed());
    matches.forEach(m -> System.out.println(m.value() + ": " + m.score()));
    return matches.get(0).value();
  }

  private static double bestMatch(String s1, String s2, char sep) {
    return combinations(split(s2, sep)).stream()
        .mapToDouble(s3 -> editDistance(s1, s3))
        .max()
        .orElseThrow();
  }

  private static List<String> combinations(List<String> as) {
    Set<List<String>> permutations = new LinkedHashSet<>();
    permutations.add(new ArrayList<>());
    for (String a : as) {
      Set<List<String>> nextPermutations = new LinkedHashSet<>();
      for (List<String> permutation : permutations) {
        for (int index = 0; index <= permutation.size(); index++) {
          List<String> nextPermutation = new ArrayList<>(permutation);
          nextPermutation.add(index, a);
          nextPermutations.add(nextPermutation);
        }
      }
      permutations = nextPermutations;
    }

    Set<String> combinations = new LinkedHashSet<>();
    for (List<String> permutation : permutations) {
      String combination = String.join("", permutation);
      if (!combination.isEmpty()) {
        combinations.add(combination);
      }
    }
    return new ArrayList<>(combinations);
  }

  private static List<String> split(String s, char sep) {
    return new ArrayList<>(List.of(s.split(Pattern.quote(String.valueOf(sep)))));
  }

  private static double editDistance(String s1, String s2) {
    if (s1.equals(s2)) {
      return 1.0;
    }
    if (s1.isEmpty() || s2.isEmpty()) {
      return 0.0;
    }

    int[] previous = new int[s2.length() + 1];
    int[] current = new int[s2.length() + 1];
    for (int j = 0; j <= s2.length(); j++) {
      previous[j] = j;
    }
    for (int i = 1; i <= s1.length(); i++) {
      current[0] = i;
      for (int j = 1; j <= s2.length(); j++) {
        int substitutionCost = s1.charAt(i - 1) == s2.charAt(j - 1) ? 0 : 1;
        current[j] =
            Math.min(
                Math.min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + substitutionCost);
      }
      int[] temporary = previous;
      previous = current;
      current = temporary;
    }

    int editDistance = previous[s2.length()];
    return 1.0 - (double) editDistance / Math.max(s1.length(), s2.length());
  }

  public static void main(String[] args) {
    test("k80100_5144_000-basismodul-model", "144_k_80100", "k_80100", "144", "00_basis");
  }
}

Mt dem Methodennamen test und mit der LinkedHashSet bin ich noch ein wenig unglücklich, weil die Reihenfolge unwichtig ist...
 
Mir war nicht bewusst, dass Codebeispiele gewünscht wurden.

Der Code ist nicht falsch, aber die Benennung der Methode editDistance ist irreführend. Sie sollte nicht editDistance heißen, sondern beispielsweise getNormalizedSimilarity.
Bei der Levenshtein-Distanz ist 0 der beste Wert (0 Änderungen notwendig, die Strings sind identisch).
Dein Code berechnet hier jedoch nicht die pure Distanz, sondern einen Ähnlichkeitswert (Similarity Score) zwischen 0.0 (keinerlei Übereinstimmung) und 1.0 (identisch).

In der Methode combinations berechnest du alle Permutationen der gesplitteten Wortteile. Wenn ein String viele Unterstriche enthält (z. B. a_b_c_d_e_f_g_h), explodiert die Anzahl fakultativ (n!).
  • Bei 8 Elementen sind das schon 40.320 Permutationen.
  • Bei 12 Elementen über 479 Millionen. Das führt unweigerlich zu einem OutOfMemoryError oder lässt das Programm einfrieren.
Das lässt sich relativ einfach vermeiden.

Zusätzlich: Stream-Anti-Pattern, ca. Zeile 9 nutzt du .forEach(), um eine externe Liste zu befüllen:
Java:
Arrays.stream(bs).forEach(b -> matches.add(...));
Das bricht mit dem funktionalen Paradigma von Streams (Seiteneffekte). Nutzer erwarten hier:
Java:
.map().toList().
 
Danke für die Anmerkung! Bin mir oft nicht sicher, wann rein funktional und wann prozedural angebracht ist.

Das mit den Permutationen gesplitteter Wortteile war nur so eine Idee. Ich kenne die Aufgabenstellung nicht.
 
Der Typ hieß übersetzt Vladimir Levenshtein
Ja so habe ich die Funktion ja auch bezeichnet.
und den snake_case sollte man in Java vermeiden.
Den habe ich auch nicht verwendet. Falls ja wo?
Und wenn du eine Utils-Klasse einführst (Plural...)
Ich verwende Util weil auch ohne Plural ersichtlich ist um was es sich handelt. Vorteil ist, dass der Aufruf der statischen Funktion sich um ein Zeichen verkürzt. Das ist aber letztendlich Geschmackssache.
Und wenn du eine Utils-Klasse einführst (Plural...), dann dürfen die Checks nicht fehlen, ob die Strings wirklich nicht leer sind.

Wenn das Trennzeichen (separator char) vorher nicht bekannt ist, würden die alle Vereinigungen aller unique Subtrings Sinn machen... das sind aber viele.
Ja den Check auf Leerstring sollte man machen. Mir ging es in erster Linie darum dem TE aufzuzeigen, dass eine Funktion die eine Klasse selbst nicht benötigt auch nicht in diese gehört.
 
Zu der Frage XxxUtils oder XxxUtil: Beides ist in Ordnung und vorhanden. Im Plural ist es etwas verbreiteter aber das heisst nicht, dass das andere falsch wäre oder so ...

Einfaches kleines Beispiel: Hibernate (StringUtil, ReflectionUtil, ...)

Und in Java sind Klassennamen eigentlich eher Singular und der Namespace ist ja auch java.util. und nicht java.utils. ... (Ups, wie konnte da nur mehr wie eine Klasse reingepackt werden? Ist doch Singular! Nur um dem Argument von Tobias zu folgen ...)

Wenn man hier mit Clean Code argumentieren wollte, dann wäre eher beides Unsinnig, denn braucht man da ein Util oder Utils überhaupt? Da sollten doch besser aussagekräftige Namen her. Statt einem JsonUtil dann lieber ein JsonSerializer, der dann die notwendigen statischen Methoden enthält ...

Aber damit sind wir weg vom eigentlichen Theme zu einer Diskussion um eine Meinung von Tobias ....
 

Neue Themen


Zurück
Oben