字典序最小的01字符串 | 豆包MarsCode AI刷题

71 阅读11分钟

问题回顾

假设你有一个由字符 01 组成的字符串,你的任务是通过最多进行 k 次交换,尽量将字符串按升序排列,也就是将所有的 0 排在前面,所有的 1 排在后面。每次交换可以选择任意两个字符的位置。

目标:通过最多 k 次交换操作,得到一个最小的字典序字符串。我们要尽可能将 0 排在前面,1 排在后面,或者至少将相同的字符聚集在一起。

思路

为了达到这个目标,我们可以通过一种贪心算法的方式来考虑如何在有限的交换次数下优化字符串的排列。

1. 贪心策略

贪心算法的核心思想是:在每一步操作中,我们做出一个局部最优的选择,并希望这些局部最优的选择能够导致最终的全局最优解。

在这个问题中,贪心策略可以如下实现:

  • 遍历字符串:从左到右遍历字符串,尝试每次将当前字符交换到最优的位置。
  • 交换操作:在遍历过程中,对于每一个字符位置,我们希望将它与后面最小的符合条件的字符交换(通常是 0),以达到字符串的字典序最小。
  • 控制交换次数:每次交换时,减少可用的交换次数 k。如果 k 用尽,则不再进行交换。

具体步骤

  1. 初始化:首先,我们记录字符串的长度 n 和允许交换的次数 k

  2. 遍历字符串

    • 对于每一个字符位置 i,找到从当前位置 i 到字符串末尾的范围内最小的字符(一般是 0)。
    • 如果这个最小字符的索引 j 满足 j > i,并且交换次数 k 还足够,那么我们就交换字符 i 和 j
    • 交换后,我们减少交换次数 k,并继续下一轮操作。
  3. 交换策略

    • 最优选择:每次选择当前字符后最小的符合条件的字符(通常是 0),交换位置,尽量将 0 向前移动。
    • 减少交换次数:每次交换都会消耗一次交换机会 k,所以我们要在交换过程中谨慎选择,避免浪费交换次数。

具体的贪心算法实现

  1. 从字符串的左侧开始遍历,每次选择当前字符后面的最小字符与之交换,直至达到交换次数 k 的上限。
  2. 对于每一个位置 i,我们在其后找到最小的字符位置 j,如果 j > i 且我们还有剩余的交换次数 k,就进行交换。
  3. 交换时,我们需要将这个字符与当前位置的字符交换,交换次数 k 减少 1,继续处理下一个字符。

复杂度分析:

  • 时间复杂度:每次找到一个“0”并尝试向前交换,最多进行 O(n) 次交换,总的复杂度是 O(n * k),其中 n 是字符串的长度,k 是最大交换次数。最坏情况下,交换次数可能达到 k
  • 空间复杂度:由于我们将字符串转换为列表,因此空间复杂度为 O(n)

代码实现

C++

  • 通过 for (int i = 0; i < n; ++i) 遍历每个字符,在每次迭代时,目标是将当前位置 i 的字符交换到最小可能的字典序字符。
  • 查找最小字符:对于每个位置 i,遍历从 i+1 到 n 的字符,寻找最小的字符及其索引 min_index
  • 计算交换所需次数:计算从 min_index 移动到 i 需要多少次交换,具体是 min_index - i
  • 交换操作:如果剩余的交换次数 k 足够,执行交换操作,将最小字符逐步交换到 i 位置。每次交换将 chars[j] 和 chars[j-1] 交换,直到最小字符到达目标位置。
  • 更新交换次数:每次执行交换后,减少 k
#include <iostream>
#include <vector>
#include <string>

using namespace std;

