t...">
美文网首页
一道数字解析面试题的函数式解法

一道数字解析面试题的函数式解法

作者: 吉志龙 | 来源:发表于2019-10-13 14:54 被阅读0次

以下是leetcode上一道标注为"困难"的题:

验证给定的字符串是否可以解释为十进制数字。

例如:

"0" => true
" 0.1 " => true
"abc" => false
"1 a" => false
"2e10" => true
" -90e3 " => true
" 1e" => false
"e3" => false
" 6e-1" => true
" 99e2.5 " => false
"53.5e93" => true
" --6 " => false
"-+3" => false
"95a54e53" => false

不同于算法题,它的难点不在于如何提高执行效率,缩减执行时间,而在于如何清晰、正确地完成"复杂"业务逻辑到代码的转换。参考题解大多使用了显式维护状态机的思路,通过仔细构建状态转移表,再将状态转移表用代码进行表述,能得到执行效率颇高(O(n))的算法。不过这种解法对代码复用很不友好,稍微改下题目,例如"验证给定字符串是否可以解释为复数",就得重新构建状态转移表,重新写代码。而且构建状态转移表的过程是机械化、串行的,与人类通过层层分解来处理复杂问题的习惯相悖,这就导致对大部分人而言,构建状态转移表枯燥而容易出错。

本文受haskell中的parsec库启发,尝试基于java8引入的函数式特性(lambdamethod reference),先实现一个简陋的java版parsec,然后使用这个库定义一组number parser,用来解决文章开头提到的面试题。

预览

简而言之,我们最终将得到一组parser库,在这个库的基础上,可以使用声明式语法构建具体的文本解析器。最终用来解析leetcode 面试题的parser可以按如下方式构建:

  // 无符号整数
  public static final Parser<Int> unsignedInt = digits1plus.map(Int::unsigned);

  // 有符号整数
  public static final Parser<Int> signedInt = seq(flag, unsignedInt, Int::signed);

  // 包含整数部分的无符号浮点数
  public static final Parser<Float> fullUnsignedFloat =
      seq(digits1plus, point.then(digits0plus), Float::full);

  // 不含整数部分的无符号浮点数
  public static final Parser<Float> onlyFractionPartUnsignedFloat =
      point.then(digits1plus).map(Float::onlyFractionPart);

  // 只含整数部分的无符号浮点数
  public static final Parser<Float> onlyIntPartUnsignedFloat =
      digits1plus.map(Float::onlyIntPart);

  // 任意无符号浮点数
  public static final Parser<Float> unsignedFloat =
      fullUnsignedFloat.or(onlyFractionPartUnsignedFloat).or(onlyIntPartUnsignedFloat);

  // 任意有符号浮点数
  public static final Parser<Float> floatParser = seq(flag, unsignedFloat, Float::signed);

  // 包含指数部分的科学计数
  public static final Parser<Number> withExpNumber = seq(floatParser, expFlag.then(signedInt),
      Number::full);

  // 不包含指数部分的科学计数
  public static final Parser<Number> justFloatNumber = floatParser.map(Number::justFloat);

  // 任意科学计数
  public static final Parser<Number> number = withExpNumber.or(justFloatNumber);

  // 前后为空白的任意科学计数, 用于解决leetcode面试题
  public static final Parser<Number> numberSurroundWithSpaces = number.surroundWith(spaces);

怎么实现?

我们将使用函数式编程的思想,构建一个简易的适合组合的parser库。关于函数式编程,在scala with cats一书中有一个简单而明确的解释:

This means designing systems as small composable units, expressing constraints and interactions via the type system, and using composition to guide the construction of large systems in a way that maintains the original architectural vision.

这个过程有点像拼积木,使用简单的基本构件,通过层层组合,构造出更大更复杂的系统:


定义关键类型

函数式编程的要素之一是无副作用的”纯“函数(pure function)。和数学函数一样,这类函数的返回值,完全由调用者提供的参数决定,而且除了返回结果外,不执行其他任何带副作用的操作。相对的,”不纯“的函数,它的返回值可能依赖当前时间这类定义不明确而且易变的状态,可能执行一些副作用(side effect),例如抛出异常、修改全局状态等。

