GreedyStringTiling.java 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. package de.jplag;
  2. import java.util.ArrayList;
  3. import java.util.HashSet;
  4. import java.util.IdentityHashMap;
  5. import java.util.List;
  6. import java.util.Map;
  7. import java.util.Set;
  8. import java.util.concurrent.ConcurrentHashMap;
  9. import java.util.concurrent.ConcurrentMap;
  10. import java.util.stream.Collectors;
  11. import java.util.stream.IntStream;
  12. import de.jplag.options.JPlagOptions;
  13. /**
  14. * This class implements the Greedy String Tiling algorithm as introduced by Michael Wise. However, it is very specific
  15. * to the classes {@link Token}, and {@link Match}. Class implementation is thread-safe, i.e. submission can be compared
  16. * in parallel.
  17. * @see <a href=
  18. * "https://www.researchgate.net/publication/262763983_String_Similarity_via_Greedy_String_Tiling_and_Running_Karp-Rabin_Matching">
  19. * String Similarity via Greedy String Tiling and Running Karp−Rabin Matching </a>
  20. */
  21. public class GreedyStringTiling {
  22. private final int minimumMatchLength;
  23. private ConcurrentMap<TokenType, Integer> tokenTypeValues;
  24. private final Map<Submission, Set<Token>> baseCodeMarkings = new IdentityHashMap<>();
  25. private final Map<Submission, int[]> cachedTokenValueLists = new IdentityHashMap<>();
  26. private final Map<Submission, SubsequenceHashLookupTable> cachedHashLookupTables = new IdentityHashMap<>();
  27. public GreedyStringTiling(JPlagOptions options) {
  28. this.minimumMatchLength = options.minimumTokenMatch();
  29. this.tokenTypeValues = new ConcurrentHashMap<>();
  30. this.tokenTypeValues.put(SharedTokenType.FILE_END, 0);
  31. }
  32. /**
  33. * Compares the given submission with the base code submission. Marks the identified base code sections in the
  34. * submission such that further comparisons do not generate matches for these parts. Must be called before generating a
  35. * comparison with a regular submission for the given submission.
  36. * @param submission is the submission to generate base-code markings for.
  37. * @param baseCodeSubmission is the base code submission.
  38. * @return the comparison of the submission with the base code submission.
  39. */
  40. public final JPlagComparison generateBaseCodeMarking(Submission submission, Submission baseCodeSubmission) {
  41. JPlagComparison comparison = compare(submission, baseCodeSubmission);
  42. List<Token> submissionTokenList = submission.getTokenList();
  43. Set<Token> baseCodeMarking = new HashSet<>();
  44. for (Match match : comparison.matches()) {
  45. int startIndex = comparison.firstSubmission() == submission ? match.startOfFirst() : match.startOfSecond();
  46. baseCodeMarking.addAll(submissionTokenList.subList(startIndex, startIndex + match.length()));
  47. }
  48. baseCodeMarkings.put(submission, baseCodeMarking);
  49. // Remove the lookup table for the current submission to trigger a regeneration as hashes will change due to the new
  50. // baseCodeMarking.
  51. // This is a performance optimization to not suggest subsequences with baseCode for the matching.
  52. // Removing this optimization would not change the result as the baseCode matches are additionally checked by validating
  53. // that no match has a marked token (which baseCode-containing tokens are).
  54. cachedHashLookupTables.remove(submission);
  55. return comparison;
  56. }
  57. /**
  58. * Compares the two submissions and generates matches between them. To exclude base code from the result, call
  59. * {@link #generateBaseCodeMarking} with each submission beforehand.
  60. * @param firstSubmission is one of the two submissions.
  61. * @param secondSubmission is the other of the two submissions.
  62. * @return the comparison between the two submissions.
  63. */
  64. public final JPlagComparison compare(Submission firstSubmission, Submission secondSubmission) {
  65. Submission smallerSubmission;
  66. Submission largerSubmission;
  67. if (firstSubmission.getTokenList().size() > secondSubmission.getTokenList().size()) {
  68. smallerSubmission = secondSubmission;
  69. largerSubmission = firstSubmission;
  70. } else {
  71. smallerSubmission = firstSubmission;
  72. largerSubmission = secondSubmission;
  73. }
  74. return compareInternal(smallerSubmission, largerSubmission);
  75. }
  76. /**
  77. * Compares two submissions. FILE_END is used as pivot
  78. * @param leftSubmission is the submission with the smaller sequence.
  79. * @param rightSubmission is the submission with the larger sequence.
  80. * @return the comparison results.
  81. */
  82. private JPlagComparison compareInternal(Submission leftSubmission, Submission rightSubmission) {
  83. List<Token> leftTokens = leftSubmission.getTokenList();
  84. List<Token> rightTokens = rightSubmission.getTokenList();
  85. int[] leftValues = tokenValueListFromSubmission(leftSubmission);
  86. int[] rightValues = tokenValueListFromSubmission(rightSubmission);
  87. // comparison uses <= because it is assumed that the last token is a pivot (FILE_END)
  88. if (leftTokens.size() <= minimumMatchLength || rightTokens.size() <= minimumMatchLength) {
  89. return new JPlagComparison(leftSubmission, rightSubmission, List.of());
  90. }
  91. Set<Integer> leftMarkedIndexes = initiallyMarkedTokenIndexes(leftSubmission);
  92. Set<Integer> rightMarkedIndexes = initiallyMarkedTokenIndexes(rightSubmission);
  93. SubsequenceHashLookupTable leftLookupTable = subsequenceHashLookupTableForSubmission(leftSubmission, leftMarkedIndexes);
  94. SubsequenceHashLookupTable rightLookupTable = subsequenceHashLookupTableForSubmission(rightSubmission, rightMarkedIndexes);
  95. int maximumMatchLength;
  96. List<Match> globalMatches = new ArrayList<>();
  97. do {
  98. maximumMatchLength = minimumMatchLength;
  99. List<Match> iterationMatches = new ArrayList<>();
  100. for (int leftStartIndex = 0; leftStartIndex < leftValues.length - maximumMatchLength; leftStartIndex++) {
  101. int leftSubsequenceHash = leftLookupTable.subsequenceHashForStartIndex(leftStartIndex);
  102. if (leftMarkedIndexes.contains(leftStartIndex) || leftSubsequenceHash == SubsequenceHashLookupTable.NO_HASH) {
  103. continue;
  104. }
  105. List<Integer> possiblyMatchingRightStartIndexes = rightLookupTable
  106. .startIndexesOfPossiblyMatchingSubsequencesForSubsequenceHash(leftSubsequenceHash);
  107. for (Integer rightStartIndex : possiblyMatchingRightStartIndexes) {
  108. // comparison uses >= because it is assumed that the last token is a pivot (FILE_END)
  109. if (rightMarkedIndexes.contains(rightStartIndex) || maximumMatchLength >= rightValues.length - rightStartIndex) {
  110. continue;
  111. }
  112. int subsequenceMatchLength = maximalMatchingSubsequenceLengthNotMarked(leftValues, leftStartIndex, leftMarkedIndexes, rightValues,
  113. rightStartIndex, rightMarkedIndexes, maximumMatchLength);
  114. if (subsequenceMatchLength >= maximumMatchLength) {
  115. if (subsequenceMatchLength > maximumMatchLength) {
  116. iterationMatches.clear();
  117. maximumMatchLength = subsequenceMatchLength;
  118. }
  119. Match match = new Match(leftStartIndex, rightStartIndex, subsequenceMatchLength);
  120. addMatchIfNotOverlapping(iterationMatches, match);
  121. }
  122. }
  123. }
  124. for (Match match : iterationMatches) {
  125. addMatchIfNotOverlapping(globalMatches, match);
  126. int leftStartIndex = match.startOfFirst();
  127. int rightStartIndex = match.startOfSecond();
  128. for (int offset = 0; offset < match.length(); offset++) {
  129. leftMarkedIndexes.add(leftStartIndex + offset);
  130. rightMarkedIndexes.add(rightStartIndex + offset);
  131. }
  132. }
  133. } while (maximumMatchLength != minimumMatchLength);
  134. return new JPlagComparison(leftSubmission, rightSubmission, globalMatches);
  135. }
  136. /**
  137. * Computes the maximal matching subsequence between the two lists starting at their respective indexes. Values are
  138. * matching if they are equal and not marked. Comparison is performed backwards for the minimum sequence length based on
  139. * the assumption that the further tokens are away, the more likely they differ.
  140. * @param leftValues The list of left values.
  141. * @param leftStartIndex The start index in the left list.
  142. * @param leftMarkedIndexes The marked indexes of the left list.
  143. * @param rightValues The list of right values.
  144. * @param rightStartIndex The start index in the right list.
  145. * @param rightMarkedIndexes The marked indexes of the right list.
  146. * @param minimumSequenceLength The minimal sequence length for a matching subsequence. Must be not negative.
  147. * @return the maximal matching subsequence length, or 0 if there is no subsequence of at least the minimum sequence
  148. * length.
  149. */
  150. private int maximalMatchingSubsequenceLengthNotMarked(int[] leftValues, int leftStartIndex, Set<Integer> leftMarkedIndexes, int[] rightValues,
  151. int rightStartIndex, Set<Integer> rightMarkedIndexes, int minimumSequenceLength) {
  152. for (int offset = minimumSequenceLength - 1; offset >= 0; offset--) {
  153. int leftIndex = leftStartIndex + offset;
  154. int rightIndex = rightStartIndex + offset;
  155. if (leftValues[leftIndex] != rightValues[rightIndex] || leftMarkedIndexes.contains(leftIndex)
  156. || rightMarkedIndexes.contains(rightIndex)) {
  157. return 0;
  158. }
  159. }
  160. int offset = minimumSequenceLength;
  161. while (leftValues[leftStartIndex + offset] == rightValues[rightStartIndex + offset] && !leftMarkedIndexes.contains(leftStartIndex + offset)
  162. && !rightMarkedIndexes.contains(rightStartIndex + offset)) {
  163. offset++;
  164. }
  165. return offset;
  166. }
  167. private void addMatchIfNotOverlapping(List<Match> matches, Match match) {
  168. for (int i = matches.size() - 1; i >= 0; i--) { // starting at the end is better(?)
  169. if (matches.get(i).overlaps(match)) {
  170. return; // no overlaps allowed!
  171. }
  172. }
  173. matches.add(match);
  174. }
  175. private Set<Integer> initiallyMarkedTokenIndexes(Submission submission) {
  176. Set<Token> baseCodeTokens = baseCodeMarkings.get(submission);
  177. List<Token> tokens = submission.getTokenList();
  178. return IntStream.range(0, tokens.size())
  179. .filter(i -> tokens.get(i).getType().isExcludedFromMatching() || (baseCodeTokens != null && baseCodeTokens.contains(tokens.get(i))))
  180. .boxed().collect(Collectors.toSet());
  181. }
  182. private SubsequenceHashLookupTable subsequenceHashLookupTableForSubmission(Submission submission, Set<Integer> markedIndexes) {
  183. return cachedHashLookupTables.computeIfAbsent(submission,
  184. (key -> new SubsequenceHashLookupTable(minimumMatchLength, tokenValueListFromSubmission(key), markedIndexes)));
  185. }
  186. /**
  187. * Converts the tokens of the submission to a list of values.
  188. * @param submission The submission from which to convert the tokens.
  189. */
  190. private int[] tokenValueListFromSubmission(Submission submission) {
  191. return cachedTokenValueLists.computeIfAbsent(submission, (key -> {
  192. List<Token> tokens = key.getTokenList();
  193. int[] tokenValueList = new int[tokens.size()];
  194. for (int i = 0; i < tokens.size(); i++) {
  195. TokenType type = tokens.get(i).getType();
  196. synchronized (tokenTypeValues) {
  197. tokenTypeValues.putIfAbsent(type, tokenTypeValues.size());
  198. }
  199. tokenValueList[i] = tokenTypeValues.get(type);
  200. }
  201. return tokenValueList;
  202. }));
  203. }
  204. }