1. 程式人生 > >【帶你裝逼帶你飛】吐血總結了這五大常用演算法技巧,讓你在同事/面試官面前驚豔全場!

【帶你裝逼帶你飛】吐血總結了這五大常用演算法技巧,讓你在同事/面試官面前驚豔全場!

對於演算法技巧,之前的文章也寫過一些演算法技巧,不過相對零散一些,今天我把之前的很多文章總結了下,並且通過增刪查改,給大家總結一些常用的演算法解題技巧,當然,這些也不是多牛逼的技巧,不過可以讓你的程式碼看起來更加短小精悍,如果你能夠充分掌握這些技巧,能夠混合運用起來,那麼寫出來的程式碼,必然可以讓別人拍案叫絕。

1、多思考能否使用位運算

如果你去看一些大佬的解題程式碼,你會發現大部分程式碼裡都會出現位運算相關的程式碼,而且不瞞你說,如果我看到一個人的程式碼裡,如果出現了位運算,我就會感覺這個人還是有點東西。

最簡單地位運算使用場景就是當我們在進行除法和乘法運算的時候了,例如每次遇到 n / 2,n / 4,n / 8這些運算地時候,完全可以使用位運算,也可以使你地程式碼執行效率更高,例如

n / 2 等價於 n >> 1
n / 4 等價於 n >> 2
n / 8 等價於 n >> 3。

當然,如果你現在去找個 IDE 寫個程式碼測試下 n / 2 和 n >> 1 的執行效率,可能會發現沒啥差別,其實並非沒有差別,而是大部分編譯器會自動幫你把 n / 2 優化成 n >> 1,不過我還是建議你寫成 n >> 1,這可以讓你的程式碼顯的更加牛逼一些,給面試官的印象可能也會好一些。當然,我說的說可能。

還有一個非常常用的就是奇偶的判斷,判斷一個數是否說奇數,常規操作長這樣

if( n % 2 == 1){
    dosomething();
}

不過你可以採用與運算來代替 n % 2,改成這樣

if( (n & 2) == 1){
    dosomething();
}

你去看原始碼的話,基本都是採用這些位運算的,如果你用慣,以後遇到這些程式碼,看起來也會比較容易懂。

上面列舉的這個說最常用的,也說不上什麼技巧,不過建議可以多使用熟悉,對於位運算的技巧,我推薦你熟悉如下幾個。

1、利用 n & (n - 1)消去 n 最後的一位 1

在 n 的二進位制表示中,如果我們對 n 執行

n = n & (n - 1)

那麼可以把 n 最右邊的 1 消除掉,例如

n = 1001
n - 1 = 1000
n = n & (n - 1) = (1001) & (1000) = 1000

這個公式有哪些用處呢?

其實還是有挺多用處的,在做題的時候也是會經常碰到,下面我列舉幾道經典、常考的例題。

(1)、判斷一個正整數 n 是否為 2 的冪次方

如果一個數是 2 的冪次方,意味著 n 的二進位制表示中,只有一個位 是1,其他都是0。我舉個例子,例如

2^0 = 0…..0001

2^1 = 0…..0010

2^2 = 0….0100

那麼我們完全可以對 n 執行 n = n & (n - 1),執行之後結果如果不為 0,則代表 n 不是 2 的冪次方,程式碼如下

boolean judege(int n){
     return (n & (n - 1)) == 0;// 
}

如果你使用常規手段對話,得把 n 不停著除以 2,最後判斷得出結果,用這個位運算技巧,一行程式碼搞定。

(2)、判斷 正整數 n 的二進位制表示中有多少個 1

例如 n = 13,那麼二進位制表示為 n = 1101,那麼就表示有 3 個1,這道題常規做法還是把 n 不停著除以 2,然後統計除後的結果是否為奇數,是則 1 的個數加 1,否則不需要加1,繼續除以 2。