string solution(int n, int k, string s) {
     // 将字符串转换为字符数组,方便操作
    vector<char> chars(s.begin(), s.end());
    
    // 遍历字符数组
    for (int i = 0; i < n; ++i) {
        // 找到当前位置 i 之后的最小字符及其位置
        int min_index = i;
        for (int j = i + 1; j < n; ++j) {
            if (chars[j] < chars[min_index]) {
                min_index = j;
            }
        }
        
        // 计算从 min_index 移动到 i 需要的交换次数
        int swaps_needed = min_index - i;
        
        // 如果剩余的交换次数足够,进行交换
        if (swaps_needed <= k) {
            // 将 min_index 处的字符移动到 i 处
            // 这里需要实现具体的交换操作
            for (int j = min_index; j > i; --j) {
                swap(chars[j], chars[j - 1]);
            }
            // 同时减少剩余的交换次数
            k -= swaps_needed;
        }
    }
    
    // 将字符数组转换回字符串并返回
    return string(chars.begin(), chars.end());
}

Java

  • 在Java中,我们使用 String.toCharArray() 方法将字符串转换为字符数组,并且可以直接修改字符数组。
  • Java中的字符交换类似于C语言,通过使用一个临时变量来交换字符。
  • 最后,使用 new String(chars) 将字符数组重新转换回字符串并返回。
    public static String solution(int n, int k, String s) {
        // 将字符串转换为字符数组
        char[] chars = s.toCharArray();
        
        // 遍历字符数组
        for (int i = 0; i < n; ++i) {
            // 找到当前位置 i 之后的最小字符及其位置
            int minIndex = i;
            for (int j = i + 1; j < n; ++j) {
                if (chars[j] < chars[minIndex]) {
                    minIndex = j;
                }
            }
            
            // 计算从 minIndex 移动到 i 需要的交换次数
            int swapsNeeded = minIndex - i;
            
            // 如果剩余的交换次数足够,进行交换
            if (swapsNeeded <= k) {
                // 将 minIndex 处的字符移动到 i 处
                for (int j = minIndex; j > i; --j) {
                    char temp = chars[j];
                    chars[j] = chars[j - 1];
                    chars[j - 1] = temp;
                }
                // 同时减少剩余的交换次数
                k -= swapsNeeded;
            }
        }
        
        // 将字符数组转换回字符串并返回
        return new String(chars);
    }

总结

其他算法思路

1. 回溯法(Backtracking)

回溯法是一种试探性算法,在这里我们可以通过递归尝试不同的交换操作,并根据条件决定是否继续执行某个交换。通过深度优先搜索(DFS)来探索所有可能的交换路径,回溯法可以帮助我们找到最优的交换顺序,尤其在有限交换次数下找到最佳字典序。

思路:

  • 从字符串的第一个字符开始,通过交换当前字符与后面的字符(满足交换次数限制)。
  • 对于每个交换后的新字符串,递归进行后续操作,直到交换次数耗尽或达到最优字典序。
  • 如果当前的字典序比之前的字典序好,则保存当前结果。

这种方法适用于较小规模的问题,因为回溯法在搜索空间较大时的时间复杂度可能较高。

时间复杂度:

  • 最坏情况时间复杂度是 O(n^2 * k), 其中每次交换可能需要遍历字符的所有对(n^2),并且我们递归调用最多 k 次交换操作。

优点:

  • 适用于需要进行深度搜索的情况,能够保证找到最优解。

缺点:

  • 对于较大数据量的输入,回溯法的时间复杂度很高,容易超时。

2. 堆排序(Heap Sort)结合贪心

在某些情况下,我们可以使用堆来优先选择最小的字符进行交换。结合贪心思想和堆排序,利用堆的特性,我们可以有效地进行交换决策。

思路:

  • 将字符串按字符转化成一个堆结构,通过堆来始终选择最小的字符来进行交换。
  • 每次交换时,堆结构会动态调整,保证始终可以得到当前可交换位置的最小字符。

这种方法类似于选择排序,但是堆排序能够在 O(log n) 时间内找到最小值,而选择排序是 O(n)

