【问题标题】:C++ String Interview QuestionC++字符串面试题
【发布时间】:2011-05-28 13:11:56
【问题描述】:

我最近参加了一次 C++ 技术面试,在那里我得到了一些简单的字符串操作代码,该代码旨在获取一个字符串并返回一个由第一个和最后一个 n 个字符组成的字符串,然后继续为了更正任何错误并使功能尽可能高效,我想出了下面的解决方案,但是面试官声称有一个更快更优化的方法:

原码:

std::string first_last_n(int n, std::string s)
{
   std::string first_n = s.substr(0,n);
   std::string last_n = s.substr(s.size()-n-1,n);
   return first_n + last_n;
}

我的代码:

bool first_last_n(const std::size_t& n, const std::string& s, std::string& r)
{
   if (s.size() < n)
      return false;
   r.reserve(2 * n);
   r.resize(0);
   r.append(s.data(),s.data() + n);
   r.append(s.data() + (s.size() - n), s.data() + s.size());
   return true;
}

我的更改总结:

  • 更改了接口以将返回字符串作为参考(假设 RVO 和右值尚不可用)

  • 删除了正在通过 substr 构造的临时字符串

  • 将输入字符串作为 const 引用传递,以绕过输入的临时实例化

  • 修复了 last_n 字符串中的 off-by-1 错误

  • 将每个角色的触地次数减少到一次或两次(在重叠场景的情况下)

  • 在字符串 s 的大小小于 n 的情况下进行检查,失败返回 false。

假设只允许使用本机 C++,是否有其他方法可以更有效或最佳地完成上述操作?

注1:原始输入字符串实例不可修改。

注意2:所有解决方案必须通过以下测试用例,否则无效。

void test()
{
   {
      std::string s = "0123456789";
      std::string r = first_last_n(10,s);
      assert(r == "01234567890123456789");
   }

   {
      std::string s = "0123456789ABC0123456789";
      std::string r = first_last_n(10,s);
      assert(r == "01234567890123456789");
   }

   {
      std::string s = "1234321";
      std::string r = first_last_n(5,s);
      assert(r == "1234334321");
   }

}

【问题讨论】:

  • 您只能猜测(或物理测量)哪个是最佳的,因为它们都会受到 std::string 的实际底层实现的影响。我个人更喜欢第一个,因为它直观易读。但如果这是为在库中重用而设计的(如 STL),我会选择第二个(如果我能证明有一些真正的优势)。第二个的优点是使用保留来确保不涉及太多复制。 另请注意:复制回字符串并不像您想象的那样昂贵,因为大多数实现都使用写时复制。
  • @Zenikoder:实际上,在任何“正常”C++ 库实现上都可以正常工作,但您应该知道,c_str() 并不要求您返回原地表示string - 允许复制,在这种情况下,您对它的多次调用会使事情变慢。 (这样做的动机是理论上字符串库可能不会以空值终止其内部表示。我有 99% 的信心没有库会这样做,但因为这意味着打开蠕虫的内存管理罐。)
  • @j_random_hacker:我同意关于 c_str 的规则,但在实践和现实中,有没有比现在更快的实现 c_str 的方法?
  • @Martin:我不认为 gcc 4.4 或 vs 9.0 或 vs 10.0 是“实验性”编译器——它们在 std::string 实现中不支持 COW,支持 std: :string 导致的效率低下和问题比它解决的要多,现在已经有一段时间了,内存很便宜 yada yada。顺便说一句,马丁如果你认为有一个 STL,你能否提供一个版本和可能的文件/行号,我可以快速查看一下,我可以访问大多数可用的 C++ 编译器(不包括 hpux 平台)跨度>
  • @Mike Dunlavey:这些问题并不是针对像你这样的“脾气暴躁的老袜子”......:D

标签: c++ optimization string processing-efficiency


【解决方案1】:

这个实现应该很快:

inline std::string first_last_n(std::string::size_type n, const std::string& s)
{
    n = std::min(n, s.size());
    std::string ret;
    ret.reserve(2*n);
    ret.append(s.begin(), s.begin() + n);
    ret.append(s.end() - n, s.end());
    return ret;
}

passes all three unit tests

使用 GNU libstdc++ 时,声明和初始化 ret 的行非常快,因为 libstdc++ 使用全局“空字符串”变量。因此,它只是一个指针副本。在s 上调用beginend 也很快,因为它们将解析为beginendbegin() constend() const 的常量版本,所以s 的内部表示是没有“泄露”。对于 libstdc++,std::string::const_iteratorconst char*,这是一个指针类型和随机访问迭代器。因此,当std::string::append&lt;const char*&gt;(const char*, const char*)调用std::distance获取输入范围的长度时,就是指针差分操作。此外,std::string::append&lt;const char*&gt;(const char*, const char*) 的结果类似于 memmove。最后,reserve 操作确保有足够的内存可用于返回值。

