通过解决“构造包含所有给定子串的最短字符串”问题思考算法优化

最近由于工作相对比较忙,需要学习一些新的技术项目,写代码的时间比较少。继续解决百度2017秋招4星的题目,今天要分析的这个题目,是目前我遇到相对其他4星题目算是有一点难度的题目。


今天我们将从一个题目的不同解决方案:超时到快速出解(无法等待结果 -> 10s -> 70ms)的改进过程来讨论算法的优化过程和一些思想。

域名选择


<题目来源:百度2017秋招,http://exercise.acmcoder.com/... >

问题描述

给网站选择一个好的域名是一件令人头痛的事,你希望你的域名在包含给定的一组关键字的同时,最短的长度是多少。

输入与输出:
输入文件的第一行包含一个整数 n,表示关键字的数目。(n<=10)
接下来的n行,每行包含了一个长度小于等于100的字符串,表示一组关键字。

输出一行一个数字,表示最短的长度

样例:
输入
3
ABC
CBA
AB
输出
5

题目描述很简单,你需要构造一个字符串S,使得它给定每个单词串s[i]都是这个S的一个子串,并且要求S的长度尽可能的短。首先,我们需要考虑如何去构造满足条件的字符串,分析样例5是如何得出的:
ABC__
__CBA
AB___
可以看出最短的串是ABCBA,长度为5。观察发现:

性质1.如果单词A和B之间如果存在重叠的部分,那么将AB构造一个新的字符串增加的长度是len(A)+len(B)-len(A∩B),其中A∩B表示A和B重叠的部分。需要注意到,A和B都必须是新构造出的字符串的一个子串,而不仅仅是A和B中的所有字符在构造的串中出现。

显然我们的目标是用这边单词排成一列,使得两两之间的重叠部分尽可能的多,这样最后构造出来字符串才能尽可能的短。但一时间我们似乎没有特别好的构造方法,观察数据规模,n<=10,枚举似乎可行,时间复杂度O(n!),当n=10,n!=3628800,对于C/C++来说,在1000ms内计算完成应该是可以,但也不会太轻松。对于我所使用的python几乎已经接近极限时间3000ms

产生一个全排列结果后,我们还需要时间去计算两两之间可以重叠多少,每次增加一个单词都在原来的基础上去检查它们能够重叠多少,并更保留合并结果,这样会使得保留的串越来越长,后续在计算重叠的时候(还要考虑子串的情况)计算量越来越大。提交测试后,50%的数据无法计算出结果。

于是,考虑对算法进行优化:
考虑到每次在计算单词重叠和合并时的时间太长,先从这里入手,再次回到题目中,每次计算时是否可以只考虑当前两个单词之间的关系,而不考虑之间已经合并的内容。那么假如存在下列情况
a.没有重叠部分
abcdef
cdgvp
hik
直接按顺序合并即可,需要注意到,尽管两个单词都含有“cd”,但他们并不能合并
abcdefcdgvphik

b.有重叠部分
abcdef
cdefgh
xyzab
按顺序逐一合并后得到,后面的单词不能合并到之前的单词前面
abcdefghxyzab

这并不是最优结果,最佳的合并顺序是xyzab->abcdef->cdefgh
但是我们也发现了在合并两个单词的时候,我们只需要考虑两两之间的情况,而不需要考虑之前的,因此尽管当前看起来并不是一个最佳的构造方案,但是当我们枚举了所有的情况以后,实际上是总能找到一个最佳的构造的方案
例如:(数字表明了重叠的部分的长度)
et->abcetfgh->fgh (最佳构造方案)
2+3
abcetfgh->et->fgh (非最佳)
2+0
abcetfgh->fgh->et (非最佳)
3+0

c.存在子串的情况
实际上,我们在最佳合并方案中观察到,如果存在子串,例如下面的单词
f
dfg
adfgk
ceadfgkh
abceadfgkhev
按任意顺序合并后都可以得到abceadfgkhev

性质2.如果A⊆B,即单词A是单词B的一个子串,那么A和B构成的最短的字符串就是B,并且这种性质具有传递性,例如A⊆B、B⊆C、C⊆D,那么最终构成的最短字符串是D。

那么在存在子串的情况下,我们必须先将子串和主串合并,这样才可能是最优的构造。并且实际上在子串与主串合并后,相对于主串并没有带来长度的增加。至此,我们得到两种算法的优化方案。

优化1:以预处理方式计算所有单词之间两两合并后的重叠部分
显然,我们在预处理的时候就可以计算出单词i与单词j(i在前j在后,因为我们还要计算到j在前i后的情况)的重叠字符数,用overlap[i, j]表示

优化2:预处理同时去掉所有的子串,保留主串计算即可
显然对于子串,甚至子串的子串都不会对计算产生影响,我们需要将不必要的计算因素去掉,可能我们只去掉了一个单词,但是对于计的影响却是显著的,例如10!和9!相比,计算量整整少了1个数量级,对效率的提升很大。此外,去掉子串后,也简化优化1的计算(不再识别子串,所有的串那么两头部分重叠,要不没有重叠)

编写代码时,我们先实现优化2的步骤,再来完成优化1,之后,我们依然是全排列后,对每一组排列都利用预处理得到的overlap[i, j]进行重叠部分的计算,并记录重叠部分的总和。最后用所有单词的总长度-重叠部分的总和,那就是问题的答案了。

提交测试后,极限数据n=10仍然不能在规定的时间内出解,我们来看看计算量,假设没有子串存在的情况,我们的全排列有n!,对每个排列还要计算n-1次重叠总和,实际上,我们计算量是n!*(n - 1),这里还忽略了全排列生成本身花费的时间。

