|
|
@@ -4,100 +4,69 @@ import static de.jplag.TokenConstants.FILE_END;
|
|
|
import static de.jplag.TokenConstants.SEPARATOR_TOKEN;
|
|
|
|
|
|
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.stream.Collectors;
|
|
|
|
|
|
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 TokenList}, {@link Token}, and {@link Match}. While this class was reworked, it still contains
|
|
|
- * some quirks from the initial version.
|
|
|
+ * to the classes {@link Token}, and {@link Match}.
|
|
|
* @see <a href=
|
|
|
* "https://www.researchgate.net/publication/262763983_String_Similarity_via_Greedy_String_Tiling_and_Running_Karp-Rabin_Matching">
|
|
|
* String Similarity via Greedy String Tiling and Running Karp−Rabin Matching </a>
|
|
|
*/
|
|
|
public class GreedyStringTiling {
|
|
|
|
|
|
- private final JPlagOptions options;
|
|
|
+ private final int minimumMatchLength;
|
|
|
+ private final Map<Submission, SubsequenceHashLookupTable> cachedHashLookupTables = new IdentityHashMap<>();
|
|
|
+ private final Map<Submission, Set<Token>> baseCodeMarkings = new IdentityHashMap<>();
|
|
|
|
|
|
public GreedyStringTiling(JPlagOptions options) {
|
|
|
- this.options = options;
|
|
|
+ this.minimumMatchLength = options.minimumTokenMatch();
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Creating hashes in linear time. The hash-code will be written in every Token for the next <hashLength> token
|
|
|
- * (includes the Token itself).
|
|
|
- * @param tokenList contains the tokens.
|
|
|
- * @param hashLength is the hash length (condition: 1 < hashLength < 26)
|
|
|
- * @param makeTable determines if a simple hash table is created in the structure.
|
|
|
+ * 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 void createHashes(TokenList tokenList, int hashLength, boolean makeTable) {
|
|
|
- // Here the upper boundary of the hash length is set.
|
|
|
- // It is determined by the number of bits of the 'int' data type and the number of tokens.
|
|
|
- if (hashLength < 1) {
|
|
|
- hashLength = 1;
|
|
|
+ public final JPlagComparison generateBaseCodeMarking(Submission submission, Submission baseCodeSubmission) {
|
|
|
+ JPlagComparison comparison = compare(submission, baseCodeSubmission);
|
|
|
+
|
|
|
+ List<Token> submissionTokenList = submission.getTokenList();
|
|
|
+ Set<Token> 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()));
|
|
|
}
|
|
|
- hashLength = (hashLength < 26 ? hashLength : 25);
|
|
|
+ baseCodeMarkings.put(submission, baseCodeMarking);
|
|
|
|
|
|
- if (tokenList.size() < hashLength) {
|
|
|
- return;
|
|
|
- }
|
|
|
+ // 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);
|
|
|
|
|
|
- int modulo = ((1 << 6) - 1); // Modulo 64!
|
|
|
-
|
|
|
- int loops = tokenList.size() - hashLength;
|
|
|
- tokenList.tokenHashes = (makeTable ? new TokenHashMap(3 * loops) : null);
|
|
|
- int hash = 0;
|
|
|
- int hashedLength = 0;
|
|
|
- for (int i = 0; i < hashLength; i++) {
|
|
|
- hash = (2 * hash) + (tokenList.getToken(i).type & modulo);
|
|
|
- hashedLength++;
|
|
|
- if (tokenList.getToken(i).isMarked()) {
|
|
|
- hashedLength = 0;
|
|
|
- }
|
|
|
- }
|
|
|
- int factor = (hashLength != 1 ? (2 << (hashLength - 2)) : 1);
|
|
|
-
|
|
|
- if (makeTable) {
|
|
|
- for (int i = 0; i < loops; i++) {
|
|
|
- if (hashedLength >= hashLength) {
|
|
|
- tokenList.getToken(i).setHash(hash);
|
|
|
- tokenList.tokenHashes.put(hash, i); // add into hashtable
|
|
|
- } else {
|
|
|
- tokenList.getToken(i).setHash(-1);
|
|
|
- }
|
|
|
- hash -= factor * (tokenList.getToken(i).type & modulo);
|
|
|
- hash = (2 * hash) + (tokenList.getToken(i + hashLength).type & modulo);
|
|
|
- if (tokenList.getToken(i + hashLength).isMarked()) {
|
|
|
- hashedLength = 0;
|
|
|
- } else {
|
|
|
- hashedLength++;
|
|
|
- }
|
|
|
- }
|
|
|
- } else {
|
|
|
- for (int i = 0; i < loops; i++) {
|
|
|
- tokenList.getToken(i).setHash((hashedLength >= hashLength) ? hash : -1);
|
|
|
- hash -= factor * (tokenList.getToken(i).type & modulo);
|
|
|
- hash = (2 * hash) + (tokenList.getToken(i + hashLength).type & modulo);
|
|
|
- if (tokenList.getToken(i + hashLength).isMarked()) {
|
|
|
- hashedLength = 0;
|
|
|
- } else {
|
|
|
- hashedLength++;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- tokenList.hashLength = hashLength;
|
|
|
+ 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) {
|
|
|
- return swapAndCompare(firstSubmission, secondSubmission, false);
|
|
|
- }
|
|
|
-
|
|
|
- public final JPlagComparison compareWithBaseCode(Submission firstSubmission, Submission secondSubmission) {
|
|
|
- return swapAndCompare(firstSubmission, secondSubmission, true);
|
|
|
- }
|
|
|
-
|
|
|
- private JPlagComparison swapAndCompare(Submission firstSubmission, Submission secondSubmission, boolean isBaseCodeComparison) {
|
|
|
Submission smallerSubmission;
|
|
|
Submission largerSubmission;
|
|
|
if (firstSubmission.getTokenList().size() > secondSubmission.getTokenList().size()) {
|
|
|
@@ -107,129 +76,126 @@ public class GreedyStringTiling {
|
|
|
smallerSubmission = firstSubmission;
|
|
|
largerSubmission = secondSubmission;
|
|
|
}
|
|
|
- // if hashtable exists in first but not in second structure: flip around!
|
|
|
- if (largerSubmission.getTokenList().tokenHashes == null && smallerSubmission.getTokenList().tokenHashes != null) {
|
|
|
- Submission swap = smallerSubmission;
|
|
|
- smallerSubmission = largerSubmission;
|
|
|
- largerSubmission = swap;
|
|
|
- }
|
|
|
- return compare(smallerSubmission, largerSubmission, isBaseCodeComparison);
|
|
|
+ return compareInternal(smallerSubmission, largerSubmission);
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* Compares two submissions. FILE_END is used as pivot
|
|
|
- * @param firstSubmission is the submission with the smaller sequence.
|
|
|
- * @param secondSubmission is the submission with the larger sequence.
|
|
|
- * @param isBaseCodeComparison specifies whether one of the submissions is the base code.
|
|
|
+ * @param leftSubmission is the submission with the smaller sequence.
|
|
|
+ * @param rightSubmission is the submission with the larger sequence.
|
|
|
* @return the comparison results.
|
|
|
*/
|
|
|
- private JPlagComparison compare(Submission firstSubmission, Submission secondSubmission, boolean isBaseCodeComparison) {
|
|
|
- // first and second refer to the list of tokens of the first and second submission:
|
|
|
- TokenList first = firstSubmission.getTokenList();
|
|
|
- TokenList second = secondSubmission.getTokenList();
|
|
|
+ private JPlagComparison compareInternal(Submission leftSubmission, Submission rightSubmission) {
|
|
|
+ List<Token> leftTokens = leftSubmission.getTokenList();
|
|
|
+ List<Token> rightTokens = rightSubmission.getTokenList();
|
|
|
|
|
|
- // Initialize:
|
|
|
- JPlagComparison comparison = new JPlagComparison(firstSubmission, secondSubmission);
|
|
|
- int minimumTokenMatch = options.minimumTokenMatch(); // minimal required token match
|
|
|
-
|
|
|
- if (first.size() <= minimumTokenMatch || second.size() <= minimumTokenMatch) { // <= because of pivots!
|
|
|
- return comparison;
|
|
|
+ // 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());
|
|
|
}
|
|
|
|
|
|
- markTokens(first, isBaseCodeComparison);
|
|
|
- markTokens(second, isBaseCodeComparison);
|
|
|
+ Set<Token> leftMarkedTokens = initiallyMarkedTokens(leftSubmission);
|
|
|
+ Set<Token> rightMarkedTokens = initiallyMarkedTokens(rightSubmission);
|
|
|
|
|
|
- // create hashes:
|
|
|
- if (first.hashLength != minimumTokenMatch) {
|
|
|
- createHashes(first, minimumTokenMatch, isBaseCodeComparison); // don't make table if it is not a base code comparison
|
|
|
- }
|
|
|
- if (second.hashLength != minimumTokenMatch || second.tokenHashes == null) {
|
|
|
- createHashes(second, minimumTokenMatch, true);
|
|
|
- }
|
|
|
+ SubsequenceHashLookupTable leftLookupTable = subsequenceHashLookupTableForSubmission(leftSubmission, leftMarkedTokens);
|
|
|
+ SubsequenceHashLookupTable rightLookupTable = subsequenceHashLookupTableForSubmission(rightSubmission, rightMarkedTokens);
|
|
|
|
|
|
- List<Match> matches = new ArrayList<>();
|
|
|
-
|
|
|
- // start the black magic:
|
|
|
- int maxMatch;
|
|
|
+ int maximumMatchLength;
|
|
|
+ List<Match> globalMatches = new ArrayList<>();
|
|
|
do {
|
|
|
- maxMatch = minimumTokenMatch;
|
|
|
- matches.clear();
|
|
|
- for (int x = 0; x < first.size() - maxMatch; x++) {
|
|
|
- List<Integer> hashedTokens = second.tokenHashes.get(first.getToken(x).getHash());
|
|
|
- if (first.getToken(x).isMarked() || first.getToken(x).getHash() == -1) {
|
|
|
+ maximumMatchLength = minimumMatchLength;
|
|
|
+ List<Match> iterationMatches = new ArrayList<>();
|
|
|
+ for (int leftStartIndex = 0; leftStartIndex < leftTokens.size() - maximumMatchLength; leftStartIndex++) {
|
|
|
+ int leftSubsequenceHash = leftLookupTable.subsequenceHashForStartIndex(leftStartIndex);
|
|
|
+ if (leftMarkedTokens.contains(leftTokens.get(leftStartIndex)) || leftSubsequenceHash == SubsequenceHashLookupTable.NO_HASH) {
|
|
|
continue;
|
|
|
}
|
|
|
- inner: for (Integer y : hashedTokens) {
|
|
|
- if (second.getToken(y).isMarked() || maxMatch >= second.size() - y) { // >= because of pivots!
|
|
|
+ List<Integer> possiblyMatchingRightStartIndexes = rightLookupTable
|
|
|
+ .startIndexesOfPossiblyMatchingSubsequencesForSubsequenceHash(leftSubsequenceHash);
|
|
|
+ for (Integer rightStartIndex : possiblyMatchingRightStartIndexes) {
|
|
|
+ // comparison uses >= because it is assumed that the last token is a pivot (FILE_END)
|
|
|
+ if (rightMarkedTokens.contains(rightTokens.get(rightStartIndex)) || maximumMatchLength >= rightTokens.size() - rightStartIndex) {
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
- int j, hx, hy;
|
|
|
- for (j = maxMatch - 1; j >= 0; j--) { // begins comparison from behind
|
|
|
- if (first.getToken(hx = x + j).type != second.getToken(hy = y + j).type || first.getToken(hx).isMarked()
|
|
|
- || second.getToken(hy).isMarked()) {
|
|
|
- continue inner;
|
|
|
- }
|
|
|
+ if (!subsequencesAreMatchingAndNotMarked(leftTokens.subList(leftStartIndex, leftStartIndex + maximumMatchLength),
|
|
|
+ leftMarkedTokens, rightTokens.subList(rightStartIndex, rightStartIndex + maximumMatchLength), rightMarkedTokens)) {
|
|
|
+ continue;
|
|
|
}
|
|
|
|
|
|
// expand match
|
|
|
- j = maxMatch;
|
|
|
- while (first.getToken(hx = x + j).type == second.getToken(hy = y + j).type && !first.getToken(hx).isMarked()
|
|
|
- && !second.getToken(hy).isMarked()) {
|
|
|
- j++;
|
|
|
+ int offset = maximumMatchLength;
|
|
|
+ while (leftTokens.get(leftStartIndex + offset).type == rightTokens.get(rightStartIndex + offset).type
|
|
|
+ && !leftMarkedTokens.contains(leftTokens.get(leftStartIndex + offset))
|
|
|
+ && !rightMarkedTokens.contains(rightTokens.get(rightStartIndex + offset))) {
|
|
|
+ offset++;
|
|
|
}
|
|
|
|
|
|
- if (j > maxMatch && !isBaseCodeComparison || j != maxMatch && isBaseCodeComparison) { // new biggest match? -> delete current
|
|
|
- // smaller
|
|
|
- matches.clear();
|
|
|
- maxMatch = j;
|
|
|
+ if (offset > maximumMatchLength) {
|
|
|
+ iterationMatches.clear();
|
|
|
+ maximumMatchLength = offset;
|
|
|
}
|
|
|
- addMatchIfNotOverlapping(matches, x, y, j);
|
|
|
+ Match match = new Match(leftStartIndex, rightStartIndex, offset);
|
|
|
+ addMatchIfNotOverlapping(iterationMatches, match);
|
|
|
}
|
|
|
}
|
|
|
- for (int i = matches.size() - 1; i >= 0; i--) {
|
|
|
- int x = matches.get(i).startOfFirst(); // Beginning of/in sequence A
|
|
|
- int y = matches.get(i).startOfSecond(); // Beginning of/in sequence B
|
|
|
- comparison.addMatch(x, y, matches.get(i).length());
|
|
|
- // in order that "Match" will be newly build (because reusing)
|
|
|
- for (int j = matches.get(i).length(); j > 0; j--) {
|
|
|
- first.getToken(x).setMarked(true); // mark all Tokens!
|
|
|
- second.getToken(y).setMarked(true);
|
|
|
- if (isBaseCodeComparison) {
|
|
|
- first.getToken(x).setBasecode(true);
|
|
|
- second.getToken(y).setBasecode(true);
|
|
|
- }
|
|
|
- x++;
|
|
|
- y++;
|
|
|
+ for (Match match : iterationMatches) {
|
|
|
+ addMatchIfNotOverlapping(globalMatches, match);
|
|
|
+ int leftStartIndex = match.startOfFirst();
|
|
|
+ int rightStartIndex = match.startOfSecond();
|
|
|
+ for (int offset = 0; offset < match.length(); offset++) {
|
|
|
+ leftMarkedTokens.add(leftTokens.get(leftStartIndex + offset));
|
|
|
+ rightMarkedTokens.add(rightTokens.get(rightStartIndex + offset));
|
|
|
}
|
|
|
}
|
|
|
+ } while (maximumMatchLength != minimumMatchLength);
|
|
|
+ return new JPlagComparison(leftSubmission, rightSubmission, globalMatches);
|
|
|
+ }
|
|
|
|
|
|
- } while (maxMatch != minimumTokenMatch);
|
|
|
-
|
|
|
- return comparison;
|
|
|
+ /**
|
|
|
+ * Checks if the two provided subsequences are equal and not marked. Comparison is performed backwards based on the
|
|
|
+ * assumption that the further tokens are away, the more likely they differ. leftTokens and rightTokens must be of equal
|
|
|
+ * size.
|
|
|
+ * @param leftTokens The subsequence of left tokens.
|
|
|
+ * @param leftMarkedTokens The marked tokens of the left token list.
|
|
|
+ * @param rightTokens The subsequence of right tokens.
|
|
|
+ * @param rightMarkedTokens The marked tokens of the right token list.
|
|
|
+ * @return true if the subsequences are matching and not marked, otherwise false.
|
|
|
+ */
|
|
|
+ private boolean subsequencesAreMatchingAndNotMarked(List<Token> leftTokens, Set<Token> leftMarkedTokens, List<Token> rightTokens,
|
|
|
+ Set<Token> rightMarkedTokens) {
|
|
|
+ for (int offset = leftTokens.size() - 1; offset >= 0; offset--) {
|
|
|
+ Token leftToken = leftTokens.get(offset);
|
|
|
+ Token rightToken = rightTokens.get(offset);
|
|
|
+ if (leftToken.type != rightToken.type || leftMarkedTokens.contains(leftToken) || rightMarkedTokens.contains(rightToken)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return true;
|
|
|
}
|
|
|
|
|
|
- private void addMatchIfNotOverlapping(List<Match> matches, int startA, int startB, int length) {
|
|
|
+ private void addMatchIfNotOverlapping(List<Match> matches, Match match) {
|
|
|
for (int i = matches.size() - 1; i >= 0; i--) { // starting at the end is better(?)
|
|
|
- if (matches.get(i).overlap(startA, startB, length)) {
|
|
|
+ if (matches.get(i).overlaps(match)) {
|
|
|
return; // no overlaps allowed!
|
|
|
}
|
|
|
}
|
|
|
- matches.add(new Match(startA, startB, length));
|
|
|
+ matches.add(match);
|
|
|
}
|
|
|
|
|
|
- /**
|
|
|
- * Disable finding a match at separator tokens and basecode matches for non-basecode comparisons.
|
|
|
- * @param tokenList Tokens to mark.
|
|
|
- * @param isBaseCodeComparison Whether the {@link Token#isBasecode()} matches should be enabled for matching.
|
|
|
- */
|
|
|
- private void markTokens(TokenList tokenList, boolean isBaseCodeComparison) {
|
|
|
- for (Token token : tokenList.allTokens()) {
|
|
|
- if (isBaseCodeComparison) {
|
|
|
- token.setMarked(token.type == FILE_END || token.type == SEPARATOR_TOKEN);
|
|
|
- } else {
|
|
|
- token.setMarked(token.type == FILE_END || token.type == SEPARATOR_TOKEN || (token.isBasecode() && options.hasBaseCode()));
|
|
|
- }
|
|
|
+ private Set<Token> initiallyMarkedTokens(Submission submission) {
|
|
|
+ Set<Token> baseCodeTokens = baseCodeMarkings.get(submission);
|
|
|
+ return submission.getTokenList().stream().filter(
|
|
|
+ token -> token.type == FILE_END || token.type == SEPARATOR_TOKEN || (baseCodeTokens != null && baseCodeTokens.contains(token)))
|
|
|
+ .collect(Collectors.toSet());
|
|
|
+ }
|
|
|
+
|
|
|
+ private SubsequenceHashLookupTable subsequenceHashLookupTableForSubmission(Submission submission, Set<Token> markedTokens) {
|
|
|
+ if (cachedHashLookupTables.containsKey(submission)) {
|
|
|
+ return cachedHashLookupTables.get(submission);
|
|
|
}
|
|
|
+ SubsequenceHashLookupTable lookupTable = new SubsequenceHashLookupTable(minimumMatchLength, submission.getTokenList(), markedTokens);
|
|
|
+ cachedHashLookupTables.put(submission, lookupTable);
|
|
|
+ return lookupTable;
|
|
|
}
|
|
|
}
|