数组分割(编程之美) HyperS面试题 思考与分享

问题描述: 有一个无序, 元素个数为2n的正整数数组. 要求: 如何能把这个数组分割为元素个数为n的两个数组, 并使两个子数组的和最接近.
当时面试的时候, 看完描述想了十秒钟, 只能想到C(2n, n)把所有情况遍历一遍, 想不到特别简单的算法, 就直接放弃了.. 面试官问我应该怎么做呢, 我只能靠直觉说用动态规划吧.
虽然现在早就入职了, 但终于有时间重新思考这个问题, 并用这篇日志记录下来.


这道面试题其实来自 编程之美 2.18 数组分割, 讽刺的是我以前很早买过这本书, 却没有仔细阅读~~


方法一:

Simple greedy algorithm:

Step1: 将这个数组进行排序.
Step2: 按奇偶划分为两个数组: l1: [a1, a3, a5, ..] 和 l2: [a2, a4, a6, ..]
Step3: 遍历这两个数组, 找出一对数进行交换, 使得Sum(l1)Sum(l2)的差值最小.
Step4: 重复Step3操作, 直到找不到可以让差值更小的交换.

缺点: 这样找到的两个子数组并不能保证是最优解.
我自己简单写了一下Python代码, 供更好的理解.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
L = [1, 5, 7, 8, 9, 6, 3, 11, 20, 17]


def method1(l):
    # 先将所有元素排序, 按奇偶划分为两个数组:
    # [a1, a3, a5, ..] 和 [a2, a4, a6, ..]
    l = sorted(l)
    l1, l2 = l[::2], l[1::2]

    print('sum(l1) - sum(l2):', sum(l1) - sum(l2))
    for i in range(5):
        swp = (0, 0)
        sub = sub_tmp = sum(l1) - sum(l2)
        for i, x in enumerate(l1):
            for j, y in enumerate(l2):
                # x与y交换之后, SUM(l1) - SUM(l2):
                #   new_sum1 = sum(l1) - x + y
                #   new_sum2 = sum(l2) - y + x
                #   new_sub = new_sum1 - new_sum2 = sum(l1) - sum(l2) - 2x + 2y
                new_sub = 2 * (x - y) - sub

                if abs(new_sub) <= abs(sub_tmp):
                    swp = (i, j)
                    sub_tmp = new_sub

        if abs(sub) != abs(sub_tmp):
            i, j = swp[0], swp[1]
            l1[i], l2[j] = l2[j], l1[i]
            print('\nswapping l1[{}] and l2[{}]'.format(i, j))
            print('sum(l1) - sum(l2):', sum(l1) - sum(l2))
        else:
            break


if __name__ == '__main__':
    method1(list(L))


方法二:

分割为两个数组, 并使他们的和最接近 == 从2n个数中挑出n个数, 使他们的和最接近与所有整数的之和的一半.
书中对这个方法二的描述也是很令人费解, 我总结了一下:

定义:
sum_set(k, i): 从k个整数中找出i个元素的和 的集合.
令L为输入的数组, 令2N为数组长度:

  1. sum_set(2N, 1): 从2N个元素中取一个元素, 得到的和的集合
  2. sum_set(2N, 2N): 从2N个元素中取2N个元素, 得到的和的集合
  3. sum_set(2N, i): S(2N-1, i) | {x+L[k-1] for x in S(2N-1, i-1)}
    大致意思就是sum_set(2N, i)可以分解为 第k个元素 选或者不选 的情况

举个例子!!!
L = [1, 2, 3, 4]

  1. sum_set(4, 1) = {1, 2, 3, 4}
  2. sum_set(4, 4) = {1+2+3+4} = {10}
  3. sum_set(4, 2)
    = sum_set(3, 2) | {x+4 for x in sum_set(3, 1)}
    = (sum_set(2, 2) | {x+3 for x in sum_set(2, 1)}) | {1+4, 2+4, 3+4}
    = ({1+2} | {1+3, 2+3}}) | {5, 6, 7}
    = {3, 4, 5, 6, 7}

书中是用for循环来写的, 但我感觉这种DP一般用递归会更加清晰, 就写了这个版本(当然可以加cache做优化, 但我主要是为了展示思路就懒得写了, 可以参考我关于动态规划的这篇日志)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def method2(k, i):
    if i == 1:
        return set(L[:k])
    if k == i:
        return {sum(L[:k])}

    # S(k, i) = S(k-1, i) U {A[k] + x | x属于S(k-1, i-1) }
    return method2(k-1, i) | {i+L[k-1] for i in method2(k-1, i-1)}


if __name__ == '__main__':
    L = [1, 5, 7, 8, 9, 6, 3, 11, 20, 17]
    N = int(len(L) / 2)

    print(method2(2*N, N))

因为每一步都拆成了两种情况(第k个元素 被选中|没被选中)进行递归,所以时间复杂度为O(2N).
要是加cache了话(把计算过的结果放在字典里), 时间复杂度是O(max(k)*max(i))=O(N2)?

但我总觉得{i+L[k-1] for i in method2(k-1, i-1)}的时间复杂度是不是没有考虑进去???

原书中的写法, 测试过了, 和递归的结果是一模一样的:

1
2
3
4
5
6
def method2_o(N, Heap, L):
    for k in range(1, 2*N+1):
        i_max = min(k-1, N-1)
        for i in reversed(range(0, i_max+1)):
            for v in Heap[i]:
                Heap[i+1].add(v+L[k-1])


方法三:

方法二的时间复杂度是指数级的, 肯定是不能接受的.
按照书上写了第三种方法, 时间复杂度为O(N2*Sum):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from collections import defaultdict

def method3(isOk):
    # isOk[i][v]: bool(从数组中取i个数, 得到sum为v)
    for k in range(2*N):
        for i in reversed(range(1, min(k+1, N)+1)):
            for v in range(1, int(sum(L)/2+1)):
                sub = v - L[k]
                if sub >= 0 and isOk[i-1][sub]:
                    isOk[i][v] = True


if __name__ == '__main__':
    L = [1, 5, 7, 8, 9, 6, 3, 11, 20, 17]
    N = int(len(L) / 2)

    isOk = {x: defaultdict(bool) for x in range(0, N+1)}
    isOk[0][0] = True
    method3(isOk)

    [print(k, v) for k, v in isOk.items()]


总结(TODO):

  1. 不知道在看这篇博客的你有没有发现一个很尴尬的问题:
    书中的解法二得到的是 取值和的集合, 解法三得到的是是否能找到答案的boolean值.
    那如何利用解法二, 解法三得到具体的子数组(最优解)呢?
  2. 解法二的我用递归的方式写的时间复杂度?
    如果我递归时, 把计算结果保存到字典里, 不去做重复计算, 那么它的时间复杂度又是多少呢?

在知乎提的问题: https://www.zhihu.com/question/60472041
希望有好心人帮我回答, 23333

关于P, NP, NP-complete

References:

  1. <编程之美> 2.18 数组分割
  2. http://www.americanscientist.org/issues/pub/2002/3/the-easiest-hard-problem/2

Comments(需翻墙)

-->