CMU15445 2023fall project0详细过程

要用到的文件
project0一共要用到这几个文件,再加个tire_store.h
路径:src/include/primer
src/primer

task1 要用到tire.h,tire.cpp.tire_test.cpp
tire.h:头文件,需要差不多读懂,需要用到一些变量名
tire.cpp:在这个里面写代码
tire_test.cpp:在tire.cpp完成后,运行这个,可以看到自己的代码有没有问题。

1、tire.h头文件解读

1.1整体

bustub空间内,一共四个类(class)
MoveBlocked 类:一个特殊类型,用于阻止移动构造和移动赋值操作。在Trie的测试中使用,以确保对象不会被意外移动(不用细看)
TireNode类
—提供构造函数来创建Trie节点(没有子节点->TrieNode() = default;
或者有子节点 ->explicit TrieNode(std::map<char, std::shared_ptr> children) : children_(std::move(children)) {} )。
—一个虚拟的Clone方法,用于复制Trie节点。
–一个表示子节点的映射
—一个标志结点内是否有值的bool变量(is_value_node_),默认没有值(false)。
TrieNodeWithValue类:继承TrieNode类,创建了能储存值的结点;重写了Clone方法,以确保值也被复制。
Tire类:一个指向根节点的智能指针;提供操作方法,如获取、插入、移除。

1.2 细节

1

在这里插入图片描述

explicit TrieNode(std::map<char, std::shared_ptr<const TrieNode>> children) : children_(std::move(children)) {}

explicit关键字用于阻止C++进行隐式类型转换,强制要求传入参数的类型必须符合声明

std::map<int,string> personnel; 一个用int作为索引,并拥有相关联的指向string的指针,叫personnel

const:阻止一个变量被改变,可使用const,在定义该const变量时,需先初始化,以后就没有机会改变他了;

shared_ptr是一种智能指针(smart pointer),作用有如同指针,但会记录有多少个shared_ptr共同指向一个对象。这便是所谓的引用计数(reference counting)。
一旦最后一个这样的指针被销毁,也就是一旦某个对象的引用计数变为0,这个对象会被自动删除。

std::move()将对象的状态或所有权转换给另一对象,这里是调用构造函数时传入的对象children到children_,之后传入的对象就为空了,这样做只是转移,没有内存的搬迁或者内存拷贝,如果不加则需要先将传入对象拷贝一份为临时对象,然后给属性成员赋值,之后销毁临时对象。因此,通过std::move(),可以避免不必要的拷贝操作。这样的构造函数称为移动构造函数

函数(类型 参数名):属性成员名(参数名){}等价于函数(类型 参数名){this.属性成员名=参数名;}
函数:TireNode
类型:std::map<char, std::shared_ptr>
参数名:children
把函数传过来的参数(children),赋值给该属性成员(children_): : children_(std::move(children))

2

virtual auto Clone() const -> std::unique_ptr<TrieNode> { return std::make_unique<TrieNode>(children_); }

auto 函数() -> 类型{…} 作用是推导出函数的返回类型

unique_ptr:一种独占式智能指针,它确保了只有一个指针可以指向资源,可以通过std::move()函数将资源的所有权转移给其他unique_ptr对象。它不允许其他的智能指针共享其内部的指针,可以通过它的构造函数初始化一个独占智能指针对象,但是不允许通过赋值将一个unique_ptr赋值给另一个unique_ptr。

std::make_unique:是C++14中引入的一个函数,用于创建一个唯一指针(unique_ptr),这是一种更安全、更简洁的方式来创建动态分配的对象。
ps:
std::make_shared:共享用智能指针

2、写代码时要用到的知识

2.1 智能指针

前面有过涉及,但是这里做一个总结
在C++中没有垃圾回收机制,必须自己释放分配的内存,否则就会造成内存泄露。解决这个问题最有效的方法是使用智能指针(smart pointer)。
例如,使用普通指针来动态分配一个整型数组:

int* arr = new int[10];
// 使用 arr 操作数组
delete[] arr; // 手动释放内存

使用智能指针 std::unique_ptr 来管理同样的数组:

std::unique_ptr<int[]> arr(new int[10]);
// 使用 arr 操作数组
// 不需要手动释放内存,智能指针会自动释放

2.2 类型转换 dynamic_cast与dynamic_pointer_cast(task 1)

