白杨尚青头像
关注
C++入门篇(十二):string(下)——OJ实战与手写模拟实现:浅拷贝、深拷贝、三个swap封面图

C++入门篇(十二):string(下)——OJ实战与手写模拟实现:浅拷贝、深拷贝、三个swap

目录

0.1概述&序言

一、OJ实战

1.1仅仅反转字母(力扣 917)

1.2找字符串中第一个只出现一次的字符(力扣 387):

1.3字符串里面最后一个单词的长度(牛客HJ1)

1.4验证一个字符串是否是回文(力扣125)

1.5字符串相加(力扣415)

1.6字符串转换整数atoi(牛客)

1.7如果对此类题还感兴趣的话,此处有几道课后作业:

二、模拟实现string

2.1一个经典的string类问题

2.2浅拷贝:两个孩子公用一个玩具

2.3深拷贝:传统版

2.4深拷贝:现代版写法(三个swap)

2.5 写时拷贝(了解)

三、最终总结

四、该系列导航,方便跳跃复习:


0.1概述&序言

这里是白杨,上两篇我们把string的接口吃透了,传送门:C++入门篇(十):string(上)——认识string:构造与三大遍历(一条龙讲透operator[]、迭代器、auto、范围for)-CSDN博客C++入门篇(十一):string(中)——容量与增删改查:一篇吃透所有常用接口(万字详解)-CSDN博客

会用只是第一步,本篇进入硬核模式:

  • OJ实战:六道经典字符串体(含(上篇)的两道面试题),检验你的string熟练度。
  • 模拟实现:面试官最爱问的“手写string类”——“浅拷贝为什么会崩,深拷贝怎么写,现代swap到底妙在哪?"

探本溯源,学有所得,让我们开始吧。

一、OJ实战

1.1仅仅反转字母(力扣 917)

917. 仅仅反转字母 - 力扣(LeetCode)

题目:给定一个字符串S,反转其中的字母,非字母保留在原来的位置:如:“a-bC-dEf-ghIj”→“j-Ih-gfE-dCba”。

思路:双指针,begin/end从两端向中间走,遇到非字母就跳过,遇到字母就交换。

class Solution {
public:
    bool isLetter(char ch)
    {
        if (ch >= 'a' && ch <= 'z') return true;
        if (ch >= 'A' && ch <= 'Z') return true;
        return false;
    }
    string reverseOnlyLetters(string S) {
        if (S.empty())
            return S;
        size_t begin = 0, end = S.size() - 1;
        while (begin < end)
        {
            while (begin < end && !isLetter(S[begin]))
                ++begin;
            while (begin < end && !isLetter(S[end]))
                --end;
            swap(S[begin], S[end]);
            ++begin;
            --end;
        }
        return S;
    }
};

打印结果(实测):

reverseOnlyLetters("a-bC-dEf-ghIj") = j-Ih-gfE-dCba

1.2找字符串中第一个只出现一次的字符(力扣 387):

387. 字符串中的第一个唯一字符 - 力扣(LeetCode)

题目:给定一个字符串,找到它的第一个不重复的字符,返回其下标;不存在则返回-1。如“loveleetcode”返回2(第一个不重复的是‘v’)。

思路:哈希计数。开一个256大小的数组统计每个字符出现次数,在按字符次序从前往后找第一个次数为1的字符。

class Solution {
public:
    int firstUniqChar(string s) {
        int count[256] = { 0 };
        int size = s.size();
        for (int i = 0; i < size; ++i)
            count[s[i]] += 1;
        for (int i = 0; i < size; ++i)
            if (1 == count[s[i]])
                return i;
        return -1;
    }
};

打印结果(实测):

firstUniqChar("loveleetcode") = 2

假如面试追问:为什么第二遍从前往后扫,就能保证找到的是“第一个不重复字符”?

原因:第二遍遍历的顺序就是字符串原顺序,第一个命中“计数为1”的下标,自然是全局第一个。哈希链表顺序是乱的,所以不能直接遍历哈希表。

1.3字符串里面最后一个单词的长度(牛客HJ1)

字符串最后一个单词的长度_牛客题霸_牛客网

题目:输入一行字符串(单词之间可能多个空格),输出最后一个单词长度。如“hello world”→5。

