清晰解題: 網易筆試合唱團
閒言: 一切講解不清晰的演算法博文== 磨鍊讀者自學能力
題目: 合唱團(網易程式設計題)
有 n 個學生站成一排,每個學生有一個能力值,牛牛想從這 n 個學生中按照順序選取 k 名學生,要求相鄰兩個學生的位置編號的差不超過 d,使得這 k 個學生的能力值的乘積最大,你能返回最大的乘積嗎?
輸入描述:
- 每個輸入包含 1 個測試用例。每個測試資料的第一行包含一個整數$ n (1 \leq n\leq 50)$,表示學生的個數,接下來的一行,包含 n 個整數,按順序表示每個學生的能力值 ()。接下來的一行包含兩個整數,k 和 。
輸出描述
- 輸出一行表示最大的乘積
輸入例子:
3
7 4 7
2 50
輸出例子:
49
先修知識:
- 動態規劃: 動態規劃表面上很難,其實存在很簡單的套路:當求解的問題滿足以下兩個條件時, 就應該使用動態規劃:
- 主問題的答案 包含了 可分解的子問題答案 (也就是說,問題可以被遞迴的思想求解)
- 遞迴求解時, 很多子問題的答案會被多次重複利用
- 動態規劃的本質思想就是遞迴, 但如果直接應用遞迴方法, 子問題的答案會被重複計算產生浪費, 同時遞迴更加耗費棧記憶體(具體為什麼更加消耗棧記憶體, 需要額外瞭解函式呼叫過程中, 程序棧記憶體的管理方式), 所以通常用一個二維矩陣(表格)來儲存不同子問題的答案, 避免重複計算。
題目難點:
- 元素有正有負
- 如何滿足相鄰元素的距離不超過d 的限制
巧妙地分解問題
-
給定n個元素, 尋找k 個元素使乘積最大,可以從這k 個元素中最後一個元素所在的位置入手來思考。
-
對於陣列 a=【7,4,7】, 假如 =2, =2. 如果假設 a[2] 為目標序列的最後一個元素時, 還需要在a[2] 之前的元素中,尋找到一個長度為 $k-1 $的乘積序列, 且該序列的最後一個元素a[p] 與a[2]的距離小於等於d, 即
-
沿著上述思路考慮, 當 a[p] 作為最後一個元素時, 能獲得的長度為 的最大乘積序列的值是多少. 由於 , 我們所需要的序列僅包含1個元素, 即 a[p] 本身, 此處 p 值只能取 0 或 1, 對應的乘積序列值分別為 7 和 4 。
-
得到子問題的解後,挑選其中最大的一個(這裡是7) 與a[2](同樣是7) 相乘, 求出以 a[2] 作為最後一個元素時, 能獲得的最大乘積序列的值是49
-
以上分析中用到的例子都是正值, 當有負值時, 我們只需要額外計算, 當以a[i]為最後一個元素時,能獲得的乘積序列的最小值是多少, 因為需要考慮負負得正。
通過上述分析可以發現該問題符合動態規劃使用的兩個條件
- 問題既可以被分解為若干子問題:
- 不斷地嘗試固定目標序列的最後一個元素, 在剩下的元素中, 尋找 $length -1 $ 的子序列。
- 有些子問題的答案又有可能被重複利用
- 在更換目標序列的最後一個元素後, 又需要再次搜尋一遍長度為 $length -1 $ 的子序列, 這個過程中包含了諸多重複計算。
建立表格 dpMax[i][j] , dpMin[p][q]
-
dpMax[i][j] 表示: 以陣列中a[i] 為結尾元素時, 長度為 j+1 的最大乘積子序列的 乘積值
-
dpMin[i][j]表示: 以陣列中a[i] 為結尾元素時, 長度為j+1的最小乘積子序列的 乘積值
-
顯然當子序列長度 j=0也就是乘積序列長度為1 時, dpMax[i][j] == dpMin[i][j] = a[i]
-
這裡用 j+1表示長度,而不用j表示的原因是避免陣列空間有所浪費, 誰讓陣列的下標是從0開始的呢。。。沒辦法
-
以此為基礎, 利用以下遞推公式可以逐次求出任意位置的dpMax[i][j] 和 dpMin[i][j]
-
dpMax[i][j] =
biggest(
bigger(
dpMax[p][j-1] * a[i] ,
dpMin[p][j-1]* a[i])
)
) for p = i-1 ..... i-d
dpMin[i][j] =
smallest(
smaller(
dpMax[p][j-1] * a[i] ,
dpMin[p][j-1]* a[i])
)
) for p = i-1 ..... i-d
- 解釋: biggest 函式 求的是多個值中的最大值, bigger函式求得是兩個值中的較大值。
- 還需要注意 p > = 0
這裡放上遞迴寫法的JAVA程式碼,思路清晰,但是由於遞迴演算法, 重複計算過多, 不能通過執行時間測試,最終需要改為DP:
import java.util.Scanner;
public class ComputeMaxProduct {
public static void main(String[] args) {
Scanner cin = new Scanner(System.in);
int n=0 , k=0, d=0;a
int[] array = null;
while(cin.hasNextInt())
{
n = cin.nextInt();
array = new int[n];
for (int i = 0; i < n; i++) {
array[i] = cin.nextInt();
}
k = cin.nextInt();
d = cin.nextInt();
}
System.out.println(computeBestK(array, k , d));
}
public static long computeBestK(int[] array, int k, int d) {
if(array.length == 0 || k == 0 || d ==0)
return 0;
if(array.length == 1 && k == 1 )
return array[0];
if(array.length >1 && k >=1 )
{
long max = Long.MIN_VALUE;
for (int i = k-1; i < array.length; i++) {
long maxEndByCurrent = computeMaxEndBy(array, k, d, i);
if( max < maxEndByCurrent)
max = maxEndByCurrent;
}
return max;
}
else
{
System.out.println("input case error");
return -1;
}
}
private static long computeMaxEndBy(int[] array, int k, int d, int end) {
if(k == 1)
return array[end];
long max = Long.MIN_VALUE;
for (int j = 1; j <= d && (end-j)>=0 && (end-j)>= (k-1)-1; j++) {
//(end-j)>= (k-1)-1 是需要保證在向前尋找的時候,結尾元素之前至少還需要有k-1個元素,否則元素數目不夠
long res1 = array[end] * computeMaxEndBy(array, k-1, d, end-j); ;
long res2 = array[end] * computeMinEndBy(array, k-1, d, end-j);
long larger = res1 > res2 ? res1: res2;
if(max < larger)
max = larger;
}
return max;
}
private static long computeMinEndBy(int[] array, int k, int d, int end) {
if(k == 1)
return array[end];
long min = Long.MAX_VALUE;
for( int j =1 ; j <= d && (end-j)>=0 && (end-j)>= (k-1)-1; j++)
//(end-j)>= (k-1)-1 是需要保證在向前尋找的時候,結尾元素之前至少還需要有k-1個元素,否則元素數目不夠
{
long res1 = array[end] * computeMaxEndBy(array, k-1, d, end-j); ;
long res2 = array[end] * computeMinEndBy(array, k-1, d, end-j);
long smaller = res1 < res2 ? res1: res2;
if(min > smaller)
min = smaller;
}
if( min == Long.MAX_VALUE)
System.out.println("k"+k+"d"+d+"end"+end);
return min;
}
}
這裡放上DP解法的Java程式碼
import java.util.Scanner;
public class ComputeMaxProductDP {
public static void main(String[] args) {
Scanner cin = new Scanner(System.in);
int n = 0, k = 0, d = 0;
int[] array = null;
while (cin.hasNextInt()) {
n = cin.nextInt();
array = new int[n];
for (int i = 0; i < n; i++) {
array[i] = cin.nextInt();
}
k = cin.nextInt();
d = cin.nextInt();
}
System.out.println(computeMaxProduct(array, k, d));
}
static long max(long a, long b) {
return a > b ? a : b;
};
static long min(long a, long b) {
return a < b ? a : b;
};
private static long computeMaxProduct(int[] array, int k, int d) {
long dpMax[][] = new long[array.length][k];
long dpMin[][] = new long[array.length][k];
// dpMax[i][j] 表示以陣列元素A【i】作結尾時, 序列長度為j+1的最大乘積結果
for (int i = 0; i < array.length; i++) {
// 最大乘積序列長度為1 時, a[i] 作為結尾元素時, 乘積序列的結果就是它本身
dpMax[i][0] = array[i];
dpMin[i][0] = array[i];
}
// 狀態轉移方程是 dpMax[i][j] = max(dpMax[i-1][j-1]* A[i], dpMin[i-d][j-1] *
// A[i])
// Tip: 一定注意, dpMax[i][j] 的含義是乘積序列長度為 j+1 時 A【i】 為最後一個元素時, 能夠找到的最大乘積結果。 使用 j+1 的原因是因為陣列下標從 0 開始, 如果用 j 表示長度, 那麼j=0 位置的元素是無意義的, 該位置的空間會被浪費
long maxSoFar = Long.MIN_VALUE;
for (int j = 1; j < k; j++) {// 開始計算乘積序列長度大於1 的情況
for (int i = j ; i < array.length; i++) {
// 長度為 j+1 時, 結尾元素 i 的位置至少要從 j 開始找起, 以保證從a[0] 到 a[j] 至少有 j+1 個元素。
dpMax[i][j] = Long.MIN_VALUE;
dpMin[i][j] = Long.MAX_VALUE;
for (int x = 1; x <= d && (i - x) >= j - 1; x++) {
// 倒數第二個元素的位置為 i-x , 下標i-x 至少需要大於等於j-1
long resMax = max(dpMax[i - x][j - 1] * array[i], dpMin[i - x][j - 1] * array[i]);
long resMin = min(dpMax[i - x][j - 1] * array[i], dpMin[i - x][j - 1] * array[i]);
if (resMax > dpMax[i][j])
dpMax[i][j] = resMax;
if (resMin < dpMin[i][j])
dpMin[i][j] = resMin;
}
}
}
// 最後一個元素的位置從 k-1 找起,遍歷一下已經計算好的DP 表格, 獲得最終解
for (int i = k-1; i < array.length; i++) {
if (dpMax[i][k-1] > maxSoFar) {
maxSoFar = dpMax[i][k-1];
}
}
return maxSoFar;
}
}