DAY8 字符串(二)反转字符串Ⅱ+反转字符串里的单词,反转单词的逻辑要特别注意

541.反转字符串Ⅱ

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

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

示例 1:

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

思路

按照2k 2k的规律去遍历字符串,直到剩下的部分不够2k,然后看剩下的部分够不够k个。够k个就反转前k个,不够k个就全部反转

普通思路的for循环:

for (i=0;i<s.size();i++){ //习惯性写i++,但是本题目是以2k个作为单位
    //因此这里可以不写i++
}

但是本题目是以2k个作为单位,因此这里可以不写i++,直接写成i+=2k

for (i=0;i<s.size();i+=2k){
    //直接写i+=2k,会以2k为单位跳跃
}

注意:写i+=2k并不会出现越界问题,因为for循环内每一次执行循环体之前,都会检查 i 是否小于 s.size()。只要在循环体内的代码中不访问超出容器 s 范围的下标,就可以避免越界问题。

写成i+=2k,每次操作的时候操作前k个就可以了。

所以当需要固定规律一段一段去处理字符串的时候,要想想在在for循环的表达式上做做文章。

伪代码

for(i=0;i<s.size();i+=2k){ //这里的简洁核心就是i+=2k
    
    //加判断,如果跳到了最后一组,最后一组的剩余数字不够k,就是越界操作空数组了
    if((i+k)<=s.size()){ 
        //这里要加上等于号,可以自己代入数据尝试
         reverse(s,i,i+k); //针对s这个字符串,从i到i+k段进行反转
        //但是i+k是左闭右开,翻转值不包含i+k这个数值。
        
    }
    //如果不够k个,剩的全反转
   else{
       reverse(s,i,s.size());
   }
    return;
}
reverse(s,i,i+k)是否包含i+k?

编程语言自己实现的库函数一般都是左闭右开原则,因此reverse这个库函数是不包含i+k的。(库函数不包含右边界)

i+=2k的循环过程

先判断i<s.size()是否成立,如果成立则执行循环体,循环体执行结束之后再i+=2k,再立刻判断是否进入循环体。如果条件成立,才会进入循环体,否则不进入。

因此,到了最后一组的时候,此时剩余的数量不够2k(因为i+=2k之后越界了),但是剩余的数组还会经历一遍循环体,这一部分剩余的数组,就需要进行 if((i+k)<=s.size())的判断,防止i+k发生越界,因为不能判断剩余的数组长度与k的关系。

if((i+k)<=s.size())为什么要加等号?

代入例子进行尝试,假设字符串abcdefghz,k=3,2k跳跃到最后剩下了ghz,此时的i=6(下标最后一位是8),k=3,s.size()=9i+k刚好=s.size()。因此,边界处理需要加上等号,否则就略过了这种情况。

主要是因为s.size()是数组的长度,本身就比下标多1,因此i+k在剩余数字为k个的时候,刚好等于长度

完整版(自定义reverse)

class Solution {
public:
  void reverse(string &s,int start,int end){  //自己写的reverse也是左闭右开的,但是要注意传入的参数问题
       
        for(int i=start,j=end-1;i<j;i++,j--){  //这里就直接左右指针逼近
            //注意左闭右开
            swap(s[i],s[j]);
        }
    }
    
    string reverseStr(string s, int k) {
        for(int i=0;i<s.size();i+=(2*k)){
            if((i+k)<=s.size()){
                reverse(s,i,i+k); //自带库函数是左闭右开的,i+k不包含在内,不存在越界问题
                //注意reverse的语法,这里的::reverse是调用了std::reverse、
                //如果只写reverse,就是自己类中定义的函数
            }
            else{
                reverse(s,i,s.size());//左闭右开,直接size()即可
            }
        }
        return s;

    }
};

完整版(调用库函数)