定义出纯函数的关键,在于明确输入输出的类型,做到不偏不倚,让输入包含函数执行所需的全部信息,让输出体现全部可能的结果。

对于一个parser来说,输入比较简单,就是一个字符串流:

interface CharStream {

  /**
   * 是否有更多字符等待解析
   */
  boolean hasMore();

  /**
   * 读取第一个字符(如果有的话)
   */
  char peek();

  /**
   * 在当前字符流中前进一步
   */
  CharStream advance();
}

输出则复杂一点,parse可能失败,也可能成功,parse之后,字符流可能被消费了若干个字符。这些都应该通过类型体现出来:

class ParseResult<T> {
  private final Throwable error;
  private final T value;
  private final CharStream stream;

  ParseResult(Throwable error, T value, CharStream stream) {
    this.error = error;
    this.value = value;
    this.stream = stream;
  }

  public static <T> ParseResult<T> success(T value, CharStream stream) {
    Objects.requireNonNull(stream);
    return new ParseResult<>(null, value, stream);
  }

   public static <T> ParseResult<T> error(Throwable error, CharStream stream) {
    Objects.requireNonNull(stream);
    return new ParseResult<>(error, null, stream);
  }
}

这里用了泛型来描述parse后得到的值,以表示可以从字符流中解析得到各种结构化数据,而不只是题目中提到的数字。值得注意的是,ParserResult中error和value其实是互斥的,两者必定一个为null,一个为非null,不过java的类型系统不能优雅地描述这种类型限定(sum type),需要我们在运行时做一些检查以保证这种约束关系的成立。另外,为了方便地基于ParseResult进行更进一步的运算,我们可以使用类成员函数的方式定义一些read类方法:

  public Throwable getError() {
    return error;
  }

  public T getValue() {
    return value;
  }

  public CharStream getStream() {
    return stream;
  }

  public <T1> ParseResult<T1> valueMap(Function<T, T1> mapper) {
    if (isFailed()) {
      @SuppressWarnings("unchecked")
      ParseResult<T1> valueMappedParseResult = (ParseResult<T1>) this;
      return valueMappedParseResult;
    } else {
      return success(mapper.apply(value), stream);
    }
  }

  public ParseResult<T> onError(Consumer<Throwable> consumer) {
    if (isFailed()) {
      consumer.accept(error);
    }

    return this;
  }

  public ParseResult<T> onValue(Consumer<T> consumer) {
    if (isSucceed()) {
      consumer.accept(value);
    }
    return this;
  }

其中valueMap和java 8中的Optional.map类似,parse result为fail的话,则什么都不做直接返回fail result,否则的话,将parse得到的value进行进一步转换。这类方法的共同点在于,用”高阶函数“将重复出现的pattern(这里的pattern是传播错误,转换结果)抽象成类型安全的API,优点是方便对其他函数进行组合和减少重复代码。

定义好输入输出类型后,parser的类型也就确定了:

interface Parser<T> {

  ParseResult<T> parse(CharStream stream);
}

这种只含一个抽象方法的interface在java 8中叫做functional interface。它既可以由普通class实现,也可以由method referencelambda 表达式实现。正是这一特性,让我们可以在java这种函数并非一等公民的语言中,比较优雅地实现函数式编程。

处理parser之间的组合

函数式编程的要素之二,是能够对函数方便、安全地进行组合,这样我们才能像搭积木一样,从简单、坚实的基础组件出发,构建更大、更复杂、功能更丰富的系统,同时保证这个大的系统和基础组件一样坚实、稳定。

而要实现对函数方便、安全地进行组合,关键在于定义和前面提到的valueMap类似的高阶函数。要定义合理的高阶函数,则首先需要识别出计算中重复出现的pattern,然后将这些pattern进行抽象。

例如,定义parser时,可能遇到的pattern包括:

  1. 使用parser<T>得到T类型的结果,然后判断结果是否符合某种特征,不符合的话,就返回解析失败
