메모리 공간은 모든 프로그램이 함께 사용하는 자원이다. 복잡한 시스템 실행 환경에서는 빈 메모리 공간이 곳곳에 흩어져 있을 수 있다. 배열을 저장할 메모리는 연속적이어야 하므로, 배열이 매우 크면 그만큼 큰 연속 공간을 확보하지 못할 수도 있다. 이때 연결 리스트의 유연성이 장점으로 드러난다.
연결 리스트(linked list)는 각 원소가 노드 객체이며, 노드들이 ‘참조’로 연결된 선형 자료 구조다. 참조는 다음 노드의 메모리 주소를 기록하므로, 이를 통해 현재 노드에서 다음 노드에 접근할 수 있다.
이러한 설계 덕분에 각 노드는 메모리 곳곳에 분산되어 저장될 수 있으며, 메모리 주소가 연속적일 필요가 없다.
그림 4-5 연결 리스트의 정의와 저장 방식
위 그림(4-5)에서 연결 리스트를 구성하는 단위는 노드(node) 객체다. 각 노드는 노드의 ‘값’과 다음 노드를 가리키는 ‘참조’라는 두 데이터를 포함한다.
연결 리스트의 첫 노드를 ‘머리 노드’, 마지막 노드를 ‘꼬리 노드’라고 한다.
꼬리 노드는 ‘없음’을 가리키며, Java, C++, Python에서는 이를 각각 null, nullptr, None으로 표기한다.
C, C++, Go, Rust처럼 포인터를 지원하는 언어에서는 위의 ‘참조’를 ‘포인터’로 바꾸어 이해하면 된다.
다음 코드와 같이 연결 리스트 노드 ListNode는 값 외에도 참조(포인터)를 하나 더 저장해야 한다. 따라서 같은 양의 데이터를 저장할 때 연결 리스트는 배열보다 메모리 공간을 더 많이 차지한다.
classListNode:"""연결 리스트 노드 클래스"""def__init__(self,val:int):self.val:int=val# 노드 값self.next:ListNode|None=None# 다음 노드를 가리키는 참조
/* 연결 리스트 노드 구조체 */structListNode{intval;// 노드 값ListNode*next;// 다음 노드를 가리키는 포인터ListNode(intx):val(x),next(nullptr){}// 생성자};
/* 연결 리스트 노드 클래스 */classListNode{intval;// 노드 값ListNodenext;// 다음 노드를 가리키는 참조ListNode(intx){val=x;}// 생성자}
/* 연결 리스트 노드 클래스 */classListNode(intx){//생성자intval=x;// 노드 값ListNode?next;// 다음 노드를 가리키는 참조}
/* 연결 리스트 노드 구조체 */typeListNodestruct{Valint// 노드 값Next*ListNode// 다음 노드를 가리키는 포인터}// NewListNode 생성자: 새 연결 리스트 생성funcNewListNode(valint)*ListNode{return&ListNode{Val:val,Next:nil,}}
/* 연결 리스트 노드 클래스 */classListNode{varval:Int// 노드 값varnext:ListNode?// 다음 노드를 가리키는 참조init(x:Int){// 생성자val=x}}
/* 연결 리스트 노드 클래스 */classListNode{constructor(val,next){this.val=(val===undefined?0:val);// 노드 값this.next=(next===undefined?null:next);// 다음 노드를 가리키는 참조}}
/* 연결 리스트 노드 클래스 */classListNode{val:number;next:ListNode|null;constructor(val?:number,next?:ListNode|null){this.val=val===undefined?0:val;// 노드 값this.next=next===undefined?null:next;// 다음 노드를 가리키는 참조}}
/* 연결 리스트 노드 클래스 */classListNode{intval;// 노드 값ListNode?next;// 다음 노드를 가리키는 참조ListNode(this.val,[this.next]);// 생성자}
usestd::rc::Rc;usestd::cell::RefCell;/* 연결 리스트 노드 클래스 */#[derive(Debug)]structListNode{val:i32,// 노드 값next:Option<Rc<RefCell<ListNode>>>,// 다음 노드를 가리키는 포인터}
/* 연결 리스트 노드 구조체 */typedefstructListNode{intval;// 노드 값structListNode*next;// 다음 노드를 가리키는 포인터}ListNode;/* 생성자 */ListNode*newListNode(intval){ListNode*node;node=(ListNode*)malloc(sizeof(ListNode));node->val=val;node->next=NULL;returnnode;}
/* 연결 리스트 노드 클래스 */// 생성자classListNode(x:Int){val_val:Int=x// 노드 값varnext:ListNode?=null// 다음 노드를 가리키는 참조}
# 연결 리스트 노드 클래스classListNodeattr_accessor:val# 노드 값attr_accessor:next# 다음 노드를 가리키는 참조definitialize(val=0,next_node=nil)@val=val@next=next_nodeendend
배열은 전체가 하나의 변수다. 예를 들어 배열 nums에는 nums[0], nums[1] 등의 원소가 들어 있다. 반면 연결 리스트는 독립된 여러 노드 객체로 이루어진다. 보통 머리 노드로 연결 리스트 전체를 지칭한다. 예를 들어 위 코드의 연결 리스트는 연결 리스트 n0라고 부를 수 있다.
연결 리스트에 노드를 삽입하기는 매우 쉽다. 아래 그림(4-6)과 같이 인접한 노드 n0와 n1 사이에 새 노드 P를 삽입하려면 노드 참조(포인터) 두 개만 바꾸면 된다. 시간 복잡도는 \(O(1)\)이다.
반면 배열의 원소 삽입은 시간 복잡도가 \(O(n)\)이므로 데이터가 많을 때 효율이 낮다.
그림 4-6 연결 리스트에 노드를 삽입하는 예
linked_list.py
definsert(n0:ListNode,P:ListNode):"""연결 리스트의 노드 n0 뒤에 노드 P 삽입"""n1=n0.nextP.next=n1n0.next=P
linked_list.cpp
/* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */voidinsert(ListNode*n0,ListNode*P){ListNode*n1=n0->next;P->next=n1;n0->next=P;}
linked_list.java
/* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */voidinsert(ListNoden0,ListNodeP){ListNoden1=n0.next;P.next=n1;n0.next=P;}
linked_list.cs
/* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */voidInsert(ListNoden0,ListNodeP){ListNode?n1=n0.next;P.next=n1;n0.next=P;}
linked_list.go
/* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */funcinsertNode(n0*ListNode,P*ListNode){n1:=n0.NextP.Next=n1n0.Next=P}
linked_list.swift
/* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */funcinsert(n0:ListNode,P:ListNode){letn1=n0.nextP.next=n1n0.next=P}
linked_list.js
/* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */functioninsert(n0,P){constn1=n0.next;P.next=n1;n0.next=P;}
linked_list.ts
/* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */functioninsert(n0:ListNode,P:ListNode):void{constn1=n0.next;P.next=n1;n0.next=P;}
linked_list.dart
/* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */voidinsert(ListNoden0,ListNodeP){ListNode?n1=n0.next;P.next=n1;n0.next=P;}
linked_list.rs
/* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */#[allow(non_snake_case)]pubfninsert<T>(n0:&Rc<RefCell<ListNode<T>>>,P:Rc<RefCell<ListNode<T>>>){letn1=n0.borrow_mut().next.take();P.borrow_mut().next=n1;n0.borrow_mut().next=Some(P);}
linked_list.c
/* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */voidinsert(ListNode*n0,ListNode*P){ListNode*n1=n0->next;P->next=n1;n0->next=P;}
linked_list.kt
/* 연결 리스트의 노드 n0 뒤에 노드 P 삽입 */funinsert(n0:ListNode?,p:ListNode?){valn1=n0?.nextp?.next=n1n0?.next=p}
linked_list.rb
### 연결 리스트의 노드 n0 뒤에 노드 _p 삽입 #### Ruby에서 `p`는 내장 함수이고 `P`는 상수이므로, 대신 `_p`를 사용할 수 있다definsert(n0,_p)n1=n0.next_p.next=n1n0.next=_pend
아래 그림(4-7)과 같이 연결 리스트의 노드 삭제도 매우 간단하다. 노드 하나의 참조(포인터)만 바꾸면 된다.
삭제 후에도 노드 P는 여전히 n1을 가리키지만, 연결 리스트를 순회해서는 더 이상 P에 접근할 수 없다. 따라서 P는 이미 이 연결 리스트에 속하지 않는다.
그림 4-7 연결 리스트의 노드 삭제
linked_list.py
defremove(n0:ListNode):"""연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제"""ifnotn0.next:return# n0 -> P -> n1P=n0.nextn1=P.nextn0.next=n1
linked_list.cpp
/* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */voidremove(ListNode*n0){if(n0->next==nullptr)return;// n0 -> P -> n1ListNode*P=n0->next;ListNode*n1=P->next;n0->next=n1;// 메모리 해제deleteP;}
linked_list.java
/* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */voidremove(ListNoden0){if(n0.next==null)return;// n0 -> P -> n1ListNodeP=n0.next;ListNoden1=P.next;n0.next=n1;}
linked_list.cs
/* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */voidRemove(ListNoden0){if(n0.next==null)return;// n0 -> P -> n1ListNodeP=n0.next;ListNode?n1=P.next;n0.next=n1;}
linked_list.go
/* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */funcremoveItem(n0*ListNode){ifn0.Next==nil{return}// n0 -> P -> n1P:=n0.Nextn1:=P.Nextn0.Next=n1}
linked_list.swift
/* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */funcremove(n0:ListNode){ifn0.next==nil{return}// n0 -> P -> n1letP=n0.nextletn1=P?.nextn0.next=n1}
linked_list.js
/* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */functionremove(n0){if(!n0.next)return;// n0 -> P -> n1constP=n0.next;constn1=P.next;n0.next=n1;}
linked_list.ts
/* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */functionremove(n0:ListNode):void{if(!n0.next){return;}// n0 -> P -> n1constP=n0.next;constn1=P.next;n0.next=n1;}
linked_list.dart
/* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */voidremove(ListNoden0){if(n0.next==null)return;// n0 -> P -> n1ListNodeP=n0.next!;ListNode?n1=P.next;n0.next=n1;}
linked_list.rs
/* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */#[allow(non_snake_case)]pubfnremove<T>(n0:&Rc<RefCell<ListNode<T>>>){// n0 -> P -> n1letP=n0.borrow_mut().next.take();ifletSome(node)=P{letn1=node.borrow_mut().next.take();n0.borrow_mut().next=n1;}}
linked_list.c
/* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */// 주의: stdio.h가 remove라는 이름을 사용한다voidremoveItem(ListNode*n0){if(!n0->next)return;// n0 -> P -> n1ListNode*P=n0->next;ListNode*n1=P->next;n0->next=n1;// 메모리 해제free(P);}
linked_list.kt
/* 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 */funremove(n0:ListNode?){if(n0?.next==null)return// n0 -> P -> n1valp=n0.nextvaln1=p?.nextn0.next=n1}
linked_list.rb
### 연결 리스트의 노드 n0 바로 뒤에 있는 노드 삭제 ###defremove(n0)returnifn0.next.nil?# n0 -> remove_node -> n1remove_node=n0.nextn1=remove_node.nextn0.next=n1end
연결 리스트의 노드 접근 효율은 낮다. 앞 절에서 설명했듯 배열은 어떤 원소든 \(O(1)\) 시간에 접근할 수 있다. 그러나 연결 리스트에서는 머리 노드부터 하나씩 뒤로 순회하여 목표 노드를 찾아야 한다. 즉, \(i\)번째 노드에 접근하려면 \(i - 1\)번 반복해야 하므로 시간 복잡도는 \(O(n)\)이다.
/* 연결 리스트에서 인덱스가 index인 노드에 접근 */ListNode*access(ListNode*head,intindex){for(inti=0;i<index;i++){if(head==nullptr)returnnullptr;head=head->next;}returnhead;}
linked_list.java
/* 연결 리스트에서 인덱스가 index인 노드에 접근 */ListNodeaccess(ListNodehead,intindex){for(inti=0;i<index;i++){if(head==null)returnnull;head=head.next;}returnhead;}
linked_list.cs
/* 연결 리스트에서 인덱스가 index인 노드에 접근 */ListNode?Access(ListNode?head,intindex){for(inti=0;i<index;i++){if(head==null)returnnull;head=head.next;}returnhead;}
linked_list.go
/* 연결 리스트에서 인덱스가 index인 노드에 접근 */funcaccess(head*ListNode,indexint)*ListNode{fori:=0;i<index;i++{ifhead==nil{returnnil}head=head.Next}returnhead}
linked_list.swift
/* 연결 리스트에서 인덱스가 index인 노드에 접근 */funcaccess(head:ListNode,index:Int)->ListNode?{varhead:ListNode?=headfor_in0..<index{ifhead==nil{returnnil}head=head?.next}returnhead}
linked_list.js
/* 연결 리스트에서 인덱스가 index인 노드에 접근 */functionaccess(head,index){for(leti=0;i<index;i++){if(!head){returnnull;}head=head.next;}returnhead;}
linked_list.ts
/* 연결 리스트에서 인덱스가 index인 노드에 접근 */functionaccess(head:ListNode|null,index:number):ListNode|null{for(leti=0;i<index;i++){if(!head){returnnull;}head=head.next;}returnhead;}
linked_list.dart
/* 연결 리스트에서 인덱스가 index인 노드에 접근 */ListNode?access(ListNode?head,intindex){for(vari=0;i<index;i++){if(head==null)returnnull;head=head.next;}returnhead;}
linked_list.rs
/* 연결 리스트에서 인덱스가 index인 노드에 접근 */pubfnaccess<T>(head:Rc<RefCell<ListNode<T>>>,index:i32)->Option<Rc<RefCell<ListNode<T>>>>{fndfs<T>(head:Option<&Rc<RefCell<ListNode<T>>>>,index:i32,)->Option<Rc<RefCell<ListNode<T>>>>{ifindex<=0{returnhead.cloned();}ifletSome(node)=head{dfs(node.borrow().next.as_ref(),index-1)}else{None}}dfs(Some(head).as_ref(),index)}
linked_list.c
/* 연결 리스트에서 인덱스가 index인 노드에 접근 */ListNode*access(ListNode*head,intindex){for(inti=0;i<index;i++){if(head==NULL)returnNULL;head=head->next;}returnhead;}
linked_list.kt
/* 연결 리스트에서 인덱스가 index인 노드에 접근 */funaccess(head:ListNode?,index:Int):ListNode?{varh=headfor(iin0..<index){if(h==null)returnnullh=h.next}returnh}
linked_list.rb
### 연결 리스트에서 인덱스가 index인 노드에 접근 ###defaccess(head,index)foriin0...indexreturnnilifhead.nil?head=head.nextendheadend
연결 리스트를 순회하여 값이 target인 노드를 찾고, 그 노드의 인덱스를 출력한다. 이 과정도 선형 탐색에 해당한다. 코드는 다음과 같다.
linked_list.py
deffind(head:ListNode,target:int)->int:"""연결 리스트에서 값이 target인 첫 번째 노드 탐색"""index=0whilehead:ifhead.val==target:returnindexhead=head.nextindex+=1return-1
linked_list.cpp
/* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */intfind(ListNode*head,inttarget){intindex=0;while(head!=nullptr){if(head->val==target)returnindex;head=head->next;index++;}return-1;}
linked_list.java
/* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */intfind(ListNodehead,inttarget){intindex=0;while(head!=null){if(head.val==target)returnindex;head=head.next;index++;}return-1;}
linked_list.cs
/* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */intFind(ListNode?head,inttarget){intindex=0;while(head!=null){if(head.val==target)returnindex;head=head.next;index++;}return-1;}
linked_list.go
/* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */funcfindNode(head*ListNode,targetint)int{index:=0forhead!=nil{ifhead.Val==target{returnindex}head=head.Nextindex++}return-1}
linked_list.swift
/* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */funcfind(head:ListNode,target:Int)->Int{varhead:ListNode?=headvarindex=0whilehead!=nil{ifhead?.val==target{returnindex}head=head?.nextindex+=1}return-1}
linked_list.js
/* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */functionfind(head,target){letindex=0;while(head!==null){if(head.val===target){returnindex;}head=head.next;index+=1;}return-1;}
linked_list.ts
/* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */functionfind(head:ListNode|null,target:number):number{letindex=0;while(head!==null){if(head.val===target){returnindex;}head=head.next;index+=1;}return-1;}
linked_list.dart
/* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */intfind(ListNode?head,inttarget){intindex=0;while(head!=null){if(head.val==target){returnindex;}head=head.next;index++;}return-1;}
linked_list.rs
/* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */pubfnfind<T:PartialEq>(head:Rc<RefCell<ListNode<T>>>,target:T)->i32{fnfind<T:PartialEq>(head:Option<&Rc<RefCell<ListNode<T>>>>,target:T,idx:i32)->i32{ifletSome(node)=head{ifnode.borrow().val==target{returnidx;}returnfind(node.borrow().next.as_ref(),target,idx+1);}else{-1}}find(Some(head).as_ref(),target,0)}
linked_list.c
/* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */intfind(ListNode*head,inttarget){intindex=0;while(head){if(head->val==target)returnindex;head=head->next;index++;}return-1;}
linked_list.kt
/* 연결 리스트에서 값이 target인 첫 번째 노드 탐색 */funfind(head:ListNode?,target:Int):Int{varindex=0varh=headwhile(h!=null){if(h._val==target)returnindexh=h.nextindex++}return-1}
linked_list.rb
### 연결 리스트에서 값이 target인 첫 번째 노드 탐색 ###deffind(head,target)index=0whileheadreturnindexifhead.val==targethead=head.nextindex+=1end-1end
단일 연결 리스트: 앞에서 소개한 일반적인 연결 리스트다. 각 노드는 값과 다음 노드를 가리키는 참조라는 두 데이터를 포함한다. 첫 노드는 머리 노드, 마지막 노드는 꼬리 노드이며, 꼬리 노드는 None을 가리킨다.
원형 연결 리스트: 단일 연결 리스트의 꼬리 노드가 머리 노드를 가리키도록 앞뒤를 이으면 원형 연결 리스트가 된다. 원형 연결 리스트에서는 어느 노드든 머리 노드로 볼 수 있다.
이중 연결 리스트: 단일 연결 리스트와 달리 양방향 참조를 기록한다. 각 노드는 후속 노드(다음 노드)와 선행 노드(이전 노드)를 가리키는 참조(포인터)를 모두 포함한다. 양방향으로 순회할 수 있어 단일 연결 리스트보다 유연하지만, 그만큼 메모리 공간도 더 많이 차지한다.
classListNode:"""이중 연결 리스트 노드 클래스"""def__init__(self,val:int):self.val:int=val# 노드 값self.next:ListNode|None=None# 후속 노드를 가리키는 참조self.prev:ListNode|None=None# 선행 노드를 가리키는 참조
/* 이중 연결 리스트 노드 구조체 */structListNode{intval;// 노드 값ListNode*next;// 후속 노드를 가리키는 포인터ListNode*prev;// 선행 노드를 가리키는 포인터ListNode(intx):val(x),next(nullptr),prev(nullptr){}// 생성자};
/* 이중 연결 리스트 노드 클래스 */classListNode{intval;// 노드 값ListNodenext;// 후속 노드를 가리키는 참조ListNodeprev;// 선행 노드를 가리키는 참조ListNode(intx){val=x;}// 생성자}
/* 이중 연결 리스트 노드 클래스 */classListNode(intx){// 생성자intval=x;// 노드 값ListNodenext;// 후속 노드를 가리키는 참조ListNodeprev;// 선행 노드를 가리키는 참조}
/* 이중 연결 리스트 노드 구조체 */typeDoublyListNodestruct{Valint// 노드 값Next*DoublyListNode// 후속 노드를 가리키는 포인터Prev*DoublyListNode// 선행 노드를 가리키는 포인터}// NewDoublyListNode 초기화funcNewDoublyListNode(valint)*DoublyListNode{return&DoublyListNode{Val:val,Next:nil,Prev:nil,}}
/* 이중 연결 리스트 노드 클래스 */classListNode{varval:Int// 노드 값varnext:ListNode?// 후속 노드를 가리키는 참조varprev:ListNode?// 선행 노드를 가리키는 참조init(x:Int){// 생성자val=x}}
/* 이중 연결 리스트 노드 클래스 */classListNode{constructor(val,next,prev){this.val=val===undefined?0:val;// 노드 값this.next=next===undefined?null:next;// 후속 노드를 가리키는 참조this.prev=prev===undefined?null:prev;// 선행 노드를 가리키는 참조}}
/* 이중 연결 리스트 노드 클래스 */classListNode{val:number;next:ListNode|null;prev:ListNode|null;constructor(val?:number,next?:ListNode|null,prev?:ListNode|null){this.val=val===undefined?0:val;// 노드 값this.next=next===undefined?null:next;// 후속 노드를 가리키는 참조this.prev=prev===undefined?null:prev;// 선행 노드를 가리키는 참조}}
/* 이중 연결 리스트 노드 클래스 */classListNode{intval;// 노드 값ListNode?next;// 후속 노드를 가리키는 참조ListNode?prev;// 선행 노드를 가리키는 참조ListNode(this.val,[this.next,this.prev]);// 생성자}
usestd::rc::Rc;usestd::cell::RefCell;/* 이중 연결 리스트 노드 자료형 */#[derive(Debug)]structListNode{val:i32,// 노드 값next:Option<Rc<RefCell<ListNode>>>,// 후속 노드를 가리키는 포인터prev:Option<Rc<RefCell<ListNode>>>,// 선행 노드를 가리키는 포인터}/* 생성자 */implListNode{fnnew(val:i32)->Self{ListNode{val,next:None,prev:None,}}}
/* 이중 연결 리스트 노드 구조체 */typedefstructListNode{intval;// 노드 값structListNode*next;// 후속 노드를 가리키는 포인터structListNode*prev;// 선행 노드를 가리키는 포인터}ListNode;/* 생성자 */ListNode*newListNode(intval){ListNode*node;node=(ListNode*)malloc(sizeof(ListNode));node->val=val;node->next=NULL;node->prev=NULL;returnnode;}
/* 이중 연결 리스트 노드 클래스 */// 생성자classListNode(x:Int){val_val:Int=x// 노드 값varnext:ListNode?=null// 후속 노드를 가리키는 참조varprev:ListNode?=null// 선행 노드를 가리키는 참조}
# 이중 연결 리스트 노드 클래스classListNodeattr_accessor:val# 노드 값attr_accessor:next# 후속 노드를 가리키는 참조attr_accessor:prev# 선행 노드를 가리키는 참조definitialize(val=0,next_node=nil,prev_node=nil)@val=val@next=next_node@prev=prev_nodeendend