英勇黄铜
【数据结构与算法】(6)基础数据结构之栈的链表实现、环形数组实现示例讲解
2.5 栈1) 概述计算机科学中,stack 是一种线性的数据结构,只能在其一端添加数据和移除数据。习惯来说,这一端称之为栈顶,另一端不能操作数据的称之为栈底,就如同生活中的一摞书先提供一个栈接口public interface Stack<E> {
/**
* 向栈顶压入元素
* @param value 待压入值
* @return 压入成功返回 true, 否则返回 false
*/
boolean push(E value);
/**
* 从栈顶弹出元素
* @return 栈非空返回栈顶元素, 栈为空返回 null
*/
E pop();
/**
* 返回栈顶元素, 不弹出
* @return 栈非空返回栈顶元素, 栈为空返回 null
*/
E peek();
/**
* 判断栈是否为空
* @return 空返回 true, 否则返回 false
*/
boolean isEmpty();
/**
* 判断栈是否已满
* @return 满返回 true, 否则返回 false
*/
boolean isFull();
}2) 链表实现public class LinkedListStack<E> implements Stack<E>, Iterable<E> {
private final int capacity;
private int size;
private final Node<E> head = new Node<>(null, null);
public LinkedListStack(int capacity) {
this.capacity = capacity;
}
@Override
public boolean push(E value) {
if (isFull()) {
return false;
}
head.next = new Node<>(value, head.next);
size++;
return true;
}
@Override
public E pop() {
if (isEmpty()) {
return null;
}
Node<E> first = head.next;
head.next = first.next;
size--;
return first.value;
}
@Override
public E peek() {
if (isEmpty()) {
return null;
}
return head.next.value;
}
@Override
public boolean isEmpty() {
return head.next == null;
}
@Override
public boolean isFull() {
return size == capacity;
}
@Override
public Iterator<E> iterator() {
return new Iterator<E>() {
Node<E> p = head.next;
@Override
public boolean hasNext() {
return p != null;
}
@Override
public E next() {
E value = p.value;
p = p.next;
return value;
}
};
}
static class Node<E> {
E value;
Node<E> next;
public Node(E value, Node<E> next) {
this.value = value;
this.next = next;
}
}
}3) 数组实现public class ArrayStack<E> implements Stack<E>, Iterable<E>{
private final E[] array;
private int top = 0;
@SuppressWarnings("all")
public ArrayStack(int capacity) {
this.array = (E[]) new Object[capacity];
}
@Override
public boolean push(E value) {
if (isFull()) {
return false;
}
array[top++] = value;
return true;
}
@Override
public E pop() {
if (isEmpty()) {
return null;
}
return array[--top];
}
@Override
public E peek() {
if (isEmpty()) {
return null;
}
return array[top-1];
}
@Override
public boolean isEmpty() {
return top == 0;
}
@Override
public boolean isFull() {
return top == array.length;
}
@Override
public Iterator<E> iterator() {
return new Iterator<E>() {
int p = top;
@Override
public boolean hasNext() {
return p > 0;
}
@Override
public E next() {
return array[--p];
}
};
}
}4) 应用模拟如下方法调用public static void main(String[] args) {
System.out.println("main1");
System.out.println("main2");
method1();
method2();
System.out.println("main3");
}
public static void method1() {
System.out.println("method1");
method3();
}
public static void method2() {
System.out.println("method2");
}
public static void method3() {
System.out.println("method3");
}模拟代码public class CPU {
static class Frame {
int exit;
public Frame(int exit) {
this.exit = exit;
}
}
static int pc = 1; // 模拟程序计数器 Program counter
static ArrayStack<Frame> stack = new ArrayStack<>(100); // 模拟方法调用栈
public static void main(String[] args) {
stack.push(new Frame(-1));
while (!stack.isEmpty()) {
switch (pc) {
case 1 -> {
System.out.println("main1");
pc++;
}
case 2 -> {
System.out.println("main2");
pc++;
}
case 3 -> {
stack.push(new Frame(pc + 1));
pc = 100;
}
case 4 -> {
stack.push(new Frame(pc + 1));
pc = 200;
}
case 5 -> {
System.out.println("main3");
pc = stack.pop().exit;
}
case 100 -> {
System.out.println("method1");
stack.push(new Frame(pc + 1));
pc = 300;
}
case 101 -> {
pc = stack.pop().exit;
}
case 200 -> {
System.out.println("method2");
pc = stack.pop().exit;
}
case 300 -> {
System.out.println("method3");
pc = stack.pop().exit;
}
}
}
}
}习题E01. 有效的括号-Leetcode 20一个字符串中可能出现 [] () 和 {} 三种括号,判断该括号是否有效有效的例子()[]{}
([{}])
()无效的例子[)
([)]
([]思路遇到左括号, 把要配对的右括号放入栈顶遇到右括号, 若此时栈为空, 返回 false,否则把它与栈顶元素对比若相等, 栈顶元素弹出, 继续对比下一组若不等, 无效括号直接返回 false循环结束若栈为空, 表示所有括号都配上对, 返回 true若栈不为空, 表示右没配对的括号, 应返回 false答案(用到了课堂案例中的 ArrayStack 类)public boolean isValid(String s) {
ArrayStack<Character> stack = new ArrayStack<>(s.length() / 2 + 1);
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '(') {
stack.push(')');
} else if (c == '[') {
stack.push(']');
} else if (c == '{') {
stack.push('}');
} else {
if (!stack.isEmpty() && stack.peek() == c) {
stack.pop();
} else {
return false;
}
}
}
return stack.isEmpty();
}
E02. 后缀表达式求值-Leetcode 120后缀表达式也称为逆波兰表达式,即运算符写在后面从左向右进行计算不必考虑运算符优先级,即不用包含括号示例输入:tokens = ["2","1","+","3","*"]
输出:9
即:(2 + 1) * 3
输入:tokens = ["4","13","5","/","+"]
输出:6
即:4 + (13 / 5)题目假设数字都视为整数数字和运算符个数给定正确,不会有除零发生代码public int evalRPN(String[] tokens) {
LinkedList<Integer> numbers = new LinkedList<>();
for (String t : tokens) {
switch (t) {
case "+" -> {
Integer b = numbers.pop();
Integer a = numbers.pop();
numbers.push(a + b);
}
case "-" -> {
Integer b = numbers.pop();
Integer a = numbers.pop();
numbers.push(a - b);
}
case "*" -> {
Integer b = numbers.pop();
Integer a = numbers.pop();
numbers.push(a * b);
}
case "/" -> {
Integer b = numbers.pop();
Integer a = numbers.pop();
numbers.push(a / b);
}
default -> numbers.push(Integer.parseInt(t));
}
}
return numbers.pop();
}E03. 中缀表达式转后缀public class E03InfixToSuffix {
/*
思路
1. 遇到数字, 拼串
2. 遇到 + - * /
- 优先级高于栈顶运算符 入栈
- 否则将栈中高级或平级运算符出栈拼串, 本运算符入栈
3. 遍历完成, 栈中剩余运算符出栈拼串
- 先出栈,意味着优先运算
4. 带 ()
- 左括号直接入栈
- 右括号要将栈中直至左括号为止的运算符出栈拼串
| |
| |
| |
_____
a+b
a+b-c
a+b*c
a*b+c
(a+b)*c
*/
public static void main(String[] args) {
System.out.println(infixToSuffix("a+b"));
System.out.println(infixToSuffix("a+b-c"));
System.out.println(infixToSuffix("a+b*c"));
System.out.println(infixToSuffix("a*b-c"));
System.out.println(infixToSuffix("(a+b)*c"));
System.out.println(infixToSuffix("a+b*c+(d*e+f)*g"));
}
static String infixToSuffix(String exp) {
LinkedList<Character> stack = new LinkedList<>();
StringBuilder sb = new StringBuilder(exp.length());
for (int i = 0; i < exp.length(); i++) {
char c = exp.charAt(i);
switch (c) {
case '+', '-', '*', '/' -> {
if (stack.isEmpty()) {
stack.push(c);
} else {
if (priority(c) > priority(stack.peek())) {
stack.push(c);
} else {
while (!stack.isEmpty()
&& priority(stack.peek()) >= priority(c)) {
sb.append(stack.pop());
}
stack.push(c);
}
}
}
case '(' -> {
stack.push(c);
}
case ')' -> {
while (!stack.isEmpty() && stack.peek() != '(') {
sb.append(stack.pop());
}
stack.pop();
}
default -> {
sb.append(c);
}
}
}
while (!stack.isEmpty()) {
sb.append(stack.pop());
}
return sb.toString();
}
static int priority(char c) {
return switch (c) {
case '(' -> 0;
case '*', '/' -> 2;
case '+', '-' -> 1;
default -> throw new IllegalArgumentException("不合法字符:" + c);
};
}
}E04. 双栈模拟队列-Leetcode 232给力扣题目用的自实现栈,可以定义为静态内部类class ArrayStack<E> {
private E[] array;
private int top; // 栈顶指针
@SuppressWarnings("all")
public ArrayStack(int capacity) {
this.array = (E[]) new Object[capacity];
}
public boolean push(E value) {
if (isFull()) {
return false;
}
array[top++] = value;
return true;
}
public E pop() {
if (isEmpty()) {
return null;
}
return array[--top];
}
public E peek() {
if (isEmpty()) {
return null;
}
return array[top - 1];
}
public boolean isEmpty() {
return top == 0;
}
public boolean isFull() {
return top == array.length;
}
}参考解答,注意:题目已说明调用 push、pop 等方法的次数最多 100public class E04Leetcode232 {
/*
队列头 队列尾
s1 s2
顶 底 底 顶
abc
push(a)
push(b)
push(c)
pop()
*/
ArrayStack<Integer> s1 = new ArrayStack<>(100);
ArrayStack<Integer> s2 = new ArrayStack<>(100);
public void push(int x) {
s2.push(x);
}
public int pop() {
if (s1.isEmpty()) {
while (!s2.isEmpty()) {
s1.push(s2.pop());
}
}
return s1.pop();
}
public int peek() {
if (s1.isEmpty()) {
while (!s2.isEmpty()) {
s1.push(s2.pop());
}
}
return s1.peek();
}
public boolean empty() {
return s1.isEmpty() && s2.isEmpty();
}
}E05. 单队列模拟栈-Leetcode 225给力扣题目用的自实现队列,可以定义为静态内部类public class ArrayQueue3<E> {
private final E[] array;
int head = 0;
int tail = 0;
@SuppressWarnings("all")
public ArrayQueue3(int c) {
c -= 1;
c |= c >> 1;
c |= c >> 2;
c |= c >> 4;
c |= c >> 8;
c |= c >> 16;
c += 1;
array = (E[]) new Object[c];
}
public boolean offer(E value) {
if (isFull()) {
return false;
}
array[tail & (array.length - 1)] = value;
tail++;
return true;
}
public E poll() {
if (isEmpty()) {
return null;
}
E value = array[head & (array.length - 1)];
head++;
return value;
}
public E peek() {
if (isEmpty()) {
return null;
}
return array[head & (array.length - 1)];
}
public boolean isEmpty() {
return head == tail;
}
public boolean isFull() {
return tail - head == array.length;
}
}参考解答,注意:题目已说明调用 push、pop 等方法的次数最多 100每次调用 pop 和 top 都能保证栈不为空public class E05Leetcode225 {
/*
队列头 队列尾
cba
顶 底
queue.offer(a)
queue.offer(b)
queue.offer(c)
*/
ArrayQueue3<Integer> queue = new ArrayQueue3<>(100);
int size = 0;
public void push(int x) {
queue.offer(x);
for (int i = 0; i < size; i++) {
queue.offer(queue.poll());
}
size++;
}
public int pop() {
size--;
return queue.poll();
}
public int top() {
return queue.peek();
}
public boolean empty() {
return queue.isEmpty();
}
}
英勇黄铜
【数据结构与算法】(4)基础数据结构 之 递归 单路递归、多路递归示例讲解 附单路递归示例
2.3 递归1) 概述定义计算机科学中,递归是一种解决计算问题的方法,其中解决方案取决于同一类问题的更小子集In computer science, recursion is a method of solving a computational problem where the solution depends on solutions to smaller instances of the same problem.比如单链表递归遍历的例子:void f(Node node) {
if(node == null) {
return;
}
println("before:" + node.value)
f(node.next);
println("after:" + node.value)
}说明:自己调用自己,如果说每个函数对应着一种解决方案,自己调用自己意味着解决方案是一样的(有规律的)每次调用,函数处理的数据会较上次缩减(子集),而且最后会缩减至无需继续递归内层函数调用(子集处理)完成,外层函数才能算调用完成原理假设链表中有 3 个节点,value 分别为 1,2,3,以上代码的执行流程就类似于下面的伪码// 1 -> 2 -> 3 -> null f(1)
void f(Node node = 1) {
println("before:" + node.value) // 1
void f(Node node = 2) {
println("before:" + node.value) // 2
void f(Node node = 3) {
println("before:" + node.value) // 3
void f(Node node = null) {
if(node == null) {
return;
}
}
println("after:" + node.value) // 3
}
println("after:" + node.value) // 2
}
println("after:" + node.value) // 1
}思路确定能否使用递归求解推导出递推关系,即父问题与子问题的关系,以及递归的结束条件例如之前遍历链表的递推关系为深入到最里层叫做递从最里层出来叫做归在递的过程中,外层函数内的局部变量(以及方法参数)并未消失,归的时候还可以用到2) 单路递归 Single RecursionE01. 阶乘用递归方法求阶乘代码private static int f(int n) {
if (n == 1) {
return 1;
}
return n * f(n - 1);
}拆解伪码如下,假设 n 初始值为 3f(int n = 3) { // 解决不了,递
return 3 * f(int n = 2) { // 解决不了,继续递
return 2 * f(int n = 1) {
if (n == 1) { // 可以解决, 开始归
return 1;
}
}
}
}E02. 反向打印字符串用递归反向打印字符串,n 为字符在整个字符串 str 中的索引位置递:n 从 0 开始,每次 n + 1,一直递到 n == str.length() - 1归:从 n == str.length() 开始归,从归打印,自然是逆序的递推关系代码为public static void reversePrint(String str, int index) {
if (index == str.length()) {
return;
}
reversePrint(str, index + 1);
System.out.println(str.charAt(index));
}拆解伪码如下,假设字符串为 “abc”void reversePrint(String str, int index = 0) {
void reversePrint(String str, int index = 1) {
void reversePrint(String str, int index = 2) {
void reversePrint(String str, int index = 3) {
if (index == str.length()) {
return; // 开始归
}
}
System.out.println(str.charAt(index)); // 打印 c
}
System.out.println(str.charAt(index)); // 打印 b
}
System.out.println(str.charAt(index)); // 打印 a
}E03. 二分查找(单路递归)public static int binarySearch(int[] a, int target) {
return recursion(a, target, 0, a.length - 1);
}
public static int recursion(int[] a, int target, int i, int j) {
if (i > j) {
return -1;
}
int m = (i + j) >>> 1;
if (target < a[m]) {
return recursion(a, target, i, m - 1);
} else if (a[m] < target) {
return recursion(a, target, m + 1, j);
} else {
return m;
}
}E04. 冒泡排序(单路递归)public static void main(String[] args) {
int[] a = {3, 2, 6, 1, 5, 4, 7};
bubble(a, 0, a.length - 1);
System.out.println(Arrays.toString(a));
}
private static void bubble(int[] a, int low, int high) {
if(low == high) {
return;
}
int j = low;
for (int i = low; i < high; i++) {
if (a[i] > a[i + 1]) {
swap(a, i, i + 1);
j = i;
}
}
bubble(a, low, j);
}
private static void swap(int[] a, int i, int j) {
int t = a[i];
a[i] = a[j];
a[j] = t;
}low 与 high 为未排序范围j 表示的是未排序的边界,下一次递归时的 high发生交换,意味着有无序情况最后一次交换(以后没有无序)时,左侧 i 仍是无序,右侧 i+1 已然有序视频中讲解的是只考虑 high 边界的情况,参考以上代码,理解在 low … high 范围内的处理方法E05. 插入排序(单路递归)public static void main(String[] args) {
int[] a = {3, 2, 6, 1, 5, 7, 4};
insertion(a, 1, a.length - 1);
System.out.println(Arrays.toString(a));
}
private static void insertion(int[] a, int low, int high) {
if (low > high) {
return;
}
int i = low - 1;
int t = a[low];
while (i >= 0 && a[i] > i) {
a[i + 1] = a[i];
i--;
}
if(i + 1 != low) {
a[i + 1] = t;
}
insertion(a, low + 1, high);
}已排序区域:[0 … i … low-1]未排序区域:[low … high]视频中讲解的是只考虑 low 边界的情况,参考以上代码,理解 low-1 … high 范围内的处理方法扩展:利用二分查找 leftmost 版本,改进寻找插入位置的代码E06. 约瑟夫问题[^16](单路递归)n 个人排成圆圈,从头开始报数,每次数到第 m 个人(m 从 1 开始)杀之,继续从下一个人重复以上过程,求最后活下来的人是谁?方法1根据最后的存活者 a 倒推出它在上一轮的索引号f(n,m)本轮索引为了让 a 是这个索引,上一轮应当这样排规律f(1,3)0x x x a(0 + 3) % 2f(2,3)1x x x 0 a(1 + 3) % 3f(3,3)1x x x 0 a(1 + 3) % 4f(4,3)0x x x a(0 + 3) % 5f(5,3)3x x x 0 1 2 a(3 + 3) % 6f(6,3)0x x x a方法2设 n 为总人数,m 为报数次数,解返回的是这些人的索引,从0开始f(n, m)解规律f(1, 3)0f(2, 3)0 1 => 13%2=1f(3, 3)0 1 2 => 0 13%3=0f(4, 3)0 1 2 3 => 3 0 13%4=3f(5, 3)0 1 2 3 4 => 3 4 0 13%5=3f(6, 3)0 1 2 3 4 5 => 3 4 5 0 13%6=3下面的表格列出了数列的前几项F0F1F2F3F4F5F6F7F8F9F10F11F12F1301123581321345589144233实现public static int f(int n) {
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
return f(n - 1) + f(n - 2);
}执行流程绿色代表正在执行(对应递),灰色代表执行结束(对应归)递不到头,不能归,对应着深度优先搜索时间复杂度1.更多 Fibonacci 参考[8][9][^10]2.以上时间复杂度分析,未考虑大数相加的因素变体1 - 兔子问题[^8]第一个月,有一对未成熟的兔子(黑色,注意图中个头较小)第二个月,它们成熟第三个月,它们能产下一对新的小兔子(蓝色)所有兔子遵循相同规律,求第 n 个月的兔子数分析n跳法规律1(1)暂时看不出2(1,1) (2)暂时看不出3(1,1,1) (1,2) (2,1)暂时看不出4(1,1,1,1) (1,2,1) (2,1,1)(1,1,2) (2,2)最后一跳,跳一个台阶的,基于f(3)最后一跳,跳两个台阶的,基于f(2)5……因此本质上还是斐波那契数列,只是从其第二项开始对应 leetcode 题目 70. 爬楼梯 - 力扣(LeetCode)E02. 汉诺塔[^13](多路递归)Tower of Hanoi,是一个源于印度古老传说:大梵天创建世界时做了三根金刚石柱,在一根柱子从下往上按大小顺序摞着 64 片黄金圆盘,大梵天命令婆罗门把圆盘重新摆放在另一根柱子上,并且规定一次只能移动一个圆盘小圆盘上不能放大圆盘下面的动图演示了4片圆盘的移动方法使用程序代码模拟圆盘的移动过程,并估算出时间复杂度思路假设每根柱子标号 a,b,c,每个圆盘用 1,2,3 … 表示其大小,圆盘初始在 a,要移动到的目标是 c如果只有一个圆盘,此时是最小问题,可以直接求解如果有两个圆盘,那么如果有三个圆盘,那么如果有四个圆盘,那么题解public class E02HanoiTower {
/*
源 借 目
h(4, a, b, c) -> h(3, a, c, b)
a -> c
h(3, b, a, c)
*/
static LinkedList<Integer> a = new LinkedList<>();
static LinkedList<Integer> b = new LinkedList<>();
static LinkedList<Integer> c = new LinkedList<>();
static void init(int n) {
for (int i = n; i >= 1; i--) {
a.add(i);
}
}
static void h(int n, LinkedList<Integer> a,
LinkedList<Integer> b,
LinkedList<Integer> c) {
if (n == 0) {
return;
}
h(n - 1, a, c, b);
c.addLast(a.removeLast());
print();
h(n - 1, b, a, c);
}
private static void print() {
System.out.println("-----------------------");
System.out.println(a);
System.out.println(b);
System.out.println(c);
}
public static void main(String[] args) {
init(3);
print();
h(3, a, b, c);
}
}E03. 杨辉三角[^6]分析把它斜着看 1
1 1
1 2 1
1 3 3 1
1 4 6 4 1
题解public static void print(int n) {
for (int i = 0; i < n; i++) {
if (i < n - 1) {
System.out.printf("%" + 2 * (n - 1 - i) + "s", " ");
}
for (int j = 0; j < i + 1; j++) {
System.out.printf("%-4d", element(i, j));
}
System.out.println();
}
}
public static int element(int i, int j) {
if (j == 0 || i == j) {
return 1;
}
return element(i - 1, j - 1) + element(i - 1, j);
}优化1是 multiple recursion,因此很多递归调用是重复的,例如recursion(3, 1) 分解为recursion(2, 0) + recursion(2, 1)而 recursion(3, 2) 分解为recursion(2, 1) + recursion(2, 2)这里 recursion(2, 1) 就重复调用了,事实上它会重复很多次,可以用 static AtomicInteger counter = new AtomicInteger(0) 来查看递归函数的调用总次数事实上,可以用 memoization 来进行优化:public static void print1(int n) {
int[][] triangle = new int[n][];
for (int i = 0; i < n; i++) {
// 打印空格
triangle[i] = new int[i + 1];
for (int j = 0; j <= i; j++) {
System.out.printf("%-4d", element1(triangle, i, j));
}
System.out.println();
}
}
public static int element1(int[][] triangle, int i, int j) {
if (triangle[i][j] > 0) {
return triangle[i][j];
}
if (j == 0 || i == j) {
triangle[i][j] = 1;
return triangle[i][j];
}
triangle[i][j] = element1(triangle, i - 1, j - 1) + element1(triangle, i - 1, j);
return triangle[i][j];
}优化2public static void print2(int n) {
int[] row = new int[n];
for (int i = 0; i < n; i++) {
// 打印空格
createRow(row, i);
for (int j = 0; j <= i; j++) {
System.out.printf("%-4d", row[j]);
}
System.out.println();
}
}
private static void createRow(int[] row, int i) {
if (i == 0) {
row[0] = 1;
return;
}
for (int j = i; j > 0; j--) {
row[j] = row[j - 1] + row[j];
}
}注意:还可以通过每一行的前一项计算出下一项,不必借助上一行,这与杨辉三角的另一个特性有关,暂不展开了其它题目力扣对应题目,但递归不适合在力扣刷高分,因此只列出相关题目,不做刷题讲解了题号名称Leetcode118杨辉三角Leetcode119杨辉三角II4) 递归优化-记忆法上述代码存在很多重复的计算,例如求 f ( 5 ) f(5)f(5) 递归分解过程Memoization 记忆法(也称备忘录)是一种优化技术,通过存储函数调用结果(通常比较昂贵),当再次出现相同的输入(子问题)时,就能实现加速效果,改进后的代码public static void main(String[] args) {
int n = 13;
int[] cache = new int[n + 1];
Arrays.fill(cache, -1);
cache[0] = 0;
cache[1] = 1;
System.out.println(f(cache, n));
}
public static int f(int[] cache, int n) {
if (cache[n] != -1) {
return cache[n];
}
cache[n] = f(cache, n - 1) + f(cache, n - 2);
return cache[n];
}优化后的图示,只要结果被缓存,就不会执行其子问题改进后的时间复杂度为 O ( n )请自行验证改进后的效果请自行分析改进后的空间复杂度注意1.记忆法是动态规划的一种情况,强调的是自顶向下的解决2.记忆法的本质是空间换时间5) 递归优化-尾递归爆栈用递归做 n + ( n − 1 ) + ( n − 2 ) . . . + 1 public static long sum(long n) {
if (n == 1) {
return 1;
}
return n + sum(n - 1);
}
在我的机器上 n = 12000 时,爆栈了Exception in thread "main" java.lang.StackOverflowError
at Test.sum(Test.java:10)
at Test.sum(Test.java:10)
at Test.sum(Test.java:10)
at Test.sum(Test.java:10)
at Test.sum(Test.java:10)
...为什么呢?每次方法调用是需要消耗一定的栈内存的,这些内存用来存储方法参数、方法内局部变量、返回地址等等方法调用占用的内存需要等到方法结束时才会释放而递归调用我们之前讲过,不到最深不会回头,最内层方法没完成之前,外层方法都结束不了long sum(long n = 3) {
return 3 + long sum(long n = 2) {
return 2 + long sum(long n = 1) {
return 1;
}
}
}尾调用如果函数的最后一步是调用一个函数,那么称为尾调用,例如function a() {
return b()
}下面三段代码不能叫做尾调用function a() {
const c = b()
return c
}因为最后一步并非调用函数function a() {
return b() + 1
}最后一步执行的是加法function a(x) {
return b() + x
}最后一步执行的是加法一些语言[^11]的编译器能够对尾调用做优化,例如function a() {
// 做前面的事
return b()
}
function b() {
// 做前面的事
return c()
}
function c() {
return 1000
}
a()没优化之前的伪码function a() {
return function b() {
return function c() {
return 1000
}
}
}优化后伪码如下a()
b()
c()为何尾递归才能优化?调用 a 时a 返回时发现:没什么可留给 b 的,将来返回的结果 b 提供就可以了,用不着我 a 了,我的内存就可以释放调用 b 时b 返回时发现:没什么可留给 c 的,将来返回的结果 c 提供就可以了,用不着我 b 了,我的内存就可以释放如果调用 a 时不是尾调用,例如 return b() + 1,那么 a 就不能提前结束,因为它还得利用 b 的结果做加法尾递归尾递归是尾调用的一种特例,也就是最后一步执行的是同一个函数尾递归避免爆栈安装 ScalaScala 入门object Main {
def main(args: Array[String]): Unit = {
println("Hello Scala")
}
}Scala 是 java 的近亲,java 中的类都可以拿来重用类型是放在变量后面的Unit 表示无返回值,类似于 void不需要以分号作为结尾,当然加上也对还是先写一个会爆栈的函数def sum(n: Long): Long = {
if (n == 1) {
return 1
}
return n + sum(n - 1)
}Scala 最后一行代码若作为返回值,可以省略 return不出所料,在 n = 11000 n = 11000n=11000 时,还是出了异常println(sum(11000))
Exception in thread "main" java.lang.StackOverflowError
at Main$.sum(Main.scala:25)
at Main$.sum(Main.scala:25)
at Main$.sum(Main.scala:25)
at Main$.sum(Main.scala:25)
...这是因为以上代码,还不是尾调用,要想成为尾调用,那么:最后一行代码,必须是一次函数调用内层函数必须摆脱与外层函数的关系,内层函数执行后不依赖于外层的变量或常量def sum(n: Long): Long = {
if (n == 1) {
return 1
}
return n + sum(n - 1) // 依赖于外层函数的 n 变量
}如何让它执行后就摆脱对 n 的依赖呢?不能等递归回来再做加法,那样就必须保留外层的 n把 n 当做内层函数的一个参数传进去,这时 n 就属于内层函数了传参时就完成累加, 不必等回来时累加sum(n - 1, n + 累加器)改写后代码如下@tailrec
def sum(n: Long, accumulator: Long): Long = {
if (n == 1) {
return 1 + accumulator
}
return sum(n - 1, n + accumulator)
}accumulator 作为累加器@tailrec 注解是 scala 提供的,用来检查方法是否符合尾递归这回 sum(10000000, 0) 也没有问题,打印 50000005000000执行流程如下,以伪码表示 s u m ( 4 , 0 )// 首次调用
def sum(n = 4, accumulator = 0): Long = {
return sum(4 - 1, 4 + accumulator)
}
// 接下来调用内层 sum, 传参时就完成了累加, 不必等回来时累加,当内层 sum 调用后,外层 sum 空间没必要保留
def sum(n = 3, accumulator = 4): Long = {
return sum(3 - 1, 3 + accumulator)
}
// 继续调用内层 sum
def sum(n = 2, accumulator = 7): Long = {
return sum(2 - 1, 2 + accumulator)
}
// 继续调用内层 sum, 这是最后的 sum 调用完就返回最后结果 10, 前面所有其它 sum 的空间早已释放
def sum(n = 1, accumulator = 9): Long = {
if (1 == 1) {
return 1 + accumulator
}
}本质上,尾递归优化是将函数的递归调用,变成了函数的循环调用改循环避免爆栈public static void main(String[] args) {
long n = 100000000;
long sum = 0;
for (long i = n; i >= 1; i--) {
sum += i;
}
System.out.println(sum);
}6) 递归时间复杂度-Master theorem[^14]例7. 二分查找递归int f(int[] a, int target, int i, int j) {
if (i > j) {
return -1;
}
int m = (i + j) >>> 1;
if (target < a[m]) {
return f(a, target, i, m - 1);
} else if (a[m] < target) {
return f(a, target, m + 1, j);
} else {
return m;
}
}例8. 归并排序递归void split(B[], i, j, A[])
{
if (j - i <= 1)
return;
m = (i + j) / 2;
// 递归
split(A, i, m, B);
split(A, m, j, B);
// 合并
merge(B, i, m, j, A);
}例9. 快速排序递归algorithm quicksort(A, lo, hi) is
if lo >= hi || lo < 0 then
return
// 分区
p := partition(A, lo, hi)
// 递归
quicksort(A, lo, p - 1)
quicksort(A, p + 1, hi) 7) 递归时间复杂度-展开求解像下面的递归式,都不能用主定理求解例1 - 递归求和long sum(long n) {
if (n == 1) {
return 1;
}
return n + sum(n - 1);
}例2 - 递归冒泡排序void bubble(int[] a, int high) {
if(0 == high) {
return;
}
for (int i = 0; i < high; i++) {
if (a[i] > a[i + 1]) {
swap(a, i, i + 1);
}
}
bubble(a, high - 1);
}
英勇黄铜
【数据结构与算法】(10)基础数据结构 之 堆 建堆及堆排序 详细代码示例讲解
2.9 堆以大顶堆为例,相对于之前的优先级队列,增加了堆化等方法public class MaxHeap {
int[] array;
int size;
public MaxHeap(int capacity) {
this.array = new int[capacity];
}
/**
* 获取堆顶元素
*
* @return 堆顶元素
*/
public int peek() {
return array[0];
}
/**
* 删除堆顶元素
*
* @return 堆顶元素
*/
public int poll() {
int top = array[0];
swap(0, size - 1);
size--;
down(0);
return top;
}
/**
* 删除指定索引处元素
*
* @param index 索引
* @return 被删除元素
*/
public int poll(int index) {
int deleted = array[index];
up(Integer.MAX_VALUE, index);
poll();
return deleted;
}
/**
* 替换堆顶元素
*
* @param replaced 新元素
*/
public void replace(int replaced) {
array[0] = replaced;
down(0);
}
/**
* 堆的尾部添加元素
*
* @param offered 新元素
* @return 是否添加成功
*/
public boolean offer(int offered) {
if (size == array.length) {
return false;
}
up(offered, size);
size++;
return true;
}
// 将 offered 元素上浮: 直至 offered 小于父元素或到堆顶
private void up(int offered, int index) {
int child = index;
while (child > 0) {
int parent = (child - 1) / 2;
if (offered > array[parent]) {
array[child] = array[parent];
} else {
break;
}
child = parent;
}
array[child] = offered;
}
public MaxHeap(int[] array) {
this.array = array;
this.size = array.length;
heapify();
}
// 建堆
private void heapify() {
// 如何找到最后这个非叶子节点 size / 2 - 1
for (int i = size / 2 - 1; i >= 0; i--) {
down(i);
}
}
// 将 parent 索引处的元素下潜: 与两个孩子较大者交换, 直至没孩子或孩子没它大
private void down(int parent) {
int left = parent * 2 + 1;
int right = left + 1;
int max = parent;
if (left < size && array[left] > array[max]) {
max = left;
}
if (right < size && array[right] > array[max]) {
max = right;
}
if (max != parent) { // 找到了更大的孩子
swap(max, parent);
down(max);
}
}
// 交换两个索引处的元素
private void swap(int i, int j) {
int t = array[i];
array[i] = array[j];
array[j] = t;
}
public static void main(String[] args) {
int[] array = {2, 3, 1, 7, 6, 4, 5};
MaxHeap heap = new MaxHeap(array);
System.out.println(Arrays.toString(heap.array));
while (heap.size > 1) {
heap.swap(0, heap.size - 1);
heap.size--;
heap.down(0);
}
System.out.println(Arrays.toString(heap.array));
}
}建堆Floyd 建堆算法作者(也是之前龟兔赛跑判环作者):找到最后一个非叶子节点从后向前,对每个节点执行下潜下面看交换次数的推导:设节点高度为 3本层节点数高度下潜最多交换次数(高度-1)4567 这层41023这层2211这层132Sum[\(40)Divide[Power[2,x],Power[2,i]]*\(40)i-1\(41)\(41),{i,1,x}]习题E01. 堆排序算法描述heapify 建立大顶堆将堆顶与堆底交换(最大元素被交换到堆底),缩小并下潜调整堆重复第二步直至堆里剩一个元素可以使用之前课堂例题的大顶堆来实现int[] array = {1, 2, 3, 4, 5, 6, 7};
MaxHeap maxHeap = new MaxHeap(array);
System.out.println(Arrays.toString(maxHeap.array));
while (maxHeap.size > 1) {
maxHeap.swap(0, maxHeap.size - 1);
maxHeap.size--;
maxHeap.down(0);
}
System.out.println(Arrays.toString(maxHeap.array));E02. 数组中第K大元素-Leetcode 215小顶堆(可删去用不到代码)class MinHeap {
int[] array;
int size;
public MinHeap(int capacity) {
array = new int[capacity];
}
private void heapify() {
for (int i = (size >> 1) - 1; i >= 0; i--) {
down(i);
}
}
public int poll() {
swap(0, size - 1);
size--;
down(0);
return array[size];
}
public int poll(int index) {
swap(index, size - 1);
size--;
down(index);
return array[size];
}
public int peek() {
return array[0];
}
public boolean offer(int offered) {
if (size == array.length) {
return false;
}
up(offered);
size++;
return true;
}
public void replace(int replaced) {
array[0] = replaced;
down(0);
}
private void up(int offered) {
int child = size;
while (child > 0) {
int parent = (child - 1) >> 1;
if (offered < array[parent]) {
array[child] = array[parent];
} else {
break;
}
child = parent;
}
array[child] = offered;
}
private void down(int parent) {
int left = (parent << 1) + 1;
int right = left + 1;
int min = parent;
if (left < size && array[left] < array[min]) {
min = left;
}
if (right < size && array[right] < array[min]) {
min = right;
}
if (min != parent) {
swap(min, parent);
down(min);
}
}
// 交换两个索引处的元素
private void swap(int i, int j) {
int t = array[i];
array[i] = array[j];
array[j] = t;
}
}题解public int findKthLargest(int[] numbers, int k) {
MinHeap heap = new MinHeap(k);
for (int i = 0; i < k; i++) {
heap.offer(numbers[i]);
}
for (int i = k; i < numbers.length; i++) {
if(numbers[i] > heap.peek()){
heap.replace(numbers[i]);
}
}
return heap.peek();
}求数组中的第 K 大元素,使用堆并不是最佳选择,可以采用快速选择算法E03. 数据流中第K大元素-Leetcode 703上题的小顶堆加一个方法class MinHeap {
// ...
public boolean isFull() {
return size == array.length;
}
}题解class KthLargest {
private MinHeap heap;
public KthLargest(int k, int[] nums) {
heap = new MinHeap(k);
for(int i = 0; i < nums.length; i++) {
add(nums[i]);
}
}
public int add(int val) {
if(!heap.isFull()){
heap.offer(val);
} else if(val > heap.peek()){
heap.replace(val);
}
return heap.peek();
}
}求数据流中的第 K 大元素,使用堆最合适不过E04. 数据流的中位数-Leetcode 295可以扩容的 heap, max 用于指定是大顶堆还是小顶堆public class Heap {
int[] array;
int size;
boolean max;
public int size() {
return size;
}
public Heap(int capacity, boolean max) {
this.array = new int[capacity];
this.max = max;
}
/**
* 获取堆顶元素
*
* @return 堆顶元素
*/
public int peek() {
return array[0];
}
/**
* 删除堆顶元素
*
* @return 堆顶元素
*/
public int poll() {
int top = array[0];
swap(0, size - 1);
size--;
down(0);
return top;
}
/**
* 删除指定索引处元素
*
* @param index 索引
* @return 被删除元素
*/
public int poll(int index) {
int deleted = array[index];
swap(index, size - 1);
size--;
down(index);
return deleted;
}
/**
* 替换堆顶元素
*
* @param replaced 新元素
*/
public void replace(int replaced) {
array[0] = replaced;
down(0);
}
/**
* 堆的尾部添加元素
*
* @param offered 新元素
*/
public void offer(int offered) {
if (size == array.length) {
grow();
}
up(offered);
size++;
}
private void grow() {
int capacity = size + (size >> 1);
int[] newArray = new int[capacity];
System.arraycopy(array, 0,
newArray, 0, size);
array = newArray;
}
// 将 offered 元素上浮: 直至 offered 小于父元素或到堆顶
private void up(int offered) {
int child = size;
while (child > 0) {
int parent = (child - 1) / 2;
boolean cmp = max ? offered > array[parent] : offered < array[parent];
if (cmp) {
array[child] = array[parent];
} else {
break;
}
child = parent;
}
array[child] = offered;
}
public Heap(int[] array, boolean max) {
this.array = array;
this.size = array.length;
this.max = max;
heapify();
}
// 建堆
private void heapify() {
// 如何找到最后这个非叶子节点 size / 2 - 1
for (int i = size / 2 - 1; i >= 0; i--) {
down(i);
}
}
// 将 parent 索引处的元素下潜: 与两个孩子较大者交换, 直至没孩子或孩子没它大
private void down(int parent) {
int left = parent * 2 + 1;
int right = left + 1;
int min = parent;
if (left < size && (max ? array[left] > array[min] : array[left] < array[min])) {
min = left;
}
if (right < size && (max ? array[right] > array[min] : array[right] < array[min])) {
min = right;
}
if (min != parent) { // 找到了更大的孩子
swap(min, parent);
down(min);
}
}
// 交换两个索引处的元素
private void swap(int i, int j) {
int t = array[i];
array[i] = array[j];
array[j] = t;
}
}题解private Heap left = new Heap(10, false);
private Heap right = new Heap(10, true);
/**
为了保证两边数据量的平衡
<ul>
<li>两边数据一样时,加入左边</li>
<li>两边数据不一样时,加入右边</li>
</ul>
但是, 随便一个数能直接加入吗?
<ul>
<li>加入左边前, 应该挑右边最小的加入</li>
<li>加入右边前, 应该挑左边最大的加入</li>
</ul>
*/
public void addNum(int num) {
if (left.size() == right.size()) {
right.offer(num);
left.offer(right.poll());
} else {
left.offer(num);
right.offer(left.poll());
}
}
/**
* <ul>
* <li>两边数据一致, 左右各取堆顶元素求平均</li>
* <li>左边多一个, 取左边元素</li>
* </ul>
*/
public double findMedian() {
if (left.size() == right.size()) {
return (left.peek() + right.peek()) / 2.0;
} else {
return left.peek();
}
}本题还可以使用平衡二叉搜索树求解,不过代码比两个堆复杂
英勇黄铜
【数据结构与算法】(7)基础数据结构之双端队列的链表实现、环形数组实现示例讲解
2.6 双端队列1) 概述双端队列、队列、栈对比定义特点队列一端删除(头)另一端添加(尾)First In First Out栈一端删除和添加(顶)Last In First Out双端队列两端都可以删除、添加优先级队列优先级高者先出队延时队列根据延时时间确定优先级并发非阻塞队列队列空或满时不阻塞并发阻塞队列队列空时删除阻塞、队列满时添加阻塞注1:Java 中 LinkedList 即为典型双端队列实现,不过它同时实现了 Queue 接口,也提供了栈的 push pop 等方法注2:不同语言,操作双端队列的方法命名有所不同,参见下表操作JavaJavaScriptC++leetCode 641尾部插入offerLastpushpush_backinsertLast头部插入offerFirstunshiftpush_frontinsertFront尾部移除pollLastpoppop_backdeleteLast头部移除pollFirstshiftpop_frontdeleteFront尾部获取peekLastat(-1)backgetRear头部获取peekFirstat(0)frontgetFront吐槽一下 leetCode 命名比较 low常见的单词还有 enqueue 入队、dequeue 出队接口定义public interface Deque<E> {
boolean offerFirst(E e);
boolean offerLast(E e);
E pollFirst();
E pollLast();
E peekFirst();
E peekLast();
boolean isEmpty();
boolean isFull();
}2) 链表实现/**
* 基于环形链表的双端队列
* @param <E> 元素类型
*/
public class LinkedListDeque<E> implements Deque<E>, Iterable<E> {
@Override
public boolean offerFirst(E e) {
if (isFull()) {
return false;
}
size++;
Node<E> a = sentinel;
Node<E> b = sentinel.next;
Node<E> offered = new Node<>(a, e, b);
a.next = offered;
b.prev = offered;
return true;
}
@Override
public boolean offerLast(E e) {
if (isFull()) {
return false;
}
size++;
Node<E> a = sentinel.prev;
Node<E> b = sentinel;
Node<E> offered = new Node<>(a, e, b);
a.next = offered;
b.prev = offered;
return true;
}
@Override
public E pollFirst() {
if (isEmpty()) {
return null;
}
Node<E> a = sentinel;
Node<E> polled = sentinel.next;
Node<E> b = polled.next;
a.next = b;
b.prev = a;
size--;
return polled.value;
}
@Override
public E pollLast() {
if (isEmpty()) {
return null;
}
Node<E> polled = sentinel.prev;
Node<E> a = polled.prev;
Node<E> b = sentinel;
a.next = b;
b.prev = a;
size--;
return polled.value;
}
@Override
public E peekFirst() {
if (isEmpty()) {
return null;
}
return sentinel.next.value;
}
@Override
public E peekLast() {
if (isEmpty()) {
return null;
}
return sentinel.prev.value;
}
@Override
public boolean isEmpty() {
return size == 0;
}
@Override
public boolean isFull() {
return size == capacity;
}
@Override
public Iterator<E> iterator() {
return new Iterator<E>() {
Node<E> p = sentinel.next;
@Override
public boolean hasNext() {
return p != sentinel;
}
@Override
public E next() {
E value = p.value;
p = p.next;
return value;
}
};
}
static class Node<E> {
Node<E> prev;
E value;
Node<E> next;
public Node(Node<E> prev, E value, Node<E> next) {
this.prev = prev;
this.value = value;
this.next = next;
}
}
Node<E> sentinel = new Node<>(null, null, null);
int capacity;
int size;
public LinkedListDeque(int capacity) {
sentinel.next = sentinel;
sentinel.prev = sentinel;
this.capacity = capacity;
}
}3) 数组实现/**
* 基于循环数组实现, 特点
* <ul>
* <li>tail 停下来的位置不存储, 会浪费一个位置</li>
* </ul>
* @param <E>
*/
public class ArrayDeque1<E> implements Deque<E>, Iterable<E> {
/*
h
t
0 1 2 3
b a
*/
@Override
public boolean offerFirst(E e) {
if (isFull()) {
return false;
}
head = dec(head, array.length);
array[head] = e;
return true;
}
@Override
public boolean offerLast(E e) {
if (isFull()) {
return false;
}
array[tail] = e;
tail = inc(tail, array.length);
return true;
}
@Override
public E pollFirst() {
if (isEmpty()) {
return null;
}
E e = array[head];
array[head] = null;
head = inc(head, array.length);
return e;
}
@Override
public E pollLast() {
if (isEmpty()) {
return null;
}
tail = dec(tail, array.length);
E e = array[tail];
array[tail] = null;
return e;
}
@Override
public E peekFirst() {
if (isEmpty()) {
return null;
}
return array[head];
}
@Override
public E peekLast() {
if (isEmpty()) {
return null;
}
return array[dec(tail, array.length)];
}
@Override
public boolean isEmpty() {
return head == tail;
}
@Override
public boolean isFull() {
if (tail > head) {
return tail - head == array.length - 1;
} else if (tail < head) {
return head - tail == 1;
} else {
return false;
}
}
@Override
public Iterator<E> iterator() {
return new Iterator<E>() {
int p = head;
@Override
public boolean hasNext() {
return p != tail;
}
@Override
public E next() {
E e = array[p];
p = inc(p, array.length);
return e;
}
};
}
E[] array;
int head;
int tail;
@SuppressWarnings("unchecked")
public ArrayDeque1(int capacity) {
array = (E[]) new Object[capacity + 1];
}
static int inc(int i, int length) {
if (i + 1 >= length) {
return 0;
}
return i + 1;
}
static int dec(int i, int length) {
if (i - 1 < 0) {
return length - 1;
}
return i - 1;
}
}数组实现中,如果存储的是基本类型,那么无需考虑内存释放,例如但如果存储的是引用类型,应当设置该位置的引用为 null,以便内存及时释放习题E01. 二叉树 Z 字层序遍历-Leetcode 103public class E01Leetcode103 {
public List<List<Integer>> zigzagLevelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) {
return result;
}
LinkedList<TreeNode> queue = new LinkedList<>();
queue.offer(root);
boolean leftToRight = true;
int c1 = 1;
while (!queue.isEmpty()) {
int c2 = 0;
LinkedList<Integer> deque = new LinkedList<>();
for (int i = 0; i < c1; i++) {
TreeNode n = queue.poll();
if (leftToRight) {
deque.offerLast(n.val);
} else {
deque.offerFirst(n.val);
}
if (n.left != null) {
queue.offer(n.left);
c2++;
}
if (n.right != null) {
queue.offer(n.right);
c2++;
}
}
c1 = c2;
leftToRight = !leftToRight;
result.add(deque);
}
return result;
}
public static void main(String[] args) {
TreeNode root = new TreeNode(
new TreeNode(
new TreeNode(4),
2,
new TreeNode(5)
),
1,
new TreeNode(
new TreeNode(6),
3,
new TreeNode(7)
)
);
List<List<Integer>> lists = new E01Leetcode103().zigzagLevelOrder(root);
for (List<Integer> list : lists) {
System.out.println(list);
}
}
}
英勇黄铜
【数据结构与算法】(11)基础数据结构 之 二叉树 二叉树的存储与遍历及相关示例 详细代码讲解
2.10 二叉树二叉树是这么一种树状结构:每个节点最多有两个孩子,左孩子和右孩子重要的二叉树结构完全二叉树(complete binary tree)是一种二叉树结构,除最后一层以外,每一层都必须填满,填充时要遵从先左后右平衡二叉树(balance binary tree)是一种二叉树结构,其中每个节点的左右子树高度相差不超过 11) 存储存储方式分为两种定义树节点与左、右孩子引用(TreeNode)使用数组,前面讲堆时用过,若以 0 作为树的根,索引可以通过如下方式计算父 = floor((子 - 1) / 2)左孩子 = 父 * 2 + 1右孩子 = 父 * 2 + 22) 遍历遍历也分为两种广度优先遍历(Breadth-first order):尽可能先访问距离根最近的节点,也称为层序遍历深度优先遍历(Depth-first order):对于二叉树,可以进一步分成三种(要深入到叶子节点)广度优先本轮开始时队列本轮访问节点[1]1[2, 3]2[3, 4]3[4, 5, 6]4[5, 6]5[6, 7, 8]6[7, 8]7[8]8[]初始化,将根节点加入队列循环处理队列中每个节点,直至队列为空每次循环内处理节点后,将它的孩子节点(即下一层的节点)加入队列注意以上用队列来层序遍历是针对 TreeNode 这种方式表示的二叉树对于数组表现的二叉树,则直接遍历数组即可,自然为层序遍历的顺序深度优先栈暂存已处理前序遍历中序遍历[1]1 ✔️ 左💤 右💤1[1, 2]2✔️ 左💤 右💤1✔️ 左💤 右💤2[1, 2, 4]4✔️ 左✔️ 右✔️2✔️ 左💤 右💤1✔️ 左💤 右💤44[1, 2]2✔️ 左✔️ 右✔️1✔️ 左💤 右💤2[1]1✔️ 左✔️ 右💤1[1, 3]3✔️ 左💤 右💤1✔️ 左✔️ 右💤3[1, 3, 5]5✔️ 左✔️ 右✔️3✔️ 左💤 右💤1✔️ 左✔️ 右💤55[1, 3]3✔️ 左✔️ 右💤1✔️ 左✔️ 右💤3[1, 3, 6]6✔️ 左✔️ 右✔️3✔️ 左✔️ 右💤1✔️ 左✔️ 右💤66[1, 3]3✔️ 左✔️ 右✔️1✔️ 左✔️ 右💤[1]1✔️ 左✔️ 右✔️[]递归实现/**
* <h3>前序遍历</h3>
* @param node 节点
*/
static void preOrder(TreeNode node) {
if (node == null) {
return;
}
System.out.print(node.val + "\t"); // 值
preOrder(node.left); // 左
preOrder(node.right); // 右
}
/**
* <h3>中序遍历</h3>
* @param node 节点
*/
static void inOrder(TreeNode node) {
if (node == null) {
return;
}
inOrder(node.left); // 左
System.out.print(node.val + "\t"); // 值
inOrder(node.right); // 右
}
/**
* <h3>后序遍历</h3>
* @param node 节点
*/
static void postOrder(TreeNode node) {
if (node == null) {
return;
}
postOrder(node.left); // 左
postOrder(node.right); // 右
System.out.print(node.val + "\t"); // 值
}非递归实现前序遍历LinkedListStack<TreeNode> stack = new LinkedListStack<>();
TreeNode curr = root;
while (!stack.isEmpty() || curr != null) {
if (curr != null) {
stack.push(curr);
System.out.println(curr);
curr = curr.left;
} else {
TreeNode pop = stack.pop();
curr = pop.right;
}
}中序遍历LinkedListStack<TreeNode> stack = new LinkedListStack<>();
TreeNode curr = root;
while (!stack.isEmpty() || curr != null) {
if (curr != null) {
stack.push(curr);
curr = curr.left;
} else {
TreeNode pop = stack.pop();
System.out.println(pop);
curr = pop.right;
}
}后序遍历LinkedListStack<TreeNode> stack = new LinkedListStack<>();
TreeNode curr = root;
TreeNode pop = null;
while (!stack.isEmpty() || curr != null) {
if (curr != null) {
stack.push(curr);
curr = curr.left;
} else {
TreeNode peek = stack.peek();
if (peek.right == null || peek.right == pop) {
pop = stack.pop();
System.out.println(pop);
} else {
curr = peek.right;
}
}
}统一写法下面是一种统一的写法,依据后序遍历修改LinkedList<TreeNode> stack = new LinkedList<>();
TreeNode curr = root; // 代表当前节点
TreeNode pop = null; // 最近一次弹栈的元素
while (curr != null || !stack.isEmpty()) {
if (curr != null) {
colorPrintln("前: " + curr.val, 31);
stack.push(curr); // 压入栈,为了记住回来的路
curr = curr.left;
} else {
TreeNode peek = stack.peek();
// 右子树可以不处理, 对中序来说, 要在右子树处理之前打印
if (peek.right == null) {
colorPrintln("中: " + peek.val, 36);
pop = stack.pop();
colorPrintln("后: " + pop.val, 34);
}
// 右子树处理完成, 对中序来说, 无需打印
else if (peek.right == pop) {
pop = stack.pop();
colorPrintln("后: " + pop.val, 34);
}
// 右子树待处理, 对中序来说, 要在右子树处理之前打印
else {
colorPrintln("中: " + peek.val, 36);
curr = peek.right;
}
}
}
public static void colorPrintln(String origin, int color) {
System.out.printf("\033[%dm%s\033[0m%n", color, origin);
}一张图演示三种遍历红色:前序遍历顺序绿色:中序遍历顺序蓝色:后续遍历顺序习题E01. 前序遍历二叉树-Leetcode 144E02. 中序遍历二叉树-Leetcode 94E03. 后序遍历二叉树-Leetcode 145E04. 对称二叉树-Leetcode 101public boolean isSymmetric(TreeNode root) {
return check(root.left, root.right);
}
public boolean check(TreeNode left, TreeNode right) {
// 若同时为 null
if (left == null && right == null) {
return true;
}
// 若有一个为 null (有上一轮筛选,另一个肯定不为 null)
if (left == null || right == null) {
return false;
}
if (left.val != right.val) {
return false;
}
return check(left.left, right.right) && check(left.right, right.left);
}类似题目:Leetcode 100 题 - 相同的树E05. 二叉树最大深度-Leetcode 104后序遍历求解/*
思路:
1. 得到左子树深度, 得到右子树深度, 二者最大者加一, 就是本节点深度
2. 因为需要先得到左右子树深度, 很显然是后序遍历典型应用
3. 关于深度的定义:从根出发, 离根最远的节点总边数,
注意: 力扣里的深度定义要多一
深度2 深度3 深度1
1 1 1
/ \ / \
2 3 2 3
\
4
*/
public int maxDepth(TreeNode node) {
if (node == null) {
return 0; // 非力扣题目改为返回 -1
}
int d1 = maxDepth(node.left);
int d2 = maxDepth(node.right);
return Integer.max(d1, d2) + 1;
}后序遍历求解-非递归/*
思路:
1. 使用非递归后序遍历, 栈的最大高度即为最大深度
*/
public int maxDepth(TreeNode root) {
TreeNode curr = root;
LinkedList<TreeNode> stack = new LinkedList<>();
int max = 0;
TreeNode pop = null;
while (curr != null || !stack.isEmpty()) {
if (curr != null) {
stack.push(curr);
int size = stack.size();
if (size > max) {
max = size;
}
curr = curr.left;
} else {
TreeNode peek = stack.peek();
if(peek.right == null || peek.right == pop) {
pop = stack.pop();
} else {
curr = peek.right;
}
}
}
return max;
}层序遍历求解/*
思路:
1. 使用层序遍历, 层数即最大深度
*/
public int maxDepth(TreeNode root) {
if(root == null) {
return 0;
}
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
int level = 0;
while (!queue.isEmpty()) {
level++;
int size = queue.size();
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
}
}
return level;
}E06. 二叉树最小深度-Leetcode 111后序遍历求解public int minDepth(TreeNode node) {
if (node == null) {
return 0;
}
int d1 = minDepth(node.left);
int d2 = minDepth(node.right);
if (d1 == 0 || d2 == 0) {
return d1 + d2 + 1;
}
return Integer.min(d1, d2) + 1;
}
相较于求最大深度,应当考虑:当右子树为 null,应当返回左子树深度加一当左子树为 null,应当返回右子树深度加一上面两种情况满足时,不应该再把为 null 子树的深度 0 参与最小值比较,例如这样 1
/
2正确深度为 2,若把为 null 的右子树的深度 0 考虑进来,会得到错误结果 1 1
\
3
\
4正确深度为 3,若把为 null 的左子树的深度 0 考虑进来,会得到错误结果 1层序遍历求解遇到的第一个叶子节点所在层就是最小深度例如,下面的树遇到的第一个叶子节点 3 所在的层就是最小深度,其他 4,7 等叶子节点深度更深,也更晚遇到 1
/ \
2 3
/ \
4 5
/
7 代码public int minDepth(TreeNode root) {
if(root == null) {
return 0;
}
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
int level = 0;
while (!queue.isEmpty()) {
level++;
int size = queue.size();
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
if (node.left == null && node.right == null) {
return level;
}
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
}
}
return level;
}效率会高于之前后序遍历解法,因为找到第一个叶子节点后,就无需后续的层序遍历了E07. 翻转二叉树-Leetcode 226public TreeNode invertTree(TreeNode root) {
fn(root);
return root;
}
private void fn(TreeNode node){
if (node == null) {
return;
}
TreeNode t = node.left;
node.left = node.right;
node.right = t;
fn(node.left);
fn(node.right);
}先交换、再递归或是先递归、再交换都可以E08. 后缀表达式转二叉树static class TreeNode {
public String val;
public TreeNode left;
public TreeNode right;
public TreeNode(String val) {
this.val = val;
}
public TreeNode(TreeNode left, String val, TreeNode right) {
this.left = left;
this.val = val;
this.right = right;
}
@Override
public String toString() {
return this.val;
}
}
/*
中缀表达式 (2-1)*3
后缀(逆波兰)表达式 21-3*
1.遇到数字入栈
2.遇到运算符, 出栈两次, 与当前节点建立父子关系, 当前节点入栈
栈
| |
| |
| |
_____
表达式树
*
/ \
- 3
/ \
2 1
21-3*
*/
public TreeNode constructExpressionTree(String[] tokens) {
LinkedList<TreeNode> stack = new LinkedList<>();
for (String t : tokens) {
switch (t) {
case "+", "-", "*", "/" -> { // 运算符
TreeNode right = stack.pop();
TreeNode left = stack.pop();
TreeNode parent = new TreeNode(t);
parent.left = left;
parent.right = right;
stack.push(parent);
}
default -> { // 数字
stack.push(new TreeNode(t));
}
}
}
return stack.peek();
}E09. 根据前序与中序遍历结果构造二叉树-Leetcode 105先通过前序遍历结果定位根节点再结合中序遍历结果切分左右子树public class E09Leetcode105 {
/*
preOrder = {1,2,4,3,6,7}
inOrder = {4,2,1,6,3,7}
根 1
pre in
左 2,4 4,2
右 3,6,7 6,3,7
根 2
左 4
根 3
左 6
右 7
*/
public TreeNode buildTree(int[] preOrder, int[] inOrder) {
if (preOrder.length == 0) {
return null;
}
// 创建根节点
int rootValue = preOrder[0];
TreeNode root = new TreeNode(rootValue);
// 区分左右子树
for (int i = 0; i < inOrder.length; i++) {
if (inOrder[i] == rootValue) {
// 0 ~ i-1 左子树
// i+1 ~ inOrder.length -1 右子树
int[] inLeft = Arrays.copyOfRange(inOrder, 0, i); // [4,2]
int[] inRight = Arrays.copyOfRange(inOrder, i + 1, inOrder.length); // [6,3,7]
int[] preLeft = Arrays.copyOfRange(preOrder, 1, i + 1); // [2,4]
int[] preRight = Arrays.copyOfRange(preOrder, i + 1, inOrder.length); // [3,6,7]
root.left = buildTree(preLeft, inLeft); // 2
root.right = buildTree(preRight, inRight); // 3
break;
}
}
return root;
}
}代码可以进一步优化,涉及新数据结构,以后实现E10. 根据中序与后序遍历结果构造二叉树-Leetcode 106先通过后序遍历结果定位根节点再结合中序遍历结果切分左右子树public TreeNode buildTree(int[] inOrder, int[] postOrder) {
if (inOrder.length == 0) {
return null;
}
// 根
int rootValue = postOrder[postOrder.length - 1];
TreeNode root = new TreeNode(rootValue);
// 切分左右子树
for (int i = 0; i < inOrder.length; i++) {
if (inOrder[i] == rootValue) {
int[] inLeft = Arrays.copyOfRange(inOrder, 0, i);
int[] inRight = Arrays.copyOfRange(inOrder, i + 1, inOrder.length);
int[] postLeft = Arrays.copyOfRange(postOrder, 0, i);
int[] postRight = Arrays.copyOfRange(postOrder, i, postOrder.length - 1);
root.left = buildTree(inLeft, postLeft);
root.right = buildTree(inRight, postRight);
break;
}
}
return root;
}代码可以进一步优化,涉及新数据结构,以后实现
英勇黄铜
【数据结构与算法】(5)基础数据结构之队列 链表实现、环形数组实现详细代码示例讲解
2.4 队列1) 概述计算机科学中,queue 是以顺序的方式维护的一组数据集合,在一端添加数据,从另一端移除数据。习惯来说,添加的一端称为尾,移除的一端称为头,就如同生活中的排队买商品In computer science, a queue is a collection of entities that are maintained in a sequence and can be modified by the addition of entities at one end of the sequence and the removal of entities from the other end of the sequence先定义一个简化的队列接口public interface Queue<E> {
/**
* 向队列尾插入值
* @param value 待插入值
* @return 插入成功返回 true, 插入失败返回 false
*/
boolean offer(E value);
/**
* 从对列头获取值, 并移除
* @return 如果队列非空返回对头值, 否则返回 null
*/
E poll();
/**
* 从对列头获取值, 不移除
* @return 如果队列非空返回对头值, 否则返回 null
*/
E peek();
/**
* 检查队列是否为空
* @return 空返回 true, 否则返回 false
*/
boolean isEmpty();
/**
* 检查队列是否已满
* @return 满返回 true, 否则返回 false
*/
boolean isFull();
}2) 链表实现下面以单向环形带哨兵链表方式来实现队列代码public class LinkedListQueue<E>
implements Queue<E>, Iterable<E> {
private static class Node<E> {
E value;
Node<E> next;
public Node(E value, Node<E> next) {
this.value = value;
this.next = next;
}
}
private Node<E> head = new Node<>(null, null);
private Node<E> tail = head;
private int size = 0;
private int capacity = Integer.MAX_VALUE;
{
tail.next = head;
}
public LinkedListQueue() {
}
public LinkedListQueue(int capacity) {
this.capacity = capacity;
}
@Override
public boolean offer(E value) {
if (isFull()) {
return false;
}
Node<E> added = new Node<>(value, head);
tail.next = added;
tail = added;
size++;
return true;
}
@Override
public E poll() {
if (isEmpty()) {
return null;
}
Node<E> first = head.next;
head.next = first.next;
if (first == tail) {
tail = head;
}
size--;
return first.value;
}
@Override
public E peek() {
if (isEmpty()) {
return null;
}
return head.next.value;
}
@Override
public boolean isEmpty() {
return head == tail;
}
@Override
public boolean isFull() {
return size == capacity;
}
@Override
public Iterator<E> iterator() {
return new Iterator<E>() {
Node<E> p = head.next;
@Override
public boolean hasNext() {
return p != head;
}
@Override
public E next() {
E value = p.value;
p = p.next;
return value;
}
};
}
}3) 环形数组实现好处对比普通数组,起点和终点更为自由,不用考虑数据移动“环”意味着不会存在【越界】问题数组性能更佳环形数组比较适合实现有界队列、RingBuffer 等下标计算例如,数组长度是 5,当前位置是 3 ,向前走 2 步,此时下标为 ( 3 + 2 ) % 5 = 0 (3 + 2)\%5 = 0(3+2)%5=0cur 当前指针位置step 前进步数length 数组长度注意:如果 step = 1,也就是一次走一步,可以在 >= length 时重置为 0 即可判断空判断满满之后的策略可以根据业务需求决定例如我们要实现的环形队列,满之后就拒绝入队代码public class ArrayQueue<E> implements Queue<E>, Iterable<E>{
private int head = 0;
private int tail = 0;
private final E[] array;
private final int length;
@SuppressWarnings("all")
public ArrayQueue(int capacity) {
length = capacity + 1;
array = (E[]) new Object[length];
}
@Override
public boolean offer(E value) {
if (isFull()) {
return false;
}
array[tail] = value;
tail = (tail + 1) % length;
return true;
}
@Override
public E poll() {
if (isEmpty()) {
return null;
}
E value = array[head];
head = (head + 1) % length;
return value;
}
@Override
public E peek() {
if (isEmpty()) {
return null;
}
return array[head];
}
@Override
public boolean isEmpty() {
return tail == head;
}
@Override
public boolean isFull() {
return (tail + 1) % length == head;
}
@Override
public Iterator<E> iterator() {
return new Iterator<E>() {
int p = head;
@Override
public boolean hasNext() {
return p != tail;
}
@Override
public E next() {
E value = array[p];
p = (p + 1) % array.length;
return value;
}
};
}
}判断空、满方法2引入 sizepublic class ArrayQueue2<E> implements Queue<E>, Iterable<E> {
private int head = 0;
private int tail = 0;
private final E[] array;
private final int capacity;
private int size = 0;
@SuppressWarnings("all")
public ArrayQueue2(int capacity) {
this.capacity = capacity;
array = (E[]) new Object[capacity];
}
@Override
public boolean offer(E value) {
if (isFull()) {
return false;
}
array[tail] = value;
tail = (tail + 1) % capacity;
size++;
return true;
}
@Override
public E poll() {
if (isEmpty()) {
return null;
}
E value = array[head];
head = (head + 1) % capacity;
size--;
return value;
}
@Override
public E peek() {
if (isEmpty()) {
return null;
}
return array[head];
}
@Override
public boolean isEmpty() {
return size == 0;
}
@Override
public boolean isFull() {
return size == capacity;
}
@Override
public Iterator<E> iterator() {
return new Iterator<E>() {
int p = head;
@Override
public boolean hasNext() {
return p != tail;
}
@Override
public E next() {
E value = array[p];
p = (p + 1) % capacity;
return value;
}
};
}
}判断空、满方法3head 和 tail 不断递增,用到索引时,再用它们进行计算,两个问题如何保证 head 和 tail 自增超过正整数最大值的正确性如何让取模运算性能更高答案:让 capacity 为 2 的幂public class ArrayQueue3<E> implements Queue<E>, Iterable<E> {
private int head = 0;
private int tail = 0;
private final E[] array;
private final int capacity;
@SuppressWarnings("all")
public ArrayQueue3(int capacity) {
if ((capacity & capacity - 1) != 0) {
throw new IllegalArgumentException("capacity 必须为 2 的幂");
}
this.capacity = capacity;
array = (E[]) new Object[this.capacity];
}
@Override
public boolean offer(E value) {
if (isFull()) {
return false;
}
array[tail & capacity - 1] = value;
tail++;
return true;
}
@Override
public E poll() {
if (isEmpty()) {
return null;
}
E value = array[head & capacity - 1];
head++;
return value;
}
@Override
public E peek() {
if (isEmpty()) {
return null;
}
return array[head & capacity - 1];
}
@Override
public boolean isEmpty() {
return tail - head == 0;
}
@Override
public boolean isFull() {
return tail - head == capacity;
}
@Override
public Iterator<E> iterator() {
return new Iterator<E>() {
int p = head;
@Override
public boolean hasNext() {
return p != tail;
}
@Override
public E next() {
E value = array[p & capacity - 1];
p++;
return value;
}
};
}
}
英勇黄铜
【数据结构与算法】(8)基础数据结构 之 优先级队列的无序数组实现、有序数组实现、堆实现详细代码示例
2.7 优先级队列1) 无序数组实现要点入队保持顺序出队前找到优先级最高的出队,相当于一次选择排序public class PriorityQueue1<E extends Priority> implements Queue<E> {
Priority[] array;
int size;
public PriorityQueue1(int capacity) {
array = new Priority[capacity];
}
@Override // O(1)
public boolean offer(E e) {
if (isFull()) {
return false;
}
array[size++] = e;
return true;
}
// 返回优先级最高的索引值
private int selectMax() {
int max = 0;
for (int i = 1; i < size; i++) {
if (array[i].priority() > array[max].priority()) {
max = i;
}
}
return max;
}
@Override // O(n)
public E poll() {
if (isEmpty()) {
return null;
}
int max = selectMax();
E e = (E) array[max];
remove(max);
return e;
}
private void remove(int index) {
if (index < size - 1) {
System.arraycopy(array, index + 1,
array, index, size - 1 - index);
}
array[--size] = null; // help GC
}
@Override
public E peek() {
if (isEmpty()) {
return null;
}
int max = selectMax();
return (E) array[max];
}
@Override
public boolean isEmpty() {
return size == 0;
}
@Override
public boolean isFull() {
return size == array.length;
}
}视频中忘记了 help GC,注意一下2) 有序数组实现要点入队后排好序,优先级最高的排列在尾部出队只需删除尾部元素即可public class PriorityQueue2<E extends Priority> implements Queue<E> {
Priority[] array;
int size;
public PriorityQueue2(int capacity) {
array = new Priority[capacity];
}
// O(n)
@Override
public boolean offer(E e) {
if (isFull()) {
return false;
}
insert(e);
size++;
return true;
}
// 一轮插入排序
private void insert(E e) {
int i = size - 1;
while (i >= 0 && array[i].priority() > e.priority()) {
array[i + 1] = array[i];
i--;
}
array[i + 1] = e;
}
// O(1)
@Override
public E poll() {
if (isEmpty()) {
return null;
}
E e = (E) array[size - 1];
array[--size] = null; // help GC
return e;
}
@Override
public E peek() {
if (isEmpty()) {
return null;
}
return (E) array[size - 1];
}
@Override
public boolean isEmpty() {
return size == 0;
}
@Override
public boolean isFull() {
return size == array.length;
}
}3) 堆实现计算机科学中,堆是一种基于树的数据结构,通常用完全二叉树实现。堆的特性如下在大顶堆中,任意节点 C 与它的父节点 P 符合 P . v a l u e ≥ C . v a l u e而小顶堆中,任意节点 C 与它的父节点 P 符合 P . v a l u e ≤ C . v a l u e 最顶层的节点(没有父亲)称之为 root 根节点In computer science, a heap is a specialized tree-based data structure which is essentially an almost complete tree that satisfies the heap property: in a max heap, for any given node C, if P is a parent node of C, then the key (the value) of P is greater than or equal to the key of C. In a min heap, the key of P is less than or equal to the key of C. The node at the “top” of the heap (with no parents) is called the root node例1 - 满二叉树(Full Binary Tree)特点:每一层都是填满的例2 - 完全二叉树(Complete Binary Tree)特点:最后一层可能未填满,靠左对齐例3 - 大顶堆例4 - 小顶堆完全二叉树可以使用数组来表示public class PriorityQueue4<E extends Priority> implements Queue<E> {
Priority[] array;
int size;
public PriorityQueue4(int capacity) {
array = new Priority[capacity];
}
@Override
public boolean offer(E offered) {
if (isFull()) {
return false;
}
int child = size++;
int parent = (child - 1) / 2;
while (child > 0 && offered.priority() > array[parent].priority()) {
array[child] = array[parent];
child = parent;
parent = (child - 1) / 2;
}
array[child] = offered;
return true;
}
private void swap(int i, int j) {
Priority t = array[i];
array[i] = array[j];
array[j] = t;
}
@Override
public E poll() {
if (isEmpty()) {
return null;
}
swap(0, size - 1);
size--;
Priority e = array[size];
array[size] = null;
shiftDown(0);
return (E) e;
}
void shiftDown(int parent) {
int left = 2 * parent + 1;
int right = left + 1;
int max = parent;
if (left < size && array[left].priority() > array[max].priority()) {
max = left;
}
if (right < size && array[right].priority() > array[max].priority()) {
max = right;
}
if (max != parent) {
swap(max, parent);
shiftDown(max);
}
}
@Override
public E peek() {
if (isEmpty()) {
return null;
}
return (E) array[0];
}
@Override
public boolean isEmpty() {
return size == 0;
}
@Override
public boolean isFull() {
return size == array.length;
}
}习题E01. 合并多个有序链表-Leetcode 23这道题目之前解答过,现在用刚学的优先级队列来实现一下题目中要从小到大排列,因此选择用小顶堆来实现,自定义小顶堆如下public class MinHeap {
ListNode[] array;
int size;
public MinHeap(int capacity) {
array = new ListNode[capacity];
}
public void offer(ListNode offered) {
int child = size++;
int parent = (child - 1) / 2;
while (child > 0 && offered.val < array[parent].val) {
array[child] = array[parent];
child = parent;
parent = (child - 1) / 2;
}
array[child] = offered;
}
public ListNode poll() {
if (isEmpty()) {
return null;
}
swap(0, size - 1);
size--;
ListNode e = array[size];
array[size] = null; // help GC
down(0);
return e;
}
private void down(int parent) {
int left = 2 * parent + 1;
int right = left + 1;
int min = parent;
if (left < size && array[left].val < array[min].val) {
min = left;
}
if (right < size && array[right].val < array[min].val) {
min = right;
}
if (min != parent) {
swap(min, parent);
down(min);
}
}
private void swap(int i, int j) {
ListNode t = array[i];
array[i] = array[j];
array[j] = t;
}
public boolean isEmpty() {
return size == 0;
}
}代码public class E01Leetcode23 {
public ListNode mergeKLists(ListNode[] lists) {
// 1. 使用 jdk 的优先级队列实现
// PriorityQueue<ListNode> queue = new PriorityQueue<>(Comparator.comparingInt(a -> a.val));
// 2. 使用自定义小顶堆实现
MinHeap queue = new MinHeap(lists.length);
for (ListNode head : lists) {
if (head != null) {
queue.offer(head);
}
}
ListNode s = new ListNode(-1, null);
ListNode p = s;
while (!queue.isEmpty()) {
ListNode node = queue.poll();
p.next = node;
p = node;
if (node.next != null) {
queue.offer(node.next);
}
}
return s.next;
}
}提问:能否将每个链表的所有元素全部加入堆,再一个个从堆顶移除?回答:可以是可以,但对空间占用就高了,堆的一个优点就是用有限的空间做事情
英勇黄铜
【数据结构与算法】(1)初识算法之什么是算法?什么是数据结构?二分查找代码示例
一. 初识算法1.1 什么是算法?定义在数学和计算机科学领域,算法是一系列有限的严谨指令,通常用于解决一类特定问题或执行计算In mathematics and computer science, an algorithm (/ˈælɡərɪðəm/) is a finite sequence of rigorous instructions, typically used to solve a class of specific problems or to perform a computation.[^1]Introduction to Algorithm[^2]不正式的说,算法就是任何定义优良的计算过程:接收一些值作为输入,在有限的时间内,产生一些值作为输出。Informally, an algorithm is any well-defined computational procedure that takes some value, or set of values, as input and produces some value, or set of values, as output in a finite amount of time.1.2 什么是数据结构?定义在计算机科学领域,数据结构是一种数据组织、管理和存储格式,通常被选择用来高效访问数据In computer science, a data structure is a data organization, management, and storage format that is usually chosen for efficient access to dataIntroduction to Algorithm[^2]数据结构是一种存储和组织数据的方式,旨在便于访问和修改A data structure is a way to store and organize data in order to facilitate access and modifications可以说,程序 = 数据结构 + 算法,它们是每一位程序员的基本功,下来我们通过对一个非常著名的二分查找算法的讲解来认识一下算法1.3 二分查找 [^3]二分查找算法也称折半查找,是一种非常高效的工作于有序数组的查找算法。后续的课程中还会学习更多的查找算法,但在此之前,不妨用它作为入门。1) 基础版需求:在有序数组 A AA 内,查找值 t a r g e t targettarget如果找到返回索引如果找不到返回 − 1 -1−1算法描述前提给定一个内含 n 个元素的有序数组 A,满足 A0≤A1≤A2≤⋯≤An−1,一个待查target1设置 i=0,j=n−12如果 i>j,结束查找,没找到3设置 m = m=floor(i+j/2) ,m 为中间索引,floor 是向下取整(≤2i+j 的最小整数)4如果 target<Am 设置 j=m−1,跳到第2步5如果 Am<target 设置 i=m+1,跳到第2步6如果 Am=target,结束查找,找到了P.S.对于一个算法来讲,都有较为严谨的描述,上面是一个例子后续讲解时,以简明直白为目标,不会总以上面的方式来描述算法java 实现public static int binarySearch(int[] a, int target) {
int i = 0, j = a.length - 1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) { // 在左边
j = m - 1;
} else if (a[m] < target) { // 在右边
i = m + 1;
} else {
return m;
}
}
return -1;
}
i , j 对应着搜索区间 [0,a.length−1](注意是闭合的区间),i < = j 意味着搜索区间内还有未比较的元素,i , j 指向的元素也可能是比较的目标m 对应着中间位置,中间位置左边和右边的元素可能不相等(差一个),不会影响结果如果某次未找到,那么缩小后的区间内不包含 m 2) 改变版另一种写法public static int binarySearch(int[] a, int target) {
int i = 0, j = a.length;
while (i < j) {
int m = (i + j) >>> 1;
if (target < a[m]) { // 在左边
j = m;
} else if (a[m] < target) { // 在右边
i = m + 1;
} else {
return m;
}
}
return -1;
}
i , j i对应着搜索区间 [[0,a.length)(注意是左闭右开的区间),i < j 意味着搜索区间内还有未比较的元素,j 指向的一定不是查找目标思考:为啥这次不加i==j 的条件了?回答:这回j指向的不是查找目标,如果还加i条件,就意味着j指向的还会再次比较,找不到时,会死循环如果某次要缩小右边界,那么 j = m,因为此时的 m 已经不是查找目标了1.4 衡量算法好坏时间复杂度下面的查找算法也能得出与之前二分查找一样的结果,那你能说出它差在哪里吗?public static int search(int[] a, int k) {
for (
int i = 0;
i < a.length;
i++
) {
if (a[i] == k) {
return i;
}
}
return -1;
}
考虑最坏情况下(没找到)例如 [1,2,3,4] 查找 5int i = 0 只执行一次i < a.length 受数组元素个数 n 的影响,比较 n + 1 次i++ 受数组元素个数 n 的影响,自增 n 次a[i] == k 受元素个数 n 的影响,比较 n 次return -1,执行一次粗略认为每行代码执行时间是 t tt,假设 n = 4 那么总执行时间是(1+4+1+4+4+1)∗t=15t可以推导出更一般地公式为,T=(3∗n+3)t如果套用二分查找算法,还是 [1,2,3,4] 查找 5public static int binarySearch(int[] a, int target) {
int i = 0, j = a.length - 1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) { // 在左边
j = m - 1;
} else if (a[m] < target) { // 在右边
i = m + 1;
} else {
return m;
}
}
return -1;
}
int i = 0, j = a.length - 1 各执行 1 次i <= j 比较floor(log2(n)+1) 再加 1 次(i + j) >>> 1 计算 floor(log2(n)+1) 次接下来 if() else if() else 会执行 3∗floor(log2(n)+1) 次,分别为if 比较else if 比较else if 比较成立后的赋值语句return -1,执行一次结果:总执行时间为 (2+(1+3)+3+3∗3+1)∗t=19t更一般地公式为(4+5∗floor(log2(n)+1))∗t注意:左侧未找到和右侧未找到结果不一样,这里不做分析两个算法比较,可以看到 n 在较小的时候,二者花费的次数差不多但随着 n 越来越大,比如说 n=1000 时,用二分查找算法(红色)也就是 54t ,而蓝色算法则需要 3003t画图采用的是 Desmos | 图形计算器计算机科学中,时间复杂度是用来衡量:一个算法的执行,随数据规模增大,而增长的时间成本不依赖于环境因素如何表示时间复杂度呢?假设算法要处理的数据规模是 n ,代码总的执行行数用函数 f ( n ) 来表示,例如:线性查找算法的函数f(n)=3∗n+3二分查找算法的函数f(n)=(floor(log2(n))+1)∗5+4为了对 f ( n ) 进行化简,应当抓住主要矛盾,找到一个变化趋势与之相近的表示法大 O 表示法[^4]其中c,c1,c2 都为一个常数f ( n ) 是实际执行代码行数与 n 的函数g ( n ) 是经过化简,变化趋势与 f ( n ) 一致的 n 的函数渐进上界渐进上界(asymptotic upper bound):从某个常数 n0开始,c∗g(n) 总是位于 f(n) 上方,那么记作O(g(n))代表算法执行的最差情况例1f(n)=3∗n+3g(n)=n取 c = 4,在n0 = 3之后,g(n) 可以作为f(n) 的渐进上界,因此表示法写作 O(n)例2f(n)=5∗floor(log2(n))+9g(n)=log2(n)O(log2(n))已知 f ( n ) f(n)f(n) 来说,求g(n)表达式中相乘的常量,可以省略,如f(n)=100∗n ^2中的100多项式中数量规模更小(低次项)的表达式,如f(n)=n2+n中的nf(n)=n^3+n^2中的n^2不同底数的对数,渐进上界可以用一个对数函数 logn 表示例如:log2(n) 可以替换为log10(n),因为log2(n)=log10(n)/log10(2),1/log10(2)可以省略类似的,对数的常数次幂可省略如:log(nc)=c∗log(n)常见大 O OO 表示法按时间复杂度从低到高黑色横线 O(1),常量时间,意味着算法时间并不随数据规模而变化绿色O(log(n)),对数时间蓝色 O(n),线性时间,算法时间与数据规模成正比橙色 O(n∗log(n)),拟线性时间红色O(n2) 平方时间黑色朝上 O(2n) 指数时间没画出来的 O(n!)渐进下界渐进下界(asymptotic lower bound):从某个常数n0开始,c∗g(n) 总是位于 f(n) 下方,那么记作)Ω(g(n))渐进紧界渐进紧界(asymptotic tight bounds):从某个常数n0开始,f(n) 总是在 c1∗g(n) 和 c2∗g(n) 之间,那么记作 Θ(g(n))空间复杂度与时间复杂度类似,一般也使用大 O表示法来衡量:一个算法执行随数据规模增大,而增长的额外空间成本public static int binarySearchBasic(int[] a, int target) {
int i = 0, j = a.length - 1; // 设置指针和初值
while (i <= j) { // i~j 范围内有东西
int m = (i + j) >>> 1;
if(target < a[m]) { // 目标在左边
j = m - 1;
} else if (a[m] < target) { // 目标在右边
i = m + 1;
} else { // 找到了
return m;
}
}
return -1;
}
二分查找性能下面分析二分查找算法的性能时间复杂度最坏情况:O(logn)最好情况:如果待查找元素恰好在数组中央,只需要循环一次 O(1)空间复杂度需要常数个指针 i,j,m ,因此额外占用的空间是O(1)1.5 再看二分查找1) 平衡版public static int binarySearchBalance(int[] a, int target) {
int i = 0, j = a.length;
while (1 < j - i) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m;
} else {
i = m;
}
}
return (a[i] == target) ? i : -1;
}
思想:左闭右开的区间,i 指向的可能是目标,而 j 指向的不是目标不奢望循环内通过 m 找出目标, 缩小区间直至剩 1 个, 剩下的这个可能就是要找的(通过i)改变 i 边界时,它指向的可能是目标,因此不能 m+1循环内的平均比较次数减少了时间复杂度 Θ(log(n))2) Java 版private static int binarySearch0(long[] a, int fromIndex, int toIndex,
long key) {
int low = fromIndex;
int high = toIndex - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
long midVal = a[mid];
if (midVal < key)
low = mid + 1;
else if (midVal > key)
high = mid - 1;
else
return mid; // key found
}
return -(low + 1); // key not found.
}
例如 [1,3,5,6] 要插入 2 那么就是找到一个位置,这个位置左侧元素都比它小插入点取负是为了与找到情况区分-1 是为了把索引 0 位置的插入点与找到的情况进行区分3) Leftmost 与 Rightmost有时我们希望返回的是最左侧的重复元素,如果用 Basic 二分查找对于数组 [1,2,3,4,4,5,6,7],查找元素4,结果是索引3对于数组[1,2,4,4,4,5,6,7],查找元素4,结果也是索引3,并不是最左侧的元素public static int binarySearchLeftmost1(int[] a, int target) {
int i = 0, j = a.length - 1;
int candidate = -1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1;
} else if (a[m] < target) {
i = m + 1;
} else {
candidate = m; // 记录候选位置
j = m - 1; // 继续向左
}
}
return candidate;
}
如果希望返回的是最右侧元素public static int binarySearchRightmost1(int[] a, int target) {
int i = 0, j = a.length - 1;
int candidate = -1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1;
} else if (a[m] < target) {
i = m + 1;
} else {
candidate = m; // 记录候选位置
i = m + 1; // 继续向右
}
}
return candidate;
}
应用对于 Leftmost 与 Rightmost,可以返回一个比 -1 更有用的值Leftmost 改为public static int binarySearchLeftmost(int[] a, int target) {
int i = 0, j = a.length - 1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target <= a[m]) {
j = m - 1;
} else {
i = m + 1;
}
}
return i;
}
leftmost 返回值的另一层含义:<target 的元素个数小于等于中间值,都要向左找Rightmost 改为public static int binarySearchRightmost(int[] a, int target) {
int i = 0, j = a.length - 1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1;
} else {
i = m + 1;
}
}
return i - 1;
}
大于等于中间值,都要向右找几个名词求最近邻居:前任和后任距离更近者
英勇黄铜
【数据结构与算法】(2)基础数据结构 之 数组 动态数组、二维数组详细示例讲解与局限性原理及越界检查
1 数组1) 概述定义在计算机科学中,数组是由一组元素(值或变量)组成的数据结构,每个元素有至少一个索引或键来标识In computer science, an array is a data structure consisting of a collection of elements (values or variables), each identified by at least one array index or key因为数组内的元素是连续存储的,所以数组中元素的地址,可以通过其索引计算出来,例如:int[] array = {1,2,3,4,5}小测试byte[] array = {1,2,3,4,5}
已知 array 的数据的起始地址是 0x7138f94c8,那么元素 3 的地址是什么?答:0x7138f94c8 + 2 * 1 = 0x7138f94ca空间占用例如int[] array = {1, 2, 3, 4, 5};的大小为 40 个字节,组成如下8 + 4 + 4 + 5*4 + 4(alignment)随机访问性能即根据索引查找元素,时间复杂度是 O ( 1 ) O(1)O(1)2) 动态数组java 版本public class DynamicArray implements Iterable<Integer> {
private int size = 0; // 逻辑大小
private int capacity = 8; // 容量
private int[] array = {};
/**
* 向最后位置 [size] 添加元素
*
* @param element 待添加元素
*/
public void addLast(int element) {
add(size, element);
}
/**
* 向 [0 .. size] 位置添加元素
*
* @param index 索引位置
* @param element 待添加元素
*/
public void add(int index, int element) {
checkAndGrow();
// 添加逻辑
if (index >= 0 && index < size) {
// 向后挪动, 空出待插入位置
System.arraycopy(array, index,
array, index + 1, size - index);
}
array[index] = element;
size++;
}
private void checkAndGrow() {
// 容量检查
if (size == 0) {
array = new int[capacity];
} else if (size == capacity) {
// 进行扩容, 1.5 1.618 2
capacity += capacity >> 1;
int[] newArray = new int[capacity];
System.arraycopy(array, 0,
newArray, 0, size);
array = newArray;
}
}
/**
* 从 [0 .. size) 范围删除元素
*
* @param index 索引位置
* @return 被删除元素
*/
public int remove(int index) { // [0..size)
int removed = array[index];
if (index < size - 1) {
// 向前挪动
System.arraycopy(array, index + 1,
array, index, size - index - 1);
}
size--;
return removed;
}
/**
* 查询元素
*
* @param index 索引位置, 在 [0..size) 区间内
* @return 该索引位置的元素
*/
public int get(int index) {
return array[index];
}
/**
* 遍历方法1
*
* @param consumer 遍历要执行的操作, 入参: 每个元素
*/
public void foreach(Consumer<Integer> consumer) {
for (int i = 0; i < size; i++) {
// 提供 array[i]
// 返回 void
consumer.accept(array[i]);
}
}
/**
* 遍历方法2 - 迭代器遍历
*/
@Override
public Iterator<Integer> iterator() {
return new Iterator<Integer>() {
int i = 0;
@Override
public boolean hasNext() { // 有没有下一个元素
return i < size;
}
@Override
public Integer next() { // 返回当前元素,并移动到下一个元素
return array[i++];
}
};
}
/**
* 遍历方法3 - stream 遍历
*
* @return stream 流
*/
public IntStream stream() {
return IntStream.of(Arrays.copyOfRange(array, 0, size));
}
}这些方法实现,都简化了 index 的有效性判断,假设输入的 index 都是合法的插入或删除性能3) 二维数组int[][] array = {
{11, 12, 13, 14, 15},
{21, 22, 23, 24, 25},
{31, 32, 33, 34, 35},
};
内存图如下小测试Java 环境下(不考虑类指针和引用压缩,此为默认情况),有下面的二维数组byte[][] array = {
{11, 12, 13, 14, 15},
{21, 22, 23, 24, 25},
{31, 32, 33, 34, 35},
};已知 array 对象起始地址是 0x1000,那么 23 这个元素的地址是什么?答:起始地址 0x1000外层数组大小:16字节对象头 + 3元素 * 每个引用4字节 + 4 对齐字节 = 32 = 0x20第一个内层数组大小:16字节对象头 + 5元素 * 每个byte1字节 + 3 对齐字节 = 24 = 0x18第二个内层数组,16字节对象头 = 0x10,待查找元素索引为 2最后结果 = 0x1000 + 0x20 + 0x18 + 0x10 + 2*1 = 0x104a4) 局部性原理这里只讨论空间局部性cpu 读取内存(速度慢)数据后,会将其放入高速缓存(速度快)当中,如果后来的计算再用到此数据,在缓存中能读到的话,就不必读内存了缓存的最小存储单位是缓存行(cache line),一般是 64 bytes,一次读的数据少了不划算啊,因此最少读 64 bytes 填满一个缓存行,因此读入某个数据时也会读取其临近的数据,这就是所谓空间局部性对效率的影响比较下面 ij 和 ji 两个方法的执行效率int rows = 1000000;
int columns = 14;
int[][] a = new int[rows][columns];
StopWatch sw = new StopWatch();
sw.start("ij");
ij(a, rows, columns);
sw.stop();
sw.start("ji");
ji(a, rows, columns);
sw.stop();
System.out.println(sw.prettyPrint());ij 方法public static void ij(int[][] a, int rows, int columns) {
long sum = 0L;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < columns; j++) {
sum += a[i][j];
}
}
System.out.println(sum);
}ji 方法public static void ji(int[][] a, int rows, int columns) {
long sum = 0L;
for (int j = 0; j < columns; j++) {
for (int i = 0; i < rows; i++) {
sum += a[i][j];
}
}
System.out.println(sum);
}执行结果0
0
StopWatch '': running time = 96283300 ns
---------------------------------------------
ns % Task name
---------------------------------------------
016196200 017% ij
080087100 083% ji可以看到 ij 的效率比 ji 快很多,为什么呢?缓存是有限的,当新数据来了后,一些旧的缓存行数据就会被覆盖如果不能充分利用缓存的数据,就会造成效率低下举一反三I/O 读写时同样可以体现局部性原理数组可以充分利用局部性原理,那么链表呢?答:链表不行,因为链表的元素并非相邻存储5) 越界检查java 中对数组元素的读写都有越界检查,类似于下面的代码bool is_within_bounds(int index) const
{
return 0 <= index && index < length();
}代码位置:openjdk\src\hotspot\share\oops\arrayOop.hpp只不过此检查代码,不需要由程序员自己来调用,JVM 会帮我们调用练习E01. 合并有序数组 - 对应 Leetcode 88将数组内两个区间内的有序元素合并例[1, 5, 6, 2, 4, 10, 11]可以视作两个有序区间[1, 5, 6] 和 [2, 4, 10, 11]合并后,结果仍存储于原有空间[1, 2, 4, 5, 6, 10, 11]方法1递归每次递归把更小的元素复制到结果数组merge(left=[1,5,6],right=[2,4,10,11],a2=[]){
merge(left=[5,6],right=[2,4,10,11],a2=[1]){
merge(left=[5,6],right=[4,10,11],a2=[1,2]){
merge(left=[5,6],right=[10,11],a2=[1,2,4]){
merge(left=[6],right=[10,11],a2=[1,2,4,5]){
merge(left=[],right=[10,11],a2=[1,2,4,5,6]){
// 拷贝10,11
}
}
}
}
}
}代码public static void merge(int[] a1, int i, int iEnd, int j, int jEnd,
int[] a2, int k) {
if (i > iEnd) {
System.arraycopy(a1, j, a2, k, jEnd - j + 1);
return;
}
if (j > jEnd) {
System.arraycopy(a1, i, a2, k, iEnd - i + 1);
return;
}
if (a1[i] < a1[j]) {
a2[k] = a1[i];
merge(a1, i + 1, iEnd, j, jEnd, a2, k + 1);
} else {
a2[k] = a1[j];
merge(a1, i, iEnd, j + 1, jEnd, a2, k + 1);
}
}测试int[] a1 = {1, 5, 6, 2, 4, 10, 11};
int[] a2 = new int[a1.length];
merge(a1, 0, 2, 3, 6, a2, 0);方法2代码public static void merge(int[] a1, int i, int iEnd,
int j, int jEnd,
int[] a2) {
int k = i;
while (i <= iEnd && j <= jEnd) {
if (a1[i] < a1[j]) {
a2[k] = a1[i];
i++;
} else {
a2[k] = a1[j];
j++;
}
k++;
}
if (i > iEnd) {
System.arraycopy(a1, j, a2, k, jEnd - j + 1);
}
if (j > jEnd) {
System.arraycopy(a1, i, a2, k, iEnd - i + 1);
}
}测试int[] a1 = {1, 5, 6, 2, 4, 10, 11};
int[] a2 = new int[a3.length];
merge(a1, 0, 2, 3, 6, a2);
英勇黄铜
【数据结构与算法】(9)基础数据结构 之 阻塞队列的单锁实现、双锁实现详细代码示例讲解
2.8 阻塞队列之前的队列在很多场景下都不能很好地工作,例如大部分场景要求分离向队列放入(生产者)、从队列拿出(消费者)两个角色、它们得由不同的线程来担当,而之前的实现根本没有考虑线程安全问题队列为空,那么在之前的实现里会返回 null,如果就是硬要拿到一个元素呢?只能不断循环尝试队列为满,那么再之前的实现里会返回 false,如果就是硬要塞入一个元素呢?只能不断循环尝试因此我们需要解决的问题有用锁保证线程安全用条件变量让等待非空线程与等待不满线程进入等待状态,而不是不断循环尝试,让 CPU 空转有同学对线程安全还没有足够的认识,下面举一个反例,两个线程都要执行入队操作(几乎在同一时刻)public class TestThreadUnsafe {
private final String[] array = new String[10];
private int tail = 0;
public void offer(String e) {
array[tail] = e;
tail++;
}
@Override
public String toString() {
return Arrays.toString(array);
}
public static void main(String[] args) {
TestThreadUnsafe queue = new TestThreadUnsafe();
new Thread(()-> queue.offer("e1"), "t1").start();
new Thread(()-> queue.offer("e2"), "t2").start();
}
}执行的时间序列如下,假设初始状态 tail = 0,在执行过程中由于 CPU 在两个线程之间切换,造成了指令交错线程1线程2说明array[tail]=e1线程1 向 tail 位置加入 e1 这个元素,但还没来得及执行 tail++array[tail]=e2线程2 向 tail 位置加入 e2 这个元素,覆盖掉了 e1tail++tail 自增为1tail++tail 自增为2最后状态 tail 为 2,数组为 [e2, null, null …]糟糕的是,由于指令交错的顺序不同,得到的结果不止以上一种,宏观上造成混乱的效果1) 单锁实现Java 中要防止代码段交错执行,需要使用锁,有两种选择synchronized 代码块,属于关键字级别提供锁保护,功能少ReentrantLock 类,功能丰富以 ReentrantLock 为例ReentrantLock lock = new ReentrantLock();
public void offer(String e) {
lock.lockInterruptibly();
try {
array[tail] = e;
tail++;
} finally {
lock.unlock();
}
}只要两个线程执行上段代码时,锁对象是同一个,就能保证 try 块内的代码的执行不会出现指令交错现象,即执行顺序只可能是下面两种情况之一线程1线程2说明lock.lockInterruptibly()t1对锁对象上锁array[tail]=e1lock.lockInterruptibly()即使 CPU 切换到线程2,但由于t1已经对该对象上锁,因此线程2卡在这儿进不去tail++切换回线程1 执行后续代码lock.unlock()线程1 解锁array[tail]=e2线程2 此时才能获得锁,执行它的代码tail++另一种情况是线程2 先获得锁,线程1 被挡在外面要明白保护的本质,本例中是保护的是 tail 位置读写的安全事情还没有完,上面的例子是队列还没有放满的情况,考虑下面的代码(这回锁同时保护了 tail 和 size 的读写安全)ReentrantLock lock = new ReentrantLock();
int size = 0;
public void offer(String e) {
lock.lockInterruptibly();
try {
if(isFull()) {
// 满了怎么办?
}
array[tail] = e;
tail++;
size++;
} finally {
lock.unlock();
}
}
private boolean isFull() {
return size == array.length;
}之前是返回 false 表示添加失败,前面分析过想达到这么一种效果:在队列满时,不是立刻返回,而是当前线程进入等待什么时候队列不满了,再唤醒这个等待的线程,从上次的代码处继续向下运行ReentrantLock 可以配合条件变量来实现,代码进化为ReentrantLock lock = new ReentrantLock();
Condition tailWaits = lock.newCondition(); // 条件变量
int size = 0;
public void offer(String e) {
lock.lockInterruptibly();
try {
while (isFull()) {
tailWaits.await(); // 当队列满时, 当前线程进入 tailWaits 等待
}
array[tail] = e;
tail++;
size++;
} finally {
lock.unlock();
}
}
private boolean isFull() {
return size == array.length;
}条件变量底层也是个队列,用来存储这些需要等待的线程,当队列满了,就会将 offer 线程加入条件队列,并暂时释放锁将来我们的队列如果不满了(由 poll 线程那边得知)可以调用 tailWaits.signal() 来唤醒 tailWaits 中首个等待的线程,被唤醒的线程会再次抢到锁,从上次 await 处继续向下运行思考为何要用 while 而不是 if,设队列容量是 3操作前offer(4)offer(5)poll()操作后[1 2 3]队列满,进入tailWaits 等待[1 2 3][1 2 3]取走 1,队列不满,唤醒线程[2 3][2 3]抢先获得锁,发现不满,放入 5[2 3 5][2 3 5]从上次等待处直接向下执行[2 3 5 ?]关键点:从 tailWaits 中唤醒的线程,会与新来的 offer 的线程争抢锁,谁能抢到是不一定的,如果后者先抢到,就会导致条件又发生变化这种情况称之为虚假唤醒,唤醒后应该重新检查条件,看是不是得重新进入等待最后的实现代码/**
* 单锁实现
* @param <E> 元素类型
*/
public class BlockingQueue1<E> implements BlockingQueue<E> {
private final E[] array;
private int head = 0;
private int tail = 0;
private int size = 0; // 元素个数
@SuppressWarnings("all")
public BlockingQueue1(int capacity) {
array = (E[]) new Object[capacity];
}
ReentrantLock lock = new ReentrantLock();
Condition tailWaits = lock.newCondition();
Condition headWaits = lock.newCondition();
@Override
public void offer(E e) throws InterruptedException {
lock.lockInterruptibly();
try {
while (isFull()) {
tailWaits.await();
}
array[tail] = e;
if (++tail == array.length) {
tail = 0;
}
size++;
headWaits.signal();
} finally {
lock.unlock();
}
}
@Override
public void offer(E e, long timeout) throws InterruptedException {
lock.lockInterruptibly();
try {
long t = TimeUnit.MILLISECONDS.toNanos(timeout);
while (isFull()) {
if (t <= 0) {
return;
}
t = tailWaits.awaitNanos(t);
}
array[tail] = e;
if (++tail == array.length) {
tail = 0;
}
size++;
headWaits.signal();
} finally {
lock.unlock();
}
}
@Override
public E poll() throws InterruptedException {
lock.lockInterruptibly();
try {
while (isEmpty()) {
headWaits.await();
}
E e = array[head];
array[head] = null; // help GC
if (++head == array.length) {
head = 0;
}
size--;
tailWaits.signal();
return e;
} finally {
lock.unlock();
}
}
private boolean isEmpty() {
return size == 0;
}
private boolean isFull() {
return size == array.length;
}
}public void offer(E e, long timeout) throws InterruptedException 是带超时的版本,可以只等待一段时间,而不是永久等下去,类似的 poll 也可以做带超时的版本,这个留给大家了注意JDK 中 BlockingQueue 接口的方法命名与我的示例有些差异方法 offer(E e) 是非阻塞的实现,阻塞实现方法为 put(E e)方法 poll() 是非阻塞的实现,阻塞实现方法为 take()2) 双锁实现单锁的缺点在于:生产和消费几乎是不冲突的,唯一冲突的是生产者和消费者它们有可能同时修改 size冲突的主要是生产者之间:多个 offer 线程修改 tail冲突的还有消费者之间:多个 poll 线程修改 head如果希望进一步提高性能,可以用两把锁一把锁保护 tail另一把锁保护 headReentrantLock headLock = new ReentrantLock(); // 保护 head 的锁
Condition headWaits = headLock.newCondition(); // 队列空时,需要等待的线程集合
ReentrantLock tailLock = new ReentrantLock(); // 保护 tail 的锁
Condition tailWaits = tailLock.newCondition(); // 队列满时,需要等待的线程集合先看看 offer 方法的初步实现@Override
public void offer(E e) throws InterruptedException {
tailLock.lockInterruptibly();
try {
// 队列满等待
while (isFull()) {
tailWaits.await();
}
// 不满则入队
array[tail] = e;
if (++tail == array.length) {
tail = 0;
}
// 修改 size (有问题)
size++;
} finally {
tailLock.unlock();
}
}上面代码的缺点是 size 并不受 tailLock 保护,tailLock 与 headLock 是两把不同的锁,并不能实现互斥的效果。因此,size 需要用下面的代码保证原子性AtomicInteger size = new AtomicInteger(0); // 保护 size 的原子变量
size.getAndIncrement(); // 自增
size.getAndDecrement(); // 自减代码修改为@Override
public void offer(E e) throws InterruptedException {
tailLock.lockInterruptibly();
try {
// 队列满等待
while (isFull()) {
tailWaits.await();
}
// 不满则入队
array[tail] = e;
if (++tail == array.length) {
tail = 0;
}
// 修改 size
size.getAndIncrement();
} finally {
tailLock.unlock();
}
}对称地,可以写出 poll 方法@Override
public E poll() throws InterruptedException {
E e;
headLock.lockInterruptibly();
try {
// 队列空等待
while (isEmpty()) {
headWaits.await();
}
// 不空则出队
e = array[head];
if (++head == array.length) {
head = 0;
}
// 修改 size
size.getAndDecrement();
} finally {
headLock.unlock();
}
return e;
}下面来看一个难题,就是如何通知 headWaits 和 tailWaits 中等待的线程,比如 poll 方法拿走一个元素,通知 tailWaits:我拿走一个,不满了噢,你们可以放了,因此代码改为@Override
public E poll() throws InterruptedException {
E e;
headLock.lockInterruptibly();
try {
// 队列空等待
while (isEmpty()) {
headWaits.await();
}
// 不空则出队
e = array[head];
if (++head == array.length) {
head = 0;
}
// 修改 size
size.getAndDecrement();
// 通知 tailWaits 不满(有问题)
tailWaits.signal();
} finally {
headLock.unlock();
}
return e;
}问题在于要使用这些条件变量的 await(), signal() 等方法需要先获得与之关联的锁,上面的代码若直接运行会出现以下错误java.lang.IllegalMonitorStateException那有同学说,加上锁不就行了吗,于是写出了下面的代码发现什么问题了?两把锁这么嵌套使用,非常容易出现死锁,如下所示因此得避免嵌套,两段加锁的代码变成了下面平级的样子性能还可以进一步提升代码调整后 offer 并没有同时获取 tailLock 和 headLock 两把锁,因此两次加锁之间会有空隙,这个空隙内可能有其它的 offer 线程添加了更多的元素,那么这些线程都要执行 signal(),通知 poll 线程队列非空吗?每次调用 signal() 都需要这些 offer 线程先获得 headLock 锁,成本较高,要想法减少 offer 线程获得 headLock 锁的次数可以加一个条件:当 offer 增加前队列为空,即从 0 变化到不空,才由此 offer 线程来通知 headWaits,其它情况不归它管2. 队列从 0 变化到不空,会唤醒一个等待的 poll 线程,这个线程被唤醒后,肯定能拿到 headLock 锁,因此它具备了唤醒 headWaits 上其它 poll 线程的先决条件。如果检查出此时有其它 offer 线程新增了元素(不空,但不是从0变化而来),那么不妨由此 poll 线程来唤醒其它 poll 线程这个技巧被称之为级联通知(cascading notifies),类似的原因3. 在 poll 时队列从满变化到不满,才由此 poll 线程来唤醒一个等待的 offer 线程,目的也是为了减少 poll 线程对 tailLock 上锁次数,剩下等待的 offer 线程由这个 offer 线程间接唤醒最终的代码为public class BlockingQueue2<E> implements BlockingQueue<E> {
private final E[] array;
private int head = 0;
private int tail = 0;
private final AtomicInteger size = new AtomicInteger(0);
ReentrantLock headLock = new ReentrantLock();
Condition headWaits = headLock.newCondition();
ReentrantLock tailLock = new ReentrantLock();
Condition tailWaits = tailLock.newCondition();
public BlockingQueue2(int capacity) {
this.array = (E[]) new Object[capacity];
}
@Override
public void offer(E e) throws InterruptedException {
int c;
tailLock.lockInterruptibly();
try {
while (isFull()) {
tailWaits.await();
}
array[tail] = e;
if (++tail == array.length) {
tail = 0;
}
c = size.getAndIncrement();
// a. 队列不满, 但不是从满->不满, 由此offer线程唤醒其它offer线程
if (c + 1 < array.length) {
tailWaits.signal();
}
} finally {
tailLock.unlock();
}
// b. 从0->不空, 由此offer线程唤醒等待的poll线程
if (c == 0) {
headLock.lock();
try {
headWaits.signal();
} finally {
headLock.unlock();
}
}
}
@Override
public E poll() throws InterruptedException {
E e;
int c;
headLock.lockInterruptibly();
try {
while (isEmpty()) {
headWaits.await();
}
e = array[head];
if (++head == array.length) {
head = 0;
}
c = size.getAndDecrement();
// b. 队列不空, 但不是从0变化到不空,由此poll线程通知其它poll线程
if (c > 1) {
headWaits.signal();
}
} finally {
headLock.unlock();
}
// a. 从满->不满, 由此poll线程唤醒等待的offer线程
if (c == array.length) {
tailLock.lock();
try {
tailWaits.signal();
} finally {
tailLock.unlock();
}
}
return e;
}
private boolean isEmpty() {
return size.get() == 0;
}
private boolean isFull() {
return size.get() == array.length;
}
}双锁实现的非常精巧,据说作者 Doug Lea 花了一年的时间才完善了此段代码
英勇黄铜
【数据结构与算法】(3)基础数据结构 之 链表 单向链表、双向链表、循环链表详细示例讲解
2.2 链表1) 概述定义在计算机科学中,链表是数据元素的线性集合,其每个元素都指向下一个元素,元素存储上并不连续In computer science, a linked list is a linear collection of data elements whose order is not given by their physical placement in memory. Instead, each element points to the next.可以分类为[^5]单向链表,每个元素只知道其下一个元素是谁双向链表,每个元素知道其上一个元素和下一个元素循环链表,通常的链表尾节点 tail 指向的都是 null,而循环链表的 tail 指向的是头节点 head链表内还有一种特殊的节点称为哨兵(Sentinel)节点,也叫做哑元( Dummy)节点,它不存储数据,通常用作头尾,用来简化边界判断,如下图所示public class SinglyLinkedList {
private Node head; // 头部节点
private static class Node { // 节点类
int value;
Node next;
public Node(int value, Node next) {
this.value = value;
this.next = next;
}
}
}
Node 定义为内部类,是为了对外隐藏实现细节,没必要让类的使用者关心 Node 结构定义为 static 内部类,是因为 Node 不需要与 SinglyLinkedList 实例相关,多个 SinglyLinkedList实例能共用 Node 类定义头部添加public class SinglyLinkedList {
// ...
public void addFirst(int value) {
this.head = new Node(value, this.head);
}
}如果 this.head == null,新增节点指向 null,并作为新的 this.head如果 this.head != null,新增节点指向原来的 this.head,并作为新的 this.headwhile 遍历public class SinglyLinkedList {
// ...
public void loop() {
Node curr = this.head;
while (curr != null) {
// 做一些事
curr = curr.next;
}
}
}for 遍历public class SinglyLinkedList {
// ...
public void loop() {
for (Node curr = this.head; curr != null; curr = curr.next) {
// 做一些事
}
}
}以上两种遍历都可以把要做的事以 Consumer 函数的方式传递进来Consumer 的规则是一个参数,无返回值,因此像 System.out::println 方法等都是 Consumer调用 Consumer 时,将当前节点 curr.value 作为参数传递给它迭代器遍历public class SinglyLinkedList implements Iterable<Integer> {
// ...
private class NodeIterator implements Iterator<Integer> {
Node curr = head;
public boolean hasNext() {
return curr != null;
}
public Integer next() {
int value = curr.value;
curr = curr.next;
return value;
}
}
public Iterator<Integer> iterator() {
return new NodeIterator();
}
}hasNext 用来判断是否还有必要调用 nextnext 做两件事返回当前节点的 value指向下一个节点NodeIterator 要定义为非 static 内部类,是因为它与 SinglyLinkedList 实例相关,是对某个 SinglyLinkedList 实例的迭代递归遍历public class SinglyLinkedList implements Iterable<Integer> {
// ...
public void loop() {
recursion(this.head);
}
private void recursion(Node curr) {
if (curr == null) {
return;
}
// 前面做些事
recursion(curr.next);
// 后面做些事
}
}尾部添加public class SinglyLinkedList {
// ...
private Node findLast() {
if (this.head == null) {
return null;
}
Node curr;
for (curr = this.head; curr.next != null; ) {
curr = curr.next;
}
return curr;
}
public void addLast(int value) {
Node last = findLast();
if (last == null) {
addFirst(value);
return;
}
last.next = new Node(value, null);
}
}注意,找最后一个节点,终止条件是 curr.next == null分成两个方法是为了代码清晰,而且 findLast() 之后还能复用尾部添加多个public class SinglyLinkedList {
// ...
public void addLast(int first, int... rest) {
Node sublist = new Node(first, null);
Node curr = sublist;
for (int value : rest) {
curr.next = new Node(value, null);
curr = curr.next;
}
Node last = findLast();
if (last == null) {
this.head = sublist;
return;
}
last.next = sublist;
}
}先串成一串 sublist再作为一个整体添加根据索引获取public class SinglyLinkedList {
// ...
private Node findNode(int index) {
int i = 0;
for (Node curr = this.head; curr != null; curr = curr.next, i++) {
if (index == i) {
return curr;
}
}
return null;
}
private IllegalArgumentException illegalIndex(int index) {
return new IllegalArgumentException(String.format("index [%d] 不合法%n", index));
}
public int get(int index) {
Node node = findNode(index);
if (node != null) {
return node.value;
}
throw illegalIndex(index);
}
}同样,分方法可以实现复用插入public class SinglyLinkedList {
// ...
public void insert(int index, int value) {
if (index == 0) {
addFirst(value);
return;
}
Node prev = findNode(index - 1); // 找到上一个节点
if (prev == null) { // 找不到
throw illegalIndex(index);
}
prev.next = new Node(value, prev.next);
}
}插入包括下面的删除,都必须找到上一个节点删除public class SinglyLinkedList {
// ...
public void remove(int index) {
if (index == 0) {
if (this.head != null) {
this.head = this.head.next;
return;
} else {
throw illegalIndex(index);
}
}
Node prev = findNode(index - 1);
Node curr;
if (prev != null && (curr = prev.next) != null) {
prev.next = curr.next;
} else {
throw illegalIndex(index);
}
}
}第一个 if 块对应着 removeFirst 情况最后一个 if 块对应着至少得两个节点的情况不仅仅判断上一个节点非空,还要保证当前节点非空3) 单向链表(带哨兵)观察之前单向链表的实现,发现每个方法内几乎都有判断是不是 head 这样的代码,能不能简化呢?用一个不参与数据存储的特殊 Node 作为哨兵,它一般被称为哨兵或哑元,拥有哨兵节点的链表称为带头链表public class SinglyLinkedListSentinel {
// ...
private Node head = new Node(Integer.MIN_VALUE, null);
}具体存什么值无所谓,因为不会用到它的值加入哨兵节点后,代码会变得比较简单,先看几个工具方法public class SinglyLinkedListSentinel {
// ...
// 根据索引获取节点
private Node findNode(int index) {
int i = -1;
for (Node curr = this.head; curr != null; curr = curr.next, i++) {
if (i == index) {
return curr;
}
}
return null;
}
// 获取最后一个节点
private Node findLast() {
Node curr;
for (curr = this.head; curr.next != null; ) {
curr = curr.next;
}
return curr;
}
}findNode 与之前类似,只是 i 初始值设置为 -1 对应哨兵,实际传入的 index 也是 [ − 1 , ∞ ) findLast 绝不会返回 null 了,就算没有其它节点,也会返回哨兵作为最后一个节点这样,代码简化为public class SinglyLinkedListSentinel {
// ...
public void addLast(int value) {
Node last = findLast();
/*
改动前
if (last == null) {
this.head = new Node(value, null);
return;
}
*/
last.next = new Node(value, null);
}
public void insert(int index, int value) {
/*
改动前
if (index == 0) {
this.head = new Node(value, this.head);
return;
}
*/
// index 传入 0 时,返回的是哨兵
Node prev = findNode(index - 1);
if (prev != null) {
prev.next = new Node(value, prev.next);
} else {
throw illegalIndex(index);
}
}
public void remove(int index) {
/*
改动前
if (index == 0) {
if (this.head != null) {
this.head = this.head.next;
return;
} else {
throw illegalIndex(index);
}
}
*/
// index 传入 0 时,返回的是哨兵
Node prev = findNode(index - 1);
Node curr;
if (prev != null && (curr = prev.next) != null) {
prev.next = curr.next;
} else {
throw illegalIndex(index);
}
}
public void addFirst(int value) {
/*
改动前
this.head = new Node(value, this.head);
*/
this.head.next = new Node(value, this.head.next);
// 也可以视为 insert 的特例, 即 insert(0, value);
}
}对于删除,前面说了【最后一个 if 块对应着至少得两个节点的情况】,现在有了哨兵,就凑足了两个节点4) 双向链表(带哨兵)public class DoublyLinkedListSentinel implements Iterable<Integer> {
private final Node head;
private final Node tail;
public DoublyLinkedListSentinel() {
head = new Node(null, 666, null);
tail = new Node(null, 888, null);
head.next = tail;
tail.prev = head;
}
private Node findNode(int index) {
int i = -1;
for (Node p = head; p != tail; p = p.next, i++) {
if (i == index) {
return p;
}
}
return null;
}
public void addFirst(int value) {
insert(0, value);
}
public void removeFirst() {
remove(0);
}
public void addLast(int value) {
Node prev = tail.prev;
Node added = new Node(prev, value, tail);
prev.next = added;
tail.prev = added;
}
public void removeLast() {
Node removed = tail.prev;
if (removed == head) {
throw illegalIndex(0);
}
Node prev = removed.prev;
prev.next = tail;
tail.prev = prev;
}
public void insert(int index, int value) {
Node prev = findNode(index - 1);
if (prev == null) {
throw illegalIndex(index);
}
Node next = prev.next;
Node inserted = new Node(prev, value, next);
prev.next = inserted;
next.prev = inserted;
}
public void remove(int index) {
Node prev = findNode(index - 1);
if (prev == null) {
throw illegalIndex(index);
}
Node removed = prev.next;
if (removed == tail) {
throw illegalIndex(index);
}
Node next = removed.next;
prev.next = next;
next.prev = prev;
}
private IllegalArgumentException illegalIndex(int index) {
return new IllegalArgumentException(
String.format("index [%d] 不合法%n", index));
}
@Override
public Iterator<Integer> iterator() {
return new Iterator<Integer>() {
Node p = head.next;
@Override
public boolean hasNext() {
return p != tail;
}
@Override
public Integer next() {
int value = p.value;
p = p.next;
return value;
}
};
}
static class Node {
Node prev;
int value;
Node next;
public Node(Node prev, int value, Node next) {
this.prev = prev;
this.value = value;
this.next = next;
}
}
}5) 环形链表(带哨兵)双向环形链表带哨兵,这时哨兵既作为头,也作为尾参考实现public class DoublyLinkedListSentinel implements Iterable<Integer> {
@Override
public Iterator<Integer> iterator() {
return new Iterator<>() {
Node p = sentinel.next;
@Override
public boolean hasNext() {
return p != sentinel;
}
@Override
public Integer next() {
int value = p.value;
p = p.next;
return value;
}
};
}
static class Node {
Node prev;
int value;
Node next;
public Node(Node prev, int value, Node next) {
this.prev = prev;
this.value = value;
this.next = next;
}
}
private final Node sentinel = new Node(null, -1, null); // 哨兵
public DoublyLinkedListSentinel() {
sentinel.next = sentinel;
sentinel.prev = sentinel;
}
/**
* 添加到第一个
* @param value 待添加值
*/
public void addFirst(int value) {
Node next = sentinel.next;
Node prev = sentinel;
Node added = new Node(prev, value, next);
prev.next = added;
next.prev = added;
}
/**
* 添加到最后一个
* @param value 待添加值
*/
public void addLast(int value) {
Node prev = sentinel.prev;
Node next = sentinel;
Node added = new Node(prev, value, next);
prev.next = added;
next.prev = added;
}
/**
* 删除第一个
*/
public void removeFirst() {
Node removed = sentinel.next;
if (removed == sentinel) {
throw new IllegalArgumentException("非法");
}
Node a = sentinel;
Node b = removed.next;
a.next = b;
b.prev = a;
}
/**
* 删除最后一个
*/
public void removeLast() {
Node removed = sentinel.prev;
if (removed == sentinel) {
throw new IllegalArgumentException("非法");
}
Node a = removed.prev;
Node b = sentinel;
a.next = b;
b.prev = a;
}
/**
* 根据值删除节点
* <p>假定 value 在链表中作为 key, 有唯一性</p>
* @param value 待删除值
*/
public void removeByValue(int value) {
Node removed = findNodeByValue(value);
if (removed != null) {
Node prev = removed.prev;
Node next = removed.next;
prev.next = next;
next.prev = prev;
}
}
private Node findNodeByValue(int value) {
Node p = sentinel.next;
while (p != sentinel) {
if (p.value == value) {
return p;
}
p = p.next;
}
return null;
}
}
英勇黄铜
数据结构与算法详解
深入剖析数据结构与算法的核心原理,从初识算法到精通各种数据结构,包括数组、链表、递归、队列、栈、双端队列、优先级队列、阻塞队列、堆、二叉树等。结合示例代码,助你轻松掌握数据结构与算法的精髓
英勇黄铜
String类对象的创建与字符串常量池的“神秘交易”
创建对象内的“那些事”话不多说,直接上代码:public static void main(String[] args) {
String s1 = "hello";
String s2 = "hello";
String s3 = new String("hello");
String s4 = new String("hello");
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
System.out.println(s3 == s4); // false
}上面这个代码我们发现创建的 String 对象的方式类似,但是结果 s1 和 s2 是同一个对象,但 s3 和 s4 却却不是?这就是要深究到 java 中的常量池了。在 java 中,“hello”,“1234” 等常量经常被频繁使用,java 为了让程序运行的速度更加快,跟节省内存,就为 8 种基本类型和 String 类提供了常量池。java 中引入了:Class 文件常量池:每个 Java 源文件编译后生成的 Class 文件中会保存当前类中的字面常量以及符号信息运行时常量池:在. Class 文件被加载时,Class 文件中的常量池被加载到内存中称为运行时常量池,运行时常量池每个类都会有一份"池" 是编程中的一种常见的, 重要的提升效率的方式, 我们会在遇到各种 "内存池", "线程池", "数据库连接池"字符串常量池字符串常量池在 JVM 中是一个 StringTable 类,实际是一固定大小的 HashTable,它是一种高效查找的数据结构,在不同的 JDK 版本下字符串常量池的位置以及默认大小是不同的:对 String 对象创建的具体分析直接使用字符串常量进行赋值public static void main(String[] args) {
String str1 ="hello";
String str2 ="hello";
System.out.println(str1 == str2);
}
这里直接通过画图分析:通过 new 创建 String 对象public static void main(String[] args) {
String str1 = new String("hello");
String str2 = "hello";
System.out.println(str1 == str2);
}这里我们得到一个结论:只要是 new 出来的对象,就是唯一的这里我们可以知道:使用常量串创建 String 类型对象的效率更高,更节省空间。用户也可以将创建的字符串对象通过 intern 方式添加进字符串常量池中intern 方法intern 方法的作用就是将创建的 String 对象添加到常量池中、public static void main(String[] args) {
char[] ch = new char[]{'a', 'b', 'c'};
String s1 = new String(ch); // s1对象并不在常量池中
//s1.intern(); 调用之后,会将s1对象的引用放入到常量池中
String s2 = "abc"; // "abc" 在常量池中存在了,s2创建时直接用常量池中"abc"的引用
System.out.println(s1 == s2);
}放开前返回的是 false,放开后返回 true:使用方法前,常量池中没有 “abc” ,导致 str2 自己重新创建了一份 “abc”使用方法后,常量池中有了 “abc” , str2 直接拿过来用就可以了
英勇黄铜
6.深入理解递归-3
6.1树的问题绝大多数都可以使用「分治思想」解决例:「力扣」第 105 题:从前序与中序遍历序列构造二叉树根据一棵树的前序遍历与中序遍历构造二叉树。注意:你可以假设树中没有重复的元素。例如,给出前序遍历 preorder = [3,9,20,15,7]
中序遍历 inorder = [9,3,15,20,7]返回如下的二叉树: 3
/ \
9 20
/ \
15 7📖文字题解前言二叉树前序遍历的顺序为:先遍历根节点;随后递归地遍历左子树;最后递归地遍历右子树。二叉树中序遍历的顺序为:先递归地遍历左子树;随后遍历根节点;最后递归地遍历右子树。在「递归」地遍历某个子树的过程中,我们也是将这颗子树看成一颗全新的树,按照上述的顺序进行遍历。挖掘「前序遍历」和「中序遍历」的性质,我们就可以得出本题的做法。方法一:递归思路对于任意一颗树而言,前序遍历的形式总是[ 根节点, [左子树的前序遍历结果], [右子树的前序遍历结果] ]即根节点总是前序遍历中的第一个节点。而中序遍历的形式总是[ [左子树的中序遍历结果], 根节点, [右子树的中序遍历结果] ]只要我们在中序遍历中定位到根节点,那么我们就可以分别知道左子树和右子树中的节点数目。由于同一颗子树的前序遍历和中序遍历的长度显然是相同的,因此我们就可以对应到前序遍历的结果中,对上述形式中的所有左右括号进行定位。这样以来,我们就知道了左子树的前序遍历和中序遍历结果,以及右子树的前序遍历和中序遍历结果,我们就可以递归地对构造出左子树和右子树,再将这两颗子树接到根节点的左右位置。细节在中序遍历中对根节点进行定位时,一种简单的方法是直接扫描整个中序遍历的结果并找出根节点,但这样做的时间复杂度较高。我们可以考虑使用哈希表来帮助我们快速地定位根节点。对于哈希映射中的每个键值对,键表示一个元素(节点的值),值表示其在中序遍历中的出现位置。在构造二叉树的过程之前,我们可以对中序遍历的列表进行一遍扫描,就可以构造出这个哈希映射。在此后构造二叉树的过程中,我们就只需要 O(1) 的时间对根节点进行定位了。下面的代码给出了详细的注释。C++class Solution {
private:
unordered_map<int, int> index;
public:
TreeNode* myBuildTree(const vector<int>& preorder, const vector<int>& inorder, int preorder_left, int preorder_right, int inorder_left, int inorder_right) {
if (preorder_left > preorder_right) {
return nullptr;
}
// 前序遍历中的第一个节点就是根节点
int preorder_root = preorder_left;
// 在中序遍历中定位根节点
int inorder_root = index[preorder[preorder_root]];
// 先把根节点建立出来
TreeNode* root = new TreeNode(preorder[preorder_root]);
// 得到左子树中的节点数目
int size_left_subtree = inorder_root - inorder_left;
// 递归地构造左子树,并连接到根节点
// 先序遍历中「从 左边界+1 开始的 size_left_subtree」个元素就对应了中序遍历中「从 左边界 开始到 根节点定位-1」的元素
root->left = myBuildTree(preorder, inorder, preorder_left + 1, preorder_left + size_left_subtree, inorder_left, inorder_root - 1);
// 递归地构造右子树,并连接到根节点
// 先序遍历中「从 左边界+1+左子树节点数目 开始到 右边界」的元素就对应了中序遍历中「从 根节点定位+1 到 右边界」的元素
root->right = myBuildTree(preorder, inorder, preorder_left + size_left_subtree + 1, preorder_right, inorder_root + 1, inorder_right);
return root;
}
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
int n = preorder.size();
// 构造哈希映射,帮助我们快速定位根节点
for (int i = 0; i < n; ++i) {
index[inorder[i]] = i;
}
return myBuildTree(preorder, inorder, 0, n - 1, 0, n - 1);
}
};复杂度分析时间复杂度:O(n),其中 n 是树中的节点个数。空间复杂度:O(n),除去返回的答案需要的 O(n) 空间之外,我们还需要使用 O(n) 的空间存储哈希映射,以及 O(h)(其中 h 是树的高度)的空间表示递归时栈空间。这里h<n,所以总空间复杂度为 O(n)。方法二:迭代思路迭代法是一种非常巧妙的实现方法。对于前序遍历中的任意两个连续节点 u 和 v,根据前序遍历的流程,我们可以知道 u 和 v 只有两种可能的关系:v 是 u 的左儿子。这是因为在遍历到 u 之后,下一个遍历的节点就是 u 的左儿子,即 v;u 没有左儿子,并且 v 是 u 的某个祖先节点(或者 u 本身)的右儿子。如果 u 没有左儿子,那么下一个遍历的节点就是 u 的右儿子。如果 u 没有右儿子,我们就会向上回溯,直到遇到第一个有右儿子(且 u 不在它的右儿子的子树中)的节点 ua,那么 v 就是 ua的右儿子。第二种关系看上去有些复杂。我们举一个例子来说明其正确性,并在例子中给出我们的迭代算法。例子我们以树 3
/ \
9 20
/ / \
8 15 7
/ \
5 10
/
4为例,它的前序遍历和中序遍历分别为preorder = [3, 9, 8, 5, 4, 10, 20, 15, 7]inorder = [4, 5, 8, 10, 9, 3, 15, 20, 7]我们用一个栈 stack 来维护「当前节点的所有还没有考虑过右儿子的祖先节点」,栈顶就是当前节点。也就是说,只有在栈中的节点才可能连接一个新的右儿子。同时,我们用一个指针 index 指向中序遍历的某个位置,初始值为 0。index 对应的节点是「当前节点不断往左走达到的最终节点」,这也是符合中序遍历的,它的作用在下面的过程中会有所体现。首先我们将根节点 3入栈,再初始化index 所指向的节点为 4,随后对于前序遍历中的每个节点,我们依次判断它是栈顶节点的左儿子,还是栈中某个节点的右儿子。我们遍历 9。9 一定是栈顶节点 3 的左儿子。我们使用反证法,假设 9 是 3 的右儿子,那么 3 没有左儿子,index 应该恰好指向 3,但实际上为 4,因此产生了矛盾。所以我们将 9 作为 3 的左儿子,并将 9 入栈。stack = [3, 9]index -> inorder[0] = 4我们遍历 8,5 和 4。同理可得它们都是上一个节点(栈顶节点)的左儿子,所以它们会依次入栈。stack = [3, 9, 8, 5, 4]index -> inorder[0] = 4我们遍历 10,这时情况就不一样了。我们发现 index 恰好指向当前的栈顶节点 4,也就是说 4 没有左儿子,那么 10 必须为栈中某个节点的右儿子。那么如何找到这个节点呢?栈中的节点的顺序和它们在前序遍历中出现的顺序是一致的,而且每一个节点的右儿子都还没有被遍历过,那么这些节点的顺序和它们在中序遍历中出现的顺序一定是相反的。这是因为栈中的任意两个相邻的节点,前者都是后者的某个祖先。并且我们知道,栈中的任意一个节点的右儿子还没有被遍历过,说明后者一定是前者左儿子的子树中的节点,那么后者就先于前者出现在中序遍历中。因此我们可以把 index 不断向右移动,并与栈顶节点进行比较。如果 index 对应的元素恰好等于栈顶节点,那么说明我们在中序遍历中找到了栈顶节点,所以将 index 增加 1 并弹出栈顶节点,直到 index 对应的元素不等于栈顶节点。按照这样的过程,我们弹出的最后一个节点 x 就是 10 的双亲节点,这是因为10 出现在了 x 与 x 在栈中的下一个节点的中序遍历之间,因此 10 就是 x 的右儿子。回到我们的例子,我们会依次从栈顶弹出 4,5 和 8,并且将 index 向右移动了三次。我们将 10 作为最后弹出的节点 8 的右儿子,并将 10 入栈。stack = [3, 9, 10]index -> inorder[3] = 10我们遍历 20。同理,index 恰好指向当前栈顶节点 10,那么我们会依次从栈顶弹出 10,9 和 3,并且将 index 向右移动了三次。我们将 20 作为最后弹出的节点 3 的右儿子,并将 20 入栈。stack = [20]index -> inorder[6] = 15我们遍历15,将 15 作为栈顶节点 20 的左儿子,并将 15 入栈。stack = [20, 15]index -> inorder[6] = 15我们遍历 7。index 恰好指向当前栈顶节点 15,那么我们会依次从栈顶弹出 15 和 20,并且将 index 向右移动了两次。我们将 7 作为最后弹出的节点 20 的右儿子,并将 7 入栈。stack = [7]index -> inorder[8] = 7此时遍历结束,我们就构造出了正确的二叉树。算法我们归纳出上述例子中的算法流程:我们用一个栈和一个指针辅助进行二叉树的构造。初始时栈中存放了根节点(前序遍历的第一个节点),指针指向中序遍历的第一个节点;我们依次枚举前序遍历中除了第一个节点以外的每个节点。如果 index 恰好指向栈顶节点,那么我们不断地弹出栈顶节点并向右移动 index,并将当前节点作为最后一个弹出的节点的右儿子;如果 index 和栈顶节点不同,我们将当前节点作为栈顶节点的左儿子;无论是哪一种情况,我们最后都将当前的节点入栈。最后得到的二叉树即为答案。C++class Solution {
public:
TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
if (!preorder.size()) {
return nullptr;
}
TreeNode* root = new TreeNode(preorder[0]);
stack<TreeNode*> stk;
stk.push(root);
int inorderIndex = 0;
for (int i = 1; i < preorder.size(); ++i) {
int preorderVal = preorder[i];
TreeNode* node = stk.top();
if (node->val != inorder[inorderIndex]) {
node->left = new TreeNode(preorderVal);
stk.push(node->left);
}
else {
while (!stk.empty() && stk.top()->val == inorder[inorderIndex]) {
node = stk.top();
stk.pop();
++inorderIndex;
}
node->right = new TreeNode(preorderVal);
stk.push(node->right);
}
}
return root;
}
};复杂度分析时间复杂度:O(n),其中 n 是树中的节点个数。空间复杂度:O(n),除去返回的答案需要的 O(n) 空间之外,我们还需要使用 O(h)(其中 h 是树的高度)的空间存储栈。这里 h<n,所以(在最坏情况下)总空间复杂度为O(n)。6.2总结与练习「递归」方法虽然会有栈的开销,但是我们在面对一个复杂问题的时候,通过「拆分子问题」,解决「子问题」的方式往往会使得问题变得简单。因此编写「递归」方法或者说「分治思想」是我们解决问题的重要手段。
英勇黄铜
顺序表与ArrayList
线性表线性表就是有多个相同属性的数据元素的有限序列。线性表是一种在我们工作中广泛可以使用到的数据结构,常见的线性表:顺序表,链表,栈,队列……线性表在逻辑上是线性结构,是一条连续的直线。但是它在物理结构上可可能不是连续的。线性表在物理上存储时,一般是以数组和链式结构的方式存储。顺序表顺序表是用一段物理地址连续的存储单元依次存储数据元素的线性结构,一般情况下采用数组存储。在数组上完成数据的增删查改。简单顺序表的模拟实现大家可以移步到 Gitee 上看代码:structure_code/java2023.9.12/src/mylist · 彭子杰/数据结构 - 码云 - 开源中国 (gitee.com)集合框架java 集合框架,也称为容器,是定义在 java.util 包下的一组接口 interfaces 和其实现类 class 。它是要的作用是将多个元素 element 置于一个单元中,用于对这些元素进行快速,便捷的存储 store ,检索 retrieve ,管理 manipulate ,即平时我们说的增删查改。类与接口总览:ArrayList 介绍在 java 的集合框架(容器)中,ArrayList 就是一个顺序表,它是一个普通的类,实现了 List 接口,框架如下:注意:ArrayList 是以泛型方式实现的,使用时需要先实例化ArrayList 实现了 RandomAccess 接口,表明 ArrayList 支持随机访问ArrayList 实现了 cloneble 接口,表明 ArrayList 是可以 clone 的ArrayList 实现了 Serializable 接口,表明 ArrayList 是支持序列化的和 Vector 不同,ArrayList 不是线程安全的,在单线程下可以使用,在多线程中可以选择 Vector 或者 CopyOnWriteArrayListArrayList 底层是一段连续的空间,并且可以动态扩容,是一个动态类型的顺序表ArrayList 的使用ArrayList 的构造public class Test {
public static void main(String[] args) {
// ArrayList创建,推荐写法
// 构造一个空的列表
List<Integer> list1 = new ArrayList<>();
// 构造一个具有10个容量的列表
List<Integer> list2 = new ArrayList<>(10);
list2.add(1);
list2.add(2);
list2.add(3);
// list2.add("hello"); // 编译失败,List<Integer>已经限定了,list2中只能存储整形元素
// list3构造好之后,与list中的元素一致
ArrayList<Integer> list3 = new ArrayList<>(list2);
// 避免省略类型,否则:任意类型的元素都可以存放,使用时将是一场灾难
List list4 = new ArrayList();
list4.add("111");
list4.add(100);
}
}ArrayList 的基本方法 public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("JavaSE");
list.add("JavaWeb");
list.add("JavaEE");
list.add("JVM");
list.add("测试课程");
System.out.println(list);
// 获取list中有效元素个数
System.out.println(list.size());
// 获取和设置index位置上的元素,注意index必须介于[0, size)间
System.out.println(list.get(1));
list.set(1, "JavaWEB");
System.out.println(list.get(1));
// 在list的index位置插入指定元素,index及后续的元素统一往后搬移一个位置
list.add(1, "Java数据结构");
System.out.println(list);
// 删除指定元素,找到了就删除,该元素之后的元素统一往前搬移一个位置
list.remove("JVM");
System.out.println(list);
// 删除list中index位置上的元素,注意index不要超过list中有效元素个数,否则会抛出下标越界异常
list.remove(list.size()-1);
System.out.println(list);
} // 查找指定元素第一次出现的位置:indexOf从前往后找,lastIndexOf从后往前找
list.add("JavaSE");
System.out.println(list.indexOf("JavaSE"));
System.out.println(list.lastIndexOf("JavaSE"));
// 使用list中[0, 4)之间的元素构成一个新的SubList返回,但是和ArrayList共用一个elementData数组
// List<String> ret = list.subList(0, 4);
System.out.println(ret);
list.clear();
System.out.println(list.size());
}ArrayList 的遍历ArrayList 可以使用三个方式来遍历: for 循环, foreach ,迭代器 public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);
list.add(4);
list.add(5);
// 使用下标+for遍历
for (int i = 0; i < list.size(); i++) {
System.out.print(list.get(i) + " ");
} System.out.println();
// 借助foreach遍历
for (Integer integer : list) {
System.out.print(integer + " ");
} System.out.println();
// 使用迭代器遍历
Iterator<Integer> it = list.listIterator();
while(it.hasNext()){
System.out.print(it.next() + " ");
} System.out.println();
}这里的迭代器是设计模式的一种。ArrayList 的扩容机制 首选我们来看一段代码:public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 100; i++) {
list.add(i);
}
}我们知道,ArrayList 是一个动态类型的顺序表,即:在插入元素的过程中会自动扩容。我们可以看一下源码:Object[] elementData; // 存放元素的空间
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}; // 默认空间
private static final int DEFAULT_CAPACITY = 10; // 默认容量大小
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
} return minCapacity;
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
private void grow(int minCapacity) {
// 获取旧空间大小
int oldCapacity = elementData.length;
// 预计按照1.5倍方式扩容
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 如果用户需要扩容大小 超过 原空间1.5倍,按照用户所需大小扩容
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 如果需要扩容大小超过MAX_ARRAY_SIZE,重新计算容量大小
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// 调用copyOf扩容
elementData = Arrays.copyOf(elementData, newCapacity);
}
private static int hugeCapacity(int minCapacity) {
// 如果minCapacity小于0,抛出OutOfMemoryError异常
if (minCapacity < 0)
throw new OutOfMemoryError();
return (minCapacity > MAX_ARRAY_SIZE) ? Integer.MAX_VALUE : MAX_ARRAY_SIZE;
}画图分析【总结】检查是否真正需要扩容,如果是 g 调用了 row 准备扩容估计需要库容的大小初步按照 1.5 倍的大小扩容如果用户需要的大小超过1.5倍,则按照用户所需大小扩容真正扩容之前检查是否可以扩容成功,防止太大导致扩容失败使用 copyof 进行扩容ArrayList 的具体使用这里举一个简单的洗牌算法:public class Card {
public int rank; // 牌面值
public String suit; // 花色
@Override
public String toString() {
return String.format("[%s %d]", suit, rank);
}
}
import java.util.List;
import java.util.ArrayList;
import java.util.Random;
public class CardDemo {
public static final String[] SUITS = {"♠", "♥", "♣", "♦"};
// 买一副牌
private static List<Card> buyDeck() {
List<Card> deck = new ArrayList<>(52);
for (int i = 0; i < 4; i++) {
for (int j = 1; j <= 13; j++) {
String suit = SUITS[i];
int rank = j;
Card card = new Card();
card.rank = rank;
card.suit = suit;
deck.add(card);
}
}
return deck;
}
private static void swap(List<Card> deck, int i, int j) {
Card t = deck.get(i);
deck.set(i, deck.get(j));
deck.set(j, t);
}
private static void shuffle(List<Card> deck) {
Random random = new Random(20190905);
for (int i = deck.size() - 1; i > 0; i--) {
int r = random.nextInt(i);
swap(deck, i, r);
}
}
public static void main(String[] args) {
List<Card> deck = buyDeck();
System.out.println("刚买回来的牌:");
System.out.println(deck);
shuffle(deck);
System.out.println("洗过的牌:");
System.out.println(deck);
// 三个人,每个人轮流抓 5 张牌
List<List<Card>> hands = new ArrayList<>();
hands.add(new ArrayList<>());
hands.add(new ArrayList<>());
hands.add(new ArrayList<>());
for (int i = 0; i < 5; i++) {
for (int j = 0; j < 3; j++) {
hands.get(j).add(deck.remove(0));
}
}
System.out.println("剩余的牌:");
System.out.println(deck);
System.out.println("A 手中的牌:");
System.out.println(hands.get(0));
System.out.println("B 手中的牌:");
System.out.println(hands.get(1));
System.out.println("C 手中的牌:");
System.out.println(hands.get(2));
}
}顺序表 ArrayList 存储结构的优缺点优点不用为了表达清楚元素之间的关系而增加额外的存储空间。可以快速地存取表中的任意一个位置的元素缺点在任位置删除插入元素的时间复杂度为O(N),所以需要移动大量的元素当顺序表长度变化较大时,难以确定存储空间的容量。容易造成存储空间的碎片,也就是浪费空间。
英勇黄铜
4.深入理解递归-1—上
4.1使用「归并排序」实现排序数组「归并排序」将数组不断拆分,直到拆到不能再拆分为止(即数组只有 1 个元素的时候)。由于 1 个元素的数组肯定是有序数组,然后我们「逐层向上」依次合并两个有序组。通过这样的方式,我们实现了排序的功能。「拆分」与「合并」就通过递归的方式,方便地实现了它们的逻辑。请大家结合下面的动画和下面参考代码,理解「递归返回以后进行合并两个有序数组」的时机。参考代码 1:第 912 题:排序数组Javapublic class Solution {
public int[] sortArray(int[] nums) {
int len = nums.length;
int[] temp = new int[len];
mergeSort(nums, 0, len - 1, temp);
return nums;
}
/**
* 递归函数语义:对数组 nums 的子区间 [left.. right] 进行归并排序
*
* @param nums
* @param left
* @param right
* @param temp 用于合并两个有序数组的辅助数组,全局使用一份,避免多次创建和销毁
*/
private void mergeSort(int[] nums, int left, int right, int[] temp) {
// 1. 递归终止条件
if (left == right) {
return;
}
// 2. 拆分,对应「分而治之」算法的「分」
int mid = (left + right) / 2;
mergeSort(nums, left, mid, temp);
mergeSort(nums, mid + 1, right, temp);
// 3. 在递归函数调用完成以后还可以做点事情
// 合并两个有序数组,对应「分而治之」的「合」
mergeOfTwoSortedArray(nums, left, mid, right, temp);
}
/**
* 合并两个有序数组:先把值复制到临时数组,再合并回去
*
* @param nums
* @param left
* @param mid mid 是第一个有序数组的最后一个元素的下标,即:[left..mid] 有序,[mid + 1..right] 有序
* @param right
* @param temp 全局使用的临时数组
*/
private void mergeOfTwoSortedArray(int[] nums, int left, int mid, int right, int[] temp) {
for (int i = left; i <= right; i++) {
temp[i] = nums[i];
}
int i = left;
int j = mid + 1;
int k = left;
while (i <= mid && j <= right) {
if (temp[i] <= temp[j]) {
// 注意写成 < 就丢失了稳定性(相同元素原来靠前的排序以后依然靠前)
nums[k] = temp[i];
k++;
i++;
} else {
nums[k] = temp[j];
k++;
j++;
}
}
while (i <= mid) {
nums[k] = temp[i];
k++;
i++;
}
while (j <= right) {
nums[k] = temp[j];
k++;
j++;
}
}
}说明:mergeSort(nums, left, mid, temp); 与 mergeSort(nums, mid + 1, right, temp); 是在递归地解决子问题。在它们之后我们编写的 mergeOfTwoSortedArray(nums, left, mid, right, temp); 是根据之前递归调用返回的结果(两个子数组 nums[left..mid] 和 nums[mid + 1.. right] 分别有序)进行「合并两个有序数组」的操作,以得到一个长度更长的有序数组 nums[left..right] 。如果我们使用迭代会变得非常麻烦,感兴趣的朋友可以阅读《算法(第 4 版)》「自底向上的归并排序」进行对比;从这个例子我们可以知道,虽然「递归」在理论上执行效率没有「递推」效率高,但正确地编写「递归」函数可以帮助我们简化逻辑,而且现代编程语言的编译器还会对递归函数进行优化。因此我们的建议是:如果「递归」函数可以清晰地表达我们想要实现的业务逻辑,不建议为了追求性能极致而改用非递归的写法。代码编写完成以后,我们可以给程序添加打印输出,方便我们更好地理解「归并排序」。参考代码 2:Javaimport java.util.Arrays;
public class Solution {
public int[] sortArray(int[] nums) {
int len = nums.length;
int[] temp = new int[len];
mergeSort(nums, 0, len - 1, temp, 0);
return nums;
}
private void mergeSort(int[] nums, int left, int right, int[] temp, int recursionLevel) {
log("拆分子问题", left, right, recursionLevel);
if (left == right) {
log("解决子问题", left, right, recursionLevel);
return;
}
int mid = (left + right) / 2;
mergeSort(nums, left, mid, temp, recursionLevel + 1);
mergeSort(nums, mid + 1, right, temp, recursionLevel + 1);
mergeOfTwoSortedArray(nums, left, mid, right, temp);
log("解决子问题", left, right, recursionLevel);
}
private void mergeOfTwoSortedArray(int[] nums, int left, int mid, int right, int[] temp) {
for (int i = left; i <= right; i++) {
temp[i] = nums[i];
}
int i = left;
int j = mid + 1;
int k = left;
while (i <= mid && j <= right) {
if (temp[i] <= temp[j]) {
// 注意写成 < 就丢失了稳定性(相同元素原来靠前的排序以后依然靠前)
nums[k] = temp[i];
k++;
i++;
} else {
nums[k] = temp[j];
k++;
j++;
}
}
while (i <= mid) {
nums[k] = temp[i];
k++;
i++;
}
while (j <= right) {
nums[k] = temp[j];
k++;
j++;
}
}
private void log(String log, int left, int right, int recursionLevel) {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(" ".repeat(Math.max(0, recursionLevel)));
stringBuilder.append(log);
stringBuilder.append(" ");
stringBuilder.append("=>");
stringBuilder.append(" ");
stringBuilder.append("[");
stringBuilder.append(left);
stringBuilder.append(", ");
stringBuilder.append(right);
stringBuilder.append("]");
System.out.println(stringBuilder.toString());
}
public static void main(String[] args) {
Solution solution = new Solution();
int[] nums = new int[]{8, 6, 7, 2, 3, 5, 4, 1};
int[] res = solution.sortArray(nums);
System.out.println(Arrays.toString(res));
}
}控制台输出:拆分子问题 => [0, 7]
拆分子问题 => [0, 3]
拆分子问题 => [0, 1]
拆分子问题 => [0, 0]
解决子问题 => [0, 0]
拆分子问题 => [1, 1]
解决子问题 => [1, 1]
解决子问题 => [0, 1]
拆分子问题 => [2, 3]
拆分子问题 => [2, 2]
解决子问题 => [2, 2]
拆分子问题 => [3, 3]
解决子问题 => [3, 3]
解决子问题 => [2, 3]
解决子问题 => [0, 3]
拆分子问题 => [4, 7]
拆分子问题 => [4, 5]
拆分子问题 => [4, 4]
解决子问题 => [4, 4]
拆分子问题 => [5, 5]
解决子问题 => [5, 5]
解决子问题 => [4, 5]
拆分子问题 => [6, 7]
拆分子问题 => [6, 6]
解决子问题 => [6, 6]
拆分子问题 => [7, 7]
解决子问题 => [7, 7]
解决子问题 => [6, 7]
解决子问题 => [4, 7]
解决子问题 => [0, 7]
[1, 2, 3, 4, 5, 6, 7, 8]根据控制台的打印输出,我们可以发现:归并排序的流程是按照「深度优先搜索」的方式进行的。事实上,所有的递归函数的调用过程,都是按照「深度优先搜索」的方式进行的。
英勇黄铜
第三章:算法面试详解
1. Stage 1:介绍在面试开始时,大多数情况下面试官会简单介绍自己和他们在公司的角色,然后让你做自我介绍。准备并排练一段自我介绍。自我介绍应该在 30 秒内总结你的教育背景、工作经历和兴趣爱好。保持微笑,且让说话的声音听起来自信。当面试官谈论他们在公司的工作时,请注意听 - 这有助于稍后提出相关问题。如果面试官提到任何你也感兴趣的事情,无论是他们的工作还是爱好,提出来。2. Stage 2:问题陈述在自我介绍之后,面试官会给你一个问题陈述。如果您在共享文本编辑器中答题,他们很可能将问题描述和测试用例一起粘贴到编辑器中,然后将问题读给您听。确保你完全理解了这个问题。在面试官把问题读完之后,通过将其解释回给他们来确认问题在问什么。询问有关输入的问题阐述,例如:输入是只有整数,还是可以有其他类型?我能假设输入是有序的吗?输入是保证有元素还是可以为空?如果给出了无效输入,我该如何处理?询问预期的输入大小。有时候,面试官会含糊其辞,但如果他们确实给了你一个范围,这可能是一个线索。例如,如果 n 非常小,则可能是回溯。如果 n 在 100 - 1000 左右,O(n 2 ) 的解决方案可能是最优的。 如果 n 非常大, 那么你可以需要比 O(n) 更好的解决方案。提出明确的问题不仅能帮助你更好地理解问题,还能表现出对细节的关注,以及对边缘情况的考虑。3. Stage 3:头脑风暴 DS&A尝试找出适用的数据结构或算法。分解问题并尝试找到你会的常用解法。弄清楚问题需要你做什么,并考虑什么样的数据结构或算法可以以较好的时间复杂度来完成。把你的想法都说出来。这会让面试官知道你善于权衡利弊。如果问题涉及到查看子数组,那么应该考虑滑动窗口,因为每个窗口都代表一个子数组。即使你错了,面试官仍然会欣赏你的思考过程。通过把想法都说出来,面试官也可以借此给你提示,并为你指出正确的方向。一旦决定了要使用的数据结构/算法,现在就需要构造实际的算法。在编码之前,你应该考虑算法的大致步骤,向面试官解释这些步骤,并确保他们理解并同意这是一个合理的方法。通常,如果你走错了路,他们会巧妙地暗示你。在这个阶段你能接受面试官所说的话是 非常 重要的。请记住:他们知道最佳解决方案。如果他们给你提示,那是因为他们希望你成功。不要固执,准备好探索他们给你的想法。4. Stage 4:实操一旦你想出了一个算法,并让面试官同意了,就该开始写代码了。如果你打算使用一个库或模块,例如 Python 的集合,在开始之前确保面试官可以接受。当你写代码时,解释你的决策。例如,如果你正在解决一个图形问题,当你声明一个集合 seen,解释它是为了防止访问同一个节点超过一次,从而也防止了循环。编写干净的代码。每一种主流的编程语言都有一个关于代码应该如何编写的约定。确保你知道你打算使用的语言的基础知识。Google 提供了适用于所有主流语言的 总结。最重要的部分是大小写约定、缩进、空格和全局变量。避免重复代码。例如,如果您在网格上进行 DFS 操作,则应该反复使用方向数组 [(0, 1), (1, 0), (0, -1), (-1, 0)] ,而不是为每个方向编写相同的逻辑 4 次。如果你发现自己在多个地方编写类似的代码,可以考虑创建一个函数或使用循环来简化它。不要害怕使用辅助函数。它们使你的代码更加模块化,这在实际软件工程中非常重要。之后的代码说不定还会用上辅助函数。如果你遇到困难或意识到你最初的计划可能行不通,不要慌。与面试官交流你的疑虑。如果你默默地挣扎,很可能又会钻牛角尖。一种策略是首先实现一个暴力解决方案,同时承认 这是一个次优解决方案。完成后,分析算法的每个部分,找出哪些步骤 “慢”,并尝试思考如何加快速度。让面试官参与进来,让他们参与讨论 —— 他们通常愿意提供帮助。5. Stage 5:测试 & debug一旦你写完代码,你的面试官可能会想要测试你的代码。根据公司的不同,会有一些不同的环境:内置测试用例,代码需要运行这些平台类似于 LeetCode。将会有各种各样的测试用例 —— 小输入,大输入,测试边缘用例的输入。这种环境给您的代码带来了最大的压力,因为会暴露出不完美的解决方案。但是,它也为创建您自己的测试带来了最小的压力,因为测试用例已经内置在了内部。自己写测试用例,代码需要运行这些平台通常是支持运行代码的共享文本编辑器。面试官会希望你编写自己的测试用例。要真正测试代码,你应该在代码的最外层范围编写,即代码将首先运行的地方。假设你在函数中解决了问题 (就像在 LeetCode 上),你可以用你编写的测试用例调用你的函数,并将结果打印到控制台。在编写自己的测试时,请确保尝试各种测试。包括边缘情况、直觉输入和可能无效的输入 (如果面试官想让你处理这种情况)。自己写测试用例,代码不需要运行这些平台只是共享文本编辑器,不支持运行代码。面试官会希望你编写自己的测试用例,并且手动模拟运行。为了 “测试” 代码,你必须在每个测试用例中手动检查算法。试着压缩一些琐碎的部分 —— 例如,你正在创建一个前缀和,不要 字面上 遍历每个元素的 for 循环。可以这样说:“在这个 for 循环之后,我们将有一个前缀和,他是这样的……”。在遍历代码时,将函数中使用的变量写入 (在编辑器中,函数外部的某处),并不断更新它们。不管在什么情况下,如果您的代码出现了错误,不要慌!如果环境支持运行代码,请在相关位置放置打印语句以尝试识别问题。用一个小的测试用例手动遍历(就像你没有运行环境时所做的那样)。当你这样做的时候,讨论变量的期望值应该是什么,并将它们与实际值进行比较。再说一遍,你说话越多,面试官就越容易帮助你。6. Stage 6:解释与跟进在编写算法并运行测试用例之后,准备回答关于算法的问题。你应该准备好回答的问题包括:算法的时间和空间复杂度是多少?你应该从最坏的情况来考虑。但是,如果最坏的情况很少,并且平均情况的运行时明显更快,那么你还应该提到这一点。你为什么选择……?这可以是你对数据结构的选择,算法的选择,循环配置的选择。准备好解释你的思考过程。你认为算法在时间和空间复杂度上是否可以改进?如果问题需要遍历输入中的每个元素 (假设输入没有排序,需要找到最大的元素),那么你很可能无法比 O(n) 更快。否则你很可能无法比 O(logn) 更快。如果面试官问这个问题,答案 通常 是肯定的。在断言你的算法是最优的时候要小心 —— 不要轻易使用绝对的形容。如果面试还有剩余时间,你可能会被问到一个全新的问题。在这种情况下,从步骤 2(问题陈述)重新开始。但是,你也可能会被要求对你已经解决的问题进行跟进。面试官可能会引入新的约束,要求改进空间复杂度,或任何其他数量的东西。这部分是为什么真正理解解决方案而不是仅仅记住它们很重要的原因。7. Stage 7:结尾面试官通常会在面试结束时留出几分钟的时间让你问一些关于他们或公司的问题。在这一点上,很少能改善面试的结果,但你肯定能让它变得更糟。面试是双向的。你应该利用这段时间来了解这家公司,看看你是否愿意在那里工作。你应该在面试前准备一些问题,比如:在公司的一天中会做些什么?你为什么决定加入这家公司而不是另一家公司?关于这份工作,你最喜欢和最不喜欢的是什么?我可以从事什么样的工作?所有的大公司都会有自己的科技博客。展示你对这家公司感兴趣的一个好方法是阅读一些博客文章,并编制一个关于公司为什么做出这些决定的问题清单。保持兴趣,保持微笑,倾听面试官的回答,并提出后续问题,以表明你理解他们的答案。如果你没有高质量的问题,或者表现得无聊或不感兴趣,这可能会给面试官一个不好的信号。如果面试官最后不喜欢你,你在技术方面做得再好也没用。8. Stage 8:面试备考总览以下是「面试的阶段」一文的摘要。如果您进行远程面试,您可以打印此浓缩版并在面试期间将其放在您面前。第一阶段:介绍30-60 秒介绍您的教育、工作经验和兴趣。自信,保持微笑。当面试官谈论他们自己时要注意,稍后将他们的工作纳入您的问题。第二阶段:问题陈述在面试官将问题读给你听后,将问题复述给他们。询问有关输入的问题描述,例如预期的输入大小、边缘情况和无效输入。第三阶段:头脑风暴 DS&A把你所有的想法都说出来。分解问题:弄清楚你需要做什么,并思考什么数据结构或算法可以以良好的时间复杂度完成它。接受面试官的任何评论或反馈,他们可能试图暗示您找到正确的解决方案。一旦你有了想法,在编码之前,向面试官解释你的想法,并确保他们理解并同意这是一种合理的方法。第四阶段:实操在你实际编码时解释你的决策。当你声明集合之类的东西时,解释一下目的是什么。编写符合规范编程语言约定的代码。避免编写重复代码 - 如果你多次编写类似代码,请使用辅助函数或 for 循环。如果你被卡住了,不要惊慌 - 与你的面试官交流你的疑虑。不要害怕暴力解决方案(同时承认它是暴力解法),然后通过优化 “慢” 的部分来改进它。继续把你的想法说出来并与面试官交谈。这让他们更容易给你提示。第五阶段:测试 & debug遍历测试用例时,通过在文件底部写入来跟踪变量,并不断更新它们。压缩琐碎的部分,例如创建前缀和以节省时间。如果有错误并且环境支持运行代码,将打印语句放入你的算法并遍历一个小测试用例,比较变量的预期值和实际值。如果遇到任何问题,请直接说出问题并继续与面试官交谈。第六阶段:解释和跟进您应该准备回答的问题:时间和空间复杂度,平均和最坏情况。你为什么选择这个数据结构、算法或逻辑?您认为该算法可以在复杂性方面进行改进吗?如果他们问你这个问题,那么答案通常是,特别是如果你的算法比 O(n) 慢。第七阶段:结尾准备好有关公司的问题。对面试官的回答表现出感兴趣、微笑并提出后续问题。
英勇黄铜
8.总结
递归函数通过「栈」记录了我们在解决问题当中的一些信息,以方便我们简化逻辑,是「空间换时间」思想的体现。要想深刻理解递归离不开大量的练习,我们在这里为大家准备了一些非常经典的使用递归实现的问题,但是我们还要提醒大家:递归只是手段。背后更深刻的「分而治之」的算法思想。因此,大家在做练习的时候一定要多思考当前要解决的问题,为什么可以用递归来解决,而不是仅仅只看它的表面。另外,「递归」和「栈」「深度优先遍历」「分而治之」密不可分,这也是我们在本专题的一开始就和大家提及的事情,大家做完这些问题以后可以再回过头来想想是不是这么回事。大家还可以自己找一些「深度优先遍历」「回溯算法」「分治算法」标签的问题做一下,重点掌握解决这些问题,我们所 设计的递归函数的语义。说明:「10. 正则表达式匹配」的经典做法有「动态规划」和「递归」,大家均可以尝试完成。
英勇黄铜
1.递归和分治
1.1递归与分治递归是编程技巧,直接体现在代码上 ,即函数自己调用自己;在调用的函数执行完毕之后,程序会回到产生调用的地方,继续做一些其他事情。调用的过程被称作为递归,返回的过程被称作为回溯。分治是一种算法设计的思想,将大问题分解成多个小问题,例如归并排序将大问题:「排序整个数组」,分解为小问题:「排序左半和右半」;绝大部分情况下「分治算法」通过「递归」实现。即:子问题的求解通过递归方法实现。算法和数据结构并不是凭空想象出来的,「递归」函数也不例外。「递归」函数基于 「自顶向下」拆分问题,再「自底向上」逐层解决问题的思想设计而成,这是所熟知的「分而治之」的算法思想。1.2递归函数的设计思想:分而治之(减而治之)分而治之(Divide-and-Conquer)的思想分为如下三步:拆分:将原问题拆分成若干个子问题;解决:解决这些子问题;合并:合并子问题的解得到原问题的解。这样的三步恰好与递归的程序写法相吻合:拆分:即对当前的大问题进行分析,写出相应代码,分解为子问题。解决:即通过递归调用解决子问题;合并:即在回溯的过程中,根据递归返回的结果,对子问题进行合并,得到大问题的解。因此,分治算法一般通过递归实现。更加形象的一种说法是:一开始我们只有一个问题,我们通过分治,将其分解成多个问题,发散开来,“自顶向下”走出去。在解决完子问题之后,在回溯的过程中合并子问题的解,将发散开来的问题合并成一个,“自底向上”走回来。典型的分治思想的应用是:归并排序、快速排序、绝大多数「树」中的问题(先把原问题拆分成子树的问题,当子树中的问题解决以后,结合子树求解的结果处理当前结点)、链表中的问题。我们在本教程里不对「分治思想」展开叙述。「分治思想」的特例是「减治思想(Decrease-and-Conquer)」:每一步将问题转换成为规模更小的子问题。「减治思想」思想的典型应用是「二分查找」「选择排序」「插入排序」「快速排序」算法。「分治」与「减治思想」的区别如下:分治思想:将一个问题拆分成若干个子问题,然后再逐个求解,根据各个子问题得到的结果得到原问题的结果;减治思想:在拆分子问题的时候,只将原问题转化成 一个 规模更小的子问题,因此子问题的结果就是上一层原问题的结果,每一步只需要解决一个规模更小的子问题,相比较于「分治思想」而言,它 没有「合并」的过程。1.3自顶向下地解决问题使用「递归」的思想解决问题的方案是:从「结果」推向「源头」,再从「源头」返回「结果」。我们以计算 5! 为例向大家解释什么是「自顶向下」地解决问题。下面的幻灯片演示了使用「递归」方法「自顶向下」地计算 5! 的步骤。编程语言在执行计算的过程中,使用了数据结构「栈」。1.4为什么需要使用栈?在计算 5! 的过程当中,需要记录拆分的过程当中的每一个子问题,并且在求解每一个子问题以后,逐层向上汇报结果。后拆分的子问题先得到了解决,整个过程恰好符合「后进先出」的规律 ,因此需要借助的数据结构是「栈」。1.5拆分的时候「先走出去」,合并的时候「再走回来」使用「递归」实现的算法需要走完下面两条路径:先「自顶向下」拆分问题,直到不能拆分为止;再「自底向上」逐层把底层的结果向上汇报,直至得到原问题的解。因此使用「递归」函数解决的问题如上图所示,有「先走出去,再走回来」的过程。1.6总结「分治」是思想,「减治」是分治的特例;「递归」是代码表现形式;「递归」有先拆分问题的过程,真正解决问题,需要在拆分到底以后,一层一层向上返回。
英勇黄铜
第二章:速记Day2
问题 1:分词如何做?基于规则(超大词表)基于统计(两字同时出现越多,就越可能是词)基于网络 LSTM + CRF 词性标注,也可以分词问题 2:word2vector 负采样时为什么要对频率做 3/4 次方?在保证高频词容易被抽到的大方向下,通过权重 3/4 次幂的方式,适当提升低频词、罕见词被抽到的概率。如果不这么做,低频词,罕见词很难被抽到,以至于不被更新到对应 Embedding。问题 3:word2vec 的两种优化方式1.第一种改进为基于层序 softmax 的模型。首先构建哈夫曼树,即以词频作为 n 个词的节点权重,不断将最小权重的节点进行合并,最终形成一棵树,权重越大的叶子结点越靠近根节点,权重越小的叶子结点离根节点越远。然后进行哈夫曼编码,即对于除根节点外的节点,左子树编码为 1,右子树编码为 0。 最后采用二元逻辑回归方法,沿着左子树走就是负类,沿着右子树走就是正类,从训练样本中学习逻辑回归的模型参数。优点:计算量由 V(单词总数)减小为 log2V;高频词靠近根节点,所需步数小,低频词远离根节点。2.第二种改进为基于负采样的模型。通过采样得到少部分的负样本,对正样本和少部分的负样本,利用二元逻辑回归模型,通过梯度上升法,来得到每个词对应的模型参数。具体负采样的方法为:根据词频进行采样,也就是词频越大的词被采到的概率也越大。问题 4:CNN 原理及优缺点CNN 是一种前馈神经网络,通常包含 5 层,输入层,卷积层,激活层,池化层,全连接 FC 层,其中核心部分是卷积层和池化层。优点:共享卷积核,对高维数据处理无压力;无需手动选取特征缺点:需要调参;需要大量样本问题 5:描述下 CRF 模型及应用给定一组输入随机变量的条件下另一组输出随机变量的条件概率分布密度。条件随机场假设输出变量构成马尔科夫随机场,而我们平时看到的大多是线性链条随机场,也就是由输入对输出进行预测的判别模型。求解方法为极大似然估计或正则化的极大似然估计。问题 6:transformer 结构Transformer 本身是一个典型的 encoder-decoder 模型,Encoder 端和 Decoder 端均有6个 Block,Encoder 端的 Block 包括两个模块,多头 self-attention 模块以及一个前馈神经网络模块;Decoder 端的 Block 包括三个模块:多头 self-attention 模块,多头 Encoder-Decoder attention 交互模块以及一个前馈神经网络模块;需要注意:Encoder 端和 Decoder 端中的每个模块都有残差层和 Layer Normalization 层。问题 7:elmo 和 Bert 的区别BERT 采用的是 Transformer 架构中的 Encoder 模块;GPT 采用的是 Transformer 架构中的 Decoder 模块;ELMo 采用的双层双向 LSTM 模块问题 8:elmo 和 word2vec 的区别elmo 词向量是包含上下文信息的,不是一成不变的,而是根据上下文而随时变化。问题 9:lstm 与 GRU 区别(1)LSTM 和 GRU 的性能在很多任务上不分伯仲;(2)GRU 参数更少,因此更容易收敛,但是在大数据集的情况下,LSTM性能表现更好;(3)GRU 只有两个门(update和reset),LSTM 有三个门(forget,input,output),GRU 直接将hidden state 传给下一个单元,而 LSTM 用 memory cell 把 hidden state 包装起来。问题 10:图像处理的基本知识:直方图均衡化、维纳滤波、锐化的操作直方图均衡化(Histogram Equalization)是一种增强图像对比度(Image Contrast)的方法,其主要思想是将一副图像的直方图分布通过累积分布函数变成近似均匀分布,从而增强图像的对比度。维纳滤波器一种以最小平方为最优准则的线性滤波器。在一定的约束条件下,其输出与一给定函数(通常称为期望输出)的差的平方达到最小。锐化滤波器则使用邻域的微分作为算子,增大邻域间像素的差值,使图像的突变部分变的更加明显。锐化的作用是加强图像的边沿和轮廓,通常也成为高通滤波器。
英勇黄铜
第三章:速记Day3
问题 1:BN 过程,为什么测试和训练不一样?对于BN,在训练时,是对每一批的训练数据进行归一化,也即用每一批数据的均值和方差。而在测试时,比如进行一个样本的预测,就并没有batch的概念,因此,这个时候用的均值和方差是全量训练数据的均值和方差,这个可以通过移动平均法求得。对于BN,当一个模型训练完成之后,它的所有参数都确定了,包括均值和方差,gamma和bata。问题 2:简单介绍 gbdt 算法的原理GBDT是梯度提升决策树,是一种基于Boosting的算法,采用以决策树为基学习器的加法模型,通过不断拟合上一个弱学习器的残差,最终实现分类或回归的模型。关键在于利用损失函数的负梯度在当前模型的值作为残差的近似值,从而拟合一个回归树。对于分类问题:常使用指数损失函数;对于回归问题:常使用平方误差损失函数(此时,其负梯度就是通常意义的残差),对于一般损失函数来说就是残差的近似。无论损失函数是什么形式,每个决策树拟合的都是负梯度。准确的说,不是用负梯度代替残差,而是当损失函数是均方损失时,负梯度刚好是残差,残差只是特例。问题 3:pca 属于有监督还是无监督?PCA 按有监督和无监督划分应该属于无监督学习,所以数据集有无 y 并不重要,只是改变样本 X 的属性(特征)维度。问题 4:Pytorch 和 Tensorflow 的区别?(1)图创建创建和运行计算图可能是两个框架最不同的地方。在pyTorch中,图结构是动态的,这意味着图在运行时构建。而在TensorFlow中,图结构是静态的,这意味着图先被“编译”然后再运行。pyTorch中简单的图结构更容易理解,更重要的是,还更容易调试。调试pyTorch代码就像调试Python代码一样。你可以使用pdb并在任何地方设置断点。调试tensorFlow代码可不容易。要么得从会话请求要检查的变量,要么学会使用tensorFlow的调试器。(2)灵活性pytorch:动态计算图,数据参数在CPU与GPU之间迁移十分灵活,调试简便;tensorflow:静态计算图,数据参数在CPU与GPU之间迁移麻烦,调试麻烦。(3)设备管理pytorch:需要明确启用的设备tensorflow:不需要手动调整,简单问题 5:torch.eval() 的作用?对BN的影响:对于BN,训练时通常采用mini-batch,所以每一批中的mean和std大致是相同的;而测试阶段往往是单个图像的输入,不存在mini-batch的概念。所以将model改为eval模式后,BN的参数固定,并采用之前训练好的全局的mean和std;总结就是使用全局固定的BN。对dropout的影响:训练阶段,隐含层神经元先乘概率P,再进行激活;而测试阶段,神经元先激活,每个隐含层神经元的输出再乘概率P,总结来说就是顺序不同!问题 6:PCA 是什么?实现过程是什么,意义是什么?主成分分析 (PCA, principal component analysis)是一种数学降维方法, 利用正交变换 (orthogonal transformation)把一系列可能线性相关的变量转换为一组线性不相关的新变量,也称为主成分,从而利用新变量在更小的维度下展示数据的特征。实现过程:一种是基于特征值分解协方差矩阵实现PCA算法,一种是基于SVD分解协方差矩阵实现PCA算法。意义:使得数据集更易使用;降低算法的计算开销;去除噪声;使得结果容易理解。问题 7:简述 K-meansK-means算法的基本思想是:以空间中k个点为中心进行聚类,对最靠近他们的对象归类。通过迭代的方法,逐次更新各聚类中心的值,直至得到最好的聚类结果。假设要把样本集分为k个类别,算法描述如下:(1)适当选择k个类的初始中心,最初一般为随机选取;(2)在每次迭代中,对任意一个样本,分别求其到k个中心的欧式距离,将该样本归到距离最短的中心所在的类;(3)利用均值方法更新该k个类的中心的值;(4)对于所有的k个聚类中心,重复(2)(3),类的中心值的移动距离满足一定条件时,则迭代结束,完成分类。 Kmeans聚类算法原理简单,效果也依赖于k值和类中初始点的选择。问题 8:图像边缘检测的原理图像的边缘是指其周围像素灰度急剧变化的那些像素的集合,它是图像最基本的特征,⽽图像的边缘检测即先检测图像的边缘点,再按照某种策略将边缘点连接成轮廓,从而构成分割区域。问题 9:图像中的角点(Harris 角点)是什么?为什么用角点作为特征点?Harris角点:在任意两个相互垂直的方向上,都有较大变化的点。角点在保留图像图形重要特征的同时,可以有效地减少信息的数据量,使其信息的含量很高,有效地提高了计算的速度,有利于图像的可靠匹配,使得实时处理成为可能。问题 10:什么是感受野某⼀层特征图中的⼀个cell,对应到原始输⼊的响应的大小区域。
英勇黄铜
1.面试为什么会考概率
这里我们将开启新的一个专题:概率题面试突击。为什么想写这个专题呢,主要是因为最近几年在程序员面试中,尤其是校招面试中,经常会问到一些与概率、期望相关的问题,一方面是考查候选人的数学素养,另一方面是概率在计算机中的应用确实很多。这些题零零散散出现在各个平台的面经,周围同学朋友的口述,以及自己的面试实践中,没有一个系统的梳理,因此本专栏的目的就是详细梳理程序员面试中常见的知识点与题目。当然,本专栏不只是适合程序员。算法工程师、策略产品经理,数据分析师都是最近几年新出现并且比较热门的岗位,而概率在这些岗位中是通用基础,面试中基本上都会问到。此外,很多程序员会去金融科技工作,而金融业中的技术岗位,概率是必考的,并且会重点考。准备这些岗位的同学,有时间的话都可以学习本专栏,将来一定会有用。我工作至今的职位一直都是算法工程师,这里我从算法工程师的角度谈一谈概率。最近几年人工智能、数据科学已成为一项推动业务发展的重要技术。而进入这个领域的人,基本上一定会翻阅领域内的文章,以及参与相关任务。那么你会发现与概率有关的问题基本上绕不开:要过滤垃圾邮件,这需要贝叶斯思维;要从文本中提取出名称实体,有赖于概率图模型研究;要做语音识别,离不开随机过程中的隐马尔可夫模型;要通过样本推断某类对象的总体特征,则需要建立估计理论和大数定理;要进行统计推断,必不可少的一类应用广泛的采样方法则是蒙特卡罗方法以及马尔可夫过程的稳定性。可以看到,概率理论既是程序员面试的高频考点,又是解决实际问题的现实工具。除了理论以外,运用好 Python 工具,例如 Numpy、Scipy、Matplotlib、Pandas 等,也有助于强化对知识的理解,后面的基础知识介绍中会穿插有这方面的内容。本专栏是一个连载专栏,主要讨论程序员面试中的高频概率问题,包括知识点和题目,但不涉及机器学习中的概率进阶理论知识。其目录规划如下:第一章为序言,也就是本小节的内容。第二章简述随机模拟,并以一实例加深大家对随机模拟的思考过程,附 Python 代码。第三章简要梳理面试中概率核心考点。第四章为连载部分,将持续更新概率计算问题及其解答。第五章为附录,主讲概率在计算机中的现实应用。
英勇黄铜
第一章:速记Day1
问题 1:L1 和 L2 的区别L1范数(L1 norm)是指向量中各个元素绝对值之和,也有个美称叫“稀疏规则算子”(Lasso regularization)。比如 向量A=[1,-1,3], 那么A的L1范数为 |1|+|-1|+|3|简单总结一下就是:L1范数: 为x向量各个元素绝对值之和L2范数: 为x向量各个元素平方和的1/2次方,L2范数又称Euclidean范数或Frobenius范数Lp范数: 为x向量各个元素绝对值p次方和的1/p次方在支持向量机学习过程中,L1范数实际是一种对于成本函数求解最优的过程,因此,L1范数正则化通过向成本函数中添加L1范数,使得学习得到的结果满足稀疏化,从而方便人类提取特征。L1范数可以使权值稀疏,方便特征提取。L2范数可以防止过拟合,提升模型的泛化能力。问题 2:过拟合问题是如何产生的1.数据量太小这个是很容易产生过拟合的一个原因。设想,我们有一组数据很好的吻合3次函数的规律,现在我们局部的拿出了很小一部分数据,用机器学习或者深度学习拟合出来的模型很大的可能性就是一个线性函数,在把这个线性函数用在测试集上,效果可想而知肯定很差了。2.训练集和验证集分布不一致训练集训练出一个适合训练集那样分布的数据集,当你把模型运用到一个不一样分布的数据集上,效果肯定大打折扣。这个是显而易见的。3.模型复杂度太大在选择模型算法的时候,首先就选定了一个复杂度很高的模型,然后数据的规律是很简单的,复杂的模型反而就不适用了。4.数据质量很差数据还有很多噪声,模型在学习的时候,肯定也会把噪声规律学习到,从而减小了具有一般性的规律。这个时候模型用来预测肯定效果也不好。5.过度训练这个是同第4个是相联系的,只要训练时间足够长,那么模型肯定就会吧一些噪声隐含的规律学习到,这个时候降低模型的性能是显而易见的。问题 3:如何解决过拟合问题过拟合的原因是算法的学习能力过强;一些假设条件(如样本独立同分布)可能是不成立的;训练样本过少不能对整个空间进行分布估计。处理方法:早停止:如在训练中多次迭代后发现模型性能没有显著提高就停止训练数据集扩增:原有数据增加、原有数据加随机噪声、重采样正则化,正则化可以限制模型的复杂度交叉验证特征选择/特征降维创建一个验证集是最基本的防止过拟合的方法。我们最终训练得到的模型目标是要在验证集上面有好的表现,而不训练集问题 4:overfitting 怎么解决dropoutregularizationbatch normalizatin问题 5:二分查找的基本思想「二分查找」的思想在我们的生活和工作中很常见,「二分查找」通过不断缩小搜索区间的范围,直到找到目标元素或者没有找到目标元素。这里「不断缩小搜索区间」是一种 减而治之 的思想,也称为减治思想。「减而治之」思想简介这里「减」是「减少问题」规模的意思,治是「解决」的意思。「减治思想」从另一个角度说,是「排除法」,意即:每一轮排除掉一定不存在目标元素的区间,在剩下 可能 存在目标元素的区间里继续查找。每一次我们通过一些判断和操作,使得问题的规模逐渐减少。又由于问题的规模是有限的,我们通过有限次的操作,一定可以解决这个问题。可能有的朋友听说过「分治思想」,「分治思想」与「减治思想」的差别就在于,我们把一个问题拆分成若干个子问题以后,应用「减治思想」解决的问题就只在其中一个子问题里寻找答案。问题 6:如何解决推荐系统冷启动问题提供非个性化的推荐最简单的例子就是热门排行榜,我们可以给用户推荐热门排行榜,然后等到用户数据收集到一定的时候,再切换为个性化推荐。2. 利用用户注册信息用户注册时提供包括用户的年龄、性别、职业、民族、学历和居住地等数据,做粗粒度的个性化。有一些网站还会让用户用文字描述他们的兴趣。3. 利用社交网络信息引导用户通过社交网络账号登录(需要用户授权),导入用户在社交网站上的好友信息,然后给用户推荐其好友喜欢的物品。问题 7:决策树算法有哪些ID3、C4.5、CART 树的算法思想ID3 算法的核心是在决策树的每个节点上应用信息增益准则选择特征,递归地构架决策树。C4.5 算法的核心是在生成过程中用信息增益比来选择特征。CART 树算法的核心是在生成过程用基尼指数来选择特征。基于决策树的算法有随机森林、GBDT、Xgboost 等。问题 8:召回和排序的差异召回的目的在于减少候选的数量(尽量控制在1000以内),方便后续排序环节使用复杂模型精准排序;因为在短时间内评估海量候选,所以召回的关键点是个快字,受限与此与排序相比,召回的算法模型相对简单,使用的特征比较少。而排序模型相对更加复杂,更追求准确性,使用的特征也会较多。问题 9:缓解梯度消失、梯度爆炸、梯度膨胀的方法1.梯度消失:根据链式法则,如果每一层神经元对上一层的输出的偏导乘上权重结果都小于1的话,那么即使这个结果是0.99,在经过足够多层传播之后,误差对输入层的偏导会趋于0。可以采用ReLU激活函数有效的解决梯度消失的情况。2.梯度膨胀:根据链式法则,如果每一层神经元对上一层的输出的偏导乘上权重结果都大于1的话,在经过足够多层传播之后,误差对输入层的偏导会趋于无穷大。可以通过激活函数来解决。3.梯度爆炸:针对梯度爆炸问题,解决方案是引入Gradient Clipping(梯度裁剪)。通过Gradient Clipping,将梯度约束在一个范围内,这样不会使得梯度过大。问题 10:Batch Normalization 缺点batch 太小,会造成波动大对于文本数据,不同有效长度问题测试集上两个数据均值和方差差别很大就不合适了附:LN 是对一个样本的一个时间步上的数据进行减均除标准差,然后再回放(参数学习)对应到普通线性回归就是一层节点求均除标准差。
英勇黄铜
第二章:算法题代码模板—下
14. 图: DFS (迭代)public int fn(int[][] graph) {
Stack<Integer> stack = new Stack<>();
Set<Integer> seen = new HashSet<>();
stack.push(START_NODE);
seen.add(START_NODE);
int ans = 0;
while (!stack.empty()) {
int node = stack.pop();
// 根据题意补充代码
for (int neighbor: graph[node]) {
if (!seen.contains(neighbor)) {
seen.add(neighbor);
stack.push(neighbor);
}
}
}
return ans;
}15. 图: BFSpublic int fn(int[][] graph) {
Queue<Integer> queue = new LinkedList<>();
Set<Integer> seen = new HashSet<>();
queue.add(START_NODE);
seen.add(START_NODE);
int ans = 0;
while (!queue.isEmpty()) {
int node = queue.remove();
// 根据题意补充代码
for (int neighbor: graph[node]) {
if (!seen.contains(neighbor)) {
seen.add(neighbor);
queue.add(neighbor);
}
}
}
return ans;
}16. 找到堆的前 k 个元素public int[] fn(int[] arr, int k) {
PriorityQueue<Integer> heap = new PriorityQueue<>(CRITERIA);
for (int num: arr) {
heap.add(num);
if (heap.size() > k) {
heap.remove();
}
}
int[] ans = new int[k];
for (int i = 0; i < k; i++) {
ans[i] = heap.remove();
}
return ans;
}17. 二分查找public int fn(int[] arr, int target) {
int left = 0;
int right = arr.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == target) {
// 根据题意补充代码
return mid;
}
if (arr[mid] > target) {
right = mid - 1;
} else {
left = mid + 1;
}
}
// left 是插入点
return left;
}18. 二分查找: 重复元素,最左边的插入点public int fn(int[] arr, int target) {
int left = 0;
int right = arr.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (arr[mid] >= target) {
right = mid
} else {
left = mid + 1;
}
}
return left;
}19. 二分查找: 重复元素,最右边的插入点public int fn(int[] arr, int target) {
int left = 0;
int right = arr.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (arr[mid] > target) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}20. 二分查找: 贪心问题寻找最小值:public int fn(int[] arr) {
int left = MINIMUM_POSSIBLE_ANSWER;
int right = MAXIMUM_POSSIBLE_ANSWER;
while (left <= right) {
int mid = left + (right - left) / 2;
if (check(mid)) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return left;
}
public boolean check(int x) {
// 这个函数的具体实现取决于问题
return BOOLEAN;
}寻找最大值:public int fn(int[] arr) {
int left = MINIMUM_POSSIBLE_ANSWER;
int right = MAXIMUM_POSSIBLE_ANSWER;
while (left <= right) {
int mid = left + (right - left) / 2;
if (check(mid)) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return right;
}
public boolean check(int x) {
// 这个函数的具体实现取决于问题
return BOOLEAN;
}21. 回溯public int backtrack(STATE curr, OTHER_ARGUMENTS...) {
if (BASE_CASE) {
// 修改答案
return 0;
}
int ans = 0;
for (ITERATE_OVER_INPUT) {
// 修改当前状态
ans += backtrack(curr, OTHER_ARGUMENTS...)
// 撤消对当前状态的修改
}
}22. 动态规划: 自顶向下法Map<STATE, Integer> memo = new HashMap<>();
public int fn(int[] arr) {
return dp(STATE_FOR_WHOLE_INPUT, arr);
}
public int dp(STATE, int[] arr) {
if (BASE_CASE) {
return 0;
}
if (memo.contains(STATE)) {
return memo.get(STATE);
}
int ans = RECURRENCE_RELATION(STATE);
memo.put(STATE, ans);
return ans;
}23. 构建前缀树(字典树)// 注意:只有需要在每个节点上存储数据时才需要使用类。
// 否则,您可以只使用哈希映射实现一个前缀树。
class TrieNode {
// 你可以将数据存储在节点上
int data;
Map<Character, TrieNode> children;
TrieNode() {
this.children = new HashMap<>();
}
}
public TrieNode buildTrie(String[] words) {
TrieNode root = new TrieNode();
for (String word: words) {
TrieNode curr = root;
for (char c: word.toCharArray()) {
if (!curr.children.containsKey(c)) {
curr.children.put(c, new TrieNode());
}
curr = curr.children.get(c);
}
// 这个位置上的 curr 已经有一个完整的单词
// 如果你愿意,你可以在这里执行更多的操作来给 curr 添加属性
}
return root;
}
英勇黄铜
第六章:速记Day6
问题 1:降维的方法缺失值比率(Missing Value Ratio)低方差滤波(Low Variance Filter)高相关滤波(High Correlation filter)随机森林(Random Forest)反向特征消除(Backward Feature Elimination)前向特征选择(Forward Feature Selection)因子分析(Factor Analysis)主成分分析(PCA)9. 独立分量分析(ICA)10. 局部线性嵌入(LLE)11. IOSMAP12. t-SNE13. UMAP14. Autoencoder15. Lap lacian Eigenmap问题 2:xgb 和 gbdt 的区别(1)xgb和gbdt效果上的区别:xgb在精度上要比gbdt好xgb在效率上比gbdt好,因为xgb使用了二阶导数。(2)xgb和gbdt在处理缺失值时的区别:gbdt是使用其它值对缺失值进行预估,而xgb是先忽略掉这些缺失值。xgboost工具支持在特征粒度上并行,大大减小计算量,各个特征的增益计算就可以开多线程进行。xgboost还提出了一种可并行的近似直方图算法,用于高效地生成候选的分割点。因此xgb计算速度更快。问题 3:虚函数和纯虚函数区别虚函数和纯虚函数可以出现在同一个类中,该类称为抽象基类(含有纯虚函数的类称为抽象基类)。使用方式不同:虚函数可以直接使用,纯虚函数必须在派生类中实现后才能使用;定义形式不同:虚函数在定义时在普通函数的基础上加上 virtual 关键字,纯虚函数定义时除了加上 virtual 关键字还需要加上 =0;虚函数必须实现,否则编译器会报错;对于实现纯虚函数的派生类,该纯虚函数在派生类中被称为虚函数,虚函数和纯虚函数都可以在派生类中重写;析构函数最好定义为虚函数,特别是对于含有继承关系的类;析构函数可以定义为纯虚函数,此时,其所在的类为抽象基类,不能创建实例化对象。问题 4:什么叫最小二乘法最小二乘法(又称最小平方法)是一种数学优化技术。它通过最小化误差的平方和寻找数据的最佳函数匹配。利用最小二乘法可以简便地求得未知的数据,并使得这些求得的数据与实际数据之间误差的平方和为最小。最小二乘法还可用于曲线拟合。其他一些优化问题也可通过最小化能量或最大化熵用最小二乘法来表达。问题 5:Adam 的优势Adam 优化算法应用在非凸优化问题中所获得的优势:直截了当地实现高效的计算所需内存少梯度对角缩放的不变性(第二部分将给予证明)适合解决含大规模数据和参数的优化问题适用于非稳态(non-stationary)目标适用于解决包含很高噪声或稀疏梯度的问题超参数可以很直观地解释,并且基本上只需极少量的调参问题 6:GMM 与 Kmeans 算法的比较(1) kmeans 主要针对圆形或球形样本进行聚类,对于椭圆数据处理效果不佳,而 GMM 可以解决这个问题。(2) kmeans 对于不均衡的样本类别聚类效果不佳,而 GMM 计算过程则考虑了各类别权重。(3) kmeans 是判别模型,直接对样本空间中寻找最有面进行划分,可解释性不强。GMM 是生成模型,从样本本身分布出发,计算联合概率分布,以求得分类结果,可解释性比 kmeans 强。(4) kmeans 是硬分类,结果属于0-1;而 GMM 是软分类,分类结果是一个概率分布。问题 7:怎么理解神经网络神经网络学习又称为神经元的基本处理单元互连而成的平行工作的复杂网络系统,简称神经网络。当已知训练样本的数据加到网络输入端时,网络的学习机制一遍又一遍地调整各神经元的权值,使其输出端达到预定的目标。这就是训练(学习、记忆)过程。神经网络原理及应用:1. 什么是神经网络?神经网络是一种模拟动物神经网络行为特征,进行分布式并行信息处理的算法。这种网络依靠系统的复杂程度,通过调整内部大量节点之间相互连接的关系,从而达到处理信息的目的。2. 神经网络基础知识构成:大量简单的基础元件——神经元相互连接工作原理:模拟生物的神经处理信息的方式功能:进行信息的并行处理和非线性转化特点:比较轻松地实现非线性映射过程,具有大规模的计算能力神经网络的本质:神经网络的本质就是利用计算机语言模拟人类大脑做决定的过程。3. 生物神经元结构4. 神经元结构模型 xj 为输入信号,θi 为阈值,wij 表示与神经元连接的权值,yi 表示输出值判断 xjwij 是否大于阈值 θi5. 什么是阈值?临界值。神经网络是模仿大脑的神经元,当外界刺激达到一定的阈值时,神经元才会受刺激,影响下一个神经元。6. 几种代表性的网络模型单层前向神经网络——线性网络阶跃网络多层前向神经网络(反推学习规则即BP神经网络)Elman网络、Hopfield网络、双向联想记忆网络、自组织竞争网络等等.7. 神经网络能干什么/应用 ?运用这些网络模型可实现函数逼近、数据聚类、模式分类、优化计算等功能。因此,神经网络广泛应用于人工智能、自动控制、机器人、统计学等领域的信息处理中。虽然神经网络的应用很广,但是在具体的使用过程中到底应当选择哪种网络结构比较合适是值得考虑的。这就需要我们对各种神经网络结构有一个较全面的认识。问题 8:PCA 是如何实现的import numpy as np
import pandas as pd
import matplotlib.pyplot as plt定义一个均值函数。#计算均值,要求输入数据为numpy的矩阵格式,行表示样本数,列表示特征
def meanX(dataX):
return np.mean(dataX,axis=0)#axis=0表示依照列来求均值。假设输入list,则axis=1开始实现 pca 的函数:def pca(XMat, k):
"""
XMat:传入的是一个numpy的矩阵格式,行表示样本数,列表示特征
k:表示取前k个特征值相应的特征向量
finalData:指的是返回的低维矩阵
reconData:相应的是移动坐标轴后的矩阵
"""
average = meanX(XMat)
m, n = np.shape(XMat)
data_adjust = []
avgs = np.tile(average, (m, 1))
data_adjust = XMat - avgs
covX = np.cov(data_adjust.T) #计算协方差矩阵
featValue, featVec= np.linalg.eig(covX) #求解协方差矩阵的特征值和特征向量
index = np.argsort(-featValue) #依照featValue进行从大到小排序
finalData = []
if k > n:
print("k must lower than feature number")
return
else:
#注意特征向量时列向量。而numpy的二维矩阵(数组)a[m][n]中,a[1]表示第1行值
selectVec = np.matrix(featVec.T[index[:k]]) #所以这里须要进行转置
finalData = data_adjust * selectVec.T
reconData = (finalData * selectVec) + average
return finalData, reconData问题 9:远程 copy 文件用什么命令1.将远程的某个目录设置为共享转载见2.通过命令行@echo off
net use \\192.168.1.2\ipc$ password /user:Administrator
rem 复制单个文件 (可以执行其他诸如del等的命令)
copy D:\setup.bat \\192.168.1.2\temp
rem 复制文件夹 /s 复制非空的目录和子目录。如果省略 /s,xcopy 将在一个目录中工作。 /e 复制所有子目录,包括空目录。同时使用 /e、/s 和 /t 命令行选项。
rem XCOPY D:\TEMP \\192.168.1.2\temp/E
net use \\192.168.1.2\ipc$ /delete
pause问题 10:Linux 里面查看文件有哪些命令Linux 查看日志文件内容命令有:cat 由第一行开始显示文件内容tac 从最后一行开始显示,可以看出 tac 是 cat 的倒着写nl 显示的时候,顺道输出行号!more 一页一页的显示文件内容less 与 more 类似,但是比 more 更好的是,他可以往前翻页!head 只看头几行tail 只看尾巴几行你可以使用 man [命令]来查看各个命令的使用文档,如 :man cp。
英勇黄铜
2.随机模拟的方法论
由于随机模拟往往可验证概率问题的结果,因此它在后面解题中基本都会用到。本章首先介绍随机模拟方法的背景,然后以一个例题来阐述实际场景中随机模拟怎么用、具体流程有哪些,最后进行总结。2.1随机模拟方法大量实际问题都存在不确定性,如:交通路况、商品库存变动、金融市场交易量变化、服务排队情况等。这些问题的共同点是要求我们用概率方法对系统进行建模,然后分析系统的行为特征。但要求解这类随机模型非常困难,我们甚至无法获得解析解。而随机模拟对于解决上述问题就显得尤为重要了。随机模拟方法是通过仿真随机系统的运行来获得系统的状态变化与输出结果的大量数据,进而对所得数据进行统计分析,估算系统行为的某些特征,并将估计的误差控制在一定范围内。这里的仿真其实是另一个单独的话题,里面也有很多分类,在这里我们只关注对随机系统的仿真。随机模拟方法除了在解析解很困难或没有解析解的情况下是很有力的方法,在有解析解的时候,还可以用于验证解析解的正确性。随机模拟有一个基本的方法论,它是一种用随机模拟解决问题的一个思路框架。有了这个方法论,拿到实际问题的时候就可以快速形成思路。这其中会分为描述系统、设置变量、运行规则、模拟系统、抽样与统计、解释结果这几步。正确地描述系统的输入输出,以及运行规则,需要对系统进行数学建模。数学建模常见的模型有图论模型(例如二分图匹配)、概率统计模型(例如逻辑回归模型)、动态模型(例如常微分方程)、优化模型(例如线性规划)等。对系统建模是需要准确找到随机事件是什么。这需要一些数学基础,主要是概率论和随机过程。对系统中的随机事件,需要正确地为该随机事件配置随机数,这需要随机数采样的技术,常见的连续型和离散型分布可以直接生成,而一些特殊分布需要一些算法,例如逆变换法,接受拒绝法,抽样多维联合分布法。蒙特卡洛法是对系统进行模拟的重要方法,主要包括马尔可夫链蒙特卡洛法,蒙特卡洛优化,蒙特卡洛积分。随机服务系统,随机游走,以及元胞自动机是随机模拟的比较大的应用。下面我们以一个实际问题来看一下随机模拟的方法论。2.2随机模拟方法论实践:电池问题考虑一个由充电电池构成的供电系统,一共有两个电池和一个充电器。其中一个电池给设备供电,另一个电池备用。电池的耗尽时间为 1, 2, 3, 4, 5, 6 小时的其中一种情况,并且随机。耗尽的电池充满电需要 2.5 小时。初始状态两个电池都是充满电的。问:设备可以持续工作多长时间。解析解刚开始的时候,设备上的电池无论随机到的耗尽时间是多少,都会继续供电,因为备用电池在初始的时候是就绪的。第一次更换电池后,新电池开始供电,备用电池开始充电。此时新电池随机到的耗尽时间就很关键了。由于充电时间是 2.5,因此如果随机到的耗尽时间为 1, 2,那么耗尽后供电就会终止。如果随机到的耗尽时间是 3, 4, 5, 6,那么耗尽时备用电池已就绪,可以回到前面刚刚更换电池的状态,也就是【新电池开始供电,备用电池开始充电】的状态。这个过程可以用有向图建模在图中,我们定义状态:S0: 新电池开始供电,备用电池已就绪,也就是初始状态S1: 新电池开始供电,备用电池开始充电,也就是过程中会不断重复的状态S2: 无新电池可用,也就是供电停止的状态状态的转移概率,以及状态转移时对应的期望时间间隔标注在图中。定义 x 为从初始到停止供电的总时间的期望,y 为从第一次换电池到停止供电的时间的期望。根据图中的状态转移关系,我们可以写出随机模拟随机模拟大致分为六个步骤。step1: 描述系统输入:新开始供电的电池的耗尽时间 r。状态:S0(2 块备用电池就绪,也就是初始状态),S1(1 块备用电池就绪,也就是正常换电池的状态),S2(无备用电池就绪,也就是停止供电的状态)。随机事件:换电池,ti 表示第 i 次随机事件(换电池)的时间点。输出:第 m 次随机事件时,状态处于 S2,则 tm 为停止时间step2: 设置变量r 是 1 ~ 6 的离散均匀分布。用随机数发生器产生一系列随机数作为输入。step3: 运行规则产生一系列随机数后,随机事件的发生也就取决于这些随机数。step4: 模拟系统首先导入必要的包。其中 font 是字体对象,在 step6 中画图的时候需要用到。import numpy as np
import matplotlib.pyplot as plt
from matplotlib.font_manager import FontProperties
font = FontProperties(fname="/home/ppp/anaconda3/envs/python-3.6/lib/python3.6/site-packages/matplotlib/mpl-data/fonts/ttf/simhei.ttf")
import seaborn as sns初始 t0 = 0, s = 0,然后模拟并产生输出即可。代码如下class PowerSupplySystem:
def __init__(self, s0=0, t0=0.0, N=int(1e5)):
self.N = N
self.charging_time = 2.5
self.s0 = s0
self.t0 = t0
self.reset()
def reset(self):
self.t = self.t0
self.s = self.s0
self.rs = np.random.randint(1, 7, self.N)
self.i = 0
def exhaust_time(self):
nxt_t = self.rs[self.i]
self.i += 1
return nxt_t
def __call__(self):
np.random.seed()
self.t += self.exhaust_time()
self.s = 1
while self.s != 2 and self.i < self.N:
t = self.exhaust_time()
self.t += t
if t < self.charging_time:
self.s = 2
tmp = self.t
self.reset()
return tmpsystem = PowerSupplySystem() 创建一个供电系统实例后,调用该实例后,会进行一次试验。并返回停止供电时间。step5: 抽样与统计重复模拟试验,对结果进行统计。system = PowerSupplySystem()
T = int(1e5)
ts = np.zeros(T)
for i in range(T):
ts[i] = system()
avg_ts = np.zeros(T)
avg_ts[0] = ts[0]
for i in range(1, ts.shape[0]):
avg_ts[i] = (avg_ts[i - 1] * i + ts[i]) / (i + 1)
print("平均停止供电时间: {:.6f}".format(avg_ts[-1]))模拟结果平均停止供电时间: 13.991030step6: 解释结果下面我们画一下平均停止时间随着试验次数的变化图,以及停止时间的分布图。fig = plt.figure()
ax1 = fig.add_subplot(1, 2, 1)
ax2 = fig.add_subplot(1, 2, 2)
ax1.plot(avg_ts)
ax1.set_title("供电系统", fontproperties=font)
ax1.set_xlabel("试验次数", fontproperties=font)
ax1.set_ylabel("平均断电时间", fontproperties=font)
ax2.hist(ts, bins=int(ts.max()))
ax2.set_title("供电系统", fontproperties=font)
ax2.set_xlabel("断电时间", fontproperties=font)
ax2.set_ylabel("频率", fontproperties=font)
plt.show()平均停止时间随着试验次数的变化图停止时间的分布图2.3随机模拟的方法论总结通过电池问题,我们简单实现了随机模拟过程,现进一步总结如下:描述系统系统的输入、状态、输出。随机事件是什么设置变量为系统的输入、状态、输出设置变量为随机事件设定随机数运行规则系统状态如何变化随机数如何产生模拟系统给定系统初始情况给出系统的输出抽样与统计大量重复模拟试验对结果进行统计解释结果解释模拟结果必要时改变某些设定重新模拟
英勇黄铜
第五章:速记Day5
问题 1:工业界中遇到上亿的图像检索任务,如何提高图像对比效率?假设原图像输出的特征维度为2048维,通过哈希的索引技术,将原图的2048维度映射到128维度的0/1值中,再进⾏特征维度对⽐。问题 2:了解正则化么?正则化是针对过拟合而提出的,以为在求解模型最优的是一般优化最小的经验风险,现在在该经验风险上加入模型复杂度这一项(正则化项是模型参数向量的范数),并使用一个 rate 比率来权衡模型复杂度与以往经验风险的权重。如果模型复杂度越高,结构化的经验风险会越大,现在的目标就变为了结构经验风险的最优化,可以防止模型训练过度复杂,有效的降低过拟合的风险。 奥卡姆剃刀原理,能够很好的解释已知数据并且十分简单才是最好的模型。问题 3:线性分类器与非线性分类器的区别以及优劣?如果模型是参数的线性函数,并且存在线性分类面,那么就是线性分类器,否则不是。常见的线性分类器有:LR、贝叶斯分类、单层感知机、线性回归。常见的非线性分类器:决策树、RF、GBDT、多层感知机。SVM两种都有(看线性核还是高斯核)。线性分类器速度快、编程方便,但是可能拟合效果不会很好。非线性分类器编程复杂,但是效果拟合能力强。问题 4:BN 和 LN 区别Batch Normalization 是对这批样本的同一维度特征做归一化, Layer Normalization 是对这单个样本的所有维度特征做归一化。区别:LN 中同层神经元输入拥有相同的均值和方差,不同的输入样本有不同的均值和方差;BN 中则针对不同神经元输入计算均值和方差,同一个 batch 中的输入拥有相同的均值和方差。所以,LN 不依赖于 batch 的大小和输入 sequence 的长度,因此可以用于 batchsize 为 1 和 RNN 中sequence 的 normalize 操作。问题 5:讲讲 self attentionSelf Attention 与传统的 Attention 机制非常的不同:传统的 Attention 是基于 source 端和 target 端的隐变量(hidden state)计算 Attention 的,得到的结果是源端的每个词与目标端每个词之间的依赖关系。但 Self Attention 不同,它分别在 source 端和 target 端进行,仅与 source input 或者 target input 自身相关的 Self Attention,捕捉 source 端或 target 端自身的词与词之间的依赖关系;然后再把 source 端的得到的 self Attention 加入到 target 端得到的 Attention 中,捕捉 source 端和 target端词与词之间的依赖关系。因此,self Attention Attention 比传统的 Attention mechanism 效果要好,主要原因之一是,传统的Attention 机制忽略了源端或目标端词与词之间的依赖关系,同时还可以有效获取源端或目标端自身词与词之间的依赖关系。问题 6:Bert 的预训练过程Bert 的预训练主要包含两个任务,MLM 和 NSP,Masked Language Model 任务可以理解为完形填空,随机 mask 每一个句子中 15% 的词,用其上下文来做预测;Next Sentence Prediction 任务选择一些句子对 A 与 B,其中 50% 的数据 B 是 A 的下一条句子,剩余 50% 的数据 B 是语料库中随机选择的,学习其中的相关性。BERT 预训练阶段实际上是将上述两个任务结合起来,同时进行,然后将所有的 Loss 相加。问题 7:Pre Norm 与 Post Norm 的区别在同一设置下,Pre Norm(也就是Norm and add)的效果是要优于 Post Norm(Add and Norm)的,但是单独调整的话,Post Norm 的效果是更好的,Pre Norm 结构无形地增加了模型的宽度而降低了模型的深度,Post Norm 每 Norm 一次就削弱一次恒等分支的权重,所以 Post Norm 反而是更突出残差分支的。问题 8:GPT 与 BERT 的区别(1) GPT 是单向模型,无法利用上下文信息,只能利用上文;而 BERT 是双向模型。(2) GPT 是基于自回归模型,可以应用在 NLU 和 NLG 两大任务,而原生的 BERT 采用的基于自编码模型,只能完成 NLU 任务,无法直接应用在文本生成上面。问题 9:递归有什么特点函数调用形成栈帧,该函数所定义的所有临时(局部)变量都在该函数的栈帧内进行空间开辟。函数返回时自动释放该函数的栈帧结构。不合法的递归容易产生栈溢出。合法的递归是有限次的。问题 10:解决哈希碰撞的方法1.开放地址法(再散列法)开放地执法有一个公式: Hi=(H(key)+di) MOD m i=1,2,…,k(k<=m-1)其中,m 为哈希表的表长;di 是产生冲突的时候的增量序列。如果 di 值可能为 1,2,3,…m-1,称线性探测再散列。如果 di 取1,则每次冲突之后,向后移动1个位置.如果 di 取值可能为 1,-1,2,-2,4,-4,9,-9,16,-16,…kk,-kk(k<=m/2),称二次探测再散列。如果 di 取值可能为伪随机数列。称伪随机探测再散列。2.再哈希法 Rehash当发生冲突时,使用第二个、第三个、哈希函数计算地址,直到无冲突时。这种方法是同时构造多个不同的哈希函数:Hi=RH1(key) i=1,2,…,k当哈希地址 Hi=RH1(key)发生冲突时,再计算 Hi=RH2(key)……,直到冲突不再产生。这种方法不易产生聚集,但增加了计算时间。3 .链地址法(拉链法)将所有关键字为同义词的记录存储在同一线性链表中,基本思想就是,将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况。4.建立一个公共溢出区假设哈希函数的值域为 [0,m-1], 则设向量 HashTable [0…m-1] 为基本表,另外设立存储空间向量 OverTable[0…v] 用以存储发生冲突的记录。
英勇黄铜
哈希表
什么是哈希表顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为 O(N),平衡树中为树的高度,即 O( logN),搜索的效率取决于搜索过程中元素的比较次数。理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素向该结构中插入元素时:根据待插入的关键码,以此函数计算出该元素的存储位置并按此位置进行存放搜索元素时:堆元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,按此位置取元素比较,若关键码相等,则查找成功这个方式就是哈希方法,哈希方法中使用的转换函数称为哈希函数,构造出来的结构称为哈希表,使用这个方法进行搜索不必进行多次的关键码的比价,使用搜索速度比较快,但是它有发生冲突的可能冲突冲突的概念不同的数据在通过同一个哈希函数的计算出相同的哈希地址的数据元素称为同义词,也就是发生冲突了。冲突的避免冲突的发生是无法避免的,因为我们哈希表的底层数组的容量往往是小于要存储的数据的数量的,这就导致冲突的发生是必然的,我们只能尽量的降低冲突率减少冲突的哈希函数引起哈希冲突的一个原因可能是:哈希函数设计不够合理。这里哈希函数的设计有一些原则:1 哈希函数的定义域必须包括需要存储的全部数据2 哈希函数计算出来的地址可以均匀分布在空间中3 哈希函数需要比较简单常见的哈希函数1. 直接定制法取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B 优点:简单、均匀 缺点:需要事先知道关键字的分布情况 使用场景:适合查找比较小且连续的情况 面试题:字符串中第一个只出现一次字符2. 除留余数法设散列表中允许的地址数为 m,取一个不大于 m,但最接近或者等于 m 的质数 p 作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址,Java 中的哈希函数就是使用的这3. 平方取中法假设关键字为 1234,对它平方就是 1522756,抽取中间的 3 位 227 作为哈希地址; 再比如关键字为 4321,对它平方就是 8671041,抽取中间的 3 位 671 (或 710) 作为哈希地址 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况 这里要注意一点:哈希函数虽然设计的越巧妙,哈希冲突的可能性就越小,但是哈希冲突是无法避免的 冲突避免的负载因子 载荷因子定义为:a = 哈希表中放入元素个数 /哈希表的长度通过图我们可以发现,放冲突率到一个程度的时候,我们就需要通过降低负载因子来降低冲突率。我们知道哈希表放入的数据是不可以变的,那我们只能调整哈希表中的数组大小了。冲突的解决我们一般解决冲突有两种方法:闭散列和开散列闭散列闭散列也叫开放地址法,当发生哈希冲突的时候,如果哈希表没有被装满,那么可以将数据方法冲突位置的下一个空位置去。1. 线性探测比如上面的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,下标为 4 ,因此 44 理论上应该插在该位置,但是该位置已经放了值为 4 的元素,即发生哈希冲突。线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。插入通过哈希函数获取待插入元素在哈希表中的位置如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素 4,如果直接删除掉,44 查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素 2. 二次探测线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为 Hi = (H0 + i^2) % m , 其中:i = 1,2,3…, 是通过散列函数 Hash(x) 对元素的关键码 key 进行计算得到的位置,m 是表的大小.研究表明:当表的长度为质数且表装载因子 a 不超过 0.5 时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子 a 不超过 0.5,如果超出必须考虑增容。 因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷开散列/哈希桶Java 中的 HashMap 和 HashSet 底层就是使用的哈希桶开散列法又叫链地址法 (开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。 我们知道,桶中放的就是冲突的元素,开散列就是一个大集合中的搜索问题转化为在小集合中做搜索了。冲突严重中的解决方法刚才我们提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:1. 每个桶的背后是另一个哈希表2. 每个桶的背后是一棵搜索树 哈希桶的模拟实现 public class HashBucket {
static class Node {
int key;
int val;
Node next;
public Node(int key, int val) {
this.key = key;
this.val = val;
}
}
private Node[] array = new Node[10];
private int usedsize;
private static final float DEFAULT_LOAD_FACTOR = 0.75f;
public void put(int key, int val) {
Node node = new Node(key, val);
int Index = key % array.length;
Node cur = array[Index];
while(cur != null) {
if(cur.key == key) {
cur.val = val;
return;
}
cur = cur.next;
}
node.next = array[Index];
array[Index] = node;
usedsize++;
if(usedsize * 1.0f / array.length > DEFAULT_LOAD_FACTOR) {
array = resize(array);
}
}
private Node[] resize(Node[] array) {
Node[] newArray = Arrays.copyOf(array, 2*array.length);
for (int i = 0; i < array.length; i++) {
Node cur = array[i];
while(cur != null) {
int Index = cur.key % newArray.length;
Node nextcur = cur.next;
cur.next = newArray[Index];
newArray[Index] = cur;
cur = nextcur;
}
}
return newArray;
}
public int get(int key) {
int Index = key % array.length;
Node cur = array[Index];
while(cur != null) {
if(cur.key == key) {
return cur.val;
}
cur = cur.next;
}
return -1;
}
}
性能分析虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入/删除/查找时间复杂度是 O(1) 哈希表与 Java 集合的关系1. HashMap 和 HashSet 即 java 中利用哈希表实现的 Map 和 Set2. java 中使用的是哈希桶方式解决冲突的3. java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)4. java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方法。所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,必须覆写 hashCode 和 equals 方法,而且要做到 equals 相等的对象,hashCode 一定是一致的。
英勇黄铜
7.递归函数的复杂度分析
递归函数本质上是对树形结构或者图形结构的深度优先遍历。如果我们能够很清楚我们所设计的算法只访问了树形结构或者图形结构中的每个结点有限次,那么递归函数的时间复杂度就等于树形结构或者图形结构的结点个数。另外,分析递归函数的时间复杂读还有一个工具,称为「主定理」。由于「主定理」的理论性很强,只需要知道结论,会应用即可。在面试的时候,绝大多数情况下,不会考察主定理的内容和证明。我严谨的论述请参考《算法导论》第 4.5 节和第 4.6 节的内容。6.1主定理如果一个规模为 n 的问题,可以拆解为 a 个子问题,每个子问题的规模是 b/n,其中 a≥1,b>1。用 f(n) 表示分解和合并的开销与 n 的关系,那么原始问题的时间复杂度 T(n) 可以表示成如下递归式:T(n)=a⋅T( b/n )+f(n)将此递推式得到关于 n 的通项公式的时候,需要利用以下结论:如果 f(n)<n log ba,那么 T(n)=O(n log ba);如果 f(n)=n log ba,那么 logT(n)=O(n log ba ⋅logn);如果 f(n)>n log ba,那么 T(n)=O(f(n))。证明过程请参考《算法导论》第 4.6 节(证明主定理)。结论可以这样记忆:比较 f(n) 与 n log ba 的大小,如果相等,T(n) 等于 logba后面乘以 logn。如果不相等,谁大就以谁作为时间复杂度,这一点与「时间复杂度考虑最差情况」的规则一致。
英勇黄铜
6.简答题
1. 随机模拟方法是什么?是通过仿真随机系统的运行来获得系统的状态变化与输出结果的大量数据,进而对所得数据进行统计分析,估算系统行为的某些特征,并将估计的误差控制在一定范围内。2. 随机模拟大致分为哪六个步骤?描述系统、设置变量、运行规则、模拟系统、抽样与统计、解释结果。3. 设置变量的一步中做了什么?为系统的输入、状态、输出设置变量为随机事件设定随机数4. 运行规则的一步中做了什么?系统状态如何变化随机数如何产生5. 古典概率模型有哪些特征?试验的所有可能结果只有有限个,即样本空间中的基本事件只有有限个。各试验结果出现的可能性相等,即所有基本事件的发生是等可能的。试验所有可能出现的结果两两互不相容。6. 请写出条件概率的公式。P(B∣A)=P(AB)/P(A),P(A)>07. 常见的两种离散型概率分布有哪两种?二项分布和泊松分布。8. 常见的连续型随机变量的概率分布有哪三种?均匀分布指数分布正态分布9. 均匀分布的loc和scale是多少?loc 为 a,scale 为 b - a。10. 请写出二项分布的数学期望和 Python 代码。X~B(n,p) E(X)=np Python代码: scipy.stats.binom.mean11. 请写出全概率公式。P(X)=y∑P(Y=y)P(X∣Y=y)(X, Y 为随机变量)12. 请写出全期望公式。EX(X)=EY(EX∣Y(X∣Y))=y∑p(Y=y)×EX∣Y=y(X∣Y=y)13. 拿到一个贝叶斯公式的概率问题,首先要确定什么?首先要确定“假设是什么”及“证据是什么”,然后再代入公式。14. 概率分析是什么?概率分析主要是用概率手段对算法进行平均情况分析。15. 确定性算法和随机算法分别要考虑什么?如果是确定性算法,只需要考虑所有输入分布即可,而如果是随机算法,处理考虑输入分布以外,算法的随机步骤也需要考虑。16. 随机算法通常使用什么作为辅助输入来指导自己的行为?均匀随机数。
英勇黄铜
二叉树
树形结构什么是树形结构树是一种非线性的数据结构,它是由 n( >= 0)个有限节点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一个根朝上,叶朝下的倒挂树。特点:有一个特殊的结点,称为根结点,根节点没有前驱结点除根结点外,其他结点被分为m个互不相交的集合树是递归定义的注意:树形结构中,子树是不能有交集的,否则就不是树形结构重要概念结点的度:一个结点含有子树的个数称为该结点的度树的度:一棵树中,所有结点度中的最大值为该树的度叶子结点:度为 0 的结点称为叶结点父结点:如果一个节点有子结点,则这个结点被称为父结点子结点:一个结点含有的子树的根结点称为该结点的子结点根结点:一课树中,没有父结点的结点结点的层次:从根结点开始定义起,根为第一层,根的子结点为第二层,依次类推树的高度或深度:树中结点的最大层次非终端结点或分支结点:度不为 0 的结点 兄弟结点:具有相同父结点的结点互称为兄弟结点堂兄弟结点:双亲在同一层的结点互为堂兄弟结点的祖先:从根到该结点所经分支上的所有结点子孙:以某结点为根的子树中任一结点都称为该结点的子孙森林:由 m( m >= 0 )棵互不相交的树组成的集合称为森林树的表示形式树的结构相对线性表比较复杂,要储存表示起来比较麻烦,实际中树有许多表示方法:双亲表示法,孩子表示法,孩子双亲表示法,孩子兄弟表示法等等。下面写一种最常用的孩子兄弟表示法。class Node {
int value; // 树中存储的数据
Node firstChild; // 第一个孩子引用
Node nextBrother; // 下一个兄弟引用
} 树的应用一般我们文件系统管理就是用树形结构来处理的二叉树什么是二叉树一颗二叉树是结点的有限集合,这个集合:可能为空可能是由一个根结点加上两颗左子树和右子树的二叉树组成的注意:二叉树不存在度大于 2 的结点二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树二叉树有几种情况复合而成:二叉树的性质若根结点的层数为 1 ,则一颗非空的二叉树的第i层上最多有 2^( i-1 ) 个结点若规定只有根结点的二叉树的深度为 1,则深度为K的二叉树的最大结点数是 2^k - 1对任何一个二叉树,如果其叶结点个数为 n0 ,度为2的非叶结点的个数为 n2 ,则有 n0 = n2 + 1具有n个结点的完全二叉树的深度 K 为 log2(n+1) 向上取整对于具有n个结点的完全二叉树,如果按照从上至下从左至右的顺序对所有结点从 0 开始编号,则对于序号为 i 的结点有: 若 i>0,双亲序号:(i-1)/2 ;i=0 ,i 为根结点编号,无双亲结点若 2i+1<n ,左孩子序号:2i+1 ,否则无左孩子若 2i+2<n ,右孩子序号:2i+2 ,否则无右孩子二叉树的存储 二叉树的存储结构分为:顺序存储和链式存储二叉树的链式存储是通过一个一个节点引用起来的,通常表示的方式有二叉和三叉表示方式// 孩子表示法
class Node {
int val; // 数据域
Node left; // 左孩子的引用,常常代表左孩子为根的整棵左子树
Node right; // 右孩子的引用,常常代表右孩子为根的整棵右子树
}
// 孩子双亲表示法
class Node {
int val; // 数据域
Node left; // 左孩子的引用,常常代表左孩子为根的整棵左子树
Node right; // 右孩子的引用,常常代表右孩子为根的整棵右子树
Node parent; // 当前节点的根节点
} 二叉树的基本操作下面是二叉树一些基本功能的代码:public class BinaryTree {
static class TreeNode {
char val;
TreeNode left;
TreeNode right;
public TreeNode(char val) {
this.val = val;
}
}
public TreeNode createTree() {
TreeNode A = new TreeNode('A');
TreeNode B = new TreeNode('B');
TreeNode C = new TreeNode('C');
TreeNode D = new TreeNode('D');
TreeNode E = new TreeNode('E');
TreeNode F = new TreeNode('F');
TreeNode G = new TreeNode('G');
TreeNode H = new TreeNode('H');
A.left = B;
A.right = C;
B.left = D;
B.right = E;
C.left = F;
C.right = G;
E.right = H;
return A;
}
//前序遍历
public void preOrder(TreeNode root) {
if(root == null) {
return;
}
System.out.print(root.val+" ");
preOrder(root.left);
preOrder(root.right);
}
//中序遍历
public void inOrder(TreeNode root) {
if(root == null) {
return;
}
preOrder(root.left);
System.out.print(root.val+" ");
preOrder(root.right);
}
//后序遍历
public void lastOrder(TreeNode root) {
if(root == null) {
return;
}
preOrder(root.right);
preOrder(root.left);
System.out.print(root.val+" ");
}
//求tree的节点个数
int Treesize;
public int size(TreeNode root) {
if(root == null) {
return 0;
}
Treesize++;
size(root.left);
size(root.right);
return Treesize;
}
public int size1(TreeNode root) {
if(root == null) {
return 0;
}
return size1(root.left) + size1(root.right) + 1;
}
// 获取叶子节点的个数
int getLeafNodeCount(TreeNode root) {
if(root == null) {
return 0;
}
if(root.left == null && root.right == null) {
return 1;
}
return getLeafNodeCount(root.left)+getLeafNodeCount(root.right);
}
// 获取第K层节点的个数
int getKLevelNodeCount(TreeNode root, int k) {
if(root == null) {
return 0;
}
if(k==1) {
return 1;
}
return getKLevelNodeCount(root.left, k-1) + getKLevelNodeCount(root.right, k-1);
}
// 获取二叉树的高度
int getHeight(TreeNode root){
if(root == null) {
return 0;
}
int a = getHeight(root.left);
int b = getHeight(root.right);
return a > b ? a+1 : b+1;
}
// 检测值为value的元素是否存在
TreeNode find(TreeNode root, char val) {
if(root == null) {
return null;
}
if(root.val == val) {
return root;
}
TreeNode leftNode = find(root.left, val);
if(leftNode != null) {
return leftNode;
}
TreeNode rightNode = find(root.right, val);
if(rightNode != null) {
return rightNode;
}
return null;
}二叉树的遍历前中后序遍历在遍历二叉树时,如果没有进行某种约定,每个人都按照自己的方式遍历,得出的结果就比较混乱,如果按照某种规则进行约定,则每个人对于同一棵树的遍历结果肯定是相同的。如果N代表根节点,L 代表根节点的左子树,R 代表根节点的右子树,则根据遍历根节点的先后次序有以下遍历方式:NLR:前序遍历 (Preorder Traversal 亦称先序遍历) —— 访问根结点 ---> 根的左子树 ---> 根的右子树。LNR:中序遍历 (Inorder Traversal)—— 根的左子树 ---> 根节点 ---> 根的右子树。LRN:后序遍历 (Postorder Traversal) —— 根的左子树 ---> 根的右子树 ---> 根节点。 具体代码://前序遍历
public void preOrder(TreeNode root) {
if(root == null) {
return;
}
System.out.print(root.val+" ");
preOrder(root.left);
preOrder(root.right);
}
//中序遍历
public void inOrder(TreeNode root) {
if(root == null) {
return;
}
preOrder(root.left);
System.out.print(root.val+" ");
preOrder(root.right);
}
//后序遍历
public void lastOrder(TreeNode root) {
if(root == null) {
return;
}
preOrder(root.right);
preOrder(root.left);
System.out.print(root.val+" ");
}层序遍历除了先序遍历、中序遍历、后序遍历外,还可以对二叉树进行层序遍历。设二叉树的根节点所在层数为 1 ,层序遍历就是从所在二叉树的根节点出发,首先访问第一层的树根节点,然后从左到右访问第 2 层上的节点,接着是第三层的节点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历具体代码:class Solution {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> array = new ArrayList<>();
if(root == null) {
return array;
}
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while(!queue.isEmpty()) {
List<Integer> tmp = new ArrayList<>();
int size = queue.size();
while(size != 0) {
TreeNode x = queue.poll();
tmp.add(x.val);
size--;
if(x.left != null) {
queue.offer(x.left);
}
if(x.right != null) {
queue.offer(x.right);
}
}
array.add(tmp);
}
return array;
}
}
英勇黄铜
3.递归函数的基本结构
3.1递归函数的基本结构写出递归终止条件(易忽略)首先写出递归终止条件,也就是先写出不能再拆分的子问题。有些朋友在初学的时候,会由于忘记编写递归终止条件而导致递归调用栈满。将原问题拆分成为规模更小的子问题(重点)这一步是编写递归函数的关键,如何拆分子问题是我们需要关注的重点。将子问题的结果进行合并(难点)有一些逻辑恰好是发生在递归函数调用返回以后,我们在这个时机还可以编写一些逻辑,使得我们求解原问题变得更加简单。我们在「递归函数的应用」章节会向大家介绍如何利用这个时机完成一些关键的逻辑。3.2写好递归函数建议学好「递归」和编写代码一样,需要经历一个先模仿、再学习、然后思考和练习的过程。在这里我们给出写好「递归」方法的建议:写好「递归」方法不是一朝一夕的事情,和学习所有的算法问题一样,我们需要通过大量的练习来理解写对「递归」方法的技巧和细节;「递归」方法与「分治思想」「减治思想」「深度优先遍历」「栈」有着千丝万缕的联系,在编写「递归」方法的同时,要有意识地思考它们之间的关系;如果一时半会不能理解「递归」函数的语义,我们建议在逻辑的关键部分编写打印输出语句,以理解递归函数的调用过程。3.3总结学习好递归的重要方法是:先模仿,再练习。其实绝大部分知识的学习都需要反复经历「模仿」和「练习」的步骤。然后才会有自己的思考和总结下一节,我们将介绍递归函数在算法与数据结构中的应用,对递归函数的应用的深刻理解,有助于我们理解具体问题、设计具体算法。
英勇黄铜
3.概率论知识—下
3.5全概率公式全概率公式如下,其中 X, Y 为随机变量。基于【全概率公式】,我们可以通过概率 DP 求 X 的概率。下面我们通过一个例题看一下具体是怎么做的。例题以下三件事哪件的概率更大投掷 6 枚骰子至少一个是 6投掷 12 枚骰子至少两个是 6投掷 18 枚骰子至少三个是 6思路参考: 全概率公式 + 概率DP假设我们要求投掷 n 枚骰子,至少 m 枚是 6 的概率。如果 m 是 0,则概率是 1,因为不论最终多少枚是 6,都符合“至少 0 枚是 6”的情况。如果 m 大于 n,则概率是 0,因为不可能投掷出 6 的个数比骰子的个数还多。当 0 < m < n 时,我们考虑其中一枚骰子的两种情况:(1) 该骰子是 6,概率 1/6,则剩下的 n - 1 枚骰子需要至少 m - 1 枚是 6(2) 该骰子不是 6,概率 5/6,则剩下的 n - 1 枚骰子需要至少 m 枚是 6综合以上分析,再结合全概率公式我们可以用概率DP的方式解决,算法如下状态定义
dp[i][j] := 投掷 i 枚骰子,至少 j 个是 6 的概率
初始化
dp[i][0] = 1.0
dp[i][j] = 0 (j > i)
答案
dp[6][1]
dp[12][2]
dp[18][3]
状态转移
dp[i][j] = 1/6 * dp[i - 1][j - 1] + 5/6 * dp[i - 1][j]下面编程计算 dp[n][m]#include <vector>
#include <iostream>
using namespace std;
int main()
{
int n, m;
cin >> n >> m;
vector<vector<double>> dp(n + 1, vector<double>(m + 1, 0.0));
for(int i = 0; i <= n; ++i)
dp[i][0] = 1.0;
for(int i = 1; i <= n; ++i)
for(int j = 1; j <= min(i, m); ++j)
dp[i][j] = 1 / 6.0 * dp[i - 1][j - 1] + 5 / 6.0 * dp[i - 1][j];
cout << dp[n][m] << endl;
}计算结果:dp[6][1] = 0.665
dp[12][2] = 0.619
dp[18][3] = 0.597。因此第一个事件的概率更大。3.6全期望公式全期望公式如下,其中 X, Y 为随机变量,公式的形式与全概率公式差不多。公式告诉我们在求 X 的均值(期望)时可转化为求 X 在 Y = yi ( i = 1, 2, ...)条件下的条件期望 E_{X|Y = yi}(X|Y = yi) 再加权求和。基于【全期望公式】,我们可以通过期望 DP 求 X 的期望。下面我们通过一个例题看一下具体是怎么做的。例题8 个男人和 7 个女人订了电影院的一排座,一排刚好有 15 个座位。问:平均有多少个相邻座位是异性?思路参考 1: 全期望公式 + 期望 DP定义 f(x, y) 表示 x 个男,y 个女排一排,相邻座位是异性的平均个数。当 x, y 任意一个为 0 的时候,值为零:f(x, 0) = 0, f(0, y) = 0当 x > 0, y > 0 时,考虑最左边的座位,它有两种可能性,一种是男的,概率 p1 = x/(x+y), 另一种是女的,概率 p2 = y/(x+y)(1) 如果最左边是男的:则期望的相邻座位是异性的个数分为两部分,第一部分是 f(x-1 , y),第二部分是最左边男人的相邻座位,当相邻位置为女人时,可以贡献 1,概率为 y/(x-1+y)。(2) 如果最左边是女的:则期望的相邻座位是异性的个数分为两部分,第一部分是 f(x , y-1),第二部分是最左边女人的相邻座位,当相邻位置为男人时,可以贡献 1,概率为 x/(y-1+x)。综合以上若干条,再结合全期望公式可以写出 f(x, y) 的转移方程。f(x, y) = p1 * (f(x - 1, y) + y / (x - 1 + y))
+ p2 * (f(x, y - 1) + x / (y - 1 + x))下面通过期望 DP 的方式解决以上问题,算法如下状态定义dp[i][j] := i 个男人,j 个女人的情况,相邻座位是异性的个数的期望
初始化
dp[i][0] = 0
dp[0][j] = 0
答案
dp[8][7]
状态转移
dp[i][j] = i / (i + j) * (dp[i - 1][j] + (j / (i - 1 + j)))
+ j / (i + j) * (dp[i][j - 1] + (i / (j - 1 + i)))下面编程计算 dp[8][7],代码如下#include <iostream>
#include <vector>
using namespace std;
int main()
{
int n = 8, m = 7;
vector<vector<double>> dp(n + 1, vector<double>(m + 1, 0));
for(int i = 1; i <= n; ++i)
for(int j = 1; j <= m; ++j)
{
dp[i][j] = (double)i / (i + j) * (dp[i - 1][j] + ((double)j / (i - 1 + j)))
+ (double)j / (i + j) * (dp[i][j - 1] + ((double)i / (j - 1 + i)));
}
cout << dp[n][m] << endl;
}计算结果为 dp[8][7] = 7.46667思路参考2: 单独考虑每个相邻座位一共有 14 个相邻座位。对于每个相邻座位,我们要考虑的都是【从 8 个男人和 7 个女人中选两个人放这个相邻座位的左右两边,这两个人是否为异性】这件事。假设这个概率为 p,则一个相邻座位可以形成的异性的个数的期望就是 E = p,14 个相邻座位中为异性的期望个数就是 p * 14。考虑一个相邻作为的左边这个座位:左边座位是男人的概率为 8/15,在此情况下右边座位是女人的概率为 7/14。左边座位是女人的概率为 7/15,在此情况下右边座位是男人的概率为 8/14。因此一个相邻座位能够形成异性的概率为14 个相邻座位中,异性的个数的期望为 8/15 * 14 = 7.46667。随机模拟验证结果import numpy as np
import operator
from multiprocessing import Pool
from functools import reduce
persons = [1] * 8 + [0] * 7
def run():
l = np.random.permutation(persons)
ans = 0
for i in range(len(l) - 1):
if l[i] != l[i + 1]:
ans += 1
return ans
def test(T):
np.random.seed()
y = (run() for _ in range(T))
n = reduce(operator.add, y)
return n / T
T = int(1e6)
args = [T] * 8
pool = Pool(8)
ts = pool.map(test, args)
for t in ts:
print("{:.6f} pairs of adjacent are the opposite sex".format(t))模拟结果7.467305 pairs of adjacent are the opposite sex
7.466081 pairs of adjacent are the opposite sex
7.470136 pairs of adjacent are the opposite sex
7.465220 pairs of adjacent are the opposite sex
7.465871 pairs of adjacent are the opposite sex
7.467655 pairs of adjacent are the opposite sex
7.464980 pairs of adjacent are the opposite sex
7.467260 pairs of adjacent are the opposite sex3.7贝叶斯公式我们有一个假设 H,可能成立可能不成立,也就是 H 是有一定概率成立的,记为 P(H)。例如我们有三个盒子,名称分别为 a,b,c。现在随机取出一个盒子,我们可以猜测该盒子的名称,如果我们猜 a,这就构成了一个假设:随机抽到的盒子的名称是 a。因为盒子共有 3 个,且盒子是随机取的,我们可以认为 P(H) = 1/3,这个概率称为先验概率。先验概率是根据经验来的,前面的例子的 1/3 就是一个经验值。现在我们得到了一条证据 e,这个证据一般是某个实验的观察值。还是以三个盒子举例,a, b, c 三个盒子内均有一些球,其中 a 中有 10 个黑球,b 中有 10 个白球,c 中有 5 个黑球 5 个白球。我们可以针对盒子名称做实验:抽出一个球看颜色。假设看到的颜色是白色,我们就得到了一条证据:随机抽出一个球的颜色是白色。根据实际情况,证据 e 可能加强假设 H 成立的可能性,也可能削弱假设 H 成立的可能性。我们想知道的是当我们拿到证据 e 之后,假设 H 成立的概率是多少,这个概率记为 P(H|e),称为后验概率,后验概率是根据数据(也就是证据)来的。下面这个根据先验概率 P(H) 和证据 e,计算后验概率 P(H|e) 的公式称为贝叶斯公式。拿到一个贝叶斯公式的概率问题,首先要确定“假设是什么”及“证据是什么”,然后再代入公式。这样就不容易出错了。例题: 不公平的硬币有 10 枚硬币,其中有 1 枚是不公平的,随机抛出正面的概率为 0.8,另外 9 枚都是公平的,也就是随机抛出正面的概率是 0.5。现在从 10 枚硬币中随机取出 1 枚,随机地抛了 3 次,结果为"正正反"。求该硬币为不公平硬币的概率。思路参考根据题目描述,假设 H 是 "该硬币为不公平硬币",证据 e 是 "随机抛出该硬币 3 次的结果为正正反"现在我们要求的是当我们有了证据 e 的条件下,假设 H 成立的概率是多少,也就是 P(H|e)贝叶斯公式如下其中 P(H) 是先验概率,根据业务理解,也就是本题给的条件,是 1/10。相应地 P( H ) 就是 9/10。P(e|H) 是当假设 H 成立时,在一次观察中获得证据 e 的概率。也就是如果该硬币为不公平硬币,随机抛3次得到正正反的概率。有了以上概率值,我们就可以根据贝叶斯公式计算后验概率 P(H|e) 了模拟验证结果import numpy as np
from multiprocessing import Pool
def flip(p):
# 如果投掷结果为正,返回 True
return np.random.rand() < p
def test(ps):
np.random.seed()
N = 0 # 记录实验结果为"正正反"的次数
n = 0 # 记录实验结果为"正正反"时,不公平硬币的次数
T = int(1e7)
for _ in range(T):
# 随机取到的硬币,如果 idx 为 9 则取到不公平硬币
idx = np.random.randint(0, 10)
p = ps[idx]
# 实验: 投 3 次,如果结果为"正正反"则记录
if not flip(p):
continue
if not flip(p):
continue
if flip(p):
continue
N += 1
if idx == 9:
n += 1
return n / N
p1 = 0.5 # 公平硬币出正面的概率为 0.5
p2 = 0.8 # 不公平硬币出正面的概率为 0.8
ps = [p1] * 9 + [p2] # 10 枚硬币,其中 1 枚为不公平硬币
args = [ps] * 8
pool = Pool(8)
ts = pool.map(test, args)
for i in range(len(args)):
t = ts[i]
print("P(不公平硬币|投掷结果为\"正正反\"): {:.6f}".format(t))模拟结果P(不公平硬币|投掷结果为"正正反"): 0.102335
P(不公平硬币|投掷结果为"正正反"): 0.102071
P(不公平硬币|投掷结果为"正正反"): 0.102196
P(不公平硬币|投掷结果为"正正反"): 0.102102
P(不公平硬币|投掷结果为"正正反"): 0.102111
P(不公平硬币|投掷结果为"正正反"): 0.102182
P(不公平硬币|投掷结果为"正正反"): 0.102154
P(不公平硬币|投掷结果为"正正反"): 0.102363
英勇黄铜
第一章:算法与数据结构要点速学
1. 时间复杂度 (大 O)首先,我们来谈谈常用操作的时间复杂度,按数据结构/算法划分。然后,我们将讨论给定输入大小的合理复杂性。数组(动态数组/列表)规定 n = arr.length,在结尾添加或删除元素: O(1) 相关讨论从任意索引中添加或删除元素: O(n)访问或修改任意索引处的元素: O(1)检查元素是否存在: O(n)双指针: O(n⋅k), k 是每次迭代所做的工作,包括滑动窗口构建前缀和: O(n)求给定前缀和的子数组的和:O(1)字符串 (不可变)规定 n = s.length,添加或删除字符: O(n)任意索引处的访问元素: O(1)两个字符串之间的连接: O(n+m), m 是另一个字符串的长度创建子字符串: O(m), m 是子字符串的长度双指针: O(n⋅k), k 是每次迭代所做的工作,包括滑动窗口通过连接数组、stringbuilder 等构建字符串:O(n)链表给定n作为链表中的节点数,给定指针位置的后面添加或删除元素: O(1)如果是双向链表,给定指针位置添加或删除元素: O(1)在没有指针的任意位置添加或删除元素: O(n)无指针任意位置的访问元素: O(n)检查元素是否存在: O(n)在位置 i 和j 之间反转: O(j−i)使用快慢指针或哈希映射完成一次遍历: O(n)哈希表/字典给定 n = dic.length,添加或删除键值对: O(1)检查 key 是否存在: O(1)检查值是否存在: O(n)访问或修改与 key 相关的值: O(1)遍历所有键值: O(n)注意: O(1) 操作相对于 n 是常数.实际上,哈希算法可能代价很高。例如,如果你的键是字符串,那么它将花费 O(m),其中 m 是字符串的长度。 这些操作只需要相对于哈希映射大小的常数时间。集合给定 n = set.length,添加或删除元素: O(1)检测元素是否存在: O(1)上面的说明也适用于这里。栈栈操作依赖于它们的实现。栈只需要支持弹出和推入。如果使用动态数组实现:给定 n = stack.length,推入元素: O(1)弹出元素: O(1)查看 (查看栈顶元素): O(1)访问或修改任意索引处的元素: O(1)检测元素是否存在: O(n)队列队列操作依赖于它们的实现。队列只需要支持出队列和入队列。如果使用双链表实现:给定 n = queue.length,入队的元素: O(1)出队的元素: O(1)查看 (查看队列前面的元素): O(1)访问或修改任意索引处的元素: O(n)检查元素是否存在: O(n)注意:大多数编程语言实现队列的方式比简单的双链表更复杂。根据实现的不同,通过索引访问元素可能比 O(n) 快,但有一个重要的常量除数。二叉树问题 (DFS/BFS)给定 n 作为树的节点数,大多数算法的时间复杂度为 O(n⋅k),k 是在每个节点上做的操作数, 通常是 O(1)。这只是一个普遍规律,并非总是如此。我们在这里假设 BFS 是用高效队列实现的。二叉搜索树给定 n 作为树中的节点数,添加或删除元素:最坏的情况下 O(n),平均情况 O(log n)检查元素是否存在:最坏的情况下 O(n),平均情况 O(logn)平均情况是当树很平衡时 —— 每个深度都接近满。最坏的情况是树只是一条直线。堆/优先队列给定 n = heap.length 并讨论最小堆,添加一个元素: O(logn)删除最小的元素: O(logn)找到最小的元素: O(1)查看元素是否存在: O(n)二分查找在最坏的情况下,二分查找的时间复杂度为 O(logn),其中 n 是初始搜索空间的大小。其他排序: O(n⋅logn), 其中 n 是要排序的数据的大小图上的 DFS 和 BFS:O(n⋅k+e),其中 n 是节点数,e 是边数,前提是每个节点处理花费都是 O(1),不需要重复遍历。DFS 和 BFS 空间复杂度:通常为 O(n),但如果它在图形中,则可能为 O(n+e) 来存储图形动态规划时间复杂度:O(n⋅k),其中 n 是状态数,k 是每个状态所需要的操作数动态规划空间复杂度:O(n),其中n是状态数2. 输入大小与时间复杂度问题的约束可以被视为提示,因为它们指示解决方案时间复杂度的上限。能够在给定输入大小的情况下计算出解决方案的预期时间复杂度是一项宝贵的技能。在所有 LeetCode 问题和大多数在线评估 (OA) 中,你都会得到问题的约束条件。不幸的是,你通常不会在面试中被明确告知问题的限制条件,但这对于在 LeetCode 上练习和完成 OAs 仍然有好处。尽管如此,在面试中,询问预期的输入大小通常也没什么坏处。n <= 10预期的时间复杂度可能具有基数大于“2”的阶乘或指数 - 例如 O(n2 ⋅n!) 或 O(4 n)。您应该考虑回溯或任何蛮力式递归算法。 n <= 10 非常小,通常任何正确找到答案的算法都足够快。10 < n <= 20预期的时间复杂度可能涉及 O(2 n )。任何更高的基数或阶乘都会太慢(320= ~35 亿,20! 更大)。 2 n通常意味着给定一个元素集合,您正在考虑所有子集/子序列 - 对于每个元素,有两种选择:接受或不接受。同样,这个界限非常小,所以大多数正确的算法可能足够快。考虑回溯和递归。20 < n <= 100在这一点上,指数将太慢。上限可能是 O(n3 )。LeetCode 上标记为「简单」的问题通常有这个界限,这可能是骗人的。可能存在以 O(n) 运行的解决方案,但小边界允许蛮力解决方案通过(找到线性时间解决方案可能不被视为「容易」)。考虑涉及嵌套循环的强力解决方案。如果你想出了一个蛮力解决方案,请尝试分析算法以找出哪些步骤“慢”,并尝试使用哈希映射或堆等工具改进这些步骤。100 < n <= 1,000在此范围内,只要常数因子不太大,时间复杂度 O(n 2 ) 就足够了。与前面的范围类似,你应该考虑嵌套循环。这个范围和前一个的区别在于,O(n 2) 通常是这个范围内的预期/最优时间复杂度,可能无法提高。1,000 < n < 100,000n<=10 5是你在 LeetCode 上看到的最常见的约束。在此范围内,可接受最慢的 通常 时间复杂度为 O(n⋅logn),尽管线性时间方法 O(n) 更为常见。在此范围内,问问自己对输入进行排序或使用堆是否有帮助。如果不是,则瞄准 O(n) 算法。在 O(n 2 ) 中运行的嵌套循环是不可接受的 - 你可能需要使用在本课程中学到的技术来实现 O(1) 或 O(logn):哈希映射类似于滑动窗口的两指针实现单调堆栈二分搜索堆以上任何一项的组合如果你有一个 O(n) 算法,常数因子可以相当大(大约 40)。字符串问题的一个常见主题涉及在每次迭代时遍历字母表中的字符,导致时间复杂度为 O(26n)。100,000 < n < 1,000,000n<=10 6 是一个罕见的约束,可能需要 O(n) 的时间复杂度。在这个范围内,O(n⋅logn) 通常是安全的,只要它有一个小的常数因子。你很可能需要以某种方式合并哈希映射。1,000,000 < n对于巨大的输入,通常在 10 9或更多的范围内,最常见的可接受时间复杂度将是对数 O(logn) 或常量 O(1)。在这些问题中,您必须要么在每次迭代时显着减少搜索空间(通常是二分搜索),要么使用巧妙的技巧在恒定时间内查找信息(例如使用数学或巧妙地使用哈希映射)。其他时间复杂度也是可能的,例如 O( n ),但这种情况非常罕见,通常只会出现在非常高级的问题中。3. 排序算法所有主要的编程语言都有一个内置的排序方法。假设并说排序成本为 O(n⋅logn)。通常是正确的,其中 n 是要排序的元素数。为了完整起见,这里有一个图表,列出了许多常见的排序算法及其完整性。编程语言实现的算法各不相同;例如,Python 使用 Timsort,但在 C++ 中,特定算法不是强制性的并且会有所不同。来自 Wikipedia的稳定排序的定义:"Stable sorting algorithms maintain the relative order of records with equal keys (i.e. values). That is, a sorting algorithm is stable if whenever there are two records R and S with the same key and with R appearing before S in the original list, R will appear before S in the sorted list." 4.通用 DS/A 流程图这是一个流程图,可以帮助您确定应该使用哪种数据结构或算法。请注意,此流程图非常笼统,因为不可能涵盖每个场景。请注意,此流程图仅涵盖 LICC 中教授的方法,因此排除了像 Dijkstra 等更高级的算法。
英勇黄铜
第二章:算法题代码模板—上
1. 双指针: 只有一个输入, 从两端开始遍历public int fn(int[] arr) {
int left = 0;
int right = arr.length - 1;
int ans = 0;
while (left < right) {
// 一些根据 letf 和 right 相关的代码补充
if (CONDITION) {
left++;
} else {
right--;
}
}
return ans;
}2. 双指针: 有两个输入, 两个都需要遍历完public int fn(int[] arr1, int[] arr2) {
int i = 0, j = 0, ans = 0;
while (i < arr1.length && j < arr2.length) {
// 根据题意补充代码
if (CONDITION) {
i++;
} else {
j++;
}
}
while (i < arr1.length) {
// 根据题意补充代码
i++;
}
while (j < arr2.length) {
// 根据题意补充代码
j++;
}
return ans;
}3. 滑动窗口public int fn(int[] arr) {
int left = 0, ans = 0, curr = 0;
for (int right = 0; right < arr.length; right++) {
// 根据题意补充代码来将 arr[right] 添加到 curr
while (WINDOW_CONDITION_BROKEN) {
// 从 curr 中删除 arr[left]
left++;
}
// 更新 ans
}
return ans;
}4. 构建前缀和public int[] fn(int[] arr) {
int[] prefix = new int[arr.length];
prefix[0] = arr[0];
for (int i = 1; i < arr.length; i++) {
prefix[i] = prefix[i - 1] + arr[i];
}
return prefix;
}5. 高效的字符串构建public String fn(char[] arr) {
StringBuilder sb = new StringBuilder();
for (char c: arr) {
sb.append(c);
}
return sb.toString();
}在 javascript中,基准测试表明连接使用 += 比使用 .join()更快。6. 链表: 快慢指针public int fn(ListNode head) {
ListNode slow = head;
ListNode fast = head;
int ans = 0;
while (fast != null && fast.next != null) {
// 根据题意补充代码
slow = slow.next;
fast = fast.next.next;
}
return ans;
}7. 反转链表public ListNode fn(ListNode head) {
ListNode curr = head;
ListNode prev = null;
while (curr != null) {
ListNode nextNode = curr.next;
curr.next = prev;
prev = curr;
curr = nextNode;
}
return prev;
}8. 找到符合确切条件的子数组数public int fn(int[] arr, int k) {
Map<Integer, Integer> counts = new HashMap<>();
counts.put(0, 1);
int ans = 0, curr = 0;
for (int num: arr) {
// 根据题意补充代码来改变 curr
ans += counts.getOrDefault(curr - k, 0);
counts.put(curr, counts.getOrDefault(curr, 0) + 1);
}
return ans;
}9. 单调递增栈可以应用相同的逻辑来维护单调队列。public int fn(int[] arr) {
Stack<Integer> stack = new Stack<>();
int ans = 0;
for (int num: arr) {
// 对于单调递减的情况,只需将 > 翻转到 <
while (!stack.empty() && stack.peek() > num) {
// 根据题意补充代码
stack.pop();
}
stack.push(num);
}
return ans;
}10. 二叉树: DFS (递归)public int dfs(TreeNode root) {
if (root == null) {
return 0;
}
int ans = 0;
// 根据题意补充代码
dfs(root.left);
dfs(root.right);
return ans;
}11. 二叉树: DFS (迭代)public int dfs(TreeNode root) {
Stack<TreeNode> stack = new Stack<>();
stack.push(root);
int ans = 0;
while (!stack.empty()) {
TreeNode node = stack.pop();
// 根据题意补充代码
if (node.left != null) {
stack.push(node.left);
}
if (node.right != null) {
stack.push(node.right);
}
}
return ans;
}12. 二叉树: BFSpublic int fn(TreeNode root) {
Queue<TreeNode> queue = new LinkedList<>();
queue.add(root);
int ans = 0;
while (!queue.isEmpty()) {
int currentLength = queue.size();
// 做一些当前层的操作
for (int i = 0; i < currentLength; i++) {
TreeNode node = queue.remove();
// 根据题意补充代码
if (node.left != null) {
queue.add(node.left);
}
if (node.right != null) {
queue.add(node.right);
}
}
}
return ans;
}13. 图: DFS (递归)以下图模板假设节点编号从 0 到 n - 1 ,并且图是以邻接表的形式给出的。根据问题的不同,您可能需要在使用模板之前将输入转换为等效的邻接表。Set<Integer> seen = new HashSet<>();
public int fn(int[][] graph) {
seen.add(START_NODE);
return dfs(START_NODE, graph);
}
public int dfs(int node, int[][] graph) {
int ans = 0;
// 根据题意补充代码
for (int neighbor: graph[node]) {
if (!seen.contains(neighbor)) {
seen.add(neighbor);
ans += dfs(neighbor, graph);
}
}
return ans;
}
英勇黄铜
链表与LinkedList
链表链表的概念与结构链表是一种物理存储结构上非连续的存储结构,数据元素的逻辑顺序是通过链表中的引用链接次序实现的。大家可以把它理解为现实中的绿皮火车这里要注意:链式在逻辑上是连续的,但是在物理上不一定是连续的现实中的结点一般都是从堆上申请出来的从堆上申请的空间,是按照一定的策略来分配的,所以两次申请的空间可能连续,也可能不连续链表中的结构是多样的,根据情况来使用,一般使用一下结构:单向或双向带头和不带头循环和非循环这些结构中,我们需要重点掌握两种:无头单向非循环链表:结构简单,一般不会单独来存数据,实际上更多的是作为其他数据结构的子结构,如哈希桶,图的邻接表等。无头双向链表:在我们java的集合框架中LinkedList低层实现的就是无头双向循环链表。单向链表的模拟实现下面是单向链表需要实现的一些基本功能:// 1、无头单向非循环链表实现
public class SingleLinkedList {
//头插法
public void addFirst(int data){
}
//尾插法
public void addLast(int data){
}
//任意位置插入,第一个数据节点为0号下标
public void addIndex(int index,int data){
}
//查找是否包含关键字key是否在单链表当中
public boolean contains(int key){
return false;
}
//删除第一次出现关键字为key的节点
public void remove(int key){
}
//删除所有值为key的节点
public void removeAllKey(int key){
}
//得到单链表的长度
public int size(){
return -1;
}
public void clear() {
}
public void display() {}
}具体实现代码MyLinkedListpackage myLinkedList;
import sun.awt.image.ImageWatched;
import java.util.List;
/**
* Created with IntelliJ IDEA.
* Description:
* User: sun杰
* Date: 2023-09-14
* Time: 10:38
*/
public class MyLinkedList implements IList{
static class LinkNode {
public int value;
public LinkNode next;
public LinkNode(int data) {
this.value = data;
}
}
LinkNode head;
public void createNode() {
LinkNode linkNode1 = new LinkNode(12);
LinkNode linkNode2 = new LinkNode(23);
LinkNode linkNode3 = new LinkNode(34);
LinkNode linkNode4 = new LinkNode(56);
LinkNode linkNode5 = new LinkNode(78);
linkNode1.next = linkNode2;
linkNode2.next = linkNode3;
linkNode3.next = linkNode4;
linkNode4.next = linkNode5;
this.head = linkNode1;
}
@Override
public void addFirst(int data) {
//实例化一个节点
LinkNode firstNode = new LinkNode(data);
if(this.head == null) {
this.head = firstNode;
return;
}
//将原第一个对象的地址给新节点的next,也就是将head给新next
firstNode.next = this.head;
//将新的对象的地址给head头
this.head = firstNode;
}
@Override
public void addLast(int data) {
//实例化一个节点
LinkNode lastNode = new LinkNode(data);
//找到最后一个节点
LinkNode cur = this.head;
while(cur.next!= null) {
cur = cur.next;
}
cur.next = lastNode;
//将最后一个节点的next记录插入节点的地址
}
@Override
public void addIndex(int index, int data) throws indexillgality {
if(index < 0 || index > size()) {
throw new indexillgality("index不合法");
}
LinkNode linkNode = new LinkNode(data);
if(this.head == null) {
addFirst(data);
return;
}
if(size() == index ) {
addLast(data);
return;
}
LinkNode cur = this.head;
int count = 0;
while(count != index - 1) {
cur = cur.next;
count++;
}
linkNode.next = cur.next;
cur.next = linkNode;
}
@Override
public boolean contains(int key) {
LinkNode cur = this.head;
while(cur != null) {
if(cur.value == key) {
return true;
}
cur = cur.next;
}
return false;
}
@Override
public void remove(int key) {
if(this.head.value == key) {
this.head = this.head.next;
return ;
}
//找前驱
LinkNode cur = findprev(key);
//判断返回值
if(cur != null) {
//删除
LinkNode del = cur.next;
cur.next = del.next;
//cur.next = cur.next.next;
}
}
//找删除的前驱
private LinkNode findprev(int key) {
LinkNode cur = head;
while(cur.next != null) {
if(cur.next.value == key) {
return cur;
}
cur = cur.next;
}
return null;
}
@Override
public void removeAllKey(int key) {
if(size() == 0) {
return ;
}
if(head.value == key) {
head = head.next;
}
LinkNode cur = head.next;
LinkNode prev = head;
while(cur != null) {
if(cur.value == key) {
prev.next = cur.next;
}
prev = cur;
cur = cur.next;
}
}
@Override
public int size() {
LinkNode cur = head;
int count = 0;
while(cur != null) {
count++;
cur = cur.next;
}
return count;
}
@Override
public void display() {
LinkNode x = head;
while(x != null) {
System.out.print(x.value + " ");
x = x.next;
}
System.out.println();
}
@Override
public void clear() {
LinkNode cur = head;
while(cur != null) {
LinkNode curNext = cur.next;
cur.next = null;
cur = curNext;
}
head = null;
}
} indexillgality这时一个自定义异常package myLinkedList;
/**
* Created with IntelliJ IDEA.
* Description:
* User: sun杰
* Date: 2023-09-14
* Time: 12:55
*/
public class indexillgality extends RuntimeException {
public indexillgality(String message) {
super(message);
}
}LinkedListLinkedList 的模拟实现这相当于无头双向链表的实现,下面是它需要的基本功能:// 2、无头双向链表实现
public class MyLinkedList {
//头插法
public void addFirst(int data){ }
//尾插法
public void addLast(int data){}
//任意位置插入,第一个数据节点为0号下标
public void addIndex(int index,int data){}
//查找是否包含关键字key是否在单链表当中
public boolean contains(int key){}
//删除第一次出现关键字为key的节点
public void remove(int key){}
//删除所有值为key的节点
public void removeAllKey(int key){}
//得到单链表的长度
public int size(){}
public void display(){}
public void clear(){}
}MyLinkedListpackage myLinkedList;
import java.util.List;
/**
* Created with IntelliJ IDEA.
* Description:
* User: sun杰
* Date: 2023-09-20
* Time: 18:49
*/
public class MyLinkedList implements IList {
//单个节点
public static class ListNode {
private int val;
private ListNode prev;
private ListNode next;
public ListNode(int val) {
this.val = val;
}
}
ListNode head;
ListNode last;
@Override
public void addFirst(int data) {
ListNode cur = new ListNode(data);
if(head == null) {
cur.next = head;
head = cur;
last = cur;
}else {
cur.next = head;
head.prev = cur;
head = cur;
}
}
@Override
public void addLast(int data) {
ListNode cur = new ListNode(data);
if(head == null) {
head = cur;
last = cur;
} else {
last.next = cur;
cur.prev = last;
last = cur;
}
}
@Override
public void addIndex(int index, int data) throws Indexexception {
ListNode cur = new ListNode(data);
if(index < 0 || index > size()) {
throw new Indexexception("下标越界");
}
//数组为空时
if(head == null) {
head = cur;
last = cur;
return ;
}
//数组只有一个节点的时候
if(head.next == null || index == 0) {
head.prev = cur;
cur.next = head;
head = cur;
return;
}
if(index == size()) {
last.next = cur;
cur.prev = last;
return ;
}
//找到对应下标的节点
ListNode x = head;
while(index != 0) {
x = x.next;
index--;
}
//头插法
cur.next = x;
cur.prev = x.prev;
x.prev.next = cur;
x.prev = cur;
}
@Override
public boolean contains(int key) {
ListNode cur = head;
while(cur != null) {
if(cur.val == key) {
return true;
}
cur = cur.next;
}
return false;
}
@Override
public void remove(int key) {
if(head == null) {
return;
}
ListNode cur = head;
while(cur != null) {
if(cur.val == key) {
if(cur.next == null && cur.prev == null) {
head = null;
last = null;
return;
}else if(cur.next == null){
cur.prev.next = null;
last = cur.prev;
return;
}else if(cur.prev == null) {
head = cur.next;
cur.next.prev = null;
return ;
}else {
ListNode frone = cur.prev;
ListNode curnext = cur.next;
frone.next = curnext;
curnext.prev = frone;
return ;
}
}
cur = cur.next;
}
}
@Override
public void removeAllKey(int key) {
if(head == null) {
return;
}
ListNode cur = head;
while(cur != null) {
if(cur.val == key) {
if(cur.next == null && cur.prev == null) {
head = null;
last = null;
} else if(cur.next == null){
cur.prev.next = null;
last = cur.prev;
}else if(cur.prev == null) {
head = cur.next;
cur.next.prev = null;
}else {
ListNode frone = cur.prev;
ListNode curnext = cur.next;
frone.next = curnext;
curnext.prev = frone;
}
}
cur = cur.next;
}
}
@Override
public int size() {
int count = 0;
ListNode cur = head;
while(cur != null) {
count++;
cur = cur.next;
}
return count;
}
@Override
public void display() {
ListNode cur = head;
while(cur != null) {
System.out.print(cur.val + " ");
cur = cur.next;
}
System.out.println();
}
@Override
public void clear() {
if(head == null) {
return;
}
ListNode cur = head.next;
while(cur != null) {
head = null;
head = cur;
cur = cur.next;
}
head = null;
}
}Indexexception这也是一个自定义异常package myLinkedList;
/**
* Created with IntelliJ IDEA.
* Description:
* User: sun杰
* Date: 2023-09-21
* Time: 9:47
*/
public class Indexexception extends RuntimeException{
public Indexexception(String message) {
super(message);
}
}java 中的 LinkedListLinkedList 的底层是双向链表结构,由于链表没有将元素存储在连续的空间中,元素存储在单独的节点中,然后通过引用将节点连接起来。因为这样,在任意位置插入和删除元素时,是不需要搬移元素,效率比较高。 在集合框架中, LinkedList 也实现了 List 接口:注意:LinkedList 实现了 List 接口LinkedList 的底层使用的是双向链表Linked 没有实现 RandomAccess 接口,因此 LinkedList 不支持随机访问LinkedList 的随机位置插入和删除元素时效率较高,复杂度为 O(1)LinkedList 比较适合任意位置插入的场景LinkedList 的使用LinkedList 的构造:一般来说有两种方法:无参构造:List<Integer> list = new LinkedList<>();使用其他集合容器中的元素构造List:public LinkedList(Collection<? extends E> c)栗子:public static void main(String[] args) {
// 构造一个空的LinkedList
List<Integer> list1 = new LinkedList<>();
List<String> list2 = new java.util.ArrayList<>();
list2.add("JavaSE");
list2.add("JavaWeb");
list2.add("JavaEE");
// 使用ArrayList构造LinkedList
List<String> list3 = new LinkedList<>(list2);
}LinkedList 的基本方法:public static void main(String[] args) {
LinkedList<Integer> list = new LinkedList<>();
list.add(1); // add(elem): 表示尾插
list.add(2);
list.add(3);
list.add(4);
list.add(5);
list.add(6);
list.add(7);
System.out.println(list.size());
System.out.println(list);
// 在起始位置插入0
list.add(0, 0); // add(index, elem): 在index位置插入元素elem
System.out.println(list);
list.remove(); // remove(): 删除第一个元素,内部调用的是removeFirst()
list.removeFirst(); // removeFirst(): 删除第一个元素
list.removeLast(); // removeLast(): 删除最后元素
list.remove(1); // remove(index): 删除index位置的元素
System.out.println(list);
// contains(elem): 检测elem元素是否存在,如果存在返回true,否则返回false
if(!list.contains(1)){
list.add(0, 1);
}
list.add(1);
System.out.println(list);
System.out.println(list.indexOf(1)); // indexOf(elem): 从前往后找到第一个elem的位置
System.out.println(list.lastIndexOf(1)); // lastIndexOf(elem): 从后往前找第一个1的位置
int elem = list.get(0); // get(index): 获取指定位置元素
list.set(0, 100); // set(index, elem): 将index位置的元素设置为elem
System.out.println(list);
// subList(from, to): 用list中[from, to)之间的元素构造一个新的LinkedList返回
List<Integer> copy = list.subList(0, 3);
System.out.println(list);
System.out.println(copy);
list.clear(); // 将list中元素清空
System.out.println(list.size());
}LinkedList 的多种遍历foreach:public static void main(String[] args) {
List<Integer> list = new LinkedList<>();
list.add(1);
list.add(3);
list.add(5);
list.add(2);
list.remove(1);
for (int x:list) {
System.out.print(x + " ");
}
}
使用迭代器遍历:
ListIterator<Integer> it = list.listIterator();
while(it.hasNext()) {
System.out.println(it.next() + " ");
}
}ArrayList 与 LinkedList的区别
英勇黄铜
5.附录:概率论在计算机中的运用
5.1算法分析算法的概率分析算法分析中常见的方法有概率分析、摊销分析、竞争分析等等。其中概率分析主要是用概率手段对算法进行平均情况分析。如果是确定性算法,只需要考虑所有输入分布即可,而如果是随机算法,处理考虑输入分布以外,算法的随机步骤也需要考虑。下面以一个例子来看一下概率分析的过程。问题: 梦幻情人的代价我去婚介所寻求对象,婚介所一次性给我 n 个人的资料,那么我需要一种算法从中选出最好的人。每个人的资料只能访问一次,但是访问顺序可以自己定,初始顺序就是婚介所给我这 n 个人时排列好的顺序。确定性算法: 见好就变这里我直接给出一种算法: 见好就变算法,具体如下用一个变量 best 记录当前的最佳对象。依次枚举 n 位候选人,每枚举一位需要支付介绍费用 Ci ,如果当前枚举到的候选人 i 比当前最佳对象 best 优秀,那么就把 i 作为当前最佳对象,首先要支付 cs分手费,然后支付介绍成功费 Ci,并且要支付与新最佳对象的相处费 Cd。问,这个见好就变的恋爱算法需要支付多大成本。分析step1: 伪代码和各行成本首先我们将算法写成伪代码,并在每一行后面标注该行对应的成本best = 0
for i = 1..n do // 介绍费 Cj
if 候选人 i 比当前最佳对象 best 优秀 then // 分手费 Cs
best = i // 介绍成功费 Cj
约会新最佳对象 i // 相处费 Cdstep2: 最好与最坏情况首先通过伪代码和每一行的成本,我们可以直接写出大 O 分析结果 O(nCi+mCd+mCj+(m−1)Cs)为了方便,记 C=Cd+Cj+Cs于是问题变为了分析 mC。其中 m 是 if 判定成功的次数。最坏情况是 m = n,也就是严格按照从坏到好的顺序介绍,此时结果为 O(nCi+nC),由生活经验,C>>Ci,结果可以写为 O(nC)最好情况是 m = 1,也就是一上来就介绍了最好的,后面的顺序不重要。此时结果为 (O(nCi+1C) 这里能否写为 O(nCi)就要看 C>>nCi是否满足了。step3: 输入分布随机时的平均情况平均情况是需要用到概率分析的地方。想当然的认为结果是 O(nCi+2nC)是错的。下面进行概率分析。记 rank(1) 是第一个候选人的排名,取值 1 ~ n。<rank(1), rank(2), ..., rank(n)> 是数列 <1, 2, ..., n> 的一个排列。现在有一个针对算法输入的重要假设: 候选人出现的顺序随机。记随机变量 Xi表示第 i 个人是否被我看中,若看中则为 1 否则为 0。于是我的总恋爱次数为随机变量和X=X1+X2+...+Xn。我们概率分析的目标是求 E[X]由于候选人出现顺序随机,因此 i 被我看中(也就是 i 比前面的 i - 1 个人都强)的概率是 1/i,,于是 E[Xi]= 1/i所以因此平均情况的结果为O(Cln(n))step4: 对输入分布的先验知识注意前面针对输入的假设不一定成立,也就是候选人出现的顺序不一定随机,例如婚介所就是从坏到好给我介绍。那么平均情况的结果不会是前面推导的 O(Cln(n)),而是最坏情况的结果。这也是前面为什么说概率分析需要考虑输入分布的原因。而婚介所因为要多挣钱,所以它基本上会按照最坏情况的顺序该我,这就是我们对输入分布的先验知识。那么对于这种情况,我们该怎么办呢,大致有以下三种方案。搞情况候选人介绍顺序到底是什么分布,但这基本不现实,因为对方一般不会提前告知数据分布。承认婚介所会按从坏到好的顺序介绍,我就做最坏打算,但这也不现实,因为我不接受。既然对方给我的顺序是按最坏情况来的(先验知识),那我自己做随机化好了。也就是在执行见好就变算法前,我自己把 n 个候选人的顺序随机排列一下,然后再依次访问各个候选人资料。其中随机排列算法如下:for i = 1..n do
swap A[i] A[Random(i, n)] // 成本 O(1)按以上算法,随机排列的成本为 O(n),总成本就变为 O(n+Cln(n)),这比最坏情况的 O(nC) 要好。5.2概率数据结构如果大家刷过一些面经的话,会发现经常见到一些大数据算法的问题,比如上来给你 400 亿个数/字符串,然后完成某种需求。大数据算法涵盖的面比较广,随机算法,近似算法,外存算法,并行算法,分布式算法等都可以算是大数据算法。其中随机算法是很重要的一种算法,而随机算法的实现需要用到一些概率数据结构,也就是应用了概率知识的数据结构。这种概率数据结构有一些经典场景,比如哈希、相似性、排名、频率、成员关系、元素种类数等等。每个场景下有几个经典数据结构,具体可以看下图,其中有红旗标志的是 Redis 里有的,可以参考一下。5.3随机算法随机算法就是在算法中引入随机因素,即通过随机数选择算法的下一步操作。随机算法是一种算法,它采用了一定程度的随机性作为其逻辑的一部分。该算法通常使用均匀随机数作为辅助输入来指导自己的行为,在力扣上有 10 道与随机算法相关的题目,主要涉及下面这些知识点,这里把知识点与题目列出来了,感兴趣的同学可以研究一下。洗牌384. 打乱数组拒绝采样478. 在圆内随机生成点470. 用 Rand7() 实现 Rand10()蓄水池抽样398. 随机数索引382. 链表随机节点加权蓄水池抽样528. 按权重随机选择黑名单映射710. 黑名单中的随机数519. 随机翻转矩阵加权随机事件二分528. 按权重随机选择497. 非重叠矩形中的随机点
英勇黄铜
Java对象的比较
priorityQueue 中如何插入对象我们知道,优先级队列在插入元素时有一个要求:需要可以比较的对象才能插入。这里我们需要知道怎样插入自定义类型对象:比如我们插入这个 student 对象:class student {
int age;
String name;
public student(int age, String name) {
this.age = age;
this.name = name;
}
}
public class Test {
public static void main(String[] args) {
PriorityQueue<student> priorityQueue = new PriorityQueue<>();
priorityQueue.offer(new student(12,"小猪佩奇"));
priorityQueue.offer(new student(12,"小猪乔治"));
}在运行后发现它会报类型不兼容的异常,这是因为在堆中插入元素,为了满足堆的性质,需要进行对象的比较,但是我们的 student 类型对象时不能直接比较的,所以会报错元素的比较基本类型的比较在 Java 中,基本类型的对象是可以直接进行比较大小的class TestCompare {
public static void main(String[] args) {
int a = 10;
int b = 20;
System.out.println(a > b);
System.out.println(a < b);
System.out.println(a == b);
char c1 = 'A';
char c2 = 'B';
System.out.println(c1 > c2);
System.out.println(c1 < c2);
System.out.println(c1 == c2);
boolean b1 = true;
boolean b2 = false;
System.out.println(b1 == b2);
System.out.println(b1 != b2);
}
}对象类型的直接比较class Card {
public int rank; // 数值
public String suit; // 花色
public Card(int rank, String suit) {
this.rank = rank;
this.suit = suit;
}
}
public class TestPriorityQueue {
public static void main(String[] args) {
Card c1 = new Card(1, "♠");
Card c2 = new Card(2, "♠");
Card c3 = c1;
//System.out.println(c1 > c2); // 编译报错
System.out.println(c1 == c2); // 编译成功 ----> 打印false,因为c1和c2指向的是不同对象
//System.out.println(c1 < c2); // 编译报错
System.out.println(c1 == c3); // 编译成功 ----> 打印true,因为c1和c3指向的是同一个对象
}
}这里我们知道,直接进行对象比较的是地址,只有相同才会返回 true,不同就会报错。但是这里为毛 == 可以比较呢?这就得提到 Object 类了,因为自定义类也会继承 Object 类,这个类中提供了 equal 方法,== 的情况下就是用的 Object 的equal 方法。但是这个方式比较的就是引用对象的地址,没有比较对象的内容,这就头疼了。// Object中equal的实现,可以看到:直接比较的是两个引用变量的地址
public boolean equals(Object obj) {
return (this == obj);
}对象正确的比较方式重写 equals 方法class student {
int age;
String name;
public student(int age, String name) {
this.age = age;
this.name = name;
}
@Override
public boolean equals(Object obj) {
if(this == obj) {
return true;
}
if(obj == null || !(obj instanceof student)) {
return false;
}
student o = (student) obj;
return this.age == ((student) obj).age && this.name.equals(o.name);
}
}如果指向一个对象,返回 true如果传入的是 null 或者不是 student,返回 false按照类的成员对象比较,只要成员对象相同就返回 true注意下调其他引用类型的比较也要调用 equals这里的缺陷就是:equals 只能按照相等来比较,不能比较大小基于 Comparble 接口类的比较Comparable 接口的源码:public interface Comparable<E> {
// 返回值:
// < 0: 表示 this 指向的对象小于 o 指向的对象
// == 0: 表示 this 指向的对象等于 o 指向的对象
// > 0: 表示 this 指向的对象大于 o 指向的对象
int compareTo(E o);
}对用户自定义类型,想要按照大小比较时,在定义类的时候,实现 Comparable 接口即可。然后在类中实现 compareTo 方法:class student implements Comparable<student>{
int age;
String name;
public student(int age, String name) {
this.age = age;
this.name = name;
}
@Override
public int compareTo(student o) {
if(o == null) {
return 1;
}
return this.age - o.age;
}
}基于比较器比较用户自定义比较器类,需要实现 Comparator 接口:public interface Comparator<T> {
// 返回值:
// < 0: 表示 o1 指向的对象小于 o2 指向的对象
// == 0: 表示 o1 指向的对象等于 o2 指向的对象
// > 0: 表示 o1 指向的对象等于 o2 指向的对象
int compare(T o1, T o2);
}这里要注意区分 Comparable 和 Comparator 接口在自定义比较器类中重写 compare 方法:import java.util.Comparator;
class Card {
public int rank; // 数值
public String suit; // 花色
public Card(int rank, String suit) {
this.rank = rank;
this.suit = suit;
}
}
class CardComparator implements Comparator<Card> {
// 根据数值比较,不管花色
// 这里我们认为 null 是最小的
@Override
public int compare(Card o1, Card o2) {
if (o1 == o2) {
return 0;
} if
(o1 == null) {
return -1;
}
if (o2 == null) {
return 1;
}
return o1.rank - o2.rank;
}
public static void main(String[] args){
Card p = new Card(1, "♠");
Card q = new Card(2, "♠");
Card o = new Card(1, "♠");
// 定义比较器对象
CardComparator cmptor = new CardComparator();
// 使用比较器对象进行比较
System.out.println(cmptor.compare(p, o)); // == 0,表示牌相等
System.out.println(cmptor.compare(p, q)); // < 0,表示 p 比较小
System.out.println(cmptor.compare(q, p)); // > 0,表示 q 比较大
}
}这里使用 Comparator 需要导入 java.util 包 集合框架中 priorityQueue 的比较方式集合框架中的 PriorityQueue 底层使用堆结构,因此其内部的元素必须要能够比大小 PriorityQueue 采用了:Comparble和 Comparator 两种方式。 1. Comparble 是默认的内部比较方式,如果用户插入自定义类型对象时,该类对象必须要实现 Comparble 接口,并覆写 compareTo 方法2. 用户也可以选择使用比较器对象,如果用户插入自定义类型对象时,必须要提供一个比较器类,让该类实现 Comparator 接口并覆写 compare 方法。 JDK中的源码:// 用户如果没有提供比较器对象,使用默认的内部比较,将comparator置为null
public PriorityQueue() {
this(DEFAULT_INITIAL_CAPACITY, null);
}
// 如果用户提供了比较器,采用用户提供的比较器进行比较
public PriorityQueue(int initialCapacity, Comparator<? super E> comparator) {
// Note: This restriction of at least one is not actually needed,
// but continues for 1.5 compatibility
if (initialCapacity < 1)
throw new IllegalArgumentException();
this.queue = new Object[initialCapacity];
this.comparator = comparator;
}
private void siftUp(int k, E x) {
if (comparator != null)
siftUpUsingComparator(k, x);
else
siftUpComparable(k, x);
}
private void siftUpComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>) x;
while (k > 0) {
int parent = (k - 1) >>> 1;
Object e = queue[parent];
if (key.compareTo((E) e) >= 0)
break;
queue[k] = e;
k = parent;
}
queue[k] = key;
}
@SuppressWarnings("unchecked")
private void siftUpUsingComparator(int k, E x) {
while (k > 0) {
int parent = (k - 1) >>> 1;
Object e = queue[parent];
if (comparator.compare(x, (E) e) >= 0)
break;
queue[k] = e;
k = parent;
}
queue[k] = x;
}画图分析:
英勇黄铜
搜索树 与 Java集合框架中的Set,Map
什么是搜索树搜索树又名二叉搜索树,它是一颗完全二叉树,且:左树不为空的话,则左子树上的所有节点的值都小于根节点的值右树不为空的话,则右子树上的所有节点的值都大于根节点的值它的左右子树也是搜索树搜索树的模拟实现查找功能实现这个功能就可以利用它的性质,比根节点的小的数在左边,比根节点大的数在右边,通过遍历的方式一直查找,要是遇到了 null ,代表就没有这个数。代码实现//查找元素
//查找复杂度:O(logN)
//最坏情况:O(N)
public boolean search(int node) {
if(root == null) {
return false;
}
TreeNode cur = root;
while(cur != null) {
if(node < cur.val) {
cur = cur.left;
}else if(node > cur.val) {
cur = cur.right;
}else {
return true;
}
}
return false;
}
画图分析新增功能新增节点,我们就分两种情况,一种没有节点,一种有节点,但大致开始用 cur 遍历找到需要插入的位置,再用一个 prev 记住 cur 的前一个节点。且相同是不需要添加的。当 cur 于 null 的时候,就用 prev 来判断它大于 key ,就将新增节点放在它的左边不然就放在右边具体代码//新增元素
public boolean push(int node) {
if(root == null) {
root = new TreeNode(node);
return true;
}
TreeNode cur = root;
TreeNode prve = null;
while(cur != null) {
if(node < cur.val) {
prve = cur;
cur = cur.left;
}else if(node > cur.val){
prve = cur;
cur = cur.right;
}else {
return false;
}
}
TreeNode treeNode = new TreeNode(node);
if(prve.val > node) {
prve.left = treeNode;
}else {
prve.right = treeNode;
}
return true;
}画图分析删除功能删除就比较复杂一点,得分几种情况,而这几种情况中又得细分:当需要删除的节点左边没有元素1 需要删除的是头节点2 需要删除的在父节点的左边3 需要删除的节点在父节点的右边当需要删删出的节点右边没有元素1 需要删除的是头节点2 需要删除的在父节点的左边3 需要删除的节点在父节点的右边当需要删除的节点两边都有元素这里是比较难处理的,我们不能直接删除这个结点,这里我们使用替换法:找到删除节点右边的最小节点,将最小节点的值赋值给需要删除的那个元素,再将最小节点删除,在删除这个最小节点的时候我们也要考虑:它的左边有没有元素,它的右边有没有元素,还是左右都没有元素具体代码//删除元素
public boolean poll(int key) {
TreeNode cur = root;
TreeNode parent = null;
while(cur != null) {
if(key < cur.val) {
parent = cur;
cur = cur.left;
}else if(key > cur.val) {
parent = cur;
cur = cur.right;
}else {
removeNode(cur, parent);
return true;
}
}
return false;
}
private void removeNode(TreeNode cur, TreeNode parent) {
//删除节点左边为空
if(cur.left == null) {
if(cur == root) {
root = root.right;
}else if(parent.left == cur) {
parent.left = cur.right;
}else {
parent.right = cur.right;
}
//删除节点右边为空
}else if(cur.right == null) {
if(root == cur) {
root = root.left;
}else if(parent.left == cur) {
parent.left = cur.left;
}else {
parent.right = cur.left;
}
//都不为空
}else {
TreeNode xparent = cur;
TreeNode x = cur.right;
while(x.left != null) {
xparent = x;
x = x.left;
}
cur.val = x.val;
if(xparent.left == x) {
xparent.left = x.right;
}else {
xparent.right = x.right;
}
}
}
}画图分析搜索树的性能这里我们可以把查找的效率看做整个搜索树的性能,因为不管是新增和删除都是需要查找的嘛。对于搜索树,我们知道,它就是一颗二叉树,所以比较的次数就是树的深度。但是所有情况都是这样嘛,这里会因为一个数据集合,如果他们数据插入的次序不一样,则会得到不一样的数据结构,比如下图:这里我们就知道了,在最坏情况下搜索树会退化成一个链表所以:最好情况时间复杂度:O(logN)最坏情况时间复杂度:O(N)搜索树与Java类集合的关系Java 中的 TreeMap 和 TreeSet 就是利用搜索树实现的,但是呢它们底层使用的是搜索树的进化再进化版红黑树(搜索树 - LAV 树 - 红黑树 ),LAV 树就是对搜索树的改进,遇到链表情况下它就是翻转这个链表,让他变成一个高度平衡的搜索树,而红黑树是在这个基础加上颜色一节红黑树性质的验证进一步的提高了它的效率。Set和Map概念Map 和 Set 是一种专门用来进行搜索的容器或者数据结构,它的搜索效率和实现它们的子类有关。一般比较简单的搜索方式有:直接遍历,复杂度为 O(N),效率比较底下二分查找,复杂度为O(logN),但它麻烦的是必须是有序的而这些搜索方式只适合静态的搜索,但对于区间这种插入和删除的操作他们就不行了,这种就属于动态搜索方式。Map 和 Set 就是用来针对这种的动态查找的集合容器模型这集合中,一般把搜索的数据称为关键字 Key 和关键字的对应值 Value,这两个称为键值对,一般有两种模型:纯 Key 模型,即只需要一个数据,比如:查找手机里面有没有这个联系人Key-Value 模型,比如:查找这个篇文章里面这个词出现了几次 (词,出现的次数)Map 就是 Key-Value 模型,Set 是纯 Key 模型Map 接口下继承了 HashMap 和 TreeMap 两个类,而 Set 接口下继承了 TreeSet 和 HashSet 两个类TreeMap 和 TreeSet 底层是红黑树,而 HashMap 和 HashSet 底层是哈希表MapMap是一个接口,它没有和其他几个类一样继承Collection,它存储的是 <K,V> 键值对,且K是唯一的,V可以重复Map.Entry<K,V>Map.Entry<K,V> 在 Map 中是一个内存类, 它是用来 Map 内部实现存放 <K,V> 键值对映射关系的,它还提供了 Key ,value 的设置和 Key 的比较方式:这里要注意它没有提供设置 Key 的方法Map 的常用方法注意事项:1 Map 是一个接口,它不可以实例化对象,要实例化只能实例化它的子类 TreeMap 或者 HashMap2 Map 中存放键值对里的 Key 是唯一的,value 是可以重复的3 在 TreeMap 中插入键值对时,Key 不能为空,否则就会抛 NullPointerExecption 异常,value 可以为空。但是 HashMap 的 Key 和 value 都可以为空4 Map 中的 Key 是可以分离出来的,存储到 Set 中来进行访问(因为 Key 不能重合)5 Map 中的 value 也可以分离出来,存放到 Collection 的任何一个子集合中,但是 value 可能会重合 6 Map 中的键值对 Key 不能直接修改,value 可以修改,如果要修改 Key,只能先将 Key 先删除,再来插入TreeMap 和 HashMap 的比较使用栗子:HashMap:public static void main(String[] args) {
Map<String, Integer> map = new HashMap<>();
map.put("猪八戒", 500);
map.put("孙悟空", 550);
map.put("唐僧", 40);
map.put("沙和尚", 100);
map.put("白龙马", 300);
System.out.println(map.get("猪八戒"));
System.out.println(map.remove("八戒"));
System.out.println(map.get("猪八戒"));
Set<Map.Entry<String, Integer>> set = map.entrySet();
for(Map.Entry<String, Integer> entry : set) {
System.out.println(entry);
}
System.out.println(map.containsKey("猪八戒"));
}TreeMap:public static void TestMap() {
Map<String, String> m = new TreeMap<>();
// put(key, value):插入key-value的键值对
// 如果key不存在,会将key-value的键值对插入到map中,返回null
m.put("林冲", "豹子头");
m.put("鲁智深", "花和尚");
m.put("武松", "行者");
m.put("宋江", "及时雨");
String str = m.put("李逵", "黑旋风");
System.out.println(m.size());
System.out.println(m);
// put(key,value): 注意key不能为空,但是value可以为空
// key如果为空,会抛出空指针异常
// m.put(null, "花名");
str = m.put("无名", null);
System.out.println(m.size());
// put(key, value):
// 如果key存在,会使用value替换原来key所对应的value,返回旧value
str = m.put("李逵", "铁牛");
// get(key): 返回key所对应的value
// 如果key存在,返回key所对应的value
// 如果key不存在,返回null
System.out.println(m.get("鲁智深"));
System.out.println(m.get("史进"));
//GetOrDefault(): 如果key存在,返回与key所对应的value,如果key不存在,返回一个默认值
System.out.println(m.getOrDefault("李逵", "铁牛"));
System.out.println(m.getOrDefault("史进", "九纹龙"));
System.out.println(m.size());
//containKey(key):检测key是否包含在Map中,时间复杂度:O(logN)
// 按照红黑树的性质来进行查找
// 找到返回true,否则返回false
System.out.println(m.containsKey("林冲"));
System.out.println(m.containsKey("史进"));
// containValue(value): 检测value是否包含在Map中,时间复杂度: O(N)
// 找到返回true,否则返回false
System.out.println(m.containsValue("豹子头"));
System.out.println(m.containsValue("九纹龙"));
// 打印所有的key
// keySet是将map中的key防止在Set中返回的
for (String s : m.keySet()) {
System.out.print(s + " ");
}
System.out.println();
// 打印所有的value
// values()是将map中的value放在collect的一个集合中返回的
for (String s : m.values()) {
System.out.print(s + " ");
}
System.out.println();
// 打印所有的键值对
// entrySet(): 将Map中的键值对放在Set中返回了
for (Map.Entry<String, String> entry : m.entrySet()) {
System.out.println(entry.getKey() + "--->" + entry.getValue());
}
System.out.println();
}SetSet 和 Map 的不同点就在于 Set 是继承于 Collection 接口类的,Set 只存储了 Key。Set 的常用方法注意事项:1 Set 是继承 Collection 的一个接口2 Set 只存储 Key,且要求 Key 是唯一的3 TreeSet 的底层是使用了 Map 来实现的,其使用 Key 与 Object 的一个默认对象作为键值对插入到 Map 中4 Set 的最大特点就是去重5 实现 Set 接口的常用类有 TreeSet 和 HashSet,还有一个 LinkedSet,它是在 HashSet 上维护了一个双向链表来记入插入元素的次序6 Set 中的 Key 不能修改,如果要修改的话,呀先将原来的删除,再重新插入7 TreeSeet 中不能插入 null 的 Key,但 HashSet 可以TreeSet 和 HashMap 的比较栗子:public static void TestSet2(){
Set<String> s = new TreeSet<>();
// add(key): 如果key不存在,则插入,返回ture
// 如果key存在,返回false
boolean isIn = s.add("apple");
s.add("orange");
s.add("peach");
s.add("banana");
System.out.println(s.size());
System.out.println(s);
isIn = s.add("apple");
// add(key): key如果是空,抛出空指针异常
//s.add(null);
// contains(key): 如果key存在,返回true,否则返回false
System.out.println(s.contains("apple"));
System.out.println(s.contains("watermelen"));
// remove(key): key存在,删除成功返回true
// key不存在,删除失败返回false
// key为空,抛出空指针异常
s.remove("apple");
System.out.println(s);
s.remove("watermelen");
System.out.println(s);
Iterator<String> it = s.iterator();
while(it.hasNext()){
System.out.print(it.next() + " ");
}
System.out.println();
}
英勇黄铜
4.概率论面试题(连载)—中
4.8仓促的决斗在某一天的决斗场,有两个人会在 5 点到 6 点之间随机的某一分钟到来,然后 5 分钟后离开。如果某一时刻这两个人同时在场,会发生决斗。问:这两个人发生决斗的概率是多少?思路参考记两个人分别为 a 和 b,如图所示,横轴 x 是 a 到达的时间,纵轴 y 是 b 到达的时间,到达的时间范围均为 0 ~ 1。(x, y) 会随机地在图中的正方形区域中产生,如果两个人的时间间隔小于 1/12 则会发生决斗,也就是 abs(x - y) < 1/12,这对应于图中的阴影部分。(x, y) 落在阴影的概率是阴影面积与正方形面积的比值。蒙特卡洛模拟import numpy as np
from multiprocessing import Pool
def test(T):
array_arrival_times = 60 * np.random.random(size=(T, 2))
array_diff = np.abs(np.diff(array_arrival_times))
return np.mean(array_diff <= 5)
T = int(1e7)
args = [T] * 8
pool = Pool(8)
ts = pool.map(test, args)
print("发生决斗的概率: {:.6f}".format(sum(ts) / len(ts)))模拟结果发生决斗的概率: 0.1598054.9帮助赌徒戒赌布朗沉迷于轮盘赌,并且强迫症地总是在 13 这个数字上押注 1 美元。轮盘赌有 38 个等概率的数字,如果玩家押注的数字出现,则拿到 35 倍投注金额,加上原始投注金额,共 36 倍,如果押注的数字没出现,则损失原始投注金额。好朋友为了帮助布朗戒赌,设计了一个赌注,与布朗等额投注 20 美元(好朋友和布朗均需要出 20 美元),赌以下事情:布朗在第 36 次游戏完成之后会亏钱(如果果真亏钱了,则朋友得到双方的筹码共 40 美元,否则布朗得到这 40 美元)。问:这个戒赌方案是否有效(如果布朗能挣钱,说明无效,如果布朗会亏钱则说明有效)。思路参考布朗每次游戏的获胜概率为 p = 1/38,期望从赌场拿回的钱为 36 * p。考虑 36 次游戏。布朗需要付出 36 美元与赌场的押注金额,以及 20 美元与朋友的押注金额。下面我们看布朗期望能够拿回多少钱。布朗拿回的钱中,一部分来自赌场,另一部分来自朋友。来自赌场的部分,布朗每次期望拿回 36 * p,36 次期望拿回 36 * 36 * p。下面考虑来自朋友的部分:每一次获胜,布朗会获得 36 美元。也就是说布朗只要赢一次就已经不亏了,也就意味着他只要不是 36 场全输了,就可以从朋友那里拿回 40 美元。布朗在 36 次游戏中至少赢一次的概率为:1−(1−p) 36因此布朗期望从好朋友那里拿回的金额为 40 * (1 - (1 - p) ^ 36)把两部分加在一起,布朗在 36 次游戏中拿回金额的期望(平均可拿到金额)为E=40(1−(1−p) 36 )+36×36×p这个数只要大于 36 + 20 = 56 美元,那么朋友设计的这个戒赌方案反而会助长布朗去赌博,戒赌方案就无效。下面我们编程计算这个数def E(p):
q = (1 - p) ** 36
return 40 * (1 - q) + 36 * 36 * p
income = E(1 / 38)
print("Except Income: {:.6f}".format(income))计算结果Except Income: 58.790419由于期望能拿回 58.790419 美元,比 56 美元多,布朗能够挣钱,朋友设计的戒赌方案无效。蒙特卡洛模拟from multiprocessing import Pool
import numpy as np
p = 1/38
def game1():
# 1 次游戏,返回游戏结束拿回的钱
if np.random.rand() < p:
return 36
return 0
def game36():
# 36 次游戏,返回36次游戏结束拿回的钱
w = 0
for _ in range(36):
w += game1()
if w >= 36:
return w + 40
return w
def test(T):
np.random.seed()
total = 0
for _ in range(T):
total += game36()
return total / T
T = int(1e6)
args = [T] * 8
pool = Pool(8)
incomes = pool.map(test, args)
for x in incomes:
print("Average Income: {:.6f}".format(x))模拟结果Average Income: 58.894648
Average Income: 58.697580
Average Income: 58.741948
Average Income: 58.745660
Average Income: 58.757380
Average Income: 58.819856
Average Income: 58.800196
Average Income: 58.7672884.10 较短的一节木棍一根棍子,随机选个分割点,将棍子分为两根。a. 较小的那一根的长度平均是多少?b. 较小的那一根与较长的那一根的比例平均是多少?思路参考假设木棍长度为 1,分隔点 X 是 (0, 1) 上的均匀分布,也就是 X ~ U(0, 1),概率密度函数 fX(x)=1, 0 < x < 1。记较短的那一根的长度为 Y,y 与 x 的关系如下下面我们来看 (a) 和 (b) 两问:(a)要求 Y 的期望,我们还需要两个知识点,一个是连续型随机变量的期望,一个是连续型随机变量函数的概率密度函数。知识点复习(1) 连续型随机变量的期望(2) 随机变量函数的概率密度函数X 的概率密度函数为 f X(x),Y = g(X),Y 的概率密度函数是多少?解决这个问题可借助下面通用流程:根据 X 的范围(记作 Rx)求出 Y 的范围(记作 Ry);对 Ry 中的每一个 y,求出分布函数 FY(y)。【一般用积分的方法】FY(y)=P(Y≤y)=P(g(X)≤y)=P(X∈Ry)=∫ RyfX(x)dx其中 x∈Ry 与 g(X)≤y 是相同的随机事件。Ry={x:g(x)≤y}对分布函数 FY(y) 求导得到密度函数 f Y(y)。而如果 g(X) 是严格单调函数,x = h(y) 为相应的反函数,且可导,则 Y = g(X) 的密度函数可以直接给出计算有了以上两个知识点,我们就可以开始计算了,下面我们推导 Y 的概率密度函数。g 是一个分段函数,在 (0, 1/2) 上,Y = X,单调递增,在 (1/2, 1) 上,Y = 1 - X,单调递减。因此我们可以对这两个范围分别来看。(1) 对于 0 < x < 1/2在 0 < x < 1/2 范围内,y = g(x) = x,因此 0 < y < 1/2,其反函数 x = h(y) = y,导数 h'(y) = 1。y 的密度函数如下fY(y)=fX(h(y))∣h′(y)∣=1y 的期望如下(2) 对于 1/2 < x < 1在 1/2 < x < 1 范围内,y = g(x) = 1 - x,因此 0 < y < 1/2,其反函数 x = h(y) = 1 - y,导数 h'(y) = -1。y 的密度函数如下fY(y)=fX(h(y))∣h′(y)∣=1y 的期望如下将两部分加到一起即可得到所求的期望值,答案为 1/4。(b)要求较小的那一根与较长的那一根的比例的期望,也就是求 y / (1 - y) 的期望。由 (a),我们已经知道 Y 的范围是 (0, 1/2),并且 f Y(y) 根据 X 的取值来源于两部分,第一部分是 1 < X < 1/2,第二部分是 1/2 < X < 1。将 X 取值在这两个范围时的 f Y(y) 相加,可以得到:f Y (y)=2y∈(0,1/2)这里 f Y(y)=2,但是 y 的取值是 (0, 1/2),这是一个均匀分布。基于 Y,我们定义随机变量 Z = Y / (1 - Y)g(y) 是严格单调的,其反函数为 h(z) = z / (z + 1),其导数为 ℎ′(z)=(z+1) −2 , z 的范围为 (0, 1),因此 z 的密度函数如下fZ(z)=fY(h(z))∣h′(z)∣=2×(z+1)−2Z 的期望如下蒙特卡洛模拟import numpy as np
def pyfunc(x):
"""
0 < x < 1
"""
if x > 0.5:
x = 1 - x
return x
def pyfunc2(x):
"""
0 < x < 0.5
"""
return x / (1 - x)
func = np.frompyfunc(pyfunc, 1, 1)
func2 = np.frompyfunc(pyfunc2, 1, 1)
def test(T):
X = np.random.uniform(0, 1, T)
X = func(X)
Y = func2(X)
return (np.mean(X), np.mean(Y))
T = int(1e7)
m1, m2 = test(T)
print("较短的木棍的期望长度: {:.6f}".format(m1))
print("较短的木棍与较长的木根长度比值的期望: {:.6f}".format(m2))模拟结果较短的木棍的期望长度: 0.249920
较短的木棍与较长的木根长度比值的期望: 0.3861874.11完美手牌从一副洗好的牌(52张)中抽 13 张,拿到完美手牌(拿到的 13 张是同花色)的概率是多少。思路参考52 张牌一共有 52! 中排列顺序。完美手牌需要抽取的 13 张是同一花色,这 13 张同一花色的牌有 13! 种排列顺序,未被抽到的 39 张牌有 39! 中排列顺序。因此对于指定花色,所有抽到的牌都是这一指定花色的概率为一共有四种花色,因此拿到完美手牌的概率为编程计算from math import factorial as fac
p = 4.0 * fac(13) * fac(39) / fac(52)
print("{:.6e}".format(p))计算结果6.299078e-124.12双骰子赌博双骰子赌博是美国的流行游戏,规则如下:每次同时投掷两个骰子并且只看点数和。第一次投掷的点数和记为 x。若 x 是 7 或 11,玩家获胜,游戏结束;若 x 是 2、3 或 12,则输掉出局,其余情况则需要继续投掷。从第二次投掷开始,如果投掷出第一次的点数和 x,则获胜;如果投掷出 7,则输掉出局。重复投掷直至分出胜负。问:获胜的概率是多少?思路参考首先考虑第一次投掷第一次投掷一共有直接输,直接赢,需要继续投掷三种可能性。(1) 直接输的情况是两枚骰子的和为 2、3、12,两个骰子各自的点数情况有 (1, 1), (2, 1), (1, 2), (6, 6) 这 4 中可能性。而两个骰子各自点数共有 6 * 6 = 36 种可能性,因此第一次投掷直接输的概率为 1/9。(2) 直接赢的情况是两枚骰子的和为 7, 11,两个骰子各自的点数情况有 (1, 6), (2, 5), (3, 4), (4, 3), (5, 2), (6, 1), (5, 6), (6, 5) 共 8 种可能性,概率 2/9。(3) 剩下 2/3 概率的情况,需要继续投掷,我们记此前的第 1 次投掷的点数和为 x。考虑第二次以及后续可能的投掷从第 2 次开始,以后每次投掷的输、赢、继续投掷的条件都一样。因此我们可以只考虑第 2 次投掷,如果投掷结果是需要继续投掷,递归处理即可。记某次投掷直接赢的概率为 p(x),与第一次的投掷结果 x 有关,直接输的概率为 q,与第一次的投掷结果 x 无关,则需要继续投掷的概率为 (1 - p(x) - q),下面我们看输和赢的具体条件:(1) 输的情况是投掷出 7,两个骰子各自的点数情况共有 (1, 6), (2, 5), (3, 4), (4, 3), (5, 2), (6, 1) 共 6 种情况,概率 q = 1 / 6。(2) 赢的情况是投掷出 x。两个骰子各自的点数情况需要看 x。下面我们枚举 x 可能取值(4、5、6、8、9、10):x = 4: (1, 3), (2, 2), (3, 1),概率为 1/12;x = 5: (1, 4), (2, 3), (3, 2), (4, 1),概率为 1/9;x = 6: (1, 5), (2, 4), (3, 3), (2, 4), (5, 1),概率为 5/36;x = 8: (2, 6), (3, 5), (4, 4), (5, 3), (6, 2),概率为 5/36;x = 9: (3, 6), (4, 5), (5, 4), (6, 3),概率为 1/9;x = 10: (4, 6), (5, 5), (6, 4),概率为 1/12。总结一下,直接赢的概率 p(x) 与 x 的所有可能取值的对应关系如下p(4) = 1/12
p(5) = 1/9
p(6) = 1/36
p(8) = 1/36
p(9) = 1/9
p(10) = 1/12(3) 剩下的情况是需要继续投掷的,概率为 (1 - p(x) - q)综合以上 3 条,从第二次投掷开始,最终能赢有两种情况,一种是第二次投掷直接赢,另一种是需要继续投掷,并在后续投掷中赢了。记 P(x) 为从第二次投掷开始,最终会赢的概率。将第一次投掷直接赢和进入第二次投掷并最终赢这两种情况合起来,就是最终能赢的两种情况,概率记为 prob下面编程计算 prob 这个数mapping = {
4: 1/12,
5: 1/9,
6: 5/36,
8: 5/36,
9: 1/9,
10: 1/12,
}
q = 1/6
prob = 2/9
for x, p in mapping.items():
P = p / (p + q)
prob += p * P
print("Probability of win: {:.6f}".format(prob))计算结果Probability of win: 0.492929蒙特卡洛模拟from multiprocessing import Pool
import numpy as np
def throw2(x0):
# 第二次以后的投掷
x1 = np.random.randint(1, 7)
x2 = np.random.randint(1, 7)
x = x1 + x2
if x == 7:
return 0
elif x == x0:
return 1
else:
return throw2(x0)
def throw1():
# 第一次投掷
x1 = np.random.randint(1, 7)
x2 = np.random.randint(1, 7)
x = x1 + x2
if x == 7 or x == 11:
return 1
elif x == 2 or x == 3 or x == 12:
return 0
else:
return throw2(x)
def test(T):
np.random.seed()
n = 0
for _ in range(T):
n += throw1()
return n / T
T = int(1e7)
args = [T] * 8
pool = Pool(8)
probs = pool.map(test, args)
for prob in probs:
print("Probability of win: {:.6f}".format(prob))模拟结果Probability of win: 0.492935
Probability of win: 0.492876
Probability of win: 0.492793
Probability of win: 0.492726
Probability of win: 0.493062
Probability of win: 0.493019
Probability of win: 0.493142
Probability of win: 0.4929614.13 收集优惠券每个盒子中有一张优惠券,优惠券共有 5 种,编号为 1 到 5。必须收集所有5张优惠券才能获得奖品。问:收集到一套完整的优惠券平均需要打开多少盒子?思路参考单独考虑每个优惠券。记 T1 为一个随机变量,表示拿到第一个编号所需盒子数。P(T1 = 1) = 1,E(T1) = 1记 T2 为一个随机变量,表示拿到第二个编号所需要的额外盒子数。根据全期望公式这里 X 表示新开盒子编号是否见过,共有两种情况:前面开盒子时见过,概率为 1/5,此时平均需要的额外盒子数(即期望)为 1 + E(T2);【可参考$4-4 方法二、有向图总结】前面开盒子时没见过,概率为 4/5,此时平均需要的额外盒子数为 1。记 T3 为一个随机变量,表示从第二个编号开始到拿到第三个新编号所需的额外盒子数。根据全期望公式这里的 X 是新开的盒子的数字是否是见过的那 2 个。共有两种情况是前面见过的那2个,概率 2/5,此时额外的盒子数的期望是 1 + E(T3)不是前面见过的那2个,概率 3/5,此时额外的盒子数就是 1记 T4 为一个随机变量,表示从第三个编号开始到拿到第四个新编号所需的额外盒子数。根据全期望公式这里 X 表示新开盒子编号是否已出现过,分为两种情况:前面开盒子时见过,概率为 3/5,此时平均需要额外盒子数(期望)为 1 + E(T4);前面开盒子时没见过,概率为 2/5,此时平均需要额外盒子数(期望)为 1。记 T5 为一个随机变量,表示从第四个编号开始到拿到第五个新编号所需的额外盒子数。根据全期望公式这里 X 表示新开盒子编号是否已出现过,分为两种情况:前面开盒子时见过,概率为 4/5,此时平均需要额外盒子数(期望)为 1 + E(T5);前面开盒子时没见过,概率为 1/5,此时平均需要额外盒子数(期望)为 1。收集到一套完整的优惠券需要打开的盒子个数的期望为 E1 + E2 + E3 + E4 + E5 = 137/12 = 11.416667蒙特卡洛模拟import numpy as np
import operator
from multiprocessing import Pool
from functools import reduce
def run():
setting = set()
n = 0
while True:
n += 1
x = np.random.randint(1, 6)
if x in setting:
continue
setting.add(x)
if len(setting) == 5:
break
return n
def test(T):
np.random.seed()
y = (run() for _ in range(T))
n = reduce(operator.add, y)
return n / T
T = int(1e7)
args = [T] * 8
pool = Pool(8)
ts = pool.map(test, args)
for t in ts:
print("{:.6f} coupons on average are required".format(t))11.415423 coupons on average are required
11.417857 coupons on average are required
11.415529 coupons on average are required
11.417148 coupons on average are required
11.416793 coupons on average are required
11.413937 coupons on average are required
11.416149 coupons on average are required
11.416380 coupons on average are required4.14 第 2 强的选手拿亚军一场由 8 个人组成的淘汰赛,对阵图如上图。8 个人随机被分到图中的 1~8 号位置。第二强的人总是会输给最强的人,同时也总是会赢剩下的人。决赛输的人拿到比赛的亚军。问: 第二强的人拿到亚军的概率是多少?思路参考第二强的人拿到亚军,等价于最强的人和第二强的人在决赛遇到,等价于这两个人一个被分到 1~4,另一个被分到 5~8。x1: 最强的人被分到 1~4x2: 最强的人被分到 5~8y1: 第二强的人被分到 1~4y2: 第二强的人被分到 5~8第二强的人获得亚军的概率为 2/7+2/7=0.571428蒙特卡洛模拟import numpy as np
import operator
from multiprocessing import Pool
from functools import reduce
ladder = range(8)
def run():
l = np.random.permutation(ladder)
x = int(np.where(l == 0)[0][0])
y = int(np.where(l == 1)[0][0])
return (x <= 3 and y >= 4) or (x >= 4 and y <= 3)
def test(T):
np.random.seed()
y = (run() for _ in range(T))
n = reduce(operator.add, y)
return n / T
T = int(1e7)
args = [T] * 8
pool = Pool(8)
ts = pool.map(test, args)
for t in ts:
print("P(the second-best player wins the runner-up cup): {:.6f}".format(t))模拟结果P(the second-best player wins the runner-up cup): 0.571250
P(the second-best player wins the runner-up cup): 0.571030
P(the second-best player wins the runner-up cup): 0.571432
P(the second-best player wins the runner-up cup): 0.571021
P(the second-best player wins the runner-up cup): 0.571379
P(the second-best player wins the runner-up cup): 0.571495
P(the second-best player wins the runner-up cup): 0.571507
P(the second-best player wins the runner-up cup): 0.5712264.15 孪生骑士(a)8 个骑士进行淘汰赛,对阵图与上图这样的网球对阵图类似。8 个骑士的水平一样,任意一组比赛双方的获胜概率均为 0.5。8 个骑士中有两个人是孪生兄弟。问:这两个孪生兄弟在比赛过程中相遇的概率?(b)将 8 个骑士的淘汰赛改为 2^n 个人的淘汰赛,问这两个孪生兄弟相遇的概率?思路参考(a)8 个骑士的比赛共有 3 轮,我们一轮一轮地考虑。记事件 X1 为孪生骑士在第一轮相遇,若想在第一轮相遇,则它们必须同时被分到 (1, 2), (2, 1), (3, 4), (4, 3), (6, 5), (5, 6), (8, 7), (7, 8) 这八种情况之一。概率为记事件 X2 为孪生骑士在第二轮相遇,若想在第二轮相遇,则需要它们两个被分到以下十六种情况的一种,同时它们两个还要都赢了(1, 3), (1, 4), (2, 3), (2, 4), (5, 7), (5, 8), (6, 7), (6, 8)
(3, 1), (4, 1), (3, 2), (4, 2), (7, 5), (8, 5), (7, 6), (8, 6) 概率为记事件 X3 为孪生骑士在第三轮相遇,若想在第三轮相遇,需要它们两个其中一个被分到 [1..4] 中的一个,另一个被分到 [5..8] 中的一个。且他们在前两轮的共四场比赛都要赢,概率为把 X1, X2, X3 这三种情况的概率加起来,得到孪生骑士相遇的概率(b)记事件 Xi 为孪生骑士在第 i 轮相遇,其中 i 取值为 1, 2, ..., n,共 T = 2^n 人。对第 i 轮,将 1 ~ 2^n 连续地分为 B = (2^n)/(2^(i-1)) 个桶,每个桶 C = 2^(i-1) 个元素。孪生骑士需要在编号为 (2k+1, 2k+2) 的相邻桶中(k=0,1,...,(2^n)/(2^i) - 1),相邻桶的组数为 N = (2^n)/(2^i),孪生骑士在满足要求的桶中的概率为孪生骑士在第 1,2,...,i-1 轮中,共有 (i-1) * 2 场比赛。这些比赛还要赢,概率为将两个概率相乘就是孪生骑士在第 i 轮相遇的概率孪生骑士相遇的概率为蒙特卡洛模拟import numpy as np
import operator
from multiprocessing import Pool
from functools import reduce
def P(n):
return 1 / (2 ** (n - 1))
def run(n):
l = np.random.permutation(ladder)
for _ in range(1, n + 1):
for i in range(0, len(l), 2):
if l[i] + l[i + 1] == 1:
return 1
if np.random.rand() < 0.5:
l[int(i / 2)] = l[i]
else:
l[int(i / 2)] = l[i + 1]
l = l[:int(len(l) / 2)]
return 0
def test(n):
T = int(1e5)
np.random.seed()
y = (run(n) for _ in range(T))
return reduce(operator.add, y) / T
def main(n):
print("n = {}: P(n) = {}".format(n, P(n)))
args = [n] * 8
pool = Pool(8)
ts = pool.map(test, args)
for t in ts:
print("P(twin knight meet): {:.6f}".format(t))
print()
for n in range(3, 7):
global ladder
ladder = range(2 ** n)
main(n)模拟结果n = 3: P(n) = 0.25
P(twin knight meet): 0.249970
P(twin knight meet): 0.249815
P(twin knight meet): 0.250011
P(twin knight meet): 0.249984
P(twin knight meet): 0.249990
P(twin knight meet): 0.250079
P(twin knight meet): 0.249889
P(twin knight meet): 0.250108
n = 4: P(n) = 0.125
P(twin knight meet): 0.124822
P(twin knight meet): 0.124963
P(twin knight meet): 0.124807
P(twin knight meet): 0.125172
P(twin knight meet): 0.124867
P(twin knight meet): 0.124937
P(twin knight meet): 0.124887
P(twin knight meet): 0.124992
n = 5: P(n) = 0.0625
P(twin knight meet): 0.062401
P(twin knight meet): 0.062294
P(twin knight meet): 0.062492
P(twin knight meet): 0.062508
P(twin knight meet): 0.062588
P(twin knight meet): 0.062417
P(twin knight meet): 0.062393
P(twin knight meet): 0.062459
n = 6: P(n) = 0.03125
P(twin knight meet): 0.031231
P(twin knight meet): 0.031373
P(twin knight meet): 0.031206
P(twin knight meet): 0.031259
P(twin knight meet): 0.031189
P(twin knight meet): 0.031246
P(twin knight meet): 0.031311
P(twin knight meet): 0.0312564.16有放回抽样还是无放回抽样两个桶 A, B,桶内有红色和黑色的球,其中:A 桶有 2 个红球和 1 个黑球。B 桶有 101 个红球和 100 个黑球。随机选出一个桶放到你面前,然后从中抽样两个球,基于这两个球的颜色猜该桶是 A 还是 B。你可以选择有放回抽样或者无放回抽样,也就是你可以决定在抽取第二个球之前,是否将抽出的第一个球放回去再抽第二次。问:为了使得猜中桶名的概率最大,应该有放回抽样还是无放回抽样,猜中的概率是多少?思路参考本题的目标是根据抽样两个球的结果,来猜当前是哪个桶。有两种获取证据的方式,一种是有放回抽样抽两个球,另一种是无放回抽样抽两个球。我们要选择使得猜中概率更大的那个抽样方式。根据第 3 章总结,我们应先确定“证据是什么”、“假设有哪些”,然后计算似然值,再推公式计算。(1) 假设和证据首先我们看都有哪些可能的证据。由于抽样两个球,且球的种类有红和黑,因此无论有放回抽样还是无放回抽样,观察到的证据都可能有 ”两红”、”一红一黑”、”两黑” 这三种,分别记为 e1, e2, e3。然后我们看都有哪些可能的假设。由于有 A 和 B 这两种桶,因此针对随机取出的桶有两种假设:桶名为 A 和 桶名为 B,分别记为 H1, H2。不论抽样是否有放回,都有上述的三种证据和两种假设。(2) 先验概率由于一共两个桶,随机抽取一个,因此抽到 A 或 B 的先验概率为 1/2,与抽样是否有放回无关。(3) 似然值有放回抽样对于 A 桶,有放回抽样两个球,得到 e1, e2, e3 三种证据的概率分别为对于 B 桶,有放回抽样两个球,得到 e1, e2, e3 三种证据的概率分别为无放回抽样对于 A 桶,无放回抽样两个球,得到 e1, e2, e3 三种证据的概率分别为对于 B 桶,无放回抽样两个球,得到 e1, e2, e3 三种证据的概率分别为(4) 推导 P(T)记 P(T) 为猜测正确的概率。P(T|e) 表示获取到的证据为 e 时,猜测正确的概率。其中 P(ei|Hj) 是我们之前算好的似然值,P(Hj) 是之前算好的先验概率。(5) 计算我们分别计算在有放回抽样和无放回抽样 2 次时,预测正确的概率 P(T)有放回抽样无放回抽样蒙特卡洛模拟import numpy as np
from multiprocessing import Pool
class Box:
def __init__(self, nR, nB, t):
# 当前子在盒子中的
self.nR = nR
self.nB = nB
# 抽出未放回的
self.mR = 0
self.mB = 0
self.t = t
def sample_with_replacement(self):
if np.random.randint(0, self.nR + self.nB) < self.nR:
return "R"
else:
return "B"
def sample_without_replacement(self):
if np.random.randint(0, self.nR + self.nB) < self.nR:
self.nR -= 1
self.mR += 1
return "R"
else:
self.nB -= 1
self.mB += 1
return "B"
def reset(self):
self.nB += self.mB
self.mB = 0
self.nR += self.mR
self.mR = 0
def sample(self, method):
if method == "with":
return self.sample_with_replacement()
else:
return self.sample_without_replacement()
class Model:
def __init__(self, boxs, method):
self.boxs = boxs
self.method = method
# mapping 记录每种证据下两种盒子种类的次数
# 共有 6 个参数
self.mapping = {"RR": np.array([0, 0])
,"RB": np.array([0, 0])
,"BB": np.array([0, 0])
}
def update(self, e, idx):
"""
根据数据 (e, idx) 更新 mapping 的参数
数据的含义是证据 e 对应硬币种类 idx
"""
self.mapping[e][idx] += 1
def train(self):
"""
随机生成数据训练
"""
n_train = int(1e5)
for i in range(n_train):
idx = np.random.randint(0, len(self.boxs))
box = self.boxs[idx]
# 抽取两次,获取证据
e = [box.sample(self.method), box.sample(self.method)]
if e[0] == "B" and e[1] == "R":
e[1], e[0] = e[0], e[1]
e = "".join(e)
self.update(e, box.t)
box.reset()
for k, v in self.mapping.items():
print("证据 {} 下的A桶有{}个, B桶有{}个".format(k, v[0], v[1]))
print("===训练结束===")
def predict(self, e):
"""
根据证据 e 猜盒子种类,返回 0 或 1
"""
return np.argmax(self.mapping[e])
def test(self, T):
correct = 0
np.random.seed()
for _ in range(T):
# 随机选盒子
idx = np.random.randint(0, len(self.boxs))
box = self.boxs[idx]
# 抽取 2 次,获取证据
e = [box.sample(self.method), box.sample(self.method)]
if e[0] == "B" and e[1] == "R":
e[1], e[0] = e[0], e[1]
e = "".join(e)
# 根据证据猜硬币种类
if self.predict(e) == box.t:
correct += 1
box.reset()
return correct / T
class Solver:
def __init__(self, method):
self.method = method
self.boxs = [Box(2, 1, 0), Box(101, 100, 1)]
self.ans = -1
def __call__(self):
model = Model(self.boxs, self.method)
model.train()
T = int(1e7)
args = [T] * 8
pool = Pool(8)
ts = pool.map(model.test, args)
self.ans = sum(ts) / len(ts)
solver1 = Solver("with")
solver1()
print("有放回时,P(T): {:.6f}".format(solver1.ans))
solver2 = Solver("without")
solver2()
print("无放回时,P(T): {:.6f}".format(solver2.ans))模拟结果证据 RR 下的A桶有22280个, B桶有12755个
证据 RB 下的A桶有21997个, B桶有24808个
证据 BB 下的A桶有5561个, B桶有12599个
===训练结束===
有放回时,P(T): 0.596081
证据 RR 下的A桶有16452个, B桶有12755个
证据 RB 下的A桶有33433个, B桶有25112个
证据 BB 下的A桶有0个, B桶有12248个
===训练结束===
无放回时,P(T): 0.623070
英勇黄铜
栈与队列
栈栈的概念栈它是一种特殊的线性表,只允许在固定的一端进行插入和删除元素操作。进行数据插入和删除操作的一端称为栈顶,另一端称为栈底。栈中的数据元素遵守着先进后出的规则。压栈:栈的插入操作叫做进栈,压栈,入栈,入数据在栈顶。出栈:栈的删除操作叫做出栈。出数据在栈顶。栈的使用代码示范 :public class Test {
public static void main(String[] args) {
Stack<Integer> stack = new Stack<>();
stack.push(1);
stack.push(2);
stack.push(3);
stack.push(4);
stack.push(5);
System.out.println(stack.peek());
System.out.println(stack.pop());
System.out.println(stack.empty());
System.out.println(stack.size());
}
}栈的模拟实现Stack 继承了 Vector ,Vector 和 ArrayList 类似,都是动态的顺序表,但不同的是 Vector 是线性安全的。代码模拟实现:public class MyStack implements Istack{
private int[] elem;
private int usedsize;
private static final int DEFAULT_CAPACITY = 10;
public MyStack() {
this.elem = new int[DEFAULT_CAPACITY];
}
@Override
public void push(int x) {
if(full()) {
elem = Arrays.copyOf(elem, 2*elem.length);
}
elem[usedsize] = x;
usedsize++;
}
@Override
public boolean full() {
if(usedsize == elem.length) {
return true;
}
return false;
}
@Override
public int pop() {
if(Empty()) {
throw new Emptyexception("数组为空");
}
int ret = elem[usedsize-1];
usedsize--;
return ret;
}
@Override
public int peek() {
if(Empty()) {
throw new Emptyexception("数组为空");
}
return elem[usedsize - 1];
}
@Override
public int size() {
return usedsize;
}
@Override
public boolean Empty() {
if(usedsize == 0) {
return true;
}
return false;
}
}
栈的应用将递归转化为循环// 递归方式
void printList(Node head){
if(null != head){
printList(head.next);
System.out.print(head.val + " ");
}
}
// 循环方式
void printList(Node head){
if(null == head){
return;
}
Stack<Node> s = new Stack<>();
// 将链表中的结点保存在栈中
Node cur = head;
while(null != cur){
s.push(cur);
cur = cur.next;
}
// 将栈中的元素出栈
while(!s.empty()){
System.out.print(s.pop().val + " ");
}
}栈,虚拟机栈,栈帧的区分栈:是一种数据结构虚拟机栈:是 java 虚拟机 JVM 中分配的一块内存栈帧:是在调用方法的时候,会在虚拟机中为这个方法分配一块内存队列( Queue )队列的概念 队列:只允许在一端进行插入数据操作,在另一端进行删除数据操作的特殊线性表,队列具有先进先出 FIFO ( FirstIn First Out ) 入队列:进行插入操作的一端称为队尾 ( Tail / Rear ) 出队列:进行删除操作的一端称为队头 队列的使用 在 java 中, Queue 是一个接口,底层是通过链表来实现的 这里要注意一个点:Queue 是一个接口,在实例化时必须实例化 LinkedList 的对象,因为 LinkedList 实现了 Queue 接口public static void main(String[] args) {
Queue<Integer> q = new LinkedList<>();
q.offer(1);
q.offer(2);
q.offer(3);
q.offer(4);
q.offer(5); // 从队尾入队列
System.out.println(q.size());
System.out.println(q.peek()); // 获取队头元素
q.poll();
System.out.println(q.poll()); // 从队头出队列,并将删除的元素返回
if(q.isEmpty()){
System.out.println("队列空");
}else{
System.out.println(q.size());
}
}队列模拟实现链式结构对列:public class MyLinkqueue {
static class ListNode {
int val;
ListNode prev;
ListNode next;
public ListNode(int val) {
this.val = val;
}
}
ListNode head;
ListNode last;
int usedsize;
public void offer(int val) {
ListNode node = new ListNode(val);
if(head == null) {
head = node;
last = node;
usedsize++;
return;
}
last.next = node;
node.prev = last;
last = node;
usedsize++;
}
public int poll() {
if(head == null) {
return -1;
}
int ret = head.val;
if(head.next == null) {
head = null;
last = null;
} else {
head.next.prev = null;
head = head.next;
}
usedsize--;
return ret;
}
public int peek() {
if(head == null) {
return -1;
}
return head.val;
}
public boolean Empty(){
return head == null;
}
public int size() {
return usedsize;
}
}
循环队列实际中我们有时还会使用一种队列叫循环队列,操作系统课程讲解生产者消费者模型时可以就会使用循环队列,环形队列通常使用数组实现代码:class MyCircularQueue {
int[] elem;
int front;
int rear;
public MyCircularQueue(int k) {
elem = new int[k+1];
}
public boolean enQueue(int value) {
if(isFull()) {
return false;
}
elem[rear] = value;
rear = (rear+1) % elem.length;
return true;
}
public boolean deQueue() {
if(isEmpty()) {
return false;
}
front = (front+1)% elem.length;
return true;
}
public int Front() {
if(isEmpty()) {
return -1;
}
return elem[front];
}
public int Rear() {
if(isEmpty()) {
return -1;
}
int ret = (rear == 0) ? elem.length-1 : rear-1;
return elem[ret];
}
public boolean isEmpty() {
return front == rear;
}
public boolean isFull() {
return (rear+1) % elem.length == front;
}
}双端队列双端队列是指允许两端都可以进行入队和出队操作的队列, deque 是 “ double ended queue ” 的简称,那就说明元素可以从队头出队和入队,也可以从队尾出队和入队,Deque 是一个接口,使用时必须创建 LinkedList 的对象
英勇黄铜
第七章:速记Day7
问题 1:gbdt 的目标函数是什么GBDT 特指梯度提升决策树算法。GBDT 是在函数空间上利用梯度下降进行优化。GBDT 是多个弱分类器合成强分类器的过程(加权求和),每次迭代产生一个弱分类器,当前弱分类器是在之前分类器残差基础上训练。GBDT 的核心就在于,每一棵树学的是之前所有树结论和的残差,这个残差就是一个加预测值后能得真实值的累加量。Gradient 即每个文档得分的一个下降方向组成的N维向量,N为样本个数。这里仅仅是把”求残差“的逻辑替换为”求梯度“,可以这样想:梯度方向为每一步最优方向,累加的步数多了,总能走到局部最优点,若该点恰好为全局最优点,那和用残差的效果是一样的。目标:损失函数尽可能快减小,则让损失函数沿着梯度方向下降。--> gbdt 的 gb 的核心了。问题 2:React 事件绑定的方式有哪些?render 方法中使用 bindrender 方法中使用箭头函数constructor 中 bind定义阶段使用箭头函数绑定问题 3:卷积神经网络可被用哪些方面 ?模式分类物体检测物体识别图像识别问题 4:请由低到高依次写出事物的隔离级别读取未提交、读取已提交、可重复读、可串行化问题 5:添加索引时需要注意哪些原则?在查询中很少使用或者参考的列不要创建索引。只有很少数据值的列 也不应该增加索引。定义为 text、image 和 bit 数据类型的列不应该增加索引。当 修改性能远远大于检索性能 时,不应该创建索引。定义有 外键 的数据列一定要创建索引。问题 6:B+ Tree 与 B-Tree 的结构很像,但是也有自己的特性,它的有哪些?所有的非叶子结点只存储 关键字信息所有具体数据都存在叶子结点中所有的叶子结点中包含了全部元素的信息所有叶子节点之间都有一个链指针问题 7:车规划算法了解哪些 ?根据车辆导航系统的研究历程 , 车辆路径规划算法可分为静态路径规划算法和动态路径算法。静态路径规划是以物理地理信息和交通规则等条件为约束来寻求最短路径,静态路径规划算法已日趋成熟 , 相对比较简单 , 但对于实际的交通状况来说 , 其应用意义不大。动态路径规划是在静态路径规划的基础上 , 结合实时的交通信息对预先规划好的最优行车路线进行适时的调整直至到达目的地最终得到最优路径。下面介绍几种常见的车辆路径规划算法。1. Dijkstra 算法Dijkstra(迪杰斯特拉)算法是最短路算法的经典算法之一,由 E.W.Dijkstra 在 1959 年提出的。该算法适于计算道路权值均为非负的最短路径问题,可以给出图中某一节点到其他所有节点的最短路径,以思路清晰,搜索准确见长。相对的,由于输入为大型稀疏矩阵,又具有耗时长,占用空间大的缺点。其算法复杂度为 O ( n ² ) ,n 为节点个数。2. Lee 算法Lee 算法最早用于印刷电路和集成电路的路径追踪,与 Dijkstra 算法相比更适合用于数据随时变化的道路路径规划,而且其运行代价要小于 Dijkstra 算法。只要最佳路径存在,该算法就能够找到最佳优化路径。Lee 算法的复杂度很难表示,而且对于多图层的路径规划则需要很大的空间。3. Floyd 算法Floyd 算法是由 Floyd 于 1962 年提出的,是一种计算图中任意两点间的最短距离的算法。可以正确处理有向图或负权的最短路径问题,同时也被用于计算有向图的传递闭包,Floyd-Warshall 算法的时间复杂度为 O ( n ³ ) ,空间复杂度为 O ( n ² ) ,n 为节点个数。与对每一节点作一次 Dijkstra 算法的时间复杂度相同,但是实际的运算效果比 Dijkstra 算法要好。4. 启发式搜索算法—— A* 算法启发式搜索有很多的算法,如 : 局部择优搜索法、最好优先搜索法、A* 算法等。其中 A* 算法是由 Hart、Nilsson、Raphael 等人首先提出的,算法通过引入估价函数,加快了搜索速度,提高了局部择优算法搜索的精度,从而得到广泛的应用,是当前较为流行的最短路算法。A* 算法所占用的存储空间少于 Dijkstra 算法。其时间复杂度为 O ( bd ) ,b 为节点的平均出度数,d 为从起点到终点的最短路的搜索深度。5. 双向搜索算法双向搜索算法由 Dantzig 提出的基本思想,Nicholson 正式提出算法。该算法在从起点开始寻找最短路径的同时也从终点开始向前进行路径搜索,最佳效果是二者在中间点汇合,这样可缩短搜索时间。但是如果终止规则不合适,该算法极有可能使搜索时间增加 1 倍,即两个方向都搜索到最后才终止。6. 蚁群算法蚁群算法是由意大利学者 M.Dorigo 等于 1991 年提出的,它是一种随机搜索算法 , 是在对大自然中蚁群集体行为的研究基础上总结归纳出的一种优化算法,具有较强的鲁棒性,而且易于与其他方法相结合,蚁群算法的复杂度要优于 Dijkstra 算法。此外 , 还有实时启发式搜索算法、基于分层路网的搜索算法、神经网络、遗传算法及模糊理论等,由于实际需求不同对算法的要求和侧重点也会有所不同,所以也出现了许多以上算法的各种改进算法。大多数算法应用于求解车辆路径规划问题时都会存在一定的缺陷,所以目前的研究侧重于利用多种算法融合来构造混合算法。问题 8:struct 和 class 区别在 C++ 里 struct 关键字与 class 关键字一般可以通用的。struct 和 class 的区别:struct 更适合看成是一个数据结构的实现体,class 更适合看成是一个对象的实现体。struct 没有继承,没有封装,要说封装只有初步封装。而 class 把数据,接口可以以三种类型封装,private, public,protected;还可以继承和派生。class 是引用类型,而 struct 是值类型。class 有默认的无参构造函数,有析构函数,struct 没有默认的无参构造函数,且只能声明有参的构造函数,没有析构函数:class 可以使用 abstract 和 sealed,有 protected 修饰符。struct 不可以用 abstract 和 sealed,没有 protected 修饰符。它们都可以提供自己的接口函数,构造函数。一个类可以由结构继承而来。struct 只能叫做数据的集合,外部可以任意访问,但是类就完成了封装,维护了数据安全,这就是面向对象的理念。class 实例由垃圾回收机制来保证内存的回收处理,而 struct 变量使用完后立即自动解除内存分配:从职能观点来看,class 表现为行为,而 struct 常用于存储数据。问题 9:AUC 是怎么计算的1. 最直观的,根据 AUC 这个名称,我们知道,计算出 ROC 曲线下面的面积,就是 AUC 的值。事实上,这也是在早期 Machine Learning 文献中常见的AUC计算方法。由于我们的测试样本是有限的。我们得到的 AUC 曲线必然是一个阶梯状的。因此,计算的 AUC 也就是这些阶梯下面的面积之和。这样,我们先把 score 排序(假设 score 越大,此样本属于正类的概率越大),然后一边扫描就可以得到我们想要的 AUC。但是,这么 做有个缺点,就是当多个测试样本的 score 相等的时候,我们调整一下阈值,得到的不是曲线一个阶梯往上或者往右的延展,而是斜着向上形成一个梯形。此时,我们就需要计算这个梯形的面积。由此,我们可以看到,用这种方法计算 AUC 实际上是比较麻烦的。2. 一个关于AUC的很有趣的性质是,它和 Wilcoxon-Mann-Witney Test 是等价的。而 Wilcoxon-Mann-Witney Test 就是测试任意给一个正类样本和一个负类样本,正类样本的 score 有多大的概率大于负类样本的 score。有了这个定义,我们就得到了另外一中计算 AUC 的办法:得到这个概率。我们知道,在有限样本中我们常用的得到概率的办法就是通过频率来估计之。这种估计随着样本规模的扩大而逐渐逼近真实值。这和上面的方法中,样本数越多,计算的 AUC 越准确类似,也和计算积分的时候,小区间划分的越细,计算的越准确是同样的道理。具体来说就是统计一下所有的 M×N(M为正类样本的数目,N为负类样本的数目)个正负样本对中,有多少个组中的正样本的 score 大于负样本的score。当二元组中正负样本的 score相等的时候,按照 0.5 计算。然后除以 MN。实现这个方法的复杂度为 O(n^2)。n 为样本数(即 n=M+N )3. 第三种方法实际上和上述第二种方法是一样的,但是复杂度减小了。它也是首先对 score 从大到小排序,然后令最大 score 对应的 sample 的 rank 为n,第二大 score 对应 sample 的 rank 为n-1,以此类推。然后把所有的正类样本的 rank 相加,再减去 M-1 种两个正样本组合的情况。得到的就是所有的样本中有多少对正类样本的 score 大于负类样本的 score。然后再除以 M×N。即公式解释:1.为了求的组合中正样本的 score 值大于负样本,如果所有的正样本 score 值都是大于负样本的,那么第一位与任意的进行组合 score 值都要大,我们取它的 rank 值为 n,但是 n-1 中有 M-1 是正样例和正样例的组合这种是不在统计范围内的(为计算方便我们取n组,相应的不符合的有 M 个),所以要减掉,那么同理排在第二位的 n-1,会有 M-1 个是不满足的,依次类推,故得到后面的公式 M*(M+1)/2,我们可以验证在正样本 score 都大于负样本的假设下,AUC 的值为12.根据上面的解释,不难得出,rank 的值代表的是能够产生 score 前大后小的这样的组合数,但是这里包含了(正,正)的情况,所以要减去这样的组(即排在它后面正例的个数),即可得到上面的公式。另外,特别需要注意的是,再存在 score 相等的情况时,对相等 score 的样本,需要 赋予相同的 rank (无论这个相等的 score 是出现在同类样本还是不同类的样本之间,都需要这样处理)。具体操作就是再把所有这些 score 相等的样本 的rank取平均。然后再使用上述公式。问题 10:讲一下多进程和多线程(1)进程:一个进程,包括了代码、数据和分配给进程的资源(内存),在计算机系统里直观地说一个进程就是一个 PID。操作系统保护进程空间不受外部进程干扰,即一个进程不能访问到另一个进程的内存。有时候进程间需要进行通信,这时可以使用操作系统提供进程间通信机制。通常情况下,执行一个可执行文件操作系统会为其创建一个进程以供它运行。但如果该执行文件是基于多进程设计的话,操作系统会在最初的进程上创建出多个进程出来,这些进程间执行的代码是一样,但执行结果可能是一样的,也可能是不一样的。为什么需要多进程?最直观的想法是,如果操作系统支持多核的话,那么一个执行文件可以在不同的核心上跑;即使是非多核的,在一个进程在等待 I/O 操作时另一个进程也可以在 CPU 上跑,提高 CPU利用率、程序的效率。在 Linux 系统上可以通过 fork () 来在父进程中创建出子进程。一个进程调用 fork () 后,系统会先给新进程分配资源,例如存储数据和代码空间。然后把原来进程的所有值、状态都复制到新的进程里,只有少数的值与原来的进程不同,以区分不同的进程。fork () 函数会返回两次,一次给父进程(返回子进程的 pid 或者 fork 失败信息),一次给子进程(返回0)。至此,两个进程分道扬镳,各自运行在系统里。#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
void print_exit(){
printf("the exit pid:%d\n",getpid() );
}
int main (){
pid_t pid;
atexit( print_exit ); //注册该进程退出时的回调函数
pid=fork(); // new process
int count = 0;
if (pid < 0){
printf("error in fork!");
}
else if (pid == 0){
printf("i am the child process, my process id is %d\n",getpid());
count ++;
printf("child process add count.\n");
}
else {
printf("i am the parent process, my process id is %d\n",getpid());
count++;
printf("parent process add count.\n");
sleep(2);
wait(NULL);
}
printf("At last, count equals to %d\n", count);
return 0;
} 上述代码在 fork() 之后进行了一次判断,以辨别当前进程是父进程还是子进程。为了说明父进程与子进程有各自的资源空间,设置了对 count 的计数。Terminal输出如下:、明显两个进程都执行了count++操作,但由于count是分别处在不同的进程里,所以实质上count在各自进程上只执行了一次。(2)线程:线程是可执行代码的可分派单元,CPU 可单独执行单元。在基于线程的多任务的环境中,所有进程至少有一个线程(主线程),但是它们可以具有多个任务。这意味着单个程序可以并发执行两个或者多个任务。也就是说,线程可以把一个进程分为很多片,每一片都可以是一个独立的流程,CPU 可以选择其中的流程来执行。但线程不是进程,不具有 PID,且分配的资源属于它的进程,共享着进程的全局变量,也可以有自己“私有”空间。但这明显不同于多进程,进程是一个拷贝的流程,而线程只是把一条河流截成很多条小溪。它没有拷贝这些额外的开销,但是仅仅是现存的一条河流,就被多线程技术几乎无开销地转成很多条小流程,它的伟大就在于它少之又少的系统开销。Linux 中可以使用 pthread 库来创建线程,但由于 pthread 不是 Linux 内核的默认库,所以编译时需要加入pthread 库一同编译。g++ -o main main.cpp -pthread#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
void* task1(void*);
void* task2(void*);
void usr();
int p1,p2;
int count = 0 ;
int main() {
usr();
return 0;
}
void usr(){
pthread_t pid1, pid2;
pthread_attr_t attr;
void *p1, *p2;
int ret1=0, ret2=0;
pthread_attr_init(&attr); //初始化线程属性结构
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE); //设置attr结构
pthread_create(&pid1, &attr, task1, NULL); //创建线程,返回线程号给pid1,线程属性设置为attr的属性
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);
pthread_create(&pid2, &attr, task2, NULL);
ret1=pthread_join(pid1, &p1); //等待pid1返回,返回值赋给p1
ret2=pthread_join(pid2, &p2); //等待pid2返回,返回值赋给p2
printf("after pthread1:ret1=%d,p1=%d\n", ret1,(int)p1);
printf("after pthread2:ret2=%d,p2=%d\n", ret2,(int)p2);
printf("At last, count equals to %d\n", count);
}
void* task1(void *arg1){
printf("task1 begin.\n");
count++;
printf("task1 thread add count.\n");
pthread_exit( (void *)1);
}
void* task2(void *arg2){
printf("thread2 begin.\n");
count ++;
printf("task2 thread add count.\n");
pthread_exit((void *)2);
} 上述代码的中主线程在usr()函数中创建了两个线程,线程的属性都是JOINABLE,即可以被其他线程收集返回信息。然后等待两个线程的返回,输出返回信息。Terminal 的输出显示 thread2 先于 thread1 执行,表明了这不是一个同步的程序,线程的运行是单独进行的,由内核线程调度来进行的。为了区别进程,在代码中也加入了 count++ 操作。最后在主线程中输出 count=2,即 count 被计数了2次,子线程被允许使用同一个进程内的共享变量,区别了进程的概念。由于线程顺序、时间的不确定性,往往需要对进程内的一个共享变量进行读写限制,比如加锁等。
英勇黄铜
八大排序算法(内含思维导图和画图分析)
什么是排序排序:就是让一串数据,按照其中的某个或某些关键字大小,递增或递减排列起来的操作。内部排序:数据元素全部放在内存中的排序外部排序:数据元素太多不能同时放在内存中的时候,根据排序过程中的要求不能在内外存之间移动的排序。稳定性:在经过排序后,这些数据的相对次序还保持不变,则这种排序算法就是稳定的常见的排序算法插入排序基本思想就是将待排序的记录按关键值的大小一个一个插入已经先排好有序的序列中,直到所有的记录插入完为止,这样就可以得到一个新的有序序列。我们打牌时的摸牌过程就是这个思想:直接插入排序它的操作就是从第二个元素开始向前比较,前面的元素大就交换,把前面的元素比较完,这就是第一轮。然后第二轮就是开始从第三个元素向前比较,以此到最后一个元素。这里比较过后的元素就是有序的了,所以要是在比较的过程中发现前面的元素比比较元素小就不必继续比较下去了,直接进入下一轮。特点:元素越有序,直接插入排序的时间效率越来越高时间复杂度:O(N^2)空间复杂度:O(1)稳定性:稳定具体代码//直接插入排序
/*
*时间复杂度 O(n^2)
*空间复杂度 O(1)
*稳定性: 稳定
*/
public static void initorderSort(int[] array) {
for(int i = 1; i < array.length; i++) {
int tmp = array[i];
int j = 0;
for(j = i - 1; j >= 0; j--) {
if(array[j] > array[j+1]) {
array[j+1] = array[j];
}else {
break;
}
array[j+1] = tmp;
}
}
}
画图分析希尔排序它的基本思想就是选定一个整数 gap ,把需要排序的数据分成若干份,将间隔这个数的数据放在一组,并对每个组进行直接插入排序,这是一轮。然后再重复以上步骤,只不过 gap 每次 /2 ,知道 gao=1 时,全部的数据再排序就排好序了。特性:希尔排序是对直接插入排序的优化gap>1 时,属于预排序,这样可以让排序更加接近有序。gap==1 时,数据就是有序了,这时直接插入排序效率就是会很高时间复杂度:O(N^1.25~1.6*N^1.25)空间复杂度:O(1)稳定性:稳定具体代码public static void shellSort(int[] array) {
int gap = array.length;
while(gap > 1) {
gap /= 2;
shell(array, gap);
}
}
private static void shell(int[] array, int gap) {
for(int i = gap; i < array.length; i++) {
int tmp = array[gap];
int j = 0;
for (j = i - gap; j >= 0; j-=gap) {
if(array[j] > tmp) {
array[j+gap] = array[j];
}else {
break;
}
}
array[j+gap] = tmp;
}
}
画图分析选择排序基本思想每一次从需要排序中的元素中挑出最小的元素放在数据的起始位置,不断重复以上操作,每操作完一轮最小元素放入的位置要往后走一个,直到数全部排序完成。直接选择排序在数据中 array[i] ~ array[n-1] 中选择最小的元素将他与这组数据的第一个元素交换在剩下的 array[i] ~ array[n-1] 中,不断重复以上操作特性:这个排序易理解,但效率地下,实际很少使用时间复杂度:O(N^2)空间复杂度:O(1)稳定性:不稳定具体代码public static void selectSort(int[] array) {
for (int i = 0; i < array.length; i++) {
int minIndex = i;
for (int j = i+1; j < array.length; j++) {
if(array[minIndex] > array[j]) {
minIndex = j;
}
}
int temp = array[i];
array[i] = array[minIndex];
array[minIndex] = temp;
}
}画图分析堆排序堆排序是指利用堆这种数据结构设计的一种排序算法,它是通过堆来进行的选择数据。排升序是建大堆,排降序建小堆。特性:堆排序使用堆来排序,效率就有明显的提高时间复杂度:O(N*logN)空间复杂度:O(1)稳定性:稳定具体代码public static void heapSort(int[] array) {
createHeap(array);
int end = array.length - 1;
while(end > 0) {
int tmp = array[0];
array[0] = array[end];
array[end] = tmp;
siftDowm(array,0, end);
end--;
}
}
private static void siftDowm(int[]array, int parent, int len) {
int child = 2*parent + 1;
while(child < len) {
if(child+1 < len && array[child+1] > array[child]) {
child+=1;
}
if(array[child] > array[parent]) {
int tmp = array[child];
array[child] = array[parent];
array[parent] = tmp;
parent = child;
child = parent*2 + 1;
}else {
break;
}
}
}
private static void createHeap(int[] array) {
if(array.length == 0) {
return;
}
int parent = (array.length - 1 - 1) / 2;
for (int i = parent; i >= 0 ; i--) {
siftDowm(array, parent, array.length);
}
}画图分析交换排序基本思想就是根据数据中两个元素的比较结果来交换这两个数据的位置,它的特点就是将值大的元素向后移动,小的向前移动冒泡排序冒泡排序就是进行 n-1 趟比较排序,每一趟都比较 n-1 次,每比较完一次都减一次比较特性:易于理解时间复杂度:O(N^2)空间复杂度:O(1)稳定性:稳定 具体代码 public static void bubbleSort(int[] array) {
int len = array.length;
for(int i = 0; i < len - 1; i++) {
boolean flag = false;
for(int j = 0; j < len - 1 - i; j++) {
if (array[j] > array[j+1]) {
int tmp = array[j];
array[j] = array[j+1];
array[j+1] = tmp;
flag = true;
}
}
if(!flag) {
break;
}
}
}画图分析快速排序它是一种二叉树结构的交换排序算法。基本思想就是去待排序中的任意一个元素作为基准值,按照这个基准值将数据分割为两个子序列,左边的都小于基准值,右边的都大于基准值,然后左右子序列再重复以上操作,知道最后元素都已经有序。特性:快速排序的性能和使用场景都是比较好的时间复杂度:O(N*logN)空间复杂度:O(logN)稳定性:不稳定具体代码递归版 public static void quickSort(int[] array, int start, int end) {
if(start >= end) {
return;
}
int key = array[start];
int left = start;
int right = end;
while(left < right) {
while(left < right && array[right] >= key) {
right--;
}
while(left < right && array[left] <= key) {
left++;
}
swap(array, left, right);
}
swap(array, start, left);
//左子树
quickSort(array, start, left - 1);
//右子树
quickSort(array, left+1, end);
}非递归版public static void quickSortnor(int[] array, int start, int end) {
Stack<Integer> stack = new Stack<>();
int pivot = partition(array, start, end);
if(pivot- 1 > start) {
stack.push(start);
stack.push(pivot - 1);
}
if(pivot+1 < end) {
stack.push(pivot + 1);
stack.push(end);
}
while(!stack.isEmpty()) {
int right = stack.pop();
int left = stack.pop();
pivot = partition(array, left, right);
if(pivot - 1 > left) {
stack.push(start);
stack.push(pivot - 1);
}
if(pivot+1 < right) {
stack.push(pivot + 1);
stack.push(end);
}
}
}
private static int partition(int[] array, int left, int right) {
int i = left;
int j = right;
int pivot = array[left];
while (i < j) {
while (i < j && array[j] >= pivot) {
j--;
}
array[i] = array[j];
while (i < j && array[i] <= pivot) {
i++;
}
array[j] = array[i];
}
array[i] = pivot;
return i;
}
画图分析递归:非递归:归并排序基本思想归并排序是建立在归并操作上的一种有效的排序算法,它采用的是分治法,将已有序的序列合并,得到完全有序的序列。就是先让子序列有序,再使子序列何合并有序,这样叫做二路归并。特性归并排序的缺点在于需要O(N)的空间复杂度,归并排序的思考是解决在磁盘中的外存排序问题时间复杂度:O(N*logN)空间复杂度:O(N)稳定性:稳定具体代码递归版//归并排序
/*
*时间复杂度:O(n*logn)
*空间复杂度 O(logn)
*稳定性:稳定
*/
public static void mergeSort(int[] array, int start, int end) {
if(start >= end) {
return ;
}
//分解
int mid = (start + end) / 2;
mergeSort(array,start, mid);
mergeSort(array, mid+1, end);
//合并
merge(array, start, mid, end);
}
private static void merge(int[] array, int start, int mid, int end) {
int s1 = start;
int s2 = mid;
int e1 = mid+1;
int e2 = end;
int i = 0;
int[] arr = new int[end - start + 1];
while(s1 <= s2 && e1<=e2) {
if(array[s1] < array[e1]) {
arr[i++] = array[s1++];
}else {
arr[i++] = array[e1++];
}
}
while(s1 <= s2) {
arr[i++] = array[s1++];
}
while(e1 <= e2) {
arr[i++] = array[e1++];
}
//把排好序的数组放到原来的数组中
for (int j = 0; j < arr.length; j++) {
array[j+start] = arr[j];
}
}
非递归版public static void mergesortNor(int[] array) {
int gap = 1;
while(gap < array.length) {
for (int i = 0; i < array.length; i+=2*gap) {
int left = i;
int mid = left + gap - 1;
int right = mid + gap;
//防止越界
if(mid >= array.length) {
mid = array.length - 1;
}
if(right >= array.length) {
right = array.length - 1;
}
merge(array, left, mid, right);
}
gap*=2;
}
}
画图分析递归:非递归:海量数据的排序处理外部排序:排序过程需要在磁盘等外部存储进行的排序前提:内存只有一个 G,需要排序的数据有 100G因为内存中无法把所有的数据发放下,所以需要外部排序,归并排序就是最常用的外部排序处理方式:先将文件分成 200 份,每个 512M分别对 512M 排序,因为内存已经可以放的下,排序进行 2 路归并,堆 200 份文件过归并过程,最后就会有序排序算法复杂度与稳定性计数排序基本思想计数排序是对哈希直接定址法的应用1 统计相同的元素次数2 根据统计的结果将序列回收到原来的序列中具体代码 public static void countSort(int[] array) {
//确定长度
int max = 0;
int min = 0;
for (int i = 0; i < array.length; i++) {
if(array[i] > max) {
max = array[i];
}
if(array[i] < min) {
min = array[i];
}
}
int[] count = new int[max - min + 1];
int j = 0;
//将相同的数的次数存储到计数数组中
for(int i = 0; i < array.length; i++) {
count[array[i] - min]++;
}
//遍历计数数组 把实际的数据写到array中
for (int i = 0; i < count.length; i++) {
while(count[i] > 0) {
array[j++] = i+min;
count[i]--;
}
}
}
画图分析特性计数排序在数据范围集中时,效率很高,但是这种情况比较少时间复杂度:O(范围)空间复杂度:O(范围)稳定性:稳定
英勇黄铜
4.深入理解递归-1—下
4.2使用「快速排序」实现排序数组「归并排序」在「拆分子问题」环节是「无脑地」进行拆分,然后我们需要在「合」的环节进行一些操作。而「快速排序」在「分」这件事情上做出了文章,因此在「合」的环节什么都不用做。「快速排序」大家可以阅读经典的算法教程《算法导论》《算法(第 4 版)》进行学习,也可以阅读 LeetBook 之 排序算法全解析 。我们在这里就不对「快速排序」算法进行具体讲解,而直接给出代码,相关的知识点通过注释给出。这一版快速排序的代码,我们引入了随机化选择切分元素 pivot,以避免递归树倾斜;并且使用了双指针技巧,将与 pivot 相等的元素平均地分散到待排序区间的开头和末尾。参考代码 3:第 912 题:排序数组Javaimport java.util.Random;
public class Solution {
/**
* 随机化是为了防止递归树偏斜的操作,此处不展开叙述
*/
private static final Random RANDOM = new Random();
public int[] sortArray(int[] nums) {
int len = nums.length;
quickSort(nums, 0, len - 1);
return nums;
}
/**
* 对数组的子区间 nums[left..right] 排序
*
* @param nums
* @param left
* @param right
*/
private void quickSort(int[] nums, int left, int right) {
// 1. 递归终止条件
if (left >= right) {
return;
}
int pIndex = partition(nums, left, right);
// 2. 拆分,对应「分而治之」算法的「分」
quickSort(nums, left, pIndex - 1);
quickSort(nums, pIndex + 1, right);
// 3. 递归完成以后没有「合」的操作,这是由「快速排序」partition 的逻辑决定的
}
/**
* 将数组 nums[left..right] 分区,返回下标 pivot,
* 且满足 [left + 1..lt) <= pivot,(gt, right] >= pivot
*
* @param nums
* @param left
* @param right
* @return
*/
private int partition(int[] nums, int left, int right) {
int randomIndex = left + RANDOM.nextInt(right - left + 1);
swap(nums, randomIndex, left);
int pivot = nums[left];
int lt = left + 1;
int gt = right;
while (true) {
while (lt <= right && nums[lt] < pivot) {
lt++;
}
while (gt > left && nums[gt] > pivot) {
gt--;
}
if (lt >= gt) {
break;
}
// 细节:相等的元素通过交换,等概率分到数组的两边
swap(nums, lt, gt);
lt++;
gt--;
}
swap(nums, left, gt);
return gt;
}
private void swap(int[] nums, int index1, int index2) {
int temp = nums[index1];
nums[index1] = nums[index2];
nums[index2] = temp;
}
}我们依然可以为「快速排序」增加打印输出语句:参考代码 4:Javaimport java.util.Arrays;
import java.util.Random;
public class Solution {
/**
* 随机化是为了防止递归树偏斜的操作,此处不展开叙述
*/
private static final Random RANDOM = new Random();
public int[] sortArray(int[] nums) {
int len = nums.length;
quickSort(nums, 0, len - 1, 0);
return nums;
}
/**
* 对数组的子区间 nums[left..right] 排序
*
* @param nums
* @param left
* @param right
*/
private void quickSort(int[] nums, int left, int right, int recursionLevel) {
log("拆分子问题", left, right, recursionLevel);
// 1. 递归终止条件
if (left >= right) {
log("递归到底", left, right, recursionLevel);
return;
}
int pIndex = partition(nums, left, right);
// 2. 拆分,对应「分而治之」算法的「分」
quickSort(nums, left, pIndex - 1, recursionLevel + 1);
quickSort(nums, pIndex + 1, right, recursionLevel + 1);
// 3. 递归完成以后没有「合」的操作,这是由「快速排序」partition 的逻辑决定的
}
/**
* 将数组 nums[left..right] 分区,返回下标 pivot,
* 且满足 [left + 1..lt) <= pivot,(gt, right] >= pivot
*
* @param nums
* @param left
* @param right
* @return
*/
private int partition(int[] nums, int left, int right) {
int randomIndex = left + RANDOM.nextInt(right - left + 1);
swap(nums, randomIndex, left);
int pivot = nums[left];
int lt = left + 1;
int gt = right;
while (true) {
while (lt <= right && nums[lt] < pivot) {
lt++;
}
while (gt > left && nums[gt] > pivot) {
gt--;
}
if (lt >= gt) {
break;
}
// 细节:相等的元素通过交换,等概率分到数组的两边
swap(nums, lt, gt);
lt++;
gt--;
}
swap(nums, left, gt);
return gt;
}
private void swap(int[] nums, int index1, int index2) {
int temp = nums[index1];
nums[index1] = nums[index2];
nums[index2] = temp;
}
private void log(String log, int left, int right, int recursionLevel) {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append(" ".repeat(Math.max(0, recursionLevel)));
stringBuilder.append(log);
stringBuilder.append(" ");
stringBuilder.append("=>");
stringBuilder.append(" ");
stringBuilder.append("[");
stringBuilder.append(left);
stringBuilder.append(", ");
stringBuilder.append(right);
stringBuilder.append("]");
System.out.println(stringBuilder.toString());
}
public static void main(String[] args) {
Solution solution = new Solution();
int[] nums = new int[]{7, 7, 7, 1, 7, 2, 3, 4, 4, 5, 5, 6, 7, 7, 8, 9};
int[] res = solution.sortArray(nums);
System.out.println(Arrays.toString(res));
}
}控制台输出:拆分子问题 => [0, 15]
拆分子问题 => [0, 9]
拆分子问题 => [0, 3]
拆分子问题 => [0, -1]
递归到底 => [0, -1]
拆分子问题 => [1, 3]
拆分子问题 => [1, 2]
拆分子问题 => [1, 0]
递归到底 => [1, 0]
拆分子问题 => [2, 2]
递归到底 => [2, 2]
拆分子问题 => [4, 3]
递归到底 => [4, 3]
拆分子问题 => [5, 9]
拆分子问题 => [5, 5]
递归到底 => [5, 5]
拆分子问题 => [7, 9]
拆分子问题 => [7, 8]
拆分子问题 => [7, 6]
递归到底 => [7, 6]
拆分子问题 => [8, 8]
递归到底 => [8, 8]
拆分子问题 => [10, 9]
递归到底 => [10, 9]
拆分子问题 => [11, 15]
拆分子问题 => [11, 14]
拆分子问题 => [11, 11]
递归到底 => [11, 11]
拆分子问题 => [13, 14]
拆分子问题 => [13, 13]
递归到底 => [13, 13]
拆分子问题 => [15, 14]
递归到底 => [15, 14]
拆分子问题 => [16, 15]
递归到底 => [16, 15]
[1, 2, 3, 4, 4, 5, 5, 6, 7, 7, 7, 7, 7, 7, 8, 9]说明:由于加入了随机化,每一次运行的结果很可能不同;遇到拆分子问题 [15, 14] ,这个时候区间里没有元素,所以马上接下来输出的语句就是「递归到底」,然后程序将结果一层一层向上返回。4.3总结在程序中添加打印输出,是我们理解程序的重要方法。它虽然很粗暴,但很实用。
英勇黄铜
2.自顶向下和自底向下
2.1使用「递归」与「循环」实现的求阶乘函数对比我们实现一个函数,输入 n ,输出 n 的阶乘。为了简化描述,我们不考虑输入为负整数,且输出发生整型溢出的情况。也就是说,我们假设输入是合法的,并且计算阶乘得到的结果的整数在 32 位整型范围之内。使用递归计算 5!代码如下:Javapublic int factorial(int n) {
if (n == 1) {
return 1;
}
return n * factorial(n - 1);
}注意:在递归方法调用返回以后,还能够做一些事情。就以上面的代码为例:factorial(n - 1) 返回以后,我们将返回的结果值和 n 相乘。以上的代码等价于下面这段代码:Javapublic int factorial(int n) {
if (n == 1) {
return 1;
}
int res = factorial(n - 1);
// 在这里还能够执行一次操作,实现「分治思想」里「合并」的逻辑
return n * res;
}这种「能够在递归调用返回的时候做一些事情」对应于「分治思想」的第 3 步「合并」的过程。「在上一层「递归」调用结束以后,我们可以实现一些逻辑」这一点是我们在学习递归的过程当中容易被忽略的,要重视这个细节的理解,才能够更好地理解,并且应用递归。使用尾递归计算 5!(了解即可)相比较上面这种递归的写法,有一种递归调用的模式称为「尾递归」。尾递归是指一个函数里的 return 语句 是返回一个函数的调用结果的情形,即最后一步调用的函数返回值被当前函数作为结果返回。「尾递归」不是我们需要熟练掌握的编写代码的技巧,并且初学的时候可能会觉得难理解,大家暂时不去理解它问题不大。尾递归的特点是:return 这一行直接实现递归调用,而不进行额外的操作(最后一个 return 语句是单纯函数),也就是说没有「合并」的过程。例如上面的阶乘的计算,我们可以把它写成:Java/**
* @param n
* @param res 递归函数上一层的结果,由于求的是阶乘,一开始需要传入 1
* @return
*/
public int factorial(int n, int res) {
if (n == 1) {
return res;
}
return factorial(n - 1, n * res);
}执行 factorial(n, 1); 计算 n 的阶乘,这里 res 初始的时候需要传入 1。可以通过下面这张函数调用图,理解 res 为什么需要传入 1 以及「尾递归」方式实现的求 5 的阶乘的过程。「尾递归」在返回以后没有做任何事情,这样的过程就等价于「自底向上」递推地解决问题。事实上,一些编程语言会检测到我们编辑的代码当中「尾递归」的语法现象,然后优化。但是具体细节取决于具体运行环境。使用循环计算 5!如果我们知道了一个问题最开始的样子,就可以通过递推的方式一步一步求解,直到得到了我们想要的问题的解,相对于递归而言,这样的思考方向是「自底向上」的,计算 5! 我们还可以使用循环实现。代码如下:Javapublic int factorial(int n) {
int res = 1;
for (int i = 2; i <= n; i++) {
res *= i;
}
return res;
}友情提示:如果大家学习过「动态规划」的朋友就会知道,动态规划有两个思考的方向:一个是记忆化递归,另一个是递推。记忆化递归对应了「自顶向下」的解决问题的方向,递推对应了「自底向上」的逐步求解问题的方向。很明显:「自底向上」思考问题的方向直接从一个问题的「源头」开始,逐步求解。相比较于「自顶向下」而言:少了一层一层拆分问题的步骤;也不需要借助一个数据结构(栈)记录拆分过程中的每一个子问题。2.2递推与递归我们通过「递归」向大家介绍了我们解决问题的两种思考的路径:「自顶向下」和「自底向上」。「自顶向下」与「递归」「自顶向下」是直接面对我们要解决的问题,逐层拆分,直到不能拆分为止,再按照拆分的顺序的逆序逐层解决,直至原问题得到了解决,这是「递归」。「自底向上」与「递推」如果我们非常清楚一个问题最开始的样子,并且也清楚一个问题是如何从它最开始的样子逐步演变成为我们想要求解的问题的样子,我们就可以通过「递推」的方式,从小规模的问题开始逐步「递推」得到最终要解决的大问题的解。2.3总结与练习「自顶向下」与「自底向上」分别对应了我们解决问题的两种思考路径:自顶向下:直接面对问题,直接解决问题;自底向上:从这个问题最开始的样子出发,一点一点「逐步演化」成我们最终想要解决的问题的样子。
英勇黄铜
优先级队列(堆)
我们了解过的队列,是一种先进先出的数据结构。但是呢,在有些情况下,数据的出入是有优先级的,一般出队时,可能需要优先级高的元素先出队列,在这种场景下,使用队列就不合适了。优先级就比如:我们使用手机玩游戏的时候,有电话打过来的时候,手机就要先处理打过来的电话。在这种情况下,我们就应该提供两种基本的操作,返回最高优先级对象和新增对象。这种数据结构就是优先级队列。优先级队列的模拟实现在 JDK1.8 中的 priorityQueue 底层使用了堆这种数据结构,而这个堆又是在完全二叉树的基础上进行的调整。什么是堆如果有一个关键码的集合 K = {k0,k1, k2,…,kn-1} ,把它的所有元素按完全二叉树的顺序存储方式存储 在一个一维数组中,并满足:Ki <= K2i+1 且 Ki<= K2i+2 (Ki >= K2i+1 且 Ki >= K2i+2) i = 0,1,2…,则称为小堆 ( 或大堆 ) 。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆 堆的性质:堆中某个节点的值总是不大于 (小根堆) 或者不小于 (大根堆) 其父节点的值 堆一定是一颗完全二叉树 堆的存储方式堆是一颗完全二叉树,可以用层序的规则采用顺序的方式来高效存储这里要注意:对于不是完全二叉树的,就不适合使用顺序存储了,因为要还原二叉树,就要将空节点也保存,这样子就会浪费空间,空间利用率低。我们可以根据以下二叉树的性质来还原二叉树:如果 i 为 0,表示的节点为根结点,否则 i 节点的双亲节点为(i - 1)/ 2如果 2*i+1 小于节点个数,则节点i的左孩子下标为 2*i+1 ,否则没有左孩子如果 2*i+2 小于节点个数, 则节点i的右孩子下标为 2*i+1 ,否则没有右孩子堆的创建这里创建堆的方式是使用向下调整的方式来建堆的。想法就是从堆的最后一个节点的父结点开始向下调整,调整完后再向前一个节点向下调整,依次不断到 0 下标的节点调整完后就算建堆成功了。画图分析:建堆的时间复杂度分析这里我们考虑最坏的情况即是一颗满叉树且每一个节点都要调整画图分析:通过上图得知向上调整的时间复杂度为 O(n)向下调整的复杂度会比向上调整的复杂度高上很多,不建议使用它来建堆原因:我们知道向上调整是从最后一层的最后一个节点开始,而向下调整只需要从倒数第二层的最后一个节点开始,而往往最后一层的节点就基本等于前面全部节点加起来之和 -1堆的插入和删除堆的插入堆的插入就只有 4 个步骤:检查空间大小将需插入节点放入最后一个位置再从最后一个位置开始向上调整最后有效位置加一画图分析:堆的删除注意这里的删除是堆顶的元素。具体步骤:将堆顶元素和堆尾元素交换有效位置减一从堆顶开始向下调整画图分析:具体代码import java.util.Arrays;
public class MyHeap {
private int[] elem;
int usedsize;
public MyHeap() {
this.elem = new int[10];
}
//初始化elem数组
public void initElem(int[] array) {
for(int i = 0; i < array.length; i++) {
elem[i] = array[i];
usedsize++;
}
}
//创建大根堆
public void createHeap() {
for(int parent = (usedsize-1-1)/2; parent>=0; parent--) {
siftDown(parent, usedsize);
}
}
private void siftDown(int parent, int len) {
//左孩子
int child = 2*parent+1;
while(child < len) {
if(child+1 < len && elem[child] < elem[child+1] ) {
child += 1;
}
if(elem[child] > elem[parent]) {
//交换
int temp = elem[child];
elem[child] = elem[parent];
elem[parent] = temp;
parent = child;
child = 2*parent+1;
}else {
break;
}
}
}
//插入元素
public void push(int val) {
//满了
if(elem.length == usedsize) {
elem = Arrays.copyOf(elem, elem.length * 2);
}
elem[usedsize] = val;
siftUp(usedsize);
usedsize++;
}
private void siftUp(int child) {
int parent = (child - 1) / 2;
while(child > 0 ) {
if(elem[child] > elem[parent]) {
int temp = elem[child];
elem[child] = elem[parent];
elem[parent] = temp;
child = parent;
parent = (child - 1) / 2;
}else {
break;
}
}
}
/*
*
* 删除元素
*
*/
public void pop() {
if(isEmpty()) {
return ;
}
int temp = elem[0];
elem[0] = elem[usedsize-1];
elem[usedsize-1] = temp;
usedsize--;
int parent = 0;
int child = 2 * parent + 1 ;
while(child < usedsize) {
if(child+1<usedsize && elem[child] < elem[child+1]) {
child+=1;
}
if(elem[child] > elem[parent]) {
int tmp = elem[child];
elem[child] = elem[parent];
elem[parent] = tmp;
parent = child;
child = 2*parent+1;
}else {
break;
}
}
}
public boolean isEmpty() {
return usedsize == 0;
}
}
priorityQueuejava 集合中提供了 priorityQueue 和 priorityBlockingQueue 两种类型的优先级队列,priorityQueue 是线程不安全的,PriorityQueue 是线程安全的。使用 priorityQueue 的注意事项:1 使用时必须导入 PriorityQueue 所在的包:import java.util.PriorityQueue;2 priorityQueue 中放置元素必须要能够比较大小,不能插入无法比较大小的对象,不然会抛出 ClassCastException 异常3 不能插入 null 对象,不然会 1 抛出 nullpointerException没有容量限制,任意插入多个元素,内部会自动扩容4 插入和删除元素的时间复杂度都是 O(logn)5 priorityQueue 的底层使用堆数据结构6 priorityQueue 默认情况下是小堆---即每次获取到的元素都是最小的元素,想要建立大堆需要自己传比较器priorityQueue 的使用优先级队列的构造方法static void TestPriorityQueue(){
// 创建一个空的优先级队列,底层默认容量是11
PriorityQueue<Integer> q1 = new PriorityQueue<>();
// 创建一个空的优先级队列,底层的容量为initialCapacity
PriorityQueue<Integer> q2 = new PriorityQueue<>(100);
ArrayList<Integer> list = new ArrayList<>();
list.add(4);
list.add(3);
list.add(2);
list.add(1);
// 用ArrayList对象来构造一个优先级队列的对象
// q3中已经包含了三个元素
PriorityQueue<Integer> q3 = new PriorityQueue<>(list);
System.out.println(q3.size());
System.out.println(q3.peek());
}
一般在默认的情况下,PriorityQueue队列是小堆,需要变成大堆需要提供比较器:
class IntCmp implements Comparator<Integer>{
@Override
public int compare(Integer o1, Integer o2) {
return o2-o1;
}
}
插入,删除,获取优先级最高的元素
static void TestPriorityQueue2() {
int[] arr = {4, 1, 9, 2, 8, 0, 7, 3, 6, 5};
// 一般在创建优先级队列对象时,如果知道元素个数,建议就直接将底层容量给好
// 否则在插入时需要不多的扩容
// 扩容机制:开辟更大的空间,拷贝元素,这样效率会比较低
PriorityQueue<Integer> q = new PriorityQueue<>(arr.length);
for (int e : arr) {
q.offer(e);
}
System.out.println(q.size()); // 打印优先级队列中有效元素个数
System.out.println(q.peek()); // 获取优先级最高的元素
// 从优先级队列中删除两个元素之和,再次获取优先级最高的元素
q.poll();
q.poll();
System.out.println(q.size()); // 打印优先级队列中有效元素个数
System.out.println(q.peek()); // 获取优先级最高的元素
q.offer(0);
System.out.println(q.peek()); // 获取优先级最高的元素
// 将优先级队列中的有效元素删除掉,检测其是否为空
q.clear();
if (q.isEmpty()) {
System.out.println("优先级队列已经为空!!!");
}
}这里要注意优先级队列扩容说明:如果容量小于 64 时,就是按 oldCapacity 的两倍大小来扩容如果容量大于等于 64 时,就是按 oldCapacity 的 1.5 倍大小扩容如果扩容后的容量大小大于整型的最大值,则按整型的最大值来扩容堆的应用用堆为数据结构来封装优先级队列Java 中的 priorityQueue 底层就是用堆来实现的堆排序堆排序就是使用堆的思想来进行排序1. 建堆升序:建大堆降序:建小堆2. 使用堆元素删除思想来进行排序交换堆顶和堆尾元素,然后向下调整
英勇黄铜
4.概率论面试题(连载)—上
4.1抽屉中的袜子抽屉里有红袜子和黑袜子,随机抽出两只,两只全红的概率为 1/2。问:a. 抽屉里的袜子最少有多少只?b. 如果黑袜子为偶数只,则抽屉里的袜子最少有多少只?思路参考a设抽屉里有 x 只红袜子,y 只黑袜子,总共 N = x + y 只袜子,记随机抽两只均为红色的概率为 P问题变成解以上不定方程 N > 2 的最小正整数解解析解由于对于 y > 0, x > 1,有以下不等式再结合我们可以得到不等式从左半部分不等式可以计算出从右半部分不等式可以计算出因此对于 y = 1, x 的范围在,所以 x = 3 是一个候选答案数值解暴力解法代码如下, 从 y = 1, x = 2 开始枚举,判断是否满足方程。y = 1
found = False
while True:
x = 2
while True:
N = x + y
if 2 * x * (x - 1) == N * (N - 1):
print("N: {}, x: {}".format(x, N))
found = True
break
elif 2 * x * (x - 1) > N * (N - 1):
break
x += 1
if found:
break
y += 1得到 N = 4, x = 3b解析解利用在此前已经推出的以下公式这里规定了 y 是偶数。依次考虑 y = 2, 4, 6, ...,对每一个 y,我们可以知道 x 的范围,这个范围的长度正好为 1,因此也就是可以知道对应的 x 具体是多少。考察这对 (x, y) 是否满足 P = 1/2 即可。第一个满足的就是答案。数值解依然用暴力法解,只需要把 y 的初始值改为 2, 每轮 y 的增加量改为 2 即可y = 2
found = False
while True:
x = 2
while True:
N = x + y
if 2 * x * (x - 1) == N * (N - 1):
print("N: {}, x: {}".format(x, N))
found = True
break
elif 2 * x * (x - 1) > N * (N - 1):
break
x += 1
if found:
break
y += 2
得到 N = 21, x = 154.2 轻率的陪审团有一个 3 人组成的陪审团,其中两个人独立做决定均有 p 概率做对,另一个人通过抛硬币做决定还有一个 1 人的陪审团,那个人做决定也有 p 概率做对。问:哪个陪审团更优可能做出正确决定?思路参考第一个陪审团做出正确决定的概率记为 P1, 第二个陪审团做出正确决定的概率记为 P2。因此 P1 = P2,两个陪审团做出正确决定的可能性相等。构造数据验证T = 10
n_trails = int(1e6)
for t in range(T):
p = np.random.random()
# 模拟第一个陪审团 3 个人各自做出的判断,1为正确判断
# 模拟 n_trails 次
c1 = np.random.binomial(1, p, size=n_trails)
c2 = np.random.binomial(1, p, size=n_trails)
c3 = np.random.binomial(1, 0.5, size=n_trails)
# c 中记录了 n_trails 次模拟中每次是否做出了正确判断
c = ((c1 + c2 + c3) >= 2).astype(int)
print("P2 = {:.5f}, P1 = {:.5f}".format(p, np.mean(c)))实验结果P2 = 0.34158, P1 = 0.34095
P2 = 0.55780, P1 = 0.55776
P2 = 0.07460, P1 = 0.07439
P2 = 0.01918, P1 = 0.01919
P2 = 0.39175, P1 = 0.39233
P2 = 0.82699, P1 = 0.82643
P2 = 0.84726, P1 = 0.84769
P2 = 0.00396, P1 = 0.00395
P2 = 0.09100, P1 = 0.09023
P2 = 0.45738, P1 = 0.457414.3 系列赛中连续获胜Elmer 如果在三场的系列赛中赢下连续的两场,则可以得到奖励,系列赛的对手安排有两种爸爸-冠军-爸爸冠军-爸爸-冠军这里冠军的水平高于爸爸,问:Elmer 应该选择哪一组,得到奖励的概率更大?思路参考一方面,由于冠军水平比爸爸高,应该尽可能少与冠军比赛,另一方面,中间的那一场是更重要的一场,因为如果中间那场输了就不可能连赢两场。Elmer 与爸爸比赛获胜概率为 P1,与冠军比赛获胜概率为 P2,由于冠军水平高于爸爸,所以 P1 > P2。对于一组系列赛,Elmer 要得到奖励有两种可能性,一种是前两场都赢了,此时不用看第三场,另一种是第一场输了,但第二第三场都赢了。下面分别考虑两组系列赛,计算得到奖励的概率第一组系列赛,前两场都赢的概率为 P1 * P2,第一场输但是第二、三场都赢的概率为 (1 - P1) * P2 * P1,得到奖励概率记为 PaPa=P1×P2+(1−P1)×P2×P1第二组系列赛,前两场都赢的概率为 P2 * P1,第一场输但是第二、三场都赢的概率为 (1 - P2) * P1 * P2,得到奖励概率记为 PbPb=P2×P1+(1−P2)×P1×P2做差比零Pa - Pb = (P1 \times P2 + (1 - P1) \times P2 \times P1) - (P2 \times P1 + (1 - P2) \times P1 \times P2) \\ = ((1 - P1) - (1 - P2)) \times P1 \times P2 \\ = (P2 - P1) \times P1 \times P2 \\ &< 0 \\所以 Pa < Pb,Elmer 应该选择第二组系列赛。构造数据验证import numpy as np
step = 0.2
for P1 in np.arange(step, 1 + step, step):
for P2 in np.arange(step, P1 - step, step):
print("P1: {:.2f}, P2: {:.2f}".format(P1, P2))
Pa = P1 * P2 + (1 - P1) * P2 * P1
Pb = P2 * P1 + (1 - P2) * P1 * P2
print("Pa: {:.2f}, Pb: {:.2f}".format(Pa, Pb))
print("----")结果如下:可看到 Pb 的值始终比 Pa 大P1: 0.60, P2: 0.20
Pa: 0.17, Pb: 0.22
----
P1: 0.60, P2: 0.40
Pa: 0.34, Pb: 0.38
----
P1: 0.80, P2: 0.20
Pa: 0.19, Pb: 0.29
----
P1: 0.80, P2: 0.40
Pa: 0.38, Pb: 0.51
----
P1: 0.80, P2: 0.60
Pa: 0.58, Pb: 0.67
----
P1: 1.00, P2: 0.20
Pa: 0.20, Pb: 0.36
----
P1: 1.00, P2: 0.40
Pa: 0.40, Pb: 0.64
----
P1: 1.00, P2: 0.60
Pa: 0.60, Pb: 0.84
----
P1: 1.00, P2: 0.80
Pa: 0.80, Pb: 0.96
----4.4 试验直到第一次成功一枚骰子掷出第一个 6 平均需要掷多少次。思路参考 1记 p(k) := 第一个 6 需要掷 k 次的概率。p := 在一次投掷中得到 6 的概率;q := 在一次投掷中不是 6 的概率,由定义 q = 1 - p。k = 1: p(1) = p
k = 2: p(2) = pq
k = 3: p(3) = pq^2
...总结规律后可以得到p(k)=pq k−1数学期望为处理上面的级数的技巧如下,首先两边都乘以 q。让两式相减其中几何级数那部分的公式如下将上面的公式代入到两式相减的公式中将 p = 1/6 代入,E(k) = 6思路参考 2这里我们仍沿用思路参考 1 的数学记号。记 m 为第一个 6 平均需要的次数(即期望)。由全期望公式【随机变量 k 的期望就是各种条件下 k 的期望加权求和】。这里条件期望有两种情况:1)当第一次投掷成功时,则需要的平均次数是 1(该情况下期望是 1,对应权重即发生概率为 p);2)当第一次投掷失败时,则需要的平均次数是 1 + m (该情况下期望是 1 + m,权重为 q),可列出如下等式:蒙特卡洛模拟验证结果import numpy as np
p = 1/6
def test():
T = int(1e6)
mapping = {}
for t in range(T):
i = 1
while True:
if np.random.rand() < p:
break
i += 1
if i not in mapping:
mapping[i] = 0
mapping[i] += 1
E = 0
for k in mapping:
E += k * (mapping[k] / T)
print("Average times is {:.4f}".format(E))
for i in range(5):
test()实验结果Average times is 6.0025
Average times is 5.9918
Average times is 6.0018
Average times is 6.0038
Average times is 6.00594.5 装备升级回顾在之前 试验直到第一次成功 中解决的一个问题,问题和解法如下一枚骰子掷出第一个 6 平均需要掷多少次。当时我们用两种方法解决了这个问题:期望的定义第一种是直接用期望的定义求期望由于 i 的所有可能取值和 p(i) 我们都可以算出来,于是通过定义再加上一些公式推导技巧我们求出了 E(X) 的解析解。全期望公式第二种是基于 全期望公式 求期望当时在那篇文章中的思路是这么写的记 m 为第一个 6 需要的次数当第一次投掷成功时,则需要的平均次数是 1当第一次投掷失败时,则需要的平均次数是 1 + m于是有以下公式,进而可以求出 mm = p * 1 + q * (1 + m)从全期望公式的角度看X 是掷出第一个 6 的次数,m 就是 E(X);Y 是第一次投掷出的点数是否为 6,是则记 Y=1,否则记 Y=0;Y=1 的概率为 p,Y=0 的概率为 q;E(X|Y=1) = 1,E(X|Y=0) = 1 + E(X)代入公式即可得到解题思路中用的公式 m = p * 1 + q * (1 + m)E(X)=p(Y=1)E(X∣Y=1)+p(Y=0)E(X∣Y=0) =p×1+q×(1+E(X))有向图全期望公式的应用难点在于找出 E(X|y)。上述问题中 E[X| y=0] = (1 + E(X)), E[X| y=1] = 1 比较简单,我们能快速看出 E[X|y],而在稍微复杂的问题上,我们需要有一种找到 E[X|y] 的方法论 -- 有向图。用全期望公式求期望的过程可以用有向图的方式表示出来。以上述问题为例,我们可以画出下面的有向图。其中节点 0 表示未投掷出 6 的状态,节点 1 为表示掷出 6 的状态。0 为起点,1 为终点。图中的每条有向边有一个概率值,还有另一个权值,节点有一个期望值。我们要求的是从起始状态走向终点状态要走的步数的期望,每个节点有一个期望值表示从该节点走到终点节点的期望。由于 1 是终点,所以 E[1] = 0。0 是起点,我们要求的就是 E[0]。从节点 0 会伸出两条边,一条回到 0,表示当前的投掷没有掷出 6,另一条通向 1,表示当前的投掷掷出了 6。边上的概率值表示当前投掷的各个值的概率,而投掷一次步数加一,因此额外的边权都是 1。定义好这个图之后,我们就可以写出 E[0] 的表达式了E[0] = p * (1 + E[1]) + q * (1 + E[0])这个表达式就是全期望公式,只是将全期望公式中 E(X|y) 的部分用图中点权和边权的方式定义了计算方法。期望 DP通过以上的分析可以发现,当根据具体问题定义好有向图 D,起点 s,终点 t 后,我们就可以用类似于动态规划的方式求从 s 到 t 的某个指标的期望。状态定义
dp[u] := 从 u 到 t 的某个指标的期望
初始化
dp[t]
答案
dp[s]
状态转移
dp[u] = g(node[u], sum(p[u][v] * f(w[u][v], dp[v])))其中 f 是边权 w[u][v] (在上面的题中是 1) 和下一点的期望 dp[v] 的函数,g 是当前点的点权 node[u](在上面的题中没有)和 sum(p[u][v] * f) 的函数, 具体的函数形式取决于问题(在上面的题中 f 就是简单的 w[u][v] + dp[v], g 没有),需要具体分析。求解 dp[s] 的过程:对反图(将原图中的每条有向边的方向反转之后形成的图)进行拓扑排序,按照拓扑排序的顺序依次求解各个节点 u 的 dp[u],直至求解到 dp[s]。高斯消元当建出的有向图中有环的时候,求解 E[s] 的过程如果直接用期望 DP 从 dp[t] 向 dp[s] 推导的话是不行的,因为在推导 DP 状态时会出现类似于下面的情况(就是后面的【用户一次游戏带来的收入】那道题的转移方程,这里提前看一下)dp[0] = dp[1]
dp[1] = 1 + 0.7 * dp[2] + 0.3 * dp[0]
dp[2] = 2 + 0.6 * dp[3] + 0.4 * dp[1]
dp[3] = 3 + 0.5 * dp[4] + 0.5 * dp[2]
dp[4] = 0这种情况 dp 状态的转移形成了环,比如要求 dp[1] 要先知道 dp[2],要求 dp[2] 就要先知道 dp[1],没法求了。如果方程组是线性方程组,还有有办法的,解决办法是利用高斯消元的方式整体解这个线性方程组。关于高斯消元的推导、代码、题目,可以找相关资料学习。方法总结现总结求解这类期望问题的方法论。1、理论基础:全期望公式;2、处理具体问题时,我们先分析建图所需的信息:1)起点 s 和终点 t 状态是什么,还有哪些状态节点;2)求出各个状态节点一步可以走到哪些状态节点,概率又分别是多少;3)分析目标期望是否需要额外的点权和边权。3、建图,初始化 dp[t];4、分析状态转移方程中的 g,f 并写出实际的转移方程;5、求解 dp[s],这里注意两点:如果图没有环,直接按拓扑排序的顺序应用动态规划的方式推导;如果图中有环,还需进一步看方程组的形式,若是线性方程组我们可以用高斯消元法求解,对于其他形式方程这里暂不作讨论。题目: 装备升级下面开始今天要看的题目。题目描述如下:玩家的装备有从 0 到 4 这 5 个等级,每次升级都需要一个工序,一共有 0->1, 1->2, 2->3, 3->4 这 4 个工序,成功率分别为 0.9, 0.7, 0.6, 0.5。工序成功,玩家的装备就会升一级,但如果失败,玩家的装备就要倒退一级。例如玩家当前的等级为 2,目前执行 2->3 这个工序,如果成功,则装备等级从 2 升为 3,如果失败,装备等级就从 2 降到 1。问:玩家装备初始等级为 0, 升到 5 平均要经历多少个工序。思路参考步骤 1: 建图按照前面的方法总结。我们首先分析题目并建图,点状态是 0,终点状态是 4,此外还有 1, 2, 3 这三个中间状态。题目描述中明确给出了状态间的转移方向以及概率。我们要求的是从 s=0 走到 t=4 平均需要走的步数。由于每经过一条边都相当于走了一步,所以边上有额外的权 1。除此之外没有别的权,节点上也没有权。根据这些信息,我们首先把图建出来,如下步骤 2:期望DP期望 DP 的方程组如下:dp[0] = 0.9 * (1 + dp[1]) + 0.1 * (1 + dp[0])
dp[1] = 0.7 * (1 + dp[2]) + 0.3 * (1 + dp[0])
dp[2] = 0.6 * (1 + dp[3]) + 0.4 * (1 + dp[1])
dp[3] = 0.5 * (1 + dp[4]) + 0.5 * (1 + dp[2])
dp[4] = 0这是一个有环的图,且方程是线性方程组,面试的时候手推就可以。如果要编程的话,需要整理成标准形式后用高斯消元解决。步骤3 :高斯消元标准形式如下高斯消元求解#include <vector>
#include <cmath>
#include <iostream>
#include <iomanip>
using namespace std;
int main()
{
// 系数矩阵
vector<vector<double>> c{{0.9, -0.9, 0, 0, 0}
,{-0.3, 1, -0.7, 0, 0}
,{0, -0.4, 1, -0.6, 0}
,{0, 0, -0.5, 1, -0.5}
,{0, 0, 0, 0, 1}
};
// 常数列
vector<double> b{1, 1, 1, 1, 0};
int n = 5;
const double EPS = 1e-10;
// 高斯消元, 保证有唯一解
for(int i = 0; i < n; ++i)
{
// 找到 x[i] 的系数不为零的一个方程
for(int j = i; j < n ;++j)
{
if(fabs(c[j][i]) > EPS)
{
for(int k = 0; k < n; ++k)
swap(c[i][k], c[j][k]);
swap(b[i], b[j]);
break;
}
}
// 消去其它方程的 x[i] 的系数
for(int j = 0; j < n; ++j)
{
if(i == j) continue;
double rate = c[j][i] / c[i][i];
for(int k = i; k < n; ++k)
c[j][k] -= c[i][k] * rate;
b[j] -= b[i] * rate;
}
}
cout << std::fixed << std::setprecision(6);
for(int i = 0; i < n; ++i)
{
cout << "dp[" << i << "] = " << b[i] / c[i][i] << endl;
}
}求解结果dp[0] = 10.888889
dp[1] = 9.777778
dp[2] = 7.873016
dp[3] = 4.936508
dp[4] = 0.000000蒙特卡洛模拟验证结果import numpy as np
import bisect
class Game:
def __init__(self, p):
self.n = p.shape[0]
self.p = p
for i in range(self.n):
for j in range(1, self.p.shape[1]):
self.p[i][j] += self.p[i][j - 1]
def step(self, pos):
return bisect.bisect_left(self.p[pos]
,np.random.rand())
def __call__(self):
pos = 0
n_steps = 0
while pos != self.n:
pos = self.step(pos)
n_steps += 1
return n_steps
p = np.array([[0.1, 0.9, 0.0, 0.0, 0.0]
,[0.3, 0.0, 0.7, 0.0, 0.0]
,[0.0, 0.4, 0.0, 0.6, 0.0]
,[0.0, 0.0, 0.5, 0.0, 0.5]
])
game = Game(p)
def test():
T = int(1e7)
total_n_steps = 0
for t in range(T):
total_n_steps += game()
print("Average Steps: {:.6f}".format(total_n_steps / T))
for i in range(10):
test()模拟结果Average Steps: 10.890664
Average Steps: 10.887758
Average Steps: 10.882893
Average Steps: 10.889820
Average Steps: 10.891342
Average Steps: 10.888397
Average Steps: 10.888496
Average Steps: 10.891009
Average Steps: 10.890464
Average Steps: 10.8860804.6 一次游戏通关带来的收入在前一小节中我们通过【有向图+期望DP+高斯消元】这个方法论解决了装备问题,题目描述和解法可以参考前一小节。下面我们在装备升级问题上做一些变化,我们保持图结构不变,但是我们把期望 DP 转移方程中的边权改为节点的权。我们考虑一个在游戏相关岗位面试常见的问题: 一次游戏通关带来的收入一个游戏有四关,通过概率依次为0.9, 0.7, 0.6, 0.5。第一关不收费,第二到四关每次收费分别为1块, 2块, 3块。用户每玩一次都会无限打下去直至通关,通关后用户可以提现 10 块钱作为奖励。问: 公司可以在每次用户游戏中平均挣多少钱。思路参考我们首先考虑【公司可以在每次用户游戏中收费多少钱】,然后减去 10 块钱的奖励就是挣的钱。建图图与上面的装备升级那道题一样,只是边权没有了,节点有权重表示费用。期望 DP期望 DP 的方程组如下:dp[0] = dp[1]
dp[1] = 1 + 0.7 * dp[2] + 0.3 * dp[0]
dp[2] = 2 + 0.6 * dp[3] + 0.4 * dp[1]
dp[3] = 3 + 0.5 * dp[4] + 0.5 * dp[2]
dp[4] = 0这是一个有环的图,且方程是线性方程组,面试的时候手推就可以。如果要编程的话,需要整理成标准形式后用高斯消元解决。高斯消元标准形式如下高斯消元求解#include <vector>
#include <cmath>
#include <iostream>
#include <iomanip>
using namespace std;
int main()
{
// 系数矩阵
vector<vector<double>> c{{1, -1, 0, 0, 0}
,{-0.3, 1, -0.7, 0, 0}
,{0, -0.4, 1, -0.6, 0}
,{0, 0, -0.5, 1, -0.5}
,{0, 0, 0, 0, 1}
};
// 常数列
vector<double> b{0, 1, 2, 3, 0};
int n = 5;
const double EPS = 1e-10;
// 高斯消元, 保证有唯一解
for(int i = 0; i < n; ++i)
{
// 找到 x[i] 的系数不为零的一个方程
for(int j = i; j < n ;++j)
{
if(fabs(c[j][i]) > EPS)
{
for(int k = 0; k < n; ++k)
swap(c[i][k], c[j][k]);
swap(b[i], b[j]);
break;
}
}
// 消去其它方程的 x[i] 的系数
for(int j = 0; j < n; ++j)
{
if(i == j) continue;
double rate = c[j][i] / c[i][i];
for(int k = i; k < n; ++k)
c[j][k] -= c[i][k] * rate;
b[j] -= b[i] * rate;
}
}
cout << std::fixed << std::setprecision(6);
for(int i = 0; i < n; ++i)
{
cout << "dp[" << i << "] = " << b[i] / c[i][i] << endl;
}
}求解结果dp[0] = 16.000000
dp[1] = 16.000000
dp[2] = 14.571429
dp[3] = 10.285714
dp[4] = 0.000000因此公司可以在每次用户游戏中收费 dp[0] = 16 块钱,减去用户通关的 10 块钱奖金,公司可以挣 6 块钱。蒙特卡洛模拟验证结果import numpy as np
import bisect
class Game:
def __init__(self, transfer, cost):
self.n = transfer.shape[0]
self.cost = cost
self.transfer = transfer
for i in range(self.n):
for j in range(1, self.transfer.shape[1]):
self.transfer[i][j] += self.transfer[i][j - 1]
def step(self, pos):
return bisect.bisect_left(self.transfer[pos]
,np.random.rand())
def __call__(self):
pos = 0
pay = 0
while pos != self.n:
pay += self.cost[pos]
pos = self.step(pos)
pay += self.cost[self.n]
return pay
transfer = np.array([[0.1, 0.9, 0.0, 0.0, 0.0]
,[0.3, 0.0, 0.7, 0.0, 0.0]
,[0.0, 0.4, 0.0, 0.6, 0.0]
,[0.0, 0.0, 0.5, 0.0, 0.5]
])
cost = np.array([0.0, 1.0, 2.0, 3.0, -10.0])
game = Game(transfer, cost)
def test():
T = int(1e5)
total_pay = 0
for t in range(T):
total_pay += game()
print("Average income: {:.4f}".format(total_pay / T))
for i in range(10):
test()模拟结果Average income: 5.9768
Average income: 6.0043
Average income: 5.9052
Average income: 6.0664
Average income: 6.0720
Average income: 5.9687
Average income: 6.0101
Average income: 6.0450
Average income: 5.9871
Average income: 6.07044.7祝你好运“祝你好运”一种赌博游戏,经常在嘉年华和赌场玩。玩家可以在 1, 2, 3, 4, 5, 6 中的某个数上下注。然后投掷 3 个骰子。如果玩家下注的数字在这三次投掷中出现了 x 次,则玩家获得 x 倍原始投注资金,并且原始投注资金也会返还,也就是总共拿回 (x + 1) 倍原始投注资金。如果下注的数字没有出现,则原始投注资金不会返还。问:每单位原始投注资金,玩家期望会输多少?思路参考设原始投注资金为 s,下注的数字在三次投掷中出现了 x 次,x 的取值为 0, 1, 2, 3,下面我们分别考虑这四种情况。按照期望的定义拿回的钱的期望为每单位投注资金的损失为 17/216 = 0.0787蒙特卡洛模拟import numpy as np
def game():
x = 0
for _ in range(3):
if np.random.randint(1, 7) == 1:
x += 1
if x == 0:
return 0
return x + 1
def test():
T = int(1e6)
total_income = 0
for t in range(T):
total_income += game()
print("expected loss: {:.6f}".format((T - total_income) / T))
for i in range(10):
test()模拟结果expected loss: 0.078817
expected loss: 0.078182
expected loss: 0.078731
expected loss: 0.078875
expected loss: 0.078526
expected loss: 0.078657
expected loss: 0.078634
expected loss: 0.079033
expected loss: 0.079353
expected loss: 0.079307
英勇黄铜
5.深入理解递归-2
5.1使用递归函数简化「链表」中「穿针引线」的操作5.1.1例题讲解:合并两个有序数组将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。示例 1:输入:l1 = [1,2,4], l2 = [1,3,4]
输出:[1,1,2,3,4,4]示例 2:输入:l1 = [], l2 = []
输出:[]示例 3:输入:l1 = [], l2 = [0]
输出:[0]提示:两个链表的节点数目范围是 [0, 50]-100 <= Node.val <= 100l1 和 l2 均按 非递减顺序 排列思路分析:这道题的解法我们就不在题解当中详细地进行说明了。我们直接给出循环的代码(穿针引线)和递归的代码。相信大家并不难理解它们的正确性。我们想一想,这两种解法它们的区别是什么。我们依然采用「打印关键变量」的方法理解程序的执行流程,请见「方法一」和「方法二」后面的「参考代码 3」。方法一:穿针引线参考代码 1:Javaclass ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
public class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummyNode = new ListNode(-1);
ListNode p1 = l1;
ListNode p2 = l2;
ListNode curNode = dummyNode;
// 两者都不为空的时候,才有必要进行比较
while (p1 != null && p2 != null) {
if (p1.val < p2.val) {
// 指针修改发生在这里
curNode.next = p1;
p1 = p1.next;
} else {
// 指针修改发生在这里
curNode.next = p2;
p2 = p2.next;
}
curNode = curNode.next;
}
// 跳出循环是因为 p1 == null 或者 p2 == null
if (p1 == null) {
curNode.next = p2;
}
if (p2 == null) {
curNode.next = p1;
}
return dummyNode.next;
}
}方法二:递归参考代码 2:Javaclass ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
public class Solution {
/**
* 使用递归
*
* @param l1 有序链表
* @param l2 有序链表
* @return 有序链表
*/
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
// 先写递归终止的条件
if (l1 == null) {
return l2;
}
if (l2 == null) {
return l1;
}
// 假设规模小的问题已经解决,如何建立和原始规模问题之间的关系
if (l1.val < l2.val) {
// l1 被选出,谁小谁在前面
l1.next = mergeTwoLists(l1.next, l2);
return l1;
} else {
// l2 被选出,谁小谁在前面
l2.next = mergeTwoLists(l1, l2.next);
return l2;
}
}
}我们在程序中添加一些打印输出语句。参考代码 3:Javaclass ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
import java.util.List;
public class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode dummyNode = new ListNode(-1);
ListNode p1 = l1;
ListNode p2 = l2;
ListNode curNode = dummyNode;
// 两者都不为空的时候,才有必要进行比较
while (p1 != null && p2 != null) {
if (p1.val < p2.val) {
// 指针修改发生在这里
curNode.next = p1;
log(curNode, p1);
p1 = p1.next;
} else {
// 指针修改发生在这里
curNode.next = p2;
log(curNode, p2);
p2 = p2.next;
}
curNode = curNode.next;
}
// 跳出循环是因为 p1 == null 或者 p2 == null
if (p1 == null) {
curNode.next = p2;
log(curNode, p2);
}
if (p2 == null) {
curNode.next = p1;
log(curNode, p1);
}
return dummyNode.next;
}
/**
* 使用递归
*
* @param l1 有序链表
* @param l2 有序链表
* @return 有序链表
*/
public ListNode mergeTwoListsByRecursion(ListNode l1, ListNode l2) {
// 先写递归终止的条件
if (l1 == null) {
return l2;
}
if (l2 == null) {
return l1;
}
// 假设规模小的问题已经解决,如何建立和原始规模问题之间的关系
if (l1.val < l2.val) {
// l1 被选出,谁小谁在前面
// 这里声明 res 不是必需的,仅仅只是为了打印输出方便
ListNode res = mergeTwoListsByRecursion(l1.next, l2);
l1.next = res;
log(l1, res);
return l1;
} else {
// l2 被选出,谁小谁在前面
ListNode res = mergeTwoListsByRecursion(l1, l2.next);
l2.next = res;
log(l2, res);
return l2;
}
}
private void log(ListNode currNode, ListNode nextNode) {
System.out.println("将结点 " + currNode.val + " 指向结点 " + nextNode.val);
}
public static void main(String[] args) {
Solution solution = new Solution();
int[] nums1 = new int[]{1, 3, 5};
int[] nums2 = new int[]{2, 4, 6};
ListNode listNode1 = new ListNode(nums1);
ListNode listNode2 = new ListNode(nums2);
System.out.println("循环:");
ListNode res1 = solution.mergeTwoLists(listNode1, listNode2);
System.out.println("结果:" + res1);
ListNode listNode3 = new ListNode(nums1);
ListNode listNode4 = new ListNode(nums2);
System.out.println("递归:");
ListNode res2 = solution.mergeTwoListsByRecursion(listNode3, listNode4);
System.out.println("结果:" +res2);
}
}程序输出:循环:
将结点 -1 指向结点 1
将结点 1 指向结点 2
将结点 2 指向结点 3
将结点 3 指向结点 4
将结点 4 指向结点 5
将结点 5 指向结点 6
结果:1 -> 2 -> 3 -> 4 -> 5 -> 6 -> null
递归:
将结点 5 指向结点 6
将结点 4 指向结点 5
将结点 3 指向结点 4
将结点 2 指向结点 3
将结点 1 指向结点 2
结果:1 -> 2 -> 3 -> 4 -> 5 -> 6 -> null我们看到「循环」方法合并两个链表的操作是「从头到尾」的,而「递归」方法合并两个链表的操作恰恰相反。这个例子恰恰说明了循环和递归这两种方法的思考路径是不一样的:循环:自底向上,从一个问题最基本的样子开始,一点一点解决问题,直到完成任务;递归:自顶向下,先对原始问题进行拆分,直到不能拆分为止,再将子问题的结果一层一层返回,直到原问题得到了解决。注意:递归的解法思考的难度要稍微小一点,这是因为在上一层递归结束以后我们可以做一点事情,我们利用了递归函数的返回值简化了「穿针引线」的操作,这其实也是「空间换时间」思想带给我们的遍历。这一点请大家细心体会。5.2总结与练习在「力扣」很多链表的问题都可以同时使用「递归」和「迭代」完成,大家可以根据我们这一节介绍的方法,比较它们实现上的差异。提示:下面这些链表问题既可以使用「递归」做,也可以使用「循环」做。能实现其中一种方案,可解释性好就可以,没有必要追求某个问题一定要使用某个特定的方法实现。
英勇黄铜
第四章:速记Day4
问题 1:工业界中遇到上亿的图像检索任务,如何提高图像对比效率?假设原图像输出的特征维度为2048维,通过哈希的索引技术,将原图的2048维度映射到128维度的0/1值中,再进⾏特征维度对⽐。问题 2:物体检测方法列举Deformable Parts ModelRCNNFast-RCNNFaster-RCNNRFCNMask-RCNN问题 3:请写出 SVM 模型的三种类型,并写出它们分别适用于怎样的训练集SVM 模型有3种:(1)线性可分支持向量机:适用于训练数据线性可分。(2)线性支持向量机:适用于训练数据近似线性可分,也就是存在一些特异点。(3)非线性支持向量机:适用于训练数据线性不可分。问题 4:请描述选择排序的基本思想在排序 n 个元素时,首先在未排序的数列中,找到最小(或最大)的元素,然后将其存放到数列的起始位置;接着从剩下的元素中继续这种选择和交换方式,即第二次遍历时,在剩下的元素中找到次小(或次大)的元素,将其与第二个元素的位置交换;直至剩下最后一个元素。问题 5:查看机器的内存使用信息的命令/proc/meminfo问题 6:请描述归并排序的基本思想该排序算法利用的是解决问题的一个常用思想,divide-and-conquer,即分而治之的思想。将 n 个元素每次 二等分,变为两个 n/2 个元素组,直至 1 个元素——1 个元素,自然是排好序了。然后,再两两合并元素组,最终合并为一个元素组。因为需要归并,所以必然需要一个额外的 n 空间来实现归并。问题 7:请简述描述梯度下降法的原理梯度下降法是根据设定的学习率和计算得到的代价函数关于参数θ的偏导数,迭代同步更新参数θ的参数最优化学习算法。问题 8:k-近邻算法在 「k值过小」时会导致什么问题?容易学习噪声,过拟合,抗异常值能力差问题 9:简述朴素贝叶斯的「朴素」它假定所有的特征在数据集中的作用是同样重要和独立的,而这个假设在现实世界中是很不真实的,因此说朴素贝叶斯很“朴素”。问题 10:概括决策树模型的主要优缺点决策树模型主要的优点是模型具有可读性,分类速度快,最主要的缺点是过拟合严重。
英勇黄铜
时间复杂度和空间复杂度
引入这里提出一个问题,我们应该如何衡量一个算法的好坏?这里就要提到算法效率了。算法效率分为两种:一种为时间效率,一种为空间效率。时间效率为称为时间复杂度,空间效率被称为空间复杂度。时间复杂度主要衡量的是一个算法的运行速度,空间复杂度衡量的是一个算法所需要的空间。在计算机的早期,因为计算机空间小,我们队空间复杂度很重视。但是在经过一段的时间的发展后,计算机的内存变得庞大起来后,我们就不太关注空间复杂度了,而是比较关心时间复杂度。时间复杂度时间复杂度的概念时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个数学函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上说,是不能算出来的,只有你把你的程序放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。 大 O 的渐进表示法 void func1(int N){
int count = 0;
for (int i = 0; i < N ; i++) {
for (int j = 0; j < N ; j++) {
count++;
}
}
for (int k = 0; k < 2 * N ; k++) {
count++;
}
int M = 10;
while ((M--) > 0) {
count++;
}
System.out.println(count);
}通过简单的计算我们可以得到 func的时间复杂度为:n^2+2*n+10 但是在实际中我们计算时间复杂度的时候,我们其实并不一定要计算精确的执行次数,而只需要大概的执行次数,这里就是我们的大 O 渐进表达法。大 O 阶表达方式1 用常数1取代运行时间中的所有加法常数。2 在修改后的运行次数函数中,加法中只保留最高阶项3 如果最高阶存在且不是1,则去除与这个项目相乘的常数,得到的结果就是大O阶那么,我们使用大O阶的渐进表示法后,func1的时间复杂度就是 O(n^2)算法的时间复杂度是会存在最好,平均,最坏的情况:最坏:任意输入规模的最大运行次数平均:任意输入规模的期望运行次数最好:任意输入规模的最小运行次数一般情况下,我们关注的是最坏的运行情况常见的时间复杂度计算实例 1 void func2(int N) {
int count = 0;
for (int k = 0; k < 2 * N ; k++) {
count++;
}
int M = 10;
while ((M--) > 0) {
count++;
}
System.out.println(count);
}实例一我们得到基本执行次数为 2*n+10 ,通过大 O 阶表示,func2 的时间复杂度为 O(n)实例 2 void func3(int N, int M) {
int count = 0;
for (int k = 0; k < M; k++) {
count++;
}
for (int k = 0; k < N ; k++) {
count++;
}
System.out.println(count);
}func 3 基本执行次数为 m+n 次,又因为 m、n 都是未知数,时间复杂度为 O(n+m)实例 3 void func4(int N) {
int count = 0;
for (int k = 0; k < 100; k++) {
count++;
}
System.out.println(count);
}实例 3 基本操作执行了 100 次,通过推导大 O 阶方法,常数都为 1 得 func4 的复杂度为 1实例 4 void bubbleSort(int[] array) {
for (int end = array.length; end > 0; end--) {
boolean sorted = true;
for (int i = 1; i < end; i++) {
if (array[i - 1] > array[i]) {
Swap(array, i - 1, i);
sorted = false;
}
} if
(sorted == true) {
break;
}
}
}实例 4 我们采用最坏的基本操作执行了 1/2*(n^2) ,大 O 阶推导就是 n^2实例 5 int binarySearch(int[] array, int value) {
int begin = 0;
int end = array.length - 1;
while (begin <= end) {
int mid = begin + ((end-begin) / 2);
if (array[mid] < value)
begin = mid + 1;
else if (array[mid] > value)
end = mid - 1;
else
return mid;
}
return -1;
}实例5的最坏基本操作执行次数为 log2N,时间复杂度为 log2N实例 6 long factorial(int N) {
return N < 2 ? N : factorial(N - 1) * N;
}实例 6 的基本执行次数为 n 次,时间复杂度为 n实例 7 long factorial(int N) {
return N < 2 ? N : factorial(N - 1) * N;
}实例 7 的基本操作执行次数为 (1-2^N) / -1 , 时间复杂度为 2^n空间复杂度空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度 。空间复杂度不是程序占用了多少 bytes 的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也使用大O渐进表示法。 常见的空间复杂度计算话不多说,我们直接上代码:实例1 void bubbleSort(int[] array) {
for (int end = array.length; end > 0; end--) {
boolean sorted = true;
for (int i = 1; i < end; i++) {
if (array[i - 1] > array[i]) {
Swap(array, i - 1, i);
sorted = false;
}
}
if
(sorted == true) {
break;
}
}实例1使用了常数个空间,所以空间复杂度为 O(1)实例 2 long[] fibonacci(int n) {
long[] fibArray = new long[n + 1];
fibArray[0] = 0;
fibArray[1] = 1;
for (int i = 2; i <= n ; i++) {
fibArray[i] = fibArray[i - 1] + fibArray [i - 2];
}
return fibArray;
}实例二它动态开辟了 n 个空间,时间复杂度为 O(n)实例 3long factorial(int N) {
return N < 2 ? N : factorial(N-1)*N;
}实例 3 递归调用了 n 次,所以空间复杂度为 O(n)