這樣玩演算法才夠酷

crazysunj發表於2017-12-24

前言

想接觸演算法的朋友經常會問一句,演算法難嗎?我掐指一算,回答一般有3種結果,難,不難,have a try。其實這個問題並不好,我們接觸的較多的一門課程叫數學,從小學到大學,甚至工作了,還不放過我們,而這個你很熟悉的東西,你覺得它難嗎?那麼結果出來了,更多的是一種興趣,很多人老是說自己智商不夠用,那是你根本不想認真去面對它,這麼跟你說吧,天賦差距肯定是有的,但你身邊可以說80%的人智商都跟你差不多。那你還有什麼理由不把這篇文章看完,挑戰一下自己?或許你能找到更優的解?或許你面試的時候正好碰到?豈不美哉?

這樣玩演算法才夠酷

本篇文章部分題目還是比較燒腦,因此收藏一下在空閒的時候去挑戰,或者記住題目,可能潛意識就已經完成了。Good Luck!

這樣玩演算法才夠酷

演算法題

現在就讓我們一起來玩一玩幾個有趣的演算法題吧!

1~n整數中1出現的次數

題目

輸入一個整數n,求1~n這n個整數的十進位制表示中1出現的次數。例如,輸入12,1~12這些整數中包含1的數字有1、10、11和12,1一共出現了5次。

分析

碰到問題,無論什麼時候都要冷靜地分析,而不是一股腦地寫程式碼,即使這道題再簡單。那有人問了,1+1還需要思考嗎?傻子都知道等於2。我不想打擊你,有時候等於3,運氣好可能等於4,悲劇點可能等於1,甚至等於0。

這樣玩演算法才夠酷

很多所謂的真理都是有條件的,那麼我們應該去分析這些條件,得到一個最優解。迴歸正題,當我們第一眼看到這個題目的時候的第一個思路就是增加一個統計變數,然後遍歷1~n,針對每一個數(大於10需要除以10)取餘是否等於1來增加我們的統計變數,最後返回我們的統計變數。思路很明確,程式碼實現也很簡單,如下:

public static int numberOf1Between1AndN(int num) {
    if (num < 1) {
        return 0;
    }
    int count = 0;
    for (int i = 1; i <= num; i++) {
        count += numberOf1(i);
    }
    return count;
}
private static int numberOf1(int num) {
    int count = 0;
    while (num != 0) {
        if (num % 10 == 1) {
            count++;
        }
        num /= 10;
    }
    return count;
}
複製程式碼

在時間複雜度的計算中,有兩步,首先有一個O(n)的遍歷,其次針對每個整數又有O(lgn)的除法取餘計算,因此它的時間複雜度為O(nlgn)。這裡要補充一點是lgn(以10為底)和log2n(以2為底)在我們分析時間複雜度的時候可以認為是沒有區別的,因為它們的比值是個常數,因此這裡記時間複雜度為O(logn),直接忽略底數,下面的分析也是如此。那我們再想想有沒有更好的辦法呢?當時我想到一個辦法,整數拼接成字串,然後遍歷字串判斷1的個數,我開開心心地寫完了程式碼,仔細一看,我那個後悔啊,這尼瑪也太low了,不忍直視啊。但還是把程式碼貼了。

public static int numberOf1Between1AndN(int num) {
    if (num < 1) {
        return 0;
    }
    StringBuilder sb = new StringBuilder();
    for (int i = 1; i <= num; i++) {
        sb.append(i);
    }
    int count = 0;
    for (int i = 0, n = sb.length(); i < n; i++) {
        if (sb.charAt(i) == '1') {
            count++;
        }
    }
    return count;
}
複製程式碼

