C++で解く「3nスライスのピザ」問題 ― 動的計画法でスライスの合計を最大化する方法
問題の概要
大きさがまちまちの 3n 個のスライスからなるピザがあるとします。私と友人2人は、次のルールに従ってピザを取っていきます。
- 私が任意のスライスを1枚選びます。
- 友人のAmalは、私が選んだスライスの反時計回り方向に隣接するスライスを取ります。
- 友人のBimalは、私が選んだスライスの時計回り方向に隣接するスライスを取ります。
- ピザのスライスがなくなるまで、この手順を繰り返します。
各スライスの大きさは、時計回りの順に並べた環状配列 slices として与えられます。求めるのは、私が手にできるスライスの大きさの合計の最大値です。
入出力例
入力が [9, 8, 6, 1, 1, 8] の場合を考えてみましょう。
このときの出力は 16 になります。毎ターン大きさ 8 のスライスを選べば、合計は 8 + 8 = 16 となるからです。もし最初に大きさ 9 のスライスを選んでしまうと、両隣にある大きさ 8 のスライスを友人たちに取られてしまい、結果的に損をしてしまいます。
解法のアプローチ
この問題の鍵は、自分がスライスを1枚取るとその両隣が友人に取られてしまうため、「環状配列から互いに隣接しない n/3 枚のスライスを選び、合計を最大化する」問題に帰着できる点です。これを動的計画法(DP)で解いていきます。
まず、配列 v と整数 m を引数にとる関数 solve() を定義します。m は「これから選べるスライスの残り枚数」を表します。
solve() 関数の処理手順
- n := v のサイズとします。
- 2つの2次元配列 dp1 と dp2 を、それぞれ (n + 1) × (m + 1) のサイズで定義します。
- i を 0 から n - 1 まで1ずつ増やしながら、以下を繰り返します。
- j を 0 から m まで1ずつ増やしながら、以下を繰り返します。
- x := v[i] とします。
- j < m の場合、次の状態遷移を行います。
- dp2[i + 1][j + 1] = max(dp2[i + 1][j + 1], dp1[i][j] + x)
- dp1[i + 1][j] = max(dp1[i + 1][j], dp2[i][j], dp1[i][j])
- j を 0 から m まで1ずつ増やしながら、以下を繰り返します。
- max(dp1[n][m], dp2[n][m]) を返します。
ここで dp1[i][j] は「i 番目のスライスを選べる状態で、先頭から i 枚の範囲で j 枚選んだときの合計の最大値」、dp2[i][j] は「直前のスライスを取ったため i 番目が選べない状態での合計の最大値」を表します。スライスを選んだ直後は隣のスライスが取れないため、この2状態のDPが必要になるのです。
メイン処理の手順
ピザは環状につながっているため、先頭のスライスを取るかどうかで場合分けします。
- n := slices のサイズとします。
- ret := 0 とします。
- ret := max( solve(slices の 2 番目以降, n / 3), slices[0] + solve(slices の 3 番目から末尾の1つ手前まで, n / 3 - 1) ) とします。
- ret を返します。
1つ目のケースは「先頭のスライスを取らない」場合、2つ目は「先頭のスライスを取る」場合です。先頭を取ると環状のつながりにより末尾のスライスは取れなくなるため、探索範囲から除外しています。
C++による実装例
それでは、以下の実装例を見て理解を深めましょう。
#include <bits/stdc++.h>
using namespace std;
class Solution {
public:
int solve(vector <int> v, int m){
int n = v.size();
vector<vector<int> > dp1(n + 1, vector<int>(m + 1));
vector<vector<int> > dp2(n + 1, vector<int>(m + 1));
for (int i = 0; i < n; i++) {
for (int j = 0; j <= m; j++) {
int x = v[i];
if (j < m)
dp2[i + 1][j + 1] = max(dp2[i + 1][j + 1], dp1[i]
[j] + x);
dp1[i + 1][j] = max({ dp1[i + 1][j], dp2[i][j],
dp1[i][j] });
}
}
return max(dp1[n][m], dp2[n][m]);
}
int maxSizeSlices(vector<int>& slices) {
int n = slices.size();
int ret = 0;
ret = max(solve(vector<int>(slices.begin() + 1,
slices.end()), n / 3), slices[0] + solve(vector<int>(slices.begin() +
2, slices.end() - 1), n / 3 - 1));
return ret;
}
};
main(){
Solution ob;
vector<int> v = {9,8,6,1,1,8};
cout << (ob.maxSizeSlices(v));
}
入力
{9,8,6,1,1,8}
出力
16
計算量
このアルゴリズムの時間計算量・空間計算量は、いずれも O(n²) です(n はスライスの総数)。DPテーブルのサイズは n × (n/3) であり、各セルの更新が定数時間で行えるためです。環状のピザを直線上の問題に分解してからDPを適用するこの手法は、同種の「環状配列から制約付きで要素を選ぶ」問題にも応用できる汎用的なテクニックです。
-
C++で学ぶ式ツリー(Expression Tree)の基本と具体例
式ツリーとは何か式ツリー(Expression Tree)とは、二分木の一種であり、木の各ノードが「演算子」または「オペランド(被演算子)」のいずれかで構成される特殊なデータ構造です。数式を木構造として表現することで、コンパイラや電卓アプリなどが数式を効率的に解析・評価できるようになります。ノードの役割式ツリーにおける各ノードは、次のように役割が分かれています。葉ノード(リーフノード):オペランド(数値や変数)を表します。非葉ノード(内部ノード):演算子(+、-、*、/ など)を表します。つまり、計算の対象となる値は必ず葉に配置され、それらをどのように処理するかを示す演算子が親ノードとして上に
-
C++で解く「最大水量コンテナ」問題 ― 二重ポインタでO(N)実装
問題概要 容器の壁の高さが配列として与えられ、その中で最大量の水を溜められる容器を見つけるのがこの問題の目標です。壁の高さは配列の要素で表され、2つの壁の間の距離(インデックスの差)が容器の幅として扱われます。たとえば、高さ Arr[i] と Arr[j] の壁の間の幅は j−i です(0 ≤ i < j ≤ N、N は壁の総数=配列の長さ)。 水位は低い方の壁の高さまでしか上がりません。Arr[i] < Arr[j] なら水位は Arr[i] となり、幅は j−i なので、溜められる水の面積は次の式で表せます。 面積 = min(Arr[i], Arr[j]) × (j − i)