【2.28】Redis持久化,集群、MySQL锁、动态规划

Redis持久化

Redis的读写操作都是在内存中进行的,所以Redis的性能高。但是当Redis重启,内存中的数据就会丢失,Redis实现数据持久化的方式,主要是以下三种:

  • AOF日志(Append Only File):当执行一条写操作命令,就把该命令以追加的方式写入到文件中,当Redis重启时,会读取该文件记录的命令,然后逐一执行命令来恢复数据。

    • Redis是先执行写操作,然后将命令记录到AOF日志的,这么做有两个好处/风险

      • 避免额外的检查开销:因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。

      • 不会阻塞当前写操作命令的执行:因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。

      • 但是数据可能会丢失: 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险。

      • 可能阻塞其他操作: 由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前命令的执行,但因为 AOF 日志也是在主线程中执行,所以当 Redis 把日志文件写入磁盘的时候,还是会阻塞后续的操作无法执行。

  • AOF写回策略

    • Redis 写入 AOF 日志的过程
      • Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区;
      • 然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;
      • 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。
    • Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。 在 Redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:
      • Always,每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
      • Everysec,每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
      • No,每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。
  • AOF日志过大触发重写机制

    • AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。

    • 在没有使用重写机制前,假设前后执行了「set name xiaolin」和「set name xiaolincoding」这两个命令的话,就会将这两个命令记录到 AOF 文件。在使用重写机制后,就会读取 name 最新的 value(键值对) ,然后用一条 「set name xiaolincoding」命令记录到新的 AOF 文件,之前的第一个命令就没有必要记录了,因为它属于「历史」命令,没有作用了。这样一来,一个键值对在重写日志中只用一条命令就行了。

    • 重写工作完成后,就会将新的 AOF 文件覆盖现有的 AOF 文件,这就相当于压缩了 AOF 文件,使得 AOF 文件体积变小了。

  • 重写AOF日志的过程

    Redis 的重写 AOF 过程是由后台子进程 *bgrewriteaof* 来完成的,这么做可以达到两个好处:

    • 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程;

    • 子进程带有主进程的数据副本,这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。

    • Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」

    • 也就是说,在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:

      • 执行客户端发来的命令;
      • 将执行后的写命令追加到 「AOF 缓冲区」;
      • 将执行后的写命令追加到 「AOF 重写缓冲区」;

      当子进程完成 AOF 重写工作(扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志)后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。

      主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:

      • 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;
      • 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。

      信号函数执行完后,主进程就可以继续像往常一样处理命令了。

  • RDB日志:将某一时刻的内存数据,以二进制的方式写入磁盘;Redis 增加了 RDB 快照。所谓的快照,就是记录某一个瞬间东西,比如当我们给风景拍照时,那一个瞬间的画面和信息就记录到了一张照片。所以,RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。

    • RDB做快照时会阻塞线程吗?

      Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,他们的区别就在于是否在「主线程」里执行:

      • 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程
      • 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞

      Redis 的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。所以执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。

    • RDB在执行快照时,数据能修改吗?

      可以的,执行 bgsave 过程中,Redis 依然可以继续处理操作命令的,也就是数据是能被修改的,关键的技术就在于写时复制技术(Copy-On-Write, COW)。

      • 执行 bgsave 命令的时候,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个。

        此时如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响。如果主线程执行写操作,则被修改的数据会复制一份副本,然后 bgsave 子进程会把该副本数据写入 RDB 文件,在这个过程中,主线程仍然可以直接修改原来的数据。

  • 混合持久化方式:Redis 4.0 新增的方式。RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。AOF 优点是丢失数据少,但是数据恢复速度不快。(重启后Redis逐一执行文件命令)

    • 为了集成了两者的优点, Redis 4.0 提出了混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。

    • 混合持久化工作在 AOF 日志重写过程,当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。

      也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据

    • 混合持久化的好处/缺点

      • 优点:混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。

      • 缺点:AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差;

        兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。

Redis集群

