I submitted the following code for a job application, and was rejected. They gave no feedback. Would appreciate any comments on the approach I took, and coding style.
This is the question:
Given two words (start and end) and the dictionary, find the length of the shortest transformation sequence from start to end, such that:
1) Only one letter can be changed at a time
2) Each intermediate word must exist in the given dictionary
3) At each step, exactly one character is replaced with another character
For example:
start = “hit”
end = “cog”
dictionary = [“hit”,”dot”,”dog”,”cog”,”hot”,”log”]
As one of the shortest transformations is “hit” -> “hot” -> “dot” -> “dog” -> “cog”, return its length 4. Note: All words have the same length. All words contain only lowercase alphabetic characters.
My solution:
public class DictionaryDashSolver
{
TransformationGraph transformationGraph = null;
HashWordMap hashWordMap;
/// <summary>
/// Loads the dictionary of words to be used when calling Solve
/// </summary>
/// <param name="dictionary">An array of string</param>
public void LoadDictionary(string[] dictionary)
{
if (dictionary == null)
throw new Exception ("Supplied dictionary is null");
dictionary = ConvertToLowerCase (dictionary);
//create the graph of words to valid word with single character transformations
this.transformationGraph = new TransformationGraph (dictionary);
//create a lookup table from hash to word for converting the result from the graph traversal
this.hashWordMap = new HashWordMap (dictionary);
}
/// <summary>
/// Finds the shortest path between two words, in the dictionary.
/// Dictionary should be supplied using LoadDictionary.
/// Returns the list of transformation, including fromWord and toWord
/// </summary>
/// <param name="fromWord">Word to transform from </param>
/// <param name="toWord">Word to transform towards</param>
public List<string> Solve(string fromWord, string toWord)
{
if (this.transformationGraph == null)
{
throw new Exception("DictionaryDashSolver has no loaded transformation graph");
}
if (this.hashWordMap == null)
{
throw new Exception("DictionaryDashSolver has no loaded hash to word map");
}
if (string.IsNullOrEmpty(fromWord) || string.IsNullOrEmpty(toWord))
{
throw new Exception("Cannot solve for null input words");
}
if (fromWord.Length != toWord.Length)
{
throw new Exception("fromWord " + fromWord + " and toWord " + toWord + " must have the same number of characters");
}
fromWord = fromWord.ToLower ();
toWord = toWord.ToLower ();
var hashRoute = FindRoute (fromWord, toWord);
return ConvertRouteIntoWords(hashRoute);
}
string[] ConvertToLowerCase(string[] inputDictionary)
{
for (int i = 0; i < inputDictionary.Length; i++)
if (inputDictionary [i] != null)
inputDictionary [i] = inputDictionary [i].ToLower ();
return inputDictionary;
}
string[] ReplaceNullWordsWithEmptyString(string[] inputDictionary)
{
for (int i = 0; i < inputDictionary.Length; i++)
if (inputDictionary [i] == null)
inputDictionary [i] = "";
return inputDictionary;
}
List<int> FindRoute(string fromWord, string toWord)
{
var astar = new AStar<int> () {
GetNeighbours = (a) => this.transformationGraph.GetNeighbours (a),
Heuristic = (a, b) => GetHammingDistanceForWordHashes (a, b),
DistanceBetween = (a, b) => GetHammingDistanceForWordHashes (a, b)
};
return astar.CalculateRoute (fromWord.GetHashCode (), toWord.GetHashCode ());
}
float GetHammingDistanceForWordHashes(int word1Hash, int word2Hash)
{
var wordA = this.hashWordMap.HashToWord (word1Hash);
var wordB = this.hashWordMap.HashToWord (word2Hash);
if (string.IsNullOrEmpty (wordA) || string.IsNullOrEmpty (wordB))
{
return float.MaxValue; //
}
return wordA.GetHammingDistance (wordB);
}
List<string> ConvertRouteIntoWords(List<int> hashRoute)
{
if (hashRoute == null)
return null;
List<string> newList = new List<string> ();
hashRoute.ForEach ((i) => newList.Add (this.hashWordMap.HashToWord (i)));
return newList;
}
}
public class TransformationGraph
{
Dictionary<int, List<int>> validTransformations = new Dictionary<int, List<int>> ();
public TransformationGraph (string[] dictionary)
{
this.validTransformations.Clear ();
LoadWordHashes (dictionary);
BuildTransformations (dictionary);
}
public List<int> GetNeighbours(int node)
{
List<int> neighbours = null;
if (!this.validTransformations.TryGetValue (node, out neighbours))
neighbours = new List<int> ();
return neighbours;
}
private void LoadWordHashes(string[] dictionary)
{
foreach (string word in dictionary)
{
if (word != null)
{
var hash = word.GetHashCode ();
this.validTransformations [hash] = null;
}
}
}
private void BuildTransformations (string[] dictionary)
{
foreach (string word in dictionary)
{
if (word != null)
this.validTransformations[word.GetHashCode()] = GetValidTransformationsFromWord (word);
}
}
private List<int> GetValidTransformationsFromWord (string fromWord)
{
List<int> transformations = new List<int>();
var stringBuilder = new StringBuilder (fromWord);
int fromWordHash = fromWord.GetHashCode ();
for (int characterIndex = 0; characterIndex < fromWord.Length; characterIndex++)
{
var existingCharacter = stringBuilder [characterIndex];
for (char character = 'a'; character <= 'z'; character++)
{
stringBuilder [characterIndex] = character;
var newWord = stringBuilder.ToString ();
var newWordHash = newWord.GetHashCode();
if (IsValid (newWordHash) && fromWordHash != newWordHash )
{
transformations.Add (newWordHash);
}
}
stringBuilder [characterIndex] = existingCharacter;
}
return transformations;
}
private bool IsValid(int wordHash)
{
return validTransformations.ContainsKey (wordHash);
}
}
public class HashWordMap : Dictionary<int,string>
{
public HashWordMap (string[] dictionary)
{
this.Clear ();
BuildMap (dictionary);
}
private void BuildMap(string[] dictionary)
{
foreach (string word in dictionary)
{
if (word != null)
{
var hash = word.GetHashCode ();
this [hash] = word;
}
}
}
public string HashToWord(int hash)
{
return this.TryGetOrDefault(hash);
}
}
public class AStar<T>
{
HashSet<T> closedSet = new HashSet<T> ();
HashSet<T> openSet = new HashSet<T> ();
Dictionary<T,T> cameFrom = new Dictionary<T,T> ();
Dictionary<T,float> gScore = new Dictionary<T,float> ();
Dictionary<T,float> fScore = new Dictionary<T,float> ();
public Func<T,List<T>> GetNeighbours { get; set; }
public Func<T,T, float> DistanceBetween { get; set; }
public Func<T,T, float> Heuristic { get; set; }
public Func<T,T, bool> Compare { get; set; } = (x,y) => EqualityComparer<T>.Default.Equals (x, y);
public List<T> CalculateRoute (T start, T end)
{
if (GetNeighbours == null || DistanceBetween == null || Heuristic == null)
throw new Exception("AStar functions not initialised");
Clear ();
openSet.Add (start);
gScore [start] = 0;
fScore [start] = Heuristic (start, end);
while (openSet.Count > 0)
{
var current = GetBestOpen ();
if (Compare (current, end))
return ReconstructPath (cameFrom, current);
openSet.Remove (current);
closedSet.Add (current);
foreach (var neighbour in GetNeighbours(current))
{
if (closedSet.Contains (neighbour))
continue;
var score = gScore.TryGetOrDefault (current, float.MaxValue) + DistanceBetween (current, neighbour);
if (!openSet.Contains (neighbour))
openSet.Add (neighbour);
if (score >= gScore.TryGetOrDefault (neighbour, float.MaxValue))
continue; //not better
cameFrom [neighbour] = current;
gScore [neighbour] = score;
fScore [neighbour] = gScore [neighbour] + Heuristic (neighbour, end);
}
}
return null;
}
void Clear ()
{
closedSet.Clear ();
openSet.Clear ();
cameFrom.Clear ();
gScore.Clear ();
fScore.Clear ();
}
T GetBestOpen ()
{
float minScore = float.MaxValue;
T minItem = default(T);
foreach (var item in openSet)
{
var score = fScore.TryGetOrDefault (item);
if (score <= minScore)
{
minItem = item;
minScore = score;
}
}
return minItem;
}
List<T> ReconstructPath (Dictionary<T,T> cameFrom, T current)
{
var path = new List<T> ();
path.Add (current);
while (cameFrom.ContainsKey (current))
{
current = cameFrom [current];
path.Add (current);
}
path.Reverse ();
return path;
}
}
public static class DotNetExtensions
{
public static int GetHammingDistance(this string strA, string strB)
{
if(strA.Length != strB.Length)
{
throw new Exception("Strings must be equal length");
}
int dist =
strA.ToCharArray()
.Zip(strB.ToCharArray(), (c1, c2) => new { c1, c2 })
.Count(m => m.c1 != m.c2);
return dist;
}
public static T1 TryGetOrDefault<T,T1>(this Dictionary<T, T1> fScoreMap, T key, T1 defaultVal = default(T1))
{
T1 value;
if (!fScoreMap.TryGetValue(key, out value))
value = defaultVal;
return value;
}
}
Tests:
[TestFixture ()]
public class DictionaryDashSolverTest
{
Random random = new Random();
[Test]
public void BasicTest()
{
var mc = new DictionaryDashSolver();
mc.LoadDictionary(new string[] {"boy", "bot"});
var result = mc.Solve("boy", "bot");
Assert.AreEqual(2, result.Count);
Assert.AreEqual("boy", result[0]);
Assert.AreEqual("bot", result[1]);
}
[Test]
public void SameWord()
{
var mc = new DictionaryDashSolver();
mc.LoadDictionary(new string[] {"boy", "bag", "bat"});
var result = mc.Solve ("boy", "boy");
Assert.AreEqual(1, result.Count);
Assert.AreEqual("boy", result[0]);
}
[Test]
public void NullWordInInput()
{
var mc = new DictionaryDashSolver();
mc.LoadDictionary(new string[] {"boy", "bag", "bat"});
Assert.Throws<Exception>( () => mc.Solve (null, "boy"));
}
[Test]
public void NullWordInDictionary()
{
var mc = new DictionaryDashSolver();
mc.LoadDictionary(new string[] {"boy", null, "bat"});
Assert.DoesNotThrow( () => mc.Solve ("bat", "boy"));
}
[Test]
public void UppercaseInput()
{
var mc = new DictionaryDashSolver();
mc.LoadDictionary(new string[] {"boy", "bot", "bat"});
var result = mc.Solve ("Boy", "bot");
Assert.AreEqual(2, result.Count);
Assert.AreEqual("boy", result[0]);
Assert.AreEqual("bot", result[1]);
}
[Test]
public void UppercaseDictionary()
{
var mc = new DictionaryDashSolver();
mc.LoadDictionary(new string[] {"Boy", "bot", "bat"});
var result = mc.Solve ("boy", "bot");
Assert.AreEqual(2, result.Count);
Assert.AreEqual("boy", result[0]);
Assert.AreEqual("bot", result[1]);
}
[Test]
public void InputWordNotInDictionary()
{
var mc = new DictionaryDashSolver();
mc.LoadDictionary(new string[] {"boy", "bot", "bat"});
Assert.IsNull(mc.Solve ("bog", "bot"));
}
[Test]
public void NumericInDictionary()
{
var mc = new DictionaryDashSolver();
mc.LoadDictionary(new string[] {"coy", "co2", "bo2", "bot", "bat"});
Assert.IsNull(mc.Solve ("coy", "bat"));
}
[Test]
public void NumericInput()
{
var mc = new DictionaryDashSolver();
mc.LoadDictionary(new string[] {"coy", "cog", "bog", "bot", "bat"});
Assert.IsNull(mc.Solve ("co2", "bat"));
}
[Test]
public void SymbolInInput()
{
var mc = new DictionaryDashSolver();
mc.LoadDictionary(new string[] {"coy", "cog", "bog", "bot", "bat"});
Assert.IsNull(mc.Solve ("co@", "bat"));
}
[Test]
public void NumCharactersDifferent()
{
var mc = new DictionaryDashSolver();
mc.LoadDictionary(new string[] {"cope", "cog", "bog", "bot", "bat"});
Assert.Throws<Exception> (() => mc.Solve ("cope", "bat"));
}
[Test]
public void NullIfNoRoute()
{
var mc = new DictionaryDashSolver();
mc.LoadDictionary(new string[] {"boy", "bag", "bat"});
Assert.IsNull(mc.Solve ("boy", "bat"));
}
[Test]
public void NullDictionary()
{
var mc = new DictionaryDashSolver();
Assert.Throws<Exception>(() => mc.Solve ("boy", "bat"));
}
[Test]
public void FindShortestPath()
{
var mc = new DictionaryDashSolver();
mc.LoadDictionary(new string[] {"aaa", "baa", "caa", "cca", "ccc", "aac", "acc"});
var result = mc.Solve ("aaa", "ccc");
Assert.AreEqual (result.Count, 4); //naive algorithm would take 5 steps, assuming transformation are checked character 0 -> n, a -> z
}
[Test]
public void LargeDictionaryFindOptimalSolution()
{
var mc = new DictionaryDashSolver();
int size = 25000;
var dict = new string[size + 5];
int i = 0;
for (; i < size; i++)
dict [i] = RandomString (4);
dict[i++] = "aaaa";
dict[i++] = "aaaz";
dict[i++] = "aazz";
dict[i++] = "azzz";
dict[i++] = "zzzz";
mc.LoadDictionary(dict);
List<string> result = null;
Assert.DoesNotThrow (() => result = mc.Solve ("aaaa", "zzzz"));
Assert.IsNotNull (result);
Assert.AreEqual (5, result.Count);
}
public string RandomString(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
return new string(Enumerable.Repeat(chars, length)
.Select(s => s[random.Next(s.Length)]).ToArray());
}
}
ConvertToLowerCase()
. We are also told that all words are of the same length but we are checking for null words. Neither are big things but could tip the balance if the interviewer was being picky. The same with leaving unused code (e.g.ReplaceNullWordsWithEmptyString()
) in the final code. \$\endgroup\$