引言


  最近春招,同学都在各种面试和各种刷题,面试完之后常常互相分享在面试过程中遇到的题目,在分享过程中,我发现有些题目之间有雷同之处,所以总结一下

先上两道题


360的2018年面试真题:

  小明手中有一张游乐园的游玩券,凭该券可以在游乐园中游玩时长度为T的时间,游乐园中有一些项目,它们占用的时长分别是t1,t2,t3,t4等等(每个项目最多只能玩一次),如果T时间到了,但是有一个项目的游玩还没有结束,那么小明可以继续把这个项目玩完,请问小明在游乐园中最多可以游玩多长时间?

为了更清晰地说明题意,举个例子:   假如T是100,游乐园中项目的时长分别是10,20,33,40,50。那么为了在游乐园中可以游玩的最长时间是20 + 33 + 40 + 50=143分钟(在100分钟到的时候,因为那个50分钟的项目还没有结束,所以可以把游玩时间一直拖延到最后的那个50分钟的项目结束)。

网易2015年笔试真题:

  任意2n个数,从中选出n个数,使得它们的和与剩下的数的和的差最小。

题目分析


  两道题目看起来没什么关系,但是进过分析之后可以看出他们都衍生自同一个基本问题,分析如下:

第一题

  为了能够游玩的时间尽可能地长,时间最长的那个项目肯定要放在最后进行游玩,这样才能尽可能地拖延游玩时间,然后我们要从剩下的项目中选取n个项目,使得这n个项目时间的和最接近T(但是必须要小于T),然后再加上时长最长的那个项目就可以得到最长游玩时间了。   举个例子:T为100,游乐园中项目的游玩时间分别为10,20,33,40,50,我们首先将50放在一旁,然后发现剩下的项目中(20 + 33 + 40 = 93)的和是最接近T(=100)的,然后将其加上时间最长的那个项目(93 + 50 = 143)就是最长的游玩时间了。   现在问题就转换成如何求出最接近T的那个n项和,假如我们我们有一个方法,可以快速查询出是否存在n项的和等于T,T-1,T-2……,这样我们就可以从T开始向前(从T到0)查询,查找到为true的第一个数就是那个最接近T的n项和,所以问题可以进一步转换为数组的n项和为M的存在性问题。下面我们将会看到第二道题最后也可以转换成这个问题。

第二题

  为了能够在2n个数中找出n个数,使得这n个数的和与剩下的n个数的和尽可能地接近,那么选出的这n个数的和就要尽可能地接近2n个数的和的一半,这个问题就和第一题差不多了,根据第一题的分析,这个问题进一步可以转换成数组的n项和为M的存在性问题。

基本问题:数组的n项和为M的存在性问题–动态规划


  从上面分析可以看出,只要解决了这个问题就可以通杀一开始给出的两道题了。   如果使用暴力破解的话,n项的组合有n!种,那么时间复杂度是指数级,显然难以接受。使用动态规划的方法可以在伪多项式的时间复杂度内解决该问题。   为了使用动态规划方法,我们定义一个布尔类型的数组flag[n][m],规定如果数组中存在n项的和为m,则flag[n][m]=true,反之flag[n][m]=false,则存在如下的递推关系(设nums代表数组):

flag[n][m]=flag[n-1][m-nums[i]]

  上面的式子其实并不完全正确(只是为了表述得更加简洁),完整的算法是:只要在nums数组中存在一个数字num使得flag[n-1][m-num]为true,则flag[n][m]为true,否则为false。   

计算flag数组

  首先初始化flag数组的第一行(下标为0),它代表0项和的存在性,显然0项的和只能是0,所以第一行中只有flag[0][0]是true,其他都是false,然后使用上面给出的递推公式一行行算出flag数组。   为了循序渐进地介绍flag数组的计算方法,这里先假设数组中的元素可以被重复使用,举个例子:数组为{1,5,6},那么该数组存在3项的和为3(即1+1+1,1被重复使用了三次)。允许重复使用数组中数字的计算nums数组的flag的Java代码如下:(注:代码使用了Java中布尔数组初始化时默认值为false的特性)

boolean[][] flag = new boolean[nums.length][T];
flag[0][0] = true;

