常见数据结构和算法实现(排序/查找/数组/链表/栈/队列/树/递归/海量数据处理/图/位图/Java版数据结构)
数据结构和算法作为程序员的基本功,一定得稳扎稳打的学习,我们常见的框架底层就是各类数据结构,例如跳表之于redis、B+树之于mysql、倒排索引之于ES,熟悉了底层数据结构,对框架有了更深层次的理解,在后续程序设计过程中就更能得心应手。掌握常见数据结构和算法的重要性显而易见,本文主要讲解了几种常见的数据结构及基础的排序和查找算法,最后对高频算法笔试面试题做了总结。本文是数据结构第三讲:栈/队列
文章目录
7、栈(一种特殊的线性表,只能固定在一端进行插入、删除操作 可分为顺序栈结构和链式栈结构)
7.1、栈的特性
递归的本质 栈
1、递归是函数里调用自身
2、必须有一个明确的递归出口
3、在递归调用的过程当中系统为每一层的返回点、局部量等开辟了栈来存储,因此递归次数过多容易造成栈溢出
递归的基本思想:
1、是把规模较大的一个问题,分解成规模较小的多个子问题去解决
2、先解决子问题,再基于子问题来解决当前问题
递归和内存:每次递归调用都在内存中生成一个新的函数副本(仅仅是一些相关的变量),一旦函数结束(即返回某些数据),改返回函数的副本就从内存中删除。
递归一般用于解决三类问题:
1、数据的定义是按递归定义的。(Fibonacci函数,n的阶乘)
2、问题解法按递归实现。(动态规划/分治/回溯)归并排序和快速排序用到了递归的思想
3、数据的结构形式是按递归定义的。(二叉树的/先/中/后序遍历,图的深度/广度优先搜索)
7.2、栈的使用场景 美团
- 子程序的调用:在跳往子程序前,会先将下个指令的地址存到堆栈中,直到子程序执行完后再将地址取出,以回到原来的程序中
- JVM中的栈帧
- 处理递归调用:和子程序的调用类似,只是除了存储下一个指令的地址外,也将参数、区域变量等数据存入堆栈中
- 表达式的转换和求值
- 二叉树的遍历
- 图的深度优先搜索
- 浏览器的前进、后退功能
- 字符串反转
1、栈在表达式求值中的应用:(一个保存操作符的栈,另一个是保存运算符的栈)
我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较(栈顶元素优先级高就取出运算符,从操作数栈取两个操作数,结果压入操作数栈)
- LeetCode150 逆波兰表示式求值(后缀表达式)(逆波兰式在计算机看来却是比较简单易懂的结构。因为计算机普遍采用的内存结构是栈式结构,它执行先进后出的顺序)
public int evalRPN(String[] tokens) {
Stack<Integer> stack = new Stack<>();
for (int i = 0; i < tokens.length; i++) {
String str = tokens[i];
if (str.length() == 1) {
char ch = str.charAt(0);
if (ch - '0' >= 0 && ch - '0' <= 9) {
Integer a = Integer.valueOf(str);
stack.push(a);
} else {//如果是运算符
if (stack.size() < 2)
return 0;
int num2 = stack.pop();
int num1 = stack.pop();
switch (ch) {
case '+':
stack.push(num1 + num2);
break;
case '-':
stack.push(num1 - num2);
break;
case '*':
stack.push(num1 * num2);
break;
case '/':
stack.push(num1 / num2);
break;
}
}
} else {
int n = Integer.valueOf(str);
stack.push(n);
}
}
return stack.pop();
}
栈在括号匹配中的应用:(我们用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号)
- LeetCode20:有效的括号 给定一个只包括 ‘(’,’)’,’{’,’}’,’[’,’]’ 的字符串,判断字符串是否有效
public class 有效的括号 {
public boolean isValid(String s) {
Stack<Character> stack = new Stack<>();
char[] chars = s.toCharArray();
for (char aChar : chars) {
if (stack.size() == 0) {
stack.push(aChar);
} else if (isSym(stack.peek(), aChar)) {
stack.pop();
} else {
stack.push(aChar);
}
}
return stack.size() == 0;
}
//括号是否能匹配成功
private boolean isSym(char c1, char c2) {
return (c1 == '(' && c2 == ')') || (c1 == '[' && c2 == ']') || (c1 == '{' && c2 == '}');
}
}
变体1:给定一个只包含 ‘(’ 和 ‘)’ 的字符串,找出最长的包含有效括号的子串的长度 例如:输入: "(()"输出: 2 输入: “)()())” 输出: 4
对于这种括号匹配问题,一般都是使用栈,我们先找到所有可以匹配的索引号,然后找出最长连续数列!O(nlogn)
public class 最长有效括号 {
public int longestValidParentheses(String s) {
if (s == null || s.length() == 0) return 0;
Deque<Integer> stack = new ArrayDeque<>();
stack.push(-1);
//System.out.println(stack);
int res = 0;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == '(')
stack.push(i);
else {
stack.pop();
if (stack.isEmpty())
stack.push(i);
else {
res = Math.max(res, i - stack.peek());
}
}
}
return res;
}
}
思路2:动态规划
public int longestValidParentheses(String s) {
if (s == null || s.length() == 0) return 0;
int[] dp = new int[s.length()];//状态转移表 下标表示对应考察元素 返回值表示最长有效括弧
int res = 0;
for (int i = 0; i < s.length(); i++) {
if (i > 0 && s.charAt(i) == ')') {
if (s.charAt(i - 1) == '(') {
dp[i] = (i - 2 >= 0 ? dp[i - 2] + 2 : 2);
} else if (s.charAt(i - 1) == ')' && i - dp[i - 1] - 1 >= 0 && s.charAt(i - dp[i - 1] - 1) == '(') {
dp[i] = dp[i - 1] + 2 + (i - dp[i - 1] - 2 >= 0 ? dp[i - dp[i - 1] - 2] : 0);
}
}
res = Math.max(res, dp[i]);
}
return res;
}
编程题5:如何实现浏览器的前进、后退功能?
我们使用两个栈,X和Y,我们把首次浏览的页面依次压入栈X,当点击后退按钮时,再依次从栈X中出栈,并将出栈的数据依次放入栈Y.当我们点击前进按钮时,
我们依次从栈Y中取出数据,放入栈X中。当栈X中没有数据时,那就说明没有页面可以继续后退浏览了。当栈Y中没有数据,那就说明没有页面可以点击前进按钮浏览了。
递归需要满足的三个条件:1、一个问题的解可以分解为几个问题的解;2、这个问题与分解后的子问题,除了数据规模不同,求解思路完全一样;3、存在递归终止条件
首先是定义ListNode,最基础的数据结构(包含int value,指向下一个结点点的指针),然后有结点构成栈(包含pop/push/print/clear等功能),最后实现浏览器功能()
public class 用栈实现浏览器的前进后退 {
private String currentPage;
//使用两个栈,X和Y
private LinkedListBasedStack backStack; //LinkedListBasedStack为基于链表实现的栈,功能有入栈/出栈/获取栈顶元素/打印栈中元素
private LinkedListBasedStack forwardStack;
//构造函数
public 用栈实现浏览器的前进后退() {
this.backStack = new LinkedListBasedStack();//第一个栈 打开新页面时入栈,页面前进时入栈 后退时出栈
this.forwardStack = new LinkedListBasedStack();//第二个栈 前进时出栈 后退时入栈
}
public void open(String url) {
if (this.currentPage != null) {
this.backStack.push(this.currentPage);//入栈 第一个栈
this.forwardStack.clear();
}
showUrl(url, "Open");
}
public boolean canGoBack() {
return this.backStack.size() > 0;
}
public boolean canGoForward() {
return this.forwardStack.size() > 0;
}
//后退功能
public String goBack() {
if (this.canGoBack()) {
this.forwardStack.push(this.currentPage);//第二个栈入栈
String backUrl = this.backStack.pop();//第一个栈出栈
showUrl(backUrl, "Back");
return backUrl;
}
System.out.println("* Cannot go back, no pages behind.");
return null;
}
//前进功能
public String goForward() {
if (this.canGoForward()) {
this.backStack.push(this.currentPage);//第一个栈入栈
String forwardUrl = this.forwardStack.pop();//第二个栈出栈
showUrl(forwardUrl, "Foward");
return forwardUrl;
}
System.out.println("** Cannot go forward, no pages ahead.");
return null;
}
public void showUrl(String url, String prefix) {
this.currentPage = url;
System.out.println(prefix + " page == " + url);
}
public void checkCurrentPage() {
System.out.println("Current page is: " + this.currentPage);
}
}
编程题3:用数组实现一个顺序栈
public class 用数组实现栈 {
private String[] items; // 数组
private int count; // 栈中元素个数
private int n; // 栈的大小
// 初始化数组,申请一个大小为n的数组空间
public 用数组实现栈(int n) {
this.items = new String[n];
this.n = n;
this.count = 0;
}
// 入栈操作
public boolean push(String item) {
// 数组空间不够了,直接返回false,入栈失败。
if (count == n)
return false;
// 将item放到下标为count的位置,并且count加一
items[count] = item;
++count;
return true;
}
// 出栈操作
public String pop() {
// 栈为空,则直接返回null
if (count == 0)
return null;
// 返回下标为count-1的数组元素,并且栈中元素个数count减一
String tmp = items[count - 1];
--count;
return tmp;
}
}
编程题4:用链表实现一个链式栈
public class 用链表实现栈 {
private ListNode top = null;
//入栈
public void push(int value) {
ListNode newNode = new ListNode(value, null);
//判断是否栈空
if (top == null) {
top = newNode;
} else {
newNode.next = top;
top = newNode;
}
}
//出栈
public int pop() {
if (top == null)
return -1;
int value = top.data;
top = top.next;
return value;
}
public void printAll() {
ListNode p = top;
while (p != null) {
System.out.print(p.data + " ");
p = p.next;
}
System.out.println();
}
}
8、队列部分知识点(关键点:确定队空/队满的判定条件)
8.1、具有某种特性的队列:循环队列、阻塞队列、并发队列(在片底层的系统、框架、中间件开发中,起着重要的作用,如高性能队列Disruptor、Linux环形缓存,用到了循环并发队列
java concurrent并发包利用ArrayBlockingQueue来实现公平锁等)
分类:顺序队列和链式队列(用数组实现的队列和链表实现的队列) 基于链表实现的无界队列(可能会导致过多的请求排队,响应时间较长),基于数组实现的有界队列(大小有限)
8.2、队列使用场景 美团
- 排队请求;
- 数据库连接池;
- 高性能队列Disruptor
8.3、高性能队列Disruptor(内存消息队列) kafka
Disruptor(线程之间用于消息传递的队列)(应用apache Storm/canal/log4j2) 性能比常用的内存消息队列ArrayblockingQueue要高出一个数量级,它还因此获得过Oracle官方的Duke大奖
1、Disruptor详解?
基于循环队列保证数据被消费的顺序性。(实现了一个最简单的“生产者-消费者模型”)在这个模型中,“生产者”生产数据,并且将数据放到一个中心存储容器中。之后,“消费者”从中心存储容器中,取出数据消费。而存储数据的中心存储容器,是用什么样的数据结构来实现的呢?(1、基于链表实现的链式队列;2、基于数组实现的顺序队列(循环队列))
基于循环队列的生产者/消费者模型:思路(当队列满了之后,生产者就轮询等待,当队列空了后,消费者就轮训等待)
public class Queue {
private Long[] data;//基于数据实现
private int size = 0, head = 0, tail = 0;
public Queue(int size) {
this.data = new Long[size];
this.size = size;
}
public boolean add(Long element) {
if ((tail + 1) % size == head) return false;//循环队列满了
data[tail] = element;
tail = (tail + 1) % size;
return true;
}
public Long poll() {
if (head == tail) return null;//循环队列为空
long ret = data[head];
head = (head + 1) % size;
return ret;
}
}
public class Producer {
private Queue queue;
public Producer(Queue queue) {
this.queue = queue;
}
public void produce(Long data) throws InterruptedException {
while (!queue.add(data)) {
Thread.sleep(100);//说明添加失败,队列为满,等待消费
}
}
}
public class Consumer {
private Queue queue;
public Consumer(Queue queue) {
this.queue = queue;
}
public void comsume() throws InterruptedException {
while (true) {
Long data = queue.poll();
if (data == null) {
Thread.sleep(100);
} else {
// TODO:...消费数据的业务逻辑...
}
}
}
}
上述代码存在的问题:多线程下,多个生产者写入的数据可能会互相覆盖,多个消费者可能会读取重复的数据
解决方法:1、加锁(同一时间只允许一个线程执行add()函数,相当于并行改成了串行),可以使用CAS乐观锁机制减少加锁的粒度。
基于无锁的并发“生产者-消费者模型”
- 对于生产者来说,它往队列中添加数据之前,先申请可用空闲存储单元,并且是批量地申请连续的n个(n≥1)存储单元。后续往队列中添加元素,就可以不用加锁了;
- 对于消费者来说,处理的过程跟生产者是类似的。它先去申请一批连续可读的存储单元,当申请到这批存储单元之后,后续的读取操作就可以不用加锁了。
源码中,Disruptor采用的是RingBuffer和AvailableBuffer这两个结构
需要注意的地方:生产者A申请到一组连续的存储单元,假设下标是3到6的存储单元,生产者B紧跟着申请到下标是7到9的存储单元,那么3-6没有完全写入数据之前,7-9的数据是无法读取的,这是Disruptor实现思路的一个弊端。实际上,不管架构设计还是产品设计,往往越简单的设计思路,越能更好地解决问题。
4、实现一个消息队列系统
编程题1:用数组实现一个顺序队列
public class 用数组实现的队列 {
// 数组:items,数组大小:n
private String[] items;
private int n = 0;
// head表示队头下标,tail表示队尾下标
private int head = 0;
private int tail = 0;
// 申请一个大小为capacity的数组
public 用数组实现的队列(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队
public boolean enqueue1(String item) {
// 如果tail == n 表示队列已经满了
if (tail == n)
return false;
items[tail] = item;
++tail;
return true;
}
//入队操作,将item放入队尾 并更新head/tail的索引 可以动态扩容的队列
public boolean enqueue2(String item) {
// tail == n表示队列末尾没有空间了
if (tail == n) {
//tail ==n && head==0,表示整个队列都占满了
if (head == 0)
return false;// 表示整个队列都占满了
// 数据搬移
for (int i = head; i < tail; ++i) {
items[i - head] = items[i];
}
//搬移完之后重新更新head和tail
tail -= head;
head = 0;
}
items[tail] = item;
++tail;
return true;
}
// 出队
public String dequeue() {
// 如果head == tail 表示队列为空
if (head == tail)
return null;
// 为了让其他语言的同学看的更加明确,把--操作放到单独一行来写了
String ret = items[head];
++head;
return ret;
}
}
编程题2:用链表实现一个链式队列
public class 基于链表实现的队列 {
// 队列的队首和队尾
private ListNode head = null;
private ListNode tail = null;
// 入队
public void enqueue(String value) {
if (tail == null) {
//新建的队列
ListNode newNode = new ListNode(value, null);
head = newNode;
tail = newNode;
} else {
tail.next = new ListNode(value, null);
tail = tail.next;
}
}
// 出队
public String dequeue() {
if (head == null)
return null;
String value = head.data;
head = head.next;
if (head == null) {
tail = null;
}
return value;
}
public void printAll() {
ListNode p = head;
while (p != null) {
System.out.print(p.data + " ");
p = p.next;
}
System.out.println();
}
}
编程题3:实现一个循环队列(最关键的是,确定好队空和队满的判定条件)(我使用数组实现)
队列为空的判断条件仍然是head == tail,当队满时,(tail+1)%n=head,循环队列会浪费一个数组的存储空间。
public class 循环队列 {
//数组:items,数组大小:n
private String[] items;
private int n = 0;
// head表示队头下标,tail表示队尾下标
private int head = 0;
private int tail = 0;
// 申请一个大小为capacity的数组
public 循环队列(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入队
public boolean enqueue(String item) {
// 队列满了
if ((tail + 1) % n == head)
return false;
items[tail] = item;
tail = (tail + 1) % n;
return true;
}
// 出队
public String dequeue(){
// 如果head == tail 表示队列为空
if (head == tail)
return null;
String ret = items[head];
head = (head + 1) % n;
return ret;
}
}
编程题4:实现一个双端队列 java中有工具包Deque
public class 自己动手实现双端队列 {
private Object[] data;
private int head = 0;
private int tail = 0;
public 自己动手实现双端队列(int k) {
data = new Object[k];
}
public boolean insertFront(int value) {
if(isFull()){
return false;
}
head= decr(head);
data[head] = value;
return true;
}
public boolean insertLast(int value) {
if(isFull()){
return false;
}
data[tail] = value;
tail = incr(tail);
return true;
}
public boolean deleteFront() {
if(isEmpty()){
return false;
}
data[head] = null;
head = incr(head);
return true;
}
public boolean deleteLast() {
if(isEmpty()){
return false;
}
tail = decr(tail);
data[tail] = null;
return true;
}
public int getFront() {
if(isEmpty()){
return -1;
}
return (int)data[head];
}
public int getRear() {
if(isEmpty()){
return -1;
}
return (int) data[decr(tail)];
}
public boolean isEmpty() {
return head == tail && data[head] == null && data[tail] == null;
}
public boolean isFull() {
return tail== head && data[head] != null && data[tail] != null;
}
//前进一步
private int incr(int index){
return ++index % data.length;
}
//后退一步
private int decr(int index){
return (--index + data.length) % data.length;
}
}
编程题5:滑动窗口最大值
public int[] maxSlidingWindow(int[] nums, int k){
if(nums==null||nums.length<2)
return nums;
//双向队列 保存当前窗口最大值的数组位置 保证队列中数组位置的数按从大到小排序
LinkedList<Integer> list = new LinkedList();
// 结果数组
int[] result = new int[nums.length-k+1];
for(int i=0;i<nums.length;i++){
//保证从大到小 如果前面数小 弹出
while(!list.isEmpty()&&nums[list.peekLast()]<=nums[i]){
list.pollLast();
}
//添加当前值对应的数组下标
list.addLast(i);
//初始化窗口 等到窗口长度为k时 下次移动在删除过期数值
if(list.peek()<=i-k){
list.poll();
}
//窗口长度为k时 再保存当前窗口中最大值
if(i-k+1>=0){
result[i-k+1] = nums[list.peek()];
}
}
return result;
}
- 编程题6:两个栈实现队列
思路:用栈a栈b模拟队列q,a为插入栈,b为弹出栈,栈a提供入队功能,栈b提供出队功能。入队时,入栈a即可,出队时,分两种情况:
*1、栈b不为空,直接弹出栈b的数据 *2、栈b为空,则依次弹出栈a的数据,放入栈b中,再弹出栈b的数据。
public class 两个栈实现队列<E> {
Stack<E> s1 = new Stack<E>();//E为链表或数组
Stack<E> s2 = new Stack<E>();
public synchronized void put(E e) {
s1.push(e);
}
public synchronized E pop() {
if (s2.isEmpty()) {
while (!s1.isEmpty()) {
s2.push(s1.pop());
}
}
return s2.pop();
}
}
编程题7:使用两个队列实现栈
思路:确保有一个队列总是空的, 入栈:在任何一个非空队列中插入元素,检查队列q1是否为空,如果q1为空,那么对q2执行入队操作;
出栈:如果队列q1非空,那么从q1移n-1个元素到q2中,然后对q1中的最后一个元素执行出队操作并返回该元素。
public class 使用队列实现栈<E> {
Queue<E> queue1 = new LinkedBlockingQueue<E>();//E为链表或数组;
Queue<E> queue2 = new LinkedBlockingQueue<E>();;
public void push(E data) {
if (queue1.isEmpty()) {
queue2.add(data);
} else {
queue1.add(data);
}
}
public E Pop(){
int i,size;
if (queue2.isEmpty()) {
size = queue1.size();
i=0;
while (i < size-1) {
queue2.add(queue1.remove());
i++;
}
return queue1.remove();
}else {
size = queue2.size();
i=0;
while (i < size-1) {
queue1.add(queue2.remove());
i++;
}
return queue2.remove();
}
}
}
8.4、写一个生产者-消费者队列 政采云问到了 ***非常好的题目 通过arrayblockingqueue的put/take+callable实现,详见后面的阻塞队列
- 1、可以通过阻塞队列实现 2、也可以通过wait-notify来实现 3、通过无锁的内存高性能队列Disruptor实现“生产者-消费者模型”
- 后续补充具体例子