After some research and discussion, I decided to go with a tripartite / state machine.
A string is parsed starting with the last character going back using TRIE if the part of the suffix that has been parsed so far can match multiple suffixes. At some point, we either press the first character of one of the possible suffixes, which means that we have a match, we get into a dead end, which means that there are no more possible matches or getting into a situation where there is only one candidate suffix. In this case, we simply compare the remainder of the suffix.
Since trie lookups are constant time, the worst difficulty is o (maximum suffix length). The function turned out to be pretty fast. At the 2.8 GHz Core i5, it can check 33,000,000 lines per second for 2K possible suffixes. 2K suffixes for a total of 18 kilobytes, expanded to 320kb trie / state machine table. I suppose I could store it more efficiently, but this solution still works quite well.
Since the list of suffixes was so large, I did not want it in a code way manually, so I ended up writing a C # application that generated C code for the suffix check function:
public static uint GetFourBytes(string s, int index) { byte[] bytes = new byte[4] { 0, 0, 0, 0}; int len = Math.Min(s.Length - index, 4); Encoding.ASCII.GetBytes(s, index, len, bytes, 0); return BitConverter.ToUInt32(bytes, 0); } public static string ReverseString(string s) { char[] chars = s.ToCharArray(); Array.Reverse(chars); return new string(chars); } static StringBuilder trieArray = new StringBuilder(); static int trieArraySize = 0; static void Main(string[] args) { // read all non-empty lines from input file var suffixes = File .ReadAllLines(@"suffixes.txt") .Where(l => !string.IsNullOrEmpty(l)); var reversedSuffixes = suffixes .Select(s => ReverseString(s)); int start = CreateTrieNode(reversedSuffixes, ""); string outFName = @"checkStringSuffix.debug.h"; if (args.Length != 0 && args[0] == "--release") { outFName = @"checkStringSuffix.h"; } using (StreamWriter wrt = new StreamWriter(outFName)) { wrt.WriteLine( "#pragma once\n\n" + "#define TRIE_NONE -1000000\n"+ "#define TRIE_DONE -2000000\n\n" ); wrt.WriteLine("const int trieArray[] = {{{0}\n}};", trieArray); wrt.WriteLine( "inline bool checkSingleSuffix(const char* str, const char* curr, const int* trie) {\n"+ " int len = trie[0];\n"+ " if (curr - str < len) return false;\n"+ " const char* cmp = (const char*)(trie + 1);\n"+ " while (len-- > 0) {\n"+ " if (*--curr != *cmp++) return false;\n"+ " }\n"+ " return true;\n"+ "}\n\n"+ "bool checkStringSuffix(const char* str, int len) {\n" + " if (len < " + suffixes.Select(s => s.Length).Min().ToString() + ") return false;\n" + " const char* curr = (str + len - 1);\n"+ " int currTrie = " + start.ToString() + ";\n"+ " while (curr >= str) {\n" + " assert(*curr >= 0x20 && *curr <= 0x7f);\n" + " currTrie = trieArray[currTrie + *curr - 0x20];\n" + " if (currTrie < 0) {\n" + " if (currTrie == TRIE_NONE) return false;\n" + " if (currTrie == TRIE_DONE) return true;\n" + " return checkSingleSuffix(str, curr, trieArray - currTrie - 1);\n" + " }\n"+ " --curr;\n"+ " }\n" + " return false;\n"+ "}\n" ); } } private static int CreateTrieNode(IEnumerable<string> suffixes, string prefix) { int retVal = trieArraySize; if (suffixes.Count() == 1) { string theSuffix = suffixes.Single(); trieArray.AppendFormat("\n\t/* {1} - {2} */ {0}, ", theSuffix.Length, trieArraySize, prefix); ++trieArraySize; for (int i = 0; i < theSuffix.Length; i += 4) { trieArray.AppendFormat("0x{0:X}, ", GetFourBytes(theSuffix, i)); ++trieArraySize; } retVal = -(retVal + 1); } else { var groupByFirstChar = from s in suffixes let first = s[0] let remainder = s.Substring(1) group remainder by first; string[] trieIndexes = new string[0x60]; for (int i = 0; i < trieIndexes.Length; ++i) { trieIndexes[i] = "TRIE_NONE"; } foreach (var g in groupByFirstChar) { if (g.Any(s => s == string.Empty)) { trieIndexes[g.Key - 0x20] = "TRIE_DONE"; continue; } trieIndexes[g.Key - 0x20] = CreateTrieNode(g, g.Key + prefix).ToString(); } trieArray.AppendFormat("\n\t/* {1} - {2} */ {0},", string.Join(", ", trieIndexes), trieArraySize, prefix); retVal = trieArraySize; trieArraySize += 0x60; } return retVal; }
So, it generates the code as follows:
inline bool checkSingleSuffix(const char* str, const char* curr, const int* trie) { int len = trie[0]; if (curr - str < len) return false; const char* cmp = (const char*)(trie + 1); while (len-- > 0) { if (*--curr != *cmp++) return false; } return true; } bool checkStringSuffix(const char* str, int len) { if (len < 5) return false; const char* curr = (str + len - 1); int currTrie = 81921; while (curr >= str) { assert(*curr >= 0x20 && *curr <= 0x7f); currTrie = trieArray[currTrie + *curr - 0x20]; if (currTrie < 0) { if (currTrie == TRIE_NONE) return false; if (currTrie == TRIE_DONE) return true; return checkSingleSuffix(str, curr, trieArray - currTrie - 1); } --curr; } return false; }
Since there was no more than 9 for my specific len data set in checkSingleSuffix, I tried to replace the comparison loop with the switches (len) and hard comparison programs that compared up to 8 bytes of data at a time, but this is not so, t affect the overall performance anyway.
Thanks to everyone who contributed their ideas!