😎

Rustの文字列内の入れ替え

2023/07/29に公開8

きっかけ

Rustを使えるようになりたいなと思ってちょこちょこ触ってます。
最近とあるところで入力された文字列の中で、その中のm番目とn番目(m,nは文字列のサイズ以下)を入れ替えるという問題が出ておりました。

C言語だと文字列は普通に配列のため、特に難しく考えずに行えるのですが、Rustだと地味に難しかったりします。

// このコードはエラーになります
fn main() {
    let mut str = "fuga".to_string();
    let c1 = str[0];
    let c2 = str[2];
    println!("{} {}", c1, c2); // "f g"を期待
}

あれ? 文字列ってスライス的に取れないのか? と思ったのですが許されないようです。
なおvscodeのコード補完などで見ると…

という感じです。ワタシムツカシイノワカラナイヨ。

文字列に関するメソッドの中では置き換えるためのメソッドで、 replace が存在するのでこれはと思いましたが、ちょっと違いました。

文字列はバイト単位じゃないんだよね

Rustでは文字列に対してUTF-8(Unicodeというべきか)を基準としているようです。
そのため、文字単位で取り出す操作と言う考え方がメインになっているようで、バイト単位で考えたいのであれば、 as_bytes()を使って強制的にバイト単位にぶった切るとかしないといけないみたいです。

なお日本語はUTF-8では基本3バイトのようです。

いろいろ悩んで結局こうなりました

  • 文字列をベクタに変換する → ベクタならn番目がざっくり取れる
  • 書き換え可能なものにする → 入れ替えてOK
  • 最後にベクタを文字列に変換する

とりあえずベクタに変換して取り出すところまでやってみましょう。

fn main() {
    let mut str = "fuga".to_string();
    let mut chars: Vec<char> = str.chars().collect();
    let c1 = chars[0];
    let c2 = chars[2];
    println!("{} {}", c1, c2); // "f g"を期待
}

これならf g無事得られました。
ということで、これをベースに入れ替える操作をしてみます。

fn main() {
    let mut str = "fuga".to_string();
    let mut chars: Vec<char> = str.chars().collect();
    let c1 = chars[0];
    let c2 = chars[2];
    chars[0] = c2; // 逆に入れる
    chars[2] = c1;
    // イテレータにして集めて結合(charで得られた文字の集合を結合してる
    let result: String = chars.into_iter().collect();
    println!("{} {}", c1, c2); // "f g"を期待
    println!("{}", result);
}

これで望んだ結果 gufa が得られました。

ま、Rustではタプルを用いてこういう書き方ができるのでもう少しスッキリできます。

fn main() {
    let mut str = "fuga".to_string();
    let mut chars: Vec<char> = str.chars().collect();
    (chars[0], chars[2]) = (chars[2], chars[0]); // タプルで直接入れ替える
    let result: String = chars.into_iter().collect();
    println!("{}", result);
}

なお、Vec::swap() もあるのでその方がわかりやすいかもしれません。

もう少し直感的な方法あるはずよね…

上記のコードをコンパイルすると、strはmutでなくていいということでサジェストの警告が出ます。ということはメモリ上では別の場所にベクタを作っており、元のStringから別のメモリを用意しての動きとなっています。これってオーバーヘッドが少なからず発生してるかと思います。

何かもう少し直感的な方法がありそうに思うのですが、Rustの強者様達のご意見が得られれば幸いです。

Discussion

kanaruskanarus

夜通し考えて気付いたら朝8時になってしまいました (以下、m < n を仮定してます)

思いついた中で一番直感的なのは

fn swap_chars(string: &mut String, m: usize, n: usize) {
    let (pos_m, char_m) = string.char_indices().nth(m).unwrap();
    let (pos_n, char_n) = string.char_indices().nth(n).unwrap();

    string.remove(pos_n);
    string.insert(pos_n, char_m);
    string.remove(pos_m);
    string.insert(pos_m, char_n);
}

で、一番速いのは

fn swap_chars(string: &mut String, m: usize, n: usize) {
    let (pos_m, char_m) = string.char_indices().nth(m).unwrap();
    let (pos_n, char_n) = string.char_indices().nth(n).unwrap();
    let (len_m, len_n) = (char_m.len_utf8(), char_n.len_utf8());
        
    let bytes = unsafe {string.as_mut_vec()};

    for i in 0..len_m.min(len_n) {
        bytes.swap(pos_m+i, pos_n+i)
    }

    if len_m > len_n {
        bytes[(pos_m+len_n)..(pos_n+len_n)].rotate_left(len_m-len_n)
    } else if len_m < len_n {
        bytes[(pos_m+len_m)..(pos_n+len_n)].rotate_right(len_n-len_m)
    }
}

