實用算法解析 - 前綴和

簡介

前綴和的思路在力扣不少題目的都出現過,經常使用於處理連續子數組類型的的問題。接下來將用逐層深刻的方式來進行介紹,javascript

從一道例題提及

看一道例題(leetcode 560)。java

給定一個整數數組和一個整數 k,你須要找到該數組中和爲 k 的連續的子數組的個數。

示例 1 :
輸入:nums = [1,1,1], k = 2
輸出: 2 , [1,1] 與 [1,1] 爲兩種不一樣的狀況。es6

說明 :
數組的長度爲 [1, 20,000]。
數組中元素的範圍是 [-1000, 1000] ,且整數 k 的範圍是 [-1e7, 1e7]。面試

基本思路

首先看到這道題,最容易想到的是暴力破解:求出全部連續子數組的和,而後遍歷它們而且統計其中和爲k的項數。算法

爲了方便 咱們定義一個sum函數 用於求任意連續子數組的和,代碼以下:數組

// 思路1:求出全部連續子數組的和 並統計知足和爲k的項數
var subarraySum = function(nums, k) {
    const len = nums.length;
    let count = 0;
    for(let i = 0; i < len; i++){
        for(let j = i+1; j < len; j++){
            if(sum(nums,i,j)===k){
                count++
            }
        }
    }
    return count;
};

// 求數組中從下標startIndex到下標endIndex之間全部元素的和
function sum(arr, startIndex, endIndex){
    let res =0;
    for(let i = startIndex; i<=endIndex ;i++){
        res += arr[i]; 
    }
    return res;
}

這種解法的時間複雜度顯然過高了:最外層外面有兩層循環 複雜度爲O(n^2), sum(arr, i, j)的複雜度爲n,因此總的時間複雜度達到了O(n^3)。所以咱們要考慮其餘的思路。緩存

優化1:引入前綴和

首先咱們先想辦法優化掉sum函數,由於這個函數對任何一個連續子數組都從新計算一次元素之和,不能有效利用以前的結果。 函數

所以咱們引入一個preSum數組,其中preSum[i]表示從數組從開始到下標爲i的全部元素的和.也就是:優化

var getPreSum = function(nums){
    let count = 0;
    const preSum = [];// preSum【i】 表示從開始到第i個元素之和 
    for(let i = 0; i < nums.length; i++){
        if(i === 0){
            preSum[i] = num[i];
        } else{
            preSum[i] = preSum[i-1] + num[i]
        }
    }
    preSum[-1] = 0; // 因爲數組從第0項開始,因此preSum[-1]表示沒有元素 天然是爲0. 這是爲了方便後面的求解
    return preSum;
}
getPreSum([1,1,1]); // 如今對於題目中輸入的[1, 1, 1]數組 咱們就獲得了一個值爲[1,2,3] 的preSum數組

會發現,獲得preSum這個數組以後,求解第i項到第j項的元素之和, 等價於求preSum[j] - preSum[i-1]。 (i=0 時,i-1= -1,這也是上面設置preSum[-1] =0的緣由)code

因此咱們如今成功把sum函數去掉了,因爲preSum[j+1]- preSum[i]的複雜度是O(1),因此總體的複雜度也從O(n^3)下降到O(n^2)。使用前綴和以後,咱們的代碼變成如今這樣:

var subarraySum = function(nums, k) {
    const len = nums.length;
    let count = 0;
    const preSum = getPreSum(nums);
    // 請注意這裏的i jd的範圍和邊界條件, 當i = j時, preSum[j] - preSum[i-1] = num[i]
    for(let i = 0; i < len; i++){
        for(let j = i; j < len; j++){
            if(preSum[j] - preSum[i-1] === k){
                count++;
            }
        }
    }
    return count;
};

優化2:去掉非必須的嵌套循環

可是這樣的複雜度O(n^2)依然是不夠的,因此咱們如今繼續優化,目前主要的複雜度集中在嵌套的for循環裏,因此先觀察下這個循環:
內層循環的關鍵條件語句是preSum[j] - preSum[i-1] === k,根據等式的基本原理,移項可得 preSum[i-1] === preSum[j] - k

如今關鍵點來了,從j的角度考慮(要考慮任意的i<=j 這就是前面提醒讀者注意邊界條件的緣由):

  • 當j = 0時,咱們要比較: preSum[-1] 是否等於 preSum[0] - k;
  • 當j = 1時,咱們要比較: preSum[0] 是否等於 preSum[1] - k, preSum[-1] 是否等於 preSum[1] - k;
  • 當j = 2時,咱們要比較: preSum[1] 是否等於 preSum[2] - k,preSum[0] 是否等於 preSum[2] - k, preSum[-1] 是否等於 preSum[2] - k;

...

發現了嗎?在上述過程當中,其實咱們屢次用到了preSum[0], preSum[1], ... preSum[len] 因此若是咱們直接把preSum[i](0<=i<=len-1)緩存起來,就能夠解開內層循環了. 因此咱們能夠考慮用一個hash結構(在js裏面一般用obj或者es6裏的map)來保存preSum[i] - kkey => value 分別對應 preSum[i] - k => 出現次數

