数组是数据结构中最核心、最重要、最变化多端的数据结构,如何操作数组的元素,成为了基于数组存储结构的算法研究方向之一。本文主要说明在操作数组元素时的两种思想,这不是新事物,只是一种笔者在学习过程中的一种总结归纳,希望对读者有益!
目录
数据结构的增删改查思想
在关系型数据库中,不管是对于数据库本身,还是对于表,都有且仅有四种基本的操作:增删改查,其他的操作都是这四种基本操作的变形或是综合。
将这种思想引入到数据结构中:数据结构中的“结构”,具有四种增、删、改、查四种基本的操作,其他的高级操作都是这四种基本操作的变形与综合。其中,“增”的含义是:创建、插入、新增,“删”的含义是:删除元素,“改”的含义是:修改,“查”的含义是:查找,查询。
例如:在一个数组(一种线性顺序表)中,在指定的下标中插入元素涉及到的操作有:查找,移动(修改),插入,共计三种基本操作。
于是,学习任意一种数据结构,只要牢牢把握住这种“结构”的增删改查四种基本操作,其他的操作都是四种基本数据类型的变形与综合!
数组中的多下标思想
背景:
- 数组中每个元素都有一个下标,可以通过该下标对元素直接操作,这也叫做随机访问
- 数组的所有操作都是通过下标来进行的。例如,遍历数组通过一个从0开始到数组长度LEN的区间[0, LEN)的遍历下标来实现的。
- 那实现以下复杂的操作,如在一次遍历中就实现大元素放右边,小元素放左边,该如何实现?答案是引入多个下标,同时对数组元素进行操作。
多下标的类型:
- 从两端向中间的双下标
- 从头开始且相邻的双下标
- 快慢下标思想(又叫快慢指针思想)
- 三个及三个以上下标
实例1-数组Partition
问题:
给定一个数组arr,和一个数num,请把小于等于num的数放在数组的左边,大于num的数放在数组的右边。要求额外空间复杂度O(1),时间复杂度O(N)。
算法思想:
- 下标x的含义是:下标为0~x之间的数全部是小于等于num的,也就是该区域、范围里的数是小于等于num的;初始时x的值为-1,表示还不存在有小于等于num的范围。
- 遍历:如果遇到a[i]≤num,则把a[i]和上述区域内的最后一个值交换,然后再扩大上述区域;如果遇到a[i]>num,直接跳到下一个位置,即i++;
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
void exchange(int* a, int len, int num)
{
int i; //遍历使用的下标
int x; //[0,x]区域内的所有数都小于等于num
int t; //交换两个数时使用的临时变量
x = 0;//x = -1; //初始时小于等于num的区域不存在
for (i = 0; i < len; i++)
{
if (a[i] < num)
{
t = a[i]; a[i] = a[x]; a[x] = t;
x += 1; //属于小于等于num的区域扩大
}
}
}
int main()
{
int len = 5;
int num = 5;
int i;
scanf("%d", &len);
int* a = (int*)malloc(sizeof(int) * len);
for (i = 0; i < len; i++)
{
scanf("%d", &a[i]);
}
scanf("%d", &num);
exchange(a, len, num);
for (i = 0; i < len; i++)
{
printf("%d ", a[i]);
}
return 0;
}
实例2-荷兰国旗问题
问题:
给定一个数组arr,和一个数num,请把小于num的数放在数组的左边,等于num的数放在数组的中间,大于num的数放在数组的右边。要求额外空间复杂度O(1),时间复杂度O(N)
算法思想:(三下标思想)
遍历过程中:
- 如果发现a[cur]小于num,则直接把a[cur]与[0, less]区域里的最后一个数交换,然后less++,cur++(也就是小于num的区域内的数量增加,又继续看下一个数);
- 如果a[cur]等于num,则什么都不做,直接看下一个;
- 如果a[cur]>num,则把a[cur]和[more, N-1]区域的第一个数交换,然后,more--(也就是more向前扩一个位置);然后看换过来的那个数,是等于num的?还是小于num的?再执行“①②”中所提到的操作;
- 当cur等于more相等的时候,表示停止操作。初始时less=-1,more=N。
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
void NetherlandsFlag(int* a, int L, int R, int num)
{
int less = L - 1;
int more = R + 1;
int cur = L;
int t;
while (cur < more)
{
if (a[cur] < num) //对应算法思想中的①操作
{
less++; //因为初始时less=L-1,当L=0时,less=-1;若不先加一,则a[-1]不合法!
t = a[less]; a[less] = a[cur]; a[cur] = t;
cur++;
}
else if (a[cur] > num) //对应算法思想中的③操作
{
more--;
t = a[cur]; a[cur] = a[more]; a[more] = t;
}
else //对应算法思想中的②操作
{
cur++;
}
}
//最终的等于区域是:下标为[less+1, more-1]
}
int main()
{
int len = 5;
int num = 5;
int i;
scanf("%d", &len);
int* a = (int*)malloc(sizeof(int) * len);
for (i = 0; i < len; i++)
{
scanf("%d", &a[i]);
}
scanf("%d", &num);
NetherlandsFlag(a, 0, len-1, num);
for (i = 0; i < len; i++)
{
printf("%d ", a[i]);
}
return 0;
}
链表中的快慢指针思想
对于链表来说,它没有数组那样的随机访问,只能从头到尾或者从尾到头依次遍历,那如何实现更加复杂的操作呢?答案还是引入多个遍历指针。
为了简化问题,专注于对思想的理解,我们以单链表为例,理解链表中的快慢指针。
用最快的方法获取单链表的中间值
利用快慢指针思想获取链表的中间值:
- 定义一个快指针pa,一个慢指针pb;快指针比慢指针快两倍;
- 同时遍历链表,等快指针到链表末尾时,就意味着慢指针到链表中间;
快慢指针获取中间值
public class MainTest {
public static void main(String[] args) throws Exception {
Node<String> first = new Node<String>("aa", null);
Node<String> second = new Node<String>("bb", null);
Node<String> third = new Node<String>("cc", null);
Node<String> fourth = new Node<String>("dd", null);
Node<String> fifth = new Node<String>("ee", null);
Node<String> six = new Node<String>("ff", null);
Node<String> seven = new Node<String>("gg", null);
//完成结点之间的指向
first.next = second;
second.next = third;
third.next = fourth;
fourth.next = fifth;
fifth.next = six;
six.next = seven;
//查找中间值
String mid = getMiddle(first);
System.out.println("中间值为:"+mid);
}
public static String getMiddle(Node<String> head) {
Node<String> fast = head;
Node<String> slow = head;
while(fast.next!=null && slow.next!=null) {
fast = fast.next.next;
slow = slow.next;
}
return slow.data;
}
private static class Node<T> {
T data;
Node<T> next;
public Node(T data, Node<T> next) {
this.data = data;
this.next = next;
}
}
}
判断单链表中是否有环
利用快慢指针思想判断单链表是否有环的问题:
主要思想:
- 设置一个快指针pa,一个慢指针pb,(这里假定快指针比慢指针快两倍);
- 当慢指针的遍历完毕了整个链表,都没有慢指针的值等于快指针的值时,则说明此链表无环;
- 反之,当慢指针遍历链表的过程中,发生的快慢指针的值相等的情况时,则说明此链表有环!
快慢指针判断是否有环
public class MainTest {
public static void main(String[] args) throws Exception {
Node<String> first = new Node<String>("aa", null);
Node<String> second = new Node<String>("bb", null);
Node<String> third = new Node<String>("cc", null);
Node<String> fourth = new Node<String>("dd", null);
Node<String> fifth = new Node<String>("ee", null);
Node<String> six = new Node<String>("ff", null);
Node<String> seven = new Node<String>("gg", null);
first.next = second;
second.next = third;
third.next = fourth;
fourth.next = fifth;
fifth.next = six;
six.next = seven;
//产生环
seven.next = third;
boolean circle = isCircle(first);
System.out.println("first链表中是否有环:"+circle);
}
public static boolean isCircle(Node<String> first) {
//定义快慢指针
Node<String> fast = first;
Node<String> slow = first;
//遍历链表,如果快慢指针指向了同一个结点,那么证明有环
while(fast!=null && fast.next!=null){
//变换fast和slow
fast = fast.next.next;
slow = slow.next;
if (fast.equals(slow)){
return true;
}
}
return false;
}
private static class Node<T> {
T data;
Node<T> next;
public Node(T data, Node<T> next) {
this.data = data;
this.next = next;
}
}
}
单链表中环的入口
当快慢指针相遇时,我们可以判断到链表中有环,这时重新设定一个新指针指向链表的起点,且步长与慢指针一样为1,则慢指针与“新”指针相遇的地方就是环的入口。证明这一结论牵涉到数论的知识,这里略,只讲实现。
检测环的入口
public class MainTest {
public static void main(String[] args) throws Exception {
Node<String> first = new Node<String>("aa", null);
Node<String> second = new Node<String>("bb", null);
Node<String> third = new Node<String>("cc", null);
Node<String> fourth = new Node<String>("dd", null);
Node<String> fifth = new Node<String>("ee", null);
Node<String> six = new Node<String>("ff", null);
Node<String> seven = new Node<String>("gg", null);
first.next = second;
second.next = third;
third.next = fourth;
fourth.next = fifth;
fifth.next = six;
six.next = seven;
//产生环
seven.next = third;
System.out.println("有环链表中的入口:"+getCircleEntrance(first).data);
}
public static Node<String> getCircleEntrance(Node<String> first) {
Node<String> fast = first;//定义快慢指针
Node<String> slow = first;
Node<String> temp = null;
//遍历链表,先找到环(快慢指针相遇),准备一个临时指针,指向链表的首结点,继续遍历,
// 直到慢指针和临时指针相遇,那么相遇时所指向的结点就是环的入口
while(fast!=null && fast.next!=null){
fast = fast.next.next;
slow = slow.next;
//判断快慢指针是否相遇
if (fast.equals(slow)){
temp = first; //让辅助指针指向首节点
continue;
}
//让辅助结点前进
if (temp!=null){
temp = temp.next;
//判断临时指针是否和慢指针相遇
if (temp.equals(slow)){
break;
}
}
}
return temp;
}
private static class Node<T> {
T data;
Node<T> next;
public Node(T data, Node<T> next) {
this.data = data;
this.next = next;
}
}
}
辅助的数组、链表思想
其实就是用空间换时间的思想,将时间复杂度转化到了空间复杂度上。
例如:在上文的数组Partition实例中,我们可以开辟两个新的数组M、N,把大于num的元素放在M中,把小于num的元素放在N中,最后将N、M中的元素依次又复制回去。但这种做法并不实用!
总结:
- 如果是数组,可以使用多下标的思想解决问题。快慢下标、从两端向中间的双下标、多下标等。灵活地使用多个下标就能高效地解决问题。
- 如果是链表,可以使用快慢指针的思想解决问题。快指针比慢指针快多少取决于具体的场景。