簡單地分析一下為什麼low,首先我們不管使用了輔助空間StringBuilder,根本沒有提的必要,其次在num的迴圈中,StringBuilder會去append一個整數,如果對StringBuilder稍微有點了解的話就知道,底層無非是個char陣列,初始化的時候會建立一個固定容量的陣列,每次不夠用的時候將會擴充容量,怎麼擴充?無非是copy,又是一次陣列的遍歷,其容量增長又是指數級別的,難免會浪費記憶體。誒,這當做一個反面教材,恥辱啊。

再想想,有沒有好一點的方法?好像沒想出來,看一眼答案?剛看書本前面幾行的分析,我就知道怎麼解了,好歹我也被女神誇獎過是數學神童,對數字還是很敏感的。大體思路是這樣的:核心思想是分析1出現的規律,舉個例子,1~520,我們來分析,可以分為1~20和21~520,其中20正好是520去掉最高位後的結果,先來分析21~520,這些數中,哪些位會出現1?個位,十位,百位,你這不是屁話麼?文明,文明,文明(手動滑稽)。首先分析最高位,帶1的無非是100~199,共計100個,那麼這裡有一種情況是最高位就是1,比如120,帶1的是100~120,共計20+1個。這個比較好理解,接下來算是難點,我們剛剛分析了最高位,也就是百位。那接下來就是十位,我們可以分成這樣幾個段,21~120,121~220,221~320,321~420,421~520。有沒有發現什麼規律呢?沒有?老哥你是不是王者榮耀被隊友坑了?冷靜點。假設針對每段十位上固定為1,那麼個位是不是可以有0~9,同理,個位固定為1,十位同樣有0~9種情況。那麼結果很明顯了,5乘以2乘以10,一共為5段(5),我們可以固定十位和個位(2),固定一位後,只剩下一位且這位有0~9共10種可能(10)。我們來簡單推演一邊,5就是最高位的數字,2可以理解為出去最高位後剩下的位數,這裡我們出去百位,那麼只剩下十位和個位,10是固定某一位是1的情況下,剩下幾位就是10的幾次冪(出去最高位),我們這裡是10^1,假如是1314,固定千位為1,那麼是不是十位和個位各有0~9種情況,就是10^2。綜上所述,我們的公式也出來了,即最高位的數字乘以除去最高位後剩下的位數乘以固定某一位是1的情況下,10的剩餘位數次冪。有了公式還不好辦?分分鐘擼出程式碼。

public static int numberOf1Between1AndN(int num) {
    if (num < 1) {
        return 0;
    }
    int len = numberOfLen(num);//得到位數
    if (len == 1) {
        return 1;//如果只有1位,那必然是1,也就說只有一個
    }
    int pow = (int) Math.pow(10, len - 1);//儲存起來,避免重複計算
    int maxDigit = num / pow;//最高位數字
    int maxDigitCount = maxDigit == 1 ? num % pow + 1 : pow;//統計最高位為1的情況
    int otherDigitCount = maxDigit * (len - 1) * (pow / 10);//統計剩餘位為1的情況
    return maxDigitCount + otherDigitCount + numberOf1Between1AndN(num % pow);
}
private static int numberOfLen(int num) {
    int len = 0;
    while (num != 0) {
        len++;
        num /= 10;
    }
    return len;
}
複製程式碼

細心的朋友發現我們用了遞迴,更細心的朋友發現我們只分析21~520,剩下的不就是520%100嗎?這並不是重點哈,重點是為什麼這樣的演算法更高效,首先我們採用了遞迴,但遞迴次數就是我們n的位數,從numberOfLen方法可知為O(logn),其次每次遞迴都會去調numberOfLen方法又是一個O(logn),如果對底層很瞭解的朋友會對Math.pow指出問題,你想想這不是幾個相同的數的乘法嗎?因為計算機可沒那麼聰明。而java底層對pow的實現採用的是根據奇偶判斷遞迴,感興趣的可以去看看,而時間複雜度為O(logn),其中n為最高位-1,在我們分析看來,可以理解為求位數,那麼就是O(logn),那麼總的時間複雜度可以說是O(log(logn)),顯然這玩意是小於O(logn)的,因此方法裡面的時間複雜度O(logn),演算法的總時間複雜度為O(logn*logn),即O(logn)。看不懂運算的趕緊回去補補數學知識。

