我的数据结构和算法笔记存档

206 阅读10分钟

数据结构

数组

  1. JavaScript并不限制数组的数据类型,因此数组可以存放任意类型的数据
  2. 当一个数组存放相同类型的数据时,数组在内存空间上是连续存储的。
  3. JavaScript提供直接操作原数组来删除数组元素的方法,例如栈方法pop()、队列方法shift()splice()方法等。
  4. JavaScript中数组的长度是动态变化的,即新增数据会自动增加。也可以通过减少数组长度来丢弃不需要的数据。

二分法

  1. 面对有序数组的查找元素问题,通常可以考虑二分法。
  2. 二分法的基本思想:把数组一分为二,把目标值和中间的那个元素进行比较,再不断缩小目标值的存在区间,最终当mid值等于目标值时,返回mid元素的下标。
  3. 运用二分法的关键在于确定循环区间的边界:循环区间到底是左闭右开[left, right),还是左闭右闭[left, right]?不同的边界情况下,对于循环结束、区间变化的处理都是不一样的。一旦确定好循环区间边界,接下来的逻辑都要跟着这个区间边界走。
  4. 确定mid值时,仅仅写(left + right) / 2可能会出现数字过大溢出的情况(比如leftright都大于1/2的MAX_VALUE时,数字会溢出),可以采取更优化的写法left + (right - left)/2

双指针法

双指针可以通过一个for循环来完成两个for循环遍历数组的工作,降低时间复杂度

快慢双指针

  1. 面对一些数组的筛选问题时(比如删除重复项),可以考虑快慢双指针法。
  2. 快指针和慢指针可以理解为一个排雷的过程,快指针在前面排雷,慢指针负责守护安全区。

首尾双指针

  1. 面对一些数组的排序问题时,可以考虑首尾双指针法。
  2. 利用首尾元素的比较进行排序。

滑动窗口

  1. 滑动窗口法的本质还是双指针法,但是滑动窗口得到的是数组的一个连续区间

  2. 滑动窗口法的基本思想是根据某个条件来不断改变数组的子序列,就像一个在滑动的窗口一样。

  3. 使用滑动窗口法的关键是要弄清楚:

  • 什么时候要扩大窗口(改变右指针)?

  • 什么时候要缩小窗口(改变左指针)?

  • 子序列改变时是否还要进行什么操作(根据需求而定)?

模拟行为

  1. 给一个数组进行模拟成别的形式(例如矩阵),或者给出模拟后的形式,要求转化回数组。
  2. 该类题目没有什么算法,最关键是要确定好边界。例如螺旋矩阵题目,就可以通过不断缩小上下左右四条边界来框定数组和矩阵的关系。边界关系一旦梳理清楚,代码就水到渠成了。

链表

  1. 链表是一种通过指针把各个节点串联在一起的线性数据结构,链表的每个节点包括数据域和指针域,指针域存放着指向下一个节点的指针(next)。

  2. 链表的节点在内存中并不是连续存储的,只是链表能通过指针把它们串联起来。

也正因为如此,对数据进行新增和删除的操作,和数组相比(时间复杂度为O(n)),链表的新增和删除效率更高,只要修改指针的指向即可完成(时间复杂度为O(1))。

与之相对的,链表的查询效率就不及数组了。

  1. 链表有单链表和双链表,其中双链表的节点指针域不仅存放next,还存放了指向前一个结点的指针(prev)。

虚拟头节点

  1. 涉及对链表的节点进行操作的问题(比如移除链表元素、删除倒数第n个节点、两两交换节点等等),通常要定义一个虚拟头节点。
  2. 虚拟头节点的作用是把头节点看成一个普通节点,免除了额外考虑头节点的情况,更便于我们对节点进行操作。

双指针法

  1. 链表中的双指针法通常指的是快慢指针法,涉及需要对链表进行遍历再解决的问题(比如反转链表、求链表的相交节点、环形链表问题等等),都可以考虑双指针。
  2. 对于环形链表求环入口的问题,要考虑清楚两个指针的所走路程之间的关系、以及所走路程和所求未知量的关系。