所谓高可用集群(High Availability Cluster,简称HA Cluster),即当前服务器出现故障时,可以将该服务器中的服务、资源、IP等转移到另外一台服务器上,从而满足业务的持续性;这两台或多台服务器构成了服务器高可用集群。

  • 要想设计一个高可用的 Redis 服务,一定要从 Redis 的多服务节点来考虑,比如 Redis 的主从复制、哨兵模式、切片集群。

  • 主从复制:主从复制是 Redis 高可用服务的最基础的保证。实现方案就是将一台 Redis 服务器,同步数据到多台从 Redis 服务器上,即一主多从的模式,且主从服务器之间采用的是「读写分离」的方式。

    • 读写分离:主服务器可以进行读写操作,当发生写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。
    • 异步传输:在主从服务器命令传播阶段,主服务器收到新的写命令后,会发送给从服务器。但是,主服务器并不会等到从服务器实际执行完命令后,再把结果返回给客户端,而是主服务器自己在本地执行完命令后,就会向客户端返回结果了。如果从服务器还没有执行主服务器同步过来的命令,主从服务器间的数据就不一致了。所以,无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。
  • 哨兵模式:在使用 Redis 主从服务的时候,会有一个问题,就是当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复。为了解决这个问题,Redis 增加了哨兵模式(Redis Sentinel),因为哨兵模式做到了可以监控主从服务器,并且提供主从节点故障转移的功能。(一个节点就是一个运行在集群模式下的Redis服务器)

  • 切片集群模式:当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群(Redis Cluster )方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。

    • Redis Cluster采用哈希槽的方式来处理数据和节点之间的关系

    • 一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区。

      每个键值对都会根据它的 key,被映射到一个哈希槽中:

      • 根据键值对的 key,按照 CRC16 算法计算一个 16 bit 的值。
      • 再用 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。
    • 之后再通过平均分配/手动分配的方式,将哈希槽映射到节点上去:

      • 平均分配: 在使用 cluster create 命令创建 Redis 集群时,Redis 会自动把所有哈希槽平均分布到集群节点上。
      • 手动分配: 可以使用 cluster meet 命令手动建立节点间的连接,组成集群,再使用 cluster addslots 命令,指定每个节点上的哈希槽个数。在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。
集群脑裂导致数据丢失怎么办?
  • 在 Redis 中,集群脑裂产生数据丢失的现象

    • 由于主节点发生网络问题,集群节点之间失去联系,导致主从数据不同步;哨兵发现主节点失联了,它就认为主节点挂了(但实际上主节点正常运行,只是网络出问题了),重新平衡选举,就会产生两个主服务(产生集群脑裂)。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了。
  • 集群脑裂的解决方案

    • **当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。**具体实现方法如下:

    • 可以把min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的写请求了。

    • 即使原主库是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从库进行同步,自然也就无法和从库进行 ACK 确认了。这样一来,min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足,原主库就会被限制接收客户端写请求,客户端也就不能在原主库中写入新数据了。等到新主库上线时,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。

MySQL是怎么加锁的?

  • 会加行级锁的SQL语句:
    • 普通的SELECT默认是不加锁的,属于快照读,是使用MVCC的方式实现的。但是可以在查询时对记录加行级锁,查询会加锁的语句称为锁定读
      • 锁定读的语句必须在事务中,因为当事务提交了,锁就会被释放。
    • update 和 delete 操作都会加行级锁,且锁的类型都是独占锁(X型锁)
