ラベル 動的計画法 の投稿を表示しています。 すべての投稿を表示
ラベル 動的計画法 の投稿を表示しています。 すべての投稿を表示

2019年11月2日土曜日

最長共通部分列問題とその解法アルゴリズムとc++のコード

最長共通部分列問題の例題とその解法アルゴリズムについてまとめます。

最長共通部分列問題の例題

AIZU ONLINE JUDGEから引用します

最長共通部分列

最長共通部分列問題 (Longest Common Subsequence problem: LCS)は、
2つの与えられた列 X={x1,x2,...,xm}と Y={y1,y2,...,yn}の最長共通部分列を求める問題です。

ある列 Zが X と Y 両方の部分列であるとき、Z を X とY の共通部分列と言います。
例えば、X={a,b,c,b,d,a,b}, Y={b,d,c,a,b,a} とすると、列 {b,c,a} は X と Y の共通部分列です。
一方、列 {b,c,a} は X と Y の最長共通部分列ではありません。

なぜなら、その長さは 3 であり、長さ 4 の共通部分列 {b,c,b,a} が存在するからです。
長さが 5 以上の共通部分列が存在しないので、列 {b,c,b,a} は X と Yの最長共通部分列の1つです。

与えられた2つの文字列 X、Yに対して、最長共通部分列 Zの長さを出力するプログラムを作成してください。
与えられる文字列は英文字のみで構成されています。

入力

複数のデータセットが与えられます。最初の行にデータセットの数 q が与えられます。続く 2×q 行にデータセットが与えられます。
各データセットでは2つの文字列 X, Yがそれぞれ1行に与えられます。

出力

各データセットについて X, Y の最長共通部分列 Zの長さを1行に出力してください。

制約

1 ≤ q ≤ 150

1 ≤ X,Yの長さ ≤ 1,000

Xまたは Y の長さが 100 を超えるデータセットが含まれる場合、qは 20 以下である。

入力例1

3
abcbdab
bdcaba
abc
abc
abc
bc

出力例1

4
3
2

最長共通部分列(LSC)問題の考え方

2つの文字列$\{x_1,x_2...,x_i\}$を$X_i$、$\{y_1,y_2...,y_j\}$を$Y_j$と表すことにします。
サイズがそれぞれm,nの2つの列X,YのLSCは$X_m$と$Y_n$のLCSを求めることで得られます。
これを部分問題に分割して考えます。

$X_mとY_n$のLCSを求める時は、以下の2つの場合を考えます。

$X_m = y_n$の場合

$X_m = y_n$の場合は、$X_{m - 1}$と$Y_{n - 1}$のLCSに$x_m(= y_n)$を連結したものが$X_m$と
$Y_n$のLCSとなります。

例えば、$X = \{a,b,c,c,d,a\},Y = \{a,b,c,b,a\}$のとき$x_m = y_n$なので、
$X_{m - 1}$と$Y_{n - 1}$のLCSである{a,b,c}に$X_m(= a)$を連結したものが$X_m$と$Y_n$のLCSとなる。

$x_m \neq y_n$の場合

$x_m \neq y_n$の場合は、$X_{m - 1}$と$Y_n$のLCSあるいは$X_m$と$Y_{n - 1}$のLCSのどちらか長いほうが、 $X_m$と$Y_n$のLCSになります。

例えば、$X = \{a,b,c,c,d,b\},Y = \{a,b,c,b,a\}$のとき$X_{m - 1}$と$Y_n$のLCSは${a,b,c}$、
$X_m$と$Y_{n - 1}$のLCSは$\{a,b,c,b\}$なので、$X_m$と$Y_{n - 1}$のLCSが$X_m$と$Y_n$のLCSになります。

最長共通部分列(LSC)解法アルゴリズム

次のような変数を用意して、LCSの部分問題の解を求めていきます

c[m + 1][n + 1] c[i][j]を$X_i$と$Y_j$のLCSの長さとする2次元配列

$c[i][j]$の値は以下のケースの漸化式で求めることができます。

$c[i][j] = 0$ {if i == 0 or j == 0}

