Swift 数据结构与算法(40) + Leetcode541. 反转字符串 II(字符串)

170 阅读10分钟

掘金 #日新计划更文活动

题目

541. 反转字符串 II

给定一个字符串 s 和一个整数 k,从字符串开头算起,每计数至 2k 个字符,就反转这 2k 字符中的前 k 个字符。

  • 如果剩余字符少于 k 个,则将剩余字符全部反转。
  • 如果剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符,其余字符保持原样。

 

示例 1:

输入: s = "abcdefg", k = 2
输出: "bacdfeg"

示例 2:

输入: s = "abcd", k = 2
输出: "bacd"

 

提示:

  • 1 <= s.length <= 104
  • s 仅由小写英文组成
  • 1 <= k <= 104
class Solution {
    func reverseStr(_ s: String, _ k: Int) -> String {

    }
}

解题思路🙋🏻‍ ♀️

. 题目分析:

要求我们回答什么?

  • 要求我们按照特定规则(每隔2k个字符反转前k个字符)来反转字符串中的字符,并返回反转后的字符串。

函数的返回值是什么?

  • 返回值是经过特定规则反转后的字符串。

这是什么类型的题目?

  • 这是一个字符串处理题目。

在LeetCode里面,这种题目常用什么套路解题?

  • 这类题目通常使用双指针或滑动窗口的方法进行解答,但在这个特定的题目中,由于我们有明确的步长(2k),所以可以直接使用循环和字符串切片来解答。
  1. 双指针技巧: 在这道题中,双指针技巧主要用于反转字符串的一部分。左右指针分别指向要反转的子串的开始和结束,然后交换两个指针所指的字符,并逐渐将两个指针向中心移动,直到两个指针相遇或交叉。
  2. 字符串处理: 这道题需要你对字符串进行一系列的操作,例如将其转换为字符数组、遍历字符、反转子串等。处理字符串的问题通常涉及对字符串的各种操作,以达到所需的输出格式。
  3. 分块处理: 这道题要求在每 2k 个字符中反转前 k 个字符,这种在固定大小的块上进行操作的题目是一种常见的题型。这要求我们能够正确地处理每一个块,并确保在边界情况下也能正确处理(例如,当字符串的长度不是 2k 的整数倍时)。

大家通常会称这类题目为“字符串处理”题或“双指针”题,但更具体的,可以称其为“分块处理”的字符串题。

解释

在 Swift 中,stride(from:to:by:) 是一个用于生成一系列数的函数,其中每个数与前一个数的差是一个固定的步长。

具体来说,stride(from: 0, to: n, by: 2*k) 会从 0 开始,每次增加 2*k,直到达到但不包括 n

例如,如果 ( n = 20 ) 和 ( k = 3 ),则 stride(from: 0, to: 20, by: 6) 会生成以下序列:

[ 0, 6, 12, 18 ]

这在本题中非常有用,因为我们需要每隔 2*k 个字符处理一个块。使用 stride 可以帮助我们轻松地定位每个块的起始位置。

边界思考🤔

1.

// 从 0 开始,每次步进 2k,遍历字符数组
        for i in stride(from: 0, to: n, by: 2*k) {
            // 计算应该反转到哪个位置,确保不超出数组界限
            let end = min(i + k - 1, n - 1)
            // 调用辅助函数,实现部分字符数组的反转
            reverse(&chars, i, end)
        }

在这段代码中,i 是通过 stride(from: 0, to: n, by: 2*k) 生成的序列中的一个值。具体来说,i 是每次需要开始反转的字符数组的起始索引。

考虑以下示例:

假设 ( s ) = "abcdefg",并且 ( k ) = 2。 此时,字符串的长度 ( n ) = 7。

当我们执行 stride(from: 0, to: 7, by: 4)(因为 2 * 2 = 4)时,会生成以下序列:

[ 0, 4 ]

这意味着:

  1. 在第一次迭代中,i 的值是 0。因此,我们会从索引 0 开始,反转长度为 k(也就是 2)的字符,即 "ab" 会被反转成 "ba"。
  2. 在第二次迭代中,i 的值是 4。因此,我们会从索引 4 开始,反转长度为 k(也就是 2)的字符,但由于字符串只剩下 "efg",我们只反转 "ef" 成为 "fe"。

