四種方式帶你層層遞進解剖演算法---hash表不一定適合尋找重複資料

煙花散盡13141發表於2021-05-20

一、題目描述

找出陣列中重複的數字

> 在一個長度為 n 的陣列 nums 裡的所有數字都在 0~n-1 的範圍內。陣列中某些數字是重複的,但不知道有幾個數字重複了,也不知道每個數字重複了幾次。請找出陣列中任意一個重複的數字。

二、思路分析

  • 演算法(Algorithm)指的是解題的方案,是一系列解決問題的明確動作。所以說演算法沒有語言區分,只要我們的方案是完整的任何語言都可以實現它。我是C++出身但是從事Java多年,下面將是通過java來實現演算法

考察點

  • 任何演算法基本上都可以通過暴力列舉來解決,但那僅僅是理論上。解決問題不僅要考慮理論最終還得取決於硬體和時間的支援。所以我們面對一個問題首先得確定方案。想要確定方案就得知道問題的痛點或者說問題的考點在哪裡
  • 此題是要找出重複的數字,想要找出重複的數字就得有一個對比的操作,想要有一個對比的操作就得將舊資料存放在一定規則的區域中。關於規則的區域這就引入了雜湊表(HashTable)。

三、程式碼+解析

初版

public int findRepeatNumber(int[] nums) {
    //構建hash表 。 Java中Map天生的Hash表
    Map<integer, object=""> map = new HashMap&lt;&gt;();
    for (int num : nums) {
        //已經在hash表中存在的說明資料重複
        if (map.containsKey(num)) {
            return num;
        }
        //沒有重複的資料需要新增到hash表中
        map.put(num, num);
    }
    return 0;
}

image-20210518174357388

  • 此題是leetcode中簡單型別的題目。既然是刷題首先得找簡單的找找自信。正好也確定下自己的刷題風格。結果很明顯是沒有問題也是一次性通過。
  • 雖然題目簡單但是我們不能僅僅滿足於完成。回過頭來想想我們這樣做有啥缺點是不是還有進步的空間呢?

首次升級

升級點

  • 在上面的那個版本中我們藉助於hash表來實現資料的儲存從而進行資料的比對是否重複。這裡因為引入了hash表而hash表就需要在記憶體中開闢空間這就導致了我們的程式在記憶體上開闢的比較大。會隨著陣列的重複性後偏導致我們的hash表記憶體越來越大,極端情況下我們的hash表中的元素和陣列中的元素趨近於相等。
  • 其次是每次都需要從hash中獲取資料和陣列中的資料進行對比。我們知道hash表尤其是Java中的Map的實現在獲取資料是需要先根據hashcode值定位到hash槽,然後在從槽頭開始遍歷連結串列或者是樹進行資料尋找。這個過程雖然已經很快了但是和陣列直接定址法相比就弱爆了。
  • 基於上面兩個痛點,我決定取消Hash表的引入。上面說了本題的考點是Hash表,但是並不意味著必須使用Hash表來實現是最優的。所謂條條大路通羅馬實現是有很多種的,善於利用周遭的環境是我們人類的本能。

優化落地

  • 既然是查詢重複資料如果是有序的陣列的話只需要逐個對相鄰的兩個進行比較就可以了。因為有序狀態的陣列每個元素會起著隔離的效果,這樣就避免的Hash表的存在在記憶體上肯定比Hash表低的,而且上面也提到了陣列的定址比HashMap快的多,所以在速度上應該也會快很多的
public int findRepeatNumber(int[] nums) {
    Arrays.sort(nums);
    for (int i = 1; i &lt; nums.length; i++) {
        if (nums[i] == nums[i - 1]) {
            return nums[i];
        }
    }
    return 0;
}

image-20210518184330732

  • 上圖中左邊是Hash表的方式執行效果,右圖是相鄰比較的執行效果。兩者在執行時間和記憶體消耗上不是在同一個量級的。提升了兩倍之多

再次升級

升級點

  • 上面排序後相鄰位置比較執行的結果我覺得還是挺滿意的,但是在程式碼的是實現上有個邊界的問題。而且我們需要逐個進行比較,逐個比較在時間上應該是比較耗時的。
  • 基於上面逐個比較,筆者這裡再次進行優化。將進行跳位比較

image-20210518185932498

  • 跳位就避免了逐個比較,將比較的次數控制下來。
public int findRepeatNumber(int[] nums) {
    Arrays.sort(nums);
    for (int i = 0; i &lt; nums.length; i++) {
        int index =i;
        while (index  != nums[index]) {
            index = nums[index];
        }
        if (index != i) {
            return index;
        }
    }
    return 0;
}

image-20210518190053631

  • 效果對比看一下,兩者基本沒有區別。在執行速度上基本一致。記憶體消耗上後者應該比前者高一點的,可能是leetcode統計記憶體沒有那麼細緻再結合執行期間不穩定因素所以執行出來的結果雖然是後者高但是實際上筆者這裡認為逐位相鄰比較才是最優的。
  • 本次的升級實際上是失敗的,充其量就是逐位相鄰比較的一種變形。但是本次的變形卻引入另外一個概念---跳位交換

最終升級

升級點

  • 其實仔細思考下為什麼跳位定址比較沒有逐位相鄰比較有什麼顯著的提升呢。說到底是因為我們已經排好順序了在已經排好的順序中我們跳位進行比對是沒有起到太大的作用的。

  • 這裡筆者又查閱了官方的推薦解法--原地交換。這裡的【原地交換】和筆者提出的【跳位定址比較】不謀而合。下面我將翻譯下官網的推薦解法(官網是真的強大)

  • 這裡官網推薦的程式碼就不貼了。大家可以直接在官網題解中找到原地交換講解 。 但是筆者嘗試了很多次都沒有題解中說的100% 。 可能語言的差異所以他的實現並不支援java的。筆者這裡對他進行稍微的帶動

public int findRepeatNumber(int[] nums) {
    for (int i = 0; i &lt; nums.length; i++) {
        while (i != nums[i]) {
            if (nums[i] == nums[nums[i]]) {
                return nums[i];
            }
            nums[i] = nums[i] ^ nums[nums[i]];
            nums[nums[i]] = nums[nums[i]]^nums[i];
            nums[i] = nums[i] ^ nums[nums[i]];
        }
    }
    return 0;
}
  • 官網中引入了一個臨時變數用於交換資料暫存。這裡記憶體就會一直被佔用。理論上記憶體也不會太受影響的。但是結果在java中執行卻不是那麼完美
  • 筆者這裡將通過異或的方式實現資料的交換。執行結果相對會高點 。這裡筆者在此提醒下leetcode每次執行因為大環境的問題並不能準確反映效能的問題
  • 下面是筆者在leetcode連續執行三次的效果圖

image-20210518192933237

四、總結

  • 不能僅僅依賴leetcode的執行結果作為衡量程式好壞的依據。筆者這裡只是從個人的角度出發區分出程式的優劣
  • 雖然leetcode不能作為唯一標準,但是多次執行的結果可以做一個參考價值。
  • 演算法的實現並不是一層不變的。我們學習演算法是基礎面對實際的問題還是得在演算法的基礎上進行擴充套件,結合實際的場景觸發才是最正確的選擇

> 最後還得送各位兄弟一句話,關注、點贊、收藏不能忘。萬一哪天你找不到我了呢

</integer,>

相關文章