【职场修炼】只实现功能就够了吗: 关于一个简单排列组合问题的多种思考

递归场景中JDK 1.5 CompletionService、Executor等的使用

本文通过一个简单的编程题,分析了多种不同Level的解题思路,目的在于启发coder新人思考的重要性:始终从用户的角度去换位思考,从“有解”到“有优秀的解”,从而达到高质交付。

转载请联系作者


前段时间看到一道Java笔试题:

有1、2、3、4四个数字,能组成多少个互不相同且无重复数字的三位数?都是多少?

这是一个简单的排列组合题,大家都知道这题的答案是24个;不过题面是要求用代码的方式计算出来,并输出这些数字。

别看只是一道简单的Java题,但我感到这是一个考察答题者思维深度和广度的好题,我有意将它用于公司新人的招聘上。子曾经曰过:同样是九阴白骨爪,由周芷若和黄衫女使出来,那就是一个地下一个天上!

解法有几种呢?我们来按武功修为从浅到深的顺序来看一看:

image

第一层:单刀直入

第一种思路非常地直来直去,一般刚毕业的学生很大一部分会给出这种解:

  1. 先从4个数字中取百位数
  2. 从剩下的3个数字中再取十位数
  3. 剩下的2个数字依次作为个位数,并将最终产生的结果放到结果集里
  4. 循环2~3,直到剩下的3个数字都被作为十位数用过
  5. 循环1~4,直到所有的4个数字都被作为百位数用过
  6. 输出最终结果
// 代码略

有什么问题?

至少是没有考虑扩展性吧?如果不是4取3,而是5取4、6取3。。。呢?所以,我们需要把候选数字的个数(candidateNum)结果位数(numLength)都给参数化掉吧?

想啥呢?接茬练!

image

第二层:隔山打牛

为了实现候选数字个数(candidateNum)结果位数(numLength)的参数化,我们应该能想到的就是:递归;这就犹如隔山打牛的内家功夫:将造数计算一层层递归下去,并完成最终击倒目标的目的~ 基本逻辑思路其实和上一解法一样。

  1. 初始化一个动态的非重候选数字列表
  2. 开始递归造数(详细参见makeNums()递归方法,每一步都有详细的注释)
  3. 输出最终结果

Shut up & Show me the CODE!

public class Main {
    // 候选最小数字
    private static int FROM = 1;
    // 候选最大数字
    private static int TO = 4;
    // 需要生成的数字长度
    private static int LENGTH = 3;

    public static void main(String[] args) {
        // 初始化候选数字
        List<Integer> candidateDigits = initCandidateDigits(FROM, TO);
        if(candidateDigits == null){
            return;
        }
        // 造数
        Date startTime = new Date();
        Set<Integer> result = new TreeSet<Integer>();
        makeNums(result, "", candidateDigits, LENGTH);
        Date endTime = new Date();
        // 输出
        System.out.println("Finish! Cost(ms): " + (endTime.getTime() - startTime.getTime()) + "\n" +
                "There're " + result.size() + " diffrent nums, and they are:");
        for(int num : result){
            System.out.println(num);
        }
    }


    /**
     *  生成指定位长度非重数
     * @param result    结果集
     * @param numHead 组成头几位的数字,首次调用时应为""
     * @param candidateDigits  候选数字
     * @param remainLength 要生成的数字的位数
     * @return
     */
    public static Set<Integer> makeNums(Set<Integer> result, String numHead, List<Integer> candidateDigits, int remainLength){
        // 如果结果集为空,就创建一个
        if(result == null){
            result = new TreeSet<Integer>();
        }
        // 如果已到最后一位数(即个位数),则将剩下的数字依次拼装给“前缀数位”,并存入最终结果
        if(remainLength <= 1){
            for(int digit : candidateDigits){
                result.add(Integer.parseInt(numHead + digit));
            }
        }
        // 否则依次选取下一个位数的数字,并递归
        else{
            for(int digit : candidateDigits){
                // 新的头部数位 = 旧的头部数位 + 下一位选定的数字
                String newNumHead = numHead + digit;
                // 去除已选定的下一位数字
                List<Integer> tmpDigits = new ArrayList<Integer>();
                tmpDigits.addAll(candidateDigits);
                tmpDigits.remove(new Integer(digit));
                int newRemainLength = remainLength - 1;
                // 将剩下的候选数字和剩下的位数进行递归
                makeNums(result, newNumHead, tmpDigits, newRemainLength);
            }
        }
        return result;
    }

