一道樸實無華的算法題:把數組排成最小的數

擊上方“圖解面試算法”,選擇“星標”公衆號

重磅乾貨,第一時間送達

大家好,我是景禹。

今天分享的題目來源於 LeetCode 上的劍指 Offer 系列 面試題45 把數組排成最小的數。

這道題目有好幾個讀者反饋說在字節二面環節中遇到過,所以今天提前來講,希望對你有所幫助。

題目鏈接:https://leetcode-cn.com/problems/ba-shu-zu-pai-cheng-zui-xiao-de-shu-lcof/

題目描述

輸入一個非負整數數組,把數組裏所有數字拼接起來排成一個數,打印能拼接出的所有數字中最小的一個。

示例1
輸入: [10,2]
輸出: "102"
示例2
輸入: [3,30,34,5,9]
輸出: "3033459"

題目解析

暴力破解

給定一個非負整數數組,把數組裏所有數字拼接起來排成一個數,打印能拼接出的所有數字中最小的一個。所有數字中最小的一個,對一個包含 n 個非負整數的數組,所有的組合數爲 個,然後對這 的組合進行排序找出最小的一個。

比如對數組 [10,2] 而言,其所有的組合就是 [ "102"、"210"]  ,然後比較 102 和 210 ,顯然是 102 比較小,輸出即可。

但是問題是,當這個數組特別大的時候,組合而成的數字就會特別大,用任何一個整數類型(int ,long)都無法表示,這裏隱含一個大數問題,所以還是要考慮用字符串來進行大小比較。

還是以數組 [10,2]  爲例,可以組合成字符串 "102""210" ,比較這兩個字符串,“102” < "210" ,所以輸出 "102" ,當然這裏只是初探。

轉化思想

僞貪心方法

爲何叫僞貪心呢?

我們以數組 [3,30,34,5,9] 爲例進行說明

對於 330 而言,可以組合出的字符串爲 "303""330"  ,”303“ < "330",所以保留 ”303“。

將 “303” 和 34 繼續進行拼接,可以拼接爲 "30334"  和  "34303"  ,"30334" < "34303",所以保留 ”30334“;

將 “30334” 和 5 繼續進行拼接,可以拼接爲 "303345"  和  "530334"  ,"303345" < "530334",所以保留 ”303345“;

將 “303345” 和 9 繼續進行拼接,可以拼接爲 "9303345"  和  "3033459"  ,"3033459" < "9303345",所以保留 ”3033459“;

即拼接出的最小的數字爲 "3033459";

因爲在每一次與後續的數字組合的時候,我們都是 儘可能選擇當前組合最小 的數字組合,然後一直向下,直到將所有的數字都拼接到字符串當中。

但是我們開心的太早了,因爲這個例子實在太特殊了,爲什麼說他特殊呢?

因爲數組  [3,30,34,5,9]  按照字符串的大小比較規則,"3" < "30" < "34" < "5" < "9" ,也就說這個數組按照字符串的大小比較規則,是一個有序的數組且是一個升序的字符串數組,所以我們才能按照前面的 “所謂的貪心” 方法得到拼接後最小的數字組合 "3033459" ,但事實上是:

給定一個非負整數數組,只要把非負整數數組當中的數字按照比較規則進行升序排列之後,然後將排序後的字符串數組連接起來就是能拼接出的所有數字組合中最小的一個。

而這個比較規則就是對於任意個兩個數字字符串 m 和 n ,如果 m + n < n + m ,則應該將 m 排在 n 的前面。

爲什麼這個方法就是合理的呢?

我們以數組  [3,30,34]  進行說明(因爲數組太大組合太多,並不能大家很好的理解),寫出數組中數字可以拼接成的所有組合:

我們可以看到,數組總共可以得到  中組合,但其中只有一個組合是最小的,就是圖中左箭頭所指的字符串 “30334”,我們對數組  [3,30,34]  按照比較規則進行排序:

m = "30",n = "3",∵ "303" < "330",∴ ”30“ 應該排在 ”3“ 的前面;

同理,∵ "334" < "343",∴ ”3“ 應該排在 ”34“ 的前面;

那麼 “30” 排在 “34” 的前面是否合理呢?

∵ "3034" < "3430",∴ ”30“ 排在 ”34“ 的前面是合理,這也就是排序規則傳遞性的一個體現;

所以最終的得到的排序字符串數組爲  ["30","3","34"]  ,這也是我們最終需要輸出的最小組合的順序。

我們爲什麼將本爲數字的數組轉化爲字符串數組之後,按照既定的規則排序,就是最小組合的順序呢?

這裏談的就是個人的一些理解,可能有誤,還望批評指正。

原因有三:

  1. 對數字組合之後得到的數組太大,無法用任何一個整數類型表示,所以考慮使用字符串進行處理(隱含的大數問題);

  2. 將數字轉化爲字符串之後,可以組合出的字符串個數沒變,但是將組合後的字符串的進行縱向比較,你會發現,一定是儘可能將最小的數字向前排,才能得到最小的組合。

  3. 將最小的數字向前排,這裏的排序規則就是對於任意個兩個數字字符串 m 和 n ,如果 m + n < n + m(+ 表示連接) ,則應該將 m 排在 n 的前面。

排序規則正確性證明