default Parser<T> filter(Predicate<T> predicate) {
    return stream -> {
      ParseResult<T> result = parse(stream);
      if (result.isFailed()) {
        return ParseResult.error(result.getError(), stream);
      } else {
        T value = result.getValue();
        if (predicate.test(value)) {
          return result;
        } else {
          return ParseResult.error(
              new Throwable(String.format("%s not match against predicate", value)), stream);
        }
      }
    };
  }
  1. 使用parser<T>得到T类型的结果,然后再将T类型的结果转换成类型为T1的值
 default <T1> Parser<T1> map(Function<T, T1> mapper) {
    return stream -> parse(stream).valueMap(mapper);
  }
  1. 先使用parser 1解析,成功的话直接返回结果,失败了再使用parser 2解析
 default Parser<T> or(Parser<T> recoverParser) {
    return stream -> {
      ParseResult<T> result = parse(stream);
      if (result.isFailed()) {
        return recoverParser.parse(stream);
      } else {
        return result;
      }
    };
  }
  1. 使用parser<T>得到T类型的结果,然后基于结果t决定如何解析字符流的其余部分
 default <T1> Parser<T1> flatMap(Function<T, Parser<T1>> mapper) {
    return stream -> {
      ParseResult<T> result = parse(stream);
      if (result.isFailed()) {
        return (ParseResult<T1>) result;
      } else {
        Parser<T1> nextParser = mapper.apply(result.getValue());
        return nextParser.parse(result.getStream());
      }
    };
  }
  1. 使用parser<T>得到T类型的结果,失败的话,返回解析失败;成功的话,用parser<T1>解析剩余的字符流,但是T结果弃之不用。
 default <T1> Parser<T1> then(Parser<T1> parser) {
    return stream -> {
      ParseResult<T> result = parse(stream);
      if (result.isFailed()) {
        return (ParseResult<T1>) result;
      } else {
        return parser.parse(result.getStream());
      }
    };
  }
  1. 使用parser<T1>得到T类型的结果,成功的话,继续使用parser<T2>解析剩余的字节流,最后基于结果T1和T2构建最终结果T3
static <T1, T2, T3> Parser<T3> seq(Parser<T1> parser1, Parser<T2> parser2,
      BiFunction<T1, T2, T3> merger) {
    return parser1.flatMap(t1 -> parser2.map(t2 -> merger.apply(t1, t2)));
  }

这个定义基本上只是flatMap和map的一个封装,其实并没有引入更深一层的抽象。之所以组这个封装,是为了避免实际应用中出现嵌套lambda,影响代码可读性。理论上,当然还可以定义seq3,seq4,seq5等封装。而在Haskell等函数式语言中,则通过do..return语法糖来解决这一问题。

  1. 重复使用parser<T>进行解析,然后将结果收集到一个list中。list长度和parse成功的次数相同。
 static <T> Parser<List<T>> more(Parser<T> subParser) {
    return stream -> {
      List<T> results = new ArrayList<>();
      ParseResult<T> result = subParser.parse(stream);
      while (result.isSucceed()) {
        results.add(result.getValue());
        stream = result.getStream();
        result = subParser.parse(stream);
      }

      return ParseResult.success(results, stream);
    };
  }
  1. 和7类似,但是要求必须至少parse成功一次
  static <T> Parser<List<T>> more1(Parser<T> subParser) {
    return more(subParser).filter(list -> !list.isEmpty());
  }

这里不再罗列所有组合函数,完整代码见这个gist

为数字解析定义值类型

为了完成接下来的数字解析器,我们先自定义一些数据类型,用来更清晰地表示各种数字的组成。

例如,一组数字字符可以通过如下class表示:

class Digits {
  static final Digits empty = new Digits(Collections.emptyList());
  final List<Character> digits;

  Digits(List<Character> digits) {
    this.digits = digits;
  }

  @Override
  public String toString() {
    return digits.stream().map(String::valueOf).collect(Collectors.joining());
  }

  public boolean isEmpty() {
    return digits.isEmpty();
  }
}

一个整数,由一个符号位,和一组数字字符组成:

class Int {
  static final Int empty = unsigned(Digits.empty);
  final Character flag;
  final Digits digits;

  public Int(Character flag, Digits digits) {
    this.flag = flag;
    this.digits = digits;
  }

