网球循环赛比赛日程表n为奇数问题

4,509 阅读7分钟

初衷

在教材上看到这个问题的时候,对于奇数的处理百思不得其解,然而网上的答案要么就是n=2k的情况,要么就是自己根本都没有理解,给你讲了一大堆,各种情况,很麻烦,甚至有些是错的误人子弟。所以写下这篇思路,分享给各位。其实这个问题的核心就是分治的治该怎么去构造的问题。

问题

设有N个运动员要进行网球循环赛,设计一个满足以下要求的比赛日程表

(1)每个选手必须与其他n-1个选手各赛一次

(2)每个选手一天只能赛一次

(3)当n 是偶数,循环赛进行n-1天,当n是奇数,循环赛进行n天。

算法分析

  1. 我们采用分治法,先算出n/2的情况,然后进行合并,构造出n的情况。难点就在于构造过程,设第i个选手第j天比赛的队员为A[i][j]。
  2. 若m = n/2为偶数,这时候我们知道偶数人数已经算出了前m个队员的前passed_days天(对于偶数而言是m-1,对于奇数是m)的比赛情况,我们怎么构造呢?
    • 先横向构造,也就是构造出后m个队员在前m-1天的比赛情况,那么为了保证不重复我们采用递增的构造方式,让i+m号选手与比A[i][j]大m的选手比赛,也是是说
      • A[i + m][j] = A[i][j] + m; (1≤i≤m,1≤j≤passed_days,i代表队员,j代表当前比赛的天数)
      • 可以看出来一定不会重复,因为前m个队员之前从未与后m个队员比赛过,然后A[i][j]彼此又是互不相同的,所以A[i+m]彼此也一定不相同
    • 再纵向构造,设n个队员比赛所需总天数为days(对于偶数而言是n-1,对于奇数是n),也就是说构造n个队员在后(days - passed_days)天的比赛情况,同样为了保证不重复我们也采用增量构造,
      • passed_days +1≤j≤days,
      • rvalue = (count + i-1)%m + m+1;//保证i队员与后面的队员(rvalue必然大于m)比赛,这样就与前面passed_days天的比赛不重复
      • count为增量初始值为0,j每加1,count++
      • A[i][j] = rvalue;
      • A[rvalue][j] = i; //因为是两两比赛,后面m对用中的与之对应个队员直接构造出来
    • 需要注意的是纵向构造的时候我们先构造A[1][j]也就是说先保证第一个队员在(days - passed_days)比赛的队员肯定与之前(passed_days)是不同的,那么由于i也是递增的所以,A[i][j]彼此之间必定也是互不相同的!
  • 举个栗子:假设n=4.

先计算n/2 = 2,我们知道A[1][1] = 2,A[2][1] = 1(偶数比赛只有一天)

1 2(队员编号)

2 1(第一天)

接下来我们构造,n=4,此时days = 3,passed_days = 1,m=2

横向构造:

1 2 3 4
2 1 4 3

纵向构造A[1][j]:

1 2 3 4
2 1 4 3
3   1 
4     1

接着纵向构造A[2][j]:

1 2 3 4
2 1 4 3
3 4 1 2
4 3 2 1
  1. 若m = n/2为奇数的话,我们需要特殊处理下

    • 由于我们是按照偶数的边界构造的,也就是奇数的时候实际上扩充为了m+1列,那么我们需要把与m+1比赛的队员置0,删掉多余的m+1列
    • 其次由于置0,那么横向构造的时候如果A[i][j]=0,说明i队员在j天没有比赛,我们直接让他与i+m号选手比赛(保证与之前的横向构造的增量一致)
    •       A[i + m][j] = i;
            A[i][j] = i + m;
      
    • 但是上面也引起了问题就是,我们在纵向构造的时候A[1][passed_days]可能会重复(因为在为0的位置可能填入了m+1的元素,所以我们需要标记一下,如果是奇数的话纵向构造的其实增量+1),只要保证了A[1][j]不重复,后面因为都是增量构造肯定不会重复 r_value = (count + (i - 1) + 1) % m + m + 1;

还是举两个例子:

n=3的构造情况

1.先算n=2

1 2
2 1

2.m=2,passed_days=1,days = 3横向构造

1 2 3 4
2 1 4 3

3.纵向构造A[1][j],j=2,3

1 2 3 4
2 1 4 3
3   1
4     1

4.纵向构造A[2][j],j=2,3

1 2 3 4
2 1 4 3
3 4 1 2
4 3 2 1

5.把扩充的置0,同时删掉多余的第4列

1 2 3 
2 1 0 
3 0 1 
0 3 2 

n=6的构造情况

1.首先n/2=3已经算出

2.m=3,passed_days=3,days = 5横向构造A[i][1],即第一天的