對於任意的兩個數字字符串 m 和 n:

  1. 若 m + n = n + m ,則 m = n;

  2. 若 m + n < n + m,則 m < n;

  3. 若 m + n > n + m ,則 m > n;

一個有效的比較規則滿足三個條件:自反性、對稱性和傳遞性(離散數學)。

(1)自反性

對於任意一個字符串 m ,顯然  m + m = m + m,所以 m = m;

(2)對稱性

對於任意字符串 m 和 n,如果 m + n < n + m,則 m < n,∴ n + m > m + n,∴ n > m;

(3)傳遞性(之前在例子當中有說明)

對於任意的數字字符串 m 、k 和 n,若 m + k < k + m 且  k + n < n + k ,則 m < n;

對於任意的數字字符串 m 、k 和 n,若 m + k < k + m,則 m < k;∵ k + n < n + k,∴ k < n;∴ m < n;

傳遞性更嚴格的證明:

對於任意的數字字符串 m 和 k ,若 m < k,則 m + k < k + m(+ 表示連接)。設 m 和 k 分別爲 i 位 和 j 位,則 m 和 k 連接的結果就等於 (這裏是加號),k 和 m 連接的結果就等於  。這裏其實不難理解,比如數字 3 和 30 連接就是 ,而 30 和 3 連接就是 .

則 m + k < k + m 可以表示爲

左右兩側移項:

提取公因子並移項(注意這裏的 i 和 j 都是 大於等於 1的,所以可以直接移項):

設 n 爲 h 位,且 k < n,則 k + n < n + k。同理可以得到:

所以

繼而得到

繼而得到   ,所以 m < n。

由於排序規則滿足自反性、對稱性和傳遞性,所以排序規則有效。

將你的思想轉變過來

此時問題就不再是我們讀到的那樣 “給定一個非負整數數組,把數組裏所有數字拼接起來排成一個數,打印能拼接出的所有數字中最小的一個”,而是 ”給定一個非負整數數組,然後對非負整數數組按照上面所證明的排序規則進行排序即可“。

算法流程也就清晰可見:

  1. 初始化:將給定的數組 nums 的所有數字轉化爲字符串,並存入字符串數組 nums_str 中;

  2. 按照上面證明的排序規則,對 nums_str 進行排序;

  3. 對排序後的 nums_str 進行拼接即可。

實現代碼

此時實現代碼就轉化爲你對排序算法的掌握程度了,使用不同的排序算法,也會得到不同的實現方式,這裏我們以快速排序爲例。

class Solution {
    public String minNumber(int[] nums) {
        String nums_str[] = new String[nums.length];
        for(int i = 0; i < nums.length; i++){
            nums_str[i] = String.valueOf(nums[i]);
        }
        QuickSort(nums_str, 0, nums.length-1);
        String result = new String();
        for(String s : nums_str){
            result += s;
        }
        return result;
    }
    public static void QuickSort(String[] strs, int l, int r) {
        if(l >= r) return;
        int i = l, j = r;
        String tmp = strs[i];
        while(i < j) {
            while((strs[j] + strs[l]).compareTo(strs[l] + strs[j]) >= 0 && i < j) j--;
            while((strs[i] + strs[l]).compareTo(strs[l] + strs[i]) <= 0 && i < j) i++;
            tmp = strs[i];
            strs[i] = strs[j];
            strs[j] = tmp;
        }
        strs[i] = strs[l];
        strs[l] = tmp;
        QuickSort(strs, l, i - 1);
        QuickSort(strs, i + 1, r);
    }
}

還有一種使用內置函數的實現方式,只需要爲內置函數指定排序規則即可。

Arrays.sort(nums_str, (m,n) -> (m + n).compareTo(n + m))

其中 (m,n) -> (m + n).compareTo(n + m) 就是自定義的 Comparator (比較器,也就是排序規則,對於任意兩個數字字符串,如果 (m + n) < (n + m),則應該將 m 排在 n 的前面)。

與前一種採用快速排序的方式,這裏也就更改了排序方式的一行代碼。

class Solution {
    public String minNumber(int[] nums) {
        String nums_str[] = new String[nums.length];
        for(int i = 0; i < nums.length; i++){
            nums_str[i] = String.valueOf(nums[i]);
        }
        Arrays.sort(nums_str, (m,n) -> (m + n).compareTo(n + m));
        String result = new String();
        for(String s : nums_str){
            result += s;
        }
        return result;
    }
}

複雜度分析

  • 時間複雜度:這裏的時間複雜度取決於你使用的排序算法,比如你採用冒泡排序,時間複雜度就是 ;而採用快速排序,時間複雜度就是 。至於 Java 內置的排序算法,具體採用哪種需要依據與排序的數組大小,所以無法給出一個肯定的時間複雜度。

  • 空間複雜度:空間複雜度很明顯,就是我們將 nums 當中的所有數字轉化爲字符串之後存儲所需的空間 nums_str ,空間複雜度爲 .

知識點

排序算法、排序規則有效性的證明、思維方式的轉變。

---

由 五分鐘學算法 原班人馬打造的公衆號:圖解面試算法,現已正式上線!
接下來我們將會在該公衆號上,爲大家分享優質的算法解題思路,堅持每天一篇原創文章的輸出,如果沒有更新,說明我還在製作動畫中^_^

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