一、数组
1、什么是数组
数组是一种线性表数据结构,它用一组连续的内存空间,来存储一组具有相同类型的数据
线性表:数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组,链表、队列、栈等也是线性表结构
非线性表:数据之间并不是简单的前后关系。比如二叉树、堆、图等
连续的内存空间和相同类型的数据,正是因为这两个限制,数组才有了随机访问的特性,时间复杂度为 O ( 1 ) O(1) O(1),但在删除、插入一个数据时,为了保证连续性,就需要做大量的数据搬移工作,时间复杂度为 O ( n ) O(n) O(n)
2、数组相关题目
1)、LeetCode1:两数之和
给定一个整数数组nums和一个目标值target,请你在该数组中找出和为目标值的那两个整数,并返回他们的数组下标
可以假设每种输入只会对应一个答案。但是,你不能重复利用这个数组中同样的元素
示例:
给定 nums = [2, 7, 11, 15], target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]
1)暴力法:
public int[] twoSum(int[] nums, int target) {
for (int i = 0; i < nums.length - 1; ++i) {
for (int j = i + 1; j < nums.length; ++j) {
if (nums[i] + nums[j] == target) {
return new int[]{i, j};
}
}
}
return new int[]{};
}
时间复杂度 O ( n 2 ) O(n^2) O(n2),空间复杂度 O ( 1 ) O(1) O(1)
2)哈希表:
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; ++i) {
int complement = target - nums[i];
if (map.containsKey(complement)) {
return new int[]{i, map.get(complement)};
}
map.put(nums[i], i);
}
return new int[]{};
}
时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( n ) O(n) O(n)
2)、LeetCode15:三数之和
给定一个包含n个整数的数组nums,判断nums中是否存在三个元素a,b,c,使得 a + b + c = 0 a+b+c=0 a+b+c=0?找出所有满足条件且不重复的三元组
注意:答案中不可以包含重复的三元组
示例:
例如, 给定数组nums = [-1, 0, 1, 2, -1, -4],
满足要求的三元组集合为:
[
[-1, 0, 1],
[-1, -1, 2]
]
题解:
public List<List<Integer>> threeSum(int[] nums) {
Arrays.sort(nums);
List<List<Integer>> result = new ArrayList<>();
for (int k = 0; k < nums.length - 2; ++k) {
if (nums[k] > 0) break;
if (k > 0 && nums[k] == nums[k - 1]) continue;
int i = k + 1, j = nums.length - 1;
while (i < j) {
int sum = nums[k] + nums[i] + nums[j];
if (sum < 0) {
while (i < j && nums[i] == nums[++i]) ;
} else if (sum > 0) {
while (i < j && nums[j] == nums[--j]) ;
} else {
result.add(Arrays.asList(nums[k], nums[i], nums[j]));
while (i < j && nums[i] == nums[++i]) ;
while (i < j && nums[j] == nums[--j]) ;
}
}
}
return result;
}
解析:https://leetcode-cn.com/problems/3sum/solution/3sumpai-xu-shuang-zhi-zhen-yi-dong-by-jyd/
时间复杂度 O ( n 2 ) O(n^2) O(n2),空间复杂度 O ( 1 ) O(1) O(1)
3)、LeetCode11:盛最多水的容器
给定n个非负整数a1,a2,…,an,每个数代表坐标中的一个点(i, ai)
。在坐标内画n条垂直线,垂直线i的两个端点分别为(i, ai)
和(i, 0)
。找出其中的两条线,使得它们与x轴共同构成的容器可以容纳最多的水
说明:你不能倾斜容器,且n的值至少为2
图中垂直线代表输入数组[1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为49
示例:
输入: [1,8,6,2,5,4,8,3,7]
输出: 49
题解:
public int maxArea(int[] height) {
int maxArea = 0;
for (int i = 0, j = height.length - 1; i < j; ) {
int minHeight = height[i] < height[j] ? height[i++] : height[j--];
maxArea = Math.max(maxArea, minHeight * (j - i + 1));
}
return maxArea;
}
4)、LeetCode283:移动零
给定一个数组nums,编写一个函数将所有0移动到数组的末尾,同时保持非零元素的相对顺序
示例:
输入: [0,1,0,3,12]
输出: [1,3,12,0,0]
题解:
public void moveZeroes(int[] nums) {
int j = 0;
for (int i = 0; i < nums.length; ++i) {
if (nums[i] != 0) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
j++;
}
}
}
5)、LeetCode66:加一
给定一个由整数组成的非空数组所表示的非负整数,在该数的基础上加一
最高位数字存放在数组的首位,数组中每个元素只存储单个数字
题解:
public int[] plusOne(int[] digits) {
for (int i = digits.length - 1; i >= 0; --i) {
digits[i] = (digits[i] + 1) % 10;
if (digits[i] != 0) return digits;
}
digits = new int[digits.length + 1];
digits[0] = 1;
return digits;
}
二、链表
1、什么是链表
数组需要一块连续的内存空间来存储
链表不需要一块连续的内存空间,它通过指针将一组零散的内存块串联起来使用
1)、单链表
链表通过指针将一组零散的内存块串联在一起,把内存块称为链表的结点。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。把这个记录下个结点地址的指针叫作后继指针next
单链表中两个结点比较特殊,分别是第一个结点和最后一个结点。第一个结点叫作头结点,用来记录链表的基地址,通过它可以遍历得到整条链表。最后一个结点叫作尾节点,指针不是指向下一个结点,而是指向一个空地址NULL,表示这是链表上最后一个结点
在链表中插入或者删除一个数据,并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就是不连续的。所以,针对链表的插入和删除操作,只需要考虑相邻结点的指针改变,所以对应的时间复杂度是 O ( 1 ) O(1) O(1)
链表想要随机访问第k个元素,就没有数组高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点,需要 O ( n ) O(n) O(n)的时间复杂度
2)、循环链表
循环链表的尾节点指针是指向链表的头结点,优点是从链尾到链头比较方便,当要处理的数据具有环型结构特点时,就适合采用循环链表
3)、双向链表
双向链表需要额外的两个空间来存储后继结点和前驱结点的地址,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间,但可以支持双向遍历
双向链表可以支持O(1)时间复杂度的情况下找到前驱结点,因此使得双向链表在某些情况下的插入、删除等操作要比单链表简单、高效
4)、双向循环链表
2、链表vs数组
数组简单易用,在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储的,所以对CPU缓存不友好,没办法有效预读
数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致内存不足。如果声明的数组过小,则可能出现不够用的情况,这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常耗时。链表本身没有大小的限制,天然地支持动态扩容
如果代码对内存的使用非常苛刻,那数组就比较适合。因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。而且,对于链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是Java语言,就有可能导致频繁的GC
3、LinkedList源码分析
LinkedList底层实现是一个双向链表
1)、真正存储元素的数据结构Node
private static class Node<E> {
//节点的值
E item;
//后继节点
Node<E> next;
//前驱结点
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
2)、关键属性
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
//链表长度
transient int size = 0;
//头结点
transient Node<E> first;
//尾节点
transient Node<E> last;
3)、构造函数
LinkedList提供了两个构造函数,一个空参构造函数;一个通过传入一个集合(Collection)作为参数初始化LinkedList
public LinkedList() {
}
public LinkedList(Collection<? extends E> c) {
this();
addAll(c);
}
有参构造函数是通过addAll方法实现的
public boolean addAll(Collection<? extends E> c) {
return addAll(size, c);
}
public boolean addAll(int index, Collection<? extends E> c) {
checkPositionIndex(index);
Object[] a = c.toArray();
int numNew = a.length;
if (numNew == 0)
return false;
//succ指代待添加节点的位置,pred指代待添加节点的前一个节点
Node<E> pred, succ;
//如果index==size则表示要添加元素的位置在链表尾部,succ为空,pred指向尾节点
if (index == size) {
succ = null;
pred = last;
}
//否则将succ指向插入待插入位置的节点,pred指向succ节点的前一个节点
else {
succ = node(index);
pred = succ.prev;
}
//遍历数组中元素,每次遍历都新建一个节点,设置前驱结点和当前节点的值.接着判断一下该节点的前一个节点是否为空,如果为空的话,则把当前节点设置为头节点.否则的话就把当前节点的前一个节点的next值设置为当前节点.最后把pred指向当前节点,以便后续新节点的添加
for (Object o : a) {
@SuppressWarnings("unchecked") E e = (E) o;
Node<E> newNode = new Node<>(pred, e, null);
if (pred == null)
first = newNode;
else
pred.next = newNode;
pred = newNode;
}
//上面遍历数组元素的操作将最后一个元素的next值设置为null,pred指向遍历添加的最后一个节点,succ指代待添加节点的位置
//如果succ==null,说明新添加的节点位于链表最后一个元素的后面,所以把last指向pred,否则把pred的next指向succ上,succ的prev指向pred
if (succ == null) {
last = pred;
} else {
pred.next = succ;
succ.prev = pred;
}
size += numNew;
modCount++;
return true;
}
//先比较一下index更靠近链表的头节点还是尾节点,然后进行遍历,获取相应的节点
Node<E> node(int index) {
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
4、链表相关题目
1)、LeetCode206:反转链表
反转一个单链表
示例:
输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL
1)方法一:迭代
public ListNode reverseList(ListNode head) {
//空链表和一个结点的链表无需反转
if (head == null || head.next == null) {
return head;
}
ListNode pre = null;//前指针节点
ListNode next = null;
while (head != null) {
next = head.next;//临时节点,暂存当前节点的下一节点,用于后移
head.next = pre;//将当前节点指向它前面的节点
pre = head;//前指针后移
head = next;//当前指针后移
}
return pre;
}
时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)
2)方法二:递归
public ListNode reverseList(ListNode head) {
if (head == null || head.next == null) return head;
ListNode newHead = reverseList(head.next);
head.next.next = head;
head.next = null;
return newHead;
}
解析:
迭代方法是从头节点开始往后依次处理,而递归方法是先循环到尾节点,然后从尾节点开始处理依次反转整个链表
首先指针H迭代到如下图所示位置,并且设置一个新的指针作为反转后的链表的头。由于整个链表翻转之后的头就是最后一个数,所以整个过程NewH指针一直指向存放5的地址空间
然后H指针逐层返回的时候依次做下图的处理,将H指向的地址赋值给H->next->next
指针
继续返回操作
返回到头
next指针赋值指向NULL主要是因为遍历到链表尾(原链表的头结点)的时候,将尾的next置为NULL,否则会形成循环
时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( n ) O(n) O(n),由于使用递归,将会使用隐式栈空间,递归深度可能会达到n层
2)、LeetCode142: 环形链表 II
给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回null
public ListNode detectCycle(ListNode head) {
//使用快慢指针判断链表是否有环
ListNode slow = head, fast = head;
boolean hasCycle = false;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
hasCycle = true;
break;
}
}
//若有环,找到入环开始的节点
if (hasCycle) {
slow = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
return null;
}
步骤二fast和slow分别从相遇结点和头结点同时同步长走,它们的相遇结点就是入环结点,推理过程如下:
首先,头结点到入环结点的距离为a,入环结点到相遇结点的距离为b,相遇结点到入环结点的距离为c。然后,当fast以slow的两倍速度前进并和slow相遇时,fast走过的距离是slow的两倍,即有等式: a + b + c + b = 2 ( a + b ) a+b+c+b=2(a+b) a+b+c+b=2(a+b),可以得出 a = c a = c a=c,所以说,让fast和slow分别从相遇结点和头结点同时同步长出发,它们的相遇结点就是入环结点
3)、LeetCode21:合并两个有序链表
将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的
示例:
输入:1->2->4, 1->3->4
输出:1->1->2->3->4->4
题解:
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
//类似归并排序中的合并过程
ListNode listNode = new ListNode(0);
ListNode current = listNode;
while (l1 != null && l2 != null) {
if (l1.val < l2.val) {
current.next = l1;
current = current.next;
l1 = l1.next;
} else {
current.next = l2;
current = current.next;
l2 = l2.next;
}
}
//任一为空,直接连接另一条链表
if (l1 != null) {
current.next = l1;
} else {
current.next = l2;
}
return listNode.next;
}
4)、LeetCode19:删除链表的倒数第N个节点
给定一个链表,删除链表的倒数第n个节点,并且返回链表的头结点
示例:
给定一个链表: 1->2->3->4->5, 和 n = 2
当删除了倒数第二个节点后,链表变为 1->2->3->5
题解:
public ListNode removeNthFromEnd(ListNode head, int n) {
if (head == null) return head;
ListNode slow = head, fast = head;
for (int i = 0; i <= n; ++i) {
if (fast == null) {
return head.next;
}
fast = fast.next;
}
while (fast != null) {
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next;
return head;
}
解析:
第一个指针从列表的开头向前移动n+1步,而第二个指针将从列表的开头出发。现在,这两个指针被n个结点分开。通过同时移动两个指针向前来保持这个恒定的间隔,直到第一个指针到达最后一个结点。此时第二个指针将指向从最后一个结点数起的第n个结点。重新链接第二个指针所引用的结点的next指针指向该结点的下下个结点
5)、LeetCode876:链表的中间结点
给定一个带有头结点head的非空单链表,返回链表的中间结点。如果有两个中间结点,则返回第二个中间结点
public ListNode middleNode(ListNode head) {
ListNode first = head;
ListNode second = head;
while (second != null && second.next != null) {
first = first.next;
second = second.next.next;
}
return first;
}
6)、LeetCode24:两两交换链表中的节点
给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换
示例:
给定 1->2->3->4, 你应该返回 2->1->4->3
题解:
public ListNode swapPairs(ListNode head) {
if (head == null || head.next == null) {
return head;
}
ListNode next = head.next;
head.next = swapPairs(next.next);
next.next = head;
return next;
}
三、跳表
1、什么是跳表
对于一个单链表来讲,即便链表中存储的数据是有序的,如果想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度是 O ( n ) O(n) O(n)
每两个结点提取一个结点到上一级,把抽出来的那一级叫做索引或索引层。下图中down表示down指针,指向下一级结点
如果现在要查找某个节点,比如16。可以先在索引层遍历,当遍历到索引层中值为13的结点时,发现下一个节点是17,那要查找的结点16就在这两个结点之间。通过索引层结点的down指针,下降到原始链表这一层,继续遍历,这时候只需要再遍历2个结点,就可以找到值等于16的这个结点了。这样,原来如果要查找16,需要遍历10个结点,现在只需要遍历7个结点
在第一级索引的基础上,每两个结点就抽出一个节点到第二级索引。再来查找16,只需要遍历6个结点了,需要遍历的结点数量又减少了
下图中,是一个包含64个结点的链表,建立了五级索引。原来没有索引的时候,查找62需要遍历62个结点,现在只需要遍历11个结点。所以,当链表的长度n比较大时,在构建索引之后,查找效率的提升就会非常明显
跳表就是链表加多级索引的结构
2、跳表时间复杂度分析
每两个结点会抽出一个结点作为上一级索引的结点,第k级索引的结点个数是第k-1级索引的结点个数的1/2,那第k级索引结点的个数就是 n / ( 2 k ) n/(2^k) n/(2k)
假设索引有h级,最高级的索引有2个结点。通过上面的公式,我们可以得到 n / ( 2 h ) = 2 n/(2^h)=2 n/(2h)=2,从而求得 h = l o g 2 n − 1 h=log_2n-1 h=log2n−1
如果包含原始链表这一层,整个跳表的高度就是 l o g 2 n log_2n log2n
在跳表中查询某个数据的时候,如果每一层都要遍历m个结点,那在跳表中查询一个数据的时间复杂度就是 O ( m ∗ l o g n ) O(m*logn) O(m∗logn)
假设要查找的数据是x,在第k级索引中,遍历到y结点之后,发现x大于y,小于后面的结点z,所以通过y的down指针,从第k级索引下降到第k-1级索引。在第k-1级索引中,y和z之间只有3个节点(包含y和z),所以,在k-1级索引中最多只需要遍历3个结点,依次类推,每一级索引都最多只需要遍历3个结点(m=3,每一级索引都最多只需要遍历3个结点)
所以在跳表中查询任意数据的时间复杂度就是 O ( l o g n ) O(logn) O(logn)
Redis中的跳跃表:http://redisbook.com/preview/skiplist/datastruct.html
常用数据结构的时间、空间复杂度:
https://www.bigocheatsheet.com/