语法:std::dynamic_cast<目标类型*>(被转换对象)
std::dynamic_pointer_cast<目标类型>(被转换智能指针)
dynamic_pointer_cast与dynamic_cast的区别
适用对象类型:dynamic_pointer_cast适用于智能指针类型,如std::shared_ptr和std::weak_ptr,用于进行智能指针的动态类型转换。而dynamic_cast适用于指针和引用类型,用于进行指针或引用类型的动态类型转换。
用法:dynamic_pointer_cast是一个函数模板,接受两个参数,分别是要转换的指针和目标类型的指针类型。它返回一个智能指针类型的结果。而dynamic_cast是一个运算符,在使用时需要使用dynamic_cast<目标类型>(被转换对象)的语法。它返回一个指针或引用类型的结果。
转换失败处理:dynamic_pointer_cast在转换失败时,会返回一个空智能指针(nullptr)。而dynamic_cast在转换失败时,如果转换是指针类型,会返回一个空指针(nullptr);如果转换是引用类型,会抛出std::bad_cast异常。
适用范围:dynamic_pointer_cast只适用于智能指针类型,因此只能用于具有动态多态性的对象。而dynamic_cast可以用于具有动态多态性的对象,也可以用于普通的指针和引用类型。

2.3 加锁解锁lock_guard和unique_lock(task 2)

语法:std::lock_guard guard(mt);
unique_lock unique(mt); unique.unlock(); 解锁
区别:unique_lock unique(mt);会在构造函数加锁,然后可以利用unique.unlock()来解锁,所以当你觉得锁的粒度太多的时候,可以利用这个来解锁,而析构的时候会判断当前锁的状态来决定是否解锁,如果当前状态已经是解锁状态了,那么就不会再次解锁,而如果当前状态是加锁状态,就会自动调用unique.unlock()来解锁。而lock_guard在析构的时候会自动解锁,也没有中途解锁的功能。

3、代码

非递归https://blog.csdn.net/qq_58887972/article/details/135708757?spm=1001.2014.3001.5502
递归 https://blog.csdn.net/Aft3rGl0w/article/details/135704274
主要参考的这两个代码,自己写的内容很少,主要集中在注释

3.1 task1

实现树节点的查找(Get 方法)、添加修改(Put方法),删除(Remove 方法)

Get方法

template <class T>
auto Trie::Get(std::string_view key) const -> const T * {
  // 从根节点开始
  auto node = root_;
  // 按输入的key值遍历Trie树
  for (char ch : key) {
    // 如果当前节点为nullptr,或者没有对应字符的子节点,返回nullptr,程序结束
    if (node == nullptr || node->children_.find(ch) == node->children_.end()) {
      return nullptr;
    }
    // 当前字符存在,接着往后面找
    node = node->children_.at(ch);
  }
  // 路径存在,检查类型是否匹配
  // 将节点转换为TrieNodeWithValue<T>类型
  const auto *value_node = dynamic_cast<const TrieNodeWithValue<T> *>(node.get());
  // 如果转换成功并且节点包含正确类型的值,则返回该值的指针
  if (value_node != nullptr) {
    return value_node->value_.get();
  }
  // 若类型不匹配,返回nullptr
  return nullptr;
  // You should walk through the trie to find the node corresponding to the key. If the node doesn't exist, return
  // nullptr. After you find the node, you should use `dynamic_cast` to cast it to `const TrieNodeWithValue<T> *`. If
  // dynamic_cast returns `nullptr`, it means the type of the value is mismatched, and you should return nullptr.
  // Otherwise, return the value.
}

Put方法

