图论法求解经典面试题:NxN匹马,N个赛道,求最快前M匹马,至少需要几次比赛?

相信不少朋友都听说过这道经典面试题

NxN匹马,每匹马速度恒定且均不同,有N个赛道,每次比赛一次就可以知道这N个赛道的每匹马,那匹快、那匹慢,请问我要求最快的前M匹马,至少需要进行几次比赛?
(不允许记录每匹马的速度,只能通过多次比较来确认)

具体来说,N,M有以下几种常见的情况

N=4,M=4,即16匹马,4个赛道,求前4名,最少进行几次比赛?
N=5,M=5,即25匹马,5个赛道,求前5名,最少进行几次比赛?
N=8,M=4,即64匹马,8个赛道,求前4名,最少进行几次比赛?
N=9,M=4,即81匹马,9个赛道,求前4名,最少进行几次比赛?

网上关于这个经典面试题的博文不少,基本都是采用一种“矩阵画图法”的方式来推导的,但更有意思的是,对于特定N=8,M=4这个最常见的场景,有的人说是需要10次,有的人说是需要11次,甚至有的人说是只需要9次,那么到底它的答案是多少呢?这样的问题有没有一种通用的解法呢?下面我来写一下我的理解,如有错误,欢迎大家评论纠正

N=8,M=4,即64匹马,8个赛道,求前4名,最少进行几次比赛?

拿这个问题来说,我同样用“矩阵画图法”来推导

1)下面是一个矩阵,起初它们都是白色状态,表示没有进行任何比较,次数为0总共次数为0
在这里插入图片描述
2)每一匹马必须至少参与一次比较,否则我是没有办法知道它是前M名,还是M名外,那么我不防每一组进行一次比较,A1-A8一次,B1-B8一次,…,最后H1-H8一次,次数为8总共次数为8

用黄色来表示:已经参与过比较,但是不确定它是第几名

这里我们不妨假设,每一组中1号马最快,8号马最慢,即A1 < A2 < A3 < … < A8,…,H1 < H2< H3 < … < H8
在这里插入图片描述
注意,我现在已经能确定每一组中每匹马的速度关系了,但不同组之间的速度关系依然无法确定,因此也无法确定谁是第一名

3)接下来,很显然,我将A1,B1,…,H1进行一次比较,是非常正确的一个做法(否则,请举一个更好的例子),这样子就一定可以确定第一名是谁了,次数为1总共次数为9

用红色来表示:已经参与过比较,且确定它是第几名

这里我们不妨再假设,A1 < B1 < C1 < … < H1
在这里插入图片描述
此时我们已经能够确定,每一组中每匹马的速度关系,并且不同组之间最快的马的速度关系,已经可以确定第一名是A1,但还无法确定第二、三、四名是谁

稍等等,仔细观察其实可以发现,有一些马已经可以排除在答案外了,比如A5,因为比它快的马有4匹(A1、A2、A3、A4),它最快也才是第5名,我们要求的是前4名

用蓝色来表示:已经参与过比较,且确定它在答案之外
在这里插入图片描述
那么这样一来,突然一下就排除很多马了!并且确定了A1是最快的马,真正需要比较的只有A2、A3、A4、B1、B2、B3、C1、C2、D1这9匹马

9匹马,8个赛道,求出第二、三、四名,需要进行多少次?有的人说2次,有的人说只需要1次,这就是总和答案是11次,还是10次的主要争论原因

不管怎么说,最多为2次,这个肯定没有问题,很明显,你只需要从9匹马中选出8匹马进行一次比较,然后再把剩下的那一匹马参与比较即可,这就是说2次的解法

那么说1次是什么情况呢?说1次其实是用特例来说的,比如我将D1作为那匹不参与第一次比较的马,其余8匹进行比较,并且得到结果是C1是第8名,且已知C1比D1快,那么还有比较D1吗?显然不需要了

不管剩下的9匹马,你选择那一匹作为不参与第一次比较的马,你总能找到一些特例,使得最后一次比较没有必要,但是!同样的,你也一定能找到一些特例,使得最后一次比较必须执行!(我这里就不举例子了,喜欢钻研的朋友可以自己研究研究)

所以,对于网上争论,到底是10次还是11次,其实本质上是对问题中 “至少需要几次比赛” 的 “至少” 这二字定义的争论。如果你认为至少含义是“最少”,那么是10次,但如果你认为至少含义是“所有情况下最少的最大”,那么就是11次

那么根据我个人的理解,至少含义是“最少”是说不通的,最终答案是11次

好,那么这个问题有没有通解呢?有没有一种解法,可以对任何N、M都可求出一个结果呢?

熟悉图论算法朋友,一定能很快看出上面“矩阵画图法”其实与图论息息相关,图论中是有顶点与边的,每个矩阵元素就是一个顶点,顶点与顶点之间存在一个单向边的关系,from => to 表示 from点的值大于to点的值

