这样玩算法才够酷

前言

想接触算法的朋友经常会问一句,算法难吗?我掐指一算,回答一般有3种结果,难,不难,have a try。其实这个问题并不好,我们接触的较多的一门课程叫数学,从小学到大学,甚至工作了,还不放过我们,而这个你很熟悉的东西,你觉得它难吗?那么结果出来了,更多的是一种兴趣,很多人老是说自己智商不够用,那是你根本不想认真去面对它,这么跟你说吧,天赋差距肯定是有的,但你身边可以说80%的人智商都跟你差不多。那你还有什么理由不把这篇文章看完,挑战一下自己?或许你能找到更优的解?或许你面试的时候正好碰到?岂不美哉?

PS :有兴趣的加入Android工程师交流QQ群:752016839 主要针对Android开发人员提升自己,突破瓶颈,相信你来学习,会有提升和收获。

本篇文章部分题目还是比较烧脑,因此收藏一下在空闲的时候去挑战,或者记住题目,可能潜意识就已经完成了。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道题,因为均出自于这本书,只是很多分析掺杂了我自己的想法,用的是我自己的思路把这个思想说出来,如果哪里错了,大佬一定要指出来。

最后,感谢一直支持我的人!

猜你喜欢

转载自blog.csdn.net/qq_43093708/article/details/82909190
今日推荐