【OD机试题解法笔记】考古学家考古问题
题目描述
有一个考古学家发现一个石碑,但是很可惜,发现时其已经断成多段,原地发现n个断口整齐的石碑碎片。为了破解石碑内容,考古学家希望有程序能帮忙计算复原后的石碑文
字组合数,你能帮忙吗?
输入描述
第一行输入n,n表示石碑碎片的个数。
第二行依次输入石碑碎片上的文字内容s,共有n组。
输出描述
输出石碑文字的组合(按照升序排列),行末无多余空格
用例
输入 | 输出 |
---|---|
3 a b c | abc acb bac bca cab cba |
3 a b a | aab aba baa |
思考
全排列问题。基本套路:定义全局变量 visited 标记索引是否已访问,result 数组存放最终结果, dfs 函数接收 一个参数 current 数组 存放枚举的字符,当 current 长度为 n 时,完成一个排列的枚举,推入结果列表 result 数组中,然后 return。dfs 函数中关键逻辑:遍历字符序列,判断 visited[i] 是否为 true,如果是就 continue,否则设置 visited[i] = true 表示已访问,把当前的字符加入 current 中,然后递归调用 dfs 函数,传入当前的 current ,在 dfs 函数后面回溯,调用 current 数组的 pop 方法把前面加入的元素移除,visited[i] 置为 false,回溯当前 dfs 调用之前的状态,表示本轮循环不选择当前的枚举字符而在下一轮循环选择其它的字符,这样每轮循环的选与不选当前字符的两种可能都包含了。最后得到的 result 按字典序升序排序就是最终的结果。忽略了一点,给的字符串中可能包含重复的字符,这些重复的字符会导致最终的全排列集合中包含许多重复项,需要去重。最好的办法是生成全排列的时候就避免重复,这样需要预先对字符序列排序,相同的字符就会相邻,这样在枚举字符的时候判断上一个字符是否和当前字符相同,如果相同,那么应该使用哪个呢?如果上一个字符和当前字符相同就不使用当前字符肯定不对,那样就少用了一个字符,整体的字符串长度就不对。比如 aab,当枚举第二个 a 时如果不使用 a,那么最终就等于求字符串 ab 的全排列,肯定不对。 以测试用例 aba 为例,如果不考虑重复字符,那么有六个全排列(暂且将第二个重复的 aaa 记为 a1a_1a1):aba1,a1ba,aa1b,a1ab,baa1,ba1aaba_1 ,\hspace{3mm} a_1ba ,\hspace{3mm} aa_1b ,\hspace{3mm} a_1ab ,\hspace{3mm} baa_1 ,\hspace{3mm} ba_1aaba1,a1ba,aa1b,a1ab,baa1,ba1a,目标是对于 aba1aba_1aba1 和 a1baa_1baa1ba 只保留一个。对 aab,如果枚举第二个 a 时发现第一个 a 已经使用了就不使用第二个 a 直接跳过,那么就会漏掉当前 aab 的排列,变成 ab,显然不对。所以上一个 a 使用了,当前的 a 也可以用。那么上一个 a 没使用呢?因为每次枚举字符的时候都有使用和不使用的选择,调用 dfs 函数后回溯了状态表示不使用当前枚举的字符的选择。那么我在枚举 aab 第一个字符 a 的时候不使用这个 a, 那么visited[0] = false, 枚举第二个 a 的时候如果使用 ,那么第二个a (a1a_1a1)作为排列的第一个字符,继续dfs,发现第一个a没使用,这时候如果使用这第一个a,就会得到序列 a1aa_1aa1a,这样第二个 a 先于第一个 a 被使用了,这样容易导致重复,我只希望存在一个 aa1aa_1aa1 而不是aa1aa_1aa1 和 a1aa_1aa1a,这样就强制始终优先使用上一个 a,如果上一个 a 没被使用,就不用下一个 a,严格按顺序来,这样就不会出现 a1aa_1aa1a 这种情况了。所以去重条件是:if (i > 0 && list[i] === list[i-1] && !visited[i-1]) continue
。表示上一个相同的字符没使用,就跳过当前字符的选择。
算法过程
- 排序输入:首先对输入的所有字符串片段进行排序,使得相同的元素相邻,便于后续去重。
- DFS回溯生成排列:使用深度优先搜索(DFS)遍历所有可能的排列组合,并在生成过程中跳过会导致重复的情况。
- 去重优化:在DFS过程中,如果当前元素和前一个元素相同,并且前一个元素未被使用(
!visited[i-1]
),则跳过当前元素,避免重复排列。
2. 算法步骤
- 输入处理:
- 读取
n
,表示字符串片段的数量。 - 读取
n
个字符串片段,存储在数组fragments
中。
- 读取
- 排序:
- 对
fragments
进行字典序排序,使得相同元素相邻。
- 对
- DFS回溯生成排列:
- 维护一个
visited
数组,标记哪些元素已被使用。 - 维护一个
current
数组,存储当前生成的排列。 - 递归尝试所有可能的组合:
- 终止条件:如果
current.length === n
,说明找到一个完整排列,加入结果列表。 - 遍历所有元素:
- 如果当前元素已被使用(
visited[i]
),跳过。 - 去重关键步骤:如果当前元素和前一个相同(
fragments[i] === fragments[i-1]
),并且前一个未被使用(!visited[i-1]
),则跳过当前元素。 - 否则,选择当前元素,继续递归搜索。
- 如果当前元素已被使用(
- 终止条件:如果
- 维护一个
- 输出结果:
- 最终生成的所有排列已经按字典序排列(因为输入先排序了),直接输出即可。
3. 时间复杂度分析
设 n
为字符串片段的数量,k
为不同字符串的数量(k ≤ n
)。
- 排序:
- 时间复杂度:
O(n log n)
(一般排序算法如快速排序)。
- 时间复杂度:
- DFS生成全排列:
- 最坏情况下(所有元素均不同),共有
n!
种排列。 - 每次递归需要
O(n)
时间(遍历所有元素)。 - 总时间复杂度:
O(n! × n)
。
- 最坏情况下(所有元素均不同),共有
- 去重优化:
- 通过剪枝(
!visited[i-1]
条件),避免了生成重复排列,但最坏情况仍然是O(n! × n)
。 - 如果输入中有大量重复元素,实际运行时间会远小于
O(n! × n)
。
- 通过剪枝(
最终时间复杂度:
- 最坏情况(所有元素不同):
O(n! × n)
。 - 平均情况(有重复元素):远小于
O(n! × n)
,取决于重复情况。
4. 空间复杂度
- 递归栈空间:
- DFS 的最大递归深度为
n
,因此空间复杂度为O(n)
。
- DFS 的最大递归深度为
- 存储结果:
- 最多存储
n!
个排列,每个排列长度为n
,因此空间复杂度为O(n! × n)
(输出空间不计入通常的空间复杂度分析,但需要注意内存限制)。
- 最多存储
5. 示例演示
输入:
3
a b a
步骤:
- 排序后
fragments = ['a', 'a', 'b']
。 - DFS 生成排列:
- 选择
a0
→a1
→b
→ 得到aab
。 - 选择
a0
→b
→a1
→ 得到aba
。 - 选择
a1
→a0
→b
→ 跳过(因为a1
和a0
相同,且a0
未被使用)。 - 选择
a1
→b
→a0
→ 跳过(因为a1
和a0
相同,且a0
未被使用) - 选择
b
→a0
→a1
→ 得到baa
。 - 选择
b
→a1
→a0
→ 跳过(a1
和a0
相同,且a0
未被使用)。
- 选择
- 最终输出:
aab aba baa
参考代码
function solution() {const n = parseInt(readline());const list = readline().split(' ');list.sort((a,b)=> a.localeCompare(b));let result = [];const visited = Array(n).fill(false);const dfs = function(current) {if (current.length === n) {result.push(current.join(''));return;}for (let i = 0; i < n; i++) {if (visited[i]) continue;if (i > 0 && list[i] === list[i-1] && !visited[i-1]) continue;current.push(list[i]);visited[i] = true;dfs(current);current.pop();visited[i] = false;}};dfs([], []);result.sort((s1, s2) => s1.localeCompare(s2));result.forEach(s => console.log(s));
}const cases = [`3a b c`, `3a b a`
];let caseIndex = 0;
let lineIndex = 0;const readline = (function () {let lines = [];return function () {if (lineIndex === 0) {lines = cases[caseIndex].trim().split("\n").map((line) => line.trim());}return lines[lineIndex++];};
})();cases.forEach((_, i) => {caseIndex = i;lineIndex = 0;// console.time(`${i} cost time`);solution();// console.timeEnd(`${i} cost time`); console.log('-------');
});