排序、KMP、链表
1 排序
排序算法比较
排序算法的稳定性:
假定在待排序的序列中存在多个具有相同关键字的记录,若经过排序,这些记录的相对次序保持不变,则称排序算法是稳定的。简而言之就是,序列中相同的记录不会做额外的互相交换,减少不必要的开销。
1.1 Shell排序
希尔排序又称作缩小增量排序,它利用了插入排序的最佳时间代价特性,先将待排序序列变为基本有序的,然后再进行插入排序来完成最后的排序工作。
工作过程:设置不同的增量将序列分成多组子序列,分别对子序列运用插入排序;然后缩小增量,重复上述过程;直至增量为1,此时便相当于对整个序列最后进行一次插入排序,但因为序列已经基本有序,因此插入排序的效率很高。
代码实现 1 - 直观的希尔排序:
int shell_sort_orig(int *data, int length) {
int gap = 0; // 增量
int i = 0, j = 0, k = 0;
for(gap = length/2; gap > 0; gap /= 2) // loop1, 不断按除以2缩小增量
{
for(i = 0; i < gap; i++) // loop2, 根据增量进行分组
{
for(j = i + gap; j < length; j += gap) // loop3, 一个分组的插入排序外循环
{
int temp = data[j];
for(k = j - gap; k >= 0 && temp < data[k]; k -= gap) // loop4,该分组的插入排序内循环
{
data[k + gap] = data[k];
}
data[k + gap] = temp;
}
}
}
return 0;
}
实际上,loop2和loop3是可以合并到一起的。
代码实现 2 - 简洁的希尔排序:
int shell_sort(int *data, int length)
{
int gap = 0; //增量
int i = 0, j = 0;
for (gap = length / 2; gap >= 1;gap /= 2) // loop1,增量分组
{
for(i = gap; i < length; i ++) // loop2+3,魅族遍历
{
int temp = data[i];
for (j = i - gap; j >= 0 && temp < data[j]; j = j - gap)
{ //loop4 组内排序
data[j+gap] = data[j];
}
data[j+gap] = temp;
}
}
return 0;
}
1.2 快速排序
快速排序采用的是分治策略,首先选定一个轴值(pivot),我们希望能够将序列分成小于轴值和大于轴值的两部分,然后在这两部分中分别再选择一个轴值进行同样的操作,直至序列不能再继续被划分,则排序也完成了。是不是很像二叉检索树BST?而且也能用递归很容易地实现。
实质上,快速排序每处理一个子序列,都会将该子序列中的轴值放到其正确的位置(即最后完成排序后其应该在的位置),对应要处理n个轴值;在最佳情况下,子序列仅划分log(n)次即可(每次都能对半分的情况,类似BST能够平衡),于是总的时间复杂度是O(nlog(n));但若轴值选得不好,划分次数则最坏将达到n次,此时得到最坏时间复杂度是O(n^2)。
一般选择子序列的第一个元素作为轴值。
代码实现 :
//每一次递归, 确定一个值的正确位置
int sort(int *data, int left, int right)
{
if (left >= right) return 0; // 递归结束
int i = left;
int j = right;
int key = data[left];
while (i < j)
{
while (i < j && key < data[j])
{
j --;
}
data[i] = data[j]; // 在右边找到一个比轴值小的元素,将其移动到左侧
while (i < j && key >= data[i])
{
i ++;
}
data[j] = data[i]; // 在左边找到一个比轴值大的元素,将其移动到右侧
}
// 直至i == j
data[i] = key; // 确定了该轮轴值的正确位置
sort(data, left, i-1); // 进入左边的子序列
sort(data, i+1, right); // 进入右边的子序列
}
2 KMP算法
KMP作用: 用于提高字符串匹配的效率。利用KMP算法后匹配的时间复杂度为O(n+m)。
面试题:写一个在字符串text(长度n)中寻找子串pattern(长度m)出现的第一个位置的函数。
采用暴力匹配的时间复杂度为O(n*m),为了加快匹配速度,就应该考虑如何减少比较的次数。思路就是:每一次比较失败时,都会获得关于text的一些信息,可以利用这些信息尽可能地跳过后续一些不可能成功的字符串比较过程,从而达到加速的目的。(参考:如何更好地理解和掌握 KMP 算法?)
示例:
对于下图中的主串S和模式串P,从S[0]处开始比较,当在P的最后一个字符P[5]与S[5]出现匹配失败时,将P[0]向后移动3次到达S[3]才出现下一次可能成功的匹配。由于此时已经知道了P的前后各有一对子串 “ab” 均可以与S中的一对子串"ab"匹配,因此实际上可以跳过中间两次无效的比较,直接将P[0]移动到S[3]并从S[5]继续比较。
那么如何找到前后相同的子串呢?
2.1 引入一些概念
前缀:除最后一个字符外的其他连续字符的顺序组合;
后缀:除第一个字符外的其他连续字符的顺序组合;
示例:
给定一个字符串"ABCABCD",长度为n=7,除最后一个字符外可以可以提取出长度为1~6的6个子串。列出各个子串的前后缀,同时可以得到各个子串前后缀的最大公共元素数量k。找到最大公共元素后,就可以用于前面所述的加快字符串匹配过程,不难看出k就是下一轮匹配时模式串P可以直接跳过的长度,从P[k]开始比较。而求这个k的过程就是KMP算法的关键。
(图片为转载)
2.2 如何计算最大公共元素数量k(也即求next[ ]数组)
所谓next[]数组就是用来记录出现失配时下一次应该从哪个位置继续匹配。当匹配到P[j]时出现失配,说明P[j-1]是最后匹配到的位置,则令j=next[j-1],然后从P[j]开始下一轮匹配。可以想像的是:这里next[j-1]即为长度为j的那个模式串的最大公共元素数量k。
示例:
(图片为转载)
2.2.1 直观的方法 - 直接遍历
对于每一个模式串,找出其前缀和后缀能够完全匹配的最大长度,这需要两层for循环,外层循环构建每一个模式串,内层循环求最大公共长度,容易理解其时间复杂度为O(m^2)。
int make_next(char* pattern, int next[])
{
int patLen = strlen(pattern); // pattern的长度
int tailIdx = 0; // 模式串的最后一个字符的下标
int subPatLen = 0; // 前后缀的长度
for(tailIdx = 0; tailIdx < patLen - 1; tailIdx++) // 外层循环,构建每一个模式串,注意patLen-1
{
next[tailIdx] = 0; // 先初始化为0
for(subPatLen = tailIdx; subPatLen > 0; subPatLen--) // 内层循环,逐渐缩小前后缀长度来匹配最大的公共元素k
{
if(memcmp(&pattern[0], &pattern[tailIdx - (subPatLen - 1)], subPatLen) == 0)
{ // 比较当前的前后缀是否一致,若一致则匹配到了最大的公共元素k
next[tailIdx] = subPatLen;
break;
}
}
}
return 0;
}
2.2.2 快速方法
核心思想: pattern自己跟自己匹配。简单地说,就是较大模式串的next可以根据已计算的较小模式串的next来快速计算,减少往前回溯模式串去搜索最大公共串的次数。
以P="abcabdabcd"为例:
-
首先有next[0]=0,这是必然的。因此从next[1]开始计算即可。
-
以q指向某个模式串的尾部,如图中q=1,指向长度为q+1的模式串的尾部;一开始让k指向模式串的首部k=0,然后向后移动k遍历模式串的前缀进行next的计算。显然此时对于模式串“ab”,P[k] != P[q],因此不存在最大公共前缀,next[1]=0,并且k不变,q=q+1=2。
-
显然,q=2时,对于模式串“abc”,P[k] != P[q],也不存在最大公共前缀,next[2]=0,k不变,q=q+1=3。
-
再往后,终于对于模式串“abca”,P[k] == P[q],直观地来看有next[3]=1。但实际上,q=3(模式串“abca”)是在q=2(模式串“abc”)的基础上进行匹配的,因此应该认为next[3]=next[2]+1;此时开始出现匹配的情况,可以将k和q同时加1,往后移动,看是否存在更长的匹配。
-
按照这个思路,后续q=4,k=1时也是匹配的,因此对于更大的模式串"abcab”其最大公共元素在模式串“abca”的基础上得到扩展,因此next[4]=next[3]+1。
-
直至下图中q=5,k=2的情况下,对于“abcabd”又出现了失配;从图中直观地看出来肯定有next[5]=0,从程序的角度来说,按2.2.1中的方法往前回溯一遍模式串“abcabd”的前后缀就可以了,但如何基于之前计算的next[0~4]来快速地计算next[5]呢?此时的模式串还不能说明问题,按着这个思路我们先继续向后遍历。
-
直到我们遍历到下图的情况。此时出现失配,我们需要往前回溯模式串“abcabdabcabc”去找能够匹配的尽可能大的公共子串。不难发现,对于前面已经匹配的子串"abcab",与next[4](即next[k-1])的模式串“abcab”是一致的。于是问题就转换为在next[4]的模式串“abcab”中去找一个能够继续当前匹配的最大子串。之前模式串“abcab”匹配到最大子串时有k=next[4],我们可以归纳出此时应有k=next[k-1]的结论。
-
当然,并不是令k=next[k]=2后,就一定出现能够匹配的子串,还必须要求P[k]==P[q],若满足,则说明当前模式串能够在k=2对应子串的基础上继续匹配,因此有next[q]=k+1;否则还应该继续令k=next[k-1],再次判断是否匹配;直至k=0,都没有能够满足匹配的子串,则说明该模式串的前后缀不存在公共元素,next[q]=0,q加1,继续下一个模式串的匹配。
代码实现:
int make_next(char* pattern, int next[])
{
int patLen = strlen(pattern);
int prefixTail = 0; // k - 前缀的尾部
int subfixTail = 0; // q - 后缀的尾部
next[0] = 0;
for(subfixTail = 1; subfixTail < patLen; subfixTail++)
{
while(prefixTail > 0 && pattern[subfixTail] != pattern[prefixTail])
{
prefixTail = next[prefixTail - 1]; // 出现失配时,回退到上一个子串,k=next[k-1]
}
if(pattern[subfixTail] == pattern[prefixTail])
{
prefixTail++; // 若匹配,则继续扩大前缀长度,k++
}
next[subfixTail] = prefixTail;
}
}
2.3 实现KMP算法及测试程序
int kmp(char* text, char* pattern)
{
int next[20] = {0}; // next的实际长度要根据pattern的长度来确定
int t = 0; // text的索引
int p = 0; // pattern的索引
int patLen = strlen(pattern);
make_next(pattern, next);
while (text[t])
{
if(text[t] != pattern[p])
{
if(p == 0)
t++; // 没有能匹配的模式串,t后移
else
p = next[p - 1]; // 获取能匹配的模式串,t不动,进行下一次匹配
}
else
{
p++; // 相同则向后移动
t++;
if(p == patLen) // 匹配完整,可返回
return t - patLen;
}
}
return -1;
}
/* 测试程序 */
int main()
{
char *text = "eabcabcabcabcabcdabc";
char *pattern = "abcabcd";
int pos = 0;
int i;
pos = kmp(text, pattern);
printf("## KMP result: %d\n", pos);
printf("## %s\n## ", text);
for(i = 0; i < pos; i++)
{
printf(" ");
}
printf("%s\n", pattern);
return 0;
}
测试结果输出:
# KMP result: 10
## eabcabcabcabcabcdabc
## abcabcd
3 链表常见问题
定义链表节点:
struct LinkNode {
int val;
LinkNode *next;
};
3.1 判断链表中是否存在环
快慢指针:也是遍历,只不过慢指针每次移动一个节点,快指针每次移动两个节点,若存在环则最终二者能相遇。时间复杂度降为O(n),但无法确定形成环的交点。
/* 快慢指针判断链表是否有环 */
bool myLink::isListLooped(LinkNode* h) const
{
if(h == nullptr)
{
return false;
}
LinkNode *slow = h; // 慢指针,一次前进1个节点
LinkNode *fast = h->next; // 快指针,一次前进2个节点
while(slow != nullptr && fast != nullptr)
{
if(slow == fast)
{
return true;
}
slow = slow->next;
fast = (fast->next != nullptr) ? fast->next->next : fast->next;
}
return false;
}
3.2 判断两个链表是否有重叠
暴力解法:时间复杂度O(m*n)
节点数差值法:链表A有m个节点,链表B有n个节点,且 m > n,则链表A从第m-n个节点开始遍历,链表B依然从0开始遍历。复杂度O(n)。
例如:共n个节点,求倒数第k个节点
做法:两个指针,一个先走k个节点,然后两个一起走;当前面指针那个走到最后一个节点时,后面的那个指针指向的就是倒数第k个节点
/* O(n)复杂度下 判断链表是否重叠,返回的是交点 */
LinkNode* myLink::isListCrossed(LinkNode* h1, LinkNode* h2) const
{
int nodeNum1 = countListNodeNum(h1); // 计算节点数目
int nodeNum2 = countListNodeNum(h2); // 计算节点数目
int nodeNumMinus = 0;
if(nodeNum1 > nodeNum2)
{
nodeNumMinus = nodeNum1 - nodeNum2;
h1 = getNthNode(h1, nodeNumMinus); // 链表1先走几个节点
}
else
{
nodeNumMinus = nodeNum2 - nodeNum1;
h2 = getNthNode(h2, nodeNumMinus); // 链表2先走几个节点
}
while(h1 != nullptr && h2 != nullptr)
{
if(h1 == h2)
{
return h1; // 找到二者的交点
}
h1 = h1->next;
h2 = h2->next;
}
return nullptr;
}
3.3 链表反转
遍历链表,并利用三个指针就地反转前后节点的指向。
LinkNode* myLink::reverseList(LinkNode* h)
{
LinkNode* prev = nullptr;
LinkNode* current = h;
LinkNode* next;
while (current != nullptr)
{
next = current->next; // 一定要在确保current != nullptr的情况才执行该句
current->next = prev;
prev = current;
current = next;
}
return prev;
}
3.4 单向链表如何找到倒数第k个节点
用递归做比较简洁,但循环效率更高。
方法: 一前一后两个指针,后者先走k个节点,然后二者同时前进,直到后者到达链表尾部,前者指向的节点即为倒数第k个节点
LinkNode* myLink::rfindNodeNo(LinkNode* h, const int & k) const
{
LinkNode* tail;
if(k <= 0)
{
return nullptr;
}
tail = getNthNode(h, k - 1); // 找到第N个节点,N从0开始数,所以 k-1
if(tail == nullptr)
{
return nullptr;
}
while(tail->next != nullptr)
{ // 二者开始同时前进
tail = tail->next;
h = h->next;
}
return h;
}
补充_3.5:带有环的单向链表如何清空?
问题: 当交点被释放后,链表名义上的最后一个节点的next指针依然指向交点原先的地址,此时该指针并不是nullptr,因此无法直接判断是否遍历到了链表的尾部。
存在重叠的链表释放时也可能存在这个问题。