12

私は最近、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 エラーを修正

  • 各キャラクターがタッチダウンする回数を1回または2回に減らしました(シナリオが重複している場合)

  • 文字列 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");
   }

}
4

7 に答える 7

6

この実装は高速である必要があります。

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

3 つの単体テストすべてに合格します。

GNU libstdc++ を使用する場合、libstdc++ はグローバルな「空の文字列」変数を使用するため、宣言と初期化を行う行retは非常に高速です。したがって、これは単にポインタのコピーです。およびの const バージョンに解決されるため、beginおよびendの呼び出しsも高速です。したがって、の内部表現は「リーク」しません。libstdc++ では、ポインタ型でランダム アクセス イテレータである is があります。したがって、入力範囲の長さを取得するために呼び出す場合、それはポインタ差分演算です。また、結果は. 最後に、操作により、戻り値に十分なメモリが使用できることが保証されます。beginendbegin() constend() constsstd::string::const_iteratorconst char*std::string::append<const char*>(const char*, const char*)std::distancestd::string::append<const char*>(const char*, const char*)memmovereserve

編集:ret興味深いことに、MinGW g++ 4.5.0 のアセンブリ出力で の初期化は次のとおりです。

    movl    $__ZNSs4_Rep20_S_empty_rep_storageE+12, (%ebx)

ポインターをグローバルな「空の表現」にコピーするだけです。

EDIT2: わかりました。g++ 4.5.0 と Visual C++ 16.00.30319.01 で 4 つのバリアントをテストしました。

バリアント 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 は 2 番目です (バリアント 4 より 5% 遅い)
  • バリアント 1 は 3 番目です (バリアント 3 より 2% 遅い)
  • バリアント 2 は 4 番目です (バリアント 1 より 0.2% 遅い)

VC++ 16.00.30319.01 の結果は次のとおりです。

  • バリアント 1 が最速
  • バリアント 2 が 2 位 (バリアント 1 より 3% 遅い)
  • バリアント 4 は 3 番目です (バリアント 2 より 4% 遅い)
  • バリアント 3 は 4 番目です (バリアント 4 より 17% 遅い)

当然のことながら、最速のバリアントはコンパイラによって異なります。ただし、どのコンパイラが使用されるかはわかりませんが、C++ の使い慣れたスタイルであり、g++ を使用すると最速であり、VC++ を使用するとバリアント 1 または 2 よりもそれほど遅くないため、私のバリアントが最適であると思います。

VC++ の結果から興味深いことの 1 つは、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;
}

sの開始イテレータと終了イテレータが保存されることを除いて、バリアント 4 と同じです。

バリアント 5 をテストしたところ、VC++ を使用すると、実際にはバリアント 2 (データ文字列のバリアント) よりも優れています。

  • バリアント 1 が最速
  • バリアント 5 は 2 番目です (バリアント 1 より 1.6% 遅い)
  • バリアント 2 は 3 番目です (バリアント 5 より 1.4% 遅い)
  • バリアント 4 は 3 番目です (バリアント 2 より 4% 遅い)
  • バリアント 3 は 4 番目です (バリアント 4 より 17% 遅い)
于 2010-12-21T00:15:20.073 に答える
3

元の文字列の内容を維持する必要がない場合は、最後の n 文字を[n+1, 2n]元の文字列の位置にコピーし、 で切り詰めることができ2nます。最初に文字列を展開するように注意する必要があります。また、文字列が より短い場合は、書き込む前に文字を上書きしないように注意する必要があります2n

これにより、文字列を作成する操作の数が半分になり、新しい文字列を作成する必要がなくなります。したがって、理論的には2〜4倍高速です。しかしもちろん、元の文字列を破棄しただけなので、インタビュアーにそれが受け入れられるかどうか尋ねなければなりません。

于 2010-12-20T23:14:59.110 に答える
1
// 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

注: タイミングは、特に「長い」文字列をテストします。つまり、短い文字列の最適化が使用されていない場所です。(私の弦の長さは100でした)。

于 2010-12-21T00:50:58.293 に答える
1

N がソース文字列の長さである場合、中間の N-2n 文字を削除するのはどうですか?

于 2010-12-21T00:38:34.170 に答える
0

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

編集 だから私はもう一方を削除しました。これはチートではありません。

于 2010-12-20T23:53:40.153 に答える
0

私の唯一の考えは、この関数が C の null で終わる文字列でのみ呼び出される場合、パラメーター 's' に対して std::string の追加の構築が必要になる可能性があるということです。

おそらく、「より」効率的な方法は、 std::string または const char * のいずれかが渡されることを許可することです。

于 2010-12-20T23:32:50.077 に答える
0

テストに合格する必要がある場合は、文字列のコピーを返す必要があるため、非効率的なコードを作成する必要があります。これは、コピーのために、動的割り当てを複数回使用する必要があることを意味します。

したがって、テストを変更し、署名を変更します。

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に文字列のサイズ以下であるかどうかのチェックを省略しました。チェックを関数の前提条件に置き換えます。前提条件はチェックよりも高速です。オーバーヘッドはゼロです。

于 2010-12-21T00:35:32.417 に答える