本文记述笔者的算法学习心得,由于笔者不熟悉算法,如有错误请不吝指正。

九宫格键盘问题

给定一个从数字到字母的映射表:1 => [a, b, c], 2 => [d, e, f], 3=> [g, h, i], ......,实现一个函数List<String> calculate(String input),若用户输入任意的数字串,要依据映射表转换每一个数字,返回所有可能的转换结果。例如,若输入“12”会得到ad, ae, af, bd, be, bf, cd, ce, cf这9个结果;若输入“123”会得到27个结果。

以上是此题的基础版,还有一个难度加强版:映射表的键还可以是数字组合,如12 => [x], 23 => [y]。若输入“12”会有10个结果,还会有x这1个结果(总共10个结果);若输入“123”,不但有之前的27个结果,还会有xg, xh, xi, ay, by, cy这6个结果(总共33个结果)。

基础版的解法

可以用递归法也可以用迭代法,两种方法是等价的。以下是算法思想和实现代码。

递归法

递归法的思想是持续把问题降解为子问题,直到子问题足够小。例如,calculate("123")等价于calculate("1") * calculate("23"),而calculate("23")等价于calculate("2") * calculate("3")。这里引入了一种特殊的集合乘法,能把如[a, b, c]和[d, e, f]这样的两个集合相乘得到9个结果(即输入“12”对应的结果),这种乘法可以用一个函数来实现。如此就清晰地表述了递归法的算法框架。

实现代码为

static List<String> calculate(String input) {
  if (input.isEmpty()) {
    return Collections.emptyList();
  }

  String key = String.valueOf(input.charAt(0));
  List<String> values = mappings.get(key);
  String substring = input.substring(1);

  if (substring.isEmpty()) {
    return values;
  } else {
    return product(values, calculate(substring));
  }
}

// 乘法
static List<String> product(List<String> lefts, List<String> rights) {
  List<String> results = new ArrayList<>();
  for (String left : lefts) {
    for (String right : rights) {
      results.add(left + right);
    }
  }
  return results;
}

迭代法

迭代法的思想是依次处理输入的每一位,用已得结果集表示当前状态,记住和更新当前状态。例如,calculate(“123”)的处理过程是,第一步处理”1”,得到初始状态[a, b, c],第二步处理”2”,让当前状态[a, b, c]乘以“2”对应的[d, e, f],得到新状态[ad, ae, af, bd, be, bf, cd, ce, cf],第三步处理”3”,让当前状态乘以”3”对应的[g, h, i],得到新状态,此时输入值已全部处理完成。

实现代码为

static List<String> calculate(String input) {
  List<String> results = Collections.emptyList();

  for (int i = 0; i < input.length(); i++) {
    String key = String.valueOf(input.charAt(i));
    List<String> values = mappings.get(key);

    if (results.isEmpty()) {
      results = values;
    } else {
      results = product(results, values);
    }
  }

  return results;
}

static List<String> product(List<String> lefts, List<String> rights) {
  List<String> results = new ArrayList<>();
  for (String left : lefts) {
    for (String right : rights) {
      results.add(left + right);
    }
  }
  return results;
}

难度加强版的解法

基础版直接把每1位数字作为key,用mappings.get(key)获取映射值。而难度加强版面对不等长的key,需要换一个方式,遍历mappings中的每一个key,判断当前输入是否以这个key开头,若是,就采用这个key的映射值,同时从当前输入去掉这个key,然后继续处理剩余输入。
基础版的递归法略加修改就能得到难度加强版的解法。迭代法不能修改得到新解法,因为每一步可能产生多个步长不同的分支,这些分支所需处理的剩余输入是不同的,不能单调迭代,用算法理论来说,由于这里的递归法不是尾递归,因此没有等价的迭代法版本。可以用栈或队列将递归计算转换为延续(continuation)来实现迭代计算,其实相当于模拟了递归,这个留给读者自行完成。
其实迭代法可以更高效地实现,但是要使用动态规划法。

递归法

实现代码为

static List<String> calculate(String input) {
  if (input.isEmpty()) {
    return Collections.emptyList();
  }

  List<String> results = new ArrayList<>();

  for (String key : mappings.keySet()) {
    if (input.startsWith(key)) {
      List<String> values = mappings.get(key);

      if (values == null) {
        throw new IllegalArgumentException("Unrecognized input");
      }

      String substring = input.substring(key.length());
      if (substring.isEmpty()) {
        results.addAll(values);
      } else {
        results.addAll(product(values, calculate(substring)));
      }
    }
  }

  return results;
}

// 乘法
static List<String> product(List<String> lefts, List<String> rights) {
  List<String> results = new ArrayList<>();
  for (String left : lefts) {
    for (String right : rights) {
      results.add(left + right);
    }
  }
  return results;
}

递归法的性能优化