优化3:已知的模型并包含高效的算法
其实我们已经发现,我们的目标是希望找到一个排列a(1),a(2),...,a(n),这也可以看做是一个路径,使得∑overlap[a(i), a(i+1)]最小,如果我们把一个单词看做一个顶点(vertex),并且任何两个顶点(单词)都有边,这个边的权就是overlap[,]中相应的值,例如单词w(i),w(j)就是两个顶点,这两个顶点之间还有一条权重为overlap[i, j]的边。显然这个问题已经转换一个TSP(旅行商的问题),这个是一个很经典的问题,解法比较多,我选择一种自己比较熟悉且效率较高的算法:状态压缩动态规划(DP)

由于这篇文章的重点是在优化思想而不是具体算法本身,并且动态规划本身是一门比较大的算法类别,状态压缩动态规划也非常灵活。因此我们简单介绍TSP的状态压缩动态规划的方法。

由于最多n个单词,我们需要用2进制的每一位去描述当前的单词是否已经参与构造最终的字符串:
例如00101表示当前有5个单词,其中第1个和第3个(从低位到高位)已参与经构造,那么我们一共有(1 << 5) - 1种状态,一个都没得选的时候状态是00000,全部都参与构造的时候11111也就是(1 << 5) -1了。
那么动态规划的要素:我们按将第i个单词拿去参与构造字符串作为阶段划分,状态已经有了,将第i个单词拿去参与构造字符串放在以哪个单词后面可以获得最大的overlap作为决策。

设f[i, j]表示在状态i的情况,以单词j作为最后一个单词获得的最大overlap,考虑在单词k加入后的情况:
f[i|(1<<k), k] = max{f[i|(1<<k), k], f[i, j] + overlap[j, k]}
其中1 =< i < (1 << n) - 1, 0 <= k < n, 0 <= j < n

注意到一些位操作:(这里为了方便描述,最低位是从0开始计数)
a.判断x的二进制位中的第i位是否为1
x & (1 << i)

b.将x的二进制位中的第i位置为1
x | (1 << i)

最后,代码里包含两种方法,包括使用全排列的的方法(注释掉的部分),附上时间消耗,其中java的两个代码一样,都是dfs,最下面的cpp采用的状态压缩的DP,倒数第2个采用的是全排列的方法,其实可见cpp在运行效率上确实还是大大优于python
图片描述

*关于全排列算法
尽管几乎所有的高级语言都带有相关的库,但有必要对其中的原理进行理解,并且尝试编写代码。全排列的算法有很多种,其中回溯法编写比较容易,字典序法效率较高,建议掌握。可以参加下面的链接:
http://www.cnblogs.com/noworn...

*TSP问题的其他算法:
a.分支限界
b.贪心
c.含剪枝的深度优先搜索(DFS)
http://blog.csdn.net/q_l_s/ar...

import sys
import itertools


def combine_words(wa, wb):
    max_overlap_len = min(len(wa), len(wb))
    for overlap_len in range(max_overlap_len - 1, -1, -1):
        match_f = True
        for i in range(overlap_len):
            if wa[len(wa) - overlap_len + i] != wb[i]:
                match_f = False
                break

        if match_f:
            return overlap_len


def calc_each_overlap(words_slv, n_slv, overlap):
    for i in range(n_slv):
        for j in range(n_slv):
            if i != j:
                overlap[i][j] = combine_words(words_slv[i], words_slv[j])


def dp(n, overlap):
    opt = [[0 for i in range(n)] for i in range(1 << (n + 1))]

    for i in range(1, 1 << n):
        for j in range(n):
            if i & (1 << j):
                for k in range(n):
                    if (i & (1 << k)) == 0 and opt[i | (1 << k)][k] < opt[i][j] + overlap[j][k]:
                        opt[i | (1 << k)][k] = opt[i][j] + overlap[j][k]

    ans = 0
    for i in range(n):
        ans = max(opt[(1 << n) - 1][i], ans)
    return ans


def main():
    n = map(int, sys.stdin.readline().strip().split())[0]
    words = []
    for i in range(n):
        words.append(map(str, sys.stdin.readline().strip().split())[0])

    sub_f = [False for i in range(n)]
    for i in range(n):
        for j in range(n):
            if i != j and ''.join(words[i]).find(''.join(words[j])) != -1:
                sub_f[j] = True

    words_slv = []
    t_len = 0
    n_slv = 0
    for i in range(n):
        if not sub_f[i]:
            n_slv += 1
            t_len += len(words[i])
            words_slv.append(words[i])

    overlap = [[0 for i in range(n_slv)] for i in range(n_slv)]
    calc_each_overlap(words_slv, n_slv, overlap)

    t_overlap_len = dp(n_slv, overlap)
    print t_len - t_overlap_len

    # per = [i for i in range(n_slv)]
    # shortest_words = sys.maxint
    # for permutation in itertools.permutations(per, n_slv):
    #     total_words_len = 0
    #     total_overlap_len = 0
    #     for i in range(n_slv):
    #         total_words_len += len(words_slv[permutation[i]])
    #         if i + 1 < n_slv:
    #             total_overlap_len += overlap[permutation[i]][permutation[i + 1]]
    #             if total_overlap_len >= shortest_words:
    #                 break
    #
    #     if total_words_len - total_overlap_len < shortest_words:
    #         shortest_words = total_words_len - total_overlap_len
    #
    # print shortest_words


if __name__ == '__main__':
    main()
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值