1 2 3 4 5 6
2 1 6 5 4 3
3 0 1 
0 3 2 

3.接着横向构造完所有passed_days天的

1 2 3 4 5 6
2 1 6 5 4 3
3 5 1 6 2 4
4 3 2 1 6 5

4.纵向构造A[1][4],A[1[5],由于m是奇数所以,构造增量加了1即A[1][4] = (0 + (1 - 1) + 1) % 3 + 3 + 1 = 5;

1 2 3 4 5 6
2 1 6 5 4 3
3 5 1 6 2 4
4 3 2 1 6 5
5       1
6         1

5.纵向构造完(由于n是偶数,不需要再进行置0操作)

2   1   6   5   4   3   
3   5   1   6   2   4   
4   3   2   1   6   5   
5   6   4   3   1   2   
6   4   5   2   3   1  

C++代码

#include <iostream>
#include <iomanip>
#include <cmath>

using namespace std;
const int MAX_NUM = 100;
int A[MAX_NUM+2][MAX_NUM+2];
/* 合并子问题 */
void merge(int n)
{
    /*
     * n 为偶数时,比赛 n - 1 天
     * n 为奇数时,比赛 n 天
     */
    int days = n&1 ? n  : n-1;
    /*
     * 中间值,若n为奇数,则使 m = (n / 2) + 1,
     * 即,前半部分不小于后半部分
     */
    int m = (int)ceil(n / 2.0);
    int passed_days = m& 1? m : m - 1;    /* 已经安排的天数 */

    /*
     *  通过前 n/2 的比赛安排,构造后n/2的比赛安排
     *  如果 n 为奇数,则会产生一个虚拟选手
     */
    for (int i = 1; i <= m; i++)
    {
        for (int j = 1; j <= passed_days; j++)
        {
            if (A[i][j] != 0)   /* 如果 i 号在第 j 天有对手 */
            {
                /*
                 * 那么,(i + m) 号在第 j 天的对手为 i号的
                 * 对手往后数 m 号
                 */
                A[i + m][j] = A[i][j] + m;
            }
            else   /* 如果 i 号在第 j 天没有对手*/
            {
                /*
                 * 那么就让 i 号和 (i + m)号互为对手
                 */
                A[i + m][j] = i;
                A[i][j] = i + m;
            }
        }
    }

    int add_one = 0;        /*标志子问题是否是奇数,如果是的话构造增量加1 */
    if (A[1][passed_days] == m + 1)
        add_one = 1;
    for (int i = 1; i <= m; i++)
    {
        for (int j = passed_days + 1, count = 0; j <= days; j++, count++)
        {
            /*
             * 构造i 号在第 j 天的对手
             */
            int r_value = (count + (i - 1)  + add_one) % m + m + 1;
            A[i][j] = r_value;
            A[r_value][j] = i;
        }
    }
    if ( n & 1 )            /* 如果 n 为奇数,消除虚拟的选手 */
    {
        for (int i = 1; i <= 2 * m; i++)
        {
            for (int j = 1; j <= days; j++)
                if (A[i][j] == n + 1)
                    A[i][j] = 0;        /* A[i][j] = 0 ,表示 i 号选手在第 j 天没有比赛 */
        }
    }
}

/* 分治求解循环赛问题 */
void tournament(int n)
{
    if (n <= 1)
        return;
    else if (n == 2)        /* 2 个选手 */
    {
        A[1][1] = 2;
        A[2][1] = 1;
    }
    else
    {
        tournament((int)ceil(n / 2.0));
        merge(n);
    }
}

/* 打印循环赛日程表 */
void show_result(int n)
{
    cout << "                 " << n << "人循环赛" << endl;
    int days = n&1 ?n : n-1;
    cout.flags(ios::left);
    cout << setw(8) << "";
    for (int i = 1; i <= n; i++)
        cout << setw(2)<<i << "号";
    cout << endl;
    cout.flags(ios::left);
    for (int j = 1; j <= days; j++)
    {
        cout << "第"<<setw(2)<<j<< "天  ";
        for (int i = 1; i <= n; i++)
        {
            cout << setw(4) << A[i][j];
        }
        cout << endl;
    }
    cout << endl;
}

int main()
{
    int num;
    while(true){
        cout << "请输入参赛人数(小于100):(0结束程序)";
        cin >> num;
        if(num == 0) break;
        tournament(num);
        show_result(num);      
    }
    return 1;
}

算法复杂度

设n=2k,第i次循环需要计算(2k/2i)2,2≤i≤k,总共的计算次数粗略的表示为 12+22 +...+ 22j + ... + 22(k-1) 等比数列求和为(22k-1)/3,粗略等于22k=n^2。 所以算法时间复杂度为O(n^2).

由于只需要一个数组,所以空间代价为:O(n^2)