//put的递归方法
// Put:递归方法具体实现
template <class T>
void PutCycle(const std::shared_ptr<bustub::TrieNode> &new_root, std::string_view key, T value) {
  // 判断元素是否为空的标志位
  bool flag = false;
  // 在new_root的children找key的第一个元素
  // 利用for(auto &a:b)循环体中修改a,b中对应内容也会修改及pair的特性
  for (auto &pair : new_root->children_) {
    // 如果找到了
    if (key.at(0) == pair.first) {
      flag = true;
      // 剩余键长度大于1
      if (key.size() > 1) {
        // 复制一份找到的子节点,然后递归对其写入
        std::shared_ptr<TrieNode> ptr = pair.second->Clone();
        // 递归写入 .substr(1,key.size()-1)也可以
        // 主要功能是复制子字符串,要求从指定位置开始,并具有指定的长度。
        PutCycle<T>(ptr, key.substr(1), std::move(value));
        // 覆盖原本的子节点
        pair.second = std::shared_ptr<const TrieNode>(ptr);
      } else {
        // 剩余键长度小于等于1,则直接插入
        // 创建新的带value的子节点
        std::shared_ptr<T> val_p = std::make_shared<T>(std::move(value));
        TrieNodeWithValue node_with_val(pair.second->children_, val_p);
        // 覆盖原本的子节点
        pair.second = std::make_shared<const TrieNodeWithValue<T>>(node_with_val);
      }
      return;
    }
  }
  if (!flag) {
    // 没找到,则新建一个子节点
    char c = key.at(0);
    // 如果为键的最后一个元素
    if (key.size() == 1) {
      // 直接插入children
      std::shared_ptr<T> val_p = std::make_shared<T>(std::move(value));
      new_root->children_.insert({c, std::make_shared<const TrieNodeWithValue<T>>(val_p)});
    } else {
      // 创建一个空的children节点
      auto ptr = std::make_shared<TrieNode>();
      // 递归
      PutCycle<T>(ptr, key.substr(1), std::move(value));
      // 插入
      new_root->children_.insert({c, std::move(ptr)});
    }
  }
}

template <class T>
auto Trie::Put(std::string_view key, T value) const -> Trie {
  // 第一种情况:值要放在根节点中,无根造根,有根放值
  // 更改根节点的值,即测试中的 trie = trie.Put<uint32_t>("", 123);key为空
  if (key.empty()) {
    std::shared_ptr<T> val_p = std::make_shared<T>(std::move(value));
    // 建立新根
    std::unique_ptr<TrieNodeWithValue<T>> new_root = nullptr;
    // 如果原根节点无子节点
    if (root_->children_.empty()) {
      // 直接修改根节点
      new_root = std::make_unique<TrieNodeWithValue<T>>(std::move(val_p));
    } else {
      // 如果有,把原根的关系转移给新根:root_的children改为newRoot的children
      // 这里看tire.h里TireNodeWithValue方法,可以看到,传入不同数量的参数,对应实现不同的方法。
      new_root = std::make_unique<TrieNodeWithValue<T>>(root_->children_, std::move(val_p));
    }
    // 返回新的Trie
    return Trie(std::move(new_root));
  }
  // 第二种情况:值不放在根节点中
  // 2.1 根节点如果为空,新建一个空的TrieNode;
  // 2.2 如果不为空,调用clone方法复制根节点
  std::shared_ptr<TrieNode> new_root = nullptr;
  if (root_ == nullptr) {
    new_root = std::make_unique<TrieNode>();
  } else {
    new_root = root_->Clone();
  }
  // 递归插入,传递根节点,要放的路径:key, 要放入的值:value
  PutCycle<T>(new_root, key, std::move(value));
  // 返回新的Trie
  return Trie(std::move(new_root));
}

Remove方法
不仅要删除键值,还需要在删除后清除所有不必要的节点。递归遍历key,如果发现当前的key的元素不在当前递归的trie节点的子节点映射中,则说明trie没有这个键,直接返回false表示没有移除任何键值。

如果当前的key的元素在当前递归的trie节点的子节点映射中,继续判断这是不是key的最后一个元素(遍历到终点),如果遍历到终点,则判断key节点是否有子节点,如果没有子节点则说明可以直接删除key节点,如果有子节点则不能删除,只能将key节点转为无value的普通节点,然后返回true表示删除了一个键值。