所以,整个字符串变为 "bacdfeg"。

在这段代码中,i 的值指示了应该从哪个索引开始反转,确保每次只反转长度为 k 的字符(或者如果字符串的剩余部分少于 k,则反转所有剩余的字符)。

2

// 计算应该反转到哪个位置,确保不超出数组界限 
let end = min(i + k - 1, n - 1)

这行代码计算了需要反转的字符的结束位置。

让我们仔细分析这行代码:

let end = min(i + k - 1, n - 1)
  1. i 是当前的起始位置。
  2. i + k - 1 计算了从 i 开始长度为 k 的结束位置。这是因为我们希望反转从 i 开始的 k 个字符。例如,如果 i 是 0,我们希望反转 0 到 1 的字符,所以 end 应该是 i + k - 1,即 0 + 2 - 1 = 1。
  3. n - 1 是字符数组的最后一个索引。
  4. min(i + k - 1, n - 1) 确保我们不会超出数组的界限。例如,当 i 是字符数组的最后一个字符时,i + k - 1 可能会超出字符数组的长度,这时我们只需反转到字符数组的末尾即可。

综上所述,end 是我们应该反转到的位置,确保不会超过字符数组的界限。

代码

class Solution {
    func reverseStr(_ s: String, _ k: Int) -> String {
        // 将输入的字符串 s 转换为字符数组,以便进行后续的操作
        var chars = Array(s)
        // 获取字符数组的长度
        let n = chars.count
        
        // 从 0 开始,每次步进 2k,遍历字符数组
        for i in stride(from: 0, to: n, by: 2*k) {
            // 计算应该反转到哪个位置,确保不超出数组界限
            let end = min(i + k - 1, n - 1)
            // 调用辅助函数,实现部分字符数组的反转
            reverse(&chars, i, end)
        }

        // 将处理后的字符数组重新转为字符串并返回
        return String(chars)
    }
    
    // 辅助函数:负责反转字符数组的一段范围
    private func reverse(_ chars: inout [Character], _ start: Int, _ end: Int) {
        var left = start
        var right = end
        // 使用双指针技巧进行字符的交换,从而实现反转
        while left < right {
            (chars[left], chars[right]) = (chars[right], chars[left])
            left += 1
            right -= 1
        }
    }
}

时空复杂度分析

错误与反思

1