    /**
     * 初始化候选数字(这里只考虑1~9为合法数字)
     * @param from 候选最小数字,应小于等于to
     * @param to   候选最大数字,应大于等于from
     * @return
     */
    public static List<Integer> initCandidateDigits(int from, int to){
        List<Integer> result = new ArrayList<Integer>();
        if(from >= to || 1 > from || 9 < from || 1 > to || 9 < to){
            return null;
        }
        for(;from <= to; from++){
            result.add(from);
        }
        return result;
    }
}

通常实现到这一步就可以了,不过,子又曰过:为了成就绝世武功,男人就应该对自己狠一点!


第三层:葵花宝典

天赋异禀而又追(xin)求(li)极(bian)致(tai)的东方不败又想到:如果是大位数怎么办呢?比如是从63个64进制的数字中挑选58个数之类的?(确实够变态)而且,现在只是拼个数,万一在某些情况下,拼好了之后,要求根据这个数进行一个实时的复杂业务计算怎么办?——好的,这时候我们应该想到的是——多线程异步

东方不败练就了这一层武功后,能将万千银针同时急速射出,目标将瞬间毙命!天下武功,唯快不破!用了插上多线程翅膀的递归模式,就连以快打快的独孤九剑也不是敌手!

但是,葵花宝典不是人人都能练的,在练之前有很多艰难险阻需要跨越(淫笑的那个去面壁):

  1. 第一个问题就是:在层层的递归嵌套面前,用什么方式来进行优雅的多线程异步?
  2. 需要有一个机制,能知晓所有这些递归的异步线程(并不是简单的并列异步的线程)什么时候结束?基于业务的需要,可能还要获取递归的中间数据进行业务操作。。。

下面我们来一个个解(zi)决(gong):

递归排列中的多线程逻辑

  1. 初始化一个动态的非重候选数字列表
  2. 初始化一个异步线程池及异步服务
  3. 用异步服务执行排列递归
  4. 在递归方法中,需要用到递归调用的时候,利用第2步中的异步服务执行排列递归
  5. 合适的时机获取最终的排列数结果

但是,如何获取递归结果?什么才是“合适的时机”

这个问题可以用JDK1.5的java.util.concurrent包解决。

利用JDK1.5并发工具包实现异步机制的原理

java.util.concurrent包是1.5新增的并发工具包。具体逻辑是:

  1. 将要异步的逻辑(本例中即排列递归的逻辑)封装在一个实现了Callable接口的类的call()方法中,使其可以被第2步中的Executor托管起来异步执行;
  2. 依赖java.util.concurrent.Executor的实现类初始化一个java.util.concurrent.CompletionService接口的实现类;其中前者负责将托管的业务逻辑按照某种线程机制(如线程池)异步地执行;后者负责将业务逻辑的执行和执行的结果分离开来,并提供一个访问执行结果的能力;
  3. 利用completionService的submit()方法进行执行异步逻辑
  4. 利用completionService.take()阻塞式得获取执行结果;得知是否异步操作已完成;

(该包的其他具体用法请参考Java API)

太抽象?好吧,假想一下这样一个场景

你和你老婆/老公很恩爱,结果没控制住,生了一打baby(业务逻辑)。孩子长大后要上学吧?于是你给他们每个人都办了入学证(实现Callable接口),使得他们能够同时入学(被托管异步执行)。

你带着他们来到学校(CompletionService)找到了校长(Executor),说:我孩子这么多,都要上学,你看咋整?

校长(Executor)说:放心交给我吧!我会安排N个老师(多个线程)、按照我们定制的教学大纲、在我们的多个多功能教室(线程使用的细节、调度等)进行系统化教学的!当然了,以上我所说的一切,你都不需要关心!你要做的就是:孩子想上学的时候,你可以随时送到学校来completionService.submit()),然后孩子上完课了(业务逻辑完成)你来学校门口跟看门老大爷说一声:“我要接孩子放学”completionService.take())就是了!当然,接了孩子回去后你可以问问他们今天都教了啥呀(completionService.take().get())?