编辑: 好奇的是,这里是MinGW g++ 4.5.0的汇编输出中ret的初始化:

    movl    $__ZNSs4_Rep20_S_empty_rep_storageE+12, (%ebx)

它只是将指针复制到全局“空表示”。

EDIT2: 好的。我现在已经使用 g++ 4.5.0 和 Visual C++ 16.00.30319.01 测试了四个变体:

变体 1(“c_str 变体”):

inline std::string first_last_n(std::string::size_type n, const std::string& s)
{
   std::string::size_type s_size = s.size();
   n = std::min(n, s_size);
   std::string ret;
   ret.reserve(2*n);
   const char *s_cStr = s.c_str(), *s_cStr_end = s_cStr + s_size;
   ret.append(s_cStr, s_cStr + n);
   ret.append(s_cStr_end - n, s_cStr_end);
   return ret;
}

变体 2(“数据字符串”变体):

inline std::string first_last_n(std::string::size_type n, const std::string& s)
{
   std::string::size_type s_size = s.size();
   n = std::min(n, s_size);
   std::string ret;
   ret.reserve(2*n);
   const char *s_data = s.data(), *s_data_end = s_data + s_size;
   ret.append(s_data, s_data + n);
   ret.append(s_data_end - n, s_data_end);
   return ret;
}

变体 3:

inline std::string first_last_n(std::string::size_type n, const std::string& s)
{
   std::string::size_type s_size = s.size();
   n = std::min(n, s_size);
   std::string ret(s);
   std::string::size_type d = s_size - n;
   return ret.replace(n, d, s, d, n);
}

变体 4(我的原始代码):

inline std::string first_last_n(std::string::size_type n, const std::string& s)
{
   n = std::min(n, s.size());
   std::string ret;
   ret.reserve(2*n);
   ret.append(s.begin(), s.begin() + n);
   ret.append(s.end() - n, s.end());
   return ret;
}

g++ 4.5.0 的结果是:

  • 变体 4 是最快的
  • 变体 3 次之(比变体 4 慢 5%)
  • 变体 1 排在第三位(比变体 3 慢 2%)
  • 变体 2 排在第四位(比变体 1 慢 0.2%)

VC++ 16.00.30319.01 的结果是:

  • 变体 1 是最快的
  • 变体 2 次之(比变体 1 慢 3%)
  • 变体 4 排在第三位(比变体 2 慢 4%)
  • 变体 3 排在第四位(比变体 4 慢 17%)

不出所料,最快的变体取决于编译器。但是,不知道会使用哪个编译器,我认为我的变体最好,因为它是 C++ 的熟悉风格,使用 g++ 时速度最快,使用 VC++ 时也不比变体 1 或 2 慢多少。

VC++ 结果中有趣的一点是使用c_str 而不是data 更快。也许这就是为什么你的面试官说有比你的实现更快的方法。

EDIT3:

其实我只是想到了另一种变体:

变体 5:

inline std::string first_last_n(std::string::size_type n, const std::string& s)
{
   n = std::min(n, s.size());
   std::string ret;
   ret.reserve(2*n);
   std::string::const_iterator s_begin = s.begin(), s_end = s.end();
   ret.append(s_begin, s_begin + n);
   ret.append(s_end - n, s_end);
   return ret;
}

它和变体 4 一样,只是 s 的开始和结束迭代器被保存了。

当测试变体 5 时,它实际上在使用 VC++ 时击败了变体 2(数据字符串变体):

  • 变体 1 是最快的
  • 变体 5 次之(比变体 1 慢 1.6%)
  • 变体 2 排在第三位(比变体 5 慢 1.4%)
  • 变体 4 排在第三位(比变体 2 慢 4%)
  • 变体 3 排在第四位(比变体 4 慢 17%)

【讨论】:

  • 另请注意,NRVO 可能由编译器完成,从而消除了将值复制回来的成本。
  • 您对 std::string::const_iterator 的评论对于 gnu stl 可能是正确的,但对于 msvc stl 肯定不是这样。我认为解决方案应该考虑一些更广泛使用的 STL 及其实现特性,而不仅仅是一个。
  • 即使使用 gnu,它似乎也不仅仅是一个指针(也许这样的实现是可能的,但由于这可能会产生隐藏的错误,它会被认为是非常糟糕的品味)。但是,经过优化后,我希望二进制文件完全等效。 - 如果考虑msvc的stl和性能,那么要么根本不使用它,要么禁用它包含的所有调试代码?
  • @visitor:我不明白你写了什么。有没有其他方式来表达你的意思?
  • @Daniel + @Zenikoder:即使不是const char *,它也必须是随机访问迭代器,这意味着append 中使用的std::distance 调用将始终有效。 (在 MSVC 中,它通常会检查以确保两个迭代器来自同一个容器,但仅此而已)
【解决方案2】:

