数据库的设计三范式:
+>
+> - 1NF:字段不可分。第一范式要求数据原子化
+> - 2NF:唯一性 一个表只说明一个事物。有主键,非主键字段依赖主键。第二范式消除部分依赖
+> - 3NF:非主键字段不能相互依赖。第三范式消除传递依赖,从而提高数据的完整性和一致性
+
+在当今信息化时代,数据库已经成为各行各业中不可或缺的一部分。从小型应用到企业级系统,数据库都在背后默默支撑着数据的存储、管理与分析。而在数据库设计中,**三范式**(3NF)是关系型数据库模型设计中最为基础和重要的规范化原则之一。三范式的核心目标是通过规范化数据库的结构,减少冗余数据,增强数据一致性和完整性,避免更新异常,确保数据的高效存储与访问。
+
+### 什么是数据库规范化?
+
+数据库规范化(Normalization)是指对数据库表的设计进行优化的过程,通过一系列规则来消除冗余数据,并提高数据的组织方式。规范化不仅帮助减少数据的重复性,还确保数据之间的逻辑关系清晰,减少因数据修改引发的不一致问题。
+
+规范化的核心思想是通过分解数据库表,降低数据冗余性和依赖性。在实际操作中,数据库规范化通常分为多个阶段,称为**范式**。每一范式都是在前一范式的基础上进一步精化和完善的。三范式是数据库设计中最为基础且常用的范式,它通过消除部分依赖和传递依赖等问题,保证数据库的高效性与一致性。
+
+### 第一范式(1NF)
+
+**第一范式**是数据库规范化的最基础范式,它的要求是:**表中的每个列必须包含原子值,即每个字段只能存储不可再分的单一数据单元**。简而言之,1NF 要求每个列的数据必须是“原子的”,也就是没有重复数据和多值属性。
+
+#### 1NF的具体要求:
+
+1. 每列中的数据必须是不可分割的单一数据项。
+2. 每一行必须唯一,不允许有重复的记录。
+3. 表中的每个字段都必须具有唯一的标识符(即主键)。
+
+**示例**: 假设我们有一个学生信息表,如下所示:
+
+| 学生ID | 姓名 | 电话号码 |
+| ------ | ---- | ---------------------- |
+| 1 | 张三 | 1234567890, 0987654321 |
+| 2 | 李四 | 1357924680, 2468013579 |
+
+从上表可以看出,"电话号码"字段存储了多个值,违反了1NF。要满足1NF要求,电话号码字段应拆分为多行或多列,像这样:
+
+| 学生ID | 姓名 | 电话号码 |
+| ------ | ---- | ---------- |
+| 1 | 张三 | 1234567890 |
+| 1 | 张三 | 0987654321 |
+| 2 | 李四 | 1357924680 |
+| 2 | 李四 | 2468013579 |
+
+这样,每个字段就变成了原子值,符合了1NF的要求。
+
+### 第二范式(2NF)
+
+第二范式建立在第一范式的基础之上,要求**消除部分依赖**。具体而言,2NF要求表中的所有非主属性(即不参与主键的字段)必须完全依赖于主键,而不能仅依赖于主键的一部分。换句话说,表中的每一个非主属性必须依赖于完整的主键,而不是主键的某一部分。
+
+#### 2NF的要求:
+
+1. 表必须满足1NF。
+2. 表中的每个非主键列必须完全依赖于整个主键。
+3. 如果表有复合主键(由多个列组成的主键),则不能有部分依赖。
+
+**示例**: 考虑以下的订单表:
+
+| 订单ID | 产品ID | 产品名称 | 数量 | 单价 |
+| ------ | ------ | -------- | ---- | ---- |
+| 1 | 101 | 手机 | 2 | 2000 |
+| 1 | 102 | 耳机 | 1 | 500 |
+| 2 | 101 | 手机 | 1 | 2000 |
+
+在这个例子中,复合主键由**订单ID**和**产品ID**组成。虽然**产品名称**只依赖于**产品ID**,但它却被存储在了当前的表中,这种依赖关系违反了2NF。我们可以通过拆分表格来消除部分依赖:
+
+**订单表:**
+
+| 订单ID | 产品ID | 数量 | 单价 |
+| ------ | ------ | ---- | ---- |
+| 1 | 101 | 2 | 2000 |
+| 1 | 102 | 1 | 500 |
+| 2 | 101 | 1 | 2000 |
+
+**产品表:**
+
+| 产品ID | 产品名称 |
+| ------ | -------- |
+| 101 | 手机 |
+| 102 | 耳机 |
+
+通过这样的拆分,我们确保了所有非主键字段完全依赖于复合主键,符合了2NF。
+
+### 第三范式(3NF)
+
+第三范式是在第二范式的基础上进一步规范化,它要求消除**传递依赖**。传递依赖是指某个非主键列依赖于另一个非主键列,而这个非主键列又依赖于主键。换句话说,第三范式要求表中的每个非主属性都直接依赖于主键,而不是间接依赖。
+
+#### 3NF的要求:
+
+1. 表必须满足2NF。
+2. 表中的每个非主键列必须直接依赖于主键,不能依赖于其他非主键列。
+
+**示例**: 考虑以下员工表:
+
+| 员工ID | 部门ID | 部门名称 | 部门经理 |
+| ------ | ------ | -------- | -------- |
+| 1 | 101 | 销售部 | 王经理 |
+| 2 | 102 | 技术部 | 张经理 |
+
+在这个例子中,**部门名称**和**部门经理**依赖于**部门ID**,而**部门ID**又依赖于**员工ID**,这就构成了传递依赖。为了满足3NF,我们应该将部门相关信息提取到单独的表中:
+
+**员工表:**
+
+| 员工ID | 部门ID |
+| ------ | ------ |
+| 1 | 101 |
+| 2 | 102 |
+
+**部门表:**
+
+| 部门ID | 部门名称 | 部门经理 |
+| ------ | -------- | -------- |
+| 101 | 销售部 | 王经理 |
+| 102 | 技术部 | 张经理 |
+
+通过这样的拆分,我们消除了传递依赖,确保了表符合3NF的要求。
+
+### 规范化的优点与实际应用
+
+数据库规范化的主要优点是减少冗余数据,提高数据的一致性和完整性。通过规范化,数据的修改操作变得更加简便,不容易出现更新异常,例如**插入异常**、**删除异常**和**更新异常**。
+
+然而,在实际应用中,数据库设计并非总是严格遵循最高范式。过度规范化可能会导致表的拆分过多,从而影响查询性能。在这种情况下,数据库设计师可能会选择在一定程度上**反规范化**,即故意增加一些冗余数据,以提高查询效率。
+
+### 总结
+
+数据库的三范式是关系型数据库设计的基础,它通过消除数据冗余和不必要的依赖关系,帮助提高数据的一致性和完整性。第一范式要求数据原子化,第二范式消除部分依赖,而第三范式消除传递依赖。在实际的数据库设计中,三范式为我们提供了科学的规范化思路,但在一些特定场景下,可能需要适当进行反规范化来优化查询性能。
+
+了解并掌握三范式的概念,能帮助开发人员在设计数据库时做出更加高效且一致的决策,提升数据库系统的整体性能和可维护性。
+
diff --git a/docs/data-management/MySQL/readMySQL.md b/docs/data-management/MySQL/readMySQL.md
index 20e4d4e390..c8f198ff9f 100644
--- a/docs/data-management/MySQL/readMySQL.md
+++ b/docs/data-management/MySQL/readMySQL.md
@@ -3,9 +3,6 @@
---
-
-
-
@@ -26,7 +23,7 @@ MySQL是个啥,就说一句话——**MySQL是一个关系型数据库管理
-
+
diff --git "a/docs/data-management/MySQL/Int(4)\345\222\214Int(11) \351\200\211\345\223\252\344\270\252\357\274\237.md" "b/docs/data-management/MySQL/reproduce/Int(4)\345\222\214Int(11) \351\200\211\345\223\252\344\270\252\357\274\237.md"
similarity index 100%
rename from "docs/data-management/MySQL/Int(4)\345\222\214Int(11) \351\200\211\345\223\252\344\270\252\357\274\237.md"
rename to "docs/data-management/MySQL/reproduce/Int(4)\345\222\214Int(11) \351\200\211\345\223\252\344\270\252\357\274\237.md"
diff --git a/docs/data-management/MySQL/MySQL-count.md b/docs/data-management/MySQL/reproduce/MySQL-count.md
similarity index 100%
rename from docs/data-management/MySQL/MySQL-count.md
rename to docs/data-management/MySQL/reproduce/MySQL-count.md
diff --git "a/docs/data-management/MySQL/MySQL\346\211\271\351\207\217\346\217\222\345\205\245\357\274\214\344\270\215\346\217\222\345\205\245\351\207\215\345\244\215\346\225\260\346\215\256.md" "b/docs/data-management/MySQL/reproduce/MySQL\346\211\271\351\207\217\346\217\222\345\205\245\357\274\214\344\270\215\346\217\222\345\205\245\351\207\215\345\244\215\346\225\260\346\215\256.md"
similarity index 100%
rename from "docs/data-management/MySQL/MySQL\346\211\271\351\207\217\346\217\222\345\205\245\357\274\214\344\270\215\346\217\222\345\205\245\351\207\215\345\244\215\346\225\260\346\215\256.md"
rename to "docs/data-management/MySQL/reproduce/MySQL\346\211\271\351\207\217\346\217\222\345\205\245\357\274\214\344\270\215\346\217\222\345\205\245\351\207\215\345\244\215\346\225\260\346\215\256.md"
diff --git "a/docs/data-management/MySQL/\344\272\222\350\201\224\347\275\221\345\270\270\347\224\250\345\210\206\345\272\223\345\210\206\350\241\250\346\226\271\346\241\210.md" "b/docs/data-management/MySQL/reproduce/\344\272\222\350\201\224\347\275\221\345\270\270\347\224\250\345\210\206\345\272\223\345\210\206\350\241\250\346\226\271\346\241\210.md"
similarity index 100%
rename from "docs/data-management/MySQL/\344\272\222\350\201\224\347\275\221\345\270\270\347\224\250\345\210\206\345\272\223\345\210\206\350\241\250\346\226\271\346\241\210.md"
rename to "docs/data-management/MySQL/reproduce/\344\272\222\350\201\224\347\275\221\345\270\270\347\224\250\345\210\206\345\272\223\345\210\206\350\241\250\346\226\271\346\241\210.md"
diff --git "a/docs/data-management/MySQL/\346\200\247\350\203\275\344\274\230\345\214\226\344\271\213\345\210\206\351\241\265\346\237\245\350\257\242.md" "b/docs/data-management/MySQL/reproduce/\346\200\247\350\203\275\344\274\230\345\214\226\344\271\213\345\210\206\351\241\265\346\237\245\350\257\242.md"
similarity index 100%
rename from "docs/data-management/MySQL/\346\200\247\350\203\275\344\274\230\345\214\226\344\271\213\345\210\206\351\241\265\346\237\245\350\257\242.md"
rename to "docs/data-management/MySQL/reproduce/\346\200\247\350\203\275\344\274\230\345\214\226\344\271\213\345\210\206\351\241\265\346\237\245\350\257\242.md"
diff --git "a/docs/data-management/MySQL/\346\225\260\346\215\256\345\272\223\344\270\211\350\214\203\345\274\217.md" "b/docs/data-management/MySQL/\346\225\260\346\215\256\345\272\223\344\270\211\350\214\203\345\274\217.md"
deleted file mode 100644
index e8f391d2a9..0000000000
--- "a/docs/data-management/MySQL/\346\225\260\346\215\256\345\272\223\344\270\211\350\214\203\345\274\217.md"
+++ /dev/null
@@ -1,96 +0,0 @@
-# 数据库三范式
-
-## mysql 数据库的设计三范式
-
-1NF:字段不可分;
-
-2NF:有主键,非主键字段依赖主键;
-
-3NF:非主键字段不能相互依赖;
-
-解释:
-
-1NF:原子性 字段不可再分,否则就不是关系数据库;
-
-2NF:唯一性 一个表只说明一个事物;
-
-3NF:每列都与主键有直接关系,不存在传递依赖;
-
-#### 第一范式(1NF)
-
-即表的列的具有原子性,不可再分解,即列的信息,不能分解, 只要数据库是关系型数据库(mysql/oracle/db2/informix/sysbase/sql server),就自动的满足1NF。数据库表的每一列都是不可分割的原子数据项,而不能是集合,数组,记录等非原子数据项。如果实体中的某个属性有多个值时,必须拆分为不同的属性 。通俗理解即一个字段只存储一项信息。
-
-
-
-关系型数据库: mysql/oracle/db2/informix/sysbase/sql server 非关系型数据库: (特点: 面向对象或者集合) NoSql数据库: MongoDB/redis(特点是面向文档)
-
-#### 第二范式(2NF)
-
-第二范式(2NF)是在第一范式(1NF)的基础上建立起来的,即满足第二范式(2NF)必须先满足第一范式(1NF)。第二范式(2NF)要求数据库表中的每个实例或行必须可以被惟一地区分。为实现区分通常需要我们设计一个主键来实现(这里的主键不包含业务逻辑)。
-
-即满足第一范式前提,当存在多个主键的时候,才会发生不符合第二范式的情况。比如有两个主键,不能存在这样的属性,它只依赖于其中一个主键,这就是不符合第二范式。通俗理解是任意一个字段都只依赖表中的同一个字段。(涉及到表的拆分)
-
-看下面的学生选课表:
-
-| 学号 | 课程 | 成绩 | 课程学分 |
-| ----- | ---- | ---- | -------- |
-| 10001 | 数学 | 100 | 6 |
-| 10001 | 语文 | 90 | 2 |
-| 10001 | 英语 | 85 | 3 |
-| 10002 | 数学 | 90 | 6 |
-| 10003 | 数学 | 99 | 6 |
-| 10004 | 语文 | 89 | 2 |
-
-表中主键为 (学号,课程),我们可以表示为 (学号,课程) -> (成绩,课程学分), 表示所有非主键列 (成绩,课程学分)都依赖于主键 (学号,课程)。 但是,表中还存在另外一个依赖:(课程)->(课程学分)。这样非主键列 ‘课程学分‘ 依赖于部分主键列 ’课程‘, 所以上表是不满足第二范式的。
-
-我们把它拆成如下2张表:
-
-
-
-学生选课表:
-
-| 学号 | 课程 | 成绩 |
-| ----- | ---- | ---- |
-| 10001 | 数学 | 100 |
-| 10001 | 语文 | 90 |
-| 10001 | 英语 | 85 |
-| 10002 | 数学 | 90 |
-| 10003 | 数学 | 99 |
-| 10004 | 语文 | 89 |
-
-课程信息表:
-
-| 课程 | 课程学分 |
-| ---- | -------- |
-| 数学 | 6 |
-| 语文 | 3 |
-| 英语 | 2 |
-
-那么上面2个表,学生选课表主键为(学号,课程),课程信息表主键为(课程),表中所有非主键列都完全依赖主键。不仅符合第二范式,还符合第三范式。
-
-
-
-再看这样一个学生信息表:
-
-| 学号 | 姓名 | 性别 | 班级 | 班主任 |
-| ----- | ------ | ---- | ---- | ------ |
-| 10001 | 张三 | 男 | 一班 | 小王 |
-| 10002 | 李四 | 男 | 一班 | 小王 |
-| 10003 | 王五 | 男 | 二班 | 小李 |
-| 10004 | 张小三 | 男 | 二班 | 小李 |
-
-上表中,主键为:(学号),所有字段 (姓名,性别,班级,班主任)都依赖与主键(学号),不存在对主键的部分依赖。所以是满足第二范式。
-
-
-
-#### 第三范式(3NF)
-
-满足第三范式(3NF)必须先满足第二范式(2NF)。简而言之,第三范式(3NF)要求一个数据库表中不包含已在其它表中已包含的非主键字段。就是说,表的信息,如果能够被推导出来,就不应该单独的设计一个字段来存放(能尽量外键join就用外键join)。很多时候,我们为了满足第三范式往往会把一张表分成多张表。
-
-即满足第二范式前提,如果某一属性依赖于其他非主键属性,而其他非主键属性又依赖于主键,那么这个属性就是间接依赖于主键,这被称作传递依赖于主属性。 通俗解释就是一张表最多只存两层同类型信息。
-
-
-
-反三范式
-
-没有冗余的数据库未必是最好的数据库,有时为了提高运行效率,提高读性能,就必须降低范式标准,适当保留冗余数据。具体做法是: 在概念数据模型设计时遵守第三范式,降低范式标准的工作放到物理数据模型设计时考虑。降低范式就是增加字段,减少了查询时的关联,提高查询效率,因为在数据库的操作中查询的比例要远远大于DML的比例。但是反范式化一定要适度,并且在原本已满足三范式的基础上再做调整的。
\ No newline at end of file
diff --git a/docs/data-management/Redis/.DS_Store b/docs/data-management/Redis/.DS_Store
index 4874b74fa2..45706ae4e1 100644
Binary files a/docs/data-management/Redis/.DS_Store and b/docs/data-management/Redis/.DS_Store differ
diff --git a/docs/data-management/Redis/Nosql-Overview.md b/docs/data-management/Redis/Nosql-Overview.md
index d432e219c0..4e5c48ff95 100644
--- a/docs/data-management/Redis/Nosql-Overview.md
+++ b/docs/data-management/Redis/Nosql-Overview.md
@@ -1,9 +1,5 @@
-
-
# NoSQL的前世今生
-> Java大猿帅成长手册,**GitHub** [JavaEgg](https://github.com/Jstarfish/JavaEgg) ,N线互联网开发必备技能兵器谱
-
### 啥玩意:
NoSQL(NoSQL = Not Only SQL ),“不仅仅是SQL”,泛指**非关系型的数据库**。随着互联网web2.0网站的兴起,传统的关系数据库在处理web2.0网站,特别是超大规模和高并发的SNS类型的web2.0纯动态网站已经显得力不从心,暴露了很多难以克服的问题,而非关系型的数据库则由于其本身的特点得到了非常迅速的发展。NoSQL数据库的产生就是为了解决大规模数据集合多重数据种类带来的挑战,尤其是大数据应用难题,包括超大规模数据的存储。(例如谷歌或Facebook每天为他们的用户收集万亿比特的数据)。这些类型的数据存储不需要固定的模式,无需多余操作就可以横向扩展。
@@ -14,7 +10,7 @@ NoSQL(NoSQL = Not Only SQL ),“不仅仅是SQL”,泛指**非关系型的
#### 1. 单机MySQL的美好年代
- 在以前,一个网站的访问量一般都不大,用单个数据库完全可以轻松应付。在那个时候,更多的都是静态网页,动态交互类型的网站不多。上述架构下,我们来看看数据存储的瓶颈是什么?
+在以前,一个网站的访问量一般都不大,用单个数据库完全可以轻松应付。在那个时候,更多的都是静态网页,动态交互类型的网站不多。上述架构下,我们来看看数据存储的瓶颈是什么?
- 数据量的总大小 一个机器放不下时
- 数据的索引(B+ Tree)一个机器的内存放不下时
@@ -22,29 +18,29 @@ NoSQL(NoSQL = Not Only SQL ),“不仅仅是SQL”,泛指**非关系型的
#### 2. Memcached(缓存)+MySQL+垂直拆分
- 后来,随着访问量的上升,几乎大部分使用MySQL架构的网站在数据库上都开始出现了性能问题,web程序不再仅仅专注在功能上,同时也在追求性能。程序员们开始大量的使用**缓存技术**来缓解数据库的压力,优化数据库的结构和索引。开始比较流行的是通过**文件缓存**来缓解数据库压力,但是当访问量继续增大的时候,多台web机器通过文件缓存不能共享,大量的小文件缓存也带了了比较高的IO压力。在这个时候,Memcached就自然的成为一个非常时尚的技术产品。
+后来,随着访问量的上升,几乎大部分使用MySQL架构的网站在数据库上都开始出现了性能问题,web程序不再仅仅专注在功能上,同时也在追求性能。程序员们开始大量的使用**缓存技术**来缓解数据库的压力,优化数据库的结构和索引。开始比较流行的是通过**文件缓存**来缓解数据库压力,但是当访问量继续增大的时候,多台web机器通过文件缓存不能共享,大量的小文件缓存也带了了比较高的IO压力。在这个时候,Memcached就自然的成为一个非常时尚的技术产品。
- Memcached作为一个**独立的分布式的缓存服务器**,为多个web服务器提供了一个共享的高性能缓存服务,在Memcached服务器上,又发展了根据hash算法来进行多台Memcached缓存服务的扩展,然后又出现了一致性hash来解决增加或减少缓存服务器导致重新hash带来的大量缓存失效的弊端
+Memcached作为一个**独立的分布式的缓存服务器**,为多个web服务器提供了一个共享的高性能缓存服务,在Memcached服务器上,又发展了根据hash算法来进行多台Memcached缓存服务的扩展,然后又出现了一致性hash来解决增加或减少缓存服务器导致重新hash带来的大量缓存失效的弊端
#### 3. Mysql主从读写分离
- 由于数据库的写入压力增加,Memcached只能缓解数据库的读取压力。读写集中在一个数据库上让数据库不堪重负,大部分网站开始**使用主从复制技术来达到读写分离,以提高读写性能和读库的可扩展性**。**Mysql的master-slave模式**成为这个时候的网站标配了。
+由于数据库的写入压力增加,Memcached只能缓解数据库的读取压力。读写集中在一个数据库上让数据库不堪重负,大部分网站开始**使用主从复制技术来达到读写分离,以提高读写性能和读库的可扩展性**。**Mysql的master-slave模式**成为这个时候的网站标配了。
#### 4. 分表分库+水平拆分+mysql集群
- 在Memcached的高速缓存,MySQL的主从复制,读写分离的基础之上,这时MySQL主库的写压力开始出现瓶颈,而数据量的持续猛增,由于**MyISAM**使用**表锁**,在高并发下会出现严重的锁问题,大量的高并发MySQL应用开始使用**InnoDB**引擎代替MyISAM。
+在Memcached的高速缓存,MySQL的主从复制,读写分离的基础之上,这时MySQL主库的写压力开始出现瓶颈,而数据量的持续猛增,由于**MyISAM**使用**表锁**,在高并发下会出现严重的锁问题,大量的高并发MySQL应用开始使用**InnoDB**引擎代替MyISAM。
- 同时,开始流行**使用分表分库来缓解写压力和数据增长的扩展问题**。这个时候,分表分库成了一个热门技术,是面试的热门问题也是业界讨论的热门技术问题。也就在这个时候,MySQL推出了还不太稳定的表分区,这也给技术实力一般的公司带来了希望。虽然MySQL推出了MySQL Cluster集群,但性能也不能很好满足互联网的要求,只是在高可靠性上提供了非常大的保证。
+同时,开始流行**使用分表分库来缓解写压力和数据增长的扩展问题**。这个时候,分表分库成了一个热门技术,是面试的热门问题也是业界讨论的热门技术问题。也就在这个时候,MySQL推出了还不太稳定的表分区,这也给技术实力一般的公司带来了希望。虽然MySQL推出了MySQL Cluster集群,但性能也不能很好满足互联网的要求,只是在高可靠性上提供了非常大的保证。
#### 5. MySQL的扩展性瓶颈
- MySQL数据库也经常存储一些大文本字段,导致数据库表非常的大,在做数据库恢复的时候就导致非常的慢,不容易快速恢复数据库。比如1000万4KB大小的文本就接近40GB的大小,如果能把这些数据从MySQL省去,MySQL将变得非常的小。关系数据库很强大,但是它并不能很好的应付所有的应用场景。MySQL的扩展性差(需要复杂的技术来实现),大数据下IO压力大,表结构更改困难,正是当前使用MySQL的开发人员面临的问题。
+MySQL数据库也经常存储一些大文本字段,导致数据库表非常的大,在做数据库恢复的时候就导致非常的慢,不容易快速恢复数据库。比如1000万4KB大小的文本就接近40GB的大小,如果能把这些数据从MySQL省去,MySQL将变得非常的小。关系数据库很强大,但是它并不能很好的应付所有的应用场景。MySQL的扩展性差(需要复杂的技术来实现),大数据下IO压力大,表结构更改困难,正是当前使用MySQL的开发人员面临的问题。
#### 6. 为什么用NoSQL
- 今天我们可以通过第三方平台(如:Google,Facebook等)可以很容易的**访问和抓取数据**(爬虫私密信息有风险哈)。用户的个人信息,社交网络,地理位置,用户生成的数据和用户操作日志已经成倍的增加。我们如果要对这些用户数据进行挖掘,那SQL数据库已经不适合这些应用了, NoSQL数据库的发展也不能很好的处理这些大的数据。
+今天我们可以通过第三方平台(如:Google,Facebook等)可以很容易的**访问和抓取数据**(爬虫私密信息有风险哈)。用户的个人信息,社交网络,地理位置,用户生成的数据和用户操作日志已经成倍的增加。我们如果要对这些用户数据进行挖掘,那SQL数据库已经不适合这些应用了, NoSQL数据库的发展也不能很好的处理这些大的数据。
-
+
diff --git a/docs/data-management/Redis/ReadRedis.md b/docs/data-management/Redis/ReadRedis.md
index 231c9c09d6..2ecbb031b8 100644
--- a/docs/data-management/Redis/ReadRedis.md
+++ b/docs/data-management/Redis/ReadRedis.md
@@ -1,4 +1,4 @@
-
+
@@ -18,96 +18,64 @@
Redis: **REmote DIctionary Server**(远程字典服务器)。
-Redis 是一个全开源免费(BSD许可)的,内存中的数据结构存储系统,它可以用作**数据库、缓存和消息中间件**。一般作为一个高性能的(key/value)分布式内存数据库,基于**内存**运行并支持持久化的 NoSQL 数据库,是当前最热门的 NoSql 数据库之一,也被人们称为**数据结构服务器**
+Redis 是一个全开源免费(BSD许可)的,使用 C 语言编写,内存中的数据结构存储系统,它可以用作**数据库、缓存和消息中间件**。一般作为一个高性能的(key/value)分布式内存数据库,基于**内存**运行并支持持久化的 NoSQL 数据库,是当前最热门的 NoSql 数据库之一,也被人们称为**数据结构服务器**
-
+它支持多种数据结构,如字符串(Strings)、哈希(Hashes)、列表(Lists)、集合(Sets)、有序集合(Sorted Sets)、位图(bitmaps)、HyperLogLogs 和地理空间索引(geospatial indexes),并带有半持久化存储的选项。
-## Redis 介绍
+### 主要特点
-Redis 是一个开源的、使用 C 语言编写的、支持网络交互的、可基于内存也可持久化的 Key-Value 数据库。
+1. **高性能**:Redis 的读写速度非常快,支持每秒百万级的请求处理能力。其所有数据都存储在内存中,保证了极高的读写性能。
+2. **多种数据结构**:Redis 不仅支持简单的键值对,还支持多种高级数据结构,如列表、集合、有序集合、哈希等,可以满足复杂的数据存储需求。
+3. **持久化**:Redis 支持多种持久化机制,如 RDB(快照)和 AOF(追加文件),可以将内存中的数据持久化到磁盘,保证数据的持久性。
+4. **高可用性**:通过 Redis 的复制(Replication)、Sentinel 和 Cluster 特性,可以实现高可用性和自动故障转移。复制机制允许数据从主节点复制到多个从节点,从而提高数据的冗余性和读取的可扩展性。
+5. **Lua脚本**:Redis 内置 Lua 脚本引擎,支持在服务器端运行复杂的脚本,减少了网络往返次数,提高了操作的原子性。
+6. **事务支持**:Redis 支持事务,通过 MULTI、EXEC、DISCARD 和 WATCH 等命令实现事务操作。
+7. **发布/订阅**:Redis 提供了发布/订阅功能,可以实现消息通知和实时消息传递。
+8. **丰富的生态系统**:Redis 拥有丰富的客户端库,支持多种编程语言,包括 C、C++、Java、Python、Go、Node.js等。
-Redis 是一个 key-value 存储系统。和 Memcached 类似,它支持存储的 value 类型相对更多,包括**string(字符串)、list(链表)、set(集合)、zset(sorted set --有序集合)和 hash(哈希类型)**。这些数据类型都支持 push/pop、add/remove 及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。在此基础上,redis 支持各种不同方式的排序。与 memcached 一样,为了保证效率,数据都是缓存在内存中。区别的是 redis 会周期性的把更新的数据写入磁盘或者把修改操作写入追加的记录文件,并且在此基础上实现了 master-slave(主从)同步,所以 Redis 也可以被看成是一个数据结构服务器。
+### 应用场景
+1. **缓存**:Redis 作为缓存系统,可以极大地提高数据读取速度,减轻数据库的压力。
+2. **会话存储**:利用 Redis 的高性能和持久化特性,可以用于存储用户会话信息。
+3. **实时分析**:利用 Redis 的集合和有序集合,可以进行实时数据分析和排名。
+4. **消息队列**:利用 Redis 的列表和发布/订阅特性,可以实现简单的消息队列系统。
+5. **计数器和限流**:利用 Redis 的原子递增操作,可以实现高效的计数器和限流机制。
-
-Redis 支持主从同步。数据可以从主服务器向任意数量的从服务器上同步,从服务器可以是关联其他从服务器的主服务器。这使得 Redis可执行单层树复制。存盘可以有意无意的对数据进行写操作。由于完全实现了发布/订阅机制,使得从数据库在任何地方同步树时,可订阅一个频道并接收主服务器完整的消息发布记录。同步对读取操作的可扩展性和数据冗余很有帮助。
+> **具体以某一论坛为例:**
+>
+> - 记录帖子的点赞数、评论数和点击数 (hash)。
+> - 记录用户的帖子 ID 列表 (排序),便于快速显示用户的帖子列表 (zset)。
+> - 记录帖子的标题、摘要、作者和封面信息,用于列表页展示 (hash)。
+> - 记录帖子的点赞用户 ID 列表,评论 ID 列表,用于显示和去重计数 (zset)。
+> - 缓存近期热帖内容 (帖子内容空间占用比较大),减少数据库压力 (hash)。
+> - 记录帖子的相关文章 ID,根据内容推荐相关帖子 (list)。
+> - 如果帖子 ID 是整数自增的,可以使用 Redis 来分配帖子 ID(计数器)。
+> - 收藏集和帖子之间的关系 (zset)。
+> - 记录热榜帖子 ID 列表,总热榜和分类热榜 (zset)。
+> - 缓存用户行为历史,进行恶意行为过滤 (zset,hash)。
Redis 的官网地址,非常好记,是 redis.io。(域名后缀io属于国家域名,是 british Indian Ocean territory,即英属印度洋领地)目前,Vmware 在资助着 Redis 项目的开发和维护。
-Redis 的所有数据都是保存在内存中,然后不定期的通过异步方式保存到磁盘上(这称为“半持久化模式”);也可以把每一次数据变化都写入到一个 append only file(aof)里面(这称为“全持久化模式”)。这就是 redis 提供的两种持久化的方式,RDB(Redis DataBase)和 AOF(Append Only File)。
-
-
-
-## Redis 特点
-
-Redis 是一个开源,先进的 key-value 存储,并用于构建高性能,可扩展的Web应用程序的完美解决方案。
-
-Redis从它的许多竞争继承来的三个主要特点:
-
-- Redis数据库完全在内存中,使用磁盘仅用于持久性。
-
-- 相比许多键值数据存储,Redis拥有一套较为丰富的数据类型。
-
-- Redis可以将数据复制到任意数量的从服务器。
-
-
-
-## Redis 优势
-
-- 异常快速:Redis 的速度非常快,每秒能执行约 11万集合,每秒约 81000+条记录。SET 操作每秒钟 110000 次,GET 操作每秒钟 81000 次,网站一般使用 Redis 作为**缓存服务器**。
-- 支持**丰富的数据类型**:Redis 支持大多数开发人员已经知道像列表,集合,有序集合,散列数据类型。这使得它非常容易解决各种各样的问题,因为我们知道哪些问题是可以处理通过它的数据类型更好。
-- 操作都是**原子性**:所有 Redis 操作是原子的,这保证了如果两个客户端同时访问的 Redis 服务器将获得更新后的值。
-- MultiUtility 工具:Redis 是一个多功能实用工具,可以在很多如:缓存,消息传递队列中使用(Redis 原生支持发布/订阅),在应用程序中,如:Web应用程序会话,网站页面点击数等任何短暂的数据;
-
-
-#### Redis 使用场景
-
-- 取最新 N 个数据的操作
-- 排行榜应用,取 TOP N 操作
-- 需要精确设定过期时间的应用
-- 定时器、计数器应用
-- Uniq 操作,获取某段时间所有数据排重值
-- 实时系统,反垃圾系统
-- Pub/Sub 构建实时消息系统
-- 构建队列系统
-- 缓存
-
-
-
-**具体以某一论坛为例:**
-
-- 记录帖子的点赞数、评论数和点击数 (hash)。
-- 记录用户的帖子 ID 列表 (排序),便于快速显示用户的帖子列表 (zset)。
-- 记录帖子的标题、摘要、作者和封面信息,用于列表页展示 (hash)。
-- 记录帖子的点赞用户 ID 列表,评论 ID 列表,用于显示和去重计数 (zset)。
-- 缓存近期热帖内容 (帖子内容空间占用比较大),减少数据库压力 (hash)。
-- 记录帖子的相关文章 ID,根据内容推荐相关帖子 (list)。
-- 如果帖子 ID 是整数自增的,可以使用 Redis 来分配帖子 ID(计数器)。
-- 收藏集和帖子之间的关系 (zset)。
-- 记录热榜帖子 ID 列表,总热榜和分类热榜 (zset)。
-- 缓存用户行为历史,进行恶意行为过滤 (zset,hash)。
-
-
**安装**
-```
+```shell
$ wget http://download.redis.io/releases/redis-5.0.6.tar.gz
$ tar xzf redis-5.0.6.tar.gz
$ cd redis-5.0.6
$ make
```
-新版本的编译文件在src中(之前在bin目录),启动server
+新版本的编译文件在 src 中(之前在bin目录),启动 server
-```
+```sh
$ src/redis-server
```
启动客户端
-```
+```shell
$ src/redis-cli
redis> set foo bar
OK
@@ -119,7 +87,7 @@ redis> get foo
## Redis 知识全景
-
+
“两大维度”就是指系统维度和应用维度,“三大主线”也就是指高性能、高可靠和高可扩展(可以简称为“三高”)。
@@ -135,6 +103,8 @@ Redis 作为庞大的键值数据库,可以说遍地都是知识,一抓一
+
+
## 推荐阅读
[《我是如何学习Redis的?高效学习Redis的路径和方法分享》](http://kaito-kidd.com/2020/09/09/how-i-learned-redis/)
\ No newline at end of file
diff --git a/docs/data-management/Redis/Redis-BloomFilter.md b/docs/data-management/Redis/Redis-BloomFilter.md
deleted file mode 100644
index 0a136edecc..0000000000
--- a/docs/data-management/Redis/Redis-BloomFilter.md
+++ /dev/null
@@ -1,338 +0,0 @@
-## 布隆过滤器是什么?
-
-布隆过滤器可以理解为一个不怎么精确的 set 结构,当你使用它的 contains 方法判断某个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别不精确,只要参数设置的合理,它的精确度可以控制的相对足够精确,只会有小小的误判概率。
-
-当布隆过滤器说某个值存在时,这个值可能不存在;当它说不存在时,那就肯定不存在。打个比方,当它说不认识你时,肯定就不认识;当它说见过你时,可能根本就没见过面,不过因为你的脸跟它认识的人中某脸比较相似 (某些熟脸的系数组合),所以误判以前见过你。
-
-套在上面的使用场景中,布隆过滤器能准确过滤掉那些已经看过的内容,那些没有看过的新内容,它也会过滤掉极小一部分 (误判),但是绝大多数新内容它都能准确识别。这样就可以完全保证推荐给用户的内容都是无重复的。
-
-## Redis 中的布隆过滤器
-
-Redis 官方提供的布隆过滤器到了 Redis 4.0 提供了插件功能之后才正式登场。布隆过滤器作为一个插件加载到 Redis Server 中,给 Redis 提供了强大的布隆去重功能。
-
-下面我们来体验一下 Redis 4.0 的布隆过滤器,为了省去繁琐安装过程,我们直接用 Docker 吧。
-
-```
-> docker pull redislabs/rebloom # 拉取镜像
-> docker run -p6379:6379 redislabs/rebloom # 运行容器
-> redis-cli # 连接容器中的 redis 服务
-```
-
-如果上面三条指令执行没有问题,下面就可以体验布隆过滤器了。
-
-## 布隆过滤器基本使用
-
-布隆过滤器有二个基本指令,`bf.add` 添加元素,`bf.exists` 查询元素是否存在,它的用法和 set 集合的 sadd 和 sismember 差不多。注意 `bf.add` 只能一次添加一个元素,如果想要一次添加多个,就需要用到 `bf.madd` 指令。同样如果需要一次查询多个元素是否存在,就需要用到 `bf.mexists` 指令。
-
-```
-127.0.0.1:6379> bf.add codehole user1
-(integer) 1
-127.0.0.1:6379> bf.add codehole user2
-(integer) 1
-127.0.0.1:6379> bf.add codehole user3
-(integer) 1
-127.0.0.1:6379> bf.exists codehole user1
-(integer) 1
-127.0.0.1:6379> bf.exists codehole user2
-(integer) 1
-127.0.0.1:6379> bf.exists codehole user3
-(integer) 1
-127.0.0.1:6379> bf.exists codehole user4
-(integer) 0
-127.0.0.1:6379> bf.madd codehole user4 user5 user6
-1) (integer) 1
-2) (integer) 1
-3) (integer) 1
-127.0.0.1:6379> bf.mexists codehole user4 user5 user6 user7
-1) (integer) 1
-2) (integer) 1
-3) (integer) 1
-4) (integer) 0
-```
-
-
-
-Java 客户端 Jedis-2.x 没有提供指令扩展机制,所以你无法直接使用 Jedis 来访问 Redis Module 提供的 [bf.xxx](http://bf.xxx/) 指令。RedisLabs 提供了一个单独的包 [JReBloom](https://github.com/RedisLabs/JReBloom),但是它是基于 Jedis-3.0,Jedis-3.0 这个包目前还没有进入 release,没有进入 maven 的中央仓库,需要在 Github 上下载。在使用上很不方便,如果怕麻烦,还可以使用 [lettuce](https://github.com/lettuce-io/lettuce-core),它是另一个 Redis 的客户端,相比 Jedis 而言,它很早就支持了指令扩展。
-
-```
-public class BloomTest {
-
- public static void main(String[] args) {
- Client client = new Client();
-
- client.delete("codehole");
- for (int i = 0; i < 100000; i++) {
- client.add("codehole", "user" + i);
- boolean ret = client.exists("codehole", "user" + i);
- if (!ret) {
- System.out.println(i);
- break;
- }
- }
-
- client.close();
- }
-
-}
-```
-
-执行上面的代码后,你会张大了嘴巴发现居然没有输出,塞进去了 100000 个元素,还是没有误判,这是怎么回事?如果你不死心的话,可以将数字再加一个 0 试试,你会发现依然没有误判。
-
- 原因就在于布隆过滤器对于已经见过的元素肯定不会误判,它只会误判那些没见过的元素。所以我们要稍微改一下上面的脚本,使用 bf.exists 去查找没见过的元素,看看它是不是以为自己见过了。
-
-```java
-public class BloomTest {
-
- public static void main(String[] args) {
- Client client = new Client();
-
- client.delete("codehole");
- for (int i = 0; i < 100000; i++) {
- client.add("codehole", "user" + i);
- boolean ret = client.exists("codehole", "user" + (i + 1));
- if (ret) {
- System.out.println(i);
- break;
- }
- }
-
- client.close();
- }
-
-}
-```
-
-
-
-运行后,我们看到了输出是 214,也就是到第 214 的时候,它出现了误判。
-
-那如何来测量误判率呢?我们先随机出一堆字符串,然后切分为 2 组,将其中一组塞入布隆过滤器,然后再判断另外一组的字符串存在与否,取误判的个数和字符串总量一半的百分比作为误判率。
-
-```java
-public class BloomTest {
-
- private String chars;
- {
- StringBuilder builder = new StringBuilder();
- for (int i = 0; i < 26; i++) {
- builder.append((char) ('a' + i));
- }
- chars = builder.toString();
- }
-
- private String randomString(int n) {
- StringBuilder builder = new StringBuilder();
- for (int i = 0; i < n; i++) {
- int idx = ThreadLocalRandom.current().nextInt(chars.length());
- builder.append(chars.charAt(idx));
- }
- return builder.toString();
- }
-
- private List randomUsers(int n) {
- List users = new ArrayList<>();
- for (int i = 0; i < 100000; i++) {
- users.add(randomString(64));
- }
- return users;
- }
-
- public static void main(String[] args) {
- BloomTest bloomer = new BloomTest();
- List users = bloomer.randomUsers(100000);
- List usersTrain = users.subList(0, users.size() / 2);
- List usersTest = users.subList(users.size() / 2, users.size());
-
- Client client = new Client();
- client.delete("codehole");
- for (String user : usersTrain) {
- client.add("codehole", user);
- }
- int falses = 0;
- for (String user : usersTest) {
- boolean ret = client.exists("codehole", user);
- if (ret) {
- falses++;
- }
- }
- System.out.printf("%d %d\n", falses, usersTest.size());
- client.close();
- }
-
-}
-```
-
-运行一下,等待大约一分钟,输出:
-
-```
-total users 100000
-all trained
-628 50000
-```
-
-可以看到误判率大约 1% 多点。你也许会问这个误判率还是有点高啊,有没有办法降低一点?答案是有的。
-
-我们上面使用的布隆过滤器只是默认参数的布隆过滤器,它在我们第一次 add 的时候自动创建。Redis 其实还提供了自定义参数的布隆过滤器,需要我们在 add 之前使用`bf.reserve`指令显式创建。如果对应的 key 已经存在,`bf.reserve`会报错。`bf.reserve`有三个参数,分别是 key, `error_rate`和`initial_size`。错误率越低,需要的空间越大。`initial_size`参数表示预计放入的元素数量,当实际数量超出这个数值时,误判率会上升。
-
-所以需要提前设置一个较大的数值避免超出导致误判率升高。如果不使用 bf.reserve,默认的`error_rate`是 0.01,默认的`initial_size`是 100。
-
- 接下来我们使用 bf.reserve 改造一下上面的脚本:
-
-Java 版本:
-
-```
-public class BloomTest {
-
- private String chars;
- {
- StringBuilder builder = new StringBuilder();
- for (int i = 0; i < 26; i++) {
- builder.append((char) ('a' + i));
- }
- chars = builder.toString();
- }
-
- private String randomString(int n) {
- StringBuilder builder = new StringBuilder();
- for (int i = 0; i < n; i++) {
- int idx = ThreadLocalRandom.current().nextInt(chars.length());
- builder.append(chars.charAt(idx));
- }
- return builder.toString();
- }
-
- private List randomUsers(int n) {
- List users = new ArrayList<>();
- for (int i = 0; i < 100000; i++) {
- users.add(randomString(64));
- }
- return users;
- }
-
- public static void main(String[] args) {
- BloomTest bloomer = new BloomTest();
- List users = bloomer.randomUsers(100000);
- List usersTrain = users.subList(0, users.size() / 2);
- List usersTest = users.subList(users.size() / 2, users.size());
-
- Client client = new Client();
- client.delete("codehole");
- // 对应 bf.reserve 指令
- client.createFilter("codehole", 50000, 0.001);
- for (String user : usersTrain) {
- client.add("codehole", user);
- }
- int falses = 0;
- for (String user : usersTest) {
- boolean ret = client.exists("codehole", user);
- if (ret) {
- falses++;
- }
- }
- System.out.printf("%d %d\n", falses, usersTest.size());
- client.close();
- }
-
-}
-```
-
-运行一下,等待约 1 分钟,输出如下:
-
-```
-total users 100000
-all trained
-6 50000
-```
-
-我们看到了误判率大约 0.012%,比预计的 0.1% 低很多,不过布隆的概率是有误差的,只要不比预计误判率高太多,都是正常现象。
-
-## 注意事项
-
-布隆过滤器的`initial_size`估计的过大,会浪费存储空间,估计的过小,就会影响准确率,用户在使用之前一定要尽可能地精确估计好元素数量,还需要加上一定的冗余空间以避免实际元素可能会意外高出估计值很多。
-
-布隆过滤器的`error_rate`越小,需要的存储空间就越大,对于不需要过于精确的场合,`error_rate`设置稍大一点也无伤大雅。比如在新闻去重上而言,误判率高一点只会让小部分文章不能让合适的人看到,文章的整体阅读量不会因为这点误判率就带来巨大的改变。
-
-## 布隆过滤器的原理
-
-学会了布隆过滤器的使用,下面有必要把原理解释一下,不然读者还会继续蒙在鼓里
-
-
-
-
-
-
-
-每个布隆过滤器对应到 Redis 的数据结构里面就是一个大型的位数组和几个不一样的无偏 hash 函数。所谓无偏就是能够把元素的 hash 值算得比较均匀。
-
-向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。
-
-向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个 key 不存在。如果都是 1,这并不能说明这个 key 就一定存在,只是极有可能存在,因为这些位被置为 1 可能是因为其它的 key 存在所致。如果这个位数组比较稀疏,判断正确的概率就会很大,如果这个位数组比较拥挤,判断正确的概率就会降低。具体的概率计算公式比较复杂,感兴趣可以阅读扩展阅读,非常烧脑,不建议读者细看。
-
-使用时不要让实际元素远大于初始化大小,当实际元素开始超出初始化大小时,应该对布隆过滤器进行重建,重新分配一个 size 更大的过滤器,再将所有的历史元素批量 add 进去 (这就要求我们在其它的存储器中记录所有的历史元素)。因为 error_rate 不会因为数量超出就急剧增加,这就给我们重建过滤器提供了较为宽松的时间。
-
-## 空间占用估计
-
-布隆过滤器的空间占用有一个简单的计算公式,但是推导比较繁琐,这里就省去推导过程了,直接引出计算公式,感兴趣的读者可以点击「扩展阅读」深入理解公式的推导过程。
-
-布隆过滤器有两个参数,第一个是预计元素的数量 n,第二个是错误率 f。公式根据这两个输入得到两个输出,第一个输出是位数组的长度 l,也就是需要的存储空间大小 (bit),第二个输出是 hash 函数的最佳数量 k。hash 函数的数量也会直接影响到错误率,最佳的数量会有最低的错误率。
-
-```
-k=0.7*(l/n) # 约等于
-f=0.6185^(l/n) # ^ 表示次方计算,也就是 math.pow
-```
-
-从公式中可以看出
-
-1. 位数组相对越长 (l/n),错误率 f 越低,这个和直观上理解是一致的
-2. 位数组相对越长 (l/n),hash 函数需要的最佳数量也越多,影响计算效率
-3. 当一个元素平均需要 1 个字节 (8bit) 的指纹空间时 (l/n=8),错误率大约为 2%
-4. 错误率为 10%,一个元素需要的平均指纹空间为 4.792 个 bit,大约为 5bit
-5. 错误率为 1%,一个元素需要的平均指纹空间为 9.585 个 bit,大约为 10bit
-6. 错误率为 0.1%,一个元素需要的平均指纹空间为 14.377 个 bit,大约为 15bit
-
-你也许会想,如果一个元素需要占据 15 个 bit,那相对 set 集合的空间优势是不是就没有那么明显了?这里需要明确的是,set 中会存储每个元素的内容,而布隆过滤器仅仅存储元素的指纹。元素的内容大小就是字符串的长度,它一般会有多个字节,甚至是几十个上百个字节,每个元素本身还需要一个指针被 set 集合来引用,这个指针又会占去 4 个字节或 8 个字节,取决于系统是 32bit 还是 64bit。而指纹空间只有接近 2 个字节,所以布隆过滤器的空间优势还是非常明显的。
-
-如果读者觉得公式计算起来太麻烦,也没有关系,有很多现成的网站已经支持计算空间占用的功能了,我们只要把参数输进去,就可以直接看到结果,比如 [布隆计算器](https://krisives.github.io/bloom-calculator/)。
-
-
-
-
-
-
-
-## 实际元素超出时,误判率会怎样变化
-
-当实际元素超出预计元素时,错误率会有多大变化,它会急剧上升么,还是平缓地上升,这就需要另外一个公式,引入参数 t 表示实际元素和预计元素的倍数 t
-
-```
-f=(1-0.5^t)^k # 极限近似,k 是 hash 函数的最佳数量
-```
-
-当 t 增大时,错误率,f 也会跟着增大,分别选择错误率为 10%,1%,0.1% 的 k 值,画出它的曲线进行直观观察
-
-
-
-
-
-
-
-从这个图中可以看出曲线还是比较陡峭的
-
-1. 错误率为 10% 时,倍数比为 2 时,错误率就会升至接近 40%,这个就比较危险了
-2. 错误率为 1% 时,倍数比为 2 时,错误率升至 15%,也挺可怕的
-3. 错误率为 0.1%,倍数比为 2 时,错误率升至 5%,也比较悬了
-
-## 用不上 Redis4.0 怎么办?
-
-Redis 4.0 之前也有第三方的布隆过滤器 lib 使用,只不过在实现上使用 redis 的位图来实现的,性能上也要差不少。比如一次 exists 查询会涉及到多次 getbit 操作,网络开销相比而言会高出不少。另外在实现上这些第三方 lib 也不尽完美,比如 pyrebloom 库就不支持重连和重试,在使用时需要对它做一层封装后才能在生产环境中使用。
-
-1. [Python Redis Bloom Filter](https://github.com/robinhoodmarkets/pyreBloom)
-2. [Java Redis Bloom Filter](https://github.com/Baqend/Orestes-Bloomfilter)
-
-## 布隆过滤器的其它应用
-
-在爬虫系统中,我们需要对 URL 进行去重,已经爬过的网页就可以不用爬了。但是 URL 太多了,几千万几个亿,如果用一个集合装下这些 URL 地址那是非常浪费空间的。这时候就可以考虑使用布隆过滤器。它可以大幅降低去重存储消耗,只不过也会使得爬虫系统错过少量的页面。
-
-布隆过滤器在 NoSQL 数据库领域使用非常广泛,我们平时用到的 HBase、Cassandra 还有 LevelDB、RocksDB 内部都有布隆过滤器结构,布隆过滤器可以显著降低数据库的 IO 请求数量。当用户来查询某个 row 时,可以先通过内存中的布隆过滤器过滤掉大量不存在的 row 请求,然后再去磁盘进行查询。
-
-邮箱系统的垃圾邮件过滤功能也普遍用到了布隆过滤器,因为用了这个过滤器,所以平时也会遇到某些正常的邮件被放进了垃圾邮件目录中,这个就是误判所致,概率很低。
\ No newline at end of file
diff --git a/docs/data-management/Redis/Redis-Cache-Model.md b/docs/data-management/Redis/Redis-Cache-Model.md
new file mode 100644
index 0000000000..9cb60433a4
--- /dev/null
+++ b/docs/data-management/Redis/Redis-Cache-Model.md
@@ -0,0 +1,191 @@
+### **什么是缓存模式?**
+
+缓存模式是指在系统中如何设计和实现缓存机制,用以快速存取数据并减轻后端数据存储的负载。Redis 提供了灵活多样的缓存策略,常见模式包括:
+
+1. **直写模式(Write Through)**
+2. **回写模式(Write Back)**
+3. **旁路缓存模式(Cache Aside)**
+4. **只缓存模式(Read Through)**
+
+通过合理选择和优化这些模式,可以满足不同业务场景对性能、数据一致性和可用性的需求。
+
+------
+
+### **Redis 缓存模式详解**
+
+#### **1. Cache Aside(旁路缓存)**
+
+Cache Aside 模式又称为 **Lazy Loading**,是使用最广泛的缓存模式之一,通常由业务代码显式管理缓存和数据库,核心思想是:
+
+- 数据从数据库加载到缓存中,缓存作为数据库的一个“旁路”。
+- 应用程序负责读取缓存,缓存未命中时再从数据库读取并更新缓存。
+
+**读请求**:
+
+- 先从缓存中读取数据;
+- 如果缓存中没有数据(缓存未命中),从数据库中获取数据,将数据写入缓存,返回给客户端。
+
+**写请求**:
+
+- 数据写入数据库后,将缓存中的数据清除或更新。
+
+**适用场景:**
+
+- 数据更新较少但读取频率较高的场景,例如商品详情、热搜榜单。
+- 对数据一致性要求不严格的系统。
+
+**优缺点:**
+
+- **优点**:简单易用,缓存与数据库解耦。
+- **缺点**:缓存预热需要时间,容易出现缓存击穿问题。
+
+
+
+#### 2. Write Through(直写模式)
+
+在直写模式中,应用程序的所有写操作都会同时更新缓存和数据库:
+
+- 数据写入数据库的同时同步写入缓存。
+
+**工作流程:**
+
+1. 应用将数据同时写入 Redis 和数据库。
+2. 读取时直接从 Redis 获取数据。
+
+**适用场景:**
+
+- 需要缓存和数据库一致性非常高的场景,例如账户余额、订单状态等敏感数据。
+
+**优缺点:**
+
+- **优点**:一致性高,数据实时同步。
+- **缺点**:写入速度较慢,因为每次写操作都需要更新两处存储。
+
+
+
+#### 3. Write Back(回写模式)
+
+在回写模式下,数据首先写入缓存,之后异步写入数据库:
+
+- 写入数据库的操作由后台线程或任务队列完成。
+
+**工作流程:**
+
+1. 数据先写入 Redis。
+2. Redis 异步将数据批量写入数据库。
+
+**适用场景:**
+
+- 写频率高、读频率较低且对一致性要求不严格的场景,例如日志系统。
+
+**优缺点:**
+
+- **优点**:写入性能高,因为写数据库是异步的。
+- **缺点**:可能导致数据丢失,如果缓存写入后还未同步到数据库时发生故障。
+
+
+
+#### 4. Read Through(只缓存模式)
+
+在这种模式中,所有的读写操作都必须通过缓存完成:
+
+- 缓存未命中时,应用从数据库中加载数据并自动更新缓存。
+
+**工作流程:**
+
+1. 应用读取 Redis,如果未命中,自动从数据库加载并更新。
+2. 写入时同步更新缓存和数据库。
+
+**适用场景:**
+
+- 读多写少且对实时性要求较高的场景。
+
+**优缺点:**
+
+- **优点**:应用层逻辑简单。
+- **缺点**:依赖于缓存层,缓存崩溃可能导致大量数据库请求。
+
+
+
+#### 5、Refresh Ahead(提前刷新缓存)
+
+Refresh Ahead 模式通过提前异步加载数据,防止缓存失效时查询数据库的性能抖动。
+
+- 基于预设的过期时间,在缓存即将失效前,后台异步加载数据并更新缓存。
+
+
+
+#### 6、Singleflight 模式
+
+Singleflight 是一种**抑制重复请求**的模式,用于解决缓存未命中时的高并发问题。
+
+- 当多个请求同时查询相同的缓存未命中数据时,只有一个请求会执行数据库查询,其余请求等待结果返回。
+
+**核心思路**
+
+- 当多个并发请求同时访问**同一个缓存键**,导致缓存未命中时,**Singleflight** 机制保证只有**一个请求**去执行实际的数据库查询或计算操作,其余请求会等待第一个请求的结果完成后直接返回相同的结果。
+- 这样可以有效避免重复查询或计算,减少对数据库、API 等资源的压力。
+
+**流程**
+
+1. 多个请求到来时,检测是否已有请求正在执行。
+ - 如果没有请求在执行,则当前请求负责查询数据。
+ - 如果已有请求在执行,其他请求进入等待队列。
+2. **第一个请求执行数据库查询或计算操作,并保存结果。**
+3. **等待中的请求获取到第一个请求的结果,直接返回相同数据。**
+
+
+
+### Redis 缓存模式的优化策略
+
+#### **1. 缓存雪崩**
+
+缓存雪崩指缓存集中失效时,大量请求直接击穿数据库,导致数据库压力激增甚至崩溃。
+
+**解决方案:**
+
+- **设置随机过期时间**:避免大量缓存同时失效。
+- **多级缓存**:在 Redis 之上增加本地缓存(如 Guava)。
+- **请求限流**:通过限流机制控制瞬时流量。
+
+#### **2. 缓存击穿**
+
+缓存击穿指某个热点数据缓存失效后,短时间内大量请求直接打到数据库。
+
+**解决方案:**
+
+- **热点数据预加载**:提前将热点数据缓存。
+- **加互斥锁**:在缓存未命中时,通过锁机制防止数据库过载。
+
+#### 3. 缓存穿透
+
+缓存穿透指用户请求的数据既不在缓存中,也不存在于数据库中,导致所有请求打到数据库。
+
+**解决方案:**
+
+- **布隆过滤器**:拦截无效请求,减少数据库查询。
+- **缓存空结果**:将不存在的数据写入缓存,避免重复查询。
+
+
+
+### Redis 缓存模式的应用场景
+
+#### **1. 电商秒杀**
+
+在高并发的秒杀场景中,Redis 通常用于缓存商品库存数据。典型模式为:
+
+- 使用 `Cache Aside` 模式缓存库存数据,避免频繁访问数据库。
+- 配合分布式锁,防止超卖问题。
+
+#### **2. 社交网络**
+
+在社交平台上,Redis 用于存储用户会话、好友关系等数据:
+
+- 使用 `Write Through` 模式保证数据一致性。
+- 通过 Redis 的 Set 结构实现快速去重和交集运算。
+
+#### **3. 实时排行榜**
+
+Redis 的 Sorted Set 结构非常适合实现排行榜功能:
+
+- 使用 `Cache Aside` 模式,定期将缓存中的排行榜数据同步到数据库。
\ No newline at end of file
diff --git a/docs/data-management/Redis/Redis-Cluster.md b/docs/data-management/Redis/Redis-Cluster.md
index 07af45c6e7..856af90b08 100644
--- a/docs/data-management/Redis/Redis-Cluster.md
+++ b/docs/data-management/Redis/Redis-Cluster.md
@@ -6,10 +6,6 @@ tags:
categories: Redis
---
-
-
-
-
## 一、Redis 集群是啥
我们先回顾下前边介绍的几种 Redis 高可用方案:持久化、主从同步和哨兵机制。但这些方案仍有痛点,其中最主要的问题就是存储能力受单机限制,以及没办法实现写操作的负载均衡。
@@ -34,7 +30,7 @@ Redis 集群刚好解决了上述问题,实现了较为完善的高可用方
2. **高可用**: 集群支持主从复制和主节点的 **自动故障转移** *(与哨兵类似)*,当任一节点发生故障时,集群仍然可以对外提供服务。
-
+
上图展示了 **Redis Cluster** 典型的架构图,集群中的每一个 Redis 节点都 **互相两两相连**,客户端任意 **直连** 到集群中的 **任意一台**,就可以对其他 Redis 节点进行 **读写** 的操作。
@@ -42,7 +38,7 @@ Redis 集群刚好解决了上述问题,实现了较为完善的高可用方
### 1.3 Redis 集群的基本原理
-
+
Redis 集群中内置了 `16384` 个哈希槽。当客户端连接到 Redis 集群之后,会同时得到一份关于这个 **集群的配置信息**,当客户端具体对某一个 `key` 值进行操作时,会计算出它的一个 Hash 值,然后把结果对 `16384` **求余数**,这样每个 `key` 都会对应一个编号在 `0-16383` 之间的哈希槽,Redis 会根据节点数量 **大致均等** 的将哈希槽映射到不同的节点。
@@ -93,7 +89,7 @@ redis-server cluster_config/redis_7005.conf
然后执行 `ps -ef | grep redis` 查看是否启动成功:
-
+
可以看到 `6` 个 Redis 节点都以集群的方式成功启动了,**但是现在每个节点还处于独立的状态**,也就是说它们每一个都各自成了一个集群,还没有互相联系起来,我们需要手动地把他们之间建立起联系。
@@ -109,7 +105,7 @@ redis-cli --cluster create 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 127.0.0.
观察控制台输出:
-
+
看到 `[OK]` 的信息之后,就表示集群已经搭建成功了,可以看到,这里我们正确地创建了三主三从的集群。
@@ -139,7 +135,7 @@ OK
我们再使用 `cluster info` *(查看集群信息)* 和 `cluster nodes` *(查看节点列表)* 来分别看看:*(任意节点输入均可)*
-
+
@@ -165,7 +161,7 @@ Redis 集群最核心的功能就是数据分区,数据分区之后又伴随
一致性哈希算法将 **整个哈希值空间** 组织成一个虚拟的圆环,范围一般是 0 - $2^{32}$,对于每一个数据,根据 `key` 计算 hash 值,确定数据在环上的位置,然后从此位置沿顺时针行走,找到的第一台服务器就是其应该映射到的服务器:
-
+
与哈希取余分区相比,一致性哈希分区将 **增减节点的影响限制在相邻节点**。以上图为例,如果在 `node1` 和 `node2` 之间增加 `node5`,则只有 `node2` 中的一部分数据会迁移到 `node5`;如果去掉 `node2`,则原 `node2` 中的数据只会迁移到 `node3` 中,只有 `node3` 会受影响。
@@ -225,7 +221,7 @@ Redis 集群相对单机在功能上存在一些限制,需要开发人员提
### 3.3 节点通信
-集群的建立离不开节点之间的通信,例如我们上面启动六个集群节点之后通过 `redis-cli` 命令帮助我们搭建起来了集群,实际上背后每个集群之间的两两连接是通过了 `CLUSTER MEET ` 命令发送 `MEET` 消息完成的。
+集群的建立离不开节点之间的通信,例如我们上面启动六个集群节点之后通过 `redis-cli` 命令帮助我们搭建起来了集群,实际上背后每个集群之间的两两连接是通过了 `CLUSTER MEET` 命令发送 `MEET` 消息完成的。
通信过程说明:
@@ -248,7 +244,7 @@ Redis 集群相对单机在功能上存在一些限制,需要开发人员提
> 对于一个分布式集群来说,它的良好运行离不开集群节点信息和节点状态的正常维护。为了实现这一目标,通常我们可以选择**中心化**的方法,使用一个第三方系统,比如 Zookeeper 或 etcd,来维护集群节点的信息、状态等。同时,我们也可以选择**去中心化**的方法,让每个节点都维护彼此的信息、状态,并且使用集群通信协议 Gossip 在节点间传播更新的信息,从而实现每个节点都能拥有一致的信息。下图就展示了这两种集群节点信息维护的方法,你可以看下。
>
-> 
+> 
节点间通信,按照通信协议可以分为几种类型:单对单、广播、Gossip 协议等。重点是广播和 Gossip 的对比。
@@ -258,9 +254,7 @@ Redis 集群相对单机在功能上存在一些限制,需要开发人员提
(为什么需要随机呢? )
- Gossip 协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息,这种方式类似流言传播。
-
-Gossip 协议的主要职责就是信息交换。信息交换的载体就是节点彼此发送的 Gossip 消息,了解这些消息有助于我们理解集群如何完成信息交换。
+ Gossip 协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息,这种方式类似**流言传播**。Gossip 协议的主要职责就是信息交换。信息交换的载体就是节点彼此发送的 Gossip 消息,了解这些消息有助于我们理解集群如何完成信息交换。
#### 消息类型
@@ -268,14 +262,14 @@ Gossip 协议的主要职责就是信息交换。信息交换的载体就是节
节点间发送的消息主要分为 `5` 种:`meet 消息`、`ping 消息`、`pong 消息`、`fail 消息`、`publish 消息`。不同的消息类型,通信协议、发送的频率和时机、接收节点的选择等是不同的:
-
+
- **MEET 消息:** 用于通知新节点加入。消息发送者通知接收者加入到当前集群,meet 消息通信正常完成后,接收节点会加入到集群中并进行周期性的 ping、pong 消息交换。
- **PING 消息:** 集群里每个节点每秒钟会选择部分节点发送 `PING` 消息,接收者收到消息后会回复一个 `PONG` 消息。**PING 消息的内容是自身节点和部分其他节点的状态信息**,作用是彼此交换信息,以及检测节点是否在线。`PING` 消息使用 Gossip 协议发送,接收节点的选择兼顾了收敛速度和带宽成本(内部频繁进行信息交换,而且 ping/pong 消息会携带当前节点和部分其他节点的状态数据,势必会加重带宽和计算的负担,所以选择需要通信的节点列表就很重要了),**具体规则如下**:
1. 随机找 5 个节点,在其中选择最久没有通信的 1 个节点;
2. 扫描节点列表,选择最近一次收到 `PONG` 消息时间大于 `cluster_node_timeout / 2` 的所有节点,防止这些节点长时间未更新。
-
+
- **PONG消息:** `PONG` 消息封装了自身状态数据。可以分为两种:
1. **第一种** 是在接到 `MEET/PING` 消息后回复的 `PONG` 消息;
@@ -489,7 +483,7 @@ Redis 集群自身实现了高可用。高可用首先需要解决集群部分
- 更新配置纪元
- 配置纪元是一个只增不减的整数,每个主节点自身维护一个配置纪元 (`clusterNode.configEpoch`)标示当前主节点的版本,所有主节点的配置纪元 都不相等,从节点会复制主节点的配置纪元。整个集群又维护一个全局的配 置纪元(`clusterState.current Epoch`),用于记录集群内所有主节点配置纪元的最大版本。
+ 配置纪元是一个只增不减的整数,每个主节点自身维护一个配置纪元 (`clusterNode.configEpoch`)标示当前主节点的版本,所有主节点的配置纪元 都不相等,从节点会复制主节点的配置纪元。整个集群又维护一个全局的配置纪元(`clusterState.current Epoch`),用于记录集群内所有主节点配置纪元的最大版本。
- 广播选举消息
@@ -503,7 +497,7 @@ Redis 集群自身实现了高可用。高可用首先需要解决集群部分
当从节点收集到 N/2+1 个持有槽的主节点投票时,从节点可以执行替换主节点操作,例如集群内有 5 个持有槽的主节点,主节点 b 故障后还有 4 个, 当其中一个从节点收集到 3 张投票时代表获得了足够的选票可以进行替换主节点操作。
- 
+
5. 替换主节点
@@ -544,7 +538,7 @@ Redis 集群自身实现了高可用。高可用首先需要解决集群部分
像 redis-cli 这种客户端又叫 Dummy(傀儡)客户端,它优点是代码实现简单,对客户端协议影响较小,只需要根据重定向信息再次发送请求即可。但是它的弊端很明显,每次执行键命令前都要到 Redis 上进行重定向才能找到要执行命令的节点,额外增加了 IO 开销, 这不是Redis 集群高效的使用方式。正因为如此通常集群客户端都采用另一 种实现:Smart(智能)客户端。
-#### 3.5.2 Smart客户端
+#### 3.5.2 Smart 客户端
大多数开发语言的 Redis 客户端都采用 Smart 客户端支持集群协议。Smart 客户端通过在内部维护 slot→node 的映射关系,本地就可实现键到节点的查找,从而保证 IO 效率的最大化,而 MOVED 重定向负责协助 Smart 客户端更新 slot→node 映射。
@@ -580,7 +574,8 @@ Redis 集群自身实现了高可用。高可用首先需要解决集群部分
### 参考与来源
-1. https://redis.io/topics/cluster-tutorial
-2. 《Redis 设计与实现》
-3. 《Redis 开发与运维》
-4. https://www.cnblogs.com/kismetv/p/9853040.html
\ No newline at end of file
+1. https://www.mybluelinux.com/redis-explained/
+2. https://redis.io/topics/cluster-tutorial
+3. 《Redis 设计与实现》
+4. 《Redis 开发与运维》
+5. https://www.cnblogs.com/kismetv/p/9853040.html
\ No newline at end of file
diff --git a/docs/data-management/Redis/Redis-Database.md b/docs/data-management/Redis/Redis-Database.md
index dcb71d3493..9c71d14892 100644
--- a/docs/data-management/Redis/Redis-Database.md
+++ b/docs/data-management/Redis/Redis-Database.md
@@ -1,10 +1,8 @@
# Redis-Database
-
-
Redis 如何表示一个数据库?数据库操作是如何实现的?
-> 这边文章是基于源码来让我们理解 Redis 的,不管是我们自己下载 redis 还是直接在 Github 上看源码,我们先要了解下 redis 更目录下的重要目录
+> 这篇文章是基于源码来让我们理解 Redis 的,不管是我们自己下载 redis 还是直接在 Github 上看源码,我们先要了解下 redis 根目录下的重要目录
>
> - `src`:用C编写的Redis实现
> - `tests`:包含在Tcl中实现的单元测试
@@ -18,8 +16,6 @@ Redis 如何表示一个数据库?数据库操作是如何实现的?
理解程序如何工作的最简单方法是理解它使用的数据结构。 从 `redis/src` 目录下可以看到 server 的源码文件(基于 `redis-6.0.5`,redis3.0 叫 `redis.c` 和 `redis.h`)。
-
-
Redis的主头文件 `server.h` 中定义了各种结构体,比如Redis 对象`redisObject` 、存储结构`redisDb `、客户端`client` 等等。
```c
@@ -118,5 +114,4 @@ Redis 解决哈希碰撞的方式 和 Java 中的 HashMap 类似,采取链表
-
-https://redisbook.readthedocs.io/en/latest/index.html
\ No newline at end of file
+> https://redisbook.readthedocs.io/en/latest/index.html
diff --git a/docs/data-management/Redis/Redis-Datatype.md b/docs/data-management/Redis/Redis-Datatype.md
index 0abfad35f3..fc8ff7ad04 100644
--- a/docs/data-management/Redis/Redis-Datatype.md
+++ b/docs/data-management/Redis/Redis-Datatype.md
@@ -1,4 +1,10 @@
-# Redis 数据类型篇
+---
+title: Redis 数据类型篇
+date: 2022-08-25
+tags:
+ - Redis
+categories: Redis
+---
> 一提到 Redis,我们的脑子里马上就会出现一个词:“快。”
>
@@ -10,7 +16,7 @@
我们都知道 Redis 是个 KV 数据库,那 KV 结构的数据在 Redis 中是如何存储的呢?
-### 一、KV 如何存储?
+## 一、KV 如何存储?
为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。类似我们的 HashMap
@@ -20,10 +26,20 @@
在下图中,可以看到,哈希桶中的 entry 元素中保存了 `*key` 和 `*value` 指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过 `*value` 指针被查找到。
-
+
因为这个哈希表保存了所有的键值对,所以,也把它称为**全局哈希表**。哈希表的最大好处很明显,就是让我们可以用 $O(1)$ 的时间复杂度来快速查找到键值对——我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素。
+```c
+struct redisObject {
+ unsigned type:4; // 类型
+ unsigned encoding:4; // 编码
+ unsigned lru:LRU_BITS; // 对象最后一次被访问的时间
+ int refcount; //引用计数
+ void *ptr; //指向实际值的指针
+};
+```
+
你看,这个查找过程主要依赖于哈希计算,和数据量的多少并没有直接关系。也就是说,不管哈希表里有 10 万个键还是 100 万个键,我们只需要一次计算就能找到相应的键。但是,如果你只是了解了哈希表的 $O(1)$ 复杂度和快速查找特性,那么,当你往 Redis 中写入大量数据后,就可能发现操作有时候会突然变慢了。这其实是因为你忽略了一个潜在的风险点,那就是哈希表的冲突问题和 rehash 可能带来的操作阻塞。
### 为什么哈希表操作变慢了?
@@ -32,15 +48,32 @@
Redis 解决哈希冲突的方式,就是链式哈希。和 JDK7 中的 HahsMap 类似,链式哈希也很容易理解,**就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接**。
-如下图所示:哈希桶 6 上就有 3 个连着的 entry,也叫作哈希冲突链。
-
-
+
但是,这里依然存在一个问题,哈希冲突链上的元素只能通过指针逐一查找再操作。如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。对于追求“快”的 Redis 来说,这是不太能接受的。
所以,Redis 会对哈希表做 rehash 操作。rehash 也就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。那具体怎么做呢?
-其实,为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:
+
+
+其实,为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。
+
+```c
+struct dict {
+ dictType *type;
+
+ dictEntry **ht_table[2];
+ unsigned long ht_used[2];
+
+ long rehashidx; /* rehashing not in progress if rehashidx == -1 */
+
+ /* Keep small vars at end for optimal (minimal) struct padding */
+ int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
+ signed char ht_size_exp[2]; /* exponent of size. (size = 1<链式哈希。和 JDK7
到此,我们就可以从哈希表 1 切换到哈希表 2,用增大的哈希表 2 保存更多数据,而原来的哈希表 1 留作下一次 rehash 扩容备用。这个过程看似简单,但是第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。为了避免这个问题,Redis 采用了渐进式 rehash。
-简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。如下图所示:
-
-
+简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。
渐进式 rehash 这样就巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。
@@ -62,31 +93,29 @@ Redis 解决哈希冲突的方式,就是链式哈希。和 JDK7
## 一、Redis 的五种基本数据类型和其数据结构
-由于 Redis 是基于标准 C 写的,只有最基础的数据类型,因此 Redis 为了满足对外使用的 5 种基本数据类型,开发了属于自己**独有的一套基础数据结构**,使用这些数据结构来实现 5 种数据类型。
+
+
+由于 Redis 是基于标准 C 写的,只有最基础的数据类型,因此 Redis 为了满足对外使用的 5 种基本数据类型,开发了属于自己**独有的一套基础数据结构**。
**Redis** 有 5 种基础数据类型,它们分别是:**string(字符串)**、**list(列表)**、**hash(字典)**、**set(集合)** 和 **zset(有序集合)**。
-Redis 底层的数据结构包括:**简单动态数组SDS、链表、字典、跳跃链表、整数集合、压缩列表、对象。**
+Redis 底层的数据结构包括:**简单动态数组SDS、链表、字典、跳跃链表、整数集合、快速列表、压缩列表、对象。**
Redis 为了平衡空间和时间效率,针对 value 的具体类型在底层会采用不同的数据结构来实现,其中哈希表和压缩列表是复用比较多的数据结构,如下图展示了对外数据类型和底层数据结构之间的映射关系:
-
+
下面我们具体看下各种数据类型的底层实现和操作。
> 安装好 Redis,我们可以使用 `redis-cli` 来对 Redis 进行命令行的操作,当然 Redis 官方也提供了在线的调试器,你也可以在里面敲入命令进行操作:http://try.redis.io/#run
-### 1、String(字符串)
-
-String 是 Redis 最基本的类型,你可以理解成与 Memcached 一模一样的类型,一个 key 对应一个 value。
-String 类型是二进制安全的。意思是 Redis 的 String 可以包含任何数据。比如 jpg 图片或者序列化的对象 。
-Redis 的字符串是动态字符串,是可以修改的字符串,**内部结构实现上类似于 Java 的 ArrayList**,采用预分配冗余空间的方式来减少内存的频繁分配,如图中所示,内部为当前字符串实际分配的空间 capacity 一般要高于实际字符串长度 len。当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。需要注意的是字符串最大长度为 512M。
-
-
+### 1、String(字符串)
+String 类型是二进制安全的。意思是 Redis 的 String 可以包含任何数据。比如 jpg 图片或者序列化的对象 。
+Redis 的字符串是动态字符串,是可以修改的字符串,**内部结构实现上类似于 Java 的 ArrayList**,采用预分配冗余空间的方式来减少内存的频繁分配,内部为当前字符串实际分配的空间 capacity 一般要高于实际字符串长度 len。当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间。需要注意的是字符串最大长度为 512M。
Redis 没有直接使用 C 语言传统的字符串表示(以空字符结尾的字符数组,以下简称 C 字符串), 而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型, 并将 SDS 用作 Redis 的默认字符串表示。
@@ -94,7 +123,7 @@ Redis 没有直接使用 C 语言传统的字符串表示(以空字符结尾
比如说, 下图就展示了一个值为 `"Redis"` 的 C 字符串:
-
+
C 语言使用的这种简单的字符串表示方式, 并不能满足 Redis 对字符串在安全性、效率、以及功能方面的要求
@@ -108,7 +137,21 @@ C 语言使用的这种简单的字符串表示方式, 并不能满足 Redis
举个例子, 对于下图所示的 SDS 来说, 程序只要访问 SDS 的 `len` 属性, 就可以立即知道 SDS 的长度为 `5` 字节:
- 
+ 
+
+ > **len**:当前字符串的长度(不包括末尾的 null 字符)。这使得 Redis 不需要在每次操作时都遍历整个字符串来获取其长度。
+ >
+ > **alloc**:当前为字符串分配的总空间(包括字符串数据和额外的内存空间)。由于 Redis 使用的是动态分配内存,因此可以避免频繁的内存分配和释放。
+ >
+ > **buf**:实际的字符串数据部分,存储字符串的字符数组。Redis 通过这个区域存储字符串的内容。
+ >
+ > ```c
+ > struct sdshdr {
+ > int len; // 当前字符串的长度
+ > int alloc; // 为字符串分配的空间, 2.X 版本用一个 free 表示当前字符串缓冲区中未使用的内存量
+ > unsigned char buf[]; // 字符串数据
+ > };
+ > ```
通过使用 SDS 而不是 C 字符串, Redis 将获取字符串长度所需的复杂度从 $O(N)$ 降低到了 $O(1)$ , 这确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈
@@ -129,11 +172,20 @@ C 语言使用的这种简单的字符串表示方式, 并不能满足 Redis
通过未使用空间, SDS 实现了空间预分配和惰性空间释放两种优化策略。
-- **二进制安全**
+- **SDS 如何保证二进制安全**:
- 因为 C 语言中的字符串必须符合某种编码(比如 ASCII),并且除了字符串的末尾之外, 字符串里面不能包含空字符, 否则最先被程序读入的空字符将被误认为是字符串结尾 —— 这些限制使得 C 字符串只能保存文本数据, 而不能保存像图片、音频、视频、压缩文件这样的二进制数据
+ - **不依赖于 null 字符**:**二进制安全**意味着 SDS 字符串可以包含任何数据,包括 null 字节。传统的 C 字符串依赖于 null 字符('\0')来表示字符串的结束,但 Redis 的 SDS 不依赖于 null 字符来确定字符串的结束位置。
+ - **动态扩展**:SDS 会根据需要动态地扩展其内部缓冲区。Redis 会使用 `alloc` 字段来记录已分配的内存大小。当你向 SDS 中追加数据时,Redis 会确保分配足够的内存,而不需要担心数据的终止符。
+ - **不需要二次编码**:二进制数据直接存储在 SDS 的 `buf` 区域内,不需要进行任何编码或转换。因此,Redis 可以原样存储任意二进制数据。
+ > C 语言中的字符串必须符合某种编码(比如 ASCII),并且除了字符串的末尾之外, 字符串里面不能包含空字符, 否则最先被程序读入的空字符将被误认为是字符串结尾 —— 这些限制使得 C 字符串只能保存文本数据, 而不能保存像图片、音频、视频、压缩文件这样的二进制数据
+- **编码方式**:
+ - **int**:当值可以用整数表示时,Redis 会使用整数编码。这样可以节省内存,并且在执行数值操作时更高效。
+ - **embstr**:这是一种特殊的编码方式,用于存储短字符串。当字符串的长度小于或等于 44 字节时,Redis 会使用 embstr 编码。只读,修改后自动转为 raw。这种编码方式将字符串直接存储在 Redis 对象的内部,这样可以减少内存分配和内存拷贝的次数,提高性能。
+ - **raw**:当字符串值不是整数时,Redis 会使用 raw 编码。raw 编码就是简单地将字符串存储为字节序列。Redis 会根据客户端发送的字节序列来存储字符串,因此可以存储任何类型的数据,包括二进制数据。
+
+> 可以通过 `TYPE KEY_NAME` 查看 key 所存储的值的类型验证下。
### 2、List(列表)
@@ -141,8 +193,6 @@ C 语言使用的这种简单的字符串表示方式, 并不能满足 Redis
当列表弹出了最后一个元素之后,该数据结构自动被删除,内存被回收。
-
-
Redis 的列表结构常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理
**右边进左边出:队列**
@@ -188,21 +238,19 @@ Redis 的列表结构常用来做异步队列使用。将需要延后处理的
- 列表中保存的单个数据(有可能是字符串类型的)小于 64 字节;
- 列表中数据个数少于 512 个。
->听到“压缩”两个字,直观的反应就是节省内存。之所以说这种存储结构节省内存,是相较于数组的存储思路而言的。我们知道,数组要求每个元素的大小相同,如果我们要存储不同长度的字符串,那我们就需要用最大长度的字符串大小作为元素的大小(假设是 20 个字节)。那当我们存储小于 20 个字节长度的字符串的时候,便会浪费部分存储空间。听起来有点儿拗口,我画个图解释一下。
->
->
->
->压缩列表这种存储结构,一方面比较节省内存,另一方面可以支持不同类型数据的存储。而且,因为数据存储在一片连续的内存空间,通过键来获取值为列表类型的数据,读取的效率也非常高。
+从 Redis 3.2 版本开始,列表的底层实现由压缩列表组成的快速列表(quicklist)所取代。
-当列表中存储的数据量比较大的时候,也就是不能同时满足刚刚讲的两个条件的时候,列表就要通过双向循环链表来实现了。
+- 快速列表由多个 ziplist 节点组成,每个节点使用指针连接,形成一个链表。
+- 这种方式结合了压缩列表的内存效率和小元素的快速访问,以及双向链表的灵活性。
-Redis 的这种双向链表的实现方式,非常值得借鉴。它额外定义一个 list 结构体,来组织链表的首、尾指针,还有长度等信息。这样,在使用的时候就会非常方便。
+>听到“压缩”两个字,直观的反应就是节省内存。之所以说这种存储结构节省内存,是相较于数组的存储思路而言的。
+>
+>它并不是基础数据结构,而是 Redis 自己设计的一种数据存储结构。它有点儿类似数组,通过一片连续的内存空间,来存储数据。不过,它跟数组不同的一点是,它允许存储的数据大小不同。听到“压缩”两个字,直观的反应就是节省内存。之所以说这种存储结构节省内存,是相较于数组的存储思路而言的。我们知道,数组要求每个元素的大小相同,如果我们要存储不同长度的字符串,那我们就需要用最大长度的字符串大小作为元素的大小(假设是 20 个字节)。那当我们存储小于 20 个字节长度的字符串的时候,便会浪费部分存储空间。压缩列表这种存储结构,一方面比较节省内存,另一方面可以支持不同类型数据的存储。而且,因为数据存储在一片连续的内存空间,通过键来获取值为列表类型的数据,读取的效率也非常高。当列表中存储的数据量比较大的时候,也就是不能同时满足刚刚讲的两个条件的时候,列表就要通过双向循环链表来实现了。
我们可以从 [源码](https://github.com/redis/redis/blob/unstable/src/adlist.h "redis源码") 的 `adlist.h/listNode` 来看到对其的定义:
```c
/* Node, List, and Iterator are the only data structures used currently. */
-
typedef struct listNode {
struct listNode *prev; // 前置节点
struct listNode *next; // 后置节点
@@ -230,23 +278,95 @@ typedef struct list {
Redis hash 是一个键值对集合。KV 模式不变,但 V 又是一个键值对。
-字典类型也有两种实现方式。一种是我们刚刚讲到的压缩列表,另一种是散列表。
+#### 1. **Redis Hash 的实现**
+
+Redis 使用的是 **哈希表(Hash Table)** 来存储 `Hash` 类型的数据。每个 `Hash` 对象都由一个或多个字段(field)和相应的值(value)组成。
+
+在 Redis 中,哈希表的数据结构是通过 **`dict`(字典)** 来实现的,`dict` 的结构包括键和值,其中每个键都是哈希表中的字段(field),而值是字段的值。
+
+哈希表的基本实现如下:
+
+- **哈希表**:采用 **开地址法** 来处理哈希冲突。每个 `Hash` 存储为一个哈希表,哈希表根据一定的哈希算法将键映射到一个位置。
+- **动态扩展**:当哈希表存储的元素数量达到一定的阈值时,Redis 会自动进行 **rehash(重哈希)** 操作,重新分配内存并调整哈希表的大小。
+
+Redis 字典由 **嵌套的三层结构** 构成,采用链地址法处理哈希冲突:
+
+```c
+// 哈希表结构
+typedef struct dictht {
+ dictEntry **table; // 二维数组(哈希桶)
+ unsigned long size; // 总槽位数(2^n 对齐)
+ unsigned long sizemask; // 槽位掩码(size-1)
+ unsigned long used; // 已用槽位数量
+} dictht;
+
+// 字典结构
+typedef struct dict {
+ dictType *type; // 类型特定函数(实现多态)
+ void *privdata; // 私有数据
+ dictht ht[2]; // 双哈希表(用于渐进式rehash)
+ long rehashidx; // rehash进度标记(-1表示未进行)
+} dict;
+
+// 哈希节点结构
+typedef struct dictEntry {
+ void *key; // 键(SDS字符串)
+ union {
+ void *val; // 值(Redis对象)
+ uint64_t u64;
+ int64_t s64;
+ } v;
+ struct dictEntry *next; // 链地址法指针
+} dictEntry;
+```
+
+#### 2. **Redis Hash 的内部结构**
+
+Redis 中的 `Hash` 是由 **哈希表** 和 **ziplist** 两种不同的数据结构实现的,具体使用哪一种结构取决于哈希表中键值对的数量和大小。
+
+2.1 **哈希表(Hash Table)**
+
+- 哈希表是 Redis 中实现字典(`dict`)的基础数据结构,使用 **哈希冲突解决方法**(例如链地址法或开地址法)来存储键值对。
+- 每个哈希表由两个数组组成:**键数组(key array)** 和 **值数组(value array)**,它们通过哈希算法映射到表中的相应位置。
+
+2.2 **Ziplist(压缩列表)**
+
+- **ziplist** 是一种内存高效的列表数据结构,在 Redis 中用于存储小型的 `Hash`,当哈希表中的元素个数较少时,Redis 会使用 `ziplist` 来节省内存。
+- Ziplist 是连续内存块,采用压缩存储。它适用于存储小量数据,避免哈希表内存开销。
+
+2.3 **切换机制**
+
+> - 字典中保存的键和值的大小都要小于 64 字节;
+> - 字典中键值对的个数要小于 512 个。
+
+当 Redis 中 `Hash` 的元素数量较小时,会使用 `ziplist`;当元素数量增多时,会切换到使用哈希表的方式。
-同样,只有当存储的数据量比较小的情况下,Redis 才使用压缩列表来实现字典类型。具体需要满足两个条件:字典中保存的键和值的大小都要小于 64 字节;字典中键值对的个数要小于 512 个。
+- **小型 Hash**:当哈希表的字段数量很少时,Redis 会使用 `ziplist` 来存储 `Hash`,因为它可以节省内存。
+- **大型 Hash**:当字段数量较多时,Redis 会将 `ziplist` 转换为 `哈希表` 来优化性能和内存管理。
-当不能同时满足上面两个条件的时候,Redis 就使用散列表来实现字典类型。Redis 使用 MurmurHash2 这种运行速度快、随机性好的哈希算法作为哈希函数。对于哈希冲突问题,Redis 使用链表法来解决。除此之外,Redis 还支持散列表的动态扩容、缩容。当数据动态增加之后,散列表的装载因子会不停地变大。为了避免散列表性能的下降,当装载因子大于 1 的时候,Redis 会触发扩容,将散列表扩大为原来大小的 2 倍左右(具体值需要计算才能得到,如果感兴趣,你可以去[阅读源码](https://github.com/redis/redis/blob/unstable/src/dict.c))。
+#### 3. Rehash(重哈希)
-扩容缩容要做大量的数据搬移和哈希值的重新计算,所以比较耗时。针对这个问题,Redis 使用我们在散列表(中)讲的渐进式扩容缩容策略,将数据的搬移分批进行,避免了大量数据一次性搬移导致的服务停顿。
+**Rehash** 是 Redis 用来扩展哈希表的一种机制。随着 `Hash` 中元素数量的增加,哈希表的负载因子(load factor)会逐渐增大,最终可能会导致哈希冲突增多,降低查询效率。为了解决这个问题,Redis 会在哈希表负载因子达到一定阈值时,执行 **rehash** 操作,即扩展哈希表。
+3.1 **Rehash 的过程**
+- **扩展哈希表**:Redis 会将哈希表的大小翻倍,并将现有的数据重新映射到新的哈希表中。扩展哈希表的目的是减少哈希冲突,提高查找效率。
-Redis 的字典相当于 Java 语言里面的 HashMap,它是无序字典, 内部实现结构上同 Java 的 HashMap 也是一致的,同样的**数组 + 链表**二维结构。第一维 hash 的数组位置碰撞时,就会将碰撞的元素使用链表串接起来。
+- **渐进式 rehash(Incremental Rehash)**:Redis 在执行重哈希时,并不会一次性将所有数据都重新映射到新的哈希表中,这样可以避免大量的阻塞操作。Redis 会分阶段地逐步迁移哈希表中的元素。这一过程通过增量的方式进行,逐步从旧哈希表中取出元素,放入新哈希表。
-不同的是,Redis 的字典的值只能是字符串,另外它们 rehash 的方式不一样,因为 Java 的 HashMap 在字典很大时,rehash 是个耗时的操作,需要一次性全部 rehash。Redis 为了高性能,不能堵塞服务,所以采用了渐进式 rehash 策略。
+ 这种增量迁移的方式保证了 **rehash** 操作不会一次性占用过多的 CPU 时间,避免了阻塞。
-渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,查询时会同时查询两个 hash 结构,然后在后续的定时任务中以及 hash 操作指令中,循序渐进地将旧 hash 的内容一点点迁移到新的 hash 结构中。当搬迁完成了,就会使用新的 hash 结构取而代之。
+3.2 **Rehash 的触发条件**
-当 hash 移除了最后一个元素之后,该数据结构自动被删除,内存被回收。
+Redis 会在以下情况下触发 rehash 操作:
+
+- 当哈希表的元素数量超过哈希表容量的负载因子阈值时(例如,默认阈值为 1),Redis 会开始进行 rehash 操作。
+- 当哈希表的空间变得非常紧张,Redis 会执行扩展操作。
+
+扩容就会涉及到键值对的迁移。具体来说,迁移操作会在以下两种情况下进行:
+
+1. **Lazy Rehashing(懒惰重哈希):** Redis 采用了懒惰重哈希的策略,即在进行哈希表扩容时,并不会立即将所有键值对都重新散列到新的存储桶中。而是在有需要的时候,例如进行读取操作时,才会将相应的键值对从旧存储桶迁移到新存储桶中。这种方式避免了一次性大规模的迁移操作,减少了扩容期间的阻塞时间。
+2. **Redis 事件循环(Event Loop):** Redis 会在事件循环中定期执行一些任务,包括一些与哈希表相关的操作。在事件循环中,Redis会检查是否有需要进行迁移的键值对,并将它们从旧存储桶迁移到新存储桶中。这样可以保证在系统负载较轻的时候进行迁移,减少对服务性能的影响。
@@ -288,7 +408,7 @@ typedef struct dict {
### 4、Set(集合)
-集合这种数据类型用来存储一组不重复的数据。这种数据类型也有两种实现方法,一种是基于有序数组,另一种是基于散列表。当要存储的数据,同时满足下面这样两个条件的时候,Redis 就采用有序数组,来实现集合这种数据类型。
+集合这种数据类型用来存储一组不重复的数据。这种数据类型也有两种实现方法,一种是基于整数集合,另一种是基于散列表。当要存储的数据,同时满足下面这样两个条件的时候,Redis 就采用整数集合(intset),来实现集合这种数据类型。
- 存储的数据都是整数;
- 存储的数据元素个数不超过 512 个。
@@ -299,9 +419,45 @@ Redis 的 Set 是 String 类型的无序集合。它是通过 HashTable 实现
当集合中最后一个元素移除之后,数据结构自动删除,内存被回收。
+```c
+// 定义整数集合结构
+typedef struct intset {
+ uint32_t encoding; // 编码方式
+ uint32_t length; // 集合长度
+ int8_t contents[]; // 元素数组
+} intset;
+
+// 定义哈希表节点结构
+typedef struct dictEntry {
+ void *key; // 键
+ union {
+ void *val; // 值
+ uint64_t u64;
+ int64_t s64;
+ double d;
+ } v;
+ struct dictEntry *next; // 指向下一个节点的指针
+} dictEntry;
+
+// 定义哈希表结构
+typedef struct dictht {
+ dictEntry **table; // 存储桶数组
+ unsigned long size; // 存储桶数量
+ unsigned long sizemask; // 存储桶数量掩码
+ unsigned long used; // 已使用存储桶数量
+} dictht;
+
+// 定义集合结构
+typedef struct {
+ uint32_t encoding; // 编码方式
+ dictht *ht; // 哈希表
+ intset *is; // 整数集合
+} set;
+```
+
-### 5、zset(sorted set:有序集合)
+### 5、Zset(sorted set:有序集合)
zset 和 set 一样也是 String 类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个 double 类型的分数。
@@ -318,41 +474,48 @@ zset 中最后一个 value 被移除后,数据结构自动删除,内存被
-为什么 Redis 要用跳表来实现有序集合,而不是红黑树?
-
-Redis 中的有序集合是通过跳表来实现的,严格点讲,其实还用到了散列表。不过散列表我们后面才会讲到,所以我们现在暂且忽略这部分。如果你去查看 Redis 的开发手册,就会发现,Redis 中的有序集合支持的核心操作主要有下面这几个:
-
-- 插入一个数据;
-- 删除一个数据;
-- 查找一个数据;
-- 按照区间查找数据(比如查找值在[100, 356]之间的数据);
-- 迭代输出有序序列。
-
-其中,插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。对于按照区间查找数据这个操作,跳表可以做到 $O(logn)$ 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效。当然,Redis 之所以用跳表来实现有序集合,还有其他原因,比如,跳表更容易代码实现。虽然跳表的实现也不简单,但比起红黑树来说还是好懂、好写多了,而简单就意味着可读性好,不容易出错。还有,跳表更加灵活,它可以通过改变索引构建策略,有效平衡执行效率和内存消耗。不过,跳表也不能完全替代红黑树。因为红黑树比跳表的出现要早一些,很多编程语言中的 Map 类型都是通过红黑树来实现的。我们做业务开发的时候,直接拿来用就可以了,不用费劲自己去实现一个红黑树,但是跳表并没有一个现成的实现,所以在开发中,如果你想使用跳表,必须要自己实现。
+> **为什么 Redis 要用跳表来实现有序集合,而不是红黑树?**
+>
+> Redis 中的有序集合是通过跳表来实现的,严格点讲,其实还用到了散列表。不过散列表我们后面才会讲到,所以我们现在暂且忽略这部分。如果你去查看 Redis 的开发手册,就会发现,Redis 中的有序集合支持的核心操作主要有下面这几个:
+>
+> - 插入一个数据;
+> - 删除一个数据;
+> - 查找一个数据;
+> - 按照区间查找数据(比如查找值在[100, 356]之间的数据);
+> - 迭代输出有序序列。
+>
+> 其中,插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。对于按照区间查找数据这个操作,跳表可以做到 $O(logn)$ 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效。当然,Redis 之所以用跳表来实现有序集合,还有其他原因,比如,跳表更容易代码实现。虽然跳表的实现也不简单,但比起红黑树来说还是好懂、好写多了,而简单就意味着可读性好,不容易出错。还有,跳表更加灵活,它可以通过改变索引构建策略,有效平衡执行效率和内存消耗。不过,跳表也不能完全替代红黑树。因为红黑树比跳表的出现要早一些,很多编程语言中的 Map 类型都是通过红黑树来实现的。我们做业务开发的时候,直接拿来用就可以了,不用费劲自己去实现一个红黑树,但是跳表并没有一个现成的实现,所以在开发中,如果你想使用跳表,必须要自己实现。
## 二、其他数据类型
-### bitmaps
+### Bitmap
-##### ☆☆位图:
+Redis 的 Bitmap 数据结构是一种基于 String 类型的位数组,它允许用户将字符串当作位向量来使用,并对这些位执行位操作。Bitmap 并不是 Redis 中的一个独立数据类型,而是通过在 String 类型上定义的一组位操作命令来实现的。由于 Redis 的 String 类型是二进制安全的,最大长度可以达到 512 MB,因此可以表示最多 $2^{32}$ 个不同的位。
-在我们平时开发过程中,会有一些 bool 型数据需要存取,比如用户一年的签到记录,签了是 1,没签是 0,要记录 365 天。如果使用普通的 key/value,每个用户要记录 365 个,当用户上亿的时候,需要的存储空间是惊人的。
+Bitmap 在 Redis 中的使用场景包括但不限于:
-为了解决这个问题,Redis 提供了位图数据结构,这样每天的签到记录只占据一个位,365 天就是 365 个位,46 个字节 (一个稍长一点的字符串) 就可以完全容纳下,这就大大节约了存储空间。
+1. **集合表示**:当集合的成员对应于整数 0 到 N 时,Bitmap 可以高效地表示这种集合。
+2. **对象权限**:每个位代表一个特定的权限,类似于文件系统存储权限的方式。
+3. **签到系统**:记录用户在特定时间段内的签到状态。
+4. **用户在线状态**:跟踪大量用户的在线或离线状态。
-
+Bitmap 操作的基本命令包括:
+- `SETBIT key offset value`:设置或清除 key 中 offset 位置的位值(只能是 0 或 1)。
+- `GETBIT key offset`:获取 key 中 offset 位置的位值,如果 key 不存在,则返回 0。
+Bitmap 还支持更复杂的位操作,如:
-位图不是特殊的数据结构,它的内容其实就是普通的字符串,也就是 byte 数组。我们可以使用普通的 get/set 直接获取和设置整个位图的内容,也可以使用位图操作 getbit/setbit 等将 byte 数组看成「位数组」来处理
+- `BITOP operation destkey key [key ...]`:对一个或多个 key 的 Bitmap 进行位操作(AND、OR、NOT、XOR)并将结果保存到 destkey。
+- `BITCOUNT key [start] [end]`:计算 key 中位数为 1 的数量,可选地在指定的 start 和 end 范围内进行计数。
- Redis 的位数组是自动扩展,如果设置了某个偏移位置超出了现有的内容范围,就会自动将位数组进行零扩充。
+Bitmap 在存储空间方面非常高效,例如,表示一亿个用户的登录状态,每个用户用一个位来表示,总共只需要 12 MB 的内存空间。
-
+在实际应用中,Bitmap 可以用于实现诸如亿级数据统计、用户行为跟踪等大规模数据集的高效管理。
-接下来我们使用 redis-cli 设置第一个字符,也就是位数组的前 8 位,我们只需要设置值为 1 的位,如上图所示,h 字符只有 1/2/4 位需要设置,e 字符只有 9/10/13/15 位需要设置。值得注意的是位数组的顺序和字符的位顺序是相反的。
+总的来说,Redis 的 Bitmap 是一种非常节省空间且功能强大的数据结构,适用于需要对大量二进制数据进行操作的场景。
```sh
127.0.0.1:6379> setbit s 1 1
@@ -375,43 +538,37 @@ Redis 中的有序集合是通过跳表来实现的,严格点讲,其实还
上面这个例子可以理解为「零存整取」,同样我们还也可以「零存零取」,「整存零取」。「零存」就是使用 setbit 对位值进行逐个设置,「整存」就是使用字符串一次性填充所有位数组,覆盖掉旧值。
-bitcount 和 bitop, bitpos, bitfield 都是操作位图的指令。
-
### HyperLogLog
Redis 在 2.8.9 版本添加了 HyperLogLog 结构。
-场景:可以用来统计站点的UV...
+Redis HyperLogLog 是一种用于基数统计的数据结构,它提供了一个近似的、不精确的解决方案来估算集合中唯一元素的数量,即集合的基数。HyperLogLog 特别适用于需要处理大量数据并且对精度要求不是特别高的场景,因为它使用非常少的内存(通常每个 HyperLogLog 实例只需要 12.4KB 左右,无论集合中有多少元素)。
-Redis HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。但是会有误差。
-
-| 命令 | 用法 | 描述 |
-| ------- | ------------------------------------------ | ----------------------------------------- |
-| pfadd | [PFADD key element [element ...] | 添加指定元素到 HyperLogLog 中 |
-| pfcount | [PFCOUNT key [key ...] | 返回给定 HyperLogLog 的基数估算值。 |
-| pfmerge | [PFMERGE destkey sourcekey [sourcekey ...] | 将多个 HyperLogLog 合并为一个 HyperLogLog |
-
-```java
-public class JedisTest {
- public static void main(String[] args) {
- Jedis jedis = new Jedis();
- for (int i = 0; i < 100000; i++) {
- jedis.pfadd("codehole", "user" + i);
- }
- long total = jedis.pfcount("codehole");
- System.out.printf("%d %d\n", 100000, total);
- jedis.close();
- }
-}
-```
+HyperLogLog 的主要特点包括:
+
+1. **近似统计**:HyperLogLog 不保证精确计算基数,但它提供了一个非常接近真实值的近似值。
+2. **内存效率**:HyperLogLog 能够使用固定大小的内存来估算基数,这使得它在处理大规模数据集时非常有用。
+3. **可合并性**:多个 HyperLogLog 实例可以合并,以估算多个集合的并集基数。
+
+HyperLogLog 的主要命令包括:
-[HyperLogLog图解](http://content.research.neustar.biz/blog/hll.html )
+- `PFADD key element [element ...]`:向 HyperLogLog 数据结构添加元素。如果 key 不存在,它将被创建。
+- `PFCOUNT key`:返回给定 HyperLogLog 的近似基数。
+- `PFMERGE destkey sourcekey [sourcekey ...]`:将多个 HyperLogLog 结构合并到一个单独的 HyperLogLog 结构中。
+
+HyperLogLog 的工作原理基于一个数学算法,它使用一个固定大小的位数组和一些哈希函数。当添加一个新元素时,它被哈希函数映射到一个位数组的索引,并根据哈希值的前几位来设置位数组中的位。基数估算是通过分析位数组中 0 的位置来完成的。
+
+由于 HyperLogLog 提供的是近似值,它有一个标准误差率,通常在 0.81% 左右。这意味着如果实际基数是 1000,HyperLogLog 估算的基数可能在 992 到 1008 之间。
+
+HyperLogLog 是处理大数据集基数统计的理想选择,尤其是当数据集太大而无法在内存中完全加载时。它在数据挖掘、日志分析、用户行为分析等领域有着广泛的应用。
+
+场景:可以用来统计站点的UV...
+> [HyperLogLog图解](http://content.research.neustar.biz/blog/hll.html )
-### Geo
diff --git a/docs/data-management/Redis/Reids-Lock.md b/docs/data-management/Redis/Redis-Lock.md
similarity index 98%
rename from docs/data-management/Redis/Reids-Lock.md
rename to docs/data-management/Redis/Redis-Lock.md
index cc7798d429..5719d23d85 100644
--- a/docs/data-management/Redis/Reids-Lock.md
+++ b/docs/data-management/Redis/Redis-Lock.md
@@ -6,7 +6,7 @@ tags:
categories: Redis
---
-
+
> 分布式锁的文章其实早就烂大街了,但有些“菜鸟”写的太浅,或者自己估计都没搞明白,没用过,看完后我更懵逼了,有些“大牛”写的吧,又太高级,只能看懂前半部分,后边就开始讲论文了,也比较懵逼,所以还得我这个中不溜的来总结下
>
@@ -146,7 +146,7 @@ end
1. 获取锁时,过期时间要设置多少合适呢?
- 预估一个合适的时间,其实没那么容易,比如操作资源的时间最慢可能要 10 s,而我们只设置了 5 s 就过期,那就存在锁提前过期的风险。这个问题先记下,我们一会看下 Javaer 要怎么在代码中用 Redis 锁。
+ 预估一个合适的时间,其实没那么容易,比如操作资源的时间最慢可能要 10s,而我们只设置了 5s 就过期,那就存在锁提前过期的风险。这个问题先记下,我们一会看下 Javaer 要怎么在代码中用 Redis 锁。
2. 容错性如何保证呢?
@@ -162,7 +162,7 @@ Redisson 是 Redis 官方的分布式锁组件。GitHub 地址:[https://github
> Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的 Java 常用对象,还实现了可重入锁(Reentrant Lock)、公平锁(Fair Lock、联锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)等,还提供了许多分布式服务。Redisson 提供了使用 Redis 的最简单和最便捷的方法。Redisson 的宗旨是促进使用者对 Redis 的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
-redisson 现在已经很强大了,github 的 wiki 也很详细,分布式锁的介绍直接戳 [Distributed locks and synchronizers](https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers)
+Redisson 现在已经很强大了,github 的 wiki 也很详细,分布式锁的介绍直接戳 [Distributed locks and synchronizers](https://github.com/redisson/redisson/wiki/8.-Distributed-locks-and-synchronizers)
Redisson 支持单点模式、主从模式、哨兵模式、集群模式,只是配置的不同,我们以单点模式来看下怎么使用,代码很简单,都已经为我们封装好了,直接拿来用就好,详细的 demo,我放在了 github: starfish-learn-redisson 上,这里就不一步步来了
@@ -176,7 +176,7 @@ RLock 提供了各种锁方法,我们来解读下这个接口方法,
#### RLock
-
+
```java
public interface RLock extends Lock, RLockAsync {
@@ -274,7 +274,7 @@ try {
先看下 RLock 的类关系
-
+
跟着源码,可以发现 RedissonLock 是 RLock 的直接实现,也是我们加锁、解锁操作的核心类
@@ -666,7 +666,7 @@ Redisson 提供了看门狗,每获得一个锁时,只设置一个很短的
-
+
## 四、RedLock
diff --git a/docs/data-management/Redis/Redis-MQ.md b/docs/data-management/Redis/Redis-MQ.md
index db3ae8b4d0..64527748f9 100644
--- a/docs/data-management/Redis/Redis-MQ.md
+++ b/docs/data-management/Redis/Redis-MQ.md
@@ -6,7 +6,7 @@ tags:
categories: Redis
---
-
+
现如今的互联网应用大都是采用 **分布式系统架构** 设计的,所以 **消息队列** 已经逐渐成为企业应用系统 **内部通信** 的核心手段,
@@ -32,7 +32,7 @@ categories: Redis
>
> 通过提供 **消息传递** 和 **消息排队** 模型,它可以在 **分布式环境** 下提供 **应用解耦**、**弹性伸缩**、**冗余存储**、**流量削峰**、**异步通信**、**数据同步** 等等功能,其作为 **分布式系统架构** 中的一个重要组件,有着举足轻重的地位。
-
+
@@ -104,7 +104,7 @@ Redis 提供了好几对 List 指令,先大概看下这些命令,混个眼
127.0.0.1:6379>
```
-
+
@@ -142,7 +142,7 @@ Redis 提供了好几对 List 指令,先大概看下这些命令,混个眼
-因为 Redis 单线程的特点,所以在消费数据时,同一个消息会不会同时被多个 `consumer` 消费掉,但是需要我们考虑消费不成功的情况。
+因为 Redis 单线程的特点,所以在消费数据时,同一个消息不会同时被多个 `consumer` 消费掉,但是需要我们考虑消费不成功的情况。
#### 可靠队列模式 | ack 机制
@@ -172,7 +172,7 @@ Redis 提供了好几对 List 指令,先大概看下这些命令,混个眼
1) "three"
```
-
+
@@ -193,7 +193,7 @@ Redis 提供了好几对 List 指令,先大概看下这些命令,混个眼
List 实现方式其实就是点对点的模式,下边我们再看下 Redis 的发布订阅模式(消息多播),这才是“根正苗红”的 Redis MQ
-
+
"发布/订阅"模式同样可以实现进程间的消息传递,其原理如下:
@@ -207,11 +207,11 @@ Redis 通过 `PUBLISH` 、 `SUBSCRIBE` 等命令实现了订阅与发布模式
我们启动三个 Redis 客户端看下效果:
-
+
先启动两个客户端订阅(subscribe) 名字叫 framework 的频道,然后第三个客户端往 framework 发消息,可以看到前两个客户端都会接收到对应的消息:
-
+
我们可以看到订阅的客户端每次可以收到一个 3 个参数的消息,分别为:
@@ -221,11 +221,11 @@ Redis 通过 `PUBLISH` 、 `SUBSCRIBE` 等命令实现了订阅与发布模式
再来看下订阅符合给定**模式**的频道,这回订阅的命令是 `PSUBSCRIBE`
-
+
我们往 `java.framework` 这个频道发送了一条消息,不止订阅了该频道的 Consumer1 和 Consumer2 可以接收到消息,订阅了模式 `java.*` 的 Consumer3 和 Consumer4 也可以接收到消息。
-
+
#### Pub/Sub 常用命令:
@@ -250,7 +250,9 @@ Redis 5.0 版本新增了一个更强大的数据结构——**Stream**。它提
它就像是个仅追加内容的**消息链表**,把所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容。而且消息是持久化的。
-
+
+
+
@@ -258,7 +260,7 @@ Redis 5.0 版本新增了一个更强大的数据结构——**Stream**。它提
-Streams 是 Redis 专门为消息队列设计的数据类型,所以提供了丰富的消息队列操作命令。
+Stream 是 Redis 专门为消息队列设计的数据类型,所以提供了丰富的消息队列操作命令。
#### Stream 常用命令
@@ -366,7 +368,7 @@ Streams 是 Redis 专门为消息队列设计的数据类型,所以提供了
`xread` 虽然可以扇形分发到 N 个客户端,然而,在某些问题中,我们想要做的不是向许多客户端提供相同的消息流,而是从同一流向许多客户端提供不同的消息子集。比如下图这样,三个消费者按轮训的方式去消费一个 Stream。
-
+
Redis Stream 借鉴了很多 Kafka 的设计。
@@ -375,15 +377,15 @@ Redis Stream 借鉴了很多 Kafka 的设计。
- **last_delivered_id** :每个消费组会有个游标 last_delivered_id 在数组之上往前移动,表示当前消费组已经消费到哪条消息了
- **pending_ids** :消费者的状态变量,作用是维护消费者的未确认的 id。 pending_ids 记录了当前已经被客户端读取的消息,但是还没有 ack。如果客户端没有 ack,这个变量里面的消息 ID 会越来越多,一旦某个消息被 ack,它就开始减少。这个 pending_ids 变量在 Redis 官方被称之为 `PEL`,也就是 `Pending Entries List`,这是一个很核心的数据结构,它用来确保客户端至少消费了消息一次,而不会在网络传输的中途丢失了没处理。
-
+
-Stream 不像 Kafak 那样有分区的概念,如果想实现类似分区的功能,就要在客户端使用一定的策略将消息写到不同的 Stream。
+Stream 不像 Kafka 那样有分区的概念,如果想实现类似分区的功能,就要在客户端使用一定的策略将消息写到不同的 Stream。
- `xgroup create`:创建消费者组
- `xgreadgroup`:读取消费组中的消息
- `xack`:ack 掉指定消息
-.jpg)
+
```shell
# 创建消费者组的时候必须指定 ID, ID 为 0 表示从头开始消费,为 $ 表示只消费新的消息,也可以自己指定
@@ -480,7 +482,7 @@ Stream 提供了 `xreadgroup` 指令可以进行消费组的组内消费,需
> 以梦为马,越骑越傻。诗和远方,越走越慌。不忘初心是对的,但切记要出发,加油吧,程序员。
-> 在路上的你,可以微信搜「 **JavaKeeper** 」一起前行,无套路领取 500+ 本电子书和 30+ 视频教学和源码,本文 **GitHub** [github.com/JavaKeeper)](https://github.com/Jstarfish/JavaKeeper)已经收录,服务端开发、面试必备技能兵器谱,有你想要的!
+
diff --git a/docs/data-management/Redis/Redis-Master-Slave.md b/docs/data-management/Redis/Redis-Master-Slave.md
index 98d80c0012..bce755ff33 100644
--- a/docs/data-management/Redis/Redis-Master-Slave.md
+++ b/docs/data-management/Redis/Redis-Master-Slave.md
@@ -6,7 +6,7 @@ tags:
categories: Redis
---
-
+
> 我们总说的 Redis 具有高可靠性,其实,这里有两层含义:一是数据尽量少丢失,二是服务尽量少中断。
>
@@ -14,7 +14,7 @@ categories: Redis
>
> 这就是 Redis 的主从模式,主从库之间采用的是读写分离的方式。
-
+
### 一、主从复制是啥
@@ -145,7 +145,7 @@ Redis 主从库之间的同步,在不同阶段有不同的处理方式,我
#### 4.1 全量复制 | 快照同步
-
+
为了节省篇幅,我把主要的步骤都 **浓缩** 在了上图中,其实也可以 **简化成三个阶段:建立连接阶段-数据同步阶段-命令传播阶段**。
@@ -171,6 +171,17 @@ Redis 主从库之间的同步,在不同阶段有不同的处理方式,我
3. 最后,也就是第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。
具体的操作是,当主库完成 RDB 文件发送后,就会把此时 `replication buffer` 中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。
+
+ > 主节点在生成 RDB 文件时,会将新的写命令(例如 `SET`、`DEL` 等)追加到**复制积压缓冲区**中,同时这些命令也会通过网络直接发送到所有已连接的从节点。
+ >
+ > 这些写操作是以 **Redis 协议格式(RESP)** 逐条发送的。
+ >
+ > - 双管齐下(RDB + 命令传播)
+ >
+ > ##### **数据传输协议:RESP**(Redis Serialization Protocol)
+ >
+ > - Redis 的所有数据通信,包括 RDB 文件和增量数据的发送,均基于其内部协议 **RESP(Redis Serialization Protocol)**。
+ > - 增量数据是以 Redis 命令流的形式,序列化为 RESP 格式后通过 TCP 连接发送的
@@ -192,7 +203,7 @@ replicaof 所选从库的IP 6379
再看下文章开头的图。
-
+
##### 无盘复制
@@ -243,13 +254,11 @@ replicaof 所选从库的IP 6379
主库对应的偏移量是 `master_repl_offset`,从库的偏移量 `slave_repl_offset` 。正常情况下,这两个偏移量基本相等。
-
-
-.jpg)
+
在网络断连阶段,主库可能会收到新的写操作命令,这时,`master_repl_offset` 会大于 `slave_repl_offset`。此时,主库只用把 `master_repl_offset` 和 `slave_repl_offset` 之间的命令操作同步给从库就可以了。
-
+
> PS:因为 repl_backlog_buffer 是一个环形缓冲区(可以理解为是一个定长的环形数组),所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。**如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致**。如果从库和主库**断连时间过长**,造成它在主库 repl_backlog_buffer 的 slave_repl_offset 位置上的数据已经被覆盖掉了,此时从库和主库间将进行全量复制。
>
diff --git a/docs/data-management/Redis/Redis-Persistence.md b/docs/data-management/Redis/Redis-Persistence.md
index 246e261b5b..9bb9414067 100644
--- a/docs/data-management/Redis/Redis-Persistence.md
+++ b/docs/data-management/Redis/Redis-Persistence.md
@@ -3,9 +3,10 @@ title: Redis的持久化机制
date: 2020-12-20
tags:
- Redis
+categories: Redis
---
-# Redis的持久化机制
+
> 带着疑问,或者是面试问题去看 Redis 的持久化,或许会有不一样的视角,这几个问题你废了吗?
>
@@ -47,11 +48,11 @@ RDB 的缺点是最后一次持久化后的数据可能丢失。
**配置位置**: SNAPSHOTTING
-
+
rdb 默认保存的是 **dump.rdb** 文件,如下(不可读)
-
+
你可以对 Redis 进行设置, 让它在“ N 秒内数据集至少有 M 个改动”这一条件被满足时, 自动保存一次数据集。
@@ -70,7 +71,20 @@ rdb 默认保存的是 **dump.rdb** 文件,如下(不可读)
> 简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。此时,如果主线程对这些数据也都是读操作(例如图中的键值对K1),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 K3),那么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。
>
-> 
+> 
+>
+> 当 Redis 触发 RDB 快照时,它会通过 `fork` 系统调用来创建一个子进程,子进程负责将内存中的数据写入到磁盘,而父进程则继续响应客户端请求。**写时复制(COW)** 机制使得在 fork 后,父子进程共享同一份内存页,只有当父进程或子进程对内存进行修改时,操作系统才会复制出新的内存页,从而减少了内存复制的开销。
+>
+> 尽管如此,`fork` 子进程的操作本身仍然是有一定开销的,尤其在数据量非常大的时候,尽管采用了 COW,但以下因素可能会导致 **性能下降**:
+>
+> - **大量数据**:当 Redis 中的数据量非常大时(几千万或几亿条键值对),fork 子进程仍然需要花费一定的时间来生成 RDB 文件,即使是写时复制也会带来一定的延迟。
+> - **系统资源压力**:如果机器的内存不足,频繁的 fork 和写时复制操作可能导致系统的 **内存不足** 和 **CPU 高负载**,尤其是在数据量较大的时候。
+> - **磁盘 I/O**:即便使用写时复制,子进程最终需要将数据写入磁盘,磁盘 I/O 的性能也会影响快照的时间。如果磁盘写入速度较慢,生成 RDB 文件的过程可能会很慢。
+>
+> 如果 Redis 在执行一次 RDB 快照时,快照尚未完成,新的写操作又触发了一个新的 RDB 快照,那么会发生 **重叠触发** 的问题。
+>
+> - **新的 RDB 快照会等待前一个快照完成**。
+> - 如果 **`save` 配置的触发条件**(如 `save 900 1`)满足且当前快照未完成,Redis 会 **阻塞新的快照请求**,直到当前的快照操作完成。这是为了避免对系统资源的过度消耗,防止多次快照操作同时进行。
@@ -88,7 +102,7 @@ rdb 默认保存的是 **dump.rdb** 文件,如下(不可读)
将备份文件 (dump.rdb) 移动到 Redis 安装目录并启动服务即可(`CONFIG GET dir` 获取目录)
-
+
@@ -115,7 +129,7 @@ rdb 默认保存的是 **dump.rdb** 文件,如下(不可读)
#### 小总结
-
+
- RDB 是一个非常紧凑的文件
@@ -139,7 +153,7 @@ AOF 默认保存的是 **appendonly.aof ** 文件
**配置位置**: APPEND ONLY MODE
-
+
@@ -166,7 +180,7 @@ AOF 默认保存的是 **appendonly.aof ** 文件
不过,AOF 日志正好相反,它是写后日志,“写后”的意思是 Redis 是先执行命令,把数据写入内存,然后才记录日志,如下图所示:
-
+
> Tip:日志先行的方式,如果宕机后,还可以通过之前保存的日志恢复到之前的数据状态。可是 AOF 后写日志的方式,如果宕机后,不就会把写入到内存的数据丢失吗?
>
@@ -178,7 +192,7 @@ AOF 默认保存的是 **appendonly.aof ** 文件
例如,`*2` 表示有两个部分,`$6` 表示 6 个字节,也就是下边的 “SELECT” 命令,`$1` 表示 1 个字节,也就是下边的 “0” 命令,合起来就是 `SELECT 0`,选择 0 库。下边的指令同理,就很好理解了 `SET K1 V1`。
-
+
但是,为了避免额外的检查开销,**Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。而写后日志这种方式,就是先让系统执行命令,只有命令能执行成功,才会被记录到日志中,否则,系统就会直接向客户端报错**。所以,Redis 使用写后日志这一方式的一大好处是,可以避免出现记录错误命令的情况。除此之外,AOF 还有一个好处:它是在命令执行后才记录日志,所以**不会阻塞当前的写操作**。
@@ -230,19 +244,26 @@ AOF 默认保存的是 **appendonly.aof ** 文件
#### rewrite(AOF 重写)
-- 是什么:AOF 采用文件追加方式,文件会越来越大,为了避免出现这种情况,新增了重写机制,当 AOF 文件的大小超过所设定的阈值时,Redis 就会启动 AOF 文件的内容压缩,只保留可以恢复数据的最小指令集,可以使用命令 `bgrewriteaof`,这个操作相当于对 AOF 文件“瘦身”。在重写的时候,是根据这个键值对当前的最新状态,为它生成对应的写入命令。这样一来,一个键值对在重写日志中只用一条命令就行了,而且,在日志恢复时,只用执行这条命令,就可以直接完成这个键值对的写入了。
-
- 
+- 是什么:AOF 采用文件追加方式,文件会越来越大,为了避免出现这种情况,新增了重写机制,当 AOF 文件的大小超过所设定的阈值时,**Redis 就会启动 AOF 文件的内容压缩,只保留可以恢复数据的最小指令集**,可以使用命令 `bgrewriteaof`,这个操作相当于对 AOF 文件“瘦身”。在重写的时候,是根据这个键值对当前的最新状态,为它生成对应的写入命令。这样一来,一个键值对在重写日志中只用一条命令就行了,而且,在日志恢复时,只用执行这条命令,就可以直接完成这个键值对的写入了。
-- 重写原理:AOF 文件持续增长而过大时,会 fork 出一条新进程来将文件重写(也是先写临时文件最后再rename),遍历新进程的内存中数据,转换成一条条的操作指令,再序列化到一个新的 AOF 文件中。
+ 
- PS: 重写 AOF 文件的操作,并没有读取旧的 AOF 文件,而是将整个内存中的数据库内容用命令的方式重写了一个新的 AOF 文件,这点和快照有点类似。
+- **重写原理**:AOF 文件持续增长而过大时,会 fork 出一条新进程来将文件重写(也是先写临时文件最后再rename),遍历新进程的内存中数据,转换成一条条的操作指令,再序列化到一个新的 AOF 文件中。
-- 触发机制:Redis 会记录上次重写时的 AOF 大小,默认配置是当 AOF 文件大小是上次 rewrite 后大小的一倍且文件大于 64M 时触发
+ > PS: 重写 AOF 文件的操作,并没有读取旧的 AOF 文件,而是将整个内存中的数据库内容用命令的方式重写了一个新的 AOF 文件,这点和快照有点类似。
- 我们在客户端输入两次 `set k1 v1` ,然后比较 `bgrewriteaof` 前后两次的 appendonly.aof 文件(先要关闭混合持久化)
+- 触发机制:
-
+ - **AOF 文件增长比例超过一定阈值**:Redis 会记录上次重写时的 AOF 大小,默认配置是当 AOF 文件大小是上次 rewrite 后大小的**一倍**且文件大于 64M 时触发
+
+ ```
+ auto-aof-rewrite-min-size 64mb //指定触发重写的 AOF 文件最小大小(默认为 64MB)
+ auto-aof-rewrite-percentage 100 //指定 AOF 文件增长的百分比(默认为 100%)
+ ```
+
+ - **手动触发 AOF 重写**: 通过执行 `BGREWRITEAOF` 命令,用户可以手动触发 AOF 重写
+
+ 我们在客户端输入两次 `set k1 v1` ,然后比较 `bgrewriteaof` 前后两次的 appendonly.aof 文件(先要关闭混合持久化)
@@ -270,13 +291,22 @@ AOF 重写和 RDB 创建快照一样,都巧妙地利用了写时复制机制
以下是 AOF 重写的执行步骤:
-1. Redis 执行 `fork()` ,现在同时拥有父进程和子进程。
-2. 子进程开始将新 AOF 文件的内容写入到临时文件。
-3. 对于所有新执行的写入命令,父进程一边将它们累积到一个内存缓存中,一边将这些改动追加到现有 AOF 文件的末尾: 这样即使在重写的中途发生停机,现有的 AOF 文件也还是安全的。
-4. 当子进程完成重写工作时,它给父进程发送一个信号,父进程在接收到信号之后,将内存缓存中的所有数据追加到新 AOF 文件的末尾。
-5. 搞定!现在 Redis 原子地用新文件替换旧文件,之后所有命令都会直接追加到新 AOF 文件的末尾。
+1. **创建一个新的 AOF 文件**:Redis 执行 `fork()` ,它会**异步地**在后台创建一个新的 AOF 文件(通常命名为 `appendonly.aof.new`)。现在同时拥有父进程和子进程。
+
+2. **重写的内容——只保留当前数据库状态**:子进程开始将新 AOF 文件的内容写入到临时文件。
+
+ 在执行 AOF 重写时,Redis 不会记录每个命令的详细内容,而是通过以下方式来确保重写后的 AOF 文件有效:
+
+ - **仅记录当前数据集的重建命令**:Redis 会根据数据库当前的状态生成一系列 `SET`、`HSET`、`LPUSH` 等命令,这些命令能够重建当前数据库的状态。这些命令是可以直接执行的、能够恢复数据的命令。
+ - **压缩冗余命令**:在重写过程中,Redis 会去除一些无意义或重复的命令。例如,如果有大量重复的 `SET key value` 操作,Redis 只会保留最新的 `SET` 命令,而忽略之前的操作。
+
+3. **复制 AOF文件**:对于所有新执行的写入命令,父进程一边将它们累积到一个内存缓存中,一边将这些改动追加到现有 AOF 文件(即 `appendonly.aof`)的末尾: 这样即使在重写的中途发生停机,现有的 AOF 文件也还是安全的,这个过程称为 **追加操作**。
+
+4. **重写完成后的替换**:当子进程完成重写工作时,它给父进程发送一个信号,父进程在接收到信号之后,将内存缓存中的所有数据追加到新 AOF 文件的末尾。
+
+5. **删除过时的 AOF 文件**:搞定!现在 Redis 原子地用新文件替换旧文件,之后所有命令都会直接追加到新 AOF 文件的末尾。
-
+
#### 优势
@@ -292,7 +322,7 @@ AOF 重写和 RDB 创建快照一样,都巧妙地利用了写时复制机制
#### 总结
-
+
- AOF 文件是一个只进行追加的日志文件
- Redis 可以在 AOF 文件体积变得过大时,自动在后台对 AOF 进行重写
@@ -355,7 +385,7 @@ Redis 4.0 中提出了一个**混合使用 AOF 日志和内存快照的方法**
同样我们执行 3 次 `set k1 v1`,然后手动瘦身 `bgrewriteaof` 后,查看 appendonly.aof 文件:
-
+
这样做的好处是可以结合 rdb 和 aof 的优点,快速加载同时避免丢失过多的数据,缺点是 aof 里面的 rdb 部分就是压缩格式不再是 aof 格式,可读性差。
@@ -365,7 +395,7 @@ Redis 4.0 中提出了一个**混合使用 AOF 日志和内存快照的方法**
如下图所示,两次快照中间时刻的修改,用 AOF 日志记录,等到第二次做全量快照时,就可以清空 AOF 日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。
-
+
这个方法既能享受到 RDB 文件快速恢复的好处,又能享受到 AOF 只记录操作命令的简单优势,有点“鱼和熊掌可以兼得”的意思。
diff --git a/docs/data-management/Redis/Redis-Sentinel.md b/docs/data-management/Redis/Redis-Sentinel.md
index 10e19df0e5..fcab77ecf2 100644
--- a/docs/data-management/Redis/Redis-Sentinel.md
+++ b/docs/data-management/Redis/Redis-Sentinel.md
@@ -6,7 +6,7 @@ tags:
categories: Redis
---
-
+
> 我们知道 Reids 提供了主从模式的机制,来保证可用性,可是如果主库发生故障了,那就直接会影响到从库的同步,怎么办呢?
>
@@ -22,7 +22,7 @@ categories: Redis
### 一、Redis Sentinel 哨兵
-
+
上图 展示了一个典型的哨兵架构图,它由两部分组成,哨兵节点和数据节点:
@@ -43,7 +43,7 @@ categories: Redis
- **配置提供者(Configuration provider):** 客户端在初始化时,通过连接哨兵来获得当前 Redis 服务的主节点地址。
- 当客户端试图连接失效的主服务器时, 集群也会向客户端返回新主服务器的地址, 使得集群可以使用新主服务器代替失效服务器。
+ 当客户端试图连接失效的主服务器时, 集群也会向客户端返回新主服务器的地址,使得集群可以使用新主服务器代替失效服务器。
其中,监控和自动故障转移功能,使得哨兵可以及时发现主节点故障并完成转移。而配置提供者和通知功能,则需要在与客户端的交互中才能体现。
@@ -162,7 +162,7 @@ master0:name=mymaster,status=ok,address=127.0.0.1:6379,slaves=2,sentinels=3
我们先看下我们启动的 redis 进程,3 个数据节点,3 个哨兵节点
-
+
使用 `kill` 命令来杀掉主节点,**同时** 在哨兵节点中执行 `info Sentinel` 命令来观察故障节点的过程:
@@ -272,9 +272,18 @@ master0:name=mymaster,status=ok,address=127.0.0.1:6381,slaves=2,sentinels=3
每个实例都会有一个 runid,这个 ID 就类似于这里的从库的编号。目前,Redis 在选主库时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库。
-.jpg)
-
+
+> ##### **哨兵模式是否会出现脑裂问题?**
+>
+> - **哨兵模式下存在脑裂风险。**
+> - 当网络分区或通信异常时,可能导致旧主节点未完全下线,新的主节点被选出,导致两个主节点同时存在,形成**脑裂**问题。
+>
+> #### **解决方法:**
+>
+> 1. **主节点心跳检测**:通过哨兵的客观下线判断,多数哨兵节点确认主节点下线,减少误判。
+> 2. **客户端重连机制**:客户端连接断开后,需要重新通过哨兵获取正确的主节点地址。
+> 3. **配置防止脑裂**:`quorum` 参数设置哨兵节点的投票数量,避免少数节点误判主节点下线。
### 四、哨兵集群的原理
@@ -323,7 +332,7 @@ sentinel monitor mymaster 127.0.0.1 6379 2
在下图中,哨兵 sentinel_26379 把自己的 IP(127.0.0.1)和端口(26379)发布到频道上,哨兵 26380 和 26381 订阅了该频道。那么此时,其他哨兵就可以从这个频道直接获取哨兵 sentinel_26379 的 IP 地址和端口号。通过这个方式,各个哨兵之间就可以建立网络连接,哨兵集群就形成了。它们相互间可以通过网络连接进行通信,比如说对主库有没有下线这件事儿进行判断和协商。
-
+
#### 4.2 哨兵和从库的连接
@@ -335,7 +344,7 @@ sentinel monitor mymaster 127.0.0.1 6379 2
就像下图所示,哨兵 sentinel_26380 给主库发送 INFO 命令,主库接受到这个命令后,就会把从库列表返回给哨兵。接着,哨兵就可以根据从库列表中的连接信息,和每个从库建立连接,并在这个连接上持续地对从库进行监控。senetinel_26379 和 senetinel_26381 可以通过相同的方法和从库建立连接。
-
+
#### 4.3 哨兵和客户端的连接
@@ -365,7 +374,7 @@ PSUBSCRIBE *
### 五、小结
-> Redis 哨兵是 Redis 的高可用实现方案:故障发现、故障自动转移、配置中心、客户端通知。
+Redis 哨兵是 Redis 的高可用实现方案:故障发现、故障自动转移、配置中心、客户端通知。
#### 5.1 哨兵机制其实就有三大功能:
@@ -411,7 +420,7 @@ PSUBSCRIBE *
-### 参考与来源
+### References
- 《Redis 开发与运维》
- 《Redis 核心技术与实战》
diff --git a/docs/data-management/Redis/Redis-Transaction.md b/docs/data-management/Redis/Redis-Transaction.md
index ded25cee88..97c95bcb8c 100644
--- a/docs/data-management/Redis/Redis-Transaction.md
+++ b/docs/data-management/Redis/Redis-Transaction.md
@@ -8,7 +8,7 @@ categories: Redis
> 文章收录在 GitHub [JavaKeeper](https://github.com/Jstarfish/JavaKeeper) ,N线互联网开发必备技能兵器谱
-
+
> 假设现在有这样一个业务,用户获取的某些数据来自第三方接口信息,为避免频繁请求第三方接口,我们往往会加一层缓存,缓存肯定要有时效性,假设我们要存储的结构是 hash(没有String的'**SET anotherkey "will expire in a minute" EX 60**'这种原子操作),我们既要批量去放入缓存,又要保证每个 key 都加上过期时间(以防 key 永不过期),这时候事务操作是个比较好的选择
@@ -34,7 +34,7 @@ Redis 在形式上看起来也差不多,分为三个阶段
2. 命令入队(业务操作)
3. 执行事务(exec)或取消事务(discard)
-```
+```sh
> multi
OK
> incr star
@@ -87,11 +87,11 @@ MULTI 执行之后, 客户端可以继续向服务器发送任意多条命令
**正常执行**(可以批处理,挺爽,每条操作成功的话都会各取所需,互不影响)
-
+
**放弃事务**(discard 操作表示放弃事务,之前的操作都不算数)
-
+
@@ -112,11 +112,11 @@ Redis 针对如上两种错误采用了不同的处理策略,对于发生在 `
**全体连坐**(某一条操作记录报错的话,exec 后所有操作都不会成功)
-
+
**冤头债主**(示例中 k1 被设置为 String 类型,decr k1 可以放入操作队列中,因为只有在执行的时候才可以判断出语句错误,其他正确的会被正常执行)
-
+
@@ -154,17 +154,17 @@ OK
我们看个简单的例子,用 watch 监控我的账号余额(一周100零花钱的我),正常消费
-
+
但这个卡,还绑定了我媳妇的支付宝,如果在我消费的时候,她也消费了,会怎么样呢?
犯困的我去楼下 711 买了包烟,买了瓶水,这时候我媳妇在超市直接刷了 100,此时余额不足的我还在挑口香糖来着,,,
-
+
这时候我去结账,发现刷卡失败(事务中断),尴尬的一批
-
+
@@ -172,7 +172,7 @@ OK
> 当然,这里也会出现只要你媳妇刷了你的卡,就没办法刷成功的问题,这时候可以先查下余额,重新开启事务继续刷
-
+
@@ -200,7 +200,7 @@ OK
在代表数据库的 `server.h/redisDb` 结构类型中, 都保存了一个 `watched_keys` 字典, 字典的键是这个数据库被监视的键, 而字典的值是一个链表, 链表中保存了所有监视这个键的客户端,如下图。
-
+
```c
typedef struct redisDb {
@@ -222,7 +222,7 @@ list *watched_keys; /* Keys WATCHED for MULTI/EXEC CAS */
举个例子, 如果当前客户端为 `client99` , 那么当客户端执行 `WATCH key2 key3` 时, 前面展示的 `watched_keys` 将被修改成这个样子:
-
+
通过 `watched_keys` 字典, 如果程序想检查某个键是否被监视, 那么它只要检查字典中是否存在这个键即可; 如果程序要获取监视某个键的所有客户端, 那么只要取出键的值(一个链表), 然后对链表进行遍历即可。
@@ -230,7 +230,7 @@ list *watched_keys; /* Keys WATCHED for MULTI/EXEC CAS */
在任何对数据库键空间(key space)进行修改的命令成功执行之后 (比如 FLUSHDB、SET 、DEL、LPUSH、 SADD,诸如此类), `multi.c/touchWatchedKey` 函数都会被调用 —— 它会去 `watched_keys` 字典, 看是否有客户端在监视已经被命令修改的键, 如果有的话, 程序将所有监视这个/这些被修改键的客户端的 `REDIS_DIRTY_CAS` 选项打开:
-
+
```c
void multiCommand(client *c) {
diff --git a/docs/data-management/Redis/Redis-clients.md b/docs/data-management/Redis/Redis-clients.md
deleted file mode 100644
index e4d8e4051a..0000000000
--- a/docs/data-management/Redis/Redis-clients.md
+++ /dev/null
@@ -1,3 +0,0 @@
- https://redis.io/clients#java
-
-
\ No newline at end of file
diff --git a/docs/data-management/Redis/reprint/.DS_Store b/docs/data-management/Redis/reproduce/.DS_Store
similarity index 100%
rename from docs/data-management/Redis/reprint/.DS_Store
rename to docs/data-management/Redis/reproduce/.DS_Store
diff --git a/docs/data-management/Redis/Cache-Design.md b/docs/data-management/Redis/reproduce/Cache-Design.md
similarity index 100%
rename from docs/data-management/Redis/Cache-Design.md
rename to docs/data-management/Redis/reproduce/Cache-Design.md
diff --git "a/docs/data-management/Redis/Key \345\257\273\345\235\200\347\256\227\346\263\225.md" "b/docs/data-management/Redis/reproduce/Key \345\257\273\345\235\200\347\256\227\346\263\225.md"
similarity index 100%
rename from "docs/data-management/Redis/Key \345\257\273\345\235\200\347\256\227\346\263\225.md"
rename to "docs/data-management/Redis/reproduce/Key \345\257\273\345\235\200\347\256\227\346\263\225.md"
diff --git "a/docs/data-management/Redis/reprint/Redis\344\270\272\344\273\200\344\271\210\345\217\230\346\205\242\344\272\206-\345\270\270\350\247\201\345\273\266\350\277\237\351\227\256\351\242\230\345\256\232\344\275\215\344\270\216\345\210\206\346\236\220.md" "b/docs/data-management/Redis/reproduce/Redis\344\270\272\344\273\200\344\271\210\345\217\230\346\205\242\344\272\206-\345\270\270\350\247\201\345\273\266\350\277\237\351\227\256\351\242\230\345\256\232\344\275\215\344\270\216\345\210\206\346\236\220.md"
similarity index 100%
rename from "docs/data-management/Redis/reprint/Redis\344\270\272\344\273\200\344\271\210\345\217\230\346\205\242\344\272\206-\345\270\270\350\247\201\345\273\266\350\277\237\351\227\256\351\242\230\345\256\232\344\275\215\344\270\216\345\210\206\346\236\220.md"
rename to "docs/data-management/Redis/reproduce/Redis\344\270\272\344\273\200\344\271\210\345\217\230\346\205\242\344\272\206-\345\270\270\350\247\201\345\273\266\350\277\237\351\227\256\351\242\230\345\256\232\344\275\215\344\270\216\345\210\206\346\236\220.md"
diff --git a/docs/data-structure-algorithms/.DS_Store b/docs/data-structure-algorithms/.DS_Store
index 17d5083a81..661b6ce50c 100644
Binary files a/docs/data-structure-algorithms/.DS_Store and b/docs/data-structure-algorithms/.DS_Store differ
diff --git a/docs/data-structure-algorithms/BFS.md b/docs/data-structure-algorithms/BFS.md
deleted file mode 100755
index 8c3521d7b3..0000000000
--- a/docs/data-structure-algorithms/BFS.md
+++ /dev/null
@@ -1,205 +0,0 @@
-## 齐头并进的广度优先遍历
-
-> DFS(深度优先搜索)和 BFS(广度优先搜索)就像孪生兄弟,提到一个总是想起另一个。然而在实际使用中,我们用 DFS 的时候远远多于 BFS。那么,是不是 BFS 就没有什么用呢?
->
-> 如果我们使用 DFS/BFS 只是为了遍历一棵树、一张图上的所有结点的话,那么 DFS 和 BFS 的能力没什么差别,我们当然更倾向于更方便写、空间复杂度更低的 DFS 遍历。不过,某些使用场景是 DFS 做不到的,只能使用 BFS 遍历。
->
-> DFS 遍历使用递归:
->
-> ```java
-> void dfs(TreeNode root) {
-> if (root == null) {
-> return;
-> }
-> dfs(root.left);
-> dfs(root.right);
-> }
-> ```
->
-> BFS 遍历使用队列数据结构:
->
-> ```jaava
-> void bfs(TreeNode root) {
-> Queue queue = new ArrayDeque<>();
-> queue.add(root);
-> while (!queue.isEmpty()) {
-> TreeNode node = queue.poll(); // Java 的 pop 写作 poll()
-> if (node.left != null) {
-> queue.add(node.left);
-> }
-> if (node.right != null) {
-> queue.add(node.right);
-> }
-> }
-> }
-> ```
->
-> 只是比较两段代码的话,最直观的感受就是:DFS 遍历的代码比 BFS 简洁太多了!这是因为递归的方式隐含地使用了系统的 栈,我们不需要自己维护一个数据结构。如果只是简单地将二叉树遍历一遍,那么 DFS 显然是更方便的选择。
->
-> 虽然 DFS 与 BFS 都是将二叉树的所有结点遍历了一遍,但它们遍历结点的顺序不同。
->
-> 
-
-
-
-
-
-「广度优先遍历」的思想在生活中随处可见:
-
-如果我们要找一个医生或者律师,我们会先在自己的一度人脉中遍历(查找),如果没有找到,继续在自己的二度人脉中遍历(查找),直到找到为止。
-
-### 广度优先遍历借助「队列」实现
-
-广度优先遍历呈现出「一层一层向外扩张」的特点,**先看到的结点先遍历,后看到的结点后遍历**,因此「广度优先遍历」可以借助「队列」实现。
-
-
-
-**说明**:遍历到一个结点时,如果这个结点有左(右)孩子结点,依次将它们加入队列。
-
-> 友情提示:广度优先遍历的写法相对固定,我们不建议大家背代码、记模板。在深刻理解广度优先遍历的应用场景(找无权图的最短路径),借助「队列」实现的基础上,多做练习,写对代码就是自然而然的事情了
->
-
-我们先介绍「树」的广度优先遍历,再介绍「图」的广度优先遍历。事实上,它们是非常像的。
-
-
-
-
-
-### 树的广度优先遍历
-
-例 1:「力扣」第 102 题:二叉树的层序遍历(中等)
-
-> 给你一个二叉树,请你返回其按 层序遍历 得到的节点值。 (即逐层地,从左到右访问所有节点)。
->
-> 示例:
->
-> 二叉树:[3,9,20,null,null,15,7],
->
-> ```
-> 3
-> / \
-> 9 20
-> / \
-> 15 7
-> ```
->
-> 返回其层序遍历结果:
->
-> ```
-> [
-> [3],
-> [9,20],
-> [15,7]
-> ]
-> ```
-
-思路分析:
-
-- 题目要求我们一层一层输出树的结点的值,很明显需要使用「广度优先遍历」实现;
-- 广度优先遍历借助「队列」实现;
-
-- 注意:
- - 这样写 for (int i = 0; i < queue.size(); i++) { 代码是不能通过测评的,这是因为 queue.size() 在循环中是变量(这条规则在 Python 中不成立,请各位读者自行验证)。正确的做法是:每一次在队列中取出元素的个数须要先暂存起来,请见参考代码;
- - 子结点入队的时候,非空的判断很重要:在队列的队首元素出队的时候,一定要在左(右)子结点非空的时候才将左(右)子结点入队。
-- 树的广度优先遍历的写法模式相对固定:
- - 使用队列;
- - 在队列非空的时候,动态取出队首元素;
- - 取出队首元素的时候,把队首元素相邻的结点(非空)加入队列。
-
-大家在做题的过程中需要多加练习,融汇贯通,不须要死记硬背。
-
-
-
-```java
-public class Solution {
-
- public List> levelOrder(TreeNode root) {
- List> res = new ArrayList<>();
- if (root == null) {
- return res;
- }
-
- Queue queue = new LinkedList<>();
- queue.offer(root);
- while (!queue.isEmpty()) {
- // 注意 1:一定要先把当前队列的结点总数暂存起来
- int currentSize = queue.size();
-
- List currentLevel = new ArrayList<>();
- for (int i = 0; i < currentSize; i++) {
- TreeNode front = queue.poll();
- currentLevel.add(front.val);
- // 注意 2:左(右)孩子结点非空才加入队列
- if (front.left != null) {
- queue.offer(front.left);
- }
- if (front.right != null) {
- queue.offer(front.right);
- }
- }
- res.add(currentLevel);
- }
- return res;
- }
-}
-```
-
-
-
-### 使用广度优先遍历得到无权图的最短路径
-
-在 无权图 中,由于广度优先遍历本身的特点,假设源点为 source,只有在遍历到 所有 距离源点 source 的距离为 d 的所有结点以后,才能遍历到所有 距离源点 source 的距离为 d + 1 的所有结点。也可以使用「两点之间、线段最短」这条经验来辅助理解如下结论:从源点 source 到目标结点 target 走直线走过的路径一定是最短的。
-
-> 在一棵树中,一个结点到另一个结点的路径是唯一的,但在图中,结点之间可能有多条路径,其中哪条路最近呢?这一类问题称为最短路径问题。最短路径问题也是 BFS 的典型应用,而且其方法与层序遍历关系密切。
->
-> 在二叉树中,BFS 可以实现一层一层的遍历。在图中同样如此。从源点出发,BFS 首先遍历到第一层结点,到源点的距离为 1,然后遍历到第二层结点,到源点的距离为 2…… 可以看到,用 BFS 的话,距离源点更近的点会先被遍历到,这样就能找到到某个点的最短路径了。
->
-> 
->
-> 小贴士:
->
-> 很多同学一看到「最短路径」,就条件反射地想到「Dijkstra 算法」。为什么 BFS 遍历也能找到最短路径呢?
->
-> 这是因为,Dijkstra 算法解决的是带权最短路径问题,而我们这里关注的是无权最短路径问题。也可以看成每条边的权重都是 1。这样的最短路径问题,用 BFS 求解就行了。
->
-> 在面试中,你可能更希望写 BFS 而不是 Dijkstra。毕竟,敢保证自己能写对 Dijkstra 算法的人不多。
->
-> 最短路径问题属于图算法。由于图的表示和描述比较复杂,本文用比较简单的网格结构代替。网格结构是一种特殊的图,它的表示和遍历都比较简单,适合作为练习题。在 LeetCode 中,最短路径问题也以网格结构为主。
->
->
-
-### 图论中的最短路径问题概述
-
-在图中,由于 图中存在环,和深度优先遍历一样,广度优先遍历也需要在遍历的时候记录已经遍历过的结点。特别注意:将结点添加到队列以后,一定要马上标记为「已经访问」,否则相同结点会重复入队,这一点在初学的时候很容易忽略。如果很难理解这样做的必要性,建议大家在代码中打印出队列中的元素进行调试:在图中,如果入队的时候不马上标记为「已访问」,相同的结点会重复入队,这是不对的。
-
-另外一点还需要强调,广度优先遍历用于求解「无权图」的最短路径,因此一定要认清「无权图」这个前提条件。如果是带权图,就需要使用相应的专门的算法去解决它们。事实上,这些「专门」的算法的思想也都基于「广度优先遍历」的思想,我们为大家例举如下:
-
-- 带权有向图、且所有权重都非负的单源最短路径问题:使用 Dijkstra 算法;
-- 带权有向图的单源最短路径问题:Bellman-Ford 算法;
-
-- 一个图的所有结点对的最短路径问题:Floy-Warshall 算法。
-
-这里列出的以三位计算机科学家的名字命名的算法,大家可以在《算法导论》这本经典著作的第 24 章、第 25 章找到相关知识的介绍。值得说明的是:应用任何一种算法,都需要认清使用算法的前提,不满足前提直接套用算法是不可取的。深刻理解应用算法的前提,也是学习算法的重要方法。例如我们在学习「二分查找」算法、「滑动窗口」算法的时候,就可以问自己,这个问题为什么可以使用「二分查找」,为什么可以使用「滑动窗口」。我们知道一个问题可以使用「优先队列」解决,是什么样的需求促使我们想到使用「优先队列」,而不是「红黑树(平衡二叉搜索树)」,想清楚使用算法(数据结构)的前提更重要。
-
-
-
-> 「力扣」第 323 题:无向图中连通分量的数目(中等)
-
-### 练习
-
-> 友情提示:第 1 - 4 题是广度优先遍历的变形问题,写对这些问题有助于掌握广度优先遍历的代码编写逻辑和细节。
-
-1. 完成「力扣」第 107 题:二叉树的层次遍历 II(简单);
-2. 完成《剑指 Offer》第 32 - I 题:从上到下打印二叉树(中等);
-3. 完成《剑指 Offer》第 32 - III 题:从上到下打印二叉树 III(中等);
-4. 完成「力扣」第 103 题:二叉树的锯齿形层次遍历(中等);
-5. 完成「力扣」第 429 题:N 叉树的层序遍历(中等);
-6. 完成「力扣」第 993 题:二叉树的堂兄弟节点(中等);
-
-
-
-
-
-## Reference
-
-- https://leetcode-cn.com/problems/binary-tree-level-order-traversal/solution/bfs-de-shi-yong-chang-jing-zong-jie-ceng-xu-bian-l/
diff --git a/docs/data-structure-algorithms/DFS.md b/docs/data-structure-algorithms/DFS.md
deleted file mode 100755
index 7df2e920a9..0000000000
--- a/docs/data-structure-algorithms/DFS.md
+++ /dev/null
@@ -1,1614 +0,0 @@
-## 一、深度优先搜索
-
-
-
-在线性结构中,按照顺序一个一个地看到所有的元素,称为线性遍历。在非线性结构中,由于元素之间的组织方式变得复杂,就有了不同的遍历行为。其中最常见的遍历有:**深度优先遍历**(Depth-First-Search)和**广度优先遍历**(Breadth-First-Search)。它们的思想非常简单,但是在算法的世界里发挥着巨大的作用。
-
-
-
-### 深度优先遍历的形象描述
-
-「一条路走到底,不撞南墙不回头」是对「深度优先遍历」的最直观描述。
-
-说明:
-
-- 深度优先遍历只要前面有可以走的路,就会一直向前走,直到无路可走才会回头;
-- 「无路可走」有两种情况:① 遇到了墙;② 遇到了已经走过的路;
-- 在「无路可走」的时候,沿着原路返回,直到回到了还有未走过的路的路口,尝试继续走没有走过的路径;
-- 有一些路径没有走到,这是因为找到了出口,程序就停止了;
-- 「深度优先遍历」也叫「深度优先搜索」,遍历是行为的描述,搜索是目的(用途);
-- 遍历不是很深奥的事情,把 **所有** 可能的情况都看一遍,才能说「找到了目标元素」或者「没找到目标元素」。遍历也称为 **穷举**,穷举的思想在人类看来虽然很不起眼,但借助 **计算机强大的计算能力**,穷举可以帮助我们解决很多专业领域知识不能解决的问题。
-
-
-
-### 初识「搜索」
-
-「遍历」和「搜索」可以看作是两个等价概念,通过遍历 **所有** 的可能的情况达到搜索的目的。遍历是手段,搜索是目的。因此「深度优先遍历」也叫「深度优先搜索」。
-
-
-
-### 树的深度优先遍历
-
-我们以「二叉树」的深度优先遍历为例,向大家介绍树的深度优先遍历。
-
-二叉树的深度优先遍历从「根结点」开始,依次 「递归地」 遍历「左子树」的所有结点和「右子树」的所有结点。
-
-
-
-事实上,「根结点 → 右子树 → 左子树」也是一种深度优先遍历的方式,为了符合人们「先左再右」的习惯。如果没有特别说明,树的深度优先遍历默认都按照 「根结点 → 左子树 → 右子树」 的方式进行。
-
-**二叉树深度优先遍历的递归终止条件**:遍历完一棵树的 **所有** 叶子结点,等价于遍历到 **空结点**。
-
-
-
-二叉树的深度优先遍历还可以分为:前序遍历、中序遍历和后序遍历。
-
-1. 前序遍历
-
- 对于任意一棵子树,先输出根结点,再递归输出左子树的 所有 结点、最后递归输出右子树的 所有 结点。上图前序遍历的结果就是深度优先遍历的结果:[0、1、3、4、7、2、5、8、9、6、10]。
-
-2. 中序遍历
-
- 对于任意一棵子树,先递归输出左子树的 所有 结点,然后输出根结点,最后递归输出右子树的 所有 结点。上图中序遍历的结果是:[3、1、7、4、0、8、5、9、2、10、6]。
-
-3. 后序遍历(重要)
- 对于任意一棵子树,总是先递归输出左子树的 所有 结点,然后递归输出右子树的 所有 结点,最后输出根结点。后序遍历体现的思想是:先必需得到左右子树的结果,才能得到当前子树的结果,这一点在解决一些问题的过程中非常有用。上图后序遍历的结果是:[3、7、4、1、8、9、5、10、6、2、0]。
-
-> 友情提示:后序遍历是非常重要的遍历方式,解决很多树的问题都采用了后序遍历的思想,请大家务必重点理解「后序遍历」一层一层向上传递信息的遍历方式。并在做题的过程中仔细体会「后序遍历」思想的应用。
-
-4. 为什么前、中、后序遍历都是深度优先遍历
-
- 可以把树的深度优先遍历想象成一只蚂蚁,从根结点绕着树的外延走一圈。每一个结点的外延按照下图分成三个部分:前序遍历是第一部分,中序遍历是第二部分,后序遍历是第三部分。
-
-
-
-只看结点的第一部分(红色区域),深度优先遍历到的结点顺序就是「前序遍历」的顺序
-
-
-
-只看结点的第二部分(黄色区域),深度优先遍历到的结点顺序就是「中序遍历」的顺序
-
-
-
-
-
-只看结点的第三部分(绿色区域),深度优先遍历到的结点顺序就是「后序遍历」的顺序
-
-
-
-5. 重要性质
-
- 根据定义不难得到以下性质。
-
- - 性质 1:二叉树的 前序遍历 序列,根结点一定是 最先 访问到的结点;
- - 性质 2:二叉树的 后序遍历 序列,根结点一定是 最后 访问到的结点;
- - 性质 3:根结点把二叉树的 中序遍历 序列划分成两个部分,第一部分的所有结点构成了根结点的左子树,第二部分的所有结点构成了根结点的右子树。
-
-> 友情提示:根据这些性质,可以完成「力扣」第 105 题、第 106 题,这两道问题是面试高频问题,请大家务必掌握。
-
-
-
-### 图的深度优先遍历
-
-深度优先遍历有「回头」的过程,在树中由于不存在「环」(回路),对于每一个结点来说,每一个结点只会被递归处理一次。而「图」中由于存在「环」(回路),就需要 记录已经被递归处理的结点(通常使用布尔数组或者哈希表),以免结点被重复遍历到。
-
-
-
-### 练习
-
-下面这些练习可能是大家在入门「树」这个专题的过程中做过的问题,以前我们在做这些问题的时候可以总结为:树的问题可以递归求解。现在我们可以用「深度优先遍历」的思想,特别是「后序遍历」的思想重新看待这些问题。
-
-请大家通过这些问题体会 「**如何设计递归函数的返回值**」 帮助我们解决问题。并理解这些简单的问题其实都是「深度优先遍历」的思想中「后序遍历」思想的体现,真正程序在执行的时候,是通过「一层一层向上汇报」的方式,最终在根结点汇总整棵树遍历的结果。
-
-1. 完成「力扣」第 104 题:二叉树的最大深度(简单):设计递归函数的返回值;
-2. 完成「力扣」第 111 题:二叉树的最小深度(简单):设计递归函数的返回值;
-3. 完成「力扣」第 112 题:路径总和(简单):设计递归函数的返回值;
-4. 完成「力扣」第 226 题:翻转二叉树(简单):前中后序遍历、广度优先遍历均可,中序遍历有一个小小的坑;
-5. 完成「力扣」第 100 题:相同的树(简单):设计递归函数的返回值;
-6. 完成「力扣」第 101 题:对称二叉树(简单):设计递归函数的返回值;
-7. 完成「力扣」第 129 题:求根到叶子节点数字之和(中等):设计递归函数的返回值。
-8. 完成「力扣」第 236 题:二叉树的最近公共祖先(中等):使用后序遍历的典型问题。
-
-请大家完成下面这些树中的问题,加深对前序遍历序列、中序遍历序列、后序遍历序列的理解。
-
-9. 完成「力扣」第 105 题:从前序与中序遍历序列构造二叉树(中等);
-10. 完成「力扣」第 106 题:从中序与后序遍历序列构造二叉树(中等);
-11. 完成「力扣」第 1008 题:前序遍历构造二叉搜索树(中等);
-
-12. 完成「力扣」第 1028 题:从先序遍历还原二叉树(困难)。
-
-> 友情提示:需要用到后序遍历思想的一些经典问题,这些问题可能有一些难度,可以不用急于完成。先做后面的问题,见多了类似的问题以后,慢慢理解「后序遍历」一层一层向上汇报,在根结点汇总的遍历思想。
-
-
-
-### 总结
-
-- 遍历可以用于搜索,思想是穷举,遍历是实现搜索的手段;
-- 树的「前、中、后」序遍历都是深度优先遍历;
-- 树的后序遍历很重要;
-- 由于图中存在环(回路),图的深度优先遍历需要记录已经访问过的结点,以避免重复访问;
-- 遍历是一种简单、朴素但是很重要的算法思想,很多树和图的问题就是在树和图上执行一次遍历,在遍历的过程中记录有用的信息,得到需要结果,区别在于为了解决不同的问题,在遍历的时候传递了不同的 与问题相关 的数据。
-
-
-
-## 二、数据结构-栈
-
-
-
-### 深度优先遍历的两种实现方式
-
-在深度优先遍历的过程中,需要将 当前遍历到的结点 的相邻结点 暂时保存 起来,以便在回退的时候可以继续访问它们。遍历到的结点的顺序呈现「后进先出」的特点,因此 深度优先遍历可以通过「栈」实现。
-
-再者,深度优先遍历有明显的递归结构。我们知道支持递归实现的数据结构也是栈。因此实现深度优先遍历有以下两种方式:
-
-- 编写递归方法;
-- 编写栈,通过迭代的方式实现。
-
-
-
-
-
-### 二叉树三种遍历方式的非递归实现
-
-我们通过例题的方式向大家展现一种使用栈模拟的二叉树深度优先遍历的过程。但是要向大家说的是:
-
-- 并不是所有的递归(深度优先遍历)的问题都可以很方便地使用「栈」实现,了解非递归实现可以作为编程练习;
-- 虽然「递归调用」在一些编程语言中会造成系统资源开销,性能不如非递归好,还有可能造成「栈溢出」的风险,但是 在工程实践 中,递归方法的可读性更强,更易于理解和以后的维护,因此没有必要苛求必需要将递归方法转换成为非递归实现。
-
-例 1:「力扣」第 144 题:二叉树的前序遍历(简单)
-
-思路分析:递归方法相信大家都会。这里介绍使用栈模拟递归的过程。
-
-对于二叉树的遍历,每一个结点有两种处理方式:
-
-- 输出该结点;
-- 递归处理该结点。
-
-我们可以在结点存入栈的时候附加一个「指令信息」,ADDTORESULT 表示输出该结点(添加到结果集),GO 表示递归处理该结点。在栈顶元素的弹出的时候,读取「指令信息」,遇到 GO 的时候,就将当前结点的左、右孩子结点按照「前序遍历」(根 -> 左 -> 右)的「倒序」的方式压入栈中。
-
-「倒序」是因为栈处理元素的顺序是「后进先出」,弹栈的时候才会按照我们想要的顺序输出到结果集。
-
-读者可以结合下面的参考代码理解这种使用「栈」模拟了递归(深度优先遍历)的思想。
-
-注意:
-
-使用栈模拟递归实现的方式并不唯一,这里介绍的栈模拟的方法可以迁移到「力扣」第 94 题(二叉树的中序遍历)和「力扣」第 145 题(二叉树的后序遍历),例 2 和例 3 我们不再过多描述;
-感兴趣的朋友还可以参考这些问题的官方题解了解更多使用栈模拟深度优先遍历的实现。
-
-参考代码 1:递归
-
-```java
-public class Solution {
-
- public List preorderTraversal(TreeNode root) {
- List res = new ArrayList<>();
- dfs(root, res);
- return res;
- }
-
- private void dfs(TreeNode treeNode, List res) {
- if (treeNode == null) {
- return;
- }
- res.add(treeNode.val);
- dfs(treeNode.left, res);
- dfs(treeNode.right, res);
- }
-}
-```
-
-复杂度分析:
-
-时间复杂度:O(N),这里 NN 为二叉树的结点总数;
-空间复杂度:O(N),栈的深度为需要使用的空间的大小,极端情况下,树成为一个链表的时候,栈的深度达到最大。
-
-参考代码 2:使用栈模拟
-
-```java
-public class Solution {
-
- private enum Action {
- /**
- * 如果当前结点有孩子结点(左右孩子结点至少存在一个),执行 GO
- */
- GO,
- /**
- * 添加到结果集(真正输出这个结点)
- */
- ADDTORESULT
- }
-
- private class Command {
- private Action action;
- private TreeNode node;
-
- /**
- * 将动作类与结点类封装起来
- *
- * @param action
- * @param node
- */
- public Command(Action action, TreeNode node) {
- this.action = action;
- this.node = node;
- }
- }
-
- public List preorderTraversal(TreeNode root) {
- List res = new ArrayList<>();
- if (root == null) {
- return res;
- }
-
- Deque stack = new ArrayDeque<>();
- stack.addLast(new Command(Action.GO, root));
- while (!stack.isEmpty()) {
- Command command = stack.removeLast();
- if (command.action == Action.ADDTORESULT) {
- res.add(command.node.val);
- } else {
- // 特别注意:以下的顺序与递归执行的顺序反着来,即:倒过来写的结果
- // 前序遍历:根结点、左子树、右子树、
- // 添加到栈的顺序:右子树、左子树、根结点
- if (command.node.right != null) {
- stack.add(new Command(Action.GO, command.node.right));
- }
- if (command.node.left != null) {
- stack.add(new Command(Action.GO, command.node.left));
- }
- stack.add(new Command(Action.ADDTORESULT, command.node));
- }
- }
- return res;
- }
-}
-```
-
-复杂度分析:(同参考代码 1)。
-
-说明:在理解了例 1 以后,例 2 和 例 3 可以类似地完成,我们不再对例 2 和例 3 进行详解、对复杂度展开分析,只给出参考代码。
-
-
-
-## 三、深度优先遍历的应用
-
-
-
-### 应用 1:获得图(树)的一些属性
-
-在一些树的问题中,其实就是通过一次深度优先遍历,获得树的某些属性。例如:「二叉树」的最大深度、「二叉树」的最小深度、平衡二叉树、是否 BST。在遍历的过程中,通常需要设计一些变量,一边遍历,一边更新设计的变量的值。
-
-「力扣」第 129 题:求根到叶子节点数字之和(中等)
-
-```java
-public int sumNumbers(TreeNode root) {
- return dfs(root,0);
-}
-
-public int dfs(TreeNode root,int prevSum){
- if(root == null){
- return 0;
- }
- int sum = prevSum * 10 + root.val;
- if(root.left == null && root.right == null){
- return sum;
- }else{
- return dfs(root.left,sum) + dfs(root.right,sum);
- }
-}
-```
-友情提示:既然可以一层一层得到一个数,广度优先遍历也是很自然的想法,读者可以尝试使用广度优先遍历的思想完成该问题。
-
-
-
-### 应用 2:计算无向图的连通分量
-
-「力扣」第 323 题:无向图中连通分量的数目(中等)
-
-思路分析:
-
-首先需要对输入数组进行处理,由于 n 个结点的编号从 0 到 n - 1 ,因此使用「嵌套数组」表示邻接表即可(具体实现见代码);
-然后遍历每一个顶点,对每一个顶点执行一次深度优先遍历,注意:在遍历的过程中使用 visited 布尔数组记录已经遍历过的结点。
-
-```java
-public class Solution {
-public int countComponents(int n, int[][] edges) {
- // 第 1 步:构建图
- List[] adj = new ArrayList[n];
- for (int i = 0; i < n; i++) {
- adj[i] = new ArrayList<>();
- }
- // 无向图,所以需要添加双向引用
- for (int[] edge : edges) {
- adj[edge[0]].add(edge[1]);
- adj[edge[1]].add(edge[0]);
- }
-
- // 第 2 步:开始深度优先遍历
- int count = 0;
- boolean[] visited = new boolean[n];
- for (int i = 0; i < n; i++) {
- if (!visited[i]) {
- dfs(adj, i, visited);
- count++;
- }
- }
- return count;
-}
-
-/**
- * @param adj 邻接表
- * @param u 从顶点 u 开始执行深度优先遍历
- * @param visited 记录某个结点是否被访问过
- */
-private void dfs(List[] adj, int u, boolean[] visited) {
- visited[u] = true;
- List successors = adj[u];
- for (int successor : successors) {
- if (!visited[successor]) {
- dfs(adj, successor, visited);
- }
- }
-}
- }
-```
-
-
-### 应用 3:检测图中是否存在环
-
-我们分别通过两个例子讲解「无向图」中环的检测和「有向图」中环的检测(是不是要讲一下拓扑排序)。
-
-例 3:「力扣」第 684 题:冗余连接(中等)
-
-```java
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-public class Solution {
-
- private Map> graph;
- private Set visited;
-
- public int[] findRedundantConnection(int[][] edges) {
- this.graph = new HashMap<>();
- this.visited = new HashSet<>();
-
- // 遍历每一条边
- for (int[] edge : edges) {
- int u = edge[0];
- int v = edge[1];
- if (graph.containsKey(u) && graph.containsKey(v)) {
- visited.clear();
- // 深度优先遍历该图,判断 u 到 v 之间是否已经存在了一条路径
- if (dfs(u, v)) {
- return edge;
- }
- }
- // 所有相邻顶点都找不到回路,才向图中添加这条边,由于是无向图,所以要添加两条边
- addEdge(u, v);
- addEdge(v, u);
- }
- return null;
- }
-
- private void addEdge(int u, int v) {
- if (graph.containsKey(u)) {
- graph.get(u).add(v);
- return;
- }
- List successors = new ArrayList<>();
- successors.add(v);
- graph.put(u, successors);
- }
-
-
- /**
- * 从 source 开始进行深度优先遍历,看看是不是能够找到一条到 target 的回路
- *
- * @param source
- * @param target
- * @return 找不到回路返回 false
- */
- private boolean dfs(int source, int target) {
- if (source == target) {
- return true;
- }
- visited.add(source);
- // 遍历 source 的所有相邻顶点
- for (int adj : graph.get(source)) {
- if (!visited.contains(adj)) {
- if (dfs(adj, target)) {
- return true;
- }
- }
- }
- // 所有相邻顶点都找不到,才能返回 false
- return false;
- }
-}
-```
-
-> 友情提示:该问题还可以使用拓扑排序完成。事实上,无向图找是否存在环是「并查集」这个数据结构的典型应用。
-
-
-
-「力扣」第 802 题:找到最终的安全状态(中等)
-
-```java
-import java.util.ArrayList;
-import java.util.List;
-
-public class Solution {
-
- /**
- * 使用 Boolean 利用了 null 表示还未计算出结果
- * true 表示从当前顶点出发的所有路径有回路
- * false 表示从当前顶点出发的所有路径没有回路
- */
- private Boolean[] visited;
-
- public List eventualSafeNodes(int[][] graph) {
- int len = graph.length;
- visited = new Boolean[len];
- List res = new ArrayList<>();
- for (int i = 0; i < len; ++i) {
- if (dfs(i, graph)) {
- continue;
- }
- res.add(i);
- }
- return res;
-
- }
-
- /**
- * @param u
- * @param graph
- * @return 从顶点 u 出发的所有路径是不是有一条能够回到 u,有回路就返回 true
- */
- private boolean dfs(int u, int[][] graph) {
- if (visited[u] != null) {
- return visited[u];
- }
- // 先默认从 u 出发的所有路径有回路
- visited[u] = true;
- // 结点 u 的所有后继结点都不能回到自己,才能认为结点 u 是安全的
- for (int successor : graph[u]) {
- if (dfs(successor, graph)) {
- return true;
- }
- }
- // 注意:这里需要重置
- visited[u] = false;
- return false;
- }
-}
-```
-复杂度分析:
-
-- 时间复杂度:O(V + E)O(V+E),这里 VV 为图的顶点总数,EE 为图的边数;
-
-- 空间复杂度:O(V + E)O(V+E)。
-
-总结:
-
-- 在声明变量、设计递归函数的时候,一定要明确递归函数的变量的定义和递归函数的返回值,写上必要的注释,这样在编写代码逻辑的时候,才不会乱。
-
-> 友情提示:还可以使用拓扑排序(借助入度和广度优先遍历)或者并查集两种方法完成。
-
-
-
-### 应用 5:拓扑排序
-
-「力扣」第 210 题:课程表 II(中等)
-
-> 现在你总共有 n 门课需要选,记为 0 到 n-1。
->
-> 在选修某些课程之前需要一些先修课程。 例如,想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们: [0,1]。
->
-> 给定课程总量以及它们的先决条件,返回你为了学完所有课程所安排的学习顺序。
->
-> 可能会有多个正确的顺序,你只要返回一种就可以了。如果不可能完成所有课程,返回一个空数组。
->
-> 示例 1:
->
-> ```
-> 输入: 2, [[1,0]]
-> 输出: [0,1]
-> 解释: 总共有 2 门课程。要学习课程 1,你需要先完成课程 0。因此,正确的课程顺序为 [0,1] 。
-> ```
->
->
-> 示例 2:
->
-> ```
-> 输入: 4, [[1,0],[2,0],[3,1],[3,2]]
-> 输出: [0,1,2,3] or [0,2,1,3]
-> 解释: 总共有 4 门课程。要学习课程 3,你应该先完成课程 1 和课程 2。并且课程 1 和课程 2 都应该排在课程 0 之后。因此,一个正确的课程顺序是 [0,1,2,3] 。另一个正确的排序是 [0,2,1,3] 。
-> ```
-
-说明:
-
-1. 输入的先决条件是由边缘列表表示的图形,而不是邻接矩阵。
-
-2. 你可以假定输入的先决条件中没有重复的边。
-
-提示:
-
-- 这个问题相当于查找一个循环是否存在于有向图中。如果存在循环,则不存在拓扑排序,因此不可能选取所有课程进行学习。
-
-思路分析:
-
-- 题目中提示已经说得很清楚了,要求我们在「有向图」中检测是否有环,如果没有环,则输出拓扑排序的结果;
-- 所谓「拓扑排序」需要保证:① 每一门课程只出现一次;② 必需保证先修课程的顺序在所有它的后续课程的前面;
-- 拓扑排序的结果不唯一,并且只有「有向无环图」才有拓扑排序;
-- 关键要把握题目的要求:「必需保证先修课程的顺序在所有它的后续课程的前面」,「所有」关键字提示我们可以使用「 遍历」的思想遍历当前课程的 所有 后续课程,并且保证这些课程之间的学习顺序不存在「环」,可以使用「深度优先遍历」或者「广度优先遍历」;
-- 我们这里给出「深度优先遍历」的实现代码,注意:需要在当前课程的所有 后续课程 结束以后才输出当前课程,所以 ① 收集结果的位置应该在「后序」的位置(类比二叉树的后序遍历);② 后序遍历的结果需要逆序才是拓扑排序的结果;
-- 事实上,「拓扑排序」问题使用「广度优先遍历」的思想和实现是更经典的做法,我们放在「广度优先遍历」专题里向大家介绍。
-
-编码前的说明:
-
-深度优先遍历的写法需要注意:
-
-- 递归函数返回值的意义:这里返回 true 表示在有向图中找到了环,返回 false 表示没有环;
-- 我们扩展了布尔数组 visited 的含义,如果在无向图中,只需要 true 和 false 两种状态。在有向图中,为了检测是否存在环,我们新增一个状态,用于表示在对一门课程进行深度优先遍历的过程中,已经被标记,使用整数 1 表示。原来的 false 用整数 0 表示,含义为还未访问;原来的 true 用整数 2 表示,含义为「已经访问」,确切地说是「当前课程的所有后续课程」已经被访问。
-
-```java
-public class Solution {
-
- public int[] findOrder(int numCourses, int[][] prerequisites) {
- // 步骤 1:构建邻接表
- Set[] adj = new HashSet[numCourses];
- for (int i = 0; i < numCourses; i++) {
- adj[i] = new HashSet<>();
- }
- int pLen = prerequisites.length;
- for (int i = 0; i < pLen; i++) {
- // 后继课程
- int second = prerequisites[i][0];
- // 先行课程
- int first = prerequisites[i][1];
- // 注意 dfs 中,后继课程作为 key,前驱课程作为 value,这种方式不符合邻接表的习惯,邻接表总是通过前驱得到后继
- adj[second].add(first);
- }
-
- // 步骤二:对每一个结点执行一次深度优先遍历
- // 0 表示没有访问过,对应于 boolean 数组里的 false
- // 1 表示已经访问过,新增状态,如果 dfs 的时候遇到 1 ,表示当前遍历的过程中形成了环
- // 2 表示当前结点的所有后继结点已经遍历完成,对应于 boolean 数组里的 true
- int[] visited = new int[numCourses];
-
- List res = new ArrayList<>();
- for (int i = 0; i < numCourses; i++) {
- // 对每一个结点执行一次深度优先遍历
- if (dfs(i, adj, visited, res)) {
- return new int[]{};
- }
- }
- return res.stream().mapToInt(i -> i).toArray();
- }
-
- /**
- * @param current
- * @param adj
- * @param visited
- * @param res
- * @return true 表示有环,false 表示没有环
- */
- private boolean dfs(int current, Set[] adj,
- int[] visited, List res) {
-
- if (visited[current] == 1) {
- return true;
- }
- if (visited[current] == 2) {
- return false;
- }
-
- visited[current] = 1;
- for (Integer successor : adj[current]) {
- if (dfs(successor, adj, visited, res)) {
- // 如果有环,返回空数组
- return true;
- }
- }
-
- // 注意:在「后序」这个位置添加到结果集
- res.add(current);
- visited[current] = 2;
- // 所有的后继结点都遍历完成以后,都没有遇到重复,才可以说没有环
- return false;
- }
-}
-```
-复杂度分析:
-
-- 时间复杂度:O(V + E),这里 V 表示课程总数,E 表示课程依赖关系总数,对每一个顶点执行一次深度优先遍历,所有顶点需要遍历的操作总数与边总数有关;
-- 空间复杂度:O(V + E),邻接表的大小为 V + E,递归调用栈的深度最多为 V,因此空间复杂度是 O(V + E)。
-
-
-
-## 四、回溯算法
-
-
-
-### 回溯算法是深度优先遍历思想的应用
-
-回溯算法是一种通过不断 尝试 ,搜索一个问题的一个解或者 所有解 的方法。在求解的过程中,如果继续求解不能得到题目要求的结果,就需要 回退 到上一步尝试新的求解路径。回溯算法的核心思想是:**在一棵 隐式的树(看不见的树) 上进行一次深度优先遍历**。
-
-我们通过一道经典的问题 N 皇后问题,向大家介绍「回溯算法」的思想。
-
-
-
-### 例题:「力扣」第 51 题:N 皇后(困难)
-
-n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
-
-
-
-上图为 8 皇后问题的一种解法。
-
-给定一个整数 n,返回所有不同的 n 皇后问题的解决方案。
-
-每一种解法包含一个明确的 n 皇后问题的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。
-
-```
-输入:4
-输出:[
- [".Q..", // 解法 1
- "...Q",
- "Q...",
- "..Q."],
-
- ["..Q.", // 解法 2
- "Q...",
- "...Q",
- ".Q.."]
-]
-解释: 4 皇后问题存在两个不同的解法。
-```
-
-提示:
-
-- 皇后彼此不能相互攻击,也就是说:任何两个皇后都不能处于同一条横行、纵行或斜线上。
-
-思路分析:解决这个问题的思路是尝试每一种可能,然后逐个判断。只不过回溯算法按照一定的顺序进行尝试,在一定不可能得到解的时候进行剪枝,进而减少了尝试的可能。下面的幻灯片展示了整个搜索的过程。
-
-
-
-
-
-**在遍历的过程中记录已经放置的皇后的位置**
-
-由于我们需要根据前面已经放置的皇后的位置,来决定当前位置是否可以放置皇后,因此记住已经放置的皇后的位置就很重要。
-
-- 由于我们一行一行考虑放置皇后,摆放的这些皇后肯定不在同一行;
-- 为了避免它们在同一列,需要一个长度为 NN 的布尔数组 cols,已经放置的皇后占据的列,就需要在对应的列的位置标记为 true;
-- 还需要考虑「任何两个皇后不能位于同一条斜线上」,下面的图展示了位于一条斜线上的皇后的位置特点。
-
-
-
-为此,我们需要一个表示主对角线方向的布尔数组 main(Main diagonal,长度为 2N-12N−1),如果某个单元格放放置了一个皇后,就需要将对应的主对角线标记为 true。注意:由于有 3 个方向的横坐标 - 纵坐标的结果为负值,可以统一地为每一个横坐标 - 纵坐标的结果增加一个偏移,具体请见参考代码 1。
-
-
-
-同理,我们还需要一个表示副对角线方向的布尔数组 sub(Sub diagonal,长度为 2N-12N−1),如果某个单元格放放置了一个皇后,就需要将对应的副对角线标记为 true。
-
-```java
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Deque;
-import java.util.List;
-
-public class Solution {
-
- private int n;
- /**
- * 记录某一列是否放置了皇后
- */
- private boolean[] col;
- /**
- * 记录主对角线上的单元格是否放置了皇后
- */
- private boolean[] main;
- /**
- * 记录了副对角线上的单元格是否放置了皇后
- */
- private boolean[] sub;
-
- private List> res;
-
- public List> solveNQueens(int n) {
- res = new ArrayList<>();
- if (n == 0) {
- return res;
- }
-
- // 设置成员变量,减少参数传递,具体作为方法参数还是作为成员变量,请参考团队开发规范
- this.n = n;
- this.col = new boolean[n];
- this.main = new boolean[2 * n - 1];
- this.sub = new boolean[2 * n - 1];
- Deque path = new ArrayDeque<>();
- dfs(0, path);
- return res;
- }
-
- private void dfs(int row, Deque path) {
- if (row == n) {
- // 深度优先遍历到下标为 n,表示 [0.. n - 1] 已经填完,得到了一个结果
- List board = convert2board(path);
- res.add(board);
- return;
- }
-
- // 针对下标为 row 的每一列,尝试是否可以放置
- for (int j = 0; j < n; j++) {
- if (!col[j] && !main[row - j + n - 1] && !sub[row + j]) {
- path.addLast(j);
- col[j] = true;
- main[row - j + n - 1] = true;
- sub[row + j] = true;
- dfs(row + 1, path);
- sub[row + j] = false;
- main[row - j + n - 1] = false;
- col[j] = false;
- path.removeLast();
- }
- }
- }
-
- private List convert2board(Deque path) {
- List board = new ArrayList<>();
- for (Integer num : path) {
- StringBuilder row = new StringBuilder();
- row.append(".".repeat(Math.max(0, n)));
- row.replace(num, num + 1, "Q");
- board.add(row.toString());
- }
- return board;
- }
-}
-```
-
-复杂度分析:
-
-- 时间复杂度:O(N!)O(N!),这里 NN 为皇后的个数,这里讨论的时间复杂度很宽松,第一行皇后可以摆放的位置有 NN 个,第二行皇后可以摆放的位置有 N - 1N−1 个,依次类推下去,按照分步计数乘法原理,一共有 N!N! 种可能;
-
-- 空间复杂度:O(N)O(N),递归调用栈的深度最多为 NN,三个布尔数组的长度分别为 NN、2N+12N+1、2N+12N+1,都是 NN 的线性组合。
-
-其实,判断是否重复,可以使用哈希表,下面给出的参考代码 2 就使用了哈希表判断是否重复,可以不用处理主对角线方向上「横坐标 - 纵坐标」的下标偏移。但事实上,哈希表底层也是数组。
-
-
-
-### 树形问题
-
-回溯算法其实是在一棵隐式的树或者图上进行了一次深度优先遍历,我们在解决问题的过程中需要把问题抽象成一个树形问题。充分理解树形问题最好的办法就是用一个小的测试用例,在纸上画出树形结构图,然后再针对树形结构图进行编码。
-
-重要的事情我们说三遍:画图分析很重要、画图分析很重要、画图分析很重要。
-
-要理解「回溯算法」的递归前后,变量需要恢复也需要想象代码是在一个树形结构中执行深度优先遍历,回到以前遍历过的结点,变量需要恢复成和第一次来到该结点的时候一样的值。
-
-另一个理解回溯算法执行流程的重要方法是:在递归方法执行的过程中,将涉及到的变量的值打印出来看,观察变量的值的变化。
-
-> 友情提示:画图分析问题是思考算法问题的重要方法,画图这个技巧在解决链表问题、回溯算法、动态规划的问题上都有重要的体现,请大家不要忽视「画图」这个简单的分析问题的方法,很多时候思路就出现在我们在草稿纸上写写画画以后。
-
-
-
-### 回溯算法问题的问法
-
-问「一个问题 **所有的** 解」一般考虑使用回溯算法。因此回溯算法也叫「暴力搜索」,但不同于最粗暴的多个 `for` 循环,回溯算法是有方向的遍历。
-
-
-
-### 再谈「搜索」
-
-计算机擅长做的事情是「计算」,即「做重复的事情」。能用编程的方法解决的问题通常 结构相同,问题规模不同。因此,我们解决一个问题的时候,通常需要将问题一步一步进行拆解,把一个大问题拆解为结构相同的若干个小问题。
-
-友情提示:我们介绍「状态」和「状态空间」这两个概念是为了方便后面的问题描述,其实大家在完成了一定练习以后对这两个概念就会有形象的理解。如果一开始不理解这些概念完全可以跳过。
-
-##### 「状态」和「状态空间」
-
-为了区分解决问题的不同阶段、不同规模,我们可以通过语言描述进行交流。在算法的世界里,是通过变量进行描述的,不同的变量的值就代表了解决一个实际问题中所处的不同的阶段,这些变量就叫做「状态变量」。所有的状态变量构成的集合称为「状态空间」。
-
-友情提示:「空间」这个词经常代表的含义是「所有」。在《线性代数》里,线性空间(向量空间)就是规定了「加法」和「数乘」,且对这两种运算封闭的 所有 元素的集合。
-
-##### 不同状态之间的联系形成图(树)结构
-
-我们可以把某种规模的问题描述想象成一个结点。由于规模相近的问题之间存在联系,我们把有联系的结点之间使用一条边连接,因此形成的状态空间就是一张图。
-
-树结构有唯一的起始结点(根结点),且不存在环,树是特殊的图。这一章节绝大多数的问题都从一个基本的问题出发,拆分成多个子问题,并且继续拆分的子问题没有相同的部分,因此这一章节遇到的绝大多数问题的状态空间是一棵树。
-
-我们要了解这个问题的状态空间,就需要通过 遍历 的方式。正是因为通过遍历,我们能够访问到状态空间的所有结点,因此可以获得一个问题的 所有 解。
-
-
-
-### 为什么叫「回溯」(难点)
-
-而「回溯」就是 深度优先遍历 状态空间的过程中发现的特有的现象,程序会回到以前访问过的结点。而程序在回到以前访问过的结点的时候,就需要将状态变量恢复成为第一次来到该结点的值。
-
-在代码层面上,在递归方法结束以后,执行递归方法之前的操作的 逆向操作 即可。
-
-> 友情提示:理解回溯算法的「回溯」需要基于一定的练习,可以不必一开始就理解透彻。另外,理解「回溯算法」的一个重要技巧是 在程序中打印状态变量进行观察,一步一步看到变量的变化。
-
-
-
-### 回溯算法的实现细节
-
-#### 解释递归后面状态重置是怎么回事
-
-- 当回到上一级的时候,所有的状态变量需要重置为第一次来到该结点的状态,这样继续尝试新的选择才有意义;
-- 在代码层面上,需要在递归结束以后,添加递归之前的操作的逆向操作;
-
-#### 基本类型变量和对象类型变量的不同处理
-
-基本类型变量每一次向下传递的时候的行为是复制,所以无需重置;
-对象类型变量在遍历的全程只有一份,因此再回退的时候需要重置;
-类比于 Java 中的 方法参数 的传递机制:
-基本类型变量在方法传递的过程中的行为是复制,每一次传递复制了参数的值;
-对象类型变量在方法传递的过程中复制的是对象地址,对象全程在内存中共享地址。
-
-#### 字符串问题的特殊性
-
-如果使用 + 拼接字符串,每一次拼接产生新的字符串,因此无需重置;
-如果使用 StringBuilder 拼接字符串,整个搜索的过程 StringBuilder 对象只有一份,需要状态重置。
-
-#### 为什么不是广度优先遍历
-
-广度优先遍历每一层需要保存所有的「状态」,如果状态空间很大,需要占用很大的内存空间;
-深度优先遍历只要有路径可以走,就继续尝试走新的路径,不同状态的差距只有一个操作,而广度优先遍历在不同的层之前,状态差异很大,就不能像深度优先遍历一样,可以 使用一份状态变量去遍历所有的状态空间,在合适的时候记录状态的值就能得到一个问题的所有的解。
-
-
-
-### 练习
-
-1. 完成「力扣」第 46 题:全排列(中等);
-2. 完成「力扣」第 37 题:数独(困难);
-
-下面是字符串的搜索问题,完成这些问题可以帮助理解回溯算法的实现细节。
-
-1. 完成「力扣」第 22 题:括号生成(中等);
-2. 完成「力扣」第 17 题:电话号码的字母组合(中等);
-3. 完成「力扣」第 784 题:字母大小写全排列(中等)。
-
-
-
-## 五、剪枝
-
-
-
-### 剪枝的必要性
-
-剪枝的想法是很自然的。回溯算法本质上是遍历算法,如果 在遍历的过程中,可以分析得到这样一条分支一定不存在需要的结果,就可以跳过这个分支。
-
-发现剪枝条件依然是通过举例的例子,画图分析,即:通过具体例子抽象出一般的剪枝规则。通常可以选取一些较典型的例子,以便抽象出一般规律。
-
-> 友情提示:阅读下面的文字,可能会有一些晦涩,建议大家了解思路,通过对具体例子的分析,逐渐分析出解决这个问题的细节。
-
-### 「剪枝」技巧例举
-
-#### 技巧 1:按照一定顺序搜索
-
-按照顺序搜索其实也是去除重复结果的必要条件。
-
-「力扣」第 47 题:全排列 II
-
-> 给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。
->
-> 示例 1:
->
-> ```
-> 输入:nums = [1,1,2]
-> 输出:
-> [[1,1,2],
-> [1,2,1],
-> [2,1,1]]
-> ```
->
->
-> 示例 2:
->
-> ```
-> 输入:nums = [1,2,3]
-> 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
-> ```
-
-思路分析:
-
-- 这道题基于「力扣」第 46 题(全排列)的思想完成,首先依然是先画出树形结构,然后编写深度优先遍历的代码,在遍历的过程中收集所有的全排列;
-- 与「力扣」第 46 题(全排列)不同的是:输入数组中有重复元素,如果还按照第 46 题的写法做,就会出现重复列表;
-- 如果搜索出来结果列表,再在结果列表里去重,比较相同的列表是一件比较麻烦的事情,我们可以 ①:依次对列表排序,再逐个比较列表中的元素;② 将列表封装成为类,使用哈希表去重的方式去掉重复的列表。这两种方式编码都不容易实现;
-- 既然需要排序,我们可以在一开始的时候,就对输入数组进行排序,在遍历的过程中,通过一定剪枝条件,发现一定会搜索到重复元素的结点,跳过它,这样在遍历完成以后,就能得到不重复的列表。
-
-我们画出树形图,找出它们重复的部分,进而发现产生重复的原因。
-
-
-
-产生重复列表的原因:
-
-- 很容易看到,在树的同一层,如果当前考虑的数字相同,就有可能搜索到重复的结果(前提:输入数组按照升序排序),因此剪枝条件为 nums[i] == nums[i - 1] 这里为了保证数组下标不越界,前提是 i > 0;
-- 光有这个条件还不够,我们观察下面两个分支,中间被着重标注的分支,满足 nums[i] == nums[i - 1] 并且 nums[i - 1] 还未被使用,就下来由于还要使用 1 一定会与前一个分支搜索出的结果重复;
-- 而左边被着重标注的分支,也满足 nums[i] == nums[i - 1] ,但是 nums[i - 1] 已经被使用,接下来不会再用到它,因此不会产生重复。
-
-
-
-```java
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Deque;
-import java.util.List;
-
-public class Solution {
-
- public List> permuteUnique(int[] nums) {
- int len = nums.length;
- List> res = new ArrayList<>();
- if (len == 0) {
- return res;
- }
-
- // 剪枝的前提是排序
- Arrays.sort(nums);
- boolean[] used = new boolean[len];
- // 使用 Deque 是 Java 官方 Stack 类的建议
- Deque path = new ArrayDeque<>(len);
- dfs(nums, 0, len, used, path, res);
- return res;
- }
-
- private void dfs(int[] nums, int index, int len, boolean[] used, Deque path, List> res) {
- if (index == len) {
- res.add(new ArrayList<>(path));
- return;
- }
-
- for (int i = 0; i < len; i++) {
- if (used[i]) {
- continue;
- }
-
- // 注意:理解 !used[i - 1],很关键
- // 剪枝条件:i > 0 是为了保证 nums[i - 1] 有意义
- // 写 !used[i - 1] 是因为 nums[i - 1] 在深度优先遍历的过程中刚刚被撤销选择
- if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) {
- continue;
- }
-
- used[i] = true;
- path.addLast(nums[i]);
- dfs(nums, index + 1, len, used, path, res);
- // 回溯部分的代码,和 dfs 之前的代码是对称的
- used[i] = false;
- path.removeLast();
- }
- }
-}
-```
-
-「力扣」第 39 题:(组合问题)
-
-> 给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
->
-> candidates 中的数字可以无限制重复被选取。
->
-> 说明:
->
-> - 所有数字(包括 target)都是正整数。
-> - 解集不能包含重复的组合。
->
-> 示例 1:
->
-> ```
-> 输入:candidates = [2,3,6,7], target = 7,
-> 所求解集为:
-> [
-> [7],
-> [2,2,3]
-> ]
-> ```
-
-思路分析:有了之前问题的求解经验,我们 强烈建议 大家使用示例 1 ,以自己熟悉的方式画出树形结构,再尝试编码、通过调试的方式把这个问题做出来。
-
-- 可以从目标值 target = 7 开始,逐个减去 2 、3 、6 、7 ,把问题转化成使用 [2, 3, 6, 7] 能够得到的组合之和为 5、 4、 1、0 的所有列表,如果能够得到有效的列表,再加上减去的那个数,就是原始问题的一个列表,这是这个问题的递归结构;
-- 减去一个数以后,得到的数为 0 或者负数以后,就可以停止了,请大家想一想这是为什么。画好这棵树以后,我们关注叶子结点的值为 0 的结点,从根结点到叶子结点的一条路径,满足沿途减去的数的和为 target = 7;
-
-
-
-
-- 由于这个问题得到的结果是组合,[2, 2, 3]、[2, 3, 2] 与 [3, 2, 2] 只能作为一个列表在结果集里输出,我们依然是按照第 47 题的分析,在图中标注出这些重复的分支,发现剪枝的条件;
-
-
-
-- 去除重复的方法通常是按照一定的顺序考虑问题,我们观察重复的三个列表 [2, 2, 3]、[2, 3, 2] 与 [2, 3, 2] ,我们只需要一个,保留自然顺序 [2, 2, 3] 即可,于是我们可以指定如下规则:如果在深度较浅的层减去的数等于 a ,那么更深的层只能减去大于等于 a 的数(根据题意,一个元素可以使用多次,因此可以减去等于 a 的数),这样就可以跳过重复的分支,深度优先遍历得到的结果就不会重复;
-- 容易发现,如果减去一个数的值小于 0 ,就没有必要再减去更大的数,这也是可以剪枝的地方。
-
-```java
-public class Solution {
-
- public List> combinationSum(int[] candidates, int target) {
- int len = candidates.length;
- List> res = new ArrayList<>();
- if (len == 0) {
- return res;
- }
-
- // 排序是剪枝的前提
- Arrays.sort(candidates);
- Deque path = new ArrayDeque<>();
- dfs(candidates, 0, len, target, path, res);
- return res;
- }
-
- private void dfs(int[] candidates, int begin, int len, int target, Deque path, List> res) {
- // 由于进入更深层的时候,小于 0 的部分被剪枝,因此递归终止条件值只判断等于 0 的情况
- if (target == 0) {
- res.add(new ArrayList<>(path));
- return;
- }
-
- for (int i = begin; i < len; i++) {
- // 重点理解这里剪枝,前提是候选数组已经有序
- if (target - candidates[i] < 0) {
- break;
- }
-
- path.addLast(candidates[i]);
- dfs(candidates, i, len, target - candidates[i], path, res);
- path.removeLast();
- }
- }
-}
-```
-
-如果对这个问题研究比较深入,可以发现,其实只要保持深层结点不重复使用浅层结点使用过的数值即可,也就是说排序对于这道问题来说不是必须的,排序用于提速,而真正去除重复元素的技巧是:设置搜索起点,这是另一种意义上的按顺序搜索(搜索起点不回头)。下面的代码也可以通过系统测评。
-
-```java
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Deque;
-import java.util.List;
-
-public class Solution {
-
- public List> combinationSum(int[] candidates, int target) {
- int len = candidates.length;
- List> res = new ArrayList<>();
- if (len == 0) {
- return res;
- }
-
- Deque path = new ArrayDeque<>();
- dfs(candidates, 0, len, target, path, res);
- return res;
- }
-
- /**
- * @param candidates 候选数组
- * @param begin 搜索起点
- * @param len 冗余变量,是 candidates 里的属性,可以不传
- * @param target 每减去一个元素,目标值变小
- * @param path 从根结点到叶子结点的路径,是一个栈
- * @param res 结果集列表
- */
- private void dfs(int[] candidates, int begin, int len, int target, Deque path, List> res) {
- // target 为负数和 0 的时候不再产生新的孩子结点
- if (target < 0) {
- return;
- }
- if (target == 0) {
- res.add(new ArrayList<>(path));
- return;
- }
-
- // 重点理解这里从 begin 开始搜索的语意
- for (int i = begin; i < len; i++) {
- path.addLast(candidates[i]);
- // 注意:由于每一个元素可以重复使用,下一轮搜索的起点依然是 i,这里非常容易弄错
- dfs(candidates, i, len, target - candidates[i], path, res);
- // 状态重置
- path.removeLast();
- }
- }
-}
-```
-
-「力扣」第 77 题:子集(中等)
-
-> 给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。
->
-> 示例:
->
-> ```
-> 输入: n = 4, k = 2
-> 输出:
-> [
-> [2,4],
-> [3,4],
-> [2,3],
-> [1,2],
-> [1,3],
-> [1,4],
-> ]
-> ```
-
-思路分析:
-
-- 依然是 强烈建议 大家在纸上根据示例画出树形结构图;
-- 根据第 39 题的经验,可以设置搜索起点,以防止不重不漏;
-- 如果对这个问题研究得比较深入,由于 k 是一个正整数,搜索起点不一定需要枚举到 n ,具体搜索起点的上限可以尝试举出一个数值合适的例子找找规律,我们在这里直接给出参考的代码。
-
-参考代码:
-
-```java
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Deque;
-import java.util.List;
-
-public class Solution {
-
- public List> combine(int n, int k) {
- List> res = new ArrayList<>();
- if (k <= 0 || n < k) {
- return res;
- }
- Deque path = new ArrayDeque<>(k);
- dfs(n, k, 1, path, res);
- return res;
- }
-
- // i 的极限值满足: n - i + 1 = (k - pre.size())
- // n - i + 1 是闭区间 [i, n] 的长度
- // k - pre.size() 是剩下还要寻找的数的个数
- private void dfs(int n, int k, int index, Deque path, List> res) {
- if (path.size() == k) {
- res.add(new ArrayList<>(path));
- return;
- }
-
- // 注意:这里搜索起点的上限为 n - (k - path.size()) + 1 ,这一步有很强的剪枝效果
- for (int i = index; i <= n - (k - path.size()) + 1; i++) {
- path.addLast(i);
- dfs(n, k, i + 1, path, res);
- path.removeLast();
- }
- }
-}
-
-```
-
-
-### 练习
-
-1. 完成「力扣」第 40 题:组合总和 II(中等);
-2. 完成「力扣」第 78 题:子集(中等);
-3. 完成「力扣」第 90 题:子集 II(中等)。
-4. 完成「力扣」第 1593 题:拆分字符串使唯一子字符串的数目最大(中等);
-5. 完成「力扣」第 1079 题:活字印刷(中等)。
-
-
-
-### 总结
-
-「剪枝」条件通常是具体问题具体分析,因此需要我们积累一定求解问题的经验。
-
-
-
-## 六、二维平面上的搜索问题(Flood Fill)
-
-「力扣」第 79 题:单词搜索(中等)
-
-> 给定一个二维网格和一个单词,找出该单词是否存在于网格中。
->
-> 单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
->
-> ```
-> board =
-> [
-> ['A','B','C','E'],
-> ['S','F','C','S'],
-> ['A','D','E','E']
-> ]
->
-> 给定 word = "ABCCED", 返回 true
-> 给定 word = "SEE", 返回 true
-> 给定 word = "ABCB", 返回 false
-> ```
-
-提示:
-
-- board 和 word 中只包含大写和小写英文字母;
-- 1 <= board.length <= 200
-- 1 <= board[i].length <= 200
-- 1 <= word.length <= 10^3
-
-思路分析:
-
-- 这道题要求我们在一个二维表格上找出给定目标单词 word 的一个路径,题目中说:「相邻」单元格是那些水平相邻或垂直相邻的单元格。也就是说:如果当前单元格恰好与 word 的某个位置的字符匹配,应该从当前单元格的上、下、左、右 44 个方向继续匹配剩下的部分;
-- 下面的幻灯片展示了整个匹配的过程,请大家注意:
- - 对于每一个单元格,在第一个字符匹配的时候,才执行一次深度优先遍历,直到找到符合条件的一条路径结束。如果第一个字符都不匹配,当然没有必要继续遍历下去;
- - 递归终止的条件是:匹配到了 word 的最后一个字符;
- - 在不能匹配的时候,需要 原路返回,尝试新的路径。这一点非常重要,我们修改了题目中的示例,请大家仔细观察下面的幻灯片中的例子,体会「回溯」在什么时候发生?
-
-整个搜索的过程可以用下面的树形结构表示,由于空间限制我们没有画出完整的树的样子,希望大家能够结合上面的幻灯片想清楚这个问题「当一条路径不能匹配的时候是如何回退的」,并且结合参考代码理解程序的执行流程。
-
-
-
-```java
-public class Solution {
-
- private boolean[][] visited;
- private int[][] directions = {{-1, 0}, {0, -1}, {0, 1}, {1, 0}};
- private int rows;
- private int cols;
- private int len;
- private char[] charArray;
- private char[][] board;
-
- public boolean exist(char[][] board, String word) {
- len = word.length();
- rows = board.length;
- if (rows == 0) {
- return false;
- }
- cols = board[0].length;
- visited = new boolean[rows][cols];
- this.charArray = word.toCharArray();
- this.board = board;
-
- for (int i = 0; i < rows; i++) {
- for (int j = 0; j < cols; j++) {
- if (dfs(i, j, 0)) {
- return true;
- }
- }
- }
- return false;
- }
-
- /**
- * @param i
- * @param j
- * @param begin 从 word[begin] 处开始搜索
- * @return
- */
- private boolean dfs(int i, int j, int begin) {
- // 字符串的最后一个字符匹配,即返回 true
- if (begin == len - 1) {
- return board[i][j] == charArray[begin];
- }
-
- // 只要当前考虑的字符能够匹配,就从四面八方继续搜索
- if (board[i][j] == charArray[begin]) {
- visited[i][j] = true;
- for (int[] direction : directions) {
- int newX = i + direction[0];
- int newY = j + direction[1];
- if (inArea(newX, newY) && !visited[newX][newY]) {
- if (dfs(newX, newY, begin + 1)) {
- return true;
- }
- }
- }
- visited[i][j] = false;
- }
- return false;
- }
-
- private boolean inArea(int x, int y) {
- return x >= 0 && x < rows && y >= 0 && y < cols;
- }
-}
-```
-
-说明:
-
-- DIRECTIONS 表示方向数组,44 个元素分别表示下、右、上、左 44 个方向向量,顺序无关紧要,建议四连通、八连通的问题都这样写;
-- 有一些朋友可能会觉得封装私有函数会降低程序的执行效率,这一点在一些编程语言中的确是这样,但是我们在日常编写代码的过程中,语义清晰和可读性是更重要的,因此在编写代码的时候,最好能够做到「一行代码只做一件事情」
-
-
-
-「力扣」第 695 题:岛屿的最大面积(中等)
-
-> 下面我们再展示一个问题,希望大家通过这个问题熟悉二维平面上回溯算法的编码技巧。
->
-> 给定一个包含了一些 0 和 1 的非空二维数组 grid 。
->
-> 一个 岛屿 是由一些相邻的 1 (代表土地) 构成的组合,这里的「相邻」要求两个 1 必须在水平或者竖直方向上相邻。你可以假设 grid 的四个边缘都被 0(代表水)包围着。
->
-> 找到给定的二维数组中最大的岛屿面积。(如果没有岛屿,则返回面积为 0 。)
->
-> ```
-> [[0,0,1,0,0,0,0,1,0,0,0,0,0],
-> [0,0,0,0,0,0,0,1,1,1,0,0,0],
-> [0,1,1,0,1,0,0,0,0,0,0,0,0],
-> [0,1,0,0,1,1,0,0,1,0,1,0,0],
-> [0,1,0,0,1,1,0,0,1,1,1,0,0],
-> [0,0,0,0,0,0,0,0,0,0,1,0,0],
-> [0,0,0,0,0,0,0,1,1,1,0,0,0],
-> [0,0,0,0,0,0,0,1,1,0,0,0,0]]
-> ```
->
-> 对于上面这个给定矩阵应返回 `6`。注意答案不应该是 `11` ,因为岛屿只能包含水平或垂直的四个方向的 `1` 。
-
-思路分析:
-
-找到一个岛屿,就是在 1(表示土地)的上、下、左、右 44 个方向执行一次深度优先遍历遍历,只要这 44 个方向上还有 1 ,就继续执行深度优先遍历。
-
-递归实现
-
-```java
-public class Solution {
-
- private int[][] directions = {{-1, 0}, {0, 1}, {1, 0}, {0, -1}};
- private int rows;
- private int cols;
- private int[][] grid;
- private boolean[][] visited;
-
- public int maxAreaOfIsland(int[][] grid) {
- if (grid == null) {
- return 0;
- }
- rows = grid.length;
- if (rows == 0) {
- return 0;
- }
- cols = grid[0].length;
- if (cols == 0) {
- return 0;
- }
-
- this.grid = grid;
- this.visited = new boolean[rows][cols];
- int res = 0;
- for (int i = 0; i < rows; i++) {
- for (int j = 0; j < cols; j++) {
- if (grid[i][j] == 1 && !visited[i][j]) {
- res = Math.max(res, dfs(i, j));
- }
- }
- }
- return res;
- }
-
- private int dfs(int x, int y) {
- visited[x][y] = true;
- int res = 1;
- for (int[] direction:directions) {
- int nextX = x + direction[0];
- int nextY = y + direction[1];
- if (inArea(nextX, nextY) && grid[nextX][nextY] == 1 && !visited[nextX][nextY]) {
- res += dfs(nextX, nextY);
- }
- }
- return res;
- }
-
- private boolean inArea(int x, int y) {
- return x >= 0 && x < rows && y >= 0 && y < cols;
- }
-}
-```
-模拟栈
-
-```java
-import java.util.ArrayDeque;
-import java.util.Deque;
-
-public class Solution {
-
- private final static int[][] DIRECTIONS = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
-
- public int maxAreaOfIsland(int[][] grid) {
- int rows = grid.length;
- int cols = grid[0].length;
- boolean[][] visited = new boolean[rows][cols];
-
- int maxArea = 0;
- for (int i = 0; i < rows; i++) {
- for (int j = 0; j < cols; j++) {
- if (grid[i][j] == 1 && !visited[i][j]) {
- maxArea = Math.max(maxArea, dfs(grid, i, j, rows, cols, visited));
- }
- }
- }
- return maxArea;
- }
-
- private int dfs(int[][] grid, int i, int j, int rows, int cols, boolean[][] visited) {
- int count = 0;
- Deque stack = new ArrayDeque<>();
- stack.addLast(new int[]{i, j});
- visited[i][j] = true;
- while (!stack.isEmpty()) {
- int[] top = stack.removeLast();
- int curX = top[0];
- int curY = top[1];
- count++;
- for (int[] direction : DIRECTIONS) {
- int newX = curX + direction[0];
- int newY = curY + direction[1];
- if (inArea(newX, newY, rows, cols) && grid[newX][newY] == 1 && !visited[newX][newY]) {
- stack.addLast(new int[]{newX, newY});
- visited[newX][newY] = true;
- }
- }
- }
- return count;
- }
-
- private boolean inArea(int i, int j, int rows, int cols) {
- return i >= 0 && i < rows && j >= 0 && j < cols;
- }
-}
-```
-
-
-### 练习
-
-提示读者这部分所有的问题都可以使用广度优先遍历完成。
-
-1. 完成「力扣」第 130 题:被围绕的区域(中等);深度优先遍历、广度优先遍历、并查集。
-2. 完成「力扣」第 200 题:岛屿数量(中等):深度优先遍历、广度优先遍历、并查集;
-3. 完成「力扣」第 417 题:太平洋大西洋水流问题(中等):深度优先遍历、广度优先遍历;
-4. 完成「力扣」第 1020 题:飞地的数量(中等):方法同第 130 题,深度优先遍历、广度优先遍历;
-5. 完成「力扣」第 1254 题:统计封闭岛屿的数目(中等):深度优先遍历、广度优先遍历;
-6. 完成「力扣」第 1034 题:边框着色(中等):深度优先遍历、广度优先遍历;
-7. 完成「力扣」第 133 题:克隆图(中等):借助哈希表,使用深度优先遍历、广度优先遍历;
-8. 完成「剑指 Offer」第 13 题:机器人的运动范围(中等):深度优先遍历、广度优先遍历;
-9. 完成「力扣」第 529 题:扫雷问题(中等):深度优先遍历、广度优先遍历;
-
-
-
-
-
-## 七、动态规划与深度优先遍历思想的结合
-
-深度优先遍历是一种重要的算法设计思想,可以用于解决「力扣」上很多问题,熟练掌握「深度优先遍历」以及与之相关的「递归」、「分治」思想的应用是十分有帮助的。事实上,有一类问题需要「深度优先遍历」思想与「动态规划」思想的结合。
-
-
-
-### 树形动态规划问题
-
-在动态规划问题里,有一类问题叫做「树形动态规划 DP」问题。这一类问题通常的解决的思路是:通过对树结构执行一次深度优先遍历,采用 后序遍历 的方式,一层一层向上传递信息,并且利用「无后效性」的思想(固定住一些状态,或者对当前维度进行升维)解决问题。即这一类问题通常采用「后序遍历」 + 「动态规划(无后效性)」的思路解决。
-
-> 友情提示:「无后效性」是「动态规划」的一个重要特征,也是一个问题可以使用「动态规划」解决的必要条件,「无后效性」就是字面意思:当前阶段的状态值一旦被计算出来就不会被修改,即:在计算之后阶段的状态值时不会修改之前阶段的状态值。
->
-> 利用「无后效性」解决动态规划问题通常有两种表现形式:
->
-> - 对当前维度进行「升维」,在「自底向上递推」的过程中记录更多的信息,避免复杂的分类讨论;
->
-> - 固定住一种的状态,通常这种状态的形式最简单,它可以组合成复杂的状态。
->
-> 理解「无后效性」需要做一些经典的使用「动态规划」解决的问题。例如:「力扣」第 62 题、第 120 题、第 53 题、第 152 题、第 300 题、第 1142 题。
-
-
-
-「力扣」第 543 题:二叉树的直径(简单)
-
-> 给定一棵二叉树,你需要计算它的直径长度。一棵二叉树的直径长度是任意两个结点路径长度中的最大值。这条路径可能穿过也可能不穿过根结点。
->
-> 示例:
->
-> 给定二叉树
->
-> ```
-> 1
-> / \
-> 2 3
-> / \
-> 4 5
-> ```
->
-> 返回 **3**, 它的长度是路径 `[4, 2, 1, 3]` 或者 `[5, 2, 1, 3]`。
->
-> **注意**:两结点之间的路径长度是以它们之间边的数目表示。
-
-思路分析:
-
-- 首先理解题意。在题目最后的「注意」中有强调:两个结点之间边的数目为直径,而不是结点的数目;
-- 要了解树当中的信息,通常来说需要执行一次 深度优先遍历,最后在根结点汇总值,自底向上,一层一层向上汇报信息,这是 后序遍历;
-- 我们再看直径的概念,题目中已经强调了:直径可能穿过也可能不穿过根结点。并且示例给出的路径 [4, 2, 1, 3] 或者 [5, 2, 1, 3] 是弯曲的,不是「从根结点到叶子结点的最长路径」,因此一条路径是否经过某个结点,就需要分类讨论。在动态规划里,可以利用一个概念,叫做「无后效性」,即:将不确定的事情确定下来,以方便以后的讨论。
-
-我们采用逐步完善代码的方式向大家展示编码过程,首先写出代码大致的框架,请大家留意代码中的注释,注释体现了编码的思路。
-
-```java
-public class Solution {
-
- public int diameterOfBinaryTree(TreeNode root) {
- dfs(root);
- return res;
- }
-
-
- /**
- * @param node 某个子树的根结点
- * @return 必需经过当前 node 结点的「单边」路径长度的「最大值」,这是动态规划「无后效性」的应用
- */
- private int dfs(TreeNode node) {
- // 递归终止条件
- if (node == null) {
- return 0;
- }
-
- // 根据左右子树的结果,再得到当前结点的结果,这是典型的「后序遍历」的思想
- int left = dfs(node.left);
- int right = dfs(node.right);
-
- // 注意:递归函数的返回值的定义,必需经过 node 并且只有一边
- return Math.max(left, right) + 1;
- }
-}
-```
-
-注意:这里递归函数 dfs 的定义,有两点很重要:① 必需经过当前 node 结点,也就是说当前结点 node 必需被选取,这一点是我们上面向大家介绍的「固定住」一些信息,方便分类讨论;② 「单边路径」是我们为了方便说明这个问题引入的概念。「单边路径」指的是 node 作为某一条路径的端点,它或者是「左端点」或者是「右端点」,它一定不是位于在这条路径中间的结点。
-
-
-
-比较难理解的地方是:为什么只讨论「单边路径」?这是因为「单边」的情况最简单,是可以拆分的最小单元。「弯曲」的情况可以由「单边」的情况组合而成。
-
-题目要求的直径,可以「弯曲」。「弯曲」的部分就是「左边单边」的长度 + 「右边单边」的长度之和,可以在遍历的过程中记录最大值。
-
-```java
-public class Solution {
-
- private int res;
-
- public int diameterOfBinaryTree(TreeNode root) {
- dfs(root);
- return res;
- }
-
-
- /**
- * @param node
- * @return 必需经过当前 node 结点的路径长度的「最大值」
- */
- private int dfs(TreeNode node) {
- if (node == null) {
- return 0;
- }
-
- int left = dfs(node.left);
- int right = dfs(node.right);
- // 注意:在深度优先遍历的过程中,记录最大值
- res = Math.max(res, left + right);
-
- return Math.max(left, right) + 1;
- }
-}
-```
-
-
-
-### 练习
-
-1. 完成「力扣」第 124 题:二叉树中的最大路径和(困难);
-2. 完成「力扣」第 298 题:二叉树最长连续序列(中等);
-3. 完成「力扣」第 549 题:二叉树中最长的连续序列(中等);
-4. 完成「力扣」第 687 题:最长同值路径(中等);
-5. 完成「力扣」第 865 题:具有所有最深节点的最小子树(中等);
-6. 完成「力扣」第 1372 题:二叉树中的最长交错路径(中等)。
-
-下面的问题可以使用「二分答案 + DFS 或者 BFS」的思想解决。
-
-7. 完成「力扣」第 1102 题:得分最高的路径(中等);
-8. 完成「力扣」第 1631 题:最小体力消耗路径(中等);
-9. 完成「力扣」第 778 题:水位上升的泳池中游泳(困难);
-10. 完成「力扣」第 403 题:青蛙过河(困难)。
-
-
-
-### 八、总结
-
-- 深度优先遍历的直观理解非常重要,支撑深度优先遍历实现的数据结构是「栈」;
-- 「力扣」上很多树和图的问题都可以通过深度优先遍历实现、使用深度优先遍历实现的问题很多时候也可以使用广度优先遍历实现;
-- 「回溯算法」是深度优先遍历算法的应用;
-- 「回溯算法」的细节很多,需要通过练习和调试理解。
-
diff --git a/docs/data-structure-algorithms/Double-Pointer.md b/docs/data-structure-algorithms/Double-Pointer.md
deleted file mode 100755
index 28ef87a38c..0000000000
--- a/docs/data-structure-algorithms/Double-Pointer.md
+++ /dev/null
@@ -1,814 +0,0 @@
-
-
-归纳下双指针算法,其实总共就三类
-
-- 左右指针,数组和字符串问题
-- 快慢指针,主要是成环问题
-- 滑动窗口,针对子串问题
-
-
-
-#### [42. 接雨水](https://leetcode-cn.com/problems/trapping-rain-water/)
-
-
-
-
-
-## 一、左右指针
-
-左右指针在数组中其实就是两个索引值,
-
-TODO: 一般都是有序数组?或者先排序后?
-
-Javaer 一般这么表示:
-
-```java
-int left = i + 1;
-int right = nums.length - 1;
-while(left < right)
- ***
-```
-
-这两个指针 **相向交替移动**
-
-
-
-
-
-> [11. 盛最多水的容器](https://leetcode-cn.com/problems/container-with-most-water/)
->
-> [15. 三数之和](https://leetcode-cn.com/problems/3sum/)
->
-> [167. 两数之和 II - 输入有序数组](https://leetcode-cn.com/problems/two-sum-ii-input-array-is-sorted/)
->
-> [125. 验证回文串](https://leetcode-cn.com/problems/valid-palindrome/)
->
-> [344. 反转字符串](https://leetcode-cn.com/problems/reverse-string/)
->
-> [283. 移动零](https://leetcode-cn.com/problems/move-zeroes/)
->
-> [704. 二分查找](https://leetcode-cn.com/problems/binary-search/)
->
-> [34. 在排序数组中查找元素的第一个和最后一个位置](https://leetcode-cn.com/problems/find-first-and-last-position-of-element-in-sorted-array/)
-
-TODO: 画图对比各个算法
-
-### 两数之和 II - 输入有序数组
-
-> 给定一个整数数组 `nums` 和一个整数目标值 `target`,请你在该数组中找出 **和为目标值** *`target`* 的那 **两个** 整数,并返回它们的数组下标。
->
-> 你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
->
-> 你可以按任意顺序返回答案。
->
-> ```
-> 输入:nums = [2,7,11,15], target = 9
-> 输出:[0,1]
-> 解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
-> ```
-
-直接用左右指针套就可以
-
-```java
-public static int[] towSum(int[] nums, int target) {
- int left = 0;
- int rigth = nums.length - 1;
- while(left < rigth){
- int tmp = nums[left] + nums[rigth];
- if (target == tmp) {
- return new int[]{left, rigth};
- } else if (tmp > target) {
- rigth--; //右移
- } else {
- left++; //左移
- }
- }
- return new int[]{-1, -1};
-}
-```
-
-
-
-### 三数之和
-
-**排序、双指针、去重**
-
-第一个想法是,这三个数,两个指针?
-
-- 对数组排序,固定一个数 $nums[i]$ ,然后遍历数组,并移动左右指针求和,判断是否有等于 0 的情况
-- 特例:
- - 排序后第一个数就大于 0,不干了
- - 有三个需要去重的地方
- - nums[i] == nums[i - 1] 直接跳过本次遍历
- - nums[left] == nums[left + 1] 移动指针,即去重
- - nums[right] == nums[right - 1] 移动指针
-
-
-
-
-
-
-
-```java
-public static List> threeSum(int[] nums) {
- //存放结果list
- List> result = new ArrayList<>();
- int length = nums.length;
- //特例判断
- if (length < 3) {
- return result;
- }
- Arrays.sort(nums);
- for (int i = 0; i < length; i++) {
- //排序后的第一个数字就大于0,就说明没有符合要求的结果
- if (nums[i] > 0) break;
-
- //去重
- if (i > 0 && nums[i] == nums[i - 1]) continue;
- //左右指针
- int l = i + 1;
- int r = length - 1;
- while (l < r) {
- int sum = nums[i] + nums[l] + nums[r];
- if (sum == 0) {
- result.add(Arrays.asList(nums[i], nums[l], nums[r]));
- //去重(相同数字的话就移动指针)
- while (nums[l] == nums[l + 1]) l++;
- while (nums[r] == nums[r - 1]) r--;
- //移动指针
- l++;
- r--;
- } else if (sum < 0) {
- l++;
- } else if (sum > 0) {
- r--;
- }
- }
- }
- return result;
-}
-```
-
-
-
-### 盛最多水的容器
-
-> 给你 n 个非负整数 a1,a2,...,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0) 。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
->
-> ```
-> 输入:[1,8,6,2,5,4,8,3,7]
-> 输出:49
-> 解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。
-> ```
->
-> 
-
-思路:
-
-- 求得是水量,水量 = 两个指针指向的数字中较小值 * 指针之间的距离(水桶原理,最短的板才不会漏水)
-- 为了求最大水量,我们需要存储所有条件的水量,进行比较才行
-- **双指针相向移动**,循环收窄,直到两个指针相遇
-- 往哪个方向移动,需要考虑清楚,如果我们移动数字较大的那个指针,那么前者「两个指针指向的数字中较小值」不会增加,后者「指针之间的距离」会减小,那么这个乘积会更小,所以我们移动**数字较小的那个指针**
-
-```java
-public int maxArea(int[] height){
- int left = 0;
- int right = height.length - 1;
- //需要保存各个阶段的值
- int result = 0;
- while(left < right){
- //水量 = 两个指针指向的数字中较小值∗指针之间的距离
- int area = Math.min(height[left],height[right]) * (right - left);
- result = Math.max(result,area);
- //移动数字较小的指针
- if(height[left] <= height[right]){
- left ++;
- }else{
- right--;
- }
- }
- return result;
-}
-```
-
-
-
-### 验证回文串
-
-> 给定一个字符串,验证它是否是回文串,只考虑字母和数字字符,可以忽略字母的大小写。
->
-> 说明:本题中,我们将空字符串定义为有效的回文串。
->
-> ```
-> 输入: "A man, a plan, a canal: Panama"
-> 输出: true
-> 解释:"amanaplanacanalpanama" 是回文串
-> ```
-
-思路:
-
-- 没看题解前,因为这个例子中有各种逗号、空格啥的,我第一想到的其实就是先遍历放在一个数组里,然后再去判断,看题解可以在原字符串完成,降低了空间复杂度
-- 首先需要知道三个 API
- - `Character.isLetterOrDigit` 确定指定的字符是否为字母或数字
- - `Character.toLowerCase` 将大写字符转换为小写
- - `public char charAt(int index)` String 中的方法,用于返回指定索引处的字符
-- 双指针,每移动一步,判断这两个值是不是相同
-- 两个指针相遇,则是回文串
-
-```java
-public boolean isPalindrome(String s) {
- int left = 0;
- int right = s.length() - 1;
- while (left < right) {
- //这里还得加个left 编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 char[] 的形式给出。
->
-> 不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
->
-> 你可以假设数组中的所有字符都是 ASCII 码表中的可打印字符。
->
-> ```
-> 输入:["h","e","l","l","o"]
-> 输出:["o","l","l","e","h"]
-> ```
->
-> ```
-> 输入:["H","a","n","n","a","h"]
-> 输出:["h","a","n","n","a","H"]
-> ```
-
-思路:
-
-- 因为要反转,所以就不需要相向移动了,如果用双指针思路的话,其实就是遍历中交换左右指针的字符
-
-```java
-public void reverseString(char[] s) {
- int left = 0;
- int right = s.length - 1;
- while (left < right){
- char tmp = s[left];
- s[left] = s[right];
- s[right] = tmp;
- left++;
- right--;
- }
-}
-```
-
-
-
-### 二分查找
-
-有重复数字的话,返回的其实就是最右匹配
-
-```java
-public static int search(int[] nums, int target) {
- int left = 0;
- int right = nums.length - 1;
- while (left <= right) {
- //不直接使用(right+left)/2 是考虑数据大的时候溢出
- int mid = (right - left) / 2 + left;
- int tmp = nums[mid];
- if (tmp == target) {
- return mid;
- } else if (tmp > target) {
- //右指针移到中间位置 - 1,也避免不存在的target造成死循环
- right = mid - 1;
- } else {
- //
- left = mid + 1;
- }
- }
- return -1;
-}
-```
-
-
-
-## 二、快慢指针
-
-「快慢指针」,也称为「同步指针」
-
-> [141. 环形链表](https://leetcode-cn.com/problems/linked-list-cycle/)
->
-> [142. 环形链表II](https://leetcode-cn.com/problems/linked-list-cycle-ii)
->
-> [19. 删除链表的倒数第 N 个结点](https://leetcode-cn.com/problems/remove-nth-node-from-end-of-list/)
->
-> [876. 链表的中间结点](https://leetcode-cn.com/problems/middle-of-the-linked-list/)
->
-> [26. 删除有序数组中的重复项](https://leetcode-cn.com/problems/remove-duplicates-from-sorted-array/)
-
-### 环形链表
-
-
-
-思路:
-
-- 快慢指针,两个指针,一块一慢的话,慢指针每次只移动一步,而快指针每次移动两步。初始时,慢指针在位置 head,而快指针在位置 head.next。这样一来,如果在移动的过程中,快指针反过来追上慢指针,就说明该链表为环形链表。否则快指针将到达链表尾部,该链表不为环形链表。
-
-```java
-public boolean hasCycle(ListNode head) {
- if (head == null || head.next == null) {
- return false;
- }
- // 龟兔起跑
- ListNode fast = head;
- ListNode slow = head;
-
- while (fast != null && fast.next != null) {
- // 龟走一步
- slow = slow.next;
- // 兔走两步
- fast = fast.next.next;
- if (slow == fast) {
- return true;
- }
- }
- return false;
-}
-```
-
-
-
-### 环形链表 II
-
-> 给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 `null`。
->
-> ```
-> 输入:head = [3,2,0,-4], pos = 1
-> 输出:返回索引为 1 的链表节点
-> 解释:链表中有一个环,其尾部连接到第二个节点。
-> ```
-
-思路:
-
-- 最初,我就把有环理解错了,看题解觉得快慢指针相交的地方就是入环的节点
-- 假设环是这样的,slow 指针进入环后,又走了 b 的距离与 fast 相遇
-- 
-
-
-
-
-
-### 链表的中间结点
-
-> 给定一个头结点为 `head` 的非空单链表,返回链表的中间结点。
->
-> 如果有两个中间结点,则返回第二个中间结点。(给定链表的结点数介于 `1` 和 `100` 之间。)
->
-> ```
-> 输入:[1,2,3,4,5]
-> 输出:此列表中的结点 3 (序列化形式:[3,4,5])
-> 返回的结点值为 3 。 (测评系统对该结点序列化表述是 [3,4,5])。
-> 注意,我们返回了一个 ListNode 类型的对象 ans,这样:
-> ans.val = 3, ans.next.val = 4, ans.next.next.val = 5, 以及 ans.next.next.next = NULL.
-> ```
-
-思路:
-
-- 快慢指针遍历,当 `fast` 到达链表的末尾时,`slow` 必然位于中间
-
-```java
-public ListNode middleNode(ListNode head) {
- ListNode fast = head;
- ListNode slow = head;
- while (fast != null && fast.next != null) {
- slow = slow.next;
- fast = fast.next.next;
- }
- return slow;
-}
-```
-
-
-
-
-
-### 删除链表的倒数第 N 个结点
-
-> 给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。
->
-> ```
-> 输入:head = [1,2,3,4,5], n = 2
-> 输出:[1,2,3,5]
-> ```
-
-
-
-### 删除有序数组中的重复项
-
-> 给你一个有序数组 nums ,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。
->
-> 不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 $O(1)$ 额外空间的条件下完成。
->
-> 说明:
->
-> 为什么返回数值是整数,但输出的答案是数组呢?
->
-> 请注意,输入数组是以**「引用」**方式传递的,这意味着在函数里修改输入数组对于调用者是可见的。
->
-> 你可以想象内部操作如下:
->
-> ```java
-> // nums 是以“引用”方式传递的。也就是说,不对实参做任何拷贝
-> int len = removeDuplicates(nums);
-> // 在函数里修改输入数组对于调用者是可见的。
-> // 根据你的函数返回的长度, 它会打印出数组中 该长度范围内 的所有元素。
-> for (int i = 0; i < len; i++) {
-> print(nums[i]);
-> }
-> ```
->
-> ```
-> 输入:nums = [1,1,2]
-> 输出:2, nums = [1,2]
-> 解释:函数应该返回新的长度 2 ,并且原数组 nums 的前两个元素被修改为 1, 2 。不需要考虑数组中超出新长度后面的元素。
-> ```
->
-> ```
-> 输入:nums = [0,0,1,1,1,2,2,3,3,4]
-> 输出:5, nums = [0,1,2,3,4]
-> 解释:函数应该返回新的长度 5 , 并且原数组 nums 的前五个元素被修改为 0, 1, 2, 3, 4 。不需要考虑数组中超出新长度后面的元素。
-> ```
-
-**思路**:
-
-- 数组有序,那相等的元素在数组中的下标一定是连续的
-- 使用快慢指针,快指针表示遍历数组到达的下标位置,慢指针表示下一个不同元素要填入的下标位置
-- 第一个元素不需要删除,所有快慢指针都从下标 1 开始
-
-```java
-public static int removeDuplicates(int[] nums) {
- if (nums == null) {
- return 0;
- }
- int fast = 1;
- int slow = 1;
- while (fast < nums.length) {
- //和前一个值比较
- if (nums[fast] != nums[fast - 1]) {
- //不一样的话,把快指针的值放在慢指针上,实现了去重,并往前移动慢指针
- nums[slow] = nums[fast];
- ++slow;
- }
- //相等的话,移动快指针就行
- ++fast;
- }
- //慢指针的位置就是不重复的数量
- return slow;
-}
-```
-
-
-
-### 最长连续递增序列
-
-> 给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。
->
-> 连续递增的子序列 可以由两个下标 l 和 r(l < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], ..., nums[r - 1], nums[r]] 就是连续递增子序列。
->
-> ```
-> 输入:nums = [1,3,5,4,7]
-> 输出:3
-> 解释:最长连续递增序列是 [1,3,5], 长度为3。尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。
-> ```
-
-思路分析:
-
-- 这个题的思路和删除有序数组中的重复项,很像
-
-```java
-public int findLengthOfLCIS(int[] nums) {
- int result = 0;
- int fast = 0;
- int slow = 0;
- while (fast < nums.length) {
- //前一个数大于后一个数的时候
- if (fast > 0 || nums[fast - 1] > nums[fast]) {
- slow = fast;
- }
- fast++;
- result = Math.max(result, fast - slow);
- }
- return result;
-}
-```
-
-
-
-## 三、滑动窗口
-
-有一类数组上的问题,需要使用两个指针变量(我们称为左指针和右指针),同向、交替向右移动完成任务。这样的过程像极了一个窗口在平面上滑动的过程,因此我们将解决这一类问题的算法称为「滑动窗口」问题
-
-
-
-
-
-滑动窗口,就是两个指针齐头并进,好像一个窗口一样,不断往前滑
-
-子串问题,几乎都是滑动窗口
-
-> [643. 子数组最大平均数 I](https://leetcode-cn.com/problems/maximum-average-subarray-i/)
->
-> [1052. 爱生气的书店老板](https://leetcode-cn.com/problems/grumpy-bookstore-owner/)
->
-> [3. 无重复字符的最长子串](https://leetcode-cn.com/problems/longest-substring-without-repeating-characters/)
->
-> [76. 最小覆盖子串](https://leetcode-cn.com/problems/minimum-window-substring/)
->
-> [424. 替换后的最长重复字符](https://leetcode-cn.com/problems/longest-repeating-character-replacement/)
->
->
-
-```java
-int left = 0, right = 0;
-
-while (right < s.size()) {
- // 增大窗口
- window.add(s[right]);
- right++;
-
- while (window needs shrink) {
- // 缩小窗口
- window.remove(s[left]);
- left++;
- }
-}
-```
-
-
-
-### 3.1 同向交替移动的两个变量
-
-有一类数组上的问题,问我们固定长度的滑动窗口的性质,这类问题还算相对简单。
-
-#### 子数组最大平均数 I
-
-> 给定 `n` 个整数,找出平均数最大且长度为 `k` 的连续子数组,并输出该最大平均数。
->
-> ```
-> 输入:[1,12,-5,-6,50,3], k = 4
-> 输出:12.75
-> 解释:最大平均数 (12-5-6+50)/4 = 51/4 = 12.75
-> ```
-
-**思路**:
-
-- 长度为固定的 K,想到用滑动窗口
-- 保存每个窗口的值,取这 k 个数的最大和就可以得出最大平均数
-- 怎么保存每个窗口的值,这一步
-
-```java
-public static double getMaxAverage(int[] nums, int k) {
- int sum = 0;
- //先求出前k个数的和
- for (int i = 0; i < nums.length; i++) {
- sum += nums[i];
- }
- //目前最大的数是前k个数
- int result = sum;
- //然后从第 K 个数开始移动,保存移动中的和值,返回最大的
- for (int i = k; i < nums.length; i++) {
- sum = sum - nums[i - k] + nums[i];
- result = Math.max(result, sum);
- }
- //返回的是double
- return 1.0 * result / k;
-}
-```
-
-
-
-### 3.2 不定长度的滑动窗口
-
-#### 无重复字符的最长子串
-
-> 给定一个字符串 `s` ,请你找出其中不含有重复字符的 **最长子串** 的长度。
->
-> ```
-> 输入: s = "abcabcbb"
-> 输出: 3
-> 解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
-> ```
-
-思路:
-
-- 滑动窗口,其实就是一个队列,比如例题中的 abcabcbb,进入这个队列(窗口)为 abc 满足题目要求,当再进入 a,队列变成了 abca,这时候不满足要求。所以,我们要移动这个队列
-- 如何移动?我们只要把队列的左边的元素移出就行了,直到满足题目要求!
-- 一直维持这样的队列,找出队列出现最长的长度时候,求出解!
-
-```java
-public static int lengthOfLongestSubstring(String s){
- HashMap map = new HashMap<>();
- int result = 0;
- int left = 0;
- //为了有左右指针的思想,我把我们常用的 i 写成了 right
- for (int right = 0; right < s.length(); right++) {
- //当前字符包含在当前有效的子段中,如:abca,当我们遍历到第二个a,当前有效最长子段是 abc,我们又遍历到a,
- //那么此时更新 left 为 map.get(a)+1=1,当前有效子段更新为 bca;
- //相当于左指针往前移动了一位
- if (map.containsKey(s.charAt(right))) {
- left = Math.max(left, map.get(s.charAt(right)) + 1);
- }
- //右指针一直往前移动
- map.put(s.charAt(right), right);
- result = Math.max(result, right - left + 1);
- }
- return result;
-}
-```
-
-
-
-#### 最小覆盖子串
-
-> 给你一个字符串 `s` 、一个字符串 `t` 。返回 `s` 中涵盖 `t` 所有字符的最小子串。如果 `s` 中不存在涵盖 `t` 所有字符的子串,则返回空字符串 `""` 。
->
-> ```
-> 输入:s = "ADOBECODEBANC", t = "ABC"
-> 输出:"BANC"
-> ```
-
-
-
-
-
-#### 替换后的最长重复字符
-
-> 给你一个仅由大写英文字母组成的字符串,你可以将任意位置上的字符替换成另外的字符,总共可最多替换 k 次。在执行上述操作后,找到包含重复字母的最长子串的长度。
->
-> 注意:字符串长度 和 k 不会超过 10^4
->
-> ```
-> 输入:s = "ABAB", k = 2
-> 输出:4
-> 解释:用两个'A'替换为两个'B',反之亦然。
-> ```
->
-> ```
-> 输入:s = "AABABBA", k = 1
-> 输出:4
-> 解释:将中间的一个'A'替换为'B',字符串变为 "AABBBBA"。子串 "BBBB" 有最长重复字母, 答案为 4。
-> ```
-
-思路:
-
--
-
-
-
-```java
-public int characterReplacement(String s, int k) {
- int len = s.length();
- if (len < 2) {
- return len;
- }
-
- char[] charArray = s.toCharArray();
- int left = 0;
- int right = 0;
-
- int res = 0;
- int maxCount = 0;
- int[] freq = new int[26];
- // [left, right) 内最多替换 k 个字符可以得到只有一种字符的子串
- while (right < len){
- freq[charArray[right] - 'A']++;
- // 在这里维护 maxCount,因为每一次右边界读入一个字符,字符频数增加,才会使得 maxCount 增加
- maxCount = Math.max(maxCount, freq[charArray[right] - 'A']);
- right++;
-
- if (right - left > maxCount + k){
- // 说明此时 k 不够用
- // 把其它不是最多出现的字符替换以后,都不能填满这个滑动的窗口,这个时候须要考虑左边界向右移动
- // 移出滑动窗口的时候,频数数组须要相应地做减法
- freq[charArray[left] - 'A']--;
- left++;
- }
- res = Math.max(res, right - left);
- }
- return res;
-}
-```
-
-
-
-### 3.3 计数问题
-
-> ### 至多包含两个不同字符的最长子串
->
-> ### 至多包含 K 个不同字符的最长子串
->
-> ### 区间子数组个数
->
-> ### K 个不同整数的子数组
-
-#### 至多包含两个不同字符的最长子串
-
-> 给定一个字符串 `s`,找出 **至多** 包含两个不同字符的最长子串 `t` ,并返回该子串的长度。
->
-> ```
-> 输入: "eceba"
-> 输出: 3
-> 解释: t 是 "ece",长度为3。
-> ```
-
-思路:
-
-- 这种字符串用滑动窗口的题目,一般用 `toCharArray()` 先转成字符数组
-
-
-
-#### 至多包含 K 个不同字符的最长子串
-
-> 给定一个字符串 `s`,找出 **至多** 包含 `k` 个不同字符的最长子串 `T`。
->
-> ```
-> 输入: s = "eceba", k = 2
-> 输出: 3
-> 解释: 则 T 为 "ece",所以长度为 3。
-> ```
-
-
-
-#### 区间子数组个数
-
-> 给定一个元素都是正整数的数组`A` ,正整数 `L` 以及 `R` (`L <= R`)。
->
-> 求连续、非空且其中最大元素满足大于等于`L` 小于等于`R`的子数组个数。
->
-> ```
-> 例如 :
-> 输入:
-> A = [2, 1, 4, 3]
-> L = 2
-> R = 3
-> 输出: 3
-> 解释: 满足条件的子数组: [2], [2, 1], [3].
-> ```
-
-
-
-#### K 个不同整数的子数组
-
->
-
-
-
-### 3.4 使用数据结构维护窗口性质
-
-有一类问题只是名字上叫「滑动窗口」,但解决这一类问题需要用到常见的数据结构。这一节给出的问题可以当做例题进行学习,一些比较复杂的问题是基于这些问题衍生出来的。
-
-#### 滑动窗口最大值
-
-#### 滑动窗口中位数
-
-
-
-
-
-## 四、其他双指针问题
-
-#### [88. 合并两个有序数组](https://leetcode-cn.com/problems/merge-sorted-array/)
-
-
-
-
-
-
-
-### 总结
-
-区间不同的定义决定了不同的初始化逻辑、遍历过程中的逻辑。
-
-- 移除元素
-- 删除排序数组中的重复项 II
-- 移动零
-
-
-
diff --git a/docs/data-structure-algorithms/Leetcode-dynamic-programming.md b/docs/data-structure-algorithms/Leetcode-dynamic-programming.md
deleted file mode 100755
index 2063844409..0000000000
--- a/docs/data-structure-algorithms/Leetcode-dynamic-programming.md
+++ /dev/null
@@ -1,519 +0,0 @@
-## 概述
-
-这里是 LeetCode 官方推出的动态规划精讲系列第一弹。
-
-完成本 LeetBook 后,你将能够:
-
-- 理解动态规划的基本思想
-- 了解动态规划算法的优缺点和问题分类
-- 掌握运用动态规划解决问题的思路
-- 能够运用动态规划解决线性、前缀和、区间这三类问题
-
-
-
-## 动态规划简介
-
-### 动态规划的背景
-
-动态规划(英语:Dynamic programming,简称 DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
-
-动态规划不是某一种具体的算法,而是一种算法思想:若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。
-
-应用这种算法思想解决问题的可行性,对子问题与原问题的关系,以及子问题之间的关系这两方面有一些要求,它们分别对应了最优子结构和重复子问题。
-
-#### 最优子结构
-
-最优子结构规定的是子问题与原问题的关系
-
-动态规划要解决的都是一些问题的最优解,即从很多解决问题的方案中找到最优的一个。当我们在求一个问题最优解的时候,如果可以把这个问题分解成多个子问题,然后递归地找到每个子问题的最优解,最后通过一定的数学方法对各个子问题的最优解进行组合得出最终的结果。总结来说就是一个问题的最优解是由它的各个子问题的最优解决定的。
-
-将子问题的解进行组合可以得到原问题的解是动态规划可行性的关键。在解题中一般用状态转移方程描述这种组合。例如原问题的解为 f(n)f(n),其中 f(n)f(n) 也叫状态。状态转移方程 f(n) = f(n - 1) + f(n - 2)f(n)=f(n−1)+f(n−2) 描述了一种原问题与子问题的组合关系 。在原问题上有一些选择,不同选择可能对应不同的子问题或者不同的组合方式。例如
-
-
-
-n = 2kn=2k 和 n = 2k + 1n=2k+1 对应了原问题 nn 上不同的选择,分别对应了不同的子问题和组合方式。
-
-找到了最优子结构,也就能推导出一个状态转移方程 f(n)f(n),通过这个状态转移方程,我们能很快的写出问题的递归实现方法。
-
-
-
-#### 重复子问题
-
-重复子问题规定的是子问题与子问题的关系。
-
-当我们在递归地寻找每个子问题的最优解的时候,有可能会重复地遇到一些更小的子问题,而且这些子问题会重叠地出现在子问题里,出现这样的情况,会有很多重复的计算,动态规划可以保证每个重叠的子问题只会被求解一次。当重复的问题很多的时候,动态规划可以减少很多重复的计算。
-
-重复子问题不是保证解的正确性必须的,但是如果递归求解子问题时,没有出现重复子问题,则没有必要用动态规划,直接普通的递归就可以了。
-
-例如,斐波那契问题的状态转移方程 f(n) = f(n - 1) + f(n - 2)。在求 f(5) 时,需要先求子问题 f(4) 和 f(3),得到结果后再组合成原问题 f(5) 的解。递归地求 f(4) 时,又要先求子问题 f(3) 和 f(2) ,这里的 f(3) 与求 f(5) 时的子问题重复了。
-
-
-
-解决动态规划问题的核心:找出子问题及其子问题与原问题的关系
-
-找到了子问题以及子问题与原问题的关系,就可以递归地求解子问题了。但重叠的子问题使得直接递归会有很多重复计算,于是就想到记忆化递归法:若能事先确定子问题的范围就可以建表存储子问题的答案。
-
-动态规划算法中关于最优子结构和重复子问题的理解的关键点:
-
-1. 证明问题的方案中包含一种选择,选择之后留下一个或多个子问题
-2. 设计子问题的递归描述方式
-3. 证明对原问题的最优解包括了对所有子问题的最优解
-4. 证明子问题是重叠的(这一步不是动态规划正确性必需的,但是如果子问题无重叠,则效率与一般递归是相同的)
-
-
-
-
-
-## 线性动态规划
-
-### 线性动态规划简介
-
-线性动态规划的主要特点是状态的推导是按照问题规模 i 从小到大依次推过去的,较大规模的问题的解依赖较小规模的问题的解。
-
-这里问题规模为 i 的含义是考虑前 i 个元素 [0..i] 时问题的解。
-
-状态定义:
-
-```
-dp[n] := [0..n] 上问题的解
-```
-
-
-状态转移:
-
-```
-dp[n] = f(dp[n-1], ..., dp[0])
-```
-
-
-从以上状态定义和状态转移可以看出,大规模问题的状态只与较小规模的问题有关,而问题规模完全用一个变量 i 表示,i 的大小表示了问题规模的大小,因此从小到大推 i 直至推到 n,就得到了大规模问题的解,这就是线性动态规划的过程。
-
-按照问题的输入格式,线性动态规划解决的问题主要是单串,双串,矩阵上的问题,因为在单串,双串,矩阵上问题规模可以完全用位置表示,并且位置的大小就是问题规模的大小。因此从前往后推位置就相当于从小到大推问题规模。
-
-线性动态规划是动态规划中最基本的一类。问题的形式、dp 状态和方程的设计、以及与其它算法的结合上面变化很多。按照 dp 方程中各个维度的含义,可以大致总结出几个主流的问题类型,见后面的小节。除此之外还有很多没有总结进来的变种问题,小众问题,和困难问题,这些问题的解法更多地需要结合自己的做题经验去积累,除此之外,常见的,主流的问题和解法都可以总结成下面的四个小类别。
-
-
-
-
-
-### 单串
-
-单串 dp[i] 线性动态规划最简单的一类问题,输入是一个串,状态一般定义为 dp[i] := 考虑[0..i]上,原问题的解,其中 i 位置的处理,根据不同的问题,主要有两种方式:
-
-- 第一种是 i 位置必须取,此时状态可以进一步描述为 dp[i] := 考虑[0..i]上,且取 i,原问题的解;
-
-- 第二种是 i 位置可以取可以不取
-
-大部分的问题,对 i 位置的处理是第一种方式,例如力扣:
-
-- 70 爬楼梯问题
-- 801 使序列递增的最小交换次数
-- 790 多米诺和托米诺平铺
-- 746 使用最小花费爬楼梯
-
-线性动态规划中单串 dp[i] 的问题,状态的推导方向以及推导公式如下
-
-
-
-
-
-#### 1. 依赖比 i 小的 O(1) 个子问题
-
-dp[n] 只与常数个小规模子问题有关,状态的推导过程 dp[i] = f(dp[i - 1], dp[i - 2], ...)。时间复杂度 O(n)O(n),空间复杂度 O(n)O(n) 可以优化为 O(1)O(1),例如上面提到的 70, 801, 790, 746 都属于这类。
-
-如图所示,虽然紫色部分的 dp[i-1], dp[i-2], ..., dp[0] 均已经计算过,但计算橙色的当前状态时,仅用到 dp[i-1],这属于比 i 小的 O(1)O(1) 个子问题。
-
-例如,当 f(dp[i-1], ...) = dp[i-1] + nums[i] 时,当前状态 dp[i] 仅与 dp[i-1] 有关。这个例子是一种数据结构前缀和的状态计算方式,关于前缀和的详细内容请参考下一章。
-
-
-
-#### 2. 依赖比 i 小的 O(n) 个子问题
-
-dp[n] 与此前的更小规模的所有子问题 dp[n - 1], dp[n - 2], ..., dp[1] 都可能有关系。
-
-状态推导过程如下:
-
-```
-dp[i] = f(dp[i - 1], dp[i - 2], ..., dp[0])
-```
-
-
-依然如图所示,计算橙色的当前状态 dp[i] 时,紫色的此前计算过的状态 dp[i-1], ..., dp[0] 均有可能用到,在计算 dp[i] 时需要将它们遍历一遍完成计算。
-
-其中 f 常见的有 max/min,可能还会对 i-1,i-2,...,0 有一些筛选条件,但推导 dp[n] 时依然是 O(n)O(n) 级的子问题数量。
-
-例如:
-
-- 139 单词拆分
-
-- 818 赛车
-
-以 min 函数为例,这种形式的问题的代码常见写法如下
-
-```
-for i = 1, ..., n
- for j = 1, ..., i-1
- dp[i] = min(dp[i], f(dp[j])
-```
-
-时间复杂度 *O*(*n*2),空间复杂度 O*(*n)
-
-
-
-#### 单串 dp[i] 经典问题
-
-以下内容将涉及到的知识点对应的典型问题进行讲解,题目和解法具有代表性,可以从一个问题推广到一类问题。
-
-1. 依赖比 i 小的 O(1) 个子问题
-
-> 53. 最大子数组和
->
-> 给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
-
-一个数组有很多个子数组,求哪个子数组的和最大。可以按照子数组的最后一个元素来分子问题,确定子问题后设计状态
-
-```
-dp[i] := [0..i] 中,以 nums[i] 结尾的最大子数组和
-```
-
-
-状态的推导是按照 i 从 0 到 n - 1 按顺序推的,推到 dp[i],时,dp[i - 1], ..., dp[0] 已经计算完。因为子数组是连续的,所以子问题 dp[i] 其实只与子问题 dp[i - 1] 有关。如果 [0..i-1] 上以 nums[i-1] 结尾的最大子数组和(缓存在 dp[i-1] )为非负数,则以 nums[i] 结尾的最大子数组和就在 dp[i-1] 的基础上加上 nums[i] 就是 dp[i] 的结果否则以 i 结尾的子数组就不要 i-1 及之前的数,因为选了的话子数组的和只会更小。
-
-按照以上的分析,状态的转移可以写出来,如下
-
-```
-dp[i] = nums[i] + max(dp[i - 1], 0)
-```
-
-这个是单串 dp[i] 的问题,状态的推导方向,以及推导公式如下
-
-
-
-在本题中,f(dp[i-1], ..., dp[0]) 即为 max(dp[i-1], 0) + nums[i],dp[i] 仅与 dp[i-1] 1 个子问题有关。因此虽然紫色部分的子问题已经计算完,但是推导当前的橙色状态时,只需要 dp[i-1] 这一个历史状态。
-
-
-
-2. 依赖比 i 小的 O(n) 个子问题
-
-> 30. 最长上升子序列
->
-> 给定一个无序的整数数组,找到其中最长上升子序列的长度。
-
-输入是一个单串,首先思考单串问题中设计状态 dp[i] 时拆分子问题的方式:枚举子串或子序列的结尾元素来拆分子问题,设计状态 dp[i] := 在子数组 [0..i] 上,且选了 nums[i] 时,的最长上升子序列。
-
-因为子序列需要上升,因此以 i 结尾的子序列中,nums[i] 之前的数字一定要比 nums[i] 小才行,因此目标就是先找到以此前比 nums[i] 小的各个元素,然后每个所选元素对应一个以它们结尾的最长子序列,从这些子序列中选择最长的,其长度加 1 就是当前的问题的结果。如果此前没有比 nums[i] 小的数字,则当前问题的结果就是 1 。
-
-按照以上的分析,状态的转移方程可以写出来,如下
-
-
-
-其中 0 <= j < i, nums[j] < nums[i]。
-
-本题依然是单串 dp[i] 的问题,状态的推导方向,以及推导公式与上一题的图示相同,
-
-状态的推导依然是按照 i 从 0 到 n-1 推的,计算 dp[i] 时,dp[i-1], dp[i-2], ..., dp[0] 依然已经计算完。
-
-但本题与上一题的区别是推导 dp[i] 时,dp[i-1]. dp[i-2], ..., dp[0] 均可能需要用上,即,因此计算当前的橙色状态时,紫色部分此前计算过的状态都可能需要用上。
-
-
-
-#### 单串相关练习题
-
-- 最经典单串 LIS 系列
-- 最大子数组和系列
-- 打家劫舍系列
-- 变形:需要两个位置的情况
-- 与其它算法配合
-- 其它单串 dp[i] 问题
-- 带维度单串 dp[i][k]
-- 股票系列
-
-
-
-
-
-### 双串
-
-有两个输入从串,长度分别为 m, n,此时子问题需要用 i, j 两个变量表示,分别代表第一个串和第二个串考虑的位置 dp[i][j]:=第一串考虑[0..i],第二串考虑[0..j]时,原问题的解
-
-较大规模的子问题只与常数个较小规模的子问题有关,其中较小规模可能是 i 更小,或者是 j 更小,也可以是 i,j 同时变小。
-其中一种最常见的状态转移形式:推导 dp[i][j] 时,dp[i][j] 仅与 dp[i-1][j], dp[i][j-1], dp[i-1][j-1],例如
-
-- 72 编辑距离
-- 712 两个字符串的最小 ASCII 删除和
-
-线性动态规划中双串 dp[i][j] 的问题,状态的推导方向以及推导公式如下
-
-
-
-如图所示,绿色部分的 dp\[i-1 ~ 0][j-1 ~ 0] 均已经计算过,但计算橙色的当前状态时,仅用到 dp\[i-1][j], dp\[i][j-1], dp\[i-1][j-1],即比 i, j 小的 O(1)个子问题。
-
-这种形式的线性 DP 的代码常见写法
-
-```
-for i = 1..m
- for j = 1..n
- dp[i][j] = f(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])
-
-```
-
-时间复杂度 O(mn),空间复杂度 O(mn)
-
-以上是 O(1) 转移的情况,即计算 dp\[i][j] 时,虽然绿色部分的子问题均已经计算完,但只需要用到 dp\[i-1][j], dp\[i][j-1], dp\[i-1][j-1]。也可能出现更高复杂度的转移,类似单串中依赖比 i 小的 O(n)) 个子问题的情况。
-
-
-
-#### 双串 dp\[i][j] 经典问题
-
-以下将涉及到的知识点对应的典型问题进行讲解,题目和解法具有代表性,可以从一个问题推广到一类问题。
-
-1143. > 1143. 最长公共子序列
- >
- > 给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
-
-输入是双串,首先思考双串问题中设计状态 dp\[i][j] 时拆分子问题的方式:枚举第一串的子序列的结尾和第二串的子序列的结尾来拆分子问题,设计状态 dp\[i][j] := text1 考虑 [0..i], text2 考虑 [0..j] 时,原问题的解,即 LCS 长度
-
-这个是单串 dp\[i][j] 的问题,状态的推导方向,以及推导公式如下
-
-
-
-
-
-状态的推导是按照 i 从 0 到 n - 1、j 从 0 到 m - 1 顺序推的,推到 dp\[i][j] 时,dp\[i - 1 .. 0][j - 1 .. 0] 均已经计算完。
-
-因为两个子序列需要相同,若两个串的末尾元素相同,则可以选择 text1[i] 和 text2[j],此时再根据此前已经 text1[0..i-1] 和 text[0..j-1] 的 LCS 长度。若两个串的末尾元素不同,则 text1[i] 和 text2[j] 中只能选一个,
-
-若选了 text1[i],则 text2 只能取到 j-1,此时 dp[i-1][j] 的结果就是当前状态 dp[i][j] 的结果。
-若选了 text2[j],则 text1 只能取到 i-1,此时 dp[i][j-1] 的结果就是当前状态 dp[i][j] 的结果。
-两个结果要取一个最长的。
-
-按照以上的分析,状态的转移方程可以写出来,如下
-
-```
-dp[i][j] =
-1. dp[i-1][j-1] + 1 (text1[i] == text2[j])
-2. max(dp[i][j-1], dp[i-1][j]) (text1[i] != text2[j])
-两者取较大值
-```
-
-
-
-> 72. 编辑距离
-> 给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
->
-> 你可以对一个单词进行如下三种操作:
->
-> 插入一个字符
-> 删除一个字符
-> 替换一个字符
-
-输入是双串,首先思考双串问题中设计状态 dp[i][j] 时拆分子问题的方式:枚举第一串的子序列的结尾和第二串的子序列的结尾来拆分子问题,设计状态 dp[i][j] := word1 考虑 [0..i], word2 考虑 [0..j] 时,原问题的解,即 word1 转换成 word2 的最少操作数
-
-这个是单串 dp[i][j] 的问题,状态的推导方向,以及推导公式与上一题的图示相同
-
-同样地,状态的推导是按照 i 从 0 到 n - 1、j 从 0 到 m - 1 顺序推的,推到 dp[i][j] 时,dp[i - 1 .. 0][j - 1 .. 0] 均已经计算完。
-
-因为操作之后两个 word 需要相同,如果两个串的末尾元素 word1[i] 和 word2[j] 不相同,则可以在 word1 的末尾元素上使用插入,删除,替>换这三种操作,操作数都要 + 1,如果两个串的末尾元素 word1[i] 和 word2[j] 相同,依然可以在 word1 的末尾元素上使用插入,删除,替换这三种操作,但是此时如果使用改,则操作数不 +1,因为两个末尾元素已经相等了。
-
-按照以上的分析,状态的转移方程可以写出来,如下
-
-```
-dp[i][j] =
-1. dp[i][j-1] + 1 (最后一步是插入)
-2. dp[i-1][j] + 1 (最后一步是删)
-3. dp[i-1][j-1] + 1 (最后一步是改,且 word1[i] != word2[j])
-4. dp[i-1][j-1] (最后一步是改,且 word1[i] == word2[j])
-取较小值
-```
-
-
-
-## 双串相关练习题
-
-1. 最经典双串 LCS 系列
-2. 字符串匹配系列
-3. 其它双串 dp[i][j] 问题
-4. 带维度双串 dp[i][j][k]
-
-
-
-
-
-## 前缀和
-
-前缀和是一种查询数组中任意区间的元素的和的数据结构,这里数组给定之后就不变了。针对这个不变的数组,前缀和用于多次查询区间 [i, j] 上元素的和。
-
-对于动态规划而言,前缀和的意义主要有两点:
-
-一维和二维前缀和的推导,分别用到了单串和矩阵中最经典的状态设计以及状态转移;
-在一些更复杂的动态规划问题中,状态转移的时候需要依赖区间和,因为状态转移是非常频繁的操作,因此必须高效地求区间和才能使得状态转移的时间复杂度可接受,此时就必须用到前缀和了。
-除此之外,一些问题需要前缀和与其它数据结构配合来解决,也有两类:
-
-先预处理出前缀和数组,这一步是动态规划,然后在前缀和数组上用其它数据结构解决;
-还是按照动态规划的方式求前缀和,也需要额外的数据结构维护前缀和,但不是预处理好前缀和数组之后再用数据结构计算,而是每求出一个前缀和,就更新一次数据结构并维护答案。
-前缀和的推导和计算隐含着动态规划的基本思想,同时它的状态设计是线性动态规划中比较简单的那一类。与线性动态规划一样,前缀和也有一维和二维两种场景。
-虽然前缀和本身很简单,但需要用到它解决的问题非常多,与其它数据结构配合的变化也很多,因此需要从线性动态规划中剥离出来单独学习。
-
-
-
-
-
-## 区间动态规划
-
-在输入为长度为 n 的数组时,子问题用区间 [i..j] 表示。
-状态的定义和转移都与区间有关,称为区间动态规划
-
-
-
-### 区间动态规划简介
-
-区间 DP 是状态的定义和转移都与区间有关,其中区间用两个端点表示。
-
-状态定义 dp\[i][j] = [i..j] 上原问题的解。i 变大,j 变小都可以得到更小规模的子问题。
-
-对于单串上的问题,我们可以对比一下线性动态规划和区间动态规划。线性动态规划, 一般是定义 dp[i], 表示考虑到前 i 个元素,原问题的解,i 变小即得到更小规模的子问题,推导状态时候是从前往后,即 i 从小到大推的。区间动态规划,一般是定义 dp[i][j],表示考虑 [i..j] 范围内的元素,原问题的解增加 i,减小 j 都可以得到更小规模的子问题。推导状态一般是按照区间长度从短到长推的。
-
-区间动态规划的状态设计,状态转移都与线性动态规划有明显区别,但是由于这两种方法都经常用在单串问题上,拿到一个单串的问题时,往往不能快速地判断到底是用线性动态规划还是区间动态规划,这也是区间动态规划的难点之一。
-
-状态转移,推导状态 dp\[i][j] 时,有两种常见情况
-
-#### 1. dp\[i][j] 仅与常数个更小规模子问题有关
-
-一般是与 dp\[i + 1][j], dp\[i][j - 1], dp\[i + 1][j - 1] 有关。
-
-dp\[i][j] = f(dp\[i + 1][j], dp\[i][j - 1], dp\[i + 1][j - 1])
-
-
-
-代码常见写法
-
-```
-for len = 1..n
- for i = i..len
- j = i + len - 1
- dp[i][j] = max(dp[i][j], f(dp[i+1][j], dp[i][j-1], dp[i+1][j-1]))
-
-```
-
-时间复杂度和空间复杂度均为 O(n^{2})*O*(*n*2)
-
-例如力扣第 516 题,详细过程参考下一节。
-
-
-
-#### 2. dp\[i][j] 与 O(n) 个更小规模子问题有关
-
-一般是枚举 [i,j] 的分割点,将区间分为 [i,k] 和 [k+1,j],对每个 k 分别求解(下面公式的 f),再汇总(下面公式的 g)。
-
-dp\[i][j] = g(f(dp\[i][k], dp\[k + 1][j])) 其中 k = i .. j-1。
-
-
-
-代码常见写法, 以下代码以 f 为 max 为例
-
-```
-for len = 1..n
- for i = i..len
- j = i + len - 1
- for k = i..j
- dp[i][j] = max(dp[i][j], f(dp[i][k], dp[k][j]))
-```
-
-时间复杂度可以达到 O(n^3),空间复杂度还是 O(n^2)
-
-例如力扣第 664 题,详细过程参考下一节
-
-
-
-#### 总结
-
-区间动态规划一般用在单串问题上,以区间 [i, j] 为单位思考状态的设计和转移。它与线性动态规划在状态设计和状态转移上都有明显的不同,但由于这两个方法都经常用在单串问题上,导致我们拿到一个单串的问题时,经常不能快速反映出应该用哪种方法。这是区间动态规划的难点之一,但是这个难点也是好解决的,就是做一定数量的练习题,因为区间动态规划的题目比线性动态规划少很多,并且区间动态规划的状态设计和转移都比较朴素,变化也比线性动态规划少很多,所以通过不多的题目数量就可以把区间动态规划常见的方法和变化看个大概了。
-
-后续节介绍区间动态规划的几个典型例题,学习区间动态规划问题常见的模式。
-并且配有力扣上出现的区间动态规划的练习题,加深理解。
-
-
-
-### 区间动态规划经典问题
-
-#### 大规模问题与常数个小规模问题有关
-
-最常见的形式如下:
-
-推导 dp\[i][j] 时,需要用到 dp\[i][j-1], dp\[i+1][j], dp\[i+1][j-1] 三个子问题
-
-> 最长回文子序列
-> 此问题在力扣上也有,是 516 题。
-> 考虑一个字符串 s 的所有子序列, 这些子序列中最长的回文子序列长度是多少
-
-这个问题如果用线性动态规划的经典思路,状态如下:
-dp[i] := 考虑 [0..i] , 原文题的答案
-但是此后我们就遇到了困难,会发现这个状态有些难以转移
-
-而如果考虑区间动态规划,状态如下:
-dp[i][j] := 区间 [i..j] 上, 原问题的答案
-转移的时候,考虑 dp\[i][j-1], dp\[i+1][j], dp\[i+1][j-1] 这三个子问题,这是考虑把边界去掉的模式,回文的特点恰好时候这种模式,
-根据两个边界的元素关系可以得到转移方程如下:
-
-```
-dp\[i][j] = dp\[i + 1][j - 1] + 2; if(s[i] == s[j])
-dp\[i][j] = max(dp\[i + 1][j], dp\[i][j - 1]); if(s[i] != s[j])
-```
-
-回文是用区间动态规划解决的常见问题,有很多变种,下一节中列出的练习题有很多类似的。
-
-#### 大规模问题与 O(n) 个小规模问题有关
-
-推导 dp\[i][j] 时,需要 [i..j] 的所有子区间信息,其中子区间的其中一个端点与原区间重合,共 O(n)O(n) 个子区间
-
-最常见的形式
-dp\[i][j] = g(f(dp\[i][k], dp\[k][j])) 其中 k = i+1 .. j-1。
-
-其中 g 常见的有 max/min,例如 664 就是 min
-
-下面就以 664 题讲解这种模式的思考方式
-
-奇怪的打印机
-有台奇怪的打印机有以下两个特殊要求:
-
-打印机每次只能打印同一个字符序列。
-每次可以在任意起始和结束位置打印新字符,并且会覆盖掉原来已有的字符。
-给定一个只包含小写英文字母的字符串 s,你的任务是计算这个打印机打印它需要的最少次数。
-
-首先区间动态规划的状态定义与前面一样,还是经典的定义方式,状态定义模式化这也是区间动态规划的一个特点。
-
-dp\[i][j] := 打印出 [i..j] 上的字符需要的最少次数
-在转移时,枚举中间的切分位置 k,考虑 i 边界以及中间枚举的切分位置 k 转移时的情况
-
-i 要自己涂一次,则 dp\[i][j] = 1 + dp\[i + 1][j]
-其中第一项 1 表示 i 位置单独花费一次次数
-i 与中间的某个切分位置 k 一起打印 (条件是 s[i] = s[k]),则 dp\[i][j] = dp\[i+1][k] + dp\[k+1][j]
-其中第一项 dp\[i+1][k] 表示 i 位置跟着 k 一起转移了,不在单独考虑 i 花费的次数了
-综合以上分析可以写出状态转移方程如下
-
-```
-dp\[i][j] = dp\[i + 1][j] + 1;
-dp\[i][j] = min(dp\[i][j], dp\[i + 1][k] + dp\[k + 1][j]); 其中 i < k <= j 且 s[i] == s[k]
-```
-
-
-
-#### 总结
-
-本小节通过两个例题介绍了区间动态规划的状态转移的两种模式。这两种模式基本上就涵盖了大部分区间动态规划问题,后续节整理了力扣上出现的区间动态规划问题,通过这些题的练习,区间动态规划就可以掌握的差不多了。
-
-
-
-### 回文相关问题
-
-以下六道题是力扣上利用区间动态规划解决的与回文相关的问题。
-
-- 最长回文子串
-- 回文子串
-- 最长回文子序列
-- 段式回文
-- 统计不同回文子字符串
-- 让字符串成为回文串的最少插入次数 —— 最长回文子序列
-
-
-
diff --git a/docs/data-structure-algorithms/Linked-List.md b/docs/data-structure-algorithms/Linked-List.md
deleted file mode 100644
index ae595c2630..0000000000
--- a/docs/data-structure-algorithms/Linked-List.md
+++ /dev/null
@@ -1,562 +0,0 @@
-# 链表
-
-与数组相似,链表也是一种`线性`数据结构。
-
-链表是一系列的存储数据元素的单元通过指针串接起来形成的,因此每个单元至少有两个域,一个域用于数据元素的存储,另一个域是指向其他单元的指针。这里具有一个数据域和多个指针域的存储单元通常称为**结点**(node)。
-
-
-
-## 单链表
-
-
-
-一种最简单的结点结构如上图所示,它是构成单链表的基本结点结构。在结点中数据域用来存储数据元素,指针域用于指向下一个具有相同结构的结点。
-
-单链表中的每个结点不仅包含值,还包含链接到下一个结点的`引用字段`。通过这种方式,单链表将所有结点按顺序组织起来。
-
-
-
-链表的第一个结点和最后一个结点,分别称为链表的**首结点**和**尾结点**。尾结点的特征是其 next 引用为空(null)。链表中每个结点的 next 引用都相当于一个指针,指向另一个结点,借助这些 next 引用,我们可以从链表的首结点移动到尾结点。如此定义的结点就称为**单链表**(single linked list)。
-
-上图蓝色箭头显示单个链接列表中的结点是如何组合在一起的。
-
-在单链表中通常使用 head 引用来指向链表的首结点,由 head 引用可以完成对整个链表中所有节点的访问。有时也可以根据需要使用指向尾结点的 tail 引用来方便某些操作的实现。
-
-在单链表结构中还需要注意的一点是,由于每个结点的数据域都是一个 Object 类的对象,因此,每个数据元素并非真正如图中那样,而是在结点中的数据域通过一个 Object 类的对象引用来指向数据元素的。
-
-与数组类似,单链表中的结点也具有一个线性次序,即如果结点 P 的 next 引用指向结点 S,则 P 就是 S 的**直接前驱**,S 是 P 的**直接后续**。单链表的一个重要特性就是只能通过前驱结点找到后续结点,而无法从后续结点找到前驱结点。
-
-接着我们来看下单链表的 CRUD:
-
-以下是单链表中结点的典型定义:
-
-```java
-// Definition for singly-linked list.
-public class SinglyListNode {
- int val;
- SinglyListNode next;
- SinglyListNode(int x) { val = x; }
-}
-```
-
-### 查找
-
-与数组不同,我们无法在常量时间内访问单链表中的随机元素。 如果我们想要获得第 i 个元素,我们必须从头结点逐个遍历。 我们按索引来访问元素平均要花费 $O(N)$ 时间,其中 N 是链表的长度。
-
-例如需要在单链表中查找是否包含某个数据元素 e,则方法是使用一个循环变量 p,起始时从单链表的头结点开始,每次循环判断 p 所指结点的数据域是否和 e 相同,如果相同则可以返回 true,否则继续循环直到链表中所有结点均被访问,此时 p 为 null。
-
-使用 Java 语言实现整个过程的关键语句是:
-
-```java
-p=head;
-while (p!=null)
-if (strategy.equal( e , p.getData() )) return true;
-return false;
-```
-
-
-
-### 添加
-
-单链表中数据元素的插入,是通过在链表中插入数据元素所属的结点来完成的。对于链表的不同位置,插入的过程会有细微的差别。
-
-
-
-除了单链表的首结点由于没有直接前驱结点,所以可以直接在首结点之前插入一个新的结点之外,在单链表中的其他任何位置插入一个新结点时,都只能是在已知某个特定结点引用的基础上在其后面插入一个新结点。并且在已知单链表中某个结点引用的基础上,完成结点的插入操作需要的时间是 $O(1)$。
-
-> 思考:如果是带头结点的单链表进行插入操作,是什么样子呢?
-
-
-
-### 删除
-
-类似的,在单链表中数据元素的删除也是通过结点的删除来完成的。在链表的不同位置删除结点,其操作过程也会有一些差别。
-
-
-
-在单链表中删除一个结点时,除首结点外都必须知道该结点的直接前驱结点的引用。并且在已知单链表中某个结点引用的基础上,完成其后续结点的删除操作需要的时间是 $O(1)$。
-
-> 在使用单链表实现线性表的时候,为了使程序更加简洁,我们通常在单链表的最前面添加一个**哑元结点**,也称为头结点。在头结点中不存储任何实质的数据对象,其 next 域指向线性表中 0 号元素所在的结点,头结点的引入可以使线性表运算中的一些边界条件更容易处理。
->
-> 对于任何基于序号的插入、删除,以及任何基于数据元素所在结点的前面或后面的插入、删除,在带头结点的单链表中均可转化为在某个特定结点之后完成结点的插入、删除,而不用考虑插入、删除是在链表的首部、中间、还是尾部等不同情况。
-
-
-
-## 双向链表
-
-单链表的一个优点是结构简单,但是它也有一个缺点,即在单链表中只能通过一个结点的引用访问其后续结点,而无法直接访问其前驱结点,要在单链表中找到某个结点的前驱结点,必须从链表的首结点出发依次向后寻找,但是需要 $Ο(n)$ 时间。
-
-所以我们在单链表结点结构中新增加一个域,该域用于指向结点的直接前驱结点。
-
-
-
-双向链表是通过上述定义的结点使用 pre 以及 next 域依次串联在一起而形成的。一个双向链表的结构如下图所示。
-
-
-
-接着我们来看下双向链表的 CRUD:
-
-以下是双链表中结点的典型定义:
-
-```java
-// Definition for doubly-linked list.
-class DoublyListNode {
- int val;
- DoublyListNode next, prev;
- DoublyListNode(int x) {val = x;}
-}
-```
-
-### 查找
-
-在双向链表中进行查找与在单链表中类似,只不过在双向链表中查找操作可以从链表的首结点开始,也可以从尾结点开始,但是需要的时间和在单链表中一样。
-
-### 添加
-
-单链表的插入操作,除了首结点之外必须在某个已知结点后面进行,而在双向链表中插入操作在一个已知的结点之前或之后都可以进行,如下表示在结点 p(11) 之前 插入 s(9)。
-
-
-
-使用 Java 语言实现整个过程的关键语句是
-
-```java
-s.setPre (p.getPre());
-p.getPre().setNext(s);
-s.setNext(p);
-p.setPre(s);
-```
-
-在结点 p 之后插入一个新结点的操作与上述操作对称,这里不再赘述。
-
-插入操作除了上述情况,还可以在双向链表的首结点之前、双向链表的尾结点之后进行,此时插入操作与上述插入操作相比更为简单。
-
-### 删除
-
-单链表的删除操作,除了首结点之外必须在知道待删结点的前驱结点的基础上才能进行,而在双向链表中在已知某个结点引用的前提下,可以完成该结点自身的删除。如下表示删除 p(16) 的过程。
-
-
-
-使用 Java 语言实现整个过程的关键语句是
-
-```java
-p.getPre().setNext(p.getNext());
-p.getNext().setPre(p.getPre());
-```
-
-
-
-对线性表的操作,无非就是排序、加法、减法、反转,说的好像很简单,我们开始刷题。
-
-
-
-## 刷题
-
-### 反转链表(206)
-
->反转一个单链表。
->
->```
->输入: 1->2->3->4->5->NULL
->输出: 5->4->3->2->1->NULL
->```
-
-**进阶:** 你可以迭代或递归地反转链表。你能否用两种方法解决这道题?
-
-**题目解析**
-
-1. 定义两个指针: pre 和 cur ;pre 在前 cur 在后。
-2. 每次让 pre 的 next 指向 cur ,实现一次局部反转
-3. 局部反转完成之后,pre 和 cur 同时往前移动一个位置
-4. 循环上述过程,直至 pre 到达链表尾部
-
-**动画描述**
-
-
-
-两个指针,最开始就把指针位置倒着放,然后遍历替换数字,最后返回 pre 就行
-
-```java
-public ListNode reverseList_1(ListNode head){
- if(head == null || head.next == null){
- return head;
- }
- //申请节点,pre和 cur,pre指向null
- ListNode cur = head;
- ListNode pre = null;
- while(cur != null) {
- //记录当前节点的下一个节点
- ListNode tmp = cur.next;
- //然后将当前节点指向pre
- cur.next = pre;
- //pre和cur节点都前进一位
- pre = cur;
- cur = tmp;
- }
- return pre;
-}
-```
-
-
-
-### [环形链表(141)](https://leetcode-cn.com/problems/linked-list-cycle/)
-
-> 给定一个链表,判断链表中是否有环。
->
-> 为了表示给定链表中的环,我们使用整数 `pos` 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 `pos` 是 `-1`,则在该链表中没有环。
->
-> ```
-> 输入:head = [3,2,0,-4], pos = 1
-> 输出:true
-> 解释:链表中有一个环,其尾部连接到第二个节点。
-> ```
->
-> 
-
-**题目解析**
-
-这道题是快慢指针的**经典应用**。
-
-设置两个指针,一个每次走一步的**慢指针**和一个每次走两步的**快指针**。
-
-- 如果不含有环,跑得快的那个指针最终会遇到 null,说明链表不含环
-- 如果含有环,快指针会超慢指针一圈,和慢指针相遇,说明链表含有环。
-
-
-
-```java
-public boolean hasCycle(ListNode head) {
- if (head == null || head.next == null) {
- return false;
- }
- // 龟兔起跑
- ListNode fast = head;
- ListNode slow = head;
-
- while (fast != null && fast.next != null) {
- // 龟走一步
- slow = slow.next;
- // 兔走两步
- fast = fast.next.next;
- if (slow == fast) {
- return true;
- }
- }
- return false;
-}
-```
-
-
-
-
-
-### 相交链表(160)
-
-> 
->
-> 输入:intersectVal = 8, listA = [4,1,8,4,5], listB = [5,0,1,8,4,5], skipA = 2, skipB = 3
-> 输出:Reference of the node with value = 8
-> 输入解释:相交节点的值为 8 (注意,如果两个链表相交则不能为 0)。从各自的表头开始算起,链表 A 为 [4,1,8,4,5],链表 B 为 [5,0,1,8,4,5]。在 A 中,相交节点前有 2 个节点;在 B 中,相交节点前有 3 个节点。
-
-**题目解析**
-
-为满足题目时间复杂度和空间复杂度的要求,我们可以使用双指针法。
-
-- 创建两个指针 pA 和 pB 分别指向链表的头结点 headA 和 headB。
-- 当 pA 到达链表的尾部时,将它重新定位到链表B的头结点 headB,同理,当 pB 到达链表的尾部时,将它重新定位到链表 A 的头结点 headA。
-- 当 pA 与 pB 相等时便是两个链表第一个相交的结点。 这里其实就是相当于把两个链表拼在一起了。pA 指针是按 B 链表拼在 A 链表后面组成的新链表遍历,而 pB 指针是按A链表拼在B链表后面组成的新链表遍历。举个简单的例子: A链表:{1,2,3,4} B链表:{6,3,4} pA按新拼接的链表{1,2,3,4,6,3,4}遍历 pB按新拼接的链表{6,3,4,1,2,3,4}遍历
-
-
-
-```java
-public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
- if (headA == null || headB == null) {
- return null;
- }
- ListNode pA = headA, pB = headB;
- while (pA != pB) {
- pA = pA == null ? headB : pA.next;
- pB = pB == null ? headA : pB.next;
- }
- return pA;
-}
-```
-
-
-
-### 合并两个有序链表(21)
-
-> 将两个升序链表合并为一个新的 **升序** 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
->
-> **示例:**
->
-> ```
-> 输入:1->2->4, 1->3->4
-> 输出:1->1->2->3->4->4
-> ```
-
-如果 l1 或者 l2 一开始就是空链表 ,那么没有任何操作需要合并,所以我们只需要返回非空链表。否则,我们要判断 l1 和 l2 哪一个链表的头节点的值更小,然后递归地决定下一个添加到结果里的节点。如果两个链表有一个为空,递归结束。
-
-
-
-```java
-public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
- if (l1 == null) {
- return l2;
- } else if (l2 == null) {
- return l1;
- } else if (l1.val < l2.val) {
- l1.next = mergeTwoLists(l1.next, l2);
- return l1;
- } else {
- l2.next = mergeTwoLists(l1, l2.next);
- return l2;
- }
-}
-```
-
-
-
-### 回文链表(234)
-
-> 请判断一个链表是否为回文链表。
->
-> ```
->输入: 1->2
-> 输出: false
-> ```
->
-> ```
->输入: 1->2->2->1
-> 输出: true
->```
-
-**解法1:**
-
-1. 复制链表值到数组列表中。
-2. 使用双指针法判断是否为回文。
-
-
-
-```java
-public static boolean isPalindrome_me(ListNode head){
- if(head == null || head.next == null){
- return false;
- }
- List list = new ArrayList<>();
- while(head != null){
- list.add(head.val);
- head = head.next;
- }
- Integer[] arrs = list.toArray(new Integer[list.size()]);
-
- int tmp = 0;
- for(int i=0;i 给出两个 非空 的链表用来表示两个非负的整数。其中,它们各自的位数是按照 逆序 的方式存储的,并且它们的每个节点只能存储 一位 数字。
->
-> 如果,我们将这两个数相加起来,则会返回一个新的链表来表示它们的和。
->
-> 您可以假设除了数字 0 之外,这两个数都不会以 0 开头。
->
-> ```
-> 输入:(2 -> 4 -> 3) + (5 -> 6 -> 4)
-> 输出:7 -> 0 -> 8
-> 原因:342 + 465 = 807
-> ```
-
-```java
-public static ListNode addTwoNumbers(ListNode l1, ListNode l2) {
- ListNode pre = new ListNode(0);
- ListNode cur = pre;
- //进位
- int carry = 0;
- while(l1 != null || l2 != null) {
- int x = l1 == null ? 0 : l1.val;
- int y = l2 == null ? 0 : l2.val;
- int sum = x + y + carry;
-
- //如果大于10了,就进位
- carry = sum / 10;
- //进位后剩下的余数
- sum = sum % 10;
- //进位后的数据
- cur.next = new ListNode(sum);
- cur = cur.next;
- //往后移动
- if(l1 != null) {
- l1 = l1.next;
- }
- if(l2 != null) {
- l2 = l2.next;
- }
- }
- //如果最后一位还有进位的话,再往后增加一个节点
- if(carry == 1) {
- cur.next = new ListNode(carry);
- }
- return pre.next;
- }
-```
-
-
-
-### [LRU 缓存机制(146)](https://leetcode-cn.com/problems/lru-cache/)
-
-> 运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制 。实现 LRUCache 类:
->
-> - LRUCache(int capacity) 以正整数作为容量 capacity 初始化 LRU 缓存
-> - int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
-> - void put(int key, int value) 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
->
-> ```
-> 输入
-> ["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
-> [[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
-> 输出
-> [null, null, null, 1, null, -1, null, -1, 3, 4]
->
-> 解释
-> LRUCache lRUCache = new LRUCache(2);
-> lRUCache.put(1, 1); // 缓存是 {1=1}
-> lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
-> lRUCache.get(1); // 返回 1
-> lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
-> lRUCache.get(2); // 返回 -1 (未找到)
-> lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
-> lRUCache.get(1); // 返回 -1 (未找到)
-> lRUCache.get(3); // 返回 3
-> lRUCache.get(4); // 返回 4
-> ```
-
-分析上面的操作过程,要让 put 和 get 方法的时间复杂度为 O(1),我们可以总结出 cache 这个数据结构必要的条件:查找快,插入快,删除快,有顺序之分。
-
-因为显然 cache 必须有顺序之分,以区分最近使用的和久未使用的数据;而且我们要在 cache 中查找键是否已存在;如果容量满了要删除最后一个数据;每次访问还要把数据插入到队头。
-
-那么,什么数据结构同时符合上述条件呢?哈希表查找快,但是数据无固定顺序;链表有顺序之分,插入删除快,但是查找慢。所以结合一下,形成一种新的数据结构:**哈希链表**。
-
-
-
-```java
-// key 映射到 Node(key, val)
-HashMap map;
-// Node(k1, v1) <-> Node(k2, v2)...
-DoubleList cache;
-
-int get(int key) {
- if (key 不存在) {
- return -1;
- } else {
- 将数据 (key, val) 提到开头;
- return val;
- }
-}
-
-void put(int key, int val) {
- Node x = new Node(key, val);
- if (key 已存在) {
- 把旧的数据删除;
- 将新节点 x 插入到开头;
- } else {
- if (cache 已满) {
- 删除链表的最后一个数据腾位置;
- 删除 map 中映射到该数据的键;
- }
- 将新节点 x 插入到开头;
- map 中新建 key 对新节点 x 的映射;
- }
-}
-```
-
-
-
-
-
-### [删除链表的倒数第 N 个结点(19)](https://leetcode-cn.com/problems/remove-nth-node-from-end-of-list/)
-
-> 给定一个链表,删除链表的倒数第 n 个节点,并且返回链表的头结点。
->
-> ```
->给定一个链表: 1->2->3->4->5, 和 n = 2.
->
->当删除了倒数第二个节点后,链表变为 1->2->3->5.
-> ```
-
-```java
-public ListNode removeNthFromEnd_1(ListNode head, int n) {
- ListNode pre = new ListNode(0,head);
- int length = getLength(head);
- ListNode cur = pre;
- //遍历到需要删除的位置的前一个,比如1,2,3,4,5 遍历到第 < 4 就可以了
- for (int i=1;i 给你链表的头结点 `head` ,请将其按 **升序** 排列并返回 **排序后的链表** 。
->
-> 在 *O*(*n* log *n*) 时间复杂度和常数级空间复杂度下,对链表进行排序。
->
-> **示例 1:**
->
-> ```
-> 输入: 4->2->1->3
-> 输出: 1->2->3->4
-> ```
-
-**解答一:归并排序(递归法)**
-
-**解答二:归并排序(从底至顶直接合并)**
-
-
-
-
-
-
-
-
-
-
-
-## 参考与感谢
-
-- https://aleej.com/2019/09/16/数据结构与算法之美学习笔记
\ No newline at end of file
diff --git a/docs/data-structure-algorithms/README.md b/docs/data-structure-algorithms/README.md
index 315fd6d525..c306a84679 100644
--- a/docs/data-structure-algorithms/README.md
+++ b/docs/data-structure-algorithms/README.md
@@ -1,119 +1,546 @@
-# 数据结构开篇
+---
+title: 数据结构与算法:Java开发者的必备修炼
+date: 2025-05-09
+categories: Algorithm
+---
+
+# 🚀 数据结构与算法开篇
+
+
+
+> 💡 **关于怎么刷题的帖子**:
+>
+> - 📖 《论如何4个月高效刷满 500 题并形成长期记忆》 https://leetcode-cn.com/circle/discuss/jq9Zke/
+
+---
+
+## 🎯 专栏介绍
+
+本专栏致力于为Java开发者提供全面的数据结构与算法学习资源,涵盖从基础概念到高级应用的完整知识体系。通过系统性的学习和实践,帮助开发者提升编程能力,掌握解决复杂问题的核心技能。
+
+### 📚 专栏特色
+
+- 🎯 **系统性学习**:从基础到进阶,循序渐进
+- 💻 **Java实现**:所有代码示例均使用Java语言
+- 🔥 **实战导向**:结合LeetCode经典题目
+- 📊 **可视化理解**:丰富的图表和动画演示
+- 🎨 **美观排版**:精心设计的文档格式
+
+---
+
+## 📊 第一部分:数据结构分类
+
+数据结构是计算机存储、组织数据的方式,是算法的基础。掌握各种数据结构的特点和应用场景,是成为优秀程序员的必经之路。
+
+### 📏 线性数据结构
+
+线性数据结构是指数据元素之间存在一对一关系的数据结构,元素按线性顺序排列。
+
+#### 📋 1. 数组 (Array)
+```
+数组结构示意图:
+Index: [0] [1] [2] [3] [4]
+Value: [12][45][78][23][56]
+ ↑ ↑ ↑ ↑ ↑
+ 连续的内存地址空间
+```
+- **特点**:连续存储,支持随机访问
+- **时间复杂度**:访问O(1),插入/删除O(n)
+- **Java实现**:`int[]`、`ArrayList`
+
+#### 🔗 2. 链表 (Linked List)
+```
+单链表结构示意图:
+[Data|Next] -> [Data|Next] -> [Data|Next] -> null
+ | | |
+ 节点1 节点2 节点3
+
+双链表结构示意图:
+null <- [Prev|Data|Next] <-> [Prev|Data|Next] <-> [Prev|Data|Next] -> null
+```
+- **特点**:动态存储,插入删除高效
+- **时间复杂度**:访问O(n),插入/删除O(1)
+- **Java实现**:`LinkedList`
+
+#### 📚 3. 栈 (Stack)
+```
+栈结构示意图:
+ | | <- top (栈顶)
+ | C |
+ | B |
+ | A |
+ |_____| <- bottom (栈底)
+
+操作:LIFO (后进先出)
+```
+- **特点**:后进先出(LIFO)
+- **时间复杂度**:push/pop/peek都是O(1)
+- **Java实现**:`Stack`、`Deque`
+
+#### 🚶 4. 队列 (Queue)
+```
+队列结构示意图:
+出队 <- [A][B][C][D] <- 入队
+ front rear
+
+操作:FIFO (先进先出)
+```
+- **特点**:先进先出(FIFO)
+- **时间复杂度**:enqueue/dequeue都是O(1)
+- **Java实现**:`Queue`、`LinkedList`
+
+### 🌳 非线性数据结构
+
+非线性数据结构是指数据元素之间存在一对多或多对多关系的数据结构,形成复杂的层次或网状结构。
+
+#### 🌲 5. 二叉树 (Binary Tree)
+```
+二叉树结构示意图:
+ A (根节点)
+ / \
+ B C
+ / \ / \
+ D E F G (叶子节点)
+
+层次:
+第0层: A
+第1层: B C
+第2层: D E F G
+```
+- **特点**:每个节点最多有两个子节点
+- **时间复杂度**:搜索/插入/删除O(logn)~O(n)
+- **Java实现**:`TreeMap`、`TreeSet`
+
+#### 🏔️ 6. 堆 (Heap)
+```
+最大堆示意图:
+ 90
+ / \
+ 80 70
+ / \ / \
+ 60 50 40 30
+
+特点:父节点 >= 子节点 (最大堆)
+数组表示:[90,80,70,60,50,40,30]
+```
+- **特点**:完全二叉树,堆序性质
+- **时间复杂度**:插入/删除O(logn),查找最值O(1)
+- **Java实现**:`PriorityQueue`
+
+#### 🕸️ 7. 图 (Graph)
+```
+无向图示意图:
+ A --- B
+ /| |\
+ / | | \
+ D | | C
+ | | /
+ E --- F
+
+邻接矩阵表示:
+ A B C D E F
+A [ 0 1 0 1 1 0 ]
+B [ 1 0 1 0 0 1 ]
+C [ 0 1 0 0 0 1 ]
+D [ 1 0 0 0 1 0 ]
+E [ 1 0 0 1 0 1 ]
+F [ 0 1 1 0 1 0 ]
+```
+- **特点**:顶点和边的集合,表示复杂关系
+- **时间复杂度**:遍历O(V+E),最短路径O(V²)
+- **Java实现**:`Map>`
+
+#### 🗂️ 8. 哈希表 (Hash Table)
+```
+哈希表结构示意图:
+Hash函数:key → hash(key) % size → index
+
+Key: "apple" → hash("apple") = 5 → index: 5 % 8 = 5
+Key: "banana" → hash("banana")= 3 → index: 3 % 8 = 3
+
+Table:
+[0] → null
+[1] → null
+[2] → null
+[3] → "banana" → value
+[4] → null
+[5] → "apple" → value
+[6] → null
+[7] → null
+
+冲突处理(链地址法):
+[3] → ["banana",value1] → ["grape",value2] → null
+```
+- **特点**:基于键值对,快速查找
+- **时间复杂度**:平均O(1),最坏O(n)
+- **Java实现**:`HashMap`、`HashSet`
+
+---
+
+## ⚡ 第二部分:常见算法
+
+算法是解决问题的步骤和方法,是程序设计的核心。掌握各种算法的思想和实现,能够帮助我们高效地解决各种复杂问题。
+
+### 🔢 1. 排序算法
+
+排序算法是计算机科学中最基础也是最重要的算法之一,掌握各种排序算法的特点和应用场景至关重要。
+
+| 算法 | 时间复杂度(平均) | 时间复杂度(最坏) | 空间复杂度 | 稳定性 |
+|------|-------------------|-------------------|------------|--------|
+| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 |
+| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 |
+| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 |
+| 快速排序 | O(nlogn) | O(n²) | O(logn) | 不稳定 |
+| 归并排序 | O(nlogn) | O(nlogn) | O(n) | 稳定 |
+| 堆排序 | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
+| 计数排序 | O(n+k) | O(n+k) | O(k) | 稳定 |
+| 基数排序 | O(nk) | O(nk) | O(n+k) | 稳定 |
+
+### 🔍 2. 搜索算法
+
+搜索算法用于在数据集合中查找特定元素,不同的搜索算法适用于不同的场景。
+
+- **线性搜索**:O(n) - 适用于无序数组
+- **二分搜索**:O(logn) - 适用于有序数组
+- **哈希查找**:O(1) - 适用于HashMap/HashSet
+- **树搜索**:O(logn) - 适用于二叉搜索树
+- **图搜索**:DFS/BFS - O(V+E)
+
+### 🕸️ 3. 图算法
+
+图算法是处理图结构数据的重要工具,广泛应用于网络分析、路径规划等领域。
+
+#### 🔍 3.1 图遍历
+- **深度优先搜索(DFS)**:O(V+E)
+- **广度优先搜索(BFS)**:O(V+E)
+
+#### 🛣️ 3.2 最短路径
+- **Dijkstra算法**:O((V+E)logV) - 单源最短路径
+- **Floyd-Warshall算法**:O(V³) - 全源最短路径
+- **Bellman-Ford算法**:O(VE) - 含负权边
+
+#### 🌲 3.3 最小生成树
+- **Prim算法**:O(ElogV)
+- **Kruskal算法**:O(ElogE)
-
+#### 📋 3.4 拓扑排序
+- **Kahn算法**:O(V+E)
+- **DFS算法**:O(V+E)
-## 概念
+### 🎯 4. 动态规划
-**数据**(data)是描述客观事物的数值、字符以及能输入机器且能被处理的各种符号集合。 数据的含义非常广泛,除了通常的数值数据、字符、字符串是数据以外,声音、图像等一切可以输入计算机并能被处理的都是数据。例如除了表示人的姓名、身高、体重等的字符、数字是数据,人的照片、指纹、三维模型、语音指令等也都是数据。
+动态规划是一种通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
+#### 🏗️ 4.1 基础DP
+- **斐波那契数列**:O(n)
+- **爬楼梯**:O(n)
+- **最大子序和**:O(n)
+#### 📝 4.2 序列DP
+- **最长递增子序列(LIS)**:O(nlogn)
+- **最长公共子序列(LCS)**:O(mn)
+- **编辑距离**:O(mn)
-**数据元素**(data element)是数据的基本单位,是数据集合的个体,在计算机程序中通常作为一个整体来进行处理。例如一条描述一位学生的完整信息的数据记录就是一个数据元素;空间中一点的三维坐标也可以是一个数据元素。数据元素通常由若干个数据项组成,例如描述学生相关信息的姓名、性别、学号等都是数据项;三维坐标中的每一维坐标值也是数据项。数据项具有原子性,是不可分割的最小单位。
+#### 🎒 4.3 背包问题
+- **0-1背包**:O(nW)
+- **完全背包**:O(nW)
+- **多重背包**:O(nWlogM)
+#### 📏 4.4 区间DP
+- **最长回文子串**:O(n²)
+- **矩阵链乘法**:O(n³)
+### 🎯 5. 贪心算法
-**数据对象**(data object)是性质相同的数据元素的集合,是数据的子集。例如一个学校的所有学生的集合就是数据对象,空间中所有点的集合也是数据对象。
+贪心算法是一种在每一步选择中都采取在当前状态下最好或最优的选择,从而希望导致结果是最好或最优的算法。
+- **活动选择问题**:O(nlogn)
+- **分数背包**:O(nlogn)
+- **最小生成树(Prim/Kruskal)**:O(ElogV)
+- **霍夫曼编码**:O(nlogn)
+- **区间调度**:O(nlogn)
+### 🔄 6. 分治算法
-**数据结构**(data structure)是指相互之间存在一种或多种特定关系的数据元素的集合。是组织并存储数据以便能够有效使用的一种专门格式,它用来反映一个数据的内部构成,即一个数据由哪些成分数据构成,以什么方式构成,呈什么结构。
+分治算法是一种很重要的算法,字面上的解释是"分而治之",就是把一个复杂的问题分成两个或更多的相同或相似的子问题。
-由于信息可以存在于逻辑思维领域,也可以存在于计算机世界,因此作为信息载体的数据同样存在于两个世界中。表示一组数据元素及其相互关系的数据结构同样也有两种不同的表现形式,一种是数据结构的逻辑层面,即数据的逻辑结构;一种是存在于计算机世界的物理层面,即数据的存储结构
+- **归并排序**:O(nlogn)
+- **快速排序**:O(nlogn)
+- **二分搜索**:O(logn)
+- **最大子数组和**:O(nlogn)
+- **最近点对**:O(nlogn)
+### 🔙 7. 回溯算法
+回溯算法是一种通过穷举所有可能情况来找到所有解的算法。当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择。
-## 逻辑结构和物理结构
+- **N皇后问题**:O(N!)
+- **数独求解**:O(9^(n*n))
+- **全排列**:O(n!)
+- **子集生成**:O(2^n)
+- **组合问题**:O(C(n,k))
-按照视点的不同,我们把数据结构分为逻辑结构和物理结构。
+### 📝 8. 字符串算法
-### 逻辑结构
+字符串算法是处理文本数据的重要工具,广泛应用于文本搜索、模式匹配等领域。
-是指数据对象中数据元素之间的相互关系。其实这也是我们今后最需要关注的问题。分为以下四种:
+- **KMP算法**:O(n+m) - 字符串匹配
+- **Rabin-Karp算法**:O(n+m) - 字符串匹配
+- **最长公共前缀**:O(S) - S为所有字符串长度和
+- **字典树(Trie)**:插入/查找 O(m)
+- **后缀数组**:O(nlogn)
-- 集合结构:集合结构中的数据元素除了同属于一个集合外,他们之间没有其他关系
+### 🧮 9. 数学算法
- 
+数学算法是解决数学问题的计算方法,在编程中经常需要用到各种数学算法。
-- 线性结构:数据之间是一对一关系
+- **最大公约数(GCD)**:O(logn)
+- **快速幂**:O(logn)
+- **素数筛选**:O(nloglogn)
+- **模运算**:O(1)
+- **组合数学**:O(nlogn)
- 
+### 🔢 10. 位运算算法
-- 树形结构:数据之间存在一对多的层次关系
+位运算是计算机中最底层的运算,掌握位运算技巧可以写出更高效的代码。
- 
+- **位运算基础**:O(1)
+- **状态压缩DP**:O(n*2^m)
+- **子集枚举**:O(2^n)
+- **位操作技巧**:O(1)
-- 图形结构:数据之间多对多的关系
+### 🏗️ 11. 高级数据结构算法
- 
+高级数据结构算法是建立在基础数据结构之上的复杂算法,能够解决更复杂的问题。
-### 物理结构
+- **并查集**:O(α(n)) - 接近常数时间
+- **线段树**:O(logn) - 区间查询/更新
+- **树状数组**:O(logn) - 前缀和查询
+- **平衡树(AVL/红黑树)**:O(logn)
+- **跳表**:O(logn)
-是指数据的逻辑结构在计算机中的存储形式。(有时也被叫存储结构)
+---
-数据是数据元素的集合,根据物理结构的定义,实际上就是如何把数据元素存储到计算机的存储器中。存储器主要是针对内存而言的,像硬盘、软盘、光盘等外部存储器的数据组织通常用文件结构来描述。
+## 🎯 第三部分:LeetCode经典题目
-数据元素的存储结构形式有两种:顺序存储和链式存储。
+LeetCode是程序员刷题的重要平台,通过系统性的刷题练习,可以快速提升算法能力。以下是按类型分类的经典题目。
-- 顺序存储:把数据元素存放在地址连续的存储单元里,其数据间的逻辑关系和物理关系一致
+### 📋 1. 数组类题目
- 
+数组是最基础的数据结构,掌握数组的各种操作技巧是算法学习的基础。
-- 链式存储:把数据元素存放在任意的存储单元里,这组存储单元可以是连续的,也可以是不连续的。
+#### 🔧 基础操作
+- **1. 两数之和** - 哈希表优化
+- **26. 删除排序数组中的重复项** - 双指针
+- **27. 移除元素** - 双指针
+- **88. 合并两个有序数组** - 双指针
- 
+#### 🔍 搜索与查找
+- **33. 搜索旋转排序数组** - 二分搜索
+- **34. 在排序数组中查找元素的第一个和最后一个位置** - 二分搜索
+- **35. 搜索插入位置** - 二分搜索
+- **153. 寻找旋转排序数组中的最小值** - 二分搜索
+#### 👆 双指针技巧
+- **15. 三数之和** - 排序+双指针
+- **16. 最接近的三数之和** - 排序+双指针
+- **18. 四数之和** - 排序+双指针
+- **42. 接雨水** - 双指针
+- **11. 盛最多水的容器** - 双指针
+#### 🪟 滑动窗口
+- **3. 无重复字符的最长子串** - 滑动窗口
+- **76. 最小覆盖子串** - 滑动窗口
+- **209. 长度最小的子数组** - 滑动窗口
+- **438. 找到字符串中所有字母异位词** - 滑动窗口
-## 抽象数据结构类型
+### 🔗 2. 链表类题目
-**数据类型**(data type)是指一组性质相同的值的集合及定义在此集合上的一些操作的总称。例如 Java 语言中就有许多不同的数据类型,包括数值型的数据类型、字符串、布尔型等数据类型。以 Java 中的 int 型为例,int 型的数据元素的集合是[-2147483648,2147483647] 间的整数,定义在其上的操作有加、减、乘、除四则运算,还有模运算等。
+链表是动态数据结构,掌握链表的操作技巧对于理解指针和递归非常重要。
-数据类型是按照值得不同进行划分的。在高级语言中,每个变量、常量和表达式都有各自的取值范围。类型就用来说明变量或表达式取值范围和所能进行的操作。
+#### 🔧 基础操作
+- **206. 反转链表** - 迭代/递归
+- **21. 合并两个有序链表** - 双指针
+- **83. 删除排序链表中的重复元素** - 单指针
+- **82. 删除排序链表中的重复元素 II** - 双指针
-定义数据类型的作用一个是隐藏计算机硬件及其特性和差别,使硬件对于用户而言是透明的,即用户可以不关心数据类型是怎么实现的而可以使用它。定义数据类型的另一个作用是,用户能够使用数据类型定义的操作,方便的实现问题的求解。例如,用户可以使用 Java 定义在 int 型的加法操作完成两个整数的加法运算,而不用关心两个整数的加法在计算机中到底是如何实现的。这样不但加快了用户解决问题的速度,也使得用户可以在更高的层面上 考虑问题。
+#### 👆 双指针技巧
+- **141. 环形链表** - 快慢指针
+- **142. 环形链表 II** - 快慢指针
+- **160. 相交链表** - 双指针
+- **19. 删除链表的倒数第 N 个结点** - 快慢指针
-**抽象数据类型**(abstract data type, 简称 ADT)由一种数据模型和在该数据模型上的一组操作组成。
+#### 🔄 复杂操作
+- **234. 回文链表** - 快慢指针+反转
+- **143. 重排链表** - 找中点+反转+合并
+- **148. 排序链表** - 归并排序
+- **23. 合并K个排序链表** - 分治/优先队列
+
+### 📚 3. 栈与队列类题目
+
+栈和队列是重要的线性数据结构,在算法中有着广泛的应用。
-抽象数据类型一方面使得使用它的人可以只关心它的逻辑特征,不需要了解它的实现方式。另一方面可以使我们更容易描述现实世界,使得我们可以在更高的层面上来考虑问题。 例如可以使用树来描述行政区划,使用图来描述通信网络。
-
-
-
-## 数据结构分类
-
-- 数组
-- 栈
-- 链表
-- 队列
-- 树
-- 图
-- 堆
-- 散列表
-
-
-
-# 算法
-
-算法设计是最具创造性的工作之一,人们解决任何问题的思想、方法和步骤实际上都可以认为是算法。人们解决问题的方法有好有坏,因此算法在性能上也就有高低之分。
-
-## 概念
-
-算法(algorithm)是指令的集合,是为解决特定问题而规定的一系列操作。它是明确定义的可计算过程,以一个数据集合作为输入,并产生一个数据集合作为输出。一个算法通常来说具有以下五个特性:
-
-- 输入:一个算法应以待解决的问题的信息作为输入。
-- 输出:输入对应指令集处理后得到的信息。
-- 可行性:算法是可行的,即算法中的每一条指令都是可以实现的,均能在有限的时间内完成。
-- 有穷性:算法执行的指令个数是有限的,每个指令又是在有限时间内完成的,因此 整个算法也是在有限时间内可以结束的。
-- 确定性:算法对于特定的合法输入,其对应的输出是唯一的。即当算法从一个特定 输入开始,多次执行同一指令集结果总是相同的。 对于随机算法,该特性应当被放宽
-
-
-
-## 算法设计要求
-
-- 正确性:算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义、能正确反映问题的需求、能得到问题的正确答案
-- 可读性:算法设计的另一目的是为了便于阅读、理解和交流
-- 健壮性:当输入数据不合法时,算法也能做出相关处理,而不是产生异常或错误结果
-- 时间效率高和存储量低
-
-
-
-
\ No newline at end of file
+#### 📚 栈的应用
+- **20. 有效的括号** - 栈匹配
+- **155. 最小栈** - 辅助栈
+- **225. 用队列实现栈** - 设计问题
+- **232. 用栈实现队列** - 设计问题
+
+#### 📈 单调栈
+- **496. 下一个更大元素 I** - 单调栈
+- **503. 下一个更大元素 II** - 单调栈
+- **739. 每日温度** - 单调栈
+- **84. 柱状图中最大的矩形** - 单调栈
+
+### 4. 树类题目
+
+#### 二叉树遍历
+- **94. 二叉树的中序遍历** - 递归/迭代
+- **144. 二叉树的前序遍历** - 递归/迭代
+- **145. 二叉树的后序遍历** - 递归/迭代
+- **102. 二叉树的层序遍历** - BFS
+
+#### 二叉树性质
+- **104. 二叉树的最大深度** - DFS
+- **111. 二叉树的最小深度** - BFS/DFS
+- **226. 翻转二叉树** - DFS
+- **101. 对称二叉树** - DFS
+
+#### 二叉搜索树
+- **98. 验证二叉搜索树** - 中序遍历
+- **700. 二叉搜索树中的搜索** - 二分搜索
+- **701. 二叉搜索树中的插入操作** - 递归
+- **450. 删除二叉搜索树中的节点** - 递归
+
+#### 树的路径问题
+- **112. 路径总和** - DFS
+- **113. 路径总和 II** - DFS+回溯
+- **124. 二叉树中的最大路径和** - DFS
+- **257. 二叉树的所有路径** - DFS+回溯
+
+### 5. 图类题目
+
+#### 图的遍历
+- **200. 岛屿数量** - DFS/BFS
+- **695. 岛屿的最大面积** - DFS/BFS
+- **994. 腐烂的橘子** - BFS
+- **130. 被围绕的区域** - DFS/BFS
+
+#### 拓扑排序
+- **207. 课程表** - 拓扑排序
+- **210. 课程表 II** - 拓扑排序
+
+#### 联通分量
+- **547. 省份数量** - 并查集/DFS
+- **684. 冗余连接** - 并查集
+- **721. 账户合并** - 并查集
+
+### 6. 动态规划类题目
+
+#### 基础DP
+- **70. 爬楼梯** - 基础DP
+- **198. 打家劫舍** - 线性DP
+- **213. 打家劫舍 II** - 环形DP
+- **53. 最大子序和** - Kadane算法
+
+#### 序列DP
+- **300. 最长递增子序列** - LIS
+- **1143. 最长公共子序列** - LCS
+- **72. 编辑距离** - 字符串DP
+- **5. 最长回文子串** - 区间DP
+
+#### 背包问题
+- **416. 分割等和子集** - 0-1背包
+- **494. 目标和** - 0-1背包
+- **322. 零钱兑换** - 完全背包
+- **518. 零钱兑换 II** - 完全背包
+
+#### 状态机型DP
+- **121. 买卖股票的最佳时机** - 状态机DP
+- **122. 买卖股票的最佳时机 II** - 状态机DP
+- **123. 买卖股票的最佳时机 III** - 状态机DP
+- **188. 买卖股票的最佳时机 IV** - 状态机DP
+
+### 7. 回溯算法类题目
+
+#### 排列组合
+- **46. 全排列** - 回溯
+- **47. 全排列 II** - 回溯+去重
+- **77. 组合** - 回溯
+- **78. 子集** - 回溯
+
+#### 分割问题
+- **131. 分割回文串** - 回溯+动态规划
+- **93. 复原IP地址** - 回溯
+
+#### 棋盘问题
+- **51. N 皇后** - 回溯
+- **37. 解数独** - 回溯
+
+### 8. 贪心算法类题目
+
+#### 区间问题
+- **435. 无重叠区间** - 贪心
+- **452. 用最少数量的箭引爆气球** - 贪心
+- **55. 跳跃游戏** - 贪心
+- **45. 跳跃游戏 II** - 贪心
+
+### 9. 字符串类题目
+
+#### 字符串匹配
+- **28. 实现 strStr()** - KMP算法
+- **459. 重复的子字符串** - KMP算法
+
+#### 回文串
+- **125. 验证回文串** - 双指针
+- **5. 最长回文子串** - 中心扩展
+- **647. 回文子串** - 中心扩展
+
+### 10. 位运算类题目
+
+- **136. 只出现一次的数字** - 异或运算
+- **191. 位1的个数** - 位运算
+- **338. 比特位计数** - 位运算+DP
+- **461. 汉明距离** - 位运算
+
+
+
+### 🎯 刷题建议
+1. **由易到难**:从Easy开始,逐步提升到Hard
+2. **分类练习**:按数据结构和算法类型分类刷题
+3. **反复练习**:重要题目要反复练习,形成肌肉记忆
+4. **总结模板**:每种题型都要总结解题模板
+5. **时间管理**:每天固定时间刷题,保持连续性
+6. **记录总结**:建立错题本,定期回顾总结
+
+---
+
+## 🛠️ 学习工具推荐
+
+### 📚 在线平台
+- [LeetCode中国](https://leetcode-cn.com/) - 主要刷题平台
+- [牛客网](https://www.nowcoder.com/) - 面试真题
+- [AcWing](https://www.acwing.com/) - 算法竞赛
+- [洛谷](https://www.luogu.com.cn/) - 算法学习
+
+### 🎥 学习资源
+- [算法可视化](https://visualgo.net/) - 算法动画演示
+- [VisuAlgo](https://visualgo.net/) - 数据结构可视化
+- [算法导论](https://mitpress.mit.edu/books/introduction-algorithms) - 经典教材
+- [算法4](https://algs4.cs.princeton.edu/) - Java实现
+
+### 💻 开发工具
+- **IDE**:IntelliJ IDEA、Eclipse
+- **调试**:LeetCode插件、本地调试
+- **版本控制**:Git管理代码
+- **笔记**:Markdown记录学习心得
+
+---
+
+> 💡 **记住**:数据结构和算法是程序员的内功,需要持续练习和积累
+>
+> 🚀 **建议**:每天至少刷一道LeetCode,坚持100天必有收获
+>
+> 📚 **资源**:[LeetCode中国](https://leetcode-cn.com/) | [算法可视化](https://visualgo.net/)
+>
+> 🎉 **加油**:相信通过系统性的学习,你一定能够掌握数据结构和算法的精髓!
\ No newline at end of file
diff --git a/docs/data-structure-algorithms/Recursion.md b/docs/data-structure-algorithms/Recursion.md
deleted file mode 100644
index b139d44ce7..0000000000
--- a/docs/data-structure-algorithms/Recursion.md
+++ /dev/null
@@ -1,646 +0,0 @@
-
-
-
-
-文章目录:
-
-1. 什么是递归
-2.
-
-
-
-**什么是递归**
-
-递归的基本思想是某个函数直接或者间接地调用自身,这样就把原问题的求解转换为许多性质相同但是规模更小的子问题。我们只需要关注如何把原问题划分成符合条件的子问题,而不需要去研究这个子问题是如何被解决的。递归和枚举的区别在于:枚举是横向地把问题划分,然后依次求解子问题,而递归是把问题逐级分解,是纵向的拆分。
-
-**简单地说,就是如果在函数中存在着调用函数本身的情况,这种现象就叫递归。**
-
-你以前肯定写过递归,只是不知道这就是递归罢了。
-
-
-
-以阶乘函数为例,如下, 在 factorial 函数中存在着 factorial(n - 1) 的调用,所以此函数是递归函数
-
-```
-public int factorial(int n) {
- if (n < =1) {
- return1;
- }
- return n * factorial(n - 1)
-}
-int fibonacci(int n) {
- // Base case
- if (n == 0 || n == 1) return n;
-
- // Recursive step
- return fibonacci(n-1) + fibonacci(n-2);
-}
-```
-
-进一步剖析「递归」,先有「递」再有「归」,「递」的意思是将问题拆解成子问题来解决, 子问题再拆解成子子问题,...,直到被拆解的子问题无需再拆分成更细的子问题(即可以求解),「归」是说最小的子问题解决了,那么它的上一层子问题也就解决了,上一层的子问题解决了,上上层子问题自然也就解决了,....,直到最开始的问题解决,文字说可能有点抽象,那我们就以阶层 f(6) 为例来看下它的「递」和「归」。
-
-
-
-求解问题 f(6), 由于 f(6) = n * f(5), 所以 f(6) 需要拆解成 f(5) 子问题进行求解,同理 f(5) = n * f(4) ,也需要进一步拆分,... ,直到 f(1), 这是「递」,f(1) 解决了,由于 f(2) = 2 f(1) = 2 也解决了,.... f(n)到最后也解决了,这是「归」,所以递归的本质是能把问题拆分成具有**相同解决思路**的子问题,。。。直到最后被拆解的子问题再也不能拆分,解决了最小粒度可求解的子问题后,在「归」的过程中自然顺其自然地解决了最开始的问题。
-
-
-
- 递归原理
-
-------
-
-> 递归是一种解决问题的有效方法,在递归过程中,函数将自身作为子例程调用
-
-你可能想知道如何实现调用自身的函数。诀窍在于,每当递归函数调用自身时,它都会将给定的问题拆解为子问题。递归调用继续进行,直到到子问题无需进一步递归就可以解决的地步。
-
-为了确保递归函数不会导致无限循环,它应具有以下属性:
-
-1. 一个简单的`基本案例(basic case)`(或一些案例) —— 能够不使用递归来产生答案的终止方案。
-2. 一组规则,也称作`递推关系(recurrence relation)`,可将所有其他情况拆分到基本案例。
-
-注意,函数可能会有多个位置进行自我调用。
-
-
-
-递归的基本思想是某个函数直接或者间接地调用自身,这样就把原问题的求解转换为许多性质相同但是规模更小的子问题。我们只需要关注如何把原问题划分成符合条件的子问题,而不需要去研究这个子问题是如何被解决的。递归和枚举的区别在于:枚举是横向地把问题划分,然后依次求解子问题,而递归是把问题逐级分解,是纵向的拆分。
-
-递归代码最重要的两个特征:结束条件和自我调用。自我调用是在解决子问题,而结束条件定义了最简子问题的答案。
-
-```
-int func(你今年几岁) {
- // 最简子问题,结束条件
- if (你1999年几岁) return 我0岁;
- // 自我调用,缩小规模
- return func(你去年几岁) + 1;
-}
-```
-
-
-
-
-
-### 反转字符串(344)
-
-> 编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 `char[]` 的形式给出。
->
-> 不要给另外的数组分配额外的空间,你必须**原地修改输入数组**、使用 O(1) 的额外空间解决这一问题。
->
-> 你可以假设数组中的所有字符都是 ASCII 码表中的可打印字符。
->
-> **示例 1:**
->
-> ```
-> 输入:["h","e","l","l","o"]
-> 输出:["o","l","l","e","h"]
-> ```
-
-
-
-
-
-
-
-
-
-# 递归
-
-递归实在计算机科学、数学等领域运用非常广泛的一种方法。使用递归的方法解决问题,一般具有这样的特征:我们在寻找一个复杂问题的解时,不能立即给出答案,然后从一个规模较小的相同问题的答案开始,却可以较为容易的求解复杂的问题。
-
-我们主要介绍两种基于递归的算法设计技术,即基于归纳的递归和分治法。
-
-
-
-## 概念
-
-递归(recursion)是指在定义自身的同时又出现了对自身的引用。如果一个算法直接或间接的调用自己,则称这个算法是一个递归算法。
-
-递归算法的实质是把问题分解成规模缩小的同类问题的子问题,然后递归调用方法来表示问题的解。
-
-任何一个有意义的递归算法总是两部分组成:**递归调用**和**递归终止条件**。
-
-
-
-## 如何理解递归
-
-递归是一种应用非常广泛的算法或者编程技巧。很多数据结构和算法的编码实现都要用到递归,比如DFS深度优先搜索、前中后序二叉树遍历等等。所以搞懂递归对学习一些复杂的数据结构和算法是非常有必要的。
-
-案例:*周末带着女朋友去电影院看电影,女朋友问,咱们现在坐在第几排啊?电影院里面太黑了,看不清,没法数,现在怎么办?*
-
-于是你就问前面一排的人他是第几排,你想只要在他的数字上加一,就知道自己在哪一排了。但是,前面的人也看不清啊,所以他也问他前面的人。就这样一排一排往前问,直到问到第一排的人,说我在第一排,然后再这样一排一排再把数字传回来。直到你前面的人告诉你他在哪一排,于是你就知道答案了。
-
-这就是一个非常标准的递归求解问题的分解过程,去的过程叫“递”,回来的过程叫“归”。
-
-基本上,所有的递归问题都可以用递推公式来表示。比如上面的案例我们用递推公式将它表示出来就是这样:
-
-```
-f(n) = f(n-1) + 1 //其中 f(1) = 1
-```
-
-f(n) 表示想知道自己在哪一排,f(n-1) 表示前面一排所在的排数,f(1) = 1表示第一排的人知道自己在第一排。有了这个递推公式,我们就可以很轻松地将它改为递归代码:
-
-```
-int f(int n) {
- if (n == 1) return 1;
- return f(n - 1) + 1;
-}
-```
-
-
-
-## 递归需要满足的三个条件
-
-只要同时满足以下三个条件,就可以用递归来解决。
-
-1. **一个问题的解可以分解为几个子问题的解**
-
- 何为子问题?子问题就是数据规模更小的问题。比如前面的案例,要知道“自己在哪一排”,可以分解为“前一排的人在哪一排”这样的一个子问题。
-
-2. **这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样**
-
- 如案例所示,求解“自己在哪一排”的思路,和前面一排人求解“自己在哪一排”的思路是一模一样的。
-
-3. **存在递归终止条件**
-
- 把问题分解为子问题,把子问题再分解为子子问题,一层一层分解下去,不能存在无限循环,这就需要有终止条件。前面的案例:第一排的人知道自己在哪一排,不需要再问别人,f(1) = 1就是递归的终止条件。
-
-
-
-## 怎样编写递归代码
-
-写递归代码,可以按三步走:
-
-**第一要素:明确你这个函数想要干什么**
-
-对于递归,我觉得很重要的一个事就是,**这个函数的功能是什么**,他要完成什么样的一件事,而这个,是完全由你自己来定义的。也就是说,我们先不管函数里面的代码什么,而是要先明白,你这个函数是要用来干什么。
-
-例如,我定义了一个函数
-
-```
-// 算 n 的阶乘(假设n不为0)
-int f(int n){
-
-}
-```
-
-这个函数的功能是算 n 的阶乘。好了,我们已经定义了一个函数,并且定义了它的功能是什么,接下来我们看第二要素。
-
-**第二要素:寻找递归结束条件**
-
-所谓递归,就是会在函数内部代码中,调用这个函数本身,所以,我们必须要找出**递归的结束条件**,不然的话,会一直调用自己,进入无底洞。也就是说,我们需要找出**当参数为啥时,递归结束,之后直接把结果返回**,请注意,这个时候我们必须能根据这个参数的值,能够**直接**知道函数的结果是什么。
-
-例如,上面那个例子,当 n = 1 时,那你应该能够直接知道 f(n) 是啥吧?此时,f(1) = 1。完善我们函数内部的代码,把第二要素加进代码里面,如下
-
-```
-// 算 n 的阶乘(假设n不为0)
-int f(int n){
- if(n == 1){
- return 1;
- }
-}
-```
-
-有人可能会说,当 n = 2 时,那我们可以直接知道 f(n) 等于多少啊,那我可以把 n = 2 作为递归的结束条件吗?
-
-当然可以,只要你觉得参数是什么时,你能够直接知道函数的结果,那么你就可以把这个参数作为结束的条件,所以下面这段代码也是可以的。
-
-```
-// 算 n 的阶乘(假设n>=2)
-int f(int n){
- if(n == 2){
- return 2;
- }
-}
-```
-
-注意我代码里面写的注释,假设 n >= 2,因为如果 n = 1时,会被漏掉,当 n <= 2时,f(n) = n,所以为了更加严谨,我们可以写成这样:
-
-```
-// 算 n 的阶乘(假设n不为0)
-int f(int n){
- if(n <= 2){
- return n;
- }
-}
-```
-
-**第三要素:找出函数的等价关系式**
-
-第三要素就是,我们要**不断缩小参数的范围**,缩小之后,我们可以通过一些辅助的变量或者操作,使原函数的结果不变。
-
-例如,f(n) 这个范围比较大,我们可以让 f(n) = n * f(n-1)。这样,范围就由 n 变成了 n-1 了,范围变小了,并且为了原函数f(n) 不变,我们需要让 f(n-1) 乘以 n。
-
-说白了,就是要找到原函数的一个等价关系式,f(n) 的等价关系式为 n * f(n-1),即
-
-f(n) = n * f(n-1)。
-
-
-
-写递归代码最关键的是**写出递推公式,找到终止条件**,剩下就是将递推公式转化为代码。
-
-案例:*假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?如果有 7 个台阶,你可以 2,2,2,1 这样子上去,也可以 1,2,1,1,2 这样子上去,总之走法有很多,那如何用编程求得总共有多少种走法呢?*
-
-我们可以根据第一步的走法把所有走法分为两类,第一类是第一步走了1个台阶,另一类是第一步走了2个台阶。所以n个台阶的走法就等于先走1阶后,n-1个台阶的走法 加上先走2阶后,n-2个台阶的走法,用公式表示:
-
-```
-f(n) = f(n-1) + f(n-2)
-```
-
-再来看下终止条件。当有一个台阶时,我们不需要再继续递归,就只有一种走法。所以f(1) = 1。这个递归终止条件足够吗?我们试试用n = 2, n = 3这样比较小的数实验一下。
-
-n = 2时,f(2) = f(1) + f(0)。如果递归终止条件只有一个f(1) = 1,那f(2)就无法求解了。所以除了f(1) = 1这一个递归终止条件外,还要有f(0) = 1,表示走0个台阶有一种走法,不过这样看起来不符合正常的逻辑思维。所以,我们可以把f(2) = 2作为一种终止条件,表示走2个台阶,只有两种走法,一步走完或者分两步走。
-
-所以,递归终止条件就是f(1) = 1,f(2) = 2。这个时候,可以再拿n = 3,n = 4来验证下,这个终止条件是否足够并且正确。
-
-我们把递归终止条件和刚刚得到的递推公式放在一起就是这样:
-
-```
-f(1) = 1;
-f(2) = 2;
-f(n) = f(n - 1) + f(n - 2);
-```
-
-最终的递归代码就是这样:
-
-```
-int f(int n) {
- if (n == 1) return 1;
- if (n == 2) return 2;
- return f(n -1) + f(n - 2);
-}
-```
-
-**写递归代码的关键就是找到如何将大问题分解为小问题的规律,请求基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码**。
-
-> 当我们面对一个问题需要分解为多个子问题的时候,递归代码往往没那么好理解,比如第二个案例,人脑几乎没办法把整个“递”和“归”的过程一步一步都想清楚。
->
-> 计算机擅长做重复的事情,所以递归正符合它的胃口。而我们人脑更喜欢平铺直叙的思维方式。当我们看到递归时,我们总想把递归平铺展开,脑子里就会循环,一层一层往下调,然后再一层一层返回,试图想搞清楚计算机每一步都是怎么执行的,这样就很容易被绕进去。
->
-> 对于递归代码,这种试图想清楚整个递和归过程的做法,实际上是进入了一个思维误区。很多时候,我们理解起来比较吃力,主要原因就是自己给自己制造了这种理解障碍。那正确的思维方式应该是怎样的呢?
->
-> 如果一个问题 A 可以分解为若干子问题 B、C、D,可以假设子问题 B、C、D 已经解决,在此基础上思考如何解决问题 A。而且,只需要思考问题 A 与子问题 B、C、D 两层之间的关系即可,不需要一层一层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节,这样子理解起来就简单多了。
-
-换句话说就是:千万不要跳进这个函数里面企图探究更多细节,否则就会陷入无穷的细节无法自拔,人脑能压几个栈啊。
-
-所以,编写递归代码的关键是:**只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤**。
-
-
-
-## 递归代码要警惕堆栈溢出
-
-在实际开发中,编写递归代码我们通常会遇到很多问题,比如堆栈溢出。而堆栈溢出会造成系统性崩溃,后果非常严重。为什么递归代码容易造成堆栈溢出呢?
-
-我们知道在函数调用时,会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险,出现`java.lang.StackOverflowError`。
-
-如何避免出现堆栈溢出?
-
-可以通过在代码中限制递归调用的最大深度的方式来解决这个问题。递归调用超过一定深度(比如1000)之后,我们就不再继续往下递归了,直接返回报错。比如前面电影院的案例,改造后的伪代码如下:
-
-```c
-// 全局变量,表示递归的深度。
-int depth = 0;
-
-int f(int n) {
- ++depth;
- if (depth > 1000) throw exception;
-
- if (n == 1) return 1;
- return f(n-1) + 1;
-}
-```
-
-但这种做法并不能完全解决问题,因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事先无法计算。如果实时计算,代码又会过于复杂,影响到代码的可读性。所以如果最大深度比较小,比如10、50,还可以用这种方法,否则这种方法不是很实用。
-
-
-
-## 递归代码要警惕重复计算
-
-使用递归时要注意重复计算的问题,比如案例二,我们把整个递归过程分解一下,那就是这样的:
-
-
-
-从图中,我们可以看到,想要计算f(5),需要先计算f(4)和f(3),而计算f(4)还需要计算f(3),因此,f(3)就被计算了很多次,这就是重复计算的问题。
-
-为了解决重复计算,我们可以通过散列表等数据结构来保存已经求解过的f(k)。当递归调用到f(k)时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,就不再重复计算了。
-
-如上思路,改造下刚才的代码:
-
-```
-public int f(int n) {
- if (n == 1) return 1;
- if (n == 2) return 2;
-
- // hasSolvedList 可以理解成一个 Map,key 是 n,value 是 f(n)
- if (hasSolvedList.containsKey(n)) {
- return hasSovledList.get(n);
- }
-
- int ret = f(n-1) + f(n-2);
- hasSovledList.put(n, ret);
- return ret;
-}
-```
-
-除了堆栈溢出、重复计算这两个常见的问题,递归代码还有很多别的问题。
-
-在时间效率上,递归代码里多了很多函数调用,当这些函数调用的数量较大时,就会积累成一个可观的时间成本。在空间复杂度上,因为递归调用一次就会在内存栈中保存一次现场数据,所以在分析递归代码空间复杂度时,需要额外考虑这部分的开销,比如前面的案例一的递归代码,空间复杂度并不是O(1),而是O(n)。
-
-
-
-## 案例
-
-### 案例1:斐波那契数列
-
-> 斐波那契数列的是这样一个数列:1、1、2、3、5、8、13、21、34....,即第一项 f(1) = 1,第二项 f(2) = 1.....,第 n 项目为 f(n) = f(n-1) + f(n-2)。求第 n 项的值是多少。
-
-**1、第一递归函数功能**
-
-假设 f(n) 的功能是求第 n 项的值,代码如下:
-
-```
-int f(int n){
-
-}
-```
-
-**2、找出递归结束的条件**
-
-显然,当 n = 1 或者 n = 2 ,我们可以轻易着知道结果 f(1) = f(2) = 1。所以递归结束条件可以为 n <= 2。代码如下:
-
-```
-int f(int n){
- if(n <= 2){
- return 1;
- }
-}
-```
-
-**第三要素:找出函数的等价关系式**
-
-题目已经把等价关系式给我们了,所以我们很容易就能够知道 f(n) = f(n-1) + f(n-2)。我说过,等价关系式是最难找的一个,而这个题目却把关系式给我们了,这也太容易,好吧,我这是为了兼顾几乎零基础的读者。
-
-所以最终代码如下:
-
-```
-int f(int n){
- // 1.先写递归结束条件
- if(n <= 2){
- return 1;
- }
- // 2.接着写等价关系式
- return f(n-1) + f(n - 2);
-}
-```
-
-搞定,是不是很简单?
-
-
-
-### 案例2:小青蛙跳台阶
-
-> 一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
-
-**1、第一递归函数功能**
-
-假设 f(n) 的功能是求青蛙跳上一个n级的台阶总共有多少种跳法,代码如下:
-
-```
-int f(int n){
-
-}
-```
-
-**2、找出递归结束的条件**
-
-我说了,求递归结束的条件,你直接把 n 压缩到很小很小就行了,因为 n 越小,我们就越容易直观着算出 f(n) 的多少,所以当 n = 1时,你知道 f(1) 为多少吧?够直观吧?即 f(1) = 1。代码如下:
-
-```
-int f(int n){
- if(n == 1){
- return 1;
- }
-}
-```
-
-**第三要素:找出函数的等价关系式**
-
-每次跳的时候,小青蛙可以跳一个台阶,也可以跳两个台阶,也就是说,每次跳的时候,小青蛙有两种跳法。
-
-第一种跳法:第一次我跳了一个台阶,那么还剩下n-1个台阶还没跳,剩下的n-1个台阶的跳法有f(n-1)种。
-
-第二种跳法:第一次跳了两个台阶,那么还剩下n-2个台阶还没,剩下的n-2个台阶的跳法有f(n-2)种。
-
-所以,小青蛙的全部跳法就是这两种跳法之和了,即 f(n) = f(n-1) + f(n-2)。至此,等价关系式就求出来了。于是写出代码:
-
-```
-int f(int n){
- if(n == 1){
- return 1;
- }
- ruturn f(n-1) + f(n-2);
-}
-```
-
-大家觉得上面的代码对不对?
-
-答是不大对,当 n = 2 时,显然会有 f(2) = f(1) + f(0)。我们知道,f(0) = 0,按道理是递归结束,不用继续往下调用的,但我们上面的代码逻辑中,会继续调用 f(0) = f(-1) + f(-2)。这会导致无限调用,进入**死循环**。
-
-这也是我要和你们说的,关于**递归结束条件是否够严谨问题**,有很多人在使用递归的时候,由于结束条件不够严谨,导致出现死循环。也就是说,当我们在第二步找出了一个递归结束条件的时候,可以把结束条件写进代码,然后进行第三步,但是**请注意**,当我们第三步找出等价函数之后,还得再返回去第二步,根据第三步函数的调用关系,会不会出现一些漏掉的结束条件。就像上面,f(n-2)这个函数的调用,有可能出现 f(0) 的情况,导致死循环,所以我们把它补上。代码如下:
-
-```
-int f(int n){
- //f(0) = 0,f(1) = 1,等价于 n<=1时,f(n) = n。
- if(n <= 1){
- return n;
- }
- ruturn f(n-1) + f(n-2);
-}
-```
-
-有人可能会说,我不知道我的结束条件有没有漏掉怎么办?别怕,多练几道就知道怎么办了。
-
-看到这里有人可能要吐槽了,这两道题也太容易了吧??能不能被这么敷衍。少侠,别走啊,下面出道难一点的。
-
-### 案例3:反转单链表。
-
-> 反转单链表。例如链表为:1->2->3->4。反转后为 4->3->2->1
-
-链表的节点定义如下:
-
-```
-class Node{
- int date;
- Node next;
-}
-```
-
-虽然是 Java语言,但就算你没学过 Java,我觉得也是影响不大,能看懂。
-
-还是老套路,三要素一步一步来。
-
-**1、定义递归函数功能**
-
-假设函数 reverseList(head) 的功能是反转但链表,其中 head 表示链表的头节点。代码如下:
-
-```
-Node reverseList(Node head){
-
-}
-```
-
-**2. 寻找结束条件**
-
-当链表只有一个节点,或者如果是空表的话,你应该知道结果吧?直接啥也不用干,直接把 head 返回呗。代码如下:
-
-```
-Node reverseList(Node head){
- if(head == null || head.next == null){
- return head;
- }
-}
-```
-
-**3. 寻找等价关系**
-
-这个的等价关系不像 n 是个数值那样,比较容易寻找。但是我告诉你,它的等价条件中,一定是范围不断在缩小,对于链表来说,就是链表的节点个数不断在变小,所以,如果你实在找不出,你就先对 reverseList(head.next) 递归走一遍,看看结果是咋样的。例如链表节点如下
-
-
-
-我们就缩小范围,先对 2->3->4递归下试试,即代码如下
-
-```
-Node reverseList(Node head){
- if(head == null || head.next == null){
- return head;
- }
- // 我们先把递归的结果保存起来,先不返回,因为我们还不清楚这样递归是对还是错。,
- Node newList = reverseList(head.next);
-}
-```
-
-我们在第一步的时候,就已经定义了 reverseLis t函数的功能可以把一个单链表反转,所以,我们对 2->3->4反转之后的结果应该是这样:
-
-
-
-我们把 2->3->4 递归成 4->3->2。不过,1 这个节点我们并没有去碰它,所以 1 的 next 节点仍然是连接这 2。
-
-接下来呢?该怎么办?
-
-其实,接下来就简单了,我们接下来只需要**把节点 2 的 next 指向 1,然后把 1 的 next 指向 null,不就行了?**,即通过改变 newList 链表之后的结果如下:
-
-
-
-也就是说,reverseList(head) 等价于 ** reverseList(head.next)** + **改变一下1,2两个节点的指向**。好了,等价关系找出来了,代码如下(有详细的解释):
-
-```
-//用递归的方法反转链表
-public static Node reverseList2(Node head){
- // 1.递归结束条件
- if (head == null || head.next == null) {
- return head;
- }
- // 递归反转 子链表
- Node newList = reverseList2(head.next);
- // 改变 1,2节点的指向。
- // 通过 head.next获取节点2
- Node t1 = head.next;
- // 让 2 的 next 指向 2
- t1.next = head;
- // 1 的 next 指向 null.
- head.next = null;
- // 把调整之后的链表返回。
- return newList;
- }
-```
-
-这道题的第三步看的很懵?正常,因为你做的太少了,可能没有想到还可以这样,多练几道就可以了。但是,我希望通过这三道题,给了你以后用递归做题时的一些思路,你以后做题可以按照我这个模式去想。通过一篇文章是不可能掌握递归的,还得多练,我相信,只要你认真看我的这篇文章,多看几次,一定能找到一些思路!!
-
-> 我已经强调了好多次,多练几道了,所以呢,后面我也会找大概 10 道递归的练习题供大家学习,不过,我找的可能会有一定的难度。不会像今天这样,比较简单,所以呢,初学者还得自己多去找题练练,相信我,掌握了递归,你的思维抽象能力会更强!
-
-接下来我讲讲有关递归的一些优化。
-
-### 有关递归的一些优化思路
-
-**1. 考虑是否重复计算**
-
-告诉你吧,如果你使用递归的时候不进行优化,是有非常非常非常多的**子问题**被重复计算的。
-
-> 啥是子问题? f(n-1),f(n-2)....就是 f(n) 的子问题了。
-
-例如对于案例2那道题,f(n) = f(n-1) + f(n-2)。递归调用的状态图如下:
-
-
-
-看到没有,递归计算的时候,重复计算了两次 f(5),五次 f(4)。。。。这是非常恐怖的,n 越大,重复计算的就越多,所以我们必须进行优化。
-
-如何优化?一般我们可以把我们计算的结果保证起来,例如把 f(4) 的计算结果保证起来,当再次要计算 f(4) 的时候,我们先判断一下,之前是否计算过,如果计算过,直接把 f(4) 的结果取出来就可以了,没有计算过的话,再递归计算。
-
-用什么保存呢?可以用数组或者 HashMap 保存,我们用数组来保存把,把 n 作为我们的数组下标,f(n) 作为值,例如 arr[n] = f(n)。f(n) 还没有计算过的时候,我们让 arr[n] 等于一个特殊值,例如 arr[n] = -1。
-
-当我们要判断的时候,如果 arr[n] = -1,则证明 f(n) 没有计算过,否则, f(n) 就已经计算过了,且 f(n) = arr[n]。直接把值取出来就行了。代码如下:
-
-```
-// 我们实现假定 arr 数组已经初始化好的了。
-int f(int n){
- if(n <= 1){
- return n;
- }
- //先判断有没计算过
- if(arr[n] != -1){
- //计算过,直接返回
- return arr[n];
- }else{
- // 没有计算过,递归计算,并且把结果保存到 arr数组里
- arr[n] = f(n-1) + f(n-1);
- reutrn arr[n];
- }
-}
-```
-
-也就是说,使用递归的时候,必要
-须要考虑有没有重复计算,如果重复计算了,一定要把计算过的状态保存起来。
-
-**2. 考虑是否可以自底向上**
-
-对于递归的问题,我们一般都是**从上往下递归**的,直到递归到最底,再一层一层着把值返回。
-
-不过,有时候当 n 比较大的时候,例如当 n = 10000 时,那么必须要往下递归10000层直到 n <=1 才将结果慢慢返回,如果n太大的话,可能栈空间会不够用。
-
-对于这种情况,其实我们是可以考虑自底向上的做法的。例如我知道
-
-f(1) = 1;
-
-f(2) = 2;
-
-那么我们就可以推出 f(3) = f(2) + f(1) = 3。从而可以推出f(4),f(5)等直到f(n)。因此,我们可以考虑使用自底向上的方法来取代递归,代码如下:
-
-```
-public int f(int n) {
- if(n <= 2)
- return n;
- int f1 = 1;
- int f2 = 2;
- int sum = 0;
-
- for (int i = 3; i <= n; i++) {
- sum = f1 + f2;
- f1 = f2;
- f2 = sum;
- }
- return sum;
- }
-```
-
-这种方法,其实也被称之为**递推**。
-
-
-
-
-
-
-
-来源:
-
-https://aleej.com/2019/10/09/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95%E4%B9%8B%E7%BE%8E%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/
-
-https://www.cnblogs.com/kubidemanong/p/10538799.html
-
diff --git a/docs/data-structure-algorithms/Sort.md b/docs/data-structure-algorithms/Sort.md
deleted file mode 100644
index 035398fe8b..0000000000
--- a/docs/data-structure-algorithms/Sort.md
+++ /dev/null
@@ -1,443 +0,0 @@
-# 排序
-
-排序算法可以分为内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。常见的内部排序算法有:**插入排序、希尔排序、选择排序、冒泡排序、归并排序、快速排序、堆排序、基数排序**等。用一张图概括:
-
-
-
-**关于时间复杂度**:
-
-1. 平方阶 ($O(n2)$) 排序 各类简单排序:直接插入、直接选择和冒泡排序。
-2. 线性对数阶 (O(nlog2n)) 排序: 快速排序、堆排序和归并排序;
-3. O(n1+§) 排序,§ 是介于 0 和 1 之间的常数。 希尔排序
-4. 线性阶 (O(n)) 排序: 基数排序,此外还有桶、箱排序。
-
-**关于稳定性**:
-
-稳定的排序算法:冒泡排序、插入排序、归并排序和基数排序。
-
-不是稳定的排序算法:选择排序、快速排序、希尔排序、堆排序。
-
-**名词解释**:
-
-**n**:数据规模
-
-**k**:“桶”的个数
-
-**In-place**:占用常数内存,不占用额外内存
-
-**Out-place**:占用额外内存
-
-**稳定性**:排序后 2 个相等键值的顺序和排序之前它们的顺序相同
-
-
-
-十种常见排序算法可以分为两大类:
-
-**非线性时间比较类排序**:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破$O(nlogn)$,因此称为非线性时间比较类排序。
-
-**线性时间非比较类排序**:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序。
-
-
-
-## 冒泡排序
-
-冒泡排序(Bubble Sort)也是一种简单直观的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
-
-作为最简单的排序算法之一,冒泡排序给我的感觉就像 Abandon 在单词书里出现的感觉一样,每次都在第一页第一位,所以最熟悉。冒泡排序还有一种优化算法,就是立一个 flag,当在一趟序列遍历中元素没有发生交换,则证明该序列已经有序。但这种改进对于提升性能来说并没有什么太大作用。
-
-### 1. 算法步骤
-
-1. 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
-2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
-3. 针对所有的元素重复以上的步骤,除了最后一个。
-4. 重复步骤1~3,直到排序完成。
-
-### 2. 动图演示
-
-
-
-
-
-### 3. 什么时候最快
-
-当输入的数据已经是正序时(都已经是正序了,我还要你冒泡排序有何用啊)。
-
-### 4. 什么时候最慢
-
-当输入的数据是反序时(写一个 for 循环反序输出数据不就行了,干嘛要用你冒泡排序呢,我是闲的吗)。
-
-```java
-public class BubbleSort {
-
- public static void main(String[] args) {
- int[] arrs = {1, 3, 4, 2, 6, 5};
-
- for (int i = 0; i < arrs.length; i++) {
- for (int j = 0; j < arrs.length - 1 - i; j++) {
- if (arrs[j] > arrs[j + 1]) {
- int tmp = arrs[j];
- arrs[j] = arrs[j + 1];
- arrs[j + 1] = tmp;
- }
- }
- }
-
- for (int arr : arrs) {
- System.out.print(arr + " ");
- }
- }
-}
-```
-
-嵌套循环,应该立马就可以得出这个算法的时间复杂度为 $O(n²)$。
-
-
-
-## 选择排序
-
-选择排序的思路是这样的:首先,找到数组中最小的元素,拎出来,将它和数组的第一个元素交换位置,第二步,在剩下的元素中继续寻找最小的元素,拎出来,和数组的第二个元素交换位置,如此循环,直到整个数组排序完成。
-
-选择排序是一种简单直观的排序算法,无论什么数据进去都是 $O(n²)$ 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。
-
-### 1. 算法步骤
-
-1. 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置
-2. 再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
-3. 重复第二步,直到所有元素均排序完毕。
-
-### 2. 动图演示
-
-
-
-```java
-public class SelectionSort {
-
- public static void main(String[] args) {
- int[] arrs = {5, 2, 4, 6, 1, 3};
-
- for (int i = 0; i < arrs.length; i++) {
- //最小元素下标
- int min = i;
- for (int j = i +1; j < arrs.length; j++) {
- if (arrs[j] < arrs[min]) {
- min = j;
- }
- }
- //交换位置
- int temp = arrs[i];
- arrs[i] = arrs[min];
- arrs[min] = temp;
- }
- for (int arr : arrs) {
- System.out.println(arr);
- }
- }
-}
-```
-
-
-
-## 插入排序
-
-插入排序的代码实现虽然没有冒泡排序和选择排序那么简单粗暴,但它的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
-
-插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入。
-
-### 1. 算法步骤
-
-1. 从第一个元素开始,该元素可以认为已经被排序;
-2. 取出下一个元素,在已经排序的元素序列中从后向前扫描;
-3. 如果该元素(已排序)大于新元素,将该元素移到下一位置;
-4. 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
-5. 将新元素插入到该位置后;
-6. 重复步骤2~5。
-
-### 2. 动图演示
-
-
-
-```java
-public static void main(String[] args) {
- int[] arr = {5, 2, 4, 6, 1, 3};
- // 从下标为1的元素开始选择合适的位置插入,因为下标为0的只有一个元素,默认是有序的
- for (int i = 1; i < arr.length; i++) {
-
- // 记录要插入的数据
- int tmp = arr[i];
-
- // 从已经排序的序列最右边的开始比较,找到比其小的数
- int j = i;
- while (j > 0 && tmp < arr[j - 1]) {
- arr[j] = arr[j - 1];
- j--;
- }
-
- // 存在比其小的数,插入
- if (j != i) {
- arr[j] = tmp;
- }
- }
-
- for (int i : arr) {
- System.out.println(i);
- }
-}
-}
-```
-
-
-
-## 快速排序
-
-这篇很好:https://www.cxyxiaowu.com/5262.html
-
-快速排序的核心思想也是分治法,分而治之。它的实现方式是每次从序列中选出一个基准值,其他数依次和基准值做比较,比基准值大的放右边,比基准值小的放左边,然后再对左边和右边的两组数分别选出一个基准值,进行同样的比较移动,重复步骤,直到最后都变成单个元素,整个数组就成了有序的序列。
-
-> 快速排序的最坏运行情况是 O(n²),比如说顺序数列的快排。但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小,比复杂度稳定等于 O(nlogn) 的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。
-
-### 1. 算法步骤
-
-1. 从数列中挑出一个元素,称为 “基准”(pivot);
-2. 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
-3. 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;
-
-递归的最底部情形,是数列的大小是零或一,也就是永远都已经被排序好了。虽然一直递归下去,但是这个算法总会退出,因为在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。
-
-### 2. 动图演示
-
-
-
-### 单边扫描
-
-快速排序的关键之处在于切分,切分的同时要进行比较和移动,这里介绍一种叫做单边扫描的做法。
-
-我们随意抽取一个数作为基准值,同时设定一个标记 mark 代表左边序列最右侧的下标位置,当然初始为 0 ,接下来遍历数组,如果元素大于基准值,无操作,继续遍历,如果元素小于基准值,则把 mark + 1 ,再将 mark 所在位置的元素和遍历到的元素交换位置,mark 这个位置存储的是比基准值小的数据,当遍历结束后,将基准值与 mark 所在元素交换位置即可。
-
-```java
-public static void sort(int[] arrs, int startIndex, int endIndex) {
- if (startIndex > endIndex) {
- return;
- }
- int pivotIndex = partion(arrs, startIndex, endIndex);
- sort(arrs, startIndex, pivotIndex - 1);
- sort(arrs, pivotIndex + 1, endIndex);
-}
-
-public static int partion(int[] arrs, int startIndex, int endIndex) {
- int pivot = arrs[startIndex];
- int mark = startIndex;
-
- for (int i = startIndex + 1; i < arrs.length; i++) {
- if (arrs[i] < pivot) {
- mark++;
- int tmp = arrs[mark];
- arrs[mark] = arrs[i];
- arrs[i] = tmp;
- }
- }
- arrs[startIndex] = arrs[mark];
- arrs[mark] = pivot;
- return mark;
-}
-```
-
-### 双边扫描
-
-另外还有一种双边扫描的做法,看起来比较直观:我们随意抽取一个数作为基准值,然后从数组左右两边进行扫描,先从左往右找到一个大于基准值的元素,将下标指针记录下来,然后转到从右往左扫描,找到一个小于基准值的元素,交换这两个元素的位置,重复步骤,直到左右两个指针相遇,再将基准值与左侧最右边的元素交换。
-
-我们来看一下实现代码,不同之处只有 partition 方法:
-
-```java
-public static void sort(int[] arr) {
- sort(arr, 0, arr.length - 1);
-}
-
-private static void sort(int[] arr, int startIndex, int endIndex) {
- if (endIndex <= startIndex) {
- return;
- }
- //切分
- int pivotIndex = partition(arr, startIndex, endIndex);
- sort(arr, startIndex, pivotIndex-1);
- sort(arr, pivotIndex+1, endIndex);
-}
-
-
-private static int partition(int[] arr, int startIndex, int endIndex) {
- int left = startIndex;
- int right = endIndex;
- int pivot = arr[startIndex];//取第一个元素为基准值
-
- while (true) {
- //从左往右扫描
- while (arr[left] <= pivot) {
- left++;
- if (left == right) {
- break;
- }
- }
-
- //从右往左扫描
- while (pivot < arr[right]) {
- right--;
- if (left == right) {
- break;
- }
- }
-
- //左右指针相遇
- if (left >= right) {
- break;
- }
-
- //交换左右数据
- int temp = arr[left];
- arr[left] = arr[right];
- arr[right] = temp;
- }
-
- //将基准值插入序列
- int temp = arr[startIndex];
- arr[startIndex] = arr[right];
- arr[right] = temp;
- return right;
-}
-```
-
-
-
-
-
-## 希尔排序
-
-希尔排序这个名字,来源于它的发明者希尔,也称作“缩小增量排序”,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。
-
-希尔排序是基于插入排序的以下两点性质而提出改进方法的:
-
-- 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
-- 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;
-
-希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。
-
-### 1. 算法步骤
-
-1. 选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;
-2. 按增量序列个数 k,对序列进行 k 趟排序;
-3. 每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
-
-### 2. 动图演示
-
-
-
-
-
-## 归并排序
-
-> https://www.cnblogs.com/chengxiao/p/6194356.html
-
-归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
-
-作为一种典型的分而治之思想的算法应用,归并排序的实现由两种方法:
-
-- 自上而下的递归(所有递归的方法都可以用迭代重写,所以就有了第 2 种方法);
-- 自下而上的迭代;
-
-在《数据结构与算法 JavaScript 描述》中,作者给出了自下而上的迭代方法。但是对于递归法,作者却认为:
-
-> However, it is not possible to do so in JavaScript, as the recursion goes too deep for the language to handle.
->
-> 然而,在 JavaScript 中这种方式不太可行,因为这个算法的递归深度对它来讲太深了。
-
-说实话,我不太理解这句话。意思是 JavaScript 编译器内存太小,递归太深容易造成内存溢出吗?还望有大神能够指教。
-
-和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是 O(nlogn) 的时间复杂度。代价是需要额外的内存空间。
-
-### 2. 算法步骤
-
-1. 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
-2. 设定两个指针,最初位置分别为两个已经排序序列的起始位置;
-3. 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
-4. 重复步骤 3 直到某一指针达到序列尾;
-5. 将另一序列剩下的所有元素直接复制到合并序列尾。
-
-### 3. 动图演示
-
-
-
-
-
-
-
-## 堆排序
-
-堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:
-
-1. 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
-2. 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
-
-堆排序的平均时间复杂度为 Ο(nlogn)。
-
-### 1. 算法步骤
-
-1. 将待排序序列构建成一个堆 H[0……n-1],根据(升序降序需求)选择大顶堆或小顶堆;
-2. 把堆首(最大值)和堆尾互换;
-3. 把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;
-4. 重复步骤 2,直到堆的尺寸为 1。
-
-### 2. 动图演示
-
-[](https://github.com/hustcc/JS-Sorting-Algorithm/blob/master/res/heapSort.gif)
-
-
-
-
-
-## 计数排序
-
-计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
-
-### 1. 动图演示
-
-[](https://github.com/hustcc/JS-Sorting-Algorithm/blob/master/res/countingSort.gif)
-
-
-
-
-
-## 桶排序
-
-桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:
-
-1. 在额外空间充足的情况下,尽量增大桶的数量
-2. 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中
-
-同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。
-
-### 1. 什么时候最快
-
-当输入的数据可以均匀的分配到每一个桶中。
-
-### 2. 什么时候最慢
-
-当输入的数据被分配到了同一个桶中。
-
-
-
-## 基数排序
-
-基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
-
-### 1. 基数排序 vs 计数排序 vs 桶排序
-
-基数排序有两种方法:
-
-这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异案例看大家发的:
-
-- 基数排序:根据键值的每位数字来分配桶;
-- 计数排序:每个桶只存储单一键值;
-- 桶排序:每个桶存储一定范围的数值;
-
-### 2. LSD 基数排序动图演示
-
-[](https://github.com/hustcc/JS-Sorting-Algorithm/blob/master/res/radixSort.gif)
-
diff --git a/docs/data-structure-algorithms/Stack.md b/docs/data-structure-algorithms/Stack.md
deleted file mode 100644
index 3b072cb431..0000000000
--- a/docs/data-structure-algorithms/Stack.md
+++ /dev/null
@@ -1,279 +0,0 @@
-# 栈
-
-## 一、概述
-
-### 定义
-
-注意:本文所说的栈是数据结构中的栈,而不是内存模型中栈
-
-栈(stack)是限定仅在表尾一端进行插入或删除操作的**特殊线性表**。又称为堆栈。
-
-对于栈来说, 允许进行插入或删除操作的一端称为栈顶(top),而另一端称为栈底(bottom)。不含元素栈称为空栈,向栈中插入一个新元素称为入栈或压栈, 从栈中删除一个元素称为出栈或退栈。
-
-假设有一个栈S=(a1, a2, …, an),a1先进栈, an最后进栈。称 a1 为栈底元素,an 为栈顶元素。出栈时只允许在栈顶进行,所以 an 先出栈,a1最后出栈。因此又称栈为后进先出(Last In First Out,LIFO)的线性表。
-
-栈(stack),是一种线性存储结构,它有以下几个特点:
-
-- 栈中数据是按照"后进先出(LIFO, Last In First Out)"方式进出栈的。
-- 向栈中添加/删除数据时,只能从栈顶进行操作。
-
-
-
-在上图中,当 ABCD 均已入栈后,出栈时得到的序列为 DCBA,这就是后进先出。
-
-
-
-### 基本操作
-
-栈的基本操作除了进栈 `push()`,出栈 `pop()` 之外,还有判空 `isEmpty()`、取栈顶元素 `peek()` 等操作。
-
-抽象成接口如下:
-
-```java
-public interface MyStack {
-
- /**
- * 返回堆栈的大小
- */
- public int getSize();
-
- /**
- * 判断堆栈是否为空
- */
- public boolean isEmpty();
-
- /**
- * 入栈
- */
- public void push(Object e);
-
- /**
- * 出栈,并删除
- */
- public Object pop();
-
- /**
- * 返回栈顶元素
- */
- public Object peek();
-}
-```
-
-
-
-和线性表类似,栈也有两种存储结构:顺序存储和链式存储。
-
-## 二、栈的顺序存储与实现
-
-顺序栈是使用顺序存储结构实现的栈,即利用一组地址连续的存储单元依次存放栈中的数据元素。由于栈是一种特殊的线性表,因此在线性表的顺序存储结构的基础上,选择线性表的一端作为栈顶即可。那么根据数组操作的特性,选择数组下标大的一端,即线性表顺序存储的表尾来作为栈顶,此时入栈、出栈操作可以 $O(1)$ 时间完成。
-
-由于栈的操作都是在栈顶完成,因此在顺序栈的实现中需要附设一个指针 top 来动态地指示栈顶元素在数组中的位置。通常 top 可以用栈顶元素所在的数组下标来表示,top=-1时表示空栈。
-
-栈在使用过程中所需的最大空间难以估计,所以,一般构造栈的时候不应设定最大容量。一种合理的做法和线性表类似,先为栈分配一个基本容量,然后在实际的使用过程中,当栈的空间不够用时再倍增存储空间。
-
-```java
-public class MyArrayStack implements MyStack {
-
- private final int capacity = 2; //默认容量
- private Object[] arrs; //数据元素数组
- private int top; //栈顶指针
-
- MyArrayStack(){
- top = -1;
- arrs = new Object[capacity];
- }
-
- public int getSize() {
- return top + 1;
- }
-
- public boolean isEmpty() {
- return top < 0;
- }
-
- public void push(Object e) {
- if(getSize() >= arrs.length){
- expandSapce(); //扩容
- }
- arrs[++top]=e;
- }
-
- private void expandSapce() {
- Object[] a = new Object[arrs.length * 2];
- for (int i = 0; i < arrs.length; i++) {
- a[i] = arrs[i];
- }
- arrs = a;
- }
-
- public Object pop() {
- if(getSize()<1){
- throw new RuntimeException("栈为空");
- }
- Object obj = arrs[top];
- arrs[top--] = null;
- return obj;
- }
-
- public Object peek() {
- if(getSize()<1){
- throw new RuntimeException("栈为空");
- }
- return arrs[top];
- }
-}
-```
-
-以上基于数据实现的栈代码并不难理解。由于有 top 指针的存在,所以`size()`、`isEmpty()`方法均可在 $O(1) $ 时间内完成。`push()`、`pop()`和`peek()`方法,除了需要`ensureCapacity()`外,都执行常数基本操作,因此它们的运行时间也是 $O(1)$
-
-
-
-## 三、栈的链式存储与实现
-
-栈的链式存储即采用链表实现栈。当采用单链表存储线性表后,根据单链表的操作特性选择单链表的头部作为栈顶,此时,入栈和出栈等操作可以在 $O(1)$ 时间内完成。
-
-由于栈的操作只在线性表的一端进行,在这里使用带头结点的单链表或不带头结点的单链表都可以。使用带头结点的单链表时,结点的插入和删除都在头结点之后进行;使用不带头结点的单链表时,结点的插入和删除都在链表的首结点上进行。
-
-下面以不带头结点的单链表为例实现栈,如下示意图所示:
-
-
-
-在上图中,top 为栈顶结点的引用,始终指向当前栈顶元素所在的结点。若 top 为null,则表示空栈。入栈操作是在 top 所指结点之前插入新的结点,使新结点的 next 域指向 top,top 前移即可;出栈则直接让 top 后移即可。
-
-```java
-public class MyLinkedStack implements MyStack {
-
- class Node {
- private Object element;
- private Node next;
-
- public Node() {
- this(null, null);
- }
-
- public Node(Object ele, Node next) {
- this.element = ele;
- this.next = next;
- }
-
- public Node getNext() {
- return next;
- }
-
- public void setNext(Node next) {
- this.next = next;
- }
-
- public Object getData() {
- return element;
- }
-
- public void setData(Object obj) {
- element = obj;
- }
- }
-
- private Node top;
- private int size;
-
- public MyLinkedStack() {
- top = null;
- size = 0;
- }
-
- public int getSize() {
- return size;
- }
-
- public boolean isEmpty() {
- return size == 0;
- }
-
- public void push(Object e) {
- Node node = new Node(e, top);
- top = node;
- size++;
- }
-
- public Object pop() {
- if (size < 1) {
- throw new RuntimeException("堆栈为空");
- }
- Object obj = top.getData();
- top = top.getNext();
- size--;
- return obj;
- }
-
- public Object peek() {
- if (size < 1) {
- throw new RuntimeException("堆栈为空");
- }
- return top.getData();
- }
-}
-```
-
-上述 `MyLinkedStack` 类中有两个成员变量,其中 `top` 表示首结点,也就是栈顶元素所在的结点;`size` 指示栈的大小,即栈中数据元素的个数。不难理解,所有的操作均可以在 $O(1)$ 时间内完成。
-
-
-
-## 四、JDK 中的栈实现 Stack
-
-Java 工具包中的 Stack 是继承于 Vector(矢量队列)的,由于 Vector 是通过数组实现的,这就意味着,Stack 也是通过数组实现的,而非链表。当然,我们也可以将 LinkedList 当作栈来使用。
-
-### Stack的继承关系
-
-```java
-java.lang.Object
- java.util.AbstractCollection
- java.util.AbstractList
- java.util.Vector
- java.util.Stack
-
-public class Stack extends Vector {}
-```
-
-
-
-
-
-## 五、栈应用
-
-栈有一个很重要的应用,在程序设计语言里实现了递归。
-
-### 有效的括号
-
->给定一个只包括 `'('`,`')'`,`'{'`,`'}'`,`'['`,`']'` 的字符串,判断字符串是否有效。
->
->有效字符串需满足:
->
->1. 左括号必须用相同类型的右括号闭合。
->2. 左括号必须以正确的顺序闭合。
->
->注意空字符串可被认为是有效字符串。
->
->```
->输入: "{[]}"
->输出: true
->输入: "([)]"
->输出: false
->```
-
-
-
-
-
-
-
->请根据每日 `气温` 列表,重新生成一个列表。对应位置的输出为:要想观测到更高的气温,至少需要等待的天数。如果气温在这之后都不会升高,请在该位置用 `0` 来代替。
->
->例如,给定一个列表 `temperatures = [73, 74, 75, 71, 69, 72, 76, 73]`,你的输出应该是 `[1, 1, 4, 2, 1, 1, 0, 0]`。
->
->**提示:**`气温` 列表长度的范围是 `[1, 30000]`。每个气温的值的均为华氏度,都是在 `[30, 100]` 范围内的整数。
-
-
-
-
-
-> 逆波兰表达式求值
\ No newline at end of file
diff --git a/docs/data-structure-algorithms/algorithm/Backtracking.md b/docs/data-structure-algorithms/algorithm/Backtracking.md
new file mode 100755
index 0000000000..bdce9eaa4f
--- /dev/null
+++ b/docs/data-structure-algorithms/algorithm/Backtracking.md
@@ -0,0 +1,834 @@
+---
+title: 回溯算法
+date: 2023-05-09
+tags:
+ - back tracking
+categories: Algorithm
+---
+
+
+
+> 🔍 **回溯算法**是解决很多算法问题的常见思想,它也是传统的人工智能的方法,其本质是 **在树形问题中寻找解** 。
+>
+> 回溯算法实际上是一个类似枚举的搜索尝试过程,主要是在**搜索尝试**过程中寻找问题的解,当发现已不满足求解条件时,就"**回溯**"返回,尝试别的路径。所以也可以叫做**回溯搜索法**。
+>
+> 💡 回溯是递归的副产品,只要有递归就会有回溯。
+
+# 一、回溯算法
+
+回溯算法是一种**深度优先搜索**(DFS)的算法,它通过递归的方式,逐步建立解空间树,从根节点开始,逐层深入,直到找到一个解或路径不可行时回退到上一个状态(即回溯)。每一步的尝试可能会有多个选择,回溯算法通过剪枝来减少不必要的计算。
+
+> 🌲 **深度优先搜索** 算法(英语:Depth-First-Search,DFS)是一种用于遍历或搜索树或图的算法。这个算法会 **尽可能深** 的搜索树的分支。当结点 `v` 的所在边都己被探寻过,搜索将 **回溯** 到发现结点 `v` 的那条边的起始结点。这一过程一直进行到已发现从源结点可达的所有结点为止。如果还存在未被发现的结点,则选择其中一个作为源结点并重复以上过程,整个进程反复进行直到所有结点都被访问为止。
+
+回溯的基本步骤通常包含以下几个要素:
+
+- 🎯 **选择**:在当前状态下,做出一个选择,进入下一个状态。
+- ⚖️ **约束**:每一步的选择必须满足问题的约束条件。
+- 🎪 **目标**:找到一个解或判断是否无法继续。
+- ↩️ **回溯**:如果当前的选择不符合目标,撤销当前的选择,回到上一状态继续尝试其他可能的选择。
+
+### 🧠 核心思想
+
+**回溯法** 采用试错的思想,它尝试分步的去解决一个问题。
+
+1. 🔍 **穷举所有可能的解**:回溯法通过在每个状态下尝试不同的选择,来遍历解空间树。每个分支代表着做出的一个选择,每次递归都尝试不同的路径,直到找到一个解或回到根节点。
+2. ✂️ **剪枝**:在回溯过程中,我们可能会遇到一些不符合约束条件的选择,这时候可以及时退出当前分支,避免无谓的计算,这被称为"剪枝"。剪枝是提高回溯算法效率的关键,能减少不必要的计算。
+3. 🌲 **深度优先搜索(DFS)**:回溯算法在解空间树中深度优先遍历,尝试选择每个分支。直到走到树的叶子节点或回溯到一个不满足条件的节点。
+
+
+
+### 🏗️ 基本框架
+
+回溯算法的基本框架可以用递归来实现,通常包含以下几个步骤:
+
+1. 🎯 **选择和扩展**:选择一个可行的扩展步骤,扩展当前的解。
+2. ✅ **约束检查**:检查当前扩展后的解是否满足问题的约束条件。
+3. 🔄 **递归调用**:如果当前解满足约束条件,则递归地尝试扩展该解。
+4. ↩️ **回溯**:如果当前解不满足约束条件,或所有扩展步骤都已经尝试,则回溯到上一步,尝试其他可能的扩展步骤。
+
+以下是回溯算法的一般伪代码:
+
+```java
+result = []
+function backtrack(solution, candidates): //入参可以理解为 路径, 选择列表
+ if solution 是一个完整解: //满足结束条件
+ result.add(solution) // 处理当前完整解
+ return
+ for candidate in candidates:
+ if candidate 满足约束条件:
+ solution.add(candidate) // 扩展解
+ backtrack(solution, new_candidates) // 递归调用
+ solution.remove(candidate) // 回溯,撤销选择
+
+```
+
+对应到 java 的一般框架如下:
+
+```java
+public void backtrack(List tempList, int start, int[] nums) {
+ // 1. 终止条件
+ if (tempList.size() == nums.length) {
+ // 找到一个解
+ result.add(new ArrayList<>(tempList));
+ return;
+ }
+
+ for (int i = start; i < nums.length; i++) {
+ // 2. 剪枝:跳过相同的数字,避免重复
+ if (i > start && nums[i] == nums[i - 1]) {
+ continue;
+ }
+
+ // 3. 做出选择
+ tempList.add(nums[i]);
+
+ // 4. 递归
+ backtrack(tempList, i + 1, nums);
+
+ // 5. 撤销选择
+ tempList.remove(tempList.size() - 1);
+ }
+}
+```
+
+**其实就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」**
+
+> 💡 **关键理解**:回溯算法 = 递归 + 选择 + 撤销选择
+
+
+
+### 🎯 常见题型
+
+回溯法,一般可以解决如下几种问题:
+
+- 🎲 **组合问题**:N个数里面按一定规则找出k个数的集合
+
+ - **题目示例**:`LeetCode 39. 组合总和`,`LeetCode 40. 组合总和 II`,`LeetCode 77. 组合`
+ - **解题思路**: 组合问题要求我们在给定的数组中选取若干个数字,组合成目标值或某种形式的子集。回溯算法的基本思路是从一个起点开始,选择当前数字或者跳过当前数字,直到找到一个合法的组合。组合问题通常有**去重**的要求,避免重复的组合。
+
+- 🔄 **排列问题**:N个数按一定规则全排列,有几种排列方式
+
+ - **题目示例**:`LeetCode 46. 全排列`,`LeetCode 47. 全排列 II`,`LeetCode 31. 下一个排列`
+ - **解题思路**: 排列问题要求我们通过给定的数字生成所有可能的排列。回溯算法通过递归生成所有排列,通过交换位置来改变元素的顺序。对于全排列 II 这类题目,必须处理重复元素的问题,确保生成的排列不重复。
+
+- 📦 **子集问题**:一个N个数的集合里有多少符合条件的子集
+
+ - **题目示例**:`LeetCode 78. 子集`,`LeetCode 90. 子集 II`
+ - **解题思路**: 子集问题要求我们生成数组的所有子集,回溯算法通过递归生成每个可能的子集。在生成子集时,每个元素有两种选择——要么包含它,要么不包含它。因此,回溯法通过逐步选择来生成所有的子集。
+
+- ✂️ **切割问题**:一个字符串按一定规则有几种切割方式
+
+ - 题目实例:` LeetCode 416. Partition Equal Subset Sum`,`LeetCode 698. Partition to K Equal Sum Subsets`
+ - 解题思路:回溯法适用于切割问题中的"探索所有可能的分割方式"的场景。特别是在无法直接通过动态规划推导出最优解时,回溯法通过递归尝试所有可能的分割方式,并通过剪枝减少不必要的计算
+
+- ♟️ **棋盘问题**:
+
+ - **题目示例**:`LeetCode 37. 解数独`,`LeetCode 51. N 皇后`,`LeetCode 52. N 皇后 II`
+
+ **解题思路**: 棋盘问题常常涉及到在二维数组中进行回溯搜索,比如在数独中填入数字,或者在 N 皇后问题中放置皇后。回溯法在这里用于逐步尝试每个位置,满足棋盘的约束条件,直到找到一个解或者回溯到一个合法的状态。
+
+- 🗺️ **图的遍历问题**:
+
+ - **题目示例**:`LeetCode 79. 单词搜索`,`LeetCode 130. 被围绕的区域`
+ - **解题思路**: 回溯算法在图遍历中的应用主要是通过递归搜索路径。常见的问题是从某个起点出发,寻找是否存在某个目标路径。通过回溯算法,可以逐步尝试每一个可能的路径,直到找到符合条件的解。
+
+
+
+
+### ⚡ 回溯算法的优化技巧
+
+1. ✂️ **剪枝**:在递归过程中,如果当前路径不符合问题约束,就提前返回,避免继续深入。例如在排列问题中,遇到重复的数字时可以跳过该分支。
+2. 📊 **排序**:对输入数据进行排序,有助于我们在递归时判断是否可以剪枝,尤其是在去重的场景下。
+3. 🗜️ **状态压缩**:在一些问题中,使用位运算或其他方式对状态进行压缩,可以显著减少存储空间和计算时间。例如在解决旅行商问题时,常常使用状态压缩来存储已经访问的节点。
+4. 🛑 **提前终止**:如果在递归过程中发现某条路径不可能达到目标(例如目标已经超过了剩余可用值),可以直接结束该分支,节省时间。
+
+
+
+# 二、热门面试题
+
+## 🎲 排列、组合类
+
+> 无论是排列、组合还是子集问题,简单说无非就是让你从序列 `nums` 中以给定规则取若干元素,主要有以下几种变体:
+>
+> **元素无重不可复选,即 `nums` 中的元素都是唯一的,每个元素最多只能被使用一次,这也是最基本的形式**。
+>
+> - 以组合为例,如果输入 `nums = [2,3,6,7]`,和为 7 的组合应该只有 `[7]`。
+>
+> **元素可重不可复选,即 `nums` 中的元素可以存在重复,每个元素最多只能被使用一次**。
+>
+> - 以组合为例,如果输入 `nums = [2,5,2,1,2]`,和为 7 的组合应该有两种 `[2,2,2,1]` 和 `[5,2]`。
+>
+> **元素无重可复选,即 `nums` 中的元素都是唯一的,每个元素可以被使用若干次**。
+>
+> - 以组合为例,如果输入 `nums = [2,3,6,7]`,和为 7 的组合应该有两种 `[2,2,3]` 和 `[7]`。
+>
+> 当然,也可以说有第四种形式,即元素可重可复选。但既然元素可复选,那又何必存在重复元素呢?元素去重之后就等同于形式三,所以这种情况不用考虑。
+>
+> 上面用组合问题举的例子,但排列、组合、子集问题都可以有这三种基本形式,所以共有 9 种变化。
+>
+> 除此之外,题目也可以再添加各种限制条件,比如让你求和为 `target` 且元素个数为 `k` 的组合,那这么一来又可以衍生出一堆变体,怪不得面试笔试中经常考到排列组合这种基本题型。
+>
+> **但无论形式怎么变化,其本质就是穷举所有解,而这些解呈现树形结构,所以合理使用回溯算法框架,稍改代码框架即可把这些问题一网打尽**。
+
+
+
+### 🎯 一、元素无重不可复选
+
+#### 📦 [子集_78](https://leetcode.cn/problems/subsets/)
+
+> 给你一个整数数组 `nums` ,数组中的元素 **互不相同** 。返回该数组所有可能的子集(幂集)。
+>
+> 解集 **不能** 包含重复的子集。你可以按 **任意顺序** 返回解集。
+>
+> ```
+> 输入:nums = [1,2,3]
+> 输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]
+> ```
+
+**💡 思路**:
+
+**子集的特性**:
+
+- 对于给定的数组 `[1, 2, 3]`,它的所有子集应该包括空集、单个元素的子集、两个元素的组合和完整数组。
+- 每个元素都有两种选择:要么加入子集,要么不加入子集。
+
+**回溯算法**:
+
+- 使用回溯的方式可以从空集开始,逐步添加元素来生成所有子集。
+- 从当前的元素出发,尝试包含它或者不包含它,然后递归地处理下一个元素。
+
+参数定义:
+
+- `res`:一个列表,存储最终的所有子集,类型是 `List>`。
+
+- `track`:一个临时列表,记录当前路径(即当前递归中形成的子集)。
+- `start`:当前递归要开始的位置(即考虑从哪个位置开始生成子集)。这个 `start` 是非常重要的,它确保了我们在递归时不会重复生成相同的子集。
+
+完成回溯树的遍历就收集了所有子集。
+
+
+
+```java
+public List> subsets(int[] nums) {
+ List> res = new ArrayList<>();
+ // 记录回溯算法的递归路径
+ List track = new ArrayList<>();
+
+ if (nums.length == 0) {
+ return res;
+ }
+
+ backtrack(nums, 0, res, track);
+ return res;
+}
+
+private void backtrack(int[] nums, int start, List> res, List track) {
+
+ res.add(new ArrayList<>(track));
+
+ // 回溯算法标准框架
+ for (int i = start; i < nums.length; i++) {
+ // 做选择
+ track.add(nums[i]);
+ // 通过 start 参数控制树枝的遍历,避免产生重复的子集
+ backtrack(nums, i + 1, res, track);
+ // 撤销选择
+ track.remove(track.size() - 1);
+ }
+}
+```
+
+
+
+#### 🎲 [组合_77](https://leetcode.cn/problems/combinations/)
+
+> 给定两个整数 `n` 和 `k`,返回范围 `[1, n]` 中所有可能的 `k` 个数的组合。你可以按 **任何顺序** 返回答案。
+>
+> ```
+> 输入:n = 4, k = 2
+> 输出:
+> [
+> [2,4],
+> [3,4],
+> [2,3],
+> [1,2],
+> [1,3],
+> [1,4],
+> ]
+> ```
+
+**💡 思路**:翻译一下就变成子集问题了:**给你输入一个数组 `nums = [1,2..,n]` 和一个正整数 `k`,请你生成所有大小为 `k` 的子集**。
+
+
+
+反映到代码上,只需要稍改 base case,控制算法仅仅收集第 `k` 层节点的值即可:
+
+```java
+public List> combine(int n, int k) {
+ List> res = new ArrayList<>();
+ // 记录回溯算法的递归路径
+ List track = new ArrayList<>();
+ // start 从 1 开始即可
+ backtrack(n, k, 1, track, res);
+ return res;
+}
+
+private void backtrack(int n, int k, int start, List track, List> res) {
+ // 遍历到了第 k 层,收集当前节点的值
+ if (track.size() == k) {
+ res.add(new ArrayList<>(track)); // 深拷贝
+ return;
+ }
+
+ // 从当前数字开始尝试
+ for (int i = start; i <= n; i++) {
+ track.add(i);
+ // 通过 start 参数控制树枝的遍历,避免产生重复的子集
+ backtrack(n, k, i + 1, track, res); // 递归选下一个数字
+ track.remove(track.size() - 1); // 撤销选择
+ }
+}
+```
+
+
+
+#### 🔄 [全排列_46](https://leetcode.cn/problems/permutations/description/)
+
+> 给定一个不含重复数字的数组 `nums` ,返回其 *所有可能的全排列* 。你可以 **按任意顺序** 返回答案。
+>
+> ```
+> 输入:nums = [1,2,3]
+> 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
+> ```
+
+**💡 思路**:组合/子集问题使用 `start` 变量保证元素 `nums[start]` 之后只会出现 `nums[start+1..]`中的元素,通过固定元素的相对位置保证不出现重复的子集。
+
+**但排列问题本身就是让你穷举元素的位置,`nums[i]` 之后也可以出现 `nums[i]` 左边的元素,所以之前的那一套玩不转了,需要额外使用 `used` 数组来标记哪些元素还可以被选择**。
+
+全排列共有 `n!` 个,我们可以按阶乘举例的思想,画出「回溯树」
+
+
+
+> 回溯树是一种树状结构,树的每个节点表示一个状态(即当前的选择或部分解),树的每条边表示一次决策的选择。在回溯过程中,我们从根节点开始,递归地选择下一个数字,每次递归都相当于进入树的下一层。
+
+> **labuladong 称为 决策树,你在每个节点上其实都在做决策**。因为比如你选了 2 之后,只能再选 1 或者 3,全排列是不允许重复使用数字的。**`[2]` 就是「路径」,记录你已经做过的选择;`[1,3]` 就是「选择列表」,表示你当前可以做出的选择;「结束条件」就是遍历到树的底层叶子节点,这里也就是选择列表为空的时候**。
+
+```java
+public class Solution {
+ public List> permute(int[] nums) {
+ List> res = new ArrayList<>();
+ // 记录「路径」
+ List track = new ArrayList<>();
+ boolean[] used = new boolean[nums.length]; // 标记数字是否被使用过
+ backtrack(nums, used, track, res);
+ return res;
+ }
+
+ private void backtrack(int[] nums, boolean[] used, List track, List> res) {
+ // 当排列的大小达到nums.length时,说明当前排列完成
+ if (track.size() == nums.length) {
+ res.add(new ArrayList<>(track)); // 将当前排列加入结果
+ return;
+ }
+
+ // 尝试每一个数字
+ for (int i = 0; i < nums.length; i++) {
+ if (used[i]) continue; // 如果当前数字已经被使用过,则跳过,剪枝操作
+
+ // 做选择
+ track.add(nums[i]);
+ used[i] = true; // 标记当前数字为已使用
+
+ // 递归进入下一层
+ backtrack(nums, used, track, res);
+
+ // 撤销选择
+ track.remove(track.size() - 1);
+ used[i] = false; // 回溯时将当前数字标记为未使用
+ }
+ }
+}
+```
+
+
+
+### 🔄 二、元素可重不可复选
+
+#### 📦 [子集 II_90](https://leetcode.cn/problems/subsets-ii/)
+
+> 给你一个整数数组 `nums` ,其中可能包含重复元素,请你返回该数组所有可能的 子集(幂集)。
+>
+> 解集 **不能** 包含重复的子集。返回的解集中,子集可以按 **任意顺序** 排列。
+>
+> ```
+> 输入:nums = [1,2,2]
+> 输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]
+> ```
+
+**💡 思路**:该问题的关键是**去重**(剪枝),**体现在代码上,需要先进行排序,让相同的元素靠在一起,如果发现 `nums[i] == nums[i-1]`,则跳过**
+
+> LeetCode 78 **Subsets** 问题并没有重复的子集。我们生成的是所有可能的子集,并且不需要考虑去除重复的子集,因为给定的数组 `nums` 不含重复元素。而在 **Subsets II** 中,由于输入数组可能包含重复元素,所以我们需要特殊处理来避免生成重复的子集。
+
+```java
+public class Solution {
+ public List> subsetsWithDup(int[] nums) {
+ List> res = new ArrayList<>();
+ Arrays.sort(nums); // 排序,确保相同的元素相邻
+ backtrack(nums, 0, new ArrayList<>(), res);
+ return res;
+ }
+
+ private void backtrack(int[] nums, int start, List track, List> res) {
+ // 每次递归时,将当前的track添加到结果中
+ res.add(new ArrayList<>(track));
+
+ // 从start位置开始遍历
+ for (int i = start; i < nums.length; i++) {
+ // 如果当前元素与前一个元素相同,并且前一个元素没有被选择,跳过当前元素
+ if (i > start && nums[i] == nums[i - 1]) {
+ continue; // 剪枝
+ }
+
+ // 做选择
+ track.add(nums[i]);
+ // 递归进入下一层
+ backtrack(nums, i + 1, track, res);
+ // 撤销选择
+ track.remove(track.size() - 1);
+ }
+ }
+}
+```
+
+
+
+#### 🎯 [组合总和 II_40](https://leetcode.cn/problems/combination-sum-ii/)
+
+> 给定一个候选人编号的集合 `candidates` 和一个目标数 `target` ,找出 `candidates` 中所有可以使数字和为 `target` 的组合。
+>
+> `candidates` 中的每个数字在每个组合中只能使用 **一次** 。
+>
+> **注意:**解集不能包含重复的组合。
+>
+> ```
+> 输入: candidates = [10,1,2,7,6,1,5], target = 8,
+> 输出:
+> [
+> [1,1,6],
+> [1,2,5],
+> [1,7],
+> [2,6]
+> ]
+> ```
+
+**💡 思路**:说这是一个组合问题,其实换个问法就变成子集问题了:请你计算 `candidates` 中所有和为 `target` 的子集。
+
+1. **排序**:首先对 `candidates` 数组进行排序,排序后的数组方便处理重复数字。
+2. **递归选择**:在递归过程中,确保如果当前数字和上一个数字相同,且上一个数字没有被选择过,则跳过当前数字,从而避免重复组合。
+3. **递归终止条件**:如果 `target` 变为 0,表示找到了一个符合条件的组合;如果 `target` 小于 0,表示当前路径不合法,应该回溯。
+
+```java
+public class Solution {
+ public List> combinationSum2(int[] candidates, int target) {
+ List> res = new ArrayList<>();
+ Arrays.sort(candidates); // 排序,便于后续去重
+ backtrack(candidates, target, 0, new ArrayList<>(), res);
+ return res;
+ }
+
+ private void backtrack(int[] candidates, int target, int start, List track, List> res) {
+ // 当目标值为0时,找到一个符合条件的组合
+ if (target == 0) {
+ res.add(new ArrayList<>(track)); // 复制当前组合并加入结果
+ return;
+ }
+
+ // 遍历候选数组
+ for (int i = start; i < candidates.length; i++) {
+ // 剪枝:当前数字大于目标值,后续不可能有合法的组合
+ if (candidates[i] > target) {
+ break;
+ }
+ // 剪枝:跳过重复的数字
+ if (i > start && candidates[i] == candidates[i - 1]) {
+ continue;
+ }
+
+ // 做选择:选择当前数字
+ track.add(candidates[i]);
+ // 递归,注意i + 1表示下一个位置,确保每个数字只使用一次
+ backtrack(candidates, target - candidates[i], i + 1, track, res);
+ // 撤销选择
+ track.remove(track.size() - 1);
+ }
+ }
+}
+
+```
+
+
+
+#### 🔄 [全排列 II_47](https://leetcode.cn/problems/permutations-ii/)
+
+> 给定一个可包含重复数字的序列 `nums` ,***按任意顺序*** 返回所有不重复的全排列。
+>
+> ```
+> 输入:nums = [1,1,2]
+> 输出:
+> [[1,1,2],
+> [1,2,1],
+> [2,1,1]]
+> ```
+
+**💡 思路**:典型的回溯
+
+1. **排序**:排序的目的是为了能够在回溯时做出剪枝选择。如果两个数字相同,并且前一个数字未被使用过,那么就可以跳过当前数字,避免产生重复的排列
+2. **回溯过程**:`backtrack` 是核心的递归函数。它每次递归时尝试将 `nums` 中的元素添加到 `track` 列表中。当 `track` 的大小等于 `nums` 的长度时,说明我们找到了一个排列,加入到结果 `res` 中。
+ - **标记数字是否被使用**:用一个布尔数组 `used[]` 来标记当前数字是否已经在某一层递归中被使用过,避免重复排列。
+ - **剪枝条件**:如果当前数字和前一个数字相同,并且前一个数字还没有被使用过,那么跳过当前数字,因为此时使用相同的数字会导致重复的排列。
+3. **递归回溯的选择与撤销**:
+ - **做选择**:选择当前数字 `nums[i]`,将其标记为已用,并添加到当前排列中。
+ - **递归**:继续递归地选择下一个数字,直到 `track` 的大小等于 `nums` 的大小。
+ - **撤销选择**:递归回到上一层时,撤销刚刚的选择,将当前数字从 `track` 中移除,并将其标记为未使用。
+
+> 剪枝的逻辑:
+>
+> - **第一轮回溯**:选择第一个 `1`,然后继续递归选择,生成一个排列。
+>
+> - **第二轮回溯**:当我们尝试选择第二个 `1` 时,假如第一个 `1` 没有被使用,第二个 `1` 会被选择,这时就会生成一个和第一轮相同的排列。为避免这种情况,我们需要 **剪枝**。
+
+```java
+public class Solution {
+ public List> permuteUnique(int[] nums) {
+ List> res = new ArrayList<>();
+ Arrays.sort(nums); // 排序,确保相同元素相邻
+ backtrack(nums, new boolean[nums.length], new ArrayList<>(), res);
+ return res;
+ }
+
+ private void backtrack(int[] nums, boolean[] used, List track, List> res) {
+ // 当排列的大小达到nums.length时,找到一个合法排列
+ if (track.size() == nums.length) {
+ res.add(new ArrayList<>(track)); // 加入当前排列
+ return;
+ }
+
+ // 遍历数组,递归生成排列
+ for (int i = 0; i < nums.length; i++) {
+ // 剪枝:当前元素已经被使用过,跳过
+ if (used[i]) continue;
+ // 剪枝:跳过相同的元素,避免生成重复排列
+ if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue;
+
+ // 做选择
+ track.add(nums[i]);
+ used[i] = true; // 标记当前元素已使用
+
+ // 递归
+ backtrack(nums, used, track, res);
+
+ // 撤销选择
+ track.remove(track.size() - 1);
+ used[i] = false; // 标记当前元素未使用
+ }
+ }
+}
+
+```
+
+
+
+### ♻️ 三、元素无重复可复选
+
+输入数组无重复元素,但每个元素可以被无限次使用
+
+#### 🎯 [组合总和_39](https://leetcode.cn/problems/combination-sum/)
+
+> 给你一个 **无重复元素** 的整数数组 `candidates` 和一个目标整数 `target` ,找出 `candidates` 中可以使数字和为目标数 `target` 的 所有 **不同组合** ,并以列表形式返回。你可以按 **任意顺序** 返回这些组合。
+>
+> `candidates` 中的 **同一个** 数字可以 **无限制重复被选取** 。如果至少一个数字的被选数量不同,则两种组合是不同的。
+>
+> 对于给定的输入,保证和为 `target` 的不同组合数少于 `150` 个。
+>
+> ```
+> 输入:candidates = [2,3,6,7], target = 7
+> 输出:[[2,2,3],[7]]
+> 解释:
+> 2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
+> 7 也是一个候选, 7 = 7 。
+> 仅有这两种组合。
+> ```
+
+**💡 思路**:**元素无重可复选,即 `nums` 中的元素都是唯一的,每个元素可以被使用若干次**,只要删掉去重逻辑即可
+
+```java
+public class Solution {
+ public List> combinationSum(int[] candidates, int target) {
+ List> res = new ArrayList<>();
+ List track = new ArrayList<>();
+ backtrack(candidates, target, 0, track, res);
+ return res;
+ }
+
+ private void backtrack(int[] candidates, int target, int start, List track, List> res) {
+ // 如果目标值为0,表示当前组合符合条件
+ if (target == 0) {
+ res.add(new ArrayList<>(track)); // 将当前组合加入结果
+ return;
+ }
+
+ // 遍历候选数组
+ for (int i = start; i < candidates.length; i++) {
+ // 如果当前数字大于目标值,跳过
+ if (candidates[i] > target) continue;
+
+ // 做选择:选择当前数字
+ track.add(candidates[i]);
+
+ // 递归,注意这里传入 i,因为可以重复选择当前数字
+ backtrack(candidates, target - candidates[i], i, track, res);
+
+ // 撤销选择
+ track.remove(track.size() - 1);
+ }
+ }
+}
+```
+
+
+
+## 其他问题
+
+### 📞 [电话号码的字母组合_17](https://leetcode.cn/problems/letter-combinations-of-a-phone-number/)
+
+> 给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
+>
+> 给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
+>
+> 
+>
+> ```
+> 输入:digits = "23"
+> 输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
+> ```
+
+**💡 思路**:回溯,递归地尝试每一位数字对应的所有字母,直到找出所有有效的组合
+
+首先,我们需要将每个数字 2 到 9 映射到其对应的字母,可以用 Map , 也可以用数组。然后就是递归处理。
+
+**递归终止条件**:当当前组合的长度与输入的数字字符串长度相同,就说明我们已经得到了一个有效的组合,可以将其加入结果集。
+
+
+
+```java
+public class Solution {
+ public List letterCombinations(String digits) {
+ List res = new ArrayList<>();
+ if (digits == null || digits.length() == 0) {
+ return res; // 如果输入为空,返回空结果
+ }
+
+ // 数字到字母的映射
+ String[] mapping = {
+ "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"
+ };
+
+ // 使用回溯法生成字母组合
+ backtrack(digits, 0, mapping, new StringBuilder(), res);
+ return res;
+ }
+
+ private void backtrack(String digits, int index, String[] mapping, StringBuilder current, List res) {
+ // 如果当前组合的长度等于输入的长度,说明已经生成了一个有效的字母组合
+ if (index == digits.length()) {
+ res.add(current.toString());
+ return;
+ }
+
+ // 获取当前数字对应的字母
+ String letters = mapping[digits.charAt(index) - '0'];
+
+ // 递归选择字母
+ for (char letter : letters.toCharArray()) {
+ current.append(letter); // 选择一个字母
+ backtrack(digits, index + 1, mapping, current, res); // 递归处理下一个数字
+ current.deleteCharAt(current.length() - 1); // 撤销选择
+ }
+ }
+}
+```
+
+
+
+### 🔗 [括号生成_22](https://leetcode.cn/problems/generate-parentheses/)
+
+> 数字 `n` 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 **有效的** 括号组合。
+>
+> ```
+> 输入:n = 3
+> 输出:["((()))","(()())","(())()","()(())","()()()"]
+> ```
+
+**💡 思路**:
+
+
+
+```java
+
+public List generateParenthesis(int n) {
+ List res = new ArrayList<>();
+ // 回溯过程中的路径
+ StringBuilder track = new StringBuilder();
+ if (n == 0) {
+ return res;
+ }
+ trackback(n, n, res, track);
+ return res;
+ }
+
+ // 可用的左括号数量为 left 个,可用的右括号数量为 right 个
+ private void trackback(int left, int right, List res, StringBuilder track) {
+ //如果剩余的左括号数量大于右括号数量
+ if (left < 0 || right < 0 || left > right) {
+ return;
+ }
+
+ // 当所有括号都恰好用完时,得到一个合法的括号组合
+ if (left == 0 && right == 0) {
+ res.add(track.toString());
+ return;
+ }
+
+ // 做选择,尝试放一个左括号
+ track.append('(');
+ trackback(left - 1, right, res, track);
+ // 撤销选择
+ track.deleteCharAt(track.length() - 1);
+
+ track.append(')');
+ trackback(left, right - 1, res, track);
+ track.deleteCharAt(track.length() - 1);
+
+ }
+```
+
+
+
+### ♛ [N 皇后_51](https://leetcode.cn/problems/n-queens/)
+
+> 按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
+>
+> **n 皇后问题** 研究的是如何将 `n` 个皇后放置在 `n×n` 的棋盘上,并且使皇后彼此之间不能相互攻击。
+>
+> 给你一个整数 `n` ,返回所有不同的 **n 皇后问题** 的解决方案。
+>
+> 每一种解法包含一个不同的 **n 皇后问题** 的棋子放置方案,该方案中 `'Q'` 和 `'.'` 分别代表了皇后和空位。
+>
+> 
+>
+> ```
+>输入:n = 4
+> 输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
+> 解释:如上图所示,4 皇后问题存在两个不同的解法。
+> ```
+
+**💡 思路**:通过回溯算法逐行放置皇后,每次递归时确保当前行、列和对角线不被其他皇后攻击。通过标记已占用的列和对角线,避免重复搜索,最终生成所有合法的解。
+
+- 如果在某一列或对角线处已有皇后,就不能在该位置放置皇后。我们可以使用三个辅助数组来追踪列和对角线的使用情况:
+ - `cols[i]`:表示第 `i` 列是否已经放置了皇后。
+ - `diag1[i]`:表示从左上到右下的对角线(`row - col`)是否已经有皇后。
+ - `diag2[i]`:表示从右上到左下的对角线(`row + col`)是否已经有皇后。
+
+- 主对角线是从左上角到右下角的对角线、副对角线是从右上角到左下角的对角线
+
+```java
+public class NQueens {
+
+ public List> solveNQueens(int N) {
+ List> result = new ArrayList<>();
+ char[][] board = new char[N][N];
+
+ // 初始化棋盘,每个位置设为'.'
+ for (int i = 0; i < N; i++) {
+ for (int j = 0; j < N; j++) {
+ board[i][j] = '.';
+ }
+ }
+
+ // 用来记录列、主对角线、副对角线是否已被占用
+ boolean[] cols = new boolean[N]; // 列占用标记
+ boolean[] diag1 = new boolean[2 * N - 1]; // 主对角线占用标记
+ boolean[] diag2 = new boolean[2 * N - 1]; // 副对角线占用标记
+
+ backtrack(N, 0, board, cols, diag1, diag2, result);
+ return result;
+ }
+
+ // 回溯函数
+ private void backtrack(int N, int row, char[][] board, boolean[] cols, boolean[] diag1, boolean[] diag2, List> result) {
+ if (row == N) { // 如果已经放置了 N 个皇后
+ List solution = new ArrayList<>();
+ for (int i = 0; i < N; i++) {
+ solution.add(new String(board[i])); // 将每一行转化为字符串并添加到结果中
+ }
+ result.add(solution);
+ return;
+ }
+
+ // 遍历每一列,尝试放置皇后
+ for (int col = 0; col < N; col++) {
+ // 判断当前位置是否可以放置皇后
+ if (cols[col] || diag1[row - col + (N - 1)] || diag2[row + col]) {
+ continue; // 如果列、主对角线或副对角线已被占用,跳过当前列
+ }
+
+ // 放置皇后
+ board[row][col] = 'Q';
+ cols[col] = true; // 标记该列已被占用
+ diag1[row - col + (N - 1)] = true; // 标记主对角线已被占用
+ diag2[row + col] = true; // 标记副对角线已被占用
+
+ // 递归放置下一行的皇后
+ backtrack(N, row + 1, board, cols, diag1, diag2, result);
+
+ // 回溯,撤销当前位置的选择
+ board[row][col] = '.';
+ cols[col] = false;
+ diag1[row - col + (N - 1)] = false;
+ diag2[row + col] = false;
+ }
+ }
+
+ // 打印结果
+ public void printSolutions(List> solutions) {
+ for (List solution : solutions) {
+ for (String row : solution) {
+ System.out.println(row);
+ }
+ System.out.println();
+ }
+ }
+
+ public static void main(String[] args) {
+ NQueens nq = new NQueens();
+ List> solutions = nq.solveNQueens(4);
+ nq.printSolutions(solutions);
+ }
+}
+
+```
+
+
+
+
+
+## 📚 参考与感谢:
+
+- https://yuminlee2.medium.com/combinations-and-combination-sum-3ed2accc8d12
+- https://medium.com/@sunshine990316/leetcode-python-backtracking-summary-medium-1-e8ae88839e85
+- https://blog.devgenius.io/10-daily-practice-problems-day-18-f7293b55224d
+- [hello 算法- 回溯算法](https://www.hello-algo.com/chapter_backtracking/backtracking_algorithm/#1312)
+
+---
+
+> 🎉 **恭喜你完成了回溯算法的学习!** 回溯算法是解决很多复杂问题的强大工具,掌握了它,你就拥有了解决排列、组合、子集等问题的钥匙。记住:**选择 → 递归 → 撤销选择** 是回溯的核心思想!
diff --git a/docs/data-structure-algorithms/algorithm/Binary-Search.md b/docs/data-structure-algorithms/algorithm/Binary-Search.md
new file mode 100755
index 0000000000..9fb5f32679
--- /dev/null
+++ b/docs/data-structure-algorithms/algorithm/Binary-Search.md
@@ -0,0 +1,1030 @@
+---
+title: 二分查找
+date: 2023-02-09
+tags:
+ - binary-search
+ - algorithms
+categories: algorithms
+---
+
+
+
+> 二分查找【折半查找】,一种简单高效的搜索算法,一般是利用有序数组的特性,通过逐步比较中间元素来快速定位目标值。
+>
+> 二分查找并不简单,Knuth 大佬(发明 KMP 算法的那位)都说二分查找:**思路很简单,细节是魔鬼**。比如二分查找让人头疼的细节问题,到底要给 `mid` 加一还是减一,while 里到底用 `<=` 还是 `<`。
+
+## 一、二分查找基础框架
+
+```java
+int binarySearch(int[] nums, int target) {
+ int left = 0, right = ...;
+
+ while(...) {
+ int mid = left + (right - left) / 2;
+ if (nums[mid] == target) {
+ ...
+ } else if (nums[mid] < target) {
+ left = ...
+ } else if (nums[mid] > target) {
+ right = ...
+ }
+ }
+ return ...;
+}
+```
+
+**分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节**。本文都会使用 else if,旨在讲清楚,读者理解后可自行简化。
+
+其中 `...` 标记的部分,就是可能出现细节问题的地方,当你见到一个二分查找的代码时,首先注意这几个地方。
+
+**另外提前说明一下,计算 `mid` 时需要防止溢出**,代码中 `left + (right - left) / 2` 就和 `(left + right) / 2` 的结果相同,但是有效防止了 `left` 和 `right` 太大,直接相加导致溢出的情况。
+
+
+
+## 二、二分查找性能分析
+
+**时间复杂度**:二分查找的时间复杂度为 $O(log n)$,其中 n 是数组的长度。这是因为每次比较后,搜索范围都会减半,非常高效。
+
+> logn 是一个非常“恐怖”的数量级,即便 n 非常非常大,对应的 logn 也很小。比如 n 等于 2 的 32 次方,这个数很大了吧?大约是 42 亿。也就是说,如果我们在 42 亿个数据中用二分查找一个数据,最多需要比较 32 次。
+
+**空间复杂度**:
+
+- 迭代法:$O(1)$,因为只需要常数级别的额外空间。
+- 递归法:$O(log n)$,因为递归调用会占用栈空间。
+
+**最坏情况**:最坏情况下,目标值位于数组两端或不存在,需要$log n$次比较才能确定。
+
+**二分查找与其他搜索算法的比较**:
+
+- 线性搜索:线性搜索简单,时间复杂度为$O(n)$,但在大规模数据集上效率较低。
+
+- 哈希:哈希在查找上能提供平均$O(1)$的时间复杂度,但需要额外空间存储哈希表,且对数据有序性无要求。
+
+
+
+## 三、刷刷热题
+
+- 二分查找,可以用循环(迭代)实现,也可以用递归实现
+- 二分查找依赖的是顺序表结构(也就是数组)
+- 二分查找针对的是有序数组
+- 数据量太小太大都不是很适用二分(太小直接顺序遍历就够了,太大的话对连续内存空间要求更高)
+
+### [二分查找『704』](https://leetcode.cn/problems/binary-search/)(基本的二分搜索)
+
+> 给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
+>
+
+```java
+int binarySearch(int[] nums, int target) {
+ int left = 0;
+ int right = nums.length - 1; // 注意
+
+ while(left <= right) {
+ int mid = left + (right - left) / 2;
+ if(nums[mid] == target)
+ return mid;
+ else if (nums[mid] < target)
+ left = mid + 1; // 注意
+ else if (nums[mid] > target)
+ right = mid - 1; // 注意
+ }
+ return -1;
+}
+```
+
+**时间复杂度**:O(log n),每次都将搜索范围缩小一半
+**空间复杂度**:O(1),只使用常量级别的额外空间
+
+**1、为什么 while 循环的条件中是 <=,而不是 <**?
+
+答:因为初始化 `right` 的赋值是 `nums.length - 1`,即最后一个元素的索引,而不是 `nums.length`。
+
+这二者可能出现在不同功能的二分查找中,区别是:前者相当于两端都闭区间 `[left, right]`,后者相当于左闭右开区间 `[left, right)`。因为索引大小为 `nums.length` 是越界的,所以我们把 `right` 这一边视为开区间。
+
+我们这个算法中使用的是前者 `[left, right]` 两端都闭的区间。**这个区间其实就是每次进行搜索的区间**。
+
+**2、为什么 `left = mid + 1`,`right = mid - 1`?我看有的代码是 `right = mid` 或者 `left = mid`,没有这些加加减减,到底怎么回事,怎么判断**?
+
+答:这也是二分查找的一个难点,不过只要你能理解前面的内容,就能够很容易判断。
+
+刚才明确了「搜索区间」这个概念,而且本算法的搜索区间是两端都闭的,即 `[left, right]`。那么当我们发现索引 `mid` 不是要找的 `target` 时,下一步应该去搜索哪里呢?
+
+当然是去搜索区间 `[left, mid-1]` 或者区间 `[mid+1, right]` 对不对?**因为 `mid` 已经搜索过,应该从搜索区间中去除**。
+
+> ##### 1. **左闭右闭区间 `[left, right]`**
+>
+> - **循环条件**:`while (left <= right)`,因为 `left == right` 时区间仍有意义。
+> - 边界调整:
+> - `nums[mid] < target` → `left = mid + 1`(排除 `mid` 左侧)
+> - `nums[mid] > target` → `right = mid - 1`(排除 `mid` 右侧)
+> - 适用场景:明确目标值存在于数组时,直接返回下标。
+>
+> ##### 2. **左闭右开区间 `[left, right)`**
+>
+> - **初始化**:`right = nums.length`。
+> - **循环条件**:`while (left < right)`,因为 `left == right` 时区间为空。
+> - 边界调整:
+> - `nums[mid] < target` → `left = mid + 1`
+> - `nums[mid] > target` → `right = mid`(右开,不包含 `mid`)
+> - **适用场景**:需要处理目标值可能不在数组中的情况,例如插入位置问题
+
+> 比如说给你有序数组 `nums = [1,2,2,2,3]`,`target` 为 2,此算法返回的索引是 2,没错。但是如果我想得到 `target` 的左侧边界,即索引 1,或者我想得到 `target` 的右侧边界,即索引 3,这样的话此算法是无法处理的。
+>
+> 所以又有了一些含有重复元素,带有边界问题的二分。
+
+### 寻找左侧边界的二分搜索
+
+```java
+public int leftBound(int[] nums, int target) {
+ int left = 0;
+ int right = nums.length - 1;
+ while (left <= right) {
+ int mid = (right - left) / 2 + left;
+ if (nums[mid] > target) {
+ right = mid - 1;
+ } else if (nums[mid] < target) {
+ left = mid + 1;
+ } else {
+ //mid 是第一个元素,或者前一个元素不等于查找值,锁定,且返回的是mid
+ if (mid == 0 || nums[mid - 1] != target) return mid;
+ else right = mid - 1;
+ }
+ }
+ return -1;
+}
+```
+
+### 寻找右侧边界的二分查找
+
+```java
+public int rightBound(int[] nums, int target){
+ int left = 0;
+ int right = nums.length - 1;
+ while(left <= right){
+ int mid = left + (right - left)/2;
+ if(nums[mid] > target){
+ right = mid - 1;
+ }else if(nums[mid] < target){
+ left = mid +1;
+ }else{
+ if(mid == nums.length - 1 || nums[mid +1] != target) return mid;
+ else left = mid + 1;
+ }
+ }
+ return -1;
+}
+```
+
+### 查找第一个大于等于给定值的元素
+
+```JAVA
+//查找第一个大于等于给定值的元素 1,3,5,7,9 找出第一个大于等于5的元素
+public int firstNum(int[] nums, int target) {
+ int left = 0;
+ int right = nums.length - 1;
+ while (left <= right) {
+ int mid = left + (right - left) / 2;
+ if (nums[mid] >= target) {
+ if (mid == 0 || nums[mid - 1] < target) return mid;
+ else right = mid - 1;
+ } else {
+ left = mid + 1;
+ }
+ }
+ return -1;
+}
+```
+
+
+
+### [搜索旋转排序数组『33』](https://leetcode-cn.com/problems/search-in-rotated-sorted-array/)
+
+> 整数数组 nums 按升序排列,数组中的值 互不相同 。
+>
+> 在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2] 。
+>
+> 给你 旋转后 的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的下标,否则返回 -1 。
+>
+> ```
+> 输入:nums = [4,5,6,7,0,1,2], target = 0
+> 输出:4
+> ```
+>
+> ```
+> 输入:nums = [4,5,6,7,0,1,2], target = 3
+> 输出:-1
+> ```
+
+**思路**:
+
+对于有序数组(部分有序也可以),可以使用二分查找的方法查找元素。
+
+旋转数组后,依然是局部有序,从数组中间分成左右两部分后,一定有一部分是有序的
+
+- 如果 [L, mid - 1] 是有序数组,且 target 的大小满足 [nums[L],nums[mid],则我们应该将搜索范围缩小至 [L, mid - 1],否则在 [mid + 1, R] 中寻找。
+- 如果 [mid, R] 是有序数组,且 target 的大小满足 ({nums}[mid+1],{nums}[R]],则我们应该将搜索范围缩小至 [mid + 1, R],否则在 [l, mid - 1] 中寻找。
+
+```java
+public int search(int[] nums, int target) {
+ if (nums == null || nums.length == 0) return -1;
+
+ int left = 0, right = nums.length - 1;
+
+ while (left <= right) {
+ int mid = left + (right - left) / 2;
+
+ if (nums[mid] == target) {
+ return mid; // 找到目标
+ }
+
+ // 判断左半部分是否有序
+ if (nums[left] <= nums[mid]) {
+ // 左半部分有序
+ if (nums[left] <= target && target < nums[mid]) {
+ right = mid - 1; // 目标在左半部分
+ } else {
+ left = mid + 1; // 目标在右半部分
+ }
+ } else {
+ // 右半部分有序
+ if (nums[mid] < target && target <= nums[right]) {
+ left = mid + 1; // 目标在右半部分
+ } else {
+ right = mid - 1; // 目标在左半部分
+ }
+ }
+ }
+
+ return -1; // 未找到目标
+}
+```
+
+**时间复杂度**:$O(log n) $,二分查找的时间复杂度
+**空间复杂度**:$O(1)$,只使用常量级别的额外空间
+
+
+
+### [在排序数组中查找元素的第一个和最后一个位置『34』](https://leetcode-cn.com/problems/find-first-and-last-position-of-element-in-sorted-array/)
+
+> 给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。
+>
+> 如果数组中不存在目标值 target,返回 [-1, -1]。
+>
+> 你可以设计并实现时间复杂度为 $O(log n) $ 的算法解决此问题吗?
+>
+> ```
+> 输入:nums = [5,7,7,8,8,10], target = 8
+> 输出:[3,4]
+> ```
+>
+> ```
+> 输入:nums = [5,7,7,8,8,10], target = 6
+> 输出:[-1,-1]
+> ```
+
+**思路**:二分法寻找左右边界值
+
+```java
+public int[] searchRange(int[] nums, int target) {
+ int first = binarySearch(nums, target, true);
+ int last = binarySearch(nums, target, false);
+ return new int[]{first, last};
+}
+
+public int binarySearch(int[] nums, int target, boolean findLast) {
+ int length = nums.length;
+ int left = 0, right = length - 1;
+ //结果,因为可能有多个值,所以需要先保存起来
+ int index = -1;
+ while (left <= right) {
+ //取中间值
+ int middle = left + (right - left) / 2;
+
+ //找到相同的值(只有这个地方和普通二分查找有不同)
+ if (nums[middle] == target) {
+ //先赋值一下,肯定是找到了,只是不知道这个值是不是在区域的边界内
+ index = middle;
+ //如果是查找最后的
+ if (findLast) {
+ //那我们将浮标移动到下一个值试探一下后面的值还是否有target
+ left = middle + 1;
+ } else {
+ //否则,就是查找第一个值,也是同理,移动指针到上一个值去试探一下上一个值是不是等于target
+ right = middle - 1;
+ }
+
+ //下面2个就是普通的二分查找流程,大于小于都移动指针
+ } else if (nums[middle] < target) {
+ left = middle + 1;
+ } else {
+ right = middle - 1;
+ }
+
+ }
+ return index;
+}
+```
+
+**时间复杂度**:$O(log n) $,需要进行两次二分查找
+**空间复杂度**:$O(1)$,只使用常量级别的额外空间
+
+
+
+### [搜索插入位置『35』](https://leetcode.cn/problems/search-insert-position/)
+
+> 给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。请必须使用时间复杂度为 `O(log n)` 的算法。
+>
+> ```
+> 输入: nums = [1,3,5,6], target = 2
+> 输出: 1
+> ```
+
+```java
+public int searchInsert(int[] nums, int target) {
+ int left = 0;
+ int right = nums.length - 1;
+ //注意:特例处理
+ if (nums[left] > target) return 0;
+ if (nums[right] < target) return right + 1;
+ while (left <= right) {
+ int mid = left + (right - left) / 2;
+ if (nums[mid] > target) {
+ right = mid - 1;
+ } else if (nums[mid] < target) {
+ left = mid + 1;
+ } else {
+ return mid;
+ }
+ }
+ //注意:这里如果没有查到,返回left,也就是需要插入的位置
+ return left;
+ }
+```
+
+**时间复杂度**:$O(log n) $,标准的二分查找时间复杂度
+**空间复杂度**:$O(1)$,只使用常量级别的额外空间
+
+
+
+### [寻找旋转排序数组中的最小值『153』](https://leetcode-cn.com/problems/find-minimum-in-rotated-sorted-array/)
+
+> 已知一个长度为 n 的数组,预先按照升序排列,经由 1 到 n 次 旋转 后,得到输入数组。例如,原数组 nums = [0,1,2,4,5,6,7] 在变化后可能得到:
+> 若旋转 4 次,则可以得到 [4,5,6,7,0,1,2] 若旋转 7 次,则可以得到 [0,1,2,4,5,6,7]
+> 注意,数组 [a[0], a[1], a[2], ..., a[n-1]] 旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]] 。
+>
+>给你一个元素值 互不相同 的数组 nums ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。
+>
+>你必须设计一个时间复杂度为 $O(log n)$ 的算法解决此问题。
+>
+>```
+> 输入:nums = [3,4,5,1,2]
+> 输出:1
+> 解释:原数组为 [1,2,3,4,5] ,旋转 3 次得到输入数组。
+> ```
+>
+
+**思路**:
+
+升序数组+旋转,仍然是部分有序,考虑用二分查找。
+
+
+
+> 我们先搞清楚题目中的数组是通过怎样的变化得来的,基本上就是等于将整个数组向右平移
+
+> 这种二分查找难就难在,arr[mid] 跟谁比。
+>
+> 我们的目的是:当进行一次比较时,一定能够确定答案在 mid 的某一侧。一次比较为 arr[mid] 跟谁比的问题。
+> 一般的比较原则有:
+>
+> - 如果有目标值 target,那么直接让 arr[mid] 和 target 比较即可。
+> - 如果没有目标值,一般可以考虑 **端点**
+>
+> 如果中值 < 右值,则最小值在左半边,可以收缩右边界。
+> 如果中值 > 右值,则最小值在右半边,可以收缩左边界。
+
+旋转数组,最小值右侧的元素肯定都小于或等于数组中的最后一个元素 `nums[n-1]`,左侧元素都大于 `num[n-1]`
+
+```java
+public static int findMin(int[] nums) {
+ int left = 0;
+ int right = nums.length - 1;
+ //左闭右开
+ while (left < right) {
+ int mid = left + (right - left) / 2;
+ //疑问:为什么right = mid;而不是 right = mid-1;
+ //解答:{4,5,1,2,3},如果right = mid-1,则丢失了最小值1
+ if (nums[mid] < nums[right]) {
+ right = mid;
+ } else {
+ left = mid + 1;
+ }
+ }
+ //循环结束条件,left = right,最小值输出nums[left]或nums[right]均可
+ return nums[left];
+}
+```
+
+**时间复杂度**:$O(log n) $,每次都将搜索范围缩小一半
+**空间复杂度**:$O(1)$,只使用常量级别的额外空间
+
+**如果是求旋转数组中的最大值呢**
+
+```java
+public static int findMax(int[] nums) {
+ int left = 0;
+ int right = nums.length - 1;
+
+ while (left < right) {
+ int mid = left + (right - left) >> 1;
+
+ //因为向下取整,left可能会等于mid,所以要考虑
+ if (nums[left] < nums[right]) {
+ return nums[right];
+ }
+
+ //[left,mid] 是递增的,最大值只会在[mid,right]中
+ if (nums[left] < nums[mid]) {
+ left = mid;
+ } else {
+ //[mid,right]递增,最大值只会在[left, mid-1]中
+ right = mid - 1;
+ }
+ }
+ return nums[left];
+}
+```
+
+### [寻找重复数『287』](https://leetcode-cn.com/problems/find-the-duplicate-number/)
+
+> 长度为 n+1 的数组,元素在 1~n 之间,有且仅有一个重复数(可能重复多次)。要求不修改数组且只用 O (1) 空间。
+>
+> ```
+>输入:nums = [1,3,4,2,2]
+> 输出:2
+>```
+>
+> ```
+> 输入:nums = [3,1,3,4,2]
+> 输出:3
+>```
+
+**思路**:
+
+- 统计小于等于 mid 的元素个数
+- 若 count > mid,说明重复数在 [1, mid] 区间
+- 否则在 [mid+1, n] 区间
+
+> 抽屉原理:把 `10` 个苹果放进 `9` 个抽屉,至少有一个抽屉里至少放 `2` 个苹果。
+
+```java
+public int findDuplicate(int[] nums) {
+ int left = 0;
+ int right = nums.length - 1;
+ while (left <= right) {
+ int mid = left + (right - left) / 2;
+
+ // nums 中小于等于 mid 的元素的个数
+ int count = 0;
+ for (int num : nums) {
+ //看这里,是 <= mid,而不是 nums[mid]
+ if (num <= mid) {
+ count += 1;
+ }
+ }
+
+ // 根据抽屉原理,小于等于 4 的个数如果严格大于 4 个,此时重复元素一定出现在 [1..4] 区间里
+ if (count > mid) {
+ // 重复元素位于区间 [left..mid]
+ right = mid - 1;
+ } else {
+ // if 分析正确了以后,else 搜索的区间就是 if 的反面区间 [mid + 1..right]
+ left = mid + 1;
+ }
+ }
+ return left;
+}
+```
+
+**时间复杂度**:$O(n log n)$,每次需要遍历数组O(n)
+**空间复杂度**:$O(1)$,只使用常量级别的额外空间
+
+
+
+### [寻找峰值『162』](https://leetcode-cn.com/problems/find-peak-element/)
+
+> 峰值元素是指其值严格大于左右相邻值的元素。
+>
+> 给你一个整数数组 nums,找到峰值元素并返回其索引。数组可能包含多个峰值,在这种情况下,返回 任何一个峰值 所在位置即可。
+>
+> 你可以假设 nums[-1] = nums[n] = -∞ 。
+>
+> 你必须实现时间复杂度为 $O(log n) $的算法来解决此问题。
+>
+> ```
+> 输入:nums = [1,2,3,1]
+> 输出:2
+> 解释:3 是峰值元素,你的函数应该返回其索引 2。
+> ```
+>
+> ```
+> 输入:nums = [1,2,1,3,5,6,4]
+> 输出:1 或 5
+> 解释:你的函数可以返回索引 1,其峰值元素为 2;
+> 或者返回索引 5, 其峰值元素为 6。
+> ```
+
+**思路**:
+
+- 比较 `nums[mid]` 和 `nums[mid+1]`
+- 如果 `nums[mid] < nums[mid+1]`,说明右侧一定有峰值
+- 否则左侧一定有峰值
+
+**为什么有效**:
+
+- 峰值条件 `nums[i] > nums[i+1]` 保证单调性
+- 二分查找每次可以缩小一半搜索范围
+
+```java
+public int findPeakElement(int[] nums) {
+ int left = 0, right = nums.length - 1;
+
+ while (left < right) {
+ int mid = left + (right - left) / 2;
+
+ if (nums[mid] < nums[mid + 1]) {
+ // 右侧有峰值
+ left = mid + 1;
+ } else {
+ // 左侧有峰值
+ right = mid;
+ }
+ }
+
+ return left; // left == right,即为峰值位置
+}
+```
+
+**时间复杂度**:$O(log n) $,二分查找的时间复杂度
+**空间复杂度**:$O(1)$,只使用常量级别的额外空间
+
+
+
+### [搜索二维矩阵『74』](https://leetcode.cn/problems/search-a-2d-matrix/)
+
+> 给你一个满足以下两点的 `m x n` 矩阵:
+>
+> - 每行从左到右递增
+> - 每行第一个数大于上一行最后一个数
+>
+> 判断目标值 `target` 是否在矩阵中。
+>
+> 给你一个整数 `target` ,如果 `target` 在矩阵中,返回 `true` ;否则,返回 `false` 。
+>
+> 
+>
+> ```
+> 输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 3
+> 输出:true
+> ```
+
+**思路**:由于每行的第一个元素都大于前一行的最后一个元素,整个矩阵可以被看作一个**完全有序的一维数组**。
+
+例如,上面的示例矩阵可以被 “拉直” 为:`[1, 3, 5, 7, 10, 11, 16, 20, 23, 30, 34, 60]`
+
+要实现 “一次二分”,必须能将一维数组的索引 `mid`(二分查找中的中间位置)转换回二维矩阵的 `(行号, 列号)`,公式如下:
+
+- **行号 = mid /n**(整除,因为每一行有 `n` 个元素,商表示当前元素在第几行)
+- **列号 = mid % n**(取余,余数表示当前元素在该行的第几列)
+
+举例:
+
+- 一维索引 `mid = 5`,`n = 4`(列数):
+ - 行号 = 5 / 4 = 1(第 2 行,索引从 0 开始)
+ - 列号 = 5 % 4 = 1(第 2 列)
+ - 对应矩阵值:`matrix[1][1] = 11`,与一维数组索引 5 的值一致。
+
+```java
+public boolean searchMatrix(int[][] matrix, int target) {
+ // 1. 处理边界情况:矩阵为空(行数为 0)
+ int m = matrix.length;
+ if (m == 0) {
+ return false;
+ }
+
+ // 2. 处理边界情况:矩阵列数为空(每行没有元素)
+ int n = matrix[0].length;
+ if (n == 0) {
+ return false;
+ }
+
+ // 3. 初始化二分查找的左右指针(对应一维数组的起始和末尾索引)
+ int left = 0;
+ int right = m * n - 1; // 总元素数 = 行数 × 列数,末尾索引 = 总元素数 - 1
+
+ // 4. 二分查找循环:left <= right 确保不遗漏元素
+ while (left <= right) {
+ // 计算中间索引:避免 left + right 直接相加导致整数溢出
+ int mid = left + (right - left) / 2;
+
+ // 关键:将一维索引 mid 转换为二维矩阵的 (row, col) 坐标
+ int row = mid / n; // 行号 = 中间索引 ÷ 列数(整除)
+ int col = mid % n; // 列号 = 中间索引 % 列数(取余)
+
+ // 获取当前中间位置的矩阵值
+ int midValue = matrix[row][col];
+
+ // 5. 比较 midValue 与 target,调整二分范围
+ if (midValue == target) {
+ // 找到目标值,直接返回 true
+ return true;
+ } else if (midValue < target) {
+ // 中间值比目标小:目标在右半部分,left 移到 mid + 1
+ left = mid + 1;
+ } else {
+ // 中间值比目标大:目标在左半部分,right 移到 mid - 1
+ right = mid - 1;
+ }
+ }
+
+ // 6. 循环结束仍未找到,说明目标不在矩阵中
+ return false;
+}
+```
+
+**时间复杂度**:$O(log(mn)) $:二分查找的时间复杂度是 `O(log(总元素数))`,总元素数为 `m×n`,因此复杂度为 `O(log(m×n))`
+**空间复杂度**:$O(1)$:仅使用了 `m、n、left、right、mid、row、col、midValue` 等常数个变量,没有额外占用空间
+
+
+
+### [搜索二维矩阵 II『240』](https://leetcode-cn.com/problems/search-a-2d-matrix-ii/)
+
+> [剑指 Offer 04. 二维数组中的查找](https://leetcode-cn.com/problems/er-wei-shu-zu-zhong-de-cha-zhao-lcof/) 一样的题目
+>
+> 在一个 n * m 的二维数组中,每一行都按照从左到右递增的顺序排序,每一列都按照从上到下递增的顺序排序。请完成一个高效的函数,输入这样的一个二维数组和一个整数,判断数组中是否含有该整数。
+>
+> 现有矩阵 matrix 如下:
+>
+> 
+>
+> ```
+> 输入:matrix = [[1,4,7,11,15],[2,5,8,12,19],[3,6,9,16,22],[10,13,14,17,24],[18,21,23,26,30]], target = 5
+> 输出:true
+> ```
+
+**思路**:
+
+站在左下角或者右上角看。这个矩阵其实就像是一个Binary Search Tree。然后,聪明的大家应该知道怎么做了。
+
+
+
+有序的数组,我们首先应该想到二分
+
+```java
+public boolean searchMatrix(int[][] matrix, int target) {
+ // 1. 处理边界情况:矩阵为空、行数为0、或列数为0
+ if (matrix == null || matrix.length == 0 || matrix[0].length == 0) {
+ return false;
+ }
+
+ // 2. 获取矩阵的行数 m 和列数 n
+ int m = matrix.length;
+ int n = matrix[0].length;
+
+ // 3. 初始化指针,从左下角开始
+ int row = m - 1; // 行指针指向最后一行
+ int col = 0; // 列指针指向第一列
+
+ // 4. 循环条件:行指针不能越界(>= 0),列指针不能越界(< n)
+ while (row >= 0 && col < n) {
+ // 获取当前指针指向的元素值
+ int current = matrix[row][col];
+
+ // 5. 比较 current 与 target
+ if (current == target) {
+ // 找到了目标值,直接返回 true
+ return true;
+ } else if (current < target) {
+ // 当前值小于目标值:目标值不可能在当前行(因为行是递增的)
+ // 将列指针向右移动,去更大的区域查找
+ col++;
+ } else { // current > target
+ // 当前值大于目标值:目标值不可能在当前列(因为列是递增的)
+ // 将行指针向上移动,去更小的区域查找
+ row--;
+ }
+ }
+
+ // 6. 如果循环结束仍未找到,说明目标值不存在于矩阵中
+ return false;
+ }
+}
+```
+
+**时间复杂度**:$O(m + n)$,其中m是行数,n是列数,最坏情况下需要遍历m+n个元素
+**空间复杂度**:$O(1)$,只使用常量级别的额外空间
+
+
+
+### [最长递增子序列『300』](https://leetcode.cn/problems/longest-increasing-subsequence/)
+
+> 给你一个整数数组 `nums` ,找到其中最长严格递增子序列的长度。
+>
+> **子序列** 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,`[3,6,2,7]`是数组 `[0,3,1,6,2,2,7]` 的子序列。
+>
+> ```
+> 输入:nums = [10,9,2,5,3,7,101,18]
+> 输出:4
+> 解释:最长递增子序列是 [2,3,7,101],因此长度为 4。
+> ```
+
+**思路**:
+
+**动态规划解法**:
+
+```java
+public int lengthOfLIS(int[] nums) {
+ if (nums == null || nums.length == 0) return 0;
+ int[] dp = new int[nums.length];
+ //初始时,每个元素自身构成一个长度为1的子序列
+ Arrays.fill(dp, 1);
+ // 记录全局最长递增子序列的长度,初始为1
+ int maxLen = 1;
+
+ for (int i = 1; i < nums.length; i++) {
+ // 对于每个i,检查所有j < i的元素
+ for (int j = 0; j < i; j++) {
+ // 如果nums[j] < nums[i],说明nums[i]可以接在nums[j]后面
+ if (nums[j] < nums[i]) {
+ // 更新dp[i]为dp[j] + 1和当前dp[i]的较大值
+ dp[i] = Math.max(dp[i], dp[j] + 1);
+ }
+ }
+ // 更新全局最大值
+ maxLen = Math.max(maxLen, dp[i]);
+ }
+ return maxLen;
+}
+```
+
+**二分查找优化解法**:
+```java
+public int lengthOfLIS(int[] nums) {
+ if (nums == null || nums.length == 0) return 0;
+
+ List tails = new ArrayList<>();
+
+ for (int num : nums) {
+ // 二分查找第一个大于等于num的位置
+ int left = 0, right = tails.size();
+ while (left < right) {
+ int mid = left + (right - left) / 2;
+ if (tails.get(mid) < num) {
+ left = mid + 1;
+ } else {
+ right = mid;
+ }
+ }
+
+ // 如果找到末尾,说明num比所有元素都大,直接添加
+ if (left == tails.size()) {
+ tails.add(num);
+ } else {
+ // 否则替换找到的位置
+ tails.set(left, num);
+ }
+ }
+
+ return tails.size();
+}
+```
+
+**时间复杂度**:
+- 动态规划:$O(n²)$
+- 二分查找优化:$O(n log n)$
+
+**空间复杂度**:$O(n)$,需要额外的数组空间
+
+
+
+### [寻找两个正序数组的中位数『4』](https://leetcode.cn/problems/median-of-two-sorted-arrays/)
+
+> 给定两个大小分别为 `m` 和 `n` 的正序(从小到大)数组 `nums1` 和 `nums2`。请你找出并返回这两个正序数组的 **中位数** 。
+>
+> 算法的时间复杂度应该为 `O(log (m+n))` 。
+>
+> ```
+> 输入:nums1 = [1,3], nums2 = [2]
+> 输出:2.00000
+> 解释:合并数组 = [1,2,3] ,中位数 2
+> ```
+
+**思路**:
+
+中位数是指将一组数据从小到大排序后,位于中间位置的数值。如果数据集中的元素数量是奇数,中位数就是中间的那个元素;如果是偶数,则中位数是中间两个元素的平均值。
+
+这道题要求时间复杂度为 O(log(m+n)),提示我们使用二分查找。关键思路是:
+
+1. **问题转化**:寻找第 k 小的元素,其中 k = (m+n+1)/2(奇数情况)或需要找第k和第k+1小的元素(偶数情况)
+2. **二分搜索**:在较短的数组上进行二分,确保左半部分元素个数等于右半部分
+3. **分割线性质**:左半部分的最大值 ≤ 右半部分的最小值
+
+```java
+public double findMedianSortedArrays(int[] nums1, int[] nums2) {
+ // 确保 nums1 是较短的数组
+ if (nums1.length > nums2.length) {
+ return findMedianSortedArrays(nums2, nums1);
+ }
+
+ int m = nums1.length;
+ int n = nums2.length;
+ int left = 0, right = m;
+
+ while (left <= right) {
+ // nums1的分割点
+ int cut1 = (left + right) / 2;
+ // nums2的分割点
+ int cut2 = (m + n + 1) / 2 - cut1;
+
+ // 处理边界情况
+ int left1 = (cut1 == 0) ? Integer.MIN_VALUE : nums1[cut1 - 1];
+ int left2 = (cut2 == 0) ? Integer.MIN_VALUE : nums2[cut2 - 1];
+ int right1 = (cut1 == m) ? Integer.MAX_VALUE : nums1[cut1];
+ int right2 = (cut2 == n) ? Integer.MAX_VALUE : nums2[cut2];
+
+ // 找到正确的分割
+ if (left1 <= right2 && left2 <= right1) {
+ // 总长度为偶数
+ if ((m + n) % 2 == 0) {
+ return (Math.max(left1, left2) + Math.min(right1, right2)) / 2.0;
+ } else {
+ // 总长度为奇数
+ return Math.max(left1, left2);
+ }
+ }
+ // nums1分割点太靠右
+ else if (left1 > right2) {
+ right = cut1 - 1;
+ }
+ // nums1分割点太靠左
+ else {
+ left = cut1 + 1;
+ }
+ }
+
+ return 0.0;
+}
+```
+
+**算法步骤详解**:
+1. 确保在较短数组上进行二分搜索,减少搜索空间
+2. 计算两个数组的分割点,使得左半部分元素个数 = 右半部分元素个数(或多1个)
+3. 检查分割是否正确:左半部分最大值 ≤ 右半部分最小值
+4. 根据总长度奇偶性计算中位数
+
+**时间复杂度**:$O(log(min(m,n)))$,在较短数组上进行二分搜索
+**空间复杂度**:$O(1)$,只使用常量级别的额外空间
+
+
+
+## 四、常见题目补充
+
+
+### [x 的平方根『69』](https://leetcode.cn/problems/sqrtx/)
+
+> 给你一个非负整数 `x`,计算并返回 `x` 的平方根的整数部分。
+
+**思路**:在区间 `[1, x/2]` 上二分,寻找最大的 `mid` 使得 `mid*mid <= x`。注意用 `long` 防止乘法溢出。
+
+```java
+public int mySqrt(int x) {
+ if (x < 2) return x;
+ int left = 1, right = x / 2, ans = 0;
+ while (left <= right) {
+ int mid = left + (right - left) / 2;
+ if ((long) mid * mid <= x) {
+ ans = mid;
+ left = mid + 1;
+ } else {
+ right = mid - 1;
+ }
+ }
+ return ans;
+}
+```
+
+**时间复杂度**:$O(log x)$
+**空间复杂度**:$O(1)$
+
+
+### [第一个错误的版本『278』](https://leetcode.cn/problems/first-bad-version/)
+
+> 有 `n` 个版本,从 1 到 n,给你 `isBadVersion(version)` 接口。找出第一个错误版本。
+
+**思路**:典型“找左边界”。`isBad(mid)` 为真时收缩到左侧,并记录答案。
+
+```java
+// isBadVersion(version) 由系统提供
+public int firstBadVersion(int n) {
+ int left = 1, right = n, ans = n;
+ while (left <= right) {
+ int mid = left + (right - left) / 2;
+ if (isBadVersion(mid)) {
+ ans = mid;
+ right = mid - 1;
+ } else {
+ left = mid + 1;
+ }
+ }
+ return ans;
+}
+```
+
+**时间复杂度**:$O(log n) $
+**空间复杂度**:$O(1)$
+
+
+### [有序数组中的单一元素『540』](https://leetcode.cn/problems/single-element-in-a-sorted-array/)
+
+> 一个按升序排列的数组,除了某个元素只出现一次外,其余每个元素均出现两次。找出这个元素。
+
+**思路**:利用“成对对齐”的性质。让 `mid` 偶数化(`mid -= mid % 2`),若 `nums[mid] == nums[mid+1]`,唯一元素在右侧;否则在左侧(含 `mid`)。
+
+```java
+public int singleNonDuplicate(int[] nums) {
+ int left = 0, right = nums.length - 1;
+ while (left < right) {
+ int mid = left + (right - left) / 2;
+ if (mid % 2 == 1) mid--; // 偶数化
+ if (nums[mid] == nums[mid + 1]) {
+ left = mid + 2;
+ } else {
+ right = mid;
+ }
+ }
+ return nums[left];
+}
+```
+
+**时间复杂度**:$O(log n) $
+**空间复杂度**:$O(1)$
+
+
+### [爱吃香蕉的珂珂『875』](https://leetcode.cn/problems/koko-eating-bananas/)
+
+> 给定每堆香蕉数量 `piles` 和总小时数 `h`,最小化吃速 `k` 使得能在 `h` 小时内吃完。
+
+**思路**:对答案 `k` 二分。检验函数为以速率 `k` 需要的总小时数是否 `<= h`。
+
+```java
+public int minEatingSpeed(int[] piles, int h) {
+ int left = 1, right = 0;
+ for (int p : piles) right = Math.max(right, p);
+ while (left < right) {
+ int mid = left + (right - left) / 2;
+ long hours = 0;
+ for (int p : piles) {
+ hours += (p + mid - 1) / mid; // 向上取整
+ }
+ if (hours <= h) right = mid;
+ else left = mid + 1;
+ }
+ return left;
+}
+```
+
+**时间复杂度**:$O(n log max(piles))$
+**空间复杂度**:$O(1)$
+
+
+### [在 D 天内送达包裹的能力『1011』](https://leetcode.cn/problems/capacity-to-ship-packages-within-d-days/)
+
+> 给定包裹重量数组 `weights` 与天数 `days`,求最小运力使得能按顺序在 `days` 天内送达。
+
+**思路**:对答案(运力)二分。下界是最大单件重量,上界是总重量。检验函数为用给定运力需要的天数是否 `<= days`。
+
+```java
+public int shipWithinDays(int[] weights, int days) {
+ int left = 0, right = 0;
+ for (int w : weights) {
+ left = Math.max(left, w);
+ right += w;
+ }
+ while (left < right) {
+ int mid = left + (right - left) / 2;
+ int need = 1, cur = 0;
+ for (int w : weights) {
+ if (cur + w > mid) {
+ need++;
+ cur = 0;
+ }
+ cur += w;
+ }
+ if (need <= days) right = mid;
+ else left = mid + 1;
+ }
+ return left;
+}
+```
+
+**时间复杂度**:$O(n log(sum(weights)))$
+**空间复杂度**:$O(1)$
diff --git a/docs/data-structure-algorithms/algorithm/DFS-BFS.md b/docs/data-structure-algorithms/algorithm/DFS-BFS.md
new file mode 100644
index 0000000000..658a24b592
--- /dev/null
+++ b/docs/data-structure-algorithms/algorithm/DFS-BFS.md
@@ -0,0 +1,627 @@
+---
+title: DFS 与 BFS
+date: 2025-03-09
+tags:
+ - Algorithm
+categories: BFS DFS
+---
+
+
+
+> 在线性结构中,按照顺序一个一个地看到所有的元素,称为线性遍历。在非线性结构中,由于元素之间的组织方式变得复杂,就有了不同的遍历行为。其中最常见的遍历有:**深度优先遍历**(Depth-First-Search)和**广度优先遍历**(Breadth-First-Search)。它们的思想非常简单,但是在算法的世界里发挥着巨大的作用,也是面试高频考点。
+>
+
+「遍历」和「搜索」可以看作是两个等价概念,通过遍历 **所有** 的可能的情况达到搜索的目的。遍历是手段,搜索是目的。因此「优先遍历」也叫「优先搜索」。
+
+
+
+## 一、DFS 与 BFS的核心原理
+
+
+
+1. **DFS(深度优先搜索)**
+
+ - 核心思想:优先沿一条路径深入探索,直到无法继续再回溯到上一个节点继续搜索,类似“不撞南墙不回头”。
+ - **适用场景**:寻找所有可能路径、拓扑排序、连通性问题等。
+ - **实现方式**:递归(隐式栈)或显式栈。
+
+ ```Java
+ void dfs(TreeNode node) {
+ if (node == null) return;
+ // 处理当前节点
+ dfs(node.left); // 深入左子树
+ dfs(node.right); // 深入右子树
+ }
+ ```
+
+2. **BFS(广度优先搜索)**
+
+ - 核心思想:按层次逐层遍历,优先访问同一层的所有节点,常用于最短路径问题。
+ - **适用场景**:层序遍历、最短路径(无权图)、扩散类问题。
+ - **实现方式**:队列(FIFO)。
+
+ ```Java
+ void bfs(TreeNode root) {
+ Queue queue = new LinkedList<>();
+ queue.offer(root);
+ while (!queue.isEmpty()) {
+ int size = queue.size();
+ for (int i = 0; i < size; i++) {
+ TreeNode node = queue.poll();
+ // 处理当前节点
+ if (node.left != null) queue.offer(node.left);
+ if (node.right != null) queue.offer(node.right);
+ }
+ }
+ }
+ ```
+
+只是比较两段代码的话,最直观的感受就是:DFS 遍历的代码比 BFS 简洁太多了!这是因为递归的方式隐含地使用了系统的 栈,我们不需要自己维护一个数据结构。如果只是简单地将二叉树遍历一遍,那么 DFS 显然是更方便的选择。
+
+
+
+## 二、勇往直前的深度优先搜索
+
+### 2.1 深度优先遍历的形象描述
+
+「一条路走到底,不撞南墙不回头」是对「深度优先遍历」的最直观描述。
+
+说明:
+
+- 深度优先遍历只要前面有可以走的路,就会一直向前走,直到无路可走才会回头;
+- 「无路可走」有两种情况:① 遇到了墙;② 遇到了已经走过的路;
+- 在「无路可走」的时候,沿着原路返回,直到回到了还有未走过的路的路口,尝试继续走没有走过的路径;
+- 有一些路径没有走到,这是因为找到了出口,程序就停止了;
+- 「深度优先遍历」也叫「深度优先搜索」,遍历是行为的描述,搜索是目的(用途);
+- 遍历不是很深奥的事情,把 **所有** 可能的情况都看一遍,才能说「找到了目标元素」或者「没找到目标元素」。遍历也称为 **穷举**,穷举的思想在人类看来虽然很不起眼,但借助 **计算机强大的计算能力**,穷举可以帮助我们解决很多专业领域知识不能解决的问题。
+
+
+
+### 2.2 树的深度优先遍历
+
+我们以「二叉树」的深度优先遍历为例,介绍树的深度优先遍历。
+
+二叉树的深度优先遍历从「根结点」开始,依次 「递归地」 遍历「左子树」的所有结点和「右子树」的所有结点。
+
+
+
+> 事实上,「根结点 → 右子树 → 左子树」也是一种深度优先遍历的方式,为了符合人们「先左再右」的习惯。如果没有特别说明,树的深度优先遍历默认都按照 「根结点 → 左子树 → 右子树」 的方式进行。
+
+**二叉树深度优先遍历的递归终止条件**:遍历完一棵树的 **所有** 叶子结点,等价于遍历到 **空结点**。
+
+二叉树的深度优先遍历可以分为:前序遍历、中序遍历和后序遍历。
+
+
+
+- 前序遍历:根节点 → 左子树 → 右子树
+- 中序遍历: 左子树 → 根节点 → 右子树
+- 后序遍历:左子树 → 右子树 → 根节点
+
+> 友情提示:后序遍历是非常重要的遍历方式,解决很多树的问题都采用了后序遍历的思想,请大家务必重点理解「后序遍历」一层一层向上传递信息的遍历方式。并在做题的过程中仔细体会「后序遍历」思想的应用。
+
+**为什么前、中、后序遍历都是深度优先遍历**
+
+可以把树的深度优先遍历想象成一只蚂蚁,从根结点绕着树的外延走一圈。每一个结点的外延按照下图分成三个部分:前序遍历是第一部分,中序遍历是第二部分,后序遍历是第三部分。
+
+
+
+**重要性质**
+
+根据定义不难得到以下性质。
+
+ - 性质 1:二叉树的 前序遍历 序列,根结点一定是 最先 访问到的结点;
+ - 性质 2:二叉树的 后序遍历 序列,根结点一定是 最后 访问到的结点;
+ - 性质 3:根结点把二叉树的 中序遍历 序列划分成两个部分,第一部分的所有结点构成了根结点的左子树,第二部分的所有结点构成了根结点的右子树。
+
+> 根据这些性质,可以完成「力扣」第 105 题、第 106 题
+
+
+
+### 2.3 图的深度优先遍历
+
+深度优先遍历有「回头」的过程,在树中由于不存在「环」(回路),对于每一个结点来说,每一个结点只会被递归处理一次。而「图」中由于存在「环」(回路),就需要 记录已经被递归处理的结点(通常使用布尔数组或者哈希表),以免结点被重复遍历到。
+
+**说明**:深度优先遍历的结果通常与图的顶点如何存储有关,所以图的深度优先遍历的结果并不唯一。
+
+#### [课程表](https://leetcode.cn/problems/course-schedule/)
+
+> 你这个学期必须选修 `numCourses` 门课程,记为 `0` 到 `numCourses - 1` 。
+>
+> 在选修某些课程之前需要一些先修课程。 先修课程按数组 `prerequisites` 给出,其中 `prerequisites[i] = [ai, bi]` ,表示如果要学习课程 `ai` 则 **必须** 先学习课程 `bi` 。
+>
+> - 例如,先修课程对 `[0, 1]` 表示:想要学习课程 `0` ,你需要先完成课程 `1` 。
+>
+> 请你判断是否可能完成所有课程的学习?如果可以,返回 `true` ;否则,返回 `false` 。
+>
+> ```
+> 输入:numCourses = 2, prerequisites = [[1,0],[0,1]]
+> 输出:false
+> 解释:总共有 2 门课程。学习课程 1 之前,你需要先完成课程 0 ;并且学习课程 0 之前,你还应先完成课程 1 。这是不可能的。
+> ```
+
+思路:对于课程表问题,实际上是在寻找一个有向无环图(DAG)的环,以确定是否存在一个有效的课程学习顺序。
+
+```java
+public class Solution {
+ private boolean hasCycle = false;
+
+ public boolean canFinish(int numCourses, int[][] prerequisites) {
+ // 构建图的邻接表表示
+ List> graph = new ArrayList<>();
+ for (int i = 0; i < numCourses; i++) {
+ graph.add(new ArrayList<>());
+ }
+ for (int[] prerequisite : prerequisites) {
+ int course = prerequisite[0];
+ int prerequisiteCourse = prerequisite[1];
+ graph.get(prerequisiteCourse).add(course);
+ }
+
+ // 初始化访问状态数组
+ boolean[] visited = new boolean[numCourses];
+ boolean[] recursionStack = new boolean[numCourses];
+
+ // 对每个节点进行DFS遍历
+ for (int i = 0; i < numCourses; i++) {
+ if (!visited[i]) {
+ dfs(graph, visited, recursionStack, i);
+ }
+ // 如果在DFS过程中检测到了环,则提前返回false
+ if (hasCycle) {
+ return false;
+ }
+ }
+
+ // 如果没有检测到环,则返回true
+ return true;
+ }
+
+ private void dfs(List> graph, boolean[] visited, boolean[] recursionStack, int node) {
+ // 将当前节点标记为已访问
+ visited[node] = true;
+ // 将当前节点加入递归栈
+ recursionStack[node] = true;
+
+ // 遍历当前节点的所有邻接节点
+ for (int neighbor : graph.get(node)) {
+ // 如果邻接节点未被访问,则递归访问
+ if (!visited[neighbor]) {
+ dfs(graph, visited, recursionStack, neighbor);
+ } else if (recursionStack[neighbor]) {
+ // 如果邻接节点已经在递归栈中,说明存在环
+ hasCycle = true;
+ }
+ }
+
+ // 将当前节点从递归栈中移除
+ recursionStack[node] = false;
+ }
+}
+```
+
+
+
+### 2.4 深度优先遍历的两种实现方式
+
+在深度优先遍历的过程中,需要将 当前遍历到的结点 的相邻结点 暂时保存 起来,以便在回退的时候可以继续访问它们。遍历到的结点的顺序呈现「后进先出」的特点,因此 深度优先遍历可以通过「栈」实现。
+
+再者,深度优先遍历有明显的递归结构。我们知道支持递归实现的数据结构也是栈。因此实现深度优先遍历有以下两种方式:
+
+- 编写递归方法;
+- 编写栈,通过迭代的方式实现。
+
+
+
+### 2.5 DFS 算法框架
+
+#### **1. 基础模板(以全排列为例)**
+
+```java
+// 全局变量:记录结果和路径
+List> result = new ArrayList<>();
+List path = new ArrayList<>();
+boolean[] visited; // 访问标记数组
+
+void dfs(int[] nums, int depth) {
+ // 终止条件:路径长度达到要求
+ if (depth == nums.length) {
+ result.add(new ArrayList<>(path));
+ return;
+ }
+
+ // 遍历选择列表
+ for (int i = 0; i < nums.length; i++) {
+ if (!visited[i]) {
+ // 做选择:标记已访问,加入路径
+ visited[i] = true;
+ path.add(nums[i]);
+ // 递归进入下一层
+ dfs(nums, depth + 1);
+ // 撤销选择:回溯
+ visited[i] = false;
+ path.remove(path.size() - 1);
+ }
+ }
+}
+```
+
+**关键点**:
+
+- **路径记录**:通过`path`列表保存当前路径。
+- **访问标记**:使用`visited`数组避免重复访问。
+- **递归与回溯**:递归调用后必须撤销选择以恢复状态
+
+#### **2. 二维矩阵遍历框架(如岛屿问题)**
+
+```java
+void dfs(int[][] grid, int i, int j) {
+ // 边界检查
+ if (i < 0 || j < 0 || i >= grid.length || j >= grid[0].length) return;
+ // 终止条件:遇到非陆地或已访问
+ if (grid[i][j] != '1') return;
+
+ // 标记为已访问(直接修改原矩阵)
+ grid[i][j] = '0';
+ // 四个方向递归
+ dfs(grid, i + 1, j); // 下
+ dfs(grid, i - 1, j); // 上
+ dfs(grid, i, j + 1); // 右
+ dfs(grid, i, j - 1); // 左
+}
+```
+
+**适用场景**:矩阵中的连通性问题(如岛屿数量、迷宫路径)
+
+
+
+### 2.6 练习
+
+> https://leetcode.cn/problem-list/depth-first-search/
+
+请大家通过这些问题体会 「**如何设计递归函数的返回值**」 帮助我们解决问题。并理解这些简单的问题其实都是「深度优先遍历」的思想中「后序遍历」思想的体现,真正程序在执行的时候,是通过「一层一层向上汇报」的方式,最终在根结点汇总整棵树遍历的结果。
+
+1. 完成「力扣」第 104 题:二叉树的最大深度(简单):设计递归函数的返回值;
+2. 完成「力扣」第 111 题:二叉树的最小深度(简单):设计递归函数的返回值;
+3. 完成「力扣」第 112 题:路径总和(简单):设计递归函数的返回值;
+4. 完成「力扣」第 226 题:翻转二叉树(简单):前中后序遍历、广度优先遍历均可,中序遍历有一个小小的坑;
+5. 完成「力扣」第 100 题:相同的树(简单):设计递归函数的返回值;
+6. 完成「力扣」第 101 题:对称二叉树(简单):设计递归函数的返回值;
+7. 完成「力扣」第 129 题:求根到叶子节点数字之和(中等):设计递归函数的返回值。
+8. 完成「力扣」第 236 题:二叉树的最近公共祖先(中等):使用后序遍历的典型问题。
+
+请大家完成下面这些树中的问题,加深对前序遍历序列、中序遍历序列、后序遍历序列的理解。
+
+9. 完成「力扣」第 105 题:从前序与中序遍历序列构造二叉树(中等);
+10. 完成「力扣」第 106 题:从中序与后序遍历序列构造二叉树(中等);
+11. 完成「力扣」第 1008 题:前序遍历构造二叉搜索树(中等);
+
+12. 完成「力扣」第 1028 题:从先序遍历还原二叉树(困难)。
+
+> 友情提示:需要用到后序遍历思想的一些经典问题,这些问题可能有一些难度,可以不用急于完成。先做后面的问题,见多了类似的问题以后,慢慢理解「后序遍历」一层一层向上汇报,在根结点汇总的遍历思想。
+
+
+
+### 2.7 总结
+
+- 遍历可以用于搜索,思想是穷举,遍历是实现搜索的手段;
+- 树的「前、中、后」序遍历都是深度优先遍历;
+- 树的后序遍历很重要;
+- 由于图中存在环(回路),图的深度优先遍历需要记录已经访问过的结点,以避免重复访问;
+- 遍历是一种简单、朴素但是很重要的算法思想,很多树和图的问题就是在树和图上执行一次遍历,在遍历的过程中记录有用的信息,得到需要结果,区别在于为了解决不同的问题,在遍历的时候传递了不同的 与问题相关 的数据。
+
+
+
+
+
+## 三、齐头并进的广度优先搜索
+
+> DFS(深度优先搜索)和 BFS(广度优先搜索)就像孪生兄弟,提到一个总是想起另一个。然而在实际使用中,我们用 DFS 的时候远远多于 BFS。那么,是不是 BFS 就没有什么用呢?
+>
+> 如果我们使用 DFS/BFS 只是为了遍历一棵树、一张图上的所有结点的话,那么 DFS 和 BFS 的能力没什么差别,我们当然更倾向于更方便写、空间复杂度更低的 DFS 遍历。不过,某些使用场景是 DFS 做不到的,只能使用 BFS 遍历。
+>
+
+
+
+「广度优先遍历」的思想在生活中随处可见:
+
+如果我们要找一个医生或者律师,我们会先在自己的一度人脉中遍历(查找),如果没有找到,继续在自己的二度人脉中遍历(查找),直到找到为止。
+
+### 3.1 广度优先遍历借助「队列」实现
+
+广度优先遍历呈现出「一层一层向外扩张」的特点,**先看到的结点先遍历,后看到的结点后遍历**,因此「广度优先遍历」可以借助「队列」实现。
+
+
+
+**说明**:遍历到一个结点时,如果这个结点有左(右)孩子结点,依次将它们加入队列。
+
+> 友情提示:广度优先遍历的写法相对固定,我们不建议大家背代码、记模板。在深刻理解广度优先遍历的应用场景(找无权图的最短路径),借助「队列」实现的基础上,多做练习,写对代码就是自然而然的事情了
+
+我们先介绍「树」的广度优先遍历,再介绍「图」的广度优先遍历。事实上,它们是非常像的。
+
+
+
+### 3.2 树的广度优先遍历
+
+二叉树的层序遍历
+
+> 给你一个二叉树,请你返回其按 层序遍历 得到的节点值。 (即逐层地,从左到右访问所有节点)。
+>
+
+思路分析:
+
+- 题目要求我们一层一层输出树的结点的值,很明显需要使用「广度优先遍历」实现;
+- 广度优先遍历借助「队列」实现;
+
+- 注意:
+ - 这样写 `for (int i = 0; i < queue.size(); i++) { `代码是不能通过测评的,这是因为 `queue.size()` 在循环中是变量。正确的做法是:每一次在队列中取出元素的个数须要先暂存起来;
+ - 子结点入队的时候,非空的判断很重要:在队列的队首元素出队的时候,一定要在左(右)子结点非空的时候才将左(右)子结点入队。
+- 树的广度优先遍历的写法模式相对固定:
+ - 使用队列;
+ - 在队列非空的时候,动态取出队首元素;
+ - 取出队首元素的时候,把队首元素相邻的结点(非空)加入队列。
+
+大家在做题的过程中需要多加练习,融汇贯通,不须要死记硬背。
+
+
+
+```java
+public List> levelOrder(TreeNode root) {
+ List> result = new ArrayList<>(); // 存储遍历结果的列表
+ if (root == null) {
+ return result; // 如果树为空,直接返回空列表
+ }
+
+ Queue queue = new LinkedList<>(); // 创建一个队列用于层序遍历
+ queue.offer(root); // 将根节点加入队列
+
+ while (!queue.isEmpty()) { // 当队列不为空时继续遍历
+ int levelSize = queue.size(); // 当前层的节点数
+ List currentLevel = new ArrayList<>(); // 存储当前层节点值的列表
+
+ for (int i = 0; i < levelSize; i++) {
+ TreeNode currentNode = queue.poll(); // 从队列中取出一个节点
+ currentLevel.add(currentNode.val); // 将节点值加入当前层列表
+
+ // 如果左子节点不为空,将其加入队列
+ if (currentNode.left != null) {
+ queue.offer(currentNode.left);
+ }
+ // 如果右子节点不为空,将其加入队列
+ if (currentNode.right != null) {
+ queue.offer(currentNode.right);
+ }
+ }
+
+ result.add(currentLevel); // 将当前层列表加入结果列表
+ }
+
+ return result; // 返回遍历结果
+}
+```
+
+
+
+### 3.3 BFS 算法框架
+
+**基础模板(队列+访问标记)**
+
+```java
+public int bfs(Node start, Node target) {
+ Queue queue = new LinkedList<>(); // 核心队列结构
+ Set visited = new HashSet<>(); // 防止重复访问
+ int step = 0; // 记录扩散步数
+
+ queue.offer(start);
+ visited.add(start);
+
+ while (!queue.isEmpty()) {
+ int levelSize = queue.size(); // 当前层节点数
+ for (int i = 0; i < levelSize; i++) { // 遍历当前层所有节点
+ Node cur = queue.poll();
+ // 终止条件(根据问题场景调整)
+ if (cur.equals(target)) return step;
+
+ // 扩散相邻节点(根据数据结构调整)
+ for (Node neighbor : getNeighbors(cur)) {
+ if (!visited.contains(neighbor)) {
+ queue.offer(neighbor);
+ visited.add(neighbor);
+ }
+ }
+ }
+ step++; // 步数递增
+ }
+ return -1; // 未找到目标
+}
+```
+
+**关键点** :
+
+- **队列控制层次**:通过 `levelSize` 逐层遍历,保证找到最短路径。
+- **访问标记**:避免重复访问(如矩阵问题可改为修改原数据)。
+- **扩散逻辑**:`getNeighbors()` 需根据具体数据结构实现(如二叉树、图、网格等)。
+
+
+
+### 3.4 使用广度优先遍历得到无权图的最短路径
+
+在 无权图 中,由于广度优先遍历本身的特点,假设源点为 source,只有在遍历到 所有 距离源点 source 的距离为 d 的所有结点以后,才能遍历到所有 距离源点 source 的距离为 d + 1 的所有结点。也可以使用「两点之间、线段最短」这条经验来辅助理解如下结论:从源点 source 到目标结点 target 走直线走过的路径一定是最短的。
+
+> 在一棵树中,一个结点到另一个结点的路径是唯一的,但在图中,结点之间可能有多条路径,其中哪条路最近呢?这一类问题称为最短路径问题。最短路径问题也是 BFS 的典型应用,而且其方法与层序遍历关系密切。
+>
+> 在二叉树中,BFS 可以实现一层一层的遍历。在图中同样如此。从源点出发,BFS 首先遍历到第一层结点,到源点的距离为 1,然后遍历到第二层结点,到源点的距离为 2…… 可以看到,用 BFS 的话,距离源点更近的点会先被遍历到,这样就能找到到某个点的最短路径了。
+>
+> 
+>
+> 小贴士:
+>
+> 很多同学一看到「最短路径」,就条件反射地想到「Dijkstra 算法」。为什么 BFS 遍历也能找到最短路径呢?
+>
+> 这是因为,Dijkstra 算法解决的是带权最短路径问题,而我们这里关注的是无权最短路径问题。也可以看成每条边的权重都是 1。这样的最短路径问题,用 BFS 求解就行了。
+>
+> 在面试中,你可能更希望写 BFS 而不是 Dijkstra。毕竟,敢保证自己能写对 Dijkstra 算法的人不多。
+>
+> 最短路径问题属于图算法。由于图的表示和描述比较复杂,本文用比较简单的网格结构代替。网格结构是一种特殊的图,它的表示和遍历都比较简单,适合作为练习题。在 LeetCode 中,最短路径问题也以网格结构为主。
+
+
+
+### 3.5 图论中的最短路径问题概述
+
+在图中,由于 图中存在环,和深度优先遍历一样,广度优先遍历也需要在遍历的时候记录已经遍历过的结点。特别注意:将结点添加到队列以后,一定要马上标记为「已经访问」,否则相同结点会重复入队,这一点在初学的时候很容易忽略。如果很难理解这样做的必要性,建议大家在代码中打印出队列中的元素进行调试:在图中,如果入队的时候不马上标记为「已访问」,相同的结点会重复入队,这是不对的。
+
+另外一点还需要强调,广度优先遍历用于求解「无权图」的最短路径,因此一定要认清「无权图」这个前提条件。如果是带权图,就需要使用相应的专门的算法去解决它们。事实上,这些「专门」的算法的思想也都基于「广度优先遍历」的思想,我们为大家例举如下:
+
+- 带权有向图、且所有权重都非负的单源最短路径问题:使用 Dijkstra 算法;
+- 带权有向图的单源最短路径问题:Bellman-Ford 算法;
+
+- 一个图的所有结点对的最短路径问题:Floy-Warshall 算法。
+
+这里列出的以三位计算机科学家的名字命名的算法,大家可以在《算法导论》这本经典著作的第 24 章、第 25 章找到相关知识的介绍。值得说明的是:应用任何一种算法,都需要认清使用算法的前提,不满足前提直接套用算法是不可取的。深刻理解应用算法的前提,也是学习算法的重要方法。例如我们在学习「二分查找」算法、「滑动窗口」算法的时候,就可以问自己,这个问题为什么可以使用「二分查找」,为什么可以使用「滑动窗口」。我们知道一个问题可以使用「优先队列」解决,是什么样的需求促使我们想到使用「优先队列」,而不是「红黑树(平衡二叉搜索树)」,想清楚使用算法(数据结构)的前提更重要。
+
+> [无向图中连通分量的数目『323』](https://leetcode.cn/problems/number-of-connected-components-in-an-undirected-graph/)
+>
+> 你有一个包含 `n` 个节点的图。给定一个整数 `n` 和一个数组 `edges` ,其中 `edges[i] = [ai, bi]` 表示图中 `ai` 和 `bi` 之间有一条边。
+>
+> 返回 *图中已连接分量的数目* 。
+>
+> 
+>
+> ```
+> 输入: n = 5, edges = [[0, 1], [1, 2], [3, 4]]
+> 输出: 2
+> ```
+>
+> 思路分析:
+>
+> 首先需要对输入数组进行处理,由于 n 个结点的编号从 0 到 n - 1 ,因此可以使用「嵌套数组」表示邻接表,具体实现请见参考代码;
+> 然后遍历每一个顶点,对每一个顶点执行一次广度优先遍历,注意:在遍历的过程中使用 visited 布尔数组记录已经遍历过的结点。
+>
+> ```java
+> public int countComponents(int n, int[][] edges) {
+> // 第 1 步:构建图
+> List[] adj = new ArrayList[n];
+> for (int i = 0; i < n; i++) {
+> adj[i] = new ArrayList<>();
+> }
+> // 无向图,所以需要添加双向引用
+> for (int[] edge : edges) {
+> adj[edge[0]].add(edge[1]);
+> adj[edge[1]].add(edge[0]);
+> }
+>
+> // 第 2 步:开始广度优先遍历
+> int res = 0;
+> boolean[] visited = new boolean[n];
+> for (int i = 0; i < n; i++) {
+> if (!visited[i]) {
+> bfs(adj, i, visited);
+> res++;
+> }
+> }
+> return res;
+> }
+>
+> /**
+> * @param adj 邻接表
+> * @param u 从 u 这个顶点开始广度优先遍历
+> * @param visited 全局使用的 visited 布尔数组
+> */
+> private void bfs(List[] adj, int u, boolean[] visited) {
+> Queue queue = new LinkedList<>();
+> queue.offer(u);
+> visited[u] = true;
+>
+> while (!queue.isEmpty()) {
+> Integer front = queue.poll();
+> // 获得队首结点的所有后继结点
+> List successors = adj[front];
+> for (int successor : successors) {
+> if (!visited[successor]) {
+> queue.offer(successor);
+> // 特别注意:在加入队列以后一定要将该结点标记为访问,否则会出现结果重复入队的情况
+> visited[successor] = true;
+> }
+> }
+> }
+> }
+> ```
+>
+> 复杂度分析:
+>
+> - 时间复杂度:O(V + E)O(V+E),这里 EE 是边的条数,即数组 edges 的长度,初始化的时候遍历数组得到邻接表。这里 VV 为输入整数 n,遍历的过程是每一个结点执行一次深度优先遍历,时间复杂度为 O(V)O(V);
+> - 空间复杂度:O(V + E)O(V+E),综合考虑邻接表 O(V + E)O(V+E)、visited 数组 O(V)O(V)、队列的长度 O(V)O(V) 三者得到。
+> 说明:和深度优先遍历一样,图的广度优先遍历的结果并不唯一,与每个结点的相邻结点的访问顺序有关。
+
+### 3.6 练习
+
+> 友情提示:第 1 - 4 题是广度优先遍历的变形问题,写对这些问题有助于掌握广度优先遍历的代码编写逻辑和细节。
+
+1. 完成「力扣」第 107 题:二叉树的层次遍历 II(简单);
+2. 完成《剑指 Offer》第 32 - I 题:从上到下打印二叉树(中等);
+3. 完成《剑指 Offer》第 32 - III 题:从上到下打印二叉树 III(中等);
+4. 完成「力扣」第 103 题:二叉树的锯齿形层次遍历(中等);
+5. 完成「力扣」第 429 题:N 叉树的层序遍历(中等);
+6. 完成「力扣」第 993 题:二叉树的堂兄弟节点(中等);
+
+
+
+#### 二维矩阵遍历(岛屿问题)
+
+```java
+void bfs(char[][] grid, int x, int y) {
+ int[][] dirs = {{1,0}, {-1,0}, {0,1}, {0,-1}};
+ Queue queue = new LinkedList<>();
+ queue.offer(new int[]{x, y});
+ grid[x][y] = '0'; // 直接修改矩阵代替visited
+
+ while (!queue.isEmpty()) {
+ int[] pos = queue.poll();
+ for (int[] d : dirs) {
+ int nx = pos[0] + d[0], ny = pos[1] + d[1];
+ if (nx >=0 && ny >=0 && nx < grid.length && ny < grid[0].length
+ && grid[nx][ny] == '1') {
+ queue.offer(new int[]{nx, ny});
+ grid[nx][ny] = '0'; // 标记为已访问
+ }
+ }
+ }
+}
+```
+
+**优化点** :
+
+- 直接修改原矩阵代替`visited`集合,节省空间。
+- 四方向扩散的坐标计算。
+
+#### 图的邻接表遍历
+
+```java
+public void bfsGraph(Map> graph, int start) {
+ Queue queue = new LinkedList<>();
+ boolean[] visited = new boolean[graph.size()];
+ queue.offer(start);
+ visited[start] = true;
+
+ while (!queue.isEmpty()) {
+ int node = queue.poll();
+ System.out.print(node + " ");
+ for (int neighbor : graph.get(node)) {
+ if (!visited[neighbor]) {
+ queue.offer(neighbor);
+ visited[neighbor] = true;
+ }
+ }
+ }
+}
+```
+
+**数据结构** :
+
+- 使用`Map`或邻接表存储图结构。
+- 通过布尔数组标记访问状态。
+
+
+
+## Reference
+
+- https://leetcode-cn.com/problems/binary-tree-level-order-traversal/solution/bfs-de-shi-yong-chang-jing-zong-jie-ceng-xu-bian-l/
diff --git a/docs/data-structure-algorithms/algorithm/Double-Pointer.md b/docs/data-structure-algorithms/algorithm/Double-Pointer.md
new file mode 100755
index 0000000000..69bcd7b253
--- /dev/null
+++ b/docs/data-structure-algorithms/algorithm/Double-Pointer.md
@@ -0,0 +1,1466 @@
+---
+title: 双指针
+date: 2023-05-17
+tags:
+ - pointers
+ - algorithms
+categories: algorithms
+---
+
+
+
+> 在数组中并没有真正意义上的指针,但我们可以把索引当做数组中的指针
+>
+> 归纳下双指针算法,其实总共就三类
+>
+> - 左右指针,数组和字符串问题
+> - 快慢指针,主要是成环问题
+> - 滑动窗口,针对子串问题
+
+
+
+## 一、左右指针
+
+
+
+左右指针在数组中其实就是两个索引值,两个指针相向而行或者相背而行
+
+Javaer 一般这么表示:
+
+```java
+int left = 0;
+int right = arr.length - 1;
+while(left < right)
+ ***
+```
+
+这两个指针 **相向交替移动**, 看着像二分查找是吧,二分也属于左右指针。
+
+
+
+### [反转字符串](https://leetcode.cn/problems/reverse-string/)
+
+> 编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 char[] 的形式给出。
+>
+> 不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。
+>
+> 你可以假设数组中的所有字符都是 ASCII 码表中的可打印字符。
+>
+> ```
+> 输入:["h","e","l","l","o"]
+> 输出:["o","l","l","e","h"]
+> ```
+>
+
+思路:
+
+- 因为要反转,所以就不需要相向移动了,如果用双指针思路的话,其实就是遍历中交换左右指针的字符
+
+```java
+public void reverseString(char[] s) {
+ int left = 0;
+ int right = s.length - 1;
+ while (left < right){
+ char tmp = s[left];
+ s[left] = s[right];
+ s[right] = tmp;
+ left++;
+ right--;
+ }
+}
+```
+
+
+
+### [两数之和 II - 输入有序数组](https://leetcode.cn/problems/two-sum-ii-input-array-is-sorted/)
+
+> 给你一个下标从 1 开始的整数数组 numbers ,该数组已按 非递减顺序排列 ,请你从数组中找出满足相加之和等于目标数 target 的两个数。如果设这两个数分别是 numbers[index1] 和 numbers[index2] ,则 1 <= index1 < index2 <= numbers.length 。
+>
+> 以长度为 2 的整数数组 [index1, index2] 的形式返回这两个整数的下标 index1 和 index2。
+>
+> 你可以假设每个输入 只对应唯一的答案 ,而且你 不可以 重复使用相同的元素。
+>
+> 你所设计的解决方案必须只使用常量级的额外空间。
+>
+> ```
+> 输入:numbers = [2,7,11,15], target = 9
+> 输出:[1,2]
+> 解释:2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。
+> ```
+
+直接用左右指针套就可以
+
+```java
+public int[] twoSum(int[] nums, int target) {
+ int left = 0;
+ int rigth = nums.length - 1;
+ while (left < rigth) {
+ int tmp = nums[left] + nums[rigth];
+ if (target == tmp) {
+ //数组下标是从1开始的
+ return new int[]{left + 1, rigth + 1};
+ } else if (tmp > target) {
+ rigth--; //右移
+ } else {
+ left++; //左移
+ }
+ }
+ return new int[]{-1, -1};
+}
+```
+
+
+
+### [三数之和](https://leetcode.cn/problems/3sum/)
+
+> 给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请你返回所有和为 0 且不重复的三元组。
+>
+> 注意:答案中不可以包含重复的三元组。
+>
+
+思路:**排序、双指针、去重**
+
+第一个想法是,这三个数,两个指针?
+
+- 对数组排序,固定一个数 $nums[i]$ ,然后遍历数组,并移动左右指针求和,判断是否有等于 0 的情况
+
+- 特例:
+ - 排序后第一个数就大于 0,不干了
+
+ - 有三个需要去重的地方
+ - `nums[i] == nums[i - 1]` 直接跳过本次遍历
+
+ > **避免重复三元组:**
+ >
+ > - 我们从第一个元素开始遍历数组,逐步往后移动。如果当前的 `nums[i]` 和前一个 `nums[i - 1]` 相同,说明我们已经处理过以 `nums[i - 1]` 为起点的组合(即已经找过包含 `nums[i - 1]` 的三元组),此时再处理 `nums[i]` 会导致生成重复的三元组,因此可以跳过。
+ > - 如果我们检查 `nums[i] == nums[i + 1]`,由于 `nums[i + 1]` 还没有被处理,这种方式无法避免重复,并且会产生错误的逻辑。
+
+ - `nums[left] == nums[left + 1]` 移动指针,即去重
+
+ - `nums[right] == nums[right - 1]` 移动指针
+
+ > **避免重复的配对:**
+ >
+ > 在每次固定一个 `nums[i]` 后,剩下的两数之和问题通常使用双指针法来解决。双指针的左右指针 `left` 和 `right` 分别从数组的两端向中间逼近,寻找合适的配对。
+ >
+ > 为了**避免相同的数字被重复使用**,导致重复的三元组,双指针法中也需要跳过相同的元素。
+ >
+ > - 左指针跳过重复元素:
+ > - 如果 `nums[left] == nums[left + 1]`,说明接下来的数字与之前处理过的数字相同。为了避免生成相同的三元组,我们将 `left` 向右移动跳过这个重复的数字。
+ > - 右指针跳过重复元素:
+ > - 同样地,`nums[right] == nums[right - 1]` 也会导致重复的配对,因此右指针也要向左移动,跳过这个重复数字。
+
+```java
+public List> threeSum(int[] nums) {
+ //存放结果list
+ List> result = new ArrayList<>();
+ int length = nums.length;
+ //特例判断
+ if (length < 3) {
+ return result;
+ }
+ Arrays.sort(nums);
+ for (int i = 0; i < length; i++) {
+ //排序后的第一个数字就大于0,就说明没有符合要求的结果
+ if (nums[i] > 0) break;
+
+ //去重, 不能是 nums[i] == nums[i +1 ],因为顺序遍历的逻辑使得前一个元素已经被处理过,而后续的元素还没有处理
+ if (i > 0 && nums[i] == nums[i - 1]) continue;
+ //左右指针
+ int l = i + 1;
+ int r = length - 1;
+ while (l < r) {
+ int sum = nums[i] + nums[l] + nums[r];
+ if (sum == 0) {
+ result.add(Arrays.asList(nums[i], nums[l], nums[r]));
+ //去重(相同数字的话就移动指针)
+ //在将左指针和右指针移动的时候,先对左右指针的值,进行判断,以防[0,0,0]这样的造成数组越界
+ //不要用成 if 判断,只跳过 1 条,还会有重复的,且需要再加上 l 0) r--;
+ }
+ }
+ return result;
+}
+```
+
+
+
+### 盛最多水的容器
+
+> 给你 n 个非负整数 a1,a2,...,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0) 。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
+>
+> ```
+> 输入:[1,8,6,2,5,4,8,3,7]
+> 输出:49
+> 解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。
+> ```
+>
+> 
+
+**思路**:
+
+- 求得是水量,水量 = 两个指针指向的数字中较小值 * 指针之间的距离(水桶原理,最短的板才不会漏水)
+- 为了求最大水量,我们需要存储所有条件的水量,进行比较才行
+- **双指针相向移动**,循环收窄,直到两个指针相遇
+- 往哪个方向移动,需要考虑清楚,如果我们移动数字较大的那个指针,那么前者「两个指针指向的数字中较小值」不会增加,后者「指针之间的距离」会减小,那么这个乘积会更小,所以我们移动**数字较小的那个指针**
+
+```java
+public int maxArea(int[] height){
+ int left = 0;
+ int right = height.length - 1;
+ //需要保存各个阶段的值
+ int result = 0;
+ while(left < right){
+ //水量 = 两个指针指向的数字中较小值∗指针之间的距离
+ int area = Math.min(height[left],height[right]) * (right - left);
+ result = Math.max(result,area);
+ //移动数字较小的指针
+ if(height[left] <= height[right]){
+ left ++;
+ }else{
+ right--;
+ }
+ }
+ return result;
+}
+```
+
+
+
+### [验证回文串](https://leetcode.cn/problems/valid-palindrome/)
+
+> 如果在将所有大写字符转换为小写字符、并移除所有非字母数字字符之后,短语正着读和反着读都一样。则可以认为该短语是一个 回文串 。
+>
+> 字母和数字都属于字母数字字符。
+>
+> 给你一个字符串 s,如果它是 回文串 ,返回 true ;否则,返回 false 。
+>
+> ```
+> 输入: "A man, a plan, a canal: Panama"
+> 输出: true
+> 解释:"amanaplanacanalpanama" 是回文串
+> ```
+
+思路:
+
+- 没看题解前,因为这个例子中有各种逗号、空格啥的,我第一想到的其实就是先遍历放在一个数组里,然后再去判断,看题解可以在原字符串完成,降低了空间复杂度
+- 首先需要知道三个 API
+ - `Character.isLetterOrDigit` 确定指定的字符是否为字母或数字
+ - `Character.toLowerCase` 将大写字符转换为小写
+ - `public char charAt(int index)` String 中的方法,用于返回指定索引处的字符
+- 双指针,每移动一步,判断这两个值是不是相同
+- 两个指针相遇,则是回文串
+
+```java
+public boolean isPalindrome(String s) {
+ // 转换为小写并去掉非字母和数字的字符
+ int left = 0, right = s.length() - 1;
+
+ while (left < right) {
+ // 忽略左边非字母和数字字符
+ while (left < right && !Character.isLetterOrDigit(s.charAt(left))) {
+ left++;
+ }
+ // 忽略右边非字母和数字字符
+ while (left < right && !Character.isLetterOrDigit(s.charAt(right))) {
+ right--;
+ }
+ // 比较两边字符
+ if (Character.toLowerCase(s.charAt(left)) != Character.toLowerCase(s.charAt(right))) {
+ return false;
+ }
+ left++;
+ right--;
+ }
+ return true;
+}
+```
+
+
+
+### 二分查找
+
+有重复数字的话,返回的其实就是最右匹配
+
+```java
+public static int search(int[] nums, int target) {
+ int left = 0;
+ int right = nums.length - 1;
+ while (left <= right) {
+ //不直接使用(right+left)/2 是考虑数据大的时候溢出
+ int mid = (right - left) / 2 + left;
+ int tmp = nums[mid];
+ if (tmp == target) {
+ return mid;
+ } else if (tmp > target) {
+ //右指针移到中间位置 - 1,也避免不存在的target造成死循环
+ right = mid - 1;
+ } else {
+ //
+ left = mid + 1;
+ }
+ }
+ return -1;
+}
+```
+
+
+
+## 二、快慢指针
+
+「快慢指针」,也称为「同步指针」,所谓快慢指针,就是两个指针同向而行,一快一慢。快慢指针处理的大都是链表问题。
+
+
+
+### [环形链表](https://leetcode-cn.com/problems/linked-list-cycle/)
+
+> 给你一个链表的头节点 head ,判断链表中是否有环。
+>
+> 如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。
+>
+> 如果链表中存在环 ,则返回 true 。 否则,返回 false 。
+>
+> 
+
+思路:
+
+- 快慢指针,两个指针,一快一慢的话,慢指针每次只移动一步,而快指针每次移动两步。初始时,慢指针在位置 head,而快指针在位置 head.next。这样一来,如果在移动的过程中,快指针反过来追上慢指针,就说明该链表为环形链表。否则快指针将到达链表尾部,该链表不为环形链表。
+
+```java
+public boolean hasCycle(ListNode head) {
+ if (head == null || head.next == null) {
+ return false;
+ }
+ // 龟兔起跑
+ ListNode fast = head;
+ ListNode slow = head;
+
+ while (fast != null && fast.next != null) {
+ // 龟走一步
+ slow = slow.next;
+ // 兔走两步
+ fast = fast.next.next;
+ if (slow == fast) {
+ return true;
+ }
+ }
+ return false;
+}
+```
+
+
+
+### [环形链表II](https://leetcode-cn.com/problems/linked-list-cycle-ii)
+
+> 给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 `null`。
+>
+> ```
+> 输入:head = [3,2,0,-4], pos = 1
+> 输出:返回索引为 1 的链表节点
+> 解释:链表中有一个环,其尾部连接到第二个节点。
+> ```
+
+思路:
+
+- 最初,我就把有环理解错了,看题解觉得快慢指针相交的地方就是入环的节点
+
+- 假设环是这样的,slow 指针进入环后,又走了 b 的距离与 fast 相遇
+
+ 
+
+1. **检测是否有环**:通过快慢指针来判断链表中是否存在环。慢指针一次走一步,快指针一次走两步。如果链表中有环,两个指针最终会相遇;如果没有环,快指针会到达链表末尾。
+
+2. **找到环的起点**:
+- 当快慢指针相遇时,我们已经确认链表中存在环。
+
+- 从相遇点开始,慢指针保持不动,快指针回到链表头部,此时两个指针每次都走一步。两个指针会在环的起点再次相遇。
+
+```java
+public ListNode detectCycle(ListNode head) {
+ if (head == null || head.next == null) {
+ return null;
+ }
+
+ ListNode slow = head;
+ ListNode fast = head;
+
+ // 判断是否有环
+ while (fast != null && fast.next != null) {
+ slow = slow.next;
+ fast = fast.next.next;
+ // 快慢指针相遇,说明有环
+ if (slow == fast) {
+ break;
+ }
+ }
+
+ // 如果没有环
+ if (fast == null || fast.next == null) {
+ return null;
+ }
+
+ // 快指针回到起点,慢指针保持在相遇点
+ fast = head;
+ while (fast != slow) {
+ fast = fast.next;
+ slow = slow.next;
+ }
+
+ // 此时快慢指针相遇的地方就是环的起点
+ return slow;
+}
+```
+
+
+
+### [链表的中间结点](https://leetcode.cn/problems/middle-of-the-linked-list/)
+
+> 给定一个头结点为 `head` 的非空单链表,返回链表的中间结点。
+>
+> 如果有两个中间结点,则返回第二个中间结点。(给定链表的结点数介于 `1` 和 `100` 之间。)
+>
+> ```
+> 输入:[1,2,3,4,5]
+> 输出:此列表中的结点 3 (序列化形式:[3,4,5])
+> 返回的结点值为 3 。 (测评系统对该结点序列化表述是 [3,4,5])。
+> 注意,我们返回了一个 ListNode 类型的对象 ans,这样:
+> ans.val = 3, ans.next.val = 4, ans.next.next.val = 5, 以及 ans.next.next.next = NULL.
+> ```
+
+思路:
+
+- 快慢指针遍历,当 `fast` 到达链表的末尾时,`slow` 必然位于中间
+
+```java
+public ListNode middleNode(ListNode head) {
+ ListNode fast = head;
+ ListNode slow = head;
+ while (fast != null && fast.next != null) {
+ slow = slow.next;
+ fast = fast.next.next;
+ }
+ return slow;
+}
+```
+
+
+
+### [ 回文链表](https://leetcode.cn/problems/palindrome-linked-list/)
+
+> 给你一个单链表的头节点 head ,请你判断该链表是否为回文链表。如果是,返回 true ;否则,返回 false
+>
+> ```
+> 输入:head = [1,2,2,1]
+> 输出:true
+> ```
+
+思路:
+
+- 双指针:将值复制到数组中后用双指针法
+- 或者使用快慢指针来确定中间结点,然后反转后半段链表,将前半部分链表和后半部分进行比较
+
+```java
+public boolean isPalindrome(ListNode head) {
+ List vals = new ArrayList();
+
+ // 将链表的值复制到数组中
+ ListNode currentNode = head;
+ while (currentNode != null) {
+ vals.add(currentNode.val);
+ currentNode = currentNode.next;
+ }
+
+ // 使用双指针判断是否回文
+ int front = 0;
+ int back = vals.size() - 1;
+ while (front < back) {
+ if (!vals.get(front).equals(vals.get(back))) {
+ return false;
+ }
+ front++;
+ back--;
+ }
+ return true;
+}
+```
+
+
+
+### 删除链表的倒数第 N 个结点
+
+> 给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。
+>
+> ```
+> 输入:head = [1,2,3,4,5], n = 2
+> 输出:[1,2,3,5]
+> ```
+
+思路:
+
+1. 计算链表长度:从头节点开始对链表进行一次遍历,得到链表的长度,随后我们再从头节点开始对链表进行一次遍历,当遍历到第 L−n+1 个节点时,它就是我们需要删除的节点(为了与题目中的 n 保持一致,节点的编号从 1 开始)
+2. 栈:根据栈「先进后出」的原则,我们弹出栈的第 n 个节点就是需要删除的节点,并且目前栈顶的节点就是待删除节点的前驱节点
+3. 双指针:由于我们需要找到倒数第 n 个节点,因此我们可以使用两个指针 first 和 second 同时对链表进行遍历,并且 first 比 second 超前 n 个节点。当 first 遍历到链表的末尾时,second 就恰好处于倒数第 n 个节点。
+
+```java
+public ListNode removeNthFromEnd(ListNode head, int n) {
+ ListNode dummy = new ListNode(0, head);
+ ListNode first = head;
+ ListNode second = dummy;
+
+ //让 first 指针先移动 n 步
+ for (int i = 0; i < n; ++i) {
+ first = first.next;
+ }
+ while (first != null) {
+ first = first.next;
+ second = second.next;
+ }
+ second.next = second.next.next;
+ return dummy.next;
+}
+```
+
+
+
+### [删除有序数组中的重复项](https://leetcode.cn/problems/remove-duplicates-from-sorted-array/)
+
+> 给你一个 升序排列 的数组 nums ,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。元素的 相对顺序 应该保持 一致 。
+>
+> 由于在某些语言中不能改变数组的长度,所以必须将结果放在数组nums的第一部分。更规范地说,如果在删除重复项之后有 k 个元素,那么 nums 的前 k 个元素应该保存最终结果。
+>
+> 将最终结果插入 nums 的前 k 个位置后返回 k 。
+>
+> 不要使用额外的空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。
+>
+> ```
+> 输入:nums = [1,1,2]
+> 输出:2, nums = [1,2,_]
+> 解释:函数应该返回新的长度 2 ,并且原数组 nums 的前两个元素被修改为 1, 2 。不需要考虑数组中超出新长度后面的元素。
+> ```
+
+**思路**:
+
+- 数组有序,那相等的元素在数组中的下标一定是连续的
+- 使用快慢指针,快指针表示遍历数组到达的下标位置,慢指针表示下一个不同元素要填入的下标位置
+- 第一个元素不需要删除,所有快慢指针都从下标 1 开始
+
+```java
+public static int removeDuplicates(int[] nums) {
+ if (nums == null) {
+ return 0;
+ }
+ int fast = 1;
+ int slow = 1;
+ while (fast < nums.length) {
+ //和前一个值比较
+ if (nums[fast] != nums[fast - 1]) {
+ //不一样的话,把快指针的值放在慢指针上,实现了去重,并往前移动慢指针
+ nums[slow] = nums[fast];
+ ++slow;
+ }
+ //相等的话,移动快指针就行
+ ++fast;
+ }
+ //慢指针的位置就是不重复的数量
+ return slow;
+}
+```
+
+
+
+### 最长连续递增序列
+
+> 给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。
+>
+> 连续递增的子序列 可以由两个下标 l 和 r(l < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], ..., nums[r - 1], nums[r]] 就是连续递增子序列。
+>
+> ```
+> 输入:nums = [1,3,5,4,7]
+> 输出:3
+> 解释:最长连续递增序列是 [1,3,5], 长度为3。尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。
+> ```
+
+思路分析:
+
+- 这个题的思路和删除有序数组中的重复项,很像
+
+```java
+public int findLengthOfLCIS(int[] nums) {
+ int result = 0;
+ int fast = 0;
+ int slow = 0;
+ while (fast < nums.length) {
+ //前一个数大于后一个数的时候
+ if (fast > 0 || nums[fast - 1] > nums[fast]) {
+ slow = fast;
+ }
+ fast++;
+ result = Math.max(result, fast - slow);
+ }
+ return result;
+}
+```
+
+
+
+## 三、滑动窗口
+
+有一类数组上的问题,需要使用两个指针变量(我们称为左指针和右指针),同向、交替向右移动完成任务。这样的过程像极了一个窗口在平面上滑动的过程,因此我们将解决这一类问题的算法称为「滑动窗口」问题
+
+
+
+滑动窗口,就是两个指针齐头并进,好像一个窗口一样,不断往前滑。
+
+滑动窗口算法通过维护一个动态调整的窗口范围,高效解决子串、子数组、限流等场景问题。其核心逻辑可概括为以下步骤:
+
+1. **初始化窗口** 使用双指针 `left` 和 `right` 定义窗口边界,初始状态均指向起点。
+2. **扩展右边界** 移动 `right` 指针,将新元素加入窗口,并根据问题需求更新状态(如统计字符频率或请求计数)。
+3. **收缩左边界** 当窗口满足特定条件(如达到限流阈值或包含冗余元素),逐步移动 `left` 指针缩小窗口,直至不满足条件为止。
+4. **记录结果** 在窗口状态变化的每个阶段,捕获符合要求的解(如最长子串长度或限流通过状态)。
+
+
+
+子串问题,几乎都是滑动窗口。滑动窗口算法技巧的思路,就是维护一个窗口,不断滑动,然后更新答案,该算法的大致逻辑如下:
+
+```java
+int left = 0, right = 0;
+
+while (right < s.size()) {
+ // 增大窗口
+ window.add(s[right]);
+ right++;
+
+ while (window needs shrink) {
+ // 缩小窗口
+ window.remove(s[left]);
+ left++;
+ }
+}
+```
+
+> 以下是适用于字符串处理、限流等场景的通用框架:
+>
+> ```java
+> public class SlidingWindow {
+>
+> // 核心双指针定义
+> int left = 0, right = 0;
+> // 窗口状态容器(如哈希表、数组)
+> Map window = new HashMap<>();
+> // 结果记录
+> List result = new ArrayList<>();
+>
+> public void slidingWindow(String s, String target) {
+> // 初始化目标状态(如字符频率)
+> Map need = new HashMap<>();
+> for (char c : target.toCharArray()) {
+> need.put(c, need.getOrDefault(c, 0) + 1);
+> }
+>
+> while (right < s.length()) {
+> char c = s.charAt(right);
+> right++;
+> // 更新窗口状态
+> window.put(c, window.getOrDefault(c, 0) + 1);
+>
+> // 窗口收缩条件
+> while (window.get(c) > need.getOrDefault(c, 0)) {
+> char d = s.charAt(left);
+> left++;
+> // 更新窗口状态
+> window.put(d, window.get(d) - 1);
+> }
+>
+> // 记录结果(如最小覆盖子串长度)
+> if (right - left == target.length()) {
+> result.add(left);
+> }
+> }
+> }
+> }
+> ```
+>
+>
+
+### 3.1 同向交替移动的两个变量
+
+有一类数组上的问题,问我们固定长度的滑动窗口的性质,这类问题还算相对简单。
+
+#### [子数组最大平均数 I(643)](https://leetcode-cn.com/problems/maximum-average-subarray-i/)
+
+> 给定 `n` 个整数,找出平均数最大且长度为 `k` 的连续子数组,并输出该最大平均数。
+>
+> ```
+> 输入:[1,12,-5,-6,50,3], k = 4
+> 输出:12.75
+> 解释:最大平均数 (12-5-6+50)/4 = 51/4 = 12.75
+> ```
+
+**思路**:
+
+- 长度为固定的 K,想到用滑动窗口
+- 保存每个窗口的值,取这 k 个数的最大和就可以得出最大平均数
+- 怎么保存每个窗口的值,这一步
+
+```java
+public static double getMaxAverage(int[] nums, int k) {
+ int sum = 0;
+ //先求出前k个数的和
+ for (int i = 0; i < nums.length; i++) {
+ sum += nums[i];
+ }
+ //目前最大的数是前k个数
+ int result = sum;
+ //然后从第 K 个数开始移动,保存移动中的和值,返回最大的
+ for (int i = k; i < nums.length; i++) {
+ sum = sum - nums[i - k] + nums[i];
+ result = Math.max(result, sum);
+ }
+ //返回的是double
+ return 1.0 * result / k;
+}
+```
+
+
+
+### 3.2 不定长度的滑动窗口
+
+#### [无重复字符的最长子串_3](https://leetcode-cn.com/problems/longest-substring-without-repeating-characters/)
+
+> 给定一个字符串 `s` ,请你找出其中不含有重复字符的 **最长子串** 的长度。
+>
+> ```
+> 输入: s = "abcabcbb"
+> 输出: 3
+> 解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
+> ```
+
+思路:
+
+- 滑动窗口,其实就是一个队列,比如例题中的 abcabcbb,进入这个队列(窗口)为 abc 满足题目要求,当再进入 a,队列变成了 abca,这时候不满足要求。所以,我们要移动这个队列
+- 如何移动?我们只要把队列的左边的元素移出就行了,直到满足题目要求!
+- 一直维持这样的队列,找出队列出现最长的长度时候,求出解!
+
+```java
+int lengthOfLongestSubstring(String s) {
+ Map window = new HashMap<>();
+
+ int left = 0, right = 0;
+ int res = 0; // 记录结果
+ while (right < s.length()) {
+ char c = s.charAt(right);
+ right++;
+ // 进行窗口内数据的一系列更新
+ window.put(c, window.getOrDefault(c, 0) + 1);
+ // 判断左侧窗口是否要收缩,字符串重复时收缩,注意这里是 while 不是 if
+ while (window.get(c) > 1) {
+ char d = s.charAt(left);
+ left++;
+ // 进行窗口内数据的一系列更新
+ window.put(d, window.get(d) - 1);
+ }
+ // 在这里更新答案,我们窗口是左闭右开的,所以窗口实际包含的字符数是 right - left,无需 +1
+ res = Math.max(res, right - left);
+ }
+ return res;
+}
+```
+
+
+
+#### [最小覆盖子串(76)](https://leetcode-cn.com/problems/minimum-window-substring/)
+
+> 给你一个字符串 `s` 、一个字符串 `t` 。返回 `s` 中涵盖 `t` 所有字符的最小子串。如果 `s` 中不存在涵盖 `t` 所有字符的子串,则返回空字符串 `""` 。
+>
+> ```
+> 输入:s = "ADOBECODEBANC", t = "ABC"
+> 输出:"BANC"
+> ```
+
+思路:
+
+1. 我们在字符串 `S` 中使用双指针中的左右指针技巧,初始化 `left = right = 0`,把索引**左闭右开**区间 `[left, right)` 称为一个「窗口」。
+
+ > [!IMPORTANT]
+ >
+ > 为什么要「左闭右开」区间
+ >
+ > **理论上你可以设计两端都开或者两端都闭的区间,但设计为左闭右开区间是最方便处理的**。
+ >
+ > 因为这样初始化 `left = right = 0` 时区间 `[0, 0)` 中没有元素,但只要让 `right` 向右移动(扩大)一位,区间 `[0, 1)` 就包含一个元素 `0` 了。
+ >
+ > 如果你设置为两端都开的区间,那么让 `right` 向右移动一位后开区间 `(0, 1)` 仍然没有元素;如果你设置为两端都闭的区间,那么初始区间 `[0, 0]` 就包含了一个元素。这两种情况都会给边界处理带来不必要的麻烦。
+
+2. 我们先不断地增加 `right` 指针扩大窗口 `[left, right)`,直到窗口中的字符串符合要求(包含了 `T` 中的所有字符)。
+
+3. 此时,我们停止增加 `right`,转而不断增加 `left` 指针缩小窗口 `[left, right)`,直到窗口中的字符串不再符合要求(不包含 `T` 中的所有字符了)。同时,每次增加 `left`,我们都要更新一轮结果。
+
+4. 重复第 2 和第 3 步,直到 `right` 到达字符串 `S` 的尽头。
+
+```java
+public String minWindow(String s, String t) {
+ // 两个map,window 记录窗口中的字符频率,need 记录t中字符的频率
+ HashMap window = new HashMap<>();
+ HashMap need = new HashMap<>();
+
+ for (int i = 0; i < t.length(); i++) {
+ char c = t.charAt(i);
+ //算出每个字符的数量,有可能有重复的
+ need.put(c, need.getOrDefault(c, 0) + 1);
+ }
+
+ //左开右闭的区间,然后创建移动窗口
+ int left = 0, right = 0;
+ // 窗口中满足need条件的字符个数, valid == need.size 说明窗口满足条件
+ int valid = 0;
+ //记录最小覆盖子串的开始索引和长度
+ int start = 0, len = Integer.MAX_VALUE;
+ while (right < s.length()) {
+ // c 代表将移入窗口的字符
+ char c = s.charAt(right);
+ //扩大窗口
+ right++;
+
+ //先判断当前滑动窗口右端(right 指针处)的字符 c 是否是目标字符串 t 中的一个字符
+ if (need.containsKey(c)) {
+ window.put(c, window.getOrDefault(c, 0) + 1);
+ //检查当前字符 c 的频率在滑动窗口中是否达到了目标字符串 t 中所要求的频率
+ if (window.get(c).equals(need.get(c))) {
+ valid++;
+ }
+ }
+ //判断左窗口是否需要收缩
+ while (valid == need.size()) {
+ //如果当前滑动窗口的长度比已记录的最小长度 len 更短,则说明找到了一个更小的符合条件的覆盖子串
+ if (right - left < len) {
+ start = left;
+ len = right - left;
+ }
+ //d 是将移除窗口的字符
+ char d = s.charAt(left);
+ left++; //缩小窗口
+
+ //更新窗口,收缩,更新窗口中的字符频率并检查是否还满足覆盖条件
+ if (need.containsKey(d)) {
+ if (window.get(d).equals(need.get(d))) {
+ valid--;
+ window.put(d, window.get(d) - 1);
+ }
+ }
+ }
+ }
+ //返回最小覆盖子串
+ return len == Integer.MAX_VALUE ? "" : s.substring(start, start + len);
+}
+```
+
+
+
+#### [字符串的排列(567)](https://leetcode.cn/problems/permutation-in-string/description/)
+
+> 给你两个字符串 `s1` 和 `s2` ,写一个函数来判断 `s2` 是否包含 `s1` 的排列。如果是,返回 `true` ;否则,返回 `false` 。
+>
+> 换句话说,`s1` 的排列之一是 `s2` 的 **子串** 。
+>
+> ```
+> 输入:s1 = "ab" s2 = "eidbaooo"
+> 输出:true
+> 解释:s2 包含 s1 的排列之一 ("ba").
+> ```
+
+思路:
+
+通过滑动窗口(Sliding Window)和字符频率统计来解决
+
+和上一题基本一致,只是 移动 `left` 缩小窗口的时机是窗口大小大于 `t.length()` 时,当发现 `valid == need.size()` 时,就说明窗口中就是一个合法的排列
+
+```java
+// 判断 s 中是否存在 t 的排列
+public boolean checkInclusion(String t, String s) {
+ Map need = new HashMap<>();
+ Map window = new HashMap<>();
+ for (char c : t.toCharArray()) {
+ need.put(c, need.getOrDefault(c, 0) + 1);
+ }
+
+ int left = 0, right = 0;
+ int valid = 0;
+ while (right < s.length()) {
+ char c = s.charAt(right);
+ right++;
+ // 进行窗口内数据的一系列更新
+ if (need.containsKey(c)) {
+ window.put(c, window.getOrDefault(c, 0) + 1);
+ if (window.get(c).intValue() == need.get(c).intValue())
+ valid++;
+ }
+
+ // 判断左侧窗口是否要收缩
+ while (right - left >= t.length()) {
+ // 在这里判断是否找到了合法的子串
+ if (valid == need.size())
+ return true;
+ char d = s.charAt(left);
+ left++;
+ // 进行窗口内数据的一系列更新
+ if (need.containsKey(d)) {
+ if (window.get(d).intValue() == need.get(d).intValue())
+ valid--;
+ window.put(d, window.get(d) - 1);
+ }
+ }
+ }
+ // 未找到符合条件的子串
+ return false;
+}
+```
+
+
+
+#### [替换后的最长重复字符(424)](https://leetcode-cn.com/problems/longest-repeating-character-replacement/)
+
+> 给你一个仅由大写英文字母组成的字符串,你可以将任意位置上的字符替换成另外的字符,总共可最多替换 k 次。在执行上述操作后,找到包含重复字母的最长子串的长度。
+>
+> 注意:字符串长度 和 k 不会超过 10^4
+>
+> ```
+> 输入:s = "ABAB", k = 2
+> 输出:4
+> 解释:用两个'A'替换为两个'B',反之亦然。
+> ```
+>
+> ```
+> 输入:s = "AABABBA", k = 1
+> 输出:4
+> 解释:将中间的一个'A'替换为'B',字符串变为 "AABBBBA"。子串 "BBBB" 有最长重复字母, 答案为 4。
+> ```
+
+思路:
+
+```java
+public int characterReplacement(String s, int k) {
+ int len = s.length();
+ if (len < 2) {
+ return len;
+ }
+
+ char[] charArray = s.toCharArray();
+ int left = 0;
+ int right = 0;
+
+ int res = 0;
+ int maxCount = 0;
+ int[] freq = new int[26];
+ // [left, right) 内最多替换 k 个字符可以得到只有一种字符的子串
+ while (right < len){
+ freq[charArray[right] - 'A']++;
+ // 在这里维护 maxCount,因为每一次右边界读入一个字符,字符频数增加,才会使得 maxCount 增加
+ maxCount = Math.max(maxCount, freq[charArray[right] - 'A']);
+ right++;
+
+ if (right - left > maxCount + k){
+ // 说明此时 k 不够用
+ // 把其它不是最多出现的字符替换以后,都不能填满这个滑动的窗口,这个时候须要考虑左边界向右移动
+ // 移出滑动窗口的时候,频数数组须要相应地做减法
+ freq[charArray[left] - 'A']--;
+ left++;
+ }
+ res = Math.max(res, right - left);
+ }
+ return res;
+}
+```
+
+
+
+### 3.3 计数问题
+
+#### 至多包含两个不同字符的最长子串
+
+> 给定一个字符串 `s`,找出 **至多** 包含两个不同字符的最长子串 `t` ,并返回该子串的长度。
+>
+> ```
+> 输入: "eceba"
+> 输出: 3
+> 解释: t 是 "ece",长度为3。
+> ```
+
+
+
+```java
+public int lengthOfLongestSubstringTwoDistinct(String s) {
+ if (s == null || s.length() == 0) {
+ return 0;
+ }
+
+ // 滑动窗口的左指针
+ int left = 0;
+ // 记录滑动窗口内的字符及其出现的频率
+ Map map = new HashMap<>();
+ // 记录最长子串的长度
+ int maxLen = 0;
+
+ // 遍历整个字符串
+ for (int right = 0; right < s.length(); right++) {
+ // 右指针的字符
+ char c = s.charAt(right);
+ // 将字符 c 加入到窗口中,并更新其出现的次数
+ map.put(c, map.getOrDefault(c, 0) + 1);
+
+ // 当窗口内的不同字符数超过 2 时,开始缩小窗口
+ while (map.size() > 2) {
+ // 左指针的字符
+ char leftChar = s.charAt(left);
+ // 减少左指针字符的频率
+ map.put(leftChar, map.get(leftChar) - 1);
+ // 如果左指针字符的频率为 0,则从窗口中移除该字符
+ if (map.get(leftChar) == 0) {
+ map.remove(leftChar);
+ }
+ // 移动左指针,缩小窗口
+ left++;
+ }
+
+ // 更新最大长度
+ maxLen = Math.max(maxLen, right - left + 1);
+ }
+
+ return maxLen;
+}
+
+
+```
+
+
+
+#### 至多包含 K 个不同字符的最长子串_340
+
+> 给定一个字符串 `s`,找出 **至多** 包含 `k` 个不同字符的最长子串 `T`。
+>
+> ```
+> 输入: s = "eceba", k = 2
+> 输出: 3
+> 解释: 则 T 为 "ece",所以长度为 3。
+> ```
+
+```java
+public int lengthOfLongestSubstringKDistinct(String s, int k) {
+ if (s == null || s.length() == 0 || k == 0) {
+ return 0;
+ }
+
+ // 滑动窗口的左指针
+ int left = 0;
+ // 记录滑动窗口内的字符及其出现的频率
+ Map map = new HashMap<>();
+ // 记录最长子串的长度
+ int maxLen = 0;
+
+ // 遍历整个字符串
+ for (int right = 0; right < s.length(); right++) {
+ // 右指针的字符
+ char c = s.charAt(right);
+ // 将字符 c 加入到窗口中,并更新其出现的次数
+ map.put(c, map.getOrDefault(c, 0) + 1);
+
+ // 当窗口内的不同字符数超过 K 时,开始缩小窗口
+ while (map.size() > k) {
+ // 左指针的字符
+ char leftChar = s.charAt(left);
+ // 减少左指针字符的频率
+ map.put(leftChar, map.get(leftChar) - 1);
+ // 如果左指针字符的频率为 0,则从窗口中移除该字符
+ if (map.get(leftChar) == 0) {
+ map.remove(leftChar);
+ }
+ // 移动左指针,缩小窗口
+ left++;
+ }
+
+ // 更新最大长度
+ maxLen = Math.max(maxLen, right - left + 1);
+ }
+
+ return maxLen;
+}
+```
+
+
+
+#### [ 区间子数组个数_795](https://leetcode.cn/problems/number-of-subarrays-with-bounded-maximum/)
+
+> 给定一个元素都是正整数的数组`A` ,正整数 `L` 以及 `R` (`L <= R`)。
+>
+> 求连续、非空且其中最大元素满足大于等于`L` 小于等于`R`的子数组个数。
+>
+> ```
+> 例如 :
+> 输入:
+> A = [2, 1, 4, 3]
+> L = 2
+> R = 3
+> 输出: 3
+> 解释: 满足条件的子数组: [2], [2, 1], [3].
+> ```
+
+```java
+public int numSubarrayBoundedMax(int[] nums, int left, int right) {
+ int count = 0;
+ int start = -1; // 记录大于right的元素下标
+ int last_bounded = -1; // 记录符合[Left, Right]区间的元素下标
+
+ for (int i = 0; i < nums.length; i++) {
+ if (nums[i] > right) {
+ // 遇到大于right的元素,重置窗口起始点
+ start = i;
+ }
+ if (nums[i] >= left && nums[i] <= right) {
+ // 记录符合区间条件的元素下标
+ last_bounded = i;
+ }
+ // 计算当前有效子数组的数量
+ count += last_bounded - start;
+ }
+
+ return count;
+}
+```
+
+
+
+
+
+### 3.4 使用数据结构维护窗口性质
+
+有一类问题只是名字上叫「滑动窗口」,但解决这一类问题需要用到常见的数据结构。这一节给出的问题可以当做例题进行学习,一些比较复杂的问题是基于这些问题衍生出来的。
+
+#### 滑动窗口最大值
+
+#### 滑动窗口中位数
+
+
+
+
+
+## 四、其他双指针问题
+
+#### [最长回文子串_5](https://leetcode.cn/problems/longest-palindromic-substring/)
+
+> 给你一个字符串 `s`,找到 `s` 中最长的 回文子串。
+
+```java
+public static String longestPalindrome(String s){
+ //处理边界
+ if(s == null || s.length() < 2){
+ return s;
+ }
+
+ //初始化start和maxLength变量,用来记录最长回文子串的起始位置和长度
+ int start = 0, maxLength = 0;
+
+ //遍历每个字符
+ for (int i = 0; i < s.length(); i++) {
+ //以当前字符为中心的奇数长度回文串
+ int len1 = centerExpand(s, i, i);
+ //以当前字符和下一个字符之间的中心的偶数长度回文串
+ int len2 = centerExpand(s, i, i+1);
+
+ int len = Math.max(len1, len2);
+
+ //当前找到的回文串大于之前的记录,更新start和maxLength
+ if(len > maxLength){
+ // i 是当前扩展的中心位置, len 是找到的回文串的总长度,我们要用这两个值计算出起始位置 start
+ // (len - 1)/2 为什么呢,计算中心到回文串起始位置的距离, 为什么不用 len/2, 这里考虑的是奇数偶数的通用性,比如'abcba' 和 'abba' 或者 'cabbad',巧妙的同时处理两种,不需要分别考虑
+ start = i - (len - 1)/2;
+ maxLength = len;
+ }
+
+ }
+
+ return s.substring(start, start + maxLength);
+}
+
+private static int centerExpand(String s, int left, int right){
+ while(left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)){
+ left --;
+ right ++;
+ }
+ //这个的含义: 假设扩展过程中,left 和 right 已经超出了回文返回, 此时回文范围是 (left+1,right-1), 那么回文长度= (right-1)-(left+1)+1=right-left-1
+ return right - left - 1;
+}
+```
+
+
+
+#### [合并两个有序数组_88](https://leetcode-cn.com/problems/merge-sorted-array/)
+
+
+
+#### [下一个排列_31](https://leetcode.cn/problems/next-permutation/)
+
+> 整数数组的一个 **排列** 就是将其所有成员以序列或线性顺序排列。
+>
+> - 例如,`arr = [1,2,3]` ,以下这些都可以视作 `arr` 的排列:`[1,2,3]`、`[1,3,2]`、`[3,1,2]`、`[2,3,1]` 。
+>
+> 整数数组的 **下一个排列** 是指其整数的下一个字典序更大的排列。更正式地,如果数组的所有排列根据其字典顺序从小到大排列在一个容器中,那么数组的 **下一个排列** 就是在这个有序容器中排在它后面的那个排列。如果不存在下一个更大的排列,那么这个数组必须重排为字典序最小的排列(即,其元素按升序排列)。
+>
+> - 例如,`arr = [1,2,3]` 的下一个排列是 `[1,3,2]` 。
+> - 类似地,`arr = [2,3,1]` 的下一个排列是 `[3,1,2]` 。
+> - 而 `arr = [3,2,1]` 的下一个排列是 `[1,2,3]` ,因为 `[3,2,1]` 不存在一个字典序更大的排列。
+>
+> 给你一个整数数组 `nums` ,找出 `nums` 的下一个排列。
+>
+> 必须**[ 原地 ](https://baike.baidu.com/item/原地算法)**修改,只允许使用额外常数空间。
+
+**Approach**:
+
+1. 我们希望下一个数 比当前数大,这样才满足 “下一个排列” 的定义。因此只需要 将后面的「大数」与前面的「小数」交换,就能得到一个更大的数。比如 123456,将 5 和 6 交换就能得到一个更大的数 123465。
+2. 我们还希望下一个数 增加的幅度尽可能的小,这样才满足“下一个排列与当前排列紧邻“的要求。为了满足这个要求,我们需要:
+ - 在 尽可能靠右的低位 进行交换,需要 从后向前 查找
+ - 将一个 尽可能小的「大数」 与前面的「小数」交换。比如 123465,下一个排列应该把 5 和 4 交换而不是把 6 和 4 交换
+ - 将「大数」换到前面后,需要将「大数」后面的所有数 重置为升序
+
+该算法可以分为三个步骤:
+
+1. **从右向左找到第一个升序对**(即`nums[i] < nums[i + 1]`),记为`i`。如果找不到,则说明数组已经是最大的排列,直接将数组反转为最小的排列。
+2. **从右向左找到第一个比`nums[i]`大的数**,记为`j`,然后交换`nums[i]`和`nums[j]`。
+3. **反转`i + 1`之后的数组**,使其变成最小的排列。
+
+```java
+public void nextPermutation(int[] nums){
+
+ //为什么从倒数第二个元素开始,因为我们第一步要从右往左找到第一个“升序对”,
+ int i = nums.length - 2;
+ //step 1: 找到第一个下降的元素
+ while (i >= 0 && nums[i] >= nums[i + 1]) {
+ i--;
+ }
+
+ //step2 : 如果找到了 i, 找到第一个比 nums[i] 大的元素 j
+ if (i > 0) {
+ int j = nums.length - 1;
+ while (j >= 0 && nums[j] <= nums[i]) {
+ j--;
+ }
+ //交换i 和 j 的位置
+ swap(nums, i, j);
+
+ }
+
+ // step3: 反转从 start 开始到末尾的部分(不需要重新排序,是因为这半部分再交换前就是降序的,我们第一步找的升序对)
+ reverse(nums, i+1);
+}
+
+private void swap(int[] nums, int i, int j){
+ int tmp = nums[i];
+ nums[i] = nums[j];
+ nums[j] = tmp;
+}
+
+private void reverse(int[] nums, int start){
+ int end = nums.length - 1;
+ while(start < end){
+ swap(nums, start, end);
+ start ++;
+ end --;
+ }
+}
+```
+
+
+
+#### [颜色分类_75](https://leetcode.cn/problems/sort-colors/)
+
+> 给定一个包含红色、白色和蓝色、共 `n` 个元素的数组 `nums` ,**[原地](https://baike.baidu.com/item/原地算法)** 对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。
+>
+> 我们使用整数 `0`、 `1` 和 `2` 分别表示红色、白色和蓝色。
+>
+> 必须在不使用库内置的 sort 函数的情况下解决这个问题。
+>
+> ```
+> 输入:nums = [2,0,2,1,1,0]
+> 输出:[0,0,1,1,2,2]
+> ```
+
+**Approach**:
+
+荷兰国旗问题
+
+我们可以使用三个指针:`low`、`mid` 和 `high`,分别用来处理 0、1 和 2 的排序问题。
+
+- `low` 表示红色 (0) 的边界,指向的元素是 1 的位置,即把所有 0 放在 `low` 的左边。
+- `mid` 表示当前处理的元素索引。
+- `high` 表示蓝色 (2) 的边界,指向的元素是 2 的位置,把所有 2 放在 `high` 的右边。
+
+**算法步骤:**
+
+1. 初始化:`low = 0`,`mid = 0`,`high = nums.length - 1`。
+2. 当 `mid <= high` 时,进行以下判断:
+ - 如果 `nums[mid] == 0`,将其与 `nums[low]` 交换,并将 `low` 和 `mid` 都加 1。
+ - 如果 `nums[mid] == 1`,只需将 `mid` 加 1,因为 1 已经在正确的位置。
+ - 如果 `nums[mid] == 2`,将其与 `nums[high]` 交换,并将 `high` 减 1,但 `mid` 不动,因为交换过来的数还未处理。
+
+```java
+public void sortColors(int[] nums) {
+ int low = 0, mid = 0, high = nums.length - 1;
+
+ while (mid <= high) {
+ if (nums[mid] == 0) {
+ // 交换 nums[mid] 和 nums[low]
+ swap(nums, low, mid);
+ low++;
+ mid++;
+ } else if (nums[mid] == 1) {
+ mid++;
+ } else if (nums[mid] == 2) {
+ // 交换 nums[mid] 和 nums[high]
+ swap(nums, mid, high);
+ high--;
+ }
+ }
+}
+
+private void swap(int[] nums, int i, int j) {
+ int temp = nums[i];
+ nums[i] = nums[j];
+ nums[j] = temp;
+}
+
+```
+
+- 时间复杂度:O(n),每个元素只遍历一次。
+
+- 空间复杂度:O(1),不需要额外的空间,只在原数组中进行操作。
+
+双指针方法的话,就是两次遍历。
+
+```java
+public void sortColors(int[] nums) {
+ int left = 0;
+ int right = nums.length - 1;
+
+ // 第一次遍历,把 0 移动到数组的左边
+ for (int i = 0; i <= right; i++) {
+ if (nums[i] == 0) {
+ swap(nums, i, left);
+ left++;
+ }
+ }
+
+ // 第二次遍历,把 2 移动到数组的右边
+ for (int i = nums.length - 1; i >= left; i--) {
+ if (nums[i] == 2) {
+ swap(nums, i, right);
+ right--;
+ }
+ }
+}
+
+private void swap(int[] nums, int i, int j) {
+ int temp = nums[i];
+ nums[i] = nums[j];
+ nums[j] = temp;
+}
+
+```
+
+
+
+#### [排序链表_148](https://leetcode.cn/problems/sort-list/description/)
+
+> 给你链表的头结点 `head` ,请将其按 **升序** 排列并返回 **排序后的链表** 。
+>
+> ```
+> 输入:head = [4,2,1,3]
+> 输出:[1,2,3,4]
+> ```
+
+**Approach**: 要将链表排序,并且时间复杂度要求为 O(nlogn)O(n \log n)O(nlogn),这提示我们需要使用 **归并排序**。归并排序的特点就是时间复杂度是 O(nlogn)O(n \log n)O(nlogn),并且它在链表上的表现很好,因为链表的分割和合并操作相对容易。
+
+具体实现步骤:
+
+1. **分割链表**:我们可以使用 **快慢指针** 来找到链表的中点,从而将链表一分为二。
+2. **递归排序**:分别对左右两部分链表进行排序。
+3. **合并有序链表**:最后将两个已经排序好的链表合并成一个有序链表。
+
+```java
+
+public ListNode sortList(ListNode head) {
+ // base case: if the list is empty or contains a single element, it's already sorted
+ if (head == null || head.next == null) {
+ return head;
+ }
+
+ // Step 1: split the linked list into two halves
+ ListNode mid = getMiddle(head);
+ // right 为链表右半部分的头结点
+ ListNode right = mid.next;
+ mid.next = null; //断开
+
+ // Step 2: recursively sort both halves
+ ListNode leftSorted = sortList(head);
+ ListNode rightSorted = sortList(right);
+
+ // Step 3: merge the sorted halves
+ return mergeTwoLists(leftSorted, rightSorted);
+}
+
+// Helper method to find the middle node of the linked list
+private ListNode getMiddle(ListNode head) {
+ ListNode slow = head;
+ ListNode fast = head;
+
+ while (fast != null && fast.next != null) {
+ slow = slow.next;
+ fast = fast.next.next;
+ }
+
+ return slow;
+}
+
+// Helper method to merge two sorted linked lists
+private ListNode mergeTwoLists(ListNode l1, ListNode l2) {
+ ListNode dummy = new ListNode(-1);
+ ListNode current = dummy;
+
+ while (l1 != null && l2 != null) {
+ if (l1.val < l2.val) {
+ current.next = l1;
+ l1 = l1.next;
+ } else {
+ current.next = l2;
+ l2 = l2.next;
+ }
+ current = current.next;
+ }
+
+ // Append the remaining elements of either list
+ if (l1 != null) {
+ current.next = l1;
+ } else {
+ current.next = l2;
+ }
+
+ return dummy.next;
+}
+```
+
+
+
+### 总结
+
+区间不同的定义决定了不同的初始化逻辑、遍历过程中的逻辑。
+
+- 移除元素
+- 删除排序数组中的重复项 II
+- 移动零
+
+
+
diff --git a/docs/data-structure-algorithms/Dynamic-Programming.md b/docs/data-structure-algorithms/algorithm/Dynamic-Programming.md
similarity index 79%
rename from docs/data-structure-algorithms/Dynamic-Programming.md
rename to docs/data-structure-algorithms/algorithm/Dynamic-Programming.md
index 6bc1cbf484..9ea856aedc 100644
--- a/docs/data-structure-algorithms/Dynamic-Programming.md
+++ b/docs/data-structure-algorithms/algorithm/Dynamic-Programming.md
@@ -1,4 +1,10 @@
-# 动态规划——刷题有套路
+---
+title: 动态规划——刷题有套路
+date: 2024-03-09
+tags:
+ - Algorithm
+categories: Algorithm
+---
> 动态规划,简直就是刷题模板、套路届的典范
@@ -23,6 +29,20 @@
所有的算法都是在让计算机【如何聪明地穷举】而已,动态规划也是如此。
+> A : "1+1+1+1+1+1+1+1 =?等式的值是多少"
+>
+> B : 计算 "8"
+>
+> A : 在上面等式的左边写上 "1+" 呢? "此时等式的值为多少"
+>
+> B : 很快得出答案 "9"
+>
+> A : "你怎么这么快就知道答案了"
+>
+> B : "只要在8的基础上加1就行了"
+>
+> A : "所以你不用重新计算,因为你记住了第一个等式的值为8!动态规划算法也可以说是 '记住求过的解来节省时间'"
+
本文将会从以下角度来讲解动态规划:
- 什么是动态规划
@@ -41,19 +61,19 @@
- **多阶段决策**:比如说我们有一个复杂的问题要处理,我们可以按问题的时间或从空间关系分解成几个互相联系的阶段,使每个阶段的决策问题都是一个比较容易求解的“**子问题**”,这样依次做完每个阶段的最优决策后,他们就构成了整个问题的最优决策。简单地说,就是每做一次决策就可以得到解的一部分,当所有决策做完之后,完整的解就“浮出水面”了。有一种**大事化小,小事化了**的感觉。
-- **最优子结构**:在我们拆成一个个子问题的时候,每个子问题一定都有一个最优解,既然它分解的子问题是全局最优解,那么依赖于它们解的原问题自然也是全局最优解。比如说,你的原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高…… 为了每门课考到最高,你要把每门课相应的选择题分数拿到最高,填空题分数拿到最高…… 当然,最终就是你每门课都是满分,这就是最高的总成绩。
+- **最优子结构**:在我们拆成一个个子问题的时候,每个子问题一定都有一个最优解,既然它分解的子问题是全局最优解,那么依赖于它们解的原问题自然也是全局最优解。比如说,你的原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高…… 为了每门课考到最高,你要把每门课相应的选择题分数拿到最高,填空题分数拿到最高…… 当然,最终就是你每门课都是满分,这就是最高的总成绩。
- **自下而上**:或者叫自底向上,对应的肯定有**自上而下**(自顶向下)
- 啥叫**自顶向下**,比如我们求解递归问题,画递归树的时候,是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 触底,然后逐层返回答案,这就叫「自顶向下」,比如我们用递归法计算斐波那契数列的时候
- 
+ 
- 反过来,自底向上,肯定就是从最底下,最简单,问题规模最小的 f(1) 和 f(2) 开始往上推,直到推到我们想要的答案 f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。
- 
+ 
@@ -65,7 +85,7 @@
> 通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,具有天然剪枝的功能,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
>
-> 动态规划在查找有很多**重叠子问题**的情况的最优解时有效。它将问题重新组合成子问题。为了避免多次解决这些子问题,它们的结果都逐渐被计算并被保存,从简单的问题直到整个问题都被解决。因此,动态规划保存递归时的结果,因而不会在解决同样的问题时花费时间。
+> 动态规划在查找有很多**重叠子问题**的情况的最优解时有效。它将问题重新组合成子问题。为了避免多次解决这些子问题,它们的结果都逐渐被计算并被保存,从简单的问题直到整个问题都被解决。因此,动态规划保存递归时的结果,而不会在解决同样问题时再花费时间。
>
> 动态规划只能应用于有**最优子结构**的问题。最优子结构的意思是局部最优解能决定全局最优解(对有些问题这个要求并不能完全满足,故有时需要引入一定的近似)。简单地说,问题能够分解成子问题来解决。
@@ -77,7 +97,7 @@
### 斐波那契数列
-PS:我们先从一个简单的斐波那契数列来进一步理解下重叠子问题与状态转移方程(斐波那契数列并不是严格意义上的动态规划,因为它没有求最值,所以也没设计到最优子结构的问题)
+PS:我们先从一个简单的斐波那契数列来进一步理解下重叠子问题与状态转移方程(斐波那契数列并不是严格意义上的动态规划,因为它没有求最值,所以也没涉及到最优子结构的问题)
**1、暴力递归**
@@ -92,8 +112,6 @@ int fib(int N) {
这个不用多说了,我们在 **自顶向下** 那部分画出的就是它的递归树,他有大量的重复计算问题,比如 `f(18)` 被计算了两次,而且你可以看到,以 `f(18)` 为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止 `f(18)` 这一个节点被重复计算,所以这个算法及其低效。
-
-
这就是动态规划问题的第一个性质:**重叠子问题**。下面,我们想办法解决这个问题。
**2、带备忘录的递归解法**
@@ -138,7 +156,7 @@ public int fib(int n) {
有了上一步「备忘录」的启发,**自顶向下**的递推,每次“缓存”之前的结果,那**自底向上**的推算不也可以吗?而且推算的时候,我们只需要存储之前的两个状态就行,还省了很多空间,我靠,真是个天才,这就是,**动态规划**的做法。
-
+
画个图就很好理解了,我们一层一层的往上计算,得到最后的结果。
@@ -167,8 +185,6 @@ public int fib(int n) {
## 四、什么样的题目适合用动态规划
-求最值的核心问题,无非就是穷举,就是把所有可能的结果都穷举出来,然后找到最值。但穷举从来不是一个好方法。
-
可以使用动态规划的问题一般都有一些特点可以遵循。如题目的问法一般是三种方式:
1. 求最大值/最小值(除了类似找出数组中最大值这种)
@@ -181,7 +197,7 @@ public int fib(int n) {
3. 求方案总数
- 路径规划问题
+ 硬币组合问题、路径规划问题
如果你碰到一个问题,是问你这三个问题之一的,那么有 90% 的概率是可以使用动态规划来求解。
@@ -207,12 +223,18 @@ public int fib(int n) {
我们知道了动态规划三要素:重叠子问题、最优子结构、状态转移方程。
-那要解决一个动态规划问题的大概步骤,就围绕这人这三要素展开:
+那要解决一个动态规划问题的大概步骤,就围绕这三要素展开:
1. **划分阶段:**分析题目可以用动态规划解决,那就先看这个问题如何划分成各个子问题
+
2. **状态定义**:也有叫选择状态的,其实就是定义子问题,我理解其实就是看求解的结果,我们一般用数组来存储子问题结果,所以状态我们一般定义为 $dp[i]$,表示规模为 i 的问题的解,$dp[i-1]$ 就是规模为 i-1 的子问题的解
+
3. **确定决策并写出状态转移方程:**听名字就觉得牛逼的一步,肯定也是最难的一步,其实就是我们从 f(1)、f(2)、f(3) ... f(n-1) 一步步递推出 f(n) 的表达式,也就是说,dp[n] 一定会和 dp[n-1], dp[n-2]....存在某种关系的,这一步就是找出数组元素的关系式,比如斐波那契数列的关系式 $dp[n] = dp[n-1] + dp[n-2]$
+
+ > 一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的
+
4. **找出初始值(包括边界条件):**既然状态转移方程式写好了,但是还需要一个**支点**来撬动它进行不断的计算下去,比如斐波那契数列中的 f(1)=1,f(2)=1,就是初始值
+
5. **优化**:思考有没有可以优化的点
@@ -223,7 +245,7 @@ public int fib(int n) {
按上面的套路走,最后的结果就可以套这个框架:
-```
+```java
# 初始化 base case
dp[0][0][...] = base
# 进行状态转移
@@ -277,8 +299,6 @@ for 状态1 in 状态1的所有取值:
>
> f(5) = f(4) + 1 .......
-。。。
-
这不是上一节的斐波那契数列吗?????
用 $f(x)$ 表示爬到第 x 级台阶的方案数,考虑最后一步可能跨了一级台阶,也可能跨了两级台阶,所以我们可以列出如下式子:
@@ -300,7 +320,7 @@ $f(x) = f(x - 1) + f(x - 2)$
public int climbStairs(int n) {
// 创建一个数组来保存历史数据
int[] dp = new int[n + 1];
- // 给出初始值, 爬楼梯的初始值应该是爬 1 级有1 种,2级的话有 2 种,这里2级也是个初始值
+ // 给出初始值, 爬楼梯的初始值
dp[0] = 0;
dp[1] = 1;
for(int i = 2; i <= n; i++) {
@@ -336,7 +356,7 @@ public int climbStairs(int n) {
想想这道题,用哪种遍历方式合适一些呢?
-用哪种遍历方式,可以逐个分析嘛。第一种遍历方式通常用于暴力解法,第二中后边我们也会用到(最长回文子串),第三种由于可以产生递推关系,动态规划问题用的挺多的。
+用哪种遍历方式,可以逐个分析嘛。第一种遍历方式通常用于暴力解法,第二种后边我们也会用到(最长回文子串),第三种由于可以产生递推关系,动态规划问题用的挺多的。
#### 分析题目
@@ -350,14 +370,14 @@ public int climbStairs(int n) {
2. **初始状态**:如果数组只有 1 个元素,那 dp 数组的第一个元素也就是数组的第一个元素本身,**`dp[0] = nums[0]`**;
-3. **状态转移方程**:因为我们的数组中可能有负数的情况,从头遍历的话,如有下一个元素是负数的话,和反而更小了,所以我们遍历以元素结尾的子序列,若前一个元素大于 0($nums[i-1] > 0$),则将它加到当前元素上 $nums[i] = nums[i-1]+nums[i]$。最终 $nums[i]$ 保存的是以原先数组中 $nums[i]$ 结尾的最大子序列和。最后整体遍历一边 $nums[i]$ 就能找到整个数组最大的子序列和啦。所以状态转移方程:
+3. **状态转移方程**:因为我们的数组中可能有负数的情况,从头遍历的话,如有下一个元素是负数的话,和反而更小了,所以我们遍历以元素结尾的子序列,若前一个元素大于 0($nums[i-1] > 0$),则将它加到当前元素上 $nums[i] = nums[i-1]+nums[i]$。最终 $nums[i]$ 保存的是以原先数组中 $nums[i]$ 结尾的最大子序列和。最后整体遍历一遍 $nums[i]$ 就能找到整个数组最大的子序列和啦。所以状态转移方程:
$dp[i]=\max \{nums[i],dp[i−1]+nums[i]\}$
4. **输出结果**:转移方程只是保存了当前元素的最大和,我们要求的是最终的那个最大值,所以需要从 dp[i] 中找到最大值返回
```java
-public int maxSubArray3(int[] nums) {
+public int maxSubArray(int[] nums) {
//特判
if (nums == null || nums.length == 0) {
return 0;
@@ -400,7 +420,7 @@ public int maxSubArray(int[] nums) {
> 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
>
-> 给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
+> 给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
>
> ```
>输入:[1,2,3,1]
@@ -409,18 +429,11 @@ public int maxSubArray(int[] nums) {
> 偷窃到的最高金额 = 1 + 3 = 4 。
> ```
>
-> ```
->输入:[2,7,9,3,1]
-> 输出:12
->解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
-> 偷窃到的最高金额 = 2 + 9 + 1 = 12 。
-> ```
>
->
-> 提示:
+>提示:
>
>0 <= nums.length <= 100
->0 <= nums[i] <= 400
+> 0 <= nums[i] <= 400
#### 分析题目
@@ -459,7 +472,7 @@ public int rob(int[] nums) {
#### 优化
-同样的优化套路,上述方法使用了数组存储结果。但是每间房屋的最高总金额只和该房屋的前两间房屋的最高总金额相关,因此可以使用滚动数组,在每个时刻只需要存储前两间房屋的最高总金额。和斐波那契额数列优化同理。
+同样的优化套路,上述方法使用了数组存储结果。但是每间房屋的最高总金额只和该房屋的前两间房屋的最高总金额相关,因此可以使用滚动数组,在每个时刻只需要存储前两间房屋的最高总金额。和斐波那契数列优化同理。
```java
public int rob(int[] nums) {
@@ -526,11 +539,11 @@ public int rob(int[] nums) {
dp[0…m-1] [0] = 1; // 机器人一直向下走,第 0 列统统为 1
```
-3. **状态转移方程**:要到达任一位置 (m,n) 的总路径条数,总是等于位置 (m-1,n) 的路径条数加上位置(m,n-1) 的路径条数。即 $dp[m][n] = dp[m-1][n] + dp[m][n-1]$
+3. **状态转移方程**:要到达任一位置 (m,n) 的总路径条数,总是等于位置 (m-1,n) 的路径条数加上位置(m,n-1) 的路径条数。即 $dp[m][n] = dp[m-1][n] + dp[m][n-1]$
4. **输出结果**:由于数组是从下标 0 开始算起的,所以 $dp[m - 1][n - 1]$ 才是我们要的结果
-
+
```java
public int uniquePaths(int m, int n) {
@@ -599,11 +612,9 @@ public int uniquePaths(int m, int n) {
以 coins = [1, 2, 5],amount = 11 为例。我们要求组成 11 的最少硬币数,可以考虑组合中的最后一个硬币分别是1,2,5 的情况,比如:
-最后一个硬币是 1 的话,最少硬币数应该为【组成 10 的最少硬币数】+ 1枚(1块硬币)
-
-最后一个硬币是 2 的话,最少硬币数应该为【组成 9 的最少硬币数】+ 1枚(2块硬币)
-
-最后一个硬币是 5 的话,最少硬币数应该为【组成 6 的最少硬币数】+ 1枚(5块硬币)
+- 最后一个硬币是 1 的话,最少硬币数应该为【组成 10 的最少硬币数】+ 1枚(1块硬币)
+- 最后一个硬币是 2 的话,最少硬币数应该为【组成 9 的最少硬币数】+ 1枚(2块硬币)
+- 最后一个硬币是 5 的话,最少硬币数应该为【组成 6 的最少硬币数】+ 1枚(5块硬币)
在这 3 种情况中硬币数最少的那个就是结果
@@ -617,39 +628,42 @@ public int uniquePaths(int m, int n) {
```java
for(int coin : coins){
- result = Math.min(result,1+dp[n-coin])
+ result = Math.min(result,1+dp[amout-coin])
}
```
4. **输出结果**: $dp[amout]$
```java
-public static int coinChange(int[] coins, int amount) {
+public int coinChange(int[] coins, int amount) {
//定义数组
int[] dp = new int[amount + 1];
int max = amount + 1;
- // 初始化每个值为 amount+1,这样当最终求得的 dp[amount] 为 amount+1 时,说明问题无解
+ // 初始化每个值为 amount+1,这样当最终求得的 dp[amount] 为 amount+1 时,说明问题无解, 或者初始化一个特殊值
Arrays.fill(dp, max);
//初始值
dp[0] = 0;
- // 外层 for 循环在遍历所有状态的所有取值
+ // 外层 for 循环在遍历所有可能得金额,(从1到amount)
//dp[i]上的值不断选择已含有硬币值当前位置的数组值 + 1,min保证每一次保存的是最小值
for (int i = 1; i < amount + 1; i++) {
- //内层 for 循环在求所有选择的最小值 状态转移方程
+ //内层循环所有硬币面额
for (int coin : coins) {
+ //如果i= coin) {
//分两种情况,使用硬币coin和不使用,取最小值
dp[i] = Math.min(dp[i - coin] + 1, dp[i]);
}
}
}
- return dp[amount] > amount ? -1 : dp[amount];
+ return dp[amount] == amount + 1 ? -1 : dp[amount];
}
```
> 为啥 `dp` 数组初始化为 `amount + 1` 呢,因为凑成 `amount` 金额的硬币数最多只可能等于 `amount`(全用 1 元面值的硬币),所以初始化为 `amount + 1` 就相当于初始化为正无穷,便于后续取最小值。为啥不直接初始化为 int 型的最大值 `Integer.MAX_VALUE` 呢?因为后面有 `dp[i - coin] + 1`,这就会导致整型溢出。
+>
+> 最终,dp[amout] 就是凑成总金额所需的最少硬币数,如果dp[amount] 仍是初始化的较大值,说明无法凑出,返回 -1。
@@ -679,7 +693,7 @@ public static int coinChange(int[] coins, int amount) {
我们需要找出给定数组中两个数字之间的最大差值(即,最大利润)。此外,第二个数字(卖出价格)必须大于第一个数字(买入价格)
```java
-public static int dp(int[] prices) {
+public int dp(int[] prices) {
int length = prices.length;
if (length == 0) {
return 0;
@@ -709,13 +723,13 @@ public static int dp(int[] prices) {
#### 分析题目
-回文的意思是正着念和倒着念一样,如:大波美人美波大
+回文的意思是正着念和倒着念一样,如:大波美人鱼人美波大
建立二维数组 `dp` ,找出所有的回文子串。
回文串两边加上两个相同字符,会形成一个新的回文串 。
-
+
@@ -723,8 +737,6 @@ public static int dp(int[] prices) {

-
-
首先,单个字符就形成一个回文串,所以,所有 `dp[i][i] = true` 。

@@ -785,158 +797,133 @@ public String longestPalindrome_1(String s) {
+### 8、数字三角形问题
+```
+7
+3 8
+8 1 0
+2 7 4 4
+4 5 2 6 5
+```
-## 总结
+从上到下选择一条路,使得经过的数字之和最大。
-
+路径上的每一步只能往左下或者右下走。
+#### 分析题目
+递归解法
-## Reference
+可以看出每走第n行第m列时有两种后续:向下或者向右下。由于最后一行可以确定,当做边界条件,所以我们自然而然想到递归求解
-- http://netedu.xauat.edu.cn/jpkc/netedu/jpkc/ycx/kcjy/kejian/pdf/05.pdf
+```java
+class Solution{
+
+ public int getMax(){
+ int MAX = 101;
+ int[][] D = new int[MAX][MAX]; //存储数字三角形
+ int n; //n表示层数
+ int i = 0; int j = 0;
+ int maxSum = getMaxSum(D,n,i,j);
+ return maxSum;
+ }
+
+ public int getMaxSum(int[][] D,int n,int i,int j){
+ if(i == n){
+ return D[i][j];
+ }
+ int x = getMaxSum(D,n,i+1,j);
+ int y = getMaxSum(D,n,i+1,j+1);
+ return Math.max(x,y)+D[i][j];
+ }
+}
+```
-- https://leetcode-cn.com/circle/article/lxC3ZB/
-- https://labuladong.gitbook.io/algo/dong-tai-gui-hua-xi-lie/dong-tai-gui-hua-xiang-jie-jin-jie
-- https://www.zhihu.com/question/39948290
-- https://zhuanlan.zhihu.com/p/26743197
-- https://writings.sh/post/algorithm-longest-palindromic-substrings
+## 总结
+
+## 番外篇
### 动态规划与其它算法的关系
-这一章我们将会介绍分治和贪心算法的核心思想,并与动态规划算法进行比较。
-
-#### 分治
-
-解决分治问题的时候,思路就是想办法把问题的规模减小,有时候减小一个,有时候减小一半,然后将每个小问题的解以及当前的情况组合起来得出最终的结果。例如归并排序和快速排序,归并排序将要排序的数组平均地分成两半,快速排序将数组随机地分成两半。然后不断地对它们递归地进行处理。
-
-这里存在有最优的子结构,即原数组的排序结果是在子数组排序的结果上组合出来的,但是不存在重复子问题,因为不断地对待排序的数组进行对半分的时候,两半边的数据并不重叠,分别解决左半边和右半边的两个子问题的时候,没有子问题重复出现,这是动态规划和分治的区别。
-
-#### 贪心
-
-关于最优子结构
-
-- 贪心:每一步的最优解一定包含上一步的最优解,上一步之前的最优解无需记录
-- 动态规划:全局最优解中一定包含某个局部最优解,但不一定包含上一步的局部最优解,因此需要记录之前的所有的局部最优解
-
-关于子问题最优解组合成原问题最优解的组合方式
-
-- 贪心:如果把所有的子问题看成一棵树的话,贪心从根出发,每次向下遍历最优子树即可,这里的最优是贪心意义上的最优。此时不需要知道一个节点的所有子树情况,于是构不成一棵完整的树
-- 动态规划:动态规划需要对每一个子树求最优解,直至下面的每一个叶子的值,最后得到一棵完整的树,在所有子树都得到最优解后,将他们组合成答案
-
-结果正确性
-
-- 贪心不能保证求得的最后解是最佳的,复杂度低
-- 动态规划本质是穷举法,可以保证结果是最佳的,复杂度高
-
-
-
-
-
-#### 线性动态规划
+#### **1. 贪心算法(Greedy Algorithm)**
-单串
-单串 dp[i] 线性动态规划最简单的一类问题,输入是一个串,状态一般定义为 dp[i] := 考虑[0..i]上,原问题的解,其中 i 位置的处理,根据不同的问题,主要有两种方式:
+**核心思想**:每一步都做出当前状态下的**局部最优选择**(即 “贪心选择”),不考虑该选择对后续步骤的影响,最终通过局部最优的累积试图得到全局最优解。本质是 “短视的”:只看眼前,不回头。
-第一种是 i 位置必须取,此时状态可以进一步描述为 dp[i] := 考虑[0..i]上,且取 i,原问题的解;
-第二种是 i 位置可以取可以不取
-大部分的问题,对 i 位置的处理是第一种方式,例如力扣:
+**特点**:
-70 爬楼梯问题
-801 使序列递增的最小交换次数
-790 多米诺和托米诺平铺
-746 使用最小花费爬楼梯
-线性动态规划中单串 dp[i] 的问题,状态的推导方向以及推导公式如下
+- 必须满足**贪心选择性质**:局部最优选择能导致全局最优解(否则贪心会失效)。
+- 子问题无依赖:每一步的选择不影响后续子问题的求解(子问题独立)。
+**适用场景**:
+- 最优子结构明确(如霍夫曼编码)
+- 贪心选择性质成立(如活动选择问题)
+- 无需全局最优验证(如零钱兑换特殊场景)
-1. 依赖比 i 小的 O(1) 个子问题
-dp[n] 只与常数个小规模子问题有关,状态的推导过程 dp[i] = f(dp[i - 1], dp[i - 2], ...)。时间复杂度 O(n)O(n),空间复杂度 O(n)O(n) 可以优化为 O(1)O(1),例如上面提到的 70, 801, 790, 746 都属于这类。
+#### 2. 分治法(Divide and Conquer)
-如图所示,虽然紫色部分的 dp[i-1], dp[i-2], ..., dp[0] 均已经计算过,但计算橙色的当前状态时,仅用到 dp[i-1],这属于比 i 小的 O(1)O(1) 个子问题。
+**核心思想**: 将原问题**分解为若干个规模更小、结构相同的子问题**,递归求解子问题后,**合并子问题的解**得到原问题的解。
+本质是 “分而治之”:拆分独立子问题,逐个击破再整合。
-例如,当 f(dp[i-1], ...) = dp[i-1] + nums[i] 时,当前状态 dp[i] 仅与 dp[i-1] 有关。这个例子是一种数据结构前缀和的状态计算方式,关于前缀和的详细内容请参考下一章。
+**特点**:
-2. 依赖比 i 小的 O(n) 个子问题
-dp[n] 与此前的更小规模的所有子问题 dp[n - 1], dp[n - 2], ..., dp[1] 都可能有关系。
+- **递归结构**:子问题相互独立(无重叠)
+- **合并成本高**:结果合并是关键步骤
+- **并行潜力**:子问题可并发求解
-状态推导过程如下:
+**适用场景**:
+- 问题可自然拆分(如排序、树操作)
+- 子问题规模相似(如二分搜索)
+- 合并操作复杂度可控(如归并排序)
-dp[i] = f(dp[i - 1], dp[i - 2], ..., dp[0])
-依然如图所示,计算橙色的当前状态 dp[i] 时,紫色的此前计算过的状态 dp[i-1], ..., dp[0] 均有可能用到,在计算 dp[i] 时需要将它们遍历一遍完成计算。
+#### **3. 动态规划(Dynamic Programming)**
-其中 f 常见的有 max/min,可能还会对 i-1,i-2,...,0 有一些筛选条件,但推导 dp[n] 时依然是 O(n)O(n) 级的子问题数量。
+**核心思想**: 对于具有**重叠子问题**和**最优子结构**的问题,将其分解为子问题后,**存储子问题的解(记忆化)** 以避免重复计算,通过子问题的解推导出原问题的解。本质是 “精打细算的”:记录历史,避免重复劳动。
-例如:
+**特点**:
-139 单词拆分
-818 赛车
-以 min 函数为例,这种形式的问题的代码常见写法如下
+- **最优子结构**:全局最优包含局部最优
+- **重叠子问题**:子问题反复出现(用表存储)
+- **状态转移方程**:定义问题间递推关系
+**适用场景**:
-for i = 1, ..., n
- for j = 1, ..., i-1
- dp[i] = min(dp[i], f(dp[j])
-时间复杂度 O(n^{2})O(n
-2
- ),空间复杂度 O(n)O(n)
+- 子问题重叠(如斐波那契数列)
+- 多阶段决策最优解(如背包问题)
+- 需要回溯最优路径(如最长公共子序列)
+| **特性** | 贪心算法 | 分治法 | 动态规划 |
+| -------------- | ---------------------- | -------------------- | ------------------ |
+| **决策依据** | 当前局部最优 | 子问题独立解 | 历史子问题最优解 |
+| **子问题关系** | 无重复计算 | 完全独立 | 高度重叠 |
+| **解空间处理** | 永不回溯 | 显式分割 | 存储+重用 |
+| **时间复杂度** | 通常最低 | 中等(依赖合并成本) | 通常较高 |
+| **结果保证** | **不保证全局最优** | 保证正确解 | **保证全局最优** |
+| **经典案例** | Dijkstra算法、活动选择 | 归并排序、快速排序 | 背包问题、最短路径 |
-**单串 dp[i] 经典问题**
-#### 1. 依赖比 i 小的 O(1) 个子问题
-
-- [53. 最大子数组和](https://leetcode-cn.com/problems/maximum-subarray/)
-
- 状态的推导是按照 i 从 0 到 n - 1 按顺序推的,推到 dp[i],时,dp[i - 1], ..., dp[0] 已经计算完。因为子数组是连续的,所以子问题 dp[i] 其实只与子问题 dp[i - 1] 有关。如果 [0..i-1] 上以 nums[i-1] 结尾的最大子数组和(缓存在 dp[i-1] )为非负数,则以 nums[i] 结尾的最大子数组和就在 dp[i-1] 的基础上加上 nums[i] 就是 dp[i] 的结果否则以 i 结尾的子数组就不要 i-1 及之前的数,因为选了的话子数组的和只会更小。
-
- 按照以上的分析,状态的转移可以写出来,如下
-
-
- dp[i] = nums[i] + max(dp[i - 1], 0)
- 这个是单串 dp[i] 的问题,状态的推导方向,以及推导公式如下
-
-
-
-
-#### 2. 依赖比 i 小的 O(n) 个子问题
-
-- [300. 最长上升子序列](https://leetcode-cn.com/problems/longest-increasing-subsequence/)
-
- 输入是一个单串,首先思考单串问题中设计状态 dp[i] 时拆分子问题的方式:枚举子串或子序列的结尾元素来拆分子问题,设计状态 dp[i] := 在子数组 [0..i] 上,且选了 nums[i] 时,的最长上升子序列。
-
- 因为子序列需要上升,因此以 i 结尾的子序列中,nums[i] 之前的数字一定要比 nums[i] 小才行,因此目标就是先找到以此前比 nums[i] 小的各个元素,然后每个所选元素对应一个以它们结尾的最长子序列,从这些子序列中选择最长的,其长度加 1 就是当前的问题的结果。如果此前没有比 nums[i] 小的数字,则当前问题的结果就是 1 。
+## Reference
- 按照以上的分析,状态的转移方程可以写出来,如下
+- http://netedu.xauat.edu.cn/jpkc/netedu/jpkc/ycx/kcjy/kejian/pdf/05.pdf
- dp[i] = max_{j}(dp[j]) + 1
- dp[i]=max
- j
+- https://leetcode-cn.com/circle/article/lxC3ZB/
- (dp[j])+1
+- https://labuladong.gitbook.io/algo/dong-tai-gui-hua-xi-lie/dong-tai-gui-hua-xiang-jie-jin-jie
- 其中 0 \leq j < i, nums[j] < nums[i]0≤j 贪心算法(greedy algorithm)是一种常见的解决优化问题的算法,其基本思想是在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期获得全局最优解。贪心算法简洁且高效,在许多实际问题中有着广泛的应用。
+>
+> 贪心算法和动态规划都常用于解决优化问题。它们之间存在一些相似之处,比如都依赖最优子结构性质,但工作原理不同。
+>
+> - 动态规划会根据之前阶段的所有决策来考虑当前决策,并使用过去子问题的解来构建当前子问题的解。
+> - 贪心算法不会考虑过去的决策,而是一路向前地进行贪心选择,不断缩小问题范围,直至问题被解决。
+
+
+
+### 贪心算法的应用场景
+
+解决一个问题需要多个步骤,每一个步骤有多种选择。可以使用贪心算法解决的问题,每一步只需要解决一个子问题,只做出一种选择,就可以完成任务。
+
+### 贪心算法特性
+
+较于动态规划,贪心算法的使用条件更加苛刻,其主要关注问题的两个性质。
+
+- **贪心选择性质**:只有当局部最优选择始终可以导致全局最优解时,贪心算法才能保证得到最优解。
+- **最优子结构**:原问题的最优解包含子问题的最优解。
+
+#### 贪心算法与回溯算法、动态规划的区别
+
+「解决一个问题需要多个步骤,每一个步骤有多种选择」这样的描述我们在「回溯算法」「动态规划」算法中都会看到。它们的区别如下:
+
+- 「回溯算法」需要记录每一个步骤、每一个选择,用于回答所有具体解的问题;
+- 「动态规划」需要记录的是每一个步骤、所有选择的汇总值(最大、最小或者计数);
+- 「贪心算法」由于适用的问题,每一个步骤只有一种选择,一般而言只需要记录与当前步骤相关的变量的值。
+
+对于不同的求解目标和不同的问题场景,需要使用不同的算法。
+
+#### 可以使用「贪心算法」的问题需要满足的条件
+
+- 最优子结构:规模较大的问题的解由规模较小的子问题的解组成,区别于「动态规划」,可以使用「贪心算法」的问题「规模较大的问题的解」只由其中一个「规模较小的子问题的解」决定;
+- 无后效性:后面阶段的求解不会修改前面阶段已经计算好的结果;
+
+- 贪心选择性质:从局部最优解可以得到全局最优解。
+
+对「最优子结构」和「无后效性」的理解同「动态规划」,「贪心选择性质」是「贪心算法」最需要关注的内容。
+
+
+
+### 贪心算法解题步骤
+
+贪心问题的解决流程大体可分为以下三步。
+
+1. **问题分析**:梳理与理解问题特性,包括状态定义、优化目标和约束条件等。这一步在回溯和动态规划中都有涉及。
+2. **确定贪心策略**:确定如何在每一步中做出贪心选择。这个策略能够在每一步减小问题的规模,并最终解决整个问题。
+3. **正确性证明**:通常需要证明问题具有贪心选择性质和最优子结构。这个步骤可能需要用到数学证明,例如归纳法或反证法等。
+
+确定贪心策略是求解问题的核心步骤,但实施起来可能并不容易,主要有以下原因。
+
+- **不同问题的贪心策略的差异较大**。对于许多问题来说,贪心策略比较浅显,我们通过一些大概的思考与尝试就能得出。而对于一些复杂问题,贪心策略可能非常隐蔽,这种情况就非常考验个人的解题经验与算法能力了。
+- **某些贪心策略具有较强的迷惑性**。当我们满怀信心设计好贪心策略,写出解题代码并提交运行,很可能发现部分测试样例无法通过。这是因为设计的贪心策略只是“部分正确”的,上文介绍的零钱兑换就是一个典型案例。
+
+
+
+#### 「力扣」第 455 题:分发饼干(简单)
+
+> 假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。
+>
+> 对每个孩子 i,都有一个胃口值 g[i],这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j,都有一个尺寸 s[j] 。如果 s[j] >= g[i],我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。
+>
+> 示例 1:
+>
+> 输入: g = [1,2,3], s = [1,1]
+> 输出: 1
+> 解释:
+> 你有三个孩子和两块小饼干,3个孩子的胃口值分别是:1,2,3。
+> 虽然你有两块小饼干,由于他们的尺寸都是1,你只能让胃口值是1的孩子满足。
+> 所以你应该输出1。
+
+```java
+class Solution {
+ public int findContentChildren(int[] g, int[] s) {
+ Arrays.sort(g);
+ Arrays.sort(s);
+ int numOfChildren = g.length, numOfCookies = s.length;
+ int count = 0;
+ for (int i = 0, j = 0; i < numOfChildren && j < numOfCookies; i++, j++) {
+ while (j < numOfCookies && g[i] > s[j]) {
+ j++;
+ }
+ if (j < numOfCookies) {
+ count++;
+ }
+ }
+ return count;
+ }
+}
+```
+
+「贪心算法」总是做出在当前看来最好的选择就可以完成任务;
+解决「贪心算法」几乎没有套路,到底如何贪心,贪什么与我们要解决的问题密切相关。因此刚开始学习「贪心算法」的时候需要学习和模仿,然后才有直觉,猜测一个问题可能需要使用「贪心算法」,进而尝试证明,学会证明。
+
+
+
+## 贪心算法典型例题
+
+贪心算法常常应用在满足贪心选择性质和最优子结构的优化问题中,以下列举了一些典型的贪心算法问题。
+
+- **硬币找零问题**:在某些硬币组合下,贪心算法总是可以得到最优解。
+- **区间调度问题**:假设你有一些任务,每个任务在一段时间内进行,你的目标是完成尽可能多的任务。如果每次都选择结束时间最早的任务,那么贪心算法就可以得到最优解。
+- **分数背包问题**:给定一组物品和一个载重量,你的目标是选择一组物品,使得总重量不超过载重量,且总价值最大。如果每次都选择性价比最高(价值 / 重量)的物品,那么贪心算法在一些情况下可以得到最优解。
+- **股票买卖问题**:给定一组股票的历史价格,你可以进行多次买卖,但如果你已经持有股票,那么在卖出之前不能再买,目标是获取最大利润。
+- **霍夫曼编码**:霍夫曼编码是一种用于无损数据压缩的贪心算法。通过构建霍夫曼树,每次选择出现频率最低的两个节点合并,最后得到的霍夫曼树的带权路径长度(编码长度)最小。
+- **Dijkstra 算法**:它是一种解决给定源顶点到其余各顶点的最短路径问题的贪心算法。
+
diff --git a/docs/data-structure-algorithms/algorithm/Recursion.md b/docs/data-structure-algorithms/algorithm/Recursion.md
new file mode 100644
index 0000000000..f8a1992021
--- /dev/null
+++ b/docs/data-structure-algorithms/algorithm/Recursion.md
@@ -0,0 +1,389 @@
+---
+title: 递归算法
+date: 2024-05-09
+tags:
+ - Recursion
+categories: Algorithm
+---
+
+
+
+
+
+### 什么是递归
+
+递归的基本思想是某个函数直接或者间接地调用自身,这样就把原问题的求解转换为许多性质相同但是规模更小的子问题。我们只需要关注如何把原问题划分成符合条件的子问题,而不需要去研究这个子问题是如何被解决的。
+
+**简单地说,就是如果在函数中存在着调用函数本身的情况,这种现象就叫递归。**
+
+你以前肯定写过递归,只是有可能某些不知道这就是递归罢了。
+
+以阶乘函数为例,在 factorial 函数中存在着 `factorial(n - 1)` 的调用,所以此函数是递归函数
+
+```java
+public long factorial(int n) {
+ if (n < =1) {
+ return 1;
+ }
+ return n * factorial(n - 1)
+}
+```
+
+进一步剖析「递归」,先有「递」再有「归」,「递」的意思是将问题拆解成子问题来解决, 子问题再拆解成子子问题,...,直到被拆解的子问题无需再拆分成更细的子问题(即可以求解),「归」是说最小的子问题解决了,那么它的上一层子问题也就解决了,上一层的子问题解决了,上上层子问题自然也就解决了,....,直到最开始的问题解决,文字说可能有点抽象,那我们就以阶层 f(6) 为例来看下它的「递」和「归」。
+
+
+
+求解问题 `f(6)`,由于 `f(6) = n * f(5)`, 所以 `f(6)` 需要拆解成 `f(5)` 子问题进行求解,同理 `f(5) = n * f(4) `,也需要进一步拆分,... ,直到 `f(1)`, 这是「递」,`f(1) `解决了,由于 `f(2) = 2 f(1) = 2` 也解决了,.... `f(n)` 到最后也解决了,这是「归」,所以递归的本质是能把问题拆分成具有**相同解决思路**的子问题,。。。直到最后被拆解的子问题再也不能拆分,解决了最小粒度可求解的子问题后,在「归」的过程中自然顺其自然地解决了最开始的问题。
+
+
+
+### 递归原理
+
+递归的基本思想是某个函数直接或者间接地调用自身,这样就把原问题的求解转换为许多性质相同但是规模更小的子问题。
+
+我们只需要关注如何把原问题划分成符合条件的子问题,而不需要去研究这个子问题是如何被解决的。
+
+递归和枚举的区别在于:枚举是横向地把问题划分,然后依次求解子问题,而递归是把问题逐级分解,是纵向的拆分。
+
+```java
+int func(你今年几岁) {
+ // 最简子问题,递归终止条件
+ if (你1999年几岁) return 我0岁;
+ // 递归调用,缩小规模
+ return func(你去年几岁) + 1;
+}
+```
+
+任何一个有意义的递归算法总是两部分组成:**递归调用**和**递归终止条件**。
+
+为了确保递归函数不会导致无限循环,它应具有以下属性:
+
+1. 一个简单的`基本案例(basic case)`(或一些案例) —— 能够不使用递归来产生答案的终止方案。
+2. 一组规则,也称作`递推关系(recurrence relation)`,可将所有其他情况拆分到基本案例。
+
+注意,函数可能会有多个位置进行自我调用。
+
+```java
+public returnType recursiveFunction(parameters) {
+ // 1. 递归终止条件(Base Case)
+ if (isBaseCase(parameters)) {
+ return baseCaseResult; // 返回问题的基本解,避免继续递归
+ }
+
+ // 2. 递归调用(Recursive Call)
+ // 将当前问题分解为更小的子问题
+ return recursiveFunction(modifiedParameters);
+
+ // 或者对于分治问题,递归调用多个子问题:
+ // return combineResults(
+ // recursiveFunction(subProblem1),
+ // recursiveFunction(subProblem2)
+ // );
+}
+
+```
+
+
+
+### 递归需要满足的三个条件
+
+只要同时满足以下三个条件,就可以用递归来解决。
+
+1. **一个问题的解可以分解为几个子问题的解**
+
+ 何为子问题?子问题就是数据规模更小的问题。比如前面的案例,要知道“自己在哪一排”,可以分解为“前一排的人在哪一排”这样的一个子问题。
+
+2. **这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样**
+
+ 如案例所示,求解“自己在哪一排”的思路,和前面一排人求解“自己在哪一排”的思路是一模一样的。
+
+3. **存在递归终止条件**
+
+ 把问题分解为子问题,把子问题再分解为子子问题,一层一层分解下去,不能存在无限循环,这就需要有终止条件。
+
+
+
+### 怎样编写递归代码
+
+写递归代码,可以按三步走:
+
+**第一要素:明确你这个函数想要干什么**
+
+首先,你需要明确你要解决的问题,以及这个问题是否适合用递归来解决。
+
+也就是说,我们先不管函数里面的代码什么,而是要先明白,你这个函数是要用来干什么。
+
+例如,我定义了一个函数,算 n 的阶乘
+
+```java
+// 算 n 的阶乘(假设n不为0)
+int factorial(int n){
+
+}
+```
+
+**第二要素:寻找递归结束条件**
+
+递归基(Base Case)是递归函数结束的条件。如果没有递归基,递归函数会无限循环下去。递归基应该是问题的最小单位,在这种情况下,函数可以直接返回结果而无需进一步递归。
+
+也就是说,我们需要找出**当参数为啥时,递归结束,之后直接把结果返回**,请注意,这个时候我们必须能根据这个参数的值,能够**直接**知道函数的结果是什么。
+
+例如,上面那个例子,当 n = 1 时,那你应该能够直接知道 f(n) 是啥吧?此时,f(1) = 1。完善我们函数内部的代码,把第二要素加进代码里面
+
+```java
+// 算 n 的阶乘(假设n不为0)
+int f(int n){
+ if(n == 1){
+ return 1;
+ }
+}
+```
+
+有人可能会说,当 n = 2 时,那我们可以直接知道 f(n) 等于多少啊,那我可以把 n = 2 作为递归的结束条件吗?
+
+当然可以,只要你觉得参数是什么时,你能够直接知道函数的结果,那么你就可以把这个参数作为结束的条件,所以下面这段代码也是可以的。
+
+```java
+// 算 n 的阶乘(假设n>=2)
+int f(int n){
+ if(n == 2){
+ return 2;
+ }
+}
+```
+
+注意我代码里面写的注释,假设 n >= 2,因为如果 n = 1时,会被漏掉,当 n <= 2时,f(n) = n,所以为了更加严谨,我们可以写成这样:
+
+```java
+// 算 n 的阶乘(假设n不为0)
+int f(int n){
+ if(n <= 2){
+ return n;
+ }
+}
+```
+
+**第三要素:定义递归关系**
+
+第三要素就是,我们要**不断缩小参数的范围**,缩小之后,我们可以通过一些辅助的变量或者操作,使原函数的结果不变。
+
+例如,f(n) 这个范围比较大,我们可以让 `f(n) = n * f(n-1)`。这样,范围就由 n 变成了 n-1 了,范围变小了,并且为了原函数 f(n) 不变,我们需要让 f(n-1) 乘以 n。
+
+说白了,就是要找到原函数的一个等价关系式,`f(n)` 的等价关系式为 `n * f(n-1)`,即 `f(n) = n * f(n-1)`。
+
+> **写递归代码的关键就是找到如何将大问题分解为小问题的规律,请求基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码**。
+>
+> 当我们面对一个问题需要分解为多个子问题的时候,递归代码往往没那么好理解,比如第二个案例,人脑几乎没办法把整个“递”和“归”的过程一步一步都想清楚。
+>
+> 计算机擅长做重复的事情,所以递归正符合它的胃口。而我们人脑更喜欢平铺直叙的思维方式。当我们看到递归时,我们总想把递归平铺展开,脑子里就会循环,一层一层往下调,然后再一层一层返回,试图想搞清楚计算机每一步都是怎么执行的,这样就很容易被绕进去。
+>
+> 对于递归代码,这种试图想清楚整个递和归过程的做法,实际上是进入了一个思维误区。很多时候,我们理解起来比较吃力,主要原因就是自己给自己制造了这种理解障碍。那正确的思维方式应该是怎样的呢?
+>
+> 如果一个问题 A 可以分解为若干子问题 B、C、D,可以假设子问题 B、C、D 已经解决,在此基础上思考如何解决问题 A。而且,只需要思考问题 A 与子问题 B、C、D 两层之间的关系即可,不需要一层一层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节,这样子理解起来就简单多了。
+>
+> 换句话说就是:千万不要跳进这个函数里面企图探究更多细节,否则就会陷入无穷的细节无法自拔,人脑能压几个栈啊。
+>
+> 所以,编写递归代码的关键是:**只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤**。
+
+
+
+#### 递归代码要警惕堆栈溢出
+
+递归调用的深度受限于程序的栈空间。如果递归深度太深,可能会导致栈溢出。对于深度较大的递归问题,可以考虑使用迭代方法或尾递归优化(Tail Recursion Optimization)。
+
+
+
+#### 递归代码要警惕重复计算
+
+在某些问题中(如斐波那契数列),直接递归可能导致大量重复计算,影响效率。可以使用记忆化(Memoization)或动态规划(Dynamic Programming)来优化递归,避免重复计算。
+
+
+
+### 案例
+
+#### 斐波那契数列
+
+> 斐波那契数列的是这样一个数列:1、1、2、3、5、8、13、21、34....,即第一项 f(1) = 1,第二项 f(2) = 1.....,第 n 项目为 f(n) = f(n-1) + f(n-2)。求第 n 项的值是多少。
+
+```java
+int f(int n){
+ // 1.先写递归结束条件
+ if(n <= 2){
+ return 1;
+ }
+ // 2.接着写等价关系式
+ return f(n-1) + f(n - 2);
+}
+```
+
+
+
+#### 小青蛙跳台阶
+
+> 一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
+
+每次跳的时候,小青蛙可以跳一个台阶,也可以跳两个台阶,也就是说,每次跳的时候,小青蛙有两种跳法。
+
+第一种跳法:第一次我跳了一个台阶,那么还剩下n-1个台阶还没跳,剩下的n-1个台阶的跳法有f(n-1)种。
+
+第二种跳法:第一次跳了两个台阶,那么还剩下n-2个台阶还没,剩下的n-2个台阶的跳法有f(n-2)种。
+
+所以,小青蛙的全部跳法就是这两种跳法之和了,即 f(n) = f(n-1) + f(n-2)。至此,等价关系式就求出来了
+
+```java
+int f(int n){
+ // 1.先写递归结束条件
+ if(n == 1){
+ return 1;
+ }
+ // 2.接着写等价关系式
+ ruturn f(n-1) + f(n-2);
+}
+```
+
+大家觉得上面的代码对不对?
+
+答是不大对,当 n = 2 时,显然会有 f(2) = f(1) + f(0)。我们知道,f(0) = 0,按道理是递归结束,不用继续往下调用的,但我们上面的代码逻辑中,会继续调用 f(0) = f(-1) + f(-2)。这会导致无限调用,进入**死循环**。
+
+这也是我要和你们说的,关于**递归结束条件是否够严谨问题**,有很多人在使用递归的时候,由于结束条件不够严谨,导致出现死循环。也就是说,当我们在第二步找出了一个递归结束条件的时候,可以把结束条件写进代码,然后进行第三步,但是**请注意**,当我们第三步找出等价函数之后,还得再返回去第二步,根据第三步函数的调用关系,会不会出现一些漏掉的结束条件。就像上面,f(n-2)这个函数的调用,有可能出现 f(0) 的情况,导致死循环,所以我们把它补上。代码如下:
+
+```java
+int f(int n){
+ //f(0) = 0,f(1) = 1,等价于 n<=1时,f(n) = n。
+ if(n <= 1){
+ return n;
+ }
+ ruturn f(n-1) + f(n-2);
+}
+```
+
+#### 反转单链表
+
+> 反转单链表。例如链表为:1->2->3->4。反转后为 4->3->2->1
+
+链表的节点定义如下:
+
+```java
+class Node{
+ int date;
+ Node next;
+}
+```
+
+这个的等价关系不像 n 是个数值那样,比较容易寻找。但是我告诉你,它的等价条件中,一定是范围不断在缩小,对于链表来说,就是链表的节点个数不断在变小
+
+```java
+//用递归的方法反转链表
+public Node reverseList(Node head){
+ // 1.递归结束条件
+ if (head == null || head.next == null) {
+ return head;
+ }
+ // 递归反转 子链表
+ Node newList = reverseList(head.next);
+ // 改变 1,2节点的指向。
+ // 通过 head.next获取节点2
+ Node t1 = head.next;
+ // 让 2 的 next 指向 2
+ t1.next = head;
+ // 1 的 next 指向 null.
+ head.next = null;
+ // 把调整之后的链表返回。
+ return newList;
+ }
+```
+
+
+
+#### [两两交换链表中的节点](https://leetcode.cn/problems/swap-nodes-in-pairs/)
+
+> 给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。
+>
+> 
+>
+> ```
+> 输入:head = [1,2,3,4]
+> 输出:[2,1,4,3]
+> ```
+
+当前节点 next,指向当前节点,指针互换
+
+```java
+ public ListNode swapPairs(ListNode head) {
+ // 基本情况:链表为空或只有一个节点
+ if (head == null || head.next == null) {
+ return head;
+ }
+
+ // 交换前两个节点
+ ListNode first = head;
+ ListNode second = head.next;
+
+ // 递归交换后续的节点
+ first.next = swapPairs(second.next);
+
+ // 交换后的第二个节点成为新的头节点
+ second.next = first;
+
+ // 返回新的头节点
+ return second;
+ }
+```
+
+下边这么写也可以,少了一个局部变量,交换操作和递归调用在一行内完成。
+
+```java
+public ListNode swapPairs(ListNode head) {
+ //递归的终止条件
+ if(head==null || head.next==null) {
+ return head;
+ }
+ //假设链表是 1->2->3->4
+ //这句就先保存节点2
+ ListNode tmp = head.next;
+ //继续递归,处理节点3->4
+ //当递归结束返回后,就变成了4->3
+ //于是head节点就指向了4,变成1->4->3
+ head.next = swapPairs(tmp.next);
+ //将2节点指向1
+ tmp.next = head;
+ return tmp;
+}
+```
+
+当然,也可以迭代实现~
+
+
+
+```java
+public class Solution {
+ public ListNode swapPairs(ListNode head) {
+ // 创建虚拟头节点,指向链表的头节点
+ ListNode dummy = new ListNode(0);
+ dummy.next = head;
+
+ // 当前节点指针,初始化为虚拟头节点
+ ListNode current = dummy;
+
+ // 遍历链表
+ while (current.next != null && current.next.next != null) {
+ // 初始化两个要交换的节点
+ ListNode first = current.next;
+ ListNode second = current.next.next;
+
+ // 交换这两个节点
+ first.next = second.next;
+ second.next = first;
+ current.next = second;
+
+ // 移动 current 指针到下一个需要交换的位置
+ current = first;
+ }
+
+ // 返回交换后的链表头
+ return dummy.next;
+ }
+}
+
+```
+
diff --git a/docs/data-structure-algorithms/algorithm/Sort.md b/docs/data-structure-algorithms/algorithm/Sort.md
new file mode 100644
index 0000000000..dbbc9842d3
--- /dev/null
+++ b/docs/data-structure-algorithms/algorithm/Sort.md
@@ -0,0 +1,826 @@
+---
+title: 排序
+date: 2023-10-09
+tags:
+ - Algorithm
+categories: Algorithm
+---
+
+
+
+> 🔢 **排序算法**,从接触计算机学科就会遇到的一个问题。
+
+排序算法可以分为**内部排序**和**外部排序**:
+- 🧠 **内部排序**:数据记录在内存中进行排序
+- 💾 **外部排序**:因排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存
+
+常见的内部排序算法有:**插入排序、希尔排序、选择排序、冒泡排序、归并排序、快速排序、堆排序、基数排序**等。
+
+| 排序算法 | 平均时间复杂度 | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 |
+| :----------------------: | -------------- | ----------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------- |
+| 冒泡排序-Bubble Sort | $O(n²)$ | $O(n)$(当数组已经有序时,只需遍历一次) | $O(n²)$(当数组是逆序时,需要多次交换) | $O(1)$(原地排序) | 稳定 |
+| 选择排序-Selection Sort | $O(n²)$ | $O(n²)$ | $O(n²)$ | $O(1)$(原地排序) | 不稳定(交换元素时可能破坏相对顺序) |
+| 插入排序-Insertion Sort | $O(n²)$ | $O(n)$(当数组已经有序时) | $O(n²)$(当数组是逆序时) | $O(1)$(原地排序) | 稳定 |
+| 快速排序-Quick Sort | $O(n \log n)$ | $O(n \log n)$(每次划分的子数组大小相等) | $O(n²)$(每次选取的基准值使得数组划分非常不平衡) | $O(\log n)$(对于递归栈) $O(n)$(最坏情况递归栈深度为 n) | 不稳定(交换可能改变相同元素的相对顺序) |
+| 希尔排序-Shell Sort | $O(n \log² n)$ | $O(n \log n)$ | $O(n²)$(不同的增量序列有不同的最坏情况) | $O(1)$(原地排序) | 不稳定 |
+| 归并排序-Merge Sort | $O(n \log n)$ | $O(n \log n)$ | $O(n \log n)$ | $O(n)$(需要额外的空间用于辅助数组) | 稳定 |
+| 堆排序-Heap Sort | $O(n \log n)$ | $O(n \log n)$ | $O(n \log n)$ | $O(1)$(原地排序) | 不稳定(在调整堆时可能改变相同元素的相对顺序) |
+| 计数排序-Counting Sort) | $O(n + k)$ | $O(n + k)$(k 是数组中元素的取值范围) | $O(n + k)$ | $O(n + k)$(需要额外的数组来存储计数结果) | 稳定 |
+| 桶排序-Bucket Sort) | $O(n + k)$ | $O(n + k)$(k 是桶的数量,n 是元素数量) | $O(n²)$(所有元素都集中到一个桶里,退化成冒泡排序) | $O(n + k)$ | 稳定 |
+| 基数排序-Radix Sort | $O(d(n + k))$ | $O(d(n + k))$(d 是位数,k 是取值范围,n 是元素数量) | $O(d(n + k))$ | $O(n + k)$ | 稳定 |
+
+## 📊 排序算法分类
+
+十种常见排序算法可以分为两大类:
+
+### 🔄 非线性时间比较类排序
+通过比较来决定元素间的相对次序,由于其时间复杂度不能突破$O(nlogn)$,因此称为非线性时间比较类排序。
+
+> 💡 **特点**:需要比较元素大小,时间复杂度下界为 $O(n\log n)$
+
+### ⚡ 线性时间非比较类排序
+不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此称为线性时间非比较类排序。
+
+> 💡 **特点**:利用数据特性,可以达到线性时间复杂度
+
+
+
+## 🫧 冒泡排序
+
+冒泡排序(Bubble Sort)也是一种简单直观的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端。
+
+> 🎯 **算法特点**:
+> - 简单易懂,适合学习排序思想
+> - 时间复杂度:$O(n²)$
+> - 空间复杂度:$O(1)$
+> - 稳定排序
+
+作为最简单的排序算法之一,冒泡排序给我的感觉就像 Abandon 在单词书里出现的感觉一样,每次都在第一页第一位,所以最熟悉。冒泡排序还有一种优化算法,就是立一个 flag,当在一趟序列遍历中元素没有发生交换,则证明该序列已经有序。但这种改进对于提升性能来说并没有什么太大作用。
+
+### 1. 算法步骤
+
+1. 🔍 **比较相邻元素**:如果第一个比第二个大,就交换他们两个
+2. 🔄 **重复比较**:对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数
+3. 🎯 **缩小范围**:针对所有的元素重复以上的步骤,除了最后一个
+4. 🔁 **重复过程**:重复步骤1~3,直到排序完成
+
+### 2. 动图演示
+
+
+
+
+
+### 3. 性能分析
+
+- 🚀 **什么时候最快**:当输入的数据已经是正序时(都已经是正序了,我还要你冒泡排序有何用啊)
+- 🐌 **什么时候最慢**:当输入的数据是反序时(写一个 for 循环反序输出数据不就行了,干嘛要用你冒泡排序呢,我是闲的吗)
+
+```java
+//冒泡排序,a 表示数组, n 表示数组大小
+public void bubbleSort(int[] a) {
+ int n = a.length;
+ if (n <= 1) return;
+ // 外层循环遍历每一个元素
+ for (int i = 0; i < n; i++) {
+ //提前退出冒泡循环的标志位
+ boolean flag = false;
+ // 内层循环进行元素的比较与交换
+ for (int j = 0; j < n - i - 1; j++) {
+ if (a[j] > a[j+1]) {
+ int temp = a[j];
+ a[j] = a[j+1];
+ a[j+1] = temp;
+ flag = true; //表示有数据交换
+ }
+ }
+ if (!flag) break; //没有数据交换,提前退出。
+ }
+}
+```
+
+嵌套循环,应该立马就可以得出这个算法的时间复杂度为 $O(n²)$。
+
+冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度是 $O(1)$,是一个原地排序算法。
+
+
+
+## 🎯 选择排序
+
+选择排序的思路是这样的:首先,找到数组中最小的元素,拎出来,将它和数组的第一个元素交换位置,第二步,在剩下的元素中继续寻找最小的元素,拎出来,和数组的第二个元素交换位置,如此循环,直到整个数组排序完成。
+
+> 🎯 **算法特点**:
+> - 简单直观,容易理解
+> - 时间复杂度:$O(n²)$(无论什么情况)
+> - 空间复杂度:$O(1)$
+> - 不稳定排序
+
+选择排序是一种简单直观的排序算法,无论什么数据进去都是 $O(n²)$ 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。
+
+### 1. 算法步骤
+
+1. 🔍 **找最小元素**:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置
+2. 🔄 **继续寻找**:再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾
+3. 🔁 **重复过程**:重复第二步,直到所有元素均排序完毕
+
+### 2. 动图演示
+
+
+
+```java
+public void selectionSort(int [] arrs) {
+ for (int i = 0; i < arrs.length; i++) {
+ //最小元素下标
+ int min = i;
+ for (int j = i +1; j < arrs.length; j++) {
+ if (arrs[j] < arrs[min]) {
+ min = j;
+ }
+ }
+ //如果当前位置i的元素已经是未排序部分的最小元素,就不需要交换了
+ if(min != i){
+ //交换位置
+ int temp = arrs[i];
+ arrs[i] = arrs[min];
+ arrs[min] = temp;
+ }
+ }
+ }
+}
+```
+
+
+
+## 📝 插入排序
+
+插入排序的代码实现虽然没有冒泡排序和选择排序那么简单粗暴,但它的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。
+
+> 🎯 **算法特点**:
+> - 像整理扑克牌一样直观
+> - 时间复杂度:$O(n²)$(最坏),$O(n)$(最好)
+> - 空间复杂度:$O(1)$
+> - 稳定排序
+
+它的工作原理为将待排列元素划分为「已排序」和「未排序」两部分,每次从「未排序的」元素中选择一个插入到「已排序的」元素中的正确位置。
+
+插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入。
+
+### 1. 算法步骤
+
+1. 🎯 **起始点**:从第一个元素开始(下标为 0 的元素),该元素可以认为已经被排序
+2. 🔍 **取下一个元素**:取出下一个元素,在已经排序的元素序列中**从后向前**扫描
+3. 🔄 **比较移动**:如果该元素(已排序)大于新元素,将该元素移到下一位置
+4. 🔁 **重复比较**:重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
+5. 📍 **插入位置**:将新元素插入到该位置后
+6. 🔁 **重复过程**:重复步骤2~5
+
+### 2. 动图演示
+
+
+
+```java
+public void insertionSort(int[] arr) {
+ // 从下标为1的元素开始选择合适的位置插入,因为下标为0的只有一个元素,默认是有序的
+ for (int i = 1; i < arr.length; i++) {
+ int current = arr[i]; // 当前待插入元素
+ int j = i - 1; // 已排序部分的末尾索引
+
+ // 从后向前扫描,找到插入位置
+ while (j >= 0 && arr[j] > current) {
+ arr[j + 1] = arr[j]; // 元素后移
+ j--; // 索引左移
+ }
+ arr[j + 1] = current; // 插入到正确位置
+ }
+}
+```
+
+
+
+## ⚡ 快速排序
+
+快速排序的核心思想是分治法,分而治之。它的实现方式是每次从序列中选出一个基准值,其他数依次和基准值做比较,比基准值大的放右边,比基准值小的放左边,然后再对左边和右边的两组数分别选出一个基准值,进行同样的比较移动,重复步骤,直到最后都变成单个元素,整个数组就成了有序的序列。
+
+> 🎯 **算法特点**:
+> - 分治法思想,效率高
+> - 时间复杂度:$O(n\log n)$(平均),$O(n²)$(最坏)
+> - 空间复杂度:$O(\log n)$
+> - 不稳定排序
+
+> 快速排序的最坏运行情况是 $O(n²)$,比如说顺序数列的快排。但它的平摊期望时间是 $O(nlogn)$,且 $O(nlogn)$ 记号中隐含的常数因子很小,比复杂度稳定等于 $O(nlogn) $的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。
+>
+> 
+
+### 1. 算法步骤
+
+1. 🎯 **选择基准值**:从数组中选择一个元素作为基准值(pivot)。常见的选择方法有选取第一个元素、最后一个元素、中间元素或随机选取一个元素
+2. 🔄 **分区(Partition)**:遍历数组,将所有小于基准值的元素放在基准值的左侧,大于基准值的元素放在右侧。基准值放置在它的正确位置上
+3. 🔁 **递归排序**:对基准值左右两边的子数组分别进行递归排序,直到每个子数组的元素个数为 0 或 1,此时数组已经有序
+
+递归的最底部情形,是数列的大小是零或一,也就是数组都已经被排序好了。虽然一直递归下去,但是这个算法总会退出,因为在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。
+
+### 2. 动图演示
+
+
+
+```java
+public class QuickSort {
+ // 对外暴露的排序方法:传入待排序数组
+ public static void sort(int[] arr) {
+ if (arr == null || arr.length <= 1) {
+ return; // 数组为空或只有1个元素,无需排序
+ }
+ // 调用递归方法:初始排序范围是整个数组(从0到最后一个元素)
+ quickSort(arr, 0, arr.length - 1);
+ }
+
+ // 递归排序方法:排序 arr 的 [left, right] 区间
+ private static void quickSort(int[] arr, int left, int right) {
+ // 递归终止条件:当 left >= right 时,子数组只有1个元素或为空,无需排序
+ if (left >= right) {
+ return;
+ }
+
+ // 1. 分区操作:返回基准值最终的索引位置
+ // (把比基准小的放左边,比基准大的放右边,基准在中间)
+ int pivotIndex = partition(arr, left, right);
+
+ quickSort(arr, left, pivotIndex - 1);
+ quickSort(arr, pivotIndex + 1, right);
+ }
+
+ // 分区核心方法:选 arr[right] 为基准,完成分区并返回基准索引
+ private static int partition(int[] arr, int left, int right) {
+ // 基准值:这里选当前区间的最后一个元素
+ int pivot = arr[right];
+ // i 指针:指向“小于基准的区域”的最后一个元素(初始时区域为空,i = left-1)
+ int i = left - 1;
+
+ // j 指针:遍历当前区间的所有元素(从 left 到 right-1,跳过基准)
+ for (int j = left; j < right; j++) {
+ // 如果当前元素 arr[j] 小于等于基准,就加入“小于基准的区域”
+ if (arr[j] <= pivot) {
+ i++; // 先扩大“小于基准的区域”
+ swap(arr, i, j); // 交换 arr[i] 和 arr[j],把 arr[j] 放进区域
+ }
+ }
+
+ // 最后:把基准值放到“小于基准区域”的后面(i+1 位置)
+ // 此时 i+1 就是基准的最终位置(左边都<=基准,右边都>基准)
+ swap(arr, i + 1, right);
+ return i + 1;
+ }
+
+ // 辅助方法:交换数组中两个位置的元素
+ private static void swap(int[] arr, int a, int b) {
+ int temp = arr[a];
+ arr[a] = arr[b];
+ arr[b] = temp;
+ }
+
+}
+```
+
+**📊 时间复杂度分析**
+
+**最优情况:$O(n \log n)$**
+
+- **适用场景**:每次分区操作都能将数组**均匀划分**(基准值接近中位数)
+- **推导过程**:
+ - 每次分区将数组分为两个近似相等的子数组
+ - 递归深度为 $\log_2 n$(分治次数),每层需遍历 $n$ 个元素
+ - 总比较次数满足递推公式:$T(n) = 2T(n/2) + O(n)$
+ - 通过主定理或递归树展开可得时间复杂度为 $O(n \log n)$
+
+**最坏情况:$O(n²)$**
+
+- **适用场景**:每次分区划分极不均衡(如基准值始终为最大/最小元素)
+- **常见案例**:输入数组已完全有序或逆序,且基准固定选择首/尾元素
+- **推导过程**:
+ - 每次分区仅减少一个元素(类似冒泡排序)
+ - 总比较次数为等差数列求和:$(n-1) + (n-2) + \cdots + 1 = \frac{n(n-1)}{2} = O(n^2)$
+ - 递归深度为 $n-1$ 层,导致时间复杂度退化为 $O(n²)$
+
+**平均情况:$O(n \log n)$**
+
+- **适用场景**:输入数据**随机分布**,基准值随机选取
+
+
+
+## 🔀 归并排序
+
+> 
+
+归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
+
+> 🎯 **算法特点**:
+> - 分治法典型应用
+> - 时间复杂度:$O(n\log n)$(稳定)
+> - 空间复杂度:$O(n)$
+> - 稳定排序
+
+分治,就是分而治之,将一个大问题分解成小的子问题来解决。小的问题解决了,大问题也就解决了。
+
+分治思想和递归思想很像。分治算法一般都是用递归来实现的。**分治是一种解决问题的处理思想,递归是一种编程技巧**,这两者并不冲突。
+
+作为一种典型的分而治之思想的算法应用,归并排序的实现有两种方法:
+
+- 🔄 **自上而下的递归**
+- 🔁 **自下而上的迭代**(所有递归的方法都可以用迭代重写,所以就有了第 2 种方法)
+
+和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是 $O(n\log n)$ 的时间复杂度。代价是需要额外的内存空间。
+
+### 1. 算法步骤
+
+1. 🔄 **分解**:将数组分成两半,递归地对每一半进行归并排序,直到每个子数组的大小为1(单个元素是有序的)
+2. 🔀 **合并**:将两个有序子数组合并成一个有序数组
+
+### 2. 动图演示
+
+
+
+
+
+```java
+public class MergeSort {
+ // 主排序函数
+ public static void mergeSort(int[] arr) {
+ if (arr.length < 2) return; // 基本情况
+ int mid = arr.length / 2;
+
+ // 分解
+ int[] left = new int[mid];
+ int[] right = new int[arr.length - mid];
+
+ // 填充左子数组
+ for (int i = 0; i < mid; i++) {
+ left[i] = arr[i];
+ }
+
+ // 填充右子数组
+ for (int i = mid; i < arr.length; i++) {
+ right[i - mid] = arr[i];
+ }
+
+ // 递归排序
+ mergeSort(left);
+ mergeSort(right);
+
+ // 合并已排序的子数组
+ merge(arr, left, right);
+ }
+
+ // 合并两个有序数组
+ private static void merge(int[] arr, int[] left, int[] right) {
+ // i、j、k 分别代表左子数组、右子数组、合并数组的指针
+ int i = 0, j = 0, k = 0;
+
+ // 合并两个有序数组,直到子数组都插入到合并数组
+ while (i < left.length && j < right.length) {
+ //比较 left[i] 和 right[j], 左右哪边小的,就放入合并数组,指针要 +1
+ if (left[i] <= right[j]) {
+ arr[k++] = left[i++];
+ } else {
+ arr[k++] = right[j++];
+ }
+ }
+
+ // 复制剩余元素
+ while (i < left.length) {
+ arr[k++] = left[i++];
+ }
+ while (j < right.length) {
+ arr[k++] = right[j++];
+ }
+ }
+
+ public static void main(String[] args) {
+ int[] arr = {38, 27, 43, 3, 9, 82, 10};
+ mergeSort(arr);
+ System.out.println("排序后的数组:");
+ for (int num : arr) {
+ System.out.print(num + " ");
+ }
+ }
+}
+
+```
+
+
+
+
+
+## 🌳 堆排序
+
+> 
+
+堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。
+
+> 🎯 **算法特点**:
+> - 基于堆数据结构
+> - 时间复杂度:$O(n\log n)$
+> - 空间复杂度:$O(1)$
+> - 不稳定排序
+
+分为两种方法:
+
+1. 📈 **大顶堆**:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列
+2. 📉 **小顶堆**:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列
+
+堆排序的平均时间复杂度为 $Ο(n\log n)$。
+
+### 1. 算法步骤
+
+1. 🏗️ **构建最大堆**:
+ - 首先将无序数组转换为一个**最大堆**。最大堆是一个完全二叉树,其中每个节点的值都大于或等于其子节点的值
+ - 最大堆的根节点(堆顶)是整个堆中的最大元素
+
+2. 🔄 **反复取出堆顶元素**:
+ - 将堆顶元素(最大值)与堆的最后一个元素交换,然后减少堆的大小,堆顶元素移到数组末尾
+ - 调整剩余的元素使其重新成为一个最大堆
+ - 重复这个过程,直到所有元素有序
+
+### 2. 动图演示
+
+
+
+```java
+public class HeapSort {
+ // 主排序函数
+ public static void heapSort(int[] arr) {
+ int n = arr.length;
+
+ // 1. 构建最大堆
+ for (int i = n / 2 - 1; i >= 0; i--) {
+ heapify(arr, n, i);
+ }
+
+ // 2. 逐步将堆顶元素与末尾元素交换,并缩小堆的范围
+ for (int i = n - 1; i > 0; i--) {
+ // 将当前堆顶(最大值)移到末尾
+ swap(arr, 0, i);
+
+ // 重新调整堆,使剩余元素保持最大堆性质
+ heapify(arr, i, 0);
+ }
+ }
+
+ // 调整堆的函数
+ private static void heapify(int[] arr, int n, int i) {
+ int largest = i; // 设当前节点为最大值
+ int left = 2 * i + 1; // 左子节点
+ int right = 2 * i + 2; // 右子节点
+
+ // 如果左子节点比当前最大值大
+ if (left < n && arr[left] > arr[largest]) {
+ largest = left;
+ }
+
+ // 如果右子节点比当前最大值大
+ if (right < n && arr[right] > arr[largest]) {
+ largest = right;
+ }
+
+ // 如果最大值不是根节点,则交换,并递归调整
+ if (largest != i) {
+ swap(arr, i, largest);
+ heapify(arr, n, largest);
+ }
+ }
+
+ // 交换两个元素的值
+ private static void swap(int[] arr, int i, int j) {
+ int temp = arr[i];
+ arr[i] = arr[j];
+ arr[j] = temp;
+ }
+}
+
+```
+
+
+
+## 🔢 计数排序
+
+**计数排序(Counting Sort)** 是一种基于计数的非比较排序算法,适用于对**非负整数**进行排序。计数排序通过统计每个元素出现的次数,然后利用这些信息将元素直接放到它们在有序数组中的位置,从而实现排序。
+
+> 🎯 **算法特点**:
+> - 非比较排序算法
+> - 时间复杂度:$O(n + k)$
+> - 空间复杂度:$O(k)$
+> - 稳定排序
+> - 适用于数据范围不大的场景
+
+计数排序的时间复杂度为 `O(n + k)`,其中 `n` 是输入数据的大小,`k` 是数据范围的大小。它特别适用于数据范围不大但数据量较多的场景。
+
+计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
+
+### 1. 算法步骤
+
+1. 🔍 **找到最大值和最小值**:找到数组中最大值和最小值,以确定计数数组的范围
+2. 🏗️ **创建计数数组**:创建一个计数数组,数组长度为 `max - min + 1`,用来记录每个元素出现的次数
+3. 📊 **统计每个元素的出现次数**:遍历原数组,将每个元素出现的次数记录在计数数组中
+4. 🔄 **累积计数**:将计数数组变为累积计数数组,使其表示元素在有序数组中的位置
+5. 📝 **回填到结果数组**:根据累积计数数组,回填到结果数组,得到最终的排序结果
+
+### 2. 动图演示
+
+[](https://github.com/hustcc/JS-Sorting-Algorithm/blob/master/res/countingSort.gif)
+
+```java
+import java.util.Arrays;
+
+public class CountingSort {
+ // 计数排序函数
+ public static void countingSort(int[] arr) {
+ if (arr.length == 0) return;
+
+ // 1. 找到数组中的最大值和最小值
+ int max = arr[0];
+ int min = arr[0];
+ for (int i = 1; i < arr.length; i++) {
+ if (arr[i] > max) {
+ max = arr[i];
+ } else if (arr[i] < min) {
+ min = arr[i];
+ }
+ }
+
+ // 2. 创建计数数组
+ int range = max - min + 1;
+ int[] count = new int[range];
+
+ // 3. 统计每个元素的出现次数
+ for (int i = 0; i < arr.length; i++) {
+ count[arr[i] - min]++;
+ }
+
+ // 4. 计算累积计数,确定元素的最终位置
+ for (int i = 1; i < count.length; i++) {
+ count[i] += count[i - 1];
+ }
+
+ // 5. 创建结果数组,并根据累积计数将元素放到正确位置
+ int[] output = new int[arr.length];
+ for (int i = arr.length - 1; i >= 0; i--) {
+ output[count[arr[i] - min] - 1] = arr[i];
+ count[arr[i] - min]--;
+ }
+
+ // 6. 将排序好的结果复制回原数组
+ for (int i = 0; i < arr.length; i++) {
+ arr[i] = output[i];
+ }
+ }
+}
+
+```
+
+
+
+## 🪣 桶排序
+
+桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。
+
+桶排序(Bucket Sort)是一种基于**分配**的排序算法,尤其适用于**均匀分布**的数据。它通过将元素分布到多个桶中,分别对每个桶进行排序,最后将各个桶中的元素合并起来,得到一个有序数组。
+
+> 🎯 **算法特点**:
+> - 基于分配的排序算法
+> - 时间复杂度:$O(n + k)$(平均)
+> - 空间复杂度:$O(n + k)$
+> - 稳定排序
+> - 适用于均匀分布的数据
+
+桶排序的平均时间复杂度为 `O(n + k)`,其中 `n` 是数据的数量,`k` 是桶的数量。桶排序通常适用于小范围的、均匀分布的浮点数或整数。
+
+为了使桶排序更加高效,我们需要做到这两点:
+
+1. 📈 **增加桶的数量**:在额外空间充足的情况下,尽量增大桶的数量
+2. 🎯 **均匀分配**:使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中
+
+同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。
+
+### 1. 算法步骤
+
+1. 🏗️ **创建桶**:创建若干个桶,每个桶表示一个数值范围
+2. 📊 **将元素分配到桶中**:根据元素的大小,将它们分配到对应的桶中
+3. 🔄 **对每个桶内部排序**:对每个桶中的元素分别进行排序(可以使用插入排序、快速排序等)
+4. 🔀 **合并所有桶中的元素**:依次将每个桶中的元素合并起来,得到最终的有序数组
+
+
+
+```java
+import java.util.ArrayList;
+import java.util.Collections;
+
+public class BucketSort {
+ // 主函数:进行桶排序
+ public static void bucketSort(float[] arr, int n) {
+ if (n <= 0) return;
+
+ // 1. 创建 n 个桶,每个桶是一个空的 ArrayList
+ ArrayList[] buckets = new ArrayList[n];
+
+ for (int i = 0; i < n; i++) {
+ buckets[i] = new ArrayList();
+ }
+
+ // 2. 将数组中的元素分配到各个桶中
+ for (int i = 0; i < n; i++) {
+ int bucketIndex = (int) arr[i] * n; // 根据值分配桶
+ buckets[bucketIndex].add(arr[i]);
+ }
+
+ // 3. 对每个桶中的元素进行排序
+ for (int i = 0; i < n; i++) {
+ Collections.sort(buckets[i]); // 可以使用任意内置排序算法
+ }
+
+ // 4. 合并所有桶中的元素,形成最终的排序数组
+ int index = 0;
+ for (int i = 0; i < n; i++) {
+ for (Float value : buckets[i]) {
+ arr[index++] = value;
+ }
+ }
+ }
+
+ // 测试主函数
+ public static void main(String[] args) {
+ float[] arr = { (float)0.42, (float)0.32, (float)0.33, (float)0.52, (float)0.37, (float)0.47, (float)0.51 };
+ int n = arr.length;
+ bucketSort(arr, n);
+
+ System.out.println("排序后的数组:");
+ for (float value : arr) {
+ System.out.print(value + " ");
+ }
+ }
+}
+
+```
+
+**性能分析**
+
+- 🚀 **什么时候最快**:当输入的数据可以均匀的分配到每一个桶中
+- 🐌 **什么时候最慢**:当输入的数据被分配到了同一个桶中
+
+> 💡 **学习提示**:思路一定要理解了,不背题哈,比如有些直接问你
+>
+> 📝 **例题**:已知一组记录的排序码为(46,79,56,38,40,80, 95,24),写出对其进行快速排序的第一趟的划分结果
+
+
+
+## 🔢 基数排序
+
+**基数排序(Radix Sort)** 是一种**非比较排序算法**,用于对整数或字符串等进行排序。它的核心思想是将数据按位(如个位、十位、百位等)进行排序,从低位到高位依次进行。基数排序的时间复杂度为 `O(n * k)`,其中 `n` 是待排序元素的个数,`k` 是数字的最大位数或字符串的长度。由于它不涉及比较操作,基数排序适用于一些特定的场景,如排序长度相同的字符串或范围固定的整数。
+
+> 🎯 **算法特点**:
+> - 非比较排序算法
+> - 时间复杂度:$O(n \times k)$
+> - 空间复杂度:$O(n + k)$
+> - 稳定排序
+> - 适用于位数固定的数据
+
+基数排序有两种实现方式:
+
+- 🔢 **LSD(Least Significant Digit)**:从最低位(个位)开始排序,常用的实现方式
+- 🔢 **MSD(Most Significant Digit)**:从最高位开始排序
+
+基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
+
+### 1. 算法步骤(LSD 实现)
+
+1. 🔍 **确定最大位数**:找出待排序数据中最大数的位数
+2. 🔄 **按位排序**:从最低位到最高位,对每一位进行排序。每次排序使用**稳定的排序算法**(如计数排序或桶排序),确保相同位数的元素相对位置不变
+
+### 2. 动图演示
+
+[](https://github.com/hustcc/JS-Sorting-Algorithm/blob/master/res/radixSort.gif)
+
+```java
+import java.util.Arrays;
+
+public class RadixSort {
+ // 主函数:进行基数排序
+ public static void radixSort(int[] arr) {
+ // 找到数组中的最大数,确定最大位数
+ int max = getMax(arr);
+
+ // 从个位开始,对每一位进行排序
+ for (int exp = 1; max / exp > 0; exp *= 10) {
+ countingSortByDigit(arr, exp);
+ }
+ }
+
+ // 找到数组中的最大值
+ private static int getMax(int[] arr) {
+ int max = arr[0];
+ for (int i = 1; i < arr.length; i++) {
+ if (arr[i] > max) {
+ max = arr[i];
+ }
+ }
+ return max;
+ }
+
+ // 根据当前位数进行计数排序
+ private static void countingSortByDigit(int[] arr, int exp) {
+ int n = arr.length;
+ int[] output = new int[n]; // 输出数组
+ int[] count = new int[10]; // 计数数组(0-9,用于处理每一位上的数字)
+
+ // 1. 统计每个数字在当前位的出现次数
+ for (int i = 0; i < n; i++) {
+ int digit = (arr[i] / exp) % 10;
+ count[digit]++;
+ }
+
+ // 2. 计算累积计数
+ for (int i = 1; i < 10; i++) {
+ count[i] += count[i - 1];
+ }
+
+ // 3. 从右到左遍历数组,按当前位将元素放入正确位置
+ for (int i = n - 1; i >= 0; i--) {
+ int digit = (arr[i] / exp) % 10;
+ output[count[digit] - 1] = arr[i];
+ count[digit]--;
+ }
+
+ // 4. 将排序好的结果复制回原数组
+ for (int i = 0; i < n; i++) {
+ arr[i] = output[i];
+ }
+ }
+
+}
+
+```
+
+
+
+## 🐚 希尔排序
+
+希尔排序这个名字,来源于它的发明者希尔,也称作"缩小增量排序",是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。
+
+**希尔排序(Shell Sort)** 是一种**基于插入排序**的排序算法,又称为**缩小增量排序**,它通过将数组按一定间隔分成若干个子数组,分别进行插入排序,逐步缩小间隔,最终进行一次标准的插入排序。通过这种方式,希尔排序能够减少数据移动次数,使得整体排序过程更为高效。
+
+> 🎯 **算法特点**:
+> - 插入排序的改进版本
+> - 时间复杂度:$O(n^{1.3})$ 到 $O(n^2)$(取决于增量序列)
+> - 空间复杂度:$O(1)$
+> - 不稳定排序
+
+希尔排序的时间复杂度依赖于**增量序列**的选择,通常在 `O(n^1.3)` 到 `O(n^2)` 之间。
+
+### 1. 算法步骤
+
+1. 🎯 **确定增量序列**:首先确定一个增量序列 `gap`,通常初始的 `gap` 为数组长度的一半,然后逐步缩小
+2. 🔄 **分组排序**:将数组按 `gap` 分组,对每个分组进行插入排序。`gap` 表示当前元素与其分组中的前一个元素的间隔
+3. 📉 **缩小增量并继续排序**:每次将 `gap` 缩小一半,重复分组排序过程,直到 `gap = 1`,即对整个数组进行一次标准的插入排序
+4. ✅ **最终排序完成**:当 `gap` 变为 1 时,希尔排序相当于执行了一次插入排序,此时数组已经接近有序,因此插入排序的效率非常高
+
+### 2. 动图演示
+
+
+
+```java
+import java.util.Arrays;
+
+public class ShellSort {
+ // 主函数:希尔排序
+ public static void shellSort(int[] arr) {
+ int n = arr.length;
+
+ // 1. 初始 gap 为数组长度的一半
+ for (int gap = n / 2; gap > 0; gap /= 2) {
+ // 2. 对每个子数组进行插入排序
+ for (int i = gap; i < n; i++) {
+ int temp = arr[i];
+ int j = i;
+
+ // 3. 对当前分组进行插入排序
+ while (j >= gap && arr[j - gap] > temp) {
+ arr[j] = arr[j - gap];
+ j -= gap;
+ }
+
+ // 将 temp 插入到正确的位置
+ arr[j] = temp;
+ }
+ }
+ }
+}
+```
+
+
+
+
+
+## 📚 参考与感谢
+
+- https://yuminlee2.medium.com/sorting-algorithms-summary-f17ea88a9174
+
+---
+
+> 🎉 **恭喜你完成了排序算法的学习!** 排序是计算机科学的基础,掌握了这些算法,你就拥有了解决各种排序问题的强大工具。记住:**选择合适的排序算法比掌握所有算法更重要**!
diff --git a/docs/data-structure-algorithms/complexity.md b/docs/data-structure-algorithms/complexity.md
index e6a522bdc9..c347f82d35 100644
--- a/docs/data-structure-algorithms/complexity.md
+++ b/docs/data-structure-algorithms/complexity.md
@@ -1,23 +1,39 @@
-# 时间复杂度
+---
+title: 算法复杂度分析:开发者的必备技能
+date: 2025-05-09
+categories: Algorithm
+---
> 高级工程师title的我,最近琢磨着好好刷刷算法题更高级一些,然鹅,当我准备回忆大学和面试时候学的数据结构之时,我发现自己对这个算法复杂度的记忆只有OOOOOooo
>
> 文章收录在 GitHub [JavaKeeper](https://github.com/Jstarfish/JavaKeeper) ,N线互联网开发必备技能兵器谱
+## 前言
+
+作为Java后端开发者,我们每天都在编写代码解决业务问题。但你是否思考过:**为什么有些系统在用户量上升时响应越来越慢?为什么某些查询在数据量大时会卡死?为什么同样功能的代码,有些占用内存巨大?**
+
+答案就在算法复杂度中。
+
算法(Algorithm)是指用来操作数据、解决程序问题的一组方法。对于同一个问题,使用不同的算法,也许最终得到的结果是一样的,但在过程中消耗的资源和时间却会有很大的区别。
+在实际的Java开发中,复杂度分析帮助我们:
+- **预测系统性能瓶颈**:在代码上线前就能预判性能问题
+- **优化关键路径**:识别出最需要优化的代码段
+- **合理选择数据结构**:HashMap vs TreeMap,ArrayList vs LinkedList
+- **面试加分项**:大厂面试必考,体现算法功底
+
那么我们应该如何去衡量不同算法之间的优劣呢?
-主要还是从算法所占用的「时间」和「空间」两个维度去考量。
+## 复杂度分析的两个维度
+
+主要还是从算法所占用的「时间」和「空间」两个维度去考量:
-- 时间维度:是指执行当前算法所消耗的时间,我们通常用「时间复杂度」来描述。
-- 空间维度:是指执行当前算法需要占用多少内存空间,我们通常用「空间复杂度」来描述。
+- **时间维度**:是指执行当前算法所消耗的时间,我们通常用「时间复杂度」来描述。
+- **空间维度**:是指执行当前算法需要占用多少内存空间,我们通常用「空间复杂度」来描述。
因此,评价一个算法的效率主要是看它的时间复杂度和空间复杂度情况。然而,有的时候时间和空间却又是「鱼和熊掌」,不可兼得的,那么我们就需要从中去取一个平衡点。
-> 数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省存储空间。所以,执行效率是算法一个非常重要的考量指标。那如何来衡量你编写的算法代码的执行效率呢?
->
-> 就是:时间、空间复杂度
+> 数据结构和算法本身解决的是"快"和"省"的问题,即如何让代码运行得更快,如何让代码更省存储空间。所以,执行效率是算法一个非常重要的考量指标。
## **时间复杂度**
@@ -27,147 +43,1217 @@
这种表示方法我们称为「 **大O符号表示法** 」,又称为**渐进符号**,是用于描述函数渐进行为的数学符号
-常见的时间复杂度量级有:
+## 如何分析算法的时间复杂度
+
+### 复杂度分析的核心思路
+
+**核心原则**:关注当输入规模n趋向无穷大时,算法执行时间的增长趋势,而非具体的执行时间。
+
+**分析步骤**:
+1. **识别基本操作**:找出算法中最频繁执行的操作
+2. **计算执行次数**:分析这个操作随输入规模n的执行次数
+3. **忽略低阶项**:只保留增长最快的项
+4. **忽略系数**:去掉常数因子
+
+### 复杂度分析的实战方法
+
+#### 方法1:数循环层数
+这是最直观的分析方法:
+
+**单层循环 → O(n)**
+```java
+for (int i = 0; i < n; i++) {
+ // 基本操作
+}
+// 分析过程:循环n次,每次执行基本操作1次,总共n次 → O(n)
+```
+
+**双层嵌套循环 → O(n²)**
+```java
+for (int i = 0; i < n; i++) { // 外层:n次
+ for (int j = 0; j < n; j++) { // 内层:每轮n次
+ // 基本操作
+ }
+}
+// 分析过程:外层n次 × 内层n次 = n² 次 → O(n²)
+```
-- 常数阶$O(1)$
-- 线性阶$O(n)$
-- 平方阶$O(n^2)$
-- 立方阶$O(n^3)$
-- 对数阶$O(logn)$
-- 线性对数阶$O(nlogn)$
-- 指数阶$O(2^n)$
+**特殊情况:内层循环次数递减**
-#### 常数阶$O(1)$
+```java
+for (int i = 0; i < n; i++) { // 外层:n次
+ for (int j = i; j < n; j++) { // 内层:第i轮执行(n-i)次
+ // 基本操作
+ }
+}
+// 分析过程:总次数 = n + (n-1) + (n-2) + ... + 1 = n(n+1)/2 ≈ n²/2
+// 忽略系数:O(n²)
+```
-$O(1)$,表示该算法的执行时间(或执行时占用空间)总是为一个常量,不论输入的数据集是大是小,只要是没有循环等复杂结构,那这个代码的时间复杂度就都是O(1),如:
+#### 方法2:分析递归调用
+递归算法的复杂度 = 递归调用总次数 × 每次调用的复杂度
+**递归分析实例:计算斐波那契数列**
```java
-int i = 1;
-int j = 2;
-int k = i + j;
+int fibonacci(int n) {
+ if (n <= 1) return n; // 基本情况:O(1)
+ return fibonacci(n-1) + fibonacci(n-2); // 递归调用:2次
+}
```
-上述代码在执行的时候,它消耗的时候并不随着某个变量的增长而增长,那么无论这类代码有多长,即使有几万几十万行,都可以用$O(1)$来表示它的时间复杂度。
+**分析过程:**
+1. **画出递归树**:
+ ```
+ fib(n)
+ ├── fib(n-1)
+ │ ├── fib(n-2)
+ │ └── fib(n-3)
+ └── fib(n-2)
+ ├── fib(n-3)
+ └── fib(n-4)
+ ```
+
+2. **分析递归深度**:最深到fib(0),深度约为n
+
+3. **分析每层节点数**:每层节点数翻倍,第k层有2^k个节点
+
+4. **计算总节点数**:2^0 + 2^1 + ... + 2^n ≈ 2^n
-#### 线性阶$O(n)$
+5. **得出复杂度**:O(2^n)
-$O(n)$,表示一个算法的性能会随着输入数据的大小变化而线性变化,如
+**为什么这么慢?** 因为有大量重复计算!fib(n-2)既在fib(n-1)中计算,又在fib(n)中计算。
+#### 方法3:分析分治算法
+分治算法的复杂度可以用递推关系式分析。
+
+**二分查找分析**
```java
-for (int i = 0; i < n; i++) {
- j = i;
- j++;
+int binarySearch(int[] arr, int target, int left, int right) {
+ if (left > right) return -1; // 基本情况
+
+ int mid = (left + right) / 2; // O(1)操作
+ if (arr[mid] == target) return mid; // O(1)操作
+ else if (arr[mid] < target)
+ return binarySearch(arr, target, mid + 1, right); // 递归一半
+ else
+ return binarySearch(arr, target, left, mid - 1); // 递归一半
}
```
-这段代码,for循环里面的代码会执行n遍,因此它消耗的时间是随着n的变化而变化的,因此这类代码都可以用$O(n)$来表示它的时间复杂度。
+**分析过程:**
+1. **建立递推关系**:T(n) = T(n/2) + O(1)
+ - 每次递归处理一半的数据
+ - 除了递归外,其他操作都是O(1)
+
+2. **展开递推式**:
+ - T(n) = T(n/2) + 1
+ - T(n/2) = T(n/4) + 1
+ - T(n/4) = T(n/8) + 1
+ - ...
+ - T(1) = 1
+
+3. **求解**:总共需要log₂n层递归,每层O(1),所以T(n) = O(logn)
+
+### 常见复杂度等级及其识别
+
+| 复杂度 | 识别特征 | 典型例子 | 数据规模感受 |
+|--------|----------|----------|-------------|
+| **O(1)** | 不随n变化 | 数组索引访问、HashMap查找 | 任何规模都很快 |
+| **O(logn)** | 每次减半 | 二分查找、平衡树操作 | 100万数据只需20步 |
+| **O(n)** | 单层循环 | 数组遍历、链表查找 | n=1000万时还可接受 |
+| **O(nlogn)** | 分治+合并 | 归并排序、快速排序 | n=100万时性能良好 |
+| **O(n²)** | 双层循环 | 冒泡排序、暴力匹配 | n>1000就开始慢了 |
+| **O(2^n)** | 无优化递归 | 递归斐波那契 | n>30就要等很久 |
+
+## 复杂度分析实战练习
+
+学会了分析方法,让我们通过几个经典例子来实际练习,重点关注**分析过程**而不是记忆结果。
+
+### 练习1:分析冒泡排序的复杂度
-#### 平方阶$O(n^2)$
+```java
+public void bubbleSort(int[] arr) {
+ int n = arr.length;
+ for (int i = 0; i < n - 1; i++) {
+ for (int j = 0; j < n - i - 1; j++) {
+ if (arr[j] > arr[j + 1]) {
+ swap(arr, j, j + 1);
+ }
+ }
+ }
+}
+```
-$O(n²)$ 表示一个算法的性能将会随着输入数据的增长而呈现出二次增长。最常见的就是对输入数据进行嵌套循环。如果嵌套层级不断深入的话,算法的性能将会变为立方阶$O(n^3)$,$O(n^4)$,$O(n^k)$以此类推
+**分析过程:**
+1. **识别基本操作**:比较和交换操作 `arr[j] > arr[j + 1]`
+2. **分析循环次数**:
+ - 外层循环:i从0到n-2,共(n-1)次
+ - 内层循环:第i轮时,j从0到(n-i-2),共(n-i-1)次
+3. **计算总次数**:
+ - 第0轮:n-1次比较
+ - 第1轮:n-2次比较
+ - 第2轮:n-3次比较
+ - ...
+ - 第(n-2)轮:1次比较
+ - 总计:(n-1) + (n-2) + ... + 1 = n(n-1)/2 次
+4. **简化结果**:n(n-1)/2 ≈ n²/2,忽略系数得到 **O(n²)**
+
+### 练习2:分析HashMap查找的复杂度
```java
-for(x=1; i<=n; x++){
- for(i=1; i<=n; i++){
- j = i;
- j++;
+// HashMap的get操作
+public V get(Object key) {
+ int hash = hash(key); // 计算hash值,O(1)
+ int index = hash % table.length; // 计算索引,O(1)
+ Node node = table[index]; // 直接访问数组,O(1)
+
+ // 在链表/红黑树中查找
+ while (node != null) {
+ if (node.key.equals(key)) {
+ return node.value;
+ }
+ node = node.next; // 遍历链表
}
+ return null;
}
```
-#### 指数阶$O(2^n)$
+**分析过程:**
+1. **理想情况**:没有hash冲突,直接命中 → **O(1)**
+2. **最坏情况**:所有元素都hash到同一个位置,形成长度为n的链表 → **O(n)**
+3. **平均情况**:hash函数分布均匀,每个链表长度约为1 → **O(1)**
+
+**为什么说HashMap是O(1)?** 指的是平均情况下的复杂度。
-$O(2^n)$,表示一个算法的性能会随着输入数据的每次增加而增大两倍,典型的方法就是裴波那契数列的递归计算实现
+### 练习3:分析递归求阶乘的复杂度
```java
-int Fibonacci(int number)
-{
- if (number <= 1) return number;
+public int factorial(int n) {
+ if (n <= 1) return 1; // 基本情况
+ return n * factorial(n - 1); // 递归调用
+}
+```
+
+**分析过程:**
+1. **递归深度**:从n递减到1,深度为n
+2. **每层工作量**:除了递归调用,只有一次乘法运算,O(1)
+3. **总复杂度**:n层 × 每层O(1) = **O(n)**
+
+**递归调用栈:**
+```
+factorial(5)
+├── 5 * factorial(4)
+ ├── 4 * factorial(3)
+ ├── 3 * factorial(2)
+ ├── 2 * factorial(1)
+ └── 1
+```
+
+### 练习4:分析快速排序的复杂度
+
+```java
+public void quickSort(int[] arr, int low, int high) {
+ if (low < high) {
+ int pivot = partition(arr, low, high); // 分区,O(n)
+ quickSort(arr, low, pivot - 1); // 递归左半部分
+ quickSort(arr, pivot + 1, high); // 递归右半部分
+ }
+}
+```
+
+**分析过程:**
+1. **最好情况**:每次都能均匀分割
+ - 递归深度:log₂n
+ - 每层工作量:O(n)
+ - 总复杂度:O(nlogn)
+
+2. **最坏情况**:每次都是最不均匀的分割(已排序数组)
+ - 递归深度:n
+ - 每层工作量:O(n)
+ - 总复杂度:O(n²)
+
+3. **平均情况**:O(nlogn)
+
+**关键洞察**:快速排序的性能很大程度上取决于pivot的选择。
+
+### 复杂度分析的误区和技巧
+
+#### 常见误区
+
+1. **误区1:认为递归一定比循环慢**
+ ```java
+ // 递归版本:O(logn)
+ int binarySearchRecursive(int[] arr, int target, int left, int right) {
+ if (left > right) return -1;
+ int mid = (left + right) / 2;
+ if (arr[mid] == target) return mid;
+ else if (arr[mid] < target)
+ return binarySearchRecursive(arr, target, mid + 1, right);
+ else
+ return binarySearchRecursive(arr, target, left, mid - 1);
+ }
+
+ // 循环版本:同样O(logn)
+ int binarySearchIterative(int[] arr, int target) {
+ int left = 0, right = arr.length - 1;
+ while (left <= right) {
+ int mid = (left + right) / 2;
+ if (arr[mid] == target) return mid;
+ else if (arr[mid] < target) left = mid + 1;
+ else right = mid - 1;
+ }
+ return -1;
+ }
+ ```
+
+2. **误区2:认为代码行数多就复杂度高**
+ ```java
+ // 虽然代码很长,但复杂度是O(1)
+ public boolean isValidSudoku(char[][] board) {
+ for (int i = 0; i < 9; i++) { // 固定9次
+ for (int j = 0; j < 9; j++) { // 固定9次
+ // 检查逻辑...
+ }
+ }
+ return true; // 9×9=81次操作,是常数,所以O(1)
+ }
+ ```
+
+#### 实用技巧
+
+1. **看循环变量的变化规律**
+ ```java
+ // 线性增长 → O(n)
+ for (int i = 0; i < n; i++)
+
+ // 指数增长 → O(logn)
+ for (int i = 1; i < n; i *= 2)
+
+ // 嵌套循环 → O(n²)
+ for (int i = 0; i < n; i++)
+ for (int j = 0; j < n; j++)
+ ```
+
+2. **分析递归的关键问题**
+ - 递归了多少次?
+ - 每次递归处理多大的子问题?
+ - 除了递归还做了什么?
+
+3. **利用已知的复杂度**
+ - 排序通常是O(nlogn)
+ - 哈希表查找通常是O(1)
+ - 数组遍历是O(n)
+ - 二分查找是O(logn)
+
+除此之外,其实还有平均情况复杂度、最好时间复杂度、最坏时间复杂度。。。一般没有特殊说明的情况下,都是指最坏时间复杂度。
+
+------
+
+## **空间复杂度分析方法**
+
+空间复杂度分析相对简单,主要关注算法运行过程中需要的**额外存储空间**。
+
+### 空间复杂度的组成
+1. **算法本身**:代码指令占用的空间(通常忽略)
+2. **输入数据**:输入参数占用的空间(通常忽略)
+3. **额外空间**:算法运行时临时申请的空间(主要分析对象)
+
+### 空间复杂度分析步骤
- return Fibonacci(number - 2) + Fibonacci(number - 1);
+#### 第一步:识别额外空间的来源
+- **局部变量**:函数内声明的变量
+- **数据结构**:数组、链表、栈、队列等
+- **递归调用栈**:递归函数的调用栈
+
+#### 第二步:分析空间随输入规模的变化
+
+**O(1) 常量空间**
+```java
+public void swap(int[] arr, int i, int j) {
+ int temp = arr[i]; // 只使用了一个额外变量
+ arr[i] = arr[j];
+ arr[j] = temp;
}
+// 无论数组多大,只用了temp一个额外空间 → O(1)
```
-#### 对数阶$O(logn)$
+**O(n) 线性空间**
+```java
+public int[] copyArray(int[] arr) {
+ int[] newArr = new int[arr.length]; // 创建了n大小的新数组
+ for (int i = 0; i < arr.length; i++) {
+ newArr[i] = arr[i];
+ }
+ return newArr;
+}
+// 创建了大小为n的数组 → O(n)
+```
+**递归空间复杂度分析**
```java
-int i = 1;
-while(i nodeStack = new Stack<>();
+ Stack sumStack = new Stack<>();
+
+ nodeStack.push(root);
+ sumStack.push(sum - root.val);
+
+ while (!nodeStack.isEmpty()) {
+ TreeNode node = nodeStack.pop();
+ int currSum = sumStack.pop();
+
+ if (node.left == null && node.right == null && currSum == 0) {
+ return true;
+ }
+
+ if (node.left != null) {
+ nodeStack.push(node.left);
+ sumStack.push(currSum - node.left.val);
+ }
+ if (node.right != null) {
+ nodeStack.push(node.right);
+ sumStack.push(currSum - node.right.val);
+ }
}
+ return false;
}
```
-除此之外,其实还有平均情况复杂度、最好时间复杂度、最坏时间复杂度。。。一般没有特殊说明的情况下,都是值最坏时间复杂度。
+------
+
+## 复杂度速查表
+
+来源:https://liam.page/2016/06/20/big-O-cheat-sheet/ 源地址:https://www.bigocheatsheet.com/
------
+#### 1. 线性递归 - O(n)空间
+
+**LeetCode应用:链表递归**
+```java
+// 反转链表(递归版本)
+public ListNode reverseList(ListNode head) {
+ if (head == null || head.next == null) {
+ return head;
+ }
+
+ ListNode newHead = reverseList(head.next); // 递归调用
+ head.next.next = head;
+ head.next = null;
+
+ return newHead;
+}
+// 递归深度等于链表长度n,空间复杂度O(n)
-## **空间复杂度**
+// 计算链表长度(递归)
+public int getLength(ListNode head) {
+ if (head == null) return 0;
+ return 1 + getLength(head.next);
+}
+// 空间复杂度:O(n),因为递归栈深度为n
+```
-空间复杂度(Space Complexity)是对一个算法在运行过程中临时占用存储空间大小的一个量度,同样反映的是一个趋势,一个算法所需的存储空间用f(n)表示。$S(n)=O(f(n))$,其中 n 为问题的规模,S(n) 表示空间复杂度。
+#### 2. 二分递归 - O(logn)空间
-一个算法在计算机存储器上所占用的存储空间,包括存储算法本身所占用的存储空间,算法的输入输出数据所占用的存储空间和算法在运行过程中临时占用的存储空间这三个方面。
+**示例:二分查找(递归版本)**
-一般情况下,一个程序在机器上执行时,除了需要存储程序本身的指令、常数、变量和输入数据外,还需要存储对数据操作的存储单元。若输入数据所占空间只取决于问题本身,和算法无关,这样只需要分析该算法在实现时所需的辅助单元即可。若算法执行时所需的辅助空间相对于输入数据量而言是个常数,则称此算法为原地工作,空间复杂度为 $O(1)$。当一个算法的空间复杂度与n成线性比例关系时,可表示为$0(n)$,类比时间复杂度。
+```java
+public int binarySearch(int[] nums, int target, int left, int right) {
+ if (left > right) return -1;
+
+ int mid = left + (right - left) / 2;
+ if (nums[mid] == target) {
+ return mid;
+ } else if (nums[mid] < target) {
+ return binarySearch(nums, target, mid + 1, right);
+ } else {
+ return binarySearch(nums, target, left, mid - 1);
+ }
+}
+// 每次递归都将问题规模减半,递归深度为log₂n,空间复杂度O(logn)
+```
-空间复杂度比较常用的有:$O(1)$、$O(n)$、$O(n²)$
+**LeetCode应用:树的遍历**
+```java
+// 二叉树的最大深度
+public int maxDepth(TreeNode root) {
+ if (root == null) return 0;
+
+ int leftDepth = maxDepth(root.left); // 递归左子树
+ int rightDepth = maxDepth(root.right); // 递归右子树
+
+ return Math.max(leftDepth, rightDepth) + 1;
+}
+// 平衡树:递归深度为O(logn),空间复杂度O(logn)
+// 最坏情况(链状树):递归深度为O(n),空间复杂度O(n)
+```
-#### 空间复杂度 $O(1)$
+#### 3. 多分支递归 - 注意陷阱!
-如果算法执行所需要的临时空间不随着某个变量n的大小而变化,即此算法空间复杂度为一个常量,可表示为 O(1)
-举例:
+**错误理解:认为空间复杂度是O(2^n)**
+```java
+// 斐波那契数列(递归版本)
+public int fibonacci(int n) {
+ if (n <= 1) return n;
+ return fibonacci(n - 1) + fibonacci(n - 2);
+}
+```
+
+**正确分析:**
+- **时间复杂度**:O(2^n) - 因为有2^n个函数调用
+- **空间复杂度**:O(n) - 因为递归栈的最大深度是n
+
+> 关键理解:虽然有很多递归调用,但任何时刻调用栈的深度最多是n层
+
+#### 4. 记忆化递归的空间复杂度
```java
-int i = 1;
-int j = 2;
-++i;
-j++;
-int m = i + j;
+// 带记忆化的斐波那契
+private Map memo = new HashMap<>();
+
+public int fibonacciMemo(int n) {
+ if (n <= 1) return n;
+
+ if (memo.containsKey(n)) {
+ return memo.get(n);
+ }
+
+ int result = fibonacciMemo(n - 1) + fibonacciMemo(n - 2);
+ memo.put(n, result);
+ return result;
+}
+// 时间复杂度:O(n)
+// 空间复杂度:O(n) = 递归栈O(n) + 缓存数组O(n)
```
-代码中的 i、j、m 所分配的空间都不随着处理数据量变化,因此它的空间复杂度 S(n) = O(1)
+### LeetCode中的空间复杂度优化技巧
+
+#### 1. 滚动数组优化
+```java
+// 原始DP:O(n)空间
+int[] dp = new int[n];
+
+// 优化后:O(1)空间
+int prev1 = dp[0], prev2 = dp[1];
+```
-#### 空间复杂度 $O(n)$
+#### 2. 就地修改避免额外空间
+```java
+// 数组去重(就地修改)
+public int removeDuplicates(int[] nums) {
+ if (nums.length == 0) return 0;
+
+ int i = 0;
+ for (int j = 1; j < nums.length; j++) {
+ if (nums[j] != nums[i]) {
+ i++;
+ nums[i] = nums[j]; // 就地修改,不需要额外空间
+ }
+ }
+ return i + 1;
+} // 空间复杂度:O(1)
+```
+#### 3. 递归转迭代
```java
-int[] m = new int[n]
-for(i=1; i<=n; ++i)
-{
- j = i;
- j++;
+// 递归版本:O(n)空间
+public void inorderRecursive(TreeNode root) {
+ if (root == null) return;
+ inorderRecursive(root.left);
+ System.out.println(root.val);
+ inorderRecursive(root.right);
+}
+
+// 迭代版本:O(h)空间,h为树的高度
+public void inorderIterative(TreeNode root) {
+ Stack stack = new Stack<>();
+ TreeNode current = root;
+
+ while (current != null || !stack.isEmpty()) {
+ while (current != null) {
+ stack.push(current);
+ current = current.left;
+ }
+ current = stack.pop();
+ System.out.println(current.val);
+ current = current.right;
+ }
}
```
-这段代码中,第一行new了一个数组出来,这个数据占用的大小为n,这段代码的2-6行,虽然有循环,但没有再分配新的空间,因此,这段代码的空间复杂度主要看第一行即可,即 S(n) = O(n)
+### 常见面试问题:时间空间复杂度权衡
+
+| 算法 | 时间复杂度 | 空间复杂度 | 权衡点 |
+|------|------------|------------|--------|
+| 快速排序 | O(nlogn) | O(logn) | 递归栈空间 |
+| 归并排序 | O(nlogn) | O(n) | 需要额外数组 |
+| 堆排序 | O(nlogn) | O(1) | 就地排序 |
+| 计数排序 | O(n+k) | O(k) | 需要额外计数数组 |
+| 哈希表查找 | O(1) | O(n) | 用空间换时间 |
+| 二分查找 | O(logn) | O(1) | 要求数组有序 |
------
+## 复杂度分析在实际开发中的应用
+### 系统设计中的复杂度考量
-## 复杂度速查表
+#### 1. 数据库查询优化
+```java
+// ❌ 没有索引的查询 - O(n)
+SELECT * FROM users WHERE email = 'user@example.com';
-来源:https://liam.page/2016/06/20/big-O-cheat-sheet/ 源地址:https://www.bigocheatsheet.com/
+// ✅ 有索引的查询 - O(logn)
+CREATE INDEX idx_email ON users(email);
+SELECT * FROM users WHERE email = 'user@example.com';
+```
+
+**在Java中的体现:**
+```java
+// JPA查询优化
+@Entity
+public class User {
+ @Column(name = "email")
+ @Index(name = "idx_email") // 添加索引
+ private String email;
+}
+
+// 避免N+1查询问题
+@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :userId")
+User findUserWithOrders(@Param("userId") Long userId);
+```
+
+#### 2. 缓存策略选择
+```java
+// Redis缓存 vs 数据库查询的复杂度对比
+public class UserService {
+
+ // ❌ 每次都查数据库 - O(logn)到O(n)
+ public User getUserById(Long id) {
+ return userRepository.findById(id);
+ }
+
+ // ✅ 使用缓存 - O(1)
+ @Cacheable(value = "users", key = "#id")
+ public User getUserByIdCached(Long id) {
+ return userRepository.findById(id);
+ }
+}
+```
+
+#### 3. 分页查询的复杂度陷阱
+```java
+// ❌ 深度分页性能问题
+// LIMIT 1000000, 20 在MySQL中是O(n),需要跳过100万条记录
+public Page getUsers(int page, int size) {
+ return userRepository.findAll(PageRequest.of(page, size));
+}
+
+// ✅ 游标分页优化为O(logn)
+public List getUsersAfterCursor(Long lastUserId, int limit) {
+ return userRepository.findByIdGreaterThanOrderById(lastUserId,
+ PageRequest.of(0, limit));
+}
+```
+
+### 微服务架构中的复杂度问题
+
+#### 1. 接口聚合的复杂度
+```java
+// ❌ 串行调用多个服务 - O(n)
+@RestController
+public class OrderController {
+
+ public OrderDetailVO getOrderDetail(Long orderId) {
+ Order order = orderService.getOrder(orderId); // 100ms
+ User user = userService.getUser(order.getUserId()); // 100ms
+ Product product = productService.getProduct(order.getProductId()); // 100ms
+ // 总耗时:300ms
+
+ return buildOrderDetail(order, user, product);
+ }
+}
-#### 图例
+// ✅ 并行调用优化 - O(1)
+@Async
+public CompletableFuture getOrderDetailAsync(Long orderId) {
+ CompletableFuture orderFuture =
+ CompletableFuture.supplyAsync(() -> orderService.getOrder(orderId));
+
+ CompletableFuture userFuture = orderFuture.thenCompose(order ->
+ CompletableFuture.supplyAsync(() -> userService.getUser(order.getUserId())));
+
+ CompletableFuture productFuture = orderFuture.thenCompose(order ->
+ CompletableFuture.supplyAsync(() -> productService.getProduct(order.getProductId())));
+
+ return CompletableFuture.allOf(orderFuture, userFuture, productFuture)
+ .thenApply(v -> buildOrderDetail(orderFuture.join(), userFuture.join(), productFuture.join()));
+ // 总耗时:约100ms
+}
+```
+
+#### 2. 批量处理优化
+```java
+// ❌ 循环调用接口 - O(n)
+public void processUsers(List userIds) {
+ for (Long userId : userIds) {
+ User user = userService.getUser(userId); // 每次一个网络调用
+ processUser(user);
+ }
+ // 1000个用户 = 1000次网络调用
+}
+
+// ✅ 批量调用 - O(1)
+public void processUsersBatch(List userIds) {
+ List users = userService.getUsers(userIds); // 一次网络调用
+ users.forEach(this::processUser);
+ // 1000个用户 = 1次网络调用
+}
+```
+
+### 大数据处理场景
+
+#### 1. 日志分析系统
+```java
+// 实时日志处理的复杂度考量
+@Component
+public class LogProcessor {
+
+ // ❌ 实时逐条处理 - 无法处理高并发
+ public void processLogRealtimeSync(LogEvent event) {
+ // 同步处理每条日志
+ analyzeLog(event); // 假设需要10ms
+ saveToDatabase(event); // 假设需要5ms
+ // 每秒最多处理:1000/(10+5) = 66条
+ }
+
+ // ✅ 批量异步处理 - 提升吞吐量
+ @EventListener
+ @Async
+ public void processLogBatch(List events) {
+ // 批量处理,均摊网络开销
+ List results = batchAnalyze(events); // 批量分析
+ batchSaveToDatabase(results); // 批量保存
+ // 每秒可处理:数万条
+ }
+}
+```
+
+#### 2. 报表查询优化
+```java
+// 大数据量报表查询的复杂度优化
+@Service
+public class ReportService {
+
+ // ❌ 实时聚合计算 - O(n),数据量大时很慢
+ public SalesReport getDailySalesReport(LocalDate date) {
+ List orders = orderRepository.findByCreateDate(date);
+ // 需要遍历所有订单进行聚合计算
+ return calculateSalesReport(orders);
+ }
+
+ // ✅ 预计算报表 - O(1)查询
+ @Scheduled(cron = "0 0 1 * * ?") // 每天凌晨1点执行
+ public void generateDailyReports() {
+ LocalDate yesterday = LocalDate.now().minusDays(1);
+ SalesReport report = calculateSalesReport(
+ orderRepository.findByCreateDate(yesterday));
+ reportRepository.save(report); // 预计算结果
+ }
+
+ public SalesReport getDailySalesReportCached(LocalDate date) {
+ return reportRepository.findByDate(date); // O(1)查询
+ }
+}
+```
+
+### Spring Boot性能优化实战
+
+#### 1. 启动时间优化
+```java
+// 启动时间复杂度优化
+@SpringBootApplication
+public class Application {
+
+ // ❌ 启动时加载大量数据
+ @PostConstruct
+ public void initData() {
+ // 启动时从数据库加载10万条配置 - 增加启动时间
+ configService.loadAllConfigs(); // O(n)
+ }
+
+ // ✅ 懒加载优化
+ @Bean
+ @Lazy
+ public ConfigCache configCache() {
+ return new ConfigCache(); // 需要时才初始化
+ }
+}
+```
+
+#### 2. 内存使用优化
+```java
+// 内存复杂度优化
+@RestController
+public class DataController {
+
+ // ❌ 一次性加载所有数据到内存 - O(n)空间
+ public List getAllData() {
+ List allData = dataRepository.findAll(); // 可能有百万条数据
+ return allData.stream()
+ .map(this::convertToVO)
+ .collect(Collectors.toList()); // 内存可能溢出
+ }
+
+ // ✅ 流式处理 - O(1)空间
+ public void exportAllData(HttpServletResponse response) {
+ try (Stream dataStream = dataRepository.findAllStream()) {
+ dataStream.map(this::convertToVO)
+ .forEach(vo -> writeToResponse(response, vo)); // 逐条处理
+ }
+ }
+}
+```
+
+### 消息队列的复杂度考量
+
+```java
+// 消息处理的复杂度优化
+@Component
+public class MessageProcessor {
+
+ // ❌ 顺序处理消息 - O(n)时间
+ @RabbitListener(queues = "order.queue")
+ public void processOrderSync(OrderMessage message) {
+ // 顺序处理,一个消息处理完才处理下一个
+ validateOrder(message); // 50ms
+ saveOrder(message); // 100ms
+ sendNotification(message); // 30ms
+ // 总计180ms/消息,吞吐量低
+ }
+
+ // ✅ 并行处理消息 - 提升吞吐量
+ @RabbitListener(queues = "order.queue", concurrency = "10")
+ public void processOrderAsync(OrderMessage message) {
+ CompletableFuture.allOf(
+ CompletableFuture.runAsync(() -> validateOrder(message)),
+ CompletableFuture.runAsync(() -> saveOrder(message)),
+ CompletableFuture.runAsync(() -> sendNotification(message))
+ ).join();
+ // 并行处理,吞吐量大幅提升
+ }
+}
+```
+
+### 实际项目中的复杂度思考框架
+
+#### 1. 性能需求评估
+```java
+// 根据业务规模选择合适的算法复杂度
+public class BusinessScaleAnalysis {
+
+ // 小规模系统(<1万用户)
+ public void smallScale() {
+ // O(n²)算法可以接受
+ // 简单直接的实现优先
+ }
+
+ // 中等规模系统(1万-100万用户)
+ public void mediumScale() {
+ // 需要O(nlogn)或更优算法
+ // 开始考虑缓存、索引优化
+ }
+
+ // 大规模系统(>100万用户)
+ public void largeScale() {
+ // 必须O(logn)或O(1)算法
+ // 分布式缓存、数据库分片等
+ }
+}
+```
+
+#### 2. 技术选型的复杂度考量
+```java
+// 不同技术栈的复杂度特性
+public class TechStackComplexity {
+
+ // HashMap vs TreeMap选择
+ Map userCache;
+
+ // 如果只需要快速查找:HashMap O(1)
+ userCache = new HashMap<>();
+
+ // 如果需要有序遍历:TreeMap O(logn)
+ userCache = new TreeMap<>();
+
+ // ArrayList vs LinkedList选择
+ List users;
+
+ // 频繁随机访问:ArrayList O(1)
+ users = new ArrayList<>();
+
+ // 频繁插入删除:LinkedList O(1)
+ users = new LinkedList<>();
+}
+```
+
+## 复杂度优化的最佳实践
+
+### LeetCode刷题的复杂度优化套路
+
+#### 1. 暴力解法 → 哈希表优化(降时间复杂度)
+```java
+// 模式:O(n²) → O(n)
+// 适用场景:查找、匹配类问题
+
+// 示例:两数之和类问题
+// 暴力:双层循环查找 O(n²)
+// 优化:哈希表存储 O(n)
+Map map = new HashMap<>();
+```
+
+#### 2. 递归 → 动态规划(消除重复计算)
+```java
+// 模式:O(2^n) → O(n)
+// 适用场景:有重叠子问题的递归
+
+// 递归优化三步走:
+// 1. 记忆化递归(自顶向下)
+Map memo = new HashMap<>();
+
+// 2. 动态规划(自底向上)
+int[] dp = new int[n+1];
+
+// 3. 空间优化(滚动变量)
+int prev1 = 0, prev2 = 1;
+```
+
+#### 3. 排序 + 双指针(降低循环层数)
+```java
+// 模式:O(n²) → O(nlogn)
+// 适用场景:需要查找配对的问题
+
+Arrays.sort(nums); // O(nlogn)
+int left = 0, right = nums.length - 1; // O(n)
+// 总体:O(nlogn),比O(n²)好
+```
+
+#### 4. 二分查找(利用有序性)
+```java
+// 模式:O(n) → O(logn)
+// 适用场景:在有序数组中查找
+
+// 关键:寻找单调性
+while (left <= right) {
+ int mid = left + (right - left) / 2;
+ // 根据mid位置的性质决定搜索方向
+}
+```
+
+### Java集合类的复杂度选择指南
+
+#### 1. List选择策略
+```java
+// 根据操作频率选择
+public class ListChoiceGuide {
+
+ // 频繁随机访问 + 较少插入删除 → ArrayList
+ List data = new ArrayList<>(); // get: O(1), add: O(1)
+
+ // 频繁插入删除 + 较少随机访问 → LinkedList
+ List data = new LinkedList<>(); // add/remove: O(1), get: O(n)
+
+ // 需要线程安全 → Vector 或 Collections.synchronizedList
+ List data = new Vector<>();
+
+ // 不可变列表 → Arrays.asList 或 List.of
+ List data = List.of("a", "b", "c");
+}
+```
+
+#### 2. Map选择策略
+```java
+public class MapChoiceGuide {
+
+ // 一般情况,追求最快查找 → HashMap
+ Map cache = new HashMap<>(); // O(1)
+
+ // 需要有序遍历 → TreeMap
+ Map sortedCache = new TreeMap<>(); // O(logn)
+
+ // 需要插入顺序 → LinkedHashMap
+ Map orderedCache = new LinkedHashMap<>(); // O(1) + 顺序
+
+ // 高并发环境 → ConcurrentHashMap
+ Map concurrentCache = new ConcurrentHashMap<>();
+}
+```
+
+#### 3. Set选择策略
+```java
+public class SetChoiceGuide {
+
+ // 一般去重需求 → HashSet
+ Set uniqueItems = new HashSet<>(); // O(1)
+
+ // 需要有序 → TreeSet
+ Set sortedItems = new TreeSet<>(); // O(logn)
+
+ // 需要保持插入顺序 → LinkedHashSet
+ Set orderedItems = new LinkedHashSet<>(); // O(1) + 顺序
+}
+```
+
+### 常见性能陷阱及避免方法
+
+#### 1. 字符串操作陷阱
+```java
+// ❌ String拼接陷阱 - O(n²)
+String result = "";
+for (String str : list) {
+ result += str; // 每次都创建新字符串
+}
+
+// ✅ 使用StringBuilder - O(n)
+StringBuilder sb = new StringBuilder();
+for (String str : list) {
+ sb.append(str);
+}
+String result = sb.toString();
+
+// ✅ Java 8 Stream方式 - O(n)
+String result = list.stream().collect(Collectors.joining());
+```
+
+#### 2. 集合遍历陷阱
+```java
+// ❌ 在遍历中修改集合 - 可能O(n²)
+for (int i = 0; i < list.size(); i++) {
+ if (shouldRemove(list.get(i))) {
+ list.remove(i); // ArrayList.remove()是O(n)操作
+ i--; // 还要调整索引
+ }
+}
+
+// ✅ 使用Iterator - O(n)
+Iterator iterator = list.iterator();
+while (iterator.hasNext()) {
+ if (shouldRemove(iterator.next())) {
+ iterator.remove(); // O(1)操作
+ }
+}
+
+// ✅ 使用removeIf - O(n)
+list.removeIf(this::shouldRemove);
+```
+
+#### 3. 数据库查询陷阱
+```java
+// ❌ N+1查询问题
+public List getOrdersWithUser(List orderIds) {
+ List orders = orderRepository.findByIds(orderIds); // 1次查询
+ return orders.stream().map(order -> {
+ User user = userRepository.findById(order.getUserId()); // N次查询
+ return new OrderVO(order, user);
+ }).collect(Collectors.toList());
+}
+
+// ✅ 批量查询优化
+public List getOrdersWithUserOptimized(List orderIds) {
+ List orders = orderRepository.findByIds(orderIds);
+ Set userIds = orders.stream().map(Order::getUserId).collect(Collectors.toSet());
+ Map userMap = userRepository.findByIds(userIds)
+ .stream().collect(Collectors.toMap(User::getId, user -> user));
+
+ return orders.stream().map(order -> {
+ User user = userMap.get(order.getUserId()); // O(1)查找
+ return new OrderVO(order, user);
+ }).collect(Collectors.toList());
+}
+```
+
+### 复杂度分析的实用技巧
+
+#### 1. 快速估算方法
+```java
+// 根据数据规模快速判断可接受的复杂度
+public class ComplexityEstimation {
+
+ public void analyzeDataScale(int n) {
+ if (n <= 10) {
+ // 任何算法都可以,包括O(n!)
+ System.out.println("可以使用任何算法");
+ } else if (n <= 20) {
+ // O(2^n)可以接受,如回溯算法
+ System.out.println("指数级算法可接受");
+ } else if (n <= 100) {
+ // O(n³)勉强可以
+ System.out.println("三次方算法勉强可以");
+ } else if (n <= 1000) {
+ // O(n²)是上限
+ System.out.println("平方级算法是上限");
+ } else if (n <= 100000) {
+ // 必须O(nlogn)或更优
+ System.out.println("必须线性对数级或更优");
+ } else {
+ // 必须O(n)或O(logn)
+ System.out.println("必须线性级或对数级");
+ }
+ }
+}
+```
+
+#### 2. 复杂度分析检查清单
+```java
+// 代码复杂度自检清单
+public class ComplexityCheckList {
+
+ public void checkComplexity() {
+ // 1. 有几层嵌套循环?
+ // - 1层 → O(n)
+ // - 2层 → O(n²)
+ // - k层 → O(n^k)
+
+ // 2. 有递归调用吗?
+ // - 递归深度 × 每层复杂度 = 总复杂度
+
+ // 3. 有二分查找吗?
+ // - 每次减半 → O(logn)
+
+ // 4. 有排序操作吗?
+ // - 通用排序 → O(nlogn)
+ // - 特殊排序 → 可能O(n)
+
+ // 5. 使用了什么数据结构?
+ // - HashMap → O(1)
+ // - TreeMap → O(logn)
+ // - LinkedList查找 → O(n)
+ }
+}
+```
+
+#### 3. 空间复杂度优化技巧
+```java
+// 常见空间优化模式
+public class SpaceOptimization {
+
+ // 1. 滚动数组优化DP
+ public int optimizedDP(int n) {
+ // 原始:int[] dp = new int[n]; // O(n)空间
+ // 优化:只保存必要的状态
+ int prev = 0, curr = 1; // O(1)空间
+ return curr;
+ }
+
+ // 2. 就地修改避免额外空间
+ public void reverseArrayInPlace(int[] arr) {
+ int left = 0, right = arr.length - 1;
+ while (left < right) {
+ // 就地交换,不需要额外数组
+ int temp = arr[left];
+ arr[left] = arr[right];
+ arr[right] = temp;
+ left++;
+ right--;
+ }
+ }
+
+ // 3. 流式处理大数据
+ public void processLargeData() {
+ // 避免一次性加载所有数据到内存
+ try (Stream lines = Files.lines(Paths.get("large-file.txt"))) {
+ lines.filter(line -> line.contains("target"))
+ .forEach(this::processLine); // 逐行处理
+ }
+ }
+}
+```
+
+## 复杂度速查表

@@ -197,7 +1283,80 @@ for(i=1; i<=n; ++i)
-## 参考
+## 总结
+
+### 核心要点回顾
-- 《大话数据结构》
-- https://zhuanlan.zhihu.com/p/50479555
\ No newline at end of file
+通过本文的学习,我们掌握了算法复杂度分析的核心知识:
+
+#### 1. 理论基础
+- **时间复杂度**:衡量算法执行时间随输入规模增长的趋势
+- **空间复杂度**:衡量算法占用内存空间随输入规模增长的趋势
+- **大O符号**:用于描述算法复杂度的数学工具
+
+#### 2. 常见复杂度等级(从优到劣)
+1. **O(1)** - 常数阶:HashMap查找、数组随机访问
+2. **O(logn)** - 对数阶:二分查找、TreeMap操作
+3. **O(n)** - 线性阶:数组遍历、链表查找
+4. **O(nlogn)** - 线性对数阶:快速排序、归并排序
+5. **O(n²)** - 平方阶:冒泡排序、嵌套循环
+6. **O(2^n)** - 指数阶:递归斐波那契(需要避免)
+
+#### 3. LeetCode刷题复杂度优化套路
+- **暴力 → 哈希表**:O(n²) → O(n)
+- **递归 → 动态规划**:O(2^n) → O(n)
+- **暴力查找 → 二分查找**:O(n) → O(logn)
+- **嵌套循环 → 双指针**:O(n²) → O(n)
+
+#### 4. Java开发实战经验
+- **集合选择**:根据操作频率选择ArrayList/LinkedList、HashMap/TreeMap
+- **避免陷阱**:字符串拼接、集合遍历修改、N+1查询
+- **空间优化**:滚动数组、就地修改、流式处理
+
+### 给Java后端开发者的建议
+
+#### 学习路径
+1. **理论基础**:掌握本文的复杂度分析方法
+2. **刷题实战**:在LeetCode上练习复杂度分析
+3. **项目应用**:在实际项目中运用复杂度思维
+4. **持续优化**:定期review代码的性能瓶颈
+
+#### 面试准备
+- **必备技能**:能快速分析代码的时间空间复杂度
+- **常考题型**:两数之和、排序算法、动态规划、树遍历
+- **优化思路**:从暴力解法开始,逐步优化到最优解
+- **实际应用**:结合项目经验谈复杂度优化案例
+
+#### 职场应用
+- **代码review**:关注性能复杂度,不只是功能正确性
+- **系统设计**:提前评估算法复杂度,避免性能瓶颈
+- **技术选型**:基于复杂度分析选择合适的数据结构和算法
+- **性能调优**:使用复杂度分析定位和解决性能问题
+
+### 进阶学习方向
+
+如果你想在算法和数据结构方面更进一步,建议关注:
+
+1. **高级数据结构**:并查集、线段树、字典树
+2. **算法设计模式**:分治、贪心、回溯、动态规划
+3. **系统设计**:分布式系统中的复杂度考量
+4. **性能优化**:JVM调优、数据库优化、缓存策略
+
+### 写在最后
+
+复杂度分析不是纸上谈兵的理论知识,而是每个Java后端开发者都应该掌握的实用技能。它帮助我们:
+
+- **写出更高效的代码**
+- **在面试中展现技术功底**
+- **在系统设计时做出正确决策**
+- **在性能调优时快速定位问题**
+
+希望这篇文章能帮助你建立起完整的复杂度分析知识体系。记住,**理论学习 + 刷题实战 + 项目应用** 才是掌握算法复杂度的最佳路径。
+
+---
+
+**下期预告**:我们将深入探讨**数组与链表**的底层实现和应用技巧,敬请期待!
+
+> 如果觉得文章对你有帮助,欢迎点赞分享,你的支持是我创作的动力!
+>
+> 更多Java技术文章请关注 GitHub [JavaKeeper](https://github.com/Jstarfish/JavaKeeper)
\ No newline at end of file
diff --git a/docs/data-structure-algorithms/Array.md b/docs/data-structure-algorithms/data-structure/Array.md
similarity index 100%
rename from docs/data-structure-algorithms/Array.md
rename to docs/data-structure-algorithms/data-structure/Array.md
diff --git a/docs/data-structure-algorithms/Binary-Tree.md b/docs/data-structure-algorithms/data-structure/Binary-Tree.md
similarity index 80%
rename from docs/data-structure-algorithms/Binary-Tree.md
rename to docs/data-structure-algorithms/data-structure/Binary-Tree.md
index 6790ef66d9..4f22ac687b 100755
--- a/docs/data-structure-algorithms/Binary-Tree.md
+++ b/docs/data-structure-algorithms/data-structure/Binary-Tree.md
@@ -1,4 +1,11 @@
-# 程序员心里得有点树——重学数据结构之二叉树
+---
+title: 程序员心里得有点树——重学数据结构之二叉树
+date: 2022-06-09
+tags:
+ - data-structure
+ - binary-tree
+categories: data-structure
+---
## 前言
@@ -13,7 +20,7 @@
- 在通用树中,一个节点可以具有任意数量的子节点,但它只能有一个父节点
- 下图就是一棵树,节点 `A` 为根节点,而其他节点可以看作是 `A` 的子节点
-
+
@@ -27,12 +34,15 @@
- **路径**: 连续边的序列称为路径。 在上图所示的树中,节点`E`的路径为`A→B→E`
- **祖先节点**: 节点的祖先是从根到该节点的路径上的任何前节点。根节点没有祖先节点。 在上图所示的树中,节点`F`的祖先是`B`和`A`
- **度**: 节点的度数等于子节点数。 在上图所示的树中,节点`B`的度数为`2`。叶子节点的度数总是`0`,而在完整的二叉树中,每个节点的度数等于`2`
-- **高度(Height)/深度(Depth)**:树中层的数量。比如上图中的树有 4 层,则高度为 4
+- **高度(Height)**:[根]节点到叶子节点的最常路径(边数)
+- 深度(Depth):根节点到这个节点所经历的边的个数
- **级别编号**: 为树的每个节点分配一个级别编号,使得每个节点都存在于高于其父级的一个级别。树的根节点始终是级别`0`。
- **层级(Level)**:根为 Level 0 层,根的子节点为 Level 1 层,以此类推
- 有序树、无序树:如果将树中的各个子树看成是从左到右是有次序的,则称该树是有序树;若不考虑子树的顺序称为无序树
- 森林:m(m>=0)棵互不交互的树的集合。对树中每个结点而言,其子树的集合即为森林
+
+
### 基本操作
1. 构造空树(初始化)
@@ -49,7 +59,6 @@
......
- [](https://camo.githubusercontent.com/8a33cd5c67ca4961456946bac93a996b6f4db0ac0d24c429b6947f15108ed17d/68747470733a2f2f69303170696363646e2e736f676f7563646e2e636f6d2f31633466386664396464333336313061)
> 其他都好理解,主要回顾下几种遍历操作,也是面试常客
@@ -59,11 +68,11 @@
| 遍历方法 | 顺序 | 示意图 | 顺序 | 应用 |
| -------- | ------------------------ | ------------------------------------------------------------ | -------- | ------------------------------------------------------------ |
-| 前序 | **根 ➜ 左 ➜ 右** |  | 12457836 | 想在节点上直接执行操作(或输出结果)使用先序 |
-| 中序 | **左 ➜ 根 ➜ 右** | [](https://camo.githubusercontent.com/5552a974bd7d5979f1e64fb5594f0cc8386eed15ee82fc46c518900139a5cf3d/68747470733a2f2f747661312e73696e61696d672e636e2f6c617267652f30303753385a496c6c79316768346635756d6263396a3330686630667261626c2e6a7067) | 42758136 | 在**二分搜索树**中,中序遍历的顺序符合从小到大(或从大到小)顺序的 要输出排序好的结果使用中序 |
-| 后序 | **左 ➜ 右 ➜ 根** |  | 47852631 | 后续遍历的特点是在执行操作时,肯定**已经遍历过该节点的左右子节点** 适用于进行破坏性操作 比如删除所有节点,比如判断树中是否存在相同子树 |
-| 广度优先 | **层序,横向访问** |  | 12345678 | 当**树的高度非常高**(非常瘦) 使用广度优先剑节省空间 |
-| 深度优先 | **纵向,探底到叶子节点** |  | 12457836 | 当**每个节点的子节点非常多**(非常胖),使用深度优先遍历节省空间 (访问顺序和入栈顺序相关,相当于先序遍历) |
+| 前序 | **根 ➜ 左 ➜ 右** |  | 12457836 | 想在节点上直接执行操作(或输出结果)使用先序 |
+| 中序 | **左 ➜ 根 ➜ 右** |  | 42758136 | 在**二分搜索树**中,中序遍历的顺序符合从小到大(或从大到小)顺序的 要输出排序好的结果使用中序 |
+| 后序 | **左 ➜ 右 ➜ 根** |  | 47852631 | 后续遍历的特点是在执行操作时,肯定**已经遍历过该节点的左右子节点** 适用于进行破坏性操作 比如删除所有节点,比如判断树中是否存在相同子树 |
+| 广度优先 | **层序,横向访问** |  | 12345678 | 当**树的高度非常高**(非常瘦) 使用广度优先剑节省空间 |
+| 深度优先 | **纵向,探底到叶子节点** |  | 12457836 | 当**每个节点的子节点非常多**(非常胖),使用深度优先遍历节省空间 (访问顺序和入栈顺序相关,相当于先序遍历) |
> 之所以叫前序、中序、后序遍历,是因为根节点在前、中、后
@@ -78,7 +87,7 @@
根据二叉树的定义和特点,可以将二叉树分为五种不同的形态,如下图所示
-
+
### 二叉树的性质
@@ -97,7 +106,7 @@
- 具有 n 个节点的满二叉树的深度为 log2(n+1)
- **完全二叉树**:若设二叉树的深度为 h,除第 h 层外,其它各层 (1~(h-1)层) 的结点数都达到最大个数,第 h 层所有的结点都连续集中在最左边,这就是完全二叉树。
-
+
**满二叉树一定是一颗棵完全二叉树,但完全二叉树不一定是满二叉树。**
@@ -113,11 +122,11 @@
完全二叉树的顺序存储,仅需从根节点开始,按照层次依次将树中节点存储到数组即可。
-
+
普通二叉树转完全二叉树,只需给二叉树额外添加一些节点,将其"拼凑"成完全二叉树即可。
-
+
#### 二叉树的链式存储结构
@@ -131,13 +140,13 @@
其中,data 域存放某结点的数据信息;lchild 与 rchild 分别存放指向左孩子和右孩子的指针,当左孩子或右孩子不存在时,相应指针域值为空(用符号 ∧ 或 NULL 表示)。
-
+
##### 三叉链表存储
为了方便找到父节点,可以在上述结点结构中增加一个指针域,指向结点的父结点。利用此结点结构得到的二叉树存储结构称为三叉链表。
-
+
### 二叉树的基本操作
@@ -158,7 +167,7 @@
3. 左、右子树也分别为二叉排序树
4. 没有键值相等的节点
-
+
### 性质
@@ -197,41 +206,82 @@ public class BinarySearchTree>{
**1.中序遍历:当到达某个节点时,先访问左子节点,再输出该节点,最后访问右子节点。**
```java
-public void inOrder(TreeNode cursor){
- if(cursor == null) return;
- inOrder(cursor.getLeft());
- System.out.println(cursor.getData());
- inOrder(cursor.getRight());
+/* 中序遍历 */
+void inOrder(TreeNode root) {
+ if (root == null)
+ return;
+ // 访问优先级:左子树 -> 根节点 -> 右子树
+ inOrder(root.left);
+ list.add(root.val);
+ inOrder(root.right);
}
```
**2. 前序遍历:当到达某个节点时,先输出该节点,再访问左子节点,最后访问右子节点。**
```java
-public void preOrder(TreeNode cursor){
- if(cursor == null) return;
- System.out.println(cursor.getData());
- inOrder(cursor.getLeft());
- inOrder(cursor.getRight());
+/* 前序遍历 */
+void preOrder(TreeNode root) {
+ if (root == null)
+ return;
+ // 访问优先级:根节点 -> 左子树 -> 右子树
+ list.add(root.val);
+ preOrder(root.left);
+ preOrder(root.right);
}
```
**3. 后序遍历:当到达某个节点时,先访问左子节点,再访问右子节点,最后输出该节点。**
```java
-public void postOrder(TreeNode cursor){
- if(cursor == null) return;
- inOrder(cursor.getLeft());
- inOrder(cursor.getRight());
- System.out.println(cursor.getData());
+/* 后序遍历 */
+void postOrder(TreeNode root) {
+ if (root == null)
+ return;
+ // 访问优先级:左子树 -> 右子树 -> 根节点
+ postOrder(root.left);
+ postOrder(root.right);
+ list.add(root.val);
+}
+```
+
+前序、中序和后序遍历都属于深度优先遍历(depth-first traversal),也称深度优先搜索(depth-first search, DFS),它体现了一种“先走到尽头,再回溯继续”的遍历方式。
+
+**深度优先遍历就像是绕着整棵二叉树的外围“走”一圈**,在每个节点都会遇到三个位置,分别对应前序遍历、中序遍历和后序遍历。
+
+
+
+**4. 层序遍历:**层序遍历(level-order traversal)从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点。
+
+层序遍历本质上属于广度优先遍历(breadth-first traversal),也称广度优先搜索(breadth-first search, BFS),它体现了一种“一圈一圈向外扩展”的逐层遍历方式。
+
+```java
+/* 层序遍历 */
+List levelOrder(TreeNode root) {
+ // 初始化队列,加入根节点
+ Queue queue = new LinkedList<>();
+ queue.add(root);
+ // 初始化一个列表,用于保存遍历序列
+ List list = new ArrayList<>();
+ while (!queue.isEmpty()) {
+ TreeNode node = queue.poll(); // 队列出队
+ list.add(node.val); // 保存节点值
+ if (node.left != null)
+ queue.offer(node.left); // 左子节点入队
+ if (node.right != null)
+ queue.offer(node.right); // 右子节点入队
+ }
+ return list;
}
```
+
+
#### 2. 树的搜索:
树的搜索和树的遍历差不多,就是在遍历的时候只搜索不输出就可以了(类比有序数组的搜索)
-
+
```java
public boolean searchNode(TreeNode node){
@@ -260,7 +310,7 @@ public boolean searchNode(TreeNode node){
3. 若插入的元素值小于根节点值,则将元素插入到左子树中
4. 若插入的元素值不小于根节点值,则将元素插入到右子树中
-
+
```java
public void insertNode(TreeNode node){
@@ -306,7 +356,7 @@ public void insertNode(TreeNode node){
例如:要在树中删除元素 20
-
+
- 如果删除的元素有两个儿子,那么可以取左子树中最大元素或者右子树中最小元素进行替换,然后将最大元素最小元素原位置置空
@@ -316,11 +366,11 @@ public void insertNode(TreeNode node){
- 有序数组转为二叉查找树
-
+
- 将二叉树转为有序数组
-
+
------
@@ -328,15 +378,13 @@ public void insertNode(TreeNode node){
二叉搜索树虽然在插入和删除时的效率都有所提升,但是如果原序列有序时,比如 {3,4,5,6,7},这个时候构造二叉树搜索树就变成了斜树,二叉树退化成单链表,搜索效率降低到 $O(n)$,查找数字 7 的话,需要找 5 次。这又说明了**二叉查找树的高度决定了二叉查找树的查找效率**。
-
+
为了解决这一问题,两位科学家大爷,G. M. Adelson-Velsky 和 E. M. Landis 又发明了平衡二叉树,从他两名字中提取出了 AVL,所以平衡二叉树又叫 **AVL 树**。
二叉搜索树的查找效率取决于树的高度,所以保持树的高度最小,就可保证树的查找效率,如下保持左右平衡,像不像天平?
-
-
-
+
**定义**:
@@ -410,7 +458,7 @@ RL的旋转示意图如下: [
+
情形 1 和情形 4 是关于 A 的镜像对称,情形 2 和情形 3 也是关于 A 的镜像对称,因此理论上看只有两种情况,但编程的角度看还是四种情形。
@@ -448,25 +496,20 @@ AVL 树和二叉查找树的删除操作情况一致,都分为四种情况:
对于删除操作造成的非平衡状态的修正,可以这样理解:对左或者右子树的删除操作相当于对右或者左子树的插入操作,然后再对应上插入的四种情况选择相应的旋转就好了。
-## 红黑树
-**红黑树的定义:**红黑树是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。它是在1972年由鲁道夫·贝尔发明的,称之为"对称二叉B树",它现代的名字是在 Leo J. Guibas 和 Robert Sedgewick 于1978年写的一篇论文中获得的。**它是复杂的,但它的操作有着良好的最坏情况运行时间,并且在实践中是高效的: 它可以在O(logn)时间内做查找,插入和删除,这里的n是树中元素的数目。**
-红黑树和AVL树一样都对插入时间、删除时间和查找时间提供了最好可能的最坏情况担保。这不只是使它们在时间敏感的应用如实时应用(real time application)中有价值,而且使它们有在提供最坏情况担保的其他数据结构中作为建造板块的价值;例如,在计算几何中使用的很多数据结构都可以基于红黑树。此外,红黑树还是2-3-4树的一种等同,它们的思想是一样的,只不过红黑树是2-3-4树用二叉树的形式表示的。
-
-**红黑树的性质:**
+## 红黑树
-红黑树是每个节点都带有颜色属性的二叉查找树,颜色为红色或黑色。在二叉查找树强制的一般要求以外,对于任何有效的红黑树我们增加了如下的额外要求:
+顾名思义,红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求:
-- 每个节点要么是黑色,要么是红色
-- 根节点是黑色
-- 所有叶子都是黑色(叶子是NIL节点)
-- 每个红色节点必须有两个黑色的子节点(从每个叶子到根的所有路径上不能有两个连续的红色节点)
-- **任意一结点到每个叶子结点的简单路径都包含数量相同的黑结点**
+- 根节点是黑色的;
+- 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
+- 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
+- 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;
下面是一个具体的红黑树的图例:
-[](https://camo.githubusercontent.com/5db2fdaae07fbac460e1ef727d9b890ce292cbcb33783efece8fc2fad30006a8/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f7468756d622f362f36362f5265642d626c61636b5f747265655f6578616d706c652e7376672f34353070782d5265642d626c61636b5f747265655f6578616d706c652e7376672e706e67)
+
这些约束确保了红黑树的关键特性: 从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。结果是这个树大致上是平衡的。因为操作比如插入、删除和查找某个值的最坏情况时间都要求与树的高度成比例,这个在高度上的理论上限允许红黑树在最坏情况下都是高效的,而不同于普通的二叉查找树。
diff --git a/docs/data-structure-algorithms/data-structure/HashTable.md b/docs/data-structure-algorithms/data-structure/HashTable.md
new file mode 100755
index 0000000000..edb7eb82ab
--- /dev/null
+++ b/docs/data-structure-algorithms/data-structure/HashTable.md
@@ -0,0 +1,168 @@
+---
+title: 深入解析哈希表:从散列冲突到算法应用全景解读
+date: 2022-06-09
+tags:
+ - data-structure
+ - HashTable
+categories: data-structure
+---
+
+
+
+## 一、散列冲突的本质与解决方案
+
+哈希表作为数据结构的核心组件,其灵魂在于通过哈希函数实现 $(1)$ 时间复杂度的数据存取。但正如硬币的两面,哈希算法在带来高效存取的同时,也面临着不可避免的**散列冲突**问题——不同的输入值经过哈希运算后映射到同一存储位置的现象。
+
+### 1.1 开放寻址法:空间换时间的博弈
+
+**典型代表**:Java 的 ThreadLocalMap
+
+开放寻址法采用"线性探测+数组存储"的经典组合,当冲突发生时,通过系统性的探测策略(线性探测/二次探测/双重哈希)在数组中寻找下一个可用槽位。这种方案具有三大显著优势:
+
+- **缓存友好性**:数据连续存储在数组中,有效利用CPU缓存行预取机制
+- **序列化简单**:无需处理链表指针等复杂内存结构
+- **空间紧凑**:内存分配完全可控,无动态内存开销
+
+但硬币的另一面是:
+
+- **删除复杂度**:需引入墓碑标记(TOMBSTONE)处理逻辑
+- **负载因子限制**:建议阈值0.7以下,否则探测次数指数级增长
+- **内存浪费**:动态扩容时旧数组需要保留至数据迁移完成
+
+```Java
+// 线性探测的典型实现片段
+int index = hash(key);
+while (table[index] != null) {
+ if (table[index].key.equals(key)) break;
+ index = (index + 1) % capacity;
+}
+```
+
+### 1.2 链表法:时间与空间的动态平衡
+
+**典型代表**:Java的LinkedHashMap
+
+链表法采用"数组+链表/树"的复合结构,每个桶位维护一个动态数据结构。其核心优势体现在:
+
+- **高负载容忍**:允许负载因子突破1.0(Java HashMap默认0.75)
+- **内存利用率**:按需创建节点,避免空槽浪费
+- **结构灵活性**:可升级为红黑树(Java8+)应对哈希碰撞攻击
+
+但需要注意:
+
+- **指针开销**:每个节点多消耗4-8字节指针空间
+- **缓存不友好**:节点内存地址离散影响访问局部性
+- **小对象劣势**:当存储值小于指针大小时内存利用率降低
+
+```Java
+// 树化转换阈值定义(Java HashMap)
+static final int TREEIFY_THRESHOLD = 8;
+static final int UNTREEIFY_THRESHOLD = 6;
+```
+
+
+
+## 二、哈希算法:从理论到工程实践
+
+哈希算法作为数字世界的"指纹生成器",必须满足四大黄金准则:
+
+1. **不可逆性**:哈希值到原文的逆向推导在计算上不可行
+2. **雪崩效应**:微小的输入变化导致输出剧变
+3. **低碰撞率**:不同输入的哈希相同概率趋近于零
+4. **高效计算**:处理海量数据时仍保持线性时间复杂度
+
+### 2.1 七大核心应用场景解析
+
+#### 场景1:安全加密(SHA-256示例)
+
+```Java
+MessageDigest md = MessageDigest.getInstance("SHA-256");
+byte[] hashBytes = md.digest("secret".getBytes());
+```
+
+#### 场景2:内容寻址存储(IPFS协议)
+
+通过三级哈希验证确保内容唯一性:
+
+1. 内容分块哈希
+2. 分块组合哈希
+3. 最终Merkle根哈希
+
+#### 场景3:P2P传输校验(BitTorrent协议)
+
+种子文件包含分片哈希树,下载时逐层校验:
+
+```
+ 分片1(SHA1) → 分片2(SHA1) → ... → 分片N(SHA1)
+ ↘ ↙ ↘ ↙
+ 中间哈希节点 根哈希
+```
+
+#### 场景4:高性能散列函数(MurmurHash3)
+
+针对不同场景的哈希优化:
+
+- 内存型:CityHash
+- 加密型:SipHash
+- 流式处理:XXHash
+
+#### 场景5:会话保持负载均衡
+
+```Python
+def get_server(client_ip):
+ hash_val = hashlib.md5(client_ip).hexdigest()
+ return servers[hash_val % len(servers)]
+```
+
+#### 场景6:大数据分片处理
+
+```SQL
+-- 按用户ID哈希分库
+CREATE TABLE user_0 (
+ id BIGINT PRIMARY KEY,
+ ...
+) PARTITION BY HASH(id) PARTITIONS 4;
+```
+
+#### 场景7:一致性哈希分布式存储
+
+构建虚拟节点环解决数据倾斜问题:
+
+```
+ NodeA → 1000虚拟节点
+NodeB → 1000虚拟节点
+NodeC → 1000虚拟节点
+```
+
+
+
+## 三、工程实践中的进阶技巧
+
+### 3.1 动态扩容策略
+
+- 渐进式扩容:避免一次性rehash导致的STW停顿
+- 容量质数选择:降低哈希聚集现象(如Java HashMap使用2^n优化模运算)
+
+### 3.2 哈希攻击防御
+
+- 盐值加密:password_hash(pass,PASSWORDBCRYPT,[′salt′=>*p**a**ss*,*P**A**SS**W**OR**D**B**CR**Y**PT*,[′*s**a**l**t*′=>salt])
+- 密钥哈希:HMAC-SHA256(secretKey, message)
+
+### 3.3 性能优化指标
+
+| 指标 | 开放寻址法 | 链表法 |
+| ------------ | ---------- | ------ |
+| 平均查询时间 | O(1/(1-α)) | O(α) |
+| 内存利用率 | 60-70% | 80-90% |
+| 最大负载因子 | 0.7 | 1.0+ |
+| 并发修改支持 | 困难 | 较容易 |
+
+
+
+## 四、未来演进方向
+
+- **量子安全哈希**:抗量子计算的Lattice-based哈希算法
+- **同态哈希**:支持密文域计算的哈希方案
+- **AI驱动哈希**:基于神经网络的自适应哈希函数
+
+哈希表及其相关算法作为计算机科学的基石,在从单机系统到云原生架构的演进历程中持续发挥着关键作用。理解其核心原理并掌握工程化实践技巧,将帮助开发者在高并发、分布式场景下构建出更健壮、更高效的系统。
diff --git a/docs/data-structure-algorithms/data-structure/Linked-List.md b/docs/data-structure-algorithms/data-structure/Linked-List.md
new file mode 100644
index 0000000000..ba18ccf934
--- /dev/null
+++ b/docs/data-structure-algorithms/data-structure/Linked-List.md
@@ -0,0 +1,336 @@
+---
+title: 链表
+date: 2022-06-08
+tags:
+ - LikedList
+categories: data-structure
+---
+
+# 链表
+
+与数组相似,链表也是一种`线性`数据结构。
+
+链表是一系列的存储数据元素的单元通过指针串接起来形成的,因此每个单元至少有两个域,一个域用于数据元素的存储,另一个域是指向其他单元的指针。这里具有一个数据域和多个指针域的存储单元通常称为**结点**(node)。
+
+
+
+## 单链表
+
+
+
+一种最简单的结点结构如上图所示,它是构成单链表的基本结点结构。在结点中数据域用来存储数据元素,指针域用于指向下一个具有相同结构的结点。
+
+单链表中的每个结点不仅包含值,还包含链接到下一个结点的`引用字段`。通过这种方式,单链表将所有结点按顺序组织起来。
+
+
+
+链表的第一个结点和最后一个结点,分别称为链表的**首结点**和**尾结点**。尾结点的特征是其 next 引用为空(null)。链表中每个结点的 next 引用都相当于一个指针,指向另一个结点,借助这些 next 引用,我们可以从链表的首结点移动到尾结点。如此定义的结点就称为**单链表**(single linked list)。
+
+上图蓝色箭头显示单个链接列表中的结点是如何组合在一起的。
+
+在单链表中通常使用 head 引用来指向链表的首结点,由 head 引用可以完成对整个链表中所有节点的访问。有时也可以根据需要使用指向尾结点的 tail 引用来方便某些操作的实现。
+
+在单链表结构中还需要注意的一点是,由于每个结点的数据域都是一个 Object 类的对象,因此,每个数据元素并非真正如图中那样,而是在结点中的数据域通过一个 Object 类的对象引用来指向数据元素的。
+
+与数组类似,单链表中的结点也具有一个线性次序,即如果结点 P 的 next 引用指向结点 S,则 P 就是 S 的**直接前驱**,S 是 P 的**直接后续**。单链表的一个重要特性就是只能通过前驱结点找到后续结点,而无法从后续结点找到前驱结点。
+
+接着我们来看下单链表的 CRUD:
+
+> [707. 设计链表](https://leetcode.cn/problems/design-linked-list/) 搞定一题
+
+以下是单链表中结点的典型定义,`值 + 链接到下一个元素的指针`:
+
+```java
+// Definition for singly-linked list.
+public class SinglyListNode {
+ int val;
+ SinglyListNode next;
+ SinglyListNode(int x) { val = x; }
+}
+```
+
+有了结点,还需要一个“链” 把所有结点串起来
+
+```java
+class MyLinkedList {
+ private SinglyListNode head;
+ /** Initialize your data structure here. */
+ public MyLinkedList() {
+ head = null;
+ }
+}
+```
+
+
+
+### 查找
+
+与数组不同,我们无法在常量时间内访问单链表中的随机元素。 如果我们想要获得第 i 个元素,我们必须从头结点逐个遍历。 我们按索引来访问元素平均要花费 $O(N)$ 时间,其中 N 是链表的长度。
+
+使用 Java 语言实现整个过程的关键语句是:
+
+```java
+/** Get the value of the index-th node in the linked list. If the index is invalid, return -1. */
+public int get(int index) {
+ // if index is invalid
+ if (index < 0 || index >= size) return -1;
+
+ ListNode curr = head;
+ // index steps needed
+ // to move from sentinel node to wanted index
+ for(int i = 0; i < index + 1; ++i) {
+ curr = curr.next;
+ }
+ return curr.val;
+}
+```
+
+
+
+### 添加
+
+单链表中数据元素的插入,是通过在链表中插入数据元素所属的结点来完成的。对于链表的不同位置,插入的过程会有细微的差别。
+
+
+
+除了单链表的首结点由于没有直接前驱结点,所以可以直接在首结点之前插入一个新的结点之外,在单链表中的其他任何位置插入一个新结点时,都只能是在已知某个特定结点引用的基础上在其后面插入一个新结点。并且在已知单链表中某个结点引用的基础上,完成结点的插入操作需要的时间是 $O(1)$。
+
+> 思考:如果是带头结点的单链表进行插入操作,是什么样子呢?
+
+```java
+//最外层有个链表长度,便于我们头插和尾插操作
+int size;
+
+public void addAtHead(int val) {
+ addAtIndex(0, val);
+}
+
+//尾插就是从最后一个
+public void addAtTail(int val) {
+ addAtIndex(size, val);
+}
+
+public void addAtIndex(int index, int val) {
+
+ if (index > size) return;
+
+ if (index < 0) index = 0;
+
+ ++size;
+ // find predecessor of the node to be added
+ ListNode pred = head;
+ for(int i = 0; i < index; ++i) {
+ pred = pred.next;
+ }
+
+ // node to be added
+ ListNode toAdd = new ListNode(val);
+ // insertion itself
+ toAdd.next = pred.next;
+ pred.next = toAdd;
+}
+```
+
+
+
+### 删除
+
+类似的,在单链表中数据元素的删除也是通过结点的删除来完成的。在链表的不同位置删除结点,其操作过程也会有一些差别。
+
+
+
+在单链表中删除一个结点时,除首结点外都必须知道该结点的直接前驱结点的引用。并且在已知单链表中某个结点引用的基础上,完成其后续结点的删除操作需要的时间是 $O(1)$。
+
+> 在使用单链表实现线性表的时候,为了使程序更加简洁,我们通常在单链表的最前面添加一个**哑元结点**,也称为头结点。在头结点中不存储任何实质的数据对象,其 next 域指向线性表中 0 号元素所在的结点,头结点的引入可以使线性表运算中的一些边界条件更容易处理。
+>
+> 对于任何基于序号的插入、删除,以及任何基于数据元素所在结点的前面或后面的插入、删除,在带头结点的单链表中均可转化为在某个特定结点之后完成结点的插入、删除,而不用考虑插入、删除是在链表的首部、中间、还是尾部等不同情况。
+
+
+
+
+
+```java
+ public void deleteAtIndex(int index) {
+ // if the index is invalid, do nothing
+ if (index < 0 || index >= size) return;
+
+ size--;
+ // find predecessor of the node to be deleted
+ ListNode pred = head;
+ for(int i = 0; i < index; ++i) {
+ pred = pred.next;
+ }
+
+ // delete pred.next
+ pred.next = pred.next.next;
+ }
+```
+
+
+
+## 双向链表
+
+单链表的一个优点是结构简单,但是它也有一个缺点,即在单链表中只能通过一个结点的引用访问其后续结点,而无法直接访问其前驱结点,要在单链表中找到某个结点的前驱结点,必须从链表的首结点出发依次向后寻找,但是需要 $Ο(n)$ 时间。
+
+所以我们在单链表结点结构中新增加一个域,该域用于指向结点的直接前驱结点。
+
+
+
+双向链表是通过上述定义的结点使用 pre 以及 next 域依次串联在一起而形成的。一个双向链表的结构如下图所示。
+
+
+
+接着我们来看下双向链表的 CRUD:
+
+以下是双链表中结点的典型定义:
+
+```java
+public class ListNode {
+ int val;
+ ListNode next;
+ ListNode prev;
+ ListNode(int x) { val = x; }
+}
+
+class MyLinkedList {
+ int size;
+ // sentinel nodes as pseudo-head and pseudo-tail
+ ListNode head, tail;
+ public MyLinkedList() {
+ size = 0;
+ head = new ListNode(0);
+ tail = new ListNode(0);
+ head.next = tail;
+ tail.prev = head;
+ }
+```
+
+### 查找
+
+在双向链表中进行查找与在单链表中类似,只不过在双向链表中查找操作可以从链表的首结点开始,也可以从尾结点开始,但是需要的时间和在单链表中一样。
+
+```java
+ /** Get the value of the index-th node in the linked list. If the index is invalid, return -1. */
+ public int get(int index) {
+ if (index < 0 || index >= size) return -1;
+
+ ListNode curr = head;
+ if (index + 1 < size - index)
+ for(int i = 0; i < index + 1; ++i) {
+ curr = curr.next;
+ }
+ else {
+ curr = tail;
+ for(int i = 0; i < size - index; ++i) {
+ curr = curr.prev;
+ }
+ }
+
+ return curr.val;
+ }
+```
+
+
+
+### 添加
+
+单链表的插入操作,除了首结点之外必须在某个已知结点后面进行,而在双向链表中插入操作在一个已知的结点之前或之后都可以进行,如下表示在结点 11 之前 插入 9。
+
+
+
+使用 Java 语言实现整个过程的关键语句是
+
+```java
+
+ public void addAtHead(int val) {
+ ListNode pred = head, succ = head.next;
+
+ ++size;
+ ListNode toAdd = new ListNode(val);
+ toAdd.prev = pred;
+ toAdd.next = succ;
+ pred.next = toAdd;
+ succ.prev = toAdd;
+ }
+
+ /** Append a node of value val to the last element of the linked list. */
+ public void addAtTail(int val) {
+ ListNode succ = tail, pred = tail.prev;
+
+ ++size;
+ ListNode toAdd = new ListNode(val);
+ toAdd.prev = pred;
+ toAdd.next = succ;
+ pred.next = toAdd;
+ succ.prev = toAdd;
+ }
+
+ public void addAtIndex(int index, int val) {
+ if (index > size) return;
+
+ if (index < 0) index = 0;
+
+ ListNode pred, succ;
+ if (index < size - index) {
+ pred = head;
+ for(int i = 0; i < index; ++i) pred = pred.next;
+ succ = pred.next;
+ }
+ else {
+ succ = tail;
+ for (int i = 0; i < size - index; ++i) succ = succ.prev;
+ pred = succ.prev;
+ }
+
+ // insertion itself
+ ++size;
+ ListNode toAdd = new ListNode(val);
+ toAdd.prev = pred;
+ toAdd.next = succ;
+ pred.next = toAdd;
+ succ.prev = toAdd;
+ }
+```
+
+在结点 p 之后插入一个新结点的操作与上述操作对称,这里不再赘述。
+
+插入操作除了上述情况,还可以在双向链表的首结点之前、双向链表的尾结点之后进行,此时插入操作与上述插入操作相比更为简单。
+
+### 删除
+
+单链表的删除操作,除了首结点之外必须在知道待删结点的前驱结点的基础上才能进行,而在双向链表中在已知某个结点引用的前提下,可以完成该结点自身的删除。如下表示删除 16 的过程。
+
+
+
+```java
+ /** Delete the index-th node in the linked list, if the index is valid. */
+ public void deleteAtIndex(int index) {
+ if (index < 0 || index >= size) return;
+
+ ListNode pred, succ;
+ if (index < size - index) {
+ pred = head;
+ for(int i = 0; i < index; ++i) pred = pred.next;
+ succ = pred.next.next;
+ }
+ else {
+ succ = tail;
+ for (int i = 0; i < size - index - 1; ++i) succ = succ.prev;
+ pred = succ.prev.prev;
+ }
+
+ // delete pred.next
+ --size;
+ pred.next = succ;
+ succ.prev = pred;
+ }
+}
+```
+
+对线性表的操作,无非就是排序、加法、减法、反转,说的好像很简单,我们去下一章刷题吧。
+
+
+
+## 参考与感谢
+
+- https://aleej.com/2019/09/16/数据结构与算法之美学习笔记
\ No newline at end of file
diff --git a/docs/data-structure-algorithms/Queue.md b/docs/data-structure-algorithms/data-structure/Queue.md
similarity index 90%
rename from docs/data-structure-algorithms/Queue.md
rename to docs/data-structure-algorithms/data-structure/Queue.md
index 30bce5fd26..2c91ca275f 100644
--- a/docs/data-structure-algorithms/Queue.md
+++ b/docs/data-structure-algorithms/data-structure/Queue.md
@@ -1,25 +1,26 @@
-# 队列
+---
+title: Queue
+date: 2023-05-03
+tags:
+ - Stack
+categories: data-structure
+---
-## 一、前言
+
+
+> 队列(queue)是一种采用先进先出(FIFO)策略的抽象数据结构,它的想法来自于生活中排队的策略。顾客在付款结账的时候,按照到来的先后顺序排队结账,先来的顾客先结账,后来的顾客后结账。
-队列(queue)是一种采用先进先出(FIFO)策略的抽象数据结构,它的想法来自于生活中排队的策略。顾客在付款结账的时候,按照到来的先后顺序排队结账,先来的顾客先结账,后来的顾客后结账。
+## 一、前言
队列是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。简称FIFO。允许插入的一端称为队尾,允许删除的一端称为队头。
-
+
1. 队列是一个有序列表,可以用数组或是链表来实现。
2. 遵循先入先出的原则。即:先存入队列的数据,要先取出。后存入的要后取出
-
-
-在 FIFO 数据结构中,将`首先处理添加到队列中的第一个元素`。
-
-如上图所示,队列是典型的 FIFO 数据结构。插入(insert)操作也称作入队(enqueue),新元素始终被添加在`队列的末尾`。 删除(delete)操作也被称为出队(dequeue)。 你只能移除`第一个元素`。
-
-
## 二、基本属性
@@ -85,13 +86,11 @@ public interface MyQueue {
### 3.1 基于数组实现的队列
-- 队列本身是有序列表,若使用数组的结构来存储队列的数据,则队列数组的声明如下图,其中 capacity是该队列的最大容量。
-- 因为队列的输出、输入是分别从前后端来处理,因此需要两个变量 front 及 rear 分别记录队列前后端的下标, **front 会随着数据输出而改变,而 rear 则是随着数据输入而改变**,如图所示:
-
-
+- 队列本身是有序列表,若使用数组的结构来存储队列的数据,则队列数组的声明如下图,其中 capacity 是该队列的最大容量。
+- 因为队列的输出、输入是分别从前后端来处理,因此需要两个变量 front 及 rear 分别记录队列前后端的下标, **front 会随着数据输出而改变,而 rear 则是随着数据输入而改变**、
- 当我们将数据存入队列时的处理需要有两个步骤:
- 1. 将尾指针往后移:`rear+1` , 当 `front == rear`队列为空
+ 1. 将尾指针往后移:`rear+1` , 当 `front == rear` 队列为空
2. 若尾指针 rear 小于队列的最大下标 `capacity-1`,则将数据存入 rear 所指的数组元素中,否则无法存入数据,即队列满了。
```java
@@ -149,15 +148,17 @@ public class MyArrayQueue implements MyQueue {
}
```
+### 3.2 基于链表实现的队列
+链表实现的队列没有固定的大小限制,可以动态地添加更多的元素。这种方式更加灵活,有效避免了空间浪费,但其操作可能比数组实现稍微复杂一些,因为需要处理节点之间的链接。
-**缺点**
-上面的实现很简单,但在某些情况下效率很低。
-假设我们分配一个最大长度为 5 的数组。当队列满时,我们想要循环利用的空间的话,在执行取出队首元素的时候,我们就必须将数组中其他所有元素都向前移动一个位置,时间复杂度就变成了 $O(n)$
+## 四、队列的变体
+上面的实现很简单,但在某些情况下效率很低。
+假设我们分配一个最大长度为 5 的数组。当队列满时,我们想要循环利用的空间的话,在执行取出队首元素的时候,我们就必须将数组中其他所有元素都向前移动一个位置,时间复杂度就变成了 $O(n)$

@@ -170,7 +171,7 @@ public class MyArrayQueue implements MyQueue {
为了提高运算的效率,我们用另一种方式来表达数组中各单元的位置关系,将数组看做是一个环形的。当 rear 到达数组的最大下标时,重新指回数组下标为`0`的位置,这样就避免了数据迁移的低效率问题。
-
+
用循环数组实现的队列称为循环队列,我们将循环队列中从对首到队尾的元素按逆时针方向存放在循环数组中的一段连续的单元中。当新元素入队时,将队尾指针 rear 按逆时针方向移动一位即可,出队操作也很简单,只要将对首指针 front 逆时针方向移动一位即可。
@@ -435,9 +436,13 @@ public class MyPriorityQueue {
-## 队列的应用
+## 五、队列的应用
+队列在计算机科学的许多领域都有应用,包括:
+- **操作系统**:在多任务处理和调度中,队列用来管理进程执行的顺序。
+- **网络**:在数据包的传输中,队列帮助管理数据包的发送顺序和处理。
+- **算法**:在广度优先搜索(BFS)等算法中,队列用于存储待处理的节点。
diff --git a/docs/data-structure-algorithms/Skip-List.md b/docs/data-structure-algorithms/data-structure/Skip-List.md
similarity index 94%
rename from docs/data-structure-algorithms/Skip-List.md
rename to docs/data-structure-algorithms/data-structure/Skip-List.md
index 830d158f81..ad767655f8 100644
--- a/docs/data-structure-algorithms/Skip-List.md
+++ b/docs/data-structure-algorithms/data-structure/Skip-List.md
@@ -1,13 +1,19 @@
-# 跳表
+---
+title: 跳表
+date: 2023-05-09
+tags:
+ - Skip List
+categories: data-structure
+---
-
+
> Redis 是怎么想的:用跳表来实现有序集合?
>
干过服务端开发的应该都知道 Redis 的 ZSet 使用跳表实现的(当然还有压缩列表、哈希表),我就不从 1990 年的那个美国大佬 William Pugh 发表的那篇论文开始了,直接开跳
-
+
文章拢共两部分
@@ -20,7 +26,7 @@
### 跳表的简历
-
+
跳表,英文名:Skip List
@@ -38,13 +44,13 @@
前提:跳表处理的是有序的链表,所以我们先看个不能再普通了的有序列表(一般是双向链表)
-
+
如果我们想查找某个数,只能遍历链表逐个比对,时间复杂度 $O(n)$,插入和删除操作都一样。
为了提高查找效率,我们对链表做个”索引“
-
+
像这样,我们每隔一个节点取一个数据作为索引节点(或者增加一个指针),比如我们要找 31 直接在索引链表就找到了(遍历 3 次),如果找 16 的话,在遍历到 31的时候,发现大于目标节点,就跳到下一层,接着遍历~ (蓝线表示搜索路径)
@@ -52,7 +58,7 @@
>
> 数据量多的话,我们也可以多建几层索引,如下 4 层索引,效果就比较明显了
-
+
每加一层索引,我们搜索的时间复杂度就降为原来的 $O(n/2)$
@@ -82,7 +88,7 @@
>
> 不信,你照着下图比划比划,看看同一层能画出 3 条线不~~
>
- > 
+ > 
5. 既然知道了每一层最多遍历两个节点,那跳表查询数据的时间复杂度就是 $O(2*log(n))$,常数 2 忽略不计,就是 $O(logn)$ 了。
@@ -104,7 +110,7 @@
其实插入数据和查找一样,先找到元素要插入的位置,时间复杂度也是 $O(logn)$,但有个问题就是如果一直往原始列表里加数据,不更新我们的索引层,极端情况下就会出现两个索引节点中间数据非常多,相当于退化成了单链表,查找效率直接变成 $O(n)$
-
+
@@ -122,7 +128,7 @@
比如我们要插入新节点 X,那要不要为 X 向上建索引呢,就是抛硬币决定的,正面的话建索引,否则就不建了,就是这么随意(比如一个节点随机出的层数是 3,那就把它链入到第1 层到第 3 层链表中,也就是我们除了原链表的之外再往上 2 层索引都加上)。
-
+
其实是因为我们不能预测跳表的添加和删除操作,很难用一种有效的算法保证索引部分始终均匀。学过概率论的我们都知道抛硬币虽然不能让索引位置绝对均匀,当数量足够多的时候最起码可以保证大体上相对均匀。
@@ -298,7 +304,7 @@ public Node search(int target) {
>
> - 当数据多的时候,ZSet 是由一个 dict + 一个 skiplist 来实现的。简单来讲,dict 用来查询数据到分数的对应关系,而 skiplist 用来根据分数查询数据(可能是范围查找)。
>
-> 
+> 
>
> Redis 的跳跃表做了些修改
>
@@ -351,7 +357,7 @@ Redis 的 zset 是一个复合结构,一方面它需要一个 hash 结构来
Redis 中的有序集合是通过压缩列表、哈希表和跳表的组合来实现的,当数据较少时,ZSet 是由一个 ziplist 来实现的。当数据多的时候,ZSet 是由一个dict + 一个 skiplist 来实现的
-
+
diff --git a/docs/data-structure-algorithms/data-structure/Stack.md b/docs/data-structure-algorithms/data-structure/Stack.md
new file mode 100644
index 0000000000..4e8996f3a0
--- /dev/null
+++ b/docs/data-structure-algorithms/data-structure/Stack.md
@@ -0,0 +1,546 @@
+---
+title: Stack
+date: 2023-05-09
+tags:
+ - Stack
+categories: data-structure
+---
+
+
+
+> 栈(stack)又名堆栈,它是**一种运算受限的线性表**。 限定仅在表尾进行插入和删除操作的线性表。
+
+
+
+## 一、概述
+
+### 定义
+
+注意:本文所说的栈是数据结构中的栈,而不是内存模型中栈。
+
+栈(stack)是限定仅在表尾一端进行插入或删除操作的**特殊线性表**。又称为堆栈。
+
+对于栈来说, 允许进行插入或删除操作的一端称为栈顶(top),而另一端称为栈底(bottom)。不含元素栈称为空栈,向栈中插入一个新元素称为入栈或压栈, 从栈中删除一个元素称为出栈或退栈。
+
+假设有一个栈S=(a1, a2, …, an),a1先进栈, an最后进栈。称 a1 为栈底元素,an 为栈顶元素。出栈时只允许在栈顶进行,所以 an 先出栈,a1最后出栈。因此又称栈为后进先出(Last In First Out,LIFO)的线性表。
+
+栈(stack),是一种线性存储结构,它有以下几个特点:
+
+- 栈中数据是按照"后进先出(LIFO, Last In First Out)"方式进出栈的。
+- 向栈中添加/删除数据时,只能从栈顶进行操作。
+
+
+
+
+
+### 基本操作
+
+栈的基本操作除了进栈 `push()`,出栈 `pop()` 之外,还有判空 `isEmpty()`、取栈顶元素 `peek()` 等操作。
+
+抽象成接口如下:
+
+```java
+public interface MyStack {
+
+ /**
+ * 返回堆栈的大小
+ */
+ public int getSize();
+
+ /**
+ * 判断堆栈是否为空
+ */
+ public boolean isEmpty();
+
+ /**
+ * 入栈
+ */
+ public void push(Object e);
+
+ /**
+ * 出栈,并删除
+ */
+ public Object pop();
+
+ /**
+ * 返回栈顶元素
+ */
+ public Object peek();
+}
+```
+
+
+
+和线性表类似,栈也有两种存储结构:顺序存储和链式存储。
+
+实际上,栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,我们叫作**顺序栈**,用链表实现的栈,我们叫作**链式栈**。
+
+## 二、栈的顺序存储与实现
+
+顺序栈是使用顺序存储结构实现的栈,即利用一组地址连续的存储单元依次存放栈中的数据元素。由于栈是一种特殊的线性表,因此在线性表的顺序存储结构的基础上,选择线性表的一端作为栈顶即可。那么根据数组操作的特性,选择数组下标大的一端,即线性表顺序存储的表尾来作为栈顶,此时入栈、出栈操作可以 $O(1)$ 时间完成。
+
+由于栈的操作都是在栈顶完成,因此在顺序栈的实现中需要附设一个指针 top 来动态地指示栈顶元素在数组中的位置。通常 top 可以用栈顶元素所在的数组下标来表示,`top=-1` 时表示空栈。
+
+栈在使用过程中所需的最大空间难以估计,所以,一般构造栈的时候不应设定最大容量。一种合理的做法和线性表类似,先为栈分配一个基本容量,然后在实际的使用过程中,当栈的空间不够用时再倍增存储空间。
+
+```java
+public class MyArrayStack implements MyStack {
+
+ private final int capacity = 2; //默认容量
+ private Object[] arrs; //数据元素数组
+ private int top; //栈顶指针
+
+ MyArrayStack(){
+ top = -1;
+ arrs = new Object[capacity];
+ }
+
+ public int getSize() {
+ return top + 1;
+ }
+
+ public boolean isEmpty() {
+ return top < 0;
+ }
+
+ public void push(Object e) {
+ if(getSize() >= arrs.length){
+ expandSapce(); //扩容
+ }
+ arrs[++top]=e;
+ }
+
+ private void expandSapce() {
+ Object[] a = new Object[arrs.length * 2];
+ for (int i = 0; i < arrs.length; i++) {
+ a[i] = arrs[i];
+ }
+ arrs = a;
+ }
+
+ public Object pop() {
+ if(getSize()<1){
+ throw new RuntimeException("栈为空");
+ }
+ Object obj = arrs[top];
+ arrs[top--] = null;
+ return obj;
+ }
+
+ public Object peek() {
+ if(getSize()<1){
+ throw new RuntimeException("栈为空");
+ }
+ return arrs[top];
+ }
+}
+```
+
+以上基于数据实现的栈代码并不难理解。由于有 top 指针的存在,所以`size()`、`isEmpty()`方法均可在 $O(1) $ 时间内完成。`push()`、`pop()`和`peek()`方法,除了需要`ensureCapacity()`外,都执行常数基本操作,因此它们的运行时间也是 $O(1)$
+
+
+
+## 三、栈的链式存储与实现
+
+栈的链式存储即采用链表实现栈。当采用单链表存储线性表后,根据单链表的操作特性选择单链表的头部作为栈顶,此时,入栈和出栈等操作可以在 $O(1)$ 时间内完成。
+
+由于栈的操作只在线性表的一端进行,在这里使用带头结点的单链表或不带头结点的单链表都可以。使用带头结点的单链表时,结点的插入和删除都在头结点之后进行;使用不带头结点的单链表时,结点的插入和删除都在链表的首结点上进行。
+
+下面以不带头结点的单链表为例实现栈,如下示意图所示:
+
+
+
+在上图中,top 为栈顶结点的引用,始终指向当前栈顶元素所在的结点。若 top 为null,则表示空栈。入栈操作是在 top 所指结点之前插入新的结点,使新结点的 next 域指向 top,top 前移即可;出栈则直接让 top 后移即可。
+
+```java
+public class MyLinkedStack implements MyStack {
+
+ class Node {
+ private Object element;
+ private Node next;
+
+ public Node() {
+ this(null, null);
+ }
+
+ public Node(Object ele, Node next) {
+ this.element = ele;
+ this.next = next;
+ }
+
+ public Node getNext() {
+ return next;
+ }
+
+ public void setNext(Node next) {
+ this.next = next;
+ }
+
+ public Object getData() {
+ return element;
+ }
+
+ public void setData(Object obj) {
+ element = obj;
+ }
+ }
+
+ private Node top;
+ private int size;
+
+ public MyLinkedStack() {
+ top = null;
+ size = 0;
+ }
+
+ public int getSize() {
+ return size;
+ }
+
+ public boolean isEmpty() {
+ return size == 0;
+ }
+
+ public void push(Object e) {
+ Node node = new Node(e, top);
+ top = node;
+ size++;
+ }
+
+ public Object pop() {
+ if (size < 1) {
+ throw new RuntimeException("堆栈为空");
+ }
+ Object obj = top.getData();
+ top = top.getNext();
+ size--;
+ return obj;
+ }
+
+ public Object peek() {
+ if (size < 1) {
+ throw new RuntimeException("堆栈为空");
+ }
+ return top.getData();
+ }
+}
+```
+
+上述 `MyLinkedStack` 类中有两个成员变量,其中 `top` 表示首结点,也就是栈顶元素所在的结点;`size` 指示栈的大小,即栈中数据元素的个数。不难理解,所有的操作均可以在 $O(1)$ 时间内完成。
+
+
+
+## 四、JDK 中的栈实现 Stack
+
+Java 工具包中的 Stack 是继承于 Vector(矢量队列)的,由于 Vector 是通过数组实现的,这就意味着,Stack 也是通过数组实现的,而非链表。当然,我们也可以将 LinkedList 当作栈来使用。
+
+### Stack的继承关系
+
+```java
+java.lang.Object
+ java.util.AbstractCollection
+ java.util.AbstractList
+ java.util.Vector
+ java.util.Stack
+
+public class Stack extends Vector {}
+```
+
+
+
+
+
+## 五、栈应用
+
+栈有一个很重要的应用,在程序设计语言里实现了递归。
+
+### [20. 有效的括号](https://leetcode.cn/problems/valid-parentheses/)
+
+>给定一个只包括 `'('`,`')'`,`'{'`,`'}'`,`'['`,`']'` 的字符串,判断字符串是否有效。
+>
+>有效字符串需满足:
+>
+>1. 左括号必须用相同类型的右括号闭合。
+>2. 左括号必须以正确的顺序闭合。
+>
+>注意空字符串可被认为是有效字符串。
+>
+>```
+>输入: "{[]}"
+>输出: true
+>输入: "([)]"
+>输出: false
+>```
+
+**思路**
+
+左括号进栈,右括号匹配出栈
+
+- 栈先入后出特点恰好与本题括号排序特点一致,即若遇到左括号时,把右括号入栈,遇到右括号时将对应栈顶元素与其对比并出栈,相同的话说明匹配,继续遍历,遍历完所有括号后 `stack` 仍然为空,说明是有效的。
+
+```java
+ public boolean isValid(String s) {
+ if(s.isEmpty()) {
+ return true;
+ }
+ Stack