编程面试中的Scala字符串相等性问题

时间:2018-09-09 05:42:51

标签: scala functional-programming scala-collections purely-functional

由于我喜欢Scala编程,因此在接受Google采访时,我要求他们给我一个Scala /函数式编程风格的问题。我得到的Scala功能样式问题如下:

您有两个由字母字符和代表退格符号的特殊字符组成的字符串。我们将此退格字符称为“ /”。到达键盘时,您将键入此字符序列,包括退格/删除字符。您要实现的解决方案必须检查两个字符序列是否产生相同的输出。例如,“ abc”,“ aa / bc”。 “ abb / c”,“ abcc /”,“ / abc”和“ // abc”都产生相同的输出“ abc”。因为这是一个Scala /函数式编程问题,所以您必须以惯用的Scala样式实现您的解决方案。

我编写了以下代码(它可能与我写的不完全一样,我只是要消耗内存)。基本上,我只是线性地遍历字符串,将字符放在列表之前,然后比较列表。

def processString(string: String): List[Char] = {
  string.foldLeft(List[Char]()){ case(accumulator: List[Char], char: Char) =>
    accumulator match {
      case head :: tail => if(char != '/') { char :: head :: tail } else { tail }
      case emptyList => if(char != '/') { char :: emptyList } else { emptyList }
    }
  }
}

def solution(string1: String, string2: String): Boolean = {
  processString(string1) == processString(string2)
}

到目前为止一切还好吗?然后,他询问时间的复杂性,我回答了线性时间(因为您必须处理每个字符一次)和线性空间(因为您必须将每个元素复制到列表中)。然后,他让我在线性时间内但空间不变。我想不出一种纯功能的方法。他说要尝试在Scala集合库中使用“ zip”或“ map”之类的函数(我明确记得他说过“ zip”一词)。

这是东西。我认为,在没有任何可变状态或副作用的情况下,在恒定的空间中进行此操作实际上是不可能的。就像我认为他搞砸了这个问题。你怎么看?

您可以在线性时间内但空间不变的情况下求解它吗?

4 个答案:

答案 0 :(得分:6)

此代码需要O(N)时间,仅需要三个整数即可。

def solution(a: String, b: String): Boolean = {

  def findNext(str: String, pos: Int): Int = {
    @annotation.tailrec
    def rec(pos: Int, backspaces: Int): Int = {
      if (pos == 0) -1
      else {
        val c = str(pos - 1)
        if (c == '/') rec(pos - 1, backspaces + 1)
        else if (backspaces > 0) rec(pos - 1, backspaces - 1)
        else pos - 1
      }
    }
    rec(pos, 0)
  }

  @annotation.tailrec 
  def rec(aPos: Int, bPos: Int): Boolean = {
    val ap = findNext(a, aPos)
    val bp = findNext(b, bPos)
    (ap < 0 && bp < 0) ||
    (ap >= 0 && bp >= 0 && (a(ap) == b(bp)) && rec(ap, bp))
  }

  rec(a.size, b.size)
}

可以在线性时间内以恒定的额外空间解决问题:如果从右向左扫描,则可以确保当前位置左侧的/符号不会影响已经处理过的位置符号(位于当前位置的右侧),因此无需存储它们。 在每一点上,您只需要知道两件事:

  1. 您在字符串中的什么位置?
  2. 由于退格,您必须丢弃多少个符号

这使得两个整数用于存储位置,另外一个整数用于临时存储在findNext调用期间累积的退格键数。这总共是三个整数的空间开销。

直觉

这是我尝试说明从右向左扫描为您提供O(1)算法的原因:

  

未来不能影响过去,因此无需记住未来。

此问题中的“自然时间”从左到右流动。因此,如果您从右向左扫描,则意味着“从未来到过去”,因此您无需记住当前位置右侧的字符。

测试

这是一个随机测试,这使我非常确定该解决方案实际上是正确的:

val rng = new util.Random(0)
def insertBackspaces(s: String): String = {
  val n = s.size
  val insPos = rng.nextInt(n)
  val (pref, suff) = s.splitAt(insPos)
  val c = ('a' + rng.nextInt(26)).toChar
  pref + c + "/" + suff
}