MySQL是怎么加行级锁的?
  • 加锁的对象是索引,加锁的基本单位是 next-key lock,它是由记录锁和间隙锁组合而成的,next-key lock 是前开后闭区间,而间隙锁是前开后开区间

  • 在能使用记录锁或者间隙锁就能避免幻读现象的场景下, next-key lock 就会退化成退化成记录锁或间隙锁

  • 唯一索引等值查询(主键索引为例)

    • 当查询的记录是「存在」的,在索引树上定位到这一条记录后,将该记录的索引中的 next-key lock 会退化成「记录锁」
    • 当查询的记录是「不存在」的,在索引树找到第一条大于该查询记录的记录后,将该记录的索引中的 next-key lock 会退化成「间隙锁」
  • 唯一索引范围查询

    • 当唯一索引进行范围查询时,会对每一个扫描到的索引加 next-key 锁,然后如果遇到下面这些情况,会退化成记录锁或者间隙锁
      • 情况一:针对「大于等于」的范围查询,因为存在等值查询的条件,那么如果等值查询的记录是存在于表中,那么该记录的索引中的 next-key 锁会退化成记录锁
      • 情况二:在针对「小于或者小于等于」的唯一索引(主键索引)范围查询时,存在这两种情况会将索引的 next-key 锁会退化成间隙锁的:
        • 当条件值的记录「不在」表中时,那么不管是「小于」还是「小于等于」条件的范围查询,扫描到终止范围查询的记录时,该记录的主键索引中的 next-key 锁会退化成间隙锁,其他扫描到的记录,都是在这些记录的主键索引上加 next-key 锁。
        • 当条件值的记录「在」表中时:
          • 如果是「小于」条件的范围查询,扫描到终止范围查询的记录时,该记录的主键索引中的 next-key 锁会退化成间隙锁,其他扫描到的记录,都是在这些记录的主键索引上,加 next-key 锁。
          • 如果是「小于等于」条件的范围查询,扫描到终止范围查询的记录时,该记录的主键索引中的 next-key 锁「不会」退化成间隙锁,其他扫描到的记录,都是在这些记录的主键索引上加 next-key 锁。
  • 非唯一索引等值查询

    • 当我们用非唯一索引进行等值查询的时候,因为存在两个索引,一个是主键索引,一个是非唯一索引(二级索引),所以在加锁时,同时会对这两个索引都加锁,但是对主键索引加锁的时候,只有满足查询条件的记录才会对它们的主键索引加锁

      针对非唯一索引等值查询时,查询的记录存不存在,加锁的规则也会不同:

      • 当查询的记录「存在」时,由于不是唯一索引,所以肯定存在索引值相同的记录,于是非唯一索引等值查询的过程是一个扫描的过程,直到扫描到第一个不符合条件的二级索引记录就停止扫描,然后在扫描的过程中,对扫描到的二级索引记录加的是 next-key 锁,而对于第一个不符合条件的二级索引记录,该二级索引的 next-key 锁会退化成间隙锁。同时,在符合查询条件的记录的主键索引上加记录锁
      • 当查询的记录「不存在」时,扫描到第一条不符合条件的二级索引记录,该二级索引的 next-key 锁会退化成间隙锁。因为不存在满足查询条件的记录,所以不会对主键索引加锁
  • 非唯一索引范围查询

    非唯一索引和主键索引的范围查询的加锁也有所不同,不同之处在于非唯一索引范围查询,索引的 next-key lock 不会有退化为间隙锁和记录锁的情况,也就是非唯一索引进行范围查询时,对二级索引记录加锁都是加 next-key 锁。

  • 没有加索引的查询

    如果锁定读查询语句,没有使用索引列作为查询条件,或者查询语句没有走索引查询,导致扫描是全表扫描。那么,每一条记录的索引上都会加 next-key 锁,这样就相当于锁住的全表,这时如果其他事务对该表进行增、删、改操作的时候,都会被阻塞

update没加索引会锁全表?

当我们执行 update 语句时,实际上是会对记录加独占锁(X 锁)的,如果其他事务对持有独占锁的记录进行修改时是会被阻塞的。另外,这个锁并不是执行完 update 语句就会释放的,而是会等事务结束时才会释放。

  • 在 update 语句的 where 条件没有使用索引,就会全表扫描,于是就会对所有记录加上 next-key 锁(记录锁 + 间隙锁),相当于把整个表锁住了

  • 那 update 语句的 where 带上索引就能避免全表记录加锁了吗?并不是。

    • 关键还得看这条语句在执行过程种,优化器最终选择的是索引扫描,还是全表扫描,如果走了全表扫描,就会对全表的记录加锁了

