Swift 数据结构与算法(41) + Leetcode剑指 Offer 05. 替换空格(字符串操作与转换)

42 阅读6分钟

Swift 数据结构与算法( ) + Leetcode 掘金 #日新计划更文活动

题目

剑指 Offer 05. 替换空格

请实现一个函数,把字符串 s 中的每个空格替换成"%20"。

示例 1:

输入: s = "We are happy."
输出: "We%20are%20happy."

 

限制:

0 <= s 的长度 <= 10000

解题思路🙋🏻‍ ♀️

从后面开始处理字符串,而不是从前面开始,是因为这样可以在原地修改字符串,避免频繁的字符移动操作,提高效率。以下是详细的解释:

  1. 避免额外的字符移动:想象一下,如果从前面开始处理字符串,并在遇到空格时插入"%20",那么每次替换都会导致后续所有字符的移动。这样,对于每个空格,都会有一次字符数组的整体移动操作,效率低下。

  2. 原地修改:由于我们已经预先计算了替换后的字符串长度并为其预留了空间,我们可以直接在原始数组中进行替换操作,不需要创建新的字符数组。

  3. 简化操作:从后向前操作时,我们可以确保在插入"%20"后,后面的字符已经在正确的位置上,不需要再次移动。

以一个简单的例子来说明:

假设字符串是 "We are",替换后的字符串应该是 "We%20are"。

  • 如果从前向后处理:

    1. 遇到第一个空格,插入"%20",字符串变为 "We%20 are"。
    2. 此时,后面的"are"需要向后移动2个位置来为"%20"腾出空间。
    3. 对于每个空格,都要进行一次这样的操作。
  • 如果从后向前处理:

    1. 我们首先预留好空间,使字符数组足够大,可以容纳替换后的所有字符。
    2. 从两个字符串的末尾开始,直接复制或替换字符,无需移动任何字符。

因此,从后向前处理更为高效,可以实现原地修改,避免频繁的字符移动操作。

2 深入理解 2.0

当我们从后向前处理并预先分配足够的空间时,我们实际上为替换后的字符串预留了位置。也就是说,我们已经知道新字符串的最终大小,而我们的目标是在这个预留的空间内完成字符的替换和移动。

为了明白这一点,我们再次使用示例:"We are",它包含2个空格。替换后的字符串应该是"We%20are%20"。

  1. 根据空格的数量,我们知道新字符串的长度应该是原始长度 + 2 * 空格数,所以预留的长度是9。
  2. 我们使用两个指针:一个指向原始字符串的末尾,另一个指向新字符串的末尾。
  3. 当我们从后向前处理原始字符串并遇到一个非空格字符时,我们只是将其复制到新字符串的当前位置,并将新字符串的指针向前移动一个位置。
  4. 当我们遇到一个空格时,我们在新字符串中插入"%20",并将指针向前移动3个位置。

关键是:由于我们已经预留了足够的空间,所以不需要移动前面的字符。每个字符只被复制替换一次,不会被后续的操作覆盖移动

这种方法的好处是,每个字符(无论是空格还是非空格)都只处理一次,不需要多次移动。从前向后处理时,每次替换空格都会导致后续的字符移动,这在有大量空格的字符串中会导致很多不必要的操作。而从后向前的方法可以避免这种情况,使得操作更为高效。

边界思考🤔

代码

class Solution {
    // 替换字符串中的空格为"%20"
    func replaceSpace(_ s: String) -> String {
          
        // 将字符串转换为字符数组,方便操作
        var chars = Array(s)
        
        // 计算字符串中空格的数量
        let spaceCount = chars.filter { $0 == " " }.count

        // 计算新字符串的长度(每个空格替换为"%20"将增加2个字符的长度)
        let newLength = chars.count + 2 * spaceCount
        
        // 原始索引,从原字符串的最后一个字符开始
        var originaIndex = chars.count - 1

        // 预留新字符串的容量
        chars.reserveCapacity(newLength)
        
        // 在字符数组后面添加足够的空格,以填充新的长度
        chars.append(contentsOf: [Character](repeating: " ", count: newLength - chars.count))
        
        // 新索引,从新字符串的最后一个字符开始
        var newIndex = newLength - 1
        
        // 当原始索引没有遍历完字符串时,继续循环
        while originaIndex >= 0 {
            // 如果当前字符是空格
            if chars[originaIndex] == " " {
                // 替换为"%20"
                chars[newIndex] = "0"
                chars[newIndex - 1] = "2"
                chars[newIndex - 2] = "%"
                newIndex -= 3
            } else {
                // 如果不是空格,直接复制字符
                chars[newIndex] = chars[originaIndex]
                newIndex -= 1
            }
            // 移动到下一个原始索引
            originaIndex -= 1
        }
        
        // 返回新的字符串
        return String(chars)
    }
}

时空复杂度分析

错误与反思

