链表

内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲的内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了。

「链表 linked list」是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,通过它可以从当前节点访问到下一个节点。

链表的设计使得各个节点可以分散存储在内存各处,它们的内存地址无须连续。

链表的组成单位是「节点 node」对象。每个节点都包含两项数据:节点的“值”和指向下一节点的“引用”。

  • 链表的首个节点被称为“头节点”,最后一个节点被称为“尾节点”。
  • 尾节点指向的是“空”,它在 Java、C++ 和 Python 中分别被记为 null、nullptr 和 None 。
  • 在 C、C++、Go 和 Rust 等支持指针的语言中,上述“引用”应被替换为“指针”。

u 链表节点 ListNode 除了包含值,还需额外保存一个引用(指针)。因此在相同数据量下,链表比数组占用更多的内存空间。

初始化链表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ListNode:
"""链表节点类"""
def __init__(self, val: int):
self.val: int = val # 节点值
self.next: ListNode | None = None # 指向下一节点的引用


# 初始化链表 1 -> 3 -> 2 -> 5 -> 4
# 初始化各个节点
n0 = ListNode(1)
n1 = ListNode(3)
n2 = ListNode(2)
n3 = ListNode(5)
n4 = ListNode(4)
# 构建节点之间的引用
n0.next = n1
n1.next = n2
n2.next = n3
n3.next = n4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 链表节点结构体 */
struct ListNode {
int val; // 节点值
ListNode *next; // 指向下一节点的指针
ListNode(int x) : val(x), next(nullptr) {} // 构造函数
};

/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
// 初始化各个节点
ListNode* n0 = new ListNode(1);
ListNode* n1 = new ListNode(3);
ListNode* n2 = new ListNode(2);
ListNode* n3 = new ListNode(5);
ListNode* n4 = new ListNode(4);
// 构建节点之间的引用
n0->next = n1;
n1->next = n2;
n2->next = n3;
n3->next = n4;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* 链表节点类 */
class ListNode {
int val; // 节点值
ListNode next; // 指向下一节点的引用
ListNode(int x) { val = x; } // 构造函数
}


/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
// 初始化各个节点
ListNode n0 = new ListNode(1);
ListNode n1 = new ListNode(3);
ListNode n2 = new ListNode(2);
ListNode n3 = new ListNode(5);
ListNode n4 = new ListNode(4);
// 构建节点之间的引用
n0.next = n1;
n1.next = n2;
n2.next = n3;
n3.next = n4;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 链表节点类 */
class ListNode(int x) { //构造函数
int val = x; // 节点值
ListNode? next; // 指向下一节点的引用
}


/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
// 初始化各个节点
ListNode n0 = new(1);
ListNode n1 = new(3);
ListNode n2 = new(2);
ListNode n3 = new(5);
ListNode n4 = new(4);
// 构建节点之间的引用
n0.next = n1;
n1.next = n2;
n2.next = n3;
n3.next = n4;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* 链表节点类 */
class ListNode {
constructor(val, next) {
this.val = (val === undefined ? 0 : val); // 节点值
this.next = (next === undefined ? null : next); // 指向下一节点的引用
}
}

/* 初始化链表 1 -> 3 -> 2 -> 5 -> 4 */
// 初始化各个节点
const n0 = new ListNode(1);
const n1 = new ListNode(3);
const n2 = new ListNode(2);
const n3 = new ListNode(5);
const n4 = new ListNode(4);
// 构建节点之间的引用
n0.next = n1;
n1.next = n2;
n2.next = n3;
n3.next = n4;

插入结点

  • 插入结点就是让插入的结点指向后结点,再让前结点指向插入结点。
1
2
3
4
5
def insert(n0: ListNode, P: ListNode):
"""在链表的节点 n0 之后插入节点 P"""
n1 = n0.next
P.next = n1
n0.next = P
1
2
3
4
5
6
/* 在链表的节点 n0 之后插入节点 P */
void insert(ListNode *n0, ListNode *P) {
ListNode *n1 = n0->next;
P->next = n1;
n0->next = P;
}
1
2
3
4
5
6
/* 在链表的节点 n0 之后插入节点 P */
void insert(ListNode n0, ListNode P) {
ListNode n1 = n0.next;
P.next = n1;
n0.next = P;
}
1
2
3
4
5
6
/* 在链表的节点 n0 之后插入节点 P */
void Insert(ListNode n0, ListNode P) {
ListNode? n1 = n0.next;
P.next = n1;
n0.next = P;
}
1
2
3
4
5
6
/* 在链表的节点 n0 之后插入节点 P */
function insert(n0, P) {
const n1 = n0.next;
P.next = n1;
n0.next = P;
}

删除结点

  • 捋清楚前后关系,其实删除结点就是将前一个结点指向不在指向被删除的结点,而是指向被删除结点之后的结点。
1
2
3
4
5
6
7
8
def remove(n0: ListNode):
"""删除链表的节点 n0 之后的首个节点"""
if not n0.next:
return
# n0 -> P -> n1
P = n0.next
n1 = P.next
n0.next = n1
1
2
3
4
5
6
7
8
9
10
11
/* 删除链表的节点 n0 之后的首个节点 */
void remove(ListNode *n0) {
if (n0->next == nullptr)
return;
// n0 -> P -> n1
ListNode *P = n0->next;
ListNode *n1 = P->next;
n0->next = n1;
// 释放内存
delete P;
}
1
2
3
4
5
6
7
8
9
/* 删除链表的节点 n0 之后的首个节点 */
void remove(ListNode n0) {
if (n0.next == null)
return;
// n0 -> P -> n1
ListNode P = n0.next;
ListNode n1 = P.next;
n0.next = n1;
}
1
2
3
4
5
6
7
8
/* 删除链表的节点 n0 之后的首个节点 */
function remove(n0) {
if (!n0.next) return;
// n0 -> P -> n1
const P = n0.next;
const n1 = P.next;
n0.next = n1;
}
1
2
3
4
5
6
/* 在链表的节点 n0 之后插入节点 P */
function insert(n0, P) {
const n1 = n0.next;
P.next = n1;
n0.next = P;
}

访问结点和查找结点

***在链表中访问节点的效率较低。***我们可以在$O(1)$时间下访问数组中的任意元素。链表则不然,程序需要从头节点出发,逐个向后遍历,直至找到目标节点。也就是说,访问链表的第$i$个节点需要循环$i-1$轮,时间复杂度为$O(n)$。

查找结点也是循环遍历。

数组和链表的区别

  • 存储方式不同:前者是 连续内存空间,后者是 分散内存空间
  • 容量扩展不同:前者是 长度不可变,后者是 可灵活扩展
  • 内存效率不同:前者是 元素占用内存少、但可能浪费空间,后者是 元素占用内存多
  • 访问元素速度不同:前者是 $O(1)$,后者是 $O(n)$
  • 添加和删除元素速度不同:前者是 $O(n)$,后者是 $O(1)$

常见链表类型

  • 单向链表:即前面介绍的普通链表。单向链表的节点包含值和指向下一节点的引用两项数据。我们将首个节点称为头节点,将最后一个节点称为尾节点,尾节点指向空 None
  • 环形链表:如果我们令单向链表的尾节点指向头节点(首尾相接),则得到一个环形链表。在环形链表中,任意节点都可以视作头节点。
  • 双向链表:与单向链表相比,双向链表记录了两个方向的引用。双向链表的节点定义同时包含指向后继节点(下一个节点)和前驱节点(上一个节点)的引用(指针)。相较于单向链表,双向链表更具灵活性,可以朝两个方向遍历链表,但相应地也需要占用更多的内存空间。
1
2
3
4
5
6
class ListNode:
"""双向链表节点类"""
def __init__(self, val: int):
self.val: int = val # 节点值
self.next: ListNode | None = None # 指向后继节点的引用
self.prev: ListNode | None = None # 指向前驱节点的引用
1
2
3
4
5
6
7
/* 双向链表节点结构体 */
struct ListNode {
int val; // 节点值
ListNode *next; // 指向后继节点的指针
ListNode *prev; // 指向前驱节点的指针
ListNode(int x) : val(x), next(nullptr), prev(nullptr) {} // 构造函数
};
1
2
3
4
5
6
7
/* 双向链表节点类 */
class ListNode {
int val; // 节点值
ListNode next; // 指向后继节点的引用
ListNode prev; // 指向前驱节点的引用
ListNode(int x) { val = x; } // 构造函数
}
1
2
3
4
5
6
/* 双向链表节点类 */
class ListNode(int x) { // 构造函数
int val = x; // 节点值
ListNode next; // 指向后继节点的引用
ListNode prev; // 指向前驱节点的引用
}
1
2
3
4
5
6
7
8
/* 双向链表节点类 */
class ListNode {
constructor(val, next, prev) {
this.val = val === undefined ? 0 : val; // 节点值
this.next = next === undefined ? null : next; // 指向后继节点的引用
this.prev = prev === undefined ? null : prev; // 指向前驱节点的引用
}
}

链表的基本应用

单向链表通常用于实现栈、队列、哈希表和图等数据结构。

  • 栈与队列:当插入和删除操作都在链表的一端进行时,它表现出先进后出的特性,对应栈;当插入操作在链表的一端进行,删除操作在链表的另一端进行,它表现出先进先出的特性,对应队列。

  • 哈希表:链式地址是解决哈希冲突的主流方案之一,在该方案中,所有冲突的元素都会被放到一个链表中。

  • :邻接表是表示图的一种常用方式,其中图的每个顶点都与一个链表相关联,链表中的每个元素都代表与该顶点相连的其他顶点。
    双向链表常用于需要快速查找前一个和后一个元素的场景。

  • 高级数据结构:比如在红黑树、B 树中,我们需要访问节点的父节点,这可以通过在节点中保存一个指向父节点的引用来实现,类似于双向链表。
    浏览器历史:在网页浏览器中,当用户点击前进或后退按钮时,浏览器需要知道用户访问过的前一个和后一个网页。双向链表的特性使得这种操作变得简单。

  • LRU 算法:在缓存淘汰(LRU)算法中,我们需要快速找到最近最少使用的数据,以及支持快速添加和删除节点。这时候使用双向链表就非常合适。
    环形链表常用于需要周期性操作的场景,比如操作系统的资源调度。

  • 时间片轮转调度算法:在操作系统中,时间片轮转调度算法是一种常见的 CPU 调度算法,它需要对一组进程进行循环。每个进程被赋予一个时间片,当时间片用完时,CPU 将切换到下一个进程。这种循环操作可以通过环形链表来实现。

  • 数据缓冲区:在某些数据缓冲区的实现中,也可能会使用环形链表。比如在音频、视频播放器中,数据流可能会被分成多个缓冲块并放入一个环形链表,以便实现无缝播放。