n個骰子的點數

題目

把n個骰子扔在地上,所有骰子朝上一面的點數之和為s。輸入n,列印出s的所有可能的值出現的概率。

分析

首先我們要明確幾個固定的點,n個骰子扔出去,那麼和至少也有n(所有都是1的情況),最大有6n(所有都是6的情況),排列總數為6^n(不清楚的同學去看看概率學)。這些都是確定的,而我們要求的是每種朝上之和的概率,有一種的方法是我們建立一個陣列,容量為6n-n+1,這樣正好能把所有情況都儲存下來,而值正好是該情況出現的次數,最後再遍歷陣列取出值比上6^n便是最後的結果。那如何去儲存呢?n可能太抽象了,不妨試試一個具體常量,比如2個骰子並列印所有值出現的概率,那你會怎麼做呢?你是不是會寫出這樣的程式碼來?

private static final int MAX_VALUE = 6;
public static void printProbabilityOf2() {
    int number = 2;
    int maxSum = number * MAX_VALUE;
    int[] pProbabilities = new int[maxSum - number + 1];
    //初始化,開始統計之前都為0次
    for (int i = number; i <= maxSum; i++) {
        pProbabilities[i - number] = 0;
    }
    int total = (int) Math.pow(MAX_VALUE, number);
    for (int i = 1; i <= MAX_VALUE; i++) {
        for (int j = 1; j <= MAX_VALUE; j++) {
            pProbabilities[i + j - number]++;
        }
    }
    for (int i = number; i <= maxSum; i++) {
        System.out.println(String.format(Locale.getDefault(), "s的值為%d,概率為%d/%d=%.4f", i, pProbabilities[i - number], total, pProbabilities[i - number] * 1.0f / total));
    }
}
複製程式碼

如果你能寫出這樣的程式碼來,說明你思路是對的,但缺少遞迴的思想,其實我自己也缺少,善用遞迴的大佬我只能交出膝蓋,遞迴的方法一開始沒想到,我只能想到第二種方法,只能用用迴圈啊。

這樣玩演算法才夠酷

迴歸正題,有2個骰子你便套了兩層迴圈,3個你是不是會套3層迴圈?4個4層?5個5層?那n個是不是n層啊。像這種不確定迴圈次數且是巢狀的情況下,你應該想到遞迴。一般解決思路無外乎兩種,要麼迴圈,要麼遞迴(說的容易,但就是tmd想不到啊)。而遞迴的實現需要一個判斷語句促使繼續遞迴下去還是終止遞迴。那這樣行不行?我從第一個骰子開始,判斷條件為當前扔的骰子為最後一個,那麼我們把資料記錄下來,否則扔下一個骰子直到是最後一個為止。說幹就幹,獻上程式碼。

private static final int MAX_VALUE = 6;
public static void printProbability(int number) {
    if (number < 1) {
        return;
    }
    int maxSum = number * MAX_VALUE;//點數和最大值6n
    int[] pProbabilities = new int[maxSum - number + 1];//儲存每一種可能的陣列
    //初始化,開始統計之前都為0次
    for (int i = number; i <= maxSum; i++) {
        pProbabilities[i - number] = 0;
    }
    int total = (int) Math.pow(MAX_VALUE, number);//情況種數6^n
    probability(number, pProbabilities);//計算n~6n每種情況出現的次數並儲存在pProbabilities中
    for (int i = number; i <= maxSum; i++) {
        System.out.println(String.format(Locale.getDefault(), "s的值為%d,概率為%d/%d=%.4f", i, pProbabilities[i - number], total, pProbabilities[i - number] * 1.0f / total));
    }
}
public static void probability(int number, int[] pProbabilities) {
    for (int i = 1; i <= MAX_VALUE; i++) {//從第一個骰子開始
        probability(number, 1, i, pProbabilities);
    }
}
/**
 * 不停遞迴每條路線直到扔完所有骰子
 *
 * @param original       總共骰子數
 * @param current        當前扔的骰子
 * @param sum            每條路線計算和
 * @param pProbabilities 儲存陣列
 */
