Java数据结构第二讲-数组/链表

常见数据结构和算法实现(排序/查找/数组/链表/栈/队列/树/递归/海量数据处理/图/位图/Java版数据结构)

数据结构和算法作为程序员的基本功,一定得稳扎稳打的学习,我们常见的框架底层就是各类数据结构,例如跳表之于redis、B+树之于mysql、倒排索引之于ES,熟悉了底层数据结构,对框架有了更深层次的理解,在后续程序设计过程中就更能得心应手。掌握常见数据结构和算法的重要性显而易见,本文主要讲解了几种常见的数据结构及基础的排序和查找算法,最后对高频算法笔试面试题做了总结。本文会持续补充,希望对大家日常学习或找工作有所帮忙。本文是第二讲:数组和链表

文章目录

5、数组部分面试题

定义是多个相同类型数据按一定顺序排列的集合,并使用一个名字命名,并通过编号的方式对这些数据进行统一管理。

  • 1、实现一个支持动态扩容的数组
  • 2、实现一个大小固定的有序数组,支持动态增删改操作 实际开发中我们使用ArrayList,更高效
  • 3、实现两个有序数组合并为一个有序数组
  • 4、数组操作常见问题(数组脚标越界异常(ArrayIndexOutOfBoundsException)/空指针异常(NullPointerException))
  • leetcode15:三数求和
    给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?找出所有满足条件且不重复的三元组
    思路:首先对数据进行排序,然后确定第一个数,使用for循环,后两个数使用两指针,依次尝试,如果值大于0-num[i],右指针左移;如果值小于0-num[i],左指针右移。
class Solution {
	public List<List<Integer>> threeSum(int[] nums) {
		Arrays.sort(nums);//由小到大
		List<List<Integer>> ls = new ArrayList<>();
		for (int i = 0; i < nums.length - 2; i++) {
			if (i == 0 || (i > 0 && nums[i] != nums[i - 1])) {  // 跳过可能重复的答案
 
				int l = i + 1, r = nums.length - 1, sum = 0 - nums[i];
				while (l < r) {
					if (nums[l] + nums[r] == sum) {
						ls.add(Arrays.asList(nums[i], nums[l], nums[r]));
						while (l < r && nums[l] == nums[l + 1]) l++;
						while (l < r && nums[r] == nums[r - 1]) r--;
						l++;
						r--;
					} else if (nums[l] + nums[r] < sum) {
						while (l < r && nums[l] == nums[l + 1]) l++;   // 跳过重复值
						l++;
					} else {
						while (l < r && nums[r] == nums[r - 1]) r--;
						r--;
					}
				}
			}
		}
		return ls;
	}
}//时间复杂度是O(n^2)
  • leetcode169:求众数 给定一个大小为n的数组,找到其中的众数。众数是指在数组中出现次数大于?n/2?的元素
    先决条件:给定的数组总是存在众数
    思路:1、利用摩尔投票法 2、利用java的api
public int majorityElement(int[] nums){
	int count = 1;
	int maj = nums[0];
	for (int i = 1; i < nums.length; i++){
		if (maj == nums[i])
			count++;
		else {
			count--;
			if (count == 0) {//说明maj所代表的数不能超过一半
				maj = nums[i + 1];
			}
		}
	}//时间复杂度O(n)
	return maj;
}
  • 第二种解法:使用java的api,排序
public int majorityElement(int[] nums){
	Arrays.sort(nums);//时间复杂度O(nlgn)
	return nums[nums.length / 2];
}
  • LeetCode41:求缺失的第一个正数
    给定一个未排序的整数数组,找出其中没有出现的最小的正整数。
class Solution {
	public int firstMissingPositive(int[] nums) {
		//先排序,然后分两种情况 :有1  和  没有1 (负数略过)
		//1.没有1,则输出1
		//2.有1 则判断下一个数和前一个数是否相等、差1或者差好几个数,相等继续,差1继续,否则退出
		boolean flag = false;
		int i;
		Arrays.sort(nums);
		for(i=0;i<nums.length;i++)
		{
			if(nums[i]<0)
				continue;//负数略过
			if(nums[i]==1)
				flag=true;
			if(i+1<nums.length && nums[i]==nums[i+1])
				continue;
			if(i+1==nums.length || nums[i]+1!=nums[i+1])
					break;
		}
		if(flag==true)
			return nums[i]+1;
		if(flag==false)
			return 1;
		return 0;
	}
}//时间复杂度O(n)