总结

  • 捋清楚每个元素指向的位置是写好链表算法最为重要的一步。
  • 数组和链表是两种基本的数据结构,分别代表数据在计算机内存中的两种存储方式:连续空间存储和分散空间存储。两者的特点呈现出互补的特性。
  • 数组支持随机访问、占用内存较少;但插入和删除元素效率低,且初始化后长度不可变。
  • 链表通过更改引用(指针)实现高效的节点插入与删除,且可以灵活调整长度;但节点访问效率低、占用内存较多。常见的链表类型包括单向链表、环形链表、双向链表。
  • 列表是一种支持增删查改的元素有序集合,通常基于动态数组实现。它保留了数组的优势,同时可以灵活调整长度。
  • 列表的出现大幅提高了数组的实用性,但可能导致部分内存空间浪费。
    程序运行时,数据主要存储在内存中。数组可提供更高的内存空间效率,而链表则在内存使用上更加灵活。
  • 缓存通过缓存行、预取机制以及空间局部性和时间局部性等数据加载机制,为 CPU 提供快速数据访问,显著提升程序的执行效率。
  • 由于数组具有更高的缓存命中率,因此它通常比链表更高效。在选择数据结构时,应根据具体需求和场景做出恰当选择。
  • 存储在栈上和堆上的数组都被存储在连续内存空间内,数据操作效率基本一致。然而,栈和堆具有各自的特点,从而导致以下不同点。
    1. 分配和释放效率:栈是一块较小的内存,分配由编译器自动完成;而堆内存相对更大,可以在代码中动态分配,更容易碎片化。因此,堆上的分配和释放操作通常比栈上的慢。
    2. 大小限制:栈内存相对较小,堆的大小一般受限于可用内存。因此堆更加适合存储大型数组。
    3. 灵活性:栈上的数组的大小需要在编译时确定,而堆上的数组的大小可以在运行时动态确定。

链表算法练习

反转链表1

输入:head = [3,6,4,1]
输出:[1,4,6,3]

反转有着 后进先出 的特性,所以可以使用栈来解决问题
由于重复性操作,本题也可以使用递归。
注意返回的是数组哦。

Java代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public int[] reverseBookList(ListNode head) {
Stack<Integer>ans = new Stack<>();
while(head!=null){
ans.push(head.val);
head = head.next;
}
int[] tmp = new int[ans.size()];
int i = 0;
while(!ans.isEmpty()){
tmp[i] = ans.pop();
i++;
}
return tmp;
}
}

反转列表2

输入:head = [1,2,3,4,5]
输出:[5,4,3,2,1]

定义一个头节点用于循环遍历,再定义一个空结点用于储存,让头节点指向储存结点就可以达到反转效果。1 到 1 <-2 到 1 <- 2 <- 3 ···

Java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public ListNode trainningPlan(ListNode head) {
if(head==null)return head;
ListNode pre = null ,cur = head;
while(cur!=null){
ListNode tmp = cur.next;//记录下一个结点,防止丢失。
cur.next = pre;//让头节点指向存储结点。
pre = cur;//存储数据
cur = tmp;
}
return pre;
}
}

删除节点

输入: head = [4,5,1,9], val = 5
输出: [4,1,9]
解释: 给定你链表中值为 5 的第二个节点,那么在调用了你的函数之后,该链表应变为 4 -> 1 -> 9.

本题思路有两种思路,一种是多走一步,一种是快慢指针。

Java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) { val = x; }
* }
*/

// 多走一步
class Solution {
public ListNode deleteNode(ListNode head, int val) {
if(head==null || head.val==val)return head.next;
ListNode first = head;
while(head.next!=null){
if(head.next.val==val){
head.next = head.next.next;
break;
}
head = head.next;
}
return first;
}
}

//快慢指针
class Solution {
public ListNode deleteNode(ListNode head, int val) {
if(head.val==val)return head.next;
ListNode quick=head,slow=head;

while(quick!=null){
if(quick.val==val){
slow.next = quick.next;//让慢结点的下一个是快结点的下一个结点,就相当于跳过了等于要删除结点的快结点。
break;
}
slow = quick; // 这个顺序是很重要的
quick = quick.next;
}
return head;

}
}

倒序查找

请查找并返回倒数第 cnt 个的数据
输入:head = [2,4,7,8], cnt = 1
输出:8

本题直接思路是循环遍历一遍得到链表长度length,用length-cnt可以得到顺序位置,再次循环得到答案。

深入思考一下可以发现当我们先走cnt,此时还未走的距离为length-cnt。这不就是我们想要的结果吗。那我们只需要在定义一个小跟班跟着这个结点走完全部路程,这个小跟班的位置不就是最后答案吗。

Java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public ListNode trainingPlan(ListNode head, int cnt) {
ListNode first = head ,second = head;
for(int i=0;i<cnt;i++){
first = first.next;
}
while(first!=null){
first = first.next;
second = second.next;
}
return second;
}
}

合并有序链表

输入:l1 = [1,2,4], l2 = [1,3,4]
输出:[1,1,2,3,4,4]

直接思路比大小,直接构造链表

java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
//直接比大小版本
class Solution {
public ListNode trainningPlan(ListNode l1, ListNode l2) {
ListNode ans = new ListNode(0),pre = ans;
while(l1!=null && l2!=null){
if(l1.val<l2.val){
pre.next = l1;
l1 = l1.next;
}else{
pre.next = l2;
l2 = l2.next;
}
pre = pre.next;
}
pre.next = l1!=null?l1:l2;
return ans.next;
}
}

//递归版本
class Solution {
public ListNode trainningPlan(ListNode l1, ListNode l2) {
if(l1==null || l2==null) return l1==null?l2:l1;

if(l1.val>=l2.val)l2.next = trainningPlan(l1,l2.next);
else l1.next = trainningPlan(l1.next,l2);

return l1.val>=l2.val?l2:l1;

}
}

查找相同结点

输入: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 个节点。

这题的思路很巧妙,当然完全可以通过两次循环去找到相同点,但是这需要额外开辟空间和多次循环。

设第一个公共节点为 node ,链表 headA 的节点数量为 a ,链表 headB 的节点数量为 b ,两链表的公共尾部的节点数量为 c ,则有:

  • 头节点 headAnode 前,共有 a−c 个节点;
  • 头节点 headBnode 前,共有 b−c 个节点;

考虑构建两个节点指针 A​ , B 分别指向两链表头节点 headA , headB ,做如下操作:

  • 指针 A 先遍历完链表 headA ,再开始遍历链表 headB ,当走到 node 时,共走步数为:
$a+(b-c)$
  • 指针 B 先遍历完链表 headB ,再开始遍历链表 headA ,当走到 node 时,共走步数为:
$b+(a-c)$

如下式所示,此时指针 A , B 重合,并有两种情况:

$a+(b−c)=b+(a−c)$
  1. 若两链表 公共尾部 (即 c > 0 ) :指针A , B 同时指向「第一个公共节点」node
  2. 若两链表 公共尾部 (即 c = 0 ) :指针 A , B 同时指向
    null
    因此返回A即可。

java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
class Solution {
ListNode getIntersectionNode(ListNode headA, ListNode headB) {
ListNode a = headA,b = headB;
while(a!=b){
a = a!=null?a.next:headB;
b = b!=null?b.next:headA;
}
return a;
}
}

栈与队列

栈的常用操作

「栈 stack」是一种遵循先入后出逻辑的线性数据结构。
把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫作“入栈”,删除栈顶元素的操作叫作“出栈”。

  • 两个栈是可以模拟队列的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 初始化栈
# Python 没有内置的栈类,可以把 list 当作栈来使用
stack: list[int] = []

# 元素入栈
stack.append(1)
stack.append(3)
stack.append(2)
stack.append(5)
stack.append(4)

# 访问栈顶元素
peek: int = stack[-1]

# 元素出栈
pop: int = stack.pop()

# 获取栈的长度
size: int = len(stack)

# 判断是否为空
is_empty: bool = len(stack) == 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 初始化栈 */
stack<int> stack;

/* 元素入栈 */
stack.push(1);
stack.push(3);
stack.push(2);
stack.push(5);
stack.push(4);

/* 访问栈顶元素 */
int top = stack.top();

/* 元素出栈 */
stack.pop(); // 无返回值

/* 获取栈的长度 */
int size = stack.size();

/* 判断是否为空 */
bool empty = stack.empty();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 初始化栈 */
Stack<Integer> stack = new Stack<>();

/* 元素入栈 */
stack.push(1);
stack.push(3);
stack.push(2);
stack.push(5);
stack.push(4);

/* 访问栈顶元素 */
int peek = stack.peek();

/* 元素出栈 */
int pop = stack.pop();

/* 获取栈的长度 */
int size = stack.size();

/* 判断是否为空 */
boolean isEmpty = stack.isEmpty();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 初始化栈 */
Stack<int> stack = new();

/* 元素入栈 */
stack.Push(1);
stack.Push(3);
stack.Push(2);
stack.Push(5);
stack.Push(4);

/* 访问栈顶元素 */
int peek = stack.Peek();

/* 元素出栈 */
int pop = stack.Pop();

/* 获取栈的长度 */
int size = stack.Count;

/* 判断是否为空 */
bool isEmpty = stack.Count == 0;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* 初始化栈 */
// JavaScript 没有内置的栈类,可以把 Array 当作栈来使用
const stack = [];

/* 元素入栈 */
stack.push(1);
stack.push(3);
stack.push(2);
stack.push(5);
stack.push(4);

/* 访问栈顶元素 */
const peek = stack[stack.length-1];

/* 元素出栈 */
const pop = stack.pop();

/* 获取栈的长度 */
const size = stack.length;

/* 判断是否为空 */
const is_empty = stack.length === 0;

队列的基本操作

「队列 queue」是一种遵循先入先出规则的线性数据结构。
将队列头部称为“队首”,尾部称为“队尾”,将把元素加入队尾的操作称为“入队”,删除队首元素的操作称为“出队”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from collections import deque

# 初始化队列
# 在 Python 中,我们一般将双向队列类 deque 当作队列使用
# 虽然 queue.Queue() 是纯正的队列类,但不太好用,因此不推荐
que: deque[int] = deque()

# 元素入队
que.append(1)
que.append(3)
que.append(2)
que.append(5)
que.append(4)

# 访问队首元素
front: int = que[0];

# 元素出队
pop: int = que.popleft()

# 获取队列的长度
size: int = len(que)

# 判断队列是否为空
is_empty: bool = len(que) == 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 初始化队列 */
queue<int> queue;

/* 元素入队 */
queue.push(1);
queue.push(3);
queue.push(2);
queue.push(5);
queue.push(4);

/* 访问队首元素 */
int front = queue.front();

/* 元素出队 */
queue.pop();

/* 获取队列的长度 */
int size = queue.size();

/* 判断队列是否为空 */
bool empty = queue.empty();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 初始化队列 */
Queue<Integer> queue = new LinkedList<>();

/* 元素入队 */
queue.offer(1);
queue.offer(3);
queue.offer(2);
queue.offer(5);
queue.offer(4);

/* 访问队首元素 */
int peek = queue.peek();

/* 元素出队 */
int pop = queue.poll();

/* 获取队列的长度 */
int size = queue.size();

/* 判断队列是否为空 */
boolean isEmpty = queue.isEmpty();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* 初始化队列 */
Queue<int> queue = new();

/* 元素入队 */
queue.Enqueue(1);
queue.Enqueue(3);
queue.Enqueue(2);
queue.Enqueue(5);
queue.Enqueue(4);

/* 访问队首元素 */
int peek = queue.Peek();

/* 元素出队 */
int pop = queue.Dequeue();

/* 获取队列的长度 */
int size = queue.Count;

/* 判断队列是否为空 */
bool isEmpty = queue.Count == 0;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 初始化队列 */
// JavaScript 没有内置的队列,可以把 Array 当作队列来使用
const queue = [];

/* 元素入队 */
queue.push(1);
queue.push(3);
queue.push(2);
queue.push(5);
queue.push(4);

/* 访问队首元素 */
const peek = queue[0];

/* 元素出队 */
// 底层是数组,因此 shift() 方法的时间复杂度为 O(n)
const pop = queue.shift();

/* 获取队列的长度 */
const size = queue.length;

/* 判断队列是否为空 */
const empty = queue.length === 0;