public static void probability(int original, int current, int sum, int[] pProbabilities) {
    if (current == original) {
        pProbabilities[sum - original]++;
    } else {
        for (int i = 1; i <= MAX_VALUE; i++) {
            probability(original, current + 1, sum + i, pProbabilities);
        }
    }
}
複製程式碼

我們發現,只不過更改了2個骰子中間迴圈巢狀部分,採用遞迴實現。我們再來回顧下2個骰子的時候的統計程式碼是:

pProbabilities[i + j - number]++;
複製程式碼

i+j其實就是每種情況的和,而我們這裡用sum來計算每條路線的和,理解這一點,那麼就很簡單了。但遞迴有著顯著的缺點,遞迴由於是方法呼叫自身,而方法呼叫時有時間和空間的消耗的:每一次方法呼叫,都需要在記憶體棧中分配空間以儲存引數、返回地址及臨時變數,而且往棧裡壓入資料和彈出資料都需要時間,另外遞迴中有可能很多計算都是重複的,從而對效能帶來很大的負面影響。其實我內心是崩潰的,你倒是寫出來啊(個人認為遞迴本身是一種很牛B的思路,但計算機不給力)。反正就這道題,我第一次做的時候沒想到遞迴,直接想到的是迴圈,廢話不多說,直接分析下迴圈是怎麼做到的。

分析前,我們得明白一個問題,假設我當前的骰子是第k次投擲且點數和為n,記為f(k,n)。那麼問題來了,k-1個骰子所得點數和又有哪幾種情況,可能第k次投了個1,那麼k-1個骰子所得點數為f(k-1,n-1),同理,其它情況為f(k-1,n-2),f(k-1,n-3),f(k-1,n-4),f(k-1,n-5),f(k-1,n-6)。這幾種情況都是第k-1次投擲到第k次投擲的路線,那麼加起來是不是就是等於f(k,n)?我們來簡單驗證下(已知f(1,1)=f(1,2)=f(1,3)=f(1,4)=f(1,5)=f(1,6)=1,這個不用說了吧),比如我們想求f(2,4),根據公式f(2,4)=f(1,3)+f(1,2)+f(1,1)+f(1,0)+f(1,-1)+f(1,-2),不對啊,咋可能f(1,0),至少也得f(1,1)啊。沒毛病,你能投擲出0來?我們得到一個限制條件,n-z必須保證大於等於k-1,其中1<=z<=6,k>=2。那麼最終結果f(2,4)=f(1,3)+f(1,2)+f(1,1)=1+1+1=3。2個數和為4,共有3種情況((2,2),(1,3),(3,1)),好像很對誒,再來驗證個長一點的?f(2,7)=f(1,6)+f(1,5)+f(1,4)+f(1,3)+f(1,2)+f(1,1)=6,那是不是6呢?((1,6),(6,1),(2,5),(5,2),(3,4),(4,3))。其實這根本不用驗證,這是比較著名的動態規劃思想。有興趣的可以學習一下,由於最近接觸比較多,我就一下子想出來了,要是放在平時,可能要想幾分鐘(手動滑稽)。

思想知道了,那麼我們如何程式設計呢?這裡有個問題是,我們需要上一次計算的結果來計算本次的結果。關於儲存,我們首先想到的就是陣列,其中陣列容量很簡單,就是點數和最大值+1。因為它的下標就表示當前點數和,值表示出現了幾次。那如何切換呢?從分析上看,我們至少需要2個陣列,每次計算完都要交換,這裡有種優雅的實現方式,採用二維陣列,一維乃標誌位,取flag=0,利用1-flag達到優雅切換。難點也分析的差不多了,這裡貼程式碼,程式碼是根據書上改編的,我自己實現的並不是這樣,但思想是一樣的。

