Remove Nth Node From End of List
LeetCode의 Remove Nth Node From End of List 문제를 함께 풀어보도록 하겠습니다.
문제
링크드 리스트가 주어졌을 때, 끝에서 n번째 노드를 제거 후, 그 링크드 리스트의 헤드를 리턴하라.
예제
링크드 리스트가 1->2->3->4->5
이고 n이 2라면, 1->2->3->5
를 리턴해야 한다.
왜냐하면 끝에서 2번째 노드는 4
이기 때문이다.
풀이 1
앞에서 n번째 노드를 제거하는 문제라면 좀 쉬웠을텐데, 끝에서 n번째 노드를 제거해야 하는 살짝 더 까다롭게 느껴지죠?
왜냐하면 이렇게 단반향 링크드 리스트는 특성상 맨 끝까지 실제로 따라가보지 않고서는 어디가 끝인지 알수가 없기 떄문입니다.
따라서 이 문제는 아무리 효율적으로 해결하더라도 O(n)
시간 복잡도를 능가하기는 어려울 것 같네요.
이 문제를 해결할 수 있는 가장 단순한 방법은 주어진 링크드 리스트를 끝까지 따라가서 길이를 먼저 알아낸 후, 다시 맨 처음부터 삭제할 노드까지 찾아가는 건데요.
링크드 리스트의 길이와 l
이라고하면, 앞에서 l - n
번째 노드가 우리가 삭제해야할 노드가 될 거에요.
여기서 중요한 것은 삭제할 노드까지 가버리면 그 노드를 삭제할 기회를 놓친다는 것입니다.
왜냐하면 노드를 삭제하기 위해서 실제로 포인터 변경 작업이 필요한 노드는 삭제할 노드가 아니라 삭제할 노드의 바로 앞에 있는 노드이기 때문이죠.
따라서 l - n - 1
번째 노드가 우리가 도달해야하는 노드이며, 이 노드가 삭제할 노드 대신에 삭제할 노드의 다음 노드를 가리키도록 해줘야합니디.
그럼 이 간단한 알고리즘을 파이썬으로 구현해볼까요?
# Definition for singly-linked list.
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def removeNthFromEnd(self, head: Optional[ListNode], n: int) -> Optional[ListNode]:
length = 0
node = head
while node:
length += 1
node = node.next
dummy = ListNode(None, head)
node = dummy
for _ in range(length - n):
node = node.next
node.next = node.next.next
return dummy.next
여기서 한가지 코딩팁은 dummy
노드를 사용해서 링크드 리스트에서 흔히 겪는 경계 사례(edge case)를 깔끔하게 처리할 수 있다는 것입니다.
이렇게 dummy
노드를 사용하면 링크드 리스트에 노드가 하나 밖에 없거나, n
이 l
과 동일하여 head
를 삭제해야하는 경우에도 버그를 피할 수 있습니다.
같은 알고리즘을 자바로 구현하면 다음과 같습니다.
// Definition for singly-linked list.
class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
int length = 0;
ListNode node = head;
while (node != null) {
length += 1;
node = node.next;
}
ListNode dummy = new ListNode(-1, head);
node = dummy;
for (int i = 0; i < length - n; i++) {
node = node.next;
}
node.next = node.next.next;
return dummy.next;
}
}
이 풀이는 링크드 리스트를 총 2번 루프를 돌긴 하지만 빅오 계산법에 따르면 l
을 링크드 리스트의 길이라고 했을 때 O(l)
시간 복잡도를 가집니다.
그리고 n
값이 클 수록 l - n
이 작아지므로, 두번째 루프가 성능에 미치는 영향이 점점 작아진다는 특성을 가지고 있습니다.
반대로 링크드 리스트의 길이가 엄청 긴데 n
값이 작으면 성능이 매우 떨어지게 됩니다.
공간 복잡도는 추가적인 메모리는 사용하지 않기 때문에 O(1)
입니다.
풀이 2
만약에 면접관이 루프를 한 번만 돌려서 이 문제를 풀 수 없겠냐고 물어본다면 어떻게 접근해야 할까요?
위 풀이에서 같은 링크드 리스트를 한 번 더 루프를 돈 이유는 단지 삭제할 노드 앞에 있는 노드에 도달하기 위함인데요. 만약에 첫 번째 루프를 돌 때 모든 노드를 상수 시간(constant time)에 접근이 가능한 자료 구조에 저장해놓는다면 링크드 리스트를 순회하는 것 보다 빠르게 삭제할 노드 앞에 있는 노드에 얻을 수 있을 것입니다.
예를 들어, 모든 노드를 배열에 저장해놓으면 인덱스가 l - n - 1
인 노드가 우리가 next
포인터를 변경해줘야 할 노드가 될 것입니다.
파이썬에 내장된 리스트 자료구조는 음수 인덱스로 원소 접근이 가능하기 때문에 다음과 같이 구현할 수 있습니다.
class Solution:
def removeNthFromEnd(self, head: Optional[ListNode], n: int) -> Optional[ListNode]:
nodes = []
dummy = ListNode(None, head)
node = dummy
while node:
nodes.append(node)
node = node.next
nodes[-n - 1].next = nodes[-n - 1].next.next
# nodes[len(nodes) - n - 1].next = nodes[len(nodes) - n - 1].next.next
return dummy.next
자바의 내장 리스트는 음수 인덱스로 원소 접근을 지원하지 않습니다.
따라서 size()
메서드로 리스트의 길이를 구한 후 양수 인덱스로 원소에 접근해야 합니다.
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
List<ListNode> nodes = new ArrayList<>();
ListNode dummy = new ListNode(-1, head);
ListNode node = dummy;
while (node != null) {
nodes.add(node);
node = node.next;
}
int length = nodes.size();
nodes.get(length - n - 1).next = nodes.get(length - n - 1).next.next;
return dummy.next;
}
}
이 풀이는 링크드 리스트를 총 1번 루프를 돌긴 하지만 빅오 기준으로는 첫번째 풀이법 동일한 O(l)
시간 복잡도를 가집니다.
반면, 주어진 링크드 리스트와 동일한 길이의 추가 자료 구조를 사용하기 때문에 공간 복잡도는 O(l)
으로 첫번째 풀이법보다 오히려 안 좋습니다.
하지만 링크드 리스트의 길이가 엄청나게 길고, n
값이 작은 상황에서는 첫번째 알고리즘보다 나은 성능을 보여주겠죠?
풀이 3
두번째 풀이에서 O(l)
공간 복잡도가 다소 아쉬웠는데요…
굳이 모든 노드를 저장할 필요가 있었을까요?
조금만 더 생각해보면 끝에서 n
번째 노드를 제거하기 위해서는 n
개의 노드만 저장해도 충분하다는 것을 깨닫게 됩니다.
n
개의 노드만 저장하려면 링크드 리스트를 루프 돌다가 저장 공간의 크기가 n
을 초과할 경우 가장 오래된 노드를 제거해야합니다.
이럴 때 적합한 자료구조는 FIFO(First In First Out)를 제공하는 큐(Queue)입니다.
링크드 리스트를 루프를 돌 때 큐의 길이를 체크해서 n
보다 클 경우, 가장 오래된 노드를 큐에서 제거하고 새로운 노드를 추가합니다.
루프가 끝나면 큐에 저장된 첫번째 노드의 next
필드를 큐에 저장된 세번째 노드로 업데이트해주면 됩니다.
파이썬에서는 collections
내장 모듈의 deque
자료구조를 사용합니다.
from collections import deque
class Solution:
def removeNthFromEnd(self, head: Optional[ListNode], n: int) -> Optional[ListNode]:
queue = deque()
dummy = ListNode(None, head)
node = dummy
for _ in range(n + 1):
queue.append(node)
node = node.next
while node:
queue.popleft()
queue.append(node)
node = node.next
queue[0].next = queue[0].next.next
return dummy.next
자바에서는 LinkList
가 Queue
인터페이스 구현체이기 때문에 사용하기 적합합니다.
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
LinkedList<ListNode> queue = new LinkedList<>();
ListNode dummy = new ListNode(-1, head);
ListNode node = dummy;
for (int i = 0; i < n + 1; i++) {
queue.add(node);
node = node.next;
}
while (node != null) {
queue.remove();
queue.add(node);
node = node.next;
}
queue.get(0).next = queue.get(0).next.next;
return dummy.next;
}
}
공간 복잡도가 O(l)
에서 O(n)
으로 향상되었습니다!
풀이 4
아예 추가 공간을 사용하지 않고 루프를 한 번만 돌 수 있는 방법은 없을까요? 두 개의 포인터를 사용해서 링크드 리스트를 순회하면 가능합니다!
일단 첫번째 포인터를 먼저 n
만큼 진행 시킵니다.
그리고 첫번째 포인터가 링크드 리스트의 끝에 다다를때까지 첫번째 포인터와 두번째 포인터를 동시에 진행 시킵니다.
그럼 두번째 포인터는 자연스럽게 끝에서 n
번째 노드를 가르키게 됩니다.
이 알고리즘도 마찬가지로 파이썬과 자바로 모두 구현해보겠습니다.
class Solution:
def removeNthFromEnd(self, head: Optional[ListNode], n: int) -> Optional[ListNode]:
first = head
for _ in range(n):
first = first.next
dummy = ListNode(None, head)
second = dummy
while first:
first = first.next
second = second.next
second.next = second.next.next
return dummy.next
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode first = head;
for (int i = 0; i < n; i++) {
first = first.next;
}
ListNode dummy = new ListNode(-1, head);
ListNode second = dummy;
while (first != null) {
first = first.next;
second = second.next;
}
second.next = second.next.next;
return dummy.next;
}
}
마지막에 풀이를 통해 공간 복잡도를 O(n)
에서 O(1)
으로 개선할 수 있게 되었습니다. 🥳
마치면서
많은 코딩 면접관들이 이 문제처럼 다양한 해결 방법을 가진 문제를 내는 것을 선호합니다. 왜냐하면 추가적인 제약사항을 제시하면서 지원자가 어떻게 문제를 해결하는지 평가하기 좋기 때문입니다.