思路:读一行带空格的字符必须用getline(cin>>遇空格停,(中篇)7.3刚踩过该坑);最后一个单词长度=总长-最后一个空格下标-1.

#include <iostream>
#include <string>
using namespace std;

int main()
{
    string line;
    while (getline(cin, line))          // 不要用 cin>>line,遇空格就结束
    {
        size_t pos = line.rfind(' ');   // 从后往前找最后一个空格
        cout << line.size() - pos - 1 << endl;
    }
    return 0;
}

打印结果(实测):

输入:hello world→输出:5

输入:nowcoder  →输出:8

1.4验证一个字符串是否是回文(力扣125)

125. 验证回文串 - 力扣(LeetCode)

题目:判断字符串在“只考虑字母和数字、忽略大小写”后是不是回文。如:"A man, a plan, a canal: Panama" → true。

思路:先把小写字母同一转大写,双指针从两端向中间走,跳过非字母数字字符,比较是否相等。

小贴士:同一成大写还是小写都行,关键是统一——否则‘A'和’a‘因该相等的却判false

class Solution {
public:
    bool isLetterOrNumber(char ch)
    {
        return (ch >= '0' && ch <= '9')
            || (ch >= 'a' && ch <= 'z')
            || (ch >= 'A' && ch <= 'Z');
    }
    bool isPalindrome(string s) {
        for (auto& ch : s)                       // 注意要 auto&,修改原字符串
        {
            if (ch >= 'a' && ch <= 'z')
                ch -= 32;                        // 小写转大写
        }
        int begin = 0, end = s.size() - 1;
        while (begin < end)
        {
            while (begin < end && !isLetterOrNumber(s[begin]))
                ++begin;
            while (begin < end && !isLetterOrNumber(s[end]))
                --end;
            if (s[begin] != s[end])
                return false;
            ++begin;
            --end;
        }
        return true;
    }
};

打印结果(实测)

isPalindrome("A man, a plan, a canal: Panama") = 1

1.5字符串相加(力扣415)

 ​​​​​​415. 字符串相加 - 力扣(LeetCode)

题目:给定两个字符串形式的非负整数,返回他们的和(字符串形式)。如“456”+“789”=“1245”。

这就是(上篇1.3)预告的第二到面试题,现在来解决它。

思路:从后往前逐位相加、处理进位。结果用+=尾插,最后reverse回来(头插insert一次挪一次,O(n^2),别用)。

class Solution {
public:
    string addStrings(string num1, string num2) {
        int end1 = num1.size() - 1;
        int end2 = num2.size() - 1;
        int value1 = 0, value2 = 0, next = 0;   // next 是进位
        string addret;
        while (end1 >= 0 || end2 >= 0)
        {
            if (end1 >= 0) value1 = num1[end1--] - '0';
            else           value1 = 0;
            if (end2 >= 0) value2 = num2[end2--] - '0';
            else           value2 = 0;

            int valueret = value1 + value2 + next;
            if (valueret > 9)
            {
                next = 1;
                valueret -= 10;
            }
            else
            {
                next = 0;
            }
            addret += (valueret + '0');          // 尾插,最后再反转
        }
        if (next == 1)
            addret += '1';
        reverse(addret.begin(), addret.end());
        return addret;
    }
};

打印结果(实测)

“456”+“789”=1245

“11”+“123”+134

1.6字符串转换整数atoi(牛客)

​​​​​​把字符串转换成整数_牛客题霸_牛客网

还记得上篇 1.3 预告的面试题吗?这道"字符串转整形数字"就是它——经典中的经典,边读边转,重点在边界条件:前导空格、正负号、非数字字符、溢出。

思路:三步走——跳过前导空格→处理正负号→逐位转换。溢出检测用longlong中转,超过int范围直接返回边界值。

class Solution {
public:
    int StrToInt(string s) {
        int i = 0, n = s.size();
        // 1. 跳过前导空格
        while (i < n && s[i] == ' ') ++i;
        // 2. 处理正负号
        int sign = 1;
        if (i < n && (s[i] == '+' || s[i] == '-'))
        {
            if (s[i] == '-') sign = -1;
            ++i;
        }
        // 3. 逐位转换,同时检查溢出
        long long result = 0;
        while (i < n && s[i] >= '0' && s[i] <= '9')
        {
            result = result * 10 + (s[i] - '0');
            if (result * sign >= INT_MAX) return INT_MAX;
            if (result * sign <= INT_MIN) return INT_MIN;
            ++i;
        }
        if(i<n)
        {
            return 0;
        }
        return (int)(result * sign);
    }
};

