JS/TS数据结构---链表/哈希表
1.链表
1.1 认识链表
链表和数组
链表和数组一样,可以用于存储一系列的元素,但是链表和数组的实现机制完全不同。
数组
- 存储多个元素,数组(或列表)可能是最常用的数据结构。
- 几乎每一种编程语言都有默认实现数组结构,提供了一个便利的
[]
语法来访问数组元素。 - 数组缺点:
- 数组的创建需要申请一段连续的内存空间(一整块内存),并且大小是固定的,当前数组不能满足容量需求时,需要扩容。 (一般情况下是申请一个更大的数组,比如 2 倍,然后将原数组中的元素复制过去)
- 在数组开头或中间位置插入/删除数据的成本很高,需要进行大量元素的位移。
链表
- 存储多个元素,另外一个选择就是使用链表。
- 不同于数组,链表中的元素在内存中不必是连续的空间。
- 链表的每个元素由一个存储元素本身的节点和一个指向下一个元素的引用(有些语言称为指针)组成。
- 链表优点:
- 内存空间不必是连续的,可以充分利用计算机的内存,实现灵活的内存动态管理。
- 链表不必在创建时就确定大小,并且大小可以无限延伸下去。
- 链表在插入和删除数据时,时间复杂度可以达到 O(1),相对数组效率高很多。
- 链表缺点:
- 访问任何一个位置的元素时,需要从头开始访问。(无法跳过第一个元素访问任何一个元素)
- 无法通过下标值直接访问元素,需要从头开始一个个访问,直到找到对应的元素。
- 虽然可以轻松地到达下一个节点,但是回到前一个节点是很难的。
如何选择
- 频繁在中间或前面插入数据时选择链表
- 需要使用下标去修改获取数据时选择数组
1.2 单向链表
单向链表类似于火车,有一个火车头,火车头会连接一个节点,节点上有乘客,并且这个节点会连接下一个节点,以此类推。
链表的火车结构:
img
链表的数据结构
img
给火车加上数据后的结构
img
链表中的常见操作
常见操作可以按增删改查分类,剩下的几个都是获取链表信息的方法
append(element)
向链表尾部添加一个新的项。insert(position, element)
向链表的特定位置插入一个新的项。get(position)
获取对应位置的元素。indexOf(element)
返回元素在链表中的索引。如果链表中没有该元素就返回-1。update(position, element)
修改某个位置的元素。removeAt(position)
从链表的特定位置移除一项。remove(element)
从链表中移除一项。isEmpty()
如果链表中不包含任何元素,返回 trun,如果链表长度大于 0 则返回 false。size()
返回链表包含的元素个数,与数组的 length 属性类似。toString()
由于链表项使用了 Node 类,就需要重写继承自 JavaScript 对象默认的 toString 方法,让其只输出元素的值。
完整实现
// 封装链表结构
// 封装链表节点类
export class Node{
constructor(data) {
this.data = data
this.next = null
}
}
// 单向链表结构的封装
export class LinkedList{
constructor() {
// 链表头节点,初始为 null
this.head = null
// 初始链表长度为 0
this.length = 0
}
// ------------ 链表的常见操作 ------------ //
// append(data) 往链表尾部追加数据
append(data) {
// 1、创建新节点
const newNode = new Node(data)
// 2、追加新节点
if (this.length === 0) {
// 链表长度为 0 时,直接修改头指针head即可
this.head = newNode
} else {
// 链表长度大于 0 时,在尾节点后面添加新节点
// 先取得链表第一个节点,之后循环遍历至尾节点
let current = this.head
// 当current.next!=null时表示不是尾节点
while (current.next) {
current = current.next
}
// 尾节点的 next 指向新节点
current.next = newNode
}
// 3、追加完新节点后,链表长度 + 1
this.length++
}
// insert(position, data) 在指定位置(position)插入节点
insert(position, data) {
// position 新插入节点的位置
// position = 0 表示新插入后是第一个节点
// position = 1 表示新插入后是第二个节点,以此类推
// 1、对 position 进行越界判断,不能小于 0 或大于链表长度
if (position < 0 || position > this.length) return false
// 2、创建新节点
const newNode = new Node(data)
// 3、插入节点
if (position === 0) {
// position = 0 即新插入节点为第一个节点的情况
// 顺序很重要,先让新节点指向原来的第一个节点,之后修改头指针指向新节点
// 让新节点的 next 指向 原来的第一个节点,即 head
newNode.next = this.head
// head 赋值为 newNode
this.head = newNode
} else {
// 0 < position <= length 的情况
// 初始化一些状态变量
let index = 0 // 遍历索引初始化为 0
let current = this.head // 遍历的当前节点初始化为 head
let previous = null // 遍历的的上一节点初始化为 null
// 在 0 ~ position 之间遍历,不断地更新 current 和 previous
// 直到找到要插入的位置
while (index++ < position) {
previous = current
current = current.next
}
// 在当前节点和当前节点的上一节点之间插入新节点,即改变它们的指向
newNode.next = current
previous.next = newNode
}
// 4、追加完新节点后,链表长度 + 1
this.length++
// 5、返回新添加的节点,方便其他操作
return newNode
}
// getData(position) 获取指定位置的 data
getData(position) {
// 1、position越界判断
if (position < 0 || position >= this.length) return null
// 2、获取指定 position 的节点
let index = 0
let current = this.head
while (index++ < position) {
current = current.next
}
// 3、返回相应节点的 data
return current.data
}
// indexOf(data) 返回指定 data 的 index,如果没有,返回 -1。
indexOf(data) {
// 1、定义遍历变量
let index = 0
let current = this.head
// 2、遍历比较链表中数据
while (current) {
if (current.data === data) {
// 找到相应数据,返回索引
return index
}
current = current.next
index++
}
// 未找到相应数据,返回-1
return -1
}
// update(position, data) 修改指定位置节点的 data
update(position, data) {
// 涉及到 position 都要进行越界判断
// 1、position越界判断
if (position < 0 || position >= this.length) return false
// 2、循环遍历,找到指定 position 的节点
let index = 0
let current = this.head
while (index++ < position) {
current = current.next
}
// 3、修改相应节点的 data
current.data = data
// 4、返回指定 position 的节点,方便其他操作
return current
}
// removeAt(position) 删除指定位置的节点,并返回删除的那个节点
removeAt(position) {
// 1、position越界判断
if (position < 0 || position >= this.length) return null
// 2、删除指定 position 节点
let current = this.head
if (position === 0) {
// position = 0 的情况
this.head = this.head.next
} else {
// position > 0 的情况
// 在 0 ~ position 之间遍历,不断地更新 current 和 previous
// 直到找到要删除的位置
let index = 0
let previous = null
while (index++ < position) {
previous = current
current = current.next
}
// 让上一节点的 next 指向当前的节点的 next,相当于删除了当前节点。
previous.next = current.next
}
// 3、更新链表长度 -1
this.length--
// 4、返回被删除的节点,方便其他操作
return current
}
// remove(data) 删除指定 data 的节点,并返回删除的那个节点
remove(data) {
return this.removeAt(this.indexOf(data))
}
// isEmpty() 判断链表是否为空
isEmpty() {
return this.length === 0
}
// size() 获取链表的长度
size() {
return this.length
}
// toString() 链表数据以字符串形式返回
toString() {
let current = this.head
let resultString = ''
// 遍历所有的节点,拼接为字符串,直到尾节点(值为null)
while (current) {
resultString += current.data + ' '
current = current.next
}
return resultString
}
}
1.3 单向链表和双向链表
单向链表
- 只能从头遍历到尾或者从尾遍历到头(一般从头到尾)。
- 链表相连的过程是单向的,实现原理是上一个节点中有指向下一个节点的引用。
- 单向链表有一个比较明显的缺点:可以轻松到达下一个节点,但回到前一个节点很难,在实际开发中, 经常会遇到需要回到上一个节点的情双向链表
双向链表
- 既可以从头遍历到尾,也可以从尾遍历到头。
- 链表相连的过程是双向的。实现原理是一个节点既有向前连接的引用,也有一个向后连接的引用。
- 双向链表可以有效的解决单向链表存在的问题。
- 双向链表缺点:
- 每次在插入或删除某个节点时,都需要处理四个引用,而不是两个,实现起来会困难些。
- 相对于单向链表,所占内存空间更大一些。
- 但是,相对于双向链表的便利性而言,这些缺点微不足道。
1.4 双向链表结构
- 双向链表不仅有 head 指针指向第一个节点,而且有 tail 指针指向最后一个节点。
- 每一个节点由三部分组成:item 储存数据、prev 指向前一个节点、next 指向后一个节点。
- 双向链表的第一个节点的 prev 指向 null。
- 双向链表的最后一个节点的 next 指向 null。
双向链表常见的操作
-
append(element)
向链表尾部追加一个新元素。 -
insert(position, element)
向链表的指定位置插入一个新元素。 -
getElement(position)
获取指定位置的元素。 -
indexOf(element)
返回元素在链表中的索引。如果链表中没有该元素就返回 -1。 -
update(position, element)
修改指定位置上的元素。 -
removeAt(position)
从链表中的删除指定位置的元素。 -
remove(element)
从链表删除指定的元素。 -
isEmpty()
如果链表中不包含任何元素,返回trun
,如果链表长度大于 0 则返回false
。 -
size()
返回链表包含的元素个数,与数组的length
属性类似。 -
toString()
由于链表项使用了 Node 类,就需要重写继承自 JavaScript 对象默认的toString
方法,让其只输出元素的值。 -
forwardString()
返回正向遍历节点字符串形式。 -
backwordString()
返回反向遍历的节点的字符串形式。完整实现
import { LinkedList, Node } from '../LinkedList/linkedList' // 双向链表结构的封装 // 双向链表的节点类(继承单向链表的节点类) class DoublyNode extends Node { constructor(element) { super(element) this.prev = null } } // 双向链表类(继承单向链表类) export class DoublyLinkedList extends LinkedList { constructor() { super() this.tail = null } // ------------ 链表的常见操作 ------------ // // append(element) 往双向链表尾部追加一个新的元素 // 重写 append() append(data) { // 1、创建新节点 const newNode = new DoublyNode(data) // 2、追加新节点 if (this.length === 0) { // 链表长度为 0 时,直接修改头尾指针即可 this.head = newNode this.tail = newNode } else { // !!跟单向链表不同,不用通过循环找到最后一个节点,因为有尾指针 // 当添加一个节点时,涉及3个指针要修改 // 1、原来的尾节点的next指针要指向新节点 // 2、新节点的prev指针要指向原来的尾节点 // 3、尾指针要指向新节点 this.tail.next = newNode newNode.prev = this.tail this.tail = newNode } // 3、追加完新节点后,链表长度 + 1 this.length++ } // insert(position, data) 插入元素 // 重写 insert() insert(position, data) { // 1、对 position 进行越界判断,不能小于 0 或大于链表长度 if (position < 0 || position > this.length) return false // 2、创建新的双向链表节点 const newNode = new DoublyNode(data) // 3、插入节点,有3钟情况要考虑 // 3.1 在第 0 个位置插入 if (position === 0) { if (this.length === 0) { // 链表长度不为 0 时,直接修改头尾指针即可 this.head = newNode this.tail = newNode } else { // 链表长度为 0 时,涉及3个指针要修改,要注意修改次序 // 1、新节点的next指针要指向原来的头节点 // 2、原来的头节点的prev指针要指向新节点 // 3、头指针要指向新节点 newNode.next = this.head this.head.prev = newNode this.head = newNode } } else if (position === this.length) { // 3.2 在最后一个位置插入,涉及3个指针要修改,要注意修改次序 // 1、新节点的prev指针要指向原来的尾节点 // 2、原来的尾节点的next指针要指向新节点 // 3、尾指针要指向新节点 newNode.prev = this.tail this.tail.next = newNode this.tail = newNode } else { // 3.3 在中间位置插入,对应 0 < position < length 的情况 // 初始化一些状态变量 // 与单向链表不同的是,不需要previous变量保存上一个节点的指针了 let index = 0 // 遍历索引初始化为 0 let current = this.head // 遍历的当前节点初始化为 head // 在 0 ~ position 之间遍历,不断地更新 current // 直到找到要插入的位置 while (index++ < position) { current = current.next } // 在当前节点之前插入新节点,涉及4个指针要修改,要注意修改次序 // 1、新节点的prev指针要指向当前节点的prev // 2、新节点的next指针要指向当前节点 // 3、当前节点的prev的next指针要指向新节点 // 4、当前节点的prev指针要指向新节点 newNode.prev = current.prev newNode.next = current current.prev.next = newNode current.prev = newNode } // 4、追加完新节点后,链表长度 + 1 this.length++ // 5、返回新添加的节点,方便其他操作 return newNode } // getData(position) 获取指定位置的 data // 重写 getData() getData(position) { // 1、position越界判断 if (position < 0 || position >= this.length) return null // 2、判断要获取的节点离头尾节点哪个比较近 // 离头节点比较近 if (this.length / 2 >= position) { // 获取指定 position 的节点 let index = 0 let current = this.head while (index++ < position) { current = current.next } // 3、返回相应节点的 data return current.data // 离尾节点比较近 } else { let index = this.length - 1 let current = this.tail while (index-- > position) { current = current.prev } // 3、返回相应节点的 data return current.data } } // removeAt(position) 删除指定位置的节点,并返回删除的那个节点 // 重写 removeAt() removeAt(position) { // 1、position越界判断 if (position < 0 || position >= this.length) return null // 2、删除指定 position 节点 let current = this.head // 删除第一个节点的情况 if (position === 0) { // 链表内只有一个节点的情况 if (this.length === 1) { this.head = null this.tail = null } else { // 链表内有多个节点的情况 this.head.next.prev = null this.head = this.head.next } } else if (position === this.length - 1) { // 删除最后一个节点的情况 current = this.tail this.tail.prev.next = null this.tail = this.tail.prev } else { // 删除 0 ~ this.length - 1 里面节点的情况 // 判断要删除的节点离头尾节点哪个比较近 // 离头节点比较近 if (this.length / 2 >= position) { // 获取指定 position 的节点 let index = 0 while (index++ < position) { current = current.next } // 删除相应节点 current.prev.next = current.next current.next.prev = current.prev } else { // 离尾节点比较近 // 获取指定 position 的节点 let index = this.length - 1 current = this.tail while (index-- > position) { current = current.prev } // 删除相应节点 current.prev.next = current.next current.next.prev = current.prev } } // 3、更新链表长度 -1 this.length-- // 4、返回被删除的节点,方便其他操作 return current } // forwardToString() 链表数据从前往后以字符串形式返回 forwardToString() { let currentNode = this.head let result = '' // 遍历所有的节点,拼接为字符串,直到节点为 null while (currentNode) { result += currentNode.data + '--' currentNode = currentNode.next } return result } // backwardString() 链表数据从后往前以字符串形式返回 backwardString() { let currentNode = this.tail let result = '' // 遍历所有的节点,拼接为字符串,直到节点为 null while (currentNode) { result += currentNode.data + '--' currentNode = currentNode.prev } return result } }
代码测试
const doublyLinkedList = new DoublyLinkedList(); // append() 测试 console.log('append() 测试'); doublyLinkedList.append('ZZ'); doublyLinkedList.append('XX'); doublyLinkedList.append('CC'); console.log(doublyLinkedList.toString()); //--> ZZ XX CC // insert() 测试 console.log('insert() 测试'); doublyLinkedList.insert(0, '00'); doublyLinkedList.insert(2, '22'); console.log(doublyLinkedList.toString()); //--> 00 ZZ 22 XX CC // getData() 测试 console.log('getData() 测试'); console.log(doublyLinkedList.getData(1)); //--> ZZ // indexOf() 测试 console.log('indexOf() 测试'); console.log(doublyLinkedList.indexOf('XX')); //--> 3 // removeAt() 测试 console.log('removeAt() 测试'); doublyLinkedList.removeAt(0); doublyLinkedList.removeAt(1); console.log(doublyLinkedList.toString()); //--> ZZ XX CC // update() 测试 console.log('update() 测试'); doublyLinkedList.update(0, '111111'); console.log(doublyLinkedList.toString()); //--> 111111 XX CC // remove() 测试 console.log('remove() 测试'); console.log(doublyLinkedList.remove('111111')); // console.log(doublyLinkedList.remove('XX')); console.log(doublyLinkedList.toString()); //--> XX CC // forwardToString() 测试 console.log('forwardToString() 测试'); console.log(doublyLinkedList.forwardToString()); //--> XX--CC-- // backwardString() 测试 console.log('backwardString() 测试'); console.log(doublyLinkedList.backwardString()); //--> CC--XX--
leetcode题精选
[2] 两数相加
给出两个 非空 的链表用来表示两个非负的整数。其中,它们各自的位数是按照 逆序 的方式存储的,并且它们的每个节点只能存储 一位 数字。
如果,我们将这两个数相加起来,则会返回一个新的链表来表示它们的和。
您可以假设除了数字 0 之外,这两个数都不会以 0 开头。
示例:
输入:(2 -> 4 -> 3) + (5 -> 6 -> 4)
输出:7 -> 0 -> 8
原因:342 + 465 = 807
链表的相加问题,需要注意的有两点:
- 设立头结点dummy,作为链表的起始节点,这样方便统一第一次和之后的每次的插入行为。
- 注意进位问题,包括在有两个链表的值相加时、两链表长度不同时只有一个链表有值时、最后的运算有进位的情况都要考虑到。
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
* }
*/
function addTwoNumbers(l1: ListNode | null, l2: ListNode | null): ListNode | null {
//添加头结点 并设置为空 这样方便统一第一次和之后每次的插入行为
const dummy = new ListNode(null);
//作为每次相加的临时结点
let cur = dummy;
let carry = 0; //进位值
while(l1||l2){
const val1 = l1?l1.val:0;
const val2 = l2?l2.val:0;
let answer = val1 + val2 + carry;
carry = answer>9?1:0;
cur.next = new ListNode(answer%10);
if(l1)l1 = l1.next;
if(l2)l2 = l2.next;
cur = cur.next;
}
if(carry===1) cur.next = new ListNode(1);
return dummy.next;
};
[21] 合并两个有序链表
将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
示例:
输入:1->2->4, 1->3->4
输出:1->1->2->3->4->4
这道题实际上就是一次归并排序的思想,两个有序子数组合并为一个有序数组,只不过形式换成了链表。
非常简单,注意新链表创建头结点即可。
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
* }
*/
function mergeTwoLists(list1: ListNode | null, list2: ListNode | null): ListNode | null {
const dummy = new ListNode(null);
let cur = dummy;
while(list1&&list2){
const node= new ListNode(null);
if(list1.val<list2.val){
node.val = list1.val;
list1 = list1.next;
}else{
node.val = list2.val;
list2 = list2.next;
}
cur.next = node;
cur = cur.next;
}
list1&&(cur.next = list1);
list2&&(cur.next = list2);
return dummy.next
};
[24] 两两交换链表中的节点
给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。
你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。
示例:
给定 1->2->3->4, 你应该返回 2->1->4->3.
对链表交换相邻节点的问题,我们还是按照普通对链表遍历的处理即可(即先加头结点遍历直到节点判空结束)。
因为是节点交换,因此需要交换的两个节点都必须有值,因此,当cur或者next任何一个没有值就可以结束循环了。
function swapPairs(head: ListNode | null): ListNode | null {
const dummy = new ListNode(null);
//dummy节点放到head的前面
dummy.next = head;
//定义一个指针 指向dummy
let pre = dummy;
//因为是节点交换,因此需要交换的两个节点都必须有值
//因此,当cur或者next任何一个没有值就可以结束循环了。
while(pre.next&&pre.next.next){
const cur = pre.next;//n1
const next = cur.next;//n2
// 进行调换
pre.next = next;//p--->n2
cur.next = next.next;//n1--->n2.next
next.next = cur;//n2--->n1
pre = cur;//p变为n1
}
return dummy.next;
};
[83] 删除排序链表中的重复元素 I
给定一个已排序的链表的头
head
, 删除所有重复的元素,使每个元素只出现一次 。返回 已排序的链表示例 1:
输入:head = [1,1,2] 输出:[1,2]
示例 2:
输入:head = [1,1,2,3,3] 输出:[1,2,3]
提示:
- 链表中节点数目在范围
[0, 300]
内-100 <= Node.val <= 100
- 题目数据保证链表已经按升序 排列
function deleteDuplicates(head: ListNode | null): ListNode | null {
let cur = head;
while(cur&&cur.next){
if(cur.val===cur.next.val){
cur.next = cur.next.next
}else{
cur=cur.next
}
}
return head;
};
[82] 删除排序链表中的重复元素 II
存在一个按升序排列的链表,给你这个链表的头节点 head ,请你删除链表中所有存在数字重复情况的节点,只保留原始链表中 没有重复出现 的数字。
返回同样按升序排列的结果链表。
示例 1:
输入:head = [1,2,3,3,4,4,5]
输出:[1,2,5]
示例 2:
输入:head = [1,1,1,2,3]
输出:[2,3]
提示:
题目数据保证链表已经按升序排列
注意点在于,对于重复元素,我们要一个不留的全部删除,因此必须保留一个每个节点的前一个节点的指针 prev,方便通过 prev.next 删除所有元素节点。
function deleteDuplicates(head: ListNode | null): ListNode | null {
//如果链表长度小于等于1,不可能有重复
if(!head||!head.next) return head;
const dummy = new ListNode(null);
dummy.next = head;
let pre = dummy;
let cur = head;
while(cur&&cur.next){
if(cur.next.val===cur.val){
const sameValue = cur.val;
//如果有重复元素,则找出所有相同val节点
while(cur&&cur.val===sameValue){
cur=cur.next; //向后遍历
}
//删除之间的所有节点
pre.next = cur; //p节点直接指向最后一个重复节点的下一位
}else{
//否则,继续往下遍历
pre = cur;
cur = cur.next;
}
}
return dummy.next;
};
[206] 反转链表
反转一个单链表。
示例:
输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL
进阶:
你可以迭代或递归地反转链表。你能否用两种方法解决这道题?
这道题是一道非常典型的双指针使用场景。既然为反转链表,因此只需要将每个节点的next指针指向前一个节点即可,因此我们需要两个指针分别记录当前节点与前一个节点,交换当前节点指向后,前后指针依次向前遍历即可。循环时需要注意,后一个节点是需要优先记录的,这样才能够进入下一次循环前找到下一个节点的引用。
function reverseList(head: ListNode | null): ListNode | null {
let pre = null;
let cur = head;
while(cur){
const next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
return pre;
};
[92] 反转链表 II
反转从位置 m 到 n 的链表。请使用一趟扫描完成反转。
说明:
1 ≤ m ≤ n ≤ 链表长度。
示例:
输入: 1->2->3->4->5->NULL, m = 2, n = 4
输出: 1->4->3->2->5->NULL
这道题与 206.反转链表
可以合并在一起看,实际上就是206题的加难版。
核心问题不变,就是翻转链表的问题。解题方法可以参考206题。
本题多出来的难点在于,在翻转完m~n之间的链表后,怎样将翻转后的头尾节点与原来的链表联系上。这里就是一个逻辑问题了,先记录下翻转链表之前和之后的节点beforeStart
与afterEnd
,翻转完后在连上头尾即可。
function reverseBetween(head: ListNode | null, left: number, right: number): ListNode | null {
//如果左右二者相同,不用调换;
if(right===left) return head;
//添加空节点在链表前
const dummy = new ListNode(null);
dummy.next = head;
//设置指针(分别表示-------用来进行占位)
let before = dummy;
let count = 1;
while(count<left){
before = before.next;
count++;
}
let end = before;
while(count<=right){
end = end.next;
count++;
}
//结束循环后 before代表left前一个位置 end代表right所在位置
const start = before.next;//left位置
const after = end.next;//right后一个的位置
let pre = start;
let cur = pre.next;
while(cur!==end){
const next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
start.next = after;
before.next = end;
cur.next = pre;
return dummy.next;
};
[203] 移除链表元素
删除链表中等于给定值 val 的所有节点。
示例:
输入: 1->2->6->3->4->5->6, val = 6
输出: 1->2->3->4->5
注意头结点也是可能被删去的节点,因此需要补充建立一个虚拟的dummy节点,next指向head
function removeElements(head: ListNode | null, val: number): ListNode | null {
const dummy = new ListNode(null);
dummy.next = head;
let cur = dummy;
while(cur.next){
if(cur.next.val==val){
const next = cur.next.next||null;//不存在即为空
cur.next = next;
}else{
cur = cur.next;
}
}
return dummy.next
};
2.哈希表
首先什么是 哈希表,哈希表(英文名字为Hash table,国内也有一些算法书籍翻译为散列表,大家看到这两个名称知道都是指hash table就可以了)。
哈希表是根据关键码的值而直接进行访问的数据结构。
这么这官方的解释可能有点懵,其实直白来讲其实数组就是一张哈希表。
哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素,如下图所示:
那么哈希表能解决什么问题呢,一般哈希表都是用来快速判断一个元素是否出现集合里。
例如要查询一个名字是否在这所学校里。
要枚举的话时间复杂度是O(n),但如果使用哈希表的话, 只需要O(1)就可以做到。
我们只需要初始化把这所学校里学生的名字都存在哈希表里,在查询的时候通过索引直接就可以知道这位同学在不在这所学校里了。
将学生姓名映射到哈希表上就涉及到了hash function ,也就是哈希函数。
哈希函数
哈希函数,把学生的姓名直接映射为哈希表上的索引,然后就可以通过查询索引下标快速知道这位同学是否在这所学校里了。
哈希函数如下图所示,通过hashCode把名字转化为数值,一般hashcode是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把学生名字映射为哈希表上的索引数字了。
如果hashCode得到的数值大于 哈希表的大小了,也就是大于tableSize了,怎么办呢?
此时为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模的操作,就要我们就保证了学生姓名一定可以映射到哈希表上了。
此时问题又来了,哈希表我们刚刚说过,就是一个数组。
如果学生的数量大于哈希表的大小怎么办,此时就算哈希函数计算的再均匀,也避免不了会有几位学生的名字同时映射到哈希表 同一个索引下标的位置。
接下来哈希碰撞登场
哈希碰撞
如图所示,小李和小王都映射到了索引下标 1 的位置,这一现象叫做哈希碰撞。
一般哈希碰撞有两种解决方法, 拉链法和线性探测法。
拉链法
刚刚小李和小王在索引1的位置发生了冲突,发生冲突的元素都被存储在链表中。 这样我们就可以通过索引找到小李和小王了
(数据规模是dataSize, 哈希表的大小为tableSize)
其实拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
线性探测法
使用线性探测法,一定要保证tableSize大于dataSize。 我们需要依靠哈希表中的空位来解决碰撞问题。
例如冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求tableSize一定要大于dataSize ,要不然哈希表上就没有空置的位置来存放 冲突的数据了。如图所示:
其实关于哈希碰撞还有非常多的细节,感兴趣的同学可以再好好研究一下,这里我就不再赘述了。
常见的js哈希结构
当我们想使用哈希法来解决问题的时候,我们一般会选择如下三种数据结构。
- object对象
- set (集合)
- map(映射)
- 用数组造轮子(本章省略)
leetcode题精选
[219] 存在重复元素 II
给定一个整数数组和一个整数 k,判断数组中是否存在两个不同的索引 i 和 j,使得 nums [i] = nums [j],并且 i 和 j 的差的
绝对值 至多为 k。
示例 1:
输入: nums = [1,2,3,1], k = 3
输出: true
示例 2:
输入: nums = [1,0,1,1], k = 1
输出: true
示例 3:
输入: nums = [1,2,3,1,2,3], k = 2
输出: false
var containsNearbyDuplicate = function(nums, k) {
const hash = {};
for (let i = 0; i < nums.length; i++) {
if (hash[nums[i]] !== undefined && i - hash[nums[i]] <= k) {
return true;
}
hash[nums[i]] = i;
}
return false;
};
[3] 无重复字符的最长子串
给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。
示例 1:
输入: "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:
输入: "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
输入: "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
这道题我们很容易想到暴力法,利用快慢指针,遍历字符串时,每次都检查当前字符是否在快慢指针之间的子串中存在,这里的快慢指针实际上形成了一个滑动窗口。
至于如何检查某个字符在子串中是否存在,我们可以直接遍历,也可以利用hash表进行优化。
- 若不存在该字符,则直接加上,值为当前字符的位置。
- 若存在该字符,则比对字符是否在滑动窗口内,若在,则说明当前子串存在重复字符。将慢指针移动到子串中重复字符的后一个位置以将重复字符剔除出滑动窗口,并继续向后遍历。
我们另外需要一个变量来记录遍历过程中的最长子串长度。遍历时,若当前子串长度长于记录值,则更新记录值即可。
var lengthOfLongestSubstring = function(s) {
const len = s.length;
let res = 0;
const hash = {};
let start = 0;
for (let i = 0; i < len; i++) {
if (hash[s[i]] !== undefined && hash[s[i]] >= start) {
start = hash[s[i]] + 1;
}
hash[s[i]] = i;
if (i - start + 1 > res) {
res = i - start + 1;
}
}
return res;
};
[974] 和可被 K 整除的子数组 ---- 前缀和/区间和/哈希表
给定一个整数数组 nums 和一个整数 k ,返回其中元素之和可被 k 整除的(连续、非空) 子数组 的数目。
子数组 是数组的 连续 部分。
示例 1:
输入:nums = [4,5,0,-2,-3,1], k = 5
输出:7
解释:
有 7 个子数组满足其元素之和可被 k = 5 整除:
[4, 5, 0, -2, -3, 1], [5], [5, 0], [5, 0, -2, -3], [0], [0, -2, -3], [-2, -3]示例 2:
输入: nums = [5], k = 9
输出: 0提示:
1 <= nums.length <= 3 * 104
-104 <= nums[i] <= 104
2 <= k <= 104
什么是前缀和
- 定义:数组 第 0 项 到 当前项 的和。用一个数组 preSum 表示:
$$
preSum[i] = A[0] + A[1] +…+A[i]
$$
$$
preSum[i]=A[0]+A[1]+…+A[i]
$$
- 数组第 i 项可以表示为相邻前缀和之差:
$$
A[i] = preSum[i] - preSum[i - 1]
$$
$$
A[i]=preSum[i]−preSum[i−1]
$$
- 多项叠加,有:
$$
A[i] +…+A[j]=preSum[j] - preSum[i - 1]
$$
$$
A[i]+…+A[j]=preSum[j]−preSum[i−1]
$$
- i 可以为 0,此时 i - 1 为 - 1,我们故意让 preSum[-1] 为 0,此时有:
$$
A[0] +A[1]+…+A[j]=preSum[j]
$$
$$
A[0]+A[1]+…+A[j]=preSum[j]
$$
- 设置这种荒谬的情况,只是为了让边界情况的计算也能套用上面的通式。
题目等价转化
- 子数组的元素之和 => A[i] 到 A[j]的和
- 元素和能被 K 整除的子数组数目 => 有几种i、j组合,使得A[i]到A[j]之和 mod K == 0 ↓ ↓ ↓ 转化为 ↓ ↓ ↓
- 有几种 i、j 组合,满足 (preSum[ j ] - preSum[ i - 1 ]) mod K== 0。
- 有几种i、j组合,满足preSum[j] mod K==preSum[i-1] mod K。(同余定理)
- 前提:preSum[j]、preSum[i-1]为正整数。负数的情况要处理。
前缀和怎么求
- 数组当前项的前缀和 = 上一项的前缀和 + 数组当前项
- 我们可以求出数组 A 每一项的前缀和,让它 mod K,mod 完再看哪两项相等,去计数。
- 但前面通式有i、j两个变量,找出所有相等的两项,需要两层循环,能否优化?
我们只关心:数值和出现次数
- 数组A的元素都有自己的前缀和,但我们不关心前缀和对应了哪一项。我们只关心出现过哪些「前缀和 mod K」的值,以及出现这个值的次数。
- 用一个变量 preSumModK,将每次求出的「前缀和 mod K」,存入哈希表:
- key:前缀和 mod K
- value:这个值出现的次数
- 「前缀和 mod K」值恰好是 0,1,2...,K-1,正好和索引对应,所以也可以用数组去存。
找到 preSumModK 的递推关系,用于迭代计算
- 模的分配率: (a + b) mod c = (a mod c + b mod c) mod c
- 当前的 preSumModK
= ( 当前的前缀和 ) mod K
= ( 上一项的前缀和 + A[i] ) mod K
= ((上一项的前缀和) mod K + A[i] mod K ) mod K
= (上一个 preSumModK + A[i] mod K) mod K
= ( 上一个 preSumModK + A[i] ) mod K ----->(逆的模分配律) - 前后的 preSumModK 有了递推关系,可以在迭代中计算。
整个流程
- 预置 preSum[-1] = 0
- 遍历数组 A 之前,map 提前放入 0:1 键值对,代表求第 0 项前缀和之前,前缀和 mod K 等于 0 这种情况出现了 1 次。
- 遍历数组 A,求当前项的 preSumModK ,存入 map 中:
- 之前没有存过,则作为 key 存入,value 为 1。
- 之前存过了,则 value 加 1。
- 于是map就录入了各项对应的【前缀和 mod K】。
- 边存边查看,如果 map 中已经存在 key 等于当前的 preSumModK:
- 说明存在之前求过的 preSumModK 等于 当前 preSumModK,把 key 对应的出现次数,累加给 count。
- 过去的这个前缀,与当前的前缀,差分出一个子数组,过去的这个前缀和出现过几次 ,就是有几个过去的前缀,与当前前缀,差分出几个满足条件的子数组。
尝试一句话概括
- 根据当前前缀和 mod K,在哈希表中找到与之相等的 key。满足条件的 历史preSumModK 出现过 n 次,就是当前前缀和 能找到 n 个历史前缀和,与之形成 n 个不同的子数组,满足元素和能被 K 整除。
- 遍历数组 A 每一项,做以上步骤,n 不断累加给 count,最后返回 count。
复杂度
Time:O(n)
Space:O(K)。 mod 的结果最多 K 种,哈希表最多存放 K 个键值对
补充:前缀和 为负数 的情况
- 拿K = 4为例,求出某个前缀和为 -1,-1 % K 应该为 3,但有的编程语言 -1 % K = -1
- 这个 -1,要加上 K,转成正 3。为什么 preSum 值为 -1 和 3 需要归为同一类?因为:
- -1 和 3 分别模 4 的结果看似不相等,但前缀和之差:3-(-1) 等于 4。4 % K = 0,即所形成的子数组满足元素和被 4 整除。所以前缀和 -1 和 3 其实是等价的。
function subarraysDivByK(nums: number[], k: number): number {
let preSumModK = 0;
let count = 0;
//preSumModK = 0的情况出现过一次
//遍历数组 A 之前,map 提前放入 0:1 键值对,代表求第 0 项前缀和之前,前缀和 mod K 等于 0 这种情况出现了 1 次。
let map = {0:1};
for(let i =0; i < nums.length; i++){
preSumModK = (preSumModK + nums[i]) % k;
if(preSumModK<0) preSumModK += k;
if(map[preSumModK]){ // 已经存在于map
count += map[preSumModK]; // 把对应的次数累加给count
map[preSumModK]++; // 并且更新出现次数,次数+1
}else{
map[preSumModK] = 1; // 之前没出现过,初始化值为1
}
}
return count;
};
[560] 和为 K 的子数组-----前缀和+哈希表
给你一个整数数组 nums 和一个整数 k ,请你统计并返回 该数组中和为 k 的子数组的个数 。
示例 1:
输入:nums = [1,1,1], k = 2
输出:2
示例 2:输入:nums = [1,2,3], k = 3
输出:2提示:
1 <= nums.length <= 2 * 104
-1000 <= nums[i] <= 1000
-107 <= k <= 107
1、暴力法(超时)
- 索引 i 和 j 确定一个子数组,枚举出所有子数组,子数组求和等于 k 的话则 count++。
- 遍历 i、 遍历 j、 从 i 到 j 的累加求和。 三重循环,时间复杂度O(n^3)
const subarraySum = (nums, k) => {
let count = 0;
for (let i = 0; i < nums.length; i++) {
for (let j = i; j < nums.length; j++) {
let sum = 0;
for (let q = i; q <= j; q++) {
sum += nums[q];
}
if (sum == k) count++;
}
}
return count;
};
2、去除重复计算
- 求和时:上轮迭代求了 i 到 j - 1 的和,这轮就没必要从头求 i 到 j 的和。
- 去掉内层循环,用一个变量保存上次的求和结果,每次累加当前项即可。
- 依旧是穷举,时间复杂度:O(n^2)。还能再优化吗?
- Runtime: 900 ms, faster than 5.03% of Go online submissions
const subarraySum = (nums, k) => {
let count = 0;
for (let i = 0; i < nums.length; i++) {
let sum = 0;
for (let j = i; j < nums.length; j++) {
sum += nums[j];
if (sum == k) count++;
}
}
return count;
};
3、引入前缀和
- 前缀和:nums 的第 0 项到 当前项 的和。
- 定义 prefixSum 数组,prefixSum[x]:第 0 项到 第 x 项 的和。
$$
prefixSum[x] = nums[0] + nums[1] +…+nums[x]
$$
- nums 的某项 = 两个相邻前缀和的差:
$$
nums[x] = prefixSum[x] - prefixSum[x - 1]
$$
- nums 的 第 i 到 j 项 的和,有:
$$
nums[i] +…+nums[j]=prefixSum[j] - prefixSum[i - 1]
$$
- 当 i 为 0,此时 i-1 为 -1,我们故意让 prefixSum[-1] 为 0,使得通式在
i=0
时也成立:
$$
nums[0] +…+nums[j]=prefixSum[j]
$$
题目的等价转化
- 题意:有几种 i、j 的组合,使得从第 i 到 j 项的子数组和等于 k。
↓ ↓ ↓ 转化为 ↓ ↓ ↓ - 有几种 i、j 的组合,满足 prefixSum[j] - prefixSum[i - 1] == k
- 可以通过求出 prefixSum 数组的每一项,再看哪些项相减等于k,求出count。
- 但该通式有 2 个变量,需要两层循环才能找出来,依旧是 O(n^2)。
不用求出 prefixSum 数组
- 其实我们不关心具体是哪两项的前缀和之差等于k,只关心等于 k 的前缀和之差出现的次数c,就知道了有c个子数组求和等于k。
- 遍历 nums 之前,我们让 -1 对应的前缀和为 0,这样通式在边界情况也成立。即在遍历之前,map 初始放入 0:1 键值对(前缀和为0出现1次了)。
- 遍历 nums 数组,求每一项的前缀和,统计对应的出现次数,以键值对存入 map。
- 边存边查看 map,如果 map 中存在 key 为「当前前缀和 - k」,说明这个之前出现的前缀和,满足「当前前缀和 - 该前缀和 == k」,它出现的次数,累加给 count。
代码
时间复杂度 O(n) 。空间复杂度 O(n)
function subarraySum(nums: number[], k: number): number {
const map = {0:1};
let prefixSum = 0;
let count = 0;
for(let i = 0; i<nums.length; i++){
//求当前项的前缀和
prefixSum += nums[i];
//判断map 中存在 key 为[当前前缀和 - k]
if(map[prefixSum-k]){
count += map[prefixSum-k];
}
// 当前前缀和value值加一 若没有设为1
if (map[prefixSum]) {
map[prefixSum]++;
} else {
map[prefixSum] = 1;
}
}
return count;
}
复盘总结
- 每个元素对应一个“前缀和”
- 遍历数组,根据当前“前缀和”,在 map 中寻找「与之相减 == k」的历史前缀和
- 当前“前缀和”与历史前缀和,差分出一个子数组,该历史前缀和出现过 c 次,就表示当前项找到 c 个子数组求和等于 k。
- 遍历过程中,c 不断加给 count,最后返回 count
[523] 连续的子数组和-----前缀和+哈希表
给你一个整数数组 nums 和一个整数 k ,编写一个函数来判断该数组是否含有同时满足下述条件的连续子数组:
子数组大小 至少为 2 ,且
子数组元素总和为 k 的倍数。
如果存在,返回 true ;否则,返回 false 。如果存在一个整数 n ,令整数 x 符合 x = n * k ,则称 x 是 k 的一个倍数。0 始终视为 k 的一个倍数。
示例 1:
输入:nums = [23,2,4,6,7], k = 6
输出:true
解释:[2,4] 是一个大小为 2 的子数组,并且和为 6 。
示例 2:输入:nums = [23,2,6,4,7], k = 6
输出:true
解释:[23, 2, 6, 4, 7] 是大小为 5 的子数组,并且和为 42 。
42 是 6 的倍数,因为 42 = 7 * 6 且 7 是一个整数。
跟974题类似不过哈希表应该存的是 余数和数组下标值的索引的键值对
且哈希表一开始应该定义为{0:-1}的形式,边界的下标值设为-1;
这次使用es6中map数据结构来解题
function checkSubarraySum(nums: number[], k: number): boolean {
const map = new Map();
map.set(0,-1);
let preSumModK = 0;
for(let i = 0; i < nums.length; i++){
preSumModK = (preSumModK + nums[i])%k;
if(map.has(preSumModK)){
let index = map.get(preSumModK);
if(i-index>=2){
return true;
}
}else{
map.set(preSumModK,i)
}
}
return false
};
[202] 快乐数
「快乐数」 定义为:
对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
如果这个过程 结果为 1,那么这个数就是快乐数。
如果 n 是 快乐数 就返回 true ;不是,则返回 false 。
示例 1:
输入:n = 19 输出:true 解释: 12 + 92 = 82 82 + 22 = 68 62 + 82 = 100 12 + 02 + 02 = 1
示例 2:
输入:n = 2 输出:false
提示:
1 <= n <= 231 - 1
ES6引入了两种新的数据结构:Set和Map。Set是一组值的集合,其中值不能重复;Map(也叫字典)是一组键值对的集合,其中键不能重复。Set和Map都由哈希表(Hash Table)实现,并可按添加时候的顺序枚举。
function isHappy(n: number): boolean {
let sum;
// 储存每次的平方和值
let set = new Set();
let num = n + '';
while(sum!==1){
sum = 0;
for(let i = 0; i < num.length; i++){
sum += Number(num[i])*Number(num[i])
}
//如果原来有了 就代表进入循环 肯定不是快乐数
if(set.has(sum)){
return false;
}
set.add(sum);
num = sum + '';
}
return true
};
[187] 重复的DNA序列
DNA序列 由一系列核苷酸组成,缩写为 'A', 'C', 'G' 和 'T'.。
例如,"ACGAATTCCG" 是一个 DNA序列 。
在研究 DNA 时,识别 DNA 中的重复序列非常有用。给定一个表示 DNA序列 的字符串 s ,返回所有在 DNA 分子中出现不止一次的 长度为 10 的序列(子字符串)。你可以按 任意顺序 返回答案。
示例 1:
输入:s = "AAAAACCCCCAAAAACCCCCCAAAAAGGGTTT"
输出:["AAAAACCCCC","CCCCCAAAAA"]
示例 2:输入:s = "AAAAAAAAAAAAA"
输出:["AAAAAAAAAA"]
子区间----一下联想到滑动窗口和哈希表的方法
方法一:哈希表
- 我们可以用一个哈希表统计 s 所有长度为 10 的子串的出现次数,返回所有出现次数超过 10的子串。
- 代码实现时,可以一边遍历子串一边记录答案,为了不重复记录答案,我们只统计当前出现次数为 2的子串。
function findRepeatedDnaSequences(s: string): string[] {
const res = [];
const map = new Map();
for(let i = 0; i <= s.length-10; i++) {
const sub = s.slice(i,i+10);
map.set(sub,(map.get(sub)||0)+1) //次数加一,没有设为1
if(map.get(sub)===2){
res.push(sub);
}
}
return res;
};
复杂度分析
时间复杂度:O(NL),其中 N 是字符串s 的长度,L=10 即目标子串的长度。
空间复杂度:O(NL)。
方法二:哈希表 + 滑动窗口 + 位运算
在介绍位运算章节中也会提到
[146] 常考 LRU缓存机制 LRU Cache -----(双向链表/哈希表)
请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。
实现 LRUCache 类:
- LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存
- int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
- void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字。
函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。
示例:
输入
["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
a. LRU 缓存策略举例:
i. 假设缓存大小为 4,依次打开了 gitlab、力扣、微信、QQ,缓存链表为 QQ -> 微信 -> 力扣 -> gitlab;
ii. 若此时切换到了「微信」,则缓存链表更新为 微信 -> QQ -> 力扣 -> gitlab;
iii. 若此时打开了腾讯会议,因为缓存已经满 4 个 ,所以要进行缓存淘汰机制,删除链表的最后一位 「gitlab」,则缓存链表更新为 腾讯会议 -> 微信 -> QQ -> 力扣;
b. 本题用的是 map 迭代器,map 实现了 iterator,next 模拟链表的下一个指针,为了方便操作,这里将 map 第一个元素作为链表的最后一个元素;
let cache = new Map();
cache.set('a', 1);
cache.set('b', 2);
cache.set('c', 3);
cache.keys(); // MapIterator {'a', 'b', 'c'}
cache.keys().next().value; // a
a. 时间复杂度:对于 put 和 get 都是 O(1);
b. 空间复杂度:O(capacity);
class LRUCache {
capacity:number;
cache:Map<number,number> = new Map();
//缓存的容量
constructor(capacity: number) {
this.capacity = capacity
}
get(key: number): number {
if(this.cache.has(key)){
let value = this.cache.get(key);
//重新set,相当于更新到cache最后----可以保证删除时,最久未使用的在第一位
//先删除
this.cache.delete(key);
this.cache.set(key,value);
return value;
}
return -1;
}
put(key: number, value: number): void {
if(this.cache.has(key)){
this.cache.delete(key);
}
this.cache.set(key,value);
if(this.cache.size>this.capacity){
//利用next指针,反复进行put时,可以删除正确的缓存
this.cache.delete(this.cache.keys().next().value);
}
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?