$c[i][j] = c[i - 1][j - 1] + 1 $ {if i,j > 0 and $x_i = y_j$}

$c[i][j] = max(c[i][j - 1],c[i - 1][j])$ {if i,j > 0 and $x_i \neq y_j$}

最長共通部分列(LSC)解法疑似アルゴリズム

これらに基づいて、2つの列XとYのLCSを動的計画法で求める疑似アルゴリズムは以下のようになります

lsc(X,Y)
 m = X.length
 n = Y.length

 for i = 1 to m
   c[i][0] = 0
 for j = 1 to n
   c[0][j] = 0
 for i = 1 to m
   for j = 1 to n
     if X[i] == Y[j]
       c[i][j] = c[i - 1][j - 1] + 1
     else if c[i - 1][j] >= c[i][j - 1]
       c[i][j] = c[i - 1][j]
     else
       c[i][j] = c[i][j - 1]

最長共通部分列(LSC)アルゴリズムの計算量

nとmの2重ループより、$O(mn)$のアルゴリズムになります。

最長共通部分列問題のc++による回答

最後に例題の回答を書きます。


#include<iostream>
#include<algorithm>
#include<string>

using namespace std;

#define N 1000

int lcs(string x,string y){
    int c[N + 1][N + 1] = {};
    int maxl = 0;
    x = ' ' + x; // X[0]に空白を挿入
    y = ' ' + y; // Y[0]に空白を挿入

    for(int i = 1;i <= x.size();++i){
        for(int j = 1;j <= y.size();++j){
            if(x[i] == y[j]){
                c[i][j] = c[i - 1][j - 1] + 1;
            }else{
                c[i][j] = max(c[i - 1][j],c[i][j - 1]);
            }
            maxl = max(maxl,c[i][j]);
        }
    }
    
    for(int i = 1;i <= x.size();++i){
        for(int j = 1;j <= y.size();++j){
            if(x[i] == y[j]){
                c[i][j] = c[i - 1][j - 1] + 1;
            }else{
                c[i][j] = max(c[i - 1][j],c[i][j - 1]);
            }
            maxl = max(maxl,c[i][j]);
        }
    }
    // 空白一致分
    return maxl - 1;
}

int main(){
    string s1,s2;
    int n;
    cin >> n;
    
    for(int i = 0;i < n;++i){
        cin >> s1 >> s2;
        cout << lcs(s1,s2) << endl;
    }
}

2019年8月3日土曜日

最大正方形問題の動的計画法による解法と考え方

最大正方形問題の解法とその考え方について、まとめたいと思います。
AIZU ONLINE JUDGEの例題をあげます。

例題

一辺が 1 cm のタイルが、H × W 個並べられています。タイルは汚れているもの、綺麗なもののいずれかです。
綺麗なタイルのみを使ってできる正方形の面積の最大値を求めてください。

入力

H W
c1,1 c1,2 ... c1,W
c2,1 c2,2 ... c2,W
:
cH,1 cH,2 ... cH,W

1行目に2つの整数 H、W が空白区切りで与えられます。続くH 行でタイルを表す H × W 個の整数 ci,j が与えられます。
ci,j が 1 のとき汚れたタイル、0 のとき綺麗なタイルを表します。

出力

面積の最大値を1行に出力してください。

入力例

4 5
0 0 1 0 0
1 0 0 0 0
0 0 0 1 0
0 0 0 1 0

出力例

4

動的計画法を使った解法

小さな部分の問題の解を記録しておく領域をdp[H][W]として定義します。
dp[i][j]にタイル(i,j)から左上に向かってできる最大の正方形の辺長さ(タイルの数)を記録していきます。

この時の短縮方法について、図を用いて考えます。

上図のようにすでに、dpに2,1,3の正方形が入っているとします。
図で示されているように、正方形は左上に向かってできています。 これらの記録を利用して、?の部分の正方形の辺の長さを求めてみます。

dp[i][j]の値はその左上、上、左の要素の中で最も小さい値に1を加えたものになります。
図では、現在の位置(i,j)の右下とした正方形の辺の長さは、最小値である上の値1 + 1より大きくすることはできないことを示しています。

