包含一個簡短而完整的Web示例, 演示如何根據用戶輸入的字符進行自動提示和補全。
一、 場景與目標
在使用 IDE 開發軟件時, IDE 會提供一種“智能提示”, 根據所輸入的字符列出可能的詞組; 在日常Web開發中,根據用戶輸入進行自動提示和補全,也能很好地改善使用體驗。本文實現輸入自動提示與補全功能。
輸入自動補全功能實際上是“前綴匹配問題”, 即給定一個前綴以及一個單詞列表, 找出所有包含該前綴的單詞。
本文實現的功能是: 根據用戶輸入的關鍵字, 給出與之匹配的 Java 關鍵字。
二、 算法與設計
最簡單直觀的方案莫過於直接遍歷單詞列表, 檢測每個單詞是否包含前綴, 並返回。這樣做的缺點是, 每次都要遍歷單詞列表, 效率非常低下。 一個更好的思路是, 先構建一個前綴匹配映射 Map<Prefix, List<Matcher>>, key 是每一個單詞中所包含的前綴, value 是包含該 key 的所有單詞列表。 那么, 問題就轉化為給定一個單詞列表 list<Word>, 將其轉換為 Map<Prefix, List<Matcher>> , 這里 Word, Prefix, Matcher 均為 String 類型。
一種思路是, 遍歷每一個單詞包含的每一個前綴, 找出所有包含該前綴的單詞。
for word in words
for prefix in word(0,i)
for word in words
if (word.startWith(prefix)) {
result.put(prefix, result.get(prefix).add(word));
}
顯然, 其效率是 O(總前綴數*總單詞數), 在單詞列表比較大的情況下, 其效率是比較低的。 要想避免這種嵌套遍歷, 就必須充分利用每一次遍歷,獲取充分的信息。
另一種思路是, 先找出每個單詞中所包含的前綴匹配對, 再將這些前綴匹配對合並為最終的前綴匹配映射。 類似 Map - Reduce 方式。
for word in words
for prefix in word(0,i)
pairs.add(new Pair(prefix, word))
mergePairs(pairs)
其效率是O(總的前綴數)。
三、 代碼設計與實現
下面給出代碼設計與實現。 注意到, 這是通過多次小步重構達到的結果, 而不是一次性實現。 具體是, 先寫出一個最簡單的實現, 可以把應用跑起來; 然后, 思考更有效率的實現, 最后進行了抽象。
1. 定義接口
package autocomplete; import java.util.Set; public interface PrefixMatcher { Set<String> obtainMatchedWords(String inputText); }
1 package autocomplete; 2 3 import java.util.Collections; 4 import java.util.HashMap; 5 import java.util.HashSet; 6 import java.util.Map; 7 import java.util.Set; 8 9 public abstract class AbstractPrefixMatcher implements PrefixMatcher { 10 11 protected final String[] javaKeywords = new String[] { 12 "abstract", "assert", 13 "boolean", "break", "byte", 14 "case", "catch", "char", "class", "const", "continue", 15 "default", "do", "double", 16 "else", "enum", "extends", 17 "final", "finally", "float", "for", 18 "goto", 19 "if", "implements", "import", "instanceof", "int", "interface", 20 "long", 21 "native", "new", 22 "package", "private", "protected", "public", 23 "return", 24 "strictfp", "short", "static", "super", "switch", "synchronized", 25 "this", "throw", "throws", "transient", "try", 26 "void", "volatile", 27 "while" 28 }; 29 30 protected Map<String, Set<String>> prefixMatchers = new HashMap<String, Set<String>>(); 31 32 abstract void dynamicAddNew(String inputText); 33 34 public Set<String> obtainMatchedWords(String inputText) { 35 Set<String> matchers = prefixMatchers.get(inputText); 36 if (matchers == null) { 37 Set<String> input = new HashSet<String>(); 38 input.add(inputText); 39 dynamicAddNew(inputText); 40 return input; 41 } 42 return matchers; 43 } 44 45 protected Map<String, Set<String>> obtainPrefixMatchers() { 46 return Collections.unmodifiableMap(prefixMatchers); 47 } 48 49 }
package autocomplete; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; public class SimpleWordMatcher extends AbstractPrefixMatcher { public SimpleWordMatcher() { prefixMatchers = buildPrefixMatchers(javaKeywords); } /** * 將輸入的單詞組轉化為前綴匹配的映射 * @param keywords * @return * * eg. {"abc", "acd", "bcd"} ===> * {"a": ["abc", "acd"], "ab": ["abc"], "abc": ["abc"], * "ac": ["acd"], "acd": ["acd"], "b": ["bcd"], "bc": ["bcd"], "bcd": ["bcd"] * } */ public Map<String, Set<String>> buildPrefixMatchers(String[] keywords) { HashMap<String, Set<String>> prefixMatchers = new HashMap<String, Set<String>>(); for (String keyword: keywords) { int wordLen = keyword.length(); for (int i=1; i < wordLen; i++) { String prefix = keyword.substring(0, i); for (String keyword2: javaKeywords) { if (keyword2.startsWith(prefix)) { Set<String> matchers = prefixMatchers.get(prefix); if (matchers == null) { matchers = new HashSet<String>(); } matchers.add(keyword2); prefixMatchers.put(prefix, matchers); } } } } return prefixMatchers; } public static void main(String[] args) { SimpleWordMatcher wordMatcher = new SimpleWordMatcher(); MapUtil.printMap(wordMatcher.obtainPrefixMatchers()); String[] prefixes = new String[] {"a", "b", "c", "d", "e", "f", "g", "i", "l", "n", "p", "r", "s", "t", "v", "w", "do", "finally"}; for (String prefix: prefixes) { System.out.println(wordMatcher.obtainMatchedWords(prefix)); } } @Override void dynamicAddNew(String inputText) { } }
4. 性能更好的實現
package autocomplete; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; public class EffectiveWordMatcher extends AbstractPrefixMatcher { public EffectiveWordMatcher() { prefixMatchers = buildPrefixMatchers(javaKeywords); } static class Pair { private String key; private String value; public Pair(String key, String value) { this.key = key; this.value = value; } public String getKey() { return key; } public String getValue() { return value; } public String toString() { return "<" + key + "," + value + ">"; } } private Map<String, Set<String>> buildPrefixMatchers(String[] javakeywords) { List<Pair> pairs = strarr2pairs(javakeywords); return mergePairs(pairs); } /* * 將 字符串數組轉化為前綴匹配對 * eg. ["ab", "ac"] ===> * [<"a","ab">, <"ab", "ab">, <"a", "ac">, <"ac", "ac">] */ private List<Pair> strarr2pairs(String[] javakeywords) { List<Pair> pairs = new ArrayList<Pair>(); for (String keyword: javakeywords) { int wordLen = keyword.length(); for (int i=1; i < wordLen; i++) { String prefix = keyword.substring(0, i); Pair pair = new Pair(prefix, keyword); pairs.add(pair); } } return pairs; } /* * 將多個 <key,value> 合並為一個映射 * eg. [<"a", "abstract">, <"b", "boolean">, <"a", "assert">, <"b", "break">, <"c", "continue">] ===> * {"a"=>["abstract", "assert", "b"=>["boolean", "break"], "c"=>["continue"]} */ private static Map<String, Set<String>> mergePairs(List<Pair> pairs) { Map<String, Set<String>> result = new HashMap<String, Set<String>>(); if (pairs != null && pairs.size() > 0) { for (Pair pair: pairs) { String key = pair.getKey(); String value = pair.getValue(); Set<String> matchers = result.get(key); if (matchers == null) { matchers = new HashSet<String>(); } matchers.add(value); result.put(key, matchers); } } return result; } @Override void dynamicAddNew(String inputText) { if (checkValid(inputText)) { List<Pair> newpairs = strarr2pairs(new String[] {inputText}); Map<String, Set<String>> newPreixMatchers = mergePairs(newpairs); mergeMap(newPreixMatchers, prefixMatchers); } } private boolean checkValid(String inputText) { return false; } private Map<String, Set<String>> mergeMap(Map<String, Set<String>> src, Map<String, Set<String>> dest) { Set<Map.Entry<String, Set<String>>> mapEntries = src.entrySet(); Iterator<Map.Entry<String, Set<String>>> iter = mapEntries.iterator(); while (iter.hasNext()) { Map.Entry<String, Set<String>> entry = iter.next(); String key = entry.getKey(); Set<String> newMatchers = entry.getValue(); if (dest.containsKey(key)) { dest.get(key).addAll(newMatchers); } else { dest.put(key, newMatchers); } } return dest; } public static void main(String[] args) { EffectiveWordMatcher wordMatcher = new EffectiveWordMatcher(); MapUtil.printMap(wordMatcher.obtainPrefixMatchers()); String[] prefixes = new String[] {"a", "b", "c", "d", "e", "f", "g", "i", "l", "n", "p", "r", "s", "t", "v", "w", "do", "finally"}; for (String prefix: prefixes) { System.out.println(wordMatcher.obtainMatchedWords(prefix)); } } }
5. Servlet 使用
package servlets; import java.io.IOException; import java.util.Set; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import autocomplete.EffectiveWordMatcher; import autocomplete.PrefixMatcher; public class AutoCompleteServlet extends HttpServlet { protected PrefixMatcher wordMatcher = new EffectiveWordMatcher(); public void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doPost(req, resp); } public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType("text/plain;charset=UTF8"); String inputText = req.getParameter("inputText"); Set<String> matchers = wordMatcher.obtainMatchedWords(inputText); StringBuilder sb = new StringBuilder(); for (String m: matchers) { sb.append(m); sb.append(' '); } sb.deleteCharAt(sb.length()-1); resp.getWriter().print(sb.toString()); } }
6. 前端交互
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd"> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>輸入自動補全功能演示</title> <script type="text/javascript" src="jquery-1.10.2.min.js"></script> <script> $(document).ready(function() { var searchMatchers = function(keycode) { var inputText = $('#inputText').val(); $.ajax( { url: 'servlets/AutoCompleteServlet', data: { 'inputText': inputText }, dataType: 'text', timeout: 10000, success: function(data) { if (keycode == 13) { // Enter $('#inputText').val($('#matchedKeywords').val()); $('#resultRegion').empty(); return ; } if (keycode == 38 || keycode == 40) { // 上下箭頭 $('#matchedKeywords').trigger('focus'); return ; } $('#resultRegion').empty(); var matchers = data.split(' '); if (matchers.length > 0 && inputText != '') { $('#resultRegion').append('<select id="matchedKeywords"></select>') $('#matchedKeywords').append('<option value="' + '' + '">' + '' + '</option>'); for (i=0; i<matchers.length; i++) { var keyword = matchers[i]; $('#matchedKeywords').append('<option value="' + keyword + '">' + keyword + '</option>'); } $('#matchedKeywords').attr('size', matchers.length+1); $('#matchedKeywords').height(20*(matchers.length+1)); $('#matchedKeywords').click(function() { $('#inputText').val($('#matchedKeywords').val()); $('#resultRegion').empty(); }); } } } ); } $(this).bind("keyup", function(eventObj) { var keycode = eventObj.which; searchMatchers(keycode); }); }); </script> <style type="text/css"> #main { margin: 15% 20% 0% 25%; } #inputText { width: 450px; height: 30px; } #matchedKeywords { width: 450px; height: 25px; } #resultRegion { text-align: left; margin: 0 0 0 128px; } </style> </head> <body> <center> <div id="main"> <h3> 輸入自動補全功能演示,請輸入Java關鍵字: </h3> <input type="text" name="inputText" id="inputText" value=""/><br/> <div id="resultRegion"></div> </div> </center> </body> </html>
四、 效果圖

五、 小結
在 EffectiveWordMatcher 中還可以支持根據用戶輸入動態添加關鍵字的功能, 但由於涉及到並發問題, 暫時沒有做更多深入。 讀者可以閱讀源碼自行完善, 我也會給出后續思考。