依然以下面这幅图来说
在这里插入图片描述
图中存在如下的关系边

  • A1 <= A2 <= A3 <= A4
  • A1 <= B1 <= B2 <= B3
  • A1 <= B1 <= C1 <= C2
  • A1 <= B1 <= C1 <= D1
    在这里插入图片描述

此时我们已经确定了A1是第一名,且可以发现第二名一定在A2与B1中,那么要求剩下的第二、三、四名,不妨就拿D1作为不参与第一次比较的马,其余的8匹马进行比较,来看一下图中的边关系会如何变化

(其实从上面这幅图中也可以看出来,不参与第一次比较的马,还可以选择A4、B3、C2)

我们拿A2、A3、A4、B1、B2、B3、C1、C2进行比较,假设比较结果是:A2 < A3 < A4 < B1 < B2 < B3 < C1 < C2,那么我们就可以画出现在的边关系,如下

  • A1 <= A2 <= A3 <= A4 <= B1 <= B2 <= B3 <= C1 <= C2
  • C1 <= D1
    在这里插入图片描述

此时,我们是可以确认D1不需要参与第二次比较的了,因为它一定在第4名之外

但是,如果我比较的结果是:A2 < C1 < A3 < A4 < B1 < B2 < B3 < C2 呢?那么边关系就要变为如下

  • A1 <= A2 <= C1 <= A3 <= A4 <= B1 <= B2 <= B3 <= C2
  • C1 <= D1
    在这里插入图片描述

此时D1就必须要参与第二次比较了,否则我没法确认到底是D1还是A3是第四名

至此,我们可以推导出通解算法如下

1、将N组赛马进行组内比较,次数为N,总共次数为N
2、将每组的第一名赛马进行比较,次数为1,总共次数为N+1
3、通过1、2建图,得到一个DAG(有向无环图)
4、在图中可以找到第一名的点,即出度为0的顶点(不妨设为A1点)
5、在图中可以找到前K名的点,是唯一到达A1点需要1,2,…,K-1步的点
(唯一很重要,然后到达A1点需要1步是第二名,需要2步是第三名,…)
6、如果K>=M,则寻找结束,否则执行下一步
7、排除掉A1及前K名的点,剩下的点中以到达A1点步数排序,最少最优先
8、排序后,在剩下的点中选择前N名,进行一次比赛,次数+1,并且修改边的指向
9、重新执行5

上面是一个算法思路,实际编码中有一些技巧,比如第5步寻找前K名节点如何寻找?第7步如何对剩下的点进行排序?第8步如何修改边的指向?这都是值得思考的问题,具体可以见我的实现代码,如下

我采用了偏暴力的实现方法,且多次随机生成数据,求所有结果最小值的最大值

package raceproblem;

import java.util.*;

public class RaceProblemSolution {

    /**
     * main方法
     */
    public static void main(String[] args) {
        RaceProblemSolution solution = new RaceProblemSolution();
        solution.randomTest(1, 1, 1);
        solution.randomTest(4, 2, 2);
        solution.randomTest(9, 3, 3);
        solution.randomTest(16, 4, 4);
        solution.randomTest(25, 5, 5);
        solution.randomTest(64, 8, 4);
        solution.randomTest(81, 9, 4);
    }

    /**
     * 检查输入是否合法
     */
    void check(int nHorse, int nRace, int nWin) {
        if (nHorse <= 0 || nRace <= 0 || nWin <= 0)
            throw new RuntimeException("nHorse <= 0 || nRace <= 0 || nWin <= 0");
        if (nHorse != nRace * nRace)
            throw new RuntimeException("nHorse != nRace * nRace");
        if (nHorse < nWin)
            throw new RuntimeException("nHorse < nWin");
    }

    /**
     * 生成足够数量次数 arr随机数组,传入solve方法求赛跑次数
     */
    void randomTest(int nHorse, int nRace, int nWin) {
        check(nHorse, nRace, nWin);
        System.out.printf("nHorse: %d, nRace: %d, nWin: %d, ", nHorse, nRace, nWin);
        int[] arr = new int[nHorse];
        for (int i = 0; i < nHorse; i ++) {
            arr[i] = i;
        }
        Random random = new Random(new Random().nextInt(100));
        int min = Integer.MAX_VALUE;
        int max = Integer.MIN_VALUE;
        for (int i = 0; i < 10000; i ++) {
            for (int j = 0; j < nHorse * 5; j ++) {
                int t0 = random.nextInt(nHorse);
                int t1 = random.nextInt(nHorse);
                int t = arr[t0];
                arr[t0] = arr[t1];
                arr[t1] = t;
            }
            int res = randomTest(arr, nHorse, nRace, nWin);
            min = Integer.min(min, res);
            max = Integer.max(max, res);
        }
        System.out.printf("min: %d, max: %d\n", min, max);
    }