//允许重复使用时的计算方法,假设待计算的数组为nums
for ( int i = 1; i < nums.length; i++ ){
    for ( int j = 0; j < T; j++ ){
        for ( int h = 0; h < nums.length - 1; h++ ){
            if ( j >= nums[h] && flag[i - 1][j - nums[h]] ){
                flag[i][j] = true;
                break;
            }
        }
    }
}

  稍微解释一下代码,flag[0][0]=true这一句代码初始化了flag数组的第一行,从之前给出的flag数组递推公式可以看出,每一行的flag数组的计算只依赖于上一行的结果,所以我们从第二行开始一行一行进行计算,最外层的循环变量i代表flag数组的行,第二层循环j代表flag数组的列,第三层循环则是遍历nums数组,只要在nums数组中存在一个num使得flag[i - 1][j - nums[h]]为true,则flag[i][j]为true,注意我在判断flag[i - 1][j - nums[h]]之前还加了一个j >= nums[h]的判断条件,这是为了避免后面计算flag的时候下标为负导致程序出错。   上面的代码假设了nums中的项可以重复使用,因为它将nums数组遍历放在了循环的第三层,计算每一个flag单元时都会尝试nums中的每一个数,不管它在计算上一行的时候有没有使用过。但是很多问题都要求数组中的项不能够重复使用,比如一开始给出的那一道360面试题中每个项目就只能够玩一次。为了让数组中每个项只使用一次,就需要将代码改一改,将nums数组遍历放在最外层循环中。不允许重复使用数组中数字的计算nums数组的flag的Java代码如下:

boolean[][] flag = new boolean[nums.length][T];
flag[0][0] = true;

//不允许重复使用时的计算方法
for ( int h = 0; h < nums.length - 1; h++ ){
    for ( int i = h + 1; i > 0; i-- ){
        for ( int j = 0; j < T; j++ ){
            if ( j >= nums[h] && flag[i - 1][j - nums[h]] ){
                flag[i][j] = true;
            }
        }
    }
}

  为了方便理解,这个代码的i,j,h的含义和之前的代码是一样的,分别代表flag的行号,flag的列号以及nums数组下标。i从h +1开始遍历的原因时,h=0时只有一个数字,所以只能求出1项和(i=1),当h=1时有两个数字,可以求出1项和与2项和(i=1,2),以此类推。

使用基本问题来解决开头给出的问题


问题一

  思路(续之前的分析):在这个问题中,我只关心是否存在n项和为T,T-1,T-2……,而并不关心n具体等于多少,所以我们在计算flag数组时顺带再计算一个一维数组colFlag[0…T],通过colFlag数组的下标就可以快速查询出是否存在n项和为T,T-1,T-2……,Java代码如下(基本上没怎么用到Java的特性,所以不熟悉Java的童鞋也不要担心看不懂):

static private void swap(int[] array, int k, int t){
    int temp = array[k];
    array[k] = array[t];
    array[t] = temp;
}

public static void main(String[] args) {
    int T = 100;
    int[] nums = {10,20,33,40,50};
    int max = 0;
    int maxindex = 0;
    for ( int i = 0; i < nums.length; i++ ){
        if ( nums[i] > max ){
            max = nums[i];
            maxindex = i;
        }
    }
    //将时间最长的项目放到最后
    swap(nums, maxindex, nums.length - 1);

    //n个数的和为m是否存在标志数组
    boolean[][] flag = new boolean[nums.length][T];
    flag[0][0] = true;

    //计算flag数组
    boolean[] colFlag = new boolean[T];
    for ( int h = 0; h < nums.length - 1; h++ ){
        for ( int i = h + 1; i > 0; i-- ){
            for ( int j = 0; j < T; j++ ){
                if ( j >= nums[h] && flag[i - 1][j - nums[h]] ){
                    flag[i][j] = true;
                    colFlag[j] = true;
                }
             }
        }
    }

    for ( int i = T - 1; i >= 0; i-- ){
        if ( colFlag[i] ){
            System.out.println("小明的最长游玩时间是:" + (i + nums[nums.length - 1]));
            break;
        }
    }
}

问题二

  思路(续之前的分析):相比基本问题唯一的区别就是它的flag数组的行数不必和nums数组规模(2n)一样大,只要一半(n)就可以了,所以在求flag数的第二层循环i的初始值是((h + 1) > n ? n : h),代码如下:

public static void main(String[] args) {
    int nums[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    //计算数组和的一半
    int sum = 0;
    for (int i = 0; i < nums.length; i++) {
        sum += nums[i];
    }
    int n = nums.length / 2;
        
    //计算flag数组
    boolean flag[][] = new boolean[n + 1][sum / 2 + 1];
    flag[0][0] = true;
    for (int h = 0; h < 2 * n; h++) {
        for (int i = (h + 1) > n ? n : h; i > 0; i--) {//只需要求到n项即可
            for (int j = 0; j <= sum / 2; j++) {
                if (j >= nums[h] && flag[i - 1][j - nums[h]]) {
                    flag[i][j] = true;
                }
            }
        }
    }
        
    for (int i = sum / 2; i > 0; i--) {
        if (flag[n][i]) {
            System.out.println("最小差值为:" + (sum - 2 * i));
            break;
        }
    }
}

END


  在面试前背一背flag数组的计算代码,应该就能轻松攻破这一类问题。