目標和問題(letCode 494)

問題描述

給定一個非負整數數組,a1, a2, ..., an, 和一個目標數,S。現在你有兩個符號 + 和 -。對於數組中的任意一個整數,你都可以從 + 或 -中選擇一個符號添加在前面。

返回可以使最終數組和爲目標數 S 的所有添加符號的方法數。

注意:

  1. 數組非空,且長度不會超過20。
  2. 初始的數組的和不會超過1000。
  3. 保證返回的最終結果能被32位整數存下。

樣例輸入

nums: [1, 1, 1, 1, 1], S: 3

樣例輸出

輸出: 5
解釋: 

-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3

問題分析

對於這樣的問題,第一反應就是暴力求解:對於數組中所有元素,只有加和減兩種操作,因此我們可以遍歷所有情況進行計算。這裏我第一反應使用遞歸計算:假設數組長度爲5,值分別爲a、b、c、d、e,目標數值爲V,那麼問題就可以轉換爲以下兩種情況的和:數組長度爲4,值分別爲a、b、c、d,目標數值爲“V-e”和“V+e”這兩種情況。

這樣一層一層遞歸下去,直到數組長度爲1,如果數組中唯一數值的絕對值等於目標值,那就是一種解法。這裏需要注意的一點是:當目標值爲0時,算作兩種解法,因爲“+0”,和“-0”是兩種情況,所以當兩種情況計算。

源碼

public int findTargetSumWays(int[] nums, int S) {
    if (nums.length != 1) {
        int[] temp = new int[nums.length - 1];
        for (int i = 0; i < temp.length; i++) {
            temp[i] = nums[i];
        }
        return findTargetSumWays(temp, S - nums[nums.length - 1])
                + findTargetSumWays(temp, S + nums[nums.length - 1]);
    } else {
        if (S == nums[0] || S == -1 * nums[0]) {
            if (S == 0) {
                return 2;
            }
            return 1;
        }
    }
    return 0;
}

第一種方案是我沒有經過任何優化的方案,雖然代碼通過了,但效率極差。

letCode評測,執行用時:1129 ms,內存消耗 36.4 MB。其中執行用時更是超過了 90%+ 的用戶。於是我決定對遞歸方法進行優化,優化方案也比較簡單:將創建數組的代碼優化掉,用一個參數表示當前遞歸到第幾位,優化後的方案如下:

public int findTargetSumWays(int[] nums, int S) {
    return echo(nums, nums.length - 1, S);
}

public int echo(int[] nums, int j, int S) {
    if (j != 0) {
        return echo(nums, j - 1, S - nums[j])
                + echo(nums, j - 1, S + nums[j]);
    } else {
        if (S == nums[0] || S == -1 * nums[0]) {
            if (S == 0) {
                return 2;
            }
            return 1;
        }
    }
    return 0;
}

優化後的代碼相比之前好了很多:執行用時:188 ms,內存消耗34.5 MB

優化探討

雖然使用遞歸可以很輕鬆的解決問題,但整體性能還是較差。就上題而言,最壞情況下,需要遍歷1024*1024種情況,而且數組長度每加1,題目需要遍歷的情況就翻一倍,所以我們考慮用另一種思路解決本題:

我們把所有要加的數字放在一個集合P(1),把所有要減去的數字放在集合P(2),那麼題意我們就可以翻譯爲:

sum(p(1)) - sum(P(2)) = 目標數

也就是說:

sum(P(1)) - sum(P(2)) + sum(P(1)) + sum(P(2)) = 目標數 + sum(P(1)) + sum(P(2))

即:2 * sum(P(1)) = 目標數 + sum(nums)   ->    sum(p(1))  = (目標數 + sum(nums)) / 2

問題也就轉換爲:尋找子數組的條數,滿足子數組元素和 等於 目標數加數組元素和的二分之一

問題變成了動態規劃問題:這裏我使用a[m][n]記錄每一次結果,其中m表示前m個元素,n表示等號後面的內容,a[m][n]表示滿足的條數。通過觀察我們可以得出以下結論:

a[m][n] = a[m-1][n] + a[m-1][n - num[m-1]]

其中a[m-1][n]表示集合中不加第m個元素的結果,a[m-1][n-num[m-1]]表示集合中放第m個元素的結果。通過上面的分析,優化後的dp方程就可以完成

優化方案

public int findTargetSumWays(int[] nums, int S) {
    int total = 0, temp = 1, k = 0;
    int[] noneZero = new int[nums.length + 1];
    // 記錄非0數,因爲對於0來說,+0、-0沒有意義,結果乘2即可,不需要再循環中繼續運行
    for (int i = 0; i < nums.length; i++) {
        total += nums[i];
        if (nums[i] != 0) {
            noneZero[k++] = nums[i];
        } else {
            temp *= 2;
        }
    }
    // 如果total小於目標值,無論如何都不可能滿足。而且右邊是二倍後的結果,所以不能是奇數
    if (total < S || (total + S) % 2 == 1) {
        return 0;
    }
    total = (total + S) / 2;
    int[][] record = new int[k + 1][total + 1];
    record[0][0] = 1;
    for (int i = 1; i <= k; i++) {
        record[i][0] = 1;
        for (int j = 1; j <= total; j++) {
            // 集合能放下第i個元素
            if (j >= noneZero[i - 1]) {
                record[i][j] = record[i - 1][j] + record[i - 1][j - noneZero[i - 1]];
            } else {
                record[i][j] = record[i - 1][j];
            }
        }
    }
    // 計算結果 和 0數量的乘積
    return record[k][total] * temp;
}

方案再優化

優化後的代碼,letcode評測:執行用時5ms,內存消耗35.7M

我們看到代碼的執行用時已經大幅度降低,但整體內存消耗還是很大,這裏想想有沒有什麼辦法可以優化內存:

對於本題來說,我們很明顯可以發現,循環執行過程中,每次循環需要計算的數,都是上次循環計算的結果,所以我們可以把動態規劃中的二維數組改爲一維數組,每次操作這個一維數組即可,用操作前的數組內容表示上次循環的結果:

public int findTargetSumWays(int[] nums, int S) {
    int total = 0;
    for (int i = 0; i < nums.length; i++) {
        total += nums[i];
    }
    // 如果total小於目標值,無論如何都不可能滿足。而且右邊是二倍後的結果,所以不能是奇數
    if (total < S || (total + S) % 2 == 1) {
        return 0;
    }
    total = (total + S) / 2;
    int[] record = new int[total + 1];
    record[0] = 1;
    for (int n : nums) {
        // 從大向小計算,保證循環中用到的數據都是"上次"循環的結果。
        // 如果從小到大計算,那麼到後面可能用到"本次"循環的結果
        for (int j = total; j >= n; j--) {
            record[j] += record[j - n];
        }
    }
    return record[total];
}

優化後的執行用時:2ms,內存消耗34.5MB。看來這種方式對於內存消耗幾乎沒有任何影響

總結

本題摘自letCode.494,難度不大,但還是挺有意思的。需要一定的數學思維,並且題目中用到了簡單的dp知識,鏈接貼在這裏,感興趣的同學可以自己試着做一遍。最後,如果您有更好的方案,可以留言,一起探討,感謝。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章