private static final int MAX_VALUE = 6;
public static void printProbability(int number) {
    if (number < 1) {
        return;
    }
    int[][] pProbabilities = new int[2][MAX_VALUE * number + 1];
    for (int i = 0; i < MAX_VALUE * number + 1; i++) {//初始化陣列
        pProbabilities[0][i] = 0;
        pProbabilities[1][i] = 0;
    }
    int flag = 0;
    for (int i = 1; i <= MAX_VALUE; i++) {//當第一次拋擲骰子時,有6種可能,每種可能出現一次
        pProbabilities[flag][i] = 1;
    }
    //從第二次開始擲骰子
    for (int k = 2; k <= number; k++) {
        for (int i = 0; i < k; i++) {//不可能發生的為0
            pProbabilities[1 - flag][i] = 0;
        }
        for (int i = k; i <= MAX_VALUE * k; i++) {//第k次擲骰子,和最小為k,最大為MAX_VALUE*k
            pProbabilities[1 - flag][i] = 0;//重置
            for (int j = 1; j <= i && j <= MAX_VALUE; j++) {//執行f(k,n)=f(k-1,n-1)+f(k-1,n-2)+f(k-1,n-3)+f(k-1,n-4)+f(k-1,n-5)+f(k-1,n-6)
                pProbabilities[1 - flag][i] += pProbabilities[flag][i - j];
            }
        }
        flag = 1 - flag;//切換陣列,保證列印的為最新陣列,計算的為上一次計算所得陣列
    }
    int total = (int) Math.pow(MAX_VALUE, number);
    for (int i = number; i <= MAX_VALUE * number; i++) {
        System.out.println(String.format(Locale.getDefault(), "s的值為%d,概率為%d/%d=%.4f", i, pProbabilities[flag][i], total, pProbabilities[flag][i] * 1.0f / total));
    }
}
複製程式碼

本題差不多到這就結束了,程式碼中其實有個小細節哈,那就是精度問題,細心的小夥伴肯定看出來了,還在罵我菜呢!我想說,這列印結果,我自己看的,想簡潔一點,大家可以根據需要修改精度。

這樣玩演算法才夠酷

圓圈中最後剩下的數字

題目

0,1,···,n-1這n個數字排成一個圓圈,從數字0開始,每次從這圓圈裡刪除第m個數字。求出這圓圈裡剩下的最後一個數字。

這樣玩演算法才夠酷

沒錯,這就是著名的約瑟夫問題並伴隨著一段浪漫的故事,故事內容我就不說了,以免被認為湊字數。。。

分析

這個問題其實很簡單哈,題目已經說的很詳細了,不停的轉圈,報到m就出局,可能有的同學會去糾結環的事情哈,沒必要哈,只要出局的順序是對的,讓人看上去是環就行了,臥槽,我不能說太多了。。。上上上程式碼:

/**
 * @param totalNum 總人數
 * @param m        報號數字
 */
public static void yueSeFu(int totalNum, int m) {
    // 初始化人數
    List<Integer> start = new ArrayList<Integer>();
    for (int i = 0; i < totalNum; i++) {
        start.add(i);
    }
    //從第k個開始計數,也就是說從1開始報數
    int k = 0;
    int size;
    while ((size = start.size()) > 0) {
        k = k + m;
        //第m人的索引位置
        k = k % size - 1;
        if (k < 0) {// 判斷是否是最後位置
            System.out.println(start.get(size - 1));
            start.remove(size - 1);
            k = 0;//刪除最後一個後重新開始
        } else {
            System.out.println(start.get(k));
            start.remove(k);
        }
    }
}
複製程式碼