当然可以,让我们来总结一下你之前在这道题目中犯过的错误:

  1. 循环条件问题:

    • 在的原始代码中,originaIndex 从字符串的末尾开始,并且在每次循环中递减。但当字符串中没有空格时,newIndex 会比 originaIndex 递减得更快。这会导致 newIndex 变为负值,从而引发 "Index out of range" 错误。
  2. 原始索引初始化问题:

    • 在的原始代码中,originaIndex 是基于原始字符串长度的。但考虑到后来为字符串添加了空格,这种初始化方式是不正确的。在修正的代码中,我们需要考虑这些额外的空格并相应地调整 originaIndex 的初始值。
  3. 空格处理:

    • 已经正确地处理了空格,将其替换为"%20"。但是,如果处理顺序或索引出现错误,可能会导致不正确的替换或 "Index out of range" 错误。
  4. 字符数组的预处理:

    • 为字符数组预留了足够的容量,并为新的长度添加了相应数量的空格。这是一个很好的策略,因为它允许你在同一个数组中进行操作,而不是创建一个新的数组。但是,你需要确保正确地计算和使用这些额外的空格。

概念

时空复杂度分析:

  1. 时间复杂度:

    • 计算空格的数量需要遍历整个字符串,时间复杂度为 (O(n))。
    • 替换空格为 "%20" 也需要遍历整个字符串,时间复杂度为 (O(n))。
    • 因此,总的时间复杂度为 (O(n))。
  2. 空间复杂度:

    • 我们在原数组上进行了替换操作,没有使用额外的数组或数据结构。尽管我们为数组预留了额外的空间,但这并不计入空间复杂度的计算。
    • 所以,空间复杂度为 (O(1))。

核心概念:

  1. 字符串操作:理解如何在字符串或字符数组上进行基本操作,如替换、添加和删除。
  2. 原地算法:不使用额外的数据结构或数组,而是在原始数据上进行操作。

关键知识点:

  1. 数组的预留容量:通过预先为数组分配足够的空间,我们可以优化算法的性能,避免多次重新分配内存。
  2. 字符数组与字符串的相互转换:在 Swift 中,可以方便地在字符数组和字符串之间进行转换,这为我们提供了更多的灵活性来处理字符串。
  3. 原地操作:在不使用额外空间的情况下,如何有效地操作数据。

通过理解和掌握这些知识点,不仅可以解决这道题目,还可以应对其他涉及字符串和数组操作的问题。

2.2

从后面开始替换是这种解决方法的关键策略。这主要是基于以下几个原因:

  1. 避免多次移动字符:如果从前面开始替换,每次替换一个空格都需要移动其后面的所有字符以腾出额外的空间。这会导致大量的字符移动,增加了算法的复杂性。

  2. 一次性确定新字符串的长度:由于我们知道原始字符串中的空格数量,所以可以预先计算出新字符串的长度。这使得我们可以一次性预留足够的空间,并从字符串的末尾开始替换,确保每次替换都不会影响之前的字符。

  3. 简化操作:从后向前替换时,我们可以直接使用原始索引和新索引来确定替换的位置。这使得算法更简单,更容易实现,并减少了错误的可能性。

通过以下示例可以更直观地理解:

考虑字符串 "a b"(其中 "a" 和 "b" 之间有一个空格)。

  • 如果从前面开始替换,当替换第一个空格时,你需要移动 "b" 来为 "%20" 腾出空间。
  • 如果从后面开始替换,你首先将 "b" 移到最后,然后再替换空格为 "%20",这样只需要一次移动。

因此,从后面开始替换不仅使算法更高效,而且更简单。

使用场景与应用

这题目涉及的核心概念和技术点有:

  1. 数组操作:在 Swift 中,数组是一个非常常用的数据结构。能够熟练地对其进行操作(如添加、删除、访问和修改元素)是非常重要的。
  2. 字符串操作与转换:字符串是一种特殊的字符数组。能够灵活地在字符串和字符数组之间进行转换,并对它们进行操作,是许多编程任务的关键。
  3. 内存优化:通过预先为数组分配足够的空间,可以优化性能,避免多次重新分配内存。

实际场景应用:

  1. 搜索和替换:例如,你可能需要在一个文本编辑器或文本处理工具中实现搜索和替换功能。此题目为你提供了一个基本的框架,即如何找到并替换字符串中的特定内容。
  2. URL编码:在 web 开发中,空格和其他特殊字符需要进行编码才能在 URL 中使用。此题目的技术可以用于此类编码操作。
  3. 数据整理:在处理来自用户输入或外部数据源的数据时,你可能需要替换或清除某些字符或子字符串。

iOS app 开发的实际使用场景:

  1. 文本输入验证:在 iOS app 中,当用户填写表单或提供输入时,你可能需要验证并清理这些输入。例如,确保电子邮件地址的格式正确,或替换不允许的特殊字符。
    • 技术点:字符串操作、正则表达式
  2. 动态UI内容调整:在动态内容的 UI(如标签或文本框)中,可能需要根据内容的大小调整 UI 元素的大小或位置。
    • 技术点:字符数组操作、布局约束
  3. 网络请求:当发送网络请求时,需要确保所有的参数都正确编码,特别是在构建 GET 请求的 URL 时。
    • 技术点:字符串替换、URL编码