1.7如果对此类题还感兴趣的话,此处有几道课后作业:

  1. 翻转字符串 II:区间部分翻转(LeetCode 541)541. 反转字符串 II - 力扣(LeetCode)
  2. 翻转字符串 III:翻转字符串中的单词(LeetCode 557)557. 反转字符串中的单词 III - 力扣(LeetCode)
  3. 字符串相乘(LeetCode 43)43. 字符串相乘 - 力扣(LeetCode)
  4. 字符串中的单词数(牛客)找出字符串中第一个只出现一次的字符_牛客题霸_牛客网

如果题目有问题,欢迎到评论区留言:我看到会一一为您解答问题。

二、模拟实现string

2.1一个经典的string类问题

先看代码,你觉得它有问题吗?

// 为了和标准库区分,此处使用 String
class String
{
public:
    String(const char* str = "")
    {
        if (nullptr == str)   // 传了nullptr,认为程序非法
        {
            assert(false);
            return;
        }
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }
    ~String()
    {
        if (_str)
        {
            delete[] _str;
            _str = nullptr;
        }
    }
private:
    char* _str;
};

void TestString()
{
    String s1("hello bit!!!");
    String s2(s1);   // 调用编译器合成的默认拷贝构造——浅拷贝!
}

先说结论:有问题,而且是致命的。

这个string类没有显示写拷贝构造和赋值重载,编译器会生成默认的——默认实现是浅拷贝,两个对象共用一块空间,析构同时一块空间被释放两次,程序崩溃。

把 TestString() 放进 main 里跑一下就知道:s2 先析构,把堆空间释放了;s1 再析构,又释放一次同一块空间——重复释放,程序直接崩溃。

面试题:什么样的类必须显示写拷贝构造和赋值重载?

只要类中涉及资源的管理(申请了堆,打开了文件等),拷贝构造、赋值运算符重载、析构函数三个必须显示给出。人们给它取了一个名—— Rule of Three(三大法则)——要写就三个一起写。

2.2浅拷贝:两个孩子公用一个玩具

先听个故事:

一家两个孩子,父母只买了一份玩具。一起玩就万事大吉;一旦不想分享,你争我夺,玩具就坏了。

2.1 里的 s1、s2 就是这么"共用玩具"的——浅拷贝(位拷贝):编译器把对象里的值原样拷贝过去,指针成员只拷贝了地址,于是两个对象共享同一份资源。一个对象销毁把资源释放了,另一个还蒙在鼓里继续用——访问违规,崩溃。

成员只有 int、double 这类普通值,浅拷贝完全没问题;一旦涉及资源管理(new 出来的指针等),共享资源就是定时炸弹。

2.3深拷贝:传统版

深拷贝:每个对象都拥有一份独立的资源,不与其他对象共享。——父母给每个孩子各自买一份玩具,各玩各的。

传统版老老实实:开空间→拷贝内容→释放旧空间。注意赋值重载里的自赋值检查和先new后delete:

class String
{
public:
    String(const char* str = "")
    {
        if (nullptr == str)
        {
            assert(false);
            return;
        }
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }

    // 拷贝构造:开新空间,拷贝内容(初始化列表一步到位)
    String(const String& s)
        : _str(new char[strlen(s._str) + 1])
    {
        strcpy(_str, s._str);
    }

    // 赋值重载:先开好新空间,再释放旧空间(注意自赋值检查)
    String& operator=(const String& s)
    {
        if (this != &s)    // 防止 s = s:不然先把自己的空间删了,后面用啥?
        {
            char* pStr = new char[strlen(s._str) + 1];
            strcpy(pStr, s._str);
            delete[] _str;
            _str = pStr;
        }
        return *this;
    }

    ~String()
    {
        if (_str)
        {
            delete[] _str;
            _str = nullptr;
        }
    }
private:
    char* _str;
};

传统版的两个细节(常被追问):

  1. 先new后delete:如果先delete旧空间、new新空间又失败了,对象就残废了
  2. 自赋值检查:this!=&s;s=s时如果先delete自己空间,后面的拷贝就是往野指针里写。