不過對於這種題,我們可以用不斷著執行 n & (n - 1),每執行一次就可以消去一個 1,當 n 為 0 時,計算總共執行了多少次即可,程式碼如下:

    public int NumberOf12(int n) {
        int count = 0;
        int k = 1;
        while (n != 0) {
            count++;
            n = (n - 1) & n;
        }
        return count;

程式碼不僅更加短小精悍,而且效率更高,關於 n & (n - 1),我就暫時舉例這兩個,主要是後面還有非常多的技巧要寫。

2、異或(^)運算的妙用

關於異或運算子,我們先來看下他的特性

特性一:兩個相同的數相互異或,運算結果為 0,例如 n ^ n = 0;

特性二:任何數和 0 異或,運算結果不變,例如 n ^ 0 = n;

特性三:支援交換律和結合律,例如 x ^ ( y ^ x) = (x ^ y) ^ x;

案例1:只出現一次是數

問題:陣列中,只有一個數出現一次,剩下都出現兩次,找出出現一次的數

常規操作就是一邊遍歷陣列一邊用雜湊表統計元素出現的次數數,最後再遍歷雜湊表,看看哪個數只出現了一次。這種方法的時間複雜度為 O(n),空間複雜度也為 O(n)了。

我們剛才說過,兩個相同的數異或的結果是 0,一個數和 0 異或的結果是它本身,所以我們把這一組整型全部異或一下,例如這組資料是:1, 2, 3, 4, 5, 1, 2, 3, 4。其中 5 只出現了一次,其他都出現了兩次,把他們全部異或一下,結果如下:

由於異或支援交換律和結合律,所以:

1^2^3^4^5^1^2^3^4 = (1^1)^(2^2)^(3^3)^(4^4)^5= 0^0^0^0^5 = 5。

通過這種方法,可以把空間複雜度降低到 O(1),而時間複雜度不變,相應的程式碼如下

int find(int[] arr){
    int tmp = arr[0];
    for(int i = 1;i < arr.length; i++){
        tmp = tmp ^ arr[i];
    }
    return tmp;
}

關於位運算的技巧真的挺多,不過由於篇幅原因,我就暫時先舉例這麼多,重點的要告訴你,平時在刷題的時候,多留意下這些技巧,然後可以總結下來,之後自己遇到的時候可以應用上去。

2、考慮是否可以使用陣列下標

陣列的下標是一個隱含的很有用的陣列,特別是在統計一些數字,或者判斷一些整型數是否出現過的時候。例如,給你一串字母,讓你判斷這些字母出現的次數時,我們就可以把這些字母作為下標,在遍歷的時候,如果字母a遍歷到,則arr[a]就可以加1了,即 arr[a]++;

通過這種巧用下標的方法,我們不需要逐個字母去判斷。

我再舉個例子:

問題:給你n個無序的int整型陣列arr,並且這些整數的取值範圍都在0-20之間,要你在 O(n) 的時間複雜度中把這 n 個數按照從小到大的順序打印出來。

對於這道題,如果你是先把這 n 個數先排序,再列印,是不可能O(n)的時間打印出來的。但是數值範圍在 0-20。我們就可以巧用陣列下標了。把對應的數值作為陣列下標,如果這個數出現過,則對應的陣列加1。

public void f(int arr[]) {

       int[] temp = new int[21];
       for (int i = 0; i < arr.length; i++) {
           temp[arr[i]]++;
       }
       //順序列印
       for (int i = 0; i < 21; i++) {
           for (int j = 0; j < temp[i]; j++) {
               System.out.println(i);
           }
       }
   }

我在舉一個例子

假如給你20億個非負數的int型整數,然後再給你一個非負數的int型整數 t ,讓你判斷t是否存在於這20億數中,你會怎麼做呢?

有人可能會用一個int陣列,然後把20億個數給存進去,然後再迴圈遍歷一下就可以了。

想一下,這樣的話,時間複雜度是O(n),所需要的記憶體空間

4byte * 20億,一共需要80億個位元組

如果採用下標法,我們可以把時間複雜度降低位 O(1),例如我們可以這樣來存資料,把一個 int 非負整數 n 作為陣列下標,如果 n 存在,則對應的值為1,如果不存在,對應的值為0。例如陣列arr[n] = 1,表示n存在,arr[n] = 0表示n不存在。

那麼,我們就可以把20億個數作為下標來存,之後直接判斷arr[t]的值,如果arr[t] = 1,則代表存在,如果arr[t] = 0,則代表不存在。這樣,我們就可以把時間複雜度降低到O(1)。

那麼大家想一下,空間上可以繼續優化嗎?

答是可以的,因為如果不需要統計個數,我們我們不需要 int 陣列,用boolean型別的陣列他不香嗎?boolean型別佔用的空間更少。

那麼大家想一下,還能繼續優化嗎?

答是可以的,可以用 bitmap 演算法,具體我這裡不展開,感興趣看這篇文章:【面試現場】如何判斷一個數是否在40億個整數中?

關於下標法的,在做題的時候,真的用到提多,這裡推薦大家以後做題的時候可以關注一下,我就暫時先講這麼多。

3、考慮能否使用雙指標

雙指標這個技巧,那就更加常用的,特別是在連結串列和有序陣列中,例如

給定一個整數有序陣列和一個目標值,找出陣列中和為目標值的兩個數,並且打印出來

一種簡單的做法就是弄個兩層的 for 迴圈,然而對於這種有序的陣列,如果是要尋找某個數之類的,大概率可以考慮雙指標,也就是設定一個頭指標和尾指標,直接看程式碼吧,程式碼如下:

int find(int arr[], int target){
    int left = 0;//頭指標
    int right = arr.length - 1;// 尾指標
    while(left < right){
        if(left + right == target){
            // 找到目標數,進行列印,這裡我就不執行列印操作兩
        }else if(left + right < target){
            left ++;
        }else{
            right --;
        }
    }
}

在 leetcode 中的三數之和和四數只和都可以採用這個型別的雙指標來處理。

當然,雙指標在連結串列中也是非常給力的,例如

在做關於單鏈表的題是特別有用,比如“判斷單鏈表是否有環”、“如何一次遍歷就找到連結串列中間位置節點”、“單鏈表中倒數第 k 個節點”等問題。對於這種問題,我們就可以使用雙指標了,會方便很多。我順便說下這三個問題怎麼用雙指標解決吧。

例如對於第一個問題

我們就可以設定一個慢指標和一個快指標來遍歷這個連結串列。慢指標一次移動一個節點,而快指標一次移動兩個節點,如果該連結串列沒有環,則快指標會先遍歷完這個表,如果有環,則快指標會在第二次遍歷時和慢指標相遇。

對於第二個問題

一樣是設定一個快指標和慢指標。慢的一次移動一個節點,而快的兩個。在遍歷連結串列的時候,當快指標遍歷完成時,慢指標剛好達到中點。

對於第三個問題

設定兩個指標,其中一個指標先移動k個節點。之後兩個指標以相同速度移動。當那個先移動的指標遍歷完成的時候,第二個指標正好處於倒數第k個節點。

你看,採用雙指標方便多了吧。所以以後在處理與連結串列相關的一些問題的時候,可以考慮雙指標哦。

關於雙指標,在這裡也是給大家提個醒,重要的還是要大家多考慮,以後才能順手拈來。

4、從遞迴到備忘錄到遞推或者動態規劃

遞迴真的太好用了,好多問題都可以使用遞迴來解決,不過 80% 的遞迴提都可以進行剪枝,並且還有還多帶有備忘錄的遞迴都可以轉化為動態規劃,我本來是要舉例一個二維DP的動態規劃題,較大家從遞迴 =》遞迴+備忘錄 =》動態規劃 =》動態規劃優化的。

不過寫起來有點多,並且有一定的難度,感覺有點偏離來這篇文章所有的技巧總結,所以我還來列舉一個簡單的例子吧,這個例子重在告訴大家遇到遞迴的題,一定要考慮是否可以剪枝,是否可以把遞迴轉化成遞推。

例如這個被我舉爛的例子

(1).對於可以遞迴的問題務必考慮是否有重複計算的

當我們使用遞迴來解決一個問題的時候,容易產生重複去算同一個子問題,這個時候我們要考慮狀態儲存以防止重複計算。例如我隨便舉一個之前舉過的問題

問題:一隻青蛙一次可以跳上1級臺階,也可以跳上2級。求該青蛙跳上一個n級的臺階總共有多少種跳法?

這個問題用遞迴很好解決。假設 f(n) 表示n級臺階的總跳數法,則有

f(n) = f(n-1) + f(n - 2)。

遞迴的結束條件是當0 <= n <= 2時, f(n) = n。因此我們可以很容易寫出遞迴的程式碼

    public int f(int n) {
       if (n <= 2) {
           return n;
       } else {
           return f(n - 1) + f(n - 2);
       }
   }

不過對於可以使用遞迴解決的問題,我們一定要考慮是否有很多重複計算,一種簡單的方法就是大家可以畫一個圖來看下。如這道題


顯然對於 f(n) = f(n-1) + f(n-2) 的遞迴,是有很多重複計算的。這個時候我們要考慮狀態儲存。例如用hashMap來進行儲存,當然用一個數組也是可以的,這個時候就像我們上面說的巧用陣列下標了。可以當arr[n] = 0時,表示n還沒計算過,當arr[n] != 0時,表示f(n)已經計算過,這時就可以把計算過的值直接返回回去了。因此我們考慮用狀態儲存的做法程式碼如下:

//陣列的大小根據具體情況來,由於int陣列元素的的預設值是0
   int[] arr = new int[1000];
   public int f(int n) {
       if (n <= 2) {
           return n;
       } else {
           if (arr[n] != 0) {
               return arr[n];//已經計算過,直接返回
           } else {
               arr[n] = f(n-1) + f(n-2);
               return arr[n];
           }
       }
   }

這樣,可以極大著提高演算法的效率。也有人把這種狀態儲存稱之為備忘錄法。

(2).考慮自底向上

對於遞迴的問題,我們一般都是從上往下遞迴的,直到遞迴到最底,再一層一層著把值返回。

不過,有時候當n比較大的時候,例如當 n = 10000時,那麼必須要往下遞迴10000層直到 n <=2 才將結果慢慢返回,如果n太大的話,可能棧空間會不夠用。

對於這種情況,其實我們是可以考慮自底向上的做法的。例如我知道

f(1) = 1;
f(2) = 2;

那麼我們就可以推出 f(3) = f(2) + f(1) = 3。從而可以推出f(4),f(5)等直到f(n)。因此,我們可以考慮使用自底向上的方法來做。

程式碼如下:

public int f(int n) {
       if(n <= 2)
           return n;

       int f1 = 1;
       int f2 = 2;
       int sum = 0;

       for (int i = 3; i <= n; i++) {
           sum = f1 + f2;
           f1 = f2;
           f2 = sum;
       }
       return sum;
   }

我們也把這種自底向上的做法稱之為遞推。

根據這種帶備忘錄的遞迴,往往可以演變成動態規劃,大家可以拿 leetcode 這兩道題試試水

leetcode 的 62 號題:https://leetcode-cn.com/problems/unique-paths/

leetcode 的第64題:https://leetcode-cn.com/problems/minimum-path-sum/

總結一下

當你在使用遞迴解決問題的時候,要考慮以下兩個問題

(1). 是否有狀態重複計算的,可不可以使用備忘錄法來優化。

(2). 是否可以採取遞推的方法來自底向上做,減少一味遞迴的開銷。

5、考慮是否可以設定哨兵位來處理臨屆問題

在連結串列的相關問題中,我們經常會設定一個頭指標,而且這個頭指標是不存任何有效資料的,只是為了操作方便,這個頭指標我們就可以稱之為哨兵位了。

例如我們要刪除頭第一個節點是時候,如果沒有設定一個哨兵位,那麼在操作上,它會與刪除第二個節點的操作有所不同。但是我們設定了哨兵,那麼刪除第一個節點和刪除第二個節點那麼在操作上就一樣了,不用做額外的判斷。當然,插入節點的時候也一樣。

有時候我們在運算元組的時候,也是可以設定一個哨兵的,把arr[0]作為哨兵。例如,要判斷兩個相鄰的元素是否相等時,設定了哨兵就不怕越界等問題了,可以直接arr[i] == arr[i-1]?了。不用怕i = 0時出現越界。

當然我這只是舉一個例子,具體的應用還有很多,例如插入排序,環形連結串列等。

總結

關於上面說的技巧,我只能說熟能生巧,居然要熟,首先你得要有機會接觸到這樣一算思想,而我上面的這些總結,便是給你找來機會接觸這些思想,並且還都給出了例子,大家可以好好消化下,這篇文章的內容有些雖然是之前總結過的,不過這一次增加了一些新的東西和例子,還是花了不少時間,希望能夠給大家帶來一些幫助勒。

如果喜歡我的文章,歡迎各位關注我的公眾號:帥地玩程式設計,專注於講解演算法,資料結果,計算機基礎知識(計算機網路+ 作業系統+資料庫+Linux),而這些都是每個程式設計師必修地底層內功,帥地帶你裝逼帶你飛。

兄dei,如果覺得我寫的不錯,不妨幫個忙

1、關注我的原創微信公眾號「帥地玩程式設計」,每天準時推送乾貨技術文章,專注於寫演算法 + 計算機基礎知識(計算機網路+ 作業系統+資料庫+Linux),聽說關注了的不優秀也會變得優秀哦。

2、給俺點個讚唄,可以讓更多的人看到這篇文章,順便激勵下我,嘻嘻。

作者簡潔

作者:大家好,我是帥地,從大學、自學一路走來,深知演算法,計算機基礎知識的重要性,所以申請了一個微星公眾號『帥地玩程式設計』,專業於寫這些底層知識,提升我們的內功,帥地期待你的關注,和我一起學習。 轉載說明:未獲得授權,禁止轉載