def prependBackspaces(s: String): String = {
  "/" * rng.nextInt(4) + s
}

def addBackspaces(s: String): String = {
  var res = s
  for (i <- 0 until 8) 
    res = insertBackspaces(res)
  prependBackspaces(res)
}

for (i <- 1 until 1000) {
  val s = "hello, world"
  val t = "another string"

  val s1 = addBackspaces(s)
  val s2 = addBackspaces(s)
  val t1 = addBackspaces(t)
  val t2 = addBackspaces(t)

  assert(solution(s1, s2))
  assert(solution(t1, t2))
  assert(!solution(s1, t1))
  assert(!solution(s1, t2))
  assert(!solution(s2, t1))
  assert(!solution(s2, t2))

  if (i % 100 == 0) {
    println(s"Examples:\n$s1\n$s2\n$t1\n$t2")
  }
}

测试生成的一些示例:

Examples:
/helly/t/oj/m/, wd/oi/g/x/rld
///e/helx/lc/rg//f/o, wosq//rld
/anotl/p/hhm//ere/t/ strih/nc/g
anotx/hb/er sw/p/tw/l/rip/j/ng
Examples:
//o/a/hellom/, i/wh/oe/q/b/rld
///hpj//est//ldb//y/lok/, world
///q/gd/h//anothi/k/eq/rk/ string
///ac/notherli// stri/ig//ina/n/g
Examples:
//hnn//ello, t/wl/oxnh///o/rld
//helfo//u/le/o, wna//ova//rld
//anolq/l//twl//her n/strinhx//g
/anol/tj/hq/er swi//trrq//d/ing
Examples:
//hy/epe//lx/lo, wr/v/t/orlc/d
f/hk/elv/jj//lz/o,wr// world
/anoto/ho/mfh///eg/r strinbm//g
///ap/b/notk/l/her sm/tq/w/rio/ng
Examples:
///hsm/y//eu/llof/n/, worlq/j/d
///gx//helf/i/lo, wt/g/orn/lq/d
///az/e/notm/hkh//er sm/tb/rio/ng
//b/aen//nother v/sthg/m//riv/ng

似乎可以正常工作。因此,我想说Google-guy并没有搞砸,看起来是一个非常有效的问题。

答案 1 :(得分:5)

您无需创建输出即可找到答案。您可以同时迭代两个序列,并在第一个差异处停止。如果您发现没有差异,并且两个序列都同时终止,则它们是相等的,否则它们是不同的。

但是现在考虑这样的序列:aaaa///a进行比较。在断言它们相等之前,需要消耗左序列中的6个元素和右序列中的一个元素。这意味着您将需要在内存中至少保留5个元素,直到您可以确认它们全部被删除。但是,如果您从头开始迭代元素怎么办?然后,您只需要计算退格键的数量,然后忽略左侧序列中必要的元素即可,而无需将其保留在内存中,因为您知道它们不会出现在最终输出中。您可以使用这两个技巧来实现O(1)的记忆。

我尝试过,它似乎可以工作:

def areEqual(s1: String, s2: String) = {
    def charAt(s: String, index: Int) = if (index < 0) '#' else s(index)

    @tailrec
    def recSol(i1: Int, backspaces1: Int, i2: Int, backspaces2: Int): Boolean = (charAt(s1, i1), charAt(s2, i2)) match {
        case ('/',  _) => recSol(i1 - 1, backspaces1 + 1, i2, backspaces2)
        case (_,  '/') => recSol(i1, backspaces1, i2 - 1, backspaces2 + 1)
        case ('#' , '#') => true
        case (ch1, ch2)  => 
            if      (backspaces1 > 0) recSol(i1 - 1, backspaces1 - 1, i2    , backspaces2    )
            else if (backspaces2 > 0) recSol(i1    , backspaces1    , i2 - 1, backspaces2 - 1)
            else        ch1 == ch2 && recSol(i1 - 1, backspaces1    , i2 - 1, backspaces2    )
    }
    recSol(s1.length - 1, 0, s2.length - 1, 0)
}

