package de.jplag; import java.util.ArrayList; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.stream.Collectors; import java.util.stream.IntStream; import de.jplag.options.JPlagOptions; /** * This class implements the Greedy String Tiling algorithm as introduced by Michael Wise. However, it is very specific * to the classes {@link Token}, and {@link Match}. Class implementation is thread-safe, i.e. submission can be compared * in parallel. * @see * String Similarity via Greedy String Tiling and Running Karp−Rabin Matching */ public class GreedyStringTiling { private final int minimumMatchLength; private ConcurrentMap tokenTypeValues; private final Map> baseCodeMarkings = new IdentityHashMap<>(); private final Map cachedTokenValueLists = new IdentityHashMap<>(); private final Map cachedHashLookupTables = new IdentityHashMap<>(); public GreedyStringTiling(JPlagOptions options) { this.minimumMatchLength = options.minimumTokenMatch(); this.tokenTypeValues = new ConcurrentHashMap<>(); this.tokenTypeValues.put(SharedTokenType.FILE_END, 0); } /** * Compares the given submission with the base code submission. Marks the identified base code sections in the * submission such that further comparisons do not generate matches for these parts. Must be called before generating a * comparison with a regular submission for the given submission. * @param submission is the submission to generate base-code markings for. * @param baseCodeSubmission is the base code submission. * @return the comparison of the submission with the base code submission. */ public final JPlagComparison generateBaseCodeMarking(Submission submission, Submission baseCodeSubmission) { JPlagComparison comparison = compare(submission, baseCodeSubmission); List submissionTokenList = submission.getTokenList(); Set baseCodeMarking = new HashSet<>(); for (Match match : comparison.matches()) { int startIndex = comparison.firstSubmission() == submission ? match.startOfFirst() : match.startOfSecond(); baseCodeMarking.addAll(submissionTokenList.subList(startIndex, startIndex + match.length())); } baseCodeMarkings.put(submission, baseCodeMarking); // Remove the lookup table for the current submission to trigger a regeneration as hashes will change due to the new // baseCodeMarking. // This is a performance optimization to not suggest subsequences with baseCode for the matching. // Removing this optimization would not change the result as the baseCode matches are additionally checked by validating // that no match has a marked token (which baseCode-containing tokens are). cachedHashLookupTables.remove(submission); return comparison; } /** * Compares the two submissions and generates matches between them. To exclude base code from the result, call * {@link #generateBaseCodeMarking} with each submission beforehand. * @param firstSubmission is one of the two submissions. * @param secondSubmission is the other of the two submissions. * @return the comparison between the two submissions. */ public final JPlagComparison compare(Submission firstSubmission, Submission secondSubmission) { Submission smallerSubmission; Submission largerSubmission; if (firstSubmission.getTokenList().size() > secondSubmission.getTokenList().size()) { smallerSubmission = secondSubmission; largerSubmission = firstSubmission; } else { smallerSubmission = firstSubmission; largerSubmission = secondSubmission; } return compareInternal(smallerSubmission, largerSubmission); } /** * Compares two submissions. FILE_END is used as pivot * @param leftSubmission is the submission with the smaller sequence. * @param rightSubmission is the submission with the larger sequence. * @return the comparison results. */ private JPlagComparison compareInternal(Submission leftSubmission, Submission rightSubmission) { List leftTokens = leftSubmission.getTokenList(); List rightTokens = rightSubmission.getTokenList(); int[] leftValues = tokenValueListFromSubmission(leftSubmission); int[] rightValues = tokenValueListFromSubmission(rightSubmission); // comparison uses <= because it is assumed that the last token is a pivot (FILE_END) if (leftTokens.size() <= minimumMatchLength || rightTokens.size() <= minimumMatchLength) { return new JPlagComparison(leftSubmission, rightSubmission, List.of()); } Set leftMarkedIndexes = initiallyMarkedTokenIndexes(leftSubmission); Set rightMarkedIndexes = initiallyMarkedTokenIndexes(rightSubmission); SubsequenceHashLookupTable leftLookupTable = subsequenceHashLookupTableForSubmission(leftSubmission, leftMarkedIndexes); SubsequenceHashLookupTable rightLookupTable = subsequenceHashLookupTableForSubmission(rightSubmission, rightMarkedIndexes); int maximumMatchLength; List globalMatches = new ArrayList<>(); do { maximumMatchLength = minimumMatchLength; List iterationMatches = new ArrayList<>(); for (int leftStartIndex = 0; leftStartIndex < leftValues.length - maximumMatchLength; leftStartIndex++) { int leftSubsequenceHash = leftLookupTable.subsequenceHashForStartIndex(leftStartIndex); if (leftMarkedIndexes.contains(leftStartIndex) || leftSubsequenceHash == SubsequenceHashLookupTable.NO_HASH) { continue; } List possiblyMatchingRightStartIndexes = rightLookupTable .startIndexesOfPossiblyMatchingSubsequencesForSubsequenceHash(leftSubsequenceHash); for (Integer rightStartIndex : possiblyMatchingRightStartIndexes) { // comparison uses >= because it is assumed that the last token is a pivot (FILE_END) if (rightMarkedIndexes.contains(rightStartIndex) || maximumMatchLength >= rightValues.length - rightStartIndex) { continue; } int subsequenceMatchLength = maximalMatchingSubsequenceLengthNotMarked(leftValues, leftStartIndex, leftMarkedIndexes, rightValues, rightStartIndex, rightMarkedIndexes, maximumMatchLength); if (subsequenceMatchLength >= maximumMatchLength) { if (subsequenceMatchLength > maximumMatchLength) { iterationMatches.clear(); maximumMatchLength = subsequenceMatchLength; } Match match = new Match(leftStartIndex, rightStartIndex, subsequenceMatchLength); addMatchIfNotOverlapping(iterationMatches, match); } } } for (Match match : iterationMatches) { addMatchIfNotOverlapping(globalMatches, match); int leftStartIndex = match.startOfFirst(); int rightStartIndex = match.startOfSecond(); for (int offset = 0; offset < match.length(); offset++) { leftMarkedIndexes.add(leftStartIndex + offset); rightMarkedIndexes.add(rightStartIndex + offset); } } } while (maximumMatchLength != minimumMatchLength); return new JPlagComparison(leftSubmission, rightSubmission, globalMatches); } /** * Computes the maximal matching subsequence between the two lists starting at their respective indexes. Values are * matching if they are equal and not marked. Comparison is performed backwards for the minimum sequence length based on * the assumption that the further tokens are away, the more likely they differ. * @param leftValues The list of left values. * @param leftStartIndex The start index in the left list. * @param leftMarkedIndexes The marked indexes of the left list. * @param rightValues The list of right values. * @param rightStartIndex The start index in the right list. * @param rightMarkedIndexes The marked indexes of the right list. * @param minimumSequenceLength The minimal sequence length for a matching subsequence. Must be not negative. * @return the maximal matching subsequence length, or 0 if there is no subsequence of at least the minimum sequence * length. */ private int maximalMatchingSubsequenceLengthNotMarked(int[] leftValues, int leftStartIndex, Set leftMarkedIndexes, int[] rightValues, int rightStartIndex, Set rightMarkedIndexes, int minimumSequenceLength) { for (int offset = minimumSequenceLength - 1; offset >= 0; offset--) { int leftIndex = leftStartIndex + offset; int rightIndex = rightStartIndex + offset; if (leftValues[leftIndex] != rightValues[rightIndex] || leftMarkedIndexes.contains(leftIndex) || rightMarkedIndexes.contains(rightIndex)) { return 0; } } int offset = minimumSequenceLength; while (leftValues[leftStartIndex + offset] == rightValues[rightStartIndex + offset] && !leftMarkedIndexes.contains(leftStartIndex + offset) && !rightMarkedIndexes.contains(rightStartIndex + offset)) { offset++; } return offset; } private void addMatchIfNotOverlapping(List matches, Match match) { for (int i = matches.size() - 1; i >= 0; i--) { // starting at the end is better(?) if (matches.get(i).overlaps(match)) { return; // no overlaps allowed! } } matches.add(match); } private Set initiallyMarkedTokenIndexes(Submission submission) { Set baseCodeTokens = baseCodeMarkings.get(submission); List tokens = submission.getTokenList(); return IntStream.range(0, tokens.size()) .filter(i -> tokens.get(i).getType().isExcludedFromMatching() || (baseCodeTokens != null && baseCodeTokens.contains(tokens.get(i)))) .boxed().collect(Collectors.toSet()); } private SubsequenceHashLookupTable subsequenceHashLookupTableForSubmission(Submission submission, Set markedIndexes) { return cachedHashLookupTables.computeIfAbsent(submission, (key -> new SubsequenceHashLookupTable(minimumMatchLength, tokenValueListFromSubmission(key), markedIndexes))); } /** * Converts the tokens of the submission to a list of values. * @param submission The submission from which to convert the tokens. */ private int[] tokenValueListFromSubmission(Submission submission) { return cachedTokenValueLists.computeIfAbsent(submission, (key -> { List tokens = key.getTokenList(); int[] tokenValueList = new int[tokens.size()]; for (int i = 0; i < tokens.size(); i++) { TokenType type = tokens.get(i).getType(); synchronized (tokenTypeValues) { tokenTypeValues.putIfAbsent(type, tokenTypeValues.size()); } tokenValueList[i] = tokenTypeValues.get(type); } return tokenValueList; })); } }