reverseStr(chars: &chars, start: i, end: minEnd, k: k)
// 交换两个数值. 左右交换. 
func reverseStr(chars: inout [Character], start:Int, end:Int, k: Int) { var left = start

在 Swift 中,当我们传递基本数据类型和结构体(包括数组、字典和集合)给函数时,它们都是按值传递的。这意味着函数会接收参数的一个拷贝,而不是参数本身。因此,对这个拷贝的任何修改都不会影响到原始的参数。

有时,我们希望函数能够修改它的参数并让这些修改反映到外部的原始数据上。在这种情况下,我们使用 inout 关键字。

以下是 inout 的一些要点:

  1. 修改原始数据inout 允许函数修改其参数,并将这些修改反映到外部的原始数据上。
  2. 调用方式:当传递一个 inout 参数给函数时,需要在参数前加上 & 符号,表示这是一个引用传递,而不是值传递。
  3. 不能传递常量和字面量:只有变量可以被标记为 inout,常量和字面量由于不能被修改,所以不能被传递为 inout 参数。

示例

func modifyValue(value: inout Int) {
    value *= 2
}

var myValue = 5
modifyValue(value: &myValue)
print(myValue)  // 输出:10

在上面的例子中,modifyValue 函数将其参数值翻倍。由于我们使用了 inoutmyValue 在函数调用后被修改了。

总的来说,当希望函数或方法修改其参数并让这些修改反映到外部时,应该使用 inout 关键字。

2

曾经犯过的错误

  1. reverseStr 函数中,试图修改传递给函数的数组参数 chars,但由于 Swift 中的数组是值类型,所以在函数内部对数组的修改并不会影响到外部的数组。

错误代码

func reverseStr(chars: [Character], start:Int, end:Int,k: Int) -> String {
    ...
    chars.swapAt(left, right)
    ...
}

分析错误的原因: 在 Swift 中,数组是值类型。当传递数组到函数时,其实传递的是该数组的一个拷贝。因此,对该拷贝所做的任何修改都不会反映到原始数组上。

如何修改: 要让函数能够修改其参数并使修改反映到外部,需要使用 inout 关键字,并在函数调用时使用 & 符号。

修改后的代码

func reverseStr(chars: inout [Character], start:Int, end:Int, k: Int) {
    ...
    chars.swapAt(left, right)
    ...
}

reverseStr(chars: &chars, start: i, end: minEnd, k: k)

当时是怎么想的: 可能想要简单地在函数内部修改数组,然后继续使用修改后的数组。但由于 Swift 的值语义,这在不使用 inout 的情况下是不可能的。

如何避免

  1. 当需要在函数或方法中修改数组或其他值类型的参数并希望这些修改反映到外部时,请使用 inout 关键字。
  2. 熟悉 Swift 中的值类型和引用类型的区别。基本数据类型、结构体、枚举和数组都是值类型,而类是引用类型。
  3. 在编写函数或方法时,明确的意图:是否希望修改参数?如果是,考虑使用 inout
  4. 经常测试代码以确保它的行为符合预期。

概念

在解决 LeetCode 或其他编程问题时,创建子方法或辅助函数是一种常见的做法。以下是一些常见的情况,其中使用子方法可能是有益的:

  1. 重复代码:如果在解决问题的过程中您发现有重复的代码块,那么将这部分代码提取到一个子方法中通常是有意义的。
  2. 模块化:将代码分解成更小的、独立的部分可以提高代码的可读性和可维护性。每个子方法都应该有一个明确的目标或功能。
  3. 递归:在解决需要递归的问题时,通常会使用子方法。这样,主方法可以处理一般的逻辑和边界条件,而子方法可以负责递归调用。
  4. 多个策略或步骤:如果解决问题需要多个步骤或策略,那么为每个步骤或策略创建一个子方法是有意义的。
  5. 清晰的意图:子方法的名称应该清楚地描述其功能。这样,其他开发者(或您自己在未来)在阅读代码时可以更容易地理解代码的目的。

例如,在排序、查找或遍历数据结构(如树或图)的问题中,经常会使用到子方法。

在本题中,子方法 reverseStr 用于反转字符串的一个子段。由于这是一个明确、独立的操作,因此将其放在一个单独的方法中是有意义的。这样,主方法可以保持简洁,而具体的反转逻辑则由子方法处理。

总之,使用子方法或辅助函数是一种提高代码结构、可读性和可维护性的有效方法。当您在解决问题时感觉到某些代码块可以被模块化或重复使用时,考虑创建一个子方法。

使用场景与应用

核心概念:字符串操作、子段反转、模块化设计

这题的核心概念主要涉及字符串的处理。特别是如何有效地对字符串的子段进行反转,同时确保代码的模块化和可读性。

实际应用场景

  1. 文本编辑器

    • 技术点:文本编辑器经常需要提供文本的反转、查找和替换等功能。能够高效地操作字符串和文本子段是非常重要的。
  2. 数据处理与转换

    • 技术点:在数据处理、清洗或转换中,可能需要对数据的特定部分进行操作,如反转、插入、删除等。
  3. 算法优化

    • 技术点:模块化设计使得代码更易于理解和维护。当面临复杂的算法问题时,将问题分解并使用子方法处理各个部分可以帮助我们更清晰地思考问题。

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

  1. 文本处理应用

    • 技术应用:例如,一个简单的文本编辑器或笔记应用可能需要提供文本反转或其他文本操作功能。这可以通过字符串处理技术来实现。
  2. 动画与视觉效果

    • 技术应用:在某些动画或特效中,可能需要对显示的文本进行动态操作,如逐字反转、淡入淡出等。这需要对字符串和其子段进行高效操作。
  3. 搜索与过滤

    • 技术应用:在应用中,用户可能想要搜索或过滤内容。这需要对字符串进行操作,如查找、匹配、高亮等。