Demo 五子棋程序中,有存盘退出和续上盘的功能,因为该二维数组中的很多值默认为0,因此记录了很多没有意义的数据 --》稀疏


6、链表部分面试题

链表使用场景

git中的分支管理

  • 当使用 git commit 进行提交操作时,Git 会先计算每一个子目录(本例中只有项目根目录)的校验和, 然后在 Git 仓库中这些校验和保存为树对象。随后,Git 便会创建一个提交对象, 它除了包含上面提到的那些信息外,还包含指向这个树对象(项目根目录)的指针。 如此一来,Git 就可以在需要的时候重现此次保存的快照。

  • 现在,Git 仓库中有五个对象:三个 blob 对象(保存着文件快照)、一个 树 对象 (记录着目录结构和 blob 对象索引)以及一个 提交 对象(包含着指向前述树对象的指针和所有提交信息)在这里插入图片描述

  • 做些修改后再次提交,那么这次产生的提交对象会包含一个指向上次提交对象(父对象)的指针在这里插入图片描述

  • Git 的分支,其实本质上仅仅是指向提交对象的可变指针。 Git 的默认分支名字是 master。 在多次提交操作之后,你其实已经有一个指向最后那个提交对象的 master 分支。 master 分支会在每次提交时自动向前移动
    在这里插入图片描述

6.1、单链表:next指针 (尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址NULL,表示这是链表上最后一个结点)
public class ListNode {
	int val;
	ListNode next;
	ListNode(int x) {
		val = x;
	}
}

循环链表:循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表(比如著名的约瑟夫问题)

ListNode p = null;//在单链表的基础之上,链尾指向链头
q =p;
for (int i = 2; i <= N; i++) {
	p = p.getNext();
	p.setVal(i);
}
p.setNext(q);//构建循环链表

在遍历循环链表时得特别小心,否则将会无限地遍历链表,因为循环链表每一个结点都有一个后继结点
双向链表:(需要额外的两个空间来存储后继结点next和前驱结点的地址prev)

public class ListNode {
	int value;
	ListNode prev;
	ListNode next;
	ListNode(int key, int val) {
		this.key = key;
		this.value = val;
	}
}

使用技巧:
1、理解指针或引用的含义:是存储所指对象的内存地址(将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针)
2、警惕指针丢失和内存泄漏 java不需考虑(使用jvm自动管理内存)
3、利用哨兵简化实现难度:如果我们引入哨兵结点,在任何时候,不管链表是不是空,head指针都会一直指向这个哨兵结点(插入排序、归并排序、动态规划)
删除最后一个结点和删除其他节点,插入第一个结点和插入其他节点可以统一为相同的代码逻辑。
哨兵的好处:它可以减少特殊情况的判断,比如判空,判越界,因为空可越界可认为是小概率情况,如实每次执行代码都走一遍,大多数情况下是多于的。
比如给一个哨兵节点,以及将key赋值给末尾元素,让数组遍历不用判断越界也可以因为相等停下来。
4、重点留意便捷条件处理:(如果链表为空时,代码是否能正常工作?如果链表只包含一个结点时,代码是否能正常工作?代码逻辑在处理头结点和尾结点的时候,是否能正常工作?)
5、举例画图,辅助思考:(举例法和画图法)


6.2、描述一下链式存储结构
  • 可以用任意一组存储单元来存储单链表中的数据结构(可以不连续),存储每个元素的值a,还必须存储后集结点的信息,这两个信息组成结点。

6.3、倒排一个LinkedList(即链表的反转) LeetCode 21

开发中使用集合工具包:Collections.reverse(List<?> list)

原理:i m n相邻,调整指针的指向,调整m的指向,指向结点i,链表会断开,需要在调整之前把n保存起来 代码P236