字符串

JavaScript中的字符串是不支持原地修改的,可以用[]来对字符串进行类似数组的访问,但是不能直接修改字符串,因此如果要对字符串中的字符进行操作,需要先用split("")Array.from()得到字符串的数组形式。

常用库函数

  1. string.replace方法

语法:string.replace(regexp/substr, replacement);

利用正则表达式来进行替换,注意不是原地替换的,而是返回了一个新字符串。

  1. string.slice方法和string.subString方法

语法:string.slice(start, end);

都是用于提取字符串中包括start但不包括end的子字符串,如果只传入start,则默认endstring.length

slice方法的参数可以接收负数,表示倒数第几个字符;subString方法只能接收正整数。

subString方法的传入参数,若start < end,则会默认交换二者。

  1. Array.reverse()

反转数组元素。

操作字符串(反转、替换)

双指针法

  1. 需要处理字符串的某个区间子串时(比如反转字符串、按规律反转子串、替换子串等问题),都可以考虑双指针法。
  2. 使用双指针法解决字符串(或数组)的替换问题时,可以先扩容,再由后往前进行替换,如果从前往后替换,修改元素时需要再额外移动元素。

反转逻辑

  1. 对于字符串的反转,除了使用reverse方法、临时变量法以外,还可以使用数组的解构赋值法
while(++left < --right){
 [s[left], s[right]] = [s[right], s[left]];
}
  1. 当需要按规律反转子串时,可以利用for循环的表达式,让循环变量按照一定的规律改变。

  2. 反转题目的逻辑思路还包括:先局部反转再整体反转先整体反转再局部反转

字符串匹配(KMP算法)

KMP的作用

当遇到字符串匹配问题时,KMP可以帮助我们避免在匹配不成功时从头进行匹配,因为KMP算法会借助next数组,利用之前已经匹配成功的文本信息,跳转到相应位置进行匹配

KMP是怎么做到的

前置概念
  1. 文本串:母串

模式串:子串,和文本串的某一部分进行匹配

  1. 前缀:从第一个字符开头的连续子串,不包括最后一个字符(包前不包后)

后缀:以最后一个字符结尾的连续子串,不包括第一个字符(包后不包前)

注意在循环过程中,前缀和后缀是随着循环变量的指针位置在不断变化的。

例如字符串 “aabcdde” ,当指针走到c时,前缀是aab,后缀是abc

  1. 最长相等前后缀:指针走到某个位置时,相等的前缀和后缀能达到的最长长度。

例如字符串 “aabcaae” ,当指针走到c时,后缀可以是cbcabc,前缀可以是aaaaab,最长相等前后缀长度为0.

当指针走到第四个a时,前缀和后缀都可以是aa,此时最长相等前后缀长度为2.

  1. 前缀表:记录指针走到每一个字符时的最长相等前后缀数值。

  2. next数组:可以是前缀表或者前缀表的变形(前缀表统一减一或前缀表向右移一位)

具体原理

因为前缀表记录的是指针走到每一个字符时的最长相等前后缀数值,所以如果这个数值大于0,那么截止到指针当前位置的字符串必定是相同子串 | 无法匹配字符 | 相同子串的形式,如果这个数值等于0,则说明指针当前位置的字符处于无法匹配字符这个区间里。

所以当我们遇到不匹配的字符时,既然当前字符已经匹配失败,就要看它的前一位的最长相等前后缀数值。

假设这个数值等于2,那么说明无法匹配字符区间是从模式串的第三个字符开始的,因此只要跳转到这个位置继续匹配即可。

代码实现KMP算法

构建next数组
  1. 从原理中可以知道KMP算法借助的实际上是前缀表,而next数组就是前缀表或者前缀表的变形,next数组的形式并不影响KMP的原理,只是影响KMP的代码实现。

比如,当next数组就是前缀表时,匹配失败时要看前一位的最长相等前后缀数值;