2.4深拷贝:现代版写法(三个swap)

现代写法:拷贝构造借助“零时工+swap”;赋值重载参数用传值传递,让编译器自动调拷贝构造生成临时对象,再swap把资源换过来。代码量少一半,还不容易写错。

class String
{
public:
    String(const char* str = "")
    {
        if (nullptr == str)
        {
            assert(false);
            return;
        }
        _str = new char[strlen(str) + 1];
        strcpy(_str, str);
    }

    // 拷贝构造:swap(1)——临时工干活,资源换过来
    String(const String& s)
        : _str(nullptr)                    // 必须先置空!否则swap后临时对象析构释放垃圾指针
    {
        String strTmp(s._str);             // 临时工:new + strcpy
        swap(_str, strTmp._str);           // 资源换过来
    }                                      // strTmp 出作用域析构:释放空指针,安全

    // 赋值重载:swap(2)——值传递参数,编译器自动拷贝构造
    String& operator=(String s)            // s 是实参的拷贝(拷贝构造自动调用)
    {
        swap(_str, s._str);                // 交换资源
        return *this;
    }                                      // s 出作用域析构:顺带把旧资源释放了

    ~String()
    {
        if (_str)
        {
            delete[] _str;
            _str = nullptr;
        }
    }
private:
    char* _str;
};

三个swap逐一拆解:(见上方代码注释)

  1. swap(1)拷贝构造里的swap:临时对象strTmp负责new=strcpy(这就是临时工),然后swap(_str,strTmp._str)把资源换到自己手上。strTmp出作用域自动析构——此时它的_str是空指针,析构安全。注意_str要先初始化为nullptr,否则swap之后临时对象析构时释放的时垃圾指针。
  2. swap(2) 赋值重载里的 swap:参数是值传递,编译器调用拷贝构造帮我们生成临时对象 s。swap 交换资源后,函数结束时 s 自动析构——顺带把旧资源释放了。连自赋值检查都不需要(s = s 也安全),传统版的两个坑全避开。
  3. swap(3) 全局 swap:这里的 swap 是标准库的全局 std::swap(交换两个指针的值,代价极小)。进阶玩法是自己再写一个成员 swap 函数 + 全局 swap 重载,面试能聊这个就是加分项,等进阶篇细讲。

对比:现代版 vs 传统版,哪个好?现代版更优雅简洁,跟不容易出错,而且面试写现代版逼格拉满。

打印结果(实测,VS x64,两种写法都验证过):(运行截图仅展示现代写法打印结果)

modern: hello bit!!! | hello bit!!! | hello bit!!! 
classic: hello bit | hello bit | hello bit

2.5 写时拷贝(了解)

写时拷贝是一种"拖延症":浅拷贝 + 引用计数。 拷贝时不真拷贝,只在某个对象真正修改数据时才拷贝。

引用计数记录资源使用者的个数:

  • 构造时计数置1
  • 每增加一个对象使用该资源,计数+1
  • 对象销毁时计数先-1,减到0(自己时最后一个使用者)才释放资源

好处是拷贝构造几乎零成本;坏处是实现复杂、计数操作有线程安全隐患。旧版 g++ 的 string 就是写时拷贝,新版本已经放弃了。了解即可

三、最终总结

  1. OJ 六道题:双指针反转字母、哈希计数找唯一字符、getline+rfind 求末单词、双指针验回文、进位模拟字符串相加、三步走 atoi
  2. 类管理资源却不写拷贝构造/赋值重载 → 默认浅拷贝 → 共享资源 → 重复释放 → 崩溃
  3. rule of Three:拷贝构造、赋值重载、析构,要写三个一起写
  4. 传统版:开新空间 → 拷内容 → 释放旧空间;赋值重载注意自赋值检查、先 new 后 delete
  5. 现代版:拷贝构造"临时工 + swap"(_str 必须先置空);赋值重载值传递,swap 完自动析构
  6. 写时拷贝 = 浅拷贝 + 引用计数,“要改的时候再拷贝”

四、该系列导航,方便跳跃复习:

好了,string 三篇到此完结!下一篇进入 STL 下一个重量级容器——vector(预告:动态数组、迭代器失效问题)。如果对你有帮助,不要忘记点赞三连一波哦!!!我是白杨,我们下期见🙂。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/Bai_YangSQ/article/details/166946348

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--