class Solution {
public:
    string reverseStr(string s, int k) {
        for(int i=0;i<s.size();i+=(2*k)){
            if((i+k)<=s.size()){
                std::reverse(s.begin()+i,s.begin()+i+k); 
                //自带库函数是左闭右开的,i+k不包含在内,不存在越界问题
                //但是要注意库函数的写法,是传入迭代器
            }
            else{
                std::reverse(s.begin()+i,s.begin()+s.size());//左闭右开,直接size()即可
            }
        }
        return s;

    }
};
补充1:

在C++中,使用 :: 作用域限定符可以指定使用全局命名空间(也称为全局作用域)中的函数或变量。C++标准库中的函数和类通常位于 std 命名空间中,因此使用 :: 限定符来调用标准库中的函数是一种常见的做法。

当你在代码中看到 :: 前缀时,它通常用于明确指定你希望调用的函数或访问的变量来自于全局命名空间。这在避免命名冲突或明确指定使用特定命名空间中的成员时非常有用。

所以,::reverse 在这里表示调用全局命名空间中的 reverse 函数,而不是来自其他命名空间的同名函数。当 :: 用于函数调用时,它常常被用来明确指定使用的函数来自于标准库的 std 命名空间。

补充2:

如果在这段代码中只写 reverse 而没有写 std::reverse,那么调用的将是 Solution 类中自己定义的 reverse 函数。

补充3:

在这段代码中,s是一个普通字符串,而不是一个迭代器。但是,在std::reverse()函数中,需要传入迭代器来指定要翻转的范围。

 std::reverse(s.begin()+i,s.begin()+s.size());

即使s只是一个普通字符串,不是stl容器,也可以调用begin()这样的迭代器。

在这里,s.begin()返回一个指向字符串s开头的迭代器,s.begin()+i表示从开头迭代器向后移动i个位置,即指向字符串s的第i个字符。同样,s.begin()+i+k表示指向字符串s的第i+k个字符。

std::reverse()函数接受两个迭代器作为参数,表示要翻转的范围,包括第一个迭代器指向的元素,但不包括第二个迭代器指向的元素。所以,在翻转字符串的子串时,需要传入正确的迭代器范围,以确保只翻转指定的子串。

需要注意的是,这里的std::reverse()函数是标准库函数,而不是成员函数,所以需要使用std::来指定命名空间。

问题:为什么普通字符串s能直接调用迭代器begin()?

在C++中,std::string类确实是标准库中的一个部分,用于操作字符串。虽然在概念上,我们可以将字符串视为字符的序列,但在C++中,std::string提供了比简单字符数组更强大、更方便的功能。例如,它可以动态改变大小,还提供了许多方便的成员函数,如find, substr, replace等。

这是因为C++标准库(STL, Standard Template Library)被设计为通用的,可以处理各种数据类型的容器库。虽然std::string专门用于处理字符,但其实现和接口设计使得它可以被看作一个容器类它像std::vector或std::list这样的容器一样,支持迭代器,并有一些类似的方法,如begin()和end()。这使得你可以像处理其他容器类一样来处理std::string。

总的来说,std::string在设计和实现上,都是一个容器类。尽管它通常用于处理字符序列(即字符串),但其功能和灵活性远超过简单的字符数组。因此,我们可以说std::string既是一个字符串,也是一个容器。

151.反转字符串里的单词

给你一个字符串 s ,请你反转字符串中 单词 的顺序。

单词 是由非空格字符组成的字符串。s 中使用至少一个空格将字符串中的 单词 分隔开。

返回 单词 顺序颠倒且 单词 之间用单个空格连接的结果字符串。

注意:输入字符串 s中可能会存在前导空格、尾随空格或者单词间的多个空格。返回的结果字符串中,单词间应当仅用单个空格分隔,且不包含任何额外的空格。

示例 1:

输入:s = "the sky is blue"
输出:"blue is sky the"

思路

删除多余空格,再将字符串整体反转,再将每一个单词(也就是字符串中用空格隔出来的)进行反转。