    /**
     * 求赛跑次数的核心方法
     */
    int randomTest(int[] arr, int nHorse, int nRace, int nWin) {
        // 先将 arr数组 转成 nodes数组
        int res = 0;
        Node[] nodes = new Node[nHorse];
        for (int i = 0; i < nHorse; i ++) {
            nodes[i] = new Node(null, arr[i]);
        }
        // 对每 nRace 匹马进行赛跑,分成 nRace 组
        for (int i = 0; i < nHorse; i += nRace) {
            Arrays.sort(nodes, i, i + nRace, Comparator.comparingInt(node -> node.speed));
            for (int j = i + 1; j < i + nRace; j ++) {
                nodes[j].prev = nodes[j-1];
            }
        }
        res += nRace;
        // 将 nRace 组中,每组第一名的马进行赛跑,然后计算每匹马的 rank
        if (nHorse > 1) {
            Node[] nts = new Node[nRace];
            for (int i = 0, t = 0; i < nHorse; i += nRace) {
                nts[t++] = nodes[i];
            }
            Arrays.sort(nts, Comparator.comparingInt(node -> node.speed));
            for (int i = 1; i < nRace; i ++) {
                nts[i].prev = nts[i-1];
            }
            buildRank(nodes);
            Arrays.sort(nodes, Comparator.comparingInt(node -> node.rank));
            res += 1;
        }
        // 循环,直到找到前 nWin 匹马为止
        while (true) {
            // 判断是否已经找到了前 nWin 匹马
            boolean ok = true;
            int t = 0;
            for (int i = 0; i < nWin; i ++) {
                if (nodes[i].rank != i) {
                    ok = false;
                    t = i - 1; // 注意是 i-1,改为 i 会 final check error
                    break;
                }
            }
            if (ok && nWin < nodes.length && nodes[nWin].rank != nWin) {
                ok = false;
                t = nWin - 1; // 注意是 nWin-1,改为 nWin 会 final check error
            }
            if (ok)
                break;
            // 对 nodes[t, t + nRace) 中的马进行一次赛跑
            Arrays.sort(nodes, t, t + nRace, Comparator.comparingInt(node -> node.speed));
            for (int i = t; i < t + nRace; i ++) {
                nodes[i].prev = nodes[i-1];
            }
            // 重新计算每匹马的 rank
            buildRank(nodes);
            Arrays.sort(nodes, Comparator.comparingInt(node -> node.rank));
            res += 1;
        }
        // 最终进行一次数据校验,保证结果正确
        for (int i = 0; i < nWin; i ++) {
            if (nodes[i].speed != i) {
                print(nodes);
                throw new RuntimeException("final check error");
            }
        }
        return res;
    }

    /**
     * 计算每匹马的 rank
     */
    void buildRank(Node[] nodes) {
        for (Node node : nodes) {
            node.succ.clear();
        }
        Node root = null;
        for (Node node : nodes) {
            Node prev = node.prev;
            if (prev != null)
                prev.succ.add(node);
            else
                root = node;
        }
        root.rank = 0;
        Queue<Node> q = new LinkedList<>();
        q.add(root);
        while (!q.isEmpty()) {
            Node x = q.poll();
            for (Node suc : x.succ) {
                suc.rank = x.rank + 1;
                q.add(suc);
            }
        }
    }

    void print(Node[] nodes) {
        for (Node node : nodes) {
            Node prev = node.prev;
            System.out.printf("(%s, %d, %d), ", prev == null ? "n" : String.valueOf(prev.speed), node.speed, node.rank);
        }
        System.out.println();
    }

    /**
     * 每匹马的类,也是图论中的顶点类
     */
    static class Node {
        Node prev; // 前驱节点,最多只有一个
        final List<Node> succ; // 所有后继节点
        final int speed; // 马的速度,固定
        int rank; // 马的排名,会变化
        Node(Node prev, int value) {
            this.prev = prev;
            this.succ = new ArrayList<>();
            this.speed = value;
            this.rank = 0;
        }
    };
}

输出结果

nHorse: 1, nRace: 1, nWin: 1, min: 1, max: 1
nHorse: 4, nRace: 2, nWin: 2, min: 4, max: 4
nHorse: 9, nRace: 3, nWin: 3, min: 5, max: 6
nHorse: 16, nRace: 4, nWin: 4, min: 7, max: 8
nHorse: 25, nRace: 5, nWin: 5, min: 8, max: 9
nHorse: 64, nRace: 8, nWin: 4, min: 10, max: 11
nHorse: 81, nRace: 9, nWin: 4, min: 11, max: 11

从输出结果来看,N=8,M=4的情况,最少是进行10次,最多是进行11次,因此在“至少”的定义是“所有情况下最少的最大”前提下,答案是11次

小结:本文以N=8,M=4的情况阐述了赛马经典面试题中如何通过“矩阵画图法”求解,讨论了问题中“至少”的定义,并且阐述了如何使用图论的方法求通解,验证了通解的结果与理论结果一致

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值