【问题标题】:Scala String Equality Question from Programming Interview编程面试中的 Scala 字符串等式问题
【发布时间】:2019-02-13 22:30:06
【问题描述】:

因为我喜欢用 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”这个词)。

事情就是这样。我认为在没有任何可变状态或副作用的情况下,在恒定空间中执行它是物理上不可能的。就像我认为他搞砸了这个问题。你怎么看?

你能在线性时间内解决它,但空间不变吗?

【问题讨论】:

  • 由于immutability 是函数式编程的基石之一,您必须创建字符串/列表/数组的副本,这意味着在恒定空间中执行任何操作似乎都不可行。就zip 而言,它的任何用法都意味着定义为线性空间。
  • 无论你做什么,你都需要以某种方式构造输出。输出大小为 O(n)。我可以看到如何恢复 O(1) 空间的唯一方法是,如果您可以在构造部分输出的同时删除部分输入。但是Strings 没有 部分可以丢弃,所以这似乎是不可能的。例如,如果输入是字符的反向列表,这将是微不足道的。其他想法,比如重用输入字符串的部分,也有同样的问题:字符串没有部分,所以你必须保留一个切片索引列表,但该列表占用 O(n) 空间。
  • 元评论:你真的可以要求面试官就特定主题提出问题吗?我觉得这很令人惊讶。
  • @SarveshKumarSingh 如果你使用 Scala 视图,你可以在O(1) additional 空间中压缩两个集合
  • @JörgWMittag solution 必须比较这两个字符串描述,它不必对它们进行规范化。输出为Boolean,占用固定空间。

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


【解决方案1】:

这段代码耗时 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)
}

这个问题可以在线性时间用恒定的额外空间解决:如果你从右到左扫描,那么你可以确定当前位置左边的/-symbols不会影响已经处理的symbols (以任何方式位于当前位置的右侧),因此无需存储它们。 在每一点上,您只需要知道两件事:

  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

似乎工作得很好。所以,我想说谷歌人没有搞砸,看起来是一个完全有效的问题。

【讨论】:

  • 嗯,确实更简洁。我认为模式匹配在眼睛上更容易。您的解决方案也会快一点,因为它不必在迭代时创建对象,但我并没有过多关注速度,我可以以牺牲一些可读性为代价删除包装器类型(从我的角度来看查看)。
  • @Dici 去除了由不必要的aBspbBsp 变量引起的一半噪音:在findNext 的调用之间,无论如何它们都保证为零。现在它更短了,只需要三个整数开销。
【解决方案2】:

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

但现在考虑这样的序列:aaaa///a 进行比较。您需要消耗左侧序列中的 6 个元素和右侧序列中的 1 个元素,然后才能断言它们相等。这意味着您需要在内存中至少保留 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:只是对代码本身的一些说明:

  • Scala 类型推断已经足够好,因此您可以在 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)
    }
}

【讨论】:

  • # 有什么用?问题描述中没有提到#
  • 这是我发现代表序列结束的一种蹩脚方式。它帮助我根据字符比较编写所有内容,而不必担心超出范围的异常。 Optional 也可以做到这一点,但不太简洁。我使用这个字符是因为问题陈述中提到输入序列中不会出现除退格之外的其他特殊字符。
  • @AndreyTyukin:这个技巧被称为“哨兵”。这是一种通过将“检测序列结束”的问题变成“处理元素”的问题来从循环中删除异常逻辑的方法。当您已经对多个不同的元素进行了大小写区分时,添加另一个元素比添加完全不同的条件更为优雅。
  • @JörgWMittag 是的,我知道它应该是一个哨兵值,但为什么选择'#' 呢?至少,可以采用 \uFFFF 之类的东西,或者其他一些保证不会出现在任何有效 unicode 文本中的保留符号。无论如何,case ('#' , '#') =&gt; true 最终都会出现在代码中。我认为检查 aPos &lt; 0 &amp;&amp; bPos &lt;0 是否更短且更健壮(在使用主题标签的输入时不会表现得很奇怪)。
  • @AndreyTyukin 实际上是我在键盘上看到的第一个不是字母或斜线的字符。很好,因为问题指定序列中只能包含字母数字字符和单个特殊字符。
【解决方案3】:

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

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 主体时,很难为迭代器辩护,因为它们都是关于维护状态的。

【讨论】:

    【解决方案4】:

    这是一个具有单个递归函数且没有其他类或库的版本。这是线性时间和常数记忆。

    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)
    }
    

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2011-05-28
      • 2020-08-26
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2017-08-11
      相关资源
      最近更新 更多