因爲咱們預設了preSum[-1] = 0,因此hash結構的第一項默認就是, 0=>1 表示前綴和爲0的狀況已經出現了一次

接下來,遍歷數組中的每一項,而且執行:

  1. 查看: 查看現有的hash,是否存在知足hash[已有的前綴和] - k 等於【當前的前綴和】
  2. 更新: 更新hash,把當前的前綴和添加到hash中去 -- 若是已經存在hash[當前前綴和],那麼出現次數加1; 若是還不存在,那出現次數設置爲1;

因此咱們能夠把上面的思路用代碼表示出來:

var subarraySum = function(nums, k) {
    const len = nums.length;
    let count = 0;
    const hash = new Map();
    hash.set(0,1); //預設了preSum[-1]= 0;
    const preSum = getPreSum(nums);
    for(let i = 0; i < len; i++){
        // 操做1: 判斷以前出現的前綴和中 是否已經有知足【當前前綴和】=【以前前綴和】- k的項
        const key = preSum[i] - k;        
        if(hash.has(key){
            count +=  hash.get(key);
        }

        // 操做2:把當前項對應的前綴和放入hash, 這個和上面的操做1的執行順序是不能夠相反的,不然會出現重複計數的問題 能夠思考下爲何
        if(!hash.has(preSum[i])){
            hash.set(preSum[i], 1);
        } else {
            hash.set(preSum[i], hash.get(preSum[i]) + 1);
        }

    }
    return count;
};

優化3:取出非必要的preSum結構

通過第二步驟的優化以後,其實已經獲得了一個比較好的前綴和算法,只有一層循環,因此時間複雜度爲O(n),須要一個preSum的數組空間和一個map,空間複雜度爲O(n)。不過仍是有地方能夠優化:
preSum必須存在嗎?
答案是沒有必要的,咱們發現getPreSum的本質實質上也是對nums作了一次單層循環,而且在subarraySum函數裏, 遍歷到i時,咱們只須要當前對應的preSum[i]便可**
因此能夠改寫成如下形式:

var subarraySum = function(nums, k) {
    const len = nums.length;
    let count = 0;
    const hash = new Map();
    hash.set(0,1); //預設了preSum[-1]= 0;
    // const preSum = getPreSum(nums); //這一行再也不須要了 用一個臨時變量代替
    let currentSum = 0; // 這個初始值其實對應的就是原先的preSum[-1]
    for(let i = 0; i < len; i++){
        currentSum += num[i]; //這一步就求解了preSum[i]

        // 操做1: 判斷以前出現的前綴和中 是否已經有知足【當前前綴和】=【以前前綴和】- k的項
        const key = currentSum - k;        
        if(hash.has(key){
            count +=  hash.get(key);
        }

        // 操做2:把當前項對應的前綴和放入hash, 這個和上面的操做1的執行順序是不能夠相反的,不然會出現重複計數的問題 能夠思考下爲何
        if(!hash.has(preSum[i])){
            hash.set(preSum[i], 1);
        } else {
            hash.set(preSum[i], hash.get(preSum[i]) + 1);
        }

    }
    return count;
};

到這裏基本上完整的算法就介紹完了。

相關題型

974. 和可被 K 整除的子數組

示例:

輸入:A = [4,5,0,-2,-3,1], K = 5
輸出:7
解釋:
有 7 個子數組知足其元素之和可被 K = 5 整除:
[4, 5, 0, -2, -3, 1], [5], [5, 0], [5, 0, -2, -3], [0], [0, -2, -3], [-2, -3]

學完本文以後有興趣的能夠在leetcode上拿這道相似的題目練練手。

小結

本文針對前綴和算法,以leetcode的一道題爲例,按照由淺入深的方式,層層遞進地進行介紹。

思路1是咱們接觸算法較少時最多見最直接的解法;優化1是引入前綴和概念,優化2是保證前綴和時間複雜度知足通常要求的關鍵步驟,初次理解有難度;優化3則是額外的細節,沒有一開始就直接介紹最終的算法,保留中間的軌跡是爲了能更方便你們理解。

慣例:若是內容有錯誤的地方歡迎指出(以爲看着不理解不舒服想吐槽也徹底沒問題);若是有幫助,歡迎點贊和收藏,轉載請徵得贊成後著明出處,若是有問題也歡迎私信交流,主頁有郵箱地址


最後順便打個小廣告:

  • RingCentral廈門地區目前有大量hc
  • 純美資外企,有工做優生活(5點多下班 我的時間超長 能夠爲所欲爲擼貓擼廚房擼算法)
  • 海景辦公,零食水果,節日福利多多
  • 免費英文口語課,硅谷工做機會,入職享受10天起超長帶薪年假
  • 須要內推請私信或投遞簡歷到郵箱ma13635251979@163.com
  • 全程跟進面試進度,提供力所能及的諮詢幫助~
相關文章
相關標籤/搜索