public class 链表反转 {
//单链表的反转 调整指针的指向,在调整next指针之前,需要保存前一个值 反转后链表的头结点为原始链表的尾节点,即next为空指针的节点
	public void reverseIteratively(Node head) {
		Node pReversedHead = head;
		Node pNode = head;
		Node pPrev = null;
		while (pNode != null) {
			Node pNext = pNode.next;
			if (pNext == null) {
				pReversedHead = pNode;//pNode此时为最后一个结点 反转后链表的头结点为原始链表的尾节点
			}
			pNode.next = pPrev;
			pPrev = pNode;
			pNode = pNext;
		}
		head = pReversedHead;
}		

6.4、判断一个单链表中是否有环? 阿里 LeetCode141
  • 思路1:蛮力法
    若链表中出现多个结点的后继指针重复,就表明存在环。从第一个结点开始,令其为当前节点,然后看看链表中其他节点的后继指针是否指向当前结点,如果存在,说明链表中存在环。
    缺点:如果不能确定链表的表尾,算法将会出现死循环。

*思路2:使用散列表(时间复杂度O(n),空间复杂度O(n))
从表头节点开始,逐一遍历链表中的每个结点;
对于每个结点,检查该结点的地址是否存在于散列表中;
如果存在,则表明当前访问的结点已经被访问过,出现此情况的原因是给定的链表中存在环;
如果散列表中没有当前节点的地址,那么把该地址插入散列表中;
重复上述过程,直至到达表尾或找到环。

  • 思路3:如果一个单链表中有环,用一个指针去遍历,永远不会结束,所以可以用两个指针,一个指针一次走一步,另一个指针一次走两步,如果存在环,则这两个指针会在环内相遇,时间复杂度为O(n) indeed(无论环的个数是奇数还是偶)被称为Floyd算法
public static  boolean checkCircle(Node list){
	if (list == null) {
		return false;
	}
	Node fast = list.next;
	Node slow = list;
	while (fast != null && fast.next !=null) {
		fast = fast.next.next;
		slow = slow.next;
		if (slow ==fast) {
			return true;
		}
	}        
	return false;
}//时间复杂度O(n) 空间复杂度O(1)

对floyd算法的补充:如果两个指针每次分别移动2个结点和3个结点,而不是移动一个和2个结点,算法仍然有效吗?
可以,算法的复杂度可能增加


6.5、判定给定的链表是否已NULL结束,如果链表中存在环,返回环的长度?

思路:在找到链表中的环后,保持slowPtr指针不变,fastPtr指针则继续移动,每次移动fastPtr指针时,计数器变量加1,直至再一次回到slowPtr指针所在的位置,即为环的长度。

public class 检测环的长度 {
	int FindLoopLength(ListNode head){
		ListNode slowPtr =head,fastPtr =head;
		boolean loopExists = false;
		int counter = 0;
		if (head == null) {
			return 0;
		}
		while (fastPtr.next != null && fastPtr.next.next != null) {
			slowPtr = slowPtr.next;
			fastPtr = fastPtr.next.next;
			if (slowPtr == fastPtr) {
				loopExists =true;
				break;
			}
		}
		if (loopExists) {
			fastPtr =fastPtr.next;
			while (slowPtr != fastPtr) {
				fastPtr =fastPtr.next;
				counter++;
			}
			return counter;
		}
		return 0;  //链表中不存在环
	}
}	//时间复杂度O(n)

补充:此思路可以引申为 求循环小数的开始位置(小数点之后的位数)和循环长度


6.6、快慢指针能解决的问题? 阿里
  • 1、已知单链表的头指针,查找到倒数第K个节点,然后删除这个节点
    思路1:快慢指针法:
    我们定义一个快指针P和慢指针Q,先让P指针走到K个节点位置,然后Q指针从头指针开始和P一起移动,当P移动到尾部的时候,那么此时Q节点所在的位置就是倒数第K个节点
public static Node deleteLastKth(Node list,int k){
	Node fast =list;
	int i =1;
	while (fast !=null && i<k) {
		fast =fast.next;
		++i;//第一个指针先走k步
	}
	if (fast ==null) {
		return list;
	}
	Node slow =list;
	Node prev =null;
	while (fast.next !=null) {
		fast = fast.next;
		prev =slow;  //prev为倒数第k个数
		slow =slow.next;
	}
	if (prev ==null) {
		list = list.next;
	}else {
		prev.next =prev.next.next;
	}
	return list;
}//时间复杂度O(n)

思路2:蛮力法(时间复杂度最高)
从链表的第一个结点开始,统计当前节点后面的结点个数。如果后面的节点个数小于k-1,算法结束;如果大于k-1,则移动到下一个结点,重复该过程
思路3:散列表O(m) 为了减少链表遍历的次数
散列表的条目是<结点的位置,结点地址>,在遍历链表时,可以得到链表的长度,令M表示链表的长度,这样求链表的导师胡第n个结点的问题转变为求链表正数
第M-n+1个结点。返回散列表中主键为M-n+1的值即可。时间复杂度O(m),空间复杂度O(m):创建一个大小为M的散列表。

