Partition Equal Subset Sum
正の整数の配列 nums が与えられます。値を2つのグループに分けて、それぞれの合計を等しくできるかどうかを判定してください。各値は必ずどちらか一方のグループに入り、グループには任意の位置の値を入れることができます。そのような分割が可能な場合は true を、そうでない場合は false を返してください。
関数
- numsinteger-array
- 正の値を2つのグループに分ける
- 戻り値boolean
- 値を合計が等しい2つのグループに分けられる場合はtrue、そうでない場合はfalse
制約
1 ≤ nums.length ≤ 2001 ≤ nums[i] ≤ 100
例
- 入力
- nums = [6, 1, 4, 9, 2]
- 出力
- true
- 説明
- 合計は22なので、各グループには11が必要です。9 + 2と6 + 1 + 4のグループはどちらも11になるので、答えは
trueです。
- 入力
- nums = [4, 7, 2, 9, 6]
- 出力
- false
- 説明
- 合計は28なので、各グループには14が必要です。9を持っているグループにはあと5必要ですが、4、7、2、6を組み合わせても5にはならないため、合計は偶数であるにもかかわらず、答えは
falseです。
- 入力
- nums = [1, 2, 3, 5]
- 出力
- false
- 説明
- 合計は11です。等しい2つの整数の和は常に偶数になるため、奇数の合計を等分することはできず、答えは
falseです。
提出時に隠しテスト+18件
発展問題
等分できない場合、2つのグループの合計の差をできるだけ小さくして返せますか?
ヒント
1つずつ開いてください。開くたびに少しずつ答えに近づきます。
2つのグループの合計が等しい場合、それぞれの合計は
numsの合計を使ってどのように表せますか? また、合計が奇数であることから、すぐに何がわかりますか?合計の半分になるグループを1つ見つければ十分です。残りの値がもう一方のグループになります。最初のいくつかの値で到達できる合計の集合と、値をもう1つ加えるとその集合がどう変わるかを考えてみましょう。
reach[0]だけが true であるブール配列reach[0..target]を保持します。各値numについて、sをtargetからnumまで降順に調べ、reach[s-num]がマークされているときにreach[s]をマークします。降順に調べることで、各値が2回使われるのを防ぎます。
解説
各グループには合計のちょうど半分が入る必要があるため、本当の問題は、numsの部分集合の合計がtarget = total / 2になるかどうかです。すべての部分集合を試すと2^n通りかかるため、200個の値では現実的ではありません。しかし、合計値自体は小さく、targetは最大でも200 × 100 / 2 = 10^4です。到達可能な合計を値ごとに記録すれば、探索はO(n × sum)ステップで埋められる0/1ナップサック表に変わります。
再帰を使ってすべての部分集合を試す
正しいが、最大のテストでは終わらない
考え方
合計から始めます。合計が奇数なら、2つの等しい整数の和は偶数になるため、分割は存在しません。それ以外の場合、各グループの合計はちょうど target = total / 2 になる必要があります。target になる値を見つけたら、選ばなかった値だけで、もう半分ができます。つまり、1つの問いだけで十分です。ある部分集合の合計が target になるでしょうか?
値を順に見て、それぞれについて、最初のグループに入れるか、2番目のグループ用に残すかを選びます。補助関数 reach(i, remaining) は、インデックス i 以降の値で remaining を作れるかどうかを判定します。remaining が 0 になったら true を返し、値が尽きるか 0 未満になったら false を返します。それ以外の場合は、nums[i] を使う選択と使わない選択の両方を試します。
すべての部分集合は選択の経路の1つなので、この探索で分割を見落とすことはなく、答えは正確です。ただし、経路が 2^n 個あるため遅く、分割できない入力では、そのほとんどすべてを試すことになります。100を199個と98を1個用意した場合を考えてください。合計は19998で、目標の9999には決して到達しません。探索では、100を高々99個選ぶすべての方法を試すことになり、その数は約 4 × 10^59 経路です。値が40個だけでも 2^40、約 10^12 経路になります。
アルゴリズム
numsの合計を求めます。合計が奇数なら、falseを返します。targetに合計の半分を設定します。reach(i, remaining)を記述します。remainingが 0 のときは true を返し、iが最後の値を過ぎているとき、またはremainingが 0 未満のときは false を返します。- それ以外の場合は、
reach(i+1, remaining-nums[i])またはreach(i+1, remaining)を返します。値を選ぶか、選ばないかです。 reach(0, target)を返します。
def canPartition(nums):
total = sum(nums)
if total % 2 == 1:
return False
# Can some of the values from index i on add up to exactly remaining?
def reach(i, remaining):
if remaining == 0:
return True
if i == len(nums) or remaining < 0:
return False
# Put nums[i] in the first group, or leave it for the second one
return reach(i + 1, remaining - nums[i]) or reach(i + 1, remaining)
return reach(0, total // 2)値と合計を入力する
考え方
再帰は同じ質問を何度も繰り返します。reach(i, remaining) は2つの数値だけに依存します。i は0からnまで、remaining は0からtargetまでです。そのため、異なる質問は最大でも (n+1) × (target+1) 個です。上限では約 201 × 10001 ≈ 2 × 10^6 個となり、すべてに一度ずつ答えるには十分少ない数です。
表を使って答えを順に構築します。can[i][s] は、最初のi個の値のうちいくつかを足してsになるかどうかを表します。値がない場合、可能なのは合計0だけなので、行0ではcan[0][0]を除いてすべてfalseです。値num = nums[i-1]を使ってsに到達する方法は2つあります。numを使わず、前の値だけですでにsに到達している場合か、numを使い、前の値でs-numに到達している場合です。これがすべての規則です。can[i][s] = can[i-1][s] or can[i-1][s-num] ただし、2つ目の条件が当てはまるのはs ≥ numの場合だけです。各行は直前の行だけを参照するので、それぞれの値は最大1回しか使われません。
targetが11の[6, 1, 4, 9, 2]では、到達可能な合計は{0}から{0, 6}へ、次に{0, 1, 6, 7}へ、そして{0, 1, 4, 5, 6, 7, 10, 11}へと増えていきます。合計11は4を加えた後(6 + 1 + 4)に現れ、その後の行にも残ります。答えはcan[n][target]です。各セルの計算は定数時間なので、時間計算量とメモリ計算量はどちらも O(n × target) です。
アルゴリズム
- 合計が奇数なら
falseを返し、targetをその半分に設定します。 - n+1行、target+1列のすべてがfalseの表を作り、
can[0][0]をtrueに設定します。 - 1からnまでの各行
iについて、num = nums[i-1]とします。 - 0から
targetまでの各合計sについて、can[i][s]をcan[i-1][s]、またはs ≥ numの場合はcan[i-1][s-num]に設定します。 can[n][target]を返します。
def canPartition(nums):
total = sum(nums)
if total % 2 == 1:
return False
target = total // 2
n = len(nums)
# can[i][s] is True when some of the first i values add up to s
can = [[False] * (target + 1) for _ in range(n + 1)]
can[0][0] = True
for i in range(1, n + 1):
num = nums[i - 1]
for s in range(target + 1):
# Leave num out, or put it in and reach s - num with the values before it
can[i][s] = can[i - 1][s] or (s >= num and can[i - 1][s - num])
return can[n][target]上から下へ埋めていく1行の合計
考え方
表の各行はその1つ上の行だけを参照するため、値をその場で更新するなら1行あれば十分です。reach[s] は、これまでに見た値の一部の合計が s になるかどうかを示します。注意すべきなのは更新の順序です。s を小さい方から大きい方へ走査すると、同じ num によって reach[s-num] がすでに true になっている可能性があります。[3, 9] と目標値 6 の場合、3によって reach[3] が true になり、続いてそこを参照して reach[6] も true にしてしまいます。まるで3を2つ持っているかのように扱われ、存在しない分割に対して true と答えてしまいます。
s を target から num まで、小さい方へ向かって走査しましょう。すると s-num はより小さいインデックスであり、この値によってまだ変更されていません。そのため、reach[s-num] には num が加わる前の答えが保持されています。これは表の can[i-1][s-num] とまったく同じで、1行だけで表全体と同じ処理ができます。
reach[target] が true になった時点で処理を止めることもできます。後から来る値は到達可能な合計を増やすだけで、取り消すことはないからです。最悪の場合でも O(n × target) 回、約 2 × 10^6 回の処理が必要で、メモリ使用量は target + 1 個のブール値に減ります。
アルゴリズム
- 合計が奇数なら
falseを返し、targetをその半分に設定します。 target + 1個の要素を持つreachを作成し、reach[0]以外はすべてfalseにします。- 各値
numについて、sをtargetからnumまで降順にたどり、reach[s-num]がtrueならreach[s]をtrueに設定します。 - 各値の処理後、
reach[target]がtrueならtrueを返します。 - ループが終了したら、falseである
reach[target]を返します。
def canPartition(nums):
total = sum(nums)
if total % 2 == 1:
return False
target = total // 2
# reach[s] is True when some of the values seen so far add up to s
reach = [False] * (target + 1)
reach[0] = True
for num in nums:
# Walk the sums downward so num is used at most once
for s in range(target, num - 1, -1):
if reach[s - num]:
reach[s] = True
if reach[target]:
return True
return reach[target]
落とし穴と境界ケース
ここで誤答につながるのは、貪欲なルールを当てにすること、奇数かどうかのチェックを省くこと、そして1行のテーブルで値を再利用することです。
- 1行バージョンで合計値を小さい方から順に調べると、値を複数回使ってしまいます。
[3, 9]の場合、目標値は6で、3によって合計3、続いて合計6が印されるため、trueと答えてしまいます。 - 奇数かどうかのチェックを省く場合:
[1, 2]では合計3を切り捨てて目標値を1とし、値1がそこに到達するため、実際には存在しない分割に対してtrueと答えてしまいます。 - 並べ替えて常に軽い方のグループに加える、といった貪欲な詰め方は、
[3, 3, 2, 2, 2]では失敗します。3 + 3 = 2 + 2 + 2 ですが、結果は片方が7、もう片方が5になります。 [2, 2, 2, 10]のように、目標値より大きい値がある場合。targetからnumまで下向きに回すループはその後まったく実行されません。これは正しい動作ですが、Rで(num+1):(target+1)のような範囲を使うと逆方向に数えてしまい、テーブルが壊れます。そのような値はスキップしてください。- 合計が偶数であるだけでは不十分です。
[4, 7, 2, 9, 6]の合計は28ですが、それでも分割できません。 - LuaとRの配列は1から始まるため、合計
sに対応する要素はインデックスs + 1にあります。
よくある質問4
なぜ「Partition Equal Subset Sum」は0/1ナップサック問題なのでしょうか?
サイズが target = total / 2 のナップサックがあり、各値を高々1回使って、ちょうど満杯にする必要があります。値を取るか取らないかが0/1の選択であり、値のサイズはその値自体です。到達可能な合計値を記録するナップサック表を使えば、O(n × target) 時間で答えを求められます。
Partition Equal Subset Sum の時間計算量はどれくらいですか?
テーブルを使う方法は O(n × target) 時間がかかります。ここでtargetは合計の半分で、1行の場合、メモリ使用量は O(target) です。最大値が100の値が200個ある場合、約 2 × 10^6 ステップになります。計算量の上限は値の個数だけでなく値の大きさにも応じて増えるため、擬似多項式と呼ばれます。値が10^9近くになるとテーブルは収まらず、一般的な問題はNP完全です。
内側のループが目標値から値まで逆向きに進むのはなぜですか?
下向きに進むと、reach[s-num] はこの値によって変化する前に読み取られるため、引き続き num より前の値を表します。上向きに進むと、num を使って作られた合計に再び num を加えられるため、1つの値を何度も数えることになります。上向きのループは、Coin Change のように無制限に複数回使える場合には適切ですが、ここでは不適切です。
Partition Equal Subset Sum はビットセットで解けますか?
はい。到達可能な合計を1つの大きな数のビットとして格納し、最初はビット0だけをセットします。各値について、bits |= bits << numにより、その値をすべての到達可能な合計に一度に加算でき、答えはビットtargetがセットされているかどうかです。これは同じ表ですが、1つのマシンワードで一度に64個の合計を処理するため、実際にははるかに高速に実行できます。
似た問題
同じ考え方を使う問題です。2〜3問解くとパターンが身につきます。
Python
def canPartition(nums):
# ここにコードを書いてくださいケース1
ケース2
ケース3
入力
nums = [6, 1, 4, 9, 2]
期待値
true