和字符串有关的经典OJ题——字符串的逆置和字符串的翻转
学习完字符串有关的函数之后,那当然在这个章节有两道经典的子题也要给大家分享一下。
分别是字符串的逆置和字符串的翻转。
一、字符串的逆置:
1. 问题描述:
问题很容易理解:对于用户任意给定的字符串,就比如说是原来的字符数组是abcdef,逆置之后应该变成fedcba。
2. 解题分析与源代码:
解决这一类问题有两大类思路:一类是通过非递归的方式解决这里的问题,一类是通过递归的方式来解决这里的问题。
这里的递归是指函数的递归(一个函数反复调用自身,可以通过少量的代码来实现重复大量的运算),递归的版本相对来说难理解一些,但是有能力的小伙伴最好把这两种都了解一下。因为在各种计算机相关校招的笔试当中曾出现过让你写递归版本的逆置。
非递归的版本
思路讲解:
非递归的逆置非常简单,用两个指针:一个left,一个right;left指向字符头部第一个有效字符,right指向字符尾部最后一个有效字符。
然后两个交换。之后left++往后走,right--往前走。然后再交换。
什么时候结束呢:只要left >= right就结束(如果字符串的字符个数是偶数,最后left > right。反之是奇数,最后left == right)。
源代码:
#include<stdio.h>
#include<string.h>
char* reverse(char* str)
{
int left = 0;
int right = strlen(str) - 1;
while (left < right)
{
char tmp = str[left];
str[left] = str[right];
str[right] = tmp;
left++;
right--;
}
return str;
}
int main()
{
char str[20] = "abcdef";
reverse(str);
printf("%s", str);
return 0;
}
递归的版本
思路讲解:
如何使用递归来实现我们字符串的逆置呢?对于函数的递归来说,有两个条件是缺一不可的:
- 函数递归必须存在限制条件,当满足这个限制条件时,函数递归便不再继续;
- 每次递归结束之后,应当比之前更加接近这个限制条件
由此得出用递归解决问题的一般过程就是:第一步写出递归思路,第二步根据递归思路写出递归结束条件,第三步根据递归思路和递归结束条件来写代码。
其中写递归思路的过程,就是思考如何不断将一个问题拆分为多个重复子问题的过程。那对于这一题它的递归思路又应该怎么写呢?我们如果以逆序a b c d e f为例,那递归思路和递归结束条件应该是下面这样的:
源代码:
来到这里,还有两个问题值得大家思考一下:
问题一:以逆置a b c d e f为例,第一步是交换a和f,如何变成转换到逆序b c d e这一步呢?
答:有两种思路。思路一,多传递一个参数,传两个参数过来,一个是待处理字符串的首元素地址str,另一个则是字符串的长度len。每一次递归结束只要让字符串的长度减去2就可以了(头尾)。但是值得注意的是:字符串的长度是size_t类型的,简单来理解就是无符号整数(注意这里是整数不是整型),无符号整数都是大于或者等于0的,如果字符串的初始长度只有1,那1 - 2不是小于0的-1,而是一个非常大的正数,所以这种情况要单独处理!
由此我们可以写出下面这个递归版本的字符串的逆置:
#include<stdio.h>
//逆序版本一:
void reverse(char* str, size_t len)
{
/*如果字符串的长度是1,就不需要进行处理。*/
if (len == 1)
{
return;
}
/*其他情况的处理:*/
char tmp = *str;
str[0] = str[len - 1];
str[len - 1] = tmp;
//递归结束条件:len - 2 == 0(首尾中间没有元素了)或者len - 2 == 1(首尾中间只有一个元素)结束
if (len - 2 >= 2)
{
reverse(str + 1, len - 2);
}
}
int main()
{
char str[20] = "abc";
reverse(str, strlen(str));
printf("%s", str);
return 0;
}
思路二,以逆序字符串a b c d e f为例,我们先创建创建一个临时变量tmp,第一步将a字符放到tmp里面去;第二步将最后一个字符f放到字符a所在的位置;第三步将最后一个字符f的位置用"/0"进行替换。
上面这个过程可以用下面这个图进行形象描述:(图中的数字表示步骤,比如1表示步骤一)
这样,你要逆序下面的b c d e只需要让str++(字符串首元素地址往后走一步)就可以了,后面同理。那什么时候结束呢?和前面一样当首尾元素中间只有一个元素或者没有元素就结束了。这个方法只需要你传一个参数过来就可以了。具体代码如下所示:
#include<stdio.h>
//递归版本二:
void reverse(char* str)
{
//易错点一:tmp的类型不要写成char*
char tmp = *str;
size_t len = strlen(str);
str[0] = str[len - 1];
str[len - 1] = '\0';
//递归结束条件:
//易错点二:不能用一次自增运算,来替换这里两次的str+1
if (strlen(str + 1) >= 2)
{
reverse(str + 1);
}
str[len - 1] = tmp;
}
int main()
{
char str[20] = "a";
reverse(str, strlen(str));
printf("%s", str);
return 0;
}
思路二相比思路一的话,更加抽象一些,但是对于只有一个元素的情况也能很好地处理啊,而不需要程序员单独进行处理。而且它只需要用户传递一个参数过来就可以了!但是思路一这种传两个参数str,len的方式,能有效处理局部字符串的逆置(任意取中间一小段字符串进行逆置)。但是你用思路二目前是做不到字符串的局部逆置的。
除此之外呢还是有一些比较容易出错的地方需要大家注意:易错点一很好理解,如果你不小心用char*类型的tmp,那后面str[0] = str[len - 1]这一步会导致tmp里面原有的数据被覆盖。
易错点二,大家可以尝试一下在草稿纸上走读代码(用一个例子根据代码一步一步来更新结果),最后你会发现用自增运算替代 str+1 的话,会导致最后一行代码syt[len - 1] = tmp这一步出现意外,走读代码分析问题还是不太复杂的,所以这个问题就交给各位读者朋友了。
二、字符串的旋转:
紧接着下面我们进入今天的第二个话题:字符串的旋转。
1. 问题描述:
问题描述如下图所示:
2. 解题分析与源代码:
首先先带大家明确一个问题:这个字符串的旋转不知道大家发现没有,这是一个循环反复的过程——以旋转ABCDE为例子,第一次旋转得到BCDEA,第二次是CDEAB,第三次是DEABC,第四次是EABCD,第五次旋转又变成了ABCDE。
什么意思呢?不妨假设用户指定的旋转次数是k,待旋转的字符串的长度是len,那么有效的旋转次数:k有效 = k % len(%是取余运算)。好这是我们的初步认识啊,知道这一点可以有效减少我们的程序运行起来之后的实际旋转次数。
另外这一题也是属于一题多解啊,这里就给大家介绍三种思路:
解题思路一:
这里以左旋ABCDE,我们先写出第一次左旋之后得到的结果:也就是:BCDEA,我们在这里值得使用观察法:我们发现字符串ABCDE每旋转一次等价于先把第一个字符A以外的其余字符全部前移一位,然后第一个字符A来到字符串最后一个元素的位置上。
这也是旋转的本质,好的上面的话翻译过来可以用下面的代码进行阐释:
//方法一:一步一步旋转,每次旋转一个元素,需要一个额外的变量:
//不足:当你的数组特别大,且旋转次数也很大,作为时间复杂度o(kn)级别的算法就很糟糕
void left_rotate_one(char* num, size_t len, int k)
{
char tem = 0;
int i = 0;
//计算有效旋转次数
k %= len;
while (k--)
{
tem = *num;
for (i = 0; i < len - 1; i++)
{
*(num + i) = *(num + i + 1);
}
*(num + len - 1) = tem;
}
}
这也就是思路一的源代码程序。
解题思路二:
解题思路一虽然简单,但是这个效率好不好呢,实际上很糟糕。我们不妨来简单分析一下:如果假设字符串的长度是n,旋转次数是k,由前面的代码我们不难看出来:旋转一次的成本有多大呢,答案是数组要在这里挪动覆盖地跑n - 1次才行,那k次旋转,就是k(n - 1)。
如果大家有算法的时间复杂度地概念的话,会知道,这里的n,k是表示问题规模的参数。表达式k(n-1)是可以反映出算法效率的表达式。这样的算法在n规模特别大,那计算机CPU都要冒烟。
好的,那有没有什么办法来优化我们的程序呢,有小伙伴就率先表态说:我发现字符串旋转k次,等价于将原来的字符前(k % len)个字符挪到其他字符后面,这个过程我们可以通过多开辟一个数组空间的方法一步到位。
好的据此我们可以写出下面这个代码:
//方法二:开辟额外的空间:
void left_rotate_two(char* num, size_t len, int k)
{
char tmp[256] = { 0 };
//计算有效旋转次数:
k %= len;
//先用strcpy函数把前k个字符后面的字符拷贝到tmp中:
strcpy(tmp, num + k);
//再用strncat函数把前k个字符追加在tmp后面:
strncat(tmp, num, k);
//最后把tmp数组的值拷贝给num即可:
strcpy(num, tmp);
}
上面这个代码普适性不是很好,因为有些时候你会发现待旋转的数组不是字符数组,也有可能是int类型的数组啥的。所以有没有办法使用我们前面学习过的memcpy或memmove函数(这两个函数可以适用于任意类型)来把我们的代码改造成普适性比较好的代码。
显然这是不难的,另外在数组中如果你巧妙地运用取余运算(%),往往有意料之外的结果,这种技巧往往用于数组的整体移动:(某元素数组下标 + 数组移动次数 + (数组长度)) % (数组长度) = 新数组中该元素下标 。(其中数组右移则移动次数是正数,反之是负数)。
其中紫色字体标注出来的数组长度选项是可选项,也就是说一般情况加不加都不影响最终的结果,加上数组长度这个选项主要是为了处理数组左移过程中下标计算可能产生负数的情况,如果当前数组是整体左移,那么不加可能会导致当前结果出现负数,从而发生数组越界访问的结果!
我们以一个例子来帮助大家理解上面这个过程:以左旋字符ABCD为例子,左旋一次变成字符BCDA的这个过程,就相当于将ABCD这个字符串整体左移了1位:
字母A原来的下标是0,根据公式新数组中A的下标 = (0 - 1 + 4) % 4 = 3。
字母B原来的下标是1,根据公式新数组中B的下标 = (1 - 1 + 4) % 4 = 0。
依此类推,可以得到新数组中C,D的下标分别是1,2。
由此可以将上面的代码写成下面这个形式:
//方法二:开辟额外的空间:
void right_rotate_three(int* num, size_t len, int k)
{
int* newNum = (int*)malloc(len * sizeof(int));
int i = 0;
for (i = 0; i < len; i++)
{
newNum[(i + len - k) % len] = num[i];
}
memmove(num, newNum, len * sizeof(int));
}
解题思路三:
前面已经给大家介绍了两种进行字符串旋转的方法,思想都比较地直接粗暴,但是接下来要给大家介绍的方法,可能就有点会让大家吃惊了。
它抓住字符串的旋转和字符串的逆序之间微妙的联系,巧妙地将一个复杂的字符串的旋转问题转变成为一个字符串的逆序问题,那究竟是怎么做到的呢?
我们不妨以左旋字符串ABCDEFG为例子,假设我现在希望左旋四次,最终得到的结果显然是EFGABCD —— 诶,它可以看成是原字符串DCBAGFE通过逆序操作得到的。而该字符串可以看成原字符串ABCDEFG先逆序前k (= 4)个字符ABCD,再逆序后三个字符EFG得到的。
由此我们得到:一个字符左旋k次 <=> 先逆序字符串前k % len个字符,再逆序其余字符,最后整体逆序。
这是左旋,那如果是右旋呢,比如ABCDEFG,右旋四次变成DEFGABC,同样地去逆向分析一下,你很快就会发现那只要将原来公式里面的k % len变成 len - (k % len)即可!
这种解决字符串的旋转的方法由于只需要几乎固定的三步,所以很多地方也把它称之为三步旋转法。由此我们的字符串的旋转的代码也可以这么写(以左旋为例子):
//方法三:使用逆序函数,采用三步旋转法:
void reverse(int* num,size_t len)
{
int* left = num;
int* right = num + len - 1;
while (left<=right)
{
int tem = *left;
*left = *right;
*right = tem;
left++;
right--;
}
}
void right_rotate_two(int* num, size_t len, int k)
{
//方法三这里的k %= len非常重要,对于其他方法而言不做只是增加了循环次数,但对于方法三不这样做会可能导致数组的越界访问
k %= len;
reverse(num, k);
reverse(num + k, len - k);
reverse(num, len);
}
3. 彩蛋题:
最后再留一个彩蛋题吧!也考验一下大家做题的思维是不是开阔。这题目其实也很简单,就是给你两个字符串,判断其中一个字符串是否可以由另一个字符串旋转得到:
诶,这时有小伙伴就说了:我都已经知道怎么去旋转一个字符串了,碰到这种题不就等于是秒杀吗?于是框框一顿操作——对其中一个字符串进行旋转操作,每旋转一次和另一个字符串比较一下看是否相同,如果旋转了len - 1(len是字符串长度),还没有相同的,那就是false,反之就true。
咋一看也行,但是效率太低下了。我们以旋转字符串ABCDE为例子,无论该字符串怎么旋转,所有的结果都包含在了字符串ABCDEABCD这个字符串里面了。
所以这个题也是只需要一步就到位的:只需要将其中一个字符串的原字符串再来一遍接在后面,然后找一找待查找的字符串是不是两倍原字符串的子集即可。
代码如下图所示:
int findRound(const char * src, char * find)
{
char tmp[256] = { 0 }; //用一个辅助空间将原字符串做成两倍原字符串
strcpy(tmp, src); //先拷贝一遍
strcat(tmp, src); //再连接一遍
return strstr(tmp, find) != NULL; //看看找不找得到
}
哈哈还在啃之前老本的小伙伴,在面对这一道题的时候可能就吃亏了哦😁!
以上就是字符串学习过程中两道必刷的经典OJ题以及相对应的解题方法及思路了,如果觉得有所收获的话,也请点点赞啦,谢谢大家!