  • 2、已知单链表的头结点,查找到链表的中间节点(只允许扫描一次)
    一个快指针P和慢指针Q,P和Q同时从头指针出发,快指针P每次移动两步,慢指针每次移动一步,当快指针P到尾部的时候,慢指针Q所在的位置就是中间节点的位置
public class 找到链表的中间节结点 {
	ListNode FindMiddle(ListNode head) {
		ListNode ptr1x, ptr2x;
		ptr1x = ptr2x = head;
		int i = 0;
		//不断循环,直至第一个指针到达表尾
		while (ptr1x.getNext() !=null) {
			if (i == 0) {
				ptr1x =ptr1x.getNext();//只移动第一个指针
				i = 1;
			}
			else if (i== 1) {
				ptr1x = ptr1x.getNext();
				ptr2x = ptr2x.getNext();
				i =0;
			}
		}        
		return ptr2x;//返回ptr2x的值,即为中间结点
	}
}//时间复杂度O(n)  空间复杂度O(1)

6.7、实现两个有序的链表合并为一个有序链表(双重遍历) LeetCode23 合并k个排序链表

思路:使用分治的思想,两两归并

class Solution {
	 public ListNode mergeKLists(ListNode[] lists) {

		if (lists.length == 0)
			return null;
		if (lists.length == 1)
			return lists[0];
		if (lists.length == 2) {
			return mergeTwoLists(lists[0], lists[1]);
		}
		int mid = lists.length/2;
		ListNode[] l1 = new ListNode[mid];
		for(int i = 0; i < mid; i++){
			l1[i] = lists[i];
		}
		ListNode[] l2 = new ListNode[lists.length-mid];
		for(int i = mid,j=0; i < lists.length; i++,j++){
			l2[j] = lists[i];
		}
		return mergeTwoLists(mergeKLists(l1),mergeKLists(l2));

	}
	//两个有序链表合并为一个新的有序链表  递归的方法
	public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
		if (l1 == null) return l2;
		if (l2 == null) return l1;

		ListNode head = null;
		if (l1.val <= l2.val){
			head = l1;
			head.next = mergeTwoLists(l1.next, l2);
		} else {
			head = l2;
			head.next = mergeTwoLists(l1, l2.next);
		}
		return head;
	}
}

6.8、在有序链表中插入一个结点
		public class 在有序链表中插入一个结点 {
			ListNode InsertSortedList(ListNode head, ListNode newNode){
				ListNode current =head;
				ListNode temp = null;
				if (head ==null) {
					return newNode;
				}
				//遍历链表,直至找到比新节点中数据值更大的节点
				while (current != null && current.val < newNode.val) {
					temp = current;//temp为current的上一个节点
					current = current.next; //current为比newNode值大的数
				}
				//在该结点前插入新节点
				newNode.setNext(current);
				temp.setNext(newNode);
				return null;  
			}   
		}//时间复杂度O(n)

6.9、求两个单向链表的合并点,合并后成为一个单向链表。假设链表list1和链表list2在相交前的节点数量分别为n和m,n/m大小不确定,求两个链表的合并点。