当next数组是前缀表向右移一位时(初始无法匹配字符的next数组值都会被赋值为-1),匹配失败时就看当前位的最长相等前后缀数值。

  1. 接下来的实现方法采取前缀表右移的next数组形式
/*
1. 初始化:
 i:循环变量,遍历模式字符串,代表最长相等前后缀的后缀末尾
 j:最长相等前后缀的前缀末尾,同时代表最长相等前后缀的长度(因为前缀末尾之前的都是匹配成功的)

 不同的构建方式中,比较和跳转的方式也不同
   若构建next[0]为-1的数组(前缀表向右移一位),遇到不匹配时j要直接跳转到next[j],则j初始值为-1, 比较s[i]和s[j+1]
   若直接以前缀表构建数组,则next[0]为0,遇到不匹配时j跳转到next[j-1],则j初始值为0, 比较s[i]和s[j]
 但是i的初始值都是1

 此处以第一种方式为例,比较s[i]和s[j+1]来构建

2. 处理前后缀不相同的情况
 j要向前回退(注意回退是一个循环过程),回退跳转规则见上

3. 处理前后缀相同的情况
 前后缀匹配,把j赋给next[i]。注意这一步对j进行操作以后,会影响比较量,所以要放在处理不相同的情况之后
*/
function getNext(s) {
   const next = [];
   let j = -1;
   next[0] = j;
   for(let i = 1;i < s.length;i++){
       while(j >= 0 && s[i] !== s[j+1]){
           j = next[j];
       }
       if(s[i] === s[j+1]){
           j++;
       }
       next[i] = j;
    }
   return next;
}
利用next数组进行匹配
  1. 初始化:

i:文本串的循环变量,注意这里和getNext()处的区别,next[0]是初始化时就确定的所以i从1开始比较,但是对于文本串要从0开始

j:和构建next数组里的j意义相同,因此初始化和跳转方式也应该和构建next数组时一致

  1. 处理前后缀不相同和相同的情况,逻辑和getNext()相同

  2. 当j到了模式串的末尾位置时,匹配结束,返回模式串在文本串中的开头位置,由于i是最长相等前后缀的后缀末尾,j是最长相等前后缀的长度,所以返回i - j即可

哈希表

  1. 哈希表是一种根据关键码的值(Key value)来进行访问的数据结构。
  2. 哈希表一般用于解决快速查找集合中的元素的问题,但是要注意哈希表常常是牺牲空间来换取时间。
  3. 常见的哈希表有数组、Set、Map

数组

  1. 当存储连续数值时,浏览器引擎会对数组的内存进行优化,此时数组的查找效率是高于Map的,因此如果所存储数据为连续值,要优先考虑数组
  2. 当查找范围的长度固定时(比如26个字母),也可以考虑数组。
  3. 比如383 赎金信242 有效的字母异位词 中,查找范围是字母表(固定的26个字母),在数组中的表现形式是连续的ASII值,此时使用数组的查找效率更高。

Set

  1. Set具有去重性的特点,因此遇到需要排除重复元素的题目,可以考虑Set。
  2. 比如求交集、并集、检查是否出现循环等等。

Map

  1. Map里的值以键-值对的形式存储,因此如果题目对返回值有其他要求(比如要得到数组中可行结果的下标),需要我们额外存储值时,可以考虑Map。

  2. JavaScript中,同样空间的条件下,Map的空间利用率高于数组。

  3. 几数之和——哈希法和双指针法

  • 两数之和:求解nums数组中是否存在两个数,其和为target值,返回这两个数的下标。

由于要返回下标,因此使用哈希法快速查找nums中是否存在target - nums[i]更得当,如果要求返回是否存在,可以使用双指针法。

  • 三数、四数之和:要求返回一个不重复的三元组/四元组。

此时用哈希法会使得去重非常困难,可以考虑双指针法。

双指针法:数组排序,使用循环变量来固定一个值(如果是三数之和,则固定nums[i],四数之和则嵌套两层for循环,固定nums[i] + nums[k]),移动left和right指针,找到当前固定值的所有可能组合。