一些测试(所有测试都通过了,如果您打算考虑更多边缘情况,请告诉我):

// examples from the question
val inputs = Array("abc", "aa/bc", "abb/c", "abcc/", "/abc", "//abc")
for (i <- 0 until inputs.length; j <- 0 until inputs.length) {
    assert(areEqual(inputs(i), inputs(j)))
}

// more deletions than required
assert(areEqual("a///////b/c/d/e/b/b", "b")) 
assert(areEqual("aa/a/a//a//a///b", "b"))
assert(areEqual("a/aa///a/b", "b"))

// not enough deletions
assert(!areEqual("aa/a/a//a//ab", "b")) 

// too many deletions
assert(!areEqual("a", "a/"))

PS:关于代码本身的几点说明:

  • 标量类型推断足够好,因此您可以在foldLeft内的部分函数中删除类型
  • Nil是引用空列表案例的惯用方式

奖金:

在实现我的想法之前,我曾想到过Tim的解决方法,但我很早就开始对字符进行模式匹配,由于某些情况需要退格键,所以它不太适合。最后,我认为一种更整洁的编写方式是模式匹配和if条件的混合。以下是我较长的原始解决方案,上面给出的是重构的:

def areEqual(s1: String, s2: String) = {
    @tailrec
    def recSol(c1: Cursor, c2: Cursor): Boolean = (c1.char, c2.char) match {
        case ('/',  '/') => recSol(c1.next, c2.next)
        case ('/' ,   _) => recSol(c1.next, c2     )
        case (_   , '/') => recSol(c1     , c2.next)
        case ('#' , '#') => true
        case (a   ,   b) if (a == b) => recSol(c1.next, c2.next)
        case _           => false
    }
    recSol(Cursor(s1, s1.length - 1), Cursor(s2, s2.length - 1))
}

private case class Cursor(s: String, index: Int) {
    val char = if (index < 0) '#' else s(index)
    def next = {
      @tailrec
      def recSol(index: Int, backspaces: Int): Cursor = {
          if      (index < 0      ) Cursor(s, index)
          else if (s(index) == '/') recSol(index - 1, backspaces + 1)
          else if (backspaces  > 1) recSol(index - 1, backspaces - 1)
          else                      Cursor(s, index - 1)
      }
      recSol(index, 0)
    }
}

答案 2 :(得分:4)

如果目标是最小化内存占用,则很难反对迭代器。

def areSame(a :String, b :String) :Boolean = {
  def getNext(ci :Iterator[Char], ignore :Int = 0) : Option[Char] =
    if (ci.hasNext) {
      val c = ci.next()
      if (c == '/')        getNext(ci, ignore+1)
      else if (ignore > 0) getNext(ci, ignore-1)
      else                 Some(c)
    } else None

  val ari = a.reverseIterator
  val bri = b.reverseIterator
  1 to a.length.max(b.length) forall(_ => getNext(ari) == getNext(bri))
}

另一方面,在争论FP主体时,很难捍卫迭代器,因为它们都是关于维护状态的。

答案 3 :(得分:2)

这里是具有单个递归函数且没有其他类或库的版本。这是线性时间和恒定的内存。

def compare(a: String, b: String): Boolean = {
  @tailrec
  def loop(aIndex: Int, aDeletes: Int, bIndex: Int, bDeletes: Int): Boolean = {
    val aVal = if (aIndex < 0) None else Some(a(aIndex))
    val bVal = if (bIndex < 0) None else Some(b(bIndex))

    if (aVal.contains('/')) {
      loop(aIndex - 1, aDeletes + 1, bIndex, bDeletes)
    } else if (aDeletes > 0) {
      loop(aIndex - 1, aDeletes - 1, bIndex, bDeletes)
    } else if (bVal.contains('/')) {
      loop(aIndex, 0, bIndex - 1, bDeletes + 1)
    } else if (bDeletes > 0) {
      loop(aIndex, 0, bIndex - 1, bDeletes - 1)
    } else {
      aVal == bVal && (aVal.isEmpty || loop(aIndex - 1, 0, bIndex - 1, 0))
    }
  }

  loop(a.length - 1, 0, b.length - 1, 0)
}