如何跟踪递归的异步多线程执行情况

不过这里有一个坑,是校长没告诉你的:学校是不知道孩子几点下课的!如果你到了学校门口跟看门老大爷说:下课了吧?俺想接俺娃回家~(completionService.take()

也许这时看门老大爷会甩给你一句:我也没看到你家娃出来,肯定是还没下课呢(业务逻辑执行中)!等着吧!(阻塞

这时,你也只能干等着。。。

解决这种尴尬的办法有两个:

1 . 根据经验预估一个孩子上课的时长,然后到点了去接(completionService.poll(long timeout, TimeUnit unit));这种方法的优点就是:够简单!缺点则是:不够稳定!: 到了规定的时间点仍没接到你要怎么办?先回去,然后再等这么长时间再来接?这太过浪费家长的时间!还有些家长则更简单粗暴:这熊孩子!等这么久都没下课!这娃俺不要了!(知道为什么现在人贩子这么好就业了吧?)


2 . 相比起来,方法二则显得更为靠谱:因为要送N个娃去上学,那我就在某个地方记住我一共送了多少个娃进学校,然后接的时候我就死等,直到接满了我要接的个数,我就回家!

for (int i = 0; i < n; ++i) {
    // 接娃,注意:如果没接到会在这里阻塞
    Future<Child> oneOfMyChildren = completionService.take();
    // 可能的业务处理:把娃的作业本拿出来替他写作业(可怜天下父母心)。。。
    HomeWork homeWork = oneOfMyChildren.get().getHomeWork();
    doHomeWork(homeWork);
}

3 . 可是问题又来了:因为你是在很多随意的时间点送了N个孩子去上学,甚至有些孩子是你委托保姆送的,所以导致你根本不记得今天一共送了几个?

有人说:那我在每送一个孩子上学(completionService.submit())的时候就累计一下数目(counter++),然后我去接的时候每接一个孩子(completionService.take())就递减一下数目(counter- -),接到counter为0,就说明都接完了,这不就行了?答案是:不行!

因为接、送孩子的时机也许是不固定的(你也许没法控制保姆接送的时机),所以多个孩子的接和送是可能发生交织的,这就导致当你的计数(counter)为0的时候,也有可能只是把之前送进学校的几个孩子接走了,这时候还有几个小孩正在保姆的带领下去学校呢!但这时候你武断地认为小孩都接完了就回去睡觉了(退出程序片段),那这几个娃放学后没人接,他们的家庭作业谁来做?!你还有没有点做家长的责任心?!

这时候,你要么依然采用方法1中的办法设超时时限;但另一个可行的办法是:提前计算好今天一共要送多少小孩上学,并设置好counter,然后管他是谁送的、在什么时候送的;只要在接的时候每接一个就递减counter,直到counter为0。这种办法很适用于递归场景

在本例的递归案例中,按照第二层武功代码中的递归逻辑,递归逻辑的异步执行次数是可以计算出来的。对于一个P(m,n)的递归计算(即从m个非重数字中挑选n个数字组成n位数),它在本例中的递归逻辑执行次数(包含首次调用)是:

Total_Counter of P(m,n)
= p(m,1) + p(m,1)*p(m-1,1)... + p(m,1)*p(m-1,1)...*p(m-n+2),1) + 1           
= m + m*(m-1) + ... + m*(m-1)*...*(m-n+2) + 1【第一次调用】

这是一个简单的排列组合题,不理解为什么是这个计算公式的童鞋去温习一下高中数学吧。。。


Shut up & Show me the CODE!

1. 主函数
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

/**
 * Created by Silence on 2016-11-18.
 */
public class Main {

    // 候选最小数字
    private static int FROM = 1;
    // 候选最大数字
    private static int TO = 5;
    // 需要生成的数字长度
    private static int LENGTH = 3;

    public static void main(String[] args) throws Exception {
        // 初始化候选数字
        List<Integer> candidateDigits = initCandidateDigits(FROM, TO);
        if(candidateDigits == null){
            return;
        }
        // 初始化异步线程池
        Date startTime = new Date();
        Set<Integer> result = Collections.synchronizedSet(new TreeSet<Integer>());
        ExecutorService service = Executors.newCachedThreadPool();
        // 初始化可跟踪排列递归次数的异步服务
        TracedRankExecutorCompletionService<Set<Integer>> completion =
                new TracedRankExecutorCompletionService<Set<Integer>>(service, candidateDigits.size(), LENGTH);
        // 造数
        completion.submit(
                new NumberMaker(result, "", candidateDigits, LENGTH, completion));
        // 确保所有递归活动都已结束
        while (true) {
            // 如果已获取完所有异步递归计算,则退出循环
            if(completion.getFutureNum() == 0){
                break;
            }
            // 如果仍有未使用的异步任务执行结果,则继续循环
            Future<Set<Integer>> future = completion.take();
            if (future != null && future.get() != null &&
                    future.isDone() && !future.isCancelled()) {
                continue;
            }
        }
        // 关闭异步服务
        service.shutdown();
        Date endTime = new Date();
        // 输出
        System.out.println("Finish! Cost(ms): " + (endTime.getTime() - startTime.getTime()) + "\n" +
                "There're " + result.size() + " diffrent nums, and they are:");
//        for(int num : result){
//            System.out.println(num);
//        }
    }

    /**
     * 初始化候选数字(这里只考虑1~9为合法数字)
     * @param from 候选最小数字,应小于等于to
     * @param to   候选最大数字,应大于等于from
     * @return
     */
    public static List<Integer> initCandidateDigits(int from, int to){
        List<Integer> result = new ArrayList<Integer>();
        if(from >= to || 1 > from || 9 < from || 1 > to || 9 < to){
            return null;
        }
        for(;from <= to; from++){
            result.add(from);
        }
        return result;
    }

}

2. 可跟踪排列递归执行次数的CompletionService
import java.util.concurrent.*;

/**
 * 可跟踪排列递归执行次数的CompletionService
 * Created by Silence on 2016-11-22.
 */
public class TracedRankExecutorCompletionService<V> extends ExecutorCompletionService<V> {

    private static Integer futureNum = 1;// 递归次数

    /**
     * Constructor
     * @param executor
     * @param candidateNum  候选非重数字的个数
     * @param pickNum   需要挑选的数字个数
     * @throws Exception
     */
    public TracedRankExecutorCompletionService(Executor executor, int candidateNum, int pickNum) throws Exception {
        super(executor);
        if(candidateNum <= pickNum){
            throw new Exception("The candidate number must greater than the picked number");
        }
        // p(m,n)的递归次数 = p(m,1) + p(m,1)*p(m-1,1)... + p(m,1)*p(m-1,1)...*p(m-n+2),1) + 1
        //                = m + m*(m-1) + ... + m*(m-1)*...*(m-n+2) + 1【第一次调用】
        // 计算递归执行次数
        int factor = 1;
        for(int i = candidateNum; i > candidateNum - pickNum + 1; i--){
            factor *= i;
            futureNum += factor;
        }
    }

    @Override
    public Future<V> take() throws InterruptedException {
        Future<V> future = null;
        try {
            future = super.take();
            // 计数递减
            synchronized (futureNum){
                futureNum--;
            }
            return future;
        } catch (InterruptedException e) {
            throw e;
        }
    }

    public static Integer getFutureNum() {
        return futureNum;
    }
}
3. 实现了Callable接口的递归逻辑
import java.util.*;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletionService;

/**
 * Created by Silence on 2016-11-20.
 */
public class NumberMaker implements Callable<Set<Integer>> {

    private Set<Integer> result;// 结果集
    private String numHead;// 组成头几位的数字,首次调用时应为""
    private List<Integer> candidateDigits;// 候选数字
    private int remainLength;// 要生成的数字的位数
    private CompletionService<Set<Integer>> completion;// 异步服务

    // 初始构造
    public NumberMaker(Set<Integer> result, String numHead, List<Integer> candidateDigits, int remainLength, CompletionService<Set<Integer>> completion) {
        this.result = result;
        this.numHead = numHead;
        this.candidateDigits = candidateDigits;
        this.remainLength = remainLength;
        this.completion = completion;
    }

    /**
     * 生成指定位长度非重数
     */
    @Override
    public Set<Integer> call() throws Exception {
        // 如果结果集为空,就创建一个线程安全的set
        if (result == null) {
            result = Collections.synchronizedSet(new TreeSet<Integer>());
        }
        // 如果已到最后一位数(即个位数),则将剩下的数字依次拼装给“前缀数位”,并存入最终结果
        if (remainLength <= 1) {
            for (int digit : candidateDigits) {
                synchronized (result) {
                    result.add(Integer.parseInt(numHead + digit));
                }
                // 模拟复杂业务计算
                Thread.sleep(300);
            }
        }
        // 否则依次选取下一个位数的数字,并异步递归
        else {
            for (int digit : candidateDigits) {
                // 新的头部数位 = 旧的头部数位 + 下一位选定的数字
                String newNumHead = numHead + digit;
                // 去除已选定的下一位数字
                List<Integer> tmpDigits = new ArrayList<Integer>();
                tmpDigits.addAll(candidateDigits);
                tmpDigits.remove(new Integer(digit));
                int newRemainLength = remainLength - 1;
                // 将剩下的候选数字和剩下的位数进行异步递归
                completion.submit(
                        new NumberMaker(result, newNumHead, tmpDigits, newRemainLength, completion));
            }
        }
        return result;
    }
}

运行结果比对:真的比第二层武功快吗?

注意到:在递归逻辑的call()方法中,我们加入了如下的代码来模拟一个“微小”的业务计算:

    // 模拟复杂业务计算
    Thread.sleep(300);

我们在上文第二层武功方法的同样位置,加入同样的模拟代码,来比较一下:在加入了一个小耗时业务运算的情况下两者的差别有多大?以下是运行比较结果(表中P(M,N)表示从M个非重数字中挑选N个数字组成互不相同的N位数;):

比较 解二耗时(s) 解三耗时(s) 解三是解二耗时的(%)
P(4,3) 3.641 0.619 17%
p(5,3) 6.013 0.969 16%
p(5,4) 18.056 0.630 3.5%
p(8,7) 没敢运行,因为大家
都能计算得出来,
其理论耗时应该在:
8*7*6*5*4*3*0.3 = 6048s
3.797 0.063%

结果一目了然!再试想一下,我们只是插入了一个300ms的业务计算量,而且也只是10进制的10以内数字的排列组合,如果候选数再大店、业务运算时间再长点,解二的耗时就会成指数增长!此时,葵花宝典将具有碾压性的优势!

第四层:还有第四层?

葵花宝典已经是武学的最高境界了吗?当然不是!只要你善于思考,永远会不断有进步!子曰:山外青山楼外楼,还有英雄在前头!

在解三中至少还有很多地方是需要进一步完善的:

  • 如何支持从外部输入待选数字?
  • 如何考虑排重等容错?
  • 如果采用的是按照设置经验过期时间的方式来获取结果,那如何加入重试机制?
  • 结果太庞大的情况下,是否应该将结果输出到日志文件?
  • 为了更好的扩展性,是否应该将int都改为long?
  • ......

这里不再一一举例实现,留待大家思考。。。

最后总结一下:只有不停得从用户角度换位思考,才能使人不断进步!只实现功能,是远远不够的!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,999评论 4 368
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,102评论 1 302
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,709评论 0 250
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,439评论 0 217
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,846评论 3 294
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,881评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,062评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,783评论 0 205
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,517评论 1 248
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,762评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,241评论 1 264
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,568评论 3 260
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,236评论 3 241
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,145评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,941评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,965评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,802评论 2 275

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,589评论 25 707
  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,296评论 18 399
  • 最近做事总是容易分心,只有画起画来心才会平静很多。每天总是很期盼画画的这段时间,这样很好,喜欢才会有动力,才能长...
    綦綦綦阅读 320评论 0 3
  • 情调,是一个人对待生活的态度和做事的方式。它是人们展示自己物质和精神面貌的一个窗口。情调,不是风骚,而是智慧和优雅...
    主持人梓惟阅读 117评论 0 0
  • 人的意义,如果不扯那么多,其实就是活着。既然如此伟大,那么生活就很重要了。 对于正常人,一般而言,生活必须稳定。还...
    李放fun阅读 2,034评论 0 6