如果没有遍历到终点,和Put逻辑一样,拷贝当前的key[i]在当前递归的trie节点的子节点映射的节点,然后以拷贝得到的节点继续递归。获取递归的结果,如果为false,则说明没有删除任何节点,直接返回false,否则判断当前节点是否可删除(是否为value节点 or 是否有子节点),如果可删除则删除当前节点并返回true。 (原文链接:https://blog.csdn.net/Aft3rGl0w/article/details/135704274)

//remove 递归移除方法
// remove 递归移除方法
bool RemoveCycle(const std::shared_ptr<TrieNode> &new_roottry, std::string_view key) {
  // 在new_root的children找key的第一个元素
  for (auto &pair : new_roottry->children_) {
    // 继续找
    if (key.at(0) != pair.first) {
      continue;
    }
    if (key.size() == 1) {
      // 是键结尾
      if (!pair.second->is_value_node_) {
        return false;
      }
      // 如果子节点为空,直接删除
      if (pair.second->children_.empty()) {
        new_roottry->children_.erase(pair.first);
      } else {
        // 否则转为TireNode
        pair.second = std::make_shared<const TrieNode>(pair.second->children_);
      }
      return true;
    }
    // 拷贝一份当前节点
    std::shared_ptr<TrieNode> ptr = pair.second->Clone();
    // 递归删除
    bool flag = RemoveCycle(ptr, key.substr(1, key.size() - 1));
    // 如果没有可删除的键
    if (!flag) {
      return false;
    }
    // 如果删除后当前节点无value且子节点为空,则删除
    if (ptr->children_.empty() && !ptr->is_value_node_) {
      new_roottry->children_.erase(pair.first);
    } else {
      // 否则将删除的子树覆盖原来的子树
      pair.second = std::shared_ptr<const TrieNode>(ptr);
    }
    return true;
  }
  return false;
}

auto Trie::Remove(std::string_view key) const -> Trie {
  if (this->root_ == nullptr) {
    return *this;
  }
  // 键为空
  if (key.empty()) {
    // 根节点有value
    if (root_->is_value_node_) {
      // 根节点无子节点
      if (root_->children_.empty()) {
        // 直接返回一个空的trie
        return Trie(nullptr);
      }
      // 根节点有子节点,把子结点转给新根
      std::shared_ptr<TrieNode> new_root = std::make_shared<TrieNode>(root_->children_);
      return Trie(new_root);
    }
    // 根节点无value,直接返回
    return *this;
  }
  // 创建一个当前根节点的副本作为新的根节点
  std::shared_ptr<TrieNode> newroot = root_->Clone();
  // 递归删除
  bool flag = RemoveCycle(newroot, key);
  if (!flag) {
    return *this;
  }
  if (newroot->children_.empty() && !newroot->is_value_node_) {
    newroot = nullptr;
  }
  return Trie(std::move(newroot));

  // You should walk through the trie and remove nodes if necessary. If the node doesn't contain a value any more,
  // you should convert it to `TrieNode`. If a node doesn't have children any more, you should remove it.
}

3.2 task2

多线程环境实现一个并发控制的键值存储,task1是实现单线程的,task2会用到task1写的方法
tire_store.h中一共两把锁root_lock_ 用于 访问 ,write_lock_ 用于读写

Get方法
get方法按照作业自带的要求写就好了(代码中的英文部分)

template <class T>
auto TrieStore::Get(std::string_view key) -> std::optional<ValueGuard<T>> {
  // Pseudo-code:
  // (1) Take the root lock, get the root, and release the root lock. Don't lookup the value in the
  //     trie while holding the root lock.
  // (2) Lookup the value in the trie.
  // (3) If the value is found, return a ValueGuard object that holds a reference to the value and the
  //     root. Otherwise, return std::nullopt.
  std::shared_ptr<const TrieNode> root_copy;
  auto trie = Trie();
  // 使用互斥锁来保护根节点的读取
  std::lock_guard<std::mutex> lock(root_lock_);
  // 获取Trie的根节点
  trie = root_;
  // 在Trie中查找键
  const T *value = trie.Get<T>(key);  // 假设TrieNode有Get方法
  // 根据查找结果返回
  if (value) {
    return ValueGuard<T>(trie, *value);
  }
  return std::nullopt;
}

Put方法
put和remove方法需要上两把锁
write_lock_(读写锁)防止其他写进程写入trie,然后向root中放入key-value
root_lock_ (访问锁)放入或移除完成后需要更新trie,所以需要避免其他进程读。更新原始trie后释放锁

template <class T>
void TrieStore::Put(std::string_view key, T value) {
  // You will need to ensure there is only one writer at a time. Think of how you can achieve this.
  // The logic should be somehow similar to `TrieStore::Get`.
  std::shared_ptr<const TrieNode> root_copy;
  // lock_guard 加锁,当离开局部作用域,析构函数自动完成解锁功能
  // 读写加锁
  std::lock_guard<std::mutex> lock(write_lock_);
  // 复制当前根节点,对其进行修改
  Trie new_trie = root_.Put<T>(key, std::move(value));
  std::lock_guard<std::mutex> root_lock(root_lock_);
  root_ = new_trie;
}

Remove方法

void TrieStore::Remove(std::string_view key) {
  // You will need to ensure there is only one writer at a time. Think of how you can achieve this.
  // The logic should be somehow similar to `TrieStore::Get`.
  // 访问和读写一起加锁
  std::lock_guard<std::mutex> lock(write_lock_);

  // 执行删除操作并获取新的 Trie 实例
  Trie new_trie_version = root_.Remove(key);
  std::lock_guard<std::mutex> root_lock(root_lock_);
  // 更新 Trie 的根节点
  root_ = new_trie_version;
}

3.3 task3

task3 在注释要求的地方添加断点,根据要求出三个数,放在tire_answer.h中
(1)有几个孩子节点 :children下[0]-[9]一共10个
(2)9号节点有几个孩子节点:1个
(3)“969”路径对应的值是什么:(char)57=9,可以看到值是25
在这里插入图片描述在这里插入图片描述
在这里插入图片描述

3.4 task4

在string_expression.h和plan_func_call.cpp中实现大小写函数。没有c++基础写之前我还疑惑为什么大家都不写,实现了以后确实不难
string_expression.h

 auto Compute(const std::string &val) const -> std::string {
    // TODO(qijiarui): implement upper / lower.
    std::string ret;
    switch (expr_type_) {
      case StringExpressionType::Lower:
        for (auto c : val) {
          ret.push_back(std::tolower(c));
        }
        break;
      case StringExpressionType::Upper:
        for (auto c : val) {
          ret.push_back(std::toupper(c));
        }
        break;
      default:
        break;
    }
    return ret;
  }

plan_func_call.cpp

auto Planner::GetFuncCallFromFactory(const std::string &func_name, std::vector<AbstractExpressionRef> args)
    -> AbstractExpressionRef {
  // 1. check if the parsed function name is "lower" or "upper".
  // 2. verify the number of args (should be 1), refer to the test cases for when you should throw an `Exception`.
  // 3. return a `StringExpression` std::shared_ptr.
  if ((func_name == "lower" || func_name == "upper") && args.size() == 1) {
    return static_cast<std::shared_ptr<StringExpression>>(std::make_shared<StringExpression>(
        args[0], func_name == "lower" ? StringExpressionType::Lower : StringExpressionType::Upper));
  }
  throw Exception(fmt::format("func call {} not supported in planner yet", func_name));
}

4 提交

本地运行通过了我以为打包好提交就结束了,结果这个地方卡了两天。把可能出问题的地方说一下

4.1流程

注册
进入这个网址
https://www.gradescope.com/courses/579715
在这里插入图片描述
在这里插入图片描述
Course Entry Code输入:KK5DVJ(2023fall) 其余年份可以去这里面找,,网址改为对应年份的就行: https://15445.courses.cs.cmu.edu/fall2023/faq.html
学校输入:Carnegie Mellon University
最后界面如下图:
在这里插入图片描述
执行格式检查
安装clang-fromat,clang-tidy

sudo apt-get install clang-format

下方语句需管理员权限(输入su和密码)

apt-get install clang-tidy

然后在build文件夹下依次执行以下命令

make format
make check-lint
make check-clang-tidy-p0

可能会报错,按照提示更改自己的文件就好
打包
(官网教程内容)在build文件夹下调出终端输入
make submit-p0
调到上层目录 cd …
输入python3 gradescope_sign.py
这里会让你输入很多东西,邮箱,姓名,githubID等等,按提示输入就好
完成后提交到网站

4.2遇见问题

问题1:
压缩包里的内容正常应该是trie_store.cpp等等一系列cpp和.h文件,但我的压缩包里只有orset.h和orset.cpp文件
解决方法1
打开build文件夹内的makefile文件,找到submit-p0对应的文件路径(308行)
在这里插入图片描述
看第69、70行,我之前的只有orset路径,没有tire_store那些,更改后就可以了

	cd /home/qijiarui/bustub-gun && zip project0-submission.zip src/planner/plan_func_call.cpp src/include/execution/expressions/string_expression.h src/include/primer/trie.h src/include/primer/trie_store.h src/include/primer/trie_answer.h src/primer/trie.cpp src/primer/trie_store.cpp

在这里插入图片描述
问题2:
即使之前进行了格式检查,实际放到线上时,格式检查也会不通过。(分数是0)
解决办法2:
按照线上提示的内容进行修改,他会上一行给出问题语句,下一行给出怎么改的建议,按照建议来就可以。莫慌

通过截图
在这里插入图片描述
放在结尾的一些碎碎念
这个项目网上找了好多代码,自己写的很少,之后还要再看两天代码,把不了解的写法总结一下,但是总算在腊月二十九的晚上写完了,不然大年三十调代码也太绝望了。这一个项目算上之后的两天做了整整半个月,c++一无所知的我觉得好难啊,那些半个月做5个项目的是什么神仙,只能用好歹也一直在进步安慰自己了。就祝自己新年快乐,新的一年事事顺心吧!

参考文章
[1]https://blog.csdn.net/baidu_31541363/article/details/95802210(std::shared_ptr 详解)
[2]https://blog.csdn.net/Falling_Asteroid/article/details/1339636549(P0 C++Primer)
[3]https://www.cnblogs.com/spruce/p/13095720.html(std::map(转))
[4]https://blog.csdn.net/qq_58887972/article/details/135708757?spm=1001.2014.3001.5502(CMU-15445project0(仅自己记录留念))
[5]https://blog.csdn.net/yi_chengyu/article/details/121921622(【C/C++】C++11 智能指针与普通指针重要区别)
[6]https://blog.csdn.net/Aft3rGl0w/article/details/135704274(CMU 15445 2023fall #Project0 实现一个简单的k-v存储引擎)
[7]https://zhuanlan.zhihu.com/p/340348726(C++11多线程编程(三)——lock_guard和unique_lock)
[8]https://blog.csdn.net/weixin_43721070/article/details/122638851(C++代码自动检测工具clang-format和clang-tidy)
[9]https://zhuanlan.zhihu.com/p/622663820(【CMU15-445 FALL 2022】Project #0 - C++ Primer)

  • 15
    点赞
  • 41
    收藏
    觉得还不错? 一键收藏
  • 26
    评论
CMU 15445 课程的 Project 0 是一个调试练习,旨在帮助学生熟悉调试工具和技术。在这个项目中,你将开始使用 GDB 和 Valgrind 这两个常用的调试工具,以及一些其他辅助工具。以下是一些问题和步骤,帮助你完成这个练习: 1. 你需要查看项目中提供的代码,并了解它的结构和功能,这样你才能更好地理解程序的逻辑和可能出现的 bug。 2. 接下来,你需要编译项目,并确保没有编译错误。如果出现错误,你需要修复它们,这可能需要检查一些语法错误或缺失的库。 3. 一旦成功编译项目,你就可以使用 GDB 进行调试了。GDB 是一个强大的调试器,可以帮助你找出程序中的错误。你可以使用 GDB 来单步执行代码、设置断点、查看变量的值等等。通过使用 GDB,你可以逐步查看代码运行的路径,并找出程序崩溃或产生错误的原因。 4. 在使用 GDB 进行调试时,你可以通过设置断点来暂停程序的执行,并查看变量的值和程序的状态。你可以使用“break”命令在程序中设置断点,并通过“run”命令启动程序。当程序到达这个断点时,它会停止执行,你可以使用“print”命令查看变量的值,或者“step”命令逐步执行代码。 5. 另一个常用的调试工具是 Valgrind。Valgrind 可以帮助你检测内存泄漏和错误的访问方式。你可以使用“valgrind”命令来运行程序,并查看 Valgrind 的输出。它会告诉你有关程序中任何潜在问题的信息,例如未初始化的变量、访问越界等。 6. 最后,当你发现 bug 并修复它们后,可以运行各种测试用例来验证程序的正确性。测试用例可以帮助你确定程序是否按预期工作,并且在修改代码后,它们可以帮助你确保你的修复没有引入新的错误。 通过完成 CMU 15445 项目 0 的调试练习,你将掌握一些重要的调试技巧和工具,这对于进一步开发和调试软件应用程序将非常有用。希望上述步骤和建议对你有所帮助,祝你顺利完成这个项目!

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 26
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值