MySQL 记录锁+间隙锁可以防止删除操作而导致的幻读吗?

  • 在 MySQL 的可重复读隔离级别下,针对当前读的语句会对索引加记录锁+间隙锁,这样可以避免其他事务执行增、删、改时导致幻读的问题。

  • 在线上在执行 update、delete、select … for update 等具有加锁性质的语句,一定要检查语句是否走了索引,如果是全表扫描的话,会对每一个索引加 next-key 锁,相当于把整个表锁住了

  • 1035. 不相交的线 - 力扣(LeetCode)

    本题的本质在与求nums1与nums2的最长公共子序列。所以与上一题一致。

  • 53. 最大子数组和 - 力扣(LeetCode)

    1. 确定dp数组(dp table)以及下标的含义:dp[i]:包括下标i(以nums[i]为结尾)的最大连续子序列和为dp[i]

    2. 确定递推公式:dp[i]只有两个方向可以推出来:

      • dp[i - 1] + nums[i],即:nums[i]加入当前连续子序列和

      • nums[i],即:从头开始计算当前连续子序列和

      一定是取最大的,所以dp[i] = max(dp[i - 1] + nums[i], nums[i]);

    3. dp数组如何初始化:从递推公式可以看出来dp[i]是依赖于dp[i - 1]的状态,dp[0]就是递推公式的基础。dp[0]应为nums[0]即dp[0] = nums[0]。

    4. 确定遍历顺序:递推公式中dp[i]依赖于dp[i - 1]的状态,需要从前向后遍历。

    5. 举例推导dp数组

      class Solution {
          public int maxSubArray(int[] nums) {
              int ans = nums[0] ;
              int n = nums.length;
              int [] dp = new int [n + 1];
              dp[0] = nums[0];
              for(int i = 1 ; i < n ; i ++){
                  dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
                  if(ans < dp[i]) ans = dp[i];
                  //System.out.println("nums[" + i + "]及之前的连续子数组之和为" + dp[i]);
              }
              return ans;
          }
      }
      

编辑距离问题

题目关键点
392. 判断子序列 - 力扣(LeetCode)dp数组由两个方向推导而来
115. 不同的子序列 - 力扣(LeetCode)dp数组由两个方向推导而来
  • 题目:

    • 给定字符串 s 和 t ,判断 s 是否为 t 的子序列。字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。
  • 使用动规五步法:

  1. 确定dp数组(dp table)以及下标的含义:

    dp[i][j] 表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]。注意这里是判断s是否为t的子序列。即t的长度是大于等于s的。

  2. 确定递推公式

