這樣玩算法纔夠酷

前言

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

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

算法題

如今就讓咱們一塊兒來玩一玩幾個有趣的算法題吧!github

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

題目

輸入一個整數n,求1~n這n個整數的十進制表示中1出現的次數。例如,輸入12,1~12這些整數中包含1的數字有一、十、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,又是一次數組的遍歷,其容量增加又是指數級別的,不免會浪費內存。誒,這當作一個反面教材,恥辱啊。bash

再想一想,有沒有好一點的方法?好像沒想出來,看一眼答案?剛看書本前面幾行的分析,我就知道怎麼解了,好歹我也被女神誇獎過是數學神童,對數字仍是很敏感的。大致思路是這樣的:核心思想是分析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的剩餘位數次冪。有了公式還很差辦?分分鐘擼出代碼。app

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/

相關文章
相關標籤/搜索