🎒

Rustで動的計画法の実装:Knapsack

2023/04/07に公開

はじめに

動的計画法を実装してみて、Rustの勉強をやってみる。問題としてはEducational DP Contestという動的計画法の練習を目的としたコンテストのものを使用。

https://atcoder.jp/contests/dp/

AからZまで問題が設定されているが、今回はDとEのKnapsack。動的計画法の例題としては有名ですが今まで実装したことは無かった。DとEの違いは制約だけで、Dは価値v_iの範囲が、Eはナップサックの重量Wの範囲が大きくなっている。

完成したコード

解法はいろいろあるが、イメージ的には動的計画法というより、幅優先探索に枝切りをつけたという感じで解いてみた。最終的には以下のようなコードになった。

knapsack.rs
use proconio::input;

fn main(){
    input!(
        n: usize, w:i64,
        items: [(i64, i64); n],
    );
    
    let mut dp: Vec<Vec<(i64,i64)>>  = vec![Vec::new(); n];
    dp[0].push((0,0)); dp[0].push(items[0]);
    for i in 1..n {
        let (item_weight, item_value) = items[i];
        let mut working = Vec::new();
        for (weight, value) in dp[i - 1].clone() {
            working.push((weight, value));
            if weight + item_weight <= w {
                working.push((weight + item_weight, value + item_value));
            }
        }
        working.sort_by(|a,b| {
            match a.0.cmp(&b.0) {
                std::cmp::Ordering::Equal => a.1.cmp(&b.1).reverse(),
                other => other
            }
        });
        let mut max_value = -1;
        for element in working.drain(..) {
            if element.1 > max_value { 
                max_value = element.1;
                dp[i].push(element);
            }
        }
    }
    let (_ans_weight, ans_value) = dp[n - 1].last().unwrap();
    println!("{}", ans_value);
}

基本的なアルゴリズム

tupleを使って、価値vを実現するための重量の最低値wの組み(w, v)を表す。荷物0\cdots i-1までを使った場合の全てのtupleが求められている時、荷物iを追加することを考える。

knapsack.rs
        for (weight, value) in dp[i - 1].clone() {
            working.push((weight, value));
            if weight + item_weight <= w {
                working.push((weight + item_weight, value + item_value));
            }
        }

基本的に荷物iを追加するか、追加しないかなので、追加しない場合はそのままtupleをpush, 追加する場合は、重量がナップサックの上限を超えないことを確認してから、荷物iの重量と価値を加えたtupleを作業用のベクタworkingに追加。とりあえず作業用ベクタには、可能性のある組を全部入れてしまう。

dp[i - 1].clone()の部分はclone()なしの状態だとVec<Vec<(i64,i64)>>のindexの外にはmoveできないとコンパイラに怒られたので追加した。clone()の負荷は大きいイメージがあるので、何とかしたい気はするけど、対処法は見つかっていない。

knapsack.rs
        working.sort_by(|a,b| {
            match a.0.cmp(&b.0) {
                std::cmp::Ordering::Equal => a.1.cmp(&b.1).reverse(),
                other => other
            }
        });

作業用のベクタworkingをソートする。まず重量で昇順にソートし、重量が同じ場合は価値が降順となるようにソートする。

knapsack.rs
       let mut max_value = -1;
        for element in working.drain(..) {
            if element.1 > max_value { 
                max_value = element.1;
                dp[i].push(element);
            }
        }

作業用ベクタworkingを、dp[i]に移す時に、価値vを実現するための重量の最低値wの組み(w, v)だけを移す。具体的には(w_i, v_i), (w_j, w_j)において、w_i \leq w_j, v_i \geq v_j すなわち、重量が大きいのに価値が小さい組み(w_j, w_j)を取り除くという処理をしている。ベクタを最初から走査するだけでこの処理ができるように、事前に作業用ベクタをソートしておいた。

workingからdp[i]への「移し替え」について、Rustでは所有権を移すことになるので作業用ベクタから「取り出して(削除して)」渡さないといけない。Rustのpop()が後ろから取り出す仕様だったので、分かりにくいけど逆向きにソートして実装するかなぁと思っていたら、vecから指定された範囲を一括削除し、削除された要素のイテレータを返すdrain()という関数を見つけた。これを、作業用ベクタの全ての範囲を指定して使った。順番が保持されるのかについては明示的な記載が無かったが、説明的には保持されるだろうし、試した範囲では実際に保持されている。

計算量