  static Int unsigned(Digits digits) {
    return new Int(null, digits);
  }

  static Int signed(Character flag, Int unsigned) {
    return new Int(flag, unsigned.digits);
  }

  @Override
  public String toString() {
    return flag == null ? digits.toString() : flag + digits.toString();
  }

  public boolean isEmpty() {
    return digits.isEmpty();
  }
}

一个形如-3.14156的浮点数,可以认为由三部分组成-符号位,小数点前的整数部分,小数点后的部分:

class Float {
  final Character flag;
  final Digits intPart;
  final Digits fractionPart;

  public Float(Character flag, Digits intPart, Digits fractionPart) {
    this.flag = flag;
    this.intPart = intPart;
    this.fractionPart = fractionPart;
  }

  static Float full(Digits intPart, Digits fractionPart) {
    assert !intPart.isEmpty();
    assert !fractionPart.isEmpty();
    return new Float(null, intPart, fractionPart);
  }

  static Float onlyIntPart(Digits intPart) {
    assert !intPart.isEmpty();
    return new Float(null, intPart, Digits.empty);
  }

  static Float onlyFractionPart(Digits fractionPart) {
    return new Float(null, Digits.empty, fractionPart);
  }

  static Float signed(Character flag, Float unsigned) {
    return new Float(flag, unsigned.intPart, unsigned.fractionPart);
  }

  @Override
  public String toString() {
    String formattedUnsignedPart = String.format("%s.%s", intPart, fractionPart);
    return flag == null ? formattedUnsignedPart : flag + formattedUnsignedPart;
  }

  public boolean isEmpty() {
    return intPart.isEmpty() && fractionPart.isEmpty();
  }
}

而一个形如-3.1415E10的科学计数,则可以认为由两个部分组成-浮点数部分,以及E后面的指数部分:

class Number {
  final Float floatPart;
  final Int expPart;

  Number(Float floatPart, Int expPart) {
    this.floatPart = floatPart;
    this.expPart = expPart;
  }

  static Number full(Float floatPart, Int expPart) {
    assert !floatPart.isEmpty();
    assert !expPart.isEmpty();
    return new Number(floatPart, expPart);
  }

  static Number justFloat(Float floatPart) {
    assert !floatPart.isEmpty();
    return new Number(floatPart, Int.empty);
  }

  @Override
  public String toString() {
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.append(floatPart);
    if (expPart != null) {
      stringBuilder.append('e').append(expPart);
    }
    return stringBuilder.toString();
  }
}

注意,Int、Float、Number等自定义类型,主要是用来定义相关数据的字面构成,而不没有包含任何数值运算的部分。当然,要用这些class的实例做数值计算也简单,给每个class都实现一个到double或long的转换函数就好。

到这里,一个简易的parser combinator library就算搭建完成了。接下来,我们可以转移到”应用层“-构建数字解析器。

构建常见的基础parser

前面提到,函数式编程类似于搭积木。我们已经花了很大篇幅来定义积木之间组合的规则,这一步我们开始制作几个基础积木,也就是无法基于其他parser组合得到的基础parser。

  1. 提取任意单个字符的parser
 public static final Parser<Character> one = stream -> {
    if (!stream.hasMore()) {
      return ParseResult.error(new Throwable("no more characters"), stream);
    } else {
      return ParseResult.success(stream.peek(), stream.advance());
    }
  };
  1. 匹配空字符流/字符流末尾的parser
 public static Parser<Void> eof = stream -> {
    if (stream.hasMore()) {
      return ParseResult.error(new Throwable("there are more characters"), stream);
    } else {
      return ParseResult.success(null, stream);
    }
  };

组合