比较麻烦的一点是删除空格,开头、中间、结束都有空格。移除空格是最重要的

本题目用这种解法,可以达到空间复杂度是O(1),也就是不申请新的字符串,不申请任何辅助空间

补充:空间复杂度

空间复杂度是指一个算法或一个程序在运行时所需要的内存空间的量,它和输入的大小有关。空间复杂度可以用大O符号来表示,比如 O(1)、O(log n)、O(n)、O(n log n)、O(n^2) 等,它们表示了算法的空间消耗随着输入大小的增长而变化的趋势。空间复杂度可以分为输入空间和辅助空间,输入空间是指存储输入数据所需的空间,辅助空间是指算法执行过程中额外使用的空间。

空间复杂度和时间复杂度是两个衡量算法效率的重要指标,它们之间通常存在一定的权衡和平衡。有时候,我们可以通过牺牲一些空间来换取时间上的优化,或者反之。比如,我们可以用一个哈希表来存储一些已经计算过的结果,从而避免重复计算,这样就提高了时间效率,但是也增加了空间消耗。

移除空格

移除空格的思路和数组章节“移除元素”题目比较类似

暴力解法可能会想到for循环+erase,erase可以从字符串中移除元素,但是erase的时间复杂度是O(n)

注:erase操作的解释:因为字符串的 erase 操作需要把字符串中的某个子串移除,这涉及到大量的元素移动操作。**一个包含 n 个字符的字符串,当删除一个中间的字符后,原本在这个字符后面的所有字符都需要向前移动一位来填补这个空位。**这就需要进行 n/2 次操作,因为大约有 n/2 个字符需要移动。因此,最坏情况下,可能需要进行约 n 次操作。而我们用大O符号来描述算法的时间复杂度时,通常关注的是最坏情况的运行时间。

for循环+erase,时间复杂度可能会达到n^2

因此,移除空格可以使用和之前移除元素一样的解法,采用一个快指针和一个慢指针

**采用快慢指针的方法,空间复杂度是O(1),**因为没有开辟新空间

代码拆分

1.移除空格部分
int slow=0;
for(fast=0;fast<s.size();fast++){
    if(s[fast]!=0){ //因为不能保证当while循环结束的时候,fast跳出来,出现的空格只有一个。而不是多个空格。如果是多个空格,就不能一跳出来就判断slow是不是=0,而是需要再次发现单词的时候,再对slow加上空格!
        if(slow!=0){
            s[slow]='';
            slow++;
        }
        while(s[fast]!=''){ //while就是在处理单独一个单词的情况
            s[slow]=s[fast];
            slow++;
            fast++; //slow和fast一起++,同时移动,包括整个单词
    
         }
    }
}
s.resize(slow); //注意:slow始终指向被处理字符的下一个位置!



注意:
  • 在跳出while循环的时候,i已经累加到了一定的数字,这个时候再返回for循环执行i++的话,i就会从累加到的数字开始累积。也就是说,跳出while循环的时候,就是第一个单词已经结束了

  • 这里所谓的快指针,就是快在遍历空格的时候,快指针会直接跳过所有的空格,直到真正需要空格插入的地方,才会让慢指针加上空格并++,在一个单词的内部,快慢指针移动速度是一样的。

  • 一定要注意,并不是跳出while循环之后slow直接++!因为跳出循环,代表着一个单词结束,但是一个单词结束后可能还会有更多的空格,此时不能让慢指针++!必须在快指针找到下一个非0的位置,意味着第二个单词开始,这个时候慢指针才能设置第二个单词之前的空格。

  • 放置单词之间的空格,需要注意slow必须不为0,且slow是下标而不是s[slow]

为什么s.resize(slow)不是s.resize(slow+1)

slow 变量的作用是作为 “慢指针” 来在字符串 s 中移动,每遇到一个非空格字符就会复制该字符并且将 slow 的值加一,所以 slow 始终指向新处理过的字符串的末尾的下一个位置。也就是说,当 slow 停下来时,它位于应该被裁剪掉的部分的开始。

