DEV Community 👩‍💻👨‍💻

Cover image for Reimplementing grep in JavaScript using rolling hashes
Jeff Gould
Jeff Gould

Posted on • Updated on • Originally published at jrgould.com

Reimplementing grep in JavaScript using rolling hashes

Some time ago I watched an interview on interviewing.io where the task that the interviewer posed to the interviewee was to create a function called grep() that should accept two strings, haystack and needle, and return an array of indexes where needle exists in haystack.

The naive approach

The naive approach is pretty simple in javascript, just loop through each letter in the haystack, grab a slice starting at the current letter that is the length of the needle, and check to see if that slice is equal to the needle.

Here's what that might look like:

// Naive approach
function grepNaive(haystack, needle) {
  const matches = [];
  for(let i = 0; i < haystack.length - (needle.length-1); i++){
    const substr = haystack.slice(i, i + needle.length);
    if( substr === needle ) matches.push(i);
  } 
  return matches; 
}

This works great but it's not super efficient. The loop only runs once for each possible match, but the string comparison is arguably a heavy operation that further requires that each letter in the slice be compared to each letter in the needle. JavaScript hides this bit of complexity from us by allowing us to check string equality, but under the hood each individual letter still needs to be evaluated and since we're making this comparison once for each letter in the haystack, most letters actually get compared multiple times.

A more efficient solution: rolling-hash search

If you were to get asked this question in an interview, this is the point where the interviewer would say something a long the lines of "this is great, but is there a more efficient solution?" Any maybe you've memorized Rabin-Karp because you're a 10x-rockstar-ninja-guru. Awesome. If you're like me and you have no idea what fish have to do with pattern matching algorithms - then you've come to the right place!

First thing's first: What even is a rolling hash? Well, basically it's the idea that you can find hash of a substring within a string and then shift the first letter out of that hash and push another letter onto the end of the hash to find the hash of the substring next to the original without recalculating the entire hash. To do that we need to come up with a way to hash strings that will allow us to do this shift and push operation while still producing hashes that don't produce too many collisions (eg hash('ba') ≠ hash('ab')).

To do that, let's start simple and hash just a single character with a hash1() function. We can do this by finding the character code of a character and just multiplying that by a large number - multiplying by a large number will help us to avoid hash collisions when we're finding hashes of whole strings.

function hash1(str) {
  return str.charCodeAt(0) * 256;
}

We could use this function to create hashes of stings by adding the hashes together, but right now this will still be very prone to collision because hash1('a') + hash1('b') === hash1('b') + hash1('a') - we need to add a way to create hashes that respect the order of the characters in a string, and we can do that by first adding a vector argument to our hash1() function.

function hash1(char, vector = 0) {
  return char.charCodeAt(0) * 256 ** vector;
}

All that's changed here is that we're accepting that vector argument and using it as an exponent on our hash - so now, since vector defaults to 0, hash1('d') will actually return the same value as 'd'.charCodeAt(0) which is 100 but we can specify a vector to signify different positions of the letter, so the hash1 of the letter d within the string mad would be hash1('d', 2) which would be 6553600.

Now we just need to create a string hashing function that will use our hash1() function to create hashes of whole strings. We can do that by just passing each letter to hash1(), using its position in the string as the vector and adding all of those values up. I'll do that by splitting the string up into an array of characters and then utilize JavaScript's Array.map and Array.reduce methods to find the hash value and then get the sum of all hash values in the string:

function hash(str) {
  return str
    .split('') // transform the string into an array of characters
    .map(hash1) // hash each character, using its index in the array as vector
    .reduce(add, 0) // add all of the values up (assume add function defined as `(a,b) => a+b`)
}

What's nice about this hash method is that while there is a low chance of hash collisions, the math is pretty straightforward which allows us to modify the hash without having to jump through too many hoops. For example, if you find the hash for a string like foo (7303014) and want to find the hash for just oo, all you have to do is subtract the hash for f which is just its character code 7303014 - 102 = 7302912 and then shift the exponents on the rest of the hashed characters down by one which can be done by dividing the the remaining hash number by 256 7302912/256 = 28527.

Why divide by 256? Because (a*256^n)/256 is equal to (a*256^(n-1)). So assuming the character code for "f" is 102 and "o" is 111, we can find the original hash using the following equation hash(foo) = (102*256^0) + (111*256^1) + (111*256^2) so when we subtract 102 that equation looks like hash(_oo) = (111*256^1) + (111*256^2) and then dividing by 256 gets us hash(oo) = (111*256^0)+(111*256^1). Let's create a function to express that:

function shiftHash(originalHash, firstChar) {
  return (originalHash - hash1(firstChar)) / 256;
}

Next, we can create a function to pop a character on to the end of the existing hash. To add a character onto an existing hash, we just need to add the hash1() of the character that we'd like to add, with the vector appropriate for the position that we'll add it. So if we have the hash for oo (28527) and we'd like to find the hash for oof, we just need to find hash1('f', 2) //6684672 and add that to the existing hash 28527 + 6684672 = 6713199. Here's how that would work as a function

function pushHash(originalHash, strLen, nextChar) {
  return originalHash + hash1(nextChar, strLen-1); // Subtract 1 from strLen because indexes start at 0
}

And from that we can compose a rollHash function that will remove the first character from an existing hash and add a new character onto the end:

function rollHash(originalHash, firstChar, nextChar, strLen) {
  const shiftedHash = shiftHash(originalHash, firstChar);
  return pushHash(shiftedHash, strLen, nextChar);
}

Now we can find the hash of a string hash('foo') //7303014 and then easily find the hash of oof by using rollHash(7303014, 'f', 'f', 3); //6713199.

To put it all together, we can now build our grep function by looping through our haystack to find occurrences of our needle

// Rolling Hash Approach
function grepHash(haystack, needle) {
  const matches = []; // This will store all indexes where a match occurs
  const needleHash = hash(needle);
  const needleLength = needle.length;
  let currentHash = hash(haystack.substr(0, needleLength)); 
  for (let i = 0; i < haystack.length - (needleLength - 1); i++) {
    if (needleHash === currentHash) matches.push(i); // If hashes match, push the current index into matches
    currentHash = rollHash(
      currentHash,
      haystack[i], // first character in current search
      haystack.substr(i + needleLength, 1), // next letter in haystack
      needleLength
    );
  }

  return matches;
}

The code here is definitely more complicated, but this is often the case with optimized solutions - we're doing more work up front to prevent the computer from having to do it at runtime. Here it is in a CodeSandbox for you to play around with.

Conclusion

To be completely honest, modern hardware and languages would probably still support the naive solution just fine, even at scale. So this may only be something that you use in the context of interviewing or if you enjoy trying to level up on platforms like HackerRank. As a self-taught developer, I've come to enjoy learning about algorithms because even if I don't end up using them in my day to day, it helps me to expand the way that I think about code and problem solving.

Top comments (1)

Collapse
 
jrgould profile image
Jeff Gould Author

Thanks to Chris Biscardi for the workshopped title!

⬇️ The only reason people scroll to the bottom...

is because they want to read more. Sign up for an account to bookmark, comment, and react to articles that interest you.