来找找优化点。递归过程有很多重复的计算,可以用memoization技术搞一个缓存来提速,据测试能提速一倍,缓存的数据结构可以用哈希表或数组来实现。哈希表以calculate()的input参数作为键,以calculate()的结果作为值;数组以input.length()作为索引值,以calculate()的结果作为元素值。若输入规模为n,缓存的空间复杂度为O(n)。这个就交给读者自行实现吧。

results.addAll(product(values, calculate(substring)));这行是可以优化的,product()返回一个新建的list,这个list又被复制到results里,存在一些内存分配和复制的开销。如果product()能直接输出到results而不是返回一个list,就不用新建和复制一个list。据测试能提速10%左右。
代码修改为

// 调用处这么改
product(values, calculate(substring), results);

// 定义处这么改
static List<String> product(List<String> lefts, List<String> rights, List<String> results) {
  for (String left : lefts) {
    for (String right : rights) {
      results.add(left + right);
    }
  }
}

String substring = input.substring(key.length());这行好像也可以优化,substring()返回一个新建的子字符串,存在一些内存分配和复制的开销。其实,在旧版JVM上有一个优化,substring()是像subList()一样的视图对象,直接引用原始字符串而不是创建副本,但新版JVM为了防止内存泄漏而取消了这一优化(大型原始字符串已不再使用,但仍被子字符串引用而无法释放空间)。我们可以手动实现一个StringView类来做同样的优化,但substring()的调用次数不多,因此对实际性能没有什么提升。这个技术还是比较有意思的,StringView的实现在文尾的附录提供。

动态规划法

动态规划法能达到和memoization差不多甚至更快的速度,因为它也避免了重复的计算,而且空间复杂度只有O(1)。
《算法概论》和《算法设计》这两本算法教材都讲到,动态规划不可能用递归来实现。动态规划的思想是从初始状态出发,一步步产生新状态,每一个新状态是从之前的一个或多个状态得到的,实现起来像一个状态机,因此不可能用递归来表达。对于这道题,可以持续扫描输入串,每一步向前扫一位,用当前已扫过的字符序列来表示当前状态,若当前状态以某个key结尾,则把此key的映射值乘到它所对应的“上一步”的结果集上,把新的结果集保存到缓存。这个“上一步”是哪一步呢?不是简单地回退一位,而是把刚才扫到的key从当前状态去掉,就能回退到上一步的状态(相当于回退了key.length位),由于每一步都把结果集保存到缓存了,因此只要查询缓存就能得到上一步的结果集。缓存以回退位数为键,由于回退位数最多也不可能超过最长的key的length,因此缓存是一个大小不超过这个数的滑动窗口,新的值进来就把最旧的值挤掉,只需常数级空间。

实现代码为

static List<String> calculate(String input) {
  String state = "";
  List<String> results = new ArrayList<>();
  Cache cache = new Cache();

  for (int i = 0; i < input.length(); i++) {
    state += input.charAt(i);
    List<String> newResults = new ArrayList<>();

    for (String key : mappings.keySet()) {
      if (state.endsWith(key)) {
        List<String> prevResult = cache.get(key.length());
        List<String> values = mappings.get(key);
        if (prevResult == null) {
          newResults.addAll(values);
        } else {
          newResults.addAll(product(prevResult, values));
        }
      }
    }

    results = newResults;
    cache.put(results);
  }

  return results;
}

static List<String> product(List<String> lefts, List<String> rights) {
  List<String> results = new ArrayList<>();
  for (String left : lefts) {
    for (String right : rights) {
      results.add(left + right);
    }
  }
  return results;
}

// Sliding window
private static class Cache {
  private int maxLength = mappings.keySet().stream().map(String::length).max(Integer::compareTo).get();
  private LinkedList<List<String>> queue = new LinkedList<>();

  List<String> get(int lookBackLength) {
    if (queue.size() < lookBackLength) {
      return null;
    }
    return queue.get(lookBackLength - 1);
  }

  void put(List<String> solutions) {
    if (queue.size() == maxLength) {
      queue.removeLast();
    }
    queue.offerFirst(solutions);
  }
}

附录

StringView的实现代码

class StringView {
  private final String string;
  private final int offset;

  StringView(String string, int offset) {
    if (offset > string.length()) {
      throw new IllegalArgumentException("offset should be within string length");
    }
    this.string = string;
    this.offset = offset;
  }

  StringView subview(int additionalOffset) {
    return new StringView(string, offset + additionalOffset);
  }

  boolean isEmpty() {
    return offset == string.length();
  }

  boolean startsWith(String key) {
    int keyLength = key.length();
    if (string.length() < offset + keyLength) {
      return false;
    }

    for (int i = 0; i < keyLength; i++) {
      if (key.charAt(i) != string.charAt(i + offset)) {
        return false;
      }
    }

    return true;
  }
}

sorra
841 声望78 粉丝