Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ plugins {
id("org.jetbrains.dokka") version "0.10.0"
id("com.github.ben-manes.versions") version "0.27.0"

kotlin("jvm") version "1.3.60"
kotlin("jvm") version "1.4.30"
}

group = "com.londogard"
Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.2.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
116 changes: 55 additions & 61 deletions src/main/kotlin/com/londogard/fuzzymatch/FuzzyMatcher.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,105 +2,85 @@ package com.londogard.fuzzymatch

import kotlin.math.min

class FuzzyMatcher(private val scoreConfig: ScoreConfig = ScoreConfig()) {
data class Result(val indices: List<Int>, val score: Int, val text: String? = null)
private data class DataHolder(val textLeft: String, val matches: List<Int>, val recursiveResults: List<Result>)
private data class DataHolder(val textLeft: String, val matches: List<Int>, val recursiveResults: List<Result>)

private val emptyResult = Result(emptyList(), 0)
class FuzzyMatcher(private val scoreConfig: ScoreConfig = ScoreConfig()) {
/**
* Returns true if each character in pattern is found sequentially within text. ~3 times faster than contains
* Example use-case:
* fuzzyMatchSimple(abc, "hello are you a bear?") // true (hello Are you a Bear
* fuzzyMatchSimple(abc, "hello are you a deer?") // false
* @param pattern String - the pattern to be found
* @param text String - the text where we want to find the pattern
*/
fun fuzzyMatchSimple(pattern: String, text: String): Boolean {
//text.contains()
var patternIdx = 0
var textIdx = 0
val patternLen = pattern.length
val textLen = text.length

// Could be replaced by fold and still be speedy -- benchmark
while (patternIdx != patternLen && textIdx != textLen) {
if (pattern[patternIdx].toLowerCase() == text[textIdx].toLowerCase()) ++patternIdx
if (pattern[patternIdx].equals(text[textIdx], ignoreCase = true)) ++patternIdx
++textIdx
}

return patternLen != 0 && textLen != 0 && patternIdx == patternLen
}

/**
* A fuzzy match, finds all possible matches and retrieves the optimal solution using ScoringConfig.
* Currently only returns if full match. Else empty result returned.
*
* @param text: the text input
* @param pattern: the pattern we want to match to the text
* @param res: recursion param (ignore it)
* @param textLen: recursion param (ignore it)
* @param fullText: recursion param (ignore it)
*/
// TODO optimize by extracting all simpleMatches by using Set with index, then sort by scoring recursively
// TODO optimizations
// 1. Only save relevant characters
// 2. Better early exit (?)
private fun fuzzyMatchFunc(
text: String,
pattern: String,
res: Result = emptyResult,
indices: List<Int> = emptyList(),
textLen: Int = text.length,
fullText: String = text
): Result {
return when {
pattern.length > text.length || text.isEmpty() -> emptyResult
pattern.isEmpty() -> res
pattern.isEmpty() -> MatchResult(indices, scoringFunction(indices, fullText))
pattern.length > text.length || text.isEmpty() -> EmptyResult
else -> {
val recursiveParams = pattern.foldIndexed(
DataHolder(
text,
res.indices,
indices,
emptyList()
)
) { index, (textLeft, matches, recursiveRes), patternChar ->
when {
textLeft.isEmpty() -> return emptyResult
patternChar.equals(textLeft[0], true) -> {
textLeft.isEmpty() -> return EmptyResult
patternChar.equals(textLeft.first(), ignoreCase = true) -> {
val recursiveResult = fuzzyMatchFunc(
textLeft.drop(1),
textLeft.substring(1),
pattern.substring(index),
res.copy(indices = matches),
matches,
textLen,
fullText
)

DataHolder(
textLeft.drop(1),
textLeft.substring(1),
matches + (textLen - textLeft.length),
recursiveRes + recursiveResult
)
}
else -> {
val updatedText = textLeft.dropWhile { !it.equals(patternChar, true) }
if (updatedText.isEmpty()) return emptyResult
val updatedText = textLeft.dropWhile { !it.equals(patternChar, ignoreCase = true) }

val recursiveResult = fuzzyMatchFunc(
updatedText.drop(1),
pattern.substring(index),
res.copy(indices = matches),
textLen,
fullText
)

DataHolder(
updatedText.drop(1),
matches + (textLen - updatedText.length),
recursiveRes + recursiveResult
)
DataHolder(updatedText, matches, recursiveRes)
}
}
}
val results = recursiveParams.recursiveResults + Result(recursiveParams.matches, 10)

if (recursiveParams.matches.size != pattern.length) emptyResult
else results.filter { it.score > 0 }.map {
Result(
it.indices,
scoringFunction(it.indices, fullText)
)
}.maxBy { it.score }?.copy(text = fullText)!!
val result = if (recursiveParams.matches.size != pattern.length) EmptyResult else MatchResult(recursiveParams.matches, scoringFunction(recursiveParams.matches, fullText))

(recursiveParams.recursiveResults + result)
.mapNotNull { result -> result as? MatchResult }
.filter { result -> result.score > 0 }
.maxByOrNull { it.score } ?: EmptyResult
}
}
}
Expand All @@ -115,28 +95,42 @@ class FuzzyMatcher(private val scoreConfig: ScoreConfig = ScoreConfig()) {
if (pattern.length == 1) texts.asSequence()
.filter { it.contains(pattern) }
.take(topN)
.map { match -> Result(listOf(match.indexOf(pattern)), scoreConfig.firstLetterMatch, match) }
.map { match -> EndResult(listOf(match.indexOf(pattern)), scoreConfig.firstLetterMatch, match) }
.toList()
else texts
.map { fuzzyMatchFunc(it, pattern) }
.filter { it.score > 0 }
.mapNotNull { word ->
(fuzzyMatchFunc(word, pattern) as? MatchResult)
?.let { result -> EndResult(result.indices, result.score, word) }
}
.sortedByDescending { it.score }
.take(topN)

private fun scoringFunction(indices: List<Int>, text: String): Int {
return listOf(
min(3, indices[0]) * scoreConfig.unmatchedLeadingLetter,
indices.windowed(2).map { indexWindow ->
val firstLetter = if (indexWindow.first() == 0) scoreConfig.firstLetterMatch else 0
val consecutive = if (indexWindow.first() == indexWindow.last() - 1) scoreConfig.consecutiveMatch else 0
val neighbour = text[indexWindow.first()]
val camelCase =
if (neighbour.isLowerCase() && text[indexWindow.last()].isUpperCase()) scoreConfig.camelCaseMatch else 0
val separator = if (neighbour == ' ' || neighbour == '_') scoreConfig.separatorMatch else 0
val unmatched = (indices.lastOrNull() ?: text.length) * scoreConfig.unmatchedLetter
indices
.asSequence()
.windowed(2)
.map { indexWindow ->
val firstLetter = if (indexWindow.first() == 0) scoreConfig.firstLetterMatch else 0
val consecutive =
if (indexWindow.first() == indexWindow.last() - 1) scoreConfig.consecutiveMatch else 0
val neighbour = text[indexWindow.first()]
val camelCase =
if (neighbour.isLowerCase() && text[indexWindow.last()].isUpperCase()) scoreConfig.camelCaseMatch else 0
val separator = if (neighbour == ' ' || neighbour == '_') scoreConfig.separatorMatch else 0
val unmatched = (indices.lastOrNull() ?: text.length) * scoreConfig.unmatchedLetter

firstLetter + consecutive + camelCase + separator + unmatched
}.sum()
firstLetter + consecutive + camelCase + separator + unmatched
}
.sum()
).sum()
}

object A {
@JvmStatic
fun main(args: Array<String>) {

}
}
}
7 changes: 7 additions & 0 deletions src/main/kotlin/com/londogard/fuzzymatch/Result.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.londogard.fuzzymatch

sealed class Result

data class MatchResult(val indices: List<Int>, val score: Int): Result()
data class EndResult(val indices: List<Int>, val score: Int, val text: String): Result()
object EmptyResult: Result()
27 changes: 24 additions & 3 deletions src/test/kotlin/com/londogard/fuzzymatch/FuzzyMatcherTest.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
package com.londogard.fuzzymatch

import org.junit.Test
import kotlin.system.measureNanoTime


class FuzzyMatcherTest {
val lines = javaClass.getResourceAsStream("english_355k_words.txt").bufferedReader().readLines()
val fuzzyMatcher = FuzzyMatcher()
private val lines = javaClass.getResourceAsStream("english_355k_words.txt")
.bufferedReader()
.readLines()
private val fuzzyMatcher = FuzzyMatcher()

@Test
fun `test speed`() {
// Old ~2.6s for 1 'abc' match
println(lines.take(10))

(1 until 1000).forEach {
fuzzyMatcher.fuzzyMatch(lines, "abc", 20)
}
var a = 0
measureNanoTime {
(1 until 1000).forEach {
a = fuzzyMatcher.fuzzyMatch(lines, "he", 20).size
}
}.also { println(it / 1000 / 1000 / 1000) }
println(fuzzyMatcher.fuzzyMatch(lines, "he", 20))
}

@Test
fun `fuzzy match should match something`() {
assert(fuzzyMatcher.fuzzyMatch(lines, "2nd").contains(FuzzyMatcher.Result(listOf(0, 1, 2), 41, "2nd")))
// assert(fuzzyMatcher.fuzzyMatch(lines, "2nd").contains(FuzzyMatcher.Result(listOf(0, 1, 2), 41, "2nd")))
assert(fuzzyMatcher.fuzzyMatch(lines, "a").size == 20)
}

Expand Down