前回の記事では、暴力的再帰が動的計画法に変換できることを簡単に紹介しましたが、暴力的再帰中に部分処理で繰り返し解が見つかると、暴力的再帰が動的計画法に変換できることが証明されます。
この投稿では、少し難しい動的プログラミングへの暴力的な再帰変換の実践を続けます。
トピック
整数配列 arr[] が与えられると、異なる値を持つカードが 1 列に配置されます。プレイヤー A とプレイヤー B は順番に各カードを受け取ります。プレイヤー A が先に取り、プレイヤー B が後で取ると規定されていますが、各プレイヤーは毎回左端または右端のカードしか取ることができません。プレイヤー A とプレイヤー B は非常に賢いので、最終的に勝った人のスコアを返してください。
暴力的な再帰は
依然として最初に暴力的な再帰から始まり、一方が最初にそれを取り、もう一方が後から取り込みます。両者とも非常に賢く、利益を最大化するための取り方を知っています。
先に取った後、再度取るときは後手が取り終わった配列から選ばなければなりません。
同様に、後手が先手が取るのを待った場合、残りの配列から最も収益性の高いものを選択することは可能でしょうか?
それでも最初に基本ケースを決定します。
それを最初に取得した場合、理想的な状態は、配列の最後の数値が残っているときに、それを取り出すことができるということです。
後で保持すると、最も悲しいことは、配列の最後の番号さえ取得できないことです。
コード中のf()関数は、まず配列をL~Rの範囲で保持して得られる最大値を返す関数です。
g()関数は配列L~Rの範囲で取得できる最大値を表します。
注意しなければならないのはアイデンティティの変化です。先に取ってしまったら、次に取った時には後手になってしまいます。後手に取った場合、私は後手ですが、やはり一番儲かる方を選びます。配列から一つ、先に取った人に任せるのも良くないので、私が一番乗りになります。
//先手方法
public static int f(int[] arr,int L,int R){
//base case:先手拿,并且数组中剩一个元素,我拿走
if(L == R){
return arr[L];
}
//因为可以选择从左边拿和右边拿,从左边拿下一次就是L + 1开始,右边拿就是 R - 1 开始。
//需要注意的是我从左或者从右拿完之后,再拿就是拿别人拿剩下的了,要以后手姿态获取其余分数,所以要调用g()方法
int p1 = arr[L] + g(arr,L + 1,R);
int p2 = arr[R] + g(arr, L, R -1);
//两种决策中取最大值
return Math.max(p1,p2);
}
//后手方法
public static int g(int[] arr,int L,int R){
//剩最后一个也不是我的,毛都拿不到,return 0
if(L == R){
return 0;
}
//后手方法是在先手方法后,挑选最大值,那如果先手方法选择了L,则我要从L + 1位置选,
//如果先手选择了R,那我要从R - 1位置开始往下选。
//是从对手选择后再次选择最大值
int p1 = f(arr,L + 1,R);
int p2 = f(arr,L,R - 1);
//因为是后手,是在先手后做决定,是被迫的,所以取Min。
return Math.min(p1,p2);
}
先手と後手のメソッドが決まったので、メインプロセスの呼び出し方を見てみましょう
public static int win1(int[] arr){
//如果是无效数组,则返回一个无效数字 -1
if(arr == null || arr.length == 0){
return -1;
}
int first = f(arr, 0 ,arr.length - 1);
int second = g(arr,0,arr.length - 1);
return Math.max(first,second);
}
暴力的再帰の解析とコードが完成しましたので、次に、暴力的再帰の呼び出しプロセスを分析し、その依存関係を見つけ、その繰り返しの解決策を見つけることで、最適化の最初のステップを実現します。
具体的な例を挙げると、arr[] の範囲は 0 から 7 です。上記の暴力的な再帰のコード ロジックに従って、その依存関係と呼び出しプロセスを見てみましょう。変数パラメータと依存関係が判明したら、動的プログラミングへの最適化を試みることはできますか?
コードのロジックによれば、左側の L + 1 または右側の R - 1 のいずれかを使用できるため、変数パラメーターが L と R であることが判断でき、プロセス全体で解が繰り返されます。
ただし、違いは、これが 2 層の再帰循環依存関係呼び出しであるため、キャッシュ テーブルが可変パラメーター パラメーター L および R に従って構築される場合、別々に記録するには 2 つの異なるキャッシュ テーブルが必要になることです。
最適化
暴力的な再帰呼び出しプロセス全体は以前に分析されており、繰り返される解決策が見つかりました。変数パラメーターは L、R であり、キャッシュ テーブルは L、R に従って構築されます。これは、f の循環依存呼び出しであるためです。 () と g() なので、キャッシュテーブルを 2 つ用意する必要があります。
public static int win2(int[] arr) {
if (arr == null || arr.length == 0) {
return -1;
}
int N = arr.length;
int[][] fmap = new int[N][N];
int[][] gmap = new int[N][N];
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
fmap[i][j] = -1;
gmap[i][j] = -1;
}
}
int first = f1(arr, 0, arr.length - 1, fmap, gmap);
int second = g1(arr, 0, arr.length - 1, fmap, gmap);
return Math.max(first, second);
}
public static int f1(int[] arr, int L, int R, int[][] fmap, int[][] gmap) {
// != -1,说明之前计算过该值,直接返回即可
if (fmap[L][R] != -1) {
return fmap[L][R];
}
int ans = 0;
if (L == R){
ans = arr[L];
}else{
int p1 = arr[L] + g1(arr, L + 1, R, fmap, gmap);
int p2 = arr[R] + g1(arr, L, R - 1, fmap, gmap);
ans = Math.max(p1, p2);
}
//这一步能够取得的最大值
fmap[L][R] = ans;
return ans;
}
public static int g1(int[] arr, int L, int R, int[][] fmap, int[][] gmap) {
if (gmap[L][R] != -1){
return gmap[L][R];
}
//因为如果 L == R,后手方法会返回0,默认ans也是等于0,省略一步判断
int ans = 0;
if (L != R){
int p1 = f1(arr,L + 1,R,fmap,gmap);
int p2 = f1(arr,L,R - 1,fmap,gmap);
ans = Math.min(p1,p2);
}
gmap[L][R] = ans;
return ans;
}
二次最適化
上記のキャッシュ テーブルを作成し、変数 L と R を見つけました。次に、例を示してキャッシュ テーブルを描画し、テーブル内の各列の対応関係を確認します。このキャッシュが見つかったら、対応するテーブルの関係について、テーブルを作成した後、勝者の最大値を直接取得することは可能ですか?
Array arr = {7,4,16,15,1} キャッシュ テーブルが 2 つあるため、2 つのテーブルの依存関係を調べる必要があります。次に、元の暴力的な再帰手法に戻り、コード ロジックに従って段階的に依存関係を見つけます。
public static int win1(int[] arr) {
if (arr == null || arr.length == 0) {
return -1;
}
int first = f(arr, 0, arr.length - 1);
int second = g(arr, 0, arr.length - 1);
return Math.max(first, second);
}
public static int f(int[] arr, int L, int R) {
if (L == R) {
return arr[L];
}
int p1 = arr[L] + g(arr, L + 1, R);
int p2 = arr[R] + g(arr, L, R - 1);
return Math.max(p1, p2);
}
public static int g(int[] arr, int L, int R) {
if (L == R) {
return 0;
}
int p1 = f(arr, L + 1, R);
int p2 = f(arr, L, R - 1);
return Math.min(p1, p2);
}
最初のメソッド f() と 2 番目のメソッド g() の基本ケースから、L == R の場合、この時点で f() メソッドは配列 arr[L] 自体の値と等しいことがわかります。 g() は 0 で、L のみまたは R のみを選択するたびに、L = R のときに返されるため、L が R > R になることはありません。必要な L ~ R の範囲は配列全体の値 0 ~ 4 で、この時点でグラフは次のように埋めることができます。
このとき、LR がランダムに値を与える場合 (たとえば、現在の fmap で L = 1 および R = 3)、その依存関係のプロセスを見てみましょう。
コードによると、g()メソッドのL + 1とR - 1に依存していることがわかります。したがって、gmapの対応する依存関係は丸印の部分になります。同様に、L = 1 R = 3 も gmap 内の fmap に対応する位置に依存します。
さて、L == R のときの fmap と gmap の値だけでなく、キャッシュ テーブル内の各位置の依存関係もありますが、他のグリッドの値を計算できるでしょうか?
コード
public static int win3(int[] arr) {
if (arr == null || arr.length == 0) {
return -1;
}
int N = arr.length;
int[][] fmap = new int[N][N];
int[][] gmap = new int[N][N];
//根据base case填充fmap,gmap都是0,数组初始化值也是0,不用填充
for (int i = 0; i < N; i++) {
fmap[i][i] = arr[i];
}
//根据对角线填充,从第一列开始
for (int startCol = 1; startCol < N; startCol++) {
int L = 0;
int R = startCol;
while (R < N) {
//将调用的g()和f()都替换成对应的缓存表
fmap[L][R] = Math.max(arr[L] + gmap[L + 1][R], arr[R] + gmap[L][R - 1]);
gmap[L][R] = Math.min(fmap[L + 1][R], fmap[L][R - 1]);
L++;
R++;
}
}
//最后从L ~ R位置,取最大值
return Math.max(fmap[0][N -1],gmap[0][N-1]);
}