s.resize()的用法

在 C++ 中,数组和字符串的索引都是从 0 开始的。例如,如果我们有一个长度为 n 的字符串,那么它的最后一个字符的索引应该是 n-1,而 n 是超过最后一个字符的位置。在这个函数中,slow 也就是 n 的位置,所以使用 resize(slow) 就会把长度为 n 的字符串的末尾(也就是从索引 slow 开始的部分)裁剪掉。

因为slow始终指向操作元素的下一位,因此,索引 slow 代表的位置实际上是超出了有效字符的位置。**s.resize(slow);**执行后,**字符串s的长度变为slow,最后一个元素的索引是slow - 1,而非slow。**这就是为什么我们将字符串大小设置为slow,而不是slow + 1的原因,因为slow实际上是指向了处理过的字符串末尾的下一个位置,也就是无效字符的开始。

2.反转字符串部分

移除空格并设置好单词间的空格之后,设置函数对左闭右闭的区间进行反转。

void reverseWords(string& s,int start,int end) {
        for(;start<end;start++,end--){
            swap(s[start],s[end]); //左闭右闭写法
        }
    }

完整版

class Solution {
public:
    //先移除空格
    void reverseK(string& s){
        int slow=0;
        for(int fast=0;fast<s.size();fast++){
            //一定要注意,并不是跳出while循环之后slow就直接++!因为跳出循环之后可能还有更多的空格!	要先让fast指针找到不是空格的单词位置,再进行空格的放置	
        if(s[fast]!=' '){
            if(slow!=0){
               //放置单词之间的空格
                s[slow]=' ';
                slow++;
            }
            //一个单词内部
            //这里的fast++操作,因此while要重新判定边界是否越界
            while(fast<s.size()&&s[fast]!=' '){
                s[slow]=s[fast];
                slow++;
                fast++;
            }
          }  
        }
        s.resize(slow);
    }
   
    void reverseS(string& s,int start,int end) {
        for(;start<end;start++,end--){
            swap(s[start],s[end]); //左闭右闭写法
        }
    }
    
    string reverseWords(string s) {
        reverseK(s);//移除空格
        reverseS(s,0,s.size()-1);//先全部反转过来,再单独反转单词
        int start=0;
        //i的大小可以越界?
        for(int i=0;i<=s.size();i++){
            if(i==s.size()||s[i]==' '){  //一定要注意这里的翻转条件,是到了空格才开始翻转!
                //不能漏掉到了数组末尾的情况
                reverseS(s,start,i-1);
                start = i+1; //准备反转下一个
            }
        }
        return s;

    }
};
i<=s.size()的越界问题?