有了前面提到的两个基础parser,以及前面的组合规则,我们可以开始逐步构造一些”更实用“的简单parser了。例如:

  1. 单个数字/多个数字
  public static Parser<Character> digit = one.filter(ch -> ch >= '0' && ch <= '9');
  public static final Parser<Digits> digits0plus = more(digit).map(Digits::new);
  public static final Parser<Digits> digits1plus = more1(digit).map(Digits::new);
  1. 单个空格/多个空格
  public static Parser<Character> space = one.filter(eq(' '));
  public static Parser<List<Character>> spaces = more(space);
  1. 正负符号
  public static final Parser<Character> positiveFlag = one.filter(eq('+'));
  public static final Parser<Character> negativeFlag = one.filter(eq('-'));
  public static final Parser<Character> flag = positiveFlag.or(negativeFlag).or(just(null));
  1. 小数点
   public static final Parser<Character> point = one.filter(eq('.'));
  1. e指数符号
  public static final Parser<Character> expFlag = one.filter(ch -> ch == 'e' || ch == 'E');

再组合

有了数字,正负符号,小数点,我们就可以逐步开始构建无符号整数->整数->浮点数->科学计数啦:

无符号整数的语法很简单,一到多个数字字符连续排列即可:

  public static final Parser<Int> unsignedInt = digits1plus.map(Int::unsigned);

如果你想严格一点,要求第一个字符不能为0的话,改动也比较简单:

 public static final Parser<Int> unsignedInt = digits1plus.filter(digits -> digits.first() != '0').map(Int::unsigned);

有符号整数,则是符号位后面紧跟一个无符号整数:

  public static final Parser<Int> signedInt = seq(flag, unsignedInt, Int::signed);

无符号浮点数的情况比无符号整数复杂一点,需要考虑是否包含小数点,有小数点的话,要考虑小数点前是否有数字。一个方法是给每种情况定义一个专门的parser,然后or组合构成最终的无符号浮点数parser:

  // 包含整数部分的无符号浮点数
  public static final Parser<Float> fullUnsignedFloat =
      seq(digits1plus, point.then(digits0plus), Float::full);

  // 不含整数部分的无符号浮点数
  public static final Parser<Float> onlyFractionPartUnsignedFloat =
      point.then(digits1plus).map(Float::onlyFractionPart);

  // 只含整数部分的无符号浮点数
  public static final Parser<Float> onlyIntPartUnsignedFloat =
      digits1plus.map(Float::onlyIntPart);

  // 任意无符号浮点数
  public static final Parser<Float> unsignedFloat =
      fullUnsignedFloat.or(onlyFractionPartUnsignedFloat).or(onlyIntPartUnsignedFloat);

类似的,正确的科学计数可能只包含浮点数部分,也可能由浮点数部分和指数部分共同组成:

  // 包含指数部分的科学计数
  public static final Parser<Number> withExpNumber = seq(floatParser, expFlag.then(signedInt),
      Number::full);

  // 不包含指数部分的科学计数
  public static final Parser<Number> justFloatNumber = floatParser.map(Number::justFloat);

  // 任意科学计数
  public static final Parser<Number> number = withExpNumber.or(justFloatNumber);

leetcode的题目中,允许数字前后出现任意多个空格:

  public static final Parser<Number> numberSurroundWithSpaces = number.surroundWith(spaces);

至此,我们就完成了一个用于求解原面试题的数字parser,不过原题中只需要验证是否可以解析得到正确的科学计数,所以最终solution方法为:

  public boolean isNumber(String s) {
    return numberSurroundWithSpaces.parse(s).isSucceed();
  }

总结

我们花了很大篇幅用来构建一个简单的类parsec库,然后在这个库的基础上,用声明式语法构建了一个科学计数解析器。对于一道面试题来说,确实有大动干戈的嫌疑(我最终AC的solution行数为400,执行时间也排在末尾5%, 而推荐题解只有36行)。但实际上,对于绝大部分现代程序员来说,日常工作中最大的挑战,不是如何优化代码的执行复杂度(怎么写执行更快),而是优化代码的逻辑复杂度(怎么写更容易读懂),即如何让你的代码不会比业务逻辑本身更复杂,更难以理解。从这个角度考虑,函数式编程是我们的不二之选:它对副作用的谨慎对待让我们更容易写出安全、稳定的代码;它对高阶函数的提倡,把依赖经验口口相传的设计模式落地到具体的代码中,在类型系统的加持下,我们可以顺利地打造各个问题领域的乐高世界。

相关文章

网友评论

      本文标题:一道数字解析面试题的函数式解法

      本文链接:https://www.haomeiwen.com/subject/notmpctx.html