双向队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from collections import deque

# 初始化双向队列
deque: deque[int] = deque()

# 元素入队
deque.append(2) # 添加至队尾
deque.append(5)
deque.append(4)
deque.appendleft(3) # 添加至队首
deque.appendleft(1)

# 访问元素
front: int = deque[0] # 队首元素
rear: int = deque[-1] # 队尾元素

# 元素出队
pop_front: int = deque.popleft() # 队首元素出队
pop_rear: int = deque.pop() # 队尾元素出队

# 获取双向队列的长度
size: int = len(deque)

# 判断双向队列是否为空
is_empty: bool = len(deque) == 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 初始化双向队列 */
deque<int> deque;

/* 元素入队 */
deque.push_back(2); // 添加至队尾
deque.push_back(5);
deque.push_back(4);
deque.push_front(3); // 添加至队首
deque.push_front(1);

/* 访问元素 */
int front = deque.front(); // 队首元素
int back = deque.back(); // 队尾元素

/* 元素出队 */
deque.pop_front(); // 队首元素出队
deque.pop_back(); // 队尾元素出队

/* 获取双向队列的长度 */
int size = deque.size();

/* 判断双向队列是否为空 */
bool empty = deque.empty();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 初始化双向队列 */
Deque<Integer> deque = new LinkedList<>();

/* 元素入队 */
deque.offerLast(2); // 添加至队尾
deque.offerLast(5);
deque.offerLast(4);
deque.offerFirst(3); // 添加至队首
deque.offerFirst(1);

/* 访问元素 */
int peekFirst = deque.peekFirst(); // 队首元素
int peekLast = deque.peekLast(); // 队尾元素

/* 元素出队 */
int popFirst = deque.pollFirst(); // 队首元素出队
int popLast = deque.pollLast(); // 队尾元素出队

/* 获取双向队列的长度 */
int size = deque.size();

/* 判断双向队列是否为空 */
boolean isEmpty = deque.isEmpty();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* 初始化双向队列 */
// 在 C# 中,将链表 LinkedList 看作双向队列来使用
LinkedList<int> deque = new();

/* 元素入队 */
deque.AddLast(2); // 添加至队尾
deque.AddLast(5);
deque.AddLast(4);
deque.AddFirst(3); // 添加至队首
deque.AddFirst(1);

/* 访问元素 */
int peekFirst = deque.First.Value; // 队首元素
int peekLast = deque.Last.Value; // 队尾元素

/* 元素出队 */
deque.RemoveFirst(); // 队首元素出队
deque.RemoveLast(); // 队尾元素出队

/* 获取双向队列的长度 */
int size = deque.Count;

/* 判断双向队列是否为空 */
bool isEmpty = deque.Count == 0;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* 初始化双向队列 */
// JavaScript 没有内置的双端队列,只能把 Array 当作双端队列来使用
const deque = [];

/* 元素入队 */
deque.push(2);
deque.push(5);
deque.push(4);
// 请注意,由于是数组,unshift() 方法的时间复杂度为 O(n)
deque.unshift(3);
deque.unshift(1);

/* 访问元素 */
const peekFirst = deque[0];
const peekLast = deque[deque.length - 1];

/* 元素出队 */
// 请注意,由于是数组,shift() 方法的时间复杂度为 O(n)
const popFront = deque.shift();
const popBack = deque.pop();

/* 获取双向队列的长度 */
const size = deque.length;

/* 判断双向队列是否为空 */
const isEmpty = size === 0;

算法锻炼

最小栈

请你设计一个 最小栈 。它提供 pushpoptop 操作,并能在{ % u 常数时间 %} 内检索到最小元素的栈。

输入:
[“MinStack”,“push”,“push”,“push”,“getMin”,“pop”,“top”,“getMin”]
[[],[-2],[2],[-3],[],[],[],[]]

输出:
[null,null,null,null,-3,null,2,-2]

解释:
MinStack minStack = new MinStack();
minStack.push(-2);
minStack.push(2);
minStack.push(-3);
minStack.getMin();   --> 返回 -3.
minStack.pop();
minStack.top();      --> 返回 2.
minStack.getMin();   --> 返回 -2.

本题很明显是要使用栈来实现,但是难点在于如何在常数时间内获取堆栈中的最小元素。
试想一下:当我们设定一个min去记录每次压栈元素的最小值,貌似这种方法是可行的。但是一旦出栈元素等于我们的min时,我们便会失去最小值。也就是说我们需要多次记录最小值,也就是拥有可以回退的功能。
那好,现在定义两个栈A,B。一个用来正常记录数据,一个用来记录最小值。
A 中压入 -2,此时B为空,我们也将其压入,B也有元素-2
接着A压入22大于B中的栈顶元素-2,不压入。
那么A: -2 2 B : -2(右侧为栈顶)。
现在压入-3-3小于B栈顶元素,压栈。
那么A: -2 2 -3 B : -2 -3(右侧为栈顶)。
输出最小值,不就是B的栈顶元素吗,如若我们弹出-3,因为-3B栈顶元素相同,所以B也要弹出栈顶元素。那么此时最小值就是-2。如此类推,便完成此题。

java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class MinStack {
Stack<Integer> A,B;
/** initialize your data structure here. */
public MinStack() {
A = new Stack<Integer>();
B = new Stack<Integer>();
}

public void push(int x) {
A.push(x);
if(B.isEmpty())B.push(x);
else if(B.peek()>=x)B.push(x);
}

public void pop() {
if(A.pop().equals(B.peek())&&!B.isEmpty())B.pop();
}

public int top() {
return A.peek();
}

public int getMin() {
return B.peek();
}
}

类似题

数据流中的中位数

输入:
[“MedianFinder”,“addNum”,“addNum”,“findMedian”,“addNum”,“findMedian”]
[[],[1],[2],[],[3],[]]
输出:[null,null,null,1.50000,null,2.00000]

  • 知识点:Java中优先队列用PriorityQueue表示,底层用堆(heap)实现,在优先队列中,任何时刻队首元素都是当前队列中优先级最高(小根堆,默认)或最低(大根堆)的元素。每次出队或入队后,会自动调整结构,始终保证队首元素优先级最高。入队、出队优先级均为 $log_2n$。
    优先队列代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.util.PriorityQueue;

public class Test {

public static void main(String[] args) {
// 默认小根堆
PriorityQueue<Integer> pq1 = new PriorityQueue<Integer>();
for (int i = 1; i <= 10; i++) {
pq1.add(i);
}
for (int i = 1; i < 10; i++) {
System.out.print(pq1.poll() + " ");
}
System.out.println();
// 大根堆实现
PriorityQueue<Integer> pq2 = new PriorityQueue<Integer>((x,y)->y-x);
for (int i = 1; i <= 10; i++) {
pq2.add(i);
}
for (int i = 1; i < 10; i++) {
System.out.print(pq2.poll() + " ");
}
}
}

解题思路: 本题关键在于如何将数据有序化。通过优先队列即可完成。
我们将一组数据按照大小堆的形式存放。 A中存放大数,B中存放小数。但是我们只能保证一侧有序化,如何确保插入元素在全局上有序呢。

假设插入数字 num 遇到情况 1. 。由于 num 可能属于 “较小的一半” (即属于 B ),因此不能将 nums 直接插入至 A 。而应先将 num 插入至 B ,再将B 堆顶元素插入至 A 。这样就可以始终保A 保存较大一半、 B 保存较小一半。(优先队列会自动完成有序化)

java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MedianFinder {
Queue<Integer>A,B;
/** initialize your data structure here. */
public MedianFinder() {
A = new PriorityQueue<>();//小顶堆
B = new PriorityQueue<>((x,y)->(y-x));//大顶堆
}

public void addNum(int num) {
if(A.size()!=B.size()){
A.add(num);
B.add(A.poll());
}else{
B.add(num);
A.add(B.poll());
}

}

public double findMedian() {
return A.size() != B.size()?A.peek():(A.peek()+B.peek())/2.0;
}
}

哈希表

「哈希表 hash table」,又称「散列表」,它通过建立键 key 与值 value 之间的映射,实现高效的元素查询。具体而言,我们向哈希表中输入一个键 key ,则可以在 $O(1)$时间内获取对应的值 value 。

通常情况下哈希函数的输入空间远大于输出空间,因此理论上哈希冲突是不可避免的。比如,输入空间为全体整数,输出空间为数组容量大小,则必然有多个整数映射至同一桶索引。

哈希冲突会导致查询结果错误,严重影响哈希表的可用性。为了解决该问题,每当遇到哈希冲突时,我们就进行哈希表扩容,直至冲突消失为止。此方法简单粗暴且有效,但效率太低,因为哈希表扩容需要进行大量的数据搬运与哈希值计算。为了提升效率,我们可以采用以下策略。

  1. 改良哈希表数据结构,使得哈希表可以在出现哈希冲突时正常工作。
  2. 仅在必要时,即当哈希冲突比较严重时,才执行扩容操作。

哈希表的结构改良方法主要包括“ 链式地址 ”和“ 开放寻址 ”。

链式地址

基于链式地址实现的哈希表的操作方法发生了以下变化。

  • 查询元素:输入 key ,经过哈希函数得到桶索引,即可访问链表头节点,然后遍历链表并对比 key 以查找目标键值对。
  • 添加元素:首先通过哈希函数访问链表头节点,然后将节点(键值对)添加到链表中。
  • 删除元素:根据哈希函数的结果访问链表头部,接着遍历链表以查找目标节点并将其删除。

链式地址存在以下局限性。

  • 占用空间增大:链表包含节点指针,它相比数组更加耗费内存空间。
  • 查询效率降低:因为需要线性遍历链表来查找对应元素。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class HashMapChaining:
"""链式地址哈希表"""

def __init__(self):
"""构造方法"""
self.size = 0 # 键值对数量
self.capacity = 4 # 哈希表容量
self.load_thres = 2.0 / 3.0 # 触发扩容的负载因子阈值
self.extend_ratio = 2 # 扩容倍数
self.buckets = [[] for _ in range(self.capacity)] # 桶数组

def hash_func(self, key: int) -> int:
"""哈希函数"""
return key % self.capacity

def load_factor(self) -> float:
"""负载因子"""
return self.size / self.capacity

def get(self, key: int) -> str | None:
"""查询操作"""
index = self.hash_func(key)
bucket = self.buckets[index]
# 遍历桶,若找到 key ,则返回对应 val
for pair in bucket:
if pair.key == key:
return pair.val
# 若未找到 key ,则返回 None
return None

def put(self, key: int, val: str):
"""添加操作"""
# 当负载因子超过阈值时,执行扩容
if self.load_factor() > self.load_thres:
self.extend()
index = self.hash_func(key)
bucket = self.buckets[index]
# 遍历桶,若遇到指定 key ,则更新对应 val 并返回
for pair in bucket:
if pair.key == key:
pair.val = val
return
# 若无该 key ,则将键值对添加至尾部
pair = Pair(key, val)
bucket.append(pair)
self.size += 1

def remove(self, key: int):
"""删除操作"""
index = self.hash_func(key)
bucket = self.buckets[index]
# 遍历桶,从中删除键值对
for pair in bucket:
if pair.key == key:
bucket.remove(pair)
self.size -= 1
break

def extend(self):
"""扩容哈希表"""
# 暂存原哈希表
buckets = self.buckets
# 初始化扩容后的新哈希表
self.capacity *= self.extend_ratio
self.buckets = [[] for _ in range(self.capacity)]
self.size = 0
# 将键值对从原哈希表搬运至新哈希表
for bucket in buckets:
for pair in bucket:
self.put(pair.key, pair.val)