i==s.size()的时候,if (i == s.size() || s[i] == ’ ')这句话,就只执行if (i == s.size()) 这个判断了,因为这是一个逻辑或运算,如果第一个条件为真,那么就不需要再判断第二个条件了。这叫做短路求值。示例如下:

#include <iostream>
using namespace std;

int main()
{
    int i = 0;
    int j = 0;
    if (i == 0 || ++j == 1) // i == 0 is true, so ++j == 1 is not evaluated
    {
        cout << "i = " << i << ", j = " << j << endl; // i = 0, j = 0
    }
    return 0;
}

逻辑或运算,第一个为真的”第一个“,就是单纯的顺序上的第一个。也就是说,如果你写if (s[i] == ’ ’ || i == s.size()),那么就会先判断s[i] == ’ ',如果为真,就不会再判断i == s.size()了。

while(fast<s.size()&&s[fast]!=' ')循环条件判定

这句代码while循环中重新判定fast<s.size()是因为fast一直在向前推进,如果一直没有空格,fast就会一直推进到越界。因为有while循环在内部的情况下,fast可能会在for循环的每一次迭代中增加多次,并不受for大循环条件的影响。

只要是while循环中,指针递增的情况,就需要考虑越界的可能性。

补充:单引号’ '和双引号" "的区别

在 C++ 中,单引号 (’ ') 和双引号 (" ") 用于表示两种不同的数据类型:字符和字符串。

单引号 (’ ') 用于表示单个字符。在 C++ 中,每个字符占据一个字节,即8位。例如,空格字符是 ’ ',字母 a 是 ‘a’,等等。

双引号 (" ") 用于表示字符串,也就是字符的序列。在 C++ 中,字符串是以 null 字符 (‘\0’) 结尾的字符数组。这意味着 " " 实际上包含了两个字符:一个空格字符和一个 null 字符。这就是为什么你不能用 " " 来表示单个空格字符,因为它实际上表示的是包含两个字符的字符串。

所以,在需要表示单个空格字符的情况下,应使用 ’ ’ 而不是 " "。

补充2:空格字符’ ‘和空字符’\0’的区别

’ ’ 和 ‘\0’ 代表的是不同的含义。

’ ’ 代表的是空格字符而 ‘\0’ 代表的是空字符或者说是字符串的结束符。在 C++ 中,字符串是由字符组成的,每个字符后面都有一个 ‘\0’ 作为结束符。这是 C++ 字符串的特点。

在这个代码中,我们用 ’ ’ 来判断是否为空格字符,而不是空字符。如果是空格字符,我们就跳过它,不进行处理;如果不是空格字符,我们就将其复制到新的位置。这个处理过程的目的是去除字符串中的所有多余的空格。

如果我们将 ’ ’ 替换为 ‘\0’,那么代码的含义就完全不同了,它将会去除所有的空字符,而不是空格字符

  1. 空格字符 (’ '):这是一个可见字符,也就是我们在键盘上可以直接输入的空格。**它在 ASCII 表中的十进制值为 32。**在文本处理中,空格字符常常被用于分隔单词或者作为缩进。
  2. 空字符 (‘\0’):这是一个不可见字符,有时也被称为 NULL 字符或者终止符。它在 ASCII 表中的十进制值为 0。在 C++ 或者其他一些语言中,空字符被用于标记字符串的结束。也就是说,当编译器或者程序遇到 ‘\0’ 时,它会认为字符串在这里结束。这是一种字符串存储和处理的约定。

所以,虽然它们在视觉上都看不见,但空格字符和空字符在功能和用途上是完全不同的。

补充3 erase操作的时间复杂度

字符串和数组有很多相似之处,特别是在它们的内部实现上。在许多编程语言中,字符串就是字符数组,只是在字符数组的末尾加上一个表示结束的特殊字符,通常是空字符(‘\0’)。

这是因为,计算机内存实际上并不知道一个数组在哪里结束,所以在C语言(以及一些其他语言)中,字符串是以**‘\0’**来标记结束的。这个特殊的字符使得程序可以知道何时停止读取内存中的连续字符。

这个设计决定了字符串的一些操作(比如插入、删除、连接等)的时间和空间复杂度。比如说,删除操作就需要移动删除点之后的所有字符,这和数组的删除操作是一样的。所以,字符串的删除操作的时间复杂度是 O(n),这跟数组是一样的。

std::string::erase的时间复杂度是线性的,也就是O(n),其中n是要擦除的字符后面的字符数量。这是因为在擦除字符后,字符串必须将其后的所有字符向前移动以填补空位。对于删除字符串最前面或最后面的空格的操作,时间复杂度也是O(n),因为同样需要移动剩余的字符。如果多次进行这样的操作,那么时间复杂度会变为O(n^2)。本题目中因为有for循环的存在,如果使用erase的话,时间复杂度会成为O(n)

(有的同学可能发现用erase来移除空格,在leetcode上性能也还行。主要是以下几点;:

  1. leetcode上的测试集里,字符串的长度不够长,如果足够长,性能差距会非常明显。
  2. leetcode的测程序耗时不是很准确的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值