时间复杂度:

  • 构建堆的时间复杂度是 O(n)
  • 每次取出最小元素的时间复杂度是 O(log n),执行 k 次交换,总时间复杂度为 O(k * log n)

优点:

  • 如果交换次数 k 很小,这种方法能够提供较好的性能。

缺点:

  • 对于较大的 k 值,堆的操作可能无法显著减少时间复杂度。

3. 动态规划(Dynamic Programming)

动态规划是一种通过分阶段处理问题的方法,适用于求解最优解的问题。我们可以使用动态规划来记录当前状态下交换后的最小字典序,从而避免重复计算和不必要的计算。

思路:

  • 定义一个状态 dp[i][j] 表示从第 i 个字符到第 n-1 个字符之间,使用 j 次交换所能得到的最小字典序。
  • 通过递推的方式,逐步计算出最优解。

时间复杂度:

  • 最坏情况时间复杂度是 O(n^2 * k),对于每次交换,我们都需要遍历字符,且交换次数为 k

优点:

  • 动态规划能够通过分阶段计算避免重复计算,保证找到最优解。

缺点:

  • 需要使用较多的空间,且时间复杂度较高,适用于较小规模的输入。

4. 字符串的贪心策略结合排序

通过对每个字符的出现位置进行排序,可以将问题转化为寻找每次交换后的最优位置。这种方法结合了字符串的特性,并利用排序来提前安排交换的优先级。

思路:

  • 遍历字符串,找到最优的字符进行交换。
  • 交换后,使用贪心策略尽可能向最优字典序推进。

这种方法通常与贪心算法结合,避免了过多的嵌套循环。

优化方向

优化方向一:减小查找最小字符的时间复杂度

目前的解法中,我们每次都遍历剩余部分寻找最小字符的位置,这是一个 O(n) 的操作。如果能够使用更高效的数据结构来管理字符的位置,那么时间复杂度有可能降低。

一种可能的优化是 使用堆线性扫描 + 位置缓存 来加速最小字符的查找。具体方法如下:

  1. 使用最小堆

我们可以使用一个最小堆(优先队列)来管理剩余字符的位置,这样可以在 O(log n) 时间内找到最小的字符。这种方法通常用于查找最小元素或最大元素的应用,尤其在需要多次更新和查询的情况下非常有效。

然而,堆的复杂度会引入额外的维护和更新开销,具体来说:

  • 插入、删除元素的时间复杂度是 O(log n),但每次交换时需要调整堆结构。
  • 因此,使用堆来查找最小字符并不一定能在这种问题中提供显著的优化,特别是在 n 较小的情况下。
  1. 线性扫描 + 位置缓存

如果我们能够维护一个动态更新的最小值索引,即每次扫描时根据之前的信息更新最小字符的位置,而不是每次都遍历字符串,那么可以将查找最小字符的时间复杂度减少到 O(1) 。具体方法是:

  • 在遍历过程中,保持一个记录位置的数组,记录每个字符后续的最小值的位置。这样可以在常数时间内得到当前字符后最小字符的位置。

这种方法的实现较为复杂,但它能够将问题的时间复杂度降低到 O(n) ,从而使整个算法的时间复杂度从 O(n²) 降到 O(n)

优化方向二:回溯法/DFS

如果允许更多的交换次数或对解法没有严格要求,另一种可能的方法是使用回溯法(深度优先搜索 DFS)来搜索所有可能的交换。通过尝试所有交换组合,可以得到最优解。然而,这种方法的时间复杂度会非常高,因为其可能需要尝试 n!(阶乘)种不同的排列,对于较大的 n 来说非常不现实。

小结

通过使用 预处理贪心策略,我们能够显著提高字符串排序的效率,尤其是在大规模数据场景下。原始的 O(n²) 时间复杂度被优化到了 O(n),使得该算法能够处理更大规模的输入。同时,考虑到交换次数的限制,我们在每一步操作时都尽量做出最优的选择,以确保在给定次数的交换内尽量将字符串排序成最小字典序。