CMU15-445 Project3 Query Execution心得
Project3 Query Execution 心得
一、概述
首先要说:这个 project很有趣很硬核!从这个 project 开始才感觉自己在数据库方面真正成长了!
第一个 project :buffer pool manager 相对独立且简单,说白了就是使用 LRU-K 算法维护一个 page 数组,2022 fall 又加了一点内容:使用可扩展哈希来将对外暴露的 page_id 和数组下标映射起来。 2023 sping 又添加了 COW 的功能,不过 MITOS 已经写过相关 lab 了,也不眼馋了。
第二个 project 虽然烧脑,但是更加独立,有种 干货是有但 trick 更多的意味(个人感觉,手写 b+tree有一点浪费时间)。 虽然对 b+tree 有了全面的认识,但除了知道这是数据库的存储引擎外,和 bustub 也没有更多的互动。
但是第三个 project!我太喜欢了!这才是学数据库的感觉!太爽了!大量的源码阅读带来极致的好奇心的满足,如果说前两个 project 都是数据库的组件,这个 project 开始,终于要从宏观的角度观察数据库了!
同时想要独立完成 project 的同学不能心急,我自己的经验是:如果是第一次接触 project3 的内容,至少先沉下心来读 +调试 10 小时以上的源码,才有思路动手开始写,不然直接上手就是浪费时间或者糊里糊涂抄答案,没有任何意义。
最后这篇博客有两个目的:
- 记录我的学习心得,而不是project 的答案,我自己在开始做这个 project 时最大的难点是有一种“有劲没处使”的感觉,即我不怕智力挑战,但是怕的是我连怎么开始这个挑战都不明白,所以墙裂建议大家一定要先去读源码、跟源码!
- (以我自己微薄的力量尽可能地)受人以渔,所以会记录我自己怎么读源码,怎么调试 bug。
二、写的很好的相关博客
https://blog.csdn.net/Kprogram/article/details/125837906 :这位博主对源码的分析比较多
https://zhuanlan.zhihu.com/p/570917775:迟先生(bustub 源码重要贡献者)对 bustub 的说明,建议自己读完源码之后有了自己的感悟再来看这篇文章,才能有更深的体会
三、如何读 bustub源码
最好的方式就是跟一个 sql 语句的执行过程:
建议使用 clion在 tools/shell.cpp 中使用 debug 模式进行调试,如果使用 CLion,记得加参数:--disable-tty,它的含义是含义是禁用终端窗口(TTY)。这意味着在执行命令时,不会弹出一个新的终端窗口,而是在 CLion 的控制台输出中直接显示命令的结果。这个选项可以避免终端窗口的干扰。
比如我要跟一个 insert 语句的执行过程:
先执行建表语句 create table t1(v1 int, v2 varchar(128), v3 int);
然后执行insert into t1 values (0, '', 10);
并开始跟踪,进行单步调试,函数调用顺序基本上如下(下图是一个知乎老哥整理的,我找不到出处了,等找到补充一下),可以看到经过paser、binder、 planner 和 optimizer 之后,输出的执行计划树被送到了执行引擎,引擎中最重要的方法就是Execute()方法。
这里需要理解的一个关键是 plan node 究竟是什么?
我之前一直不理解 node 和 executor 的关系,在没有认真读源码之前,我一直觉得 node 和 executor 是一个东西,无非是执行计划树上的节点,但是现在我有了新的理解:node 确实是执行计划树上的节点,但是他不是具体的算法实现,以 aggregate 算子为例,aggregationPlanNode 记录了这条聚集语句的具体列的各种属性,比如:针对哪些列进行聚集?聚集的种类是什么?聚集的表达式树什么?node 实际上是为executor 准备数据,而 executor 负责具体实现,说来说去还是解耦那一套。
node作为计划树的一个节点,这个节点有两个成员变量:schema,用来指示这个 node 吐出的 tuple 是哪几列,children:用来指示 该 node 的子节点是哪些。把节点 paln_打印出来:下图可以看到根节点是 insertPlanNode,子节点是 ProjectionPlanNode,然后再子节点是 ValuesPlanNode。(这个 plan_ 其实就是根节点,但是其中包含了 children 的信息,可以根据这些信息推出整棵计划树的样貌,所以 plan_ 既可以理解为计划树的根节点,也可以理解为整棵计划树。)
然后把执行计划树 plan_送到优化器中,optimizer输出的执行计划树是 optimized_plan:
可以看到 ProjectionPlanNode 被优化了,只包含根节点 insertPlanNode和其子节点 ValuesPlanNode。
上图中 ValuesPlanNode 的 values_成员变量则分别存储了 (0, '', 10) ,每列用一个 expression 来表示,所以可以根据 values_ 构造出要插入的 tuple
这个optimized_plan就是被送入执行器的最终计划。
一定要熟悉的一个编程模式是火山模型如何不停地从子节点 pull tuple,答案就是下面这句:
while(executor->Next(&tuple, &rid)) {
//do anything you like
}
在每一个 executor 中(叶子节点除外,因为他没有 child,只能自己生产 tuple),一定会包含这句代码,正常情况下,Next()会返回 true,同时在 tuple 和 rid 中填充child 返回的那条 tuple。(一般情况下是在 Next()函数中调用while(executor->Next(&tuple, &rid))
,但有些算子是“pipe breaker”,比如 aggregate 和 sort,就需要在 Init()函数中调用while(executor->Next(&tuple, &rid))
)
四、以 Insert 为例,实现细节
前面已经讲了,一个 insert 语句优化出的结果是一个 InsertPlanNode 节点和 ValuesPlanNode 节点,我们的目标是实现 InsertExecutor 中的 Init 和 Next 方法,首先得把 ValuesPlanNode 和 ValuesExecutor 的源码看了吧
ValuesPlanNode 比较简单,最核心的数据结构是下面的values_,用来存储 rows of values,即行数据,根据values_就可以构造出 tuple
private:
std::vector<std::vector<AbstractExpressionRef>> values_;
ValuesExecutor 的Next()方法注释如下 :其中 AbstractExpressionRef -> Value -> Tuple 的转化太美妙了,所以ValuesExecutor 的Next()的结果就是吐出一条tuple,就是 sql 语句中自己的写的那条要插入的数据。
auto ValuesExecutor::Next(Tuple *tuple, RID *rid) -> bool {
if (cursor_ >= plan_->GetValues().size()) {
return false;
}
std::vector<Value> values{};
//预留空间
values.reserve(GetOutputSchema().GetColumnCount());
//如一条 sql 中一次插入 5 条数据,则cursor_取值为 0~4
const auto &row_expr = plan_->GetValues()[cursor_];
for (const auto &col : row_expr) {
//AbstractExpressionRef -> Value
values.push_back(col->Evaluate(nullptr, dummy_schema_));
}
//Value -> Tuple
*tuple = Tuple(values, &GetOutputSchema());
cursor_ += 1;
return true;
}
了解了 ValuesExecutor 的 Next() 方法之后,再写 InsertExecutor 就有些感觉了:
拿到要插入的 tuple:row 本来在 sql 中,经过 paser、binder、plaaner、optimizer 后 row 变成了 row_expr 并且存储到了 ValuesPlanNode 的 values_数据结构中(再一次体会到 node 是为 executor 准备数据),所以可以使用 ValuesPlanNode->GetValues() 方法拿到 row_expr,然后使用AbstractExpression->Evaluate() 方法将 row_expr 转换为 Value,Value是 Tuple 的重要参数,然后使用 Tuple 的构造函数可以构造出 Tuple,但是以上所有流程都是 bustub 已经实现好了,学生只需要使用 ValuesExecutor 的 Next()方法就可以拿到要插入的 tuple:
child_executor_->Next(tuple, rid)
插入到表中,这就要拿出这张图了,图源:(https://blog.eleven.wiki/posts/cmu15-445-project3-query-execution/#insert--delete),所以插入表就要我们再看源码,table heap、table page 好像都可以有插入表的方法,仔细看一下就知道table heap 中有已经实现了的方法:
InsertTuple()
/**
* Insert a tuple into the table. If the tuple is too large (>= page_size), return false.
* @param tuple tuple to insert
* @param[out] rid the rid of the inserted tuple
* @param txn the transaction performing the insert
* @return true iff the insert is successful
*/
auto InsertTuple(const Tuple &tuple, RID *rid, Transaction *txn) -> bool;
- 更新索引,索引都存储在在 Catalog 中,而且 Catalog 已经实现了GetTableIndexes() 方法,所以拿到索引后直接使用
InsertEntry()
方法,就可以更新索引。 - 注意输出,要求 insertPlanNode 吐出一条 tuple,用来指示多少行被插入了,注意这个吐出的 tuple 和用户要插入的 tuple 不是一个东西,你要自己构造一个 tuple,把插入的行数(比如 5)塞入这个 tuple 中,这就需要认真阅读 Tuple 的构造函数,如何把 5 塞进 Tuple 中,所谓返回 tuple。
实现以上4 点,就实现了 task1 中的 InsertExecutor,后面的几个 task 也是这种套路,但是有了这种阅读源码的思路,接下来不会有劲没处使了,祝大家顺利通关!
五、记录一次实际 debug 的过程
debug 步骤:
- 使用测试文件中的 sql 语句复现 bug
- 精确跟踪到报错语句
- 阅读源码并思考
我在写完delete 之后执行相应的测试,发现有以下错误:
复现 bug
可以看到上面的报错信息中给出了函数调用栈,所以可以很快确定错误位置在 DletePlanNode 中,于是首先要复现bug,先运行前置 sql 语句:
create table t1(v1 int, v2 varchar(128), v3 int);
insert into t1 values (0, '', 10), (1, '', 11), (2, '', 12), (3, '', 13), (4, '', 14);
select * from t1;
然后打断点(这里有一个小技巧:根据函数的调用栈打断点,比如根据上图,一次在 ExecuteSql、Execute、delete_executor::Init()等函数处依次打断点,一方面对于函数的执行流程更加了解,而且也可以迅速找到出错的位置),运行出错的语句:
delete from t1 where v1 >= 3
精确找到报错语句
单步跟踪后发现执行下面的语句就会报错,而且 idea 也在行的左边给出了错误提示感叹号,在下方的Debugger 显示界面可以看到是 table_oid_没有值导致的错误。
阅读源码并思考
进一步查询源码,会发现 table_oid_
是DeletePlanNode
的成员变量,给他赋值的语句应该是DeleteExecutor
的构造函数,转到DeleteExecutor
的构造函数:
发现果然没有在初始化列表中给DeletePlanNode
对象赋值,所以在初始换列表中添加:plan_(plan)
之后, bug 就可以顺利解决,当然还可能有其他 bug,但是根据我 30 小时+做project3 的经验,依据以上的步骤,目前还没有解决不了的 bug,所以加油吧!
六、project 中有趣的/开眼界/有坑的点
具体每个算子的实现我就不一一赘述了,上面的博客中的老哥写的很好了,我只记录一下自己印象深刻的几个点:
1. Aggregate 真的是用 hashmap 实现的
group by 语句使用的 Aggregate 算子竟然真的是用 hashmap 实现的。 Aggregate 算子的子算子就是普通的 SeqScanExecutor,在 AggregationExecutor 的Init()函数中,每次都会从 SeqScanExecutor中 pull 一条新的 tuple,然后根据 AgggationPlanNode 中的聚集类型、表达树式、聚集列去更新 hashmap 中的数据。
首先要明白 agg 算子的实现效果,比如一张表如下:
| 姓名 | 年龄 | 年级 |
| ---- | ---- | ---- |
| 张三 | 10 | 一 |
| 李四 | 20 | 二 |
| 王二 | 30 | 三 |
| 赵五 | 40 | 一 |
| 刘丹 | 50 | 二 |
使用 sql 语句 agg:
select grade, sum(age) as sum_age from table group by grade;
得到输出结果:
+-----------+-----------+
| grade | sum_age |
+-----------+-----------+
| 一 | 50 |
| 二 | 70 |
| 三 | 30 |
+-----------+-----------+
结果解释:
就是以 grade 种类分组,由于grade 只有{一、二、三}三种情况,所以 grade 为“一”有两条数据,sum_age就是 10+40 = 50,以此类推
agg 算子是如何实现的?
- Init() : 在 AggregationExecutor 的 Init() 函数中机上会构建一张 hash table,这张 hash table 构建完成后长这样:(hashmap 的内容就是最终的输出)
+-----------+-----------+
| agg_key | agg_value |
+-----------+-----------+
| 一 | {一,50} |
| 二 | {二,70} |
| 三 | {三,30} |
+-----------+-----------+
其中 key 就是 group by 列可能的所有情况,agg_value 就是表达式树进行聚集类型运算后的结果值,比如这条 sql 中有两颗表达树式:{grade, sum(age)}(因为AggregationExecutor的子节点是 SeqscanExecutor,所以SeqscanExecutor每吐一行新数据,AggregationExecutor->Init()就可以更新自己的 hash table,最终 Init()的结果就是构建一个完全体版本的 hash table)
- Next() : AggregationExecutor 的 Next() 函数中会维护一个 hash_table_iterator,每次吐一个 kv 对,返回给最上层 projecttionPlanNode,然后他会根据 schem 显示sql 的执行结果
2. NestedLoopJoin虽然逻辑简单,但是有坑
默认情况下,DBMS将对所有 join 操作使用NestedLoopJoinPlanNode(当然,要区分 inner join 和 left join)。NestedLoopJoin在 ppt 中也被称为 StupidNestedLoopJoin,逻辑非常简单,就是两层循环:拿着左表的 tuple 去遍历右表,找到了就返回。
易错点
双层循环结构中,左表需要遍历一次,右表需要遍历(左表条数)次,如果使用火山模型,直接写代码:
while (left_child->Next(&left_tuple)){
while (right_child->Next(&right_tuple)){
if (left_tuple matches right_tuple){
*tuple = ...; // assemble left & right together
return true;
}
}
}
return false;
根据SeqScanExecutor,看当内层的
while (right_child->Next(&right_tuple))
遍历完一次后,right_child->Next(&right_tuple)
就会返回 false,所以右表只能遍历一次,为了解决这个问题,可以把右表数据保存到内存中(如vector),这样右表就可以遍历任意次数了。如果左表的 left_tuple 已经匹配了右表的某一行:right_tuple,但是右表还没有扫描完成,NestedLoopJoinExecutor 吐出匹配的结果(left_tuple + right_tuple)后,下次调用 NestedLoopJoinExecutor 还要用这个左表行:left_tuple 接着扫描右表 right_tuple 的下一行(即:既不能取左表行的下一行,又不能将右表从头开始扫描)
所以就需要有一个全局游标记录扫描到了右表的哪一行,如果这个游标指向右表开头,才可以取左表一行新的数据,否则就要用旧的左表的tuple继续扫描右表
所以在代码实现中,要加入两个新变量:
- 存储右表数据的 vector
- 存储右表遍历到何处的游标 index
3. NestedIndexJoin 的优化规则
如果查询包含等值条件的连接,且连接的右表具有该列的索引,则DBMS将使用NestedlndexJoinPlanNode
CREATE TABLE t1(v1 int, v2 int);
CREATE TABLE t2(v3 int, v4 int);
CREATE INDEX t2v3 on t2(v3);
EXPLAIN SELECT * FROM t1 INNER JOIN t2 ON v1 = v3; //包含等值、且右表 t2 的 等值列:v3 的索引
=== PLANNER ===
Projection { exprs=[#0.0, #0.1, #0.2, #0.3] } | (t1.v1:INTEGER, t1.v2:INTEGER, t2.v3:INTEGER, t2.v4:INTEGER)
NestedLoopJoin { type=Inner, predicate=(#0.0=#1.0) } | (t1.v1:INTEGER, t1.v2:INTEGER, t2.v3:INTEGER, t2.v4:INTEGER)
SeqScan { table=t1 } | (t1.v1:INTEGER, t1.v2:INTEGER)
SeqScan { table=t2 } | (t2.v3:INTEGER, t2.v4:INTEGER)
=== OPTIMIZER ===
NestedIndexJoin { type=Inner, key_predicate=#0.0, index=t2v3, index_table=t2 } | (t1.v1:INTEGER, t1.v2:INTEGER, t2.v3:INTEGER, t2.v4:INTEGER)
SeqScan { table=t1 } | (t1.v1:INTEGER, t1.v2:INTEGER)
NestedIndexJoin 执行过程:
左表 (inner/left) JOIN 右表 ON 左表.col1 = 右表.col3;
右表的 col3 有索引,流程全部在 NestedIndexJoinExecutor 的 Next() 函数中处理
- 首先拿到左表的 col1 的值,然后在右表的索引中寻找这个值
- 一般情况下,索引中可以找到这个值对应的 rid,然后回表,把左表的 tuple 和右表的 tuple 连接起来,吐出去,结束。(假设索引中没有相同的值)
4. 初窥优化器
在task3 中需要实现 topn,就是把 limit + sort 节点优化为一个 topn 节点,具体来看,未优化之前:
bustub> EXPLAIN SELECT * FROM __mock_table_1 ORDER BY colA LIMIT 10;
=== PLANNER ===
Limit { limit=10 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
Sort { order_bys=[(Default, #0.0)] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
Projection { exprs=[#0.0, #0.1] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
=== OPTIMIZER ===
Limit { limit=10 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
Sort { order_bys=[(Default, #0.0)] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
优化之后,相同的 sql:
bustub> EXPLAIN SELECT * FROM __mock_table_1 ORDER BY colA LIMIT 10;
=== PLANNER ===
Limit { limit=10 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
Sort { order_bys=[(Default, #0.0)] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
Projection { exprs=[#0.0, #0.1] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
=== OPTIMIZER ===
TopN { n=10, order_bys=[(Default, #0.0)]} | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER)
可以看到 limit + sort 节点已经优化为一个 topn 节点
优化器的实现框架是很简单的,如下所示,把planner 输出的初始计划输入到优化器中,优化器会遍历所有的优化规则,命中哪一个规则,就执行相应的优化
auto Optimizer::OptimizeCustom(const AbstractPlanNodeRef &plan) -> AbstractPlanNodeRef {
auto p = plan;
//所有的优化规则
p = OptimizeMergeProjection(p);
p = OptimizeMergeFilterNLJ(p);
p = OptimizeNLJAsIndexJoin(p);
// p = OptimizeNLJAsHashJoin(p); // Enable this rule after you have implemented hash join.
p = OptimizeOrderByAsIndexScan(p);
p = OptimizeSortLimitAsTopN(p);
return p;
}
而且优化的执行过程也很有趣,比如我们的原始计划是树状: LimitPlanNode->SortPlanNode->SeqScanPlanNode,参考其他优化器的代码可以发现,优化器执行优化的逻辑是:使用递归后序遍历这棵树,遍历过程中,每次得到的子树都会去判断是否满足( LimitPlanNode->SortPlanNode)条件,满足则进行替换,最终得到 optimize_plan~
所以 topn 的有趣的地方不在于写 limit+sort 逻辑,而是在如何写优化器这里的替换逻辑,但只要记住:后序遍历原始计划树,然后判断子树是否满足优化条件就可以完成~
七、测试
提醒一下,大家写注释的时候 // 之后一定要跟空格,否则make check-lint
会报错!
andy 终于大气了一次,给出了所有的测试用例,不用在 gradescope 上找报错信息了
make -j$(nproc) sqllogictest
./bin/bustub-sqllogictest ../test/sql/p3.00-primer.slt --verbose
将 p3.00-primer.slt 改成 /test/sql 文件夹下的其他测试文件,然后单独测试每个算子即可。
ps:虽然 p3 没有隐藏测试用例,但是线上测试会检查内存泄漏问题,被查到依旧无法通过线上测试,所以编码时要注意这一点,比如不要随意使用 new 来构造对象。
最后贴张通过的图吧,好歹纪念下哈哈~
CMU15-445 Project3 Query Execution心得的更多相关文章
- Query execution was interrupted, max_statement_time exceeded
版本:5.6.16 群里看见一个问题,在备份的时候,报如下错误:[root@B28-19-75 bak]# mysqldump -root -p --single-transaction --mast ...
- Multiple Server Query Execution报The result set could not be merged..
在SQL Server中使用Multiple Server Query Execution这个功能做数据库维护或脚本发布时非常方便,昨天由于磁盘空间原因,删除清理了大量的软件和组件,结果导致SSMS客 ...
- (十二)数据库查询处理之Query Execution(1)
(十二)数据库查询处理之Query Execution(1) 1. 写在前面 这一大部分就是为了Lab3做准备的 每一个query plan都要实现一个next函数和一个init函数 对于next函数 ...
- ClickHouse之Distributed Query Execution
原文地址:https://clickhouse.yandex/docs/en/development/architecture/ 集群中的所有节点都是彼此独立的,当你在集群中的一个节点或者多个节点创建 ...
- 一次MySQL异常排查:Query execution was interrupted
异常日志: 查询被中断了,先是在Google上查,又是再百度上查,基本上都是说程序超时设置setQueryTimeout的问题,就是说查询时间超过了设置的最大查询时间,导致查询被中断.我也没办法断定是 ...
- 对数据集“dsArea”执行查询失败。 (rsErrorExecutingCommand),Query execution failed for dataset 'dsArea'. (rsErrorExecutingCommand),Manually process the TFS data warehouse and analysis services cube
错误提示: 处理报表时出错. (rsProcessingAborted)对数据集“dsArea”执行查询失败. (rsErrorExecutingCommand)Team System 多维数据集或者 ...
- CMU15445 (Fall 2019) 之 Project#3 - Query Execution 详解
前言 经过前面两个实验的铺垫,终于到了给数据库系统添加执行查询计划功能的时候了.给定一条 SQL 语句,我们可以将其中的操作符组织为一棵树,树中的每一个父节点都能从子节点获取 tuple 并处理成操作 ...
- Limit the query running time with Resource limit facility (RLF)
If you need to limit the query(package,plan) running time, but the JCL/JOB TIME parameters doesn't w ...
- Inside TSQL Querying - Chapter 2. Physical Query Processing
Summary Description The SQL language is spoken by most database experts, and all relational database ...
- 在执行context.getContentResolver.query()方法时出现错误。
1. 在执行context.getContentResolver.query()方法时出现错误. 07-15 18:46:13.470: E/AndroidRuntime(13624): FATAL ...
随机推荐
- linux网络开发者定位问题常用工具和命令总结
本文章来自我的微信个人技术公众号---网络技术修炼,公众号中总结普及网络基础知识,包括基础原理.网络方案.开发经验和问题定位案例等,欢迎关注. Linux网络开发者面临的问题往往比较复杂,因此需要使用 ...
- React课堂笔记2
一.JSX 1.1.什么是JSX JSX = JavaScript XML,这是React官方发明的一种JS语法(糖) 概念:JSX是 JavaScript XML(HTML)的缩写,表示在 JS 代 ...
- 谈谈selenium中的clear后输入内容异常的处理
谈谈selenium中的clear后输入内容异常的处理 案例 在线考试项目的登录:http://124.223.31.21:9097/#/ 代码 from selenium import webdri ...
- 通过重构来加深理解——DDD
上部分模型驱动设计的构造块为维护模型和实现之间的关系打下了基础.在开发过程中使用一系列成熟的基本构造块并运用一致的语言,能够使开发工作更加清晰而有条理. 我们面临的真正挑战是找到深层次的模型,这个模型 ...
- Prism Sample 21-PassingParameters
这个例子是说明导航中传递参数,类似Asp.net中实现. 例子的模板,是例16中使用regionContext实现过的.在例16中, <Grid x:Name="LayoutRoot& ...
- 字符串处理------Brute Force与KMP
一,字符串的简单介绍 例:POJ1488 http://poj.org/problem?id=1488 题意:替换文本中的双引号: #include <iostream> #includ ...
- 2022-11-26:给定一个字符串s,只含有0~9这些字符 你可以使用来自s中的数字,目的是拼出一个最大的回文数 使用数字的个数,不能超过s里含有的个数 比如 : 39878,能拼出的最大回文数是
2022-11-26:给定一个字符串s,只含有0~9这些字符 你可以使用来自s中的数字,目的是拼出一个最大的回文数 使用数字的个数,不能超过s里含有的个数 比如 : 39878,能拼出的最大回文数是 ...
- Redis数据结构二之SDS和双向链表
本文首发于公众号:Hunter后端 原文链接:Redis数据结构二之SDS和双向链表 这一篇笔记介绍一下 SDS(simple dynamic string)和双向链表. 以下是本篇笔记目录: SDS ...
- APRIL 2022-Explanation-Aware Experience Replay in Rule-Dense Environments
I. INTRODUCTION 解释是人类智能的关键机制,这种机制有可能提高RL代理在复杂环境中的表现 实现这一目标的一个核心设计挑战是将解释集成到计算表示中.即使在最小的规则集变化下,将规则集(或部 ...
- L3-017 森森快递
一.题目: 7-2 森森快递 (30 分) 森森开了一家快递公司,叫森森快递.因为公司刚刚开张,所以业务路线很简单,可以认为是一条直线上的N个城市,这些城市从左到右依次从0到(N−1)编号.由于道路限 ...