次のように、各行を左から右へ見ていく処理を、上から下へ順番に行えば、
dp[i][j]を計算する際に、左上、上、左の要素はすでに計算済みなので、これらの解を有効に利用することができます。

疑似言語による動的計画法を使った最大正方形を求めるアルゴリズム

for i = 1 to H - 1
 for j = 1 to W - 1
  if G[i][j] dirty
   dp[i][j] = 0
  else
   // 左上,上,左の最小値の+1がdp[i][j]の正方形の最大面積となる
   dp[i][j] = min(dp[i-1][j-1],min(dp[i-1][j],dp[i][j - 1])) + 1
   // 最大値を更新
   maxWidth = max(maxWidth,dp[i][j]

では、例題の解法をc++で書いてみます。

#include 
#include
using namespace std;

#define MAX 1400

int dp[MAX][MAX],G[MAX][MAX];

int getLargestSquare(int h,int w){
    int maxWidth = 0;
    for(int i = 0;i < h;++i){
        for(int j = 0;j < w;++j){
            // 1が汚れたタイルなので、面積は0に、0は綺麗なタイルなので、面積が1になる
            dp[i][j] = (G[i][j] + 1) % 2;
            // 面積が1の場合、最大面積を計算する処理がスルーされるので、面積を暫定でいれる
            maxWidth |= dp[i][j];
        }
    }

    for(int i = 1;i < h;++i){
        for(int j = 1;j < w;++j){
            // 汚れている状態
            if(G[i][j]){
                dp[i][j] = 0;
            }else{
                // 左上,上,左の最小値の+1がdp[i][j]の正方形の最大面積となる
                dp[i][j] = min(dp[i-1][j-1],min(dp[i-1][j],dp[i][j - 1])) + 1;
                maxWidth = max(maxWidth,dp[i][j]);
            }
        }
    }
    return maxWidth * maxWidth;
}

int main(){
    int h,w;
    scanf("%d %d",&h,&w);
    
    for(int i = 0;i < h;++i){
        for(int j = 0;j < w;++j){
            // 地面の状態
            scanf("%d",&G[i][j]);
        }
    }
    
    printf("%d\n",getLargestSquare(h,w));
    return 0;
}

参考
プログラミングコンテスト攻略のためのアルゴリズムとデータ構造

2019年7月24日水曜日

ナップザック問題の解き方

ナップザック問題とはどういう問題なのか、その解法を理解できるようにまとめたいと思います。

ナップザック問題の例題

価値がVi重さがWiであるようなN個の品物と、容量がWのナップザックがあります。
次の条件を満たすように、品物を選んでナップザックに入れます。

1.選んだ品物の価値の合計をできるだけ高くする
2.選んだ品物の重さの総和はWを超えないようにする

このような各品物を選択するかしないかの組み合わせの問題を0-1ナップザック問題といいます。

問題を考えると、N個の各品物を選択するか選択しないかの組み合わせを全て調べることになるので、
計算効率は0(N^2)ということになります。

0-1ナップザック問題は動的計画法により、0(NW)の効率で求めることができます。

0-1ナップザック問題の解法

まず、次のような役割の変数を用意します。

変数名例 役割
items[N + 1] items[i].w,items[i].vにそれぞれ重さと価値を代入するための一元配列。
構造体などを使って定義します。
C[N + 1][W + 1] i個目までの品物を考慮して、大きさWのナップザックに入れる場合の価値の合計の最大値をC[i][w]とする2次元配列

例を挙げると、C[1][3]には品物1番目におけるナップザック容量3における最大価値が入ることになり、
配列の末端であるC[N][W]には、全ての組み合わせにおける最大の価値が入ることになります。

考慮する品物の数iに対し、各iにおけるナップザックの重さwを増やしていき、
C[i][w]を更新していきます。
C[i][w]の値は

1.C[i - 1][w - 品物iの重さ] + 品物iの価値または、
2.C[i - 1][w]

の大きいほうになります。
1はこの時点で、対象の品物iを選択する。
2はこの時点で、対象の品物iを選択しないという処理になります。

また、1の品物iを選択するケースでは、品物iの重さがwを超えない場合のみ選択されます。

では、例題を元に処理に流れを見ていきます。
入力は、例題の通り

4 5
4 2
5 2
2 1
8 3

つまり、
品物の最大数N = 4、ナップザックの最大容量W = 5となります。

0-1ナップザック問題の解法

要素が0のケース

要素が0のケースを考えます。
要素が0の場合は、C[0][Wまで]の要素をすべて0に初期化します。

要素が1のケース

要素が1の場合を考えます。

品物1の重さは2なので、大きさが1のナップザックには入れることができないので、C[1][1]は0となります。 大きさが2のナップザックには、品物1を入れることができます。
ここで、選択した場合は重さが0 + 4 = 4(幅がw = 2の斜めの矢印)、
選択しない場合は0(縦の矢印)となり、大きい方の4をc[1][2]に記録します。
大きさ3,4,5のナップザックについても、同様の処理をします。

要素が2のケース

大きさが1のナップザックに、品物2は入られないので、C[2][1]は0になります。
大きさが2,3,4,5のナップザックには、品物2を入れることができます。
C[2][4]ではC[1][2](品物2の重さが2なので、4 - 2で前の品物の価値最大値をチェックしたいので、C[1][2]となります。図の斜め矢印は品物の重さを示しています。) + 品物2の価値(つまり4 + 5)または、C[1][4](価値4)つまりこの品物を選ばないケースのいずれか大きい方を選びます。
C[2][5]も同様の考え方をします。

要素が3のケース

品物3の重さは1なので、大きさが1から5のナップザックに、品物3を入れることができます。
各々の大きさで、品物を選択する場合と選択しない場合の最適なケースを考えて、選択していきます。

要素が4のケース

大きさが1,2のナップザックに、品物4をいれることはできません。
斜めの矢印は重さを意味するので、容量を超えることは、斜めの矢印が配列オーバーすることを意味します。
大きさが3,4,5のナップザックには、品物4をいれることができるので、下図のように最適な選択をします

図でまとめる

C{N][W]が最大の価値になります。
斜めの矢印が選択した品物を表しており、
C{N][W]から斜めの矢印を逆にたどっていくと、選ぶべき品物がわかるようになっています。

ということで、品物の選択状況を別の2次元配列[i][w]に記憶しておけば、
最適解における品物の組み合わせを復元することができます。
2次元配列をG[i][w]とすると、品物iが選択するときをDIAGONAL、選択されなかった時をTOPとして、
変数定義をすることが一般的なようです(boolでももちろん可)。

例題をc++を使って解く

例題をc++を使って、解説を入れつつ解いてみたいと思います。

まず、必要な変数を定義します。

#define NMAX 105
#define WMAX 10005
// 選択する場合
#define DIAGONAL 1
// 選択しない場合
#define TOP 0

// 価値と重さを持った構造体を定義
struct Item {
    int value,weight;
};
// 品物の数(入力により決められる)
int N;
// ナップザックの容量(入力により決められる)
int W;
// 入力された品物の価値と重さを保存する配列
Item items[NMAX + 1];
// 各々に価値の最適解を入れておく配列
int C[NMAX + 1][WMAX + 1];
// 各々の品物を選択するか選択しないかを入れておく配列
int G[NMAX + 1][WMAX + 1];

続いて、図解で示した処理を行う関数を定義します。 コメントで処理内容の詳細を書きました

/**
    0-1ナップザック問題の解法を行う
    最大値を返す。
*/
int compute(){
    // 配列要素0を0で初期化(図参照)
    for(int w = 0;w <= W;++w){
        C[0][w] = 0;
        G[0][w] = DIAGONAL;
    }
    
    // C[1 to N)を0で初期化(図参照)
    for(int i = 1;i <= N;++i) C[i][0] = 0;
    
    // 図の要素1から要素の末尾までの処理を行う
    for(int i = 1;i <= N;++i){
        for(int w = 1;w <= W;++w){
            // 選択しなかったケースを代入しておく
            // 選択しなかったケースは図のように[i - 1]を重さを入れておく
            C[i][w] = C[i - 1][w];
            // Gは選択するかしないかを格納する配列、格納しないケースであるTOPを入れる
            G[i][w] = TOP;
            // 現時点でのナップザックの重さが対象の品物の重さ以上でないと、その品物を選択することができない
            if(items[i].weight > w) continue;
            // 左辺は対象の品物の価値と品物の重さを引いたC配列の最適解を加算したものを示しています。
            // 右辺はC[i - 1][w]つまり選択しなかったケースです。
            // この大小をチェック、つまりは最適解のチェックをしています。
            if(items[i].value + C[i - 1][w - items[i].weight] > C[i - 1][w]){
                // 更新する
                C[i][w] = items[i].value + C[i - 1][w - items[i].weight];
                G[i][w] = DIAGONAL;
            }
        }
    }
    
    // C[N][W]が最大となる
    return C[N][W];
}

問題には関係ありませんが、選んだ品物を順番に表示するには、 以下のように処理を書きます。

/**
 選んだ品物を順番に表示する
*/
void printSelection(){
    vector selection;
    for(int i = N, w = W;i >= 1;--i){
        if(G[i][w] == DIAGONAL){
            selection.push_back(i);
            // 選んだ品物の重量を引く、斜め矢印の処理にあたる
            w -= items[i].weight;
        }
    }
    
    reverse(selection.begin(),selection.end());
    
    for (int i = 0;i < selection.size();++i) {
        printf("%d ",selection.at(i));
    }
}

プログラムの全体は以下のようになります。

#include<iostream>
#include<vector>
#include<algorithm>

using namespace std;

#define NMAX 105
#define WMAX 10005
// 選択する場合
#define DIAGONAL 1
// 選択しない場合
#define TOP 0

// 価値と重さを持った構造体を定義
struct Item {
    int value,weight;
};
// 品物の数(入力により決められる)
int N;
// ナップザックの容量(入力により決められる)
int W;
// 入力された品物の価値と重さを保存する配列
Item items[NMAX + 1];
// 各々に価値の最適解を入れておく配列
int C[NMAX + 1][WMAX + 1];
// 各々の品物を選択するか選択しないかを入れておく配列
int G[NMAX + 1][WMAX + 1];

/**
    0-1ナップザック問題の解法を行う
    最大値を返す。
*/
int compute(){
    // 配列要素0を0で初期化(図参照)
    for(int w = 0;w <= W;++w){
        C[0][w] = 0;
        G[0][w] = DIAGONAL;
    }
    
    // C[1 to N)を0で初期化(図参照)
    for(int i = 1;i <= N;++i) C[i][0] = 0;
    
    // 図の要素1から要素の末尾までの処理を行う
    for(int i = 1;i <= N;++i){
        for(int w = 1;w <= W;++w){
            // 選択しなかったケースを代入しておく
            // 選択しなかったケースは図のように[i - 1]を重さを入れておく
            C[i][w] = C[i - 1][w];
            // Gは選択するかしないかを格納する配列、格納しないケースであるTOPを入れる
            G[i][w] = TOP;
            // 現時点でのナップザックの重さが対象の品物の重さ以上でないと、その品物を選択することができない
            if(items[i].weight > w) continue;
            // 左辺は対象の品物の価値と品物の重さを引いたC配列の最適解を加算したものを示しています。
            // 右辺はC[i - 1][w]つまり選択しなかったケースです。
            // この大小をチェック、つまりは最適解のチェックをしています。
            if(items[i].value + C[i - 1][w - items[i].weight] > C[i - 1][w]){
                // 更新する
                C[i][w] = items[i].value + C[i - 1][w - items[i].weight];
                G[i][w] = DIAGONAL;
            }
        }
    }
    
    // C[N][W]が最大となる
    return C[N][W];
}

int main(){
    int maxValue;
    // 入力処理
    cin >> N >> W;
    for(int i = 1;i <= N;++i){
        cin >> items[i].value >> items[i].weight;
    }
    maxValue = compute();
    
    cout << maxValue << endl;
    return 0;
}

参考
プログラミングコンテスト攻略のためのアルゴリズムとデータ構造

2018年2月27日火曜日

コイン問題を動的計画法とC++で解く

AIZU_ONLINE_JUDGEのDPL_1_Aの問題を使用して、コイン問題を解きます。
コイン問題を以下に引用します。

問題

額面がc1, c2,..., cm 円の m 種類のコインを使って、
n 円を支払うときの、コインの最小の枚数を求めて下さい。
各額面のコインは何度でも使用することができます。

入力

n m
C1C2・・・Cm

1行目に整数 n と整数 m が1つの空白区切りで1行に与えられます。
2行目に各コインの額面が1つの空白区切りで1行に与えられます。

出力

コインの最小枚数を1行に出力してください

制約

1 ≤ n ≤ 50,000
1 ≤ m ≤ 20
1 ≤ 額面 ≤ 10,000
額面はすべて異なり、必ず1を含む。

入力・出力例

15 6
1 2 7 8 12
2

解法

使用できるコインの額面が決まって入れば、額面が大きいものから引いていけば、
最小枚数を求めることができますが、今回の出力例でその方法を使用すると...

12 2 1
3

3となってしまい7,8を選んだ場合の最小値2となりません。
なので、貪欲法は使えません。

ここでは、以下のような配列の入れ物を定義し、
最小枚数を求めます

coin[m] : coin[i]をi番目のコインの額面とする配列
T[m][n + 1] : T[i][j]をi番目までのコインを使ってj円支払う時のコインの最小枚数とする2次元配列

コインの枚数をi,各iにおける支払う金額jを増やしていき、T[i][j]を更新していきます。
T[i][j]は、i番目のコインを使う場合と使わない場合の枚数を比べ、小さい方を選べばよいので、
次のような漸化式で求めることができます。

T[i][j] = min(T[i - 1][j],T[i][j - C[i]] + 1)

i枚目のコインを使わない場合は、ここまで計算したj円を払う最適解T[i - 1][j]となり、
使った場合は、現在の金額jからC[i]を引いた金額を払う最適解に1枚足した値になります。

配列の具体例

例題のように、支払う金額を15とし、コインの額面を1 2 7 8 12として
coin[m]とT[m][n + 1]の中身を見てみましょう

coin

1
2
7
8
12

T

0 INF INF INF INF INF INF INF INF INF INF INF INF INF INF INF
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8
0 1 1 2 2 3 3 1 2 2 3 3 4 4 2 3
0 1 1 2 2 3 3 1 1 2 2 3 3 4 2 2
0 1 1 2 2 3 3 1 1 2 2 3 1 2 2 2

例として、3枚目のコイン(額面8indexは0から)までのコインを使って15円を払う最適解は、
min(T[2][15],T[3][15-8] + 1)より2枚になります。
これは、先に述べたように3枚目のコインまでを使って7円払う最適解に1足したものです。

先の表からわかるように、コインの額面ごとに最適枚数を記録しておく必要はないので、
j円を支払うときのコインの最小枚数は1次元配列の要素T[j]として、次のように求めることができます。

T[j] = min[T[j],T[j - C[i]] + 1)

以上を踏まえて、c++を言語としてプログラムをみていきましょう

#include <iostream>
#include <algorithm>

using namespace std;

#define M_MAX 20
#define N_MAX 50000
#define INFTY 1 << 29

int main(){
    int n,m;
    int coin[21];
    int T[N_MAX + 1];
    
    cin >> n >> m;
    // コインに額面を代入する
    for(int i = 1;i <= m;++i){
        cin >> coin[i];
    }
    
    // 最大値で初期化
    for(int i = 0;i < N_MAX;++i){
        T[i] = INFTY;
    }
    T[0] = 0;
    for(int i = 1;i <= m;++i){
        for(int j = 0;j + coin[i] <= n;++j){
            T[j + coin[i]] = min(T[j + coin[i]],T[j] + 1);
        }
    }
    cout << T[n] << endl;
    return 0;
}

参考