如果你不需要维护原字符串的内容,那么你可以将最后n个字符复制到原字符串的[n+1, 2n]位置,并在2n处截断。如果字符串比2n 短,您必须小心先扩展字符串,并注意在写入之前不要覆盖任何字符。

这将使构造字符串的操作数量减半,并且无需创建新字符串。所以理论上它的速度要快 2 到 4 倍。但是当然你只是破坏了原始字符串,你必须询问面试官是否可以接受。

【讨论】:

  • 随后使用原始输入字符串,因此现在允许对其进行变异/更改。
  • @Zenikoder:字符串(很好)是按值传递的,所以在函数体中操作复制并返回它没有问题。
  • @Charles 作为 const 引用传递,函数不能修改。
  • @Charles 按值传递然后执行我在此建议的操作比通过引用传递然后执行@Zenikoder 所做的效率低。
【解决方案3】:

去掉中间的N-2n个字符怎么样,其中N是源字符串的长度?

【讨论】:

  • 提出一个想法很棒,但是与其他人和我自己已经提供的示例相比,您将如何产生更少的副本/洗牌和实例化? - 总之,一个代码示例将能够更好地为我们提供您的见解。
【解决方案4】:
// compiled with cl /Ox first_last_n.cpp /W4 /EHsc

inline void
first_last_n2(string::size_type n, const std::string &s, string &out)  // method 2
{
  // check against degenerate input
  assert(n > 0);
  assert(n <= s.size());

  out.reserve(2*n);
  out.assign(s, 0, n);
  out.append(s, s.size()-n, n);
}

次:

method 1:  // original method
2.281
method 2:  // my method
0.687
method 3:  // your code.
0.782

注意:计时专门测试“长”字符串。 IE。那些不使用短字符串优化的地方。 (我的字符串长度为 100)。

【讨论】:

  • 断言是在发布模式下生成的吗?如果他们不是,那么你的时间就错了。
  • @Zenikoder:只要未定义 NDEBUG,就会生成它们。我测试了有和没有 - 几乎没有区别。
【解决方案5】:

我唯一的想法是,如果仅使用 C 空终止字符串调用此函数,您可能需要为参数 's' 额外构造一个 std::string。

可能“更”有效的方法是允许传入 std::string 或 const char *s。

【讨论】:

  • 在使用 std::string 类型时,你真的不能做出这样的假设。
  • @Zenikoder 不能假设没有制作副本?我不知道标准是否需要它,但我还没有看到不复制字符串的 std::string::string( const char * ) 构造函数的实现。
【解决方案6】:

Memcpy 是个骗子?

#include <cstring>
#include <iostream>
#include <string>

std::string first_last_n(int n, const std::string& s)
{
  if (s.size() < n)
      return "";

    char str[n*2];
    memcpy(str, s.data(), n);
    memcpy(str+n, s.data() + s.size()-n, n);

    return (const char *)str;
}

int main()
{
    std::cout << first_last_n(2, "123454321") << std::endl;
}

编辑 所以我删除了另一个。这不是作弊。

【讨论】:

  • 最初使用标准库中的任何东西都不是作弊,所以 memcpy 很好,但是为什么要费心摆弄堆栈上的内存,只需要将所有内容复制回字符串例如,为什么不对实际结果字符串进行所有的摆弄呢?
  • 这里会进行返回值优化,因此不需要对结果对象进行引用。
  • 你能保证RVO会发生吗?这不是 C++ 标准中的强制行为,并且大多数优化器在如何/何时执行此操作方面非常有选择性。
  • 不过,此代码依赖于非标准的可变长度数组。
  • 这只是复制最后 10 个字符中的 9 个,这不是一个小错误吗?您的数组需要为 2*n+1。字符为 2*n,空终止符为 1。
【解决方案7】:

如果您必须通过测试,那么您将不得不编写低效的代码,因为您必须返回一个字符串的副本。这意味着您必须使用动态分配,可能因为副本而多次使用。

所以更改测试并更改签名。

template<class Out>
Out first_last_n(const std::string::size_type& n, const std::string& s, Out r)
{
    r = copy_n(s.begin(), n, r);
    std::string::const_iterator pos(s.end());
    std::advance(pos, -n);
    return copy_n(pos, n, r);
}

然后这样称呼它:

std::string s("Hello world!");
char r[5];
r[4] = 0;
first_last_n(2, s, r);

这允许您使用动态编程,并且它消除了函数中动态分配的需要。

我喜欢我的算法极简主义,我特意取消了对n 是否小于或等于字符串大小的检查。我用该功能的先决条件替换检查。先决条件比检查更快:它们的开销为零。

【讨论】:

  • 如前所述,它在一天结束时的 r 值很重要。只要实际值保持不变,就可以随意修改测试。
猜你喜欢
  • 2017-08-11
  • 1970-01-01
  • 2010-12-07
  • 1970-01-01
  • 1970-01-01
  • 2019-02-13
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多