本文以 typescript 实现数据结构,虽说是 ts 实现,但更准确说是面向对象的方式实现,因此可以无缝切换成 Java 等面向对象语言。
什么是数据结构(Data Structure)?
- “数据结构是ADT(抽象数据类型 Abstract Data Type)的物理实现。” — 《数据结构与算法分析》
- “数据结构(data structure)是计算机中存储、组织数据的方式。通常情况下,精心选择的数据结构可以 带来最优效率的算法。” —中文维基百科
线性结构
◼ 线性结构(英語:Linear List)是由n(n≥0)个数据元素(结点)a[0],a[1],a[2]…,a[n-1]组成的有限序列。
◼ 其中:
数据元素的个数n定义为表的长度 = “list”.length() (“list”.length() = 0(表里没有一个元素)时称为空表)。
将非空的线性表(n>=1)记作:(a[0],a[1],a[2],…,a[n-1])。
数据元素a[i](0≤i≤n-1)只是个抽象符号,其具体含义在不同情况下可以不同。
◼ 上面是维基百科对于线性结构的定义,有一点点抽象,其实我们只需要记住几个常见的线性结构即可
数组
数组(Array)结构是一种重要的数据结构:
几乎是每种编程语言都会提供的一种原生数据结构(语言自带的);
并且我们可以借助于数组结构来实现其他的数据结构,比如栈(Stack)、队列(Queue)、堆(Heap);
栈
概念
◼ 栈也是一种 非常常见 的数据结构, 并且在程序中的 应用非常广泛。
◼ 数组
我们知道数组是一种线性结构, 并且可以在数组的 任意位置 插入和删除数据。
但是有时候, 我们为了实现某些功能, 必须对这种任意性 加以 限制。
而 栈和队列 就是比较常见的 受限的线性结构, 我们先来学习栈结构。
可以看到 栈 就是受限的线性结构,重点就是受限。它就像数组的“子集”一样。
因此不是严肃使用栈的场景,我们完全可以把数组来模拟当栈使用,比如刷题的时候。只是注意要自我限制,只用栈有的那几个方法,别突然用下标访问数组数据,那就已经不是栈了。
数组和链表都能实现。
js 中没有自带的链表结构,但 Java 中有 LinkList,它底层就是链表。ArrayList 是数组。
常见方法
push(element): 添加一个新元素到栈顶位置。
pop():移除栈顶的元素,同时返回被移除的元素。
peek():返回栈顶的元素,不对栈做任何修改(这个方法不会移除栈顶的元素,仅仅返回它)。
isEmpty():如果栈里没有任何元素就返回true,否则返回false。
size():返回栈里的元素个数。这个方法和数组的length属性很类似。
实现
/**
* 栈接口。后续想要实现栈,就 implement 这个接口。
* @example
* class ArrayStack<T> implements Stack<T>
*/
export interface Stack<T> {
push(value: T): void;
pop(): T | undefined;
peek(): T | undefined;
size(): number;
isEmpty(): boolean;
}
import { Stack } from './Stack';
/*
* 基于数组的栈
*/
export class ArrayStack<T> implements Stack<T> {
private data: T[];
constructor() {
this.data = [];
}
push(element: T) {
this.data.push(element);
}
pop() {
return this.data.pop();
}
peek() {
return this.data[this.data.length - 1];
}
isEmpty() {
return this.data.length === 0;
}
size() {
return this.data.length;
}
}
题目
进制转换
// 十进制转二级制
import { ArrayStack } from '../ArrayStack';
/**
* 首先 js 自带了进制转换的方法:Number(10).toString(2); 1010
* @param decimal 10进制
* @returns
*/
export function decimalToBinary(decimal: number) {
return decimal.toString(2);
}
// 示例
// console.log(decimalToBinary(10)); // 输出:1010
/**
* 栈实现
* @param decimal 10进制
* @param target 目标进制
*/
export function decimalToBinaryByStack(decimal: number, target: number = 2) {
const stack = new ArrayStack();
let remainder: number;
while (decimal > 0) {
remainder = decimal % target;
stack.push(remainder);
decimal = (decimal - remainder) / target;
}
let res = "";
while (!stack.isEmpty()) {
res += stack.pop();
}
return res;
}
有效的括号
- https://leetcode.cn/problems/valid-parentheses/
/**
* 思路:
* 括号分成左右两部分
* 遍历字符串只要碰到左部分,就把对应的右括号入栈。这样入栈,栈顶元素一定是最先成对的括号。入栈另一半只是为了更好的比较。
* 这样只要遍历碰到右部分,就立即与栈顶比较,看是否一致,一致就出栈,表示一对已经配对完毕。
*/
import { ArrayStack } from '../ArrayStack';
export function isValid(s: string) {
const stack = new ArrayStack<string>();
const map = {
'(': () => stack.push(')'),
'[': () => stack.push(']'),
'{': () => stack.push('}')
};
for (let i = 0; i < s.length; i++) {
const fn = map[s[i]];
if (fn) {
fn();
} else if (s[i] !== stack.peek()) {
return false;
} else {
stack.pop();
}
}
return stack.isEmpty();
}
队
概念
队列也是一种受限的线性结构。
受限之处在于它只允许在队列的前端(front)进行删除操作;而在队列的后端(rear)进行插入操作;
数组和链表同样都能实现队列,但是链表效率更高。
因为队列涉及到首尾元素的删除。栈删除元素,我们可以让数组最后一个位置为栈顶,这样就不用移动所有元素,所以用数组和链表区别不大。但队列不行,如果用数组一定会移动所有元素。
常见方法
enqueue(element) :向队列尾部添加一个(或多个)新的项。
dequeue():移除队列的第一(即排在队列最前面的)项,并返回被移除的元素。
front/peek():返回队列中第一个元素——最先被添加,也将是最先被移除的元素。队列不做任何变动(不移除元素,只返回元素信息——与Stack类的peek方法非常类似)。
isEmpty():如果队列中不包含任何元素,返回true,否则返回false。
size():返回队列包含的元素个数,与数组的length属性类似
实现
数组实现
export interface Queue<T> {
enqueue(element: T): void;
dequeue(): T | undefined;
peek(): T | undefined;
isEmpty(): boolean;
// 设为 getter,使用的时候就可以把 size 当属性使用(queue.size),而不是方法了(queue.size())
get size(): number;
}
import { Queue } from './Queue';
export class ArrayQueue<T> implements Queue<T> {
private data: T[] = [];
enqueue(element: T): void {
this.data.push(element);
}
dequeue(): T | undefined {
return this.data.shift();
}
peek(): T | undefined {
return this.data[0]; // 返回队首元素
}
isEmpty(): boolean {
return this.data.length === 0;
}
get size(): number {
return this.data.length;
}
}
队列和栈同为线性结构,它们都有相同的方法,如表长度,获取表元素 peek,表是否为空。
因此可以进一步抽象出接口。
export interface List<T> {
get size(): number;
isEmpty(): boolean;
peek(): T | undefined;
}
import { List } from "../list/List";
export interface Queue<T> extends List<T> {
enqueue(element: T): void;
dequeue(): T | undefined;
}
链表实现
题目
击鼓传花 / 烫手山芋(hotPotato)
- https://leetcode.cn/problems/yuan-quan-zhong-zui-hou-sheng-xia-de-shu-zi-lcof
◼ 原游戏规则:
班级中玩一个游戏,所有学生围成一圈,从某位同学手里开始向旁边的同学传一束花。
这个时候某个人(比如班长),在击鼓,鼓声停下的一颗,花落在谁手里,谁就出来表演节目。
◼ 修改游戏规则:
我们来修改一下这个游戏规则。
几个朋友一起玩一个游戏,围成一圈,开始数数,数到某个数字的人自动淘汰。
最后剩下的这个人会获得胜利,请问最后剩下的是原来在哪一个位置上的人?
◼ 封装一个基于队列的函数:
参数:所有参与人的姓名,基于的数字;
结果:最终剩下的一人的姓名;
测试数据:["John", "Jack", "Camila", "Ingrid", "Carl"]
这种测试数据要循环使用,而不是遍历一遍就够了的问题,是否就是适合队列来解决呢?
/**
* 思路:
* 元素全部进队后,依次出队,并计数,计数不是目标数字的就从队尾重新进队,是目标数字就不再进队
* 一直重复出队进队,直到队里只剩下一个人,这个人就是幸存者
*/
import { ArrayQueue } from "../ArrayQueue";
export function hotPotato(elements: string[], num: number): number {
const queue = new ArrayQueue<string>();
elements.forEach(element => queue.enqueue(element));
let count = 0;
while (queue.size > 1) {
const el = queue.dequeue()!;
count++;
if (count === num) {
count = 0;
} else {
queue.enqueue(el);
}
}
const res = queue.dequeue();
return elements.findIndex(item => item === res);
}
约瑟夫环问题
历史:
◼ 阿桥问题(有时也称为约瑟夫斯置换),是一个出现在计算机科学和数学中的问题。在计算机编程的算法中,类似问题又称为约瑟夫环。
人们站在一个等待被处决的圈子里。
计数从圆圈中的指定点开始,并沿指定方向围绕圆圈进行。
在跳过指定数量的人之后,处刑下一个人。
对剩下的人重复该过程,从下一个人开始,朝同一方向跳过相同数量的人,直到只剩下一个人,并被释放。
在给定数量的情况下,站在第几个位置可以避免被处决?
◼ 这个问题是以弗拉维奥·约瑟夫命名的,他是1世纪的一名犹太历史学家。
他在自己的日记中写道,他和他的40个战友被罗马军队包围在洞中。
他们讨论是自杀还是被俘,最终决定自杀,并以抽签的方式决定谁杀掉谁。
约瑟夫环问题其实就是击鼓传花问题,它还有很多名字。
- 剑指offer 62题:圆圈中的最后剩下的数字
- LeetCode:LCR 187. 破冰游戏