​ 在确定递推公式的时候,首先要考虑如下两种操作,整理如下:

  • if (s[i - 1] == t[j - 1])

    • t中找到了一个字符在s中也出现了
    • if (s[i - 1] == t[j - 1]),那么dp[i][j] = dp[i - 1][j - 1] + 1;,因为找到了一个相同的字符,相同子序列长度自然要在dp[i-1][j-1]的基础上加1(如果不理解,在回看一下dp[i][j]的定义
  • if (s[i - 1] != t[j - 1])

    • 相当于t要删除元素,继续匹配

    • if (s[i - 1] != t[j - 1]),此时相当于t要删除元素,t如果把当前元素t[j - 1]删除,那么dp[i][j] 的数值就是 看s[i - 1]与 t[j - 2]的比较结果了,即:dp[i][j] = dp[i][j - 1];

​ 和 1143.最长公共子序列 (opens new window)的递推公式基本那就是一样的,区别就是 本题 如果删元素一定是字符串t,而 1143.最长公共子序列 是两个字符串都可以删元素。

  1. dp数组如何初始化

    从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] dp[i][j - 1],所以dp[0][0]和dp[i][0]是一定要初始化的。这里大家已经可以发现,在定义dp[i][j]含义的时候为什么要表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]。因为这样的定义在dp二维矩阵中可以留出初始化的区间。

    dp[i][0] 表示以下标i-1为结尾的字符串,与空字符串的相同子序列长度,所以为0. dp[0][j]同理。

  2. 确定遍历顺序:同理从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1],那么遍历顺序也应该是从上到下,从左到右。

  3. 举例推导dp数组

    class Solution {
        public boolean isSubsequence(String s, String t) {
            int ans = 0;
            int m = s.length();
            int n = t.length();
            int [][] dp = new int [m + 1][n + 1];
            for(int i = 1 ; i <= m ; i ++){
                for(int j = 1 ; j <= n ; j ++){
                    char s1 = s.charAt(i - 1);
                    char t1 = t.charAt(j - 1);
                    if(s1 == t1) dp[i][j] = dp[i - 1][j - 1] + 1;
                    else dp[i][j] = dp[i][j - 1];
                    
                    //System.out.println("以下标" + (i - 1) + "为结尾的字符串s,和以下标" + (j - 1) + "为结尾的字符串t,相同子序列的长度为" + dp[i][j]);
                }
            }
            return dp[m][n] == m ? true : false;
        }
    }
    
  • 115. 不同的子序列 - 力扣(LeetCode)

    1. 确定dp数组(dp table)以及下标的含义

      dp[i][j]:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]

    2. 确定递推公式:这一类问题,基本是要分析两种情况

      • s[i - 1] 与 t[j - 1]相等

        • 当s[i - 1] 与 t[j - 1]相等时,dp[i][j]可以有两部分组成。

          一部分是用s[i - 1]来匹配,那么个数为dp[i - 1][j - 1]。即不需要考虑当前s子串和t子串的最后一位字母,所以只需要 dp[i-1][j-1]

          一部分是不用s[i - 1]来匹配,个数为dp[i - 1][j]

      • s[i - 1] 与 t[j - 1] 不相等

        当s[i - 1] 与 t[j - 1]不相等时,dp[i][j]只有一部分组成,不用s[i - 1]来匹配(就是模拟在s中删除这个元素),即:dp[i - 1][j]

        所以递推公式为:dp[i][j] = dp[i - 1][j];

    为什么还要考虑 不用s[i - 1]来匹配,都相同了指定要匹配啊

    例如: s:bagg 和 t:bag ,s[3] 和 t[2]是相同的,但是字符串s也可以不用s[3]来匹配,即用s[0]s[1]s[2]组成的bag。

    当然也可以用s[3]来匹配,即:s[0]s[1]s[3]组成的bag。

    所以当s[i - 1] 与 t[j - 1]相等时,dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];

    为什么只考虑 “不用s[i - 1]来匹配” 这种情况, 不考虑 “不用t[j - 1]来匹配” 的情况呢。

    这里大家要明确,我们求的是 s 中有多少个 t,而不是 求t中有多少个s,所以只考虑 s中删除元素的情况,即 不用s[i - 1]来匹配 的情况。

    1. dp数组如何初始化

      从递推公式dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]; 和 dp[i][j] = dp[i - 1][j]; 中可以看出dp[i][j] 是从上方和左上方推导而来,如图:,那么dp[i][0]dp[0][j]是一定要初始化的。

      • dp[i][0] 表示:以i-1为结尾的s可以随便删除元素,出现空字符串的个数。那么dp[i][0]一定都是1,因为也就是把以i-1为结尾的s,删除所有元素,出现空字符串的个数就是1。

      • dp[0][j]:空字符串s可以随便删除元素,出现以j-1为结尾的字符串t的个数。那么dp[0][j]一定都是0,s如论如何也变成不了t。

      • dp[0][0]应该是1,空字符串s,可以删除0个元素,变成空字符串t。

    2. 遍历的时候一定是从上到下,从左到右,这样保证dp[i][j]可以根据之前计算出来的数值进行计算。

    3. 举例推导dp

      class Solution {
          public int numDistinct(String s, String t) {
              int m = s.length();
              int n = t.length();
              int [][] dp = new int [m + 1][n + 1];
              //dp数组的初始化
              for(int i = 1 ; i <= m ; i ++){
                  dp[i][0] = 1;
              }
              for(int i = 1 ; i <= n ; i ++){
                  dp[0][i] = 0;
              }
              dp[0][0] = 1;
              for(int i = 1 ; i <= m ; i ++){
                  char s1 = s.charAt(i - 1);
                  for(int j = 1 ; j <= n ; j ++){
                      char t1 = t.charAt(j - 1);
                      //s1 == t1 存在两种情况,不用s[i - 1]匹配 + 用s[i - 1]匹配
                      if(s1 == t1) dp[i][j] = dp[i - 1][j] + dp[i - 1][j - 1];
                      //s1 != t1 只有一种情况,不用s[i - 1]匹配。
                      else dp[i][j] = dp[i - 1][j];
      
                     // System.out.println("以s[" + (i - 1) + "]结尾的字符串中,以t[" + (j - 1) +"]结尾的子序列的个数为" + dp[i][j]);
                  }
              }
              return dp[m][n];
          }
      }
      
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Sivan_Xin

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值