でした (雑なベンチマークなのであれですが参考までに : この記事の方法に対して、前者は3倍くらい、後者は5倍くらい速かったです)

msakutamsakuta

ベンチマークはしていませんが、もしバイト単位でswapしたいなら(例えばASCII文字列だとわかっているのであれば)次のように String::into_bytesString::from_utf8 で行うのが一番速いと思います。この方法だと所有権を移行しているので追加のメモリアロケーションは行いません。ただし、 Rust の文字列は有効な utf-8 である必要があるので、バイト列を編集した結果有効な utf-8 にならない場合、 String::from_utf8 は失敗します(ここでは unwrap() しています)。

fn main() {
    let str = "fuga".to_string();
    let mut bytes = str.into_bytes();
    bytes.swap(0, 2);
    let str = String::from_utf8(bytes).unwrap();
    println!("{}", str);
}

で、もし多バイト文字を含む utf-8 なら、最後の例でも動きますが、次のようにすると unsafe を使わなくても済みます。

fn swap_chars(string: &mut String, mut m: usize, mut n: usize) {
    if n < m {
        (m, n) = (n, m);
    }
    let (pos_m, char_m) = string.char_indices().nth(m).unwrap();
    let (pos_n, char_n) = string.char_indices().nth(n).unwrap();
    let (len_m, len_n) = (char_m.len_utf8(), char_n.len_utf8());

    let mut bytes = std::mem::take(string).into_bytes();

    let len = len_m.min(len_n);
    let (left, right) = bytes.split_at_mut(pos_n);
    left[pos_m..pos_m + len].swap_with_slice(&mut right[0..len]);

    if len_m > len_n {
        bytes[(pos_m+len_n)..(pos_n+len_n)].rotate_left(len_m-len_n)
    } else if len_m < len_n {
        bytes[(pos_m+len_m)..(pos_n+len_n)].rotate_right(len_n-len_m)
    }
    
    *string = String::from_utf8(bytes).unwrap();
}
kanaruskanarus

補足ありがとうございます!

  • 筆者さんご自身の明言がないので確信はできませんが、入力はマルチバイト文字を含みうる文字列だと解釈しました (シングルバイト文字に限定していいなら単に swap すればいいのはわかっています)
  • 個人的に unsafe を使うことに抵抗がないので気にしてなかったのですが、指摘ありがとうございます (この場合 mem::take して *string = 〜 だと as_mut_vec でやるよりパフォーマンスは落ちるので一長一短ですかね 。。。)
msakutamsakuta

この場合 mem::take して *string = 〜 だと as_mut_vec でやるよりパフォーマンスは落ちるので一長一短ですかね 。。。

パフォーマンスに関しては mem::take よりも効きそうなのは最後の String::from_utf8 ですかね。これは文字列全体をスキャンして有効な utf8 であることを確認するということですから、特に長い文字列では効いてきそうです。 mem::take は実質的にポインタの交換ですから定数時間です。最後の代入も所有権を移行していますからポインタの代入です。

どのような方法を取るにしろ、 utf-8 のチェックを回避するには unsafe が必要なので、性能的には safe と unsafe の壁がここに現れる気がします。

unsafe を許すかどうかは状況次第だと思いますので、使うべきではないとは思いませんが、 safe バージョンで書くならこうなるよというのは示しておいてもいいかなと思った次第です。

ふがふが

ナンデコンナテキトウナオハナシナノニツワモノタチカラこめんとイタダケテルンデスカ
という失礼なツッコミをさせていただきましたが、本当にありがとうございます。

個人的には短くてコードを読むストレスの少なさで、 「かなる」さんの1番目のやつを(遅いと言われても)使うのがストレス低めで良いかと思いました(やはりunsafeは最後の最後にしておきたい)。

ASCII文字列限定(バイト単位で処理できる)のであれば、msakutaさんのinto_bytes()案も捨てがたい所です。なにより所有権が移動してるのであれば安心感高まりますね。

unsafeを回避したいという気持ちはイケナイ?

kanaruskanarus

返信ありがとうございます!(やっぱりUTF-8なんですね)

unsafeを回避したいという気持ちは僕の中ではもう擦り切れて消えてしまっていますが、言われてみればその方がおかしいんでしょうね 。。

パフォーマンス的にも「1番目では足りなくて2番目ならいける」という状況はかなり (競プロの難問くらいしか) 考えにくいので、普通は1番目でいいと思います (もしそんな状況になったら2番目のやつをコピペしていってください)

ふがふが

なお文字列に関してはUTF-8を基準にしてました。なので「あいうえお」の0,2文字目とかも想定しておく必要があるのでなにげに辛いところです。