方法1:蛮力法
把第一个链表中的每一个结点指针与第二个链表中的每一个结点指针比较,当结点相等时,即为相交结点。时间复杂度为O(mn)
方法2:散列表
选择结点较少的链表(若链表长度未知,那么随便选择一个链表),将其所有结点的指针值保存在散列表中;遍历另一个链表,对于该链表中的每一个结点,检查散列表
中是否已经保存了其结点指针。如果两个链表存在合并点,那么必定会在散列表中找到记录。时间复杂度O(m)+O(n);空间复杂度O(m)或O(n)
方法3:两个栈
创建两个栈,然后遍历两个链表,分别把所有结点存入第一个和第二个栈,两个栈包含了对应链表的结点地址,比较两个栈的栈顶元素,如果相等,则弹出两个栈
的栈顶元素并保存在临时变量中,继续上述操作,直至两个栈的栈顶元素不相等,此时即找到了两个链表的合并点。时间复杂度O(m+n),空间复杂度O(m+n)
方法4:时间复杂度超低的解法
获取两个链表L1/L2的长度,O(max(m,n));计算两个长度的差d,从较长链表的表头开始,移动d步,然后两个链表同时移动,直至出现两个后继指针相等的情况。

			public class 求两个链表的合并点 {
				ListNode FindIntersectingNode(ListNode list1, ListNode list2){
					int L1=0,L2=0,diff=0;//L1为第一个链表的长度,L2为第二个链表的长度,diff为两链表的差值
					ListNode head1=list1,head2=list2;
					while (head1 !=null) {
						L1++;
						head1 = head1.getNext();
					}
					while (head2 !=null) {
						L2++;
						head2 = head2.getNext();
					}
					if (L1<L2) {
						head1 = list2;
						head2 = list1;
						diff = L2-L1;
					}
					else  {
						head1 = list1;
						head2 = list2;
						diff = L1-L2;
					}
					for (int i = 0; i < diff; i++) {
						head1 = head1.getNext();
					}
					while (head1 != null && head2 != null) {
						if (head1 == head2) {
						   return head1;
						}
						head1= head1.getNext();
						head2 = head2.getNext();
					}
					return null;
				}
			}//时间复杂度O(max(m,n)) 空间复杂度O(1)

6.10、如何判断一个字符串(链表)是否是回文字符串的问题(字符串是通过单链表来存储)(上海自来水来自海上)

1)前提:字符串以单个字符的形式存储在单链表中。
2)遍历链表,判断字符个数是否为奇数,若为偶数,则不是。
3)将链表中的字符倒序存储一份在另一个链表中。
4)同步遍历2个链表,比较对应的字符是否相等,若相等,则是水仙花字串,否则,不是。
思路2:使用快慢两个指针找到链表中点,慢指针每次前进一步,快指针每次前进两步。在慢指针前进的过程中,同时修改其 next 指针,使得链表前半部分反序。最后比较中点两侧的链表是否相等
时间复杂度O(n) 空间复杂度O(1)


6.11、O(1)时间内删除单链表中某一个节点

把后一个元素赋值给待删除节点,这样也就相当于是删除了当前元素
1. 如果待删除节点不是最后一个节点,就用他的next节点的value覆盖它的value,然后删掉它的next节点
2、如果是最后一个节点,顺序遍历o(n)


6.12、如何逐对逆置链表?初始1->2->3->4->X,逐对转置后,为2->1->4->3->X。
//递归版本
ListNode ReversePairRecursive(ListNode head){
	ListNode temp;
	if (head ==null || head.next == null) {
		return head;  //当前链表为空或只有一个元素
	}else {
		//逆置第一对
		temp = head.next;
		head.next = temp.next;//第一个结点的下一个为第三个结点
		temp.next = head;//第一个结点变为第二个
		head =temp;//第二个结点变第一个
		head.next.next=ReversePairRecursive(head.next.next);
		return head;
	}
}

6.13、约瑟夫环(N个人想选出一个领头人,他们排成一个环,沿着环每数到第M个人就排除该人,并从下一个人开始重新数,求最后留在环中的人)
		/**
		 * @param N 人数
		 * @param M 需要排除的人序号
		 * @return 最后留下来的人
		 */
		ListNode GetJosephusPosition(int N, int M){
			ListNode p = null,q;
			//建立一个包含所有人的循环链表
			p.setVal(1);
			q =p;
			for (int i = 2; i <= N; i++) {
				p = p.getNext();
				p.setVal(i);
			}
			p.setNext(q);//构建循环链表
			for (int count = N; count >1; --count) {
				for (int i = 0; i < M-1; i++) {
					p = p.getNext();
				}
				p.setNext(p.getNext().getNext());//删除选手
			}
			return p;//最后留下的勇者
		}
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员 jet_qi

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值