ナップサック問題は、N個の荷物に対して「入れる」か「入れない」かの2択の組み合わせとなるので、探索区間は2^Nとなる。今回の実装ではdp(i)に含まれるtuppleの数 × Nに探索区間を減らしている。最悪値をざっくりと計算すると重量w_iと価値v_iのかぶりがないようにしている、つまりdp(i)の中の任意のtupleの組み(w_i, v_i), (w_j, w_j)においてw_i \ne w_jかつv_i \ne v_jが成り立つので、dp(i)に含まれるtuppleの数の最大値は、(重量、価値共に整数値をとるので)ナップサップの上限重量Wと、1つの荷物の価値の最大値V×Nの小さい方になる。つまり、計算量はO(NW)O(N^2V)の小さい方となり、D, Eどちらの問題にも対応できる。

データ構造について

今回「とりあえず全部挿入」して「ソート」して「条件に合わない要素を削除」している。Listを使ってSorted Listを作って挿入時に条件判断を入れる方が素直な気がするが、リストの場合小さなメモリ断片を大量に確保するのでパフォーマンスが低下する可能性がある。Rustのstd::collections::LinkedListのドキュメントにも以下の記述がある。

NOTE: It is almost always better to use Vec or VecDeque because array-based containers are generally faster, more memory efficient, and make better use of CPU cache.

ちゃんと作ろうとすると、挿入時に削除を行わないといけないので、実装が意外と面倒だったり、その割に計算量のオーダーとしては変わらなかったりするので、とりあえず全部入れてしまってから不要なものを削除するのは悪くない方法だと思う。

荷物の組み合わせを導出する(復元)

どの荷物を詰め込むのがいいか?という所まで導出するには、各tupleにいままでの選択を追加すれば良い。ture, falseの2値の列を保持すればいいのでVec<bool>やビット列でいいのだが、immutableなvectorに1つ要素を足してimmutableなvectorを作る処理が直感的に書けないので文字列にした。

knapsack.rs
use proconio::input;

fn main(){
    input!(
        n: usize, w:i64,
        items: [(i64, i64); n],
    );
    
+   let mut dp: Vec<Vec<(i64,i64,String)>>  = vec![Vec::new(); n];
+   dp[0].push((0,0,String::from("0"))); dp[0].push((items[0].0, items[0].1, String::from("1")));
    for i in 1..n {
        let (item_weight, item_value) = items[i];
        let mut working = Vec::new();
+       for (weight, value, path) in dp[i - 1].clone() {
+           working.push((weight, value, format!("{}0", path)));
            if weight + item_weight <= w {
+               working.push((weight + item_weight, value + item_value, format!("{}1", path)));
            }
        }
        working.sort_by(|a,b| {
            match a.0.cmp(&b.0) {
                std::cmp::Ordering::Equal => a.1.cmp(&b.1).reverse(),
                other => other
            }
        });
        let mut max_value = -1;
        for element in working.drain(..) {
            if element.1 > max_value { 
                max_value = element.1;
                dp[i].push(element);
            }
        }
    }
+   let (_ans_weight, ans_value, ans_path) = dp[n - 1].last().unwrap();
    println!("{}", ans_value);
+   for it in ans_path.chars() {
+       if it == '1' { println!("{:?}", items[i]); };
+       i += 1;
+   }
}

出力はこんな感じ

OUTPUT
17
(5, 6)
(6, 6)
(3, 5)

計算量の測定

Knapsack問題のgeneratorがあったので、計算量を測定してみる。

http://hjemmesider.diku.dk/~pisinger/codes.html

問題の種類として、w_iv_iの間に相関があるものとないものが存在していて、相関があると枝切りがしにくいので計算量が増えてしまう。
荷物の数Nを5から100まで変化させた時の計算量のグラフ。なお、復元のプロセスを入れる前のコードで評価している。ナップサックの容量Wは、全部の荷物の重量の和の半分にしており、Nが変わるとWも変わるので、横軸はNWでプロットしている。なお、w_iv_iの範囲は同じで1から10^4まで。

相関がない下のグラフは数msで終わっているが、相関の強い上のグラフは数百msかかってしまっている。

Nを100に固定して、w_i, v_iの上限を変化させた場合はこんなグラフ。NVWの大小...だとするともう少し低い場所に変化があるはずなので、アルゴリズムの差というよりは、何か実装上の差だろうか。

関連記事

Rustで動的計画法の実装:
🐸Frog | 🌴Vacation | 🎒Knapsack | 🐍LCS | 🚶‍♂️Longest Path | 🕸️Grid | 💰Coins | 🍣Sushi | 🪨Stones | 📐dequeue | 🍬Candies | 🫥Slimes | 💑Matching | 🌲Indipendent Set | 🌻Flowers | 👣Walk | 🖥️Digit Sum | 🎰Permutation | 🐰Grouping | 🌿Subtree | ⏱️Intervals | 🗼Tower | 🐸Frog3

Discussion