def print(self):
"""打印哈希表"""
for bucket in self.buckets:
res = []
for pair in bucket:
res.append(str(pair.key) + " -> " + pair.val)
print(res)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
/* 链式地址哈希表 */
class HashMapChaining {
private:
int size; // 键值对数量
int capacity; // 哈希表容量
double loadThres; // 触发扩容的负载因子阈值
int extendRatio; // 扩容倍数
vector<vector<Pair *>> buckets; // 桶数组

public:
/* 构造方法 */
HashMapChaining() : size(0), capacity(4), loadThres(2.0 / 3.0), extendRatio(2) {
buckets.resize(capacity);
}

/* 析构方法 */
~HashMapChaining() {
for (auto &bucket : buckets) {
for (Pair *pair : bucket) {
// 释放内存
delete pair;
}
}
}

/* 哈希函数 */
int hashFunc(int key) {
return key % capacity;
}

/* 负载因子 */
double loadFactor() {
return (double)size / (double)capacity;
}

/* 查询操作 */
string get(int key) {
int index = hashFunc(key);
// 遍历桶,若找到 key ,则返回对应 val
for (Pair *pair : buckets[index]) {
if (pair->key == key) {
return pair->val;
}
}
// 若未找到 key ,则返回空字符串
return "";
}

/* 添加操作 */
void put(int key, string val) {
// 当负载因子超过阈值时,执行扩容
if (loadFactor() > loadThres) {
extend();
}
int index = hashFunc(key);
// 遍历桶,若遇到指定 key ,则更新对应 val 并返回
for (Pair *pair : buckets[index]) {
if (pair->key == key) {
pair->val = val;
return;
}
}
// 若无该 key ,则将键值对添加至尾部
buckets[index].push_back(new Pair(key, val));
size++;
}

/* 删除操作 */
void remove(int key) {
int index = hashFunc(key);
auto &bucket = buckets[index];
// 遍历桶,从中删除键值对
for (int i = 0; i < bucket.size(); i++) {
if (bucket[i]->key == key) {
Pair *tmp = bucket[i];
bucket.erase(bucket.begin() + i); // 从中删除键值对
delete tmp; // 释放内存
size--;
return;
}
}
}

/* 扩容哈希表 */
void extend() {
// 暂存原哈希表
vector<vector<Pair *>> bucketsTmp = buckets;
// 初始化扩容后的新哈希表
capacity *= extendRatio;
buckets.clear();
buckets.resize(capacity);
size = 0;
// 将键值对从原哈希表搬运至新哈希表
for (auto &bucket : bucketsTmp) {
for (Pair *pair : bucket) {
put(pair->key, pair->val);
// 释放内存
delete pair;
}
}
}

/* 打印哈希表 */
void print() {
for (auto &bucket : buckets) {
cout << "[";
for (Pair *pair : bucket) {
cout << pair->key << " -> " << pair->val << ", ";
}
cout << "]\n";
}
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
/* 链式地址哈希表 */
class HashMapChaining {
int size; // 键值对数量
int capacity; // 哈希表容量
double loadThres; // 触发扩容的负载因子阈值
int extendRatio; // 扩容倍数
List<List<Pair>> buckets; // 桶数组

/* 构造方法 */
public HashMapChaining() {
size = 0;
capacity = 4;
loadThres = 2.0 / 3.0;
extendRatio = 2;
buckets = new ArrayList<>(capacity);
for (int i = 0; i < capacity; i++) {
buckets.add(new ArrayList<>());
}
}

/* 哈希函数 */
int hashFunc(int key) {
return key % capacity;
}

/* 负载因子 */
double loadFactor() {
return (double) size / capacity;
}

/* 查询操作 */
String get(int key) {
int index = hashFunc(key);
List<Pair> bucket = buckets.get(index);
// 遍历桶,若找到 key ,则返回对应 val
for (Pair pair : bucket) {
if (pair.key == key) {
return pair.val;
}
}
// 若未找到 key ,则返回 null
return null;
}

/* 添加操作 */
void put(int key, String val) {
// 当负载因子超过阈值时,执行扩容
if (loadFactor() > loadThres) {
extend();
}
int index = hashFunc(key);
List<Pair> bucket = buckets.get(index);
// 遍历桶,若遇到指定 key ,则更新对应 val 并返回
for (Pair pair : bucket) {
if (pair.key == key) {
pair.val = val;
return;
}
}
// 若无该 key ,则将键值对添加至尾部
Pair pair = new Pair(key, val);
bucket.add(pair);
size++;
}

/* 删除操作 */
void remove(int key) {
int index = hashFunc(key);
List<Pair> bucket = buckets.get(index);
// 遍历桶,从中删除键值对
for (Pair pair : bucket) {
if (pair.key == key) {
bucket.remove(pair);
size--;
break;
}
}
}

/* 扩容哈希表 */
void extend() {
// 暂存原哈希表
List<List<Pair>> bucketsTmp = buckets;
// 初始化扩容后的新哈希表
capacity *= extendRatio;
buckets = new ArrayList<>(capacity);
for (int i = 0; i < capacity; i++) {
buckets.add(new ArrayList<>());
}
size = 0;
// 将键值对从原哈希表搬运至新哈希表
for (List<Pair> bucket : bucketsTmp) {
for (Pair pair : bucket) {
put(pair.key, pair.val);
}
}
}

/* 打印哈希表 */
void print() {
for (List<Pair> bucket : buckets) {
List<String> res = new ArrayList<>();
for (Pair pair : bucket) {
res.add(pair.key + " -> " + pair.val);
}
System.out.println(res);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/* 链式地址哈希表 */
class HashMapChaining {
int size; // 键值对数量
int capacity; // 哈希表容量
double loadThres; // 触发扩容的负载因子阈值
int extendRatio; // 扩容倍数
List<List<Pair>> buckets; // 桶数组

/* 构造方法 */
public HashMapChaining() {
size = 0;
capacity = 4;
loadThres = 2.0 / 3.0;
extendRatio = 2;
buckets = new List<List<Pair>>(capacity);
for (int i = 0; i < capacity; i++) {
buckets.Add([]);
}
}

/* 哈希函数 */
int HashFunc(int key) {
return key % capacity;
}

/* 负载因子 */
double LoadFactor() {
return (double)size / capacity;
}

/* 查询操作 */
public string? Get(int key) {
int index = HashFunc(key);
// 遍历桶,若找到 key ,则返回对应 val
foreach (Pair pair in buckets[index]) {
if (pair.key == key) {
return pair.val;
}
}
// 若未找到 key ,则返回 null
return null;
}

/* 添加操作 */
public void Put(int key, string val) {
// 当负载因子超过阈值时,执行扩容
if (LoadFactor() > loadThres) {
Extend();
}
int index = HashFunc(key);
// 遍历桶,若遇到指定 key ,则更新对应 val 并返回
foreach (Pair pair in buckets[index]) {
if (pair.key == key) {
pair.val = val;
return;
}
}
// 若无该 key ,则将键值对添加至尾部
buckets[index].Add(new Pair(key, val));
size++;
}

/* 删除操作 */
public void Remove(int key) {
int index = HashFunc(key);
// 遍历桶,从中删除键值对
foreach (Pair pair in buckets[index].ToList()) {
if (pair.key == key) {
buckets[index].Remove(pair);
size--;
break;
}
}
}

/* 扩容哈希表 */
void Extend() {
// 暂存原哈希表
List<List<Pair>> bucketsTmp = buckets;
// 初始化扩容后的新哈希表
capacity *= extendRatio;
buckets = new List<List<Pair>>(capacity);
for (int i = 0; i < capacity; i++) {
buckets.Add([]);
}
size = 0;
// 将键值对从原哈希表搬运至新哈希表
foreach (List<Pair> bucket in bucketsTmp) {
foreach (Pair pair in bucket) {
Put(pair.key, pair.val);
}
}
}

/* 打印哈希表 */
public void Print() {
foreach (List<Pair> bucket in buckets) {
List<string> res = [];
foreach (Pair pair in bucket) {
res.Add(pair.key + " -> " + pair.val);
}
foreach (string kv in res) {
Console.WriteLine(kv);
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
/* 链式地址哈希表 */
class HashMapChaining {
#size; // 键值对数量
#capacity; // 哈希表容量
#loadThres; // 触发扩容的负载因子阈值
#extendRatio; // 扩容倍数
#buckets; // 桶数组

/* 构造方法 */
constructor() {
this.#size = 0;
this.#capacity = 4;
this.#loadThres = 2.0 / 3.0;
this.#extendRatio = 2;
this.#buckets = new Array(this.#capacity).fill(null).map((x) => []);
}

/* 哈希函数 */
#hashFunc(key) {
return key % this.#capacity;
}

/* 负载因子 */
#loadFactor() {
return this.#size / this.#capacity;
}

/* 查询操作 */
get(key) {
const index = this.#hashFunc(key);
const bucket = this.#buckets[index];
// 遍历桶,若找到 key ,则返回对应 val
for (const pair of bucket) {
if (pair.key === key) {
return pair.val;
}
}
// 若未找到 key ,则返回 null
return null;
}

/* 添加操作 */
put(key, val) {
// 当负载因子超过阈值时,执行扩容
if (this.#loadFactor() > this.#loadThres) {
this.#extend();
}
const index = this.#hashFunc(key);
const bucket = this.#buckets[index];
// 遍历桶,若遇到指定 key ,则更新对应 val 并返回
for (const pair of bucket) {
if (pair.key === key) {
pair.val = val;
return;
}
}
// 若无该 key ,则将键值对添加至尾部
const pair = new Pair(key, val);
bucket.push(pair);
this.#size++;
}

/* 删除操作 */
remove(key) {
const index = this.#hashFunc(key);
let bucket = this.#buckets[index];
// 遍历桶,从中删除键值对
for (let i = 0; i < bucket.length; i++) {
if (bucket[i].key === key) {
bucket.splice(i, 1);
this.#size--;
break;
}
}
}

/* 扩容哈希表 */
#extend() {
// 暂存原哈希表
const bucketsTmp = this.#buckets;
// 初始化扩容后的新哈希表
this.#capacity *= this.#extendRatio;
this.#buckets = new Array(this.#capacity).fill(null).map((x) => []);
this.#size = 0;
// 将键值对从原哈希表搬运至新哈希表
for (const bucket of bucketsTmp) {
for (const pair of bucket) {
this.put(pair.key, pair.val);
}
}
}

/* 打印哈希表 */
print() {
for (const bucket of this.#buckets) {
let res = [];
for (const pair of bucket) {
res.push(pair.key + ' -> ' + pair.val);
}
console.log(res);
}
}
}

当链表很长时,查询效率$O(n)$很差。此时可以将链表转换为“AVL 树”或“红黑树”,从而将查询操作的时间复杂度优化至$O(log n)$

开放寻址

线性探测

线性探测采用固定步长的线性搜索来进行探测,其操作方法与普通哈希表有所不同。

  • 插入元素:通过哈希函数计算桶索引,若发现桶内已有元素,则从冲突位置向后线性遍历(步长通常为$1$),直至找到空桶,将元素插入其中。
  • 查找元素:若发现哈希冲突,则使用相同步长向后进行线性遍历,直到找到对应元素,返回 value 即可;如果遇到空桶,说明目标元素不在哈希表中,返回 None

线性探测容易产生“聚集现象”。具体来说,数组中连续被占用的位置越长,这些连续位置发生哈希冲突的可能性越大,从而进一步促使该位置的聚堆生长,形成恶性循环,最终导致增删查改操作效率劣化。

值得注意的是,我们不能在开放寻址哈希表中直接删除元素。这是因为删除元素会在数组内产生一个空桶 None ,而当查询元素时,线性探测到该空桶就会返回,因此在该空桶之下的元素都无法再被访问到,程序可能误判这些元素不存在。

为了解决该问题,我们可以采用「懒删除 lazy deletion」机制:它不直接从哈希表中移除元素,而是利用一个常量 TOMBSTONE 来标记这个桶。在该机制下,None 和 TOMBSTONE 都代表空桶,都可以放置键值对。但不同的是,线性探测到 TOMBSTONE 时应该继续遍历,因为其之下可能还存在键值对。

然而,懒删除可能会加速哈希表的性能退化。这是因为每次删除操作都会产生一个删除标记,随着 TOMBSTONE 的增加,搜索时间也会增加,因为线性探测可能需要跳过多个 TOMBSTONE 才能找到目标元素。

为此,考虑在线性探测中记录遇到的首个 TOMBSTONE 的索引,并将搜索到的目标元素与该 TOMBSTONE 交换位置。这样做的好处是当每次查询或添加元素时,元素会被移动至距离理想位置(探测起始点)更近的桶,从而优化查询效率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
class HashMapOpenAddressing:
"""开放寻址哈希表"""

def __init__(self):
"""构造方法"""
self.size = 0 # 键值对数量
self.capacity = 4 # 哈希表容量
self.load_thres = 2.0 / 3.0 # 触发扩容的负载因子阈值
self.extend_ratio = 2 # 扩容倍数
self.buckets: list[Pair | None] = [None] * self.capacity # 桶数组
self.TOMBSTONE = Pair(-1, "-1") # 删除标记

def hash_func(self, key: int) -> int:
"""哈希函数"""
return key % self.capacity

def load_factor(self) -> float:
"""负载因子"""
return self.size / self.capacity

def find_bucket(self, key: int) -> int:
"""搜索 key 对应的桶索引"""
index = self.hash_func(key)
first_tombstone = -1
# 线性探测,当遇到空桶时跳出
while self.buckets[index] is not None:
# 若遇到 key ,返回对应的桶索引
if self.buckets[index].key == key:
# 若之前遇到了删除标记,则将键值对移动至该索引处
if first_tombstone != -1:
self.buckets[first_tombstone] = self.buckets[index]
self.buckets[index] = self.TOMBSTONE
return first_tombstone # 返回移动后的桶索引
return index # 返回桶索引
# 记录遇到的首个删除标记
if first_tombstone == -1 and self.buckets[index] is self.TOMBSTONE:
first_tombstone = index
# 计算桶索引,越过尾部则返回头部
index = (index + 1) % self.capacity
# 若 key 不存在,则返回添加点的索引
return index if first_tombstone == -1 else first_tombstone

def get(self, key: int) -> str:
"""查询操作"""
# 搜索 key 对应的桶索引
index = self.find_bucket(key)
# 若找到键值对,则返回对应 val
if self.buckets[index] not in [None, self.TOMBSTONE]:
return self.buckets[index].val
# 若键值对不存在,则返回 None
return None

def put(self, key: int, val: str):
"""添加操作"""
# 当负载因子超过阈值时,执行扩容
if self.load_factor() > self.load_thres:
self.extend()
# 搜索 key 对应的桶索引
index = self.find_bucket(key)
# 若找到键值对,则覆盖 val 并返回
if self.buckets[index] not in [None, self.TOMBSTONE]:
self.buckets[index].val = val
return
# 若键值对不存在,则添加该键值对
self.buckets[index] = Pair(key, val)
self.size += 1

def remove(self, key: int):
"""删除操作"""
# 搜索 key 对应的桶索引
index = self.find_bucket(key)
# 若找到键值对,则用删除标记覆盖它
if self.buckets[index] not in [None, self.TOMBSTONE]:
self.buckets[index] = self.TOMBSTONE
self.size -= 1

def extend(self):
"""扩容哈希表"""
# 暂存原哈希表
buckets_tmp = self.buckets
# 初始化扩容后的新哈希表
self.capacity *= self.extend_ratio
self.buckets = [None] * self.capacity
self.size = 0
# 将键值对从原哈希表搬运至新哈希表
for pair in buckets_tmp:
if pair not in [None, self.TOMBSTONE]:
self.put(pair.key, pair.val)

def print(self):
"""打印哈希表"""
for pair in self.buckets:
if pair is None:
print("None")
elif pair is self.TOMBSTONE:
print("TOMBSTONE")
else:
print(pair.key, "->", pair.val)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
/* 开放寻址哈希表 */
class HashMapOpenAddressing {
private:
int size; // 键值对数量
int capacity = 4; // 哈希表容量
const double loadThres = 2.0 / 3.0; // 触发扩容的负载因子阈值
const int extendRatio = 2; // 扩容倍数
vector<Pair *> buckets; // 桶数组
Pair *TOMBSTONE = new Pair(-1, "-1"); // 删除标记

public:
/* 构造方法 */
HashMapOpenAddressing() : size(0), buckets(capacity, nullptr) {
}

/* 析构方法 */
~HashMapOpenAddressing() {
for (Pair *pair : buckets) {
if (pair != nullptr && pair != TOMBSTONE) {
delete pair;
}
}
delete TOMBSTONE;
}

/* 哈希函数 */
int hashFunc(int key) {
return key % capacity;
}

/* 负载因子 */
double loadFactor() {
return (double)size / capacity;
}

/* 搜索 key 对应的桶索引 */
int findBucket(int key) {
int index = hashFunc(key);
int firstTombstone = -1;
// 线性探测,当遇到空桶时跳出
while (buckets[index] != nullptr) {
// 若遇到 key ,返回对应的桶索引
if (buckets[index]->key == key) {
// 若之前遇到了删除标记,则将键值对移动至该索引处
if (firstTombstone != -1) {
buckets[firstTombstone] = buckets[index];
buckets[index] = TOMBSTONE;
return firstTombstone; // 返回移动后的桶索引
}
return index; // 返回桶索引
}
// 记录遇到的首个删除标记
if (firstTombstone == -1 && buckets[index] == TOMBSTONE) {
firstTombstone = index;
}
// 计算桶索引,越过尾部则返回头部
index = (index + 1) % capacity;
}
// 若 key 不存在,则返回添加点的索引
return firstTombstone == -1 ? index : firstTombstone;
}

/* 查询操作 */
string get(int key) {
// 搜索 key 对应的桶索引
int index = findBucket(key);
// 若找到键值对,则返回对应 val
if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {
return buckets[index]->val;
}
// 若键值对不存在,则返回空字符串
return "";
}

/* 添加操作 */
void put(int key, string val) {
// 当负载因子超过阈值时,执行扩容
if (loadFactor() > loadThres) {
extend();
}
// 搜索 key 对应的桶索引
int index = findBucket(key);
// 若找到键值对,则覆盖 val 并返回
if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {
buckets[index]->val = val;
return;
}
// 若键值对不存在,则添加该键值对
buckets[index] = new Pair(key, val);
size++;
}

/* 删除操作 */
void remove(int key) {
// 搜索 key 对应的桶索引
int index = findBucket(key);
// 若找到键值对,则用删除标记覆盖它
if (buckets[index] != nullptr && buckets[index] != TOMBSTONE) {
delete buckets[index];
buckets[index] = TOMBSTONE;
size--;
}
}

/* 扩容哈希表 */
void extend() {
// 暂存原哈希表
vector<Pair *> bucketsTmp = buckets;
// 初始化扩容后的新哈希表
capacity *= extendRatio;
buckets = vector<Pair *>(capacity, nullptr);
size = 0;
// 将键值对从原哈希表搬运至新哈希表
for (Pair *pair : bucketsTmp) {
if (pair != nullptr && pair != TOMBSTONE) {
put(pair->key, pair->val);
delete pair;
}
}
}

/* 打印哈希表 */
void print() {
for (Pair *pair : buckets) {
if (pair == nullptr) {
cout << "nullptr" << endl;
} else if (pair == TOMBSTONE) {
cout << "TOMBSTONE" << endl;
} else {
cout << pair->key << " -> " << pair->val << endl;
}
}
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
/* 开放寻址哈希表 */
class HashMapOpenAddressing {
private int size; // 键值对数量
private int capacity = 4; // 哈希表容量
private final double loadThres = 2.0 / 3.0; // 触发扩容的负载因子阈值
private final int extendRatio = 2; // 扩容倍数
private Pair[] buckets; // 桶数组
private final Pair TOMBSTONE = new Pair(-1, "-1"); // 删除标记

/* 构造方法 */
public HashMapOpenAddressing() {
size = 0;
buckets = new Pair[capacity];
}

/* 哈希函数 */
private int hashFunc(int key) {
return key % capacity;
}

/* 负载因子 */
private double loadFactor() {
return (double) size / capacity;
}

/* 搜索 key 对应的桶索引 */
private int findBucket(int key) {
int index = hashFunc(key);
int firstTombstone = -1;
// 线性探测,当遇到空桶时跳出
while (buckets[index] != null) {
// 若遇到 key ,返回对应的桶索引
if (buckets[index].key == key) {
// 若之前遇到了删除标记,则将键值对移动至该索引处
if (firstTombstone != -1) {
buckets[firstTombstone] = buckets[index];
buckets[index] = TOMBSTONE;
return firstTombstone; // 返回移动后的桶索引
}
return index; // 返回桶索引
}
// 记录遇到的首个删除标记
if (firstTombstone == -1 && buckets[index] == TOMBSTONE) {
firstTombstone = index;
}
// 计算桶索引,越过尾部则返回头部
index = (index + 1) % capacity;
}
// 若 key 不存在,则返回添加点的索引
return firstTombstone == -1 ? index : firstTombstone;
}

/* 查询操作 */
public String get(int key) {
// 搜索 key 对应的桶索引
int index = findBucket(key);
// 若找到键值对,则返回对应 val
if (buckets[index] != null && buckets[index] != TOMBSTONE) {
return buckets[index].val;
}
// 若键值对不存在,则返回 null
return null;
}

/* 添加操作 */
public void put(int key, String val) {
// 当负载因子超过阈值时,执行扩容
if (loadFactor() > loadThres) {
extend();
}
// 搜索 key 对应的桶索引
int index = findBucket(key);
// 若找到键值对,则覆盖 val 并返回
if (buckets[index] != null && buckets[index] != TOMBSTONE) {
buckets[index].val = val;
return;
}
// 若键值对不存在,则添加该键值对
buckets[index] = new Pair(key, val);
size++;
}

/* 删除操作 */
public void remove(int key) {
// 搜索 key 对应的桶索引
int index = findBucket(key);
// 若找到键值对,则用删除标记覆盖它
if (buckets[index] != null && buckets[index] != TOMBSTONE) {
buckets[index] = TOMBSTONE;
size--;
}
}

/* 扩容哈希表 */
private void extend() {
// 暂存原哈希表
Pair[] bucketsTmp = buckets;
// 初始化扩容后的新哈希表
capacity *= extendRatio;
buckets = new Pair[capacity];
size = 0;
// 将键值对从原哈希表搬运至新哈希表
for (Pair pair : bucketsTmp) {
if (pair != null && pair != TOMBSTONE) {
put(pair.key, pair.val);
}
}
}

/* 打印哈希表 */
public void print() {
for (Pair pair : buckets) {
if (pair == null) {
System.out.println("null");
} else if (pair == TOMBSTONE) {
System.out.println("TOMBSTONE");
} else {
System.out.println(pair.key + " -> " + pair.val);
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
/* 开放寻址哈希表 */
class HashMapOpenAddressing {
int size; // 键值对数量
int capacity = 4; // 哈希表容量
double loadThres = 2.0 / 3.0; // 触发扩容的负载因子阈值
int extendRatio = 2; // 扩容倍数
Pair[] buckets; // 桶数组
Pair TOMBSTONE = new(-1, "-1"); // 删除标记

/* 构造方法 */
public HashMapOpenAddressing() {
size = 0;
buckets = new Pair[capacity];
}

/* 哈希函数 */
int HashFunc(int key) {
return key % capacity;
}

/* 负载因子 */
double LoadFactor() {
return (double)size / capacity;
}

/* 搜索 key 对应的桶索引 */
int FindBucket(int key) {
int index = HashFunc(key);
int firstTombstone = -1;
// 线性探测,当遇到空桶时跳出
while (buckets[index] != null) {
// 若遇到 key ,返回对应的桶索引
if (buckets[index].key == key) {
// 若之前遇到了删除标记,则将键值对移动至该索引处
if (firstTombstone != -1) {
buckets[firstTombstone] = buckets[index];
buckets[index] = TOMBSTONE;
return firstTombstone; // 返回移动后的桶索引
}
return index; // 返回桶索引
}
// 记录遇到的首个删除标记
if (firstTombstone == -1 && buckets[index] == TOMBSTONE) {
firstTombstone = index;
}
// 计算桶索引,越过尾部则返回头部
index = (index + 1) % capacity;
}
// 若 key 不存在,则返回添加点的索引
return firstTombstone == -1 ? index : firstTombstone;
}

/* 查询操作 */
public string? Get(int key) {
// 搜索 key 对应的桶索引
int index = FindBucket(key);
// 若找到键值对,则返回对应 val
if (buckets[index] != null && buckets[index] != TOMBSTONE) {
return buckets[index].val;
}
// 若键值对不存在,则返回 null
return null;
}

/* 添加操作 */
public void Put(int key, string val) {
// 当负载因子超过阈值时,执行扩容
if (LoadFactor() > loadThres) {
Extend();
}
// 搜索 key 对应的桶索引
int index = FindBucket(key);
// 若找到键值对,则覆盖 val 并返回
if (buckets[index] != null && buckets[index] != TOMBSTONE) {
buckets[index].val = val;
return;
}
// 若键值对不存在,则添加该键值对
buckets[index] = new Pair(key, val);
size++;
}

/* 删除操作 */
public void Remove(int key) {
// 搜索 key 对应的桶索引
int index = FindBucket(key);
// 若找到键值对,则用删除标记覆盖它
if (buckets[index] != null && buckets[index] != TOMBSTONE) {
buckets[index] = TOMBSTONE;
size--;
}
}

/* 扩容哈希表 */
void Extend() {
// 暂存原哈希表
Pair[] bucketsTmp = buckets;
// 初始化扩容后的新哈希表
capacity *= extendRatio;
buckets = new Pair[capacity];
size = 0;
// 将键值对从原哈希表搬运至新哈希表
foreach (Pair pair in bucketsTmp) {
if (pair != null && pair != TOMBSTONE) {
Put(pair.key, pair.val);
}
}
}

/* 打印哈希表 */
public void Print() {
foreach (Pair pair in buckets) {
if (pair == null) {
Console.WriteLine("null");
} else if (pair == TOMBSTONE) {
Console.WriteLine("TOMBSTONE");
} else {
Console.WriteLine(pair.key + " -> " + pair.val);
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
/* 开放寻址哈希表 */
class HashMapOpenAddressing {
#size; // 键值对数量
#capacity; // 哈希表容量
#loadThres; // 触发扩容的负载因子阈值
#extendRatio; // 扩容倍数
#buckets; // 桶数组
#TOMBSTONE; // 删除标记

/* 构造方法 */
constructor() {
this.#size = 0; // 键值对数量
this.#capacity = 4; // 哈希表容量
this.#loadThres = 2.0 / 3.0; // 触发扩容的负载因子阈值
this.#extendRatio = 2; // 扩容倍数
this.#buckets = Array(this.#capacity).fill(null); // 桶数组
this.#TOMBSTONE = new Pair(-1, '-1'); // 删除标记
}

/* 哈希函数 */
#hashFunc(key) {
return key % this.#capacity;
}

/* 负载因子 */
#loadFactor() {
return this.#size / this.#capacity;
}

/* 搜索 key 对应的桶索引 */
#findBucket(key) {
let index = this.#hashFunc(key);
let firstTombstone = -1;
// 线性探测,当遇到空桶时跳出
while (this.#buckets[index] !== null) {
// 若遇到 key ,返回对应的桶索引
if (this.#buckets[index].key === key) {
// 若之前遇到了删除标记,则将键值对移动至该索引处
if (firstTombstone !== -1) {
this.#buckets[firstTombstone] = this.#buckets[index];
this.#buckets[index] = this.#TOMBSTONE;
return firstTombstone; // 返回移动后的桶索引
}
return index; // 返回桶索引
}
// 记录遇到的首个删除标记
if (
firstTombstone === -1 &&
this.#buckets[index] === this.#TOMBSTONE
) {
firstTombstone = index;
}
// 计算桶索引,越过尾部则返回头部
index = (index + 1) % this.#capacity;
}
// 若 key 不存在,则返回添加点的索引
return firstTombstone === -1 ? index : firstTombstone;
}

/* 查询操作 */
get(key) {
// 搜索 key 对应的桶索引
const index = this.#findBucket(key);
// 若找到键值对,则返回对应 val
if (
this.#buckets[index] !== null &&
this.#buckets[index] !== this.#TOMBSTONE
) {
return this.#buckets[index].val;
}
// 若键值对不存在,则返回 null
return null;
}

/* 添加操作 */
put(key, val) {
// 当负载因子超过阈值时,执行扩容
if (this.#loadFactor() > this.#loadThres) {
this.#extend();
}
// 搜索 key 对应的桶索引
const index = this.#findBucket(key);
// 若找到键值对,则覆盖 val 并返回
if (
this.#buckets[index] !== null &&
this.#buckets[index] !== this.#TOMBSTONE
) {
this.#buckets[index].val = val;
return;
}
// 若键值对不存在,则添加该键值对
this.#buckets[index] = new Pair(key, val);
this.#size++;
}

/* 删除操作 */
remove(key) {
// 搜索 key 对应的桶索引
const index = this.#findBucket(key);
// 若找到键值对,则用删除标记覆盖它
if (
this.#buckets[index] !== null &&
this.#buckets[index] !== this.#TOMBSTONE
) {
this.#buckets[index] = this.#TOMBSTONE;
this.#size--;
}
}

/* 扩容哈希表 */
#extend() {
// 暂存原哈希表
const bucketsTmp = this.#buckets;
// 初始化扩容后的新哈希表
this.#capacity *= this.#extendRatio;
this.#buckets = Array(this.#capacity).fill(null);
this.#size = 0;
// 将键值对从原哈希表搬运至新哈希表
for (const pair of bucketsTmp) {
if (pair !== null && pair !== this.#TOMBSTONE) {
this.put(pair.key, pair.val);
}
}
}

/* 打印哈希表 */
print() {
for (const pair of this.#buckets) {
if (pair === null) {
console.log('null');
} else if (pair === this.#TOMBSTONE) {
console.log('TOMBSTONE');
} else {
console.log(pair.key + ' -> ' + pair.val);
}
}
}
}

平方探测

平方探测与线性探测类似,都是开放寻址的常见策略之一。当发生冲突时,平方探测不是简单地跳过一个固定的步数,而是跳过“探测次数的平方”的步数,即$1,4,9,·····$步。

平方探测主要具有以下优势。

  • 平方探测通过跳过探测次数平方的距离,试图缓解线性探测的聚集效应。

  • 平方探测会跳过更大的距离来寻找空位置,有助于数据分布得更加均匀。
    然而,平方探测并不是完美的。

  • 仍然存在聚集现象,即某些位置比其他位置更容易被占用。

  • 由于平方的增长,平方探测可能不会探测整个哈希表,这意味着即使哈希表中有空桶,平方探测也可能无法访问到它。

多次哈希

顾名思义,多次哈希方法使用多个哈希函数$ f_1(x),f_2(x),f_3(x)···$ 进行探测。

  • 插入元素:若哈希函数$f_1(x)$ 出现冲突,则尝试 $f_2(x)$,以此类推,直到找到空位后插入元素。
  • 查找元素:在相同的哈希函数顺序下进行查找,直到找到目标元素时返回;若遇到空位或已尝试所有哈希函数,说明哈希表中不存在该元素,则返回 None

与线性探测相比,多次哈希方法不易产生聚集,但多个哈希函数会带来额外的计算量。

请注意,开放寻址(线性探测、平方探测和多次哈希)哈希表都存在“不能直接删除元素”的问题。

二叉树

「二叉树 binary tree」是一种非线性数据结构,代表“祖先”与“后代”之间的派生关系,体现了“一分为二”的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含值、左子节点引用和右子节点引用。每个节点都有两个引用(指针),分别指向「左子节点 left-child node」和「右子节点 right-child node」,该节点被称为这两个子节点的「父节点 parent node」。当给定一个二叉树的节点时,我们将该节点的左子节点及其以下节点形成的树称为该节点的「左子树 left subtree」,同理可得「右子树 right subtree」。在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树。

  • 「根节点 root node」:位于二叉树顶层的节点,没有父节点。
  • 「叶节点 leaf node」:没有子节点的节点,其两个指针均指向 None 。
  • 「边 edge」:连接两个节点的线段,即节点引用(指针)。
  • 节点所在的「层 level」:从顶至底递增,根节点所在层为 1 。
  • 节点的「度 degree」:节点的子节点的数量。在二叉树中,度的取值范围是 0、1、2 。
  • 二叉树的「高度 height」:从根节点到最远叶节点所经过的边的数量。
  • 节点的「深度 depth」:从根节点到该节点所经过的边的数量。
  • 节点的「高度 height」:从距离该节点最远的叶节点到该节点所经过的边的数量。

常见二叉树类型

  • 「完美二叉树or满二叉树 perfect binary tree」所有层的节点都被完全填满。在完美二叉树中,叶节点的度为 $0$ ,其余所有节点的度都为$2$;若树的高度为$h$,则节点总数为$2^{h+1}-1$。

  • 「完全二叉树 complete binary tree」只有最底层的节点未被填满,且最底层节点尽量靠左填充。

  • 「完满二叉树 full binary tree」除了叶节点之外,其余所有节点都有两个子节点。

  • 「平衡二叉树 balanced binary tree」中任意节点的左子树和右子树的高度之差的绝对值不超过 1 。

二叉树的遍历

  1. 「层序遍历 level-order traversal」从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点。层序遍历本质上属于「广度优先遍历 breadth-first traversal」,也称「广度优先搜索 breadth-first search, BFS」,它体现了一种“一圈一圈向外扩展”的逐层遍历方式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def level_order(root: TreeNode | None) -> list[int]:
"""层序遍历"""
# 初始化队列,加入根节点
queue: deque[TreeNode] = deque()
queue.append(root)
# 初始化一个列表,用于保存遍历序列
res = []
while queue:
node: TreeNode = queue.popleft() # 队列出队
res.append(node.val) # 保存节点值
if node.left is not None:
queue.append(node.left) # 左子节点入队
if node.right is not None:
queue.append(node.right) # 右子节点入队
return res
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 层序遍历 */
vector<int> levelOrder(TreeNode *root) {
// 初始化队列,加入根节点
queue<TreeNode *> queue;
queue.push(root);
// 初始化一个列表,用于保存遍历序列
vector<int> vec;
while (!queue.empty()) {
TreeNode *node = queue.front();
queue.pop(); // 队列出队
vec.push_back(node->val); // 保存节点值
if (node->left != nullptr)
queue.push(node->left); // 左子节点入队
if (node->right != nullptr)
queue.push(node->right); // 右子节点入队
}
return vec;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* 层序遍历 */
List<Integer> levelOrder(TreeNode root) {
// 初始化队列,加入根节点
Queue<TreeNode> queue = new LinkedList<>();
queue.add(root);
// 初始化一个列表,用于保存遍历序列
List<Integer> 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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* 层序遍历 */
List<int> LevelOrder(TreeNode root) {
// 初始化队列,加入根节点
Queue<TreeNode> queue = new();
queue.Enqueue(root);
// 初始化一个列表,用于保存遍历序列
List<int> list = [];
while (queue.Count != 0) {
TreeNode node = queue.Dequeue(); // 队列出队
list.Add(node.val!.Value); // 保存节点值
if (node.left != null)
queue.Enqueue(node.left); // 左子节点入队
if (node.right != null)
queue.Enqueue(node.right); // 右子节点入队
}
return list;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 层序遍历 */
function levelOrder(root) {
// 初始化队列,加入根节点
const queue = [root];
// 初始化一个列表,用于保存遍历序列
const list = [];
while (queue.length) {
let node = queue.shift(); // 队列出队
list.push(node.val); // 保存节点值
if (node.left) queue.push(node.left); // 左子节点入队
if (node.right) queue.push(node.right); // 右子节点入队
}
return list;
}
  1. 前序、中序和后序遍历都属于「深度优先遍历 depth-first traversal」,也称「深度优先搜索 depth-first search, DFS」,它体现了一种“先走到尽头,再回溯继续”的遍历方式。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def pre_order(root: TreeNode | None):
"""前序遍历"""
if root is None:
return
# 访问优先级:根节点 -> 左子树 -> 右子树
res.append(root.val)
pre_order(root=root.left)
pre_order(root=root.right)

def in_order(root: TreeNode | None):
"""中序遍历"""
if root is None:
return
# 访问优先级:左子树 -> 根节点 -> 右子树
in_order(root=root.left)
res.append(root.val)
in_order(root=root.right)

def post_order(root: TreeNode | None):
"""后序遍历"""
if root is None:
return
# 访问优先级:左子树 -> 右子树 -> 根节点
post_order(root=root.left)
post_order(root=root.right)
res.append(root.val)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* 前序遍历 */
void preOrder(TreeNode *root) {
if (root == nullptr)
return;
// 访问优先级:根节点 -> 左子树 -> 右子树
vec.push_back(root->val);
preOrder(root->left);
preOrder(root->right);
}

/* 中序遍历 */
void inOrder(TreeNode *root) {
if (root == nullptr)
return;
// 访问优先级:左子树 -> 根节点 -> 右子树
inOrder(root->left);
vec.push_back(root->val);
inOrder(root->right);
}

/* 后序遍历 */
void postOrder(TreeNode *root) {
if (root == nullptr)
return;
// 访问优先级:左子树 -> 右子树 -> 根节点
postOrder(root->left);
postOrder(root->right);
vec.push_back(root->val);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/* 前序遍历 */
void preOrder(TreeNode root) {
if (root == null)
return;
// 访问优先级:根节点 -> 左子树 -> 右子树
list.add(root.val);
preOrder(root.left);
preOrder(root.right);
}

/* 中序遍历 */
void inOrder(TreeNode root) {
if (root == null)
return;
// 访问优先级:左子树 -> 根节点 -> 右子树
inOrder(root.left);
list.add(root.val);
inOrder(root.right);
}

/* 后序遍历 */
void postOrder(TreeNode root) {
if (root == null)
return;
// 访问优先级:左子树 -> 右子树 -> 根节点
postOrder(root.left);
postOrder(root.right);
list.add(root.val);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* 前序遍历 */
void PreOrder(TreeNode? root) {
if (root == null) return;
// 访问优先级:根节点 -> 左子树 -> 右子树
list.Add(root.val!.Value);
PreOrder(root.left);
PreOrder(root.right);
}

/* 中序遍历 */
void InOrder(TreeNode? root) {
if (root == null) return;
// 访问优先级:左子树 -> 根节点 -> 右子树
InOrder(root.left);
list.Add(root.val!.Value);
InOrder(root.right);
}

/* 后序遍历 */
void PostOrder(TreeNode? root) {
if (root == null) return;
// 访问优先级:左子树 -> 右子树 -> 根节点
PostOrder(root.left);
PostOrder(root.right);
list.Add(root.val!.Value);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* 前序遍历 */
function preOrder(root) {
if (root === null) return;
// 访问优先级:根节点 -> 左子树 -> 右子树
list.push(root.val);
preOrder(root.left);
preOrder(root.right);
}

/* 中序遍历 */
function inOrder(root) {
if (root === null) return;
// 访问优先级:左子树 -> 根节点 -> 右子树
inOrder(root.left);
list.push(root.val);
inOrder(root.right);
}

/* 后序遍历 */
function postOrder(root) {
if (root === null) return;
// 访问优先级:左子树 -> 右子树 -> 根节点
postOrder(root.left);
postOrder(root.right);
list.push(root.val);
}

广度优先遍历

  • 广度优先遍历是一种由近及远的遍历方式,从某个几节点出发,始终优先访问最近的顶点,并一层层向外扩张。

  • 广度优先通常使用队列来实现,队列的先入先出的性质和BFS的思想异曲同工。

  1. 将遍历起始顶点加入队列中开始循环
  2. 在每次循环的每轮迭代中,弹出队首顶点并记录访问,然后将该顶点的所以邻接点加入到队尾。
  3. 重复2,直到所有顶点被访问完毕。

java代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* 广度优先遍历 */
// 使用邻接表来表示图,以便获取指定顶点的所有邻接顶点
List<Vertex> graphBFS(GraphAdjList graph, Vertex startVet) {
// 顶点遍历序列
List<Vertex> res = new ArrayList<>();
// 哈希表,用于记录已被访问过的顶点
Set<Vertex> visited = new HashSet<>();
visited.add(startVet);
// 队列用于实现 BFS
Queue<Vertex> que = new LinkedList<>();
que.offer(startVet);
// 以顶点 vet 为起点,循环直至访问完所有顶点
while (!que.isEmpty()) {
Vertex vet = que.poll(); // 队首顶点出队
res.add(vet); // 记录访问顶点
// 遍历该顶点的所有邻接顶点
for (Vertex adjVet : graph.adjList.get(vet)) {
if (visited.contains(adjVet))
continue; // 跳过已被访问的顶点
que.offer(adjVet); // 只入队未访问的顶点
visited.add(adjVet); // 标记该顶点已被访问
}
}
// 返回顶点遍历序列
return res;
}

广度优先遍历的序列是不唯一的,多个相同的距离的顶点的遍历顺序允许被任意打乱

深度优先遍历

  • 深度优先遍历是一种优先走到底,无路可走再回头的遍历方式。(不撞南墙不回头)
    java代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 深度优先遍历辅助函数 */
void dfs(GraphAdjList graph, Set<Vertex> visited, List<Vertex> res, Vertex vet) {
res.add(vet); // 记录访问顶点
visited.add(vet); // 标记该顶点已被访问
// 遍历该顶点的所有邻接顶点
for (Vertex adjVet : graph.adjList.get(vet)) {
if (visited.contains(adjVet))
continue; // 跳过已被访问的顶点
// 递归访问邻接顶点
dfs(graph, visited, res, adjVet);
}
}

/* 深度优先遍历 */
// 使用邻接表来表示图,以便获取指定顶点的所有邻接顶点
List<Vertex> graphDFS(GraphAdjList graph, Vertex startVet) {
// 顶点遍历序列
List<Vertex> res = new ArrayList<>();
// 哈希表,用于记录已被访问过的顶点
Set<Vertex> visited = new HashSet<>();
dfs(graph, visited, res, startVet);
return res;
}

深度优先遍历的序列也是不唯一的

图的广度优先遍历是一种由近及远、层层扩张的搜索方式,通常借助队列实现。
图的深度优先遍历是一种优先走到底、无路可走时再回溯的搜索方式,常基于递归来实现。

排序算法

桶排序

  • 桶排序简单理解就是用一组有序的容器(桶),将原先无序的数组进行归类。这样按照有序的容器逐个输出,就将原先无序的数组变成有序的数组了。
    初始数组: 5 5 2 6 6 3 9 7
    准备有序容器从1-1010个桶,分别将初始数组元素装入。
    有序容器: 1 2 3 4 5 6 7 8 9 10
    装有个数: 0 1 1 0 2 2 1 0 1 0
    现在在按照有序容器的顺序输出(相当于按照容器顺序将桶内的数给拿出去),就可以达到排序的结果。

java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int[] tong = new int[1001];
int n = sc.nextInt();
for(int i=0;i<n;i++){
tong[sc.nextInt()] += 1;
}

for(int i=0;i<tong.length;i++){
while(tong[i]>0){
System.out.print(i);
System.out.print(' ');
tong[i] -= 1;
}
}
}

桶排序的时间复杂度是$O(M+N)$,这是一个很快速的排序算法,但是它特别浪费空间,试想一下如果你的数据范围是在0-2亿,但是你的数组实际上就只有5个数。你要准备的桶的数量是很多的。更进一步来说,当你需要排序的数据出现小数的时候,桶排序是很难做到。

冒泡排序

  • 基本思想:每次比较两个相邻的元素,如果它们的顺序错误就交换它们的位置。
    代码思路: 你想想假设5个数字排序,你每次冒泡交换位置之后可以确定一个元素的唯一位置,那么当你执行完第4个时,第五个就确定了。所以外层循环为n-1次。内层循环就是为了冒泡而生,交换相邻位置(jj+1) 这样冒泡排序就完成了。

java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int[] maopao = new int[n];
for(int i=0;i<n;i++){
maopao[i] = sc.nextInt();
}

for(int i=0;i<n-1;i++){
for(int j=0;j<n-i-1;j++){
if(maopao[j]<maopao[j+1]){
int tmp = maopao[j+1];
maopao[j+1] = maopao[j];
maopao[j] = tmp;
}
}
}
for(int i=0;i<n;i++){
System.out.print(maopao[i]);
System.out.print(' ');
}
}

冒泡排序的核心就是这个双层循环,时间复杂度是是 $O(N^2)$,当数据量特别大的时候,这个算法的时间复杂度是很大。

快速排序

快速排序是基于二分的思想,首先在这个序列中随便找一个数作为基准数M,为了将这个基准数移到某个位置k,使得左边的数都小于等于 M,右边的数都大于等于 M。

例如:“6 1 2 7 9 3 4 5 10 8”两端开始“探测”。先从右往左找一个小于 6 的数,再从左往右找一个大于 6 的数,然后交换它们。这里可以用两个变量 i 和 j,分别指向序列最左边和最右边让哨兵 j 先出动,这一点非常重要。哨兵 j 一步一步地向左挪动(即 j++),直到找到一个小于 6 的数停下来。接下来哨兵 i 再一步一步向右挪动(即 i++),直到找到一个大于 6的数停下来。最后哨兵 j 停在了数字 5 面前,哨兵 i 停在了数字 7 面前。现在交换二者位置。原序列变为 “6 1 2 5 9 3 4 7 10 8”
第一次交换结束。接下来哨兵 j 继续向左挪动(再次友情提醒,每次必须是哨兵j 先出发)。他发现了 4(比基准数 6 要小,满足要求)之后停了下来。哨兵 i 也继续向右挪动,他发现了 9(比基准数 6 要大,满足要求)之后停了下来。此时再次进行交换,交换之后的序列如下“6 1 2 5 4 3 9 7 10 8”
第二次交换结束,“探测”继续。哨兵 j 继续向左挪动,他发现了 3(比基准数 6 要小,满足要求)之后又停了下来。哨兵 i 继续向右移动,此时哨兵 i 和哨兵 j 相遇了,哨兵 i 和哨兵 j 都走到 3 面前。说明此时“探测”结束。我们将基准数 6 和 3 进行交换。交换之后的序列如下。“3 1 2 5 4 6 9 7 10 8 ”
这样我们就得到了一个满足要求的序列,此时以基准数 6 为分界点,6 左边的数都小于等于 6,6右边的数都大于等于 6。“3 1 2 5 4 6 9 7 10 8 ”
对剩下两边重复上面这个过程,就得到了一个有序序列。
变化过程如下:

10
6 1 2 7 9 3 4 5 10 8


3 1 2 5 4 6 9 7 10 8
2 1 3 5 4 6 9 7 10 8
1 2 3 5 4 6 9 7 10 8
1 2 3 5 4 6 9 7 10 8
1 2 3 4 5 6 9 7 10 8
1 2 3 4 5 6 9 7 10 8
1 2 3 4 5 6 8 7 9 10
1 2 3 4 5 6 7 8 9 10
1 2 3 4 5 6 7 8 9 10
1 2 3 4 5 6 7 8 9 10


java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public static void quicksort(int[] quick,int left,int right){

if(left>right)return;

int i = left,j = right,tmp=quick[left];
//tmp为基准数
while(i!=j){
//先右后左
while(quick[j]>=tmp && i<j)j--;
while(quick[i]<=tmp && i<j)i++;

//满足要求就交换位置
if(i<j){
int a = quick[i];
quick[i] = quick[j];
quick[j] = a;
}
}

//最终将基准数归位,实际上就是找到了基准数最后的位置。
quick[left] = quick[i];
quick[i] = tmp;


//现在就相当于一遍完成了。对左右两个序列用相同方法。
quicksort(quick,left,i-1);//左边序列
quicksort(quick,i+1,right);//右边序列
}

快速排序之所以比较快,是因为相比冒泡排序,每次交换是跳跃式的。每次排序的时候设置一个基准点,将小于等于基准点的数全部放到基准点的左边,将大于等于基准点的数全部放到基准点的右边。这样在每次交换的时候就不会像冒泡排序一样只能在相邻的数之间进行交换,交换的距离就大得多了。因此总的比较和交换次数就少了,速度自然就提高了。当然在最坏的情况下,仍可能是相邻的两个数进行了交换。因此快速排序的最差时间复杂度和冒泡排序是一样的,都是 $O(N^2)$,它的平均时间复杂度为 $O(NlogN)$。

算法优化

尾递归:

由于普通快速排序每轮选取「子数组最左元素」作为「基准数」,因此在输入数组 完全倒序 时, partition() 的递归深度会达到 N ,即 最差空间复杂度 为 $O(N)$ 。每轮递归时,仅对 较短的子数组 执行哨兵划分 partition() ,就可将最差的递归深度控制在 $O(logN)$ (每轮递归的子数组长度都 $≤$ 当前数组长度 $/2$ ),即实现最差空间复杂度 $O(logN)$ 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void quickSort(int[] nums, int l, int r) {
// 子数组长度为 1 时终止递归
while (l < r) {
// 哨兵划分操作
int i = partition(nums, l, r);
// 仅递归至较短子数组,控制递归深度
if (i - l < r - i) {
quickSort(nums, l, i - 1);
l = i + 1;
} else {
quickSort(nums, i + 1, r);
r = i - 1;
}
}
}

int partition(int[] nums, int l, int r) {
// 以 nums[l] 作为基准数
int i = l, j = r;
while (i < j) {
while (i < j && nums[j] >= nums[l]) j--;
while (i < j && nums[i] <= nums[l]) i++;
swap(nums, i, j);
}
swap(nums, i, l);
return i;
}

void swap(int[] nums, int i, int j) {
// 交换 nums[i] 和 nums[j]
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}

滚动窗口

输入:target = 12
输出:[[3, 4, 5]]
解释:在上述示例中,存在一个连续正整数序列的和为 12,为 [3, 4, 5]。

本题可以使用求和公式求解,但是我想要记录一下另外一种方法,滑动窗口

思路如下:传入target,找到一个数组求和等于它。我们可以假设一个一个小窗口
窗口初始位置i=1,末尾位置为j=2,窗口求和sum=3
i<j时不断循环,一开始sum=3 < target。那我们就让j++,相当于窗口扩大,sum += j 6 此时sum < target,重复刚刚的过程。sum=10 < target,当sum=15 >target 让窗口缩小,左侧移动,i++,此时窗口sum减小,sum -= i14,重复上述逻辑,i++ sum-=i12,那我们就找到了可以组成target数组的首相和尾项。最后组合数组就完成了。

Java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public int[][] fileCombination(int target) {
int i=1,j=2,sum=3;
List<int[]> res = new ArrayList<>();
while(i<j){
if(sum==target){
int[] ans = new int[j-i+1];
for(int k=i;k<=j;k++)
ans[k-i] = k;
res.add(ans);
}
if(sum>=target){
sum -= i;
i++;
}else{
j++;
sum+=j;
}
}
return res.toArray(new int[0][]);
}
}

kmp算法

我们先来看一道题:

给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。如果 needle 不是 haystack 的一部分,则返回  -1 。

输入:haystack = “sadbutsad”, needle = “sad”
输出:0
解释:“sad” 在下标 0 和 6 处匹配。
第一个匹配项的下标是 0 ,所以返回 0 。

暴力代码java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public int strStr(String haystack, String needle) {
if (needle.length() == 0)
return 0;
int i = 0;
int j = 0;
while (i < haystack.length() && j < needle.length()) {
if (haystack.charAt(i) == needle.charAt(j)) {
i++;
j++;
} else {
//如果不匹配,就回退,从第一次匹配的下一个开始,
i = i - j + 1;
j = 0;
}
if (j == needle.length())
return i - j;
}
return -1;
}

很容易想到的就是暴力算法,通过一个个匹配,如果发现不匹配的地方回退,重新匹配。但是这样的匹配算法的复杂度在$O(n*m)$,算法的效率是低的,那有没有一种高效的算法呢。kmp便是一种高效的字符匹配算法。

首先要理解kmp算法就必须要明白什么是前缀,什么是后缀。
前缀: 不包含最后一个字符的子串。
后缀: 不包含第一个字符的子串。

$abbab$
前缀: $a,ab,abb,abba$
后缀: $b,ab,bab,bbab$

当我们知道这个概念的时候,我们就可以改进我们的暴力算法了。
试想一下,当我们不断向下匹配的时候,突然发现一个字符匹配不上,按照暴力的思想我们是不是要将待匹配串从头开始和匹配串冲突下一位开始匹配。但是我们其实知道我们待匹配的字符串和匹配串之间是有匹配成功的地方,那我们是不是就可以跳过这个已经成功匹配的地方呢。实际上就是去找前后缀的最大子串。
举一个例子:
匹配串: a b a b a b a b c a
待匹配串:a b a b a b c a

第一次冲突:
a b (a b a b) a b c a
a b (a b a b) c a

我们将跳过重复字段(a b a b)那么此时:
a b (a b a b) a b c a
      (a b a b) a b c a

kmp算法简单来说就是不让匹配串回退,让它一路向前。跳过最大相同子串,实现剪枝。

实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public int strStr(String haystack, String needle) {
int i = 0;
int j = 0;
int[] next = new int[needle.length()];
getNext(needle, next);
while (i < haystack.length()) {
if (j == -1 || haystack.charAt(i) == needle.charAt(j)) {
i++;
j++;
} else {
j = next[j];
}
if (j == needle.length())
return i - j;
}
return -1;
}

private void getNext(String p, int next[]) {
int length = p.length();
int preIndex = 0;// 前一个位置的下标
int count = -1;//公共前缀的长度
next[0] = -1;
while (preIndex < length - 1) {
if (count == -1 || p.charAt(preIndex) == p.charAt(count)) {
preIndex++;
count++;
// preIndex执行加1就变成当前位置的下标了
next[preIndex] = count;
} else {
count = next[count];
}
}
}

快速幂算法

首先我们将 n 表示为 2 进制,举一个例子:
$3^{13} = 3^{(1101)_2} = 3^8 \cdot 3^4 \cdot 3^1$
因为 n 有 $\lfloor \log_2 n \rfloor + 1$ 个二进制位,因此当我们知道了$a^1$, $a^2$, $a^4$, $a^8$, …,后,我们只用计算 $O(logn)$次乘法就可以计算出 $a^n$。

1
2
3
4
5
6
7
8
9
10
11
12
def binpow(a, b):
res = 1
if(b < 0) {
x = 1 / x;
b = -b;
}
while b > 0:
if (b & 1):
res = res * a
a = a * a
b >>= 1
return res
1
2
3
4
5
6
7
8
9
10
11
12
13
long long binpow(long long a, long long b) {
long long res = 1;
if(b < 0) {
x = 1 / x;
b = -b;
}
while (b > 0) {
if (b & 1) res = res * a;
a = a * a;
b >>= 1;
}
return res;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public double myPow(double x, int n) {
if(x == 0.0f) return 0.0d;
long b = n;
double res = 1.0;
if(b < 0) {
x = 1 / x;
b = -b;
}
while(b > 0) {
if((b & 1) == 1) res *= x;
x *= x;
b >>= 1;
}
return res;
}
}

动态规划

  1. 零钱兑换

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。

输入:coins = [1, 2, 5], amount = 11
输出:3 解释:11 = 5 + 5 + 1

思路:我们将这个问题转化为多个小问题的求解。让这个大问题变成每一个最优小问题的解不就可以了吗。记dp[i] 表示i元需要最少的硬币数。
那么状态转移方程就是:dp[i] = min(dp[i],dp[i-coins[j]]+1)
解释一下这个dp[i-coins[j]]+1,这个表示i元需要用最少i-coins[j]个硬币和一个coins[j]元的硬币表示。

那么java代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public int coinChange(int[] coins, int amount) {
int max = amount + 1;
int[] dp = new int[max];//表示凑出0元需要硬币的最小个数
Arrays.fill(dp,max);
dp[0] = 0;//表示凑出0元需要硬币的个数为0
//动态思想就是将这个问题分割为多个小问题,我们要求amount就可以先求小的。用小的来求大的。
for(int i=1;i<=amount;i++){
for(int j=0;j<coins.length;j++){
//当前硬币面值小于我要凑的面值,才可以更新方程
if(coins[j]<=i)dp[i] = Math.min(dp[i],dp[i-coins[j]]+1);
}
}
return dp[amount]>amount?-1:dp[amount];
}
}
  • dp问题最重要的就是将问题由大化小,通过去寻求每一个小问题的最优解从而求得大问题的最优解。去寻找问题的状态转移方程。