如果真是這麼簡單還有必要曬出來嗎?可是上面的程式碼時間複雜度不是O(n)嗎?咋看一下好像還真是,真的是嗎?你還需要多讀書。不說這個,它還使用了長度n的輔助集合。當然,也可以不說這個,有沒有更好的辦法呢?精益求精啊。

我們冷靜下來分析一下,如果原來的序列為0,1,···,k-1,k,k+1,···,n-1,其中k就是第一個出局的人,顯然k=(m-1)%n。我們再來看看這樣的對映:

k+1 -> 0
k+2 -> 1
...
n-1 -> n-k-2
0   -> n-k-1
1   -> n-k
...
k-1 -> n-2
複製程式碼

有沒有數學大佬,求一求這關係式?額,這還需要數學大佬麼?顯然f(x)=(x-k-1)%n啊。心裡驗算了幾個,好像還真是。那逆對映呢?g(x)=(x+k+1)%n。厲害了,我的哥。不服的小夥伴可以用權威的數學驗證。現在我們來假設一個函式f(n)就是針對於序列為0~n-1的最後留下來的數字,那麼很顯然f(n-1)就是針對於序列為0~n-2的最後留下的數字。那麼根據上面我們得出的對映關係式,f(n-1)留下的數字必然等於f(n)留下的數字,但它們確切來說屬於不同函式,一個是針對0~n-1,一個是針對0~n-2,但是根據上面的逆對映我們可以推出原來針對於0~n-1中的數字。

我們再看一眼上面的對映,右邊是不是0~n-2的序列?左邊是不是針對於0~n-1,去掉k之後的序列?那麼f(n)是不是必然存在左邊的序列中?那麼是不是說f(n-1)代入公式g(x)=(x+k+1)%n,最後等於f(n)呢?很好,最後得出這樣的公式f(n)=[f(n-1)+k+1]%n,我們又已知k=(m-1)%n,繼續代入,最後解得f(n)=[f(n-1)+m]%n。其中n-1必須要大於0,即n>1,那麼一個人也能玩這遊戲吧?誰說不可以?大夥說是不是?那麼n=1的時候結果不就是0嗎?公式都有了,程式碼就賊簡單:

/**
 * @param totalNum 總人數
 * @param m        報號數字
 */
public static void yueSeFu(int totalNum, int m) {
    if (totalNum < 1 || m < 1) {
        throw new RuntimeException("這遊戲至少一個人玩!");
    }
    int last = 0;
    for (int i = 2; i <= totalNum; i++) {
        last = (last + m) % i;
    }
    System.out.println("最後出局的是:" + last);
}
複製程式碼

題後騷話

這3道題下來大家覺得怎麼樣?是不是覺得演算法特別有意思?但我感覺絕大多數的小夥伴已經右上角了。堅持到這裡的小夥伴,我很佩服你,很欣賞你,我看你骨骼精奇,是萬中無一的武學奇才,哦,不,是難得一見的邏輯演算法天才,這裡留你兩道題(用java語言)。

  • 求1+2+···+n,要求不能使用乘除法、for、while、if、else、switch、case等關鍵字及條件判斷語句(A?B:C)。
  • 寫一個方法,求兩個整數之和,要求在方法體內不得使用+、-、*、/四則運算子號。

題目很無聊卻很有意思,第一道題,我就想出一種,就是遞迴+異常,第二道表示不看解答真想不出來,智商已經欠費,誒,大家可以考慮與、或、非、異或。當然了,看過《劍指Offer》的小夥伴肯定很熟悉這些題目,包括最上面3道題,因為均出自於這本書,只是很多分析摻雜了我自己的想法,用的是我自己的思路把這個思想說出來,如果哪裡錯了,大佬一定要指出來。

最後,感謝一直支援我的人!

這樣玩演算法才夠酷

傳送門